From 7558266c2d3962f575535d4e4936ad4257a13815 Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Fri, 23 Oct 2026 15:56:42 +0000 Subject: [PATCH 01/50] docs: add sovereign-clouds feature research and plan Record the investigation of Entra ID sign-in failures (AADSTS65002) in Azure US Government: the client and resource app IDs, why preauthorization is fixed outside the extension, the opaque broker error, per-cloud OSSRDBMS audiences published in ARM metadata, and how other extensions handle sovereign clouds. Add decisions and the iteration plan for a cloud-aware DocumentDB token audience (interactive, managed identity, custom clouds) and a single-table Entra error classifier routed through ConnectionDiagnosticsService. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/ai-and-plans/README.md | 1 + .../features/sovereign-clouds/README.md | 88 +++++++ .../features/sovereign-clouds/decisions.md | 164 ++++++++++++ ...cloud-aware-scope-and-entra-diagnostics.md | 238 +++++++++++++++++ .../sovereign-clouds/research-findings.md | 243 ++++++++++++++++++ 5 files changed, 734 insertions(+) create mode 100644 docs/ai-and-plans/features/sovereign-clouds/README.md create mode 100644 docs/ai-and-plans/features/sovereign-clouds/decisions.md create mode 100644 docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics.md create mode 100644 docs/ai-and-plans/features/sovereign-clouds/research-findings.md diff --git a/docs/ai-and-plans/README.md b/docs/ai-and-plans/README.md index 39d8756db..a248ebcf5 100644 --- a/docs/ai-and-plans/README.md +++ b/docs/ai-and-plans/README.md @@ -49,6 +49,7 @@ treat `status: historical` or `status: superseded` as current. | [survey](./features/survey/README.md) | Survey refresh: simpler eligibility, broader activity coverage, cohort telemetry, and invitation surface | draft #992; enabled | | [docs-site](./features/docs-site/README.md) | The GitHub Pages documentation site: Just the Docs theme, OS dark mode, local preview | draft #420 | | [pre-release-polish](./features/pre-release-polish/README.md) | Cross-feature visual and documentation refinements prepared for the 1.0 release | active | +| [sovereign-clouds](./features/sovereign-clouds/README.md) | Entra sign-in in Azure Government and China: per-cloud token audience, Entra error classifier | planned | Everything else is a single file at this root, such as: diff --git a/docs/ai-and-plans/features/sovereign-clouds/README.md b/docs/ai-and-plans/features/sovereign-clouds/README.md new file mode 100644 index 000000000..c04919d26 --- /dev/null +++ b/docs/ai-and-plans/features/sovereign-clouds/README.md @@ -0,0 +1,88 @@ +--- +feature: sovereign-clouds +kind: design +status: active +code: + - src/documentdb/auth/entraScopes.ts + - src/documentdb/auth/MicrosoftEntraIDAuthHandler.ts + - src/documentdb/auth/ManagedIdentityAuthHandler.ts + - src/documentdb/auth/managedIdentityConnectionString.ts + - src/documentdb/auth/oidcAllowedHosts.ts + - src/documentdb/playground/playgroundWorker.ts + - src/services/connectionDiagnosticsService.ts +--- + +# Sovereign Clouds (Azure Government, Azure China) + +Microsoft Entra ID sign-in to Azure DocumentDB clusters in sovereign clouds, and readable +explanations when Entra sign-in fails. + +## Status + +**Investigation complete, implementation planned.** No PR yet. Nobody on the team has access to a +sovereign cloud, so every change here is made without being able to test it end to end. See +[D2](decisions.md#d2-implement-without-sovereign-cloud-access). + +## The problem in one paragraph + +An issue was reported: in Azure US Government, listing Azure resources works, but connecting to a +DocumentDB cluster with Entra ID fails with `AADSTS65002`. VS Code's sign-in app (`aebc6443-…`) is +not preauthorized for the **Azure OSSRDBMS Database** app (`123cd850-…`) in that cloud. Listing works because management calls use the Azure +Resource Manager scope, which VS Code is preauthorized for in every cloud. Connecting uses the +database scope, which it is not, in US Government. This is a Microsoft-side directory configuration +gap that the extension cannot fix. A separate extension-side defect also exists: we always request +the public-cloud audience `https://ossrdbms-aad.database.windows.net`, whatever cloud the user +selected. + +Full evidence: [research-findings.md](research-findings.md). + +## What is being done + +| # | Track | Who | State | +| --- | ------------------------------------------------------------------------------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------- | +| 1 | Ask the owner of `123cd850` to preauthorize VS Code `aebc6443` in US Government (and China) | Microsoft internal, outside the code | Followed up through internal channels; not tracked in this repository | +| 2 | Request the DocumentDB token for the selected cloud, not always the public one | This repo | Planned: [iteration 01, part A](iterations/01-cloud-aware-scope-and-entra-diagnostics.md#part-a-cloud-aware-token-audience) | +| 3 | Explain Entra sign-in failures (AADSTS codes, broker errors) through one classifier table | This repo | Planned: [iteration 01, part B](iterations/01-cloud-aware-scope-and-entra-diagnostics.md#part-b-entra-error-classifier) | + +Track 2 alone does not fix the reported failure; track 1 does. Track 2 is needed so that the token +is issued for the right audience once track 1 lands. Track 3 makes the next failure of this kind +readable without a support round trip. + +## Code map (today) + +- [entraScopes.ts](../../../../src/documentdb/auth/entraScopes.ts): the single public-cloud + `DOCUMENTDB_TOKEN_RESOURCE` and `DOCUMENTDB_ENTRA_SCOPE`. +- [MicrosoftEntraIDAuthHandler.ts](../../../../src/documentdb/auth/MicrosoftEntraIDAuthHandler.ts): + interactive sign-in through `getSessionFromVSCode`, which already picks the sovereign VS Code + auth provider, but is handed the public scope. +- [ManagedIdentityAuthHandler.ts](../../../../src/documentdb/auth/ManagedIdentityAuthHandler.ts): + managed identity, public scope. +- [playgroundWorker.ts](../../../../src/documentdb/playground/playgroundWorker.ts): the shell and + playground worker asks the main thread for a token with the public scope. +- [managedIdentityConnectionString.ts](../../../../src/documentdb/auth/managedIdentityConnectionString.ts): + writes `TOKEN_RESOURCE:` into copied connection strings; parses `TOKEN_RESOURCE` but the + value is not used afterwards. +- [oidcAllowedHosts.ts](../../../../src/documentdb/auth/oidcAllowedHosts.ts): already accepts + `*.azure.us` and `*.azure.cn` hosts (#639, #934). +- [connectionDiagnosticsService.ts](../../../../src/services/connectionDiagnosticsService.ts): the + provider registry the Entra classifier plugs into. + +Azure management (subscription listing, ARM clients) already follows the selected cloud through +`AzureSubscription.environment`; nothing is planned there. + +## Documents + +- [research-findings.md](research-findings.md): the reported failure, error analysis, where the GUIDs + come from, precedent, how other extensions handle sovereign clouds, per-cloud values with sources, + and open questions. +- [decisions.md](decisions.md): operator decisions and proposals awaiting confirmation. +- [iterations/01-cloud-aware-scope-and-entra-diagnostics.md](iterations/01-cloud-aware-scope-and-entra-diagnostics.md): + the plan for tracks 2 and 3. + +## Open gaps + +- No sovereign-cloud test access. Behavior after the change is inferred, not observed. +- Not confirmed which audience DocumentDB accepts in US Government. ARM metadata publishes the + `usgovcloudapi.net` URI as the OSSRDBMS resource there, but no DocumentDB document confirms it. +- Whether China clusters accept the public audience today (check telemetry before shipping, D11). +- DocumentDB availability and host suffix in US Government are not publicly documented. diff --git a/docs/ai-and-plans/features/sovereign-clouds/decisions.md b/docs/ai-and-plans/features/sovereign-clouds/decisions.md new file mode 100644 index 000000000..89d03445b --- /dev/null +++ b/docs/ai-and-plans/features/sovereign-clouds/decisions.md @@ -0,0 +1,164 @@ +--- +feature: sovereign-clouds +kind: decisions +status: active +--- + +# Sovereign Clouds: Decisions + +"Adopted" means the operator chose to move forward with the entry while other research is +pending; the operator may still revisit it. Superseded entries are kept for their rejected +alternatives. + +| ID | Status | Summary | +| --- | --------------------------- | -------------------------------------------------------------------------------------------------- | +| D1 | **Adopted** | The preauthorization gap is fixed outside the extension; no own client ID | +| D2 | **Accepted** (operator) | Implement without sovereign-cloud access, copying patterns proven elsewhere | +| D3 | **Accepted** (operator) | Entra sign-in errors are classified in one table, routed through the diagnostics service | +| D4 | **Superseded** by D6 and D7 | Which signal decides the cloud | +| D5 | **Superseded** by D6 | Custom clouds keep the public audience | +| D6 | **Adopted** | Interactive sign-in: cloud from VS Code's setting; custom clouds resolved, never public | +| D7 | **Adopted** | Managed identity: cloud from an override, the cluster host, then IMDS; otherwise fail clearly | +| D8 | **Adopted** | Stop before requesting a token when the cluster host and the selected cloud clearly disagree | +| D9 | **Adopted** | An opaque broker error in a sovereign cloud gets a hedged message that asks for the on-screen code | +| D10 | **Adopted** | Messages are text only in this iteration; no buttons | +| D11 | **Adopted** | No public-audience fallback for China unless telemetry shows it works today | + +--- + +## D1. Service-side preauthorization, not a client workaround + +**Adopted.** + +The missing preauthorization (see [README](README.md#what-is-being-done)) is raised with Microsoft +outside the code. Two public precedents were resolved the same way: microsoft/vscode-mssql#22296 +(US Government) and microsoft/vscode#244689 (China). + +Rejected: + +- **Own client ID through `VSCODE_CLIENT_ID:`.** Moves the same approval to a new app and adds a + registration per national cloud. Whether a Microsoft-published extension may do this at all is a + policy question. See [research §2](research-findings.md#using-our-own-client-id-instead-not-recommended). +- **Asking customers to grant consent.** Not possible for first-party to first-party access. + +## D2. Implement without sovereign-cloud access + +**Accepted** by the operator. + +The operator has no Azure Government access and asked for changes that are safe "blindly", modeled on +the Azure Resources, Azure Functions, and Azure tools libraries, which are maintained by the team that +handles sovereign clouds daily, and on `vscode-cosmosdb` with more caution because its sovereign paths +are less exercised. + +Consequences: + +- Every cloud-dependent value comes from a closed, sourced table or from the cloud's own ARM + metadata, never from a hostname. +- Public-cloud behavior must stay byte-for-byte identical; tests pin that. +- The outcome section of each iteration states plainly that sovereign behavior was not observed. + +## D3. One classifier table for Entra sign-in errors + +**Accepted** by the operator. + +The operator wants one place to add Entra error codes, acting as a router. Adding a code must be a +one-entry change to a table, with no new provider and no new call site. + +Design: [iteration 01, part B](iterations/01-cloud-aware-scope-and-entra-diagnostics.md#part-b-entra-error-classifier). +The classifier is consumed by a single `ConnectionDiagnosticsProvider`, so every surface that already +calls `ConnectionDiagnosticsService.explain()` gets it without wiring. + +## D4. Which signal decides the cloud + +**Superseded** by D6 (interactive) and D7 (managed identity). + +The original proposal used `microsoft-sovereign-cloud.environment` for interactive sign-in, and for +managed identity a `TOKEN_RESOURCE` hint, then the host suffix, then the setting. Rejected then and +still rejected: persisting the discovered `subscription.environment` on the connection (only +discovered connections have it; overlaps #931). + +## D5. Custom clouds keep the public audience + +**Superseded** by D6. Requesting the public audience in a custom cloud would be a silent, wrong +fallback; failing clearly is better. + +## D6. Interactive sign-in + +**Adopted.** + +The audience follows the same setting that chooses the sign-in authority, +`microsoft-sovereign-cloud.environment`, so the two cannot disagree. + +| Setting | Audience | +| -------------- | ------------------------------------------------------------------------------------------- | +| (unset) | `https://ossrdbms-aad.database.windows.net`, unchanged | +| `USGovernment` | `https://ossrdbms-aad.database.usgovcloudapi.net` | +| `ChinaCloud` | `https://ossrdbms-aad.database.chinacloudapi.cn` | +| `custom` | Resolved in this order; **never** the public value by default: | +| | 1. `documentDB.customEnvironment.ossrdbmsEndpoint` | +| | 2. the login host of `microsoft-sovereign-cloud.customEnvironment` matched to a known cloud | +| | 3. `ossrDbmsResourceId` from the custom cloud's ARM metadata (`2022-09-01`), cached | +| | 4. otherwise fail with a message naming the setting from step 1 | + +`documentDB.customEnvironment` mirrors `mssql.customEnvironment`: an object setting used together +with `microsoft-sovereign-cloud.environment = custom`. Its `ossrdbmsEndpoint` property is the +"sovereign cloud equivalent for `https://ossrdbms-aad.database.windows.net/`". + +Deferred: the addendum-style "ask once, pre-filled from `suffixes.sqlServerHostname`" prompt. A +prompt in the token path would also fire from the shell and the playground, where a modal input box +is the wrong surface. Step 4 fails with a clear message instead; the prompt can be added later as a +command. + +## D7. Managed identity + +**Adopted.** + +Managed identity tokens come from the VM, not from VS Code's sign-in, so the setting is not a signal. + +1. `documentDB.customEnvironment.ossrdbmsEndpoint`, if set. +2. A `TOKEN_RESOURCE` from the connection string, only if it is one of the known audiences. +3. The cluster host: `.azure.cn` → China, `.azure.com` → public. No `.azure.us` rule until a US + Government DocumentDB host suffix is publicly documented. +4. IMDS `compute.azEnvironment`: `AZUREPUBLICCLOUD` → public. Any other value means "not public, + cloud unknown". +5. Otherwise fail with a message naming the setting from step 1. + +The VS Code setting is deliberately not consulted: a user who never signs in interactively has no +reason to set it, and a stale value would pick the wrong audience silently. + +## D8. Cloud mismatch stops the request + +**Adopted.** + +For interactive sign-in, when the cluster host ends in `.azure.cn` but the selected cloud is not +China, or ends in `.azure.com` but the selected cloud is not public, the request is stopped before a +token is requested, with a message naming both clouds and the setting. No rule for `.azure.us` (see +D7). Hosts that match neither suffix, such as private DNS names, are not checked. + +## D9. Opaque broker errors + +**Adopted.** + +On Windows the extension receives only `platform_broker_error`, without the AADSTS code (research §3). +When the failure is opaque, the selected cloud is not public, and the failing request was the +DocumentDB token, the message says what AADSTS65002 would mean and asks the user to check the code in +the sign-in window. It never asserts 65002. In the public cloud, an opaque broker error gets a generic +message. + +## D10. Text-only messages in this iteration + +**Adopted.** + +`ConnectionDiagnosticsService` providers return text only, by design (error-translation skill). +Buttons such as "Report issue" or "Switch cloud" would change that contract and every call site. Each +message therefore states who can fix the problem and links to a public troubleshooting page. +Structured actions are future work. + +## D11. China and the public audience + +**Adopted.** + +Microsoft's DocumentDB China documentation uses the China audience, so the extension switches to it. +Before shipping, check telemetry for successful Entra connections to `*.azure.cn` hosts. If there are +any, add a China-only fallback to the public audience after a failure, with telemetry, and remove it +once the data is clear. If there are none, no fallback. diff --git a/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics.md b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics.md new file mode 100644 index 000000000..55a1d9396 --- /dev/null +++ b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics.md @@ -0,0 +1,238 @@ +--- +feature: sovereign-clouds +kind: plan +status: active +code: + - src/documentdb/auth/entraScopes.ts + - src/documentdb/auth/MicrosoftEntraIDAuthHandler.ts + - src/documentdb/auth/ManagedIdentityAuthHandler.ts + - src/documentdb/auth/managedIdentityConnectionString.ts + - src/documentdb/auth/oidcAllowedHosts.ts + - src/documentdb/playground/playgroundWorker.ts + - src/documentdb/playground/PlaygroundEvaluator.ts + - src/documentdb/shell/ShellSessionManager.ts + - src/commands/copyConnectionString/copyConnectionString.ts + - src/services/connectionDiagnosticsService.ts +--- + +# Iteration 01: Cloud-aware token audience and Entra error classifier + +Plan for tracks 2 and 3 of the [README](../README.md#what-is-being-done). Track 1 (service-side +preauthorization) is outside the code. + +Progress is recorded inline under each work item, with the commit that carries it. + +**Verification case:** Case 1 while in progress (`npm run build`, targeted `npx vitest run`). +Case 2 only at hand-over. + +**Constraint (D2):** no sovereign-cloud access. Public-cloud behavior must not change, and tests pin +the exact public scope string. + +## Revision 1 (before implementation started) + +The first version of this plan assumed the Windows broker passes the AADSTS code to the extension, +kept the public audience for custom clouds, used the VS Code setting for managed identity, and only +logged cloud mismatches. Follow-up research changed all four: the broker error is opaque (research +§3), custom clouds must never fall back to public (D6), managed identity follows the VM and the +cluster (D7), and a clear mismatch stops the request (D8). ARM's `2022-09-01` metadata turned out to +publish the OSSRDBMS resource for every cloud (research §5), which gives custom clouds a source. +Decisions D6 to D11 record the outcome. + +--- + +# Part A: Cloud-aware token audience + +Goal: the DocumentDB token is requested for the OSSRDBMS resource of the right cloud, never silently +for the public one. + +## A1. Audience table and helpers + +`src/documentdb/auth/entraScopes.ts`: + +- `DocumentDBEntraCloud = 'AzureCloud' | 'AzureUSGovernment' | 'AzureChinaCloud'`. +- A closed `TOKEN_RESOURCE_BY_CLOUD` table (research §5). `DOCUMENTDB_TOKEN_RESOURCE` and + `DOCUMENTDB_ENTRA_SCOPE` keep their public values. +- `toEntraScope(resource)`: strips one trailing slash, appends `/.default`. +- `parseTokenResource(value)`: accepts only `https://host` with no path, query or fragment; returns + the normalized value or `undefined`. +- `getCloudForTokenResource(value)`: the built-in cloud for a known audience, else `undefined`. + +## A2. Interactive resolver (D6, D8) + +`src/documentdb/auth/resolveDocumentDBTokenResource.ts`: + +```ts +export interface ResolvedTokenResource { + readonly resource: string; + readonly scope: string; + /** For telemetry and traces only. */ + readonly cloudCategory: 'public' | 'usgov' | 'china' | 'custom'; + readonly reason: 'setting' | 'customOverride' | 'customLoginHost' | 'armMetadata' | 'connectionString' | 'host' | 'imds'; +} + +export function resolveInteractiveTokenResource(connectionString: string, deps?: ResolverDeps): Promise; +``` + +- Built-in clouds come from `getConfiguredAzureEnv()` (internal module path, as for + `getSessionFromVSCode`). +- `custom`: the D6 order. The ARM metadata call uses `fetch` with a short timeout, reads + `ossrDbmsResourceId` ignoring case, validates it with `parseTokenResource`, and caches per + `resourceManagerEndpointUrl` (success only). Known login hosts: `login.microsoftonline.com`, + `login.microsoftonline.us`, `login.chinacloudapi.cn`, `login.sovcloud-identity.fr`, + `login.sovcloud-identity.de` (Azure CLI `cloud.py`). +- Unresolved custom cloud: throws a localized error naming + `documentDB.customEnvironment.ossrdbmsEndpoint`. +- Mismatch (D8): `.azure.cn` host with a non-China cloud, or `.azure.com` host with a non-public + cloud, throws a localized error naming both clouds and `microsoft-sovereign-cloud.environment`. +- A warning is logged when the override equals the public audience but the cloud is not public. +- `deps` injects settings, `fetch` and the environment for tests. + +## A3. Managed identity resolver (D7) + +Same file: `resolveManagedIdentityTokenResource(connectionString, deps?)`. + +- Order: override, known `TOKEN_RESOURCE` in the connection string, host suffix, IMDS + `compute.azEnvironment` (`AZUREPUBLICCLOUD` only), else a localized error naming the override. +- IMDS: `GET http://169.254.169.254/metadata/instance/compute/azEnvironment?api-version=2021-02-01&format=text` + with `Metadata: true` and a short timeout; called only when the earlier steps found nothing. + +## A4. Wiring + +- [MicrosoftEntraIDAuthHandler.ts](../../../../../src/documentdb/auth/MicrosoftEntraIDAuthHandler.ts) and + [ManagedIdentityAuthHandler.ts](../../../../../src/documentdb/auth/ManagedIdentityAuthHandler.ts): use the + resolvers; add `cloudCategory` and `reason` to the existing traces. +- Shell and playground: the main thread's `handleTokenRequest` resolves the scope from the cluster's + credentials and ignores the worker's `scopes`, which the worker stops sending. +- Copy connection string: `TOKEN_RESOURCE` comes from a synchronous subset (override, known + `TOKEN_RESOURCE`, host suffix, built-in VS Code cloud), else public. Copying must not wait on IMDS; + the copied string is a starting point the user can edit. +- Record each failed DocumentDB token request by `clusterId` (cloud category, auth method, time) in + a small in-memory registry, for Part B. This is the error-translation skill's "record it while + preparing the connection" pattern; the error object is not touched. + +## A5. Settings + +`documentDB.customEnvironment`, an object setting mirroring `mssql.customEnvironment`: + +| Property | Description | +| ---------------------------- | ----------------------------------------------------------------------------- | +| `ossrdbmsEndpoint` | Sovereign cloud equivalent for `https://ossrdbms-aad.database.windows.net/` | +| `mongoClusterHostnameSuffix` | Sovereign cloud equivalent for `.mongocluster.cosmos.azure.com` (leading dot) | + +The description says it is used together with `microsoft-sovereign-cloud.environment = custom`. +`mongoClusterHostnameSuffix` extends `ALLOWED_HOSTS` explicitly; automatic trust stays limited to +`azure.com`, `azure.us` and `azure.cn`, which [oidcAllowedHosts.ts](../../../../../src/documentdb/auth/oidcAllowedHosts.ts) +already enforces (#934). + +## A6. Tests + +- Resolver tables: every setting value; custom with override, known login host, ARM metadata (key + present, missing, differently cased, invalid, timeout), and nothing; trailing slashes; invalid + overrides; mismatch in both directions; private hosts not checked. +- Managed identity: override, known and unknown `TOKEN_RESOURCE`, `.azure.cn`, `.azure.com`, + `AZUREPUBLICCLOUD`, another IMDS value, IMDS timeout. +- Handlers: public scope string unchanged; US Government scope when the setting says so. +- Allowed hosts: `*.azure.xyz` rejected; the configured custom suffix accepted. + +--- + +# Part B: Entra error classifier + +Goal (D3): one table maps Entra sign-in failures to text that says what happened, who can fix it, and +where to go next. + +## B1. Where it plugs in + +`ConnectionDiagnosticsService.explain()` is already called wherever a connection failure is shown, so +one provider covers tree connect in both views, the shell, the playground, tree commands, and +webviews. All of them pass at least the message, so the classifier works on text. + +## B2. Files + +``` +src/documentdb/auth/entraErrors/ +├── entraErrorRules.ts # THE table. The only file touched to add a code. +├── classifyEntraError.ts # Pure: text in, classification out. +├── entraTokenFailures.ts # Registry from A4: failed DocumentDB token requests by clusterId. +└── EntraDiagnosticsProvider.ts # ConnectionDiagnosticsProvider adapter; registered once. +``` + +## B3. Classification + +`classifyEntraError(text)` returns `{ ruleId, code?, brokered }` or `undefined`: + +1. Cheap rejection unless the text contains `AADSTS`, `platform_broker_error`, or the + preauthorization phrase. +2. `AADSTS(\d{5,7})` codes; first rule listing one of them wins. +3. The preauthorization phrase without a code maps to 65002 (harmless extra; not the broker + strategy). +4. `platform_broker_error` without a code: `brokerOpaque`. +5. Any other AADSTS code: `unknownAadsts`. + +## B4. Rules + +Each rule has an id, codes, and `explain(context)` with `context = { code, cloud, authMethod }`. +Every message names who can fix the problem and links to the public troubleshooting page +(`Links.SovereignCloudsTroubleshooting`). + +| Rule id | Codes | Who can fix it | +| ---------------------------- | ----------------------- | ----------------------------------------------- | +| `firstPartyPreauthorization` | 65002, or the phrase | Microsoft only | +| `brokerOpaque` | `platform_broker_error` | Depends; hedged text (D9) in sovereign clouds | +| `resourceNotInTenant` | 500011 | Cloud selection, else the tenant administrator | +| `invalidResource` | 50001 | The extension or the custom override | +| `accountNotInTenant` | 50020, 90072 | The cluster tenant's administrator, or the user | +| `applicationNotInDirectory` | 700016 | The user (cloud or tenant choice) | +| `tenantNotFound` | 90002 | The user (cloud or tenant choice) | +| `crossCloudNotAllowed` | 1000104 | Tenant administrators | +| `conditionalAccess` | 53003 | Tenant administrator or IT help desk | +| `additionalVerification` | 50076, 50079 | The user | +| `unknownAadsts` | any other | Unknown; the message gives the code | + +`brokerOpaque` gets the hedged 65002 text only when the cloud is not public **and** the registry from +A4 holds a recent DocumentDB token failure for this `clusterId`. Otherwise it gets a generic text that +asks for the code shown in the sign-in window and points to **Developer: Show Logs… → Microsoft +Sovereign Cloud Authentication** (or **Microsoft Authentication**). `msal-no-broker` is mentioned only +as a manual diagnostic step, never as a fix. + +## B5. Telemetry + +The provider sends `entraSignIn.classified` with `ruleId`, `aadstsCode`, `cloudCategory`, `brokered` +and `authMethod`. No tenant, subscription, cluster, user, or correlation identifiers. + +## B6. Tests + +- Fixtures with fake GUIDs: the no-broker 65002 string, the opaque broker string, each rule's code, + HTML-escaped quotes, ordinary driver errors (no match). +- Table integrity: unique ids, no code in two rules, a fixture per rule. +- Provider: string and `Error` input, the hedged and generic broker variants. + +--- + +# Part C: Documentation + +- New user-manual page `docs/user-manual/sovereign-clouds.md`: selecting a cloud, the audience per + cloud, `documentDB.customEnvironment`, the known Entra limitation where VS Code is not yet + authorized, workarounds (native authentication, managed identity), finding the error code, and how + to report it on GitHub. It is the target of `Links.SovereignCloudsTroubleshooting`. +- `src/documentdb/utils/connection-string-parameters.md`: the per-cloud `TOKEN_RESOURCE` values. +- `oidcAllowedHosts.ts`: point the "tracked separately" comment at the resolver. + +--- + +# Before shipping + +- D11: check telemetry for successful Entra connections to `*.azure.cn` hosts. +- Validation without sovereign access: public-cloud behavior unchanged (manual); with + `microsoft-sovereign-cloud.environment = USGovernment`, the DocumentDB output shows the US Government + scope before sign-in fails (proves the request, not the outcome). + +# Future work + +- The ask-once prompt for custom clouds, as a command (D6). +- Buttons on diagnostics: Report issue, Switch cloud, Copy details (D10). +- A US Government host suffix rule once it is publicly documented (D7, D8). + +# Outcome + +_To be written at hand-over._ diff --git a/docs/ai-and-plans/features/sovereign-clouds/research-findings.md b/docs/ai-and-plans/features/sovereign-clouds/research-findings.md new file mode 100644 index 000000000..caa2471a7 --- /dev/null +++ b/docs/ai-and-plans/features/sovereign-clouds/research-findings.md @@ -0,0 +1,243 @@ +--- +feature: sovereign-clouds +kind: research +status: active +--- + +# Research Findings: Entra Sign-in in Sovereign Clouds + +Collected from the extension's code, the installed `@microsoft/vscode-azext-azureauth` 4.1.1, and +public sources. Nothing here was observed in a sovereign cloud. Items marked **unverified** are +inferences. + +## 1. The reported failure + +An issue was reported: Microsoft Entra ID sign-in to an Azure DocumentDB cluster in Azure US +Government fails, while listing Azure resources works. The Microsoft Entra error is: + +```text +AADSTS65002: Consent between first party application 'aebc6443-996d-45c2-90f0-388ff96faa56' +and first party resource '123cd850-d9df-40bd-94d5-c9f07b7fa203' must be configured via +preauthorization - applications owned and operated by Microsoft must get approval from the API +owner before requesting tokens for that API. +``` + +What this establishes: + +- The failure is in the database token request, not in listing resources, so it affects every + entry point that connects with Entra ID. +- Sign-in reached the US Government directory, so the sign-in authority already follows + `microsoft-sovereign-cloud.environment`. +- The US Government directory resolved our requested resource + (`https://ossrdbms-aad.database.windows.net`) to app `123cd850`. The request reached the right + app; it was refused for lack of preauthorization. + +## 2. Where the two GUIDs come from + +| GUID | What it is | Owner | Scoped to our extension? | +| -------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------- | ------------------------------------------------------------------------------------------------ | +| `aebc6443-996d-45c2-90f0-388ff96faa56` | **VS Code's** client (application) ID. The built-in `microsoft` and `microsoft-sovereign-cloud` auth providers use it for every token request unless the caller adds a `VSCODE_CLIENT_ID:` pseudo-scope. | Microsoft, VS Code team | **No.** Every extension that calls `vscode.authentication.getSession('microsoft', …)` shares it. | +| `123cd850-d9df-40bd-94d5-c9f07b7fa203` | The application ID of the first-party **Azure OSSRDBMS Database** app. Its identifier URI is `https://ossrdbms-aad.database.windows.net`. Azure Database for PostgreSQL, MySQL, and Azure DocumentDB all accept tokens for it. | Microsoft, the OSSRDBMS resource owner team | No. | + +Sources: + +- `aebc6443` is `DEFAULT_CLIENT_ID` in + [microsoft/vscode `extensions/microsoft-authentication/src/common/scopeData.ts`](https://github.com/microsoft/vscode/blob/main/extensions/microsoft-authentication/src/common/scopeData.ts), + and is listed as "Visual Studio Code" in the community + [merill/microsoft-info](https://github.com/merill/microsoft-info) app list. +- The **name** "Azure OSSRDBMS Database" for `123cd850` comes only from the third-party + [AzAdvertizer](https://www.azadvertizer.net/azentraidapipermissions/123cd850-d9df-40bd-94d5-c9f07b7fa203_017211c5-049f-46b5-a0f0-bcc46299e550.html) + catalog, which syncs from Microsoft Graph. No Microsoft document naming it was found, and + merill/microsoft-info does not list it. +- That `123cd850` is the app **behind** `https://ossrdbms-aad.database.windows.net` is inferred from + the AADSTS65002 error: the only resource we request is that URI, and Entra named `123cd850` as the + resource. No published mapping was found. +- That PostgreSQL, MySQL and DocumentDB all use that URI is documented on Microsoft Learn: + [DocumentDB](https://learn.microsoft.com/azure/documentdb/how-to-connect-role-based-access-control), + [PostgreSQL](https://learn.microsoft.com/azure/postgresql/security/security-entra-configure), + [MySQL](https://learn.microsoft.com/azure/mysql/flexible-server/how-to-azure-ad). + +### What "preauthorization" means, and who can grant it + +- Normally a client app gets permission to call an API through **consent**: the user or tenant admin + approves it. +- When both the client and the API are **Microsoft first-party** apps, tenant consent is not + available. The API owner must list the client in the API's **preauthorized applications**. That is + what AADSTS65002 says: "applications owned and operated by Microsoft must get approval from the API + owner". +- Each national cloud (Public, US Government, China, and others) is a separate Entra deployment. + First-party app IDs are usually the same in every cloud. **Inferred, not confirmed:** each cloud + holds its own preauthorization configuration, so a client can be preauthorized in one cloud and + not another. This follows from the public error text, the vscode-mssql precedent below and + standard Entra behavior; no published document states it. In Public cloud, VS Code can get + OSSRDBMS tokens (Entra sign-in works there). In US Government it evidently cannot. + +So (the last two rows are inferred, see above): + +| Actor | Can fix it? | Why | +| -------------------------------------- | ----------- | ----------------------------------------------------------------------------------------------------------- | +| A user or their tenant admin | **No** | First-party to first-party consent cannot be granted by a tenant. | +| This extension, in code | **No** | We do not own a client ID; we use VS Code's. Changing the scope does not change who is asking. | +| Microsoft, the OSSRDBMS resource owner | **Yes** | Adds `aebc6443` to the preauthorized applications of `123cd850` in the US Government directory (and China). | +| Microsoft, the VS Code auth team | Indirectly | Owns `aebc6443`; may need to be involved or may already have a request process for this. | + +Because the client ID is shared, the fix also unblocks any other VS Code extension that requests +OSSRDBMS tokens in US Government, for example a PostgreSQL extension. + +### Using our own client ID instead (not recommended) + +VS Code honors a `VSCODE_CLIENT_ID:` pseudo-scope that swaps in a different app registration. +This does not avoid the problem: our own app would need either first-party status plus the same +preauthorization from the OSSRDBMS owner, or third-party consent in every customer tenant, and a +separate registration in every national cloud. The extensions that use `VSCODE_CLIENT_ID` (API +Management, API Center, Fabric) do so for their own APIs, not to bypass a first-party gap. + +## 3. Precedent: the same failure for Azure SQL, fixed by configuration + +[microsoft/vscode-mssql#22296](https://github.com/microsoft/vscode-mssql/issues/22296), +"Sovereign Cloud Universal with MFA Support auth error", closed as resolved. + +- Two US Government tenants, server `*.database.usgovcloudapi.net`. +- With the broker (`microsoft-authentication.implementation = msal`, the default) the extension + received only `platform_broker_error: See https://aka.ms/msal.js.errors#platform_broker_error for details`. +- With `msal-no-broker` the extension received the full text: `invalid_request - AADSTS65002: Consent +between first party application 'aebc6443-…' and first party resource '022907d3-…' must be +configured via preauthorization …` (`022907d3` is Azure SQL Database). +- Resolution stated in the issue: a configuration update in the US Government cloud, made by the + service side. + +No extension code changed. This is the model for track 1. + +Two consequences for track 3: + +1. On Windows with the default broker, the error that reaches our code contains only + `platform_broker_error: See https://aka.ms/msal.js.errors#platform_broker_error for details`, not + the AADSTS code or the preauthorization text. Evidence: the vscode-mssql reproduction steps; VS + Code's `microsoft-authentication` provider rethrows the MSAL error unchanged; and the Azure tools + auth package (6.0.0-alpha.5, microsoft/vscode-azuretools#2215) treats the message as opaque. The + detailed text in the broker's own dialog does not reach the extension. The classifier must handle + both shapes. +2. The user can still see the code: the system sign-in window shows it (users in + [microsoft/vscode#244689](https://github.com/microsoft/vscode/issues/244689) reported + `CAA20002, AADSTS65002` on screen). Messages should ask for it rather than guess it. +3. `microsoft-authentication.implementation = msal-no-broker` makes the AADSTS code reach the + extension, but it is a global user setting, and sovereign-cloud tokens are currently not kept in + that mode ([microsoft/vscode#307340](https://github.com/microsoft/vscode/issues/307340), open). It + is a manual diagnostic step only; the extension must never change it. + +### A second precedent: VS Code's own client in China + +[microsoft/vscode#244689](https://github.com/microsoft/vscode/issues/244689): Azure sign-in in Azure +China failed with AADSTS65002 for VS Code's client. The VS Code maintainers raised it with the +identity team and the issue was closed after a service-side fix. Same pattern as vscode-mssql#22296: +a configuration change outside the client code. + +## 4. Why listing works but connecting fails + +| Step | Scope requested | Result in US Gov | +| ------------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------ | --------------------------------------- | +| Listing subscriptions and clusters (Azure Resources, our Discovery view) | ARM, built from `subscription.environment.resourceManagerEndpointUrl` (`management.usgovcloudapi.net`) | Works: VS Code is preauthorized for ARM | +| Connecting to the cluster (`MicrosoftEntraIDAuthHandler`) | `https://ossrdbms-aad.database.windows.net/.default` (hardcoded) | AADSTS65002 | + +Our management path already follows the selected cloud: `azureClients.ts` passes the full +`AzureSubscription` (with `environment`) into `createSubscriptionContext()` and `createAzureClient()`. +`vscode-cosmosdb` and Azure Resources do the same. + +## 5. Per-cloud OSSRDBMS token resource + +`getConfiguredAzureEnv()` in `@microsoft/vscode-azext-azureauth` returns an `Environment` from +`@azure/ms-rest-azure-env` based on `microsoft-sovereign-cloud.environment`. That `Environment` has +**no** OSSRDBMS field. + +ARM's cloud metadata endpoint does publish it, with api-version `2022-09-01` (not with the older +`2019-05-01`). Each cloud's endpoint is public and unauthenticated: + +| Endpoint | `ossrDbmsResourceId` | `suffixes.sqlServerHostname` | +| -------------------------------------------------------------------------------- | ------------------------------------------------- | ---------------------------- | +| `https://management.azure.com/metadata/endpoints?api-version=2022-09-01` | `https://ossrdbms-aad.database.windows.net` | `database.windows.net` | +| `https://management.usgovcloudapi.net/metadata/endpoints?api-version=2022-09-01` | `https://ossrdbms-aad.database.usgovcloudapi.net` | `database.usgovcloudapi.net` | +| `https://management.chinacloudapi.cn/metadata/endpoints?api-version=2022-09-01` | `https://ossrdbms-aad.database.chinacloudapi.cn` | `database.chinacloudapi.cn` | + +These values match Azure CLI's static table, which uses them as a fallback when ARM metadata lacks +the key. For the three built-in clouds a static table is therefore safe and needs no network call; +ARM metadata is the right source for custom clouds. + +| `getConfiguredAzureEnv().name` | Setting value | OSSRDBMS resource | Sources | +| ------------------------------ | -------------- | ------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| `AzureCloud` | (unset) | `https://ossrdbms-aad.database.windows.net` | Azure CLI `cloud.py`; DocumentDB docs | +| `AzureUSGovernment` | `USGovernment` | `https://ossrdbms-aad.database.usgovcloudapi.net` | Azure CLI `cloud.py`; US Government ARM metadata | +| `AzureChinaCloud` | `ChinaCloud` | `https://ossrdbms-aad.database.chinacloudapi.cn` | Azure CLI `cloud.py`; [DocumentDB China docs](https://docs.azure.cn/en-us/documentdb/how-to-connect-role-based-access-control) | +| custom | `custom` | from the custom cloud's ARM metadata, if present | that cloud's `/metadata/endpoints` | + +Azure CLI also lists `cloudapi.de` (retired German cloud), `sovcloud-api.fr` and `sovcloud-api.de` +(Bleu, Delos). VS Code's setting can reach those only through `custom`. + +**Public, China:** Microsoft's DocumentDB documentation for Azure China uses +`TOKEN_RESOURCE:https://ossrdbms-aad.database.chinacloudapi.cn`, with cluster hosts +`.mongocluster.cosmos.azure.cn` and `.global.mongocluster.cosmos.azure.cn` +([connect with Entra ID](https://docs.azure.cn/en-us/documentdb/how-to-connect-role-based-access-control), +[regional availability](https://docs.azure.cn/zh-cn/documentdb/regional-availability)). + +**Unverified, US Government:** that Azure DocumentDB accepts tokens whose audience is the +`usgovcloudapi.net` URI. US Government ARM metadata and Azure CLI publish it as the OSSRDBMS resource, +and the China documentation follows the same per-cloud pattern, so it is very likely, but no +DocumentDB document for US Government confirms it. + +**Unverified, US Government:** DocumentDB availability and host suffix. They are not publicly +documented; `mongocluster.cosmos.azure.us` is an assumption, which our ALLOWED_HOSTS code already +makes. + +**Unverified, all sovereign clouds:** whether a cluster also accepts the public `windows.net` +audience. + +## 6. How other extensions handle sovereign clouds + +| Extension | Management plane | Data-plane token | +| ---------------------------------------------- | ---------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| Azure Resources (`vscode-azureresourcegroups`) | `subscription.environment`; cloud picker writes `microsoft-sovereign-cloud.environment` | n/a | +| `vscode-azuretools` auth package | `getConfiguredAzureEnv()`, `getConfiguredAuthProviderId()` choose authority and provider | Callers pass their own scopes | +| `vscode-cosmosdb` | `subscription.environment` into ARM clients, with sovereign tests | Lets the Cosmos SDK derive scopes from the account endpoint | +| `vscode-mssql` | Own Azure config model | Per-cloud resource descriptors (`sqlResource`, `ossRdbmsResource`) in a static table; same VS Code client ID | +| Azure CLI | ARM metadata | Static per-cloud `ossrdbms_resource_id`, used by `az account get-access-token --resource-type oss-rdbms` | + +The pattern that holds up without test access: **pick the cloud from the same source that picks the +authority** (`microsoft-sovereign-cloud.environment`), and map it to a static, closed table of +resources. Never build an audience from a hostname. + +## 7. Workarounds for affected users + +- Native DocumentDB username and password, if enabled on the cluster. +- To confirm the diagnosis: read the code in the system sign-in window, or open **Developer: Show + Logs… → Microsoft Sovereign Cloud Authentication**. Whether that log contains the inner broker error + is unverified. Setting `microsoft-authentication.implementation` to `msal-no-broker` also exposes the + code, with the caveat in §3. +- **Unverified:** an Azure CLI token (`az account get-access-token --resource-type oss-rdbms`) uses + the Azure CLI client ID, which is probably preauthorized for OSSRDBMS in US Government because the + PostgreSQL docs recommend it there. The extension cannot consume such a token today. + +## 8. Managed identity: which cloud is the VM in? + +Managed identity tokens come from the VM's instance metadata service (IMDS), not from VS Code's +sign-in, so `microsoft-sovereign-cloud.environment` says nothing about them. + +- IMDS documents `compute.azEnvironment`, "Azure Environment where the VM is running in", since + api-version 2018-10-01. The only documented value is `AZUREPUBLICCLOUD`; values for other clouds are + not documented ([Instance Metadata Service](https://learn.microsoft.com/azure/virtual-machines/instance-metadata-service)). +- The cluster host is a stronger signal: managed identity needs the VM and the cluster in the same + tenant, hence the same cloud. `.mongocluster.cosmos.azure.cn` identifies China (public docs above). +- Azure China region names (`chinaeast`, `chinaeast2`, `chinaeast3`, `chinanorth`, `chinanorth2`, + `chinanorth3`) are public ([regions list](https://docs.azure.cn/zh-cn/reliability/regions-list)). + +## 9. Open questions + +1. Who owns `123cd850` in US Government and China, and what is the request process? +2. Is VS Code `aebc6443` preauthorized for OSSRDBMS in China? In other national clouds? +3. Which audiences does a DocumentDB cluster accept in US Government and China? In particular, does + Entra sign-in to China clusters work today with the public audience? If it does, switching to the + per-cloud audience could break those users. Check telemetry for successful Entra connections to + `*.azure.cn` hosts before shipping. +4. DocumentDB availability and host suffix in US Government (not publicly documented). +5. `compute.azEnvironment` values outside the public cloud. +6. Does the Microsoft Sovereign Cloud Authentication log contain the inner broker error? + +Answered: on Windows with the broker, only `platform_broker_error` reaches the extension (§3). From 3388cb755591d33d843e0296ce600f022da8166a Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Fri, 23 Oct 2026 16:39:06 +0000 Subject: [PATCH 02/50] docs(sovereign-clouds): record plan approval and azure.us cluster domain Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../features/sovereign-clouds/README.md | 3 +- .../features/sovereign-clouds/decisions.md | 52 ++++++++++--------- ...cloud-aware-scope-and-entra-diagnostics.md | 17 +++--- .../sovereign-clouds/research-findings.md | 3 +- 4 files changed, 41 insertions(+), 34 deletions(-) diff --git a/docs/ai-and-plans/features/sovereign-clouds/README.md b/docs/ai-and-plans/features/sovereign-clouds/README.md index c04919d26..791d6a5e2 100644 --- a/docs/ai-and-plans/features/sovereign-clouds/README.md +++ b/docs/ai-and-plans/features/sovereign-clouds/README.md @@ -19,7 +19,7 @@ explanations when Entra sign-in fails. ## Status -**Investigation complete, implementation planned.** No PR yet. Nobody on the team has access to a +**Plan approved, implementation in progress.** No PR yet. Nobody on the team has access to a sovereign cloud, so every change here is made without being able to test it end to end. See [D2](decisions.md#d2-implement-without-sovereign-cloud-access). @@ -85,4 +85,3 @@ Azure management (subscription listing, ARM clients) already follows the selecte - Not confirmed which audience DocumentDB accepts in US Government. ARM metadata publishes the `usgovcloudapi.net` URI as the OSSRDBMS resource there, but no DocumentDB document confirms it. - Whether China clusters accept the public audience today (check telemetry before shipping, D11). -- DocumentDB availability and host suffix in US Government are not publicly documented. diff --git a/docs/ai-and-plans/features/sovereign-clouds/decisions.md b/docs/ai-and-plans/features/sovereign-clouds/decisions.md index 89d03445b..52aa0cbaf 100644 --- a/docs/ai-and-plans/features/sovereign-clouds/decisions.md +++ b/docs/ai-and-plans/features/sovereign-clouds/decisions.md @@ -6,29 +6,28 @@ status: active # Sovereign Clouds: Decisions -"Adopted" means the operator chose to move forward with the entry while other research is -pending; the operator may still revisit it. Superseded entries are kept for their rejected +"Accepted" entries were approved by the operator. Superseded entries are kept for their rejected alternatives. | ID | Status | Summary | | --- | --------------------------- | -------------------------------------------------------------------------------------------------- | -| D1 | **Adopted** | The preauthorization gap is fixed outside the extension; no own client ID | +| D1 | **Accepted** (operator) | The preauthorization gap is fixed outside the extension; no own client ID | | D2 | **Accepted** (operator) | Implement without sovereign-cloud access, copying patterns proven elsewhere | | D3 | **Accepted** (operator) | Entra sign-in errors are classified in one table, routed through the diagnostics service | | D4 | **Superseded** by D6 and D7 | Which signal decides the cloud | | D5 | **Superseded** by D6 | Custom clouds keep the public audience | -| D6 | **Adopted** | Interactive sign-in: cloud from VS Code's setting; custom clouds resolved, never public | -| D7 | **Adopted** | Managed identity: cloud from an override, the cluster host, then IMDS; otherwise fail clearly | -| D8 | **Adopted** | Stop before requesting a token when the cluster host and the selected cloud clearly disagree | -| D9 | **Adopted** | An opaque broker error in a sovereign cloud gets a hedged message that asks for the on-screen code | -| D10 | **Adopted** | Messages are text only in this iteration; no buttons | -| D11 | **Adopted** | No public-audience fallback for China unless telemetry shows it works today | +| D6 | **Accepted** (operator) | Interactive sign-in: cloud from VS Code's setting; custom clouds resolved, never public | +| D7 | **Accepted** (operator) | Managed identity: cloud from an override, the cluster host, then IMDS; otherwise fail clearly | +| D8 | **Accepted** (operator) | Stop before requesting a token when the cluster host and the selected cloud clearly disagree | +| D9 | **Accepted** (operator) | An opaque broker error in a sovereign cloud gets a hedged message that asks for the on-screen code | +| D10 | **Accepted** (operator) | Messages are text only in this iteration; no buttons | +| D11 | **Accepted** (operator) | No public-audience fallback for China unless telemetry shows it works today | --- ## D1. Service-side preauthorization, not a client workaround -**Adopted.** +**Accepted** by the operator. The missing preauthorization (see [README](README.md#what-is-being-done)) is raised with Microsoft outside the code. Two public precedents were resolved the same way: microsoft/vscode-mssql#22296 @@ -84,7 +83,7 @@ fallback; failing clearly is better. ## D6. Interactive sign-in -**Adopted.** +**Accepted** by the operator. The audience follows the same setting that chooses the sign-in authority, `microsoft-sovereign-cloud.environment`, so the two cannot disagree. @@ -111,33 +110,36 @@ command. ## D7. Managed identity -**Adopted.** +**Accepted** by the operator. Managed identity tokens come from the VM, not from VS Code's sign-in, so the setting is not a signal. 1. `documentDB.customEnvironment.ossrdbmsEndpoint`, if set. -2. A `TOKEN_RESOURCE` from the connection string, only if it is one of the known audiences. -3. The cluster host: `.azure.cn` → China, `.azure.com` → public. No `.azure.us` rule until a US - Government DocumentDB host suffix is publicly documented. -4. IMDS `compute.azEnvironment`: `AZUREPUBLICCLOUD` → public. Any other value means "not public, +2. The cluster host: `.azure.com` → public, `.azure.us` → US Government, `.azure.cn` → China. The + operator confirmed `azure.us` as the domain of US Government clusters. +3. IMDS `compute.azEnvironment`: `AZUREPUBLICCLOUD` → public. Any other value means "not public, cloud unknown". -5. Otherwise fail with a message naming the setting from step 1. +4. Otherwise fail with a message naming the setting from step 1. + +Dropped during implementation: reading `TOKEN_RESOURCE` from the connection string. The wizard +removes `authMechanismProperties` before the connection is saved, so the value never reaches connect +time. The VS Code setting is deliberately not consulted: a user who never signs in interactively has no reason to set it, and a stale value would pick the wrong audience silently. ## D8. Cloud mismatch stops the request -**Adopted.** +**Accepted** by the operator. -For interactive sign-in, when the cluster host ends in `.azure.cn` but the selected cloud is not -China, or ends in `.azure.com` but the selected cloud is not public, the request is stopped before a -token is requested, with a message naming both clouds and the setting. No rule for `.azure.us` (see -D7). Hosts that match neither suffix, such as private DNS names, are not checked. +For interactive sign-in, when the cluster host's Azure domain (`azure.com`, `azure.us`, `azure.cn`) +belongs to a different cloud than the one VS Code is signed in to, the request is stopped before a +token is requested, with a message naming both clouds and the setting. Hosts outside these domains, +such as private DNS names, are not checked. ## D9. Opaque broker errors -**Adopted.** +**Accepted** by the operator. On Windows the extension receives only `platform_broker_error`, without the AADSTS code (research §3). When the failure is opaque, the selected cloud is not public, and the failing request was the @@ -147,7 +149,7 @@ message. ## D10. Text-only messages in this iteration -**Adopted.** +**Accepted** by the operator. `ConnectionDiagnosticsService` providers return text only, by design (error-translation skill). Buttons such as "Report issue" or "Switch cloud" would change that contract and every call site. Each @@ -156,7 +158,7 @@ Structured actions are future work. ## D11. China and the public audience -**Adopted.** +**Accepted** by the operator. Microsoft's DocumentDB China documentation uses the China audience, so the extension switches to it. Before shipping, check telemetry for successful Entra connections to `*.azure.cn` hosts. If there are diff --git a/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics.md b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics.md index 55a1d9396..f8c351aa7 100644 --- a/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics.md +++ b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics.md @@ -28,7 +28,7 @@ Case 2 only at hand-over. **Constraint (D2):** no sovereign-cloud access. Public-cloud behavior must not change, and tests pin the exact public scope string. -## Revision 1 (before implementation started) +## Revision 1 (before implementation started; approved by the operator) The first version of this plan assumed the Windows broker passes the AADSTS code to the extension, kept the public audience for custom clouds, used the VS Code setting for managed identity, and only @@ -82,8 +82,10 @@ export function resolveInteractiveTokenResource(connectionString: string, deps?: `login.sovcloud-identity.de` (Azure CLI `cloud.py`). - Unresolved custom cloud: throws a localized error naming `documentDB.customEnvironment.ossrdbmsEndpoint`. -- Mismatch (D8): `.azure.cn` host with a non-China cloud, or `.azure.com` host with a non-public - cloud, throws a localized error naming both clouds and `microsoft-sovereign-cloud.environment`. +- Mismatch (D8): a host under `azure.com`, `azure.us` or `azure.cn` whose cloud differs from the + audience's cloud throws a localized error naming both clouds and + `microsoft-sovereign-cloud.environment`. (Revised after approval: `azure.us` was added once the + operator confirmed it as the US Government cluster domain.) - A warning is logged when the override equals the public audience but the cloud is not public. - `deps` injects settings, `fetch` and the environment for tests. @@ -91,8 +93,12 @@ export function resolveInteractiveTokenResource(connectionString: string, deps?: Same file: `resolveManagedIdentityTokenResource(connectionString, deps?)`. -- Order: override, known `TOKEN_RESOURCE` in the connection string, host suffix, IMDS - `compute.azEnvironment` (`AZUREPUBLICCLOUD` only), else a localized error naming the override. +- Order: override, host suffix, IMDS `compute.azEnvironment` (`AZUREPUBLICCLOUD` only), else a + localized error naming the override. +- **Deviation:** the plan also read a known `TOKEN_RESOURCE` from the connection string. Dropped: + `stripManagedIdentityMarkers` removes `authMechanismProperties` before the connection is saved, so + the value never reaches connect time. Keeping it would have meant a storage change for a signal the + host suffix already covers. - IMDS: `GET http://169.254.169.254/metadata/instance/compute/azEnvironment?api-version=2021-02-01&format=text` with `Metadata: true` and a short timeout; called only when the earlier steps found nothing. @@ -231,7 +237,6 @@ and `authMethod`. No tenant, subscription, cluster, user, or correlation identif - The ask-once prompt for custom clouds, as a command (D6). - Buttons on diagnostics: Report issue, Switch cloud, Copy details (D10). -- A US Government host suffix rule once it is publicly documented (D7, D8). # Outcome diff --git a/docs/ai-and-plans/features/sovereign-clouds/research-findings.md b/docs/ai-and-plans/features/sovereign-clouds/research-findings.md index caa2471a7..9b8c43bef 100644 --- a/docs/ai-and-plans/features/sovereign-clouds/research-findings.md +++ b/docs/ai-and-plans/features/sovereign-clouds/research-findings.md @@ -236,7 +236,8 @@ sign-in, so `microsoft-sovereign-cloud.environment` says nothing about them. Entra sign-in to China clusters work today with the public audience? If it does, switching to the per-cloud audience could break those users. Check telemetry for successful Entra connections to `*.azure.cn` hosts before shipping. -4. DocumentDB availability and host suffix in US Government (not publicly documented). +4. DocumentDB availability in US Government is not publicly documented. The operator confirmed + `azure.us` as the domain of US Government clusters. 5. `compute.azEnvironment` values outside the public cloud. 6. Does the Microsoft Sovereign Cloud Authentication log contain the inner broker error? From 99f7c2b036995a7cc279b775f81bf0440eea355e Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Fri, 23 Oct 2026 16:41:54 +0000 Subject: [PATCH 03/50] feat(auth): add cloud-aware scopes and explicit custom host trust Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/documentdb/auth/entraScopes.test.ts | 65 ++++++++++++++ src/documentdb/auth/entraScopes.ts | 85 ++++++++++++++++++- src/documentdb/auth/oidcAllowedHosts.test.ts | 49 ++++++++++- src/documentdb/auth/oidcAllowedHosts.ts | 89 +++++++++++++++++--- 4 files changed, 273 insertions(+), 15 deletions(-) create mode 100644 src/documentdb/auth/entraScopes.test.ts diff --git a/src/documentdb/auth/entraScopes.test.ts b/src/documentdb/auth/entraScopes.test.ts new file mode 100644 index 000000000..a8b68ecdd --- /dev/null +++ b/src/documentdb/auth/entraScopes.test.ts @@ -0,0 +1,65 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it } from 'vitest'; +import { + DOCUMENTDB_ENTRA_SCOPE, + DOCUMENTDB_TOKEN_RESOURCE, + getCloudForTokenResource, + getTokenResourceForCloud, + parseTokenResource, + toEntraScope, +} from './entraScopes'; + +describe('DocumentDB Entra scopes', () => { + it('keeps the exact public resource and scope unchanged', () => { + expect(DOCUMENTDB_TOKEN_RESOURCE).toBe('https://ossrdbms-aad.database.windows.net'); + expect(DOCUMENTDB_ENTRA_SCOPE).toBe('https://ossrdbms-aad.database.windows.net/.default'); + }); + + it.each([ + ['AzureCloud', 'https://ossrdbms-aad.database.windows.net'], + ['AzureUSGovernment', 'https://ossrdbms-aad.database.usgovcloudapi.net'], + ['AzureChinaCloud', 'https://ossrdbms-aad.database.chinacloudapi.cn'], + ] as const)('maps %s to its resource and back', (cloud, resource) => { + expect(getTokenResourceForCloud(cloud)).toBe(resource); + expect(getCloudForTokenResource(resource)).toBe(cloud); + expect(getCloudForTokenResource(`${resource}/`)).toBe(cloud); + }); + + it('normalizes the trailing slash and hostname case', () => { + expect(toEntraScope(`${DOCUMENTDB_TOKEN_RESOURCE}/`)).toBe(DOCUMENTDB_ENTRA_SCOPE); + expect(toEntraScope(DOCUMENTDB_TOKEN_RESOURCE)).toBe(DOCUMENTDB_ENTRA_SCOPE); + expect(parseTokenResource(' https://RESOURCE.EXAMPLE.COM/ ')).toBe('https://resource.example.com'); + }); + + it.each([ + undefined, + null, + 42, + '', + 'not a URL', + 'http://resource.example.com', + 'https://resource.example.com/path', + 'https://resource.example.com/path/..', + 'https://resource.example.com//', + 'https://resource.example.com?query=true', + 'https://resource.example.com?', + 'https://resource.example.com#fragment', + 'https://resource.example.com#', + 'https://resource.example.com:443', + 'https://resource.example.com:8443', + 'https://user:password@resource.example.com', + 'https://@resource.example.com', + 'https://resource.example.com\\path', + ])('rejects invalid token resource %s', (value) => { + expect(parseTokenResource(value)).toBeUndefined(); + }); + + it('does not classify unknown or invalid resources', () => { + expect(getCloudForTokenResource('https://resource.example.com')).toBeUndefined(); + expect(getCloudForTokenResource('http://ossrdbms-aad.database.windows.net')).toBeUndefined(); + }); +}); diff --git a/src/documentdb/auth/entraScopes.ts b/src/documentdb/auth/entraScopes.ts index 482cf5c30..12cacd325 100644 --- a/src/documentdb/auth/entraScopes.ts +++ b/src/documentdb/auth/entraScopes.ts @@ -3,15 +3,92 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +/** The Azure clouds whose DocumentDB token resource is known without any lookup. */ +export type DocumentDBEntraCloud = 'AzureCloud' | 'AzureUSGovernment' | 'AzureChinaCloud'; + /** * The Microsoft Entra ID resource that DocumentDB (and the wider Azure OSS RDBMS family) accepts - * tokens for. + * tokens for, per cloud. + * + * Closed on purpose. The values are the `ossrDbmsResourceId` each cloud publishes in its Azure + * Resource Manager metadata (`/metadata/endpoints?api-version=2022-09-01`), and match Azure CLI's + * `ossrdbms_resource_id` table. Never derive a token resource from a hostname. + */ +const TOKEN_RESOURCE_BY_CLOUD: Readonly> = { + AzureCloud: 'https://ossrdbms-aad.database.windows.net', + AzureUSGovernment: 'https://ossrdbms-aad.database.usgovcloudapi.net', + AzureChinaCloud: 'https://ossrdbms-aad.database.chinacloudapi.cn', +}; + +/** + * The public-cloud token resource. * * This is the `TOKEN_RESOURCE` value used in the driver-native connection string form documented on - * Microsoft Learn, and the base of the `.default` scope requested by every Entra-based auth path in - * this extension. + * Microsoft Learn, and the base of the public-cloud `.default` scope. */ -export const DOCUMENTDB_TOKEN_RESOURCE = 'https://ossrdbms-aad.database.windows.net'; +export const DOCUMENTDB_TOKEN_RESOURCE = TOKEN_RESOURCE_BY_CLOUD.AzureCloud; /** The scope form of {@link DOCUMENTDB_TOKEN_RESOURCE}, as passed to token providers. */ export const DOCUMENTDB_ENTRA_SCOPE = `${DOCUMENTDB_TOKEN_RESOURCE}/.default`; + +export function getTokenResourceForCloud(cloud: DocumentDBEntraCloud): string { + return TOKEN_RESOURCE_BY_CLOUD[cloud]; +} + +/** Turns a token resource into the `.default` scope token providers expect. */ +export function toEntraScope(tokenResource: string): string { + return `${tokenResource.replace(/\/$/, '')}/.default`; +} + +/** + * Validates a token resource that came from a setting, a connection string, or cloud metadata. + * + * Accepts only `https://` with an optional trailing slash, and returns it without the slash. + * Anything else (another scheme, a path, a query, credentials) returns `undefined`. + */ +export function parseTokenResource(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + + const normalized = value.trim(); + const authority = /^https:\/\/([^/?#:@\\]+)\/?$/i.exec(normalized)?.[1]; + if (!authority) { + return undefined; + } + + let url: URL; + try { + url = new URL(normalized); + } catch { + return undefined; + } + + if ( + url.protocol !== 'https:' || + !url.hostname || + url.hostname !== authority.toLowerCase() || + url.username || + url.password || + url.port || + url.search || + url.hash || + (url.pathname !== '/' && url.pathname !== '') + ) { + return undefined; + } + + return `https://${url.hostname.toLowerCase()}`; +} + +/** Returns the built-in cloud a token resource belongs to, or `undefined` if it is not a known one. */ +export function getCloudForTokenResource(value: string): DocumentDBEntraCloud | undefined { + const normalized = parseTokenResource(value); + if (!normalized) { + return undefined; + } + + return (Object.keys(TOKEN_RESOURCE_BY_CLOUD) as DocumentDBEntraCloud[]).find( + (cloud) => TOKEN_RESOURCE_BY_CLOUD[cloud] === normalized, + ); +} diff --git a/src/documentdb/auth/oidcAllowedHosts.test.ts b/src/documentdb/auth/oidcAllowedHosts.test.ts index 8c8a0ebdb..700a90344 100644 --- a/src/documentdb/auth/oidcAllowedHosts.test.ts +++ b/src/documentdb/auth/oidcAllowedHosts.test.ts @@ -5,7 +5,7 @@ import { describe, expect, it } from 'vitest'; -import { getOidcAllowedHosts } from './oidcAllowedHosts'; +import { getAzureCloudFromHosts, getOidcAllowedHosts, normalizeCustomHostnameSuffix } from './oidcAllowedHosts'; describe('getOidcAllowedHosts', () => { it('allows only the Azure suffix for a public-cloud host, not the full host', () => { @@ -34,12 +34,14 @@ describe('getOidcAllowedHosts', () => { expect(getOidcAllowedHosts('mongodb://evil.com:10255/')).toEqual(['*.azure.com']); // Lookalike where "azure" is not the registrable second level. expect(getOidcAllowedHosts('mongodb://node.azure.com.evil.com:10255/')).toEqual(['*.azure.com']); + expect(getOidcAllowedHosts('mongodb://node.azure.us.evil.com:10255/')).toEqual(['*.azure.com']); }); it('does not trust an azure second-level label under an unknown top-level domain', () => { expect(getOidcAllowedHosts('mongodb://db.azure.tk:10255/')).toEqual(['*.azure.com']); expect(getOidcAllowedHosts('mongodb://db.azure.io:10255/')).toEqual(['*.azure.com']); expect(getOidcAllowedHosts('mongodb://db.azure.co:10255/')).toEqual(['*.azure.com']); + expect(getOidcAllowedHosts('mongodb://db.azure.xyz:10255/')).toEqual(['*.azure.com']); }); it('does not let one known-cloud host carry an unknown-TLD host into the allowlist', () => { @@ -63,4 +65,49 @@ describe('getOidcAllowedHosts', () => { expect(getOidcAllowedHosts('not a connection string')).toEqual(['*.azure.com']); expect(getOidcAllowedHosts('')).toEqual(['*.azure.com']); }); + + it('trusts a custom suffix only when configured and matching a hostname boundary', () => { + const connectionString = 'mongodb://cluster.database.example.com:10255/'; + expect(getOidcAllowedHosts(connectionString)).toEqual(['*.azure.com']); + expect(getOidcAllowedHosts(connectionString, '.database.example.com')).toEqual(['*.database.example.com']); + expect(getOidcAllowedHosts(connectionString, '.other.example.com')).toEqual(['*.azure.com']); + expect(getOidcAllowedHosts('mongodb://notdatabase.example.com/', '.database.example.com')).toEqual([ + '*.azure.com', + ]); + expect(getOidcAllowedHosts('mongodb://database.example.com.evil.com/', '.database.example.com')).toEqual([ + '*.azure.com', + ]); + }); + + it('normalizes the custom suffix without trusting invalid or bare TLD values', () => { + expect(normalizeCustomHostnameSuffix(' .DATABASE.EXAMPLE.COM ')).toBe('database.example.com'); + for (const value of [undefined, '', '.', 'com', '*.example.com', 'https://example.com', '..example.com']) { + expect(normalizeCustomHostnameSuffix(value)).toBeUndefined(); + } + }); +}); + +describe('getAzureCloudFromHosts', () => { + it.each([ + ['azure.com', 'AzureCloud'], + ['azure.us', 'AzureUSGovernment'], + ['azure.cn', 'AzureChinaCloud'], + ])('classifies %s only when every host agrees', (suffix, cloud) => { + expect(getAzureCloudFromHosts(`mongodb://a.${suffix}:10255,b.${suffix}:10255/`)).toBe(cloud); + expect(getAzureCloudFromHosts(`mongodb+srv://CLUSTER.${suffix}/`)).toBe(cloud); + }); + + it.each([ + 'mongodb://a.azure.com,b.azure.us/', + 'mongodb://a.azure.cn,b.azure.com/', + 'mongodb://a.azure.com,private.example.com/', + 'mongodb://private.example.com/', + 'mongodb://node.azure.us.evil.com/', + 'mongodb://node.azure.xyz/', + 'mongodb://127.0.0.1/', + 'not a connection string', + '', + ])('does not classify mixed, unknown, or invalid hosts: %s', (connectionString) => { + expect(getAzureCloudFromHosts(connectionString)).toBeUndefined(); + }); }); diff --git a/src/documentdb/auth/oidcAllowedHosts.ts b/src/documentdb/auth/oidcAllowedHosts.ts index ff3ff01e5..b5a96dcb2 100644 --- a/src/documentdb/auth/oidcAllowedHosts.ts +++ b/src/documentdb/auth/oidcAllowedHosts.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { DocumentDBConnectionString } from '../utils/DocumentDBConnectionString'; +import { type DocumentDBEntraCloud } from './entraScopes'; // Safe default when we cannot positively identify an Azure endpoint. This is the // historical behavior and covers the public cloud (including private endpoints, @@ -30,12 +31,15 @@ const KNOWN_AZURE_TOP_LEVEL_DOMAINS: ReadonlySet = new Set(['com', 'us', * sovereign clouds. Anything we cannot positively classify as one of these * clouds falls back to the safe public-cloud default. * - * Note: sovereign clouds also use a different Entra token endpoint; wiring that - * up is tracked separately. This change only widens the host allowlist. + * A custom cloud's DocumentDB host suffix (`documentDB.customEnvironment.mongoClusterHostnameSuffix`) + * is trusted only when the user configured it explicitly and the host actually ends with it. + * + * The token audience for each cloud is chosen separately, in `documentdbTokenResource.ts`. */ -export function getOidcAllowedHosts(connectionString: string): string[] { +export function getOidcAllowedHosts(connectionString: string, customHostnameSuffix?: string): string[] { try { const parsed = new DocumentDBConnectionString(connectionString); + const customSuffix = normalizeCustomHostnameSuffix(customHostnameSuffix); // Deduplicate so a replica-set connection string with several hosts in // the same cloud yields a single `*.azure.` entry. @@ -44,6 +48,12 @@ export function getOidcAllowedHosts(connectionString: string): string[] { const suffix = getAzureHostSuffix(host); if (suffix) { suffixes.add(suffix); + continue; + } + + const hostname = getHostname(host); + if (customSuffix && hostname?.endsWith(`.${customSuffix}`)) { + suffixes.add(customSuffix); } } @@ -57,6 +67,71 @@ export function getOidcAllowedHosts(connectionString: string): string[] { return DEFAULT_ALLOWED_HOSTS; } +/** + * Returns the built-in Azure cloud a connection string's hosts belong to, when every host agrees. + * + * `azure.com` is the public cloud, `azure.us` Azure US Government, and `azure.cn` Azure China. Hosts + * outside these domains (private DNS names, IP addresses) return `undefined`. + */ +export function getAzureCloudFromHosts(connectionString: string): DocumentDBEntraCloud | undefined { + let hosts: string[]; + try { + hosts = new DocumentDBConnectionString(connectionString).hosts ?? []; + } catch { + return undefined; + } + + const clouds = new Set( + hosts.map((host) => { + switch (getAzureHostSuffix(host)) { + case 'azure.com': + return 'AzureCloud'; + case 'azure.us': + return 'AzureUSGovernment'; + case 'azure.cn': + return 'AzureChinaCloud'; + default: + return undefined; + } + }), + ); + + if (clouds.size !== 1) { + return undefined; + } + return [...clouds][0]; +} + +/** + * Validates `documentDB.customEnvironment.mongoClusterHostnameSuffix` and returns it without the + * leading dot. At least two labels are required so a bare TLD can never be trusted. + */ +export function normalizeCustomHostnameSuffix(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + + const suffix = value.trim().toLowerCase().replace(/^\./, ''); + const labels = suffix.split('.'); + if (labels.length < 2 || labels.some((label) => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label))) { + return undefined; + } + + return suffix; +} + +function getHostname(host: string): string | undefined { + // `hosts` entries may carry a port (e.g. `cluster.azure.com:10255`) and an + // IPv6 literal is wrapped in brackets; neither is part of the suffix. + return ( + host + .replace(/^\[.*\]/, '') // drop bracketed IPv6 literals (never Azure FQDNs) + .split(':')[0] + ?.trim() + .toLowerCase() || undefined + ); +} + /** * Returns the Azure cloud suffix (`azure.`) for a host, or `undefined` if * the host is not an endpoint in a known Azure cloud. @@ -66,13 +141,7 @@ export function getOidcAllowedHosts(connectionString: string): string[] { * and the top-level label must be one of {@link KNOWN_AZURE_TOP_LEVEL_DOMAINS}. */ function getAzureHostSuffix(host: string): string | undefined { - // `hosts` entries may carry a port (e.g. `cluster.azure.com:10255`) and an - // IPv6 literal is wrapped in brackets; neither is part of the suffix. - const hostname = host - .replace(/^\[.*\]/, '') // drop bracketed IPv6 literals (never Azure FQDNs) - .split(':')[0] - ?.trim() - .toLowerCase(); + const hostname = getHostname(host); if (!hostname) { return undefined; From 5aafc30e4b0e4f54c589211eb3a6c637a09f1aca Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Fri, 23 Oct 2026 16:42:03 +0000 Subject: [PATCH 04/50] feat(auth): add custom sovereign cloud settings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- package.json | 18 ++++++++++++++++++ src/settingsKeys.ts | 1 + 2 files changed, 19 insertions(+) diff --git a/package.json b/package.json index 3ad946931..13302a851 100644 --- a/package.json +++ b/package.json @@ -1986,6 +1986,24 @@ "maximum": 65535, "scope": "machine-overridable", "description": "Starting port for automatic local port selection when localPortStrategy is 'autoSelect'. The extension scans upward until a free port is found." + }, + "documentDB.customEnvironment": { + "order": 5, + "type": "object", + "default": {}, + "scope": "machine-overridable", + "markdownDescription": "Additional DocumentDB-specific configuration for a custom sovereign cloud. Use it together with `#microsoft-sovereign-cloud.environment#` set to `custom` and `#microsoft-sovereign-cloud.customEnvironment#`. `ossrdbmsEndpoint` is also used for managed identity when the cloud of the virtual machine cannot be detected.", + "properties": { + "ossrdbmsEndpoint": { + "type": "string", + "description": "Sovereign cloud equivalent for https://ossrdbms-aad.database.windows.net/ (the Microsoft Entra resource for open-source databases)." + }, + "mongoClusterHostnameSuffix": { + "type": "string", + "description": "Sovereign cloud equivalent for .mongocluster.cosmos.azure.com (including the leading dot). Microsoft Entra ID tokens are sent only to hosts ending with this suffix or with a built-in Azure cloud domain." + } + }, + "additionalProperties": false } } }, diff --git a/src/settingsKeys.ts b/src/settingsKeys.ts index f97f6a3e9..22fbc0b30 100644 --- a/src/settingsKeys.ts +++ b/src/settingsKeys.ts @@ -22,6 +22,7 @@ export namespace settingsKeys { export const localPort = 'documentDB.local.port'; export const kubernetesLocalPortStrategy = 'documentDB.serviceDiscovery.kubernetes.portForward.localPortStrategy'; export const kubernetesLocalPortBase = 'documentDB.serviceDiscovery.kubernetes.portForward.localPortBase'; + export const customEnvironment = 'documentDB.customEnvironment'; // Queries & Results export const batchSize = 'documentDB.batchSize'; From 79af884e6b68e903c8b09288ea7c3c04ac8b1ac5 Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Fri, 23 Oct 2026 16:42:06 +0000 Subject: [PATCH 05/50] docs: add sovereign clouds guidance Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/user-manual/sovereign-clouds.md | 115 +++++++++++++++++++++++++++ 1 file changed, 115 insertions(+) create mode 100644 docs/user-manual/sovereign-clouds.md diff --git a/docs/user-manual/sovereign-clouds.md b/docs/user-manual/sovereign-clouds.md new file mode 100644 index 000000000..d5769c9b8 --- /dev/null +++ b/docs/user-manual/sovereign-clouds.md @@ -0,0 +1,115 @@ +--- +title: 'Sovereign Clouds (Azure Government, Azure China)' +parent: 'Connecting to Databases' +nav_order: 6 +--- + +# Sovereign Clouds (Azure Government, Azure China) + +The DocumentDB extension for Visual Studio Code supports Azure US Government, Azure China, and +custom Azure clouds when you connect with Microsoft Entra ID or a managed identity. + +## Select a cloud + +VS Code signs in to the cloud selected by the `microsoft-sovereign-cloud.environment` setting: + +- `USGovernment` for Azure US Government +- `ChinaCloud` for Azure China +- `custom` for a custom cloud +- Unset for the public cloud + +The Azure Resources extension has a cloud picker that updates this setting. After you change the +cloud, sign out of VS Code and sign in again. + +## Token resource for Microsoft Entra ID + +The extension requests a token for the open-source database resource of the selected cloud: + +| Cloud | Resource | +| ------------------- | ------------------------------------------------- | +| Public | `https://ossrdbms-aad.database.windows.net` | +| Azure US Government | `https://ossrdbms-aad.database.usgovcloudapi.net` | +| Azure China | `https://ossrdbms-aad.database.chinacloudapi.cn` | + +These values are published by each cloud in its Azure Resource Manager metadata at +`/metadata/endpoints?api-version=2022-09-01`. + +If a cluster host ends in `azure.com`, `azure.us`, or `azure.cn`, and that suffix identifies a +different cloud from the one VS Code is signed in to, the extension stops before sign-in. It tells +you which `microsoft-sovereign-cloud.environment` setting to change. + +## Managed identity + +For a managed identity, the extension determines the cloud from the cluster host: +`azure.com`, `azure.us`, or `azure.cn`. Otherwise, it uses the Azure VM's instance metadata. + +If neither source identifies the cloud, set `documentDB.customEnvironment.ossrdbmsEndpoint`. + +## Custom clouds + +Set `microsoft-sovereign-cloud.environment` to `custom` and fill in +`microsoft-sovereign-cloud.customEnvironment`. The extension then finds the open-source database +resource in this order: + +1. `documentDB.customEnvironment.ossrdbmsEndpoint` +2. The custom cloud's sign-in host, if it is a known Azure cloud +3. The cloud's Azure Resource Manager metadata, `ossrDbmsResourceId` + +If none is available, connecting fails with a message that names +`documentDB.customEnvironment.ossrdbmsEndpoint`. It never falls back to the public cloud. + +By default, the extension sends tokens only to cluster hosts under `azure.com`, `azure.us`, and +`azure.cn`. Set `documentDB.customEnvironment.mongoClusterHostnameSuffix` to allow cluster hosts +in a custom cloud. Include the leading dot. + +```json +{ + "documentDB.customEnvironment.ossrdbmsEndpoint": "https://ossrdbms-aad.database.example", + "documentDB.customEnvironment.mongoClusterHostnameSuffix": ".mongocluster.cosmos.example" +} +``` + +## Sign-in error AADSTS65002 + +In some clouds, Microsoft Entra ID has not authorized VS Code to request tokens for Azure +DocumentDB. Sign-in can then show **Something went wrong** or fail with AADSTS65002, which says +the application "must be configured via preauthorization." + +Microsoft must make this configuration change. You and your tenant administrator cannot change it. +The Azure Resources view can still list resources because it requests a token for a different +resource. + +Until the configuration changes, you can: + +- Use native DocumentDB authentication with a username and password, if the cluster enables it. +- Use a managed identity when VS Code runs on an Azure VM. + +## Find the error code + +On Windows, the sign-in window shows the error code in its troubleshooting details. The extension +receives only the generic `platform_broker_error`. + +To find more details: + +1. Check the sign-in window. +2. Run **Developer: Show Logs...**. +3. Open **Microsoft Sovereign Cloud Authentication**, or **Microsoft Authentication** for the + public cloud. + +As a manual diagnostic step only, set `microsoft-authentication.implementation` to +`msal-no-broker`. The raw AADSTS error then reaches the extension. This setting affects all of VS +Code. In Azure China, tokens are currently not kept in this mode. See +[VS Code issue 307340](https://github.com/microsoft/vscode/issues/307340). Change the setting back +after you finish diagnosing the problem. + +## Report a problem + +[Open an issue](https://github.com/microsoft/vscode-documentdb/issues) and include: + +- DocumentDB extension version +- VS Code version +- Operating system +- Selected cloud +- AADSTS error code + +Remove tenant IDs, subscription IDs, account names, and correlation IDs before posting. From 891af7168bbda0b8791c7989bde2e08d99b5d2c7 Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Fri, 23 Oct 2026 16:42:23 +0000 Subject: [PATCH 06/50] docs: link sovereign clouds guidance Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/user-manual/connect-with-managed-identity.md | 2 ++ docs/user-manual/connecting-to-databases.md | 2 ++ src/constants.ts | 2 ++ 3 files changed, 6 insertions(+) diff --git a/docs/user-manual/connect-with-managed-identity.md b/docs/user-manual/connect-with-managed-identity.md index 152d9be18..4e7354adf 100644 --- a/docs/user-manual/connect-with-managed-identity.md +++ b/docs/user-manual/connect-with-managed-identity.md @@ -10,6 +10,8 @@ When VS Code runs on an **Azure VM** that has a managed identity assigned, the e This is the right choice for a shared jump box, a build agent, or any Azure VM where an interactive sign-in is impractical or where you want the connection to be attributable to the machine rather than to a person. +For Azure US Government, Azure China, or a custom cloud, see [Sovereign Clouds](./sovereign-clouds). + **Table of Contents** - [Before you start](#before-you-start) diff --git a/docs/user-manual/connecting-to-databases.md b/docs/user-manual/connecting-to-databases.md index ed7e35a34..33944a473 100644 --- a/docs/user-manual/connecting-to-databases.md +++ b/docs/user-manual/connecting-to-databases.md @@ -7,3 +7,5 @@ nav_order: 1 # Connecting to Databases Connect to DocumentDB and MongoDB databases: with a URL or connection string, through service discovery in Azure, Kubernetes and MongoDB Atlas, or to a database running on your machine. + +For Azure US Government, Azure China, or a custom cloud, see [Sovereign Clouds](./sovereign-clouds). diff --git a/src/constants.ts b/src/constants.ts index d8695667c..02ba4edaf 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -14,6 +14,8 @@ export namespace Links { export const LocalConnectionDebuggingTips: string = 'https://aka.ms/vscode-documentdb-local-connections'; export const SrvDnsTroubleshooting: string = 'https://microsoft.github.io/vscode-documentdb/user-manual/troubleshooting-srv-dns'; + export const SovereignClouds: string = + 'https://microsoft.github.io/vscode-documentdb/user-manual/sovereign-clouds'; } export const doubleClickDebounceDelay = 500; //milliseconds From b1e39d1b5bc656c2fc4c3cb87fc1e7df26792d81 Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Fri, 23 Oct 2026 16:43:56 +0000 Subject: [PATCH 07/50] feat(auth): resolve token audiences for interactive and managed identity flows Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../auth/documentdbTokenResource.test.ts | 342 ++++++++++++++++ .../auth/documentdbTokenResource.ts | 382 ++++++++++++++++++ 2 files changed, 724 insertions(+) create mode 100644 src/documentdb/auth/documentdbTokenResource.test.ts create mode 100644 src/documentdb/auth/documentdbTokenResource.ts diff --git a/src/documentdb/auth/documentdbTokenResource.test.ts b/src/documentdb/auth/documentdbTokenResource.test.ts new file mode 100644 index 000000000..a2e7949ae --- /dev/null +++ b/src/documentdb/auth/documentdbTokenResource.test.ts @@ -0,0 +1,342 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// eslint-disable-next-line import/no-internal-modules +import { getConfiguredAzureEnv } from '@microsoft/vscode-azext-azureauth/out/src/utils/configuredAzureEnv'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as vscode from 'vscode'; +import { + getCloudCategory, + resetTokenResourceCacheForTests, + resolveInteractiveTokenResource, + resolveManagedIdentityTokenResource, + resolveTokenResourceForCopy, + TokenResourceResolutionError, + type TokenResourceResolverDeps, +} from './documentdbTokenResource'; +import { DOCUMENTDB_TOKEN_RESOURCE, getTokenResourceForCloud } from './entraScopes'; + +vi.mock('../../extensionVariables', () => ({ ext: {} })); + +const privateConnection = 'mongodb://cluster.private.example.com/'; +const customResource = 'https://database.example.com'; +const publicResource = DOCUMENTDB_TOKEN_RESOURCE; +const governmentResource = getTokenResourceForCloud('AzureUSGovernment'); +const chinaResource = getTokenResourceForCloud('AzureChinaCloud'); + +function createDeps(): TokenResourceResolverDeps { + return { + getConfiguredCloud: vi.fn(() => ({ name: 'AzureCloud', isCustomCloud: false })), + getCustomEnvironment: vi.fn(() => ({})), + fetchArmMetadata: vi.fn().mockResolvedValue(undefined), + readImdsAzEnvironment: vi.fn().mockResolvedValue(undefined), + warn: vi.fn(), + }; +} + +function useCustomCloud(deps: TokenResourceResolverDeps): void { + vi.mocked(deps.getConfiguredCloud).mockReturnValue({ + name: 'custom', + isCustomCloud: true, + activeDirectoryEndpointUrl: 'https://login.example.com/', + resourceManagerEndpointUrl: 'https://management.example.com/', + }); +} + +beforeEach(() => { + vi.restoreAllMocks(); + resetTokenResourceCacheForTests(); +}); + +describe('resolveInteractiveTokenResource', () => { + it.each([ + [undefined, publicResource, 'public'], + ['USGovernment', governmentResource, 'usgov'], + ['ChinaCloud', chinaResource, 'china'], + ])('uses the VS Code setting %s', async (setting, resource, category) => { + vi.spyOn(vscode.workspace, 'getConfiguration').mockReturnValue({ + get: (key: string): unknown => (key === 'environment' ? setting : undefined), + } as vscode.WorkspaceConfiguration); + const deps = createDeps(); + deps.getConfiguredCloud = getConfiguredAzureEnv; + + expect(await resolveInteractiveTokenResource(privateConnection, deps)).toEqual({ + resource, + scope: `${resource}/.default`, + cloudCategory: category, + reason: 'setting', + }); + expect(deps.fetchArmMetadata).not.toHaveBeenCalled(); + expect(deps.readImdsAzEnvironment).not.toHaveBeenCalled(); + }); + + it('uses a validated custom override before the login host or metadata', async () => { + const deps = createDeps(); + useCustomCloud(deps); + vi.mocked(deps.getCustomEnvironment).mockReturnValue({ ossrdbmsEndpoint: `${customResource}/` }); + + expect(await resolveInteractiveTokenResource(privateConnection, deps)).toEqual({ + resource: customResource, + scope: `${customResource}/.default`, + cloudCategory: 'custom', + reason: 'customOverride', + }); + expect(deps.fetchArmMetadata).not.toHaveBeenCalled(); + }); + + it('does not apply the custom override to a built-in interactive cloud', async () => { + const deps = createDeps(); + vi.mocked(deps.getCustomEnvironment).mockReturnValue({ ossrdbmsEndpoint: customResource }); + expect(await resolveInteractiveTokenResource(privateConnection, deps)).toMatchObject({ + resource: publicResource, + reason: 'setting', + }); + }); + + it.each([ + ['login.microsoftonline.com', publicResource], + ['login.microsoftonline.us', governmentResource], + ['login.chinacloudapi.cn', chinaResource], + ['login.sovcloud-identity.fr', 'https://ossrdbms-aad.database.sovcloud-api.fr'], + ['login.sovcloud-identity.de', 'https://ossrdbms-aad.database.sovcloud-api.de'], + ])('recognizes the public Azure CLI login host %s', async (host, resource) => { + const deps = createDeps(); + vi.mocked(deps.getConfiguredCloud).mockReturnValue({ + name: 'custom', + isCustomCloud: true, + activeDirectoryEndpointUrl: `https://${host.toUpperCase()}/`, + }); + expect(await resolveInteractiveTokenResource(privateConnection, deps)).toMatchObject({ + resource, + reason: 'customLoginHost', + }); + expect(deps.fetchArmMetadata).not.toHaveBeenCalled(); + }); + + it.each(['ossrDbmsResourceId', 'OSSRDBMSRESOURCEID'])('reads the ARM metadata key %s', async (key) => { + const deps = createDeps(); + useCustomCloud(deps); + vi.mocked(deps.fetchArmMetadata).mockResolvedValue({ [key]: `${customResource}/` }); + + expect(await resolveInteractiveTokenResource(privateConnection, deps)).toMatchObject({ + resource: customResource, + reason: 'armMetadata', + }); + expect(deps.fetchArmMetadata).toHaveBeenCalledWith( + 'https://management.example.com/metadata/endpoints?api-version=2022-09-01', + ); + }); + + it.each([ + undefined, + {}, + { ossrDbmsResourceId: 'http://database.example.com' }, + { ossrDbmsResourceId: 'https://database.example.com/path' }, + { ossrDbmsResourceId: 42 }, + ])('fails clearly for missing or invalid ARM metadata: %s', async (body) => { + const deps = createDeps(); + useCustomCloud(deps); + vi.mocked(deps.fetchArmMetadata).mockResolvedValue(body); + await expect(resolveInteractiveTokenResource(privateConnection, deps)).rejects.toMatchObject({ + kind: 'customCloudUnresolved', + message: expect.stringContaining('documentDB.customEnvironment.ossrdbmsEndpoint'), + }); + }); + + it.each(['http://database.example.com', 'https://database.example.com/path', 'not a URL'])( + 'does not use an invalid custom override: %s', + async (override) => { + const deps = createDeps(); + useCustomCloud(deps); + vi.mocked(deps.getCustomEnvironment).mockReturnValue({ ossrdbmsEndpoint: override }); + await expect(resolveInteractiveTokenResource(privateConnection, deps)).rejects.toMatchObject({ + kind: 'customCloudUnresolved', + }); + }, + ); + + it('fails clearly when custom is selected without a custom cloud definition', async () => { + const deps = createDeps(); + vi.mocked(deps.getConfiguredCloud).mockImplementation(() => { + throw new Error('Custom cloud not configured'); + }); + await expect(resolveInteractiveTokenResource(privateConnection, deps)).rejects.toBeInstanceOf( + TokenResourceResolutionError, + ); + await expect(resolveInteractiveTokenResource(privateConnection, deps)).rejects.toMatchObject({ + kind: 'customCloudUnresolved', + }); + }); + + it('caches successful metadata per Resource Manager endpoint', async () => { + const deps = createDeps(); + useCustomCloud(deps); + vi.mocked(deps.fetchArmMetadata).mockResolvedValue({ ossrDbmsResourceId: customResource }); + await resolveInteractiveTokenResource(privateConnection, deps); + await resolveInteractiveTokenResource(privateConnection, deps); + expect(deps.fetchArmMetadata).toHaveBeenCalledTimes(1); + + vi.mocked(deps.getConfiguredCloud).mockReturnValue({ + name: 'custom', + isCustomCloud: true, + resourceManagerEndpointUrl: 'https://other-management.example.com', + }); + await resolveInteractiveTokenResource(privateConnection, deps); + expect(deps.fetchArmMetadata).toHaveBeenCalledTimes(2); + }); + + it('does not cache failed metadata lookups', async () => { + const deps = createDeps(); + useCustomCloud(deps); + await expect(resolveInteractiveTokenResource(privateConnection, deps)).rejects.toMatchObject({ + kind: 'customCloudUnresolved', + }); + vi.mocked(deps.fetchArmMetadata).mockResolvedValue({ ossrDbmsResourceId: customResource }); + await expect(resolveInteractiveTokenResource(privateConnection, deps)).resolves.toMatchObject({ + resource: customResource, + }); + expect(deps.fetchArmMetadata).toHaveBeenCalledTimes(2); + }); + + it.each([ + ['azure.us', 'AzureCloud', 'Azure US Government', 'Azure (public cloud)'], + ['azure.com', 'AzureUSGovernment', 'Azure (public cloud)', 'Azure US Government'], + ['azure.cn', 'AzureCloud', 'Azure China', 'Azure (public cloud)'], + ['azure.com', 'AzureChinaCloud', 'Azure (public cloud)', 'Azure China'], + ])('stops a %s host with the %s audience', async (suffix, name, hostDisplay, audienceDisplay) => { + const deps = createDeps(); + vi.mocked(deps.getConfiguredCloud).mockReturnValue({ name, isCustomCloud: false }); + await expect(resolveInteractiveTokenResource(`mongodb://cluster.${suffix}/`, deps)).rejects.toMatchObject({ + kind: 'cloudMismatch', + message: expect.stringContaining('microsoft-sovereign-cloud.environment'), + }); + await expect(resolveInteractiveTokenResource(`mongodb://cluster.${suffix}/`, deps)).rejects.toThrow( + hostDisplay, + ); + await expect(resolveInteractiveTokenResource(`mongodb://cluster.${suffix}/`, deps)).rejects.toThrow( + audienceDisplay, + ); + }); + + it('does not check private hosts for a cloud mismatch', async () => { + const deps = createDeps(); + vi.mocked(deps.getConfiguredCloud).mockReturnValue({ name: 'AzureUSGovernment', isCustomCloud: false }); + expect(await resolveInteractiveTokenResource(privateConnection, deps)).toMatchObject({ + resource: governmentResource, + }); + }); + + it('warns when a custom cloud explicitly overrides the resource to public', async () => { + const deps = createDeps(); + useCustomCloud(deps); + vi.mocked(deps.getCustomEnvironment).mockReturnValue({ ossrdbmsEndpoint: publicResource }); + await resolveInteractiveTokenResource(privateConnection, deps); + expect(deps.warn).toHaveBeenCalledWith(expect.stringContaining('documentDB.customEnvironment.ossrdbmsEndpoint')); + }); +}); + +describe('resolveManagedIdentityTokenResource', () => { + it('uses the override before the host and never reads the VS Code cloud setting', async () => { + const deps = createDeps(); + vi.mocked(deps.getCustomEnvironment).mockReturnValue({ ossrdbmsEndpoint: `${customResource}/` }); + expect(await resolveManagedIdentityTokenResource('mongodb://cluster.azure.us/', deps)).toMatchObject({ + resource: customResource, + reason: 'customOverride', + }); + expect(deps.getConfiguredCloud).not.toHaveBeenCalled(); + expect(deps.readImdsAzEnvironment).not.toHaveBeenCalled(); + }); + + it.each([ + ['azure.com', publicResource], + ['azure.us', governmentResource], + ['azure.cn', chinaResource], + ])('uses the %s host before IMDS', async (suffix, resource) => { + const deps = createDeps(); + expect(await resolveManagedIdentityTokenResource(`mongodb://cluster.${suffix}/`, deps)).toMatchObject({ + resource, + scope: `${resource}/.default`, + reason: 'host', + }); + expect(deps.getConfiguredCloud).not.toHaveBeenCalled(); + expect(deps.readImdsAzEnvironment).not.toHaveBeenCalled(); + }); + + it.each(['AZUREPUBLICCLOUD', 'azurepubliccloud', ' AzurePublicCloud '])( + 'recognizes only the public IMDS environment, ignoring case: %s', + async (environment) => { + const deps = createDeps(); + vi.mocked(deps.readImdsAzEnvironment).mockResolvedValue(environment); + expect(await resolveManagedIdentityTokenResource(privateConnection, deps)).toMatchObject({ + resource: publicResource, + reason: 'imds', + }); + expect(deps.getConfiguredCloud).not.toHaveBeenCalled(); + }, + ); + + it.each([undefined, 'AZUREUSGOVERNMENT', 'AZURECHINACLOUD', 'unknown', ''])( + 'fails clearly for unknown or unavailable IMDS environment %s', + async (environment) => { + const deps = createDeps(); + vi.mocked(deps.readImdsAzEnvironment).mockResolvedValue(environment); + await expect(resolveManagedIdentityTokenResource(privateConnection, deps)).rejects.toMatchObject({ + kind: 'managedIdentityCloudUnknown', + message: expect.stringContaining('documentDB.customEnvironment.ossrdbmsEndpoint'), + }); + }, + ); +}); + +describe('resolveTokenResourceForCopy', () => { + it('prefers the override to the host', () => { + const deps = createDeps(); + vi.mocked(deps.getCustomEnvironment).mockReturnValue({ ossrdbmsEndpoint: customResource }); + expect(resolveTokenResourceForCopy('mongodb://cluster.azure.us/', deps)).toMatchObject({ + resource: customResource, + reason: 'customOverride', + }); + }); + + it('prefers the host to the VS Code setting', () => { + const deps = createDeps(); + expect(resolveTokenResourceForCopy('mongodb://cluster.azure.us/', deps)).toMatchObject({ + resource: governmentResource, + reason: 'host', + }); + }); + + it.each([ + ['AzureCloud', publicResource], + ['AzureUSGovernment', governmentResource], + ['AzureChinaCloud', chinaResource], + ])('uses the built-in VS Code cloud %s for private hosts', (name, resource) => { + const deps = createDeps(); + vi.mocked(deps.getConfiguredCloud).mockReturnValue({ name, isCustomCloud: false }); + expect(resolveTokenResourceForCopy(privateConnection, deps)).toMatchObject({ resource, reason: 'setting' }); + }); + + it('defaults to public for a custom cloud, without consulting metadata or IMDS', () => { + const deps = createDeps(); + useCustomCloud(deps); + expect(resolveTokenResourceForCopy(privateConnection, deps)).toMatchObject({ + resource: publicResource, + reason: 'copyDefault', + }); + expect(deps.fetchArmMetadata).not.toHaveBeenCalled(); + expect(deps.readImdsAzEnvironment).not.toHaveBeenCalled(); + }); +}); + +describe('getCloudCategory', () => { + it.each([ + [publicResource, 'public'], + [governmentResource, 'usgov'], + [chinaResource, 'china'], + [customResource, 'custom'], + ])('categorizes %s without including identifiers', (resource, category) => { + expect(getCloudCategory(resource)).toBe(category); + }); +}); diff --git a/src/documentdb/auth/documentdbTokenResource.ts b/src/documentdb/auth/documentdbTokenResource.ts new file mode 100644 index 000000000..637c43e43 --- /dev/null +++ b/src/documentdb/auth/documentdbTokenResource.ts @@ -0,0 +1,382 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +// eslint-disable-next-line import/no-internal-modules +import { getConfiguredAzureEnv } from '@microsoft/vscode-azext-azureauth/out/src/utils/configuredAzureEnv'; +import * as l10n from '@vscode/l10n'; +import * as http from 'http'; +import * as vscode from 'vscode'; +import { ext } from '../../extensionVariables'; +import { settingsKeys } from '../../settingsKeys'; +import { + DOCUMENTDB_TOKEN_RESOURCE, + getCloudForTokenResource, + getTokenResourceForCloud, + parseTokenResource, + toEntraScope, + type DocumentDBEntraCloud, +} from './entraScopes'; +import { getAzureCloudFromHosts } from './oidcAllowedHosts'; + +/** Coarse cloud label. Safe for traces and telemetry; never carries an identifier. */ +export type CloudCategory = 'public' | 'usgov' | 'china' | 'custom'; + +export type TokenResourceReason = + | 'setting' + | 'customOverride' + | 'customLoginHost' + | 'armMetadata' + | 'host' + | 'imds' + | 'copyDefault'; + +export interface ResolvedTokenResource { + readonly resource: string; + readonly scope: string; + readonly cloudCategory: CloudCategory; + readonly reason: TokenResourceReason; +} + +/** The parts of `getConfiguredAzureEnv()` this module reads. */ +export interface ConfiguredCloud { + readonly name: string; + readonly isCustomCloud: boolean; + readonly activeDirectoryEndpointUrl?: string; + readonly resourceManagerEndpointUrl?: string; +} + +/** `documentDB.customEnvironment`. */ +export interface DocumentDBCustomEnvironment { + readonly ossrdbmsEndpoint?: string; + readonly mongoClusterHostnameSuffix?: string; +} + +export interface TokenResourceResolverDeps { + /** Throws when `custom` is selected without a usable `customEnvironment`. */ + getConfiguredCloud(): ConfiguredCloud; + getCustomEnvironment(): DocumentDBCustomEnvironment; + /** Returns the parsed JSON body, or `undefined` on any failure. */ + fetchArmMetadata(url: string): Promise; + /** Returns IMDS `compute.azEnvironment`, or `undefined` on any failure. */ + readImdsAzEnvironment(): Promise; + warn(message: string): void; +} + +export type TokenResourceErrorKind = 'customCloudUnresolved' | 'cloudMismatch' | 'managedIdentityCloudUnknown'; + +/** A failure to choose a token audience. The message is user-facing and already actionable. */ +export class TokenResourceResolutionError extends Error { + constructor( + message: string, + public readonly kind: TokenResourceErrorKind, + ) { + super(message); + this.name = 'TokenResourceResolutionError'; + } +} + +const OSSRDBMS_ENDPOINT_SETTING = `${settingsKeys.customEnvironment}.ossrdbmsEndpoint`; +const SOVEREIGN_CLOUD_SETTING = 'microsoft-sovereign-cloud.environment'; + +/** + * Entra login hosts of known clouds, for custom cloud definitions that point at one of them. + * Source: Azure CLI `cloud.py` (`active_directory` and `ossrdbms_resource_id` per cloud). + */ +const TOKEN_RESOURCE_BY_LOGIN_HOST: ReadonlyMap = new Map([ + ['login.microsoftonline.com', 'https://ossrdbms-aad.database.windows.net'], + ['login.microsoftonline.us', 'https://ossrdbms-aad.database.usgovcloudapi.net'], + ['login.chinacloudapi.cn', 'https://ossrdbms-aad.database.chinacloudapi.cn'], + ['login.sovcloud-identity.fr', 'https://ossrdbms-aad.database.sovcloud-api.fr'], + ['login.sovcloud-identity.de', 'https://ossrdbms-aad.database.sovcloud-api.de'], +]); + +const ARM_METADATA_API_VERSION = '2022-09-01'; +const ARM_METADATA_TIMEOUT_MS = 5_000; +const IMDS_TIMEOUT_MS = 2_000; + +/** Successful ARM metadata lookups, keyed by Resource Manager endpoint. Failures are not cached. */ +const armMetadataCache = new Map(); + +/** + * The token audience for interactive Entra ID sign-in (decisions D6 and D8). + * + * It follows `microsoft-sovereign-cloud.environment`, the same setting that chooses the sign-in + * authority, so the two cannot disagree. A custom cloud is resolved from explicit configuration or + * the cloud's own metadata and never falls back to the public audience. + */ +export async function resolveInteractiveTokenResource( + connectionString: string, + deps: TokenResourceResolverDeps = getDefaultDeps(), +): Promise { + let configured: ConfiguredCloud; + try { + configured = deps.getConfiguredCloud(); + } catch { + // `custom` without a usable `customEnvironment`: still a custom cloud, never the public one. + configured = { name: 'custom', isCustomCloud: true }; + } + + const resolved = configured.isCustomCloud + ? await resolveCustomCloud(configured, deps) + : fromBuiltInCloud(toBuiltInCloud(configured.name), 'setting'); + + if ( + configured.isCustomCloud && + resolved.reason === 'customOverride' && + resolved.resource === DOCUMENTDB_TOKEN_RESOURCE + ) { + deps.warn( + l10n.t('"{setting}" is the public-cloud value, but VS Code is signed in to a custom cloud.', { + setting: OSSRDBMS_ENDPOINT_SETTING, + }), + ); + } + + const hostCloud = getAzureCloudFromHosts(connectionString); + const audienceCloud = getCloudForTokenResource(resolved.resource); + if (hostCloud && hostCloud !== audienceCloud) { + throw new TokenResourceResolutionError( + l10n.t( + 'This cluster is in {clusterCloud}, but VS Code is signed in to {signedInCloud}. Change the "{setting}" setting to the cloud of the cluster, then connect again.', + { + clusterCloud: getCloudDisplayName(hostCloud), + signedInCloud: audienceCloud ? getCloudDisplayName(audienceCloud) : l10n.t('a custom cloud'), + setting: SOVEREIGN_CLOUD_SETTING, + }, + ), + 'cloudMismatch', + ); + } + + return resolved; +} + +/** + * The token audience for managed identity (decision D7). + * + * The token comes from the VM, not from VS Code's sign-in, so the VS Code cloud setting is not a + * signal here. The cluster host is: managed identity needs the VM and the cluster in the same + * tenant, so they are in the same cloud. + */ +export async function resolveManagedIdentityTokenResource( + connectionString: string, + deps: TokenResourceResolverDeps = getDefaultDeps(), +): Promise { + const override = parseTokenResource(deps.getCustomEnvironment().ossrdbmsEndpoint); + if (override) { + return fromResource(override, 'customOverride'); + } + + const hostCloud = getAzureCloudFromHosts(connectionString); + if (hostCloud) { + return fromBuiltInCloud(hostCloud, 'host'); + } + + const azEnvironment = await deps.readImdsAzEnvironment(); + if (azEnvironment?.trim().toUpperCase() === 'AZUREPUBLICCLOUD') { + return fromBuiltInCloud('AzureCloud', 'imds'); + } + + throw new TokenResourceResolutionError( + l10n.t( + 'We could not determine which Azure cloud this virtual machine is in, so we do not know which Microsoft Entra resource to request a token for. Set "{setting}" to the open-source database resource of your cloud, for example "{example}".', + { setting: OSSRDBMS_ENDPOINT_SETTING, example: getTokenResourceForCloud('AzureUSGovernment') }, + ), + 'managedIdentityCloudUnknown', + ); +} + +/** + * Best-effort `TOKEN_RESOURCE` for a copied managed identity connection string. + * + * Synchronous and offline on purpose: copying must not wait on the instance metadata service. The + * copied string is a starting point the user can edit, so the public value is an acceptable last + * resort here, unlike at connect time. + */ +export function resolveTokenResourceForCopy( + connectionString: string, + deps: Pick = getDefaultDeps(), +): ResolvedTokenResource { + const override = parseTokenResource(deps.getCustomEnvironment().ossrdbmsEndpoint); + if (override) { + return fromResource(override, 'customOverride'); + } + + const hostCloud = getAzureCloudFromHosts(connectionString); + if (hostCloud) { + return fromBuiltInCloud(hostCloud, 'host'); + } + + try { + const configured = deps.getConfiguredCloud(); + if (!configured.isCustomCloud) { + return fromBuiltInCloud(toBuiltInCloud(configured.name), 'setting'); + } + } catch { + // Misconfigured custom cloud: fall through to the default below. + } + + return fromBuiltInCloud('AzureCloud', 'copyDefault'); +} + +export function getCloudCategory(resource: string): CloudCategory { + switch (getCloudForTokenResource(resource)) { + case 'AzureCloud': + return 'public'; + case 'AzureUSGovernment': + return 'usgov'; + case 'AzureChinaCloud': + return 'china'; + default: + return 'custom'; + } +} + +export function getCloudDisplayName(cloud: DocumentDBEntraCloud): string { + switch (cloud) { + case 'AzureUSGovernment': + return l10n.t('Azure US Government'); + case 'AzureChinaCloud': + return l10n.t('Azure China'); + default: + return l10n.t('Azure (public cloud)'); + } +} + +/** Test-only: clears the ARM metadata cache. */ +export function resetTokenResourceCacheForTests(): void { + armMetadataCache.clear(); +} + +async function resolveCustomCloud( + configured: ConfiguredCloud, + deps: TokenResourceResolverDeps, +): Promise { + const override = parseTokenResource(deps.getCustomEnvironment().ossrdbmsEndpoint); + if (override) { + return fromResource(override, 'customOverride'); + } + + const loginHost = getHostname(configured.activeDirectoryEndpointUrl); + const knownResource = loginHost ? TOKEN_RESOURCE_BY_LOGIN_HOST.get(loginHost) : undefined; + if (knownResource) { + return fromResource(knownResource, 'customLoginHost'); + } + + const fromMetadata = await lookUpArmMetadata(configured.resourceManagerEndpointUrl, deps); + if (fromMetadata) { + return fromResource(fromMetadata, 'armMetadata'); + } + + throw new TokenResourceResolutionError( + l10n.t( + 'VS Code is signed in to a custom cloud, and we could not find which Microsoft Entra resource to request a token for. Set "{setting}" to the open-source database resource of your cloud, usually "https://ossrdbms-aad.database.". Your cloud operator or Azure administrator can tell you the exact value.', + { setting: OSSRDBMS_ENDPOINT_SETTING }, + ), + 'customCloudUnresolved', + ); +} + +async function lookUpArmMetadata( + resourceManagerEndpointUrl: string | undefined, + deps: TokenResourceResolverDeps, +): Promise { + const base = parseTokenResource(resourceManagerEndpointUrl); + if (!base) { + return undefined; + } + + const cached = armMetadataCache.get(base); + if (cached) { + return cached; + } + + const body = await deps.fetchArmMetadata(`${base}/metadata/endpoints?api-version=${ARM_METADATA_API_VERSION}`); + if (!body || typeof body !== 'object' || Array.isArray(body)) { + return undefined; + } + + const entry = Object.entries(body as Record).find( + ([key]) => key.toLowerCase() === 'ossrdbmsresourceid', + ); + const resource = parseTokenResource(entry?.[1]); + if (resource) { + armMetadataCache.set(base, resource); + } + return resource; +} + +function toBuiltInCloud(name: string): DocumentDBEntraCloud { + switch (name) { + case 'AzureUSGovernment': + return 'AzureUSGovernment'; + case 'AzureChinaCloud': + return 'AzureChinaCloud'; + default: + return 'AzureCloud'; + } +} + +function fromBuiltInCloud(cloud: DocumentDBEntraCloud, reason: TokenResourceReason): ResolvedTokenResource { + return fromResource(getTokenResourceForCloud(cloud), reason); +} + +function fromResource(resource: string, reason: TokenResourceReason): ResolvedTokenResource { + return { resource, scope: toEntraScope(resource), cloudCategory: getCloudCategory(resource), reason }; +} + +function getHostname(url: string | undefined): string | undefined { + if (!url) { + return undefined; + } + try { + return new URL(url).hostname.toLowerCase(); + } catch { + return undefined; + } +} + +function getDefaultDeps(): TokenResourceResolverDeps { + return { + getConfiguredCloud: () => getConfiguredAzureEnv(), + getCustomEnvironment: () => + vscode.workspace.getConfiguration().get(settingsKeys.customEnvironment) ?? {}, + fetchArmMetadata: fetchJson, + readImdsAzEnvironment, + warn: (message) => ext.outputChannel?.warn(`[Authentication] ${message}`), + }; +} + +async function fetchJson(url: string): Promise { + try { + const response = await fetch(url, { signal: AbortSignal.timeout(ARM_METADATA_TIMEOUT_MS) }); + return response.ok ? ((await response.json()) as unknown) : undefined; + } catch { + return undefined; + } +} + +function readImdsAzEnvironment(): Promise { + return new Promise((resolve) => { + const request = http.get( + 'http://169.254.169.254/metadata/instance/compute/azEnvironment?api-version=2021-02-01&format=text', + { headers: { Metadata: 'true' }, timeout: IMDS_TIMEOUT_MS }, + (response) => { + if (response.statusCode !== 200) { + response.resume(); + resolve(undefined); + return; + } + let body = ''; + response.setEncoding('utf8'); + response.on('data', (chunk: string) => (body += chunk)); + response.on('end', () => resolve(body.trim() || undefined)); + response.on('error', () => resolve(undefined)); + }, + ); + request.on('timeout', () => request.destroy()); + request.on('error', () => resolve(undefined)); + }); +} From 00c8752eeac57188941f72523ed392bc3ba9c7a8 Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Fri, 23 Oct 2026 16:44:34 +0000 Subject: [PATCH 08/50] feat(auth): use resolved cloud audience for interactive sign-in Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../auth/MicrosoftEntraIDAuthHandler.test.ts | 56 +++++++++++++++++++ .../auth/MicrosoftEntraIDAuthHandler.ts | 19 +++++-- 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/src/documentdb/auth/MicrosoftEntraIDAuthHandler.test.ts b/src/documentdb/auth/MicrosoftEntraIDAuthHandler.test.ts index f661078f9..7dd5d2a2d 100644 --- a/src/documentdb/auth/MicrosoftEntraIDAuthHandler.test.ts +++ b/src/documentdb/auth/MicrosoftEntraIDAuthHandler.test.ts @@ -4,6 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { beforeEach, describe, expect, it, vi } from 'vitest'; +import * as vscode from 'vscode'; const getSessionFromVSCode = vi.fn(); const mockOutputChannel = { info: vi.fn(), error: vi.fn() }; @@ -19,9 +20,15 @@ vi.mock('@microsoft/vscode-azext-azureauth/out/src/getSessionFromVSCode', () => getSessionFromVSCode: (...args: unknown[]) => getSessionFromVSCode(...args), })); +vi.mock('./documentdbTokenResource', async (importOriginal) => ({ + ...(await importOriginal()), + resolveInteractiveTokenResource: vi.fn(), +})); + import { type CachedClusterCredentials } from '../CredentialCache'; import { ConnectionStartupTimings } from '../utils/ConnectionStartupTimings'; import { AuthMethodId } from './AuthMethod'; +import { resolveInteractiveTokenResource, TokenResourceResolutionError } from './documentdbTokenResource'; import { MicrosoftEntraIDAuthHandler } from './MicrosoftEntraIDAuthHandler'; function buildCredentials(connectionString: string): CachedClusterCredentials { @@ -39,6 +46,15 @@ describe('MicrosoftEntraIDAuthHandler', () => { vi.clearAllMocks(); getSessionFromVSCode.mockReset(); getSessionFromVSCode.mockResolvedValue({ accessToken: 'access-token' }); + vi.mocked(resolveInteractiveTokenResource).mockResolvedValue({ + resource: 'https://ossrdbms-aad.database.windows.net', + scope: 'https://ossrdbms-aad.database.windows.net/.default', + cloudCategory: 'public', + reason: 'setting', + }); + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: vi.fn().mockReturnValue(undefined), + } as unknown as vscode.WorkspaceConfiguration); }); it('strips credentials and stale OIDC markers from the connection string', async () => { @@ -53,12 +69,52 @@ describe('MicrosoftEntraIDAuthHandler', () => { expect(result.connectionString).not.toContain('authMechanismProperties'); expect(result.connectionString).toContain('retryWrites=true'); expect(result.options.authMechanismProperties).toHaveProperty('OIDC_CALLBACK'); + expect(getSessionFromVSCode).toHaveBeenCalledWith( + ['https://ossrdbms-aad.database.windows.net/.default'], + 'tenant', + { createIfNone: true }, + ); const output = JSON.stringify(mockOutputChannel.info.mock.calls); expect(output).toContain('interactiveEntra.getSession'); expect(output).not.toContain('access-token'); expect(output).not.toContain('my-cluster'); }); + it('requests the resolved US Government scope and records its category on the existing trace', async () => { + const scope = 'https://ossrdbms-aad.database.usgovcloudapi.net/.default'; + vi.mocked(resolveInteractiveTokenResource).mockResolvedValueOnce({ + resource: 'https://ossrdbms-aad.database.usgovcloudapi.net', + scope, + cloudCategory: 'usgov', + reason: 'setting', + }); + const connectionString = 'mongodb://cluster.azure.us/'; + await new MicrosoftEntraIDAuthHandler(buildCredentials(connectionString)).configureAuth(); + expect(resolveInteractiveTokenResource).toHaveBeenCalledWith(connectionString); + expect(getSessionFromVSCode).toHaveBeenCalledWith([scope], 'tenant', { createIfNone: true }); + const output = String(mockOutputChannel.info.mock.calls[0][0]); + expect(output).toContain('"cloudCategory":"usgov"'); + expect(output).toContain('"tokenResourceReason":"setting"'); + expect(mockOutputChannel.info).toHaveBeenCalledTimes(3); + }); + + it('propagates a cloud mismatch unchanged before requesting a session', async () => { + const error = new TokenResourceResolutionError('Select the cloud of the cluster.', 'cloudMismatch'); + vi.mocked(resolveInteractiveTokenResource).mockRejectedValueOnce(error); + const handler = new MicrosoftEntraIDAuthHandler(buildCredentials('mongodb://cluster.azure.us/')); + await expect(handler.configureAuth()).rejects.toBe(error); + expect(getSessionFromVSCode).not.toHaveBeenCalled(); + }); + + it('passes the explicitly configured custom hostname suffix to the OIDC allowlist', async () => { + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: vi.fn().mockReturnValue({ mongoClusterHostnameSuffix: '.database.example.com' }), + } as unknown as vscode.WorkspaceConfiguration); + const handler = new MicrosoftEntraIDAuthHandler(buildCredentials('mongodb://cluster.database.example.com/')); + const { options } = await handler.configureAuth(); + expect(options.authMechanismProperties?.ALLOWED_HOSTS).toEqual(['*.database.example.com']); + }); + it('logs interactive authentication failure without the raw message', async () => { getSessionFromVSCode.mockRejectedValueOnce(new Error('secret-token')); const handler = new MicrosoftEntraIDAuthHandler(buildCredentials('mongodb://localhost:27017/')); diff --git a/src/documentdb/auth/MicrosoftEntraIDAuthHandler.ts b/src/documentdb/auth/MicrosoftEntraIDAuthHandler.ts index 4079525a7..bfa8951a6 100644 --- a/src/documentdb/auth/MicrosoftEntraIDAuthHandler.ts +++ b/src/documentdb/auth/MicrosoftEntraIDAuthHandler.ts @@ -8,13 +8,15 @@ import { getSessionFromVSCode } from '@microsoft/vscode-azext-azureauth/out/src/ import * as l10n from '@vscode/l10n'; import { randomUUID } from 'crypto'; import { type MongoClientOptions, type OIDCCallbackParams, type OIDCResponse } from 'mongodb'; +import * as vscode from 'vscode'; +import { settingsKeys } from '../../settingsKeys'; import { traceAuthFlow, traceAuthOperation, type AuthTraceContext } from '../../utils/authTrace'; import { type CachedClusterCredentials } from '../CredentialCache'; import { withTokenAcquisitionTiming, type ConnectionStartupTimings } from '../utils/ConnectionStartupTimings'; import { DocumentDBConnectionString } from '../utils/DocumentDBConnectionString'; import { resolveAllowInvalidCertificates } from '../utils/tlsException'; import { type AuthHandler, type AuthHandlerResponse } from './AuthHandler'; -import { DOCUMENTDB_ENTRA_SCOPE } from './entraScopes'; +import { resolveInteractiveTokenResource, type DocumentDBCustomEnvironment } from './documentdbTokenResource'; import { getOidcAllowedHosts } from './oidcAllowedHosts'; /** @@ -28,16 +30,25 @@ export class MicrosoftEntraIDAuthHandler implements AuthHandler { traceContext?: AuthTraceContext, ): Promise { const authCorrelationId = randomUUID(); + const resolved = await resolveInteractiveTokenResource(this.clusterCredentials.connectionString); + const customHostnameSuffix = vscode.workspace + .getConfiguration() + .get(settingsKeys.customEnvironment)?.mongoClusterHostnameSuffix; // Get Microsoft Entra ID token const session = await traceAuthOperation( 'interactiveEntra.getSession', () => withTokenAcquisitionTiming(timings, () => - getSessionFromVSCode([DOCUMENTDB_ENTRA_SCOPE], this.clusterCredentials.entraIdConfig?.tenantId, { + getSessionFromVSCode([resolved.scope], this.clusterCredentials.entraIdConfig?.tenantId, { createIfNone: true, }), ), - { tenantSpecified: !!this.clusterCredentials.entraIdConfig?.tenantId, createIfNone: true }, + { + tenantSpecified: !!this.clusterCredentials.entraIdConfig?.tenantId, + createIfNone: true, + cloudCategory: resolved.cloudCategory, + tokenResourceReason: resolved.reason, + }, authCorrelationId, traceContext, ); @@ -65,7 +76,7 @@ export class MicrosoftEntraIDAuthHandler implements AuthHandler { authMechanism: 'MONGODB-OIDC', tls: true, authMechanismProperties: { - ALLOWED_HOSTS: getOidcAllowedHosts(this.clusterCredentials.connectionString), + ALLOWED_HOSTS: getOidcAllowedHosts(this.clusterCredentials.connectionString, customHostnameSuffix), OIDC_CALLBACK: (_params: OIDCCallbackParams): Promise => Promise.resolve({ accessToken: session.accessToken, From 5e1930e7cdef2ff8c496fd410f9bda1b4dc71dd6 Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Fri, 23 Oct 2026 16:44:40 +0000 Subject: [PATCH 09/50] docs: fix customEnvironment settings example Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/user-manual/sovereign-clouds.md | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/docs/user-manual/sovereign-clouds.md b/docs/user-manual/sovereign-clouds.md index d5769c9b8..1eb16eb99 100644 --- a/docs/user-manual/sovereign-clouds.md +++ b/docs/user-manual/sovereign-clouds.md @@ -35,8 +35,8 @@ These values are published by each cloud in its Azure Resource Manager metadata `/metadata/endpoints?api-version=2022-09-01`. If a cluster host ends in `azure.com`, `azure.us`, or `azure.cn`, and that suffix identifies a -different cloud from the one VS Code is signed in to, the extension stops before sign-in. It tells -you which `microsoft-sovereign-cloud.environment` setting to change. +different cloud from the one VS Code is signed in to, the extension stops before sign-in and asks +you to change `microsoft-sovereign-cloud.environment` to the cluster's cloud. ## Managed identity @@ -62,10 +62,15 @@ By default, the extension sends tokens only to cluster hosts under `azure.com`, `azure.cn`. Set `documentDB.customEnvironment.mongoClusterHostnameSuffix` to allow cluster hosts in a custom cloud. Include the leading dot. +Both are properties of the `documentDB.customEnvironment` setting, so they go in one object in +`settings.json`: + ```json { - "documentDB.customEnvironment.ossrdbmsEndpoint": "https://ossrdbms-aad.database.example", - "documentDB.customEnvironment.mongoClusterHostnameSuffix": ".mongocluster.cosmos.example" + "documentDB.customEnvironment": { + "ossrdbmsEndpoint": "https://ossrdbms-aad.database.example", + "mongoClusterHostnameSuffix": ".mongocluster.cosmos.example" + } } ``` @@ -73,7 +78,7 @@ in a custom cloud. Include the leading dot. In some clouds, Microsoft Entra ID has not authorized VS Code to request tokens for Azure DocumentDB. Sign-in can then show **Something went wrong** or fail with AADSTS65002, which says -the application "must be configured via preauthorization." +the consent "must be configured via preauthorization." Microsoft must make this configuration change. You and your tenant administrator cannot change it. The Azure Resources view can still list resources because it requests a token for a different From 89a2489ee6d26c8650c88ec79513a77fcb77eba7 Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Fri, 23 Oct 2026 16:44:58 +0000 Subject: [PATCH 10/50] feat(auth): use resolved cloud audience for managed identity Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../auth/ManagedIdentityAuthHandler.test.ts | 43 +++++++++++++++++++ .../auth/ManagedIdentityAuthHandler.ts | 14 ++++-- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/src/documentdb/auth/ManagedIdentityAuthHandler.test.ts b/src/documentdb/auth/ManagedIdentityAuthHandler.test.ts index 1761593a5..884560f91 100644 --- a/src/documentdb/auth/ManagedIdentityAuthHandler.test.ts +++ b/src/documentdb/auth/ManagedIdentityAuthHandler.test.ts @@ -6,6 +6,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { type OIDCCallbackParams, type OIDCResponse } from 'mongodb'; +import * as vscode from 'vscode'; +import { ext } from '../../extensionVariables'; import { type CachedClusterCredentials } from '../CredentialCache'; import { ConnectionStartupTimings } from '../utils/ConnectionStartupTimings'; import { AuthMethodId } from './AuthMethod'; @@ -64,6 +66,10 @@ describe('expiresInSecondsFromTimestamp', () => { describe('ManagedIdentityAuthHandler', () => { beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: vi.fn().mockReturnValue(undefined), + } as unknown as vscode.WorkspaceConfiguration); getManagedIdentityAccessToken.mockReset(); getManagedIdentityAccessToken.mockResolvedValue({ accessToken: 'a-token', @@ -130,6 +136,40 @@ describe('ManagedIdentityAuthHandler', () => { ); }); + it('requests the US Government audience for an azure.us host and traces the resolution', async () => { + const connectionString = 'mongodb://cluster.mongocluster.cosmos.azure.us/'; + const handler = new ManagedIdentityAuthHandler( + buildCredentials({ connectionString, connectionStringWithPassword: connectionString }), + ); + const { options } = await handler.configureAuth(); + await invokeOidcCallback(options); + expect(options.authMechanismProperties?.ALLOWED_HOSTS).toEqual(['*.azure.us']); + expect(getManagedIdentityAccessToken).toHaveBeenCalledWith( + ['https://ossrdbms-aad.database.usgovcloudapi.net/.default'], + undefined, + undefined, + expect.any(String), + undefined, + ); + expect(ext.outputChannel.info).toHaveBeenCalledWith(expect.stringContaining('"cloudCategory":"usgov"')); + expect(ext.outputChannel.info).toHaveBeenCalledWith(expect.stringContaining('"tokenResourceReason":"host"')); + }); + + it('passes the configured custom suffix to the allowed-hosts list', async () => { + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: vi.fn().mockReturnValue({ + ossrdbmsEndpoint: 'https://database.example.com', + mongoClusterHostnameSuffix: '.database.example.com', + }), + } as unknown as vscode.WorkspaceConfiguration); + const connectionString = 'mongodb://cluster.database.example.com/'; + const handler = new ManagedIdentityAuthHandler( + buildCredentials({ connectionString, connectionStringWithPassword: connectionString }), + ); + const { options } = await handler.configureAuth(); + expect(options.authMechanismProperties?.ALLOWED_HOSTS).toEqual(['*.database.example.com']); + }); + it('passes startup context into the deferred token provider', async (): Promise => { const handler = new ManagedIdentityAuthHandler(buildCredentials()); const context = { startupCorrelationId: 'startup-id', surface: 'extension' }; @@ -174,6 +214,9 @@ describe('ManagedIdentityAuthHandler', () => { }); it('honors the host-gated TLS exception', async () => { + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: vi.fn().mockReturnValue({ ossrdbmsEndpoint: 'https://ossrdbms-aad.database.windows.net' }), + } as unknown as vscode.WorkspaceConfiguration); const cs = 'mongodb://localhost:27017/'; const handler = new ManagedIdentityAuthHandler( buildCredentials({ diff --git a/src/documentdb/auth/ManagedIdentityAuthHandler.ts b/src/documentdb/auth/ManagedIdentityAuthHandler.ts index 14e29815a..511d58d65 100644 --- a/src/documentdb/auth/ManagedIdentityAuthHandler.ts +++ b/src/documentdb/auth/ManagedIdentityAuthHandler.ts @@ -5,13 +5,15 @@ import { randomUUID } from 'crypto'; import { type MongoClientOptions, type OIDCResponse } from 'mongodb'; +import * as vscode from 'vscode'; +import { settingsKeys } from '../../settingsKeys'; import { traceAuthFlow, traceAuthOperation, type AuthTraceContext } from '../../utils/authTrace'; import { type CachedClusterCredentials } from '../CredentialCache'; import { withTokenAcquisitionTiming, type ConnectionStartupTimings } from '../utils/ConnectionStartupTimings'; import { DocumentDBConnectionString } from '../utils/DocumentDBConnectionString'; import { resolveAllowInvalidCertificates } from '../utils/tlsException'; import { type AuthHandler, type AuthHandlerResponse } from './AuthHandler'; -import { DOCUMENTDB_ENTRA_SCOPE } from './entraScopes'; +import { resolveManagedIdentityTokenResource, type DocumentDBCustomEnvironment } from './documentdbTokenResource'; import { getManagedIdentityAccessToken } from './managedIdentityTokenProvider'; import { getOidcAllowedHosts } from './oidcAllowedHosts'; import { expiresInSecondsFromTimestamp } from './tokenExpiry'; @@ -36,12 +38,18 @@ export class ManagedIdentityAuthHandler implements AuthHandler { ): Promise { const clientId = this.clusterCredentials.managedIdentityConfig?.clientId; const tenantId = this.clusterCredentials.managedIdentityConfig?.tenantId; + const resolved = await resolveManagedIdentityTokenResource(this.clusterCredentials.connectionString); + const customHostnameSuffix = vscode.workspace + .getConfiguration() + .get(settingsKeys.customEnvironment)?.mongoClusterHostnameSuffix; traceAuthFlow( 'managedIdentity.configureOidc', { identityKind: clientId ? 'userAssigned' : 'systemAssigned', clusterTenantKnown: !!tenantId, tokenAcquisition: 'deferredUntilDriverCallback', + cloudCategory: resolved.cloudCategory, + tokenResourceReason: resolved.reason, }, this.tokenCorrelationId, traceContext, @@ -60,14 +68,14 @@ export class ManagedIdentityAuthHandler implements AuthHandler { authMechanism: 'MONGODB-OIDC', tls: true, authMechanismProperties: { - ALLOWED_HOSTS: getOidcAllowedHosts(this.clusterCredentials.connectionString), + ALLOWED_HOSTS: getOidcAllowedHosts(this.clusterCredentials.connectionString, customHostnameSuffix), OIDC_CALLBACK: async (): Promise => { const token = await traceAuthOperation( 'managedIdentity.oidcCallback', () => withTokenAcquisitionTiming(timings, () => getManagedIdentityAccessToken( - [DOCUMENTDB_ENTRA_SCOPE], + [resolved.scope], clientId, tenantId, this.tokenCorrelationId, From 32e8f5f2be4856a343e7c14fbe5d2379635ec67f Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Fri, 23 Oct 2026 16:46:18 +0000 Subject: [PATCH 11/50] feat(auth): resolve shell and playground audiences on the main thread Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../playground/PlaygroundEvaluator.ts | 21 +++- .../playground/WorkerSessionManager.test.ts | 29 +++++- .../playground/WorkerSessionManager.ts | 9 +- src/documentdb/playground/playgroundWorker.ts | 2 - src/documentdb/playground/workerTypes.ts | 1 - .../shell/ShellSessionManager.test.ts | 98 ++++++++++++++++++- src/documentdb/shell/ShellSessionManager.ts | 20 +++- 7 files changed, 161 insertions(+), 19 deletions(-) diff --git a/src/documentdb/playground/PlaygroundEvaluator.ts b/src/documentdb/playground/PlaygroundEvaluator.ts index cd8cc3c35..ddfe7f61a 100644 --- a/src/documentdb/playground/PlaygroundEvaluator.ts +++ b/src/documentdb/playground/PlaygroundEvaluator.ts @@ -17,6 +17,7 @@ import { meterSilentCatch } from '../../utils/accumulatingTelemetry'; import { traceAuthOperation, type AuthTraceContext } from '../../utils/authTrace'; import { getBatchSizeSetting, getConnectionTimeoutMs } from '../../utils/workspacUtils'; import { AuthMethodId } from '../auth/AuthMethod'; +import { resolveInteractiveTokenResource, resolveManagedIdentityTokenResource } from '../auth/documentdbTokenResource'; import { CredentialCache } from '../CredentialCache'; import { withTokenAcquisitionTiming, type ConnectionStartupTimings } from '../utils/ConnectionStartupTimings'; import { resolveAllowInvalidCertificates } from '../utils/tlsException'; @@ -374,6 +375,15 @@ export class PlaygroundEvaluator implements vscode.Disposable { traceContext?: AuthTraceContext, ): Promise { try { + const clusterId = this._workerManager.workerClusterId; + const credentials = clusterId ? CredentialCache.getCredentials(clusterId) : undefined; + if (!credentials) { + throw new Error(l10n.t('No credentials found for the query playground connection.')); + } + const resolved = + msg.source === 'managedIdentity' + ? await resolveManagedIdentityTokenResource(credentials.connectionString) + : await resolveInteractiveTokenResource(credentials.connectionString); let accessToken: string; if (msg.source === 'managedIdentity') { @@ -382,7 +392,7 @@ export class PlaygroundEvaluator implements vscode.Disposable { accessToken = ( await withTokenAcquisitionTiming(timings, () => getManagedIdentityAccessToken( - msg.scopes as string[], + [resolved.scope], msg.clientId, msg.tenantId, this._sessionId, @@ -399,9 +409,14 @@ export class PlaygroundEvaluator implements vscode.Disposable { 'interactiveEntra.getSession', () => withTokenAcquisitionTiming(timings, () => - getSessionFromVSCode(msg.scopes as string[], msg.tenantId, { createIfNone: true }), + getSessionFromVSCode([resolved.scope], msg.tenantId, { createIfNone: true }), ), - { tenantSpecified: !!msg.tenantId, createIfNone: true }, + { + tenantSpecified: !!msg.tenantId, + createIfNone: true, + cloudCategory: resolved.cloudCategory, + tokenResourceReason: resolved.reason, + }, msg.requestId, traceContext, ); diff --git a/src/documentdb/playground/WorkerSessionManager.test.ts b/src/documentdb/playground/WorkerSessionManager.test.ts index 9bd74b018..e98687645 100644 --- a/src/documentdb/playground/WorkerSessionManager.test.ts +++ b/src/documentdb/playground/WorkerSessionManager.test.ts @@ -7,6 +7,7 @@ import { afterEach, beforeEach, describe, expect, it, vi, type Mock, type MockIn import { UserCancelledError, type IActionContext, type ITelemetryContext } from '@microsoft/vscode-azext-utils'; import * as path from 'path'; +import * as vscode from 'vscode'; import * as workerThreads from 'worker_threads'; import { Worker } from 'worker_threads'; import { ext } from '../../extensionVariables'; @@ -142,6 +143,9 @@ describe('WorkerSessionManager', () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: vi.fn().mockReturnValue(undefined), + } as unknown as vscode.WorkspaceConfiguration); mockTelemetryEvents.length = 0; vi.mocked(prepareDriverConnectionString).mockImplementation(async (input) => ({ connectionString: input.connectionString, @@ -270,6 +274,23 @@ describe('WorkerSessionManager', () => { return mockTelemetryEvents.find((event) => event.eventName === 'worker.startup')!; } + it('passes the configured custom hostname suffix to the worker allowlist', async (): Promise => { + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: vi.fn().mockReturnValue({ mongoClusterHostnameSuffix: '.database.example.com' }), + } as unknown as vscode.WorkspaceConfiguration); + const manager = new WorkerSessionManager(callbacks); + const initializing = manager.ensureWorker( + 'cluster-id', + { ...initMessage(), connectionString: 'mongodb://cluster.database.example.com/' }, + 1000, + ); + await Promise.resolve(); + const posted = replyInit(); + await initializing; + expect(posted.oidcAllowedHosts).toEqual(['*.database.example.com']); + manager.dispose(); + }); + it('posts only serializable prepared options without changing the input', async (): Promise => { const input = Object.freeze({ ...initMessage(), @@ -738,7 +759,7 @@ describe('WorkerSessionManager', () => { timing('tokenWait', 'token-request', true); timing('tokenWait', 'token-request', true); vi.advanceTimersByTime(5); - worker._emit('message', { type: 'tokenRequest', requestId: 'token-request', scopes: ['private-scope'] }); + worker._emit('message', { type: 'tokenRequest', requestId: 'token-request' }); vi.advanceTimersByTime(5); timing('tokenWait', 'token-request', false); vi.advanceTimersByTime(10); @@ -794,7 +815,7 @@ describe('WorkerSessionManager', () => { started: true, }); vi.advanceTimersByTime(50); - worker._emit('message', { type: 'tokenRequest', requestId: 'token-request', scopes: ['private-scope'] }); + worker._emit('message', { type: 'tokenRequest', requestId: 'token-request' }); vi.advanceTimersByTime(850); await expect(initPromise).rejects.toThrow('Operation timed out'); @@ -884,10 +905,10 @@ describe('WorkerSessionManager', () => { contexts.push(context); }); const { manager, initPromise, requestId, worker } = await startWorker(); - worker._emit('message', { type: 'tokenRequest', requestId: 'token-request', scopes: [] }); + worker._emit('message', { type: 'tokenRequest', requestId: 'token-request' }); worker._emit('message', { type: 'initResult', requestId, success: true }); await initPromise; - worker._emit('message', { type: 'tokenRequest', requestId: 'refresh-request', scopes: [] }); + worker._emit('message', { type: 'tokenRequest', requestId: 'refresh-request' }); expect(contexts).toEqual([ { startupCorrelationId: requestId, surface: 'shell' }, { startupCorrelationId: requestId, surface: 'shell' }, diff --git a/src/documentdb/playground/WorkerSessionManager.ts b/src/documentdb/playground/WorkerSessionManager.ts index ab1742ac4..262c3d5fc 100644 --- a/src/documentdb/playground/WorkerSessionManager.ts +++ b/src/documentdb/playground/WorkerSessionManager.ts @@ -11,11 +11,12 @@ import { import * as l10n from '@vscode/l10n'; import { randomUUID } from 'crypto'; import * as path from 'path'; -import type * as vscode from 'vscode'; +import * as vscode from 'vscode'; import { Worker } from 'worker_threads'; import { ext } from '../../extensionVariables'; import { settingsKeys } from '../../settingsKeys'; import { type AuthTraceContext } from '../../utils/authTrace'; +import { type DocumentDBCustomEnvironment } from '../auth/documentdbTokenResource'; import { getOidcAllowedHosts } from '../auth/oidcAllowedHosts'; import { SettingsHintError } from '../shell/SettingsHintError'; import { prepareDriverConnectionString } from '../srv/prepareDriverConnectionString'; @@ -345,7 +346,11 @@ export class WorkerSessionManager implements vscode.Disposable { appName: prepared.options.appName, tls: prepared.options.tls, }, - oidcAllowedHosts: getOidcAllowedHosts(initMsg.connectionString), + oidcAllowedHosts: getOidcAllowedHosts( + initMsg.connectionString, + vscode.workspace.getConfiguration().get(settingsKeys.customEnvironment) + ?.mongoClusterHostnameSuffix, + ), }; this._spawnCount++; this._lastSpawnTime = Date.now(); diff --git a/src/documentdb/playground/playgroundWorker.ts b/src/documentdb/playground/playgroundWorker.ts index 43cb3ac93..20ac5a9e6 100644 --- a/src/documentdb/playground/playgroundWorker.ts +++ b/src/documentdb/playground/playgroundWorker.ts @@ -23,7 +23,6 @@ import { EJSON } from 'bson'; import { randomUUID } from 'crypto'; import { type MongoClientOptions, type MongoClient as MongoClientType } from 'mongodb'; import { parentPort } from 'worker_threads'; -import { DOCUMENTDB_ENTRA_SCOPE } from '../auth/entraScopes'; import { getOidcAllowedHosts } from '../auth/oidcAllowedHosts'; import { expiresInSecondsFromTimestamp } from '../auth/tokenExpiry'; import { type MainToWorkerMessage, type WorkerStartupStage, type WorkerToMainMessage } from './workerTypes'; @@ -159,7 +158,6 @@ async function handleInit(msg: Extract): const tokenRequest: WorkerToMainMessage = { type: 'tokenRequest', requestId, - scopes: [DOCUMENTDB_ENTRA_SCOPE], tenantId: msg.tenantId, source: usesManagedIdentity ? 'managedIdentity' : 'vscode', clientId: usesManagedIdentity ? msg.managedIdentityClientId : undefined, diff --git a/src/documentdb/playground/workerTypes.ts b/src/documentdb/playground/workerTypes.ts index baec13df7..bb248ca12 100644 --- a/src/documentdb/playground/workerTypes.ts +++ b/src/documentdb/playground/workerTypes.ts @@ -156,7 +156,6 @@ export type WorkerToMainMessage = | { readonly type: 'tokenRequest'; readonly requestId: string; - readonly scopes: readonly string[]; readonly tenantId?: string; /** Where the main thread should get the token from. Absent means 'vscode', for compatibility. */ readonly source?: 'vscode' | 'managedIdentity'; diff --git a/src/documentdb/shell/ShellSessionManager.test.ts b/src/documentdb/shell/ShellSessionManager.test.ts index c5b755f9e..8ba5f0d74 100644 --- a/src/documentdb/shell/ShellSessionManager.test.ts +++ b/src/documentdb/shell/ShellSessionManager.test.ts @@ -8,6 +8,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { type AuthenticationSession } from 'vscode'; import { ext } from '../../extensionVariables'; import { AuthMethodId } from '../auth/AuthMethod'; +import { + resolveInteractiveTokenResource, + resolveManagedIdentityTokenResource, + TokenResourceResolutionError, +} from '../auth/documentdbTokenResource'; import { CredentialCache } from '../CredentialCache'; import { PlaygroundEvaluator } from '../playground/PlaygroundEvaluator'; import { type WorkerSessionCallbacks } from '../playground/WorkerSessionManager'; @@ -18,6 +23,7 @@ import { ShellSessionManager } from './ShellSessionManager'; const mockGetSessionFromVSCode = vi.fn(); const mockGetManagedIdentityAccessToken = vi.fn(); let workerCallbacks: WorkerSessionCallbacks; +let workerClusterId: string | undefined; vi.mock('../../extensionVariables', () => ({ ext: { @@ -39,12 +45,19 @@ vi.mock('../auth/managedIdentityTokenProvider', () => ({ getManagedIdentityAccessToken: (...args: unknown[]) => mockGetManagedIdentityAccessToken(...args), })); +vi.mock('../auth/documentdbTokenResource', async (importOriginal) => ({ + ...(await importOriginal()), + resolveInteractiveTokenResource: vi.fn(), + resolveManagedIdentityTokenResource: vi.fn(), +})); + vi.mock('../playground/WorkerSessionManager', () => ({ WorkerSessionManager: vi.fn().mockImplementation(function (callbacks: WorkerSessionCallbacks) { workerCallbacks = callbacks; return { ensureWorker: vi.fn( - async (_clusterId: string, initMessage: MainToWorkerMessage & { type: 'init' }): Promise => { + async (clusterId: string, initMessage: MainToWorkerMessage & { type: 'init' }): Promise => { + workerClusterId = clusterId; if (initMessage.authMechanism !== 'MicrosoftEntraID') { return; } @@ -52,7 +65,6 @@ vi.mock('../playground/WorkerSessionManager', () => ({ const tokenRequest: Extract = { type: 'tokenRequest', requestId: 'token-request', - scopes: ['scope'], source: 'vscode', tenantId: initMessage.tenantId, }; @@ -68,6 +80,9 @@ vi.mock('../playground/WorkerSessionManager', () => ({ get workerState(): string { return 'ready'; }, + get workerClusterId(): string | undefined { + return workerClusterId; + }, dispose: vi.fn(), }; }), @@ -78,6 +93,15 @@ describe('ShellSessionManager', () => { beforeEach(() => { vi.clearAllMocks(); + workerClusterId = clusterId; + const publicResolution = { + resource: 'https://ossrdbms-aad.database.windows.net', + scope: 'https://ossrdbms-aad.database.windows.net/.default', + cloudCategory: 'public', + reason: 'setting', + } as const; + vi.mocked(resolveInteractiveTokenResource).mockResolvedValue(publicResolution); + vi.mocked(resolveManagedIdentityTokenResource).mockResolvedValue({ ...publicResolution, reason: 'host' }); CredentialCache.setAuthCredentials( clusterId, AuthMethodId.MicrosoftEntraID, @@ -171,7 +195,7 @@ describe('ShellSessionManager', () => { const postResponse = vi.fn(); const traceContext = { startupCorrelationId: 'startup-id', surface }; await workerCallbacks.onTokenRequest?.( - { type: 'tokenRequest', requestId: 'token-request', scopes: ['scope'], source }, + { type: 'tokenRequest', requestId: 'token-request', source }, postResponse, timings, traceContext, @@ -193,7 +217,7 @@ describe('ShellSessionManager', () => { }); if (source === 'managedIdentity') { expect(mockGetManagedIdentityAccessToken).toHaveBeenCalledWith( - ['scope'], + ['https://ossrdbms-aad.database.windows.net/.default'], undefined, undefined, expect.any(String), @@ -208,4 +232,70 @@ describe('ShellSessionManager', () => { } manager.dispose(); }); + + it.each([ + ['shell', 'vscode'], + ['shell', 'managedIdentity'], + ['playground', 'vscode'], + ['playground', 'managedIdentity'], + ] as const)('resolves the %s %s token audience from the cluster credentials', async (surface, source) => { + const connectionString = 'mongodb://cluster.azure.us/'; + CredentialCache.setAuthCredentials( + clusterId, + source === 'vscode' ? AuthMethodId.MicrosoftEntraID : AuthMethodId.ManagedIdentity, + connectionString, + ); + const resolver = source === 'vscode' ? resolveInteractiveTokenResource : resolveManagedIdentityTokenResource; + vi.mocked(resolver).mockResolvedValueOnce({ + resource: 'https://ossrdbms-aad.database.usgovcloudapi.net', + scope: 'https://ossrdbms-aad.database.usgovcloudapi.net/.default', + cloudCategory: 'usgov', + reason: source === 'vscode' ? 'setting' : 'host', + }); + const provider = source === 'vscode' ? mockGetSessionFromVSCode : mockGetManagedIdentityAccessToken; + provider.mockResolvedValueOnce({ accessToken: 'private-token', account: { label: 'private-account' } }); + const manager = + surface === 'shell' + ? new ShellSessionManager({ clusterId, clusterDisplayName: 'Test Cluster', databaseName: 'test' }) + : new PlaygroundEvaluator(); + const postResponse = vi.fn(); + await workerCallbacks.onTokenRequest?.( + { type: 'tokenRequest', requestId: 'token-request', source }, + postResponse, + ); + expect(resolver).toHaveBeenCalledWith(connectionString); + expect(provider.mock.calls.at(-1)?.[0]).toEqual([ + 'https://ossrdbms-aad.database.usgovcloudapi.net/.default', + ]); + expect(postResponse).toHaveBeenCalledWith({ + type: 'tokenResponse', + requestId: 'token-request', + accessToken: 'private-token', + }); + manager.dispose(); + }); + + it.each(['shell', 'playground'] as const)( + 'returns a mismatch error before requesting a %s interactive token', + async (surface) => { + const error = new TokenResourceResolutionError('Select the cloud of the cluster.', 'cloudMismatch'); + vi.mocked(resolveInteractiveTokenResource).mockRejectedValueOnce(error); + const manager = + surface === 'shell' + ? new ShellSessionManager({ clusterId, clusterDisplayName: 'Test Cluster', databaseName: 'test' }) + : new PlaygroundEvaluator(); + const postResponse = vi.fn(); + await workerCallbacks.onTokenRequest?.( + { type: 'tokenRequest', requestId: 'token-request', source: 'vscode' }, + postResponse, + ); + expect(mockGetSessionFromVSCode).not.toHaveBeenCalled(); + expect(postResponse).toHaveBeenCalledWith({ + type: 'tokenError', + requestId: 'token-request', + error: error.message, + }); + manager.dispose(); + }, + ); }); diff --git a/src/documentdb/shell/ShellSessionManager.ts b/src/documentdb/shell/ShellSessionManager.ts index 51fb4890e..0b7332541 100644 --- a/src/documentdb/shell/ShellSessionManager.ts +++ b/src/documentdb/shell/ShellSessionManager.ts @@ -11,6 +11,7 @@ import { traceAuthOperation, type AuthTraceContext } from '../../utils/authTrace import { getBatchSizeSetting, getConnectionTimeoutMs } from '../../utils/workspacUtils'; import { CredentialCache } from '../CredentialCache'; import { AuthMethodId } from '../auth/AuthMethod'; +import { resolveInteractiveTokenResource, resolveManagedIdentityTokenResource } from '../auth/documentdbTokenResource'; import { WorkerSessionManager, type WorkerSessionCallbacks, @@ -358,6 +359,14 @@ export class ShellSessionManager implements vscode.Disposable { traceContext?: AuthTraceContext, ): Promise { try { + const credentials = CredentialCache.getCredentials(this._connectionInfo.clusterId); + if (!credentials) { + throw new Error(l10n.t('No credentials found for cluster {0}', this._connectionInfo.clusterId)); + } + const resolved = + msg.source === 'managedIdentity' + ? await resolveManagedIdentityTokenResource(credentials.connectionString) + : await resolveInteractiveTokenResource(credentials.connectionString); let accessToken: string; if (msg.source === 'managedIdentity') { @@ -365,7 +374,7 @@ export class ShellSessionManager implements vscode.Disposable { accessToken = ( await withTokenAcquisitionTiming(timings, () => getManagedIdentityAccessToken( - msg.scopes as string[], + [resolved.scope], msg.clientId, msg.tenantId, this._managedIdentityTokenCorrelationId, @@ -382,9 +391,14 @@ export class ShellSessionManager implements vscode.Disposable { 'interactiveEntra.getSession', () => withTokenAcquisitionTiming(timings, () => - getSessionFromVSCode(msg.scopes as string[], msg.tenantId, { createIfNone: true }), + getSessionFromVSCode([resolved.scope], msg.tenantId, { createIfNone: true }), ), - { tenantSpecified: !!msg.tenantId, createIfNone: true }, + { + tenantSpecified: !!msg.tenantId, + createIfNone: true, + cloudCategory: resolved.cloudCategory, + tokenResourceReason: resolved.reason, + }, msg.requestId, traceContext, ); From df8652a51149b57e56c2c48336837786d92a44da Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Fri, 23 Oct 2026 16:47:17 +0000 Subject: [PATCH 12/50] feat(auth): copy managed identity connection strings with cloud audiences Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../copyConnectionString.test.ts | 21 ++++++++++++++ .../copyConnectionString.ts | 8 ++++-- .../PromptConnectionStringStep.test.ts | 10 ++++--- .../managedIdentityConnectionString.test.ts | 28 +++++++++++++++---- .../auth/managedIdentityConnectionString.ts | 5 ++-- 5 files changed, 58 insertions(+), 14 deletions(-) diff --git a/src/commands/copyConnectionString/copyConnectionString.test.ts b/src/commands/copyConnectionString/copyConnectionString.test.ts index f41a13645..20b0d0174 100644 --- a/src/commands/copyConnectionString/copyConnectionString.test.ts +++ b/src/commands/copyConnectionString/copyConnectionString.test.ts @@ -4,6 +4,8 @@ *--------------------------------------------------------------------------------------------*/ import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; +import { type IActionContext } from '@microsoft/vscode-azext-utils'; +import { type ClusterItemBase } from '../../tree/documentdb/ClusterItemBase'; const mockClipboardWriteText = vi.fn(); const mockShowQuickPick = vi.fn(); @@ -29,6 +31,9 @@ vi.mock('vscode', () => ({ l10n: { t: vi.fn((message: string) => message), }, + workspace: { + getConfiguration: vi.fn(() => ({ get: vi.fn().mockReturnValue(undefined) })), + }, })); vi.mock('@vscode/l10n', () => ({ @@ -453,6 +458,21 @@ describe('copyConnectionString', () => { const azureConnString = 'mongodb+srv://my-cluster.mongocluster.cosmos.azure.com/?retryWrites=true'; const clientId = '11111111-2222-3333-4444-555555555555'; + it('copies the US Government token resource for an azure.us cluster', async () => { + const ctx = makeContext(); + const node = makeNode('connectionsView;treeitem_documentdbcluster', { + connectionString: 'mongodb+srv://my-cluster.mongocluster.cosmos.azure.us/?retryWrites=true', + availableAuthMethods: [AuthMethodId.ManagedIdentity], + selectedAuthMethod: AuthMethodId.ManagedIdentity, + managedIdentityAuthConfig: { clientId }, + }); + await copyConnectionString(ctx as unknown as IActionContext, node as unknown as ClusterItemBase); + const written = mockClipboardWriteText.mock.calls[0][0] as string; + expect(getConnectionStringAuthFacts(new DocumentDBConnectionString(written)).tokenResource).toBe( + 'https://ossrdbms-aad.database.usgovcloudapi.net', + ); + }); + it('T-13 user-assigned identity -> documented driver-native form, no password prompt', async () => { const ctx = makeContext(); const node = makeNode('connectionsView;treeitem_documentdbcluster', { @@ -511,6 +531,7 @@ describe('copyConnectionString', () => { expect(facts).toMatchObject({ usesOidc: true, declaresAzureMachineWorkflow: true, + tokenResource: 'https://ossrdbms-aad.database.windows.net', username: clientId, usernameIsGuid: true, }); diff --git a/src/commands/copyConnectionString/copyConnectionString.ts b/src/commands/copyConnectionString/copyConnectionString.ts index 17c16e565..7faf5ddb0 100644 --- a/src/commands/copyConnectionString/copyConnectionString.ts +++ b/src/commands/copyConnectionString/copyConnectionString.ts @@ -7,7 +7,8 @@ import { type IActionContext } from '@microsoft/vscode-azext-utils'; import * as l10n from '@vscode/l10n'; import * as vscode from 'vscode'; import { AuthMethodId } from '../../documentdb/auth/AuthMethod'; -import { MANAGED_IDENTITY_AUTH_MECHANISM_PROPERTIES } from '../../documentdb/auth/managedIdentityConnectionString'; +import { resolveTokenResourceForCopy } from '../../documentdb/auth/documentdbTokenResource'; +import { getManagedIdentityAuthMechanismProperties } from '../../documentdb/auth/managedIdentityConnectionString'; import { DocumentDBConnectionString } from '../../documentdb/utils/DocumentDBConnectionString'; import { Views } from '../../documentdb/Views'; import { ext } from '../../extensionVariables'; @@ -148,7 +149,10 @@ function buildParsedConnectionString(credentials: EphemeralClusterCredentials): // The documented driver-native form (D1a): it works in mongosh and application drivers on the // same Azure VM, and it round-trips back into our own New Connection flow. parsedConnectionString.searchParams.set('authMechanism', 'MONGODB-OIDC'); - parsedConnectionString.searchParams.set('authMechanismProperties', MANAGED_IDENTITY_AUTH_MECHANISM_PROPERTIES); + parsedConnectionString.searchParams.set( + 'authMechanismProperties', + getManagedIdentityAuthMechanismProperties(resolveTokenResourceForCopy(credentials.connectionString).resource), + ); // The client ID rides in the username position. Empty for the system-assigned identity. parsedConnectionString.username = credentials.managedIdentityAuthConfig?.clientId ?? ''; } diff --git a/src/commands/newConnection/PromptConnectionStringStep.test.ts b/src/commands/newConnection/PromptConnectionStringStep.test.ts index 0fa1e48f1..f09561117 100644 --- a/src/commands/newConnection/PromptConnectionStringStep.test.ts +++ b/src/commands/newConnection/PromptConnectionStringStep.test.ts @@ -6,11 +6,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { AuthMethodId } from '../../documentdb/auth/AuthMethod'; -import { MANAGED_IDENTITY_AUTH_MECHANISM_PROPERTIES } from '../../documentdb/auth/managedIdentityConnectionString'; +import { DOCUMENTDB_TOKEN_RESOURCE } from '../../documentdb/auth/entraScopes'; +import { getManagedIdentityAuthMechanismProperties } from '../../documentdb/auth/managedIdentityConnectionString'; import { type NewConnectionWizardContext } from './NewConnectionWizardContext'; import { PromptConnectionStringStep } from './PromptConnectionStringStep'; const mockInfo = vi.fn(); +const publicAuthMechanismProperties = getManagedIdentityAuthMechanismProperties(DOCUMENTDB_TOKEN_RESOURCE); vi.mock('../../extensionVariables', () => ({ ext: { outputChannel: { @@ -51,7 +53,7 @@ describe('PromptConnectionStringStep', () => { it('keeps managed identity available when an explicit hint uses a custom host', async () => { const clientId = '11111111-2222-3333-4444-555555555555'; const context = makeContext( - `mongodb://${clientId}@private.documentdb.internal:10260/?authMechanism=MONGODB-OIDC&authMechanismProperties=${MANAGED_IDENTITY_AUTH_MECHANISM_PROPERTIES}`, + `mongodb://${clientId}@private.documentdb.internal:10260/?authMechanism=MONGODB-OIDC&authMechanismProperties=${publicAuthMechanismProperties}`, ); await new PromptConnectionStringStep().prompt(context); @@ -82,7 +84,7 @@ describe('PromptConnectionStringStep', () => { it('does not infer managed identity from ENVIRONMENT:azure without OIDC', async () => { const context = makeContext( - `mongodb://private.documentdb.internal:10260/?authMechanismProperties=${MANAGED_IDENTITY_AUTH_MECHANISM_PROPERTIES}`, + `mongodb://private.documentdb.internal:10260/?authMechanismProperties=${publicAuthMechanismProperties}`, ); await new PromptConnectionStringStep().prompt(context); @@ -98,7 +100,7 @@ describe('PromptConnectionStringStep', () => { it('keeps an unusable machine identity visible for correction without storing machine-flow markers', async () => { const context = makeContext( - `mongodb://display-name@private.documentdb.internal:10260/?authMechanism=MONGODB-OIDC&authMechanismProperties=${MANAGED_IDENTITY_AUTH_MECHANISM_PROPERTIES}`, + `mongodb://display-name@private.documentdb.internal:10260/?authMechanism=MONGODB-OIDC&authMechanismProperties=${publicAuthMechanismProperties}`, ); await new PromptConnectionStringStep().prompt(context); diff --git a/src/documentdb/auth/managedIdentityConnectionString.test.ts b/src/documentdb/auth/managedIdentityConnectionString.test.ts index 0a6051b4e..d12d681c0 100644 --- a/src/documentdb/auth/managedIdentityConnectionString.test.ts +++ b/src/documentdb/auth/managedIdentityConnectionString.test.ts @@ -6,23 +6,39 @@ import { describe, expect, it } from 'vitest'; import { DocumentDBConnectionString } from '../utils/DocumentDBConnectionString'; +import { DOCUMENTDB_TOKEN_RESOURCE } from './entraScopes'; import { getConnectionStringAuthFacts, - MANAGED_IDENTITY_AUTH_MECHANISM_PROPERTIES, + getManagedIdentityAuthMechanismProperties, stripManagedIdentityMarkers, } from './managedIdentityConnectionString'; const CLIENT_ID = '11111111-2222-3333-4444-555555555555'; const HOST = 'my-cluster.mongocluster.cosmos.azure.com'; +const publicAuthMechanismProperties = getManagedIdentityAuthMechanismProperties(DOCUMENTDB_TOKEN_RESOURCE); function parse(uri: string): DocumentDBConnectionString { return new DocumentDBConnectionString(uri); } +describe('getManagedIdentityAuthMechanismProperties', () => { + it('keeps the exact public-cloud output unchanged', () => { + expect(publicAuthMechanismProperties).toBe( + 'ENVIRONMENT:azure,TOKEN_RESOURCE:https://ossrdbms-aad.database.windows.net', + ); + }); + + it('emits the US Government token resource', () => { + expect(getManagedIdentityAuthMechanismProperties('https://ossrdbms-aad.database.usgovcloudapi.net')).toBe( + 'ENVIRONMENT:azure,TOKEN_RESOURCE:https://ossrdbms-aad.database.usgovcloudapi.net', + ); + }); +}); + describe('getConnectionStringAuthFacts', () => { it('reports the documented driver-native form as an Azure machine workflow', () => { const cs = parse( - `mongodb+srv://${CLIENT_ID}@${HOST}/?authMechanism=MONGODB-OIDC&authMechanismProperties=${MANAGED_IDENTITY_AUTH_MECHANISM_PROPERTIES}`, + `mongodb+srv://${CLIENT_ID}@${HOST}/?authMechanism=MONGODB-OIDC&authMechanismProperties=${publicAuthMechanismProperties}`, ); expect(getConnectionStringAuthFacts(cs)).toEqual({ @@ -36,7 +52,7 @@ describe('getConnectionStringAuthFacts', () => { it('reports an omitted username for the system-assigned form', () => { const cs = parse( - `mongodb+srv://${HOST}/?authMechanism=MONGODB-OIDC&authMechanismProperties=${MANAGED_IDENTITY_AUTH_MECHANISM_PROPERTIES}`, + `mongodb+srv://${HOST}/?authMechanism=MONGODB-OIDC&authMechanismProperties=${publicAuthMechanismProperties}`, ); expect(getConnectionStringAuthFacts(cs)).toMatchObject({ username: undefined, usernameIsGuid: false }); @@ -72,7 +88,7 @@ describe('getConnectionStringAuthFacts', () => { it('works for a plain mongodb:// host as well as +srv', () => { const cs = parse( - `mongodb://${CLIENT_ID}@${HOST}:10260/?authMechanism=MONGODB-OIDC&authMechanismProperties=${MANAGED_IDENTITY_AUTH_MECHANISM_PROPERTIES}`, + `mongodb://${CLIENT_ID}@${HOST}:10260/?authMechanism=MONGODB-OIDC&authMechanismProperties=${publicAuthMechanismProperties}`, ); expect(getConnectionStringAuthFacts(cs)).toMatchObject({ username: CLIENT_ID, usernameIsGuid: true }); @@ -80,7 +96,7 @@ describe('getConnectionStringAuthFacts', () => { it('keeps a username that is not GUID shaped as a supplied identity to review', () => { const cs = parse( - `mongodb+srv://alice@${HOST}/?authMechanism=MONGODB-OIDC&authMechanismProperties=${MANAGED_IDENTITY_AUTH_MECHANISM_PROPERTIES}`, + `mongodb+srv://alice@${HOST}/?authMechanism=MONGODB-OIDC&authMechanismProperties=${publicAuthMechanismProperties}`, ); expect(getConnectionStringAuthFacts(cs)).toMatchObject({ username: 'alice', usernameIsGuid: false }); @@ -121,7 +137,7 @@ describe('getConnectionStringAuthFacts', () => { describe('stripManagedIdentityMarkers', () => { it('removes the mechanism markers and the credentials but keeps everything else', () => { const cs = parse( - `mongodb+srv://${CLIENT_ID}@${HOST}/?authMechanism=MONGODB-OIDC&authMechanismProperties=${MANAGED_IDENTITY_AUTH_MECHANISM_PROPERTIES}&retryWrites=true&appName=demo`, + `mongodb+srv://${CLIENT_ID}@${HOST}/?authMechanism=MONGODB-OIDC&authMechanismProperties=${publicAuthMechanismProperties}&retryWrites=true&appName=demo`, ); stripManagedIdentityMarkers(cs); diff --git a/src/documentdb/auth/managedIdentityConnectionString.ts b/src/documentdb/auth/managedIdentityConnectionString.ts index 3153b0578..f23ea054d 100644 --- a/src/documentdb/auth/managedIdentityConnectionString.ts +++ b/src/documentdb/auth/managedIdentityConnectionString.ts @@ -4,7 +4,6 @@ *--------------------------------------------------------------------------------------------*/ import { type DocumentDBConnectionString } from '../utils/DocumentDBConnectionString'; -import { DOCUMENTDB_TOKEN_RESOURCE } from './entraScopes'; /** * The `authMechanismProperties` entry that marks a connection string as using the driver-native @@ -13,7 +12,9 @@ import { DOCUMENTDB_TOKEN_RESOURCE } from './entraScopes'; export const AZURE_ENVIRONMENT_PROPERTY = 'ENVIRONMENT:azure'; /** The full `authMechanismProperties` value emitted by `Copy Connection String` (decision D1a). */ -export const MANAGED_IDENTITY_AUTH_MECHANISM_PROPERTIES = `ENVIRONMENT:azure,TOKEN_RESOURCE:${DOCUMENTDB_TOKEN_RESOURCE}`; +export function getManagedIdentityAuthMechanismProperties(tokenResource: string): string { + return `ENVIRONMENT:azure,TOKEN_RESOURCE:${tokenResource}`; +} const GUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; From 8082fa9cd9363a6953e7b5c2fadb7b670eb36b7b Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Fri, 23 Oct 2026 16:47:27 +0000 Subject: [PATCH 13/50] docs(auth): list per-cloud managed identity token resources Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/documentdb/utils/connection-string-parameters.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/documentdb/utils/connection-string-parameters.md b/src/documentdb/utils/connection-string-parameters.md index 418f2768c..7f8f717d3 100644 --- a/src/documentdb/utils/connection-string-parameters.md +++ b/src/documentdb/utils/connection-string-parameters.md @@ -81,6 +81,15 @@ Azure DocumentDB documents a machine authentication form that carries two proper ?authMechanism=MONGODB-OIDC&authMechanismProperties=ENVIRONMENT:azure,TOKEN_RESOURCE:https://ossrdbms-aad.database.windows.net ``` +`TOKEN_RESOURCE` is the cloud's open-source database resource, as published by ARM's +`/metadata/endpoints?api-version=2022-09-01` and Azure CLI's `cloud.py`: + +| Azure cloud | `TOKEN_RESOURCE` | +| ------------------- | ------------------------------------------------------ | +| Public | `https://ossrdbms-aad.database.windows.net` | +| US Government | `https://ossrdbms-aad.database.usgovcloudapi.net` | +| China | `https://ossrdbms-aad.database.chinacloudapi.cn` | + The extension **produces** this form (see `copyConnectionString`) and **reads** it on paste (see `getConnectionStringAuthFacts`), but it does not hand it to the driver. On paste, the parser reports the OIDC mechanism, Azure machine workflow, token resource, and username as facts. The wizard then From a01fc37b312d944fc16a38425425f597239c03ea Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Fri, 23 Oct 2026 16:51:15 +0000 Subject: [PATCH 14/50] docs(sovereign-clouds): record Part A and Part C progress Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...cloud-aware-scope-and-entra-diagnostics.md | 32 +++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics.md b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics.md index f8c351aa7..11409cb2f 100644 --- a/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics.md +++ b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics.md @@ -57,6 +57,9 @@ for the public one. the normalized value or `undefined`. - `getCloudForTokenResource(value)`: the built-in cloud for a known audience, else `undefined`. +**Done** in `99f7c2b0` (with the allowed-hosts changes): table, `toEntraScope`, `parseTokenResource`, +`getCloudForTokenResource`, tests pinning the exact public scope string. + ## A2. Interactive resolver (D6, D8) `src/documentdb/auth/resolveDocumentDBTokenResource.ts`: @@ -89,6 +92,15 @@ export function resolveInteractiveTokenResource(connectionString: string, deps?: - A warning is logged when the override equals the public audience but the cloud is not public. - `deps` injects settings, `fetch` and the environment for tests. +**Done** in `b1e39d1b` (`documentdbTokenResource.ts`, shared with A3). `azure.us` maps to US +Government after the operator confirmed it (D8). + +**ARM metadata api-version:** kept at `2022-09-01`. A research pass found that vscode-cosmosdb, Azure +Resources and the Azure tools libraries never call `/metadata/endpoints` (they use the static +`getConfiguredAzureEnv()`), so there is no peer version to follow. The public endpoint returns the +same body, including `ossrDbmsResourceId`, for `2022-09-01` and the newer `2026-06-01` stable spec +version. Review again only if the response content changes. + ## A3. Managed identity resolver (D7) Same file: `resolveManagedIdentityTokenResource(connectionString, deps?)`. @@ -102,6 +114,8 @@ Same file: `resolveManagedIdentityTokenResource(connectionString, deps?)`. - IMDS: `GET http://169.254.169.254/metadata/instance/compute/azEnvironment?api-version=2021-02-01&format=text` with `Metadata: true` and a short timeout; called only when the earlier steps found nothing. +**Done** in `b1e39d1b`. + ## A4. Wiring - [MicrosoftEntraIDAuthHandler.ts](../../../../../src/documentdb/auth/MicrosoftEntraIDAuthHandler.ts) and @@ -116,6 +130,10 @@ Same file: `resolveManagedIdentityTokenResource(connectionString, deps?)`. a small in-memory registry, for Part B. This is the error-translation skill's "record it while preparing the connection" pattern; the error object is not touched. +**Done**: interactive handler `00c8752e`, managed identity handler `89a2489e`, shell and playground +`32e8f5f2` (the playground uses its existing `workerClusterId`), copy connection string `df8652a5`. +The failure registry moved to Part B, which owns it. + ## A5. Settings `documentDB.customEnvironment`, an object setting mirroring `mssql.customEnvironment`: @@ -130,6 +148,8 @@ The description says it is used together with `microsoft-sovereign-cloud.environ `azure.com`, `azure.us` and `azure.cn`, which [oidcAllowedHosts.ts](../../../../../src/documentdb/auth/oidcAllowedHosts.ts) already enforces (#934). +**Done** in `5aafc30e`; the custom suffix is passed to `getOidcAllowedHosts` by every caller. + ## A6. Tests - Resolver tables: every setting value; custom with override, known login host, ARM metadata (key @@ -147,6 +167,9 @@ already enforces (#934). Goal (D3): one table maps Entra sign-in failures to text that says what happened, who can fix it, and where to go next. +**Done** with each commit. Case 1 checkpoint after Part A: `npm run build` passed; targeted Vitest +(auth, shell, playground, copy, new connection, settings) 43 files, 992 tests passed. + ## B1. Where it plugs in `ConnectionDiagnosticsService.explain()` is already called wherever a connection failure is shown, so @@ -179,7 +202,7 @@ src/documentdb/auth/entraErrors/ Each rule has an id, codes, and `explain(context)` with `context = { code, cloud, authMethod }`. Every message names who can fix the problem and links to the public troubleshooting page -(`Links.SovereignCloudsTroubleshooting`). +(`Links.SovereignClouds`). | Rule id | Codes | Who can fix it | | ---------------------------- | ----------------------- | ----------------------------------------------- | @@ -220,10 +243,15 @@ and `authMethod`. No tenant, subscription, cluster, user, or correlation identif - New user-manual page `docs/user-manual/sovereign-clouds.md`: selecting a cloud, the audience per cloud, `documentDB.customEnvironment`, the known Entra limitation where VS Code is not yet authorized, workarounds (native authentication, managed identity), finding the error code, and how - to report it on GitHub. It is the target of `Links.SovereignCloudsTroubleshooting`. + to report it on GitHub. It is the target of `Links.SovereignClouds`. - `src/documentdb/utils/connection-string-parameters.md`: the per-cloud `TOKEN_RESOURCE` values. - `oidcAllowedHosts.ts`: point the "tracked separately" comment at the resolver. +**Done**: user-manual page `79af884e`, links and `Links.SovereignClouds` (named without the +`Troubleshooting` suffix, since the page covers setup too) `891af716`, settings example fix +`5e1930e7` (the first draft used dotted keys; `documentDB.customEnvironment` is one object setting), +`connection-string-parameters.md` `8082fa9c`, `oidcAllowedHosts.ts` comment `99f7c2b0`. + --- # Before shipping From 6ad4da8a13850ef1d4a4a8fce4db77dcd534bbdf Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Fri, 23 Oct 2026 16:51:53 +0000 Subject: [PATCH 15/50] Add recent Entra token failure registry Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../entraErrors/entraTokenFailures.test.ts | 70 +++++++++++++++++++ .../auth/entraErrors/entraTokenFailures.ts | 41 +++++++++++ 2 files changed, 111 insertions(+) create mode 100644 src/documentdb/auth/entraErrors/entraTokenFailures.test.ts create mode 100644 src/documentdb/auth/entraErrors/entraTokenFailures.ts diff --git a/src/documentdb/auth/entraErrors/entraTokenFailures.test.ts b/src/documentdb/auth/entraErrors/entraTokenFailures.test.ts new file mode 100644 index 000000000..3c6c5ecf3 --- /dev/null +++ b/src/documentdb/auth/entraErrors/entraTokenFailures.test.ts @@ -0,0 +1,70 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { + getRecentEntraTokenFailure, + recordEntraTokenFailure, + resetEntraTokenFailuresForTests, +} from './entraTokenFailures'; + +describe('Entra token failure registry', () => { + beforeEach((): void => { + resetEntraTokenFailuresForTests(); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:00:00Z')); + }); + + afterEach((): void => { + resetEntraTokenFailuresForTests(); + vi.useRealTimers(); + }); + + it('records only the cloud, auth method and timestamp for the stable cluster key', (): void => { + recordEntraTokenFailure('cluster-1', { cloudCategory: 'usgov', authMethod: 'interactive' }); + + expect(getRecentEntraTokenFailure('cluster-1')).toEqual({ + cloudCategory: 'usgov', + authMethod: 'interactive', + timestamp: Date.now(), + }); + expect(getRecentEntraTokenFailure('another-cluster')).toBeUndefined(); + }); + + it('expires a failure after ten minutes by default', (): void => { + recordEntraTokenFailure('cluster-1', { cloudCategory: 'china', authMethod: 'managedIdentity' }); + vi.advanceTimersByTime(10 * 60_000); + expect(getRecentEntraTokenFailure('cluster-1')).toBeDefined(); + vi.advanceTimersByTime(1); + expect(getRecentEntraTokenFailure('cluster-1')).toBeUndefined(); + }); + + it('accepts a custom maximum age and removes expired entries', (): void => { + recordEntraTokenFailure('cluster-1', { cloudCategory: 'public', authMethod: 'interactive' }); + vi.advanceTimersByTime(101); + expect(getRecentEntraTokenFailure('cluster-1', 100)).toBeUndefined(); + expect(getRecentEntraTokenFailure('cluster-1', 1_000)).toBeUndefined(); + }); + + it('replaces an earlier failure and refreshes its timestamp', (): void => { + recordEntraTokenFailure('cluster-1', { cloudCategory: 'public', authMethod: 'interactive' }); + vi.advanceTimersByTime(60_000); + recordEntraTokenFailure('cluster-1', { cloudCategory: 'custom', authMethod: 'managedIdentity' }); + + expect(getRecentEntraTokenFailure('cluster-1')).toEqual({ + cloudCategory: 'custom', + authMethod: 'managedIdentity', + timestamp: Date.now(), + }); + }); + + it('resets all entries for tests', (): void => { + recordEntraTokenFailure('cluster-1', { cloudCategory: 'usgov', authMethod: 'interactive' }); + recordEntraTokenFailure('cluster-2', { cloudCategory: 'china', authMethod: 'managedIdentity' }); + resetEntraTokenFailuresForTests(); + expect(getRecentEntraTokenFailure('cluster-1')).toBeUndefined(); + expect(getRecentEntraTokenFailure('cluster-2')).toBeUndefined(); + }); +}); diff --git a/src/documentdb/auth/entraErrors/entraTokenFailures.ts b/src/documentdb/auth/entraErrors/entraTokenFailures.ts new file mode 100644 index 000000000..febd29c0f --- /dev/null +++ b/src/documentdb/auth/entraErrors/entraTokenFailures.ts @@ -0,0 +1,41 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type CloudCategory } from '../documentdbTokenResource'; + +export interface EntraTokenFailureContext { + readonly cloudCategory: CloudCategory; + readonly authMethod: 'interactive' | 'managedIdentity'; +} + +export interface EntraTokenFailure extends EntraTokenFailureContext { + readonly timestamp: number; +} + +const failures = new Map(); + +export function recordEntraTokenFailure(clusterId: string, context: EntraTokenFailureContext): void { + failures.set(clusterId, { + cloudCategory: context.cloudCategory, + authMethod: context.authMethod, + timestamp: Date.now(), + }); +} + +export function getRecentEntraTokenFailure( + clusterId: string, + maxAgeMs = 10 * 60_000, +): EntraTokenFailure | undefined { + const failure = failures.get(clusterId); + if (failure && Date.now() - failure.timestamp > maxAgeMs) { + failures.delete(clusterId); + return undefined; + } + return failure; +} + +export function resetEntraTokenFailuresForTests(): void { + failures.clear(); +} From 7d47bb8243a7e5d71e4a299dacd9eda7e00247e3 Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Fri, 23 Oct 2026 16:53:53 +0000 Subject: [PATCH 16/50] Classify Entra sign-in errors with a single rule table Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../entraErrors/classifyEntraError.test.ts | 147 ++++++++++++++++++ .../auth/entraErrors/classifyEntraError.ts | 44 ++++++ .../auth/entraErrors/entraErrorRules.ts | 147 ++++++++++++++++++ 3 files changed, 338 insertions(+) create mode 100644 src/documentdb/auth/entraErrors/classifyEntraError.test.ts create mode 100644 src/documentdb/auth/entraErrors/classifyEntraError.ts create mode 100644 src/documentdb/auth/entraErrors/entraErrorRules.ts diff --git a/src/documentdb/auth/entraErrors/classifyEntraError.test.ts b/src/documentdb/auth/entraErrors/classifyEntraError.test.ts new file mode 100644 index 000000000..7a463d2ef --- /dev/null +++ b/src/documentdb/auth/entraErrors/classifyEntraError.test.ts @@ -0,0 +1,147 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it } from 'vitest'; +import { Links } from '../../../constants'; +import { classifyEntraError } from './classifyEntraError'; +import { entraErrorRules, type EntraErrorContext } from './entraErrorRules'; + +const APPLICATION = '00000000-0000-0000-0000-000000000001'; +const RESOURCE = '00000000-0000-0000-0000-000000000002'; +const preauthorization = `Consent between first party application '${APPLICATION}' and first party resource '${RESOURCE}' must be configured via preauthorization - applications owned and operated by Microsoft must get approval from the API owner before requesting tokens for that API.`; +const broker = 'platform_broker_error: See https://aka.ms/msal.js.errors#platform_broker_error for details'; + +const fixtures = [ + { text: `invalid_request - AADSTS65002: ${preauthorization}`, ruleId: 'firstPartyPreauthorization', code: '65002' }, + { text: 'AADSTS500011: Resource principal was not found in the tenant', ruleId: 'resourceNotInTenant', code: '500011' }, + { text: 'AADSTS50001: Resource identifier is not valid', ruleId: 'invalidResource', code: '50001' }, + { text: 'AADSTS50020: Account is not in tenant', ruleId: 'accountNotInTenant', code: '50020' }, + { text: 'AADSTS90072: Account is not in tenant', ruleId: 'accountNotInTenant', code: '90072' }, + { text: 'AADSTS700016: Application was not found', ruleId: 'applicationNotInDirectory', code: '700016' }, + { text: 'AADSTS90002: Tenant was not found', ruleId: 'tenantNotFound', code: '90002' }, + { text: 'AADSTS1000104: Cross-cloud access is not allowed', ruleId: 'crossCloudNotAllowed', code: '1000104' }, + { text: 'AADSTS53003: Blocked by conditional access', ruleId: 'conditionalAccess', code: '53003' }, + { text: 'AADSTS50076: Verification required', ruleId: 'additionalVerification', code: '50076' }, + { text: 'AADSTS50079: Verification required', ruleId: 'additionalVerification', code: '50079' }, + { text: broker, ruleId: 'brokerOpaque', code: undefined }, + { text: 'AADSTS1234567: Unrecognized failure', ruleId: 'unknownAadsts', code: '1234567' }, +]; + +describe('classifyEntraError', () => { + it.each(fixtures)('classifies $ruleId ($code)', ({ text, ruleId, code }): void => { + expect(classifyEntraError(text)).toEqual({ + ruleId, + ...(code ? { code } : {}), + brokered: text.includes('platform_broker_error'), + }); + }); + + it.each([ + preauthorization, + preauthorization.replaceAll("'", '''), + preauthorization.replaceAll("'", '&#39;'), + ])('recognizes the preauthorization phrase without a code: %s', (text): void => { + expect(classifyEntraError(text)).toEqual({ + ruleId: 'firstPartyPreauthorization', + code: '65002', + brokered: false, + }); + }); + + it('prefers the first matching table rule, not the first code in the message', (): void => { + expect(classifyEntraError('AADSTS90002 AADSTS500011 AADSTS65002')).toEqual({ + ruleId: 'firstPartyPreauthorization', + code: '65002', + brokered: false, + }); + }); + + it('prefers a known code over a phrase or opaque broker and keeps the broker flag', (): void => { + expect(classifyEntraError(`${broker} AADSTS50001 ${preauthorization}`)).toEqual({ + ruleId: 'invalidResource', + code: '50001', + brokered: true, + }); + }); + + it('prefers a known code over an unknown one', (): void => { + expect(classifyEntraError('AADSTS1234567 AADSTS50079')?.ruleId).toBe('additionalVerification'); + }); + + it('prefers the phrase over the broker, and the broker over an unknown code', (): void => { + expect(classifyEntraError(`${broker} ${preauthorization}`)).toEqual({ + ruleId: 'firstPartyPreauthorization', + code: '65002', + brokered: true, + }); + expect(classifyEntraError(`${broker} AADSTS1234567`)).toEqual({ + ruleId: 'brokerOpaque', + brokered: true, + }); + }); + + it('uses the first unknown code for the fallback', (): void => { + expect(classifyEntraError('AADSTS12345 AADSTS1234567')?.code).toBe('12345'); + }); + + it.each([ + '', + 'querySrv EBADRESP _mongodb._tcp.example.com', + 'MongoServerSelectionError: connect ECONNREFUSED 127.0.0.1:27017', + 'Authentication failed.', + 'AADSTS has no numeric code', + ])('does not classify an ordinary or incomplete error: %s', (text): void => { + expect(classifyEntraError(text)).toBeUndefined(); + }); +}); + +describe('Entra error rule table', () => { + it('has unique ids and no repeated AADSTS or MSAL codes', (): void => { + const ids = entraErrorRules.map((rule): string => rule.id); + const codes = entraErrorRules.flatMap((rule): readonly string[] => rule.aadsts ?? []); + const msal = entraErrorRules.flatMap((rule): readonly string[] => rule.msal ?? []); + expect(new Set(ids).size).toBe(ids.length); + expect(new Set(codes).size).toBe(codes.length); + expect(new Set(msal).size).toBe(msal.length); + }); + + it('has a fixture for every rule and every listed code', (): void => { + for (const rule of entraErrorRules) { + expect(fixtures.some((fixture): boolean => fixture.ruleId === rule.id), rule.id).toBe(true); + for (const code of rule.aadsts ?? []) { + expect(fixtures.some((fixture): boolean => fixture.ruleId === rule.id && fixture.code === code)).toBe(true); + } + for (const code of rule.msal ?? []) { + expect(fixtures.some((fixture): boolean => fixture.ruleId === rule.id && fixture.text.includes(code))).toBe(true); + } + } + }); + + it('keeps messages to one paragraph with a public help link and no identifiers', (): void => { + const contexts: EntraErrorContext[] = [ + { code: '1234567', recentTokenFailure: false }, + { code: '65002', cloudCategory: 'usgov', recentTokenFailure: true }, + { code: '65002', cloudCategory: 'china', recentTokenFailure: true }, + { cloudCategory: 'custom', recentTokenFailure: true }, + { cloudCategory: 'public', recentTokenFailure: true }, + ]; + for (const rule of entraErrorRules) { + for (const context of contexts) { + const message = rule.explain(context); + expect(message).not.toMatch(/[\n\r\u2013\u2014]/); + expect(message).toMatch(/Microsoft|administrator|help desk|verification/); + expect(message.endsWith(Links.SovereignClouds)).toBe(true); + expect(message).not.toMatch(/[0-9a-f]{8}-[0-9a-f-]{27}|msal-no-broker/i); + } + } + }); + + it('provides the official error-code lookup for unknown codes', (): void => { + const rule = entraErrorRules.find((candidate): boolean => candidate.id === 'unknownAadsts'); + expect(rule?.explain({ code: '1234567', recentTokenFailure: false })).toContain( + 'https://login.microsoftonline.com/error?code=1234567', + ); + }); +}); diff --git a/src/documentdb/auth/entraErrors/classifyEntraError.ts b/src/documentdb/auth/entraErrors/classifyEntraError.ts new file mode 100644 index 000000000..b1314b3a9 --- /dev/null +++ b/src/documentdb/auth/entraErrors/classifyEntraError.ts @@ -0,0 +1,44 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { entraErrorRules } from './entraErrorRules'; + +export interface EntraErrorClassification { + readonly ruleId: string; + readonly code?: string; + readonly brokered: boolean; +} + +const PREAUTHORIZATION_PHRASE = 'must be configured via preauthorization'; + +export function classifyEntraError(text: string): EntraErrorClassification | undefined { + const brokered = text.includes('platform_broker_error'); + if (!text.includes('AADSTS') && !brokered && !text.includes(PREAUTHORIZATION_PHRASE)) { + return undefined; + } + + const codes = Array.from(text.matchAll(/AADSTS(\d{5,7})/g), (match): string => match[1]); + for (const rule of entraErrorRules) { + const code = rule.aadsts?.find((candidate): boolean => codes.includes(candidate)); + if (code) { + return { ruleId: rule.id, code, brokered }; + } + } + + const decoded = text.replace(/&#39;|'/g, "'"); + if (decoded.includes(PREAUTHORIZATION_PHRASE)) { + return { ruleId: 'firstPartyPreauthorization', code: '65002', brokered }; + } + + const msalRule = entraErrorRules.find((rule): boolean => !!rule.msal?.some((code): boolean => text.includes(code))); + if (msalRule) { + return { ruleId: msalRule.id, brokered }; + } + + if (codes.length > 0) { + return { ruleId: 'unknownAadsts', code: codes[0], brokered }; + } + return undefined; +} diff --git a/src/documentdb/auth/entraErrors/entraErrorRules.ts b/src/documentdb/auth/entraErrors/entraErrorRules.ts new file mode 100644 index 000000000..e32c8d44d --- /dev/null +++ b/src/documentdb/auth/entraErrors/entraErrorRules.ts @@ -0,0 +1,147 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as l10n from '@vscode/l10n'; +import { Links } from '../../../constants'; +import { getCloudDisplayName, type CloudCategory } from '../documentdbTokenResource'; + +export interface EntraErrorContext { + readonly code?: string; + readonly cloudCategory?: CloudCategory; + readonly recentTokenFailure: boolean; +} + +export interface EntraErrorRule { + readonly id: string; + readonly aadsts?: readonly string[]; + readonly msal?: readonly string[]; + explain(context: EntraErrorContext): string; +} + +export const entraErrorRules: readonly EntraErrorRule[] = [ + { + id: 'firstPartyPreauthorization', + aadsts: ['65002'], + explain: (): string => + l10n.t( + 'Microsoft Entra ID sign-in for Azure DocumentDB appears to require Microsoft to preauthorize VS Code. Only Microsoft can change this, not you or your tenant administrator. Use native DocumentDB authentication or managed identity on an Azure VM as a workaround, and report this on GitHub. See {link}', + { link: Links.SovereignClouds }, + ), + }, + { + id: 'resourceNotInTenant', + aadsts: ['500011'], + explain: (): string => + l10n.t( + 'The selected Azure cloud very likely does not match the cluster. Check the "microsoft-sovereign-cloud.environment" setting. If it already matches, report this on GitHub or ask your tenant administrator to check whether the sign-in resource is available in your tenant. See {link}', + { link: Links.SovereignClouds }, + ), + }, + { + id: 'invalidResource', + aadsts: ['50001'], + explain: (): string => + l10n.t( + 'The requested Microsoft Entra sign-in resource does not appear to be valid for this cloud. If "documentDB.customEnvironment.ossrdbmsEndpoint" is set, check its value with your cloud administrator. Otherwise, report this on GitHub so the extension maintainers can investigate. See {link}', + { link: Links.SovereignClouds }, + ), + }, + { + id: 'accountNotInTenant', + aadsts: ['50020', '90072'], + explain: (): string => + l10n.t( + 'This account does not appear to be a member or guest of the organization that owns the cluster. Use Update Credentials to choose another account or tenant, or ask that organization\'s administrator to grant access. See {link}', + { link: Links.SovereignClouds }, + ), + }, + { + id: 'applicationNotInDirectory', + aadsts: ['700016'], + explain: (): string => + l10n.t( + 'The sign-in application could not be found in the directory, usually because the wrong Azure cloud or tenant is selected. Check "microsoft-sovereign-cloud.environment" and use Update Credentials to choose the cluster\'s tenant. If both are correct, ask your tenant administrator or report this on GitHub. See {link}', + { link: Links.SovereignClouds }, + ), + }, + { + id: 'tenantNotFound', + aadsts: ['90002'], + explain: (): string => + l10n.t( + 'The tenant does not appear to exist in the selected Azure cloud. Check "microsoft-sovereign-cloud.environment" and use Update Credentials to select the right tenant. Your organization\'s administrator can confirm which cloud and tenant to use. See {link}', + { link: Links.SovereignClouds }, + ), + }, + { + id: 'crossCloudNotAllowed', + aadsts: ['1000104'], + explain: (): string => + l10n.t( + 'Sign-in appears to be blocked because the account is from another Azure cloud. Sign in with an account from the cluster\'s cloud, or ask the organizations\' administrators to check their cross-cloud access settings. See {link}', + { link: Links.SovereignClouds }, + ), + }, + { + id: 'conditionalAccess', + aadsts: ['53003'], + explain: (): string => + l10n.t( + 'Your organization\'s policy appears to have blocked this sign-in. Contact your IT help desk with code AADSTS53003 and the time of the failure so they can check the sign-in policy. See {link}', + { link: Links.SovereignClouds }, + ), + }, + { + id: 'additionalVerification', + aadsts: ['50076', '50079'], + explain: (): string => + l10n.t( + 'Microsoft Entra ID appears to require additional verification. Sign in again and complete the verification requested by your organization. If you cannot complete it, contact your IT help desk. See {link}', + { link: Links.SovereignClouds }, + ), + }, + { + id: 'brokerOpaque', + msal: ['platform_broker_error'], + explain: (context): string => { + if (context.recentTokenFailure && context.cloudCategory && context.cloudCategory !== 'public') { + return l10n.t( + 'Sign-in failed in {cloud}. If the sign-in window showed AADSTS65002 ("must be configured via preauthorization"), Microsoft Entra ID sign-in for Azure DocumentDB is not enabled yet for VS Code in this cloud. Only Microsoft can change this, not you or your tenant administrator. Use native DocumentDB authentication or managed identity on an Azure VM as a workaround, and report this on GitHub. See {link}', + { cloud: cloudDisplayName(context.cloudCategory), link: Links.SovereignClouds }, + ); + } + + const authenticationLog = + context.cloudCategory && context.cloudCategory !== 'public' + ? 'Microsoft Sovereign Cloud Authentication' + : 'Microsoft Authentication'; + return l10n.t( + 'The Windows sign-in window reported an error without details. Check the error code shown in that window, or open Developer: Show Logs... > {authenticationLog} in VS Code. Your IT help desk can help interpret the code; if the problem persists, report it on GitHub. See {link}', + { authenticationLog, link: Links.SovereignClouds }, + ); + }, + }, + { + id: 'unknownAadsts', + explain: (context): string => + l10n.t( + 'Microsoft Entra ID rejected the sign-in (AADSTS{code}). See https://login.microsoftonline.com/error?code={code} for details and ask your tenant administrator or IT help desk for help. Report this on GitHub if it persists. See {link}', + { code: context.code ?? '', link: Links.SovereignClouds }, + ), + }, +]; + +function cloudDisplayName(category: CloudCategory): string { + switch (category) { + case 'public': + return getCloudDisplayName('AzureCloud'); + case 'usgov': + return getCloudDisplayName('AzureUSGovernment'); + case 'china': + return getCloudDisplayName('AzureChinaCloud'); + case 'custom': + return l10n.t('a custom cloud'); + } +} From 050eeffca3f2ee1eb4863b4747136d9f2107440f Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Fri, 23 Oct 2026 16:55:36 +0000 Subject: [PATCH 17/50] Explain Entra sign-in failures through connection diagnostics Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/documentdb/ClustersExtension.ts | 2 + .../EntraDiagnosticsProvider.test.ts | 189 ++++++++++++++++++ .../entraErrors/EntraDiagnosticsProvider.ts | 65 ++++++ 3 files changed, 256 insertions(+) create mode 100644 src/documentdb/auth/entraErrors/EntraDiagnosticsProvider.test.ts create mode 100644 src/documentdb/auth/entraErrors/EntraDiagnosticsProvider.ts diff --git a/src/documentdb/ClustersExtension.ts b/src/documentdb/ClustersExtension.ts index d1abbb7cf..eddb21e65 100644 --- a/src/documentdb/ClustersExtension.ts +++ b/src/documentdb/ClustersExtension.ts @@ -141,6 +141,7 @@ import { } from '../utils/commandErrorHandling'; import { withCommandCorrelation, withTreeNodeCommandCorrelation } from '../utils/commandTelemetry'; import { registerClusterDashboardContextMenuCommands } from '../webviews/documentdb/clusterDashboard/clusterDashboardController'; +import { EntraDiagnosticsProvider } from './auth/entraErrors/EntraDiagnosticsProvider'; import { PLAYGROUND_FILE_EXTENSION, PLAYGROUND_LANGUAGE_ID, PlaygroundCommandIds } from './playground/constants'; import { PlaygroundBlockHighlighter } from './playground/PlaygroundBlockHighlighter'; import { PlaygroundCodeLensProvider } from './playground/PlaygroundCodeLensProvider'; @@ -185,6 +186,7 @@ export class ClustersExtension implements vscode.Disposable { ConnectionDiagnosticsService.registerProvider(new AtlasDiagnosticsProvider()); // First explanation wins, so source-specific diagnostics take priority over generic SRV failures. ConnectionDiagnosticsService.registerProvider(new SrvDnsDiagnosticsProvider()); + ConnectionDiagnosticsService.registerProvider(new EntraDiagnosticsProvider()); } registerConnectionsTree(_activateContext: IActionContext): void { diff --git a/src/documentdb/auth/entraErrors/EntraDiagnosticsProvider.test.ts b/src/documentdb/auth/entraErrors/EntraDiagnosticsProvider.test.ts new file mode 100644 index 000000000..9f95c6e6a --- /dev/null +++ b/src/documentdb/auth/entraErrors/EntraDiagnosticsProvider.test.ts @@ -0,0 +1,189 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { callWithTelemetryAndErrorHandling, UserCancelledError } from '@microsoft/vscode-azext-utils'; +import { Links } from '../../../constants'; +import { EntraDiagnosticsProvider } from './EntraDiagnosticsProvider'; +import { recordEntraTokenFailure, resetEntraTokenFailuresForTests } from './entraTokenFailures'; + +const telemetryContext = vi.hoisted(() => ({ + errorHandling: { suppressDisplay: false, suppressReportIssue: false }, + telemetry: { properties: {} as Record }, +})); + +vi.mock('@microsoft/vscode-azext-utils', () => ({ + callWithTelemetryAndErrorHandling: vi.fn( + async (_event: string, callback: (context: typeof telemetryContext) => Promise): Promise => { + await callback(telemetryContext); + }, + ), + UserCancelledError: class UserCancelledError extends Error {}, +})); + +const broker = 'platform_broker_error: See https://aka.ms/msal.js.errors#platform_broker_error for details'; +const provider = new EntraDiagnosticsProvider(); +const clusterId = '00000000-0000-0000-0000-000000000001'; + +describe('EntraDiagnosticsProvider', () => { + beforeEach((): void => { + vi.clearAllMocks(); + resetEntraTokenFailuresForTests(); + telemetryContext.telemetry.properties = {}; + telemetryContext.errorHandling.suppressDisplay = false; + telemetryContext.errorHandling.suppressReportIssue = false; + }); + + afterEach((): void => { + resetEntraTokenFailuresForTests(); + vi.useRealTimers(); + }); + + it('has the stable diagnostics id', (): void => { + expect(provider.id).toBe('entra-sign-in'); + }); + + it.each(['AADSTS65002: Preauthorization required', new Error('AADSTS65002: Preauthorization required')])( + 'explains a string or Error without needing registry context', + async (error): Promise => { + const message = await provider.explain({ clusterId, error }); + expect(message).toContain('Only Microsoft can change this'); + expect(message).toContain('not you or your tenant administrator'); + expect(message).toContain('native DocumentDB authentication'); + expect(message).toContain('managed identity on an Azure VM'); + expect(message).toContain('GitHub'); + expect(message).toContain(Links.SovereignClouds); + }, + ); + + it('reads causes without changing the error or its properties', async (): Promise => { + const cause = Object.freeze(new Error('AADSTS50020: Account is not in tenant')); + const error = Object.freeze(Object.assign(new Error('Database connection failed', { cause }), { code: 123 })); + const properties = Object.getOwnPropertyDescriptors(error); + + expect(await provider.explain({ clusterId, error })).toContain('Use Update Credentials'); + expect(Object.getOwnPropertyDescriptors(error)).toEqual(properties); + expect(error.cause).toBe(cause); + expect(cause.message).toBe('AADSTS50020: Account is not in tenant'); + }); + + it('concatenates messages to retain the broker flag when a cause contains the code', async (): Promise => { + const error = new Error(broker, { cause: new Error('AADSTS53003: Blocked') }); + expect(await provider.explain({ clusterId, error })).toContain('IT help desk'); + expect(telemetryContext.telemetry.properties).toMatchObject({ + ruleId: 'conditionalAccess', + aadstsCode: '53003', + brokered: 'true', + }); + }); + + it('accepts a string cause relayed by a worker', async (): Promise => { + const error = new Error('Connection failed', { cause: 'AADSTS90002: Tenant was not found' }); + expect(await provider.explain({ clusterId, error })).toContain('right tenant'); + }); + + it('walks at most three cause links', async (): Promise => { + const signIn = new Error('AADSTS65002: Preauthorization required'); + const depthThree = new Error('Driver', { + cause: new Error('Wrapper', { cause: new Error('Wrapper', { cause: signIn }) }), + }); + expect(await provider.explain({ clusterId, error: depthThree })).toContain('Only Microsoft'); + expect(await provider.explain({ clusterId, error: new Error('Outer', { cause: depthThree }) })).toBeUndefined(); + }); + + it('safely rejects a cyclic error cause', async (): Promise => { + const error = new Error('Driver error'); + error.cause = error; + await expect(provider.explain({ clusterId, error })).resolves.toBeUndefined(); + expect(error.cause).toBe(error); + }); + + it.each([ + ['usgov', 'Azure US Government'], + ['china', 'Azure China'], + ['custom', 'a custom cloud'], + ] as const)('hedges an opaque broker failure in %s after a DocumentDB token failure', async (cloudCategory, cloud): Promise => { + recordEntraTokenFailure(clusterId, { cloudCategory, authMethod: 'interactive' }); + const message = await provider.explain({ clusterId, error: broker }); + expect(message).toContain(`Sign-in failed in ${cloud}. If the sign-in window showed AADSTS65002`); + expect(message).toContain('must be configured via preauthorization'); + expect(message).toContain('Only Microsoft can change this'); + expect(message).toContain('native DocumentDB authentication or managed identity on an Azure VM'); + expect(message).not.toContain('msal-no-broker'); + }); + + it('keeps a public-cloud opaque broker failure generic even with a recent token failure', async (): Promise => { + recordEntraTokenFailure(clusterId, { cloudCategory: 'public', authMethod: 'interactive' }); + const message = await provider.explain({ clusterId, error: broker }); + expect(message).toContain('without details'); + expect(message).toContain('Developer: Show Logs... > Microsoft Authentication'); + expect(message).not.toContain('AADSTS65002'); + }); + + it('keeps a broker failure generic without a recent failure for this cluster', async (): Promise => { + recordEntraTokenFailure('another-cluster', { cloudCategory: 'usgov', authMethod: 'interactive' }); + const message = await provider.explain({ clusterId, error: broker }); + expect(message).toContain('without details'); + expect(message).toContain('Microsoft Authentication'); + expect(message).not.toContain('AADSTS65002'); + expect(message).not.toContain('Sign-in failed in'); + }); + + it('does not use expired context to imply preauthorization is missing', async (): Promise => { + vi.useFakeTimers(); + recordEntraTokenFailure(clusterId, { cloudCategory: 'china', authMethod: 'interactive' }); + vi.advanceTimersByTime(10 * 60_000 + 1); + const message = await provider.explain({ clusterId, error: broker }); + expect(message).toContain('without details'); + expect(message).not.toContain('AADSTS65002'); + expect(telemetryContext.telemetry.properties.cloudCategory).toBe('unknown'); + }); + + it('emits only classification categories, never identifiers or raw error text', async (): Promise => { + recordEntraTokenFailure(clusterId, { cloudCategory: 'usgov', authMethod: 'managedIdentity' }); + const error = new Error( + `AADSTS65002: Tenant '00000000-0000-0000-0000-000000000002' at cluster.example.com rejected secret-token`, + ); + const message = await provider.explain({ clusterId, error }); + expect(callWithTelemetryAndErrorHandling).toHaveBeenCalledOnce(); + expect(callWithTelemetryAndErrorHandling).toHaveBeenCalledWith('entraSignIn.classified', expect.any(Function)); + expect(telemetryContext.telemetry.properties).toEqual({ + ruleId: 'firstPartyPreauthorization', + aadstsCode: '65002', + cloudCategory: 'usgov', + brokered: 'false', + authMethod: 'managedIdentity', + }); + expect(telemetryContext.errorHandling).toEqual({ suppressDisplay: true, suppressReportIssue: true }); + expect(JSON.stringify(telemetryContext.telemetry.properties)).not.toMatch( + /[0-9a-f]{8}-[0-9a-f-]{27}|cluster\.example\.com|secret-token/i, + ); + expect(message).not.toContain('00000000'); + expect(message).not.toContain('secret-token'); + }); + + it('uses unknown telemetry categories and an empty code for an uncorrelated broker error', async (): Promise => { + await provider.explain({ clusterId, error: broker }); + expect(telemetryContext.telemetry.properties).toEqual({ + ruleId: 'brokerOpaque', + aadstsCode: '', + cloudCategory: 'unknown', + brokered: 'true', + authMethod: 'unknown', + }); + }); + + it.each([ + 'querySrv EBADRESP _mongodb._tcp.example.com', + new Error('MongoServerSelectionError: connection timed out'), + new UserCancelledError('AADSTS65002'), + 42, + { message: 'AADSTS65002' }, + undefined, + ])('rejects unrelated or cancelled input without telemetry', async (error): Promise => { + await expect(provider.explain({ clusterId, error })).resolves.toBeUndefined(); + expect(callWithTelemetryAndErrorHandling).not.toHaveBeenCalled(); + }); +}); diff --git a/src/documentdb/auth/entraErrors/EntraDiagnosticsProvider.ts b/src/documentdb/auth/entraErrors/EntraDiagnosticsProvider.ts new file mode 100644 index 000000000..842ee455b --- /dev/null +++ b/src/documentdb/auth/entraErrors/EntraDiagnosticsProvider.ts @@ -0,0 +1,65 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { callWithTelemetryAndErrorHandling, UserCancelledError } from '@microsoft/vscode-azext-utils'; +import { + type ConnectionDiagnosticsProvider, + type ConnectionDiagnosticsRequest, +} from '../../../services/connectionDiagnosticsService'; +import { classifyEntraError } from './classifyEntraError'; +import { entraErrorRules } from './entraErrorRules'; +import { getRecentEntraTokenFailure } from './entraTokenFailures'; + +export class EntraDiagnosticsProvider implements ConnectionDiagnosticsProvider { + public readonly id = 'entra-sign-in'; + + public async explain({ clusterId, error }: ConnectionDiagnosticsRequest): Promise { + if (error instanceof UserCancelledError) { + return undefined; + } + const classification = classifyEntraError(errorMessages(error)); + if (!classification) { + return undefined; + } + const rule = entraErrorRules.find((candidate): boolean => candidate.id === classification.ruleId); + if (!rule) { + return undefined; + } + + const failure = getRecentEntraTokenFailure(clusterId); + const message = rule.explain({ + code: classification.code, + cloudCategory: failure?.cloudCategory, + recentTokenFailure: failure !== undefined, + }); + void callWithTelemetryAndErrorHandling('entraSignIn.classified', async (context): Promise => { + context.errorHandling.suppressDisplay = true; + context.errorHandling.suppressReportIssue = true; + context.telemetry.properties.ruleId = classification.ruleId; + context.telemetry.properties.aadstsCode = classification.code ?? ''; + context.telemetry.properties.cloudCategory = failure?.cloudCategory ?? 'unknown'; + context.telemetry.properties.brokered = classification.brokered ? 'true' : 'false'; + context.telemetry.properties.authMethod = failure?.authMethod ?? 'unknown'; + }); + return message; + } +} + +function errorMessages(error: unknown): string { + const messages: string[] = []; + // Driver wrappers can retain the original sign-in error, but causes may also be cyclic. + for (let depth = 0; depth <= 3; depth++) { + if (typeof error === 'string') { + messages.push(error); + break; + } + if (!(error instanceof Error)) { + break; + } + messages.push(error.message); + error = error.cause; + } + return messages.join('\n'); +} From 97e4b03705e53fead10bc02c56370fa9a76a6b74 Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Fri, 23 Oct 2026 17:00:54 +0000 Subject: [PATCH 18/50] Record failed Entra token acquisitions across connection surfaces Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../auth/ManagedIdentityAuthHandler.test.ts | 41 +++++- .../auth/ManagedIdentityAuthHandler.ts | 47 ++++--- .../auth/MicrosoftEntraIDAuthHandler.test.ts | 46 ++++++- .../auth/MicrosoftEntraIDAuthHandler.ts | 56 ++++---- .../playground/PlaygroundEvaluator.ts | 76 ++++++----- .../shell/ShellSessionManager.test.ts | 120 +++++++++++++++++- src/documentdb/shell/ShellSessionManager.ts | 74 +++++++---- 7 files changed, 353 insertions(+), 107 deletions(-) diff --git a/src/documentdb/auth/ManagedIdentityAuthHandler.test.ts b/src/documentdb/auth/ManagedIdentityAuthHandler.test.ts index 884560f91..4cd11086a 100644 --- a/src/documentdb/auth/ManagedIdentityAuthHandler.test.ts +++ b/src/documentdb/auth/ManagedIdentityAuthHandler.test.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { type OIDCCallbackParams, type OIDCResponse } from 'mongodb'; import * as vscode from 'vscode'; @@ -11,6 +11,8 @@ import { ext } from '../../extensionVariables'; import { type CachedClusterCredentials } from '../CredentialCache'; import { ConnectionStartupTimings } from '../utils/ConnectionStartupTimings'; import { AuthMethodId } from './AuthMethod'; +import * as tokenResources from './documentdbTokenResource'; +import { getRecentEntraTokenFailure, resetEntraTokenFailuresForTests } from './entraErrors/entraTokenFailures'; import { ManagedIdentityAuthHandler } from './ManagedIdentityAuthHandler'; import { expiresInSecondsFromTimestamp } from './tokenExpiry'; @@ -67,6 +69,7 @@ describe('expiresInSecondsFromTimestamp', () => { describe('ManagedIdentityAuthHandler', () => { beforeEach(() => { vi.clearAllMocks(); + resetEntraTokenFailuresForTests(); vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ get: vi.fn().mockReturnValue(undefined), } as unknown as vscode.WorkspaceConfiguration); @@ -77,6 +80,11 @@ describe('ManagedIdentityAuthHandler', () => { }); }); + afterEach((): void => { + vi.restoreAllMocks(); + resetEntraTokenFailuresForTests(); + }); + it('configures the OIDC mechanism with a curated allowed-hosts list', async () => { const handler = new ManagedIdentityAuthHandler(buildCredentials()); @@ -192,6 +200,7 @@ describe('ManagedIdentityAuthHandler', () => { expect(response.accessToken).toBe('a-token'); expect(response.expiresInSeconds).toBeGreaterThan(0); + expect(getRecentEntraTokenFailure('cluster-1')).toBeUndefined(); }); it('reuses one token correlation ID across OIDC callback retries', async () => { @@ -213,6 +222,36 @@ describe('ManagedIdentityAuthHandler', () => { await expect(invokeOidcCallback(options)).rejects.toThrow(/more than one managed identity/i); }); + it('records failures only inside the deferred token callback and rethrows the same error', async (): Promise => { + const connectionString = 'mongodb://cluster.azure.us/'; + const handler = new ManagedIdentityAuthHandler( + buildCredentials({ connectionString, connectionStringWithPassword: connectionString }), + ); + const error = Object.freeze(new Error('AADSTS500011: Resource not found')); + getManagedIdentityAccessToken.mockRejectedValueOnce(error); + + const { options } = await handler.configureAuth(); + expect(getRecentEntraTokenFailure('cluster-1')).toBeUndefined(); + expect(getManagedIdentityAccessToken).not.toHaveBeenCalled(); + + await expect(invokeOidcCallback(options)).rejects.toBe(error); + expect(getRecentEntraTokenFailure('cluster-1')).toEqual({ + cloudCategory: 'usgov', + authMethod: 'managedIdentity', + timestamp: expect.any(Number), + }); + }); + + it('does not record token resource resolution errors as token-acquisition failures', async (): Promise => { + const error = new tokenResources.TokenResourceResolutionError('Set the cloud resource.', 'managedIdentityCloudUnknown'); + vi.spyOn(tokenResources, 'resolveManagedIdentityTokenResource').mockRejectedValueOnce(error); + const handler = new ManagedIdentityAuthHandler(buildCredentials()); + + await expect(handler.configureAuth()).rejects.toBe(error); + expect(getManagedIdentityAccessToken).not.toHaveBeenCalled(); + expect(getRecentEntraTokenFailure('cluster-1')).toBeUndefined(); + }); + it('honors the host-gated TLS exception', async () => { vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ get: vi.fn().mockReturnValue({ ossrdbmsEndpoint: 'https://ossrdbms-aad.database.windows.net' }), diff --git a/src/documentdb/auth/ManagedIdentityAuthHandler.ts b/src/documentdb/auth/ManagedIdentityAuthHandler.ts index 511d58d65..70302439d 100644 --- a/src/documentdb/auth/ManagedIdentityAuthHandler.ts +++ b/src/documentdb/auth/ManagedIdentityAuthHandler.ts @@ -14,6 +14,7 @@ import { DocumentDBConnectionString } from '../utils/DocumentDBConnectionString' import { resolveAllowInvalidCertificates } from '../utils/tlsException'; import { type AuthHandler, type AuthHandlerResponse } from './AuthHandler'; import { resolveManagedIdentityTokenResource, type DocumentDBCustomEnvironment } from './documentdbTokenResource'; +import { recordEntraTokenFailure } from './entraErrors/entraTokenFailures'; import { getManagedIdentityAccessToken } from './managedIdentityTokenProvider'; import { getOidcAllowedHosts } from './oidcAllowedHosts'; import { expiresInSecondsFromTimestamp } from './tokenExpiry'; @@ -70,27 +71,35 @@ export class ManagedIdentityAuthHandler implements AuthHandler { authMechanismProperties: { ALLOWED_HOSTS: getOidcAllowedHosts(this.clusterCredentials.connectionString, customHostnameSuffix), OIDC_CALLBACK: async (): Promise => { - const token = await traceAuthOperation( - 'managedIdentity.oidcCallback', - () => - withTokenAcquisitionTiming(timings, () => - getManagedIdentityAccessToken( - [resolved.scope], - clientId, - tenantId, - this.tokenCorrelationId, - traceContext, + try { + const token = await traceAuthOperation( + 'managedIdentity.oidcCallback', + () => + withTokenAcquisitionTiming(timings, () => + getManagedIdentityAccessToken( + [resolved.scope], + clientId, + tenantId, + this.tokenCorrelationId, + traceContext, + ), ), - ), - {}, - this.tokenCorrelationId, - traceContext, - ); + {}, + this.tokenCorrelationId, + traceContext, + ); - return { - accessToken: token.accessToken, - expiresInSeconds: expiresInSecondsFromTimestamp(token.expiresOnTimestamp), - }; + return { + accessToken: token.accessToken, + expiresInSeconds: expiresInSecondsFromTimestamp(token.expiresOnTimestamp), + }; + } catch (error) { + recordEntraTokenFailure(this.clusterCredentials.clusterId, { + cloudCategory: resolved.cloudCategory, + authMethod: 'managedIdentity', + }); + throw error; + } }, }, }; diff --git a/src/documentdb/auth/MicrosoftEntraIDAuthHandler.test.ts b/src/documentdb/auth/MicrosoftEntraIDAuthHandler.test.ts index 7dd5d2a2d..fa62c0681 100644 --- a/src/documentdb/auth/MicrosoftEntraIDAuthHandler.test.ts +++ b/src/documentdb/auth/MicrosoftEntraIDAuthHandler.test.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as vscode from 'vscode'; const getSessionFromVSCode = vi.fn(); @@ -29,6 +29,7 @@ import { type CachedClusterCredentials } from '../CredentialCache'; import { ConnectionStartupTimings } from '../utils/ConnectionStartupTimings'; import { AuthMethodId } from './AuthMethod'; import { resolveInteractiveTokenResource, TokenResourceResolutionError } from './documentdbTokenResource'; +import { getRecentEntraTokenFailure, resetEntraTokenFailuresForTests } from './entraErrors/entraTokenFailures'; import { MicrosoftEntraIDAuthHandler } from './MicrosoftEntraIDAuthHandler'; function buildCredentials(connectionString: string): CachedClusterCredentials { @@ -44,6 +45,7 @@ function buildCredentials(connectionString: string): CachedClusterCredentials { describe('MicrosoftEntraIDAuthHandler', () => { beforeEach(() => { vi.clearAllMocks(); + resetEntraTokenFailuresForTests(); getSessionFromVSCode.mockReset(); getSessionFromVSCode.mockResolvedValue({ accessToken: 'access-token' }); vi.mocked(resolveInteractiveTokenResource).mockResolvedValue({ @@ -57,6 +59,10 @@ describe('MicrosoftEntraIDAuthHandler', () => { } as unknown as vscode.WorkspaceConfiguration); }); + afterEach((): void => { + resetEntraTokenFailuresForTests(); + }); + it('strips credentials and stale OIDC markers from the connection string', async () => { const connectionString = 'mongodb+srv://managed-identity@my-cluster.mongocluster.cosmos.azure.com/' + @@ -78,6 +84,7 @@ describe('MicrosoftEntraIDAuthHandler', () => { expect(output).toContain('interactiveEntra.getSession'); expect(output).not.toContain('access-token'); expect(output).not.toContain('my-cluster'); + expect(getRecentEntraTokenFailure('cluster-1')).toBeUndefined(); }); it('requests the resolved US Government scope and records its category on the existing trace', async () => { @@ -104,6 +111,43 @@ describe('MicrosoftEntraIDAuthHandler', () => { const handler = new MicrosoftEntraIDAuthHandler(buildCredentials('mongodb://cluster.azure.us/')); await expect(handler.configureAuth()).rejects.toBe(error); expect(getSessionFromVSCode).not.toHaveBeenCalled(); + expect(getRecentEntraTokenFailure('cluster-1')).toBeUndefined(); + }); + + it.each(['public', 'usgov', 'china', 'custom'] as const)( + 'records a failed token acquisition in %s without changing the error', + async (cloudCategory): Promise => { + vi.mocked(resolveInteractiveTokenResource).mockResolvedValueOnce({ + resource: 'https://database.example.com', + scope: 'https://database.example.com/.default', + cloudCategory, + reason: 'setting', + }); + const error = Object.freeze( + Object.assign(new Error('AADSTS65002: Preauthorization required'), { code: 'original-code' }), + ); + getSessionFromVSCode.mockRejectedValueOnce(error); + const handler = new MicrosoftEntraIDAuthHandler(buildCredentials('mongodb://cluster.example.com/')); + + await expect(handler.configureAuth()).rejects.toBe(error); + expect(error.code).toBe('original-code'); + expect(getRecentEntraTokenFailure('cluster-1')).toEqual({ + cloudCategory, + authMethod: 'interactive', + timestamp: expect.any(Number), + }); + expect(mockOutputChannel.error).toHaveBeenCalledTimes(1); + }, + ); + + it('records a token-acquisition failure when no session is returned', async (): Promise => { + getSessionFromVSCode.mockResolvedValueOnce(undefined); + const handler = new MicrosoftEntraIDAuthHandler(buildCredentials('mongodb://cluster.example.com/')); + await expect(handler.configureAuth()).rejects.toThrow('Failed to obtain Entra ID token.'); + expect(getRecentEntraTokenFailure('cluster-1')).toMatchObject({ + cloudCategory: 'public', + authMethod: 'interactive', + }); }); it('passes the explicitly configured custom hostname suffix to the OIDC allowlist', async () => { diff --git a/src/documentdb/auth/MicrosoftEntraIDAuthHandler.ts b/src/documentdb/auth/MicrosoftEntraIDAuthHandler.ts index bfa8951a6..47ebd8f18 100644 --- a/src/documentdb/auth/MicrosoftEntraIDAuthHandler.ts +++ b/src/documentdb/auth/MicrosoftEntraIDAuthHandler.ts @@ -17,6 +17,7 @@ import { DocumentDBConnectionString } from '../utils/DocumentDBConnectionString' import { resolveAllowInvalidCertificates } from '../utils/tlsException'; import { type AuthHandler, type AuthHandlerResponse } from './AuthHandler'; import { resolveInteractiveTokenResource, type DocumentDBCustomEnvironment } from './documentdbTokenResource'; +import { recordEntraTokenFailure } from './entraErrors/entraTokenFailures'; import { getOidcAllowedHosts } from './oidcAllowedHosts'; /** @@ -35,32 +36,41 @@ export class MicrosoftEntraIDAuthHandler implements AuthHandler { .getConfiguration() .get(settingsKeys.customEnvironment)?.mongoClusterHostnameSuffix; // Get Microsoft Entra ID token - const session = await traceAuthOperation( - 'interactiveEntra.getSession', - () => - withTokenAcquisitionTiming(timings, () => - getSessionFromVSCode([resolved.scope], this.clusterCredentials.entraIdConfig?.tenantId, { - createIfNone: true, - }), - ), - { - tenantSpecified: !!this.clusterCredentials.entraIdConfig?.tenantId, - createIfNone: true, - cloudCategory: resolved.cloudCategory, - tokenResourceReason: resolved.reason, - }, - authCorrelationId, - traceContext, - ); - - if (!session) { - traceAuthFlow( - 'interactiveEntra.sessionUnavailable', - { reason: 'noSessionReturned' }, + let session: vscode.AuthenticationSession | undefined; + try { + session = await traceAuthOperation( + 'interactiveEntra.getSession', + () => + withTokenAcquisitionTiming(timings, () => + getSessionFromVSCode([resolved.scope], this.clusterCredentials.entraIdConfig?.tenantId, { + createIfNone: true, + }), + ), + { + tenantSpecified: !!this.clusterCredentials.entraIdConfig?.tenantId, + createIfNone: true, + cloudCategory: resolved.cloudCategory, + tokenResourceReason: resolved.reason, + }, authCorrelationId, traceContext, ); - throw new Error(l10n.t('Failed to obtain Entra ID token.')); + + if (!session) { + traceAuthFlow( + 'interactiveEntra.sessionUnavailable', + { reason: 'noSessionReturned' }, + authCorrelationId, + traceContext, + ); + throw new Error(l10n.t('Failed to obtain Entra ID token.')); + } + } catch (error) { + recordEntraTokenFailure(this.clusterCredentials.clusterId, { + cloudCategory: resolved.cloudCategory, + authMethod: 'interactive', + }); + throw error; } // Prepare connection string diff --git a/src/documentdb/playground/PlaygroundEvaluator.ts b/src/documentdb/playground/PlaygroundEvaluator.ts index ddfe7f61a..26d66ce30 100644 --- a/src/documentdb/playground/PlaygroundEvaluator.ts +++ b/src/documentdb/playground/PlaygroundEvaluator.ts @@ -18,6 +18,7 @@ import { traceAuthOperation, type AuthTraceContext } from '../../utils/authTrace import { getBatchSizeSetting, getConnectionTimeoutMs } from '../../utils/workspacUtils'; import { AuthMethodId } from '../auth/AuthMethod'; import { resolveInteractiveTokenResource, resolveManagedIdentityTokenResource } from '../auth/documentdbTokenResource'; +import { recordEntraTokenFailure } from '../auth/entraErrors/entraTokenFailures'; import { CredentialCache } from '../CredentialCache'; import { withTokenAcquisitionTiming, type ConnectionStartupTimings } from '../utils/ConnectionStartupTimings'; import { resolveAllowInvalidCertificates } from '../utils/tlsException'; @@ -377,7 +378,7 @@ export class PlaygroundEvaluator implements vscode.Disposable { try { const clusterId = this._workerManager.workerClusterId; const credentials = clusterId ? CredentialCache.getCredentials(clusterId) : undefined; - if (!credentials) { + if (!clusterId || !credentials) { throw new Error(l10n.t('No credentials found for the query playground connection.')); } const resolved = @@ -389,40 +390,57 @@ export class PlaygroundEvaluator implements vscode.Disposable { if (msg.source === 'managedIdentity') { const { getManagedIdentityAccessToken } = await import('../auth/managedIdentityTokenProvider'); this._sessionId ??= randomUUID(); - accessToken = ( - await withTokenAcquisitionTiming(timings, () => - getManagedIdentityAccessToken( - [resolved.scope], - msg.clientId, - msg.tenantId, - this._sessionId, - traceContext, - ), - ) - ).accessToken; + try { + accessToken = ( + await withTokenAcquisitionTiming(timings, () => + getManagedIdentityAccessToken( + [resolved.scope], + msg.clientId, + msg.tenantId, + this._sessionId, + traceContext, + ), + ) + ).accessToken; + } catch (error) { + recordEntraTokenFailure(clusterId, { + cloudCategory: resolved.cloudCategory, + authMethod: 'managedIdentity', + }); + throw error; + } } else { const { getSessionFromVSCode } = await import( // eslint-disable-next-line import/no-internal-modules '@microsoft/vscode-azext-azureauth/out/src/getSessionFromVSCode' ); - const session = await traceAuthOperation( - 'interactiveEntra.getSession', - () => - withTokenAcquisitionTiming(timings, () => - getSessionFromVSCode([resolved.scope], msg.tenantId, { createIfNone: true }), - ), - { - tenantSpecified: !!msg.tenantId, - createIfNone: true, + let session: vscode.AuthenticationSession | undefined; + try { + session = await traceAuthOperation( + 'interactiveEntra.getSession', + () => + withTokenAcquisitionTiming(timings, () => + getSessionFromVSCode([resolved.scope], msg.tenantId, { createIfNone: true }), + ), + { + tenantSpecified: !!msg.tenantId, + createIfNone: true, + cloudCategory: resolved.cloudCategory, + tokenResourceReason: resolved.reason, + }, + msg.requestId, + traceContext, + ); + + if (!session) { + throw new Error(l10n.t('Failed to obtain Entra ID token.')); + } + } catch (error) { + recordEntraTokenFailure(clusterId, { cloudCategory: resolved.cloudCategory, - tokenResourceReason: resolved.reason, - }, - msg.requestId, - traceContext, - ); - - if (!session) { - throw new Error(l10n.t('Failed to obtain Entra ID token.')); + authMethod: 'interactive', + }); + throw error; } accessToken = session.accessToken; diff --git a/src/documentdb/shell/ShellSessionManager.test.ts b/src/documentdb/shell/ShellSessionManager.test.ts index 8ba5f0d74..87f5d677f 100644 --- a/src/documentdb/shell/ShellSessionManager.test.ts +++ b/src/documentdb/shell/ShellSessionManager.test.ts @@ -13,6 +13,7 @@ import { resolveManagedIdentityTokenResource, TokenResourceResolutionError, } from '../auth/documentdbTokenResource'; +import { getRecentEntraTokenFailure, resetEntraTokenFailuresForTests } from '../auth/entraErrors/entraTokenFailures'; import { CredentialCache } from '../CredentialCache'; import { PlaygroundEvaluator } from '../playground/PlaygroundEvaluator'; import { type WorkerSessionCallbacks } from '../playground/WorkerSessionManager'; @@ -93,6 +94,7 @@ describe('ShellSessionManager', () => { beforeEach(() => { vi.clearAllMocks(); + resetEntraTokenFailuresForTests(); workerClusterId = clusterId; const publicResolution = { resource: 'https://ossrdbms-aad.database.windows.net', @@ -114,6 +116,7 @@ describe('ShellSessionManager', () => { afterEach(() => { CredentialCache.deleteCredentials(clusterId); + resetEntraTokenFailuresForTests(); }); it('returns the optional Microsoft Entra account display name', async () => { @@ -210,6 +213,7 @@ describe('ShellSessionManager', () => { requestId: 'token-request', accessToken: 'private-token', }); + expect(getRecentEntraTokenFailure(clusterId)).toBeUndefined(); expect(timings.finish()).toEqual({ databaseConnectDurationMs: 25, tokenAcquireDurationMs: 25, @@ -275,21 +279,32 @@ describe('ShellSessionManager', () => { manager.dispose(); }); - it.each(['shell', 'playground'] as const)( - 'returns a mismatch error before requesting a %s interactive token', - async (surface) => { - const error = new TokenResourceResolutionError('Select the cloud of the cluster.', 'cloudMismatch'); - vi.mocked(resolveInteractiveTokenResource).mockRejectedValueOnce(error); + it.each([ + ['shell', 'vscode'], + ['shell', 'managedIdentity'], + ['playground', 'vscode'], + ['playground', 'managedIdentity'], + ] as const)( + 'does not record a %s %s resolution error as a failed token acquisition', + async (surface, source): Promise => { + const error = new TokenResourceResolutionError( + 'Select the cloud of the cluster.', + source === 'vscode' ? 'cloudMismatch' : 'managedIdentityCloudUnknown', + ); + const resolver = source === 'vscode' ? resolveInteractiveTokenResource : resolveManagedIdentityTokenResource; + vi.mocked(resolver).mockRejectedValueOnce(error); const manager = surface === 'shell' ? new ShellSessionManager({ clusterId, clusterDisplayName: 'Test Cluster', databaseName: 'test' }) : new PlaygroundEvaluator(); const postResponse = vi.fn(); await workerCallbacks.onTokenRequest?.( - { type: 'tokenRequest', requestId: 'token-request', source: 'vscode' }, + { type: 'tokenRequest', requestId: 'token-request', source }, postResponse, ); expect(mockGetSessionFromVSCode).not.toHaveBeenCalled(); + expect(mockGetManagedIdentityAccessToken).not.toHaveBeenCalled(); + expect(getRecentEntraTokenFailure(clusterId)).toBeUndefined(); expect(postResponse).toHaveBeenCalledWith({ type: 'tokenError', requestId: 'token-request', @@ -298,4 +313,97 @@ describe('ShellSessionManager', () => { manager.dispose(); }, ); + + it.each([ + ['shell', 'vscode'], + ['shell', 'managedIdentity'], + ['playground', 'vscode'], + ['playground', 'managedIdentity'], + ] as const)('records the cloud and auth method for a failed %s %s token call', async (surface, source): Promise => { + const resolver = source === 'vscode' ? resolveInteractiveTokenResource : resolveManagedIdentityTokenResource; + vi.mocked(resolver).mockResolvedValueOnce({ + resource: 'https://ossrdbms-aad.database.chinacloudapi.cn', + scope: 'https://ossrdbms-aad.database.chinacloudapi.cn/.default', + cloudCategory: 'china', + reason: source === 'vscode' ? 'setting' : 'host', + }); + const error = Object.freeze( + Object.assign(new Error('AADSTS65002: Token failed'), { code: 'original-code' }), + ); + const provider = source === 'vscode' ? mockGetSessionFromVSCode : mockGetManagedIdentityAccessToken; + provider.mockRejectedValueOnce(error); + const manager = + surface === 'shell' + ? new ShellSessionManager({ clusterId, clusterDisplayName: 'Test Cluster', databaseName: 'test' }) + : new PlaygroundEvaluator(); + const postResponse = vi.fn(); + + await workerCallbacks.onTokenRequest?.( + { type: 'tokenRequest', requestId: 'token-request', source }, + postResponse, + ); + + expect(postResponse).toHaveBeenCalledExactlyOnceWith({ + type: 'tokenError', + requestId: 'token-request', + error: error.message, + }); + expect(error.code).toBe('original-code'); + expect(getRecentEntraTokenFailure(clusterId)).toEqual({ + cloudCategory: 'china', + authMethod: source === 'vscode' ? 'interactive' : 'managedIdentity', + timestamp: expect.any(Number), + }); + manager.dispose(); + }); + + it.each(['shell', 'playground'] as const)( + 'records a failed %s interactive token request when no session is returned', + async (surface): Promise => { + mockGetSessionFromVSCode.mockResolvedValueOnce(undefined); + const manager = + surface === 'shell' + ? new ShellSessionManager({ clusterId, clusterDisplayName: 'Test Cluster', databaseName: 'test' }) + : new PlaygroundEvaluator(); + const postResponse = vi.fn(); + + await workerCallbacks.onTokenRequest?.( + { type: 'tokenRequest', requestId: 'token-request', source: 'vscode' }, + postResponse, + ); + + expect(postResponse).toHaveBeenCalledExactlyOnceWith({ + type: 'tokenError', + requestId: 'token-request', + error: 'Failed to obtain Entra ID token.', + }); + expect(getRecentEntraTokenFailure(clusterId)).toMatchObject({ + cloudCategory: 'public', + authMethod: 'interactive', + }); + manager.dispose(); + }, + ); + + it.each(['shell', 'playground'] as const)( + 'does not record a %s connection failure before credentials are available', + async (surface): Promise => { + CredentialCache.deleteCredentials(clusterId); + const manager = + surface === 'shell' + ? new ShellSessionManager({ clusterId, clusterDisplayName: 'Test Cluster', databaseName: 'test' }) + : new PlaygroundEvaluator(); + const postResponse = vi.fn(); + + await workerCallbacks.onTokenRequest?.( + { type: 'tokenRequest', requestId: 'token-request', source: 'vscode' }, + postResponse, + ); + + expect(postResponse).toHaveBeenCalledWith(expect.objectContaining({ type: 'tokenError' })); + expect(mockGetSessionFromVSCode).not.toHaveBeenCalled(); + expect(getRecentEntraTokenFailure(clusterId)).toBeUndefined(); + manager.dispose(); + }, + ); }); diff --git a/src/documentdb/shell/ShellSessionManager.ts b/src/documentdb/shell/ShellSessionManager.ts index 0b7332541..1b86b8fde 100644 --- a/src/documentdb/shell/ShellSessionManager.ts +++ b/src/documentdb/shell/ShellSessionManager.ts @@ -12,6 +12,7 @@ import { getBatchSizeSetting, getConnectionTimeoutMs } from '../../utils/workspa import { CredentialCache } from '../CredentialCache'; import { AuthMethodId } from '../auth/AuthMethod'; import { resolveInteractiveTokenResource, resolveManagedIdentityTokenResource } from '../auth/documentdbTokenResource'; +import { recordEntraTokenFailure } from '../auth/entraErrors/entraTokenFailures'; import { WorkerSessionManager, type WorkerSessionCallbacks, @@ -371,40 +372,57 @@ export class ShellSessionManager implements vscode.Disposable { if (msg.source === 'managedIdentity') { const { getManagedIdentityAccessToken } = await import('../auth/managedIdentityTokenProvider'); - accessToken = ( - await withTokenAcquisitionTiming(timings, () => - getManagedIdentityAccessToken( - [resolved.scope], - msg.clientId, - msg.tenantId, - this._managedIdentityTokenCorrelationId, - traceContext, - ), - ) - ).accessToken; + try { + accessToken = ( + await withTokenAcquisitionTiming(timings, () => + getManagedIdentityAccessToken( + [resolved.scope], + msg.clientId, + msg.tenantId, + this._managedIdentityTokenCorrelationId, + traceContext, + ), + ) + ).accessToken; + } catch (error) { + recordEntraTokenFailure(this._connectionInfo.clusterId, { + cloudCategory: resolved.cloudCategory, + authMethod: 'managedIdentity', + }); + throw error; + } } else { const { getSessionFromVSCode } = await import( // eslint-disable-next-line import/no-internal-modules '@microsoft/vscode-azext-azureauth/out/src/getSessionFromVSCode' ); - const session = await traceAuthOperation( - 'interactiveEntra.getSession', - () => - withTokenAcquisitionTiming(timings, () => - getSessionFromVSCode([resolved.scope], msg.tenantId, { createIfNone: true }), - ), - { - tenantSpecified: !!msg.tenantId, - createIfNone: true, + let session: vscode.AuthenticationSession | undefined; + try { + session = await traceAuthOperation( + 'interactiveEntra.getSession', + () => + withTokenAcquisitionTiming(timings, () => + getSessionFromVSCode([resolved.scope], msg.tenantId, { createIfNone: true }), + ), + { + tenantSpecified: !!msg.tenantId, + createIfNone: true, + cloudCategory: resolved.cloudCategory, + tokenResourceReason: resolved.reason, + }, + msg.requestId, + traceContext, + ); + + if (!session) { + throw new Error(l10n.t('Failed to obtain Entra ID token.')); + } + } catch (error) { + recordEntraTokenFailure(this._connectionInfo.clusterId, { cloudCategory: resolved.cloudCategory, - tokenResourceReason: resolved.reason, - }, - msg.requestId, - traceContext, - ); - - if (!session) { - throw new Error(l10n.t('Failed to obtain Entra ID token.')); + authMethod: 'interactive', + }); + throw error; } accessToken = session.accessToken; From 20c38ba7720b8fc918e5f68c6f648c063298c03b Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Fri, 23 Oct 2026 17:00:54 +0000 Subject: [PATCH 19/50] Document implemented Entra diagnostics and validation Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/ai-and-plans/README.md | 2 +- .../features/sovereign-clouds/README.md | 39 +++++++++++-------- ...cloud-aware-scope-and-entra-diagnostics.md | 16 +++++++- 3 files changed, 37 insertions(+), 20 deletions(-) diff --git a/docs/ai-and-plans/README.md b/docs/ai-and-plans/README.md index a248ebcf5..5c14cca7d 100644 --- a/docs/ai-and-plans/README.md +++ b/docs/ai-and-plans/README.md @@ -49,7 +49,7 @@ treat `status: historical` or `status: superseded` as current. | [survey](./features/survey/README.md) | Survey refresh: simpler eligibility, broader activity coverage, cohort telemetry, and invitation surface | draft #992; enabled | | [docs-site](./features/docs-site/README.md) | The GitHub Pages documentation site: Just the Docs theme, OS dark mode, local preview | draft #420 | | [pre-release-polish](./features/pre-release-polish/README.md) | Cross-feature visual and documentation refinements prepared for the 1.0 release | active | -| [sovereign-clouds](./features/sovereign-clouds/README.md) | Entra sign-in in Azure Government and China: per-cloud token audience, Entra error classifier | planned | +| [sovereign-clouds](./features/sovereign-clouds/README.md) | Entra sign-in in Azure Government and China: per-cloud token audience, Entra error classifier | implemented; sovereign checks pending | Everything else is a single file at this root, such as: diff --git a/docs/ai-and-plans/features/sovereign-clouds/README.md b/docs/ai-and-plans/features/sovereign-clouds/README.md index 791d6a5e2..5f309a09e 100644 --- a/docs/ai-and-plans/features/sovereign-clouds/README.md +++ b/docs/ai-and-plans/features/sovereign-clouds/README.md @@ -4,6 +4,8 @@ kind: design status: active code: - src/documentdb/auth/entraScopes.ts + - src/documentdb/auth/documentdbTokenResource.ts + - src/documentdb/auth/entraErrors/EntraDiagnosticsProvider.ts - src/documentdb/auth/MicrosoftEntraIDAuthHandler.ts - src/documentdb/auth/ManagedIdentityAuthHandler.ts - src/documentdb/auth/managedIdentityConnectionString.ts @@ -19,20 +21,20 @@ explanations when Entra sign-in fails. ## Status -**Plan approved, implementation in progress.** No PR yet. Nobody on the team has access to a -sovereign cloud, so every change here is made without being able to test it end to end. See +**Parts A and B implemented; sovereign-cloud verification pending.** No PR yet. Nobody on the team +has access to a sovereign cloud, so every change here is made without being able to test it end to end. See [D2](decisions.md#d2-implement-without-sovereign-cloud-access). ## The problem in one paragraph An issue was reported: in Azure US Government, listing Azure resources works, but connecting to a -DocumentDB cluster with Entra ID fails with `AADSTS65002`. VS Code's sign-in app (`aebc6443-…`) is -not preauthorized for the **Azure OSSRDBMS Database** app (`123cd850-…`) in that cloud. Listing works because management calls use the Azure +DocumentDB cluster with Entra ID fails with `AADSTS65002`. VS Code's sign-in app is not preauthorized +for the **Azure OSSRDBMS Database** app in that cloud. Listing works because management calls use the Azure Resource Manager scope, which VS Code is preauthorized for in every cloud. Connecting uses the database scope, which it is not, in US Government. This is a Microsoft-side directory configuration -gap that the extension cannot fix. A separate extension-side defect also exists: we always request +gap that the extension cannot fix. A separate extension-side defect was that we always requested the public-cloud audience `https://ossrdbms-aad.database.windows.net`, whatever cloud the user -selected. +selected. Part A now resolves the audience for the cloud. Full evidence: [research-findings.md](research-findings.md). @@ -40,9 +42,9 @@ Full evidence: [research-findings.md](research-findings.md). | # | Track | Who | State | | --- | ------------------------------------------------------------------------------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------- | -| 1 | Ask the owner of `123cd850` to preauthorize VS Code `aebc6443` in US Government (and China) | Microsoft internal, outside the code | Followed up through internal channels; not tracked in this repository | -| 2 | Request the DocumentDB token for the selected cloud, not always the public one | This repo | Planned: [iteration 01, part A](iterations/01-cloud-aware-scope-and-entra-diagnostics.md#part-a-cloud-aware-token-audience) | -| 3 | Explain Entra sign-in failures (AADSTS codes, broker errors) through one classifier table | This repo | Planned: [iteration 01, part B](iterations/01-cloud-aware-scope-and-entra-diagnostics.md#part-b-entra-error-classifier) | +| 1 | Microsoft preauthorizes VS Code for the Azure OSSRDBMS Database resource in US Government and China | Microsoft, outside the code | Not tracked in this repository | +| 2 | Request the DocumentDB token for the selected cloud, not always the public one | This repo | Implemented: [iteration 01, part A](iterations/01-cloud-aware-scope-and-entra-diagnostics.md#part-a-cloud-aware-token-audience) | +| 3 | Explain Entra sign-in failures (AADSTS codes, broker errors) through one classifier table | This repo | Implemented: [iteration 01, part B](iterations/01-cloud-aware-scope-and-entra-diagnostics.md#part-b-entra-error-classifier) | Track 2 alone does not fix the reported failure; track 1 does. Track 2 is needed so that the token is issued for the right audience once track 1 lands. Track 3 makes the next failure of this kind @@ -50,22 +52,25 @@ readable without a support round trip. ## Code map (today) -- [entraScopes.ts](../../../../src/documentdb/auth/entraScopes.ts): the single public-cloud - `DOCUMENTDB_TOKEN_RESOURCE` and `DOCUMENTDB_ENTRA_SCOPE`. +- [entraScopes.ts](../../../../src/documentdb/auth/entraScopes.ts): the built-in cloud audience + table and unchanged public `DOCUMENTDB_TOKEN_RESOURCE` and `DOCUMENTDB_ENTRA_SCOPE`. +- [documentdbTokenResource.ts](../../../../src/documentdb/auth/documentdbTokenResource.ts): + interactive and managed identity audience resolution, custom clouds, and mismatch checks. - [MicrosoftEntraIDAuthHandler.ts](../../../../src/documentdb/auth/MicrosoftEntraIDAuthHandler.ts): interactive sign-in through `getSessionFromVSCode`, which already picks the sovereign VS Code - auth provider, but is handed the public scope. + auth provider and requests the resolved cloud's scope. - [ManagedIdentityAuthHandler.ts](../../../../src/documentdb/auth/ManagedIdentityAuthHandler.ts): - managed identity, public scope. + managed identity, using the VM and cluster's resolved cloud scope. - [playgroundWorker.ts](../../../../src/documentdb/playground/playgroundWorker.ts): the shell and - playground worker asks the main thread for a token with the public scope. + playground worker asks the main thread for a token; the main thread resolves its scope. - [managedIdentityConnectionString.ts](../../../../src/documentdb/auth/managedIdentityConnectionString.ts): - writes `TOKEN_RESOURCE:` into copied connection strings; parses `TOKEN_RESOURCE` but the - value is not used afterwards. + writes a best-effort cloud-aware `TOKEN_RESOURCE` into copied connection strings. - [oidcAllowedHosts.ts](../../../../src/documentdb/auth/oidcAllowedHosts.ts): already accepts `*.azure.us` and `*.azure.cn` hosts (#639, #934). +- [entraErrors/](../../../../src/documentdb/auth/entraErrors/): the single classifier table, + pure text classifier, recent token-failure registry, and Entra diagnostics provider. - [connectionDiagnosticsService.ts](../../../../src/services/connectionDiagnosticsService.ts): the - provider registry the Entra classifier plugs into. + provider registry that routes Entra explanations to existing foreground error surfaces. Azure management (subscription listing, ARM clients) already follows the selected cloud through `AzureSubscription.environment`; nothing is planned there. diff --git a/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics.md b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics.md index 11409cb2f..06bcc4f0d 100644 --- a/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics.md +++ b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics.md @@ -200,7 +200,8 @@ src/documentdb/auth/entraErrors/ ## B4. Rules -Each rule has an id, codes, and `explain(context)` with `context = { code, cloud, authMethod }`. +Each rule has an id, codes, and `explain(context)` with +`context = { code, cloudCategory, recentTokenFailure }`. Every message names who can fix the problem and links to the public troubleshooting page (`Links.SovereignClouds`). @@ -228,6 +229,7 @@ as a manual diagnostic step, never as a fix. The provider sends `entraSignIn.classified` with `ruleId`, `aadstsCode`, `cloudCategory`, `brokered` and `authMethod`. No tenant, subscription, cluster, user, or correlation identifiers. +The auth method comes from the recent token-failure registry, not from the error text. ## B6. Tests @@ -268,4 +270,14 @@ and `authMethod`. No tenant, subscription, cluster, user, or correlation identif # Outcome -_To be written at hand-over._ +Part B is implemented: one rule table and pure text classifier, a ten-minute in-memory +token-failure registry, and the `entra-sign-in` diagnostics provider registered next to SRV DNS +diagnostics. Interactive and managed identity handlers, the shell, and the playground record only +token-acquisition failures, leaving audience-resolution failures and original errors unchanged. +An empty interactive session is also recorded as a token-acquisition failure. + +Verification: `npx vitest run src/documentdb/auth src/documentdb/shell src/documentdb/playground src/services` +passed, 85 files and 2,061 tests. No build, lint, formatter, localization generation, or packaging was +run for Part B, as requested by the operator. Sovereign-cloud behavior is inferred from fixtures and +has not been observed end to end (D2). The service-side preauthorization work remains outside this +repository. From 17a95f665bbac12b0cf708348661d14701c4f39d Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Fri, 23 Oct 2026 17:27:36 +0000 Subject: [PATCH 20/50] Add Entra cloud auth traces Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../auth/ManagedIdentityAuthHandler.test.ts | 5 +- .../auth/ManagedIdentityAuthHandler.ts | 21 +- .../auth/MicrosoftEntraIDAuthHandler.test.ts | 26 ++- .../auth/MicrosoftEntraIDAuthHandler.ts | 22 +- .../auth/documentdbTokenResource.test.ts | 77 +++++- .../auth/documentdbTokenResource.ts | 219 ++++++++++++++---- .../EntraDiagnosticsProvider.test.ts | 13 ++ .../entraErrors/EntraDiagnosticsProvider.ts | 8 + .../playground/PlaygroundEvaluator.ts | 25 +- .../shell/ShellSessionManager.test.ts | 16 ++ src/documentdb/shell/ShellSessionManager.ts | 25 +- src/utils/authTrace.test.ts | 21 ++ src/utils/authTrace.ts | 3 +- 13 files changed, 403 insertions(+), 78 deletions(-) create mode 100644 src/utils/authTrace.test.ts diff --git a/src/documentdb/auth/ManagedIdentityAuthHandler.test.ts b/src/documentdb/auth/ManagedIdentityAuthHandler.test.ts index 4cd11086a..94f8fb13a 100644 --- a/src/documentdb/auth/ManagedIdentityAuthHandler.test.ts +++ b/src/documentdb/auth/ManagedIdentityAuthHandler.test.ts @@ -243,7 +243,10 @@ describe('ManagedIdentityAuthHandler', () => { }); it('does not record token resource resolution errors as token-acquisition failures', async (): Promise => { - const error = new tokenResources.TokenResourceResolutionError('Set the cloud resource.', 'managedIdentityCloudUnknown'); + const error = new tokenResources.TokenResourceResolutionError('Set the cloud resource.', 'managedIdentityCloudUnknown', { + flow: 'managedIdentity', + errorKind: 'managedIdentityCloudUnknown', + }); vi.spyOn(tokenResources, 'resolveManagedIdentityTokenResource').mockRejectedValueOnce(error); const handler = new ManagedIdentityAuthHandler(buildCredentials()); diff --git a/src/documentdb/auth/ManagedIdentityAuthHandler.ts b/src/documentdb/auth/ManagedIdentityAuthHandler.ts index 70302439d..807700e17 100644 --- a/src/documentdb/auth/ManagedIdentityAuthHandler.ts +++ b/src/documentdb/auth/ManagedIdentityAuthHandler.ts @@ -13,7 +13,11 @@ import { withTokenAcquisitionTiming, type ConnectionStartupTimings } from '../ut import { DocumentDBConnectionString } from '../utils/DocumentDBConnectionString'; import { resolveAllowInvalidCertificates } from '../utils/tlsException'; import { type AuthHandler, type AuthHandlerResponse } from './AuthHandler'; -import { resolveManagedIdentityTokenResource, type DocumentDBCustomEnvironment } from './documentdbTokenResource'; +import { + resolveManagedIdentityTokenResource, + TokenResourceResolutionError, + type DocumentDBCustomEnvironment, +} from './documentdbTokenResource'; import { recordEntraTokenFailure } from './entraErrors/entraTokenFailures'; import { getManagedIdentityAccessToken } from './managedIdentityTokenProvider'; import { getOidcAllowedHosts } from './oidcAllowedHosts'; @@ -39,10 +43,20 @@ export class ManagedIdentityAuthHandler implements AuthHandler { ): Promise { const clientId = this.clusterCredentials.managedIdentityConfig?.clientId; const tenantId = this.clusterCredentials.managedIdentityConfig?.tenantId; - const resolved = await resolveManagedIdentityTokenResource(this.clusterCredentials.connectionString); + let resolved: Awaited>; + try { + resolved = await resolveManagedIdentityTokenResource(this.clusterCredentials.connectionString); + traceAuthFlow('entraCloud.resolved', resolved.details, this.tokenCorrelationId, traceContext); + } catch (error) { + if (error instanceof TokenResourceResolutionError) { + traceAuthFlow('entraCloud.unresolved', error.details, this.tokenCorrelationId, traceContext); + } + throw error; + } const customHostnameSuffix = vscode.workspace .getConfiguration() .get(settingsKeys.customEnvironment)?.mongoClusterHostnameSuffix; + const allowedHosts = getOidcAllowedHosts(this.clusterCredentials.connectionString, customHostnameSuffix); traceAuthFlow( 'managedIdentity.configureOidc', { @@ -51,6 +65,7 @@ export class ManagedIdentityAuthHandler implements AuthHandler { tokenAcquisition: 'deferredUntilDriverCallback', cloudCategory: resolved.cloudCategory, tokenResourceReason: resolved.reason, + allowedHosts: allowedHosts.join(','), }, this.tokenCorrelationId, traceContext, @@ -69,7 +84,7 @@ export class ManagedIdentityAuthHandler implements AuthHandler { authMechanism: 'MONGODB-OIDC', tls: true, authMechanismProperties: { - ALLOWED_HOSTS: getOidcAllowedHosts(this.clusterCredentials.connectionString, customHostnameSuffix), + ALLOWED_HOSTS: allowedHosts, OIDC_CALLBACK: async (): Promise => { try { const token = await traceAuthOperation( diff --git a/src/documentdb/auth/MicrosoftEntraIDAuthHandler.test.ts b/src/documentdb/auth/MicrosoftEntraIDAuthHandler.test.ts index fa62c0681..b1b9928a0 100644 --- a/src/documentdb/auth/MicrosoftEntraIDAuthHandler.test.ts +++ b/src/documentdb/auth/MicrosoftEntraIDAuthHandler.test.ts @@ -53,6 +53,10 @@ describe('MicrosoftEntraIDAuthHandler', () => { scope: 'https://ossrdbms-aad.database.windows.net/.default', cloudCategory: 'public', reason: 'setting', + details: { + flow: 'interactive', + tokenResource: 'https://ossrdbms-aad.database.windows.net', + }, }); vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ get: vi.fn().mockReturnValue(undefined), @@ -84,6 +88,11 @@ describe('MicrosoftEntraIDAuthHandler', () => { expect(output).toContain('interactiveEntra.getSession'); expect(output).not.toContain('access-token'); expect(output).not.toContain('my-cluster'); + const cloudResolutionTrace = mockOutputChannel.info.mock.calls + .map((call) => String(call[0])) + .find((message) => message.includes('entraCloud.resolved')); + expect(cloudResolutionTrace).toContain('"tokenResource":"https://ossrdbms-aad.database.windows.net"'); + expect(cloudResolutionTrace).not.toContain('my-cluster'); expect(getRecentEntraTokenFailure('cluster-1')).toBeUndefined(); }); @@ -94,19 +103,23 @@ describe('MicrosoftEntraIDAuthHandler', () => { scope, cloudCategory: 'usgov', reason: 'setting', + details: { flow: 'interactive', tokenResource: 'https://ossrdbms-aad.database.usgovcloudapi.net' }, }); const connectionString = 'mongodb://cluster.azure.us/'; await new MicrosoftEntraIDAuthHandler(buildCredentials(connectionString)).configureAuth(); expect(resolveInteractiveTokenResource).toHaveBeenCalledWith(connectionString); expect(getSessionFromVSCode).toHaveBeenCalledWith([scope], 'tenant', { createIfNone: true }); - const output = String(mockOutputChannel.info.mock.calls[0][0]); + const output = mockOutputChannel.info.mock.calls.map((call) => String(call[0])).join('\n'); expect(output).toContain('"cloudCategory":"usgov"'); expect(output).toContain('"tokenResourceReason":"setting"'); - expect(mockOutputChannel.info).toHaveBeenCalledTimes(3); + expect(mockOutputChannel.info).toHaveBeenCalledTimes(4); }); it('propagates a cloud mismatch unchanged before requesting a session', async () => { - const error = new TokenResourceResolutionError('Select the cloud of the cluster.', 'cloudMismatch'); + const error = new TokenResourceResolutionError('Select the cloud of the cluster.', 'cloudMismatch', { + flow: 'interactive', + errorKind: 'cloudMismatch', + }); vi.mocked(resolveInteractiveTokenResource).mockRejectedValueOnce(error); const handler = new MicrosoftEntraIDAuthHandler(buildCredentials('mongodb://cluster.azure.us/')); await expect(handler.configureAuth()).rejects.toBe(error); @@ -122,6 +135,7 @@ describe('MicrosoftEntraIDAuthHandler', () => { scope: 'https://database.example.com/.default', cloudCategory, reason: 'setting', + details: { flow: 'interactive', tokenResource: 'https://database.example.com' }, }); const error = Object.freeze( Object.assign(new Error('AADSTS65002: Preauthorization required'), { code: 'original-code' }), @@ -174,9 +188,11 @@ describe('MicrosoftEntraIDAuthHandler', () => { await handler.configureAuth(undefined, { startupCorrelationId: 'startup-id', surface: 'extension' }); const messages = mockOutputChannel.info.mock.calls.map((call) => String(call[0])); - expect(messages).toHaveLength(3); + expect(messages).toHaveLength(4); for (const message of messages) { - expect(message).toMatch(/^\[Authentication\] startup=startup-id surface=extension interactiveEntra\./); + expect(message).toMatch( + /^\[Authentication\] startup=startup-id surface=extension (entraCloud\.resolved|interactiveEntra\.)/, + ); } }); diff --git a/src/documentdb/auth/MicrosoftEntraIDAuthHandler.ts b/src/documentdb/auth/MicrosoftEntraIDAuthHandler.ts index 47ebd8f18..a9aaf205f 100644 --- a/src/documentdb/auth/MicrosoftEntraIDAuthHandler.ts +++ b/src/documentdb/auth/MicrosoftEntraIDAuthHandler.ts @@ -16,7 +16,11 @@ import { withTokenAcquisitionTiming, type ConnectionStartupTimings } from '../ut import { DocumentDBConnectionString } from '../utils/DocumentDBConnectionString'; import { resolveAllowInvalidCertificates } from '../utils/tlsException'; import { type AuthHandler, type AuthHandlerResponse } from './AuthHandler'; -import { resolveInteractiveTokenResource, type DocumentDBCustomEnvironment } from './documentdbTokenResource'; +import { + resolveInteractiveTokenResource, + TokenResourceResolutionError, + type DocumentDBCustomEnvironment, +} from './documentdbTokenResource'; import { recordEntraTokenFailure } from './entraErrors/entraTokenFailures'; import { getOidcAllowedHosts } from './oidcAllowedHosts'; @@ -31,7 +35,16 @@ export class MicrosoftEntraIDAuthHandler implements AuthHandler { traceContext?: AuthTraceContext, ): Promise { const authCorrelationId = randomUUID(); - const resolved = await resolveInteractiveTokenResource(this.clusterCredentials.connectionString); + let resolved: Awaited>; + try { + resolved = await resolveInteractiveTokenResource(this.clusterCredentials.connectionString); + traceAuthFlow('entraCloud.resolved', resolved.details, authCorrelationId, traceContext); + } catch (error) { + if (error instanceof TokenResourceResolutionError) { + traceAuthFlow('entraCloud.unresolved', error.details, authCorrelationId, traceContext); + } + throw error; + } const customHostnameSuffix = vscode.workspace .getConfiguration() .get(settingsKeys.customEnvironment)?.mongoClusterHostnameSuffix; @@ -82,11 +95,12 @@ export class MicrosoftEntraIDAuthHandler implements AuthHandler { dbConnectionString.searchParams.delete('tls'); // Configure MongoDB client options for OIDC + const allowedHosts = getOidcAllowedHosts(this.clusterCredentials.connectionString, customHostnameSuffix); const options: MongoClientOptions = { authMechanism: 'MONGODB-OIDC', tls: true, authMechanismProperties: { - ALLOWED_HOSTS: getOidcAllowedHosts(this.clusterCredentials.connectionString, customHostnameSuffix), + ALLOWED_HOSTS: allowedHosts, OIDC_CALLBACK: (_params: OIDCCallbackParams): Promise => Promise.resolve({ accessToken: session.accessToken, @@ -110,7 +124,7 @@ export class MicrosoftEntraIDAuthHandler implements AuthHandler { traceAuthFlow( 'interactiveEntra.oidcConfigured', - { sessionAvailable: true, tls: true }, + { sessionAvailable: true, tls: true, allowedHosts: allowedHosts.join(',') }, authCorrelationId, traceContext, ); diff --git a/src/documentdb/auth/documentdbTokenResource.test.ts b/src/documentdb/auth/documentdbTokenResource.test.ts index a2e7949ae..334ce651a 100644 --- a/src/documentdb/auth/documentdbTokenResource.test.ts +++ b/src/documentdb/auth/documentdbTokenResource.test.ts @@ -32,6 +32,7 @@ function createDeps(): TokenResourceResolverDeps { getCustomEnvironment: vi.fn(() => ({})), fetchArmMetadata: vi.fn().mockResolvedValue(undefined), readImdsAzEnvironment: vi.fn().mockResolvedValue(undefined), + getSignInEnvironment: vi.fn(() => ({ authProvider: 'microsoft', brokerImplementation: undefined })), warn: vi.fn(), }; } @@ -62,7 +63,7 @@ describe('resolveInteractiveTokenResource', () => { const deps = createDeps(); deps.getConfiguredCloud = getConfiguredAzureEnv; - expect(await resolveInteractiveTokenResource(privateConnection, deps)).toEqual({ + expect(await resolveInteractiveTokenResource(privateConnection, deps)).toMatchObject({ resource, scope: `${resource}/.default`, cloudCategory: category, @@ -72,12 +73,51 @@ describe('resolveInteractiveTokenResource', () => { expect(deps.readImdsAzEnvironment).not.toHaveBeenCalled(); }); + it('records public interactive resolution details without host information', async () => { + const resolution = await resolveInteractiveTokenResource(privateConnection, createDeps()); + + expect(resolution.details).toMatchObject({ + flow: 'interactive', + configuredCloud: 'AzureCloud', + authProvider: 'microsoft', + brokerImplementation: 'default', + hostCloud: 'none', + customOssrdbmsEndpointSet: false, + customHostnameSuffixSet: false, + armMetadata: 'notNeeded', + imdsAzEnvironment: 'notQueried', + cloudCategory: 'public', + reason: 'setting', + tokenResource: publicResource, + }); + expect(JSON.stringify(resolution.details)).not.toContain('cluster.private.example.com'); + }); + + it('records US Government interactive resolution details', async () => { + const deps = createDeps(); + vi.mocked(deps.getConfiguredCloud).mockReturnValue({ name: 'AzureUSGovernment', isCustomCloud: false }); + vi.mocked(deps.getSignInEnvironment).mockReturnValue({ + authProvider: 'microsoft-sovereign-cloud', + brokerImplementation: 'msal-no-broker', + }); + + const resolution = await resolveInteractiveTokenResource('mongodb://cluster.azure.us/', deps); + + expect(resolution.details).toMatchObject({ + configuredCloud: 'AzureUSGovernment', + authProvider: 'microsoft-sovereign-cloud', + brokerImplementation: 'msal-no-broker', + hostCloud: 'AzureUSGovernment', + tokenResource: governmentResource, + }); + }); + it('uses a validated custom override before the login host or metadata', async () => { const deps = createDeps(); useCustomCloud(deps); vi.mocked(deps.getCustomEnvironment).mockReturnValue({ ossrdbmsEndpoint: `${customResource}/` }); - expect(await resolveInteractiveTokenResource(privateConnection, deps)).toEqual({ + expect(await resolveInteractiveTokenResource(privateConnection, deps)).toMatchObject({ resource: customResource, scope: `${customResource}/.default`, cloudCategory: 'custom', @@ -120,10 +160,12 @@ describe('resolveInteractiveTokenResource', () => { useCustomCloud(deps); vi.mocked(deps.fetchArmMetadata).mockResolvedValue({ [key]: `${customResource}/` }); - expect(await resolveInteractiveTokenResource(privateConnection, deps)).toMatchObject({ + const resolution = await resolveInteractiveTokenResource(privateConnection, deps); + expect(resolution).toMatchObject({ resource: customResource, reason: 'armMetadata', }); + expect(resolution.details).toMatchObject({ armMetadata: 'found', configuredCloud: 'custom' }); expect(deps.fetchArmMetadata).toHaveBeenCalledWith( 'https://management.example.com/metadata/endpoints?api-version=2022-09-01', ); @@ -211,6 +253,15 @@ describe('resolveInteractiveTokenResource', () => { await expect(resolveInteractiveTokenResource(`mongodb://cluster.${suffix}/`, deps)).rejects.toMatchObject({ kind: 'cloudMismatch', message: expect.stringContaining('microsoft-sovereign-cloud.environment'), + details: expect.objectContaining({ + hostCloud: + suffix === 'azure.us' + ? 'AzureUSGovernment' + : suffix === 'azure.cn' + ? 'AzureChinaCloud' + : 'AzureCloud', + errorKind: 'cloudMismatch', + }), }); await expect(resolveInteractiveTokenResource(`mongodb://cluster.${suffix}/`, deps)).rejects.toThrow( hostDisplay, @@ -245,7 +296,7 @@ describe('resolveManagedIdentityTokenResource', () => { resource: customResource, reason: 'customOverride', }); - expect(deps.getConfiguredCloud).not.toHaveBeenCalled(); + expect(deps.getConfiguredCloud).toHaveBeenCalledTimes(1); expect(deps.readImdsAzEnvironment).not.toHaveBeenCalled(); }); @@ -260,7 +311,7 @@ describe('resolveManagedIdentityTokenResource', () => { scope: `${resource}/.default`, reason: 'host', }); - expect(deps.getConfiguredCloud).not.toHaveBeenCalled(); + expect(deps.getConfiguredCloud).toHaveBeenCalledTimes(1); expect(deps.readImdsAzEnvironment).not.toHaveBeenCalled(); }); @@ -273,10 +324,24 @@ describe('resolveManagedIdentityTokenResource', () => { resource: publicResource, reason: 'imds', }); - expect(deps.getConfiguredCloud).not.toHaveBeenCalled(); + expect(deps.getConfiguredCloud).toHaveBeenCalledTimes(1); }, ); + it('records IMDS classification for managed identity resolution', async () => { + const deps = createDeps(); + vi.mocked(deps.readImdsAzEnvironment).mockResolvedValue('azurepubliccloud'); + const resolution = await resolveManagedIdentityTokenResource(privateConnection, deps); + + expect(resolution.details).toMatchObject({ + flow: 'managedIdentity', + configuredCloud: 'AzureCloud', + hostCloud: 'none', + imdsAzEnvironment: 'AZUREPUBLICCLOUD', + tokenResource: publicResource, + }); + }); + it.each([undefined, 'AZUREUSGOVERNMENT', 'AZURECHINACLOUD', 'unknown', ''])( 'fails clearly for unknown or unavailable IMDS environment %s', async (environment) => { diff --git a/src/documentdb/auth/documentdbTokenResource.ts b/src/documentdb/auth/documentdbTokenResource.ts index 637c43e43..7a0055132 100644 --- a/src/documentdb/auth/documentdbTokenResource.ts +++ b/src/documentdb/auth/documentdbTokenResource.ts @@ -4,7 +4,10 @@ *--------------------------------------------------------------------------------------------*/ // eslint-disable-next-line import/no-internal-modules -import { getConfiguredAzureEnv } from '@microsoft/vscode-azext-azureauth/out/src/utils/configuredAzureEnv'; +import { + getConfiguredAuthProviderId, + getConfiguredAzureEnv, +} from '@microsoft/vscode-azext-azureauth/out/src/utils/configuredAzureEnv'; import * as l10n from '@vscode/l10n'; import * as http from 'http'; import * as vscode from 'vscode'; @@ -18,7 +21,7 @@ import { toEntraScope, type DocumentDBEntraCloud, } from './entraScopes'; -import { getAzureCloudFromHosts } from './oidcAllowedHosts'; +import { getAzureCloudFromHosts, normalizeCustomHostnameSuffix } from './oidcAllowedHosts'; /** Coarse cloud label. Safe for traces and telemetry; never carries an identifier. */ export type CloudCategory = 'public' | 'usgov' | 'china' | 'custom'; @@ -37,6 +40,7 @@ export interface ResolvedTokenResource { readonly scope: string; readonly cloudCategory: CloudCategory; readonly reason: TokenResourceReason; + readonly details: Readonly>; } /** The parts of `getConfiguredAzureEnv()` this module reads. */ @@ -61,6 +65,7 @@ export interface TokenResourceResolverDeps { fetchArmMetadata(url: string): Promise; /** Returns IMDS `compute.azEnvironment`, or `undefined` on any failure. */ readImdsAzEnvironment(): Promise; + getSignInEnvironment(): { authProvider: string; brokerImplementation: string | undefined }; warn(message: string): void; } @@ -71,6 +76,7 @@ export class TokenResourceResolutionError extends Error { constructor( message: string, public readonly kind: TokenResourceErrorKind, + public readonly details: Readonly>, ) { super(message); this.name = 'TokenResourceResolutionError'; @@ -110,20 +116,17 @@ export async function resolveInteractiveTokenResource( connectionString: string, deps: TokenResourceResolverDeps = getDefaultDeps(), ): Promise { - let configured: ConfiguredCloud; - try { - configured = deps.getConfiguredCloud(); - } catch { - // `custom` without a usable `customEnvironment`: still a custom cloud, never the public one. - configured = { name: 'custom', isCustomCloud: true }; - } - - const resolved = configured.isCustomCloud - ? await resolveCustomCloud(configured, deps) - : fromBuiltInCloud(toBuiltInCloud(configured.name), 'setting'); + const customEnvironment = deps.getCustomEnvironment(); + const details = createResolutionDetails('interactive', customEnvironment, deps); + const configured = getConfiguredCloudForDetails(deps, details); + const resolved = configured + ? configured.isCustomCloud + ? await resolveCustomCloud(configured, customEnvironment, deps, details) + : fromBuiltInCloud(toBuiltInCloud(configured.name), 'setting') + : await resolveCustomCloud({ name: 'custom', isCustomCloud: true }, customEnvironment, deps, details); if ( - configured.isCustomCloud && + configured?.isCustomCloud && resolved.reason === 'customOverride' && resolved.resource === DOCUMENTDB_TOKEN_RESOURCE ) { @@ -135,9 +138,10 @@ export async function resolveInteractiveTokenResource( } const hostCloud = getAzureCloudFromHosts(connectionString); + details.hostCloud = hostCloud ?? 'none'; const audienceCloud = getCloudForTokenResource(resolved.resource); if (hostCloud && hostCloud !== audienceCloud) { - throw new TokenResourceResolutionError( + throw resolutionError( l10n.t( 'This cluster is in {clusterCloud}, but VS Code is signed in to {signedInCloud}. Change the "{setting}" setting to the cloud of the cluster, then connect again.', { @@ -147,10 +151,11 @@ export async function resolveInteractiveTokenResource( }, ), 'cloudMismatch', + details, ); } - return resolved; + return withDetails(resolved, details); } /** @@ -164,27 +169,33 @@ export async function resolveManagedIdentityTokenResource( connectionString: string, deps: TokenResourceResolverDeps = getDefaultDeps(), ): Promise { - const override = parseTokenResource(deps.getCustomEnvironment().ossrdbmsEndpoint); + const customEnvironment = deps.getCustomEnvironment(); + const details = createResolutionDetails('managedIdentity', customEnvironment, deps); + getConfiguredCloudForDetails(deps, details); + const override = parseTokenResource(customEnvironment.ossrdbmsEndpoint); if (override) { - return fromResource(override, 'customOverride'); + return withDetails(fromResource(override, 'customOverride'), details); } const hostCloud = getAzureCloudFromHosts(connectionString); + details.hostCloud = hostCloud ?? 'none'; if (hostCloud) { - return fromBuiltInCloud(hostCloud, 'host'); + return withDetails(fromBuiltInCloud(hostCloud, 'host'), details); } const azEnvironment = await deps.readImdsAzEnvironment(); + details.imdsAzEnvironment = classifyImdsAzEnvironment(azEnvironment); if (azEnvironment?.trim().toUpperCase() === 'AZUREPUBLICCLOUD') { - return fromBuiltInCloud('AzureCloud', 'imds'); + return withDetails(fromBuiltInCloud('AzureCloud', 'imds'), details); } - throw new TokenResourceResolutionError( + throw resolutionError( l10n.t( 'We could not determine which Azure cloud this virtual machine is in, so we do not know which Microsoft Entra resource to request a token for. Set "{setting}" to the open-source database resource of your cloud, for example "{example}".', { setting: OSSRDBMS_ENDPOINT_SETTING, example: getTokenResourceForCloud('AzureUSGovernment') }, ), 'managedIdentityCloudUnknown', + details, ); } @@ -197,28 +208,30 @@ export async function resolveManagedIdentityTokenResource( */ export function resolveTokenResourceForCopy( connectionString: string, - deps: Pick = getDefaultDeps(), + deps: Pick< + TokenResourceResolverDeps, + 'getConfiguredCloud' | 'getCustomEnvironment' | 'getSignInEnvironment' + > = getDefaultDeps(), ): ResolvedTokenResource { - const override = parseTokenResource(deps.getCustomEnvironment().ossrdbmsEndpoint); + const customEnvironment = deps.getCustomEnvironment(); + const details = createResolutionDetails('copy', customEnvironment, deps); + const override = parseTokenResource(customEnvironment.ossrdbmsEndpoint); if (override) { - return fromResource(override, 'customOverride'); + return withDetails(fromResource(override, 'customOverride'), details); } const hostCloud = getAzureCloudFromHosts(connectionString); + details.hostCloud = hostCloud ?? 'none'; if (hostCloud) { - return fromBuiltInCloud(hostCloud, 'host'); + return withDetails(fromBuiltInCloud(hostCloud, 'host'), details); } - try { - const configured = deps.getConfiguredCloud(); - if (!configured.isCustomCloud) { - return fromBuiltInCloud(toBuiltInCloud(configured.name), 'setting'); - } - } catch { - // Misconfigured custom cloud: fall through to the default below. + const configured = getConfiguredCloudForDetails(deps, details); + if (configured && !configured.isCustomCloud) { + return withDetails(fromBuiltInCloud(toBuiltInCloud(configured.name), 'setting'), details); } - return fromBuiltInCloud('AzureCloud', 'copyDefault'); + return withDetails(fromBuiltInCloud('AzureCloud', 'copyDefault'), details); } export function getCloudCategory(resource: string): CloudCategory { @@ -252,9 +265,11 @@ export function resetTokenResourceCacheForTests(): void { async function resolveCustomCloud( configured: ConfiguredCloud, + customEnvironment: DocumentDBCustomEnvironment, deps: TokenResourceResolverDeps, -): Promise { - const override = parseTokenResource(deps.getCustomEnvironment().ossrdbmsEndpoint); + details: Record, +): Promise> { + const override = parseTokenResource(customEnvironment.ossrdbmsEndpoint); if (override) { return fromResource(override, 'customOverride'); } @@ -266,36 +281,43 @@ async function resolveCustomCloud( } const fromMetadata = await lookUpArmMetadata(configured.resourceManagerEndpointUrl, deps); - if (fromMetadata) { - return fromResource(fromMetadata, 'armMetadata'); + details.armMetadata = fromMetadata.status; + if (fromMetadata.resource) { + return fromResource(fromMetadata.resource, 'armMetadata'); } - throw new TokenResourceResolutionError( + throw resolutionError( l10n.t( 'VS Code is signed in to a custom cloud, and we could not find which Microsoft Entra resource to request a token for. Set "{setting}" to the open-source database resource of your cloud, usually "https://ossrdbms-aad.database.". Your cloud operator or Azure administrator can tell you the exact value.', { setting: OSSRDBMS_ENDPOINT_SETTING }, ), 'customCloudUnresolved', + details, ); } async function lookUpArmMetadata( resourceManagerEndpointUrl: string | undefined, deps: TokenResourceResolverDeps, -): Promise { +): Promise<{ resource: string | undefined; status: 'cached' | 'found' | 'missingKey' | 'unavailable' }> { const base = parseTokenResource(resourceManagerEndpointUrl); if (!base) { - return undefined; + return { resource: undefined, status: 'unavailable' }; } const cached = armMetadataCache.get(base); if (cached) { - return cached; + return { resource: cached, status: 'cached' }; } - const body = await deps.fetchArmMetadata(`${base}/metadata/endpoints?api-version=${ARM_METADATA_API_VERSION}`); + let body: unknown; + try { + body = await deps.fetchArmMetadata(`${base}/metadata/endpoints?api-version=${ARM_METADATA_API_VERSION}`); + } catch { + return { resource: undefined, status: 'unavailable' }; + } if (!body || typeof body !== 'object' || Array.isArray(body)) { - return undefined; + return { resource: undefined, status: 'unavailable' }; } const entry = Object.entries(body as Record).find( @@ -304,8 +326,9 @@ async function lookUpArmMetadata( const resource = parseTokenResource(entry?.[1]); if (resource) { armMetadataCache.set(base, resource); + return { resource, status: 'found' }; } - return resource; + return { resource: undefined, status: 'missingKey' }; } function toBuiltInCloud(name: string): DocumentDBEntraCloud { @@ -319,14 +342,112 @@ function toBuiltInCloud(name: string): DocumentDBEntraCloud { } } -function fromBuiltInCloud(cloud: DocumentDBEntraCloud, reason: TokenResourceReason): ResolvedTokenResource { +function fromBuiltInCloud(cloud: DocumentDBEntraCloud, reason: TokenResourceReason): Omit { return fromResource(getTokenResourceForCloud(cloud), reason); } -function fromResource(resource: string, reason: TokenResourceReason): ResolvedTokenResource { +function fromResource(resource: string, reason: TokenResourceReason): Omit { return { resource, scope: toEntraScope(resource), cloudCategory: getCloudCategory(resource), reason }; } +function withDetails( + resolved: Omit, + details: Record, +): ResolvedTokenResource { + return { + ...resolved, + details: { + ...details, + cloudCategory: resolved.cloudCategory, + reason: resolved.reason, + tokenResource: resolved.resource, + }, + }; +} + +function resolutionError( + message: string, + kind: TokenResourceErrorKind, + details: Record, +): TokenResourceResolutionError { + return new TokenResourceResolutionError(message, kind, { ...details, errorKind: kind }); +} + +function createResolutionDetails( + flow: 'interactive' | 'managedIdentity' | 'copy', + customEnvironment: DocumentDBCustomEnvironment, + deps: Pick, +): Record { + let authProvider = 'unknown'; + let brokerImplementation = 'default'; + try { + const signInEnvironment = deps.getSignInEnvironment(); + if ( + signInEnvironment.authProvider === 'microsoft' || + signInEnvironment.authProvider === 'microsoft-sovereign-cloud' + ) { + authProvider = signInEnvironment.authProvider; + } + brokerImplementation = normalizeBrokerImplementation(signInEnvironment.brokerImplementation); + } catch { + // Configuration can be read only as support diagnostics, never as an auth decision. + } + return { + flow, + configuredCloud: 'unreadable', + authProvider, + brokerImplementation, + hostCloud: 'none', + customOssrdbmsEndpointSet: !!parseTokenResource(customEnvironment.ossrdbmsEndpoint), + customHostnameSuffixSet: !!normalizeCustomHostnameSuffix(customEnvironment.mongoClusterHostnameSuffix), + armMetadata: 'notNeeded', + imdsAzEnvironment: 'notQueried', + }; +} + +function getConfiguredCloudForDetails( + deps: Pick, + details: Record, +): ConfiguredCloud | undefined { + try { + const configured = deps.getConfiguredCloud(); + details.configuredCloud = configured.isCustomCloud ? 'custom' : toConfiguredCloudTraceValue(configured.name); + return configured; + } catch { + return undefined; + } +} + +function toConfiguredCloudTraceValue(name: string): string { + switch (name) { + case 'AzureCloud': + case 'AzureUSGovernment': + case 'AzureChinaCloud': + return name; + default: + return 'custom'; + } +} + +function normalizeBrokerImplementation(value: string | undefined): string { + switch (value) { + case undefined: + return 'default'; + case 'msal': + case 'msal-no-broker': + return value; + default: + return 'other'; + } +} + +function classifyImdsAzEnvironment(value: string | undefined): string { + if (!value?.trim()) { + return 'unavailable'; + } + return value.trim().toUpperCase() === 'AZUREPUBLICCLOUD' ? 'AZUREPUBLICCLOUD' : 'other'; +} + function getHostname(url: string | undefined): string | undefined { if (!url) { return undefined; @@ -345,6 +466,12 @@ function getDefaultDeps(): TokenResourceResolverDeps { vscode.workspace.getConfiguration().get(settingsKeys.customEnvironment) ?? {}, fetchArmMetadata: fetchJson, readImdsAzEnvironment, + getSignInEnvironment: () => ({ + authProvider: getConfiguredAuthProviderId(), + brokerImplementation: vscode.workspace + .getConfiguration('microsoft-authentication') + .get('implementation'), + }), warn: (message) => ext.outputChannel?.warn(`[Authentication] ${message}`), }; } diff --git a/src/documentdb/auth/entraErrors/EntraDiagnosticsProvider.test.ts b/src/documentdb/auth/entraErrors/EntraDiagnosticsProvider.test.ts index 9f95c6e6a..b61fe1057 100644 --- a/src/documentdb/auth/entraErrors/EntraDiagnosticsProvider.test.ts +++ b/src/documentdb/auth/entraErrors/EntraDiagnosticsProvider.test.ts @@ -9,6 +9,12 @@ import { Links } from '../../../constants'; import { EntraDiagnosticsProvider } from './EntraDiagnosticsProvider'; import { recordEntraTokenFailure, resetEntraTokenFailuresForTests } from './entraTokenFailures'; +const mockOutputChannel = vi.hoisted(() => ({ info: vi.fn() })); + +vi.mock('../../../extensionVariables', () => ({ + ext: { outputChannel: mockOutputChannel }, +})); + const telemetryContext = vi.hoisted(() => ({ errorHandling: { suppressDisplay: false, suppressReportIssue: false }, telemetry: { properties: {} as Record }, @@ -77,6 +83,13 @@ describe('EntraDiagnosticsProvider', () => { aadstsCode: '53003', brokered: 'true', }); + const trace = String(mockOutputChannel.info.mock.calls[0][0]); + expect(trace).toContain('entraSignIn.classified'); + expect(trace).toContain('"ruleId":"conditionalAccess"'); + expect(trace).toContain('"aadstsCode":"53003"'); + expect(trace).toContain('"cloudCategory":"unknown"'); + expect(trace).toContain('"recentTokenFailure":false'); + expect(trace).toContain('"brokered":true'); }); it('accepts a string cause relayed by a worker', async (): Promise => { diff --git a/src/documentdb/auth/entraErrors/EntraDiagnosticsProvider.ts b/src/documentdb/auth/entraErrors/EntraDiagnosticsProvider.ts index 842ee455b..1a21f44d2 100644 --- a/src/documentdb/auth/entraErrors/EntraDiagnosticsProvider.ts +++ b/src/documentdb/auth/entraErrors/EntraDiagnosticsProvider.ts @@ -11,6 +11,7 @@ import { import { classifyEntraError } from './classifyEntraError'; import { entraErrorRules } from './entraErrorRules'; import { getRecentEntraTokenFailure } from './entraTokenFailures'; +import { traceAuthFlow } from '../../../utils/authTrace'; export class EntraDiagnosticsProvider implements ConnectionDiagnosticsProvider { public readonly id = 'entra-sign-in'; @@ -29,6 +30,13 @@ export class EntraDiagnosticsProvider implements ConnectionDiagnosticsProvider { } const failure = getRecentEntraTokenFailure(clusterId); + traceAuthFlow('entraSignIn.classified', { + ruleId: classification.ruleId, + aadstsCode: classification.code ?? 'none', + cloudCategory: failure?.cloudCategory ?? 'unknown', + recentTokenFailure: failure !== undefined, + brokered: classification.brokered, + }); const message = rule.explain({ code: classification.code, cloudCategory: failure?.cloudCategory, diff --git a/src/documentdb/playground/PlaygroundEvaluator.ts b/src/documentdb/playground/PlaygroundEvaluator.ts index 26d66ce30..1ddf50058 100644 --- a/src/documentdb/playground/PlaygroundEvaluator.ts +++ b/src/documentdb/playground/PlaygroundEvaluator.ts @@ -14,10 +14,14 @@ import { randomUUID } from 'crypto'; import type * as vscode from 'vscode'; import { ext } from '../../extensionVariables'; import { meterSilentCatch } from '../../utils/accumulatingTelemetry'; -import { traceAuthOperation, type AuthTraceContext } from '../../utils/authTrace'; +import { traceAuthFlow, traceAuthOperation, type AuthTraceContext } from '../../utils/authTrace'; import { getBatchSizeSetting, getConnectionTimeoutMs } from '../../utils/workspacUtils'; import { AuthMethodId } from '../auth/AuthMethod'; -import { resolveInteractiveTokenResource, resolveManagedIdentityTokenResource } from '../auth/documentdbTokenResource'; +import { + resolveInteractiveTokenResource, + resolveManagedIdentityTokenResource, + TokenResourceResolutionError, +} from '../auth/documentdbTokenResource'; import { recordEntraTokenFailure } from '../auth/entraErrors/entraTokenFailures'; import { CredentialCache } from '../CredentialCache'; import { withTokenAcquisitionTiming, type ConnectionStartupTimings } from '../utils/ConnectionStartupTimings'; @@ -381,10 +385,19 @@ export class PlaygroundEvaluator implements vscode.Disposable { if (!clusterId || !credentials) { throw new Error(l10n.t('No credentials found for the query playground connection.')); } - const resolved = - msg.source === 'managedIdentity' - ? await resolveManagedIdentityTokenResource(credentials.connectionString) - : await resolveInteractiveTokenResource(credentials.connectionString); + let resolved: Awaited>; + try { + resolved = + msg.source === 'managedIdentity' + ? await resolveManagedIdentityTokenResource(credentials.connectionString) + : await resolveInteractiveTokenResource(credentials.connectionString); + traceAuthFlow('entraCloud.resolved', resolved.details, msg.requestId, traceContext); + } catch (error) { + if (error instanceof TokenResourceResolutionError) { + traceAuthFlow('entraCloud.unresolved', error.details, msg.requestId, traceContext); + } + throw error; + } let accessToken: string; if (msg.source === 'managedIdentity') { diff --git a/src/documentdb/shell/ShellSessionManager.test.ts b/src/documentdb/shell/ShellSessionManager.test.ts index 87f5d677f..e4cb78989 100644 --- a/src/documentdb/shell/ShellSessionManager.test.ts +++ b/src/documentdb/shell/ShellSessionManager.test.ts @@ -101,6 +101,10 @@ describe('ShellSessionManager', () => { scope: 'https://ossrdbms-aad.database.windows.net/.default', cloudCategory: 'public', reason: 'setting', + details: { + flow: 'interactive', + tokenResource: 'https://ossrdbms-aad.database.windows.net', + }, } as const; vi.mocked(resolveInteractiveTokenResource).mockResolvedValue(publicResolution); vi.mocked(resolveManagedIdentityTokenResource).mockResolvedValue({ ...publicResolution, reason: 'host' }); @@ -255,6 +259,10 @@ describe('ShellSessionManager', () => { scope: 'https://ossrdbms-aad.database.usgovcloudapi.net/.default', cloudCategory: 'usgov', reason: source === 'vscode' ? 'setting' : 'host', + details: { + flow: source === 'vscode' ? 'interactive' : 'managedIdentity', + tokenResource: 'https://ossrdbms-aad.database.usgovcloudapi.net', + }, }); const provider = source === 'vscode' ? mockGetSessionFromVSCode : mockGetManagedIdentityAccessToken; provider.mockResolvedValueOnce({ accessToken: 'private-token', account: { label: 'private-account' } }); @@ -290,6 +298,10 @@ describe('ShellSessionManager', () => { const error = new TokenResourceResolutionError( 'Select the cloud of the cluster.', source === 'vscode' ? 'cloudMismatch' : 'managedIdentityCloudUnknown', + { + flow: source === 'vscode' ? 'interactive' : 'managedIdentity', + errorKind: source === 'vscode' ? 'cloudMismatch' : 'managedIdentityCloudUnknown', + }, ); const resolver = source === 'vscode' ? resolveInteractiveTokenResource : resolveManagedIdentityTokenResource; vi.mocked(resolver).mockRejectedValueOnce(error); @@ -326,6 +338,10 @@ describe('ShellSessionManager', () => { scope: 'https://ossrdbms-aad.database.chinacloudapi.cn/.default', cloudCategory: 'china', reason: source === 'vscode' ? 'setting' : 'host', + details: { + flow: source === 'vscode' ? 'interactive' : 'managedIdentity', + tokenResource: 'https://ossrdbms-aad.database.chinacloudapi.cn', + }, }); const error = Object.freeze( Object.assign(new Error('AADSTS65002: Token failed'), { code: 'original-code' }), diff --git a/src/documentdb/shell/ShellSessionManager.ts b/src/documentdb/shell/ShellSessionManager.ts index 1b86b8fde..e1b2e1a57 100644 --- a/src/documentdb/shell/ShellSessionManager.ts +++ b/src/documentdb/shell/ShellSessionManager.ts @@ -7,11 +7,15 @@ import * as l10n from '@vscode/l10n'; import { randomUUID } from 'crypto'; import type * as vscode from 'vscode'; import { ext } from '../../extensionVariables'; -import { traceAuthOperation, type AuthTraceContext } from '../../utils/authTrace'; +import { traceAuthFlow, traceAuthOperation, type AuthTraceContext } from '../../utils/authTrace'; import { getBatchSizeSetting, getConnectionTimeoutMs } from '../../utils/workspacUtils'; import { CredentialCache } from '../CredentialCache'; import { AuthMethodId } from '../auth/AuthMethod'; -import { resolveInteractiveTokenResource, resolveManagedIdentityTokenResource } from '../auth/documentdbTokenResource'; +import { + resolveInteractiveTokenResource, + resolveManagedIdentityTokenResource, + TokenResourceResolutionError, +} from '../auth/documentdbTokenResource'; import { recordEntraTokenFailure } from '../auth/entraErrors/entraTokenFailures'; import { WorkerSessionManager, @@ -364,10 +368,19 @@ export class ShellSessionManager implements vscode.Disposable { if (!credentials) { throw new Error(l10n.t('No credentials found for cluster {0}', this._connectionInfo.clusterId)); } - const resolved = - msg.source === 'managedIdentity' - ? await resolveManagedIdentityTokenResource(credentials.connectionString) - : await resolveInteractiveTokenResource(credentials.connectionString); + let resolved: Awaited>; + try { + resolved = + msg.source === 'managedIdentity' + ? await resolveManagedIdentityTokenResource(credentials.connectionString) + : await resolveInteractiveTokenResource(credentials.connectionString); + traceAuthFlow('entraCloud.resolved', resolved.details, msg.requestId, traceContext); + } catch (error) { + if (error instanceof TokenResourceResolutionError) { + traceAuthFlow('entraCloud.unresolved', error.details, msg.requestId, traceContext); + } + throw error; + } let accessToken: string; if (msg.source === 'managedIdentity') { diff --git a/src/utils/authTrace.test.ts b/src/utils/authTrace.test.ts new file mode 100644 index 000000000..2fd122c6e --- /dev/null +++ b/src/utils/authTrace.test.ts @@ -0,0 +1,21 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { describe, expect, it } from 'vitest'; +import { describeAuthError } from './authTrace'; + +describe('describeAuthError', () => { + it('labels platform broker errors without exposing the raw message', (): void => { + expect(describeAuthError(new Error('platform_broker_error: private sign-in failure'))).toBe( + 'type=Error; code=unknown; httpStatus=unknown; aadsts=none; broker=platform_broker_error', + ); + }); + + it('labels other errors as not brokered', (): void => { + expect(describeAuthError(new Error('private sign-in failure'))).toBe( + 'type=Error; code=unknown; httpStatus=unknown; aadsts=none; broker=none', + ); + }); +}); diff --git a/src/utils/authTrace.ts b/src/utils/authTrace.ts index 36816873c..b80185c2d 100644 --- a/src/utils/authTrace.ts +++ b/src/utils/authTrace.ts @@ -61,7 +61,8 @@ export function describeAuthError(error: unknown): string { ? statusCode : 'unknown'; const aadstsCode = error instanceof Error ? /\bAADSTS\d{5,9}\b/.exec(error.message)?.[0] : undefined; - return `type=${errorName}; code=${code}; httpStatus=${httpStatus}; aadsts=${aadstsCode ?? 'none'}`; + const broker = error instanceof Error && error.message.includes('platform_broker_error') ? 'platform_broker_error' : 'none'; + return `type=${errorName}; code=${code}; httpStatus=${httpStatus}; aadsts=${aadstsCode ?? 'none'}; broker=${broker}`; } type AuthTraceDetails = Readonly>; From 66812b32634511cce399806340a51c95c34fadbc Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Fri, 23 Oct 2026 17:28:46 +0000 Subject: [PATCH 21/50] docs(sovereign-clouds): record support tracing Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../01-cloud-aware-scope-and-entra-diagnostics.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics.md b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics.md index 06bcc4f0d..fcff64434 100644 --- a/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics.md +++ b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics.md @@ -256,6 +256,18 @@ The auth method comes from the recent token-failure registry, not from the error --- +# Addition: support tracing (operator request) + +**Done** in `17a95f66`. Every Entra connection, including the public cloud, writes +`entraCloud.resolved` (or `entraCloud.unresolved`) to the DocumentDB output: flow, configured cloud, +sign-in provider, broker mode, cloud implied by the host, whether the custom settings are set, ARM +and IMDS lookup outcomes, and the chosen token resource and reason. The OIDC traces add the +`ALLOWED_HOSTS` patterns, auth error summaries add `broker=platform_broker_error|none`, and the +classifier writes `entraSignIn.classified`. No tenant, client, account, host, or connection-string +values are logged, consistent with the managed identity tracing rules. + +--- + # Before shipping - D11: check telemetry for successful Entra connections to `*.azure.cn` hosts. From 4c4f8714ec714581e9a24c772766aa43f0b8282a Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Fri, 23 Oct 2026 22:03:44 +0000 Subject: [PATCH 22/50] chore(l10n): regenerate bundle for sovereign cloud strings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- l10n/bundle.l10n.json | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index b121ad864..aee1943d7 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -25,6 +25,7 @@ "\"{0}\" is on the network share \"\\\\{1}\", which the editor cannot read directly.": "\"{0}\" is on the network share \"\\\\{1}\", which the editor cannot read directly.", "\"{0}\" uses the unsupported URI scheme \"{1}\".": "\"{0}\" uses the unsupported URI scheme \"{1}\".", "\"{service}\" in namespace \"{namespace}\"": "\"{service}\" in namespace \"{namespace}\"", + "\"{setting}\" is the public-cloud value, but VS Code is signed in to a custom cloud.": "\"{setting}\" is the public-cloud value, but VS Code is signed in to a custom cloud.", "\"{user}\" signs in with {method}.": "\"{user}\" signs in with {method}.", "\"mongodb://\" or \"mongodb+srv://\" must be the prefix of the connection string.": "\"mongodb://\" or \"mongodb+srv://\" must be the prefix of the connection string.", "\"registerAzureUtilsExtensionVariables\" must be called before using the vscode-azext-azureutils package.": "\"registerAzureUtilsExtensionVariables\" must be called before using the vscode-azext-azureutils package.", @@ -284,6 +285,7 @@ "A connection to the same host with the same authentication settings already exists.": "A connection to the same host with the same authentication settings already exists.", "A connection with the same username and host already exists.": "A connection with the same username and host already exists.", "A connection with this name already exists at this level.": "A connection with this name already exists at this level.", + "a custom cloud": "a custom cloud", "A default kubeconfig source already exists.": "A default kubeconfig source already exists.", "A DocumentDB deep-link was opened without a connection string. Ensure the link includes a connectionString query parameter.": "A DocumentDB deep-link was opened without a connection string. Ensure the link includes a connectionString query parameter.", "A folder with this name already exists at this level": "A folder with this name already exists at this level", @@ -406,12 +408,14 @@ "Authentication: {0} | Database: {1}": "Authentication: {0} | Database: {1}", "Auto-deletes documents after a set age.": "Auto-deletes documents after a set age.", "Availability": "Availability", + "Azure (public cloud)": "Azure (public cloud)", "Azure account added successfully.": "Azure account added successfully.", "Azure account management failed: {0}": "Azure account management failed: {0}", "Azure account management was cancelled by user.": "Azure account management was cancelled by user.", "Azure account management wizard completed.": "Azure account management wizard completed.", "Azure accounts used for service discovery": "Azure accounts used for service discovery", "Azure Activity": "Azure Activity", + "Azure China": "Azure China", "Azure Cosmos DB for MongoDB (RU)": "Azure Cosmos DB for MongoDB (RU)", "Azure Cosmos DB for MongoDB (RU) Emulator": "Azure Cosmos DB for MongoDB (RU) Emulator", "Azure discovery filters applied successfully.": "Azure discovery filters applied successfully.", @@ -425,6 +429,7 @@ "Azure sign-in failed: {0}": "Azure sign-in failed: {0}", "Azure sign-in was cancelled or failed": "Azure sign-in was cancelled or failed", "Azure tenant lookup: account management is using cached results for {0} accounts.": "Azure tenant lookup: account management is using cached results for {0} accounts.", + "Azure US Government": "Azure US Government", "Azure VM Service Discovery": "Azure VM Service Discovery", "Azure VM: Attempting to authenticate with \"{vmName}\"…": "Azure VM: Attempting to authenticate with \"{vmName}\"…", "Azure VM: Connected to \"{vmName}\" as \"{username}\".": "Azure VM: Connected to \"{vmName}\" as \"{username}\".", @@ -1490,6 +1495,9 @@ "Microsoft Entra ID": "Microsoft Entra ID", "Microsoft Entra ID (Account)": "Microsoft Entra ID (Account)", "Microsoft Entra ID (Managed Identity)": "Microsoft Entra ID (Managed Identity)", + "Microsoft Entra ID appears to require additional verification. Sign in again and complete the verification requested by your organization. If you cannot complete it, contact your IT help desk. See {link}": "Microsoft Entra ID appears to require additional verification. Sign in again and complete the verification requested by your organization. If you cannot complete it, contact your IT help desk. See {link}", + "Microsoft Entra ID rejected the sign-in (AADSTS{code}). See https://login.microsoftonline.com/error?code={code} for details and ask your tenant administrator or IT help desk for help. Report this on GitHub if it persists. See {link}": "Microsoft Entra ID rejected the sign-in (AADSTS{code}). See https://login.microsoftonline.com/error?code={code} for details and ask your tenant administrator or IT help desk for help. Report this on GitHub if it persists. See {link}", + "Microsoft Entra ID sign-in for Azure DocumentDB appears to require Microsoft to preauthorize VS Code. Only Microsoft can change this, not you or your tenant administrator. Use native DocumentDB authentication or managed identity on an Azure VM as a workaround, and report this on GitHub. See {link}": "Microsoft Entra ID sign-in for Azure DocumentDB appears to require Microsoft to preauthorize VS Code. Only Microsoft can change this, not you or your tenant administrator. Use native DocumentDB authentication or managed identity on an Azure VM as a workaround, and report this on GitHub. See {link}", "Migration Providers: {0}": "Migration Providers: {0}", "Mode: {0}": "Mode: {0}", "Moderate efficiency ratio": "Moderate efficiency ratio", @@ -1571,6 +1579,7 @@ "No credentials found for cluster \"{0}\"": "No credentials found for cluster \"{0}\"", "No credentials found for cluster {0}": "No credentials found for cluster {0}", "No credentials found for id {clusterId}": "No credentials found for id {clusterId}", + "No credentials found for the query playground connection.": "No credentials found for the query playground connection.", "No credentials found for the selected cluster.": "No credentials found for the selected cluster.", "No databases in this cluster": "No databases in this cluster", "no Docker CLI found": "no Docker CLI found", @@ -2107,6 +2116,8 @@ "Sign out of every MongoDB Atlas credential?": "Sign out of every MongoDB Atlas credential?", "Sign out of the MongoDB Atlas credential \"{0}\"?": "Sign out of the MongoDB Atlas credential \"{0}\"?", "Sign out of this credential only. The others stay signed in.": "Sign out of this credential only. The others stay signed in.", + "Sign-in appears to be blocked because the account is from another Azure cloud. Sign in with an account from the cluster's cloud, or ask the organizations' administrators to check their cross-cloud access settings. See {link}": "Sign-in appears to be blocked because the account is from another Azure cloud. Sign in with an account from the cluster's cloud, or ask the organizations' administrators to check their cross-cloud access settings. See {link}", + "Sign-in failed in {cloud}. If the sign-in window showed AADSTS65002 (\"must be configured via preauthorization\"), Microsoft Entra ID sign-in for Azure DocumentDB is not enabled yet for VS Code in this cloud. Only Microsoft can change this, not you or your tenant administrator. Use native DocumentDB authentication or managed identity on an Azure VM as a workaround, and report this on GitHub. See {link}": "Sign-in failed in {cloud}. If the sign-in window showed AADSTS65002 (\"must be configured via preauthorization\"), Microsoft Entra ID sign-in for Azure DocumentDB is not enabled yet for VS Code in this cloud. Only Microsoft can change this, not you or your tenant administrator. Use native DocumentDB authentication or managed identity on an Azure VM as a workaround, and report this on GitHub. See {link}", "Sign-in to tenant was cancelled or failed: {0}": "Sign-in to tenant was cancelled or failed: {0}", "Signed in to tenant \"{0}\"": "Signed in to tenant \"{0}\"", "Signed out of the MongoDB Atlas credential \"{0}\".": "Signed out of the MongoDB Atlas credential \"{0}\".", @@ -2362,13 +2373,16 @@ "The port-forward tunnel to {target} looks active, but the service did not answer. The pod behind it may have restarted or been rescheduled.": "The port-forward tunnel to {target} looks active, but the service did not answer. The pod behind it may have restarted or been rescheduled.", "The query returned no documents.\n\nNo document fetching was needed because no documents matched the filter criteria.": "The query returned no documents.\n\nNo document fetching was needed because no documents matched the filter criteria.", "The requested managed identity is not assigned to this machine.": "The requested managed identity is not assigned to this machine.", + "The requested Microsoft Entra sign-in resource does not appear to be valid for this cloud. If \"documentDB.customEnvironment.ossrdbmsEndpoint\" is set, check its value with your cloud administrator. Otherwise, report this on GitHub so the extension maintainers can investigate. See {link}": "The requested Microsoft Entra sign-in resource does not appear to be valid for this cloud. If \"documentDB.customEnvironment.ossrdbmsEndpoint\" is set, check its value with your cloud administrator. Otherwise, report this on GitHub so the extension maintainers can investigate. See {link}", "The selected {0} index is not supported by Copy/Paste Indexes.": "The selected {0} index is not supported by Copy/Paste Indexes.", "The selected authentication method is not supported.": "The selected authentication method is not supported.", + "The selected Azure cloud very likely does not match the cluster. Check the \"microsoft-sovereign-cloud.environment\" setting. If it already matches, report this on GitHub or ask your tenant administrator to check whether the sign-in resource is available in your tenant. See {link}": "The selected Azure cloud very likely does not match the cluster. Check the \"microsoft-sovereign-cloud.environment\" setting. If it already matches, report this on GitHub or ask your tenant administrator to check whether the sign-in resource is available in your tenant. See {link}", "The selected connection has been removed.": "The selected connection has been removed.", "The selected folder has been removed.": "The selected folder has been removed.", "The selected item is not a database.": "The selected item is not a database.", "The selected paths, and every field nested under them, are excluded. All other fields are indexed.": "The selected paths, and every field nested under them, are excluded. All other fields are indexed.", "The server hostname could not be resolved. The cluster may have been deleted or the URL may be incorrect.": "The server hostname could not be resolved. The cluster may have been deleted or the URL may be incorrect.", + "The sign-in application could not be found in the directory, usually because the wrong Azure cloud or tenant is selected. Check \"microsoft-sovereign-cloud.environment\" and use Update Credentials to choose the cluster's tenant. If both are correct, ask your tenant administrator or report this on GitHub. See {link}": "The sign-in application could not be found in the directory, usually because the wrong Azure cloud or tenant is selected. Check \"microsoft-sovereign-cloud.environment\" and use Update Credentials to choose the cluster's tenant. If both are correct, ask your tenant administrator or report this on GitHub. See {link}", "The SORT stage exceeded the {0}MB memory limit.\n\n**Solutions:**\n1. Add .allowDiskUse(true) to allow disk-based sorting for large result sets\n2. Create an index matching the sort pattern: {1}\n3. Add filters to reduce the number of documents being sorted\n4. Increase server memory limit (requires server configuration)": "The SORT stage exceeded the {0}MB memory limit.\n\n**Solutions:**\n1. Add .allowDiskUse(true) to allow disk-based sorting for large result sets\n2. Create an index matching the sort pattern: {1}\n3. Add filters to reduce the number of documents being sorted\n4. Increase server memory limit (requires server configuration)", "The source cluster is no longer connected. Please reconnect and copy the collection again.": "The source cluster is no longer connected. Please reconnect and copy the collection again.", "The source collection \"{0}\" no longer exists in database \"{1}\". It may have been deleted or renamed.": "The source collection \"{0}\" no longer exists in database \"{1}\". It may have been deleted or renamed.", @@ -2380,11 +2394,13 @@ "The stored credential was rejected. Update it, then try again.": "The stored credential was rejected. Update it, then try again.", "The survey is in English.": "The survey is in English.", "The tag cannot be empty.": "The tag cannot be empty.", + "The tenant does not appear to exist in the selected Azure cloud. Check \"microsoft-sovereign-cloud.environment\" and use Update Credentials to select the right tenant. Your organization's administrator can confirm which cloud and tenant to use. See {link}": "The tenant does not appear to exist in the selected Azure cloud. Check \"microsoft-sovereign-cloud.environment\" and use Update Credentials to select the right tenant. Your organization's administrator can confirm which cloud and tenant to use. See {link}", "The username “{0}” is reserved. Pick a different one.": "The username “{0}” is reserved. Pick a different one.", "The value must be {0} characters long.": "The value must be {0} characters long.", "The value must be {0} characters or greater.": "The value must be {0} characters or greater.", "The value must be {0} characters or less.": "The value must be {0} characters or less.", "The value must be between {0} and {1} characters long.": "The value must be between {0} and {1} characters long.", + "The Windows sign-in window reported an error without details. Check the error code shown in that window, or open Developer: Show Logs... > {authenticationLog} in VS Code. Your IT help desk can help interpret the code; if the problem persists, report it on GitHub. See {link}": "The Windows sign-in window reported an error without details. Check the error code shown in that window, or open Developer: Show Logs... > {authenticationLog} in VS Code. Your IT help desk can help interpret the code; if the problem persists, report it on GitHub. See {link}", "The worker for this cluster is busy executing a playground": "The worker for this cluster is busy executing a playground", "There are no copyable secondary indexes in the source collection.": "There are no copyable secondary indexes in the source collection.", "There is no DocumentDB Local container to follow. Run \"Set up DocumentDB Local\" to create one.": "There is no DocumentDB Local container to follow. Run \"Set up DocumentDB Local\" to create one.", @@ -2394,6 +2410,7 @@ "These defaults work for most people. Change them only if you need to.": "These defaults work for most people. Change them only if you need to.", "They look like binary files rather than kubeconfigs.": "They look like binary files rather than kubeconfigs.", "This {0} index cannot be copied by Copy/Paste Indexes.": "This {0} index cannot be copied by Copy/Paste Indexes.", + "This account does not appear to be a member or guest of the organization that owns the cluster. Use Update Credentials to choose another account or tenant, or ask that organization's administrator to grant access. See {link}": "This account does not appear to be a member or guest of the organization that owns the cluster. Use Update Credentials to choose another account or tenant, or ask that organization's administrator to grant access. See {link}", "This action works through the tree view, and the cluster \"{cluster}\" could not be found there. Make sure the connection is still listed in the tree view, then try again.": "This action works through the tree view, and the cluster \"{cluster}\" could not be found there. Make sure the connection is still listed in the tree view, then try again.", "This action works through the tree view, and the collection \"{collection}\" is not loaded there yet. Expand \"{cluster}\" and then \"{database}\" in the tree view, then try again.": "This action works through the tree view, and the collection \"{collection}\" is not loaded there yet. Expand \"{cluster}\" and then \"{database}\" in the tree view, then try again.", "This action works through the tree view, and the database \"{database}\" is not loaded there yet. Expand the cluster \"{cluster}\" in the tree view, then try again.": "This action works through the tree view, and the database \"{database}\" is not loaded there yet. Expand the cluster \"{cluster}\" in the tree view, then try again.", @@ -2408,6 +2425,7 @@ "This cluster is being repaired. It may be temporarily unavailable.": "This cluster is being repaired. It may be temporarily unavailable.", "This cluster is being updated. It is visible here to match the discovery tree, but it is not connectable until the update completes and the cluster returns to IDLE.": "This cluster is being updated. It is visible here to match the discovery tree, but it is not connectable until the update completes and the cluster returns to IDLE.", "This cluster is being updated. It may be temporarily unavailable.": "This cluster is being updated. It may be temporarily unavailable.", + "This cluster is in {clusterCloud}, but VS Code is signed in to {signedInCloud}. Change the \"{setting}\" setting to the cloud of the cluster, then connect again.": "This cluster is in {clusterCloud}, but VS Code is signed in to {signedInCloud}. Change the \"{setting}\" setting to the cloud of the cluster, then connect again.", "This cluster is in an unknown state. Try refreshing to update its status before connecting from the wizard.": "This cluster is in an unknown state. Try refreshing to update its status before connecting from the wizard.", "This cluster is in an unknown state. Try refreshing to update its status.": "This cluster is in an unknown state. Try refreshing to update its status.", "This cluster is not connectable from the wizard right now.": "This cluster is not connectable from the wizard right now.", @@ -2633,6 +2651,7 @@ "Visible in the tree, but not connectable until the cluster returns to IDLE.": "Visible in the tree, but not connectable until the cluster returns to IDLE.", "Visit the [documentation]({0}) for more information about TLS/SSL certificates.": "Visit the [documentation]({0}) for more information about TLS/SSL certificates.", "VS Code connects through the Kubernetes PortForward API. Connection strings using 127.0.0.1 only work on this machine while the tunnel is active.": "VS Code connects through the Kubernetes PortForward API. Connection strings using 127.0.0.1 only work on this machine while the tunnel is active.", + "VS Code is signed in to a custom cloud, and we could not find which Microsoft Entra resource to request a token for. Set \"{setting}\" to the open-source database resource of your cloud, usually \"https://ossrdbms-aad.database.\". Your cloud operator or Azure administrator can tell you the exact value.": "VS Code is signed in to a custom cloud, and we could not find which Microsoft Entra resource to request a token for. Set \"{setting}\" to the open-source database resource of your cloud, usually \"https://ossrdbms-aad.database.\". Your cloud operator or Azure administrator can tell you the exact value.", "VS Code: v{0}": "VS Code: v{0}", "Wait longer": "Wait longer", "Waiting {0}": "Waiting {0}", @@ -2652,6 +2671,7 @@ "We check your credentials with MongoDB Atlas before saving your connection.": "We check your credentials with MongoDB Atlas before saving your connection.", "We could not complete the DocumentDB Local operation: {0}. View the setup log for details.": "We could not complete the DocumentDB Local operation: {0}. View the setup log for details.", "We could not delete DocumentDB Local: {0}. View the setup log for details.": "We could not delete DocumentDB Local: {0}. View the setup log for details.", + "We could not determine which Azure cloud this virtual machine is in, so we do not know which Microsoft Entra resource to request a token for. Set \"{setting}\" to the open-source database resource of your cloud, for example \"{example}\".": "We could not determine which Azure cloud this virtual machine is in, so we do not know which Microsoft Entra resource to request a token for. Set \"{setting}\" to the open-source database resource of your cloud, for example \"{example}\".", "We could not remove the existing data volume. Check whether another container is using it, then try again. Docker reported: {0}": "We could not remove the existing data volume. Check whether another container is using it, then try again. Docker reported: {0}", "We could not restart DocumentDB Local: {0}. View the setup log for details.": "We could not restart DocumentDB Local: {0}. View the setup log for details.", "We could not sign in because this password contains unsupported characters. Go back to Configure and choose a different password.": "We could not sign in because this password contains unsupported characters. Go back to Configure and choose a different password.", @@ -2747,6 +2767,7 @@ "Your default kubeconfig ({0}) could not be loaded: {1}. Fix the kubeconfig and try again.": "Your default kubeconfig ({0}) could not be loaded: {1}. Fix the kubeconfig and try again.", "Your Docker group change requires a new WSL session. Run this command in a Windows terminal. This VS Code window will disconnect. Reconnect to WSL, then open DocumentDB Local setup again.": "Your Docker group change requires a new WSL session. Run this command in a Windows terminal. This VS Code window will disconnect. Reconnect to WSL, then open DocumentDB Local setup again.", "Your local connections have been moved to '{0}' in the Connections view.": "Your local connections have been moved to '{0}' in the Connections view.", + "Your organization's policy appears to have blocked this sign-in. Contact your IT help desk with code AADSTS53003 and the time of the failure so they can check the sign-in policy. See {link}": "Your organization's policy appears to have blocked this sign-in. Contact your IT help desk with code AADSTS53003 and the time of the failure so they can check the sign-in policy. See {link}", "Your own DocumentDB": "Your own DocumentDB", "Your own username and password": "Your own username and password", "Your query does not require sorting, which avoids additional processing overhead.": "Your query does not require sorting, which avoids additional processing overhead.", From 4f0fd24a5a69ce037e8191dc406bdb456610634c Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Fri, 23 Oct 2026 22:07:09 +0000 Subject: [PATCH 23/50] fix(lint): use public azureauth exports and type imports Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/documentdb/auth/MicrosoftEntraIDAuthHandler.test.ts | 3 ++- src/documentdb/auth/documentdbTokenResource.ts | 3 +-- src/documentdb/shell/ShellSessionManager.test.ts | 3 ++- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/documentdb/auth/MicrosoftEntraIDAuthHandler.test.ts b/src/documentdb/auth/MicrosoftEntraIDAuthHandler.test.ts index b1b9928a0..257d5d301 100644 --- a/src/documentdb/auth/MicrosoftEntraIDAuthHandler.test.ts +++ b/src/documentdb/auth/MicrosoftEntraIDAuthHandler.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as vscode from 'vscode'; +import type * as documentdbTokenResource from './documentdbTokenResource'; const getSessionFromVSCode = vi.fn(); const mockOutputChannel = { info: vi.fn(), error: vi.fn() }; @@ -21,7 +22,7 @@ vi.mock('@microsoft/vscode-azext-azureauth/out/src/getSessionFromVSCode', () => })); vi.mock('./documentdbTokenResource', async (importOriginal) => ({ - ...(await importOriginal()), + ...(await importOriginal()), resolveInteractiveTokenResource: vi.fn(), })); diff --git a/src/documentdb/auth/documentdbTokenResource.ts b/src/documentdb/auth/documentdbTokenResource.ts index 7a0055132..c9325357c 100644 --- a/src/documentdb/auth/documentdbTokenResource.ts +++ b/src/documentdb/auth/documentdbTokenResource.ts @@ -3,11 +3,10 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -// eslint-disable-next-line import/no-internal-modules import { getConfiguredAuthProviderId, getConfiguredAzureEnv, -} from '@microsoft/vscode-azext-azureauth/out/src/utils/configuredAzureEnv'; +} from '@microsoft/vscode-azext-azureauth'; import * as l10n from '@vscode/l10n'; import * as http from 'http'; import * as vscode from 'vscode'; diff --git a/src/documentdb/shell/ShellSessionManager.test.ts b/src/documentdb/shell/ShellSessionManager.test.ts index e4cb78989..13c46226b 100644 --- a/src/documentdb/shell/ShellSessionManager.test.ts +++ b/src/documentdb/shell/ShellSessionManager.test.ts @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { type AuthenticationSession } from 'vscode'; +import type * as documentdbTokenResource from '../auth/documentdbTokenResource'; import { ext } from '../../extensionVariables'; import { AuthMethodId } from '../auth/AuthMethod'; import { @@ -47,7 +48,7 @@ vi.mock('../auth/managedIdentityTokenProvider', () => ({ })); vi.mock('../auth/documentdbTokenResource', async (importOriginal) => ({ - ...(await importOriginal()), + ...(await importOriginal()), resolveInteractiveTokenResource: vi.fn(), resolveManagedIdentityTokenResource: vi.fn(), })); From e98e2f40b686c702143ed30b0664624c2ffabeaa Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Fri, 23 Oct 2026 22:08:08 +0000 Subject: [PATCH 24/50] style: format sovereign cloud changes Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- docs/ai-and-plans/README.md | 54 +++++------ .../features/sovereign-clouds/README.md | 10 +- .../copyConnectionString.test.ts | 2 +- .../copyConnectionString.ts | 4 +- src/constants.ts | 3 +- .../auth/ManagedIdentityAuthHandler.test.ts | 12 ++- .../auth/documentdbTokenResource.test.ts | 4 +- .../auth/documentdbTokenResource.ts | 10 +- .../EntraDiagnosticsProvider.test.ts | 23 +++-- .../entraErrors/EntraDiagnosticsProvider.ts | 2 +- .../entraErrors/classifyEntraError.test.ts | 19 +++- .../auth/entraErrors/entraErrorRules.ts | 6 +- .../auth/entraErrors/entraTokenFailures.ts | 5 +- .../shell/ShellSessionManager.test.ts | 91 ++++++++++--------- .../utils/connection-string-parameters.md | 10 +- src/utils/authTrace.ts | 3 +- 16 files changed, 140 insertions(+), 118 deletions(-) diff --git a/docs/ai-and-plans/README.md b/docs/ai-and-plans/README.md index 5c14cca7d..f6ef96779 100644 --- a/docs/ai-and-plans/README.md +++ b/docs/ai-and-plans/README.md @@ -22,33 +22,33 @@ treat `status: historical` or `status: superseded` as current. ## Features -| Feature | What it covers | Status | -| ------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | ----------------------------------- | -| [local-quickstart](./features/local-quickstart/README.md) | Run DocumentDB locally in one click: Docker provisioning, readiness, lifecycle | shipped 0.10.0 | -| [query-insights](./features/query-insights/README.md) | Query performance rating, static analysis, and streaming AI recommendations | shipped | -| [interactive-shell](./features/interactive-shell/README.md) | The in-terminal REPL, its runtime, completions, and highlighting | shipped | -| [query-playground](./features/query-playground/README.md) | `.documentdb` files, persistent worker evaluation, per-document connections | shipped | -| [completions-and-schema](./features/completions-and-schema/README.md) | Operator registry, schema analyzer, and the shared completion layer | shipped | -| [index-management](./features/index-management/README.md) | The Indexes tab, the create drawer, and the Collection View chrome redesign | shipped | -| [atlas-discovery](./features/atlas-discovery/README.md) | MongoDB Atlas discovery: Admin API auth, discovery tree, credentials | shipped | -| [kubernetes-discovery](./features/kubernetes-discovery/README.md) | Kubernetes service discovery and connect-time reachability providers | shipped | -| [webview-ext-package](./features/webview-ext-package/README.md) | `@microsoft/vscode-ext-webview`: the extracted webview transport package | published preview | -| [webview-fluentui-package](./features/webview-fluentui-package/README.md) | `@microsoft/vscode-ext-webview-fluentui`: Fluent theming and reusable components for webviews | 1.1.1 prepared | -| [connections-tree](./features/connections-tree/README.md) | The Connections view: node item counts, connection load performance | shipped | -| [no-auth](./features/no-auth/README.md) | Credential-free connections and connection-string TLS handling | shipped | -| [managed-identities](./features/managed-identities/README.md) | Explicit managed identity authentication from Azure VMs | PR #886 | -| [copy-paste-collections](./features/copy-paste-collections/README.md) | Collection copy/paste tasks, document streaming, conflict handling, and optional index copying | active | -| [cluster-dashboard](./features/cluster-dashboard/README.md) | Cluster-wide inventory and resilience facts | POC, unmerged | -| [settings](./features/settings/README.md) | Settings editor grouping, setting ID naming, and how a setting is renamed safely | active | -| [welcome](./features/welcome/README.md) | Welcome walkthrough onboarding and quiet next-session deferral for upgraded installations | active | -| [escaping](./features/escaping/README.md) | Safe rendering of user/database content and least-privilege Markdown tooltips | active | -| [srv-dns-fallback](./features/srv-dns-fallback/README.md) | In-memory SRV fallback for `mongodb+srv://` when c-ares rejects compressed SRV replies (#993) | implemented (manual checks pending) | -| [build-and-test-stack](./features/build-and-test-stack/README.md) | Vite host and webview builds, Vitest, packaged checks, CI tiers, and modernization history | active | -| [e2e-testing](./features/e2e-testing/README.md) | The planned installed-workbench and webview end-to-end test iteration | planned | -| [data-grid](./features/data-grid/README.md) | The Collection View table and tree, including the planned SlickGrid replacement | planned | -| [survey](./features/survey/README.md) | Survey refresh: simpler eligibility, broader activity coverage, cohort telemetry, and invitation surface | draft #992; enabled | -| [docs-site](./features/docs-site/README.md) | The GitHub Pages documentation site: Just the Docs theme, OS dark mode, local preview | draft #420 | -| [pre-release-polish](./features/pre-release-polish/README.md) | Cross-feature visual and documentation refinements prepared for the 1.0 release | active | +| Feature | What it covers | Status | +| ------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- | ------------------------------------- | +| [local-quickstart](./features/local-quickstart/README.md) | Run DocumentDB locally in one click: Docker provisioning, readiness, lifecycle | shipped 0.10.0 | +| [query-insights](./features/query-insights/README.md) | Query performance rating, static analysis, and streaming AI recommendations | shipped | +| [interactive-shell](./features/interactive-shell/README.md) | The in-terminal REPL, its runtime, completions, and highlighting | shipped | +| [query-playground](./features/query-playground/README.md) | `.documentdb` files, persistent worker evaluation, per-document connections | shipped | +| [completions-and-schema](./features/completions-and-schema/README.md) | Operator registry, schema analyzer, and the shared completion layer | shipped | +| [index-management](./features/index-management/README.md) | The Indexes tab, the create drawer, and the Collection View chrome redesign | shipped | +| [atlas-discovery](./features/atlas-discovery/README.md) | MongoDB Atlas discovery: Admin API auth, discovery tree, credentials | shipped | +| [kubernetes-discovery](./features/kubernetes-discovery/README.md) | Kubernetes service discovery and connect-time reachability providers | shipped | +| [webview-ext-package](./features/webview-ext-package/README.md) | `@microsoft/vscode-ext-webview`: the extracted webview transport package | published preview | +| [webview-fluentui-package](./features/webview-fluentui-package/README.md) | `@microsoft/vscode-ext-webview-fluentui`: Fluent theming and reusable components for webviews | 1.1.1 prepared | +| [connections-tree](./features/connections-tree/README.md) | The Connections view: node item counts, connection load performance | shipped | +| [no-auth](./features/no-auth/README.md) | Credential-free connections and connection-string TLS handling | shipped | +| [managed-identities](./features/managed-identities/README.md) | Explicit managed identity authentication from Azure VMs | PR #886 | +| [copy-paste-collections](./features/copy-paste-collections/README.md) | Collection copy/paste tasks, document streaming, conflict handling, and optional index copying | active | +| [cluster-dashboard](./features/cluster-dashboard/README.md) | Cluster-wide inventory and resilience facts | POC, unmerged | +| [settings](./features/settings/README.md) | Settings editor grouping, setting ID naming, and how a setting is renamed safely | active | +| [welcome](./features/welcome/README.md) | Welcome walkthrough onboarding and quiet next-session deferral for upgraded installations | active | +| [escaping](./features/escaping/README.md) | Safe rendering of user/database content and least-privilege Markdown tooltips | active | +| [srv-dns-fallback](./features/srv-dns-fallback/README.md) | In-memory SRV fallback for `mongodb+srv://` when c-ares rejects compressed SRV replies (#993) | implemented (manual checks pending) | +| [build-and-test-stack](./features/build-and-test-stack/README.md) | Vite host and webview builds, Vitest, packaged checks, CI tiers, and modernization history | active | +| [e2e-testing](./features/e2e-testing/README.md) | The planned installed-workbench and webview end-to-end test iteration | planned | +| [data-grid](./features/data-grid/README.md) | The Collection View table and tree, including the planned SlickGrid replacement | planned | +| [survey](./features/survey/README.md) | Survey refresh: simpler eligibility, broader activity coverage, cohort telemetry, and invitation surface | draft #992; enabled | +| [docs-site](./features/docs-site/README.md) | The GitHub Pages documentation site: Just the Docs theme, OS dark mode, local preview | draft #420 | +| [pre-release-polish](./features/pre-release-polish/README.md) | Cross-feature visual and documentation refinements prepared for the 1.0 release | active | | [sovereign-clouds](./features/sovereign-clouds/README.md) | Entra sign-in in Azure Government and China: per-cloud token audience, Entra error classifier | implemented; sovereign checks pending | Everything else is a single file at this root, such as: diff --git a/docs/ai-and-plans/features/sovereign-clouds/README.md b/docs/ai-and-plans/features/sovereign-clouds/README.md index 5f309a09e..c82ccdc87 100644 --- a/docs/ai-and-plans/features/sovereign-clouds/README.md +++ b/docs/ai-and-plans/features/sovereign-clouds/README.md @@ -40,11 +40,11 @@ Full evidence: [research-findings.md](research-findings.md). ## What is being done -| # | Track | Who | State | -| --- | ------------------------------------------------------------------------------------------- | ------------------------------------ | --------------------------------------------------------------------------------------------------------------------------- | -| 1 | Microsoft preauthorizes VS Code for the Azure OSSRDBMS Database resource in US Government and China | Microsoft, outside the code | Not tracked in this repository | -| 2 | Request the DocumentDB token for the selected cloud, not always the public one | This repo | Implemented: [iteration 01, part A](iterations/01-cloud-aware-scope-and-entra-diagnostics.md#part-a-cloud-aware-token-audience) | -| 3 | Explain Entra sign-in failures (AADSTS codes, broker errors) through one classifier table | This repo | Implemented: [iteration 01, part B](iterations/01-cloud-aware-scope-and-entra-diagnostics.md#part-b-entra-error-classifier) | +| # | Track | Who | State | +| --- | --------------------------------------------------------------------------------------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | +| 1 | Microsoft preauthorizes VS Code for the Azure OSSRDBMS Database resource in US Government and China | Microsoft, outside the code | Not tracked in this repository | +| 2 | Request the DocumentDB token for the selected cloud, not always the public one | This repo | Implemented: [iteration 01, part A](iterations/01-cloud-aware-scope-and-entra-diagnostics.md#part-a-cloud-aware-token-audience) | +| 3 | Explain Entra sign-in failures (AADSTS codes, broker errors) through one classifier table | This repo | Implemented: [iteration 01, part B](iterations/01-cloud-aware-scope-and-entra-diagnostics.md#part-b-entra-error-classifier) | Track 2 alone does not fix the reported failure; track 1 does. Track 2 is needed so that the token is issued for the right audience once track 1 lands. Track 3 makes the next failure of this kind diff --git a/src/commands/copyConnectionString/copyConnectionString.test.ts b/src/commands/copyConnectionString/copyConnectionString.test.ts index 20b0d0174..4b073f783 100644 --- a/src/commands/copyConnectionString/copyConnectionString.test.ts +++ b/src/commands/copyConnectionString/copyConnectionString.test.ts @@ -3,8 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; import { type IActionContext } from '@microsoft/vscode-azext-utils'; +import { beforeEach, describe, expect, it, vi, type Mock } from 'vitest'; import { type ClusterItemBase } from '../../tree/documentdb/ClusterItemBase'; const mockClipboardWriteText = vi.fn(); diff --git a/src/commands/copyConnectionString/copyConnectionString.ts b/src/commands/copyConnectionString/copyConnectionString.ts index 7faf5ddb0..dfd93d888 100644 --- a/src/commands/copyConnectionString/copyConnectionString.ts +++ b/src/commands/copyConnectionString/copyConnectionString.ts @@ -151,7 +151,9 @@ function buildParsedConnectionString(credentials: EphemeralClusterCredentials): parsedConnectionString.searchParams.set('authMechanism', 'MONGODB-OIDC'); parsedConnectionString.searchParams.set( 'authMechanismProperties', - getManagedIdentityAuthMechanismProperties(resolveTokenResourceForCopy(credentials.connectionString).resource), + getManagedIdentityAuthMechanismProperties( + resolveTokenResourceForCopy(credentials.connectionString).resource, + ), ); // The client ID rides in the username position. Empty for the system-assigned identity. parsedConnectionString.username = credentials.managedIdentityAuthConfig?.clientId ?? ''; diff --git a/src/constants.ts b/src/constants.ts index 02ba4edaf..95beed9ee 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -14,8 +14,7 @@ export namespace Links { export const LocalConnectionDebuggingTips: string = 'https://aka.ms/vscode-documentdb-local-connections'; export const SrvDnsTroubleshooting: string = 'https://microsoft.github.io/vscode-documentdb/user-manual/troubleshooting-srv-dns'; - export const SovereignClouds: string = - 'https://microsoft.github.io/vscode-documentdb/user-manual/sovereign-clouds'; + export const SovereignClouds: string = 'https://microsoft.github.io/vscode-documentdb/user-manual/sovereign-clouds'; } export const doubleClickDebounceDelay = 500; //milliseconds diff --git a/src/documentdb/auth/ManagedIdentityAuthHandler.test.ts b/src/documentdb/auth/ManagedIdentityAuthHandler.test.ts index 94f8fb13a..19c911deb 100644 --- a/src/documentdb/auth/ManagedIdentityAuthHandler.test.ts +++ b/src/documentdb/auth/ManagedIdentityAuthHandler.test.ts @@ -243,10 +243,14 @@ describe('ManagedIdentityAuthHandler', () => { }); it('does not record token resource resolution errors as token-acquisition failures', async (): Promise => { - const error = new tokenResources.TokenResourceResolutionError('Set the cloud resource.', 'managedIdentityCloudUnknown', { - flow: 'managedIdentity', - errorKind: 'managedIdentityCloudUnknown', - }); + const error = new tokenResources.TokenResourceResolutionError( + 'Set the cloud resource.', + 'managedIdentityCloudUnknown', + { + flow: 'managedIdentity', + errorKind: 'managedIdentityCloudUnknown', + }, + ); vi.spyOn(tokenResources, 'resolveManagedIdentityTokenResource').mockRejectedValueOnce(error); const handler = new ManagedIdentityAuthHandler(buildCredentials()); diff --git a/src/documentdb/auth/documentdbTokenResource.test.ts b/src/documentdb/auth/documentdbTokenResource.test.ts index 334ce651a..23d193ec2 100644 --- a/src/documentdb/auth/documentdbTokenResource.test.ts +++ b/src/documentdb/auth/documentdbTokenResource.test.ts @@ -284,7 +284,9 @@ describe('resolveInteractiveTokenResource', () => { useCustomCloud(deps); vi.mocked(deps.getCustomEnvironment).mockReturnValue({ ossrdbmsEndpoint: publicResource }); await resolveInteractiveTokenResource(privateConnection, deps); - expect(deps.warn).toHaveBeenCalledWith(expect.stringContaining('documentDB.customEnvironment.ossrdbmsEndpoint')); + expect(deps.warn).toHaveBeenCalledWith( + expect.stringContaining('documentDB.customEnvironment.ossrdbmsEndpoint'), + ); }); }); diff --git a/src/documentdb/auth/documentdbTokenResource.ts b/src/documentdb/auth/documentdbTokenResource.ts index c9325357c..13a845e46 100644 --- a/src/documentdb/auth/documentdbTokenResource.ts +++ b/src/documentdb/auth/documentdbTokenResource.ts @@ -3,10 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { - getConfiguredAuthProviderId, - getConfiguredAzureEnv, -} from '@microsoft/vscode-azext-azureauth'; +import { getConfiguredAuthProviderId, getConfiguredAzureEnv } from '@microsoft/vscode-azext-azureauth'; import * as l10n from '@vscode/l10n'; import * as http from 'http'; import * as vscode from 'vscode'; @@ -341,7 +338,10 @@ function toBuiltInCloud(name: string): DocumentDBEntraCloud { } } -function fromBuiltInCloud(cloud: DocumentDBEntraCloud, reason: TokenResourceReason): Omit { +function fromBuiltInCloud( + cloud: DocumentDBEntraCloud, + reason: TokenResourceReason, +): Omit { return fromResource(getTokenResourceForCloud(cloud), reason); } diff --git a/src/documentdb/auth/entraErrors/EntraDiagnosticsProvider.test.ts b/src/documentdb/auth/entraErrors/EntraDiagnosticsProvider.test.ts index b61fe1057..76d39fb21 100644 --- a/src/documentdb/auth/entraErrors/EntraDiagnosticsProvider.test.ts +++ b/src/documentdb/auth/entraErrors/EntraDiagnosticsProvider.test.ts @@ -3,8 +3,8 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { callWithTelemetryAndErrorHandling, UserCancelledError } from '@microsoft/vscode-azext-utils'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { Links } from '../../../constants'; import { EntraDiagnosticsProvider } from './EntraDiagnosticsProvider'; import { recordEntraTokenFailure, resetEntraTokenFailuresForTests } from './entraTokenFailures'; @@ -117,15 +117,18 @@ describe('EntraDiagnosticsProvider', () => { ['usgov', 'Azure US Government'], ['china', 'Azure China'], ['custom', 'a custom cloud'], - ] as const)('hedges an opaque broker failure in %s after a DocumentDB token failure', async (cloudCategory, cloud): Promise => { - recordEntraTokenFailure(clusterId, { cloudCategory, authMethod: 'interactive' }); - const message = await provider.explain({ clusterId, error: broker }); - expect(message).toContain(`Sign-in failed in ${cloud}. If the sign-in window showed AADSTS65002`); - expect(message).toContain('must be configured via preauthorization'); - expect(message).toContain('Only Microsoft can change this'); - expect(message).toContain('native DocumentDB authentication or managed identity on an Azure VM'); - expect(message).not.toContain('msal-no-broker'); - }); + ] as const)( + 'hedges an opaque broker failure in %s after a DocumentDB token failure', + async (cloudCategory, cloud): Promise => { + recordEntraTokenFailure(clusterId, { cloudCategory, authMethod: 'interactive' }); + const message = await provider.explain({ clusterId, error: broker }); + expect(message).toContain(`Sign-in failed in ${cloud}. If the sign-in window showed AADSTS65002`); + expect(message).toContain('must be configured via preauthorization'); + expect(message).toContain('Only Microsoft can change this'); + expect(message).toContain('native DocumentDB authentication or managed identity on an Azure VM'); + expect(message).not.toContain('msal-no-broker'); + }, + ); it('keeps a public-cloud opaque broker failure generic even with a recent token failure', async (): Promise => { recordEntraTokenFailure(clusterId, { cloudCategory: 'public', authMethod: 'interactive' }); diff --git a/src/documentdb/auth/entraErrors/EntraDiagnosticsProvider.ts b/src/documentdb/auth/entraErrors/EntraDiagnosticsProvider.ts index 1a21f44d2..24f8c9069 100644 --- a/src/documentdb/auth/entraErrors/EntraDiagnosticsProvider.ts +++ b/src/documentdb/auth/entraErrors/EntraDiagnosticsProvider.ts @@ -8,10 +8,10 @@ import { type ConnectionDiagnosticsProvider, type ConnectionDiagnosticsRequest, } from '../../../services/connectionDiagnosticsService'; +import { traceAuthFlow } from '../../../utils/authTrace'; import { classifyEntraError } from './classifyEntraError'; import { entraErrorRules } from './entraErrorRules'; import { getRecentEntraTokenFailure } from './entraTokenFailures'; -import { traceAuthFlow } from '../../../utils/authTrace'; export class EntraDiagnosticsProvider implements ConnectionDiagnosticsProvider { public readonly id = 'entra-sign-in'; diff --git a/src/documentdb/auth/entraErrors/classifyEntraError.test.ts b/src/documentdb/auth/entraErrors/classifyEntraError.test.ts index 7a463d2ef..ee4126b06 100644 --- a/src/documentdb/auth/entraErrors/classifyEntraError.test.ts +++ b/src/documentdb/auth/entraErrors/classifyEntraError.test.ts @@ -15,7 +15,11 @@ const broker = 'platform_broker_error: See https://aka.ms/msal.js.errors#platfor const fixtures = [ { text: `invalid_request - AADSTS65002: ${preauthorization}`, ruleId: 'firstPartyPreauthorization', code: '65002' }, - { text: 'AADSTS500011: Resource principal was not found in the tenant', ruleId: 'resourceNotInTenant', code: '500011' }, + { + text: 'AADSTS500011: Resource principal was not found in the tenant', + ruleId: 'resourceNotInTenant', + code: '500011', + }, { text: 'AADSTS50001: Resource identifier is not valid', ruleId: 'invalidResource', code: '50001' }, { text: 'AADSTS50020: Account is not in tenant', ruleId: 'accountNotInTenant', code: '50020' }, { text: 'AADSTS90072: Account is not in tenant', ruleId: 'accountNotInTenant', code: '90072' }, @@ -109,12 +113,19 @@ describe('Entra error rule table', () => { it('has a fixture for every rule and every listed code', (): void => { for (const rule of entraErrorRules) { - expect(fixtures.some((fixture): boolean => fixture.ruleId === rule.id), rule.id).toBe(true); + expect( + fixtures.some((fixture): boolean => fixture.ruleId === rule.id), + rule.id, + ).toBe(true); for (const code of rule.aadsts ?? []) { - expect(fixtures.some((fixture): boolean => fixture.ruleId === rule.id && fixture.code === code)).toBe(true); + expect(fixtures.some((fixture): boolean => fixture.ruleId === rule.id && fixture.code === code)).toBe( + true, + ); } for (const code of rule.msal ?? []) { - expect(fixtures.some((fixture): boolean => fixture.ruleId === rule.id && fixture.text.includes(code))).toBe(true); + expect( + fixtures.some((fixture): boolean => fixture.ruleId === rule.id && fixture.text.includes(code)), + ).toBe(true); } } }); diff --git a/src/documentdb/auth/entraErrors/entraErrorRules.ts b/src/documentdb/auth/entraErrors/entraErrorRules.ts index e32c8d44d..b394b395b 100644 --- a/src/documentdb/auth/entraErrors/entraErrorRules.ts +++ b/src/documentdb/auth/entraErrors/entraErrorRules.ts @@ -53,7 +53,7 @@ export const entraErrorRules: readonly EntraErrorRule[] = [ aadsts: ['50020', '90072'], explain: (): string => l10n.t( - 'This account does not appear to be a member or guest of the organization that owns the cluster. Use Update Credentials to choose another account or tenant, or ask that organization\'s administrator to grant access. See {link}', + "This account does not appear to be a member or guest of the organization that owns the cluster. Use Update Credentials to choose another account or tenant, or ask that organization's administrator to grant access. See {link}", { link: Links.SovereignClouds }, ), }, @@ -80,7 +80,7 @@ export const entraErrorRules: readonly EntraErrorRule[] = [ aadsts: ['1000104'], explain: (): string => l10n.t( - 'Sign-in appears to be blocked because the account is from another Azure cloud. Sign in with an account from the cluster\'s cloud, or ask the organizations\' administrators to check their cross-cloud access settings. See {link}', + "Sign-in appears to be blocked because the account is from another Azure cloud. Sign in with an account from the cluster's cloud, or ask the organizations' administrators to check their cross-cloud access settings. See {link}", { link: Links.SovereignClouds }, ), }, @@ -89,7 +89,7 @@ export const entraErrorRules: readonly EntraErrorRule[] = [ aadsts: ['53003'], explain: (): string => l10n.t( - 'Your organization\'s policy appears to have blocked this sign-in. Contact your IT help desk with code AADSTS53003 and the time of the failure so they can check the sign-in policy. See {link}', + "Your organization's policy appears to have blocked this sign-in. Contact your IT help desk with code AADSTS53003 and the time of the failure so they can check the sign-in policy. See {link}", { link: Links.SovereignClouds }, ), }, diff --git a/src/documentdb/auth/entraErrors/entraTokenFailures.ts b/src/documentdb/auth/entraErrors/entraTokenFailures.ts index febd29c0f..7718f3d14 100644 --- a/src/documentdb/auth/entraErrors/entraTokenFailures.ts +++ b/src/documentdb/auth/entraErrors/entraTokenFailures.ts @@ -24,10 +24,7 @@ export function recordEntraTokenFailure(clusterId: string, context: EntraTokenFa }); } -export function getRecentEntraTokenFailure( - clusterId: string, - maxAgeMs = 10 * 60_000, -): EntraTokenFailure | undefined { +export function getRecentEntraTokenFailure(clusterId: string, maxAgeMs = 10 * 60_000): EntraTokenFailure | undefined { const failure = failures.get(clusterId); if (failure && Date.now() - failure.timestamp > maxAgeMs) { failures.delete(clusterId); diff --git a/src/documentdb/shell/ShellSessionManager.test.ts b/src/documentdb/shell/ShellSessionManager.test.ts index 13c46226b..4f5bc64df 100644 --- a/src/documentdb/shell/ShellSessionManager.test.ts +++ b/src/documentdb/shell/ShellSessionManager.test.ts @@ -6,9 +6,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { type AuthenticationSession } from 'vscode'; -import type * as documentdbTokenResource from '../auth/documentdbTokenResource'; import { ext } from '../../extensionVariables'; import { AuthMethodId } from '../auth/AuthMethod'; +import type * as documentdbTokenResource from '../auth/documentdbTokenResource'; import { resolveInteractiveTokenResource, resolveManagedIdentityTokenResource, @@ -277,9 +277,7 @@ describe('ShellSessionManager', () => { postResponse, ); expect(resolver).toHaveBeenCalledWith(connectionString); - expect(provider.mock.calls.at(-1)?.[0]).toEqual([ - 'https://ossrdbms-aad.database.usgovcloudapi.net/.default', - ]); + expect(provider.mock.calls.at(-1)?.[0]).toEqual(['https://ossrdbms-aad.database.usgovcloudapi.net/.default']); expect(postResponse).toHaveBeenCalledWith({ type: 'tokenResponse', requestId: 'token-request', @@ -304,7 +302,8 @@ describe('ShellSessionManager', () => { errorKind: source === 'vscode' ? 'cloudMismatch' : 'managedIdentityCloudUnknown', }, ); - const resolver = source === 'vscode' ? resolveInteractiveTokenResource : resolveManagedIdentityTokenResource; + const resolver = + source === 'vscode' ? resolveInteractiveTokenResource : resolveManagedIdentityTokenResource; vi.mocked(resolver).mockRejectedValueOnce(error); const manager = surface === 'shell' @@ -332,47 +331,51 @@ describe('ShellSessionManager', () => { ['shell', 'managedIdentity'], ['playground', 'vscode'], ['playground', 'managedIdentity'], - ] as const)('records the cloud and auth method for a failed %s %s token call', async (surface, source): Promise => { - const resolver = source === 'vscode' ? resolveInteractiveTokenResource : resolveManagedIdentityTokenResource; - vi.mocked(resolver).mockResolvedValueOnce({ - resource: 'https://ossrdbms-aad.database.chinacloudapi.cn', - scope: 'https://ossrdbms-aad.database.chinacloudapi.cn/.default', - cloudCategory: 'china', - reason: source === 'vscode' ? 'setting' : 'host', - details: { - flow: source === 'vscode' ? 'interactive' : 'managedIdentity', - tokenResource: 'https://ossrdbms-aad.database.chinacloudapi.cn', - }, - }); - const error = Object.freeze( - Object.assign(new Error('AADSTS65002: Token failed'), { code: 'original-code' }), - ); - const provider = source === 'vscode' ? mockGetSessionFromVSCode : mockGetManagedIdentityAccessToken; - provider.mockRejectedValueOnce(error); - const manager = - surface === 'shell' - ? new ShellSessionManager({ clusterId, clusterDisplayName: 'Test Cluster', databaseName: 'test' }) - : new PlaygroundEvaluator(); - const postResponse = vi.fn(); + ] as const)( + 'records the cloud and auth method for a failed %s %s token call', + async (surface, source): Promise => { + const resolver = + source === 'vscode' ? resolveInteractiveTokenResource : resolveManagedIdentityTokenResource; + vi.mocked(resolver).mockResolvedValueOnce({ + resource: 'https://ossrdbms-aad.database.chinacloudapi.cn', + scope: 'https://ossrdbms-aad.database.chinacloudapi.cn/.default', + cloudCategory: 'china', + reason: source === 'vscode' ? 'setting' : 'host', + details: { + flow: source === 'vscode' ? 'interactive' : 'managedIdentity', + tokenResource: 'https://ossrdbms-aad.database.chinacloudapi.cn', + }, + }); + const error = Object.freeze( + Object.assign(new Error('AADSTS65002: Token failed'), { code: 'original-code' }), + ); + const provider = source === 'vscode' ? mockGetSessionFromVSCode : mockGetManagedIdentityAccessToken; + provider.mockRejectedValueOnce(error); + const manager = + surface === 'shell' + ? new ShellSessionManager({ clusterId, clusterDisplayName: 'Test Cluster', databaseName: 'test' }) + : new PlaygroundEvaluator(); + const postResponse = vi.fn(); - await workerCallbacks.onTokenRequest?.( - { type: 'tokenRequest', requestId: 'token-request', source }, - postResponse, - ); + await workerCallbacks.onTokenRequest?.( + { type: 'tokenRequest', requestId: 'token-request', source }, + postResponse, + ); - expect(postResponse).toHaveBeenCalledExactlyOnceWith({ - type: 'tokenError', - requestId: 'token-request', - error: error.message, - }); - expect(error.code).toBe('original-code'); - expect(getRecentEntraTokenFailure(clusterId)).toEqual({ - cloudCategory: 'china', - authMethod: source === 'vscode' ? 'interactive' : 'managedIdentity', - timestamp: expect.any(Number), - }); - manager.dispose(); - }); + expect(postResponse).toHaveBeenCalledExactlyOnceWith({ + type: 'tokenError', + requestId: 'token-request', + error: error.message, + }); + expect(error.code).toBe('original-code'); + expect(getRecentEntraTokenFailure(clusterId)).toEqual({ + cloudCategory: 'china', + authMethod: source === 'vscode' ? 'interactive' : 'managedIdentity', + timestamp: expect.any(Number), + }); + manager.dispose(); + }, + ); it.each(['shell', 'playground'] as const)( 'records a failed %s interactive token request when no session is returned', diff --git a/src/documentdb/utils/connection-string-parameters.md b/src/documentdb/utils/connection-string-parameters.md index 7f8f717d3..a59c858c9 100644 --- a/src/documentdb/utils/connection-string-parameters.md +++ b/src/documentdb/utils/connection-string-parameters.md @@ -84,11 +84,11 @@ Azure DocumentDB documents a machine authentication form that carries two proper `TOKEN_RESOURCE` is the cloud's open-source database resource, as published by ARM's `/metadata/endpoints?api-version=2022-09-01` and Azure CLI's `cloud.py`: -| Azure cloud | `TOKEN_RESOURCE` | -| ------------------- | ------------------------------------------------------ | -| Public | `https://ossrdbms-aad.database.windows.net` | -| US Government | `https://ossrdbms-aad.database.usgovcloudapi.net` | -| China | `https://ossrdbms-aad.database.chinacloudapi.cn` | +| Azure cloud | `TOKEN_RESOURCE` | +| ------------- | ------------------------------------------------- | +| Public | `https://ossrdbms-aad.database.windows.net` | +| US Government | `https://ossrdbms-aad.database.usgovcloudapi.net` | +| China | `https://ossrdbms-aad.database.chinacloudapi.cn` | The extension **produces** this form (see `copyConnectionString`) and **reads** it on paste (see `getConnectionStringAuthFacts`), but it does not hand it to the driver. On paste, the parser reports diff --git a/src/utils/authTrace.ts b/src/utils/authTrace.ts index b80185c2d..1ca279d2b 100644 --- a/src/utils/authTrace.ts +++ b/src/utils/authTrace.ts @@ -61,7 +61,8 @@ export function describeAuthError(error: unknown): string { ? statusCode : 'unknown'; const aadstsCode = error instanceof Error ? /\bAADSTS\d{5,9}\b/.exec(error.message)?.[0] : undefined; - const broker = error instanceof Error && error.message.includes('platform_broker_error') ? 'platform_broker_error' : 'none'; + const broker = + error instanceof Error && error.message.includes('platform_broker_error') ? 'platform_broker_error' : 'none'; return `type=${errorName}; code=${code}; httpStatus=${httpStatus}; aadsts=${aadstsCode ?? 'none'}; broker=${broker}`; } From f3a1b6805c5e2c5f7f7cfa66063a56bcdcb2f81e Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Mon, 26 Oct 2026 08:34:24 +0000 Subject: [PATCH 25/50] docs: record sovereign cloud connectivity review Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../features/sovereign-clouds/README.md | 5 +- ...ware-scope-and-entra-diagnostics-review.md | 217 ++++++++++++++++++ 2 files changed, 221 insertions(+), 1 deletion(-) create mode 100644 docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md diff --git a/docs/ai-and-plans/features/sovereign-clouds/README.md b/docs/ai-and-plans/features/sovereign-clouds/README.md index c82ccdc87..3e073d435 100644 --- a/docs/ai-and-plans/features/sovereign-clouds/README.md +++ b/docs/ai-and-plans/features/sovereign-clouds/README.md @@ -21,7 +21,8 @@ explanations when Entra sign-in fails. ## Status -**Parts A and B implemented; sovereign-cloud verification pending.** No PR yet. Nobody on the team +**Parts A and B implemented; sovereign-cloud verification pending.** Draft +[PR #1050](https://github.com/microsoft/vscode-documentdb/pull/1050). Nobody on the team has access to a sovereign cloud, so every change here is made without being able to test it end to end. See [D2](decisions.md#d2-implement-without-sovereign-cloud-access). @@ -83,6 +84,8 @@ Azure management (subscription listing, ARM clients) already follows the selecte - [decisions.md](decisions.md): operator decisions and proposals awaiting confirmation. - [iterations/01-cloud-aware-scope-and-entra-diagnostics.md](iterations/01-cloud-aware-scope-and-entra-diagnostics.md): the plan for tracks 2 and 3. +- [iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md](iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md): + the correctness review, local reproductions, and remaining verification gates for PR #1050. ## Open gaps diff --git a/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md new file mode 100644 index 000000000..01ea1a13b --- /dev/null +++ b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md @@ -0,0 +1,217 @@ +--- +feature: sovereign-clouds +kind: review +status: active +--- + +# Sovereign cloud connectivity: correctness review + +- PR: [#1050](https://github.com/microsoft/vscode-documentdb/pull/1050). +- Reviewed on: 2026-10-26. +- Head: `e98e2f40b686c702143ed30b0664624c2ffabeaa`. +- Base: `e15fa6c12e64ad9483e1211805956043ce91720d` (`main`, also the merge base). +- Scope: audience and authority selection, managed identity, worker token delivery, copied connection + strings, error translation, support traces, telemetry, and public-cloud regressions. +- Constraint: no sovereign-cloud account, VM, or cluster was used. Passing local tests and reading + public ARM metadata are not evidence of successful sovereign-cloud authentication. + +This is a review, not an implementation pass. Product code is unchanged. Author decisions below +remain open; recommendations are not approvals to implement a different design. + +## Verdict + +**Request changes for two P2 findings before relying on this implementation for remote +troubleshooting.** No P0/P1 issue or additional high-confidence correctness defect was established. +The cloud audience table and the main token-delivery paths are supported by the inspected code, +local tests, installed SDK behavior, and public metadata. Successful sovereign authentication is +still unverified. + +| ID | Priority | Finding | Confidence | Validation | +| --- | --- | --- | --- | --- | +| R1 | P2 / medium | Invalid explicit audience overrides silently fall through | High (99%) | Code trace, local reproduction, separate reviewer confirmed | +| R2 | P2 / medium | Support traces omit or misstate available cloud evidence | High (99%) | Three local reproductions, separate reviewer confirmed | + +## Review workflow + +Following [CONTRIBUTING section 6](../../../../../CONTRIBUTING.md#6-ai-assisted-review-workflow): + +1. Initial edge-case review: completed, with local reproductions of the findings below. +2. Copilot comments: both the PR review-comments endpoint and reviews endpoint returned no entries + when inspected. There are no Copilot comments to merge or reference. +3. Validation gate: a separate read-only reviewer confirmed R1 and R2, retained P2 severity, and + reassessed solution options. Neither reviewer has attestable runtime model/vendor metadata. + **The separate-context check is complete, but the different-vendor requirement cannot be + attested; do not represent the full section 6.1 vendor gate as satisfied.** +4. Independent sweep: the reviewer checked beyond the captured issues, including the installed + Azure-auth SDK and driver/worker boundaries. No additional high-confidence defect was established. + The provisional telemetry concern was assessed as a documented limitation, not a third finding. +5. Author decisions and fixes: not started. + +## Validated findings + +### R1. P2: Reject an invalid explicit audience instead of silently selecting another + +**Location:** [custom resolution](../../../../../src/documentdb/auth/documentdbTokenResource.ts#L268) +and [managed identity resolution](../../../../../src/documentdb/auth/documentdbTokenResource.ts#L171). + +The resolver treats a nonempty, invalid `ossrdbmsEndpoint` exactly like an absent setting. For +example, putting a scope rather than a resource in the setting, +`https://ossrdbms-aad.database.windows.net/.default`, fails parsing. Interactive custom-cloud +resolution then selects the login host's resource; managed identity selects the host's resource +or IMDS. Neither reports the invalid setting, and `customOssrdbmsEndpointSet` is `false`. + +This matters when the override is the operator's deliberate fix for an otherwise unresolvable or +incompatible audience. A typo can cause the extension to request a different audience while +reporting that no override was configured. Remote troubleshooting then starts from false +configuration evidence. + +**Reproduced:** with a Government login host and `mongodb://cluster.azure.us/`, both interactive +and managed identity resolution returned the Government audience despite the malformed explicit +public-resource override. Reasons were `customLoginHost` and `host`; warnings were not called. + +**Recommended:** distinguish absent, valid, and invalid values. Reject a nonempty invalid override +with a localized, setting-specific error before fallback on paths where the override applies. +Keep the intentional behavior that built-in interactive clouds ignore the custom override. + +| Option | Pros | Cons | +| --- | --- | --- | +| Reject an invalid applicable override (recommended) | Makes precedence explicit; avoids requesting an unintended audience; gives a direct corrective action | A currently ignored typo becomes a visible configuration failure | +| Warn and continue with a trace value of `invalid` | Preserves permissive behavior | Still requests an audience different from the explicit configuration; warning can be missed | + +**Regression tests:** nonempty malformed override with a recognized custom login host, a recognized +managed-identity cluster host, valid ARM metadata, and a usable IMDS result; assert no +fallback/token request for the recommended option. Test absent and valid values separately, +including unchanged public behavior. Keep the intentional copy-operation fallback separate. + +**Validation gate:** confirmed, high confidence (99%). A new override-handling defect, not evidence +of a normal public-cloud regression. Existing invalid-override tests cover only situations where +the lower-priority resolution sources also fail. + +**Author decision:** pending. + +### R2. P2: Populate cloud evidence before early returns and mismatch errors + +**Location:** [interactive mismatch handling](../../../../../src/documentdb/auth/documentdbTokenResource.ts#L136) +and [managed identity override handling](../../../../../src/documentdb/auth/documentdbTokenResource.ts#L169). + +Some new support records contain incorrect or incomplete evidence precisely on the paths where +an operator will need it: + +- Managed identity returns an explicit override before computing the host cloud, so a recognizable + `cluster.azure.us` is logged with `hostCloud: "none"`. +- Interactive custom resolution can throw before computing the host cloud, again reporting + `"none"` for a recognizable Government host. +- A cloud mismatch is thrown before `withDetails` adds the selected resource, reason, and cloud + category. The unresolved trace therefore loses which candidate audience caused the rejection, + especially useful when the selected configuration is merely labeled `"custom"`. + +**Reproduced:** all three cases were exercised against the production resolver with injected +configuration and network dependencies. The mismatch error's `details` object has no +`tokenResource`, `reason`, or `cloudCategory` keys. + +**Recommended:** collect the host classification before audience selection and merge the selected +candidate's safe resolution fields before the mismatch check. Distinguish an unexamined value from +an examined but unrecognized host. Preserve the current audience precedence; this does not propose +adding a managed-identity mismatch check. Do not log cluster hosts, credentials, or raw errors. + +| Option | Pros | Cons | +| --- | --- | --- | +| Populate known evidence before branching (recommended) | Accurate traces across all consumers; small central change | Requires updating trace expectations | +| Mark skipped fields `notEvaluated` and add evidence at each error site | Avoids asserting a false negative | More branch-specific handling and incomplete evidence | + +**Regression tests:** override plus recognized host, unresolved custom cloud plus recognized host, +and custom-resource mismatch. Assert the full resolved/unresolved trace shape, not just the error +kind or that an event was written. Include an emitted-trace test using the real resolver rather +than a mocked resolution object. + +**Validation gate:** confirmed, high confidence (99%). This is a defect in the new support +instrumentation, not a demonstrated authentication-selection regression. + +**Author decision:** pending. + +## What checked out + +- The installed Azure-auth helper chooses its provider from the same configured Azure environment + used by the interactive resolver. The built-in cloud names and resources match. +- The existing public resource and scope remain byte-for-byte unchanged; targeted tests pin them. +- Built-in audience selection does not add ARM lookups. Successful custom ARM metadata is cached, + while failed lookups are retryable. +- Managed identity does not use the VS Code sign-in cloud to decide its audience, as required by D7. +- Shell and playground request their scopes on the main thread. Both token sources and the + custom host-suffix configuration are wired through the affected integration paths. +- The Entra diagnostics provider returns explanations without rewriting errors or recovering. + Known AADSTS codes take precedence over opaque broker hints; cancellation and unrelated-error + cases have focused tests. + +## Observability limitation, not a third finding + +The new cloud-resolution details are local output records, not properties attached to +`connect.startup`. `entraSignIn.classified` is emitted when an error text matches a rule; it can +also be emitted without a recent token-failure record, with an unknown cloud/auth method. + +Consequently: + +- Classification-event counts are not connection success/failure rates. +- Resolution errors and ordinary database authentication rejections after successful token + acquisition are not classified by this provider. +- Existing startup outcomes and correlated domain metadata offer partial evidence for known + host domains, but not the audience selected or why it was selected. +- Provider events intentionally lack correlation IDs in the accepted plan. That is not a defect + raised by this review. + +Broader cloud-attributed startup telemetry would require a separate scope decision. For the +current design, accurate local traces are particularly important, which is why R2 is actionable. + +## Evidence and verification + +### Local checks + +Verification is **Case 1: still working**, because this is a review of a draft PR, not a request to +mark it ready for review. + +| Check | Result | +| --- | --- | +| `npm run build` | Passed, including workspace prebuilds | +| Focused Vitest run: auth resolver/scopes/allowed hosts/handlers/Entra errors, shell/playground worker integration, copy connection string, connection-string input, diagnostics service, auth traces | 15 files, 297 tests passed | +| Settings contribution suite | 11 tests passed | +| Temporary characterization probes of R1/R2 | 5 tests passed; removed after execution | + +The IDE test runner did not discover these files, so the suites were run with the repository's +`npx vitest run` command. One initial temporary-probe assertion incorrectly equated absent object +keys with present `undefined` values; correcting the assertion verified the missing keys. This +was a probe error, not a failing product test. + +The full local Case 2 checks, including `prettier-fix`, were deferred until ready for review: +localization generation/check, formatting, lint, strict package-test type checking, the full runtime +suite, and packaging were not run in this review. + +GitHub checks inspected for the reviewed PR head were all successful, including static checks, +unit tests, packaging, VSIX inspection/activation, and the merge gate. That is separate CI evidence, +not a claim that this review ran the local Case 2 list. + +### External facts verified without cloud credentials + +- [US Government ARM metadata](https://management.usgovcloudapi.net/metadata/endpoints?api-version=2022-09-01) + returned `ossrDbmsResourceId: https://ossrdbms-aad.database.usgovcloudapi.net`. +- [China ARM metadata](https://management.chinacloudapi.cn/metadata/endpoints?api-version=2022-09-01) + returned `ossrDbmsResourceId: https://ossrdbms-aad.database.chinacloudapi.cn`. +- [Azure China DocumentDB connection documentation](https://docs.azure.cn/en-us/documentdb/how-to-connect-role-based-access-control) + explicitly uses `TOKEN_RESOURCE:https://ossrdbms-aad.database.chinacloudapi.cn`. + +These support the audience table. They do not establish Government DocumentDB data-plane acceptance, +VS Code client preauthorization, broker behavior, or a customer's private DNS/proxy configuration. + +## Decisions respected and remaining rollout gates + +- The service-side preauthorization dependency is acknowledged, not an extension bug to work + around with another client ID or public-cloud fallback (D1). +- Refusing unknown sovereign IMDS values is intentional (D7), not a missing mapping to invent. +- Synchronous best-effort public fallback when copying an otherwise unknown managed-identity + connection string is explicitly documented in the implementation plan. It is not the runtime + connection policy. +- Lack of sovereign end-to-end access is an accepted constraint (D2). Preserve that limitation in + release claims rather than treating fixture tests as real-cloud validation. +- The China compatibility check in D11 is still a shipping gate. This review did not query + production telemetry or establish whether existing China connections accept the public audience. +- When the Microsoft-side change is available, obtain a reporter-run preview check that covers + token acquisition and an actual database command, not merely successful resource listing. From 0b9452778366d66dd1640f676c9c023fb7640184 Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Mon, 26 Oct 2026 09:32:09 +0000 Subject: [PATCH 26/50] docs(sovereign-clouds): enhance correctness review with detailed second pass findings and recommendations --- ...ware-scope-and-entra-diagnostics-review.md | 523 +++++++++++++++++- 1 file changed, 510 insertions(+), 13 deletions(-) diff --git a/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md index 01ea1a13b..96c798fe3 100644 --- a/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md +++ b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md @@ -7,7 +7,8 @@ status: active # Sovereign cloud connectivity: correctness review - PR: [#1050](https://github.com/microsoft/vscode-documentdb/pull/1050). -- Reviewed on: 2026-10-26. +- Reviewed on: 2026-10-26. Second pass (verification of R1/R2, independent edge-case sweep, + shipping enhancements) on the same day and the same head. - Head: `e98e2f40b686c702143ed30b0664624c2ffabeaa`. - Base: `e15fa6c12e64ad9483e1211805956043ce91720d` (`main`, also the merge base). - Scope: audience and authority selection, managed identity, worker token delivery, copied connection @@ -15,21 +16,36 @@ status: active - Constraint: no sovereign-cloud account, VM, or cluster was used. Passing local tests and reading public ARM metadata are not evidence of successful sovereign-cloud authentication. -This is a review, not an implementation pass. Product code is unchanged. Author decisions below -remain open; recommendations are not approvals to implement a different design. +This is a review, not an implementation pass. Product code is unchanged. On 2026-10-26 the +operator accepted the recommended option for every finding, R1 to R9, with neutral wording only +for R7. S1 to S7 are accepted as follow-up todos. S8 is accepted as preview-build-only work. The +fixes are not implemented yet. ## Verdict -**Request changes for two P2 findings before relying on this implementation for remote -troubleshooting.** No P0/P1 issue or additional high-confidence correctness defect was established. -The cloud audience table and the main token-delivery paths are supported by the inspected code, -local tests, installed SDK behavior, and public metadata. Successful sovereign authentication is -still unverified. +**Request changes.** The first pass found two P2 findings (R1, R2). The second pass confirmed +both and found one P1 gap (R3) that keeps the PR's own recommended workaround, managed identity, out +of reach for Government and China clusters added from a connection string. It also found two more P2 +issues (R4, R5) and several P3 items. No P0 issue and no public-cloud regression was established. The +cloud audience table and the main token-delivery paths are supported by the inspected code, local +tests, installed SDK and driver behavior, and public metadata. Successful sovereign authentication +is still unverified. | ID | Priority | Finding | Confidence | Validation | | --- | --- | --- | --- | --- | -| R1 | P2 / medium | Invalid explicit audience overrides silently fall through | High (99%) | Code trace, local reproduction, separate reviewer confirmed | -| R2 | P2 / medium | Support traces omit or misstate available cloud evidence | High (99%) | Three local reproductions, separate reviewer confirmed | +| R1 | P2 / medium | Invalid explicit audience overrides silently fall through | High (99%) | Code trace, local reproduction, separate reviewer confirmed, re-reproduced in second pass | +| R2 | P2 / medium | Support traces omit or misstate available cloud evidence | High (99%) | Three local reproductions, separate reviewer confirmed, re-reproduced in second pass | +| R3 | P1 / high | Government and China hosts are not recognized as DocumentDB, so managed identity is never offered in New Connection or Update Credentials | High (99%) | Code trace, local reproduction (second pass) | +| R4 | P2 / medium | A missing or incomplete custom cloud definition gets remediation that names the wrong setting | High (95%) | Code trace, installed SDK inspection, local reproduction (second pass) | +| R5 | P2 / medium | `mongoClusterHostnameSuffix` widens the token allowlist from workspace settings and accepts public suffixes | High (90%) | Code trace, installed driver inspection, local reproduction (second pass) | +| R6 | P3 / low | AADSTS code regex is unbounded and disagrees with the trace regex | High (99%) | Local reproduction (second pass) | +| R7 | P3 / low | Opaque-broker message assumes Windows | Medium (70%) | Code trace (second pass) | +| R8 | P3 / low | Token-failure registry is never cleared on success | Medium (75%) | Code trace (second pass) | +| R9 | P3 / low | User manual overstates managed identity cloud detection and scopes the host suffix to custom clouds only | High (95%) | Doc/code comparison (second pass) | + +Enhancements that reduce the risk of shipping without sovereign access are listed separately in +[Shipping without sovereign access](#shipping-without-sovereign-access-enhancements). They are +not defects. ## Review workflow @@ -45,7 +61,17 @@ Following [CONTRIBUTING section 6](../../../../../CONTRIBUTING.md#6-ai-assisted- 4. Independent sweep: the reviewer checked beyond the captured issues, including the installed Azure-auth SDK and driver/worker boundaries. No additional high-confidence defect was established. The provisional telemetry concern was assessed as a documented limitation, not a third finding. -5. Author decisions and fixes: not started. +5. Author decisions: recorded on 2026-10-26 for R1 to R9 (all recommended options; R7 neutral + wording only). Fixes: not started. +6. Second pass (same head): re-verified R1 and R2 against the code and reassessed their + recommendations (see "Second-pass verification" under each). Then an independent sweep + covered the New Connection and Update Credentials wizards, the installed Azure-auth SDK + (`@microsoft/vscode-azext-azureauth` 4.1.1, `@azure/ms-rest-azure-env`), and the installed + MongoDB driver (7.2.0) `ALLOWED_HOSTS` enforcement. It also covered the diagnostics service + ordering and deadline, the managed identity error path, settings scope, user documentation, + and existing telemetry. 19 temporary probe tests reproduced R1 to R6. They were removed after + execution. This pass also comes from a single reviewer without attestable vendor metadata, so + the section 6.1 vendor gate is still not satisfied. ## Validated findings @@ -87,7 +113,27 @@ including unchanged public behavior. Keep the intentional copy-operation fallbac of a normal public-cloud regression. Existing invalid-override tests cover only situations where the lower-priority resolution sources also fail. -**Author decision:** pending. +**Second-pass verification:** confirmed. Re-reproduced: Government login host, an override of +`https://ossrdbms-aad.database.windows.net/.default`, and an `azure.us` host. The result was +`reason: customLoginHost`, the Government audience, and `customOssrdbmsEndpointSet: false`, with no +warning. A scheme-less override (`ossrdbms-aad.database.usgovcloudapi.net`) on a managed identity +with a private host and public IMDS silently resolved to the **public** audience. In that case the +fallback crosses clouds, not just sources. The recommended option is correct. Refinements: + +- Treat `undefined`, `""`, and whitespace-only values as absent. Clearing the field in + `settings.json` often leaves `""`, and that must not start failing connections. +- Report the override as a tri-state in traces (`absent`, `valid`, `invalid`) and add whether it + applied on this path. Today `customOssrdbmsEndpointSet: true` is also logged for built-in + interactive clouds, where the override is intentionally ignored. That reads as if it was used. +- In the error, show the expected form (`https://ossrdbms-aad.database.usgovcloudapi.net`, no + `/.default`, no path). The value is not secret, but keep it out of traces. +- Add a JSON-schema `pattern` and `patternErrorMessage` to `ossrdbmsEndpoint` in `package.json` + (for example `^\s*https://[^/?#:@\s]+/?\s*$`). The Settings editor then flags the typo before + any connection attempt. This is a cheap complement to the runtime check, not a replacement. +- Copy: keep the documented permissive fallback, but log a warning so a copied string that ignores + the override is not silent. + +**Author decision:** accepted (2026-10-26), recommended option. Reject a nonempty invalid override on the paths where it applies, including the second-pass refinements: empty or whitespace-only values count as absent, a tri-state trace value, the expected form in the error, a `package.json` `pattern`, and a warning on copy. ### R2. P2: Populate cloud evidence before early returns and mismatch errors @@ -127,7 +173,427 @@ than a mocked resolution object. **Validation gate:** confirmed, high confidence (99%). This is a defect in the new support instrumentation, not a demonstrated authentication-selection regression. -**Author decision:** pending. +**Second-pass verification:** confirmed. Re-reproduced all three cases. The mismatch error's +`details` keys are exactly `flow, configuredCloud, authProvider, brokerImplementation, hostCloud, +customOssrdbmsEndpointSet, customHostnameSuffixSet, armMetadata, imdsAzEnvironment, errorKind`, +with no `tokenResource`, `reason`, or `cloudCategory`. The recommended option is correct, and the +simplest form of it removes the need for a `notEvaluated` value: + +- Host classification is a pure, offline parse. Compute `details.hostCloud` as the **first** step + of every flow. `none` then always means "examined, not a built-in Azure domain", and the second + option in the table becomes unnecessary. +- In the mismatch branch, build the error from `withDetails(resolved, details).details` instead of + `details`. +- `resolveTokenResourceForCopy` has the same early return before `hostCloud`. Its details are not + traced today, so it has no user impact, but fix it the same way to keep the three flows + identical. + +**Second-pass refinement, related to R4:** when the configured cloud cannot be read, the mismatch +message says "VS Code is signed in to {cloud of the override}". VS Code is not signed in to anything +in that state. See R4. + +**Author decision:** accepted (2026-10-26), recommended option. Classify the host first in every flow, including copy, and build the mismatch error from the merged resolution details. + +## Second-pass findings + +### R3. P1: Recognize Government and China DocumentDB hosts so managed identity is offered + +**Location:** [`AzureDomains.vCore`](../../../../../src/documentdb/utils/connectionStringHelpers.ts#L101), +used by [New Connection](../../../../../src/commands/newConnection/PromptConnectionStringStep.ts#L107) +and [Update Credentials](../../../../../src/commands/updateCredentials/updateCredentials.ts#L68); +the gate is in [`SelectEntraTokenSourceStep`](../../../../../src/documentdb/wizards/authenticate/SelectEntraTokenSourceStep.ts#L259). + +`AzureDomains.vCore` is `mongocluster.cosmos.azure.com`. A portal-copied Government or China string +(`.mongocluster.cosmos.azure.us` or `.azure.cn`, confirmed for China by the +[China documentation](https://docs.azure.cn/en-us/documentdb/how-to-connect-role-based-access-control)) +therefore gets `availableAuthenticationMethods = [NativeAuth, NoAuth]`. Microsoft Entra ID is still +selectable, because manual entry shows every top-level method. Managed identity is not a top-level +method, though: `SelectEntraTokenSourceStep` offers it only when `ManagedIdentity` is in the +available list. Otherwise it returns `managedIdentityUnavailable` and goes straight to interactive +sign-in. Update Credentials does not add Entra ID or managed identity for these hosts either. + +**Why P1:** the PR's AADSTS65002 explanation, the `brokerOpaque` message, and the user manual all +tell Government and China users to use managed identity as the workaround. For the most common +entry path, a pasted portal connection string, the UI never offers it. Remaining routes are the +Azure discovery tree, which synthesizes managed identity from ARM `allowedModes`, and pasting a +driver-native `MONGODB-OIDC` + `ENVIRONMENT:azure` string. Neither is discoverable from the error +message. This is a gap in the feature, not a public-cloud regression. + +**Reproduced:** with `PromptConnectionStringStep`, `azure.com` yields +`NativeAuth,MicrosoftEntraID,ManagedIdentity,NoAuth`, while `azure.us` and `azure.cn` yield +`NativeAuth,NoAuth`. + +**Recommended:** replace the single-suffix check with a helper that recognizes +`mongocluster.cosmos.azure.{com,us,cn}`. Reuse the closed TLD list in `oidcAllowedHosts.ts` rather +than a second hardcoded list. Consider also accepting a host that matches a configured +`mongoClusterHostnameSuffix`. Leave `AzureDomains.RU` alone; it is out of scope. Regression tests: +New Connection and Update Credentials for each of the three suffixes, plus a lookalike +(`mongocluster.cosmos.azure.us.evil.com`) that must not match. Note that `hasDomainSuffix` matches +with a bare `endsWith`, so `xmongocluster.cosmos.azure.com` also matches today; use a dot boundary +in the new helper. + +**Author decision:** accepted (2026-10-26), recommended option. Recognize `mongocluster.cosmos.azure.{com,us,cn}` with a dot boundary, reuse the closed TLD list, also accept a configured `mongoClusterHostnameSuffix`, and apply the result in both New Connection and Update Credentials. + +### R4. P2: Name `microsoft-sovereign-cloud.customEnvironment` when it is missing or incomplete + +**Location:** [`getConfiguredCloudForDetails`](../../../../../src/documentdb/auth/documentdbTokenResource.ts#L407) +and the [`customCloudUnresolved` error](../../../../../src/documentdb/auth/documentdbTokenResource.ts#L290). + +`getConfiguredAzureEnv()` throws in two cases. One is when `environment` is `custom` and +`customEnvironment` is absent. The other is when `customEnvironment` lacks any of the six fields +the `@azure/ms-rest-azure-env` constructor requires (`name`, `portalUrl`, `managementEndpointUrl`, +`resourceManagerEndpointUrl`, `activeDirectoryEndpointUrl`, `activeDirectoryResourceId`). The +resolver swallows that error and treats the state as a custom cloud with no endpoints. Results: + +- Without an override, the user is told "VS Code is signed in to a custom cloud ... Set + `documentDB.customEnvironment.ossrdbmsEndpoint`". That cannot fix the problem. +- If the user follows the advice, resolution succeeds (`reason: customOverride`). Then + `getSessionFromVSCode` calls `getConfiguredAuthProviderId()`, which throws the raw SDK message, + for example `Please provide "portalUrl" for the environment...`. That failure is recorded as an + Entra token failure. +- With an override and an `azure.com` host, the message says "VS Code is signed in to Azure US + Government". VS Code is not signed in at all. + +**Reproduced:** all three, with the trace showing `configuredCloud: unreadable`. + +**Recommended:** when the configured cloud is unreadable, throw a new `TokenResourceErrorKind` +(for example `customCloudDefinitionInvalid`) before audience selection. Its message should name +`microsoft-sovereign-cloud.customEnvironment` and list the required fields. Never include the SDK +error text in traces. Regression tests: missing `customEnvironment`, one missing field, and the +override-plus-`azure.com` case. + +**Author decision:** accepted (2026-10-26), recommended option. Add a new error kind for an unreadable custom cloud definition that names `microsoft-sovereign-cloud.customEnvironment` and its required fields, raised before audience selection. + +### R5. P2: Do not let a workspace widen the OIDC token allowlist + +**Location:** [`documentDB.customEnvironment` contribution](../../../../../package.json#L1990) +(`"scope": "machine-overridable"`) and +[`normalizeCustomHostnameSuffix`](../../../../../src/documentdb/auth/oidcAllowedHosts.ts#L109). + +Driver 7.2.0 enforces `ALLOWED_HOSTS` for callback-based OIDC after SRV resolution, and the +extension never sets `ENVIRONMENT`, so the allowlist is a real control. `mongoClusterHostnameSuffix` +extends that allowlist. Two weaknesses: + +1. `machine-overridable` lets workspace and folder settings override the value. A trusted + repository's `.vscode/settings.json` can therefore add any suffix. The extension declares no + `untrustedWorkspaces` capability, so Restricted Mode disables it. The exposure is limited to + trusted workspaces, and exploitation also needs the user to choose Entra ID for a host the + attacker controls. For example, a deep link through the URI handler adds a connection after a + confirmation. +2. Any two-label suffix is accepted, including public suffixes. Reproduced: `.com.cn` and `.co.uk` + normalize successfully, and `attacker.com.cn` gets `ALLOWED_HOSTS: ['*.com.cn']`. + +The token's audience (`ossrdbms-aad...`) is shared by the Azure open-source database family. +A leaked token is therefore broader than one DocumentDB cluster, which raises the stakes above a +typical setting typo. + +**Recommended:** make the suffix setting `machine` scoped, or read only +`inspect(...).globalValue`/machine values for the suffix. Then reject suffixes that equal a known +public-suffix pattern; at minimum, reject `com.`, `co.`, `net.`, `org.`, and +`gov.` style two-label values. Record `customHostnameSuffixSource` (`user`, `workspace`, or +`none`) in traces. Applying the same scope to `ossrdbmsEndpoint` is lower risk but keeps the object +consistent. VS Code's own `microsoft-sovereign-cloud.*` settings use the default scope, so this is +hardening, not parity. + +**Author decision:** accepted (2026-10-26), recommended option. Use `machine` scope for `documentDB.customEnvironment`, covering both properties. Reject two-label public-suffix patterns and trace `customHostnameSuffixSource`. + +### R6. P3: Bound the AADSTS code match + +**Location:** [`classifyEntraError`](../../../../../src/documentdb/auth/entraErrors/classifyEntraError.ts#L22). + +`/AADSTS(\d{5,7})/g` has no trailing boundary. `AADSTS12345678` is classified as code `1234567`, +while `describeAuthError` in `authTrace.ts` uses `\d{5,9}` with word boundaries. No current rule is +affected, because the regex is greedy and `500011` is not truncated to `50001`. But `unknownAadsts` +would build a wrong lookup link, and the trace and classifier would disagree. **Recommended:** +`/\bAADSTS(\d{5,9})\b/g` in both places, with a test. + +**Author decision:** accepted (2026-10-26), recommended option. + +### R7. P3: Do not assume Windows in the opaque-broker message + +**Location:** [`brokerOpaque` rule](../../../../../src/documentdb/auth/entraErrors/entraErrorRules.ts#L121). + +The generic branch says "The Windows sign-in window reported an error". The classification is +based only on the presence of `platform_broker_error`. If VS Code's broker is used on other +platforms, or the text appears on another path, the message misleads. **Recommended:** neutral +wording ("The sign-in window reported an error without details"), or gate the Windows wording on +`process.platform === 'win32'`. + +**Author decision:** accepted (2026-10-26), neutral wording only. No platform gate: the message +must not mention Windows on any platform. + +### R8. P3: Clear the token-failure record on success + +**Location:** [`recordEntraTokenFailure`](../../../../../src/documentdb/auth/entraErrors/entraTokenFailures.ts#L19). + +Records persist for 10 minutes per `clusterId` and are only overwritten by the next failure. A later +error for the same cluster that is not a token failure can therefore be explained with the earlier +attempt's `cloudCategory` and `authMethod`, and the telemetry gets the same stale values. +That earlier attempt may have used a different cloud setting. Practical impact is small because +only Entra-matching texts are classified. **Recommended:** clear the record when token acquisition +succeeds on any of the four call sites. Add one test per surface. + +**Author decision:** accepted (2026-10-26), recommended option. + +### R9. P3: Correct the user manual on managed identity and private hosts + +**Location:** [sovereign-clouds.md](../../../../user-manual/sovereign-clouds.md#managed-identity). + +- "Otherwise, it uses the Azure VM's instance metadata" implies a Government VM is detected. By + decision D7, only `AZUREPUBLICCLOUD` is recognized. Reproduced: IMDS `AzureUSGovernmentCloud` + with a private host fails with `managedIdentityCloudUnknown`. State that only the public cloud + is detected this way and that other clouds need `ossrdbmsEndpoint`. +- `mongoClusterHostnameSuffix` is documented under "Custom clouds" only. A Government or China + cluster reached through a private DNS name or CNAME also fails `ALLOWED_HOSTS` + (`*.azure.com` default) and needs this setting. A private-host managed identity user in + Government needs **both** properties. Add a "Private endpoints and custom DNS names" subsection. +- The driver's rejection (`Host '...' is not valid for OIDC authentication with ALLOWED_HOSTS of + '*.azure.com'`) is not translated by any provider. See enhancement S4. + +**Author decision:** accepted (2026-10-26), all three corrections. + +## Shipping without sovereign access: enhancements + +These items are not defects. They are ordered by how much they reduce the risk of a blind release. + +| ID | Enhancement | Why it matters without a sovereign cluster | +| --- | --- | --- | +| S1 | Attach the coarse resolution fields (`cloudCategory`, `tokenResourceReason`, `configuredCloud`, `hostCloud`, `errorKind`) to the existing connection-startup telemetry for Entra and managed identity attempts. | The only scalable signal for whether Government/China connections succeed after release. All values are closed enums with no identifiers. The first pass called this a separate scope decision; this pass recommends making it before shipping. | +| S2 | Set the extension `appName` for `azure.us` and `azure.cn` hosts too ([`ClustersClient`](../../../../../src/documentdb/ClustersClient.ts#L365) uses `hasAzureDomain`, which matches `azure.com` only). | The service team can then attribute VS Code connections in sovereign clusters and confirm success or failure server-side, independent of client telemetry. Same data as already sent for public clusters. | +| S3 | Trace the raw IMDS `azEnvironment` when it matches a safe shape such as `^Azure[A-Za-z]{1,40}$`; keep `other` for anything else. | Answers research open question 5 from the first Government managed identity report, which is the evidence needed to revisit D7 safely. | +| S4 | Translate the driver's `ALLOWED_HOSTS` rejection into a message naming `documentDB.customEnvironment.mongoClusterHostnameSuffix` (a rule matched on the driver text, routed through `ConnectionDiagnosticsService`). | Private endpoints with custom DNS are common in Government tenants; today the user sees a raw driver message that mentions `*.azure.com`. | +| S5 | Add `pattern` validation for both `documentDB.customEnvironment` properties in `package.json` (see R1). | Catches configuration mistakes in the Settings editor, before any remote troubleshooting round trip. | +| S6 | Add end-to-end handler tests using the real resolver, not a mocked resolution, for: Government interactive (scope passed to `getSessionFromVSCode`, `ALLOWED_HOSTS`), Government managed identity by host, China both flows, and the shell and playground `tokenRequest` path with a Government connection string. | The current handler tests mock `resolveInteractiveTokenResource`, so a wiring regression between resolver and token request would pass. | +| S7 | Use the existing hashed domain telemetry to run the D11 check. `*.azure.cn` and `*.azure.us` hosts are classified `domainInfo_isAzure = false` and hashed. Match `domainInfo_domain_l2 = 6a619114` (`azure.cn`) or `domainInfo_domain_l3 = 49133d63` (`cosmos.azure.cn`); for Government, `8bafc480` (`azure.us`) and `f3e87358` (`cosmos.azure.us`). Values are the first 8 hex characters of SHA-256, as computed by `getClusterMetadata.ts`. | Makes the D11 shipping gate answerable with data that already exists. | +| S8 | Preview-build connection diagnostics dialogs. Replaces the earlier scripted-checklist idea. See [S8 design](#s8-preview-build-connection-diagnostics-dialogs). | The reporter sees what the extension detected and decided at each stage, and can paste a redacted report with one click. | + +### Follow-up todos (S1 to S7) + +Accepted by the operator on 2026-10-26 as follow-up work. These are not blocking findings. Track +each one as a separate commit, so they can be reviewed apart from the R fixes. + +- [ ] **S1.** Add `cloudCategory`, `tokenResourceReason`, `configuredCloud`, `hostCloud`, and + `errorKind` to the connection-startup telemetry for Entra ID and managed identity attempts. + Use closed enums only. +- [ ] **S2.** Set the extension `appName` for `azure.us` and `azure.cn` hosts, not only + `azure.com` (`ClustersClient`, `hasAzureDomain`). +- [ ] **S3.** Trace the raw IMDS `azEnvironment` value when it matches `^Azure[A-Za-z]{1,40}$`. + Otherwise keep `other`. +- [ ] **S4.** Translate the driver's `ALLOWED_HOSTS` rejection into a message that names + `documentDB.customEnvironment.mongoClusterHostnameSuffix`. +- [ ] **S5.** Add `pattern` and `patternErrorMessage` validation for both + `documentDB.customEnvironment` properties. This overlaps with the R1 decision; implement it once. +- [ ] **S6.** Add end-to-end handler tests with the real resolver for the Government and China + interactive and managed identity flows, and for the shell and playground `tokenRequest` path. +- [ ] **S7.** Run the D11 China check against existing hashed domain telemetry. Use + `6a619114` (`azure.cn`) and `49133d63` (`cosmos.azure.cn`); for Government, use `8bafc480` + (`azure.us`) and `f3e87358` (`cosmos.azure.us`). + +### S8. Preview-build connection diagnostics dialogs + +Accepted by the operator on 2026-10-26. It replaces the earlier scripted-checklist idea. + +**Purpose.** Nobody on the team can sign in to a Government or China cluster. The preview reporter +is the only real-cloud test. A preview build should therefore show, at each connection stage, what +the extension detected, what it decided, and what happens next. Each stage needs one button that +copies a redacted report the reporter can paste into the issue. + +**Delivery: never in a release.** + +- **Preferred:** a separate `preview/sovereign-diagnostics` branch, based on the PR branch, that + produces a VSIX for the reporter. The branch is never merged; it is deleted when the preview + check is done. This is easier to abandon than a revert. +- **Fallback:** if it must live on the PR branch, put the whole feature in **one commit**, titled + `chore(preview): sovereign connection diagnostics dialogs (DO NOT MERGE)`. Revert it with a + single `git revert` before the PR is marked ready for review. +- Either way: + - All code is in one new folder, `src/documentdb/auth/previewDiagnostics/`. + - The hooks are one-line calls, so the commit stays small and its revert stays clean. + - Strings are plain English and not wrapped in `vscode.l10n.t()`, so `l10n:check` is not affected + and the bundle does not pick up throwaway strings. + - One constant, `PREVIEW_DIAGNOSTICS_ENABLED = true`, gates every hook, so the code can be + disabled without removing it. + +**Constraint found in this review.** Every token path uses the driver's automated `OIDC_CALLBACK`, +whose timeout is 60 s (`AUTOMATED_TIMEOUT_MS` in driver 7.2.0). Managed identity on the extension +connection, and both auth methods in the shell and playground, request the token **inside** that +callback. A dialog there must not be awaited, or a reporter who reads it slowly causes a +timeout that looks like a sign-in failure. Interactive sign-in on the extension connection gets its +token in `configureAuth`, before the driver runs, so it can safely block. + +| Dialog | Shown | Blocking? | Hook | +| --- | --- | --- | --- | +| 1 of 3: about to request a token | After cloud resolution succeeds or fails, before any token request | Yes in `configureAuth` (both handlers). No in shell and playground (`handleTokenRequest` runs inside the worker's callback) | `MicrosoftEntraIDAuthHandler`, `ManagedIdentityAuthHandler` (`configureAuth`), `ShellSessionManager` and `PlaygroundEvaluator` (`handleTokenRequest`) | +| 2 of 3: token result | After the token request succeeds or fails | Yes for interactive in `configureAuth`; no elsewhere | After `getSessionFromVSCode` or `getManagedIdentityAccessToken`, at the same four sites | +| 3 of 3: connection result | After `MongoClient.connect()` succeeds or throws, and after two probe commands | Yes (the connection is already open or has failed) | `ClustersClient.connect`, after stage 04, and in its `catch` | + +Show each dialog at most once per `(clusterId, surface, step)` per window. Token refreshes in the +shell and playground then do not repeat it. If resolution fails, dialog 1 reports the failure and +dialogs 2 and 3 are skipped. If the token request fails, dialog 2 reports the failure and dialog 3 +is skipped. + +A modal closes on any button. **Copy report** therefore writes the clipboard, shows a non-modal +"Copied" toast, and opens the same dialog again. **Continue** proceeds. The built-in **Cancel** +on dialog 1 throws `UserCancelledError`, so the reporter can capture the evidence without +attempting sign-in. `ConnectionDiagnosticsService` already ignores that error. + +**Dialog 1 of 3** (example: Government, interactive): + +```text +Preview build: connection check 1 of 3 +About to request a Microsoft Entra ID token + +What was detected + VS Code cloud setting: USGovernment (microsoft-sovereign-cloud.environment) + Cluster domain: *.azure.us -> Azure US Government + Authentication: Microsoft Entra ID, VS Code account + Sign-in provider: microsoft-sovereign-cloud broker: default + Custom settings: ossrdbmsEndpoint absent; mongoClusterHostnameSuffix absent + +What was decided + Token resource: https://ossrdbms-aad.database.usgovcloudapi.net + Why: reason=setting + Token may be sent only to: *.azure.us + +What happens next + VS Code may open a sign-in window. If it shows an error, write down the AADSTS code + it displays before closing it. + +[Continue] [Copy report] [Cancel] +``` + +For managed identity, replace the last three groups with `Authentication: Managed identity +(system-assigned | user-assigned)`, the IMDS outcome (`notQueried`, `AZUREPUBLICCLOUD`, `other`, +or `unavailable`), and "Next: the token is requested from this machine's identity endpoint when the +driver asks for it." On failure, show `errorKind` and the same message the user would see. + +**Dialog 2 of 3** (token result): + +```text +Preview build: connection check 2 of 3 +Token received + + Requested resource: https://ossrdbms-aad.database.usgovcloudapi.net + Token audience (aud): https://ossrdbms-aad.database.usgovcloudapi.net matches: yes + Issuer host (iss): login.microsoftonline.us -> Azure US Government + Token version (ver): 2.0 + Client (appid/azp): VS Code (or: other, managed identity) + Tenant (tid): present; matches the configured cluster tenant: yes | no | not configured + Expires in: 59 min + Token acquisition: 1,240 ms + +What happens next + The extension connects to the cluster and sends this token. The cluster decides whether + it accepts the audience and whether this identity has a role. + +[Continue] [Copy report] +``` + +On failure, the title becomes "Token request failed" and the body lists `describeAuthError` +(`type`, `code`, `httpStatus`, `aadsts`, `broker`), the classifier `ruleId`, and the text that +`EntraDiagnosticsProvider` would show. Decode the claims from the JWT payload locally, with no +signature check and no network call. Never show or copy the token itself. + +**Dialog 3 of 3** (connection result): + +```text +Preview build: connection check 3 of 3 +Connected and authenticated + + Handshake and authentication: succeeded (connect: 2,310 ms) + ping (admin): ok + listDatabases (nameOnly): ok, 3 databases visible + connectionStatus: ok, 1 role assigned (or: not supported by this server) + +What this proves + The cluster accepted a token for https://ossrdbms-aad.database.usgovcloudapi.net from + Azure US Government. Please paste this report into the GitHub issue. + +[Done] [Copy report] +``` + +On failure, list the error name, `code`, `codeName`, and one plain-language reading of it: + +- `AuthenticationFailed` (code 18): the cluster rejected the token. A wrong audience or a missing + role is likely. +- The `ALLOWED_HOSTS` rejection: the token was not sent; see S4. +- Timeout or network error: the token was not the problem. + +Also include the `ConnectionDiagnosticsService` explanation, if any. The probe commands run only in +the preview build and only after a successful connect. Each one is wrapped so that its failure is +reported, not thrown. + +**Copied report.** Every dialog's **Copy report** produces one fenced Markdown block. It starts with +the step header, the extension version, the VS Code version, `process.platform`/`arch`, the UTC +time, and the startup correlation ID, so the report can be matched to the output channel. It then +repeats the fields shown in the dialog, plus the `entraCloud.resolved` or `entraCloud.unresolved` +details object verbatim. That object is already identifier-free by design. + +Redaction rules, enforced by a unit test of the formatter: + +- No cluster host names. Report only the matched domain suffix (`*.azure.us`) or `private`. +- No tenant IDs, client IDs, object IDs, user names, or e-mail addresses. Report "present" and + derived yes/no comparisons instead. +- No tokens, connection strings, or raw error text from the driver or MSAL beyond the classified + fields above. + +**Implementation sketch** (all in the one commit or on the preview branch): + +```ts +// src/documentdb/auth/previewDiagnostics/previewDiagnostics.ts +export const PREVIEW_DIAGNOSTICS_ENABLED = true; + +export type PreviewStep = 1 | 2 | 3; +export type PreviewSurface = 'extension' | 'shell' | 'playground'; + +export interface PreviewDialog { + readonly step: PreviewStep; + readonly title: string; // "About to request a Microsoft Entra ID token" + readonly sections: ReadonlyArray<{ heading: string; rows: ReadonlyArray<[string, string]> }>; + readonly next?: string; + readonly details?: Readonly>; // resolution details, copied verbatim +} + +const shown = new Set(); + +/** Never throws. When `blocking` is false, returns immediately; use it inside OIDC callbacks. */ +export async function showPreviewDialog( + clusterId: string, + surface: PreviewSurface, + dialog: PreviewDialog, + options: { blocking: boolean; cancellable?: boolean }, +): Promise { + if (!PREVIEW_DIAGNOSTICS_ENABLED) return; + const key = `${clusterId}|${surface}|${dialog.step}`; + if (shown.has(key)) return; + shown.add(key); + const run = loopUntilDismissed(dialog, options.cancellable === true); // Copy report re-opens + if (options.blocking) await run; + else void run.catch(() => undefined); +} + +// formatPreviewReport(dialog): string -> the redacted Markdown block, unit-tested +// describeTokenClaims(accessToken, requestedResource, clusterTenantId?) -> rows for dialog 2 +// runPostConnectProbes(client): Promise -> ping, listDatabases({ nameOnly: true }), connectionStatus +``` + +Hook call sites, one line each: two handlers × steps 1 and 2; `ShellSessionManager` and +`PlaygroundEvaluator` × steps 1 and 2 with `blocking: false`; `ClustersClient.connect` × step 3 +(success and `catch`). Tests: formatter redaction, the once-per-key behavior, and +`blocking: false` returning before the dialog resolves. + +**Reporter run sheet** (attach to the preview VSIX): + +1. Government, interactive, portal connection string (`*.mongocluster.cosmos.azure.us`): expect + `usgov` in dialog 1, a matching `aud` in dialog 2, and dialog 3 succeeded. Then open a + collection, and run `db.runCommand({ ping: 1 })` in the shell and in a playground (dialogs 1 + and 2, non-blocking). +2. Government, managed identity on a Government VM, same connection string: `reason=host`. +3. Government, managed identity, private DNS name with both `documentDB.customEnvironment` + properties set: `reason=customOverride` and `ALLOWED_HOSTS` from the custom suffix. +4. If sign-in fails with the Windows broker: copy dialog 2, then repeat once with + `microsoft-authentication.implementation = msal-no-broker` to capture the AADSTS code. Reset the + setting afterward. ## What checked out @@ -161,6 +627,8 @@ Consequently: Broader cloud-attributed startup telemetry would require a separate scope decision. For the current design, accurate local traces are particularly important, which is why R2 is actionable. +The second pass recommends taking that decision before release (S1, S2), because without +sovereign access, field telemetry is the main way to learn whether the feature works. ## Evidence and verification @@ -175,6 +643,7 @@ mark it ready for review. | Focused Vitest run: auth resolver/scopes/allowed hosts/handlers/Entra errors, shell/playground worker integration, copy connection string, connection-string input, diagnostics service, auth traces | 15 files, 297 tests passed | | Settings contribution suite | 11 tests passed | | Temporary characterization probes of R1/R2 | 5 tests passed; removed after execution | +| Second pass: temporary probes for R1 to R6 and the edge cases below | 19 tests passed (`npx vitest run`, two files); removed after execution | The IDE test runner did not discover these files, so the suites were run with the repository's `npx vitest run` command. One initial temporary-probe assertion incorrectly equated absent object @@ -201,6 +670,33 @@ not a claim that this review ran the local Case 2 list. These support the audience table. They do not establish Government DocumentDB data-plane acceptance, VS Code client preauthorization, broker behavior, or a customer's private DNS/proxy configuration. +Second pass: + +- The same China page uses `*.global.mongocluster.cosmos.azure.cn` hosts and + `ALLOWED_HOSTS: ['*.azure.cn']`. This matches `oidcAllowedHosts.ts` and the China row of D7, and + is the basis of R3. +- The Government host suffix (`azure.us`) remains operator-confirmed (D7), not publicly documented. +- Research §8 documents only `AZUREPUBLICCLOUD` for IMDS `azEnvironment`. Third-party summaries + list `AzureUSGovernmentCloud` and `AzureChinaCloud`, but this is not authoritative, which is why + S3 captures evidence rather than adding a mapping. + +### Second-pass edge cases examined + +| Case | Observed | Assessment | +| --- | --- | --- | +| Interactive, public setting, private DNS host | Public audience, no mismatch check | Correct per D8 | +| Replica set mixing `azure.com` and `azure.us` hosts | No host cloud; `ALLOWED_HOSTS` is `*.azure.com,*.azure.us` | Acceptable; unrealistic topology | +| `mongodb+srv://` alias under a private domain whose SRV targets `azure.us` | No host cloud from the alias; `ALLOWED_HOSTS` defaults to `*.azure.com`, so the driver rejects the `azure.us` targets | **Sovereign-specific.** The same alias works in the public cloud only because the default happens to be `*.azure.com`. Consider deriving `ALLOWED_HOSTS` from the configured interactive cloud when the host is unclassified, or document it under R9; S4 makes it diagnosable | +| Copy, managed identity, private host, `USGovernment` setting | Government audience (`reason: setting`) | Documented copy policy; it can disagree with connect-time D7, which ignores the setting. Acceptable because the copy is editable | +| Text with `platform_broker_error` and AADSTS65002 | `firstPartyPreauthorization`, `brokered: true` | Correct precedence | +| Text with AADSTS50001 and AADSTS500011 | `resourceNotInTenant` (rule order, not text order) | Acceptable; document that rule order is the precedence | +| HTML-escaped preauthorization phrase only | `firstPartyPreauthorization` | Correct | +| IMDS request through a VS Code proxy or a stalled body | Resolves `undefined` on error or socket timeout | Acceptable. The managed identity token request uses the same patched `http` stack, so a proxy that breaks IMDS breaks the token anyway | +| Shell/playground token refresh | The audience is re-resolved on every worker request; managed identity with a private host repeats IMDS (2 s timeout) | Acceptable at hourly refresh; consider caching the IMDS outcome for the window | +| Diagnostics provider order and deadline | The Entra provider is registered last and is synchronous; the service deadline is shared across providers | Low risk; it depends on earlier providers declining Azure clusters quickly, which this pass did not trace in detail | +| Managed identity token errors | The provider rethrows a described message; the uncategorized branch keeps the original text, so AADSTS codes generally survive for classification | Acceptable; categorized branches drop the code, but those reasons are not Entra-classifier rules | +| Driver `ALLOWED_HOSTS` enforcement (7.2.0) | Enforced after SRV resolution for callback OIDC unless `ENVIRONMENT` is set; the extension never sets it | Confirms that `ALLOWED_HOSTS` is a real control, which is the basis of R5 | + ## Decisions respected and remaining rollout gates - The service-side preauthorization dependency is acknowledged, not an extension bug to work @@ -213,5 +709,6 @@ VS Code client preauthorization, broker behavior, or a customer's private DNS/pr release claims rather than treating fixture tests as real-cloud validation. - The China compatibility check in D11 is still a shipping gate. This review did not query production telemetry or establish whether existing China connections accept the public audience. + S7 lists the hashed domain values that make the query possible with existing data. - When the Microsoft-side change is available, obtain a reporter-run preview check that covers token acquisition and an actual database command, not merely successful resource listing. From 047465a2bd3b561e92d0069570dd74ba0bd48f99 Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Mon, 26 Oct 2026 09:40:20 +0000 Subject: [PATCH 27/50] fix(auth): reject invalid applicable token resource overrides Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...ware-scope-and-entra-diagnostics-review.md | 48 ++++++++- package.json | 2 + .../auth/documentdbTokenResource.test.ts | 102 ++++++++++++++++-- .../auth/documentdbTokenResource.ts | 45 +++++++- src/settingsContributions.test.ts | 19 ++++ 5 files changed, 199 insertions(+), 17 deletions(-) diff --git a/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md index 96c798fe3..187659d86 100644 --- a/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md +++ b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md @@ -19,7 +19,47 @@ status: active This is a review, not an implementation pass. Product code is unchanged. On 2026-10-26 the operator accepted the recommended option for every finding, R1 to R9, with neutral wording only for R7. S1 to S7 are accepted as follow-up todos. S8 is accepted as preview-build-only work. The -fixes are not implemented yet. +implementation began on 2026-10-26. See the live progress below; the original review and its +evidence are preserved. + +## Implementation progress (2026-10-26) + +The operator requested GPT-6.1 Sol for narrowly scoped coding, an Opus 5.5 review after the fixes +and again after enhancements, separate commits per item, and a Terra final verification run. +Only medium-or-higher findings are in the fix scope; R6 to R9 remain documented low-severity +follow-ups. S1 to S8 remain accepted enhancement work. PR #1050 remains a draft. + +Started at 09:36 UTC. Durations below are wall-clock observations, not model compute time. +Token usage is unavailable unless explicitly reported. Commits are local unless stated otherwise. +The read-only attachment is unchanged; this repository copy is the live progress record. + +| Item | State | Model | Result / commit | +| --- | --- | --- | --- | +| R1 | Complete | GPT-6.1 Sol | 09:38:47-09:40 UTC; 114 focused tests passed; commit recorded below | +| R2 | Pending | GPT-6.1 Sol | Populate safe cloud evidence before branching | +| R3 | Pending | GPT-6.1 Sol | Sovereign and configured host recognition in both credential wizards | +| R4 | Pending | GPT-6.1 Sol | Setting-specific invalid custom-cloud error | +| R5 | Pending | GPT-6.1 Sol | Machine-scoped settings and public-suffix rejection | +| R6-R9 | Deferred (low) | — | Kept documented per the operator's medium-or-higher threshold | +| Fix review | Pending | Opus 5.5 | Review completed R1-R5; fix medium-or-higher findings | +| S1 | Pending | GPT-6.1 Sol | Closed-enum startup telemetry | +| S2 | Pending | GPT-6.1 Sol | Sovereign connection appName | +| S3 | Pending | GPT-6.1 Sol | Safe IMDS environment evidence | +| S4 | Pending | GPT-6.1 Sol | Actionable OIDC allowed-host explanation | +| S5 | Pending | GPT-6.1 Sol | Settings patterns; reuse R1 audience validation | +| S6 | Pending | GPT-6.1 Sol | Real-resolver integration tests | +| S7 | Pending | Orchestrator | Attempt D11 telemetry check; preserve any access blocker explicitly | +| S8 | Pending | GPT-6.1 Sol | Preview-only dialogs on separate `preview/sovereign-diagnostics` branch | +| Enhancement review | Pending | Opus 5.5 | Review enhancements, including isolated preview | +| Full verification | Pending | Terra | Case 1 plus all Case 2 commands, including strict package-test types | + +### Execution log + +- 09:36-09:39 UTC: orchestrator read the supplied review, checked the clean worktree and draft PR, + and mapped implementation, review, preview isolation, and verification work. No code changed. +- R1: GPT-6.1 Sol, 09:38:47-09:40 UTC. IDE runner found no tests; `npx vitest run` + covered resolver, settings contributions, scopes, and copied managed-identity strings: + 4 files / 114 tests passed. Build deferred to the five-fix cluster. Commit hash follows in R2. ## Verdict @@ -135,6 +175,12 @@ fallback crosses clouds, not just sources. The recommended option is correct. Re **Author decision:** accepted (2026-10-26), recommended option. Reject a nonempty invalid override on the paths where it applies, including the second-pass refinements: empty or whitespace-only values count as absent, a tri-state trace value, the expected form in the error, a `package.json` `pattern`, and a warning on copy. +**Implementation (R1):** applicable invalid overrides now fail before login-host, ARM, host, or +IMDS fallback with a localized setting-specific expected-form error. Empty/whitespace values remain +absent; built-in interactive clouds ignore overrides. Traces record `absent|valid|invalid` and +applicability without the configured value. Settings validate resource form; copy warns and keeps +its permissive fallback. Focused resolver, settings, scope, and copy suites: 114 tests passed. + ### R2. P2: Populate cloud evidence before early returns and mismatch errors **Location:** [interactive mismatch handling](../../../../../src/documentdb/auth/documentdbTokenResource.ts#L136) diff --git a/package.json b/package.json index 13302a851..1534b05a8 100644 --- a/package.json +++ b/package.json @@ -1996,6 +1996,8 @@ "properties": { "ossrdbmsEndpoint": { "type": "string", + "pattern": "^\\s*(?:https://[^/?#:@\\\\\\s]+/?)?\\s*$", + "patternErrorMessage": "Use an HTTPS resource such as https://ossrdbms-aad.database.usgovcloudapi.net, with no path or /.default, or leave it empty.", "description": "Sovereign cloud equivalent for https://ossrdbms-aad.database.windows.net/ (the Microsoft Entra resource for open-source databases)." }, "mongoClusterHostnameSuffix": { diff --git a/src/documentdb/auth/documentdbTokenResource.test.ts b/src/documentdb/auth/documentdbTokenResource.test.ts index 23d193ec2..5c2cbfb06 100644 --- a/src/documentdb/auth/documentdbTokenResource.test.ts +++ b/src/documentdb/auth/documentdbTokenResource.test.ts @@ -82,7 +82,8 @@ describe('resolveInteractiveTokenResource', () => { authProvider: 'microsoft', brokerImplementation: 'default', hostCloud: 'none', - customOssrdbmsEndpointSet: false, + customOssrdbmsEndpointState: 'absent', + customOssrdbmsEndpointApplicable: false, customHostnameSuffixSet: false, armMetadata: 'notNeeded', imdsAzEnvironment: 'notQueried', @@ -122,18 +123,26 @@ describe('resolveInteractiveTokenResource', () => { scope: `${customResource}/.default`, cloudCategory: 'custom', reason: 'customOverride', + details: { customOssrdbmsEndpointState: 'valid', customOssrdbmsEndpointApplicable: true }, }); expect(deps.fetchArmMetadata).not.toHaveBeenCalled(); }); - it('does not apply the custom override to a built-in interactive cloud', async () => { - const deps = createDeps(); - vi.mocked(deps.getCustomEnvironment).mockReturnValue({ ossrdbmsEndpoint: customResource }); - expect(await resolveInteractiveTokenResource(privateConnection, deps)).toMatchObject({ - resource: publicResource, - reason: 'setting', - }); - }); + it.each([customResource, 'https://database.example.com/.default'])( + 'does not apply the custom override to a built-in interactive cloud: %s', + async (ossrdbmsEndpoint) => { + const deps = createDeps(); + vi.mocked(deps.getCustomEnvironment).mockReturnValue({ ossrdbmsEndpoint }); + expect(await resolveInteractiveTokenResource(privateConnection, deps)).toMatchObject({ + resource: publicResource, + reason: 'setting', + details: { + customOssrdbmsEndpointState: ossrdbmsEndpoint === customResource ? 'valid' : 'invalid', + customOssrdbmsEndpointApplicable: false, + }, + }); + }, + ); it.each([ ['login.microsoftonline.com', publicResource], @@ -188,17 +197,51 @@ describe('resolveInteractiveTokenResource', () => { }); it.each(['http://database.example.com', 'https://database.example.com/path', 'not a URL'])( - 'does not use an invalid custom override: %s', + 'rejects an invalid custom override before ARM metadata: %s', async (override) => { const deps = createDeps(); useCustomCloud(deps); vi.mocked(deps.getCustomEnvironment).mockReturnValue({ ossrdbmsEndpoint: override }); + vi.mocked(deps.fetchArmMetadata).mockResolvedValue({ ossrDbmsResourceId: customResource }); await expect(resolveInteractiveTokenResource(privateConnection, deps)).rejects.toMatchObject({ - kind: 'customCloudUnresolved', + kind: 'invalidCustomOverride', + message: expect.stringContaining('documentDB.customEnvironment.ossrdbmsEndpoint'), + details: { + customOssrdbmsEndpointState: 'invalid', + customOssrdbmsEndpointApplicable: true, + }, }); + expect(deps.fetchArmMetadata).not.toHaveBeenCalled(); }, ); + it('rejects an invalid override before a recognized custom login host', async () => { + const deps = createDeps(); + vi.mocked(deps.getConfiguredCloud).mockReturnValue({ + name: 'custom', + isCustomCloud: true, + activeDirectoryEndpointUrl: 'https://login.microsoftonline.us/', + }); + vi.mocked(deps.getCustomEnvironment).mockReturnValue({ ossrdbmsEndpoint: `${publicResource}/.default` }); + await expect(resolveInteractiveTokenResource('mongodb://cluster.azure.us/', deps)).rejects.toMatchObject({ + kind: 'invalidCustomOverride', + message: expect.stringContaining('https://ossrdbms-aad.database.usgovcloudapi.net'), + }); + expect(deps.fetchArmMetadata).not.toHaveBeenCalled(); + }); + + it.each([undefined, '', ' \t '])('treats an empty override as absent: %s', async (ossrdbmsEndpoint) => { + const deps = createDeps(); + useCustomCloud(deps); + vi.mocked(deps.getCustomEnvironment).mockReturnValue({ ossrdbmsEndpoint }); + vi.mocked(deps.fetchArmMetadata).mockResolvedValue({ ossrDbmsResourceId: customResource }); + expect(await resolveInteractiveTokenResource(privateConnection, deps)).toMatchObject({ + resource: customResource, + reason: 'armMetadata', + details: { customOssrdbmsEndpointState: 'absent', customOssrdbmsEndpointApplicable: true }, + }); + }); + it('fails clearly when custom is selected without a custom cloud definition', async () => { const deps = createDeps(); vi.mocked(deps.getConfiguredCloud).mockImplementation(() => { @@ -291,6 +334,30 @@ describe('resolveInteractiveTokenResource', () => { }); describe('resolveManagedIdentityTokenResource', () => { + it.each(['mongodb://cluster.azure.us/', privateConnection])( + 'rejects an invalid override before host or IMDS fallback: %s', + async (connectionString) => { + const deps = createDeps(); + vi.mocked(deps.getCustomEnvironment).mockReturnValue({ ossrdbmsEndpoint: `${publicResource}/.default` }); + vi.mocked(deps.readImdsAzEnvironment).mockResolvedValue('AZUREPUBLICCLOUD'); + await expect(resolveManagedIdentityTokenResource(connectionString, deps)).rejects.toMatchObject({ + kind: 'invalidCustomOverride', + details: { customOssrdbmsEndpointState: 'invalid', customOssrdbmsEndpointApplicable: true }, + }); + expect(deps.readImdsAzEnvironment).not.toHaveBeenCalled(); + }, + ); + + it.each([undefined, '', ' \t '])('treats an empty override as absent: %s', async (ossrdbmsEndpoint) => { + const deps = createDeps(); + vi.mocked(deps.getCustomEnvironment).mockReturnValue({ ossrdbmsEndpoint }); + expect(await resolveManagedIdentityTokenResource('mongodb://cluster.azure.com/', deps)).toMatchObject({ + resource: publicResource, + reason: 'host', + details: { customOssrdbmsEndpointState: 'absent', customOssrdbmsEndpointApplicable: true }, + }); + }); + it('uses the override before the host and never reads the VS Code cloud setting', async () => { const deps = createDeps(); vi.mocked(deps.getCustomEnvironment).mockReturnValue({ ossrdbmsEndpoint: `${customResource}/` }); @@ -358,6 +425,19 @@ describe('resolveManagedIdentityTokenResource', () => { }); describe('resolveTokenResourceForCopy', () => { + it('warns and preserves the permissive fallback for an invalid override', () => { + const deps = createDeps(); + vi.mocked(deps.getCustomEnvironment).mockReturnValue({ ossrdbmsEndpoint: `${publicResource}/.default` }); + expect(resolveTokenResourceForCopy('mongodb://cluster.azure.us/', deps)).toMatchObject({ + resource: governmentResource, + reason: 'host', + details: { customOssrdbmsEndpointState: 'invalid', customOssrdbmsEndpointApplicable: true }, + }); + expect(deps.warn).toHaveBeenCalledWith( + expect.stringContaining('documentDB.customEnvironment.ossrdbmsEndpoint'), + ); + }); + it('prefers the override to the host', () => { const deps = createDeps(); vi.mocked(deps.getCustomEnvironment).mockReturnValue({ ossrdbmsEndpoint: customResource }); diff --git a/src/documentdb/auth/documentdbTokenResource.ts b/src/documentdb/auth/documentdbTokenResource.ts index 13a845e46..9ce822f46 100644 --- a/src/documentdb/auth/documentdbTokenResource.ts +++ b/src/documentdb/auth/documentdbTokenResource.ts @@ -65,7 +65,11 @@ export interface TokenResourceResolverDeps { warn(message: string): void; } -export type TokenResourceErrorKind = 'customCloudUnresolved' | 'cloudMismatch' | 'managedIdentityCloudUnknown'; +export type TokenResourceErrorKind = + | 'customCloudUnresolved' + | 'invalidCustomOverride' + | 'cloudMismatch' + | 'managedIdentityCloudUnknown'; /** A failure to choose a token audience. The message is user-facing and already actionable. */ export class TokenResourceResolutionError extends Error { @@ -168,7 +172,7 @@ export async function resolveManagedIdentityTokenResource( const customEnvironment = deps.getCustomEnvironment(); const details = createResolutionDetails('managedIdentity', customEnvironment, deps); getConfiguredCloudForDetails(deps, details); - const override = parseTokenResource(customEnvironment.ossrdbmsEndpoint); + const override = getApplicableOverride(customEnvironment, details); if (override) { return withDetails(fromResource(override, 'customOverride'), details); } @@ -206,15 +210,23 @@ export function resolveTokenResourceForCopy( connectionString: string, deps: Pick< TokenResourceResolverDeps, - 'getConfiguredCloud' | 'getCustomEnvironment' | 'getSignInEnvironment' + 'getConfiguredCloud' | 'getCustomEnvironment' | 'getSignInEnvironment' | 'warn' > = getDefaultDeps(), ): ResolvedTokenResource { const customEnvironment = deps.getCustomEnvironment(); const details = createResolutionDetails('copy', customEnvironment, deps); + details.customOssrdbmsEndpointApplicable = true; const override = parseTokenResource(customEnvironment.ossrdbmsEndpoint); if (override) { return withDetails(fromResource(override, 'customOverride'), details); } + if (details.customOssrdbmsEndpointState === 'invalid') { + deps.warn( + l10n.t('Ignoring invalid "{setting}" while copying the connection string; using the fallback resource.', { + setting: OSSRDBMS_ENDPOINT_SETTING, + }), + ); + } const hostCloud = getAzureCloudFromHosts(connectionString); details.hostCloud = hostCloud ?? 'none'; @@ -265,7 +277,7 @@ async function resolveCustomCloud( deps: TokenResourceResolverDeps, details: Record, ): Promise> { - const override = parseTokenResource(customEnvironment.ossrdbmsEndpoint); + const override = getApplicableOverride(customEnvironment, details); if (override) { return fromResource(override, 'customOverride'); } @@ -292,6 +304,24 @@ async function resolveCustomCloud( ); } +function getApplicableOverride( + customEnvironment: DocumentDBCustomEnvironment, + details: Record, +): string | undefined { + details.customOssrdbmsEndpointApplicable = true; + if (details.customOssrdbmsEndpointState === 'invalid') { + throw resolutionError( + l10n.t( + '"{setting}" must be an HTTPS resource such as "https://ossrdbms-aad.database.usgovcloudapi.net", with no path or "/.default". Clear the setting to use automatic resource selection.', + { setting: OSSRDBMS_ENDPOINT_SETTING }, + ), + 'invalidCustomOverride', + details, + ); + } + return parseTokenResource(customEnvironment.ossrdbmsEndpoint); +} + async function lookUpArmMetadata( resourceManagerEndpointUrl: string | undefined, deps: TokenResourceResolverDeps, @@ -397,7 +427,12 @@ function createResolutionDetails( authProvider, brokerImplementation, hostCloud: 'none', - customOssrdbmsEndpointSet: !!parseTokenResource(customEnvironment.ossrdbmsEndpoint), + customOssrdbmsEndpointState: !customEnvironment.ossrdbmsEndpoint?.trim() + ? 'absent' + : parseTokenResource(customEnvironment.ossrdbmsEndpoint) + ? 'valid' + : 'invalid', + customOssrdbmsEndpointApplicable: false, customHostnameSuffixSet: !!normalizeCustomHostnameSuffix(customEnvironment.mongoClusterHostnameSuffix), armMetadata: 'notNeeded', imdsAzEnvironment: 'notQueried', diff --git a/src/settingsContributions.test.ts b/src/settingsContributions.test.ts index 10180c60c..1750a99af 100644 --- a/src/settingsContributions.test.ts +++ b/src/settingsContributions.test.ts @@ -35,6 +35,25 @@ function constantKeys(): string[] { } describe('settings contributions', () => { + it('validates audience overrides in Settings while allowing an empty setting', () => { + const setting = packageJson.contributes.configuration + .flatMap((node) => Object.entries(node.properties)) + .find(([key]) => key === settingsKeys.customEnvironment)?.[1]; + expect(setting).toBeDefined(); + if (!setting || !('properties' in setting)) { + throw new Error('Missing custom environment schema'); + } + const schema = setting.properties.ossrdbmsEndpoint; + const pattern = new RegExp(schema.pattern); + expect(schema.patternErrorMessage).toContain('https://ossrdbms-aad.database.usgovcloudapi.net'); + for (const value of ['', ' \t ', 'https://database.example.com', ' https://database.example.com/ ']) { + expect(pattern.test(value), value).toBe(true); + } + for (const value of ['http://database.example.com', 'https://database.example.com/.default', 'not a URL']) { + expect(pattern.test(value), value).toBe(false); + } + }); + describe('table of contents grouping', () => { // A node titled the same as the extension has its settings hoisted onto the parent // entry instead of becoming an expandable child, collapsing the whole grouping. From 2e27b5583d2e622d1b06bd7ac319eb56f52dff09 Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Mon, 26 Oct 2026 09:41:33 +0000 Subject: [PATCH 28/50] fix(auth): retain cloud evidence on all resolution paths Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...ware-scope-and-entra-diagnostics-review.md | 16 +++++- .../auth/MicrosoftEntraIDAuthHandler.test.ts | 55 +++++++++++++++++++ .../auth/documentdbTokenResource.test.ts | 37 +++++++++++++ .../auth/documentdbTokenResource.ts | 14 ++--- 4 files changed, 112 insertions(+), 10 deletions(-) diff --git a/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md index 187659d86..6a9fed40f 100644 --- a/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md +++ b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md @@ -35,8 +35,8 @@ The read-only attachment is unchanged; this repository copy is the live progress | Item | State | Model | Result / commit | | --- | --- | --- | --- | -| R1 | Complete | GPT-6.1 Sol | 09:38:47-09:40 UTC; 114 focused tests passed; commit recorded below | -| R2 | Pending | GPT-6.1 Sol | Populate safe cloud evidence before branching | +| R1 | Complete | GPT-6.1 Sol | 09:38:47-09:40:20 UTC; 114 focused tests passed; `047465a2bd3b561e92d0069570dd74ba0bd48f99` | +| R2 | Complete | GPT-6.1 Sol | 09:40:20-09:41:21 UTC; 95 focused tests passed; commit recorded in R4 | | R3 | Pending | GPT-6.1 Sol | Sovereign and configured host recognition in both credential wizards | | R4 | Pending | GPT-6.1 Sol | Setting-specific invalid custom-cloud error | | R5 | Pending | GPT-6.1 Sol | Machine-scoped settings and public-suffix rejection | @@ -59,7 +59,11 @@ The read-only attachment is unchanged; this repository copy is the live progress and mapped implementation, review, preview isolation, and verification work. No code changed. - R1: GPT-6.1 Sol, 09:38:47-09:40 UTC. IDE runner found no tests; `npx vitest run` covered resolver, settings contributions, scopes, and copied managed-identity strings: - 4 files / 114 tests passed. Build deferred to the five-fix cluster. Commit hash follows in R2. + 4 files / 114 tests passed. Build deferred to the five-fix cluster. + Commit: `047465a2bd3b561e92d0069570dd74ba0bd48f99`. +- R2: GPT-6.1 Sol, 09:40:20-09:41:21 UTC. Resolver, interactive handler, and copied + managed-identity string suites: 3 files / 95 tests passed, including real-resolver emitted + resolved/unresolved records. Commit hash follows in R4. ## Verdict @@ -240,6 +244,12 @@ in that state. See R4. **Author decision:** accepted (2026-10-26), recommended option. Classify the host first in every flow, including copy, and build the mismatch error from the merged resolution details. +**Implementation (R2):** interactive, managed identity, and copy classify hosts before branching. +Mismatch errors now carry the candidate's resource, reason, and cloud category. Focused tests +cover override early returns, unresolved custom clouds, and complete emitted resolved/unresolved +trace objects through the interactive handler using the real resolver: 95 tests passed. No host, +token, or raw error is added to traces; audience precedence and D8 are unchanged. + ## Second-pass findings ### R3. P1: Recognize Government and China DocumentDB hosts so managed identity is offered diff --git a/src/documentdb/auth/MicrosoftEntraIDAuthHandler.test.ts b/src/documentdb/auth/MicrosoftEntraIDAuthHandler.test.ts index 257d5d301..7f4ee44c7 100644 --- a/src/documentdb/auth/MicrosoftEntraIDAuthHandler.test.ts +++ b/src/documentdb/auth/MicrosoftEntraIDAuthHandler.test.ts @@ -128,6 +128,61 @@ describe('MicrosoftEntraIDAuthHandler', () => { expect(getRecentEntraTokenFailure('cluster-1')).toBeUndefined(); }); + it.each([false, true])('emits complete real-resolver cloud evidence (mismatch: %s)', async (mismatch) => { + const actual = await vi.importActual('./documentdbTokenResource'); + const resource = mismatch + ? 'https://database.example.com' + : 'https://ossrdbms-aad.database.usgovcloudapi.net'; + const deps: documentdbTokenResource.TokenResourceResolverDeps = { + getConfiguredCloud: () => ({ name: 'custom', isCustomCloud: true }), + getCustomEnvironment: () => ({ ossrdbmsEndpoint: resource }), + getSignInEnvironment: () => ({ + authProvider: 'microsoft-sovereign-cloud', + brokerImplementation: undefined, + }), + fetchArmMetadata: vi.fn(), + readImdsAzEnvironment: vi.fn(), + warn: vi.fn(), + }; + vi.mocked(resolveInteractiveTokenResource).mockImplementationOnce((connectionString) => + actual.resolveInteractiveTokenResource(connectionString, deps), + ); + const result = new MicrosoftEntraIDAuthHandler( + buildCredentials('mongodb://private-identifier.azure.us/'), + ).configureAuth(); + if (mismatch) { + await expect(result).rejects.toMatchObject({ kind: 'cloudMismatch' }); + expect(getSessionFromVSCode).not.toHaveBeenCalled(); + } else { + await result; + } + const trace = mockOutputChannel.info.mock.calls + .map((call) => String(call[0])) + .find((message) => message.includes(mismatch ? 'entraCloud.unresolved' : 'entraCloud.resolved')); + if (!trace) { + throw new Error('Missing cloud resolution trace'); + } + const details: unknown = JSON.parse(trace.slice(trace.indexOf(': ') + 2)); + expect(details).toEqual({ + flow: 'interactive', + configuredCloud: 'custom', + authProvider: 'microsoft-sovereign-cloud', + brokerImplementation: 'default', + hostCloud: 'AzureUSGovernment', + customOssrdbmsEndpointState: 'valid', + customOssrdbmsEndpointApplicable: true, + customHostnameSuffixSet: false, + armMetadata: 'notNeeded', + imdsAzEnvironment: 'notQueried', + cloudCategory: mismatch ? 'custom' : 'usgov', + reason: 'customOverride', + tokenResource: resource, + ...(mismatch ? { errorKind: 'cloudMismatch' } : {}), + }); + expect(trace).not.toContain('private-identifier'); + expect(trace).not.toContain('access-token'); + }); + it.each(['public', 'usgov', 'china', 'custom'] as const)( 'records a failed token acquisition in %s without changing the error', async (cloudCategory): Promise => { diff --git a/src/documentdb/auth/documentdbTokenResource.test.ts b/src/documentdb/auth/documentdbTokenResource.test.ts index 5c2cbfb06..7130a4b5e 100644 --- a/src/documentdb/auth/documentdbTokenResource.test.ts +++ b/src/documentdb/auth/documentdbTokenResource.test.ts @@ -322,6 +322,27 @@ describe('resolveInteractiveTokenResource', () => { }); }); + it('retains examined host evidence when custom resolution fails', async () => { + const deps = createDeps(); + useCustomCloud(deps); + await expect(resolveInteractiveTokenResource('mongodb://cluster.azure.us/', deps)).rejects.toMatchObject({ + kind: 'customCloudUnresolved', + details: { + flow: 'interactive', + configuredCloud: 'custom', + authProvider: 'microsoft', + brokerImplementation: 'default', + hostCloud: 'AzureUSGovernment', + customOssrdbmsEndpointState: 'absent', + customOssrdbmsEndpointApplicable: true, + customHostnameSuffixSet: false, + armMetadata: 'unavailable', + imdsAzEnvironment: 'notQueried', + errorKind: 'customCloudUnresolved', + }, + }); + }); + it('warns when a custom cloud explicitly overrides the resource to public', async () => { const deps = createDeps(); useCustomCloud(deps); @@ -364,6 +385,21 @@ describe('resolveManagedIdentityTokenResource', () => { expect(await resolveManagedIdentityTokenResource('mongodb://cluster.azure.us/', deps)).toMatchObject({ resource: customResource, reason: 'customOverride', + details: { + flow: 'managedIdentity', + configuredCloud: 'AzureCloud', + authProvider: 'microsoft', + brokerImplementation: 'default', + hostCloud: 'AzureUSGovernment', + customOssrdbmsEndpointState: 'valid', + customOssrdbmsEndpointApplicable: true, + customHostnameSuffixSet: false, + armMetadata: 'notNeeded', + imdsAzEnvironment: 'notQueried', + cloudCategory: 'custom', + reason: 'customOverride', + tokenResource: customResource, + }, }); expect(deps.getConfiguredCloud).toHaveBeenCalledTimes(1); expect(deps.readImdsAzEnvironment).not.toHaveBeenCalled(); @@ -444,6 +480,7 @@ describe('resolveTokenResourceForCopy', () => { expect(resolveTokenResourceForCopy('mongodb://cluster.azure.us/', deps)).toMatchObject({ resource: customResource, reason: 'customOverride', + details: { hostCloud: 'AzureUSGovernment' }, }); }); diff --git a/src/documentdb/auth/documentdbTokenResource.ts b/src/documentdb/auth/documentdbTokenResource.ts index 9ce822f46..b8fe48740 100644 --- a/src/documentdb/auth/documentdbTokenResource.ts +++ b/src/documentdb/auth/documentdbTokenResource.ts @@ -116,8 +116,10 @@ export async function resolveInteractiveTokenResource( connectionString: string, deps: TokenResourceResolverDeps = getDefaultDeps(), ): Promise { + const hostCloud = getAzureCloudFromHosts(connectionString); const customEnvironment = deps.getCustomEnvironment(); const details = createResolutionDetails('interactive', customEnvironment, deps); + details.hostCloud = hostCloud ?? 'none'; const configured = getConfiguredCloudForDetails(deps, details); const resolved = configured ? configured.isCustomCloud @@ -137,8 +139,6 @@ export async function resolveInteractiveTokenResource( ); } - const hostCloud = getAzureCloudFromHosts(connectionString); - details.hostCloud = hostCloud ?? 'none'; const audienceCloud = getCloudForTokenResource(resolved.resource); if (hostCloud && hostCloud !== audienceCloud) { throw resolutionError( @@ -151,7 +151,7 @@ export async function resolveInteractiveTokenResource( }, ), 'cloudMismatch', - details, + withDetails(resolved, details).details, ); } @@ -169,16 +169,16 @@ export async function resolveManagedIdentityTokenResource( connectionString: string, deps: TokenResourceResolverDeps = getDefaultDeps(), ): Promise { + const hostCloud = getAzureCloudFromHosts(connectionString); const customEnvironment = deps.getCustomEnvironment(); const details = createResolutionDetails('managedIdentity', customEnvironment, deps); + details.hostCloud = hostCloud ?? 'none'; getConfiguredCloudForDetails(deps, details); const override = getApplicableOverride(customEnvironment, details); if (override) { return withDetails(fromResource(override, 'customOverride'), details); } - const hostCloud = getAzureCloudFromHosts(connectionString); - details.hostCloud = hostCloud ?? 'none'; if (hostCloud) { return withDetails(fromBuiltInCloud(hostCloud, 'host'), details); } @@ -213,8 +213,10 @@ export function resolveTokenResourceForCopy( 'getConfiguredCloud' | 'getCustomEnvironment' | 'getSignInEnvironment' | 'warn' > = getDefaultDeps(), ): ResolvedTokenResource { + const hostCloud = getAzureCloudFromHosts(connectionString); const customEnvironment = deps.getCustomEnvironment(); const details = createResolutionDetails('copy', customEnvironment, deps); + details.hostCloud = hostCloud ?? 'none'; details.customOssrdbmsEndpointApplicable = true; const override = parseTokenResource(customEnvironment.ossrdbmsEndpoint); if (override) { @@ -228,8 +230,6 @@ export function resolveTokenResourceForCopy( ); } - const hostCloud = getAzureCloudFromHosts(connectionString); - details.hostCloud = hostCloud ?? 'none'; if (hostCloud) { return withDetails(fromBuiltInCloud(hostCloud, 'host'), details); } From f1bc8bffeb0d6106d5f4182767f5d7219884bd65 Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Mon, 26 Oct 2026 09:42:14 +0000 Subject: [PATCH 29/50] fix(auth): explain invalid VS Code custom cloud definitions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...ware-scope-and-entra-diagnostics-review.md | 15 +++- .../auth/documentdbTokenResource.test.ts | 69 ++++++++++++++++++- .../auth/documentdbTokenResource.ts | 18 +++-- 3 files changed, 93 insertions(+), 9 deletions(-) diff --git a/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md index 6a9fed40f..767fbfac5 100644 --- a/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md +++ b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md @@ -36,9 +36,9 @@ The read-only attachment is unchanged; this repository copy is the live progress | Item | State | Model | Result / commit | | --- | --- | --- | --- | | R1 | Complete | GPT-6.1 Sol | 09:38:47-09:40:20 UTC; 114 focused tests passed; `047465a2bd3b561e92d0069570dd74ba0bd48f99` | -| R2 | Complete | GPT-6.1 Sol | 09:40:20-09:41:21 UTC; 95 focused tests passed; commit recorded in R4 | +| R2 | Complete | GPT-6.1 Sol | 09:40:20-09:41:21 UTC; 95 focused tests passed; `2e27b5583d2e622d1b06bd7ac319eb56f52dff09` | | R3 | Pending | GPT-6.1 Sol | Sovereign and configured host recognition in both credential wizards | -| R4 | Pending | GPT-6.1 Sol | Setting-specific invalid custom-cloud error | +| R4 | Complete | GPT-6.1 Sol | 09:41:33-09:42:03 UTC; 118 focused tests passed; commit recorded in R5 | | R5 | Pending | GPT-6.1 Sol | Machine-scoped settings and public-suffix rejection | | R6-R9 | Deferred (low) | — | Kept documented per the operator's medium-or-higher threshold | | Fix review | Pending | Opus 5.5 | Review completed R1-R5; fix medium-or-higher findings | @@ -63,7 +63,9 @@ The read-only attachment is unchanged; this repository copy is the live progress Commit: `047465a2bd3b561e92d0069570dd74ba0bd48f99`. - R2: GPT-6.1 Sol, 09:40:20-09:41:21 UTC. Resolver, interactive handler, and copied managed-identity string suites: 3 files / 95 tests passed, including real-resolver emitted - resolved/unresolved records. Commit hash follows in R4. + resolved/unresolved records. Commit: `2e27b5583d2e622d1b06bd7ac319eb56f52dff09`. +- R4: GPT-6.1 Sol, 09:41:33-09:42:03 UTC. Resolver, interactive/managed-identity handlers, + and copied managed-identity string suites: 4 files / 118 tests passed. Commit hash follows in R5. ## Verdict @@ -320,6 +322,13 @@ override-plus-`azure.com` case. **Author decision:** accepted (2026-10-26), recommended option. Add a new error kind for an unreadable custom cloud definition that names `microsoft-sovereign-cloud.customEnvironment` and its required fields, raised before audience selection. +**Implementation (R4):** interactive resolution now raises `customCloudDefinitionInvalid` before +audience selection when the VS Code cloud definition is unreadable. Its localized message names +`microsoft-sovereign-cloud.customEnvironment` and all six SDK-required fields. Tests exercise the +real SDK with an absent definition and missing `portalUrl`, including an override with a public +host, and preserve managed identity's override/host/IMDS independence from unreadable VS Code +settings. No SDK error text enters traces. Focused suites: 118 tests passed. + ### R5. P2: Do not let a workspace widen the OIDC token allowlist **Location:** [`documentDB.customEnvironment` contribution](../../../../../package.json#L1990) diff --git a/src/documentdb/auth/documentdbTokenResource.test.ts b/src/documentdb/auth/documentdbTokenResource.test.ts index 7130a4b5e..e360d8463 100644 --- a/src/documentdb/auth/documentdbTokenResource.test.ts +++ b/src/documentdb/auth/documentdbTokenResource.test.ts @@ -251,10 +251,50 @@ describe('resolveInteractiveTokenResource', () => { TokenResourceResolutionError, ); await expect(resolveInteractiveTokenResource(privateConnection, deps)).rejects.toMatchObject({ - kind: 'customCloudUnresolved', + kind: 'customCloudDefinitionInvalid', }); }); + it.each([undefined, 'portalUrl'])( + 'names the custom-cloud setting when the SDK rejects its definition (missing: %s)', + async (missingField) => { + const definition = { + name: 'private-custom-name', + portalUrl: 'https://portal.example.com', + managementEndpointUrl: 'https://management.example.com', + resourceManagerEndpointUrl: 'https://management.example.com', + activeDirectoryEndpointUrl: 'https://login.microsoftonline.us', + activeDirectoryResourceId: 'https://management.example.com', + }; + const customEnvironment = missingField + ? Object.fromEntries(Object.entries(definition).filter(([key]) => key !== missingField)) + : undefined; + vi.spyOn(vscode.workspace, 'getConfiguration').mockReturnValue({ + get: (key: string): unknown => (key === 'environment' ? 'custom' : customEnvironment), + } as vscode.WorkspaceConfiguration); + const deps = createDeps(); + deps.getConfiguredCloud = getConfiguredAzureEnv; + vi.mocked(deps.getCustomEnvironment).mockReturnValue({ ossrdbmsEndpoint: governmentResource }); + + const result = resolveInteractiveTokenResource('mongodb://cluster.azure.com/', deps); + await expect(result).rejects.toMatchObject({ + kind: 'customCloudDefinitionInvalid', + message: expect.stringContaining('microsoft-sovereign-cloud.customEnvironment'), + details: { + configuredCloud: 'unreadable', + hostCloud: 'AzureCloud', + errorKind: 'customCloudDefinitionInvalid', + }, + }); + for (const field of Object.keys(definition)) { + await expect(result).rejects.toThrow(field); + } + await expect(result).rejects.not.toThrow('VS Code is signed in'); + expect(deps.fetchArmMetadata).not.toHaveBeenCalled(); + expect(deps.readImdsAzEnvironment).not.toHaveBeenCalled(); + }, + ); + it('caches successful metadata per Resource Manager endpoint', async () => { const deps = createDeps(); useCustomCloud(deps); @@ -355,6 +395,33 @@ describe('resolveInteractiveTokenResource', () => { }); describe('resolveManagedIdentityTokenResource', () => { + it.each(['override', 'host', 'imds'] as const)( + 'does not require readable VS Code cloud configuration for %s selection', + async (source) => { + const deps = createDeps(); + vi.mocked(deps.getConfiguredCloud).mockImplementation(() => { + throw new Error('private SDK configuration error'); + }); + vi.mocked(deps.getSignInEnvironment).mockImplementation(() => { + throw new Error('private SDK configuration error'); + }); + vi.mocked(deps.getCustomEnvironment).mockReturnValue( + source === 'override' ? { ossrdbmsEndpoint: governmentResource } : {}, + ); + vi.mocked(deps.readImdsAzEnvironment).mockResolvedValue('AZUREPUBLICCLOUD'); + const resolution = await resolveManagedIdentityTokenResource( + source === 'host' ? 'mongodb://cluster.azure.us/' : privateConnection, + deps, + ); + expect(resolution).toMatchObject({ + resource: source === 'imds' ? publicResource : governmentResource, + reason: source === 'override' ? 'customOverride' : source, + details: { configuredCloud: 'unreadable', authProvider: 'unknown' }, + }); + expect(JSON.stringify(resolution.details)).not.toContain('private SDK configuration error'); + }, + ); + it.each(['mongodb://cluster.azure.us/', privateConnection])( 'rejects an invalid override before host or IMDS fallback: %s', async (connectionString) => { diff --git a/src/documentdb/auth/documentdbTokenResource.ts b/src/documentdb/auth/documentdbTokenResource.ts index b8fe48740..3548ec835 100644 --- a/src/documentdb/auth/documentdbTokenResource.ts +++ b/src/documentdb/auth/documentdbTokenResource.ts @@ -67,6 +67,7 @@ export interface TokenResourceResolverDeps { export type TokenResourceErrorKind = | 'customCloudUnresolved' + | 'customCloudDefinitionInvalid' | 'invalidCustomOverride' | 'cloudMismatch' | 'managedIdentityCloudUnknown'; @@ -121,11 +122,18 @@ export async function resolveInteractiveTokenResource( const details = createResolutionDetails('interactive', customEnvironment, deps); details.hostCloud = hostCloud ?? 'none'; const configured = getConfiguredCloudForDetails(deps, details); - const resolved = configured - ? configured.isCustomCloud - ? await resolveCustomCloud(configured, customEnvironment, deps, details) - : fromBuiltInCloud(toBuiltInCloud(configured.name), 'setting') - : await resolveCustomCloud({ name: 'custom', isCustomCloud: true }, customEnvironment, deps, details); + if (!configured) { + throw resolutionError( + l10n.t( + 'The VS Code custom cloud definition could not be read. Configure "microsoft-sovereign-cloud.customEnvironment" with all six required fields: name, portalUrl, managementEndpointUrl, resourceManagerEndpointUrl, activeDirectoryEndpointUrl, activeDirectoryResourceId. Then connect again.', + ), + 'customCloudDefinitionInvalid', + details, + ); + } + const resolved = configured.isCustomCloud + ? await resolveCustomCloud(configured, customEnvironment, deps, details) + : fromBuiltInCloud(toBuiltInCloud(configured.name), 'setting'); if ( configured?.isCustomCloud && From ecd0995babb996863cd2bfb71b9181941a347228 Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Mon, 26 Oct 2026 09:44:40 +0000 Subject: [PATCH 30/50] fix(auth): harden custom OIDC hostname suffix settings Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../features/sovereign-clouds/README.md | 4 ++- ...ware-scope-and-entra-diagnostics-review.md | 18 ++++++++++--- package.json | 2 +- .../copyConnectionString.test.ts | 5 +++- .../auth/ManagedIdentityAuthHandler.test.ts | 5 ++++ .../auth/MicrosoftEntraIDAuthHandler.test.ts | 2 ++ .../auth/documentdbTokenResource.test.ts | 25 +++++++++++++++++++ .../auth/documentdbTokenResource.ts | 21 ++++++++++++++-- src/documentdb/auth/oidcAllowedHosts.test.ts | 14 +++++++++++ src/documentdb/auth/oidcAllowedHosts.ts | 5 +++- src/settingsContributions.test.ts | 5 ++++ 11 files changed, 97 insertions(+), 9 deletions(-) diff --git a/docs/ai-and-plans/features/sovereign-clouds/README.md b/docs/ai-and-plans/features/sovereign-clouds/README.md index 3e073d435..b216b8236 100644 --- a/docs/ai-and-plans/features/sovereign-clouds/README.md +++ b/docs/ai-and-plans/features/sovereign-clouds/README.md @@ -67,7 +67,9 @@ readable without a support round trip. - [managedIdentityConnectionString.ts](../../../../src/documentdb/auth/managedIdentityConnectionString.ts): writes a best-effort cloud-aware `TOKEN_RESOURCE` into copied connection strings. - [oidcAllowedHosts.ts](../../../../src/documentdb/auth/oidcAllowedHosts.ts): already accepts - `*.azure.us` and `*.azure.cn` hosts (#639, #934). + `*.azure.us` and `*.azure.cn` hosts (#639, #934). Custom suffixes come from the machine-scoped + `documentDB.customEnvironment` object, not workspace overrides; bare TLDs and common two-label + public suffixes are rejected. - [entraErrors/](../../../../src/documentdb/auth/entraErrors/): the single classifier table, pure text classifier, recent token-failure registry, and Entra diagnostics provider. - [connectionDiagnosticsService.ts](../../../../src/services/connectionDiagnosticsService.ts): the diff --git a/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md index 767fbfac5..6a83210bc 100644 --- a/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md +++ b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md @@ -38,8 +38,8 @@ The read-only attachment is unchanged; this repository copy is the live progress | R1 | Complete | GPT-6.1 Sol | 09:38:47-09:40:20 UTC; 114 focused tests passed; `047465a2bd3b561e92d0069570dd74ba0bd48f99` | | R2 | Complete | GPT-6.1 Sol | 09:40:20-09:41:21 UTC; 95 focused tests passed; `2e27b5583d2e622d1b06bd7ac319eb56f52dff09` | | R3 | Pending | GPT-6.1 Sol | Sovereign and configured host recognition in both credential wizards | -| R4 | Complete | GPT-6.1 Sol | 09:41:33-09:42:03 UTC; 118 focused tests passed; commit recorded in R5 | -| R5 | Pending | GPT-6.1 Sol | Machine-scoped settings and public-suffix rejection | +| R4 | Complete | GPT-6.1 Sol | 09:41:33-09:42:03 UTC; 118 focused tests passed; `f1bc8bffeb0d6106d5f4182767f5d7219884bd65` | +| R5 | Complete | GPT-6.1 Sol | 09:42:14-09:44:14 UTC; 174 focused tests passed; commit recorded in R3 | | R6-R9 | Deferred (low) | — | Kept documented per the operator's medium-or-higher threshold | | Fix review | Pending | Opus 5.5 | Review completed R1-R5; fix medium-or-higher findings | | S1 | Pending | GPT-6.1 Sol | Closed-enum startup telemetry | @@ -65,7 +65,12 @@ The read-only attachment is unchanged; this repository copy is the live progress managed-identity string suites: 3 files / 95 tests passed, including real-resolver emitted resolved/unresolved records. Commit: `2e27b5583d2e622d1b06bd7ac319eb56f52dff09`. - R4: GPT-6.1 Sol, 09:41:33-09:42:03 UTC. Resolver, interactive/managed-identity handlers, - and copied managed-identity string suites: 4 files / 118 tests passed. Commit hash follows in R5. + and copied managed-identity string suites: 4 files / 118 tests passed. + Commit: `f1bc8bffeb0d6106d5f4182767f5d7219884bd65`. +- R5: GPT-6.1 Sol, 09:42:14-09:44:14 UTC. Allowlist, resolver, both auth handlers, settings, + and copy-command suites: 6 files / 174 tests passed. Initial copy and managed-identity handler + failures identified configuration mocks missing the newly used `inspect` API; updated only those + mock APIs and reran successfully. No `TDD:` contract failed. Commit hash follows in R3. ## Verdict @@ -362,6 +367,13 @@ hardening, not parity. **Author decision:** accepted (2026-10-26), recommended option. Use `machine` scope for `documentDB.customEnvironment`, covering both properties. Reject two-label public-suffix patterns and trace `customHostnameSuffixSource`. +**Implementation (R5):** the entire `documentDB.customEnvironment` object is machine scoped; +the settings contract test pins this rather than adding another settings reader. Suffix normalization +rejects `com|co|net|org|gov.` after case/dot normalization while retaining narrower +service suffixes. Traces report only `user|workspace|none` from configuration inspection, never raw +suffixes. Focused allowlist, source-trace, manifest scope, handler, and copy tests: 174 passed. +This is targeted pattern hardening, not a complete public-suffix database. + ### R6. P3: Bound the AADSTS code match **Location:** [`classifyEntraError`](../../../../../src/documentdb/auth/entraErrors/classifyEntraError.ts#L22). diff --git a/package.json b/package.json index 1534b05a8..cca63dacd 100644 --- a/package.json +++ b/package.json @@ -1991,7 +1991,7 @@ "order": 5, "type": "object", "default": {}, - "scope": "machine-overridable", + "scope": "machine", "markdownDescription": "Additional DocumentDB-specific configuration for a custom sovereign cloud. Use it together with `#microsoft-sovereign-cloud.environment#` set to `custom` and `#microsoft-sovereign-cloud.customEnvironment#`. `ossrdbmsEndpoint` is also used for managed identity when the cloud of the virtual machine cannot be detected.", "properties": { "ossrdbmsEndpoint": { diff --git a/src/commands/copyConnectionString/copyConnectionString.test.ts b/src/commands/copyConnectionString/copyConnectionString.test.ts index 4b073f783..cd0eaf162 100644 --- a/src/commands/copyConnectionString/copyConnectionString.test.ts +++ b/src/commands/copyConnectionString/copyConnectionString.test.ts @@ -32,7 +32,10 @@ vi.mock('vscode', () => ({ t: vi.fn((message: string) => message), }, workspace: { - getConfiguration: vi.fn(() => ({ get: vi.fn().mockReturnValue(undefined) })), + getConfiguration: vi.fn(() => ({ + get: vi.fn().mockReturnValue(undefined), + inspect: vi.fn().mockReturnValue(undefined), + })), }, })); diff --git a/src/documentdb/auth/ManagedIdentityAuthHandler.test.ts b/src/documentdb/auth/ManagedIdentityAuthHandler.test.ts index 19c911deb..84fd59211 100644 --- a/src/documentdb/auth/ManagedIdentityAuthHandler.test.ts +++ b/src/documentdb/auth/ManagedIdentityAuthHandler.test.ts @@ -72,6 +72,7 @@ describe('ManagedIdentityAuthHandler', () => { resetEntraTokenFailuresForTests(); vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ get: vi.fn().mockReturnValue(undefined), + inspect: vi.fn().mockReturnValue(undefined), } as unknown as vscode.WorkspaceConfiguration); getManagedIdentityAccessToken.mockReset(); getManagedIdentityAccessToken.mockResolvedValue({ @@ -169,6 +170,9 @@ describe('ManagedIdentityAuthHandler', () => { ossrdbmsEndpoint: 'https://database.example.com', mongoClusterHostnameSuffix: '.database.example.com', }), + inspect: vi.fn().mockReturnValue({ + globalValue: { mongoClusterHostnameSuffix: '.database.example.com' }, + }), } as unknown as vscode.WorkspaceConfiguration); const connectionString = 'mongodb://cluster.database.example.com/'; const handler = new ManagedIdentityAuthHandler( @@ -262,6 +266,7 @@ describe('ManagedIdentityAuthHandler', () => { it('honors the host-gated TLS exception', async () => { vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ get: vi.fn().mockReturnValue({ ossrdbmsEndpoint: 'https://ossrdbms-aad.database.windows.net' }), + inspect: vi.fn().mockReturnValue(undefined), } as unknown as vscode.WorkspaceConfiguration); const cs = 'mongodb://localhost:27017/'; const handler = new ManagedIdentityAuthHandler( diff --git a/src/documentdb/auth/MicrosoftEntraIDAuthHandler.test.ts b/src/documentdb/auth/MicrosoftEntraIDAuthHandler.test.ts index 7f4ee44c7..bb2f6ea28 100644 --- a/src/documentdb/auth/MicrosoftEntraIDAuthHandler.test.ts +++ b/src/documentdb/auth/MicrosoftEntraIDAuthHandler.test.ts @@ -136,6 +136,7 @@ describe('MicrosoftEntraIDAuthHandler', () => { const deps: documentdbTokenResource.TokenResourceResolverDeps = { getConfiguredCloud: () => ({ name: 'custom', isCustomCloud: true }), getCustomEnvironment: () => ({ ossrdbmsEndpoint: resource }), + getCustomHostnameSuffixSource: () => 'none', getSignInEnvironment: () => ({ authProvider: 'microsoft-sovereign-cloud', brokerImplementation: undefined, @@ -172,6 +173,7 @@ describe('MicrosoftEntraIDAuthHandler', () => { customOssrdbmsEndpointState: 'valid', customOssrdbmsEndpointApplicable: true, customHostnameSuffixSet: false, + customHostnameSuffixSource: 'none', armMetadata: 'notNeeded', imdsAzEnvironment: 'notQueried', cloudCategory: mismatch ? 'custom' : 'usgov', diff --git a/src/documentdb/auth/documentdbTokenResource.test.ts b/src/documentdb/auth/documentdbTokenResource.test.ts index e360d8463..7a736f02e 100644 --- a/src/documentdb/auth/documentdbTokenResource.test.ts +++ b/src/documentdb/auth/documentdbTokenResource.test.ts @@ -30,6 +30,7 @@ function createDeps(): TokenResourceResolverDeps { return { getConfiguredCloud: vi.fn(() => ({ name: 'AzureCloud', isCustomCloud: false })), getCustomEnvironment: vi.fn(() => ({})), + getCustomHostnameSuffixSource: vi.fn(() => 'none'), fetchArmMetadata: vi.fn().mockResolvedValue(undefined), readImdsAzEnvironment: vi.fn().mockResolvedValue(undefined), getSignInEnvironment: vi.fn(() => ({ authProvider: 'microsoft', brokerImplementation: undefined })), @@ -85,6 +86,7 @@ describe('resolveInteractiveTokenResource', () => { customOssrdbmsEndpointState: 'absent', customOssrdbmsEndpointApplicable: false, customHostnameSuffixSet: false, + customHostnameSuffixSource: 'none', armMetadata: 'notNeeded', imdsAzEnvironment: 'notQueried', cloudCategory: 'public', @@ -528,6 +530,29 @@ describe('resolveManagedIdentityTokenResource', () => { }); describe('resolveTokenResourceForCopy', () => { + it.each([ + [{ globalValue: { mongoClusterHostnameSuffix: '.private.example.com' } }, 'user'], + [{ workspaceValue: { mongoClusterHostnameSuffix: '.private.example.com' } }, 'workspace'], + [{ workspaceFolderValue: { mongoClusterHostnameSuffix: '.private.example.com' } }, 'workspace'], + [ + { + globalValue: { mongoClusterHostnameSuffix: '.user.example.com' }, + workspaceValue: { mongoClusterHostnameSuffix: '.private.example.com' }, + }, + 'user', + ], + [{}, 'none'], + ] as const)('records only the closed suffix source from inspected configuration: %j', (inspected, source) => { + vi.spyOn(vscode.workspace, 'getConfiguration').mockReturnValue({ + get: (key: string): unknown => + key === 'documentDB.customEnvironment' ? { ossrdbmsEndpoint: governmentResource } : undefined, + inspect: () => inspected, + } as unknown as vscode.WorkspaceConfiguration); + const resolution = resolveTokenResourceForCopy(privateConnection); + expect(resolution.details.customHostnameSuffixSource).toBe(source); + expect(JSON.stringify(resolution.details)).not.toContain('private.example.com'); + }); + it('warns and preserves the permissive fallback for an invalid override', () => { const deps = createDeps(); vi.mocked(deps.getCustomEnvironment).mockReturnValue({ ossrdbmsEndpoint: `${publicResource}/.default` }); diff --git a/src/documentdb/auth/documentdbTokenResource.ts b/src/documentdb/auth/documentdbTokenResource.ts index 3548ec835..31ae3d65d 100644 --- a/src/documentdb/auth/documentdbTokenResource.ts +++ b/src/documentdb/auth/documentdbTokenResource.ts @@ -57,6 +57,7 @@ export interface TokenResourceResolverDeps { /** Throws when `custom` is selected without a usable `customEnvironment`. */ getConfiguredCloud(): ConfiguredCloud; getCustomEnvironment(): DocumentDBCustomEnvironment; + getCustomHostnameSuffixSource(): 'user' | 'workspace' | 'none'; /** Returns the parsed JSON body, or `undefined` on any failure. */ fetchArmMetadata(url: string): Promise; /** Returns IMDS `compute.azEnvironment`, or `undefined` on any failure. */ @@ -218,7 +219,7 @@ export function resolveTokenResourceForCopy( connectionString: string, deps: Pick< TokenResourceResolverDeps, - 'getConfiguredCloud' | 'getCustomEnvironment' | 'getSignInEnvironment' | 'warn' + 'getConfiguredCloud' | 'getCustomEnvironment' | 'getCustomHostnameSuffixSource' | 'getSignInEnvironment' | 'warn' > = getDefaultDeps(), ): ResolvedTokenResource { const hostCloud = getAzureCloudFromHosts(connectionString); @@ -413,7 +414,7 @@ function resolutionError( function createResolutionDetails( flow: 'interactive' | 'managedIdentity' | 'copy', customEnvironment: DocumentDBCustomEnvironment, - deps: Pick, + deps: Pick, ): Record { let authProvider = 'unknown'; let brokerImplementation = 'default'; @@ -442,6 +443,7 @@ function createResolutionDetails( : 'invalid', customOssrdbmsEndpointApplicable: false, customHostnameSuffixSet: !!normalizeCustomHostnameSuffix(customEnvironment.mongoClusterHostnameSuffix), + customHostnameSuffixSource: deps.getCustomHostnameSuffixSource(), armMetadata: 'notNeeded', imdsAzEnvironment: 'notQueried', }; @@ -506,6 +508,21 @@ function getDefaultDeps(): TokenResourceResolverDeps { getConfiguredCloud: () => getConfiguredAzureEnv(), getCustomEnvironment: () => vscode.workspace.getConfiguration().get(settingsKeys.customEnvironment) ?? {}, + getCustomHostnameSuffixSource: () => { + const inspected = vscode.workspace + .getConfiguration() + .inspect(settingsKeys.customEnvironment); + if (inspected?.globalValue?.mongoClusterHostnameSuffix?.trim()) { + return 'user'; + } + if ( + inspected?.workspaceValue?.mongoClusterHostnameSuffix?.trim() || + inspected?.workspaceFolderValue?.mongoClusterHostnameSuffix?.trim() + ) { + return 'workspace'; + } + return 'none'; + }, fetchArmMetadata: fetchJson, readImdsAzEnvironment, getSignInEnvironment: () => ({ diff --git a/src/documentdb/auth/oidcAllowedHosts.test.ts b/src/documentdb/auth/oidcAllowedHosts.test.ts index 700a90344..df13a4354 100644 --- a/src/documentdb/auth/oidcAllowedHosts.test.ts +++ b/src/documentdb/auth/oidcAllowedHosts.test.ts @@ -85,6 +85,20 @@ describe('getOidcAllowedHosts', () => { expect(normalizeCustomHostnameSuffix(value)).toBeUndefined(); } }); + + it.each(['.com.cn', '.co.uk', 'net.au', '.org.uk', '.gov.us', ' CO.ZA '])( + 'rejects the public suffix %s without widening the token allowlist', + (suffix) => { + expect(normalizeCustomHostnameSuffix(suffix)).toBeUndefined(); + const normalized = suffix.trim().toLowerCase().replace(/^\./, ''); + expect(getOidcAllowedHosts(`mongodb://attacker.${normalized}/`, suffix)).toEqual(['*.azure.com']); + }, + ); + + it.each(['example.com', 'database.co.uk', 'service.com.cn'])('accepts the narrower custom suffix %s', (suffix) => { + expect(normalizeCustomHostnameSuffix(` .${suffix.toUpperCase()} `)).toBe(suffix); + expect(getOidcAllowedHosts(`mongodb://cluster.${suffix}/`, suffix)).toEqual([`*.${suffix}`]); + }); }); describe('getAzureCloudFromHosts', () => { diff --git a/src/documentdb/auth/oidcAllowedHosts.ts b/src/documentdb/auth/oidcAllowedHosts.ts index b5a96dcb2..736228821 100644 --- a/src/documentdb/auth/oidcAllowedHosts.ts +++ b/src/documentdb/auth/oidcAllowedHosts.ts @@ -104,7 +104,7 @@ export function getAzureCloudFromHosts(connectionString: string): DocumentDBEntr /** * Validates `documentDB.customEnvironment.mongoClusterHostnameSuffix` and returns it without the - * leading dot. At least two labels are required so a bare TLD can never be trusted. + * leading dot. Bare TLDs and common two-label public suffixes can never be trusted. */ export function normalizeCustomHostnameSuffix(value: unknown): string | undefined { if (typeof value !== 'string') { @@ -116,6 +116,9 @@ export function normalizeCustomHostnameSuffix(value: unknown): string | undefine if (labels.length < 2 || labels.some((label) => !/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/.test(label))) { return undefined; } + if (/^(?:com|co|net|org|gov)\.[a-z]{2}$/.test(suffix)) { + return undefined; + } return suffix; } diff --git a/src/settingsContributions.test.ts b/src/settingsContributions.test.ts index 1750a99af..6271433c6 100644 --- a/src/settingsContributions.test.ts +++ b/src/settingsContributions.test.ts @@ -35,6 +35,11 @@ function constantKeys(): string[] { } describe('settings contributions', () => { + it('keeps the entire custom environment machine scoped, not workspace or folder overridable', () => { + const properties = Object.fromEntries(nodes.flatMap((node) => Object.entries(node.properties))); + expect(properties[settingsKeys.customEnvironment]?.scope).toBe('machine'); + }); + it('validates audience overrides in Settings while allowing an empty setting', () => { const setting = packageJson.contributes.configuration .flatMap((node) => Object.entries(node.properties)) From 0de180b1e93495b3a2c9504431b7fd34079614fa Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Mon, 26 Oct 2026 09:48:56 +0000 Subject: [PATCH 31/50] fix(auth): recognize sovereign DocumentDB hosts in credential wizards Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../features/sovereign-clouds/README.md | 4 +- ...ware-scope-and-entra-diagnostics-review.md | 56 ++++++- .../PromptConnectionStringStep.test.ts | 51 +++++++ .../PromptConnectionStringStep.ts | 13 +- .../updateCredentials.test.ts | 138 ++++++++++++++++++ .../updateCredentials/updateCredentials.ts | 10 +- .../auth/documentdbTokenResource.test.ts | 2 +- src/documentdb/auth/oidcAllowedHosts.test.ts | 32 +++- src/documentdb/auth/oidcAllowedHosts.ts | 14 ++ 9 files changed, 307 insertions(+), 13 deletions(-) create mode 100644 src/commands/updateCredentials/updateCredentials.test.ts diff --git a/docs/ai-and-plans/features/sovereign-clouds/README.md b/docs/ai-and-plans/features/sovereign-clouds/README.md index b216b8236..2580c60bd 100644 --- a/docs/ai-and-plans/features/sovereign-clouds/README.md +++ b/docs/ai-and-plans/features/sovereign-clouds/README.md @@ -69,7 +69,9 @@ readable without a support round trip. - [oidcAllowedHosts.ts](../../../../src/documentdb/auth/oidcAllowedHosts.ts): already accepts `*.azure.us` and `*.azure.cn` hosts (#639, #934). Custom suffixes come from the machine-scoped `documentDB.customEnvironment` object, not workspace overrides; bare TLDs and common two-label - public suffixes are rejected. + public suffixes are rejected. The same closed Azure TLD list and validated suffix recognize + DocumentDB hosts in New Connection and Update Credentials, making managed identity available + for public, Government, China, and configured service domains without matching lookalikes. - [entraErrors/](../../../../src/documentdb/auth/entraErrors/): the single classifier table, pure text classifier, recent token-failure registry, and Entra diagnostics provider. - [connectionDiagnosticsService.ts](../../../../src/services/connectionDiagnosticsService.ts): the diff --git a/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md index 6a83210bc..be9dd5cac 100644 --- a/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md +++ b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md @@ -16,7 +16,7 @@ status: active - Constraint: no sovereign-cloud account, VM, or cluster was used. Passing local tests and reading public ARM metadata are not evidence of successful sovereign-cloud authentication. -This is a review, not an implementation pass. Product code is unchanged. On 2026-10-26 the +The original review below is preserved alongside the live implementation progress. On 2026-10-26 the operator accepted the recommended option for every finding, R1 to R9, with neutral wording only for R7. S1 to S7 are accepted as follow-up todos. S8 is accepted as preview-build-only work. The implementation began on 2026-10-26. See the live progress below; the original review and its @@ -37,9 +37,9 @@ The read-only attachment is unchanged; this repository copy is the live progress | --- | --- | --- | --- | | R1 | Complete | GPT-6.1 Sol | 09:38:47-09:40:20 UTC; 114 focused tests passed; `047465a2bd3b561e92d0069570dd74ba0bd48f99` | | R2 | Complete | GPT-6.1 Sol | 09:40:20-09:41:21 UTC; 95 focused tests passed; `2e27b5583d2e622d1b06bd7ac319eb56f52dff09` | -| R3 | Pending | GPT-6.1 Sol | Sovereign and configured host recognition in both credential wizards | +| R3 | Complete | GPT-6.1 Sol | 09:44:40-09:48:03 UTC; 189 focused tests passed; final commit hash recorded after commit | | R4 | Complete | GPT-6.1 Sol | 09:41:33-09:42:03 UTC; 118 focused tests passed; `f1bc8bffeb0d6106d5f4182767f5d7219884bd65` | -| R5 | Complete | GPT-6.1 Sol | 09:42:14-09:44:14 UTC; 174 focused tests passed; commit recorded in R3 | +| R5 | Complete | GPT-6.1 Sol | 09:42:14-09:44:14 UTC; 174 focused tests passed; `ecd0995babb996863cd2bfb71b9181941a347228` | | R6-R9 | Deferred (low) | — | Kept documented per the operator's medium-or-higher threshold | | Fix review | Pending | Opus 5.5 | Review completed R1-R5; fix medium-or-higher findings | | S1 | Pending | GPT-6.1 Sol | Closed-enum startup telemetry | @@ -70,7 +70,46 @@ The read-only attachment is unchanged; this repository copy is the live progress - R5: GPT-6.1 Sol, 09:42:14-09:44:14 UTC. Allowlist, resolver, both auth handlers, settings, and copy-command suites: 6 files / 174 tests passed. Initial copy and managed-identity handler failures identified configuration mocks missing the newly used `inspect` API; updated only those - mock APIs and reran successfully. No `TDD:` contract failed. Commit hash follows in R3. + mock APIs and reran successfully. No `TDD:` contract failed. + Commit: `ecd0995babb996863cd2bfb71b9181941a347228`. +- R3: GPT-6.1 Sol, 09:44:40-09:48:03 UTC. Host helper, New Connection, Update Credentials, + token-source eligibility, and resolver suites: 5 files / 189 tests passed. Initial new assertions + inspected JSON-escaped log text rather than the message; corrected those assertions. The one + cluster build found a widened mock return type introduced in R5; pinned its literal return type + in this commit and rechecked root compilation successfully. Final commit hash recorded after commit. + +### Five-fix cluster verification and handoff + +- **Case 1: still working**, draft PR. Exactly one `npm run build` was run after R1/R2/R4/R5/R3 + implementation. Workspace prebuilds passed; root compilation failed on the suffix-source mock's + widened `string` return type. After its explicit `'none'` return type fix, `npx tsc --noEmit` + passed. The build command itself was not rerun and is not reported as passed. +- Focused tests passed for every item, including the final 189-test run. No `TDD:`-prefixed + contract failed. IDE discovery was unavailable, so Vitest CLI was used. +- Public resource/scope definitions in `src/documentdb/auth/entraScopes.ts`, and all constants + (including RU) in `src/documentdb/utils/connectionStringHelpers.ts`, are unchanged byte-for-byte. +- Full checks, including `prettier-fix`, remain deferred until ready for review. No lint, + localization generation/check, packaging, full runtime suite, or strict package-test suite ran. + No dependencies were changed, and no live sovereign-cloud validation or token-usage measurement + was performed. R6-R9 and S1-S8 were not implemented. + +Changed files across the five scoped commits: + +- `package.json` +- `src/documentdb/auth/documentdbTokenResource.ts` +- `src/documentdb/auth/documentdbTokenResource.test.ts` +- `src/documentdb/auth/oidcAllowedHosts.ts` +- `src/documentdb/auth/oidcAllowedHosts.test.ts` +- `src/documentdb/auth/MicrosoftEntraIDAuthHandler.test.ts` +- `src/documentdb/auth/ManagedIdentityAuthHandler.test.ts` +- `src/settingsContributions.test.ts` +- `src/commands/copyConnectionString/copyConnectionString.test.ts` +- `src/commands/newConnection/PromptConnectionStringStep.ts` +- `src/commands/newConnection/PromptConnectionStringStep.test.ts` +- `src/commands/updateCredentials/updateCredentials.ts` +- `src/commands/updateCredentials/updateCredentials.test.ts` +- `docs/ai-and-plans/features/sovereign-clouds/README.md` +- This live review/progress file. ## Verdict @@ -113,7 +152,7 @@ Following [CONTRIBUTING section 6](../../../../../CONTRIBUTING.md#6-ai-assisted- Azure-auth SDK and driver/worker boundaries. No additional high-confidence defect was established. The provisional telemetry concern was assessed as a documented limitation, not a third finding. 5. Author decisions: recorded on 2026-10-26 for R1 to R9 (all recommended options; R7 neutral - wording only). Fixes: not started. + wording only). R1-R5 are now implemented locally; R6-R9 remain deferred. 6. Second pass (same head): re-verified R1 and R2 against the code and reassessed their recommendations (see "Second-pass verification" under each). Then an independent sweep covered the New Connection and Update Credentials wizards, the installed Azure-auth SDK @@ -297,6 +336,13 @@ in the new helper. **Author decision:** accepted (2026-10-26), recommended option. Recognize `mongocluster.cosmos.azure.{com,us,cn}` with a dot boundary, reuse the closed TLD list, also accept a configured `mongoClusterHostnameSuffix`, and apply the result in both New Connection and Update Credentials. +**Implementation (R3):** both credential wizards use `hasDocumentDBHost`, which reuses the existing +closed Azure TLD set through the host-suffix helper and the R5-hardened custom suffix validator. +Matching requires a dot boundary, including configured suffixes. No RU or public-cloud constants +changed. Tests cover all three service domains, configured service hosts, lookalikes, rejected public +suffixes, unchanged RU availability, and the real managed-identity token-source eligibility gate in +both wizard flows. The final focused run (including resolver regression coverage): 189 tests passed. + ### R4. P2: Name `microsoft-sovereign-cloud.customEnvironment` when it is missing or incomplete **Location:** [`getConfiguredCloudForDetails`](../../../../../src/documentdb/auth/documentdbTokenResource.ts#L407) diff --git a/src/commands/newConnection/PromptConnectionStringStep.test.ts b/src/commands/newConnection/PromptConnectionStringStep.test.ts index f09561117..cd0761fbe 100644 --- a/src/commands/newConnection/PromptConnectionStringStep.test.ts +++ b/src/commands/newConnection/PromptConnectionStringStep.test.ts @@ -4,10 +4,12 @@ *--------------------------------------------------------------------------------------------*/ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as vscode from 'vscode'; import { AuthMethodId } from '../../documentdb/auth/AuthMethod'; import { DOCUMENTDB_TOKEN_RESOURCE } from '../../documentdb/auth/entraScopes'; import { getManagedIdentityAuthMechanismProperties } from '../../documentdb/auth/managedIdentityConnectionString'; +import { SelectEntraTokenSourceStep } from '../../documentdb/wizards/authenticate/SelectEntraTokenSourceStep'; import { type NewConnectionWizardContext } from './NewConnectionWizardContext'; import { PromptConnectionStringStep } from './PromptConnectionStringStep'; @@ -25,6 +27,9 @@ vi.mock('../../extensionVariables', () => ({ beforeEach(() => { vi.clearAllMocks(); + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: vi.fn().mockReturnValue(undefined), + } as unknown as vscode.WorkspaceConfiguration); }); function expectPrivateInferenceOutput(): void { const output = JSON.stringify(mockInfo.mock.calls); @@ -50,6 +55,52 @@ function makeContext(connectionString: string): NewConnectionWizardContext { } describe('PromptConnectionStringStep', () => { + it.each(['com', 'us', 'cn'])('offers Entra ID and managed identity for azure.%s DocumentDB hosts', async (tld) => { + const context = makeContext(`mongodb+srv://cluster.global.mongocluster.cosmos.azure.${tld}/`); + await new PromptConnectionStringStep().prompt(context); + expect(context.availableAuthenticationMethods).toEqual([ + AuthMethodId.NativeAuth, + AuthMethodId.MicrosoftEntraID, + AuthMethodId.ManagedIdentity, + AuthMethodId.NoAuth, + ]); + expect(mockInfo.mock.calls.map((call) => String(call[0])).join('\n')).toContain('"entraHostRecognized":true'); + context.selectedAuthenticationMethod = AuthMethodId.MicrosoftEntraID; + const sourceStep = new SelectEntraTokenSourceStep( + (wizardContext) => wizardContext.selectedAuthenticationMethod, + (wizardContext, method) => { + wizardContext.selectedAuthenticationMethod = method; + }, + ); + expect(sourceStep.shouldPrompt(context)).toBe(true); + }); + + it.each([ + ['cluster.database.example.com', '.database.example.com', true], + ['notdatabase.example.com', '.database.example.com', false], + ['cluster.database.example.com.evil.com', '.database.example.com', false], + ['attacker.com.cn', '.com.cn', false], + ['cluster.mongocluster.cosmos.azure.us.evil.com', undefined, false], + ['xmongocluster.cosmos.azure.com', undefined, false], + ['cluster.mongo.cosmos.azure.com', undefined, false], + ])('classifies configured, lookalike, and RU host %s', async (host, suffix, recognized) => { + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: vi.fn().mockReturnValue({ mongoClusterHostnameSuffix: suffix }), + } as unknown as vscode.WorkspaceConfiguration); + const context = makeContext(`mongodb://${host}:10255/`); + await new PromptConnectionStringStep().prompt(context); + expect(context.availableAuthenticationMethods).toEqual( + recognized + ? [ + AuthMethodId.NativeAuth, + AuthMethodId.MicrosoftEntraID, + AuthMethodId.ManagedIdentity, + AuthMethodId.NoAuth, + ] + : [AuthMethodId.NativeAuth, AuthMethodId.NoAuth], + ); + }); + it('keeps managed identity available when an explicit hint uses a custom host', async () => { const clientId = '11111111-2222-3333-4444-555555555555'; const context = makeContext( diff --git a/src/commands/newConnection/PromptConnectionStringStep.ts b/src/commands/newConnection/PromptConnectionStringStep.ts index 83c304e5b..b11443b07 100644 --- a/src/commands/newConnection/PromptConnectionStringStep.ts +++ b/src/commands/newConnection/PromptConnectionStringStep.ts @@ -5,14 +5,17 @@ import { AzureWizardPromptStep, parseError } from '@microsoft/vscode-azext-utils'; import * as l10n from '@vscode/l10n'; +import * as vscode from 'vscode'; import { AuthMethodId } from '../../documentdb/auth/AuthMethod'; +import { type DocumentDBCustomEnvironment } from '../../documentdb/auth/documentdbTokenResource'; import { getConnectionStringAuthFacts, stripManagedIdentityMarkers, } from '../../documentdb/auth/managedIdentityConnectionString'; -import { AzureDomains, hasDomainSuffix } from '../../documentdb/utils/connectionStringHelpers'; +import { hasDocumentDBHost } from '../../documentdb/auth/oidcAllowedHosts'; import { DocumentDBConnectionString } from '../../documentdb/utils/DocumentDBConnectionString'; import { canonicalizeTlsException } from '../../documentdb/utils/tlsException'; +import { settingsKeys } from '../../settingsKeys'; import { traceAuthFlow } from '../../utils/authTrace'; import { type NewConnectionWizardContext } from './NewConnectionWizardContext'; @@ -104,7 +107,11 @@ export class PromptConnectionStringStep extends AzureWizardPromptStep(settingsKeys.customEnvironment)?.mongoClusterHostnameSuffix; + const entraHostRecognized = hasDocumentDBHost(parsedConnectionString.hosts, customHostnameSuffix); + if (entraHostRecognized) { supportedAuthMethods.push(AuthMethodId.MicrosoftEntraID); // Managed identity is Entra ID on the wire; wherever one is offered, so is the other. supportedAuthMethods.push(AuthMethodId.ManagedIdentity); @@ -128,7 +135,7 @@ export class PromptConnectionStringStep extends AzureWizardPromptStep ({ + ...(await importOriginal()), + AzureWizard: class { + constructor(context: UpdateCredentialsWizardContext) { + capturedContexts.push(context); + } + public prompt(): Promise { + return Promise.resolve(); + } + public execute(): Promise { + return Promise.resolve(); + } + }, +})); + +vi.mock('../../services/connectionStorageService', async (importOriginal) => ({ + ...(await importOriginal()), + ConnectionStorageService: { get: (...args: unknown[]): unknown => mockGet(...args) }, +})); +vi.mock('../../documentdb/ClustersClient', () => ({ + ClustersClient: { deleteClient: (...args: unknown[]): unknown => mockDeleteClient(...args) }, +})); +vi.mock('../../documentdb/CredentialCache', () => ({ + CredentialCache: { deleteCredentials: (...args: unknown[]): unknown => mockDeleteCredentials(...args) }, +})); +vi.mock('../refreshView/refreshView', () => ({ refreshView: vi.fn() })); +vi.mock('../../extensionVariables', () => ({ + ext: { connectionsBranchDataProvider: { hasNodeErrorState: vi.fn(() => false) } }, +})); + +beforeEach(() => { + vi.clearAllMocks(); + capturedContexts.length = 0; +}); + +async function inferMethods( + host: string, + suffix?: string, + storedMethods: AuthMethodId[] = [AuthMethodId.NativeAuth, AuthMethodId.NoAuth], +): Promise { + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: vi.fn().mockReturnValue({ mongoClusterHostnameSuffix: suffix }), + } as unknown as vscode.WorkspaceConfiguration); + mockGet.mockResolvedValue({ + id: 'stable-cluster-id', + name: 'cluster', + properties: { + type: ItemType.Connection, + api: API.DocumentDB, + availableAuthMethods: storedMethods, + selectedAuthMethod: AuthMethodId.MicrosoftEntraID, + }, + secrets: { connectionString: `mongodb://${host}:10255/` }, + }); + const context = { + valuesToMask: [], + telemetry: { properties: {}, measurements: {} }, + errorHandling: {}, + } as unknown as IActionContext; + const node = { + id: 'folder/current-tree-path', + storageId: 'stable-cluster-id', + cluster: { clusterId: 'stable-cluster-id', treeId: 'folder/current-tree-path' }, + } as unknown as DocumentDBClusterItem; + await updateCredentials(context, node); + expect(mockDeleteClient).toHaveBeenCalledWith('stable-cluster-id'); + expect(mockDeleteCredentials).toHaveBeenCalledWith('stable-cluster-id'); + const captured = capturedContexts[0]; + if (!captured) { + throw new Error('Update Credentials did not construct its wizard'); + } + return captured; +} + +describe('updateCredentials host inference', () => { + it.each(['com', 'us', 'cn'])('offers managed identity for azure.%s DocumentDB hosts', async (tld) => { + const context = await inferMethods(`CLUSTER.global.mongocluster.cosmos.azure.${tld}`); + expect(context.availableAuthenticationMethods).toEqual([ + AuthMethodId.NativeAuth, + AuthMethodId.NoAuth, + AuthMethodId.MicrosoftEntraID, + AuthMethodId.ManagedIdentity, + ]); + const sourceStep = new SelectEntraTokenSourceStep( + (wizardContext) => wizardContext.selectedAuthenticationMethod, + (wizardContext, method) => { + wizardContext.selectedAuthenticationMethod = method; + }, + ); + expect(sourceStep.shouldPrompt(context)).toBe(true); + }); + + it('recognizes a configured service suffix without duplicating existing methods', async () => { + const methods = [ + AuthMethodId.NativeAuth, + AuthMethodId.NoAuth, + AuthMethodId.MicrosoftEntraID, + AuthMethodId.ManagedIdentity, + ]; + const context = await inferMethods('cluster.database.example.com', ' .DATABASE.EXAMPLE.COM ', methods); + expect(context.availableAuthenticationMethods).toEqual(methods); + }); + + it.each([ + ['cluster.mongocluster.cosmos.azure.us.evil.com', undefined], + ['xmongocluster.cosmos.azure.com', undefined], + ['cluster.mongo.cosmos.azure.com', undefined], + ['notdatabase.example.com', '.database.example.com'], + ['cluster.database.example.com.evil.com', '.database.example.com'], + ['attacker.com.cn', '.com.cn'], + ])('does not add Entra methods for lookalike, RU, or rejected custom host %s', async (host, suffix) => { + const context = await inferMethods(host, suffix); + expect(context.availableAuthenticationMethods).toEqual([AuthMethodId.NativeAuth, AuthMethodId.NoAuth]); + }); +}); diff --git a/src/commands/updateCredentials/updateCredentials.ts b/src/commands/updateCredentials/updateCredentials.ts index 7aae313c5..413913043 100644 --- a/src/commands/updateCredentials/updateCredentials.ts +++ b/src/commands/updateCredentials/updateCredentials.ts @@ -5,15 +5,18 @@ import { AzureWizard, type IActionContext } from '@microsoft/vscode-azext-utils'; import * as l10n from '@vscode/l10n'; +import * as vscode from 'vscode'; import { AuthMethodId, authMethodFromString, authMethodsFromString } from '../../documentdb/auth/AuthMethod'; +import { type DocumentDBCustomEnvironment } from '../../documentdb/auth/documentdbTokenResource'; +import { hasDocumentDBHost } from '../../documentdb/auth/oidcAllowedHosts'; import { ClustersClient } from '../../documentdb/ClustersClient'; import { CredentialCache } from '../../documentdb/CredentialCache'; -import { AzureDomains, hasDomainSuffix } from '../../documentdb/utils/connectionStringHelpers'; import { DocumentDBConnectionString } from '../../documentdb/utils/DocumentDBConnectionString'; import { Views } from '../../documentdb/Views'; import { SelectEntraTokenSourceStep } from '../../documentdb/wizards/authenticate/SelectEntraTokenSourceStep'; import { ext } from '../../extensionVariables'; import { ConnectionStorageService, isConnection } from '../../services/connectionStorageService'; +import { settingsKeys } from '../../settingsKeys'; import { type DocumentDBClusterItem } from '../../tree/connections-view/DocumentDBClusterItem'; import { resolveStorageZone } from '../../tree/connections-view/models/ConnectionClusterModel'; import { refreshView } from '../refreshView/refreshView'; @@ -65,7 +68,10 @@ export async function updateCredentials(context: IActionContext, node: DocumentD const parsedCS = new DocumentDBConnectionString(connectionCredentials?.secrets.connectionString ?? ''); const supportedAuthMethods = [...(connectionCredentials?.properties.availableAuthMethods ?? [])]; - if (hasDomainSuffix(AzureDomains.vCore, ...parsedCS.hosts)) { + const customHostnameSuffix = vscode.workspace + .getConfiguration() + .get(settingsKeys.customEnvironment)?.mongoClusterHostnameSuffix; + if (hasDocumentDBHost(parsedCS.hosts, customHostnameSuffix)) { if (!supportedAuthMethods.includes(AuthMethodId.MicrosoftEntraID)) { supportedAuthMethods.push(AuthMethodId.MicrosoftEntraID); } diff --git a/src/documentdb/auth/documentdbTokenResource.test.ts b/src/documentdb/auth/documentdbTokenResource.test.ts index 7a736f02e..b59717186 100644 --- a/src/documentdb/auth/documentdbTokenResource.test.ts +++ b/src/documentdb/auth/documentdbTokenResource.test.ts @@ -30,7 +30,7 @@ function createDeps(): TokenResourceResolverDeps { return { getConfiguredCloud: vi.fn(() => ({ name: 'AzureCloud', isCustomCloud: false })), getCustomEnvironment: vi.fn(() => ({})), - getCustomHostnameSuffixSource: vi.fn(() => 'none'), + getCustomHostnameSuffixSource: vi.fn((): 'none' => 'none'), fetchArmMetadata: vi.fn().mockResolvedValue(undefined), readImdsAzEnvironment: vi.fn().mockResolvedValue(undefined), getSignInEnvironment: vi.fn(() => ({ authProvider: 'microsoft', brokerImplementation: undefined })), diff --git a/src/documentdb/auth/oidcAllowedHosts.test.ts b/src/documentdb/auth/oidcAllowedHosts.test.ts index df13a4354..cc7fef397 100644 --- a/src/documentdb/auth/oidcAllowedHosts.test.ts +++ b/src/documentdb/auth/oidcAllowedHosts.test.ts @@ -5,7 +5,37 @@ import { describe, expect, it } from 'vitest'; -import { getAzureCloudFromHosts, getOidcAllowedHosts, normalizeCustomHostnameSuffix } from './oidcAllowedHosts'; +import { + getAzureCloudFromHosts, + getOidcAllowedHosts, + hasDocumentDBHost, + normalizeCustomHostnameSuffix, +} from './oidcAllowedHosts'; + +describe('hasDocumentDBHost', () => { + it.each(['com', 'us', 'cn'])('recognizes DocumentDB service hosts under azure.%s', (tld) => { + expect(hasDocumentDBHost([`CLUSTER.GLOBAL.mongocluster.cosmos.azure.${tld}:10255`])).toBe(true); + }); + + it.each([ + 'cluster.mongocluster.cosmos.azure.us.evil.com', + 'xmongocluster.cosmos.azure.com', + 'cluster.mongocluster.cosmos.azure.tk', + 'cluster.other.azure.us', + 'cluster.mongo.cosmos.azure.com', + '[::1]:27017', + ])('does not recognize lookalikes, other services, or RU hosts: %s', (host) => { + expect(hasDocumentDBHost([host])).toBe(false); + }); + + it('uses the hardened configured suffix with a dot boundary', () => { + expect(hasDocumentDBHost(['CLUSTER.database.example.com:10255'], ' .DATABASE.EXAMPLE.COM ')).toBe(true); + expect(hasDocumentDBHost(['notdatabase.example.com'], '.database.example.com')).toBe(false); + expect(hasDocumentDBHost(['cluster.database.example.com.evil.com'], '.database.example.com')).toBe(false); + expect(hasDocumentDBHost(['attacker.com.cn'], '.com.cn')).toBe(false); + expect(hasDocumentDBHost([])).toBe(false); + }); +}); describe('getOidcAllowedHosts', () => { it('allows only the Azure suffix for a public-cloud host, not the full host', () => { diff --git a/src/documentdb/auth/oidcAllowedHosts.ts b/src/documentdb/auth/oidcAllowedHosts.ts index 736228821..75c900957 100644 --- a/src/documentdb/auth/oidcAllowedHosts.ts +++ b/src/documentdb/auth/oidcAllowedHosts.ts @@ -16,6 +16,20 @@ const DEFAULT_ALLOWED_HOSTS = ['*.azure.com']; // top-level domain (for example a ccTLD with open registration) is not ours. const KNOWN_AZURE_TOP_LEVEL_DOMAINS: ReadonlySet = new Set(['com', 'us', 'cn']); +/** Recognizes DocumentDB service hosts without trusting lookalikes or arbitrary Azure services. */ +export function hasDocumentDBHost(hosts: readonly string[], customHostnameSuffix?: string): boolean { + const customSuffix = normalizeCustomHostnameSuffix(customHostnameSuffix); + return hosts.some((host) => { + const hostname = getHostname(host); + const azureSuffix = getAzureHostSuffix(host); + return ( + !!hostname && + ((!!azureSuffix && hostname.endsWith(`.mongocluster.cosmos.${azureSuffix}`)) || + (!!customSuffix && hostname.endsWith(`.${customSuffix}`))) + ); + }); +} + /** * Resolve the OIDC ALLOWED_HOSTS list from a connection string. * From 147e0bdc5c4b93f900b3b280b0c8ebda314092f6 Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Mon, 26 Oct 2026 09:54:59 +0000 Subject: [PATCH 32/50] docs(sovereign-clouds): record fix review checkpoint Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...ware-scope-and-entra-diagnostics-review.md | 28 +++++++++++++++++-- 1 file changed, 25 insertions(+), 3 deletions(-) diff --git a/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md index be9dd5cac..cc1d0ef49 100644 --- a/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md +++ b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md @@ -37,11 +37,11 @@ The read-only attachment is unchanged; this repository copy is the live progress | --- | --- | --- | --- | | R1 | Complete | GPT-6.1 Sol | 09:38:47-09:40:20 UTC; 114 focused tests passed; `047465a2bd3b561e92d0069570dd74ba0bd48f99` | | R2 | Complete | GPT-6.1 Sol | 09:40:20-09:41:21 UTC; 95 focused tests passed; `2e27b5583d2e622d1b06bd7ac319eb56f52dff09` | -| R3 | Complete | GPT-6.1 Sol | 09:44:40-09:48:03 UTC; 189 focused tests passed; final commit hash recorded after commit | +| R3 | Complete | GPT-6.1 Sol | 09:44:40-09:48:03 UTC; 189 focused tests passed; `0de180b1e93495b3a2c9504431b7fd34079614fa` | | R4 | Complete | GPT-6.1 Sol | 09:41:33-09:42:03 UTC; 118 focused tests passed; `f1bc8bffeb0d6106d5f4182767f5d7219884bd65` | | R5 | Complete | GPT-6.1 Sol | 09:42:14-09:44:14 UTC; 174 focused tests passed; `ecd0995babb996863cd2bfb71b9181941a347228` | | R6-R9 | Deferred (low) | — | Kept documented per the operator's medium-or-higher threshold | -| Fix review | Pending | Opus 5.5 | Review completed R1-R5; fix medium-or-higher findings | +| Fix review | Complete | Opus 5.5 | 09:49:45-09:53:46 UTC; no medium-or-higher findings | | S1 | Pending | GPT-6.1 Sol | Closed-enum startup telemetry | | S2 | Pending | GPT-6.1 Sol | Sovereign connection appName | | S3 | Pending | GPT-6.1 Sol | Safe IMDS environment evidence | @@ -76,10 +76,15 @@ The read-only attachment is unchanged; this repository copy is the live progress token-source eligibility, and resolver suites: 5 files / 189 tests passed. Initial new assertions inspected JSON-escaped log text rather than the message; corrected those assertions. The one cluster build found a widened mock return type introduced in R5; pinned its literal return type - in this commit and rechecked root compilation successfully. Final commit hash recorded after commit. + in this commit and rechecked root compilation successfully. + Commit: `0de180b1e93495b3a2c9504431b7fd34079614fa`. ### Five-fix cluster verification and handoff +**Subsequent verification:** the orchestrator reran the exact `npm run build` command after the +mock correction; all workspace prebuilds and root compilation passed (09:49-09:50 UTC). +The historical first-attempt result below is retained rather than overwritten. + - **Case 1: still working**, draft PR. Exactly one `npm run build` was run after R1/R2/R4/R5/R3 implementation. Workspace prebuilds passed; root compilation failed on the suffix-source mock's widened `string` return type. After its explicit `'none'` return type fix, `npx tsc --noEmit` @@ -92,6 +97,8 @@ The read-only attachment is unchanged; this repository copy is the live progress localization generation/check, packaging, full runtime suite, or strict package-test suite ran. No dependencies were changed, and no live sovereign-cloud validation or token-usage measurement was performed. R6-R9 and S1-S8 were not implemented. +- All five code/doc commits are local. Only this review's final R3 hash record is uncommitted, + intentionally left for the next documentation commit rather than amending or making a sixth commit. Changed files across the five scoped commits: @@ -111,6 +118,21 @@ Changed files across the five scoped commits: - `docs/ai-and-plans/features/sovereign-clouds/README.md` - This live review/progress file. +### Opus 5.5 fix review + +- 09:49:45-09:53:46 UTC (about four minutes), read-only review of all five R1-R5 commits against + the complete accepted decisions and refinements. No high, medium, or new actionable low findings. +- Checked real resolver callers, both credential wizards, copy fallback, machine scope enforcement, + dot-boundary recognition, mismatch evidence, invalid custom-cloud remediation, and public behavior. +- The reviewer additionally ran a cluster-wide runtime checkpoint: 322 files, 5,486 tests passed, + one skipped; `npx tsc --noEmit` passed. This is not the final strict package-test check. +- R6-R9 remain deferred low findings. No evidence raised their severity. +- The coding and review tool requests explicitly selected GPT-6.1 Sol and Claude Opus 5.5, + respectively. These requested model identities are recorded; no additional runtime/vendor + attestation or token accounting was supplied. +- No live sovereign authentication was performed. Full Case 2 checks are still pending; the draft + PR has not been marked ready. + ## Verdict **Request changes.** The first pass found two P2 findings (R1, R2). The second pass confirmed From f29b4e1ce6f87cb6b963a9cf605e99da0990d1b4 Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Mon, 26 Oct 2026 09:55:31 +0000 Subject: [PATCH 33/50] docs(sovereign-clouds): record S7 telemetry query and access blocker Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...ware-scope-and-entra-diagnostics-review.md | 54 ++++++++++++++++++- 1 file changed, 52 insertions(+), 2 deletions(-) diff --git a/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md index cc1d0ef49..0339265fd 100644 --- a/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md +++ b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md @@ -42,13 +42,13 @@ The read-only attachment is unchanged; this repository copy is the live progress | R5 | Complete | GPT-6.1 Sol | 09:42:14-09:44:14 UTC; 174 focused tests passed; `ecd0995babb996863cd2bfb71b9181941a347228` | | R6-R9 | Deferred (low) | — | Kept documented per the operator's medium-or-higher threshold | | Fix review | Complete | Opus 5.5 | 09:49:45-09:53:46 UTC; no medium-or-higher findings | -| S1 | Pending | GPT-6.1 Sol | Closed-enum startup telemetry | +| S1 | In progress | GPT-6.1 Sol | Closed-enum startup telemetry | | S2 | Pending | GPT-6.1 Sol | Sovereign connection appName | | S3 | Pending | GPT-6.1 Sol | Safe IMDS environment evidence | | S4 | Pending | GPT-6.1 Sol | Actionable OIDC allowed-host explanation | | S5 | Pending | GPT-6.1 Sol | Settings patterns; reuse R1 audience validation | | S6 | Pending | GPT-6.1 Sol | Real-resolver integration tests | -| S7 | Pending | Orchestrator | Attempt D11 telemetry check; preserve any access blocker explicitly | +| S7 | Externally blocked | Orchestrator | 09:54-09:57 UTC; Kusto has no configured cluster/database; query recorded below | | S8 | Pending | GPT-6.1 Sol | Preview-only dialogs on separate `preview/sovereign-diagnostics` branch | | Enhancement review | Pending | Opus 5.5 | Review enhancements, including isolated preview | | Full verification | Pending | Terra | Case 1 plus all Case 2 commands, including strict package-test types | @@ -57,6 +57,10 @@ The read-only attachment is unchanged; this repository copy is the live progress - 09:36-09:39 UTC: orchestrator read the supplied review, checked the clean worktree and draft PR, and mapped implementation, review, preview isolation, and verification work. No code changed. +- S7: orchestrator, 09:54-09:57 UTC. The read-only Kusto attempt returned "No connection configured" + and requires explicit cluster/database instructions. The repository has no Kusto query files or + connection settings. All four supplied domain hashes were independently recomputed with SHA-256 + and match. No telemetry rows were retrieved; no China audience compatibility claim is possible. - R1: GPT-6.1 Sol, 09:38:47-09:40 UTC. IDE runner found no tests; `npx vitest run` covered resolver, settings contributions, scopes, and copied managed-identity strings: 4 files / 114 tests passed. Build deferred to the five-fix cluster. @@ -534,6 +538,52 @@ each one as a separate commit, so they can be reviewed apart from the R fixes. `6a619114` (`azure.cn`) and `49133d63` (`cosmos.azure.cn`); for Government, use `8bafc480` (`azure.us`) and `f3e87358` (`cosmos.azure.us`). +### S7 execution: telemetry access blocker and query + +**Blocked externally (2026-10-26).** The available Kusto tool returned +`No connection configured. The query must include :setDefaultCluster and :setDefaultDb instructions.` +No approved telemetry endpoint or database was available in the repository settings. The initial +availability probe therefore never reached a server. No credential, endpoint, or database was +invented. D11 remains an open shipping gate, not a passed check and not evidence of zero users. + +The following read-only query uses the repository's documented `RawEventsVSCodeExt` schema, +lowercase ingested property names, and startup correlation IDs. Add the approved cluster/database +instructions before running it. Its live schema and results are **not verified**: + +```kusto +let recent = materialize( + RawEventsVSCodeExt + | where ServerTimestamp > ago(30d) + | where EventName contains "vscode-documentdb" + | where EventName endswith "connect.staticmetadata" or EventName endswith "connect.startup" + | project EventName, Properties +); +let sovereignAttempts = recent + | where EventName endswith "connect.staticmetadata" + | extend l2 = tostring(Properties.domaininfo_domain_l2), + l3 = tostring(Properties.domaininfo_domain_l3), + startup = coalesce(tostring(Properties.startupcorrelationid), + tostring(Properties.connectioncorrelationid)) + | extend cloud = case(l2 == "6a619114" or l3 == "49133d63", "china", + l2 == "8bafc480" or l3 == "f3e87358", "usgov", "") + | where cloud != "" and startup != "" + | distinct startup, cloud; +recent +| where EventName endswith "connect.startup" +| extend startup = tostring(Properties.startupcorrelationid), + auth = tostring(Properties.authmethod), + outcome = tostring(Properties.startupoutcome) +| join kind=inner sovereignAttempts on startup +| summarize attempts = count() by cloud, auth, outcome +| order by cloud asc, auth asc, outcome asc +``` + +Restrict the reporting period to versions predating the sovereign audience change when evaluating +whether China previously accepted the public audience. Historical telemetry does not record the +requested audience directly. Count only the relevant Entra/managed-identity methods, and investigate +missing metadata/correlation rather than interpreting absent joined rows as absent connections. +The query returns aggregate counts only, with no machine, tenant, user, or host identifiers. + ### S8. Preview-build connection diagnostics dialogs Accepted by the operator on 2026-10-26. It replaces the earlier scripted-checklist idea. From 20e07a36bf6e3d294cf14bcbe1c330b5821708e6 Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Mon, 26 Oct 2026 09:58:59 +0000 Subject: [PATCH 34/50] Add closed-enum cloud telemetry to connection startup (S1) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../features/sovereign-clouds/README.md | 6 +++ ...ware-scope-and-entra-diagnostics-review.md | 18 +++++-- .../ai-and-plans/telemetry-correlation-ids.md | 14 +++++ src/documentdb/ClustersClient.test.ts | 36 +++++++++++-- src/documentdb/ClustersClient.ts | 1 + .../auth/ManagedIdentityAuthHandler.test.ts | 22 ++++++-- .../auth/ManagedIdentityAuthHandler.ts | 3 ++ .../auth/MicrosoftEntraIDAuthHandler.test.ts | 11 +++- .../auth/MicrosoftEntraIDAuthHandler.ts | 3 ++ .../playground/PlaygroundEvaluator.ts | 6 +++ .../playground/WorkerSessionManager.test.ts | 40 ++++++++++++-- .../playground/WorkerSessionManager.ts | 6 ++- .../shell/ShellSessionManager.test.ts | 20 +++++++ src/documentdb/shell/ShellSessionManager.ts | 6 +++ src/utils/authTrace.test.ts | 52 ++++++++++++++++++- src/utils/authTrace.ts | 41 +++++++++++++++ 16 files changed, 266 insertions(+), 19 deletions(-) diff --git a/docs/ai-and-plans/features/sovereign-clouds/README.md b/docs/ai-and-plans/features/sovereign-clouds/README.md index 2580c60bd..8f6d1e816 100644 --- a/docs/ai-and-plans/features/sovereign-clouds/README.md +++ b/docs/ai-and-plans/features/sovereign-clouds/README.md @@ -80,6 +80,12 @@ readable without a support round trip. Azure management (subscription listing, ARM clients) already follows the selected cloud through `AzureSubscription.environment`; nothing is planned there. +Entra and managed identity attempts attach only `cloudCategory`, `tokenResourceReason`, +`configuredCloud`, `hostCloud`, and `errorKind` closed enums to existing `connect.startup` and +`worker.startup` telemetry. The attempt's trace context owns these properties; raw resolution +details (including resources and local IMDS evidence) are never spread into telemetry. +See [telemetry correlation IDs](../../telemetry-correlation-ids.md#entra-cloud-resolution). + ## Documents - [research-findings.md](research-findings.md): the reported failure, error analysis, where the GUIDs diff --git a/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md index 0339265fd..b74f66748 100644 --- a/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md +++ b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md @@ -42,22 +42,29 @@ The read-only attachment is unchanged; this repository copy is the live progress | R5 | Complete | GPT-6.1 Sol | 09:42:14-09:44:14 UTC; 174 focused tests passed; `ecd0995babb996863cd2bfb71b9181941a347228` | | R6-R9 | Deferred (low) | — | Kept documented per the operator's medium-or-higher threshold | | Fix review | Complete | Opus 5.5 | 09:49:45-09:53:46 UTC; no medium-or-higher findings | -| S1 | In progress | GPT-6.1 Sol | Closed-enum startup telemetry | +| S1 | Complete | GPT-6.1 Sol | 09:56:21-09:58:33 UTC; 124 focused tests passed; commit recorded with S2 | | S2 | Pending | GPT-6.1 Sol | Sovereign connection appName | | S3 | Pending | GPT-6.1 Sol | Safe IMDS environment evidence | | S4 | Pending | GPT-6.1 Sol | Actionable OIDC allowed-host explanation | | S5 | Pending | GPT-6.1 Sol | Settings patterns; reuse R1 audience validation | | S6 | Pending | GPT-6.1 Sol | Real-resolver integration tests | -| S7 | Externally blocked | Orchestrator | 09:54-09:57 UTC; Kusto has no configured cluster/database; query recorded below | +| S7 | Externally blocked | Orchestrator | 09:54-09:55 UTC; Kusto has no configured cluster/database; query recorded below | | S8 | Pending | GPT-6.1 Sol | Preview-only dialogs on separate `preview/sovereign-diagnostics` branch | | Enhancement review | Pending | Opus 5.5 | Review enhancements, including isolated preview | | Full verification | Pending | Terra | Case 1 plus all Case 2 commands, including strict package-test types | ### Execution log +- S1: GPT-6.1 Sol, 09:56:21-09:58:33 UTC (132 seconds). Existing per-attempt auth trace contexts + project only the five allowlisted enums onto startup telemetry; token catches mark a closed + failure category without replacing errors. Covered both handlers and worker surfaces, resolution + and token failures, retry/late-result isolation, and exclusion of raw resources/custom/IMDS data. + Six focused files / 124 tests passed. Initial new assertion expected an unreadable cloud, but + the existing auth mock supplies public Azure; corrected that assertion. No TDD contract failed. + Build deferred to the end of S1-S6. Local commit hash will be recorded in S2. - 09:36-09:39 UTC: orchestrator read the supplied review, checked the clean worktree and draft PR, and mapped implementation, review, preview isolation, and verification work. No code changed. -- S7: orchestrator, 09:54-09:57 UTC. The read-only Kusto attempt returned "No connection configured" +- S7: orchestrator, 09:54-09:55 UTC. The read-only Kusto attempt returned "No connection configured" and requires explicit cluster/database instructions. The repository has no Kusto query files or connection settings. All four supplied domain hashes were independently recomputed with SHA-256 and match. No telemetry rows were retrieved; no China audience compatibility claim is possible. @@ -521,9 +528,10 @@ These items are not defects. They are ordered by how much they reduce the risk o Accepted by the operator on 2026-10-26 as follow-up work. These are not blocking findings. Track each one as a separate commit, so they can be reviewed apart from the R fixes. -- [ ] **S1.** Add `cloudCategory`, `tokenResourceReason`, `configuredCloud`, `hostCloud`, and +- [x] **S1.** Add `cloudCategory`, `tokenResourceReason`, `configuredCloud`, `hostCloud`, and `errorKind` to the connection-startup telemetry for Entra ID and managed identity attempts. - Use closed enums only. + Use closed enums only. Implemented through existing attempt-bound trace contexts, never a + cluster-keyed registry or raw trace spread. - [ ] **S2.** Set the extension `appName` for `azure.us` and `azure.cn` hosts, not only `azure.com` (`ClustersClient`, `hasAzureDomain`). - [ ] **S3.** Trace the raw IMDS `azEnvironment` value when it matches `^Azure[A-Za-z]{1,40}$`. diff --git a/docs/ai-and-plans/telemetry-correlation-ids.md b/docs/ai-and-plans/telemetry-correlation-ids.md index 4dc30b1cd..a088ec67f 100644 --- a/docs/ai-and-plans/telemetry-correlation-ids.md +++ b/docs/ai-and-plans/telemetry-correlation-ids.md @@ -65,6 +65,20 @@ RawEventsVSCodeExt Scope the query to this extension and a time range. Ingested property names are lowercase. +### Entra cloud resolution + +Interactive and managed identity startup events also carry only five closed-enum fields: +`cloudCategory` (`public`, `usgov`, `china`, `custom`, `unknown`), `tokenResourceReason` +(`setting`, `customOverride`, `customLoginHost`, `armMetadata`, `host`, `imds`, `copyDefault`, +`unknown`), `configuredCloud` (the three built-in Azure cloud names, `custom`, `unreadable`, +`unknown`), `hostCloud` (the three built-in names, `none`, `unknown`), and `errorKind` +(`none`, the five token-resource resolution error kinds, or `tokenAcquisitionFailed`). +Unknown resolution fields are reported as `unknown`, not inferred from the selected cloud. +These properties use the existing attempt's auth trace context, including deferred managed +identity callbacks and worker token relays. No cluster-keyed verdict cache is used. +Native and no-auth attempts are unchanged. Resources, scopes, authorities, identifiers, custom +configuration, and IMDS environment strings remain outside this telemetry projection. + ## Why this needs a convention Ids are the only way to join events that come from different telemetry contexts: a command, the diff --git a/src/documentdb/ClustersClient.test.ts b/src/documentdb/ClustersClient.test.ts index 06bb63499..5f9f67b46 100644 --- a/src/documentdb/ClustersClient.test.ts +++ b/src/documentdb/ClustersClient.test.ts @@ -9,7 +9,7 @@ import { type IActionContext, type ITelemetryContext, UserCancelledError } from import type * as Mongodb from 'mongodb'; import { MongoClient, type MongoClientOptions } from 'mongodb'; import { ext } from '../extensionVariables'; -import { type AuthTraceContext } from '../utils/authTrace'; +import { traceAuthFlow, type AuthTraceContext } from '../utils/authTrace'; import { AuthMethodId } from './auth/AuthMethod'; import { ManagedIdentityAuthHandler } from './auth/ManagedIdentityAuthHandler'; import { MicrosoftEntraIDAuthHandler } from './auth/MicrosoftEntraIDAuthHandler'; @@ -79,7 +79,7 @@ vi.mock('mongodb', async () => { }); vi.mock('../extensionVariables', () => ({ - ext: { outputChannel: { trace: vi.fn(), error: vi.fn(), debug: vi.fn() } }, + ext: { outputChannel: { trace: vi.fn(), error: vi.fn(), debug: vi.fn(), info: vi.fn() } }, })); vi.mock('./utils/getClusterMetadata', () => ({ @@ -227,9 +227,39 @@ describe('regular connection startup diagnostics', () => { }, ); const client = await ClustersClient.getClient(clusterId); - expect(traceContext).toEqual({ startupCorrelationId: client.connectionCorrelationId, surface: 'extension' }); + expect(traceContext).toEqual({ + startupCorrelationId: client.connectionCorrelationId, + surface: 'extension', + startupProperties: startupEvent().telemetry.properties, + }); }); + it.each([AuthMethodId.MicrosoftEntraID, AuthMethodId.ManagedIdentity])( + 'isolates %s cloud telemetry from the next attempt on the same cluster', + async (authMethod) => { + CredentialCache.setAuthCredentials(clusterId, authMethod, 'mongodb://private-host:27017'); + const handler = authMethod === AuthMethodId.ManagedIdentity ? ManagedIdentityAuthHandler : MicrosoftEntraIDAuthHandler; + vi.mocked(handler.prototype.configureAuth).mockImplementationOnce(async (_timings, context) => { + traceAuthFlow('entraCloud.unresolved', { + cloudCategory: 'usgov', reason: 'host', configuredCloud: 'custom', + hostCloud: 'AzureUSGovernment', errorKind: 'cloudMismatch', tokenResource: 'private-resource', + }, undefined, context); + throw new Error('private-resolution-failure'); + }); + await expect(ClustersClient.getClient(clusterId)).rejects.toThrow('private-resolution-failure'); + const failedProperties = { ...startupEvent().telemetry.properties }; + await ClustersClient.getClient(clusterId); + const events = mockTelemetryEvents.filter((event) => event.eventName === 'connect.startup'); + expect(events[0].telemetry.properties).toEqual(failedProperties); + expect(failedProperties).toMatchObject({ + cloudCategory: 'usgov', tokenResourceReason: 'host', configuredCloud: 'custom', + hostCloud: 'AzureUSGovernment', errorKind: 'cloudMismatch', + }); + expect(events[1].telemetry.properties.cloudCategory).toBeUndefined(); + expect(JSON.stringify(events)).not.toContain('private-'); + }, + ); + it('preserves auth failures and records their partial stage without private error data', async () => { const originalError = new Error('private-token private-account'); vi.mocked(MicrosoftEntraIDAuthHandler.prototype.configureAuth).mockImplementationOnce(async () => { diff --git a/src/documentdb/ClustersClient.ts b/src/documentdb/ClustersClient.ts index 6ffe88789..727b02c5c 100644 --- a/src/documentdb/ClustersClient.ts +++ b/src/documentdb/ClustersClient.ts @@ -358,6 +358,7 @@ export class ClustersClient { const { connectionString, options } = await authHandler.configureAuth(startup.costTimings, { startupCorrelationId: startup.id, surface: 'extension', + startupProperties: startup.telemetry.properties, }); this.advanceClientStartup(startup, 'stage03PreparingClient'); diff --git a/src/documentdb/auth/ManagedIdentityAuthHandler.test.ts b/src/documentdb/auth/ManagedIdentityAuthHandler.test.ts index 84fd59211..ed7e65666 100644 --- a/src/documentdb/auth/ManagedIdentityAuthHandler.test.ts +++ b/src/documentdb/auth/ManagedIdentityAuthHandler.test.ts @@ -89,7 +89,14 @@ describe('ManagedIdentityAuthHandler', () => { it('configures the OIDC mechanism with a curated allowed-hosts list', async () => { const handler = new ManagedIdentityAuthHandler(buildCredentials()); - const { options } = await handler.configureAuth(); + const startupProperties: Record = {}; + const { options } = await handler.configureAuth(undefined, { + startupCorrelationId: 'startup-id', surface: 'extension', startupProperties, + }); + expect(startupProperties).toEqual({ + cloudCategory: 'public', tokenResourceReason: 'host', configuredCloud: 'AzureCloud', + hostCloud: 'AzureCloud', errorKind: 'none', + }); expect(options.authMechanism).toBe('MONGODB-OIDC'); expect(options.tls).toBe(true); @@ -234,11 +241,16 @@ describe('ManagedIdentityAuthHandler', () => { const error = Object.freeze(new Error('AADSTS500011: Resource not found')); getManagedIdentityAccessToken.mockRejectedValueOnce(error); - const { options } = await handler.configureAuth(); + const startupProperties: Record = {}; + const { options } = await handler.configureAuth(undefined, { + startupCorrelationId: 'startup-id', surface: 'extension', startupProperties, + }); expect(getRecentEntraTokenFailure('cluster-1')).toBeUndefined(); expect(getManagedIdentityAccessToken).not.toHaveBeenCalled(); await expect(invokeOidcCallback(options)).rejects.toBe(error); + expect(startupProperties.errorKind).toBe('tokenAcquisitionFailed'); + expect(JSON.stringify(startupProperties)).not.toMatch(/cluster-1|AADSTS|https/); expect(getRecentEntraTokenFailure('cluster-1')).toEqual({ cloudCategory: 'usgov', authMethod: 'managedIdentity', @@ -258,7 +270,11 @@ describe('ManagedIdentityAuthHandler', () => { vi.spyOn(tokenResources, 'resolveManagedIdentityTokenResource').mockRejectedValueOnce(error); const handler = new ManagedIdentityAuthHandler(buildCredentials()); - await expect(handler.configureAuth()).rejects.toBe(error); + const startupProperties: Record = {}; + await expect(handler.configureAuth(undefined, { + startupCorrelationId: 'startup-id', surface: 'extension', startupProperties, + })).rejects.toBe(error); + expect(startupProperties.errorKind).toBe('managedIdentityCloudUnknown'); expect(getManagedIdentityAccessToken).not.toHaveBeenCalled(); expect(getRecentEntraTokenFailure('cluster-1')).toBeUndefined(); }); diff --git a/src/documentdb/auth/ManagedIdentityAuthHandler.ts b/src/documentdb/auth/ManagedIdentityAuthHandler.ts index 807700e17..b13bf59c8 100644 --- a/src/documentdb/auth/ManagedIdentityAuthHandler.ts +++ b/src/documentdb/auth/ManagedIdentityAuthHandler.ts @@ -109,6 +109,9 @@ export class ManagedIdentityAuthHandler implements AuthHandler { expiresInSeconds: expiresInSecondsFromTimestamp(token.expiresOnTimestamp), }; } catch (error) { + if (traceContext?.startupProperties) { + traceContext.startupProperties.errorKind = 'tokenAcquisitionFailed'; + } recordEntraTokenFailure(this.clusterCredentials.clusterId, { cloudCategory: resolved.cloudCategory, authMethod: 'managedIdentity', diff --git a/src/documentdb/auth/MicrosoftEntraIDAuthHandler.test.ts b/src/documentdb/auth/MicrosoftEntraIDAuthHandler.test.ts index bb2f6ea28..92174c5ee 100644 --- a/src/documentdb/auth/MicrosoftEntraIDAuthHandler.test.ts +++ b/src/documentdb/auth/MicrosoftEntraIDAuthHandler.test.ts @@ -123,7 +123,11 @@ describe('MicrosoftEntraIDAuthHandler', () => { }); vi.mocked(resolveInteractiveTokenResource).mockRejectedValueOnce(error); const handler = new MicrosoftEntraIDAuthHandler(buildCredentials('mongodb://cluster.azure.us/')); - await expect(handler.configureAuth()).rejects.toBe(error); + const startupProperties: Record = {}; + await expect(handler.configureAuth(undefined, { + startupCorrelationId: 'startup-id', surface: 'extension', startupProperties, + })).rejects.toBe(error); + expect(startupProperties.errorKind).toBe('cloudMismatch'); expect(getSessionFromVSCode).not.toHaveBeenCalled(); expect(getRecentEntraTokenFailure('cluster-1')).toBeUndefined(); }); @@ -257,9 +261,12 @@ describe('MicrosoftEntraIDAuthHandler', () => { it('labels authentication failure with its startup ID without exposing secrets', async (): Promise => { getSessionFromVSCode.mockRejectedValueOnce(new Error('secret-token')); const handler = new MicrosoftEntraIDAuthHandler(buildCredentials('mongodb://localhost:27017/')); + const startupProperties: Record = {}; await expect( - handler.configureAuth(undefined, { startupCorrelationId: 'startup-id', surface: 'extension' }), + handler.configureAuth(undefined, { startupCorrelationId: 'startup-id', surface: 'extension', startupProperties }), ).rejects.toThrow('secret-token'); + expect(startupProperties.errorKind).toBe('tokenAcquisitionFailed'); + expect(JSON.stringify(startupProperties)).not.toContain('secret-token'); expect(mockOutputChannel.error).toHaveBeenCalledWith( expect.stringContaining('[Authentication] startup=startup-id surface=extension'), ); diff --git a/src/documentdb/auth/MicrosoftEntraIDAuthHandler.ts b/src/documentdb/auth/MicrosoftEntraIDAuthHandler.ts index a9aaf205f..598b255cc 100644 --- a/src/documentdb/auth/MicrosoftEntraIDAuthHandler.ts +++ b/src/documentdb/auth/MicrosoftEntraIDAuthHandler.ts @@ -79,6 +79,9 @@ export class MicrosoftEntraIDAuthHandler implements AuthHandler { throw new Error(l10n.t('Failed to obtain Entra ID token.')); } } catch (error) { + if (traceContext?.startupProperties) { + traceContext.startupProperties.errorKind = 'tokenAcquisitionFailed'; + } recordEntraTokenFailure(this.clusterCredentials.clusterId, { cloudCategory: resolved.cloudCategory, authMethod: 'interactive', diff --git a/src/documentdb/playground/PlaygroundEvaluator.ts b/src/documentdb/playground/PlaygroundEvaluator.ts index 1ddf50058..a6e003210 100644 --- a/src/documentdb/playground/PlaygroundEvaluator.ts +++ b/src/documentdb/playground/PlaygroundEvaluator.ts @@ -416,6 +416,9 @@ export class PlaygroundEvaluator implements vscode.Disposable { ) ).accessToken; } catch (error) { + if (traceContext?.startupProperties) { + traceContext.startupProperties.errorKind = 'tokenAcquisitionFailed'; + } recordEntraTokenFailure(clusterId, { cloudCategory: resolved.cloudCategory, authMethod: 'managedIdentity', @@ -449,6 +452,9 @@ export class PlaygroundEvaluator implements vscode.Disposable { throw new Error(l10n.t('Failed to obtain Entra ID token.')); } } catch (error) { + if (traceContext?.startupProperties) { + traceContext.startupProperties.errorKind = 'tokenAcquisitionFailed'; + } recordEntraTokenFailure(clusterId, { cloudCategory: resolved.cloudCategory, authMethod: 'interactive', diff --git a/src/documentdb/playground/WorkerSessionManager.test.ts b/src/documentdb/playground/WorkerSessionManager.test.ts index e98687645..506ec1342 100644 --- a/src/documentdb/playground/WorkerSessionManager.test.ts +++ b/src/documentdb/playground/WorkerSessionManager.test.ts @@ -11,7 +11,7 @@ import * as vscode from 'vscode'; import * as workerThreads from 'worker_threads'; import { Worker } from 'worker_threads'; import { ext } from '../../extensionVariables'; -import { type AuthTraceContext } from '../../utils/authTrace'; +import { traceAuthFlow, type AuthTraceContext } from '../../utils/authTrace'; import { AuthMethodId } from '../auth/AuthMethod'; import { CredentialCache } from '../CredentialCache'; import { ShellSessionManager } from '../shell/ShellSessionManager'; @@ -79,6 +79,7 @@ vi.mock('../../extensionVariables', () => ({ context: { extensionPath: '/extension-root' }, outputChannel: { trace: vi.fn(), + info: vi.fn(), warn: vi.fn(), error: vi.fn(), }, @@ -910,12 +911,45 @@ describe('WorkerSessionManager', () => { await initPromise; worker._emit('message', { type: 'tokenRequest', requestId: 'refresh-request' }); expect(contexts).toEqual([ - { startupCorrelationId: requestId, surface: 'shell' }, - { startupCorrelationId: requestId, surface: 'shell' }, + { startupCorrelationId: requestId, surface: 'shell', startupProperties: startupEvent().telemetry.properties }, + { startupCorrelationId: requestId, surface: 'shell', startupProperties: startupEvent().telemetry.properties }, ]); manager.dispose(); }); + it.each([true, false])('keeps cloud properties on their own worker attempt (persistent=%s)', async (persistent) => { + let firstContext: AuthTraceContext | undefined; + callbacks.onTokenRequest = vi.fn(async (_message, _postResponse, _timings, context): Promise => { + firstContext = context; + traceAuthFlow('entraCloud.unresolved', { + cloudCategory: 'china', reason: 'setting', configuredCloud: 'AzureChinaCloud', + hostCloud: 'AzureUSGovernment', errorKind: 'cloudMismatch', tokenResource: 'private-resource', + }, undefined, context); + }); + const first = await startWorker(persistent); + first.worker._emit('message', { type: 'tokenRequest', requestId: 'token-request' }); + first.worker._emit('message', { + type: 'initResult', requestId: first.requestId, success: false, error: 'private-error', + }); + await expect(first.initPromise).rejects.toThrow('private-error'); + const firstProperties = { ...startupEvent().telemetry.properties }; + const retry = await startWorker(persistent, {}, first.manager); + // A late result from the first attempt must never write into the retry. + traceAuthFlow('entraCloud.resolved', { + cloudCategory: 'usgov', reason: 'host', configuredCloud: 'AzureCloud', hostCloud: 'AzureUSGovernment', + }, undefined, firstContext); + retry.worker._emit('message', { type: 'initResult', requestId: retry.requestId, success: true }); + await retry.initPromise; + const events = mockTelemetryEvents.filter((event) => event.eventName === 'worker.startup'); + expect(firstProperties).toMatchObject({ + cloudCategory: 'china', tokenResourceReason: 'setting', configuredCloud: 'AzureChinaCloud', + hostCloud: 'AzureUSGovernment', errorKind: 'cloudMismatch', + }); + expect(events[1].telemetry.properties.cloudCategory).toBeUndefined(); + expect(JSON.stringify(events)).not.toMatch(/private-/); + retry.manager.dispose(); + }); + it('reports worker construction failures while preserving the original error', async () => { const originalError = new Error('private-construction-error'); vi.mocked(Worker).mockImplementationOnce(function () { diff --git a/src/documentdb/playground/WorkerSessionManager.ts b/src/documentdb/playground/WorkerSessionManager.ts index 262c3d5fc..66308ee0e 100644 --- a/src/documentdb/playground/WorkerSessionManager.ts +++ b/src/documentdb/playground/WorkerSessionManager.ts @@ -366,7 +366,11 @@ export class WorkerSessionManager implements vscode.Disposable { this._worker = worker; this._workerClusterId = clusterId; // Everything this worker reports stays tied to the startup that created it. - const connection: AuthTraceContext = { startupCorrelationId: startup.requestId, surface: startup.surface }; + const connection: AuthTraceContext = { + startupCorrelationId: startup.requestId, + surface: startup.surface, + startupProperties: startup.telemetry.properties, + }; // Listen for messages from the worker worker.on('message', (msg: WorkerToMainMessage) => { diff --git a/src/documentdb/shell/ShellSessionManager.test.ts b/src/documentdb/shell/ShellSessionManager.test.ts index 4f5bc64df..6b0cdfc28 100644 --- a/src/documentdb/shell/ShellSessionManager.test.ts +++ b/src/documentdb/shell/ShellSessionManager.test.ts @@ -262,6 +262,10 @@ describe('ShellSessionManager', () => { reason: source === 'vscode' ? 'setting' : 'host', details: { flow: source === 'vscode' ? 'interactive' : 'managedIdentity', + cloudCategory: 'usgov', + reason: source === 'vscode' ? 'setting' : 'host', + configuredCloud: 'AzureUSGovernment', + hostCloud: 'AzureUSGovernment', tokenResource: 'https://ossrdbms-aad.database.usgovcloudapi.net', }, }); @@ -272,10 +276,17 @@ describe('ShellSessionManager', () => { ? new ShellSessionManager({ clusterId, clusterDisplayName: 'Test Cluster', databaseName: 'test' }) : new PlaygroundEvaluator(); const postResponse = vi.fn(); + const startupProperties: Record = {}; await workerCallbacks.onTokenRequest?.( { type: 'tokenRequest', requestId: 'token-request', source }, postResponse, + undefined, + { startupCorrelationId: 'startup-id', surface, startupProperties }, ); + expect(startupProperties).toEqual({ + cloudCategory: 'usgov', tokenResourceReason: source === 'vscode' ? 'setting' : 'host', + configuredCloud: 'AzureUSGovernment', hostCloud: 'AzureUSGovernment', errorKind: 'none', + }); expect(resolver).toHaveBeenCalledWith(connectionString); expect(provider.mock.calls.at(-1)?.[0]).toEqual(['https://ossrdbms-aad.database.usgovcloudapi.net/.default']); expect(postResponse).toHaveBeenCalledWith({ @@ -310,10 +321,14 @@ describe('ShellSessionManager', () => { ? new ShellSessionManager({ clusterId, clusterDisplayName: 'Test Cluster', databaseName: 'test' }) : new PlaygroundEvaluator(); const postResponse = vi.fn(); + const startupProperties: Record = {}; await workerCallbacks.onTokenRequest?.( { type: 'tokenRequest', requestId: 'token-request', source }, postResponse, + undefined, + { startupCorrelationId: 'startup-id', surface, startupProperties }, ); + expect(startupProperties.errorKind).toBe(error.kind); expect(mockGetSessionFromVSCode).not.toHaveBeenCalled(); expect(mockGetManagedIdentityAccessToken).not.toHaveBeenCalled(); expect(getRecentEntraTokenFailure(clusterId)).toBeUndefined(); @@ -356,11 +371,16 @@ describe('ShellSessionManager', () => { ? new ShellSessionManager({ clusterId, clusterDisplayName: 'Test Cluster', databaseName: 'test' }) : new PlaygroundEvaluator(); const postResponse = vi.fn(); + const startupProperties: Record = {}; await workerCallbacks.onTokenRequest?.( { type: 'tokenRequest', requestId: 'token-request', source }, postResponse, + undefined, + { startupCorrelationId: 'startup-id', surface, startupProperties }, ); + expect(startupProperties.errorKind).toBe('tokenAcquisitionFailed'); + expect(JSON.stringify(startupProperties)).not.toMatch(/https|private|AADSTS|original/); expect(postResponse).toHaveBeenCalledExactlyOnceWith({ type: 'tokenError', diff --git a/src/documentdb/shell/ShellSessionManager.ts b/src/documentdb/shell/ShellSessionManager.ts index e1b2e1a57..e6eb1af01 100644 --- a/src/documentdb/shell/ShellSessionManager.ts +++ b/src/documentdb/shell/ShellSessionManager.ts @@ -398,6 +398,9 @@ export class ShellSessionManager implements vscode.Disposable { ) ).accessToken; } catch (error) { + if (traceContext?.startupProperties) { + traceContext.startupProperties.errorKind = 'tokenAcquisitionFailed'; + } recordEntraTokenFailure(this._connectionInfo.clusterId, { cloudCategory: resolved.cloudCategory, authMethod: 'managedIdentity', @@ -431,6 +434,9 @@ export class ShellSessionManager implements vscode.Disposable { throw new Error(l10n.t('Failed to obtain Entra ID token.')); } } catch (error) { + if (traceContext?.startupProperties) { + traceContext.startupProperties.errorKind = 'tokenAcquisitionFailed'; + } recordEntraTokenFailure(this._connectionInfo.clusterId, { cloudCategory: resolved.cloudCategory, authMethod: 'interactive', diff --git a/src/utils/authTrace.test.ts b/src/utils/authTrace.test.ts index 2fd122c6e..eb72ca647 100644 --- a/src/utils/authTrace.test.ts +++ b/src/utils/authTrace.test.ts @@ -3,8 +3,10 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { describe, expect, it } from 'vitest'; -import { describeAuthError } from './authTrace'; +import { describe, expect, it, vi } from 'vitest'; +import { describeAuthError, traceAuthFlow } from './authTrace'; + +vi.mock('../extensionVariables', () => ({ ext: { outputChannel: { info: vi.fn() } } })); describe('describeAuthError', () => { it('labels platform broker errors without exposing the raw message', (): void => { @@ -13,6 +15,52 @@ describe('describeAuthError', () => { ); }); + describe('startup cloud telemetry', () => { + it.each(['entraCloud.resolved', 'entraCloud.unresolved'])('copies only closed enums from %s', (operation) => { + const properties: Record = {}; + traceAuthFlow(operation, { + cloudCategory: 'usgov', + reason: 'setting', + configuredCloud: 'AzureUSGovernment', + hostCloud: 'AzureUSGovernment', + errorKind: 'cloudMismatch', + tokenResource: 'https://private-resource.example', + authority: 'https://private-authority.example', + tenantId: 'private-tenant', + customHostnameSuffix: 'private.example', + imdsAzEnvironment: 'AzurePrivateCloud', + }, undefined, { startupCorrelationId: 'startup-id', surface: 'extension', startupProperties: properties }); + expect(properties).toEqual({ + cloudCategory: 'usgov', + tokenResourceReason: 'setting', + configuredCloud: 'AzureUSGovernment', + hostCloud: 'AzureUSGovernment', + errorKind: 'cloudMismatch', + }); + expect(JSON.stringify(properties)).not.toMatch(/private|https|AzurePrivateCloud/i); + }); + + it('rejects dynamic values even under known property names', () => { + const properties: Record = {}; + traceAuthFlow('entraCloud.resolved', { + cloudCategory: 'private-category', reason: 'private-reason', configuredCloud: 'private-cloud', + hostCloud: 'private-host', errorKind: 'private-error', + }, undefined, { startupCorrelationId: 'startup-id', surface: 'shell', startupProperties: properties }); + expect(properties).toEqual({ + cloudCategory: 'unknown', tokenResourceReason: 'unknown', configuredCloud: 'unknown', + hostCloud: 'unknown', errorKind: 'none', + }); + }); + + it('leaves unrelated auth traces unchanged', () => { + const properties: Record = {}; + traceAuthFlow('nativeAuth.configure', { cloudCategory: 'public' }, undefined, { + startupCorrelationId: 'startup-id', surface: 'extension', startupProperties: properties, + }); + expect(properties).toEqual({}); + }); + }); + it('labels other errors as not brokered', (): void => { expect(describeAuthError(new Error('private sign-in failure'))).toBe( 'type=Error; code=unknown; httpStatus=unknown; aadsts=none; broker=none', diff --git a/src/utils/authTrace.ts b/src/utils/authTrace.ts index 1ca279d2b..801d45afe 100644 --- a/src/utils/authTrace.ts +++ b/src/utils/authTrace.ts @@ -3,6 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import { type ITelemetryContext } from '@microsoft/vscode-azext-utils'; import * as l10n from '@vscode/l10n'; import { randomUUID } from 'crypto'; import { ext } from '../extensionVariables'; @@ -72,6 +73,40 @@ type AuthTraceDetails = Readonly>; export interface AuthTraceContext { readonly startupCorrelationId: string; readonly surface: string; + readonly startupProperties?: ITelemetryContext['properties']; +} + +function recordCloudResolution(details: AuthTraceDetails, properties: ITelemetryContext['properties']): void { + properties.cloudCategory = + typeof details.cloudCategory === 'string' && ['public', 'usgov', 'china', 'custom'].includes(details.cloudCategory) + ? details.cloudCategory + : 'unknown'; + properties.tokenResourceReason = + typeof details.reason === 'string' && + ['setting', 'customOverride', 'customLoginHost', 'armMetadata', 'host', 'imds', 'copyDefault'].includes(details.reason) + ? details.reason + : 'unknown'; + properties.configuredCloud = + typeof details.configuredCloud === 'string' && + ['AzureCloud', 'AzureUSGovernment', 'AzureChinaCloud', 'custom', 'unreadable'].includes(details.configuredCloud) + ? details.configuredCloud + : 'unknown'; + properties.hostCloud = + typeof details.hostCloud === 'string' && + ['AzureCloud', 'AzureUSGovernment', 'AzureChinaCloud', 'none'].includes(details.hostCloud) + ? details.hostCloud + : 'unknown'; + properties.errorKind = + typeof details.errorKind === 'string' && + [ + 'customCloudUnresolved', + 'customCloudDefinitionInvalid', + 'invalidCustomOverride', + 'cloudMismatch', + 'managedIdentityCloudUnknown', + ].includes(details.errorKind) + ? details.errorKind + : 'none'; } /** Connection-bound steps show the connection's startup ID; other steps show their own auth ID. */ @@ -88,6 +123,12 @@ export function traceAuthFlow( correlationId?: string, context?: AuthTraceContext, ): void { + if ( + context?.startupProperties && + (operation === 'entraCloud.resolved' || operation === 'entraCloud.unresolved') + ) { + recordCloudResolution(details, context.startupProperties); + } ext.outputChannel.info( l10n.t('[Authentication]{0} {1}: {2}', authLogIds(correlationId, context), operation, JSON.stringify(details)), ); From c20cc619363aafed9c6f717873fc5e718ad54b0e Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Mon, 26 Oct 2026 09:59:50 +0000 Subject: [PATCH 35/50] Send extension appName to sovereign Azure endpoints (S2) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../features/sovereign-clouds/README.md | 4 +++ ...ware-scope-and-entra-diagnostics-review.md | 15 ++++++---- src/documentdb/ClustersClient.test.ts | 29 +++++++++++++++++++ src/documentdb/ClustersClient.ts | 5 ++-- src/documentdb/auth/oidcAllowedHosts.ts | 2 +- 5 files changed, 47 insertions(+), 8 deletions(-) diff --git a/docs/ai-and-plans/features/sovereign-clouds/README.md b/docs/ai-and-plans/features/sovereign-clouds/README.md index 8f6d1e816..c391bbcfa 100644 --- a/docs/ai-and-plans/features/sovereign-clouds/README.md +++ b/docs/ai-and-plans/features/sovereign-clouds/README.md @@ -86,6 +86,10 @@ Entra and managed identity attempts attach only `cloudCategory`, `tokenResourceR details (including resources and local IMDS evidence) are never spread into telemetry. See [telemetry correlation IDs](../../telemetry-correlation-ids.md#entra-cloud-resolution). +`ClustersClient` sends the same extension `appName` to known `azure.com`, `azure.us`, and +`azure.cn` endpoints using the existing closed Azure suffix matcher. Lookalikes are excluded. +Domain telemetry keeps its historical classification and hashes, so the D11 query remains valid. + ## Documents - [research-findings.md](research-findings.md): the reported failure, error analysis, where the GUIDs diff --git a/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md index b74f66748..260b858b2 100644 --- a/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md +++ b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md @@ -42,8 +42,8 @@ The read-only attachment is unchanged; this repository copy is the live progress | R5 | Complete | GPT-6.1 Sol | 09:42:14-09:44:14 UTC; 174 focused tests passed; `ecd0995babb996863cd2bfb71b9181941a347228` | | R6-R9 | Deferred (low) | — | Kept documented per the operator's medium-or-higher threshold | | Fix review | Complete | Opus 5.5 | 09:49:45-09:53:46 UTC; no medium-or-higher findings | -| S1 | Complete | GPT-6.1 Sol | 09:56:21-09:58:33 UTC; 124 focused tests passed; commit recorded with S2 | -| S2 | Pending | GPT-6.1 Sol | Sovereign connection appName | +| S1 | Complete | GPT-6.1 Sol | 09:56:21-09:58:33 UTC; 124 focused tests passed; `20e07a36bf6e3d294cf14bcbe1c330b5821708e6` | +| S2 | Complete | GPT-6.1 Sol | 09:58:59-09:59:39 UTC; 76 focused tests passed; commit recorded with S3 | | S3 | Pending | GPT-6.1 Sol | Safe IMDS environment evidence | | S4 | Pending | GPT-6.1 Sol | Actionable OIDC allowed-host explanation | | S5 | Pending | GPT-6.1 Sol | Settings patterns; reuse R1 audience validation | @@ -55,13 +55,17 @@ The read-only attachment is unchanged; this repository copy is the live progress ### Execution log +- S2: GPT-6.1 Sol, 09:58:59-09:59:39 UTC (40 seconds). `ClustersClient` reuses the existing + closed Azure suffix helper for the same public-cloud appName on Government and China hosts. + Dot-boundary/lookalike tests and historical sovereign-domain hash assertions passed: + two focused files / 76 tests. No metadata hashing semantics changed. Hash recorded in S3. - S1: GPT-6.1 Sol, 09:56:21-09:58:33 UTC (132 seconds). Existing per-attempt auth trace contexts project only the five allowlisted enums onto startup telemetry; token catches mark a closed failure category without replacing errors. Covered both handlers and worker surfaces, resolution and token failures, retry/late-result isolation, and exclusion of raw resources/custom/IMDS data. Six focused files / 124 tests passed. Initial new assertion expected an unreadable cloud, but the existing auth mock supplies public Azure; corrected that assertion. No TDD contract failed. - Build deferred to the end of S1-S6. Local commit hash will be recorded in S2. + Build deferred to the end of S1-S6. Commit: `20e07a36bf6e3d294cf14bcbe1c330b5821708e6`. - 09:36-09:39 UTC: orchestrator read the supplied review, checked the clean worktree and draft PR, and mapped implementation, review, preview isolation, and verification work. No code changed. - S7: orchestrator, 09:54-09:55 UTC. The read-only Kusto attempt returned "No connection configured" @@ -532,8 +536,9 @@ each one as a separate commit, so they can be reviewed apart from the R fixes. `errorKind` to the connection-startup telemetry for Entra ID and managed identity attempts. Use closed enums only. Implemented through existing attempt-bound trace contexts, never a cluster-keyed registry or raw trace spread. -- [ ] **S2.** Set the extension `appName` for `azure.us` and `azure.cn` hosts, not only - `azure.com` (`ClustersClient`, `hasAzureDomain`). +- [x] **S2.** Set the extension `appName` for `azure.us` and `azure.cn` hosts, not only + `azure.com` (`ClustersClient`, `hasAzureDomain`). Implemented by reusing the closed suffix + matcher locally in `ClustersClient`; historical `hasAzureDomain` telemetry behavior is unchanged. - [ ] **S3.** Trace the raw IMDS `azEnvironment` value when it matches `^Azure[A-Za-z]{1,40}$`. Otherwise keep `other`. - [ ] **S4.** Translate the driver's `ALLOWED_HOSTS` rejection into a message that names diff --git a/src/documentdb/ClustersClient.test.ts b/src/documentdb/ClustersClient.test.ts index 5f9f67b46..d7c9b546c 100644 --- a/src/documentdb/ClustersClient.test.ts +++ b/src/documentdb/ClustersClient.test.ts @@ -153,6 +153,35 @@ describe('regular connection startup diagnostics', () => { return events[0]; } + it.each([ + ['cluster.mongocluster.cosmos.azure.com', 'test-extension'], + ['cluster.mongocluster.cosmos.azure.us:10255', 'test-extension'], + ['CLUSTER.mongocluster.cosmos.azure.cn:10255', 'test-extension'], + ['cluster.other.azure.us', 'test-extension'], + ['cluster.azure.cn.evil.com', undefined], + ['cluster.notazure.us', undefined], + ['cluster.notazure.com', undefined], + ['cluster.azure.tk', undefined], + ['cluster.example.com', undefined], + ['localhost:27017', undefined], + ])('sets the same Azure appName only for a known cloud domain: %s', async (host, appName) => { + vi.mocked(MicrosoftEntraIDAuthHandler.prototype.configureAuth).mockResolvedValueOnce({ + connectionString: `mongodb://${host}/`, options: {}, + }); + await ClustersClient.getClient(clusterId); + expect(vi.mocked(MongoClient).mock.calls[0]?.[1]?.appName).toBe(appName); + }); + + it.each([ + ['us', '8bafc480', 'f3e87358'], + ['cn', '6a619114', '49133d63'], + ])('preserves historical domain telemetry hashes for azure.%s', async (tld, level2, level3) => { + const actual = await vi.importActual('./utils/getClusterMetadata'); + expect(actual.getDomainMetadata([`cluster.mongocluster.cosmos.azure.${tld}`])).toMatchObject({ + domainInfo_isAzure: 'false', domainInfo_domain_l2: level2, domainInfo_domain_l3: level3, + }); + }); + it.each([ AuthMethodId.MicrosoftEntraID, AuthMethodId.ManagedIdentity, diff --git a/src/documentdb/ClustersClient.ts b/src/documentdb/ClustersClient.ts index 727b02c5c..97e504291 100644 --- a/src/documentdb/ClustersClient.ts +++ b/src/documentdb/ClustersClient.ts @@ -45,6 +45,7 @@ import { ManagedIdentityAuthHandler } from './auth/ManagedIdentityAuthHandler'; import { MicrosoftEntraIDAuthHandler } from './auth/MicrosoftEntraIDAuthHandler'; import { NativeAuthHandler } from './auth/NativeAuthHandler'; import { NoAuthHandler } from './auth/NoAuthHandler'; +import { getAzureHostSuffix } from './auth/oidcAllowedHosts'; import { QueryInsightsApis, type ExplainVerbosity } from './client/QueryInsightsApis'; import { CredentialCache, type CachedClusterCredentials } from './CredentialCache'; import { QueryError } from './errors/QueryError'; @@ -62,7 +63,7 @@ import { SchemaStore } from './SchemaStore'; import { prepareDriverConnectionString } from './srv/prepareDriverConnectionString'; import { type SrvResolutionSummary } from './srv/srvResolutionSummary'; import { ConnectionStartupTimings } from './utils/ConnectionStartupTimings'; -import { getHostsFromConnectionString, hasAzureDomain } from './utils/connectionStringHelpers'; +import { getHostsFromConnectionString } from './utils/connectionStringHelpers'; import { fixupDocumentDbExplain } from './utils/fixupDocumentDbExplain'; import { getClusterMetadata, type ClusterMetadata } from './utils/getClusterMetadata'; import { parseDocumentId } from './utils/parseDocumentId'; @@ -363,7 +364,7 @@ export class ClustersClient { this.advanceClientStartup(startup, 'stage03PreparingClient'); const hosts = getHostsFromConnectionString(connectionString); - const userAgentString = hasAzureDomain(...hosts) ? appendExtensionUserAgent() : undefined; + const userAgentString = hosts.some((host) => getAzureHostSuffix(host)) ? appendExtensionUserAgent() : undefined; if (userAgentString) { options.appName = userAgentString; } diff --git a/src/documentdb/auth/oidcAllowedHosts.ts b/src/documentdb/auth/oidcAllowedHosts.ts index 75c900957..36c6a11eb 100644 --- a/src/documentdb/auth/oidcAllowedHosts.ts +++ b/src/documentdb/auth/oidcAllowedHosts.ts @@ -157,7 +157,7 @@ function getHostname(host: string): string | undefined { * top-level label) so that lookalikes such as `azure.com.evil.com` are rejected, * and the top-level label must be one of {@link KNOWN_AZURE_TOP_LEVEL_DOMAINS}. */ -function getAzureHostSuffix(host: string): string | undefined { +export function getAzureHostSuffix(host: string): string | undefined { const hostname = getHostname(host); if (!hostname) { From 7537d69ac2197700ccf4576c321b1a43af5839d0 Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Mon, 26 Oct 2026 10:00:46 +0000 Subject: [PATCH 36/50] Retain safe local IMDS environment evidence (S3) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../features/sovereign-clouds/README.md | 5 ++++ ...ware-scope-and-entra-diagnostics-review.md | 15 +++++++---- .../auth/documentdbTokenResource.test.ts | 27 +++++++++++++++++++ .../auth/documentdbTokenResource.ts | 5 +++- 4 files changed, 46 insertions(+), 6 deletions(-) diff --git a/docs/ai-and-plans/features/sovereign-clouds/README.md b/docs/ai-and-plans/features/sovereign-clouds/README.md index c391bbcfa..1a4ed3528 100644 --- a/docs/ai-and-plans/features/sovereign-clouds/README.md +++ b/docs/ai-and-plans/features/sovereign-clouds/README.md @@ -90,6 +90,11 @@ See [telemetry correlation IDs](../../telemetry-correlation-ids.md#entra-cloud-r `azure.cn` endpoints using the existing closed Azure suffix matcher. Lookalikes are excluded. Domain telemetry keeps its historical classification and hashes, so the D11 query remains valid. +Local resolution traces retain an IMDS `azEnvironment` only when it matches +`^Azure[A-Za-z]{1,40}$`; other nonempty values are `other`. Existing case-insensitive +`AZUREPUBLICCLOUD` recognition is unchanged. No new IMDS-to-cloud mapping is inferred (D7), +and this local evidence is excluded from startup telemetry. + ## Documents - [research-findings.md](research-findings.md): the reported failure, error analysis, where the GUIDs diff --git a/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md index 260b858b2..892667e6a 100644 --- a/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md +++ b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md @@ -43,8 +43,8 @@ The read-only attachment is unchanged; this repository copy is the live progress | R6-R9 | Deferred (low) | — | Kept documented per the operator's medium-or-higher threshold | | Fix review | Complete | Opus 5.5 | 09:49:45-09:53:46 UTC; no medium-or-higher findings | | S1 | Complete | GPT-6.1 Sol | 09:56:21-09:58:33 UTC; 124 focused tests passed; `20e07a36bf6e3d294cf14bcbe1c330b5821708e6` | -| S2 | Complete | GPT-6.1 Sol | 09:58:59-09:59:39 UTC; 76 focused tests passed; commit recorded with S3 | -| S3 | Pending | GPT-6.1 Sol | Safe IMDS environment evidence | +| S2 | Complete | GPT-6.1 Sol | 09:58:59-09:59:39 UTC; 76 focused tests passed; `c20cc619363aafed9c6f717873fc5e718ad54b0e` | +| S3 | Complete | GPT-6.1 Sol | 09:59:50-10:00:36 UTC; 99 focused tests passed; commit recorded with S4 | | S4 | Pending | GPT-6.1 Sol | Actionable OIDC allowed-host explanation | | S5 | Pending | GPT-6.1 Sol | Settings patterns; reuse R1 audience validation | | S6 | Pending | GPT-6.1 Sol | Real-resolver integration tests | @@ -55,10 +55,15 @@ The read-only attachment is unchanged; this repository copy is the live progress ### Execution log +- S3: GPT-6.1 Sol, 09:59:50-10:00:36 UTC (46 seconds). Local IMDS evidence retains only the + accepted Azure-prefixed letter-only shape, with length and control-character boundaries tested. + Public-cloud normalization and D7 mapping/precedence remain unchanged; no new mappings. + Resolver and telemetry projection suites: two files / 99 tests passed. Hash recorded in S4. - S2: GPT-6.1 Sol, 09:58:59-09:59:39 UTC (40 seconds). `ClustersClient` reuses the existing closed Azure suffix helper for the same public-cloud appName on Government and China hosts. Dot-boundary/lookalike tests and historical sovereign-domain hash assertions passed: - two focused files / 76 tests. No metadata hashing semantics changed. Hash recorded in S3. + two focused files / 76 tests. No metadata hashing semantics changed. + Commit: `c20cc619363aafed9c6f717873fc5e718ad54b0e`. - S1: GPT-6.1 Sol, 09:56:21-09:58:33 UTC (132 seconds). Existing per-attempt auth trace contexts project only the five allowlisted enums onto startup telemetry; token catches mark a closed failure category without replacing errors. Covered both handlers and worker surfaces, resolution @@ -539,8 +544,8 @@ each one as a separate commit, so they can be reviewed apart from the R fixes. - [x] **S2.** Set the extension `appName` for `azure.us` and `azure.cn` hosts, not only `azure.com` (`ClustersClient`, `hasAzureDomain`). Implemented by reusing the closed suffix matcher locally in `ClustersClient`; historical `hasAzureDomain` telemetry behavior is unchanged. -- [ ] **S3.** Trace the raw IMDS `azEnvironment` value when it matches `^Azure[A-Za-z]{1,40}$`. - Otherwise keep `other`. +- [x] **S3.** Trace the raw IMDS `azEnvironment` value when it matches `^Azure[A-Za-z]{1,40}$`. + Otherwise keep `other`. Implemented locally only; the S1 telemetry allowlist excludes it. - [ ] **S4.** Translate the driver's `ALLOWED_HOSTS` rejection into a message that names `documentDB.customEnvironment.mongoClusterHostnameSuffix`. - [ ] **S5.** Add `pattern` and `patternErrorMessage` validation for both diff --git a/src/documentdb/auth/documentdbTokenResource.test.ts b/src/documentdb/auth/documentdbTokenResource.test.ts index b59717186..418fce309 100644 --- a/src/documentdb/auth/documentdbTokenResource.test.ts +++ b/src/documentdb/auth/documentdbTokenResource.test.ts @@ -516,6 +516,33 @@ describe('resolveManagedIdentityTokenResource', () => { }); }); + it.each([ + ['AzureUSGovernment', 'AzureUSGovernment'], + ['AzureChinaCloud', 'AzureChinaCloud'], + ['AzurePrivateCloud', 'AzurePrivateCloud'], + [`Azure${'A'.repeat(40)}`, `Azure${'A'.repeat(40)}`], + [`Azure${'A'.repeat(41)}`, 'other'], + ['Azure', 'other'], + ['AZUREUSGOVERNMENT', 'other'], + ['azureusgovernment', 'other'], + [' AzureUSGovernment ', 'other'], + ['AzureTenant123', 'other'], + ['AzureCloud\n', 'other'], + ['AzureCloud\r', 'other'], + ['AzureCloud\nprivate-data', 'other'], + ['AzureCloud"private-data', 'other'], + ['https://private.example', 'other'], + [undefined, 'unavailable'], + ['', 'unavailable'], + ])('retains only safe local IMDS evidence without adding a cloud mapping: %s', async (environment, evidence) => { + const deps = createDeps(); + vi.mocked(deps.readImdsAzEnvironment).mockResolvedValue(environment); + await expect(resolveManagedIdentityTokenResource(privateConnection, deps)).rejects.toMatchObject({ + kind: 'managedIdentityCloudUnknown', + details: { imdsAzEnvironment: evidence }, + }); + }); + it.each([undefined, 'AZUREUSGOVERNMENT', 'AZURECHINACLOUD', 'unknown', ''])( 'fails clearly for unknown or unavailable IMDS environment %s', async (environment) => { diff --git a/src/documentdb/auth/documentdbTokenResource.ts b/src/documentdb/auth/documentdbTokenResource.ts index 31ae3d65d..eab6dc8f9 100644 --- a/src/documentdb/auth/documentdbTokenResource.ts +++ b/src/documentdb/auth/documentdbTokenResource.ts @@ -489,7 +489,10 @@ function classifyImdsAzEnvironment(value: string | undefined): string { if (!value?.trim()) { return 'unavailable'; } - return value.trim().toUpperCase() === 'AZUREPUBLICCLOUD' ? 'AZUREPUBLICCLOUD' : 'other'; + if (value.trim().toUpperCase() === 'AZUREPUBLICCLOUD') { + return 'AZUREPUBLICCLOUD'; + } + return value === value.trim() && /^Azure[A-Za-z]{1,40}$/.test(value) ? value : 'other'; } function getHostname(url: string | undefined): string | undefined { From f6406d8f82dac2b351aa77f6de87639f41c8efa1 Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Mon, 26 Oct 2026 10:02:01 +0000 Subject: [PATCH 37/50] Explain OIDC allowed-host rejections through Entra diagnostics (S4) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../features/sovereign-clouds/README.md | 5 +++ ...ware-scope-and-entra-diagnostics-review.md | 19 +++++++--- .../EntraDiagnosticsProvider.test.ts | 36 ++++++++++++++++++- .../entraErrors/classifyEntraError.test.ts | 12 +++++++ .../auth/entraErrors/classifyEntraError.ts | 4 +++ .../auth/entraErrors/entraErrorRules.ts | 10 ++++++ 6 files changed, 80 insertions(+), 6 deletions(-) diff --git a/docs/ai-and-plans/features/sovereign-clouds/README.md b/docs/ai-and-plans/features/sovereign-clouds/README.md index 1a4ed3528..0abea3579 100644 --- a/docs/ai-and-plans/features/sovereign-clouds/README.md +++ b/docs/ai-and-plans/features/sovereign-clouds/README.md @@ -95,6 +95,11 @@ Local resolution traces retain an IMDS `azEnvironment` only when it matches `AZUREPUBLICCLOUD` recognition is unchanged. No new IMDS-to-cloud mapping is inferred (D7), and this local evidence is excluded from startup telemetry. +The existing Entra diagnostics rule table also recognizes the driver's precise OIDC +`ALLOWED_HOSTS` host-rejection message. Through `ConnectionDiagnosticsService`, it returns a +text-only explanation naming `documentDB.customEnvironment.mongoClusterHostnameSuffix` in user +settings, without echoing the host, changing the error, or requiring a recent token failure. + ## Documents - [research-findings.md](research-findings.md): the reported failure, error analysis, where the GUIDs diff --git a/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md index 892667e6a..7486bcee4 100644 --- a/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md +++ b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md @@ -44,8 +44,8 @@ The read-only attachment is unchanged; this repository copy is the live progress | Fix review | Complete | Opus 5.5 | 09:49:45-09:53:46 UTC; no medium-or-higher findings | | S1 | Complete | GPT-6.1 Sol | 09:56:21-09:58:33 UTC; 124 focused tests passed; `20e07a36bf6e3d294cf14bcbe1c330b5821708e6` | | S2 | Complete | GPT-6.1 Sol | 09:58:59-09:59:39 UTC; 76 focused tests passed; `c20cc619363aafed9c6f717873fc5e718ad54b0e` | -| S3 | Complete | GPT-6.1 Sol | 09:59:50-10:00:36 UTC; 99 focused tests passed; commit recorded with S4 | -| S4 | Pending | GPT-6.1 Sol | Actionable OIDC allowed-host explanation | +| S3 | Complete | GPT-6.1 Sol | 09:59:50-10:00:36 UTC; 99 focused tests passed; `7537d69ac2197700ccf4576c321b1a43af5839d0` | +| S4 | Complete | GPT-6.1 Sol | 10:00:46-10:01:50 UTC; 72 focused tests passed; commit recorded with S5 | | S5 | Pending | GPT-6.1 Sol | Settings patterns; reuse R1 audience validation | | S6 | Pending | GPT-6.1 Sol | Real-resolver integration tests | | S7 | Externally blocked | Orchestrator | 09:54-09:55 UTC; Kusto has no configured cluster/database; query recorded below | @@ -55,10 +55,18 @@ The read-only attachment is unchanged; this repository copy is the live progress ### Execution log +- S4: GPT-6.1 Sol, 10:00:46-10:01:50 UTC (64 seconds). One narrow driver-text rule in the + existing Entra classifier table returns only trusted-suffix settings guidance through the + diagnostics service. No host echoed, token-failure prerequisite, UI, recovery, or error mutation. + Classifier/provider/service suites: three files / 72 tests passed, including false positives, + wrapped and string errors, and frozen original errors. An initial test assumed registration + returned a disposable; corrected it to use the existing reset API. Localization generation + is deliberately deferred to the final Terra checks. Hash recorded in S5. - S3: GPT-6.1 Sol, 09:59:50-10:00:36 UTC (46 seconds). Local IMDS evidence retains only the accepted Azure-prefixed letter-only shape, with length and control-character boundaries tested. Public-cloud normalization and D7 mapping/precedence remain unchanged; no new mappings. - Resolver and telemetry projection suites: two files / 99 tests passed. Hash recorded in S4. + Resolver and telemetry projection suites: two files / 99 tests passed. + Commit: `7537d69ac2197700ccf4576c321b1a43af5839d0`. - S2: GPT-6.1 Sol, 09:58:59-09:59:39 UTC (40 seconds). `ClustersClient` reuses the existing closed Azure suffix helper for the same public-cloud appName on Government and China hosts. Dot-boundary/lookalike tests and historical sovereign-domain hash assertions passed: @@ -546,8 +554,9 @@ each one as a separate commit, so they can be reviewed apart from the R fixes. matcher locally in `ClustersClient`; historical `hasAzureDomain` telemetry behavior is unchanged. - [x] **S3.** Trace the raw IMDS `azEnvironment` value when it matches `^Azure[A-Za-z]{1,40}$`. Otherwise keep `other`. Implemented locally only; the S1 telemetry allowlist excludes it. -- [ ] **S4.** Translate the driver's `ALLOWED_HOSTS` rejection into a message that names - `documentDB.customEnvironment.mongoClusterHostnameSuffix`. +- [x] **S4.** Translate the driver's `ALLOWED_HOSTS` rejection into a message that names + `documentDB.customEnvironment.mongoClusterHostnameSuffix`. Implemented as a narrowly + anchored rule in the existing Entra table, routed as text only through the diagnostics service. - [ ] **S5.** Add `pattern` and `patternErrorMessage` validation for both `documentDB.customEnvironment` properties. This overlaps with the R1 decision; implement it once. - [ ] **S6.** Add end-to-end handler tests with the real resolver for the Government and China diff --git a/src/documentdb/auth/entraErrors/EntraDiagnosticsProvider.test.ts b/src/documentdb/auth/entraErrors/EntraDiagnosticsProvider.test.ts index 76d39fb21..bc9cb6ffb 100644 --- a/src/documentdb/auth/entraErrors/EntraDiagnosticsProvider.test.ts +++ b/src/documentdb/auth/entraErrors/EntraDiagnosticsProvider.test.ts @@ -6,8 +6,9 @@ import { callWithTelemetryAndErrorHandling, UserCancelledError } from '@microsoft/vscode-azext-utils'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { Links } from '../../../constants'; +import { ConnectionDiagnosticsService } from '../../../services/connectionDiagnosticsService'; import { EntraDiagnosticsProvider } from './EntraDiagnosticsProvider'; -import { recordEntraTokenFailure, resetEntraTokenFailuresForTests } from './entraTokenFailures'; +import { getRecentEntraTokenFailure, recordEntraTokenFailure, resetEntraTokenFailuresForTests } from './entraTokenFailures'; const mockOutputChannel = vi.hoisted(() => ({ info: vi.fn() })); @@ -51,6 +52,39 @@ describe('EntraDiagnosticsProvider', () => { expect(provider.id).toBe('entra-sign-in'); }); + it.each([false, true])('explains the driver host rejection through the service without a token failure (cause=%s)', async (wrapped) => { + const rejection = "Host 'private-cluster.example.com:10255' is not valid for OIDC authentication with ALLOWED_HOSTS of '*.azure.com'"; + const cause = Object.freeze(new Error(rejection)); + const error = Object.freeze(Object.assign( + new Error(wrapped ? 'Connection failed' : rejection, wrapped ? { cause } : undefined), + { code: 'original-code' }, + )); + const descriptors = Object.getOwnPropertyDescriptors(error); + ConnectionDiagnosticsService.resetForTests(); + ConnectionDiagnosticsService.registerProvider(provider); + try { + expect(getRecentEntraTokenFailure(clusterId)).toBeUndefined(); + const diagnosis = await ConnectionDiagnosticsService.explain({ clusterId, error }); + expect(diagnosis?.providerId).toBe('entra-sign-in'); + expect(diagnosis?.message).toContain('documentDB.customEnvironment.mongoClusterHostnameSuffix'); + expect(diagnosis?.message).toContain('user settings'); + expect(diagnosis?.message).not.toMatch(/private-cluster|example.com|10255|azure.com/); + expect(Object.getOwnPropertyDescriptors(error)).toEqual(descriptors); + expect(telemetryContext.telemetry.properties.ruleId).toBe('oidcHostNotAllowed'); + expect(JSON.stringify(telemetryContext.telemetry.properties)).not.toContain('private-cluster'); + } finally { + ConnectionDiagnosticsService.resetForTests(); + } + }); + + it('explains a raw worker host rejection string without a token-failure record', async () => { + expect(await provider.explain({ + clusterId, + error: "Host 'private.example.com' is not valid for OIDC authentication with ALLOWED_HOSTS of '*.azure.us,*.azure.cn'", + })).toContain('documentDB.customEnvironment.mongoClusterHostnameSuffix'); + expect(getRecentEntraTokenFailure(clusterId)).toBeUndefined(); + }); + it.each(['AADSTS65002: Preauthorization required', new Error('AADSTS65002: Preauthorization required')])( 'explains a string or Error without needing registry context', async (error): Promise => { diff --git a/src/documentdb/auth/entraErrors/classifyEntraError.test.ts b/src/documentdb/auth/entraErrors/classifyEntraError.test.ts index ee4126b06..748413835 100644 --- a/src/documentdb/auth/entraErrors/classifyEntraError.test.ts +++ b/src/documentdb/auth/entraErrors/classifyEntraError.test.ts @@ -30,6 +30,11 @@ const fixtures = [ { text: 'AADSTS50076: Verification required', ruleId: 'additionalVerification', code: '50076' }, { text: 'AADSTS50079: Verification required', ruleId: 'additionalVerification', code: '50079' }, { text: broker, ruleId: 'brokerOpaque', code: undefined }, + { + text: "Host 'private.example.com:10255' is not valid for OIDC authentication with ALLOWED_HOSTS of '*.azure.com'", + ruleId: 'oidcHostNotAllowed', + code: undefined, + }, { text: 'AADSTS1234567: Unrecognized failure', ruleId: 'unknownAadsts', code: '1234567' }, ]; @@ -96,6 +101,13 @@ describe('classifyEntraError', () => { 'MongoServerSelectionError: connect ECONNREFUSED 127.0.0.1:27017', 'Authentication failed.', 'AADSTS has no numeric code', + 'ALLOWED_HOSTS', + "Host 'private.example.com' is not valid for authentication with ALLOWED_HOSTS of '*.azure.com'", + "Host 'private.example.com' is not valid for OIDC authentication", + "Host 'private.example.com' is not valid for OIDC authentication with ALLOWED_HOSTS", + "Host 'private.example.com' is valid for OIDC authentication with ALLOWED_HOSTS of '*.azure.com'", + "Host 'private.example.com' is not valid for OIDC authentication with ALLOWED_HOSTS of '*.azure.com' trailing", + "Unrelated Host 'private.example.com' is not valid for OIDC authentication with ALLOWED_HOSTS of '*.azure.com'", ])('does not classify an ordinary or incomplete error: %s', (text): void => { expect(classifyEntraError(text)).toBeUndefined(); }); diff --git a/src/documentdb/auth/entraErrors/classifyEntraError.ts b/src/documentdb/auth/entraErrors/classifyEntraError.ts index b1314b3a9..36277f572 100644 --- a/src/documentdb/auth/entraErrors/classifyEntraError.ts +++ b/src/documentdb/auth/entraErrors/classifyEntraError.ts @@ -15,6 +15,10 @@ const PREAUTHORIZATION_PHRASE = 'must be configured via preauthorization'; export function classifyEntraError(text: string): EntraErrorClassification | undefined { const brokered = text.includes('platform_broker_error'); + const driverRule = entraErrorRules.find((rule): boolean => rule.pattern?.test(text) === true); + if (driverRule) { + return { ruleId: driverRule.id, brokered }; + } if (!text.includes('AADSTS') && !brokered && !text.includes(PREAUTHORIZATION_PHRASE)) { return undefined; } diff --git a/src/documentdb/auth/entraErrors/entraErrorRules.ts b/src/documentdb/auth/entraErrors/entraErrorRules.ts index b394b395b..02a389f3f 100644 --- a/src/documentdb/auth/entraErrors/entraErrorRules.ts +++ b/src/documentdb/auth/entraErrors/entraErrorRules.ts @@ -17,6 +17,7 @@ export interface EntraErrorRule { readonly id: string; readonly aadsts?: readonly string[]; readonly msal?: readonly string[]; + readonly pattern?: RegExp; explain(context: EntraErrorContext): string; } @@ -123,6 +124,15 @@ export const entraErrorRules: readonly EntraErrorRule[] = [ ); }, }, + { + id: 'oidcHostNotAllowed', + pattern: /^Host '[^'\r\n]+' is not valid for OIDC authentication with ALLOWED_HOSTS of '[^'\r\n]*'$/m, + explain: (): string => + l10n.t( + 'The driver does not appear to allow this cluster host for Microsoft Entra ID authentication. For a custom DNS endpoint, verify the trusted DocumentDB service hostname suffix with your cloud administrator and set "documentDB.customEnvironment.mongoClusterHostnameSuffix" in user settings. Only trust your service\'s domain, then connect again. See {link}', + { link: Links.SovereignClouds }, + ), + }, { id: 'unknownAadsts', explain: (context): string => From dd29c55eadf8415f0364a7b72ddbbd23d0896e92 Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Mon, 26 Oct 2026 10:02:55 +0000 Subject: [PATCH 38/50] Validate custom hostname suffixes in Settings (S5) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../features/sovereign-clouds/README.md | 6 +++++ ...ware-scope-and-entra-diagnostics-review.md | 16 ++++++++--- docs/user-manual/sovereign-clouds.md | 7 +++++ package.json | 2 ++ src/settingsContributions.test.ts | 27 +++++++++++++++++++ 5 files changed, 54 insertions(+), 4 deletions(-) diff --git a/docs/ai-and-plans/features/sovereign-clouds/README.md b/docs/ai-and-plans/features/sovereign-clouds/README.md index 0abea3579..a29fd322c 100644 --- a/docs/ai-and-plans/features/sovereign-clouds/README.md +++ b/docs/ai-and-plans/features/sovereign-clouds/README.md @@ -100,6 +100,12 @@ The existing Entra diagnostics rule table also recognizes the driver's precise O text-only explanation naming `documentDB.customEnvironment.mongoClusterHostnameSuffix` in user settings, without echoing the host, changing the error, or requiring a recent token failure. +Both custom-environment properties have Settings schema patterns and actionable validation +messages. The audience validation added in R1 is reused; the hostname-suffix pattern mirrors +the existing normalizer, including case, an optional leading dot, valid DNS labels, and R5's +bare/common-public-suffix rejection. Empty or whitespace-only values still clear either property. +There is no new runtime validation path. + ## Documents - [research-findings.md](research-findings.md): the reported failure, error analysis, where the GUIDs diff --git a/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md index 7486bcee4..b047bebbe 100644 --- a/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md +++ b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md @@ -45,8 +45,8 @@ The read-only attachment is unchanged; this repository copy is the live progress | S1 | Complete | GPT-6.1 Sol | 09:56:21-09:58:33 UTC; 124 focused tests passed; `20e07a36bf6e3d294cf14bcbe1c330b5821708e6` | | S2 | Complete | GPT-6.1 Sol | 09:58:59-09:59:39 UTC; 76 focused tests passed; `c20cc619363aafed9c6f717873fc5e718ad54b0e` | | S3 | Complete | GPT-6.1 Sol | 09:59:50-10:00:36 UTC; 99 focused tests passed; `7537d69ac2197700ccf4576c321b1a43af5839d0` | -| S4 | Complete | GPT-6.1 Sol | 10:00:46-10:01:50 UTC; 72 focused tests passed; commit recorded with S5 | -| S5 | Pending | GPT-6.1 Sol | Settings patterns; reuse R1 audience validation | +| S4 | Complete | GPT-6.1 Sol | 10:00:46-10:01:50 UTC; 72 focused tests passed; `f6406d8f82dac2b351aa77f6de87639f41c8efa1` | +| S5 | Complete | GPT-6.1 Sol | 10:02:01-10:02:40 UTC; 56 focused tests passed; commit recorded with S6 | | S6 | Pending | GPT-6.1 Sol | Real-resolver integration tests | | S7 | Externally blocked | Orchestrator | 09:54-09:55 UTC; Kusto has no configured cluster/database; query recorded below | | S8 | Pending | GPT-6.1 Sol | Preview-only dialogs on separate `preview/sovereign-diagnostics` branch | @@ -55,13 +55,20 @@ The read-only attachment is unchanged; this repository copy is the live progress ### Execution log +- S5: GPT-6.1 Sol, 10:02:01-10:02:40 UTC (39 seconds). Reused R1's audience schema unchanged + and added the hostname-suffix pattern/message, matching existing normalization and R5 public + suffix rejection. Settings tests compare valid/invalid/blank/whitespace values with the real + normalizer, including DNS-label length/case/boundary cases. Settings and allowlist suites: + two files / 56 tests passed. User settings documentation updated; no runtime path added. + Hash recorded in S6. - S4: GPT-6.1 Sol, 10:00:46-10:01:50 UTC (64 seconds). One narrow driver-text rule in the existing Entra classifier table returns only trusted-suffix settings guidance through the diagnostics service. No host echoed, token-failure prerequisite, UI, recovery, or error mutation. Classifier/provider/service suites: three files / 72 tests passed, including false positives, wrapped and string errors, and frozen original errors. An initial test assumed registration returned a disposable; corrected it to use the existing reset API. Localization generation - is deliberately deferred to the final Terra checks. Hash recorded in S5. + is deliberately deferred to the final Terra checks. + Commit: `f6406d8f82dac2b351aa77f6de87639f41c8efa1`. - S3: GPT-6.1 Sol, 09:59:50-10:00:36 UTC (46 seconds). Local IMDS evidence retains only the accepted Azure-prefixed letter-only shape, with length and control-character boundaries tested. Public-cloud normalization and D7 mapping/precedence remain unchanged; no new mappings. @@ -557,8 +564,9 @@ each one as a separate commit, so they can be reviewed apart from the R fixes. - [x] **S4.** Translate the driver's `ALLOWED_HOSTS` rejection into a message that names `documentDB.customEnvironment.mongoClusterHostnameSuffix`. Implemented as a narrowly anchored rule in the existing Entra table, routed as text only through the diagnostics service. -- [ ] **S5.** Add `pattern` and `patternErrorMessage` validation for both +- [x] **S5.** Add `pattern` and `patternErrorMessage` validation for both `documentDB.customEnvironment` properties. This overlaps with the R1 decision; implement it once. + R1 audience schema retained unchanged; suffix schema mirrors R5 and preserves empty clearing. - [ ] **S6.** Add end-to-end handler tests with the real resolver for the Government and China interactive and managed identity flows, and for the shell and playground `tokenRequest` path. - [ ] **S7.** Run the D11 China check against existing hashed domain telemetry. Use diff --git a/docs/user-manual/sovereign-clouds.md b/docs/user-manual/sovereign-clouds.md index 1eb16eb99..8f089a754 100644 --- a/docs/user-manual/sovereign-clouds.md +++ b/docs/user-manual/sovereign-clouds.md @@ -74,6 +74,13 @@ Both are properties of the `documentDB.customEnvironment` setting, so they go in } ``` +Configure this machine-scoped object in **User settings**, not Workspace or Folder settings. +The Settings editor checks both properties: the audience must be an HTTPS resource without a +path or `/.default`; the hostname suffix must use valid DNS labels, without a wildcard, URL, +port, or path. Bare top-level domains and common public suffixes such as `com.cn` are rejected. +Only configure a service domain you trust. Surrounding whitespace is ignored; an empty or +whitespace-only property clears the override and restores automatic selection. + ## Sign-in error AADSTS65002 In some clouds, Microsoft Entra ID has not authorized VS Code to request tokens for Azure diff --git a/package.json b/package.json index cca63dacd..b293c01b4 100644 --- a/package.json +++ b/package.json @@ -2002,6 +2002,8 @@ }, "mongoClusterHostnameSuffix": { "type": "string", + "pattern": "^\\s*(?:\\.?(?!(?:[cC][oO][mM]|[cC][oO]|[nN][eE][tT]|[oO][rR][gG]|[gG][oO][vV])\\.[a-zA-Z]{2}\\s*$)[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)+)?\\s*$", + "patternErrorMessage": "Use a trusted service hostname suffix such as .mongocluster.cosmos.example, with valid DNS labels and no wildcard, URL, port, or path. Bare top-level domains and common public suffixes such as com.cn are not allowed. Leave it empty to clear.", "description": "Sovereign cloud equivalent for .mongocluster.cosmos.azure.com (including the leading dot). Microsoft Entra ID tokens are sent only to hosts ending with this suffix or with a built-in Azure cloud domain." } }, diff --git a/src/settingsContributions.test.ts b/src/settingsContributions.test.ts index 6271433c6..7eb29b154 100644 --- a/src/settingsContributions.test.ts +++ b/src/settingsContributions.test.ts @@ -6,6 +6,7 @@ import { describe, expect, it } from 'vitest'; import * as packageJson from '../package.json'; +import { normalizeCustomHostnameSuffix } from './documentdb/auth/oidcAllowedHosts'; import { settingsKeys } from './settingsKeys'; type ConfigurationNode = { @@ -59,6 +60,32 @@ describe('settings contributions', () => { } }); + it('validates hostname suffixes in Settings using the existing normalization rules', () => { + const setting = packageJson.contributes.configuration + .flatMap((node) => Object.entries(node.properties)) + .find(([key]) => key === settingsKeys.customEnvironment)?.[1]; + if (!setting || !('properties' in setting)) { + throw new Error('Missing custom environment schema'); + } + const schema = setting.properties.mongoClusterHostnameSuffix; + const pattern = new RegExp(schema.pattern); + expect(schema.patternErrorMessage).toContain('.mongocluster.cosmos.example'); + expect(schema.patternErrorMessage).toContain('com.cn'); + for (const value of [ + '', ' \t\n ', '.mongocluster.cosmos.example', 'mongocluster.cosmos.example', + ' .DATABASE.EXAMPLE.COM ', '.service.example.com.cn', '.a.b', + `.${'a'.repeat(63)}.example`, '.a-b.example', + '.com', 'com', '.cn', '.com.cn', ' COM.CN ', '.Co.Uk', '.NET.CN', '.org.cn', '.gov.us', + '*.example.com', 'https://example.com', '.example.com:10255', '.example.com/path', + '.example.com/', '.example..com', '..example.com', '.example.com.', + '.-example.com', '.example-.com', '.example_com', '.éxample.com', + `.${'a'.repeat(64)}.example`, '.example. com', + ]) { + const accepted = value.trim() === '' || normalizeCustomHostnameSuffix(value) !== undefined; + expect(pattern.test(value), value).toBe(accepted); + } + }); + describe('table of contents grouping', () => { // A node titled the same as the extension has its settings hoisted onto the parent // entry instead of becoming an expandable child, collapsing the whole grouping. From bb08f47af47892c560c728222692ef2f81ef7692 Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Mon, 26 Oct 2026 10:07:14 +0000 Subject: [PATCH 39/50] Exercise sovereign auth handlers and workers with real resolvers (S6) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../features/sovereign-clouds/README.md | 5 + ...ware-scope-and-entra-diagnostics-review.md | 38 +++- .../auth/sovereignAuth.integration.test.ts | 215 ++++++++++++++++++ 3 files changed, 254 insertions(+), 4 deletions(-) create mode 100644 src/documentdb/auth/sovereignAuth.integration.test.ts diff --git a/docs/ai-and-plans/features/sovereign-clouds/README.md b/docs/ai-and-plans/features/sovereign-clouds/README.md index a29fd322c..def23064e 100644 --- a/docs/ai-and-plans/features/sovereign-clouds/README.md +++ b/docs/ai-and-plans/features/sovereign-clouds/README.md @@ -106,6 +106,11 @@ the existing normalizer, including case, an optional leading dot, valid DNS labe bare/common-public-suffix rejection. Empty or whitespace-only values still clear either property. There is no new runtime validation path. +`sovereignAuth.integration.test.ts` exercises the real default audience resolvers and cloud +configuration reader through Government/China handlers, plus Government shell/playground worker +token requests. Only token APIs and worker transport are simulated; worker/session managers and +credential lookup remain real. These are offline wiring checks, not live sovereign-cloud validation. + ## Documents - [research-findings.md](research-findings.md): the reported failure, error analysis, where the GUIDs diff --git a/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md index b047bebbe..e7c790f3f 100644 --- a/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md +++ b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md @@ -46,8 +46,8 @@ The read-only attachment is unchanged; this repository copy is the live progress | S2 | Complete | GPT-6.1 Sol | 09:58:59-09:59:39 UTC; 76 focused tests passed; `c20cc619363aafed9c6f717873fc5e718ad54b0e` | | S3 | Complete | GPT-6.1 Sol | 09:59:50-10:00:36 UTC; 99 focused tests passed; `7537d69ac2197700ccf4576c321b1a43af5839d0` | | S4 | Complete | GPT-6.1 Sol | 10:00:46-10:01:50 UTC; 72 focused tests passed; `f6406d8f82dac2b351aa77f6de87639f41c8efa1` | -| S5 | Complete | GPT-6.1 Sol | 10:02:01-10:02:40 UTC; 56 focused tests passed; commit recorded with S6 | -| S6 | Pending | GPT-6.1 Sol | Real-resolver integration tests | +| S5 | Complete | GPT-6.1 Sol | 10:02:01-10:02:40 UTC; 56 focused tests passed; `dd29c55eadf8415f0364a7b72ddbbd23d0896e92` | +| S6 | Complete | GPT-6.1 Sol | 10:02:55-10:06:22 UTC; 66 focused tests and cluster build passed; hash recorded after commit | | S7 | Externally blocked | Orchestrator | 09:54-09:55 UTC; Kusto has no configured cluster/database; query recorded below | | S8 | Pending | GPT-6.1 Sol | Preview-only dialogs on separate `preview/sovereign-diagnostics` branch | | Enhancement review | Pending | Opus 5.5 | Review enhancements, including isolated preview | @@ -55,12 +55,22 @@ The read-only attachment is unchanged; this repository copy is the live progress ### Execution log +- S6: GPT-6.1 Sol, 10:02:55-10:06:22 UTC (207 seconds, including cluster build). Eight new + real-resolver integration cases cover Government/China interactive scopes and allowlists, + Government/China managed identity scopes selected by host despite the opposite VS Code setting, + and Government tokenRequest round trips through both real worker/session managers and both token + sources. Token APIs and worker transport alone are simulated; the default resolvers and + configuration reader are not mocked. An initial test used the deprecated credential-cache API + with the wrong signature; corrected it to the existing `setAuthCredentials` API. + Four focused files / 66 tests passed (10:05:51-10:05:53 UTC). + One exact `npm run build` passed, including workspace prebuilds and root compilation + (10:05:53-10:06:22 UTC, 29 seconds). Final hash recorded after commit, without amending. - S5: GPT-6.1 Sol, 10:02:01-10:02:40 UTC (39 seconds). Reused R1's audience schema unchanged and added the hostname-suffix pattern/message, matching existing normalization and R5 public suffix rejection. Settings tests compare valid/invalid/blank/whitespace values with the real normalizer, including DNS-label length/case/boundary cases. Settings and allowlist suites: two files / 56 tests passed. User settings documentation updated; no runtime path added. - Hash recorded in S6. + Commit: `dd29c55eadf8415f0364a7b72ddbbd23d0896e92`. - S4: GPT-6.1 Sol, 10:00:46-10:01:50 UTC (64 seconds). One narrow driver-text rule in the existing Entra classifier table returns only trusted-suffix settings guidance through the diagnostics service. No host echoed, token-failure prerequisite, UI, recovery, or error mutation. @@ -153,6 +163,24 @@ Changed files across the five scoped commits: - `docs/ai-and-plans/features/sovereign-clouds/README.md` - This live review/progress file. +### S1-S6 enhancement cluster handoff + +- **Case 1: still working**, draft PR. All six accepted enhancements are implemented in order, + with one local commit each. Focused passing test totals: S1 124, S2 76, S3 99, S4 72, S5 56, + S6 66. These overlapping runs are not a count of unique tests. +- Exactly one `npm run build` ran for this enhancement cluster and passed, including all workspace + prebuilds and root compilation. No full runtime suite was run. No `TDD:` contract was modified or + failed. No dependencies were added. +- Full checks, including `prettier-fix`, lint, localization generation/check, strict package-test + typechecking, packaging, and the full runtime suite remain deferred to the final Terra run. + The new S4 localized message still needs localization generation there. +- R1-R5 remain as reviewed; R6-R9 remain deliberately deferred and untouched. S7 remains externally + blocked on Kusto cluster/database configuration; only its log window was corrected to + 09:54-09:55 UTC. S8 has not been implemented or branched here. No push, PR comment, amendment, + branch change, or subagent was used. +- No live sovereign access was exercised or implied, and no token usage counts are available. + Only the final S6 hash record may remain uncommitted to avoid a circular commit hash. + ### Opus 5.5 fix review - 09:49:45-09:53:46 UTC (about four minutes), read-only review of all five R1-R5 commits against @@ -567,8 +595,10 @@ each one as a separate commit, so they can be reviewed apart from the R fixes. - [x] **S5.** Add `pattern` and `patternErrorMessage` validation for both `documentDB.customEnvironment` properties. This overlaps with the R1 decision; implement it once. R1 audience schema retained unchanged; suffix schema mirrors R5 and preserves empty clearing. -- [ ] **S6.** Add end-to-end handler tests with the real resolver for the Government and China +- [x] **S6.** Add end-to-end handler tests with the real resolver for the Government and China interactive and managed identity flows, and for the shell and playground `tokenRequest` path. + New offline integration suite uses actual default resolvers, cloud configuration reader, + worker/session managers, and credential lookup; only tokens and worker transport are simulated. - [ ] **S7.** Run the D11 China check against existing hashed domain telemetry. Use `6a619114` (`azure.cn`) and `49133d63` (`cosmos.azure.cn`); for Government, use `8bafc480` (`azure.us`) and `f3e87358` (`cosmos.azure.us`). diff --git a/src/documentdb/auth/sovereignAuth.integration.test.ts b/src/documentdb/auth/sovereignAuth.integration.test.ts new file mode 100644 index 000000000..d73bce045 --- /dev/null +++ b/src/documentdb/auth/sovereignAuth.integration.test.ts @@ -0,0 +1,215 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { type ITelemetryContext } from '@microsoft/vscode-azext-utils'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as vscode from 'vscode'; +import { CredentialCache, type CachedClusterCredentials } from '../CredentialCache'; +import { PlaygroundEvaluator } from '../playground/PlaygroundEvaluator'; +import { type MainToWorkerMessage, type WorkerToMainMessage } from '../playground/workerTypes'; +import { ShellSessionManager } from '../shell/ShellSessionManager'; +import { AuthMethodId } from './AuthMethod'; +import { resetEntraTokenFailuresForTests } from './entraErrors/entraTokenFailures'; +import { ManagedIdentityAuthHandler } from './ManagedIdentityAuthHandler'; +import { MicrosoftEntraIDAuthHandler } from './MicrosoftEntraIDAuthHandler'; + +const getSession = vi.hoisted(() => vi.fn()); +const getManagedToken = vi.hoisted(() => vi.fn()); +const postedMessages = vi.hoisted((): MainToWorkerMessage[] => []); +const events = vi.hoisted((): { eventName: string; telemetry: ITelemetryContext }[] => []); + +vi.mock('../../extensionVariables', () => ({ + ext: { + context: { extensionPath: '/extension-root' }, + outputChannel: { trace: vi.fn(), debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }, +})); + +vi.mock('@microsoft/vscode-azext-azureauth/out/src/getSessionFromVSCode', () => ({ + getSessionFromVSCode: getSession, +})); + +vi.mock('./managedIdentityTokenProvider', () => ({ getManagedIdentityAccessToken: getManagedToken })); + +vi.mock('@microsoft/vscode-azext-utils', async (importOriginal) => ({ + ...(await importOriginal()), + callWithTelemetryAndErrorHandling: async ( + eventName: string, + callback: (context: { telemetry: ITelemetryContext; errorHandling: Record }) => Promise, + ): Promise => { + const telemetry: ITelemetryContext = { properties: {}, measurements: {} }; + const result = await callback({ telemetry, errorHandling: {} }); + events.push({ eventName, telemetry }); + return result; + }, +})); + +// Only the worker transport is simulated; managers, credential lookup, and audience resolvers are real. +vi.mock('worker_threads', async () => { + const { EventEmitter } = await import('events'); + return { + Worker: class extends EventEmitter { + private initRequestId = ''; + + public constructor() { + super(); + } + + public postMessage(message: MainToWorkerMessage): void { + postedMessages.push(message); + if (message.type === 'init') { + this.initRequestId = message.requestId; + this.reply({ + type: 'tokenRequest', + requestId: 'token-request', + source: message.authMechanism === 'ManagedIdentity' ? 'managedIdentity' : 'vscode', + tenantId: message.tenantId, + clientId: message.managedIdentityClientId, + }); + } else if (message.type === 'tokenResponse' || message.type === 'tokenError') { + this.reply({ + type: 'initResult', + requestId: this.initRequestId, + success: message.type === 'tokenResponse', + error: message.type === 'tokenError' ? message.error : undefined, + }); + } else if (message.type === 'eval') { + this.reply({ + type: 'evalResult', requestId: message.requestId, + result: { type: 'number', printable: '1', durationMs: 0 }, + }); + } + } + + private reply(message: WorkerToMainMessage): void { + queueMicrotask(() => this.emit('message', message)); + } + + public async terminate(): Promise { + return 0; + } + }, + }; +}); + +const clusterId = 'private-cluster'; +const clouds = [ + { + setting: 'USGovernment', suffix: 'us', category: 'usgov', cloud: 'AzureUSGovernment', + scope: 'https://ossrdbms-aad.database.usgovcloudapi.net/.default', + }, + { + setting: 'ChinaCloud', suffix: 'cn', category: 'china', cloud: 'AzureChinaCloud', + scope: 'https://ossrdbms-aad.database.chinacloudapi.cn/.default', + }, +] as const; + +let selectedCloud = 'USGovernment'; + +function credentials(authMechanism: AuthMethodId, suffix: string): CachedClusterCredentials { + return { + clusterId, + connectionString: `mongodb://private-cluster.mongocluster.cosmos.azure.${suffix}:10255/`, + authMechanism, + entraIdConfig: { tenantId: 'private-tenant' }, + managedIdentityConfig: { tenantId: 'private-tenant', clientId: 'private-client' }, + }; +} + +describe('sovereign authentication with real audience resolvers', () => { + beforeEach(() => { + vi.clearAllMocks(); + postedMessages.length = 0; + events.length = 0; + selectedCloud = 'USGovernment'; + resetEntraTokenFailuresForTests(); + getSession.mockResolvedValue({ accessToken: 'private-token', account: { label: 'private-account' } }); + getManagedToken.mockResolvedValue({ accessToken: 'private-token', expiresOnTimestamp: Date.now() + 3_600_000 }); + const configuration = { get: vi.fn(), inspect: vi.fn(), has: vi.fn(), update: vi.fn() }; + configuration.get.mockImplementation((key: string): unknown => key === 'environment' ? selectedCloud : undefined); + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue(configuration); + }); + + afterEach(() => { + CredentialCache.deleteCredentials(clusterId); + resetEntraTokenFailuresForTests(); + }); + + it.each(clouds)('requests the real $setting interactive scope and allowlist', async (cloud) => { + selectedCloud = cloud.setting; + const startupProperties: Record = {}; + const { options } = await new MicrosoftEntraIDAuthHandler( + credentials(AuthMethodId.MicrosoftEntraID, cloud.suffix), + ).configureAuth(undefined, { startupCorrelationId: 'startup-id', surface: 'extension', startupProperties }); + expect(getSession).toHaveBeenCalledExactlyOnceWith([cloud.scope], 'private-tenant', { createIfNone: true }); + expect(options.authMechanismProperties?.ALLOWED_HOSTS).toEqual([`*.azure.${cloud.suffix}`]); + expect(startupProperties).toEqual({ + cloudCategory: cloud.category, tokenResourceReason: 'setting', configuredCloud: cloud.cloud, + hostCloud: cloud.cloud, errorKind: 'none', + }); + expect(JSON.stringify(startupProperties)).not.toMatch(/private|https|authority/); + }); + + it.each(clouds)('requests the real azure.$suffix managed identity scope independently of VS Code', async (cloud) => { + selectedCloud = cloud.setting === 'USGovernment' ? 'ChinaCloud' : 'USGovernment'; + const { options } = await new ManagedIdentityAuthHandler( + credentials(AuthMethodId.ManagedIdentity, cloud.suffix), + ).configureAuth(); + expect(getManagedToken).not.toHaveBeenCalled(); + expect(options.authMechanismProperties?.ALLOWED_HOSTS).toEqual([`*.azure.${cloud.suffix}`]); + const callback = options.authMechanismProperties?.OIDC_CALLBACK; + if (!callback) { + throw new Error('Expected the OIDC token callback'); + } + expect(await callback({ version: 1, timeoutContext: new AbortController().signal })).toMatchObject({ + accessToken: 'private-token', + }); + expect(getManagedToken).toHaveBeenCalledExactlyOnceWith( + [cloud.scope], 'private-client', 'private-tenant', expect.any(String), undefined, + ); + expect(getSession).not.toHaveBeenCalled(); + }); + + it.each([ + ['shell', AuthMethodId.MicrosoftEntraID], + ['playground', AuthMethodId.MicrosoftEntraID], + ['shell', AuthMethodId.ManagedIdentity], + ['playground', AuthMethodId.ManagedIdentity], + ] as const)('resolves Government from the %s %s worker tokenRequest path', async (surface, authMethod) => { + const stored = credentials(authMethod, 'us'); + CredentialCache.setAuthCredentials( + clusterId, authMethod, stored.connectionString, undefined, undefined, + stored.entraIdConfig, stored.managedIdentityConfig, + ); + selectedCloud = authMethod === AuthMethodId.ManagedIdentity ? 'ChinaCloud' : 'USGovernment'; + const manager = surface === 'shell' + ? new ShellSessionManager({ clusterId, clusterDisplayName: 'Test cluster', databaseName: 'test' }) + : new PlaygroundEvaluator(); + try { + if (manager instanceof ShellSessionManager) { + await manager.initialize(); + } else { + await manager.evaluate({ clusterId, clusterDisplayName: 'Test cluster', databaseName: 'test' }, '1'); + } + expect(postedMessages).toContainEqual(expect.objectContaining({ + type: 'init', connectionString: stored.connectionString, oidcAllowedHosts: ['*.azure.us'], + })); + expect(postedMessages).toContainEqual({ + type: 'tokenResponse', requestId: 'token-request', accessToken: 'private-token', + }); + const provider = authMethod === AuthMethodId.ManagedIdentity ? getManagedToken : getSession; + expect(provider.mock.calls[0]?.[0]).toEqual([clouds[0].scope]); + const startup = events.find((event) => event.eventName === 'worker.startup'); + expect(startup?.telemetry.properties).toMatchObject({ + surface, authMethod, startupOutcome: 'succeeded', cloudCategory: 'usgov', + tokenResourceReason: authMethod === AuthMethodId.ManagedIdentity ? 'host' : 'setting', + hostCloud: 'AzureUSGovernment', errorKind: 'none', + }); + expect(JSON.stringify(startup)).not.toMatch(/private|https|authority/); + } finally { + manager.dispose(); + } + }); +}); From eee66fdc16bcc61f3fbffbf8cacdc22476442ff8 Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Mon, 26 Oct 2026 10:08:25 +0000 Subject: [PATCH 40/50] docs(sovereign-clouds): checkpoint enhancement and preview progress Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../01-cloud-aware-scope-and-entra-diagnostics-review.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md index e7c790f3f..6860f9d84 100644 --- a/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md +++ b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md @@ -47,9 +47,9 @@ The read-only attachment is unchanged; this repository copy is the live progress | S3 | Complete | GPT-6.1 Sol | 09:59:50-10:00:36 UTC; 99 focused tests passed; `7537d69ac2197700ccf4576c321b1a43af5839d0` | | S4 | Complete | GPT-6.1 Sol | 10:00:46-10:01:50 UTC; 72 focused tests passed; `f6406d8f82dac2b351aa77f6de87639f41c8efa1` | | S5 | Complete | GPT-6.1 Sol | 10:02:01-10:02:40 UTC; 56 focused tests passed; `dd29c55eadf8415f0364a7b72ddbbd23d0896e92` | -| S6 | Complete | GPT-6.1 Sol | 10:02:55-10:06:22 UTC; 66 focused tests and cluster build passed; hash recorded after commit | +| S6 | Complete | GPT-6.1 Sol | 10:02:55-10:06:22 UTC; 66 focused tests and cluster build passed; `bb08f47af47892c560c728222692ef2f81ef7692` | | S7 | Externally blocked | Orchestrator | 09:54-09:55 UTC; Kusto has no configured cluster/database; query recorded below | -| S8 | Pending | GPT-6.1 Sol | Preview-only dialogs on separate `preview/sovereign-diagnostics` branch | +| S8 | In progress | GPT-6.1 Sol | Isolated worktree and `preview/sovereign-diagnostics` branch; never merge into PR | | Enhancement review | Pending | Opus 5.5 | Review enhancements, including isolated preview | | Full verification | Pending | Terra | Case 1 plus all Case 2 commands, including strict package-test types | @@ -64,7 +64,7 @@ The read-only attachment is unchanged; this repository copy is the live progress with the wrong signature; corrected it to the existing `setAuthCredentials` API. Four focused files / 66 tests passed (10:05:51-10:05:53 UTC). One exact `npm run build` passed, including workspace prebuilds and root compilation - (10:05:53-10:06:22 UTC, 29 seconds). Final hash recorded after commit, without amending. + (10:05:53-10:06:22 UTC, 29 seconds). Commit: `bb08f47af47892c560c728222692ef2f81ef7692`. - S5: GPT-6.1 Sol, 10:02:01-10:02:40 UTC (39 seconds). Reused R1's audience schema unchanged and added the hostname-suffix pattern/message, matching existing normalization and R5 public suffix rejection. Settings tests compare valid/invalid/blank/whitespace values with the real From d8f2db967cc3bc2d0f16bc7554f012db2bbb551b Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Mon, 26 Oct 2026 10:12:30 +0000 Subject: [PATCH 41/50] Bound AADSTS classification to complete five-to-nine digit codes (R6) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...ware-scope-and-entra-diagnostics-review.md | 22 ++++++++++++++++--- .../entraErrors/classifyEntraError.test.ts | 20 +++++++++++++++++ .../auth/entraErrors/classifyEntraError.ts | 2 +- src/utils/authTrace.test.ts | 15 +++++++++++++ 4 files changed, 55 insertions(+), 4 deletions(-) diff --git a/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md index 6860f9d84..049cd2f5e 100644 --- a/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md +++ b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md @@ -26,8 +26,9 @@ evidence are preserved. The operator requested GPT-6.1 Sol for narrowly scoped coding, an Opus 5.5 review after the fixes and again after enhancements, separate commits per item, and a Terra final verification run. -Only medium-or-higher findings are in the fix scope; R6 to R9 remain documented low-severity -follow-ups. S1 to S8 remain accepted enhancement work. PR #1050 remains a draft. +The original medium-or-higher fix threshold was expanded by the operator at 10:11 UTC: +**R6 to R9 must also be implemented**, each in its own commit. New review findings remain subject +to the medium-or-higher threshold. S1 to S8 remain accepted enhancement work. PR #1050 remains a draft. Started at 09:36 UTC. Durations below are wall-clock observations, not model compute time. Token usage is unavailable unless explicitly reported. Commits are local unless stated otherwise. @@ -40,7 +41,10 @@ The read-only attachment is unchanged; this repository copy is the live progress | R3 | Complete | GPT-6.1 Sol | 09:44:40-09:48:03 UTC; 189 focused tests passed; `0de180b1e93495b3a2c9504431b7fd34079614fa` | | R4 | Complete | GPT-6.1 Sol | 09:41:33-09:42:03 UTC; 118 focused tests passed; `f1bc8bffeb0d6106d5f4182767f5d7219884bd65` | | R5 | Complete | GPT-6.1 Sol | 09:42:14-09:44:14 UTC; 174 focused tests passed; `ecd0995babb996863cd2bfb71b9181941a347228` | -| R6-R9 | Deferred (low) | — | Kept documented per the operator's medium-or-higher threshold | +| R6 | Complete | GPT-6.1 Sol | 10:11:43-10:12:18 UTC (35 seconds); 60 focused tests passed; commit recorded with R7 | +| R7 | Pending | GPT-6.1 Sol | Neutral opaque-broker wording | +| R8 | Pending | GPT-6.1 Sol | Clear token-failure context on success at all four surfaces | +| R9 | Pending | GPT-6.1 Sol | Correct IMDS and private-DNS user guidance | | Fix review | Complete | Opus 5.5 | 09:49:45-09:53:46 UTC; no medium-or-higher findings | | S1 | Complete | GPT-6.1 Sol | 09:56:21-09:58:33 UTC; 124 focused tests passed; `20e07a36bf6e3d294cf14bcbe1c330b5821708e6` | | S2 | Complete | GPT-6.1 Sol | 09:58:59-09:59:39 UTC; 76 focused tests passed; `c20cc619363aafed9c6f717873fc5e718ad54b0e` | @@ -55,6 +59,11 @@ The read-only attachment is unchanged; this repository copy is the live progress ### Execution log +- 10:11 UTC: operator explicitly expanded fix scope to R6-R9. Their earlier deferral below is + historical, not the current instruction. A separate GPT-6.1 Sol agent handles these four fixes on + the PR branch while S8 continues in its isolated worktree; preview will receive these base fixes + before final verification. + - S6: GPT-6.1 Sol, 10:02:55-10:06:22 UTC (207 seconds, including cluster build). Eight new real-resolver integration cases cover Government/China interactive scopes and allowlists, Government/China managed identity scopes selected by host despite the opposite VS Code setting, @@ -517,6 +526,13 @@ would build a wrong lookup link, and the trace and classifier would disagree. ** **Author decision:** accepted (2026-10-26), recommended option. +**Implementation (R6):** GPT-6.1 Sol, 10:11:43-10:12:18 UTC (35 seconds). +The classifier now uses `/\bAADSTS(\d{5,9})\b/g`, matching the existing trace boundaries. +Eight- and nine-digit unknown codes retain their full lookup links; overlong codes and missing +word boundaries are rejected in both classifier and trace tests. +`npx vitest run src/documentdb/auth/entraErrors/classifyEntraError.test.ts src/utils/authTrace.test.ts`: +two files / 60 tests passed. No parser abstraction or trace behavior change. + ### R7. P3: Do not assume Windows in the opaque-broker message **Location:** [`brokerOpaque` rule](../../../../../src/documentdb/auth/entraErrors/entraErrorRules.ts#L121). diff --git a/src/documentdb/auth/entraErrors/classifyEntraError.test.ts b/src/documentdb/auth/entraErrors/classifyEntraError.test.ts index 748413835..5a6d03b61 100644 --- a/src/documentdb/auth/entraErrors/classifyEntraError.test.ts +++ b/src/documentdb/auth/entraErrors/classifyEntraError.test.ts @@ -95,6 +95,26 @@ describe('classifyEntraError', () => { expect(classifyEntraError('AADSTS12345 AADSTS1234567')?.code).toBe('12345'); }); + it.each(['12345678', '123456789'])('preserves the full unknown code %s in its lookup link', (code): void => { + const classification = classifyEntraError(`Sign-in failed (AADSTS${code}).`); + expect(classification).toEqual({ ruleId: 'unknownAadsts', code, brokered: false }); + const rule = entraErrorRules.find((candidate): boolean => candidate.id === classification?.ruleId); + expect(rule?.explain({ code: classification?.code, recentTokenFailure: false })).toContain( + `https://login.microsoftonline.com/error?code=${code}`, + ); + }); + + it.each([ + 'AADSTS1234567890', + 'AADSTS6500200000', + 'xAADSTS65002', + '_AADSTS65002', + 'AADSTS65002x', + 'AADSTS65002_', + ])('rejects an overlong or unbounded code: %s', (text): void => { + expect(classifyEntraError(text)).toBeUndefined(); + }); + it.each([ '', 'querySrv EBADRESP _mongodb._tcp.example.com', diff --git a/src/documentdb/auth/entraErrors/classifyEntraError.ts b/src/documentdb/auth/entraErrors/classifyEntraError.ts index 36277f572..ae591226c 100644 --- a/src/documentdb/auth/entraErrors/classifyEntraError.ts +++ b/src/documentdb/auth/entraErrors/classifyEntraError.ts @@ -23,7 +23,7 @@ export function classifyEntraError(text: string): EntraErrorClassification | und return undefined; } - const codes = Array.from(text.matchAll(/AADSTS(\d{5,7})/g), (match): string => match[1]); + const codes = Array.from(text.matchAll(/\bAADSTS(\d{5,9})\b/g), (match): string => match[1]); for (const rule of entraErrorRules) { const code = rule.aadsts?.find((candidate): boolean => codes.includes(candidate)); if (code) { diff --git a/src/utils/authTrace.test.ts b/src/utils/authTrace.test.ts index eb72ca647..e7431b6fc 100644 --- a/src/utils/authTrace.test.ts +++ b/src/utils/authTrace.test.ts @@ -9,6 +9,21 @@ import { describeAuthError, traceAuthFlow } from './authTrace'; vi.mock('../extensionVariables', () => ({ ext: { outputChannel: { info: vi.fn() } } })); describe('describeAuthError', () => { + it.each(['12345678', '123456789'])('keeps the full bounded AADSTS code %s', (code): void => { + expect(describeAuthError(new Error(`Sign-in failed (AADSTS${code}).`))).toContain(`aadsts=AADSTS${code};`); + }); + + it.each([ + 'AADSTS1234567890', + 'AADSTS6500200000', + 'xAADSTS65002', + '_AADSTS65002', + 'AADSTS65002x', + 'AADSTS65002_', + ])('does not trace an overlong or unbounded code: %s', (text): void => { + expect(describeAuthError(new Error(text))).toContain('aadsts=none;'); + }); + it('labels platform broker errors without exposing the raw message', (): void => { expect(describeAuthError(new Error('platform_broker_error: private sign-in failure'))).toBe( 'type=Error; code=unknown; httpStatus=unknown; aadsts=none; broker=platform_broker_error', From 439047d751d4f081fcea9003aca161715163cfc3 Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Mon, 26 Oct 2026 10:12:56 +0000 Subject: [PATCH 42/50] Use platform-neutral opaque broker guidance (R7) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...aware-scope-and-entra-diagnostics-review.md | 11 +++++++++-- .../entraErrors/classifyEntraError.test.ts | 18 ++++++++++++++++++ .../auth/entraErrors/entraErrorRules.ts | 2 +- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md index 049cd2f5e..cbb9ec059 100644 --- a/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md +++ b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md @@ -41,8 +41,8 @@ The read-only attachment is unchanged; this repository copy is the live progress | R3 | Complete | GPT-6.1 Sol | 09:44:40-09:48:03 UTC; 189 focused tests passed; `0de180b1e93495b3a2c9504431b7fd34079614fa` | | R4 | Complete | GPT-6.1 Sol | 09:41:33-09:42:03 UTC; 118 focused tests passed; `f1bc8bffeb0d6106d5f4182767f5d7219884bd65` | | R5 | Complete | GPT-6.1 Sol | 09:42:14-09:44:14 UTC; 174 focused tests passed; `ecd0995babb996863cd2bfb71b9181941a347228` | -| R6 | Complete | GPT-6.1 Sol | 10:11:43-10:12:18 UTC (35 seconds); 60 focused tests passed; commit recorded with R7 | -| R7 | Pending | GPT-6.1 Sol | Neutral opaque-broker wording | +| R6 | Complete | GPT-6.1 Sol | 10:11:43-10:12:18 UTC (35 seconds); 60 focused tests passed; `d8f2db967cc3bc2d0f16bc7554f012db2bbb551b` | +| R7 | Complete | GPT-6.1 Sol | 10:12:30-10:12:45 UTC (15 seconds); 76 focused tests passed; commit recorded with R8 | | R8 | Pending | GPT-6.1 Sol | Clear token-failure context on success at all four surfaces | | R9 | Pending | GPT-6.1 Sol | Correct IMDS and private-DNS user guidance | | Fix review | Complete | Opus 5.5 | 09:49:45-09:53:46 UTC; no medium-or-higher findings | @@ -546,6 +546,13 @@ wording ("The sign-in window reported an error without details"), or gate the Wi **Author decision:** accepted (2026-10-26), neutral wording only. No platform gate: the message must not mention Windows on any platform. +**Implementation (R7):** GPT-6.1 Sol, 10:12:30-10:12:45 UTC (15 seconds). +The generic opaque-broker explanation starts "The sign-in window reported an error without details". +No platform branch; cloud-specific authentication-log selection and the sovereign hedged message +are unchanged. Five focused generic-context cases pin the neutral wording. +`npx vitest run src/documentdb/auth/entraErrors/classifyEntraError.test.ts src/documentdb/auth/entraErrors/EntraDiagnosticsProvider.test.ts`: +two files / 76 tests passed. Localization generation remains deferred to final Terra verification. + ### R8. P3: Clear the token-failure record on success **Location:** [`recordEntraTokenFailure`](../../../../../src/documentdb/auth/entraErrors/entraTokenFailures.ts#L19). diff --git a/src/documentdb/auth/entraErrors/classifyEntraError.test.ts b/src/documentdb/auth/entraErrors/classifyEntraError.test.ts index 5a6d03b61..8208f7b24 100644 --- a/src/documentdb/auth/entraErrors/classifyEntraError.test.ts +++ b/src/documentdb/auth/entraErrors/classifyEntraError.test.ts @@ -134,6 +134,24 @@ describe('classifyEntraError', () => { }); describe('Entra error rule table', () => { + it.each([ + { recentTokenFailure: false }, + { cloudCategory: 'public', recentTokenFailure: true }, + { cloudCategory: 'usgov', recentTokenFailure: false }, + { cloudCategory: 'china', recentTokenFailure: false }, + { cloudCategory: 'custom', recentTokenFailure: false }, + ])('uses platform-neutral opaque-broker wording for %j', (context): void => { + const rule = entraErrorRules.find((candidate): boolean => candidate.id === 'brokerOpaque'); + const message = rule?.explain(context); + expect(message).toMatch(/^The sign-in window reported an error without details\./); + expect(message).not.toContain('Windows'); + expect(message).toContain( + context.cloudCategory && context.cloudCategory !== 'public' + ? 'Microsoft Sovereign Cloud Authentication' + : 'Microsoft Authentication', + ); + }); + it('has unique ids and no repeated AADSTS or MSAL codes', (): void => { const ids = entraErrorRules.map((rule): string => rule.id); const codes = entraErrorRules.flatMap((rule): readonly string[] => rule.aadsts ?? []); diff --git a/src/documentdb/auth/entraErrors/entraErrorRules.ts b/src/documentdb/auth/entraErrors/entraErrorRules.ts index 02a389f3f..3817f2159 100644 --- a/src/documentdb/auth/entraErrors/entraErrorRules.ts +++ b/src/documentdb/auth/entraErrors/entraErrorRules.ts @@ -119,7 +119,7 @@ export const entraErrorRules: readonly EntraErrorRule[] = [ ? 'Microsoft Sovereign Cloud Authentication' : 'Microsoft Authentication'; return l10n.t( - 'The Windows sign-in window reported an error without details. Check the error code shown in that window, or open Developer: Show Logs... > {authenticationLog} in VS Code. Your IT help desk can help interpret the code; if the problem persists, report it on GitHub. See {link}', + 'The sign-in window reported an error without details. Check the error code shown in that window, or open Developer: Show Logs... > {authenticationLog} in VS Code. Your IT help desk can help interpret the code; if the problem persists, report it on GitHub. See {link}', { authenticationLog, link: Links.SovereignClouds }, ); }, From 6f1f507e26f9c5767cfe95b949ed163a1310e47d Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Mon, 26 Oct 2026 10:14:30 +0000 Subject: [PATCH 43/50] Clear recent Entra token failures after successful acquisition (R8) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...ware-scope-and-entra-diagnostics-review.md | 14 +++- .../auth/ManagedIdentityAuthHandler.test.ts | 40 +++++++++++ .../auth/ManagedIdentityAuthHandler.ts | 3 +- .../auth/MicrosoftEntraIDAuthHandler.test.ts | 41 +++++++++++ .../auth/MicrosoftEntraIDAuthHandler.ts | 4 +- .../entraErrors/entraTokenFailures.test.ts | 14 ++++ .../auth/entraErrors/entraTokenFailures.ts | 4 ++ .../playground/PlaygroundEvaluator.ts | 3 +- .../shell/ShellSessionManager.test.ts | 70 +++++++++++++++++++ src/documentdb/shell/ShellSessionManager.ts | 3 +- 10 files changed, 190 insertions(+), 6 deletions(-) diff --git a/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md index cbb9ec059..fc236071c 100644 --- a/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md +++ b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md @@ -42,8 +42,8 @@ The read-only attachment is unchanged; this repository copy is the live progress | R4 | Complete | GPT-6.1 Sol | 09:41:33-09:42:03 UTC; 118 focused tests passed; `f1bc8bffeb0d6106d5f4182767f5d7219884bd65` | | R5 | Complete | GPT-6.1 Sol | 09:42:14-09:44:14 UTC; 174 focused tests passed; `ecd0995babb996863cd2bfb71b9181941a347228` | | R6 | Complete | GPT-6.1 Sol | 10:11:43-10:12:18 UTC (35 seconds); 60 focused tests passed; `d8f2db967cc3bc2d0f16bc7554f012db2bbb551b` | -| R7 | Complete | GPT-6.1 Sol | 10:12:30-10:12:45 UTC (15 seconds); 76 focused tests passed; commit recorded with R8 | -| R8 | Pending | GPT-6.1 Sol | Clear token-failure context on success at all four surfaces | +| R7 | Complete | GPT-6.1 Sol | 10:12:30-10:12:45 UTC (15 seconds); 76 focused tests passed; `439047d751d4f081fcea9003aca161715163cfc3` | +| R8 | Complete | GPT-6.1 Sol | 10:12:56-10:14:11 UTC (75 seconds); 70 focused tests passed; commit recorded with R9 | | R9 | Pending | GPT-6.1 Sol | Correct IMDS and private-DNS user guidance | | Fix review | Complete | Opus 5.5 | 09:49:45-09:53:46 UTC; no medium-or-higher findings | | S1 | Complete | GPT-6.1 Sol | 09:56:21-09:58:33 UTC; 124 focused tests passed; `20e07a36bf6e3d294cf14bcbe1c330b5821708e6` | @@ -566,6 +566,16 @@ succeeds on any of the four call sites. Add one test per surface. **Author decision:** accepted (2026-10-26), recommended option. +**Implementation (R8):** GPT-6.1 Sol, 10:12:56-10:14:11 UTC (75 seconds). +A single `clearEntraTokenFailure(clusterId)` deletes only that cluster's recent failure. +Both handlers and shell/playground token requests clear after token acquisition succeeds; +managed identity configuration and in-flight acquisition retain the previous record. +Failure-then-success tests cover all four surfaces (both token sources in each worker surface), +exercise the real diagnostics provider before and after success, retain original frozen errors, +and keep failed versus successful startup telemetry isolated. No telemetry or cache redesign. +`npx vitest run src/documentdb/auth/entraErrors/entraTokenFailures.test.ts src/documentdb/auth/MicrosoftEntraIDAuthHandler.test.ts src/documentdb/auth/ManagedIdentityAuthHandler.test.ts src/documentdb/shell/ShellSessionManager.test.ts`: +four files / 70 tests passed. + ### R9. P3: Correct the user manual on managed identity and private hosts **Location:** [sovereign-clouds.md](../../../../user-manual/sovereign-clouds.md#managed-identity). diff --git a/src/documentdb/auth/ManagedIdentityAuthHandler.test.ts b/src/documentdb/auth/ManagedIdentityAuthHandler.test.ts index ed7e65666..31e9b0dc6 100644 --- a/src/documentdb/auth/ManagedIdentityAuthHandler.test.ts +++ b/src/documentdb/auth/ManagedIdentityAuthHandler.test.ts @@ -12,6 +12,7 @@ import { type CachedClusterCredentials } from '../CredentialCache'; import { ConnectionStartupTimings } from '../utils/ConnectionStartupTimings'; import { AuthMethodId } from './AuthMethod'; import * as tokenResources from './documentdbTokenResource'; +import { EntraDiagnosticsProvider } from './entraErrors/EntraDiagnosticsProvider'; import { getRecentEntraTokenFailure, resetEntraTokenFailuresForTests } from './entraErrors/entraTokenFailures'; import { ManagedIdentityAuthHandler } from './ManagedIdentityAuthHandler'; import { expiresInSecondsFromTimestamp } from './tokenExpiry'; @@ -279,6 +280,45 @@ describe('ManagedIdentityAuthHandler', () => { expect(getRecentEntraTokenFailure('cluster-1')).toBeUndefined(); }); + it('retains failure context through configureAuth and clears it only after a token succeeds', async (): Promise => { + const connectionString = 'mongodb://cluster.azure.us/'; + const handler = new ManagedIdentityAuthHandler( + buildCredentials({ connectionString, connectionStringWithPassword: connectionString }), + ); + const error = Object.freeze(new Error('platform_broker_error')); + getManagedIdentityAccessToken.mockRejectedValueOnce(error); + const failedProperties: Record = {}; + const failed = await handler.configureAuth(undefined, { + startupCorrelationId: 'failed-startup', surface: 'extension', startupProperties: failedProperties, + }); + await expect(invokeOidcCallback(failed.options)).rejects.toBe(error); + const failure = getRecentEntraTokenFailure('cluster-1'); + expect(failure).toMatchObject({ cloudCategory: 'usgov', authMethod: 'managedIdentity' }); + const diagnostics = new EntraDiagnosticsProvider(); + expect(await diagnostics.explain({ clusterId: 'cluster-1', error })).toContain('Sign-in failed in Azure US Government'); + + const successfulProperties: Record = {}; + const successful = await handler.configureAuth(undefined, { + startupCorrelationId: 'successful-startup', surface: 'extension', startupProperties: successfulProperties, + }); + expect(getRecentEntraTokenFailure('cluster-1')).toBe(failure); + getManagedIdentityAccessToken.mockImplementationOnce(async () => { + expect(getRecentEntraTokenFailure('cluster-1')).toBe(failure); + return { accessToken: 'a-token', expiresOnTimestamp: Date.now() + 3_600_000 }; + }); + await expect(invokeOidcCallback(successful.options)).resolves.toMatchObject({ accessToken: 'a-token' }); + expect(getRecentEntraTokenFailure('cluster-1')).toBeUndefined(); + expect(failedProperties.errorKind).toBe('tokenAcquisitionFailed'); + expect(successfulProperties.errorKind).toBe('none'); + expect(await diagnostics.explain({ clusterId: 'cluster-1', error })).toMatch( + /^The sign-in window reported an error without details\./, + ); + expect(ext.outputChannel.info).toHaveBeenLastCalledWith( + expect.stringContaining('"cloudCategory":"unknown","recentTokenFailure":false'), + ); + expect(error.message).toBe('platform_broker_error'); + }); + it('honors the host-gated TLS exception', async () => { vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ get: vi.fn().mockReturnValue({ ossrdbmsEndpoint: 'https://ossrdbms-aad.database.windows.net' }), diff --git a/src/documentdb/auth/ManagedIdentityAuthHandler.ts b/src/documentdb/auth/ManagedIdentityAuthHandler.ts index b13bf59c8..b375eeb3c 100644 --- a/src/documentdb/auth/ManagedIdentityAuthHandler.ts +++ b/src/documentdb/auth/ManagedIdentityAuthHandler.ts @@ -18,7 +18,7 @@ import { TokenResourceResolutionError, type DocumentDBCustomEnvironment, } from './documentdbTokenResource'; -import { recordEntraTokenFailure } from './entraErrors/entraTokenFailures'; +import { clearEntraTokenFailure, recordEntraTokenFailure } from './entraErrors/entraTokenFailures'; import { getManagedIdentityAccessToken } from './managedIdentityTokenProvider'; import { getOidcAllowedHosts } from './oidcAllowedHosts'; import { expiresInSecondsFromTimestamp } from './tokenExpiry'; @@ -104,6 +104,7 @@ export class ManagedIdentityAuthHandler implements AuthHandler { traceContext, ); + clearEntraTokenFailure(this.clusterCredentials.clusterId); return { accessToken: token.accessToken, expiresInSeconds: expiresInSecondsFromTimestamp(token.expiresOnTimestamp), diff --git a/src/documentdb/auth/MicrosoftEntraIDAuthHandler.test.ts b/src/documentdb/auth/MicrosoftEntraIDAuthHandler.test.ts index 92174c5ee..08e8984a6 100644 --- a/src/documentdb/auth/MicrosoftEntraIDAuthHandler.test.ts +++ b/src/documentdb/auth/MicrosoftEntraIDAuthHandler.test.ts @@ -30,6 +30,7 @@ import { type CachedClusterCredentials } from '../CredentialCache'; import { ConnectionStartupTimings } from '../utils/ConnectionStartupTimings'; import { AuthMethodId } from './AuthMethod'; import { resolveInteractiveTokenResource, TokenResourceResolutionError } from './documentdbTokenResource'; +import { EntraDiagnosticsProvider } from './entraErrors/EntraDiagnosticsProvider'; import { getRecentEntraTokenFailure, resetEntraTokenFailuresForTests } from './entraErrors/entraTokenFailures'; import { MicrosoftEntraIDAuthHandler } from './MicrosoftEntraIDAuthHandler'; @@ -226,6 +227,46 @@ describe('MicrosoftEntraIDAuthHandler', () => { }); }); + it('clears a prior failure only after a session succeeds, without stale explanation context', async (): Promise => { + vi.mocked(resolveInteractiveTokenResource).mockResolvedValueOnce({ + resource: 'https://ossrdbms-aad.database.usgovcloudapi.net', + scope: 'https://ossrdbms-aad.database.usgovcloudapi.net/.default', + cloudCategory: 'usgov', + reason: 'setting', + details: { flow: 'interactive', cloudCategory: 'usgov', reason: 'setting' }, + }); + const error = Object.freeze(new Error('platform_broker_error')); + getSessionFromVSCode.mockRejectedValueOnce(error); + const handler = new MicrosoftEntraIDAuthHandler(buildCredentials('mongodb://cluster.example.com/')); + const failedProperties: Record = {}; + await expect(handler.configureAuth(undefined, { + startupCorrelationId: 'failed-startup', surface: 'extension', startupProperties: failedProperties, + })).rejects.toBe(error); + const failure = getRecentEntraTokenFailure('cluster-1'); + expect(failure).toMatchObject({ cloudCategory: 'usgov', authMethod: 'interactive' }); + const diagnostics = new EntraDiagnosticsProvider(); + expect(await diagnostics.explain({ clusterId: 'cluster-1', error })).toContain('Sign-in failed in Azure US Government'); + + getSessionFromVSCode.mockImplementationOnce(async () => { + expect(getRecentEntraTokenFailure('cluster-1')).toBe(failure); + return { accessToken: 'access-token' }; + }); + const successfulProperties: Record = {}; + await handler.configureAuth(undefined, { + startupCorrelationId: 'successful-startup', surface: 'extension', startupProperties: successfulProperties, + }); + expect(getRecentEntraTokenFailure('cluster-1')).toBeUndefined(); + expect(failedProperties.errorKind).toBe('tokenAcquisitionFailed'); + expect(successfulProperties.errorKind).toBe('none'); + expect(await diagnostics.explain({ clusterId: 'cluster-1', error })).toMatch( + /^The sign-in window reported an error without details\./, + ); + expect(mockOutputChannel.info).toHaveBeenLastCalledWith( + expect.stringContaining('"cloudCategory":"unknown","recentTokenFailure":false'), + ); + expect(error.message).toBe('platform_broker_error'); + }); + it('passes the explicitly configured custom hostname suffix to the OIDC allowlist', async () => { vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ get: vi.fn().mockReturnValue({ mongoClusterHostnameSuffix: '.database.example.com' }), diff --git a/src/documentdb/auth/MicrosoftEntraIDAuthHandler.ts b/src/documentdb/auth/MicrosoftEntraIDAuthHandler.ts index 598b255cc..58f83554d 100644 --- a/src/documentdb/auth/MicrosoftEntraIDAuthHandler.ts +++ b/src/documentdb/auth/MicrosoftEntraIDAuthHandler.ts @@ -21,7 +21,7 @@ import { TokenResourceResolutionError, type DocumentDBCustomEnvironment, } from './documentdbTokenResource'; -import { recordEntraTokenFailure } from './entraErrors/entraTokenFailures'; +import { clearEntraTokenFailure, recordEntraTokenFailure } from './entraErrors/entraTokenFailures'; import { getOidcAllowedHosts } from './oidcAllowedHosts'; /** @@ -89,6 +89,8 @@ export class MicrosoftEntraIDAuthHandler implements AuthHandler { throw error; } + clearEntraTokenFailure(this.clusterCredentials.clusterId); + // Prepare connection string const dbConnectionString = new DocumentDBConnectionString(this.clusterCredentials.connectionString); dbConnectionString.username = ''; // required to move forward with Entra ID diff --git a/src/documentdb/auth/entraErrors/entraTokenFailures.test.ts b/src/documentdb/auth/entraErrors/entraTokenFailures.test.ts index 3c6c5ecf3..a7efe7d9e 100644 --- a/src/documentdb/auth/entraErrors/entraTokenFailures.test.ts +++ b/src/documentdb/auth/entraErrors/entraTokenFailures.test.ts @@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { + clearEntraTokenFailure, getRecentEntraTokenFailure, recordEntraTokenFailure, resetEntraTokenFailuresForTests, @@ -60,6 +61,19 @@ describe('Entra token failure registry', () => { }); }); + it('clears only the successful cluster and safely accepts a missing entry', (): void => { + recordEntraTokenFailure('cluster-1', { cloudCategory: 'usgov', authMethod: 'interactive' }); + recordEntraTokenFailure('cluster-2', { cloudCategory: 'china', authMethod: 'managedIdentity' }); + + clearEntraTokenFailure('cluster-1'); + clearEntraTokenFailure('cluster-1'); + expect(getRecentEntraTokenFailure('cluster-1')).toBeUndefined(); + expect(getRecentEntraTokenFailure('cluster-2')).toMatchObject({ + cloudCategory: 'china', + authMethod: 'managedIdentity', + }); + }); + it('resets all entries for tests', (): void => { recordEntraTokenFailure('cluster-1', { cloudCategory: 'usgov', authMethod: 'interactive' }); recordEntraTokenFailure('cluster-2', { cloudCategory: 'china', authMethod: 'managedIdentity' }); diff --git a/src/documentdb/auth/entraErrors/entraTokenFailures.ts b/src/documentdb/auth/entraErrors/entraTokenFailures.ts index 7718f3d14..20c008a62 100644 --- a/src/documentdb/auth/entraErrors/entraTokenFailures.ts +++ b/src/documentdb/auth/entraErrors/entraTokenFailures.ts @@ -24,6 +24,10 @@ export function recordEntraTokenFailure(clusterId: string, context: EntraTokenFa }); } +export function clearEntraTokenFailure(clusterId: string): void { + failures.delete(clusterId); +} + export function getRecentEntraTokenFailure(clusterId: string, maxAgeMs = 10 * 60_000): EntraTokenFailure | undefined { const failure = failures.get(clusterId); if (failure && Date.now() - failure.timestamp > maxAgeMs) { diff --git a/src/documentdb/playground/PlaygroundEvaluator.ts b/src/documentdb/playground/PlaygroundEvaluator.ts index a6e003210..431f96655 100644 --- a/src/documentdb/playground/PlaygroundEvaluator.ts +++ b/src/documentdb/playground/PlaygroundEvaluator.ts @@ -22,7 +22,7 @@ import { resolveManagedIdentityTokenResource, TokenResourceResolutionError, } from '../auth/documentdbTokenResource'; -import { recordEntraTokenFailure } from '../auth/entraErrors/entraTokenFailures'; +import { clearEntraTokenFailure, recordEntraTokenFailure } from '../auth/entraErrors/entraTokenFailures'; import { CredentialCache } from '../CredentialCache'; import { withTokenAcquisitionTiming, type ConnectionStartupTimings } from '../utils/ConnectionStartupTimings'; import { resolveAllowInvalidCertificates } from '../utils/tlsException'; @@ -465,6 +465,7 @@ export class PlaygroundEvaluator implements vscode.Disposable { accessToken = session.accessToken; } + clearEntraTokenFailure(clusterId); postResponse({ type: 'tokenResponse', requestId: msg.requestId, diff --git a/src/documentdb/shell/ShellSessionManager.test.ts b/src/documentdb/shell/ShellSessionManager.test.ts index 6b0cdfc28..9b263c64b 100644 --- a/src/documentdb/shell/ShellSessionManager.test.ts +++ b/src/documentdb/shell/ShellSessionManager.test.ts @@ -14,6 +14,7 @@ import { resolveManagedIdentityTokenResource, TokenResourceResolutionError, } from '../auth/documentdbTokenResource'; +import { EntraDiagnosticsProvider } from '../auth/entraErrors/EntraDiagnosticsProvider'; import { getRecentEntraTokenFailure, resetEntraTokenFailuresForTests } from '../auth/entraErrors/entraTokenFailures'; import { CredentialCache } from '../CredentialCache'; import { PlaygroundEvaluator } from '../playground/PlaygroundEvaluator'; @@ -397,6 +398,75 @@ describe('ShellSessionManager', () => { }, ); + it.each([ + ['shell', 'vscode'], + ['shell', 'managedIdentity'], + ['playground', 'vscode'], + ['playground', 'managedIdentity'], + ] as const)( + 'clears stale %s %s failure context only after token success', + async (surface, source): Promise => { + const resolver = source === 'vscode' ? resolveInteractiveTokenResource : resolveManagedIdentityTokenResource; + vi.mocked(resolver).mockResolvedValueOnce({ + resource: 'https://ossrdbms-aad.database.chinacloudapi.cn', + scope: 'https://ossrdbms-aad.database.chinacloudapi.cn/.default', + cloudCategory: 'china', + reason: source === 'vscode' ? 'setting' : 'host', + details: { flow: source === 'vscode' ? 'interactive' : 'managedIdentity', cloudCategory: 'china' }, + }); + const error = Object.freeze(new Error('platform_broker_error')); + const provider = source === 'vscode' ? mockGetSessionFromVSCode : mockGetManagedIdentityAccessToken; + provider.mockRejectedValueOnce(error); + const manager = + surface === 'shell' + ? new ShellSessionManager({ clusterId, clusterDisplayName: 'Test Cluster', databaseName: 'test' }) + : new PlaygroundEvaluator(); + const postResponse = vi.fn(); + const failedProperties: Record = {}; + await workerCallbacks.onTokenRequest?.( + { type: 'tokenRequest', requestId: 'failed-token-request', source }, + postResponse, + undefined, + { startupCorrelationId: 'failed-startup', surface, startupProperties: failedProperties }, + ); + expect(postResponse).toHaveBeenLastCalledWith({ + type: 'tokenError', requestId: 'failed-token-request', error: error.message, + }); + const failure = getRecentEntraTokenFailure(clusterId); + expect(failure).toMatchObject({ + cloudCategory: 'china', authMethod: source === 'vscode' ? 'interactive' : 'managedIdentity', + }); + const diagnostics = new EntraDiagnosticsProvider(); + expect(await diagnostics.explain({ clusterId, error })).toContain('Sign-in failed in Azure China'); + + provider.mockImplementationOnce(async () => { + expect(getRecentEntraTokenFailure(clusterId)).toBe(failure); + return { accessToken: 'private-token', account: { label: 'private-account' } }; + }); + const successfulProperties: Record = {}; + await workerCallbacks.onTokenRequest?.( + { type: 'tokenRequest', requestId: 'successful-token-request', source }, + postResponse, + undefined, + { startupCorrelationId: 'successful-startup', surface, startupProperties: successfulProperties }, + ); + expect(postResponse).toHaveBeenLastCalledWith({ + type: 'tokenResponse', requestId: 'successful-token-request', accessToken: 'private-token', + }); + expect(getRecentEntraTokenFailure(clusterId)).toBeUndefined(); + expect(failedProperties.errorKind).toBe('tokenAcquisitionFailed'); + expect(successfulProperties.errorKind).toBe('none'); + expect(await diagnostics.explain({ clusterId, error })).toMatch( + /^The sign-in window reported an error without details\./, + ); + expect(ext.outputChannel.info).toHaveBeenLastCalledWith( + expect.stringContaining('"cloudCategory":"unknown","recentTokenFailure":false'), + ); + expect(error.message).toBe('platform_broker_error'); + manager.dispose(); + }, + ); + it.each(['shell', 'playground'] as const)( 'records a failed %s interactive token request when no session is returned', async (surface): Promise => { diff --git a/src/documentdb/shell/ShellSessionManager.ts b/src/documentdb/shell/ShellSessionManager.ts index e6eb1af01..16e72b965 100644 --- a/src/documentdb/shell/ShellSessionManager.ts +++ b/src/documentdb/shell/ShellSessionManager.ts @@ -16,7 +16,7 @@ import { resolveManagedIdentityTokenResource, TokenResourceResolutionError, } from '../auth/documentdbTokenResource'; -import { recordEntraTokenFailure } from '../auth/entraErrors/entraTokenFailures'; +import { clearEntraTokenFailure, recordEntraTokenFailure } from '../auth/entraErrors/entraTokenFailures'; import { WorkerSessionManager, type WorkerSessionCallbacks, @@ -448,6 +448,7 @@ export class ShellSessionManager implements vscode.Disposable { this._displayName = session.account.label || undefined; } + clearEntraTokenFailure(this._connectionInfo.clusterId); postResponse({ type: 'tokenResponse', requestId: msg.requestId, From 853ef409fc5d4f2481768c997efea27760e67561 Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Mon, 26 Oct 2026 10:16:47 +0000 Subject: [PATCH 44/50] Clarify managed identity and private DNS sovereign guidance (R9) Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../features/sovereign-clouds/README.md | 5 +- ...ware-scope-and-entra-diagnostics-review.md | 24 ++++++- docs/user-manual/sovereign-clouds.md | 67 ++++++++++++++++--- 3 files changed, 84 insertions(+), 12 deletions(-) diff --git a/docs/ai-and-plans/features/sovereign-clouds/README.md b/docs/ai-and-plans/features/sovereign-clouds/README.md index def23064e..bdd4034bc 100644 --- a/docs/ai-and-plans/features/sovereign-clouds/README.md +++ b/docs/ai-and-plans/features/sovereign-clouds/README.md @@ -61,7 +61,10 @@ readable without a support round trip. interactive sign-in through `getSessionFromVSCode`, which already picks the sovereign VS Code auth provider and requests the resolved cloud's scope. - [ManagedIdentityAuthHandler.ts](../../../../src/documentdb/auth/ManagedIdentityAuthHandler.ts): - managed identity, using the VM and cluster's resolved cloud scope. + managed identity, using an explicit audience override, the cluster host's cloud, or public-only + IMDS recognition (`AZUREPUBLICCLOUD`). An unclassified private hostname on a Government or + China VM requires `documentDB.customEnvironment.ossrdbmsEndpoint`; the VS Code sign-in cloud + is not a managed identity signal. - [playgroundWorker.ts](../../../../src/documentdb/playground/playgroundWorker.ts): the shell and playground worker asks the main thread for a token; the main thread resolves its scope. - [managedIdentityConnectionString.ts](../../../../src/documentdb/auth/managedIdentityConnectionString.ts): diff --git a/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md index fc236071c..a95a72722 100644 --- a/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md +++ b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md @@ -43,8 +43,8 @@ The read-only attachment is unchanged; this repository copy is the live progress | R5 | Complete | GPT-6.1 Sol | 09:42:14-09:44:14 UTC; 174 focused tests passed; `ecd0995babb996863cd2bfb71b9181941a347228` | | R6 | Complete | GPT-6.1 Sol | 10:11:43-10:12:18 UTC (35 seconds); 60 focused tests passed; `d8f2db967cc3bc2d0f16bc7554f012db2bbb551b` | | R7 | Complete | GPT-6.1 Sol | 10:12:30-10:12:45 UTC (15 seconds); 76 focused tests passed; `439047d751d4f081fcea9003aca161715163cfc3` | -| R8 | Complete | GPT-6.1 Sol | 10:12:56-10:14:11 UTC (75 seconds); 70 focused tests passed; commit recorded with R9 | -| R9 | Pending | GPT-6.1 Sol | Correct IMDS and private-DNS user guidance | +| R8 | Complete | GPT-6.1 Sol | 10:12:56-10:14:11 UTC (75 seconds); 70 focused tests passed; `6f1f507e26f9c5767cfe95b949ed163a1310e47d` | +| R9 | Complete | GPT-6.1 Sol | 10:14:30-10:16:24 UTC (114 seconds); docs-only; cluster build passed; commit recorded in final note | | Fix review | Complete | Opus 5.5 | 09:49:45-09:53:46 UTC; no medium-or-higher findings | | S1 | Complete | GPT-6.1 Sol | 09:56:21-09:58:33 UTC; 124 focused tests passed; `20e07a36bf6e3d294cf14bcbe1c330b5821708e6` | | S2 | Complete | GPT-6.1 Sol | 09:58:59-09:59:39 UTC; 76 focused tests passed; `c20cc619363aafed9c6f717873fc5e718ad54b0e` | @@ -59,6 +59,16 @@ The read-only attachment is unchanged; this repository copy is the live progress ### Execution log +- R6-R9: GPT-6.1 Sol completed the expanded scope in the main worktree on + `dev/tnaum/gov-clouds`, preserving the operator's 10:11 UTC scope update. + Per-item timings, exact focused Vitest commands, and results are recorded inline below. + The single cluster `npm run build` passed at 10:15:42-10:16:15 UTC (33 seconds), + including workspace prebuilds and root compilation. No source or manifest changes to shared + packages, no access to the S8 worktree, no new agents, no dependency installs, no push/amend. + Case 1 applies: PR #1050 is still a draft. Full checks, including `prettier-fix` and localization, + remain deferred to final Terra verification. No live sovereign authentication was attempted; + model token usage was not measured. + - 10:11 UTC: operator explicitly expanded fix scope to R6-R9. Their earlier deferral below is historical, not the current instruction. A separate GPT-6.1 Sol agent handles these four fixes on the PR branch while S8 continues in its isolated worktree; preview will receive these base fixes @@ -593,6 +603,16 @@ four files / 70 tests passed. **Author decision:** accepted (2026-10-26), all three corrections. +**Implementation (R9):** GPT-6.1 Sol, 10:14:30-10:16:24 UTC (114 seconds, including +the cluster build). The manual now states the public-only IMDS mapping and explicit audience +requirement for unknown Government/China/private-host managed identity connections. A dedicated +private-DNS section explains both settings for a Government private host, their separate +audience/trust roles, built-in-cloud applicability, non-SRV CNAME versus SRV-target checks, and +the implemented S4 actionable diagnostics. Machine scope and R5/S5 suffix validation are preserved; +the guidance never recommends arbitrary or public suffixes. The active feature README's ambiguous +VM-cloud wording is corrected; unrelated history is untouched. Documentation-only: no R9 Vitest +run needed. The one `npm run build` for R6-R9 passed (10:15:42-10:16:15 UTC, 33 seconds). + ## Shipping without sovereign access: enhancements These items are not defects. They are ordered by how much they reduce the risk of a blind release. diff --git a/docs/user-manual/sovereign-clouds.md b/docs/user-manual/sovereign-clouds.md index 8f089a754..122ff0648 100644 --- a/docs/user-manual/sovereign-clouds.md +++ b/docs/user-manual/sovereign-clouds.md @@ -40,10 +40,17 @@ you to change `microsoft-sovereign-cloud.environment` to the cluster's cloud. ## Managed identity -For a managed identity, the extension determines the cloud from the cluster host: -`azure.com`, `azure.us`, or `azure.cn`. Otherwise, it uses the Azure VM's instance metadata. +For a managed identity, the extension selects the token resource in this order: -If neither source identifies the cloud, set `documentDB.customEnvironment.ossrdbmsEndpoint`. +1. `documentDB.customEnvironment.ossrdbmsEndpoint`, if configured. +2. The cluster host: `azure.com` for public, `azure.us` for Government, or `azure.cn` for China. +3. The Azure VM's instance metadata (IMDS), but **only `AZUREPUBLICCLOUD` is recognized**. + +Government, China, and other IMDS environment values are not mapped to a cloud. If the host is +private or otherwise unrecognized and IMDS does not identify the public cloud, set +`documentDB.customEnvironment.ossrdbmsEndpoint` to your cloud's resource (the built-in resources +are listed above). +The VS Code sign-in cloud setting does not select the managed identity audience. ## Custom clouds @@ -58,12 +65,53 @@ resource in this order: If none is available, connecting fails with a message that names `documentDB.customEnvironment.ossrdbmsEndpoint`. It never falls back to the public cloud. -By default, the extension sends tokens only to cluster hosts under `azure.com`, `azure.us`, and -`azure.cn`. Set `documentDB.customEnvironment.mongoClusterHostnameSuffix` to allow cluster hosts -in a custom cloud. Include the leading dot. +The token audience and trusted hostnames are separate settings. For a custom cloud's service +domain, configure `documentDB.customEnvironment.mongoClusterHostnameSuffix` as described below. + +## Private endpoints and custom DNS names + +The extension recognizes connection-string hostnames under `azure.com`, `azure.us`, and `azure.cn` +and selects the corresponding OIDC hostname allowlist. For other names it defaults to +`*.azure.com`; it does not discover the cloud or trusted suffix by following DNS aliases. +A private endpoint that keeps its recognized Azure hostname needs no suffix override. + +For a trusted private service name outside those domains, set +`documentDB.customEnvironment.mongoClusterHostnameSuffix` to the service's DNS suffix, for example +`.documentdb.private.example`. This applies to **built-in Government and China clouds too**, not +only custom clouds. The connection-string hostname must be a subdomain of the configured suffix. +For a non-SRV (`mongodb://`) CNAME, the name in the connection string is checked, not its canonical +DNS target or resolved IP address. + +A managed identity connection to a private hostname in Government needs **both** properties: +the Government token resource and the trusted private service suffix. For example, for a host +such as `cluster.documentdb.private.example`: + +```json +{ + "documentDB.customEnvironment": { + "ossrdbmsEndpoint": "https://ossrdbms-aad.database.usgovcloudapi.net", + "mongoClusterHostnameSuffix": ".documentdb.private.example" + } +} +``` + +For `mongodb+srv://`, the extension still derives its allowlist from the hostname in the +connection string, but the driver checks the **SRV target hostnames** after SRV resolution, +including when the SRV lookup follows a CNAME. Trusting an alias's private suffix does not allow +SRV targets under a different suffix. In particular, a private alias whose SRV targets end in +`azure.us` or `azure.cn` does not automatically get that cloud's allowlist. Use the service-provided +connection string with the recognized service hostname, or DNS whose connection hostname and SRV +targets share the configured trusted service suffix. The setting does not bypass the driver's +SRV-domain or TLS checks. Do not broaden it to an arbitrary domain or public suffix to accommodate +unrelated targets. + +If the driver rejects a host for OIDC authentication, the extension explains that the host does not +appear to be allowed and names `documentDB.customEnvironment.mongoClusterHostnameSuffix` in User +settings. Verify the trusted service domain with your cloud administrator, configure it, and +connect again. The audience override alone does not authorize a hostname. Both are properties of the `documentDB.customEnvironment` setting, so they go in one object in -`settings.json`: +`settings.json`. For a custom cloud, for example: ```json { @@ -76,8 +124,9 @@ Both are properties of the `documentDB.customEnvironment` setting, so they go in Configure this machine-scoped object in **User settings**, not Workspace or Folder settings. The Settings editor checks both properties: the audience must be an HTTPS resource without a -path or `/.default`; the hostname suffix must use valid DNS labels, without a wildcard, URL, -port, or path. Bare top-level domains and common public suffixes such as `com.cn` are rejected. +non-root path, query, fragment, credentials, or `/.default`; the hostname suffix must use valid DNS +labels, without a wildcard, URL, port, or path (a leading dot is optional). Bare top-level domains +and common public suffixes such as `com.cn` are rejected. Only configure a service domain you trust. Surrounding whitespace is ignored; an empty or whitespace-only property clears the override and restores automatic selection. From 18060ae09088457b4ae2bdb7420d883f51b6ab46 Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Mon, 26 Oct 2026 10:25:40 +0000 Subject: [PATCH 45/50] docs(sovereign-clouds): correct historical D11 evidence query Replace the startup join with successful metadata observations across historical releases. Record the enhancement review disposition and retain the R9 completion note. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...ware-scope-and-entra-diagnostics-review.md | 101 ++++++++++++------ 1 file changed, 67 insertions(+), 34 deletions(-) diff --git a/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md index a95a72722..06310070e 100644 --- a/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md +++ b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md @@ -44,7 +44,7 @@ The read-only attachment is unchanged; this repository copy is the live progress | R6 | Complete | GPT-6.1 Sol | 10:11:43-10:12:18 UTC (35 seconds); 60 focused tests passed; `d8f2db967cc3bc2d0f16bc7554f012db2bbb551b` | | R7 | Complete | GPT-6.1 Sol | 10:12:30-10:12:45 UTC (15 seconds); 76 focused tests passed; `439047d751d4f081fcea9003aca161715163cfc3` | | R8 | Complete | GPT-6.1 Sol | 10:12:56-10:14:11 UTC (75 seconds); 70 focused tests passed; `6f1f507e26f9c5767cfe95b949ed163a1310e47d` | -| R9 | Complete | GPT-6.1 Sol | 10:14:30-10:16:24 UTC (114 seconds); docs-only; cluster build passed; commit recorded in final note | +| R9 | Complete | GPT-6.1 Sol | 10:14:30-10:16:24 UTC (114 seconds); docs-only; cluster build passed; `853ef409fc5d4f2481768c997efea27760e67561` | | Fix review | Complete | Opus 5.5 | 09:49:45-09:53:46 UTC; no medium-or-higher findings | | S1 | Complete | GPT-6.1 Sol | 09:56:21-09:58:33 UTC; 124 focused tests passed; `20e07a36bf6e3d294cf14bcbe1c330b5821708e6` | | S2 | Complete | GPT-6.1 Sol | 09:58:59-09:59:39 UTC; 76 focused tests passed; `c20cc619363aafed9c6f717873fc5e718ad54b0e` | @@ -54,11 +54,45 @@ The read-only attachment is unchanged; this repository copy is the live progress | S6 | Complete | GPT-6.1 Sol | 10:02:55-10:06:22 UTC; 66 focused tests and cluster build passed; `bb08f47af47892c560c728222692ef2f81ef7692` | | S7 | Externally blocked | Orchestrator | 09:54-09:55 UTC; Kusto has no configured cluster/database; query recorded below | | S8 | In progress | GPT-6.1 Sol | Isolated worktree and `preview/sovereign-diagnostics` branch; never merge into PR | -| Enhancement review | Pending | Opus 5.5 | Review enhancements, including isolated preview | +| Enhancement review | Main complete; preview pending | Opus 5.5 | 10:18:12-10:22:43 UTC (271 seconds); one medium S7 documentation finding; no medium-or-higher product-code findings; correction below; isolated preview review pending | | Full verification | Pending | Terra | Case 1 plus all Case 2 commands, including strict package-test types | ### Execution log +- Main enhancement review: Opus 5.5, 10:18:12-10:22:43 UTC (271 seconds). + One medium documentation finding: S7's static-metadata/startup join excludes v0.11.0, + whose events have only `connectioncorrelationid`, and v0.10.2, which has no + `connect.startup` event. It therefore cannot answer D11 for those historical versions. + No medium-or-higher product-code findings. The isolated S8 preview review remains pending. +- S7 review correction: GPT-6.1 Sol, 10:23:48-10:25:29 UTC (101 seconds; investigation, + correction, and diff verification). Disposition: addressed in this documentation-only commit. Replaced + the join with a direct successful `connect.getmetadata` aggregation after verifying current + source, v0.11.0, and v0.10.2; all carry `authmethod` and hashed domain metadata after a + successful connection. Recomputed all four hashes. No live ingested version field was verified; + approved live schema/version filtering remains required, as does the external Kusto + cluster/database configuration. No production query, product-code change, tests, or build. +- Final R6-R9 note (10:16:47 UTC, originally left uncommitted to avoid an extra commit or amend; + preserved in this documentation-correction commit): + R9 is `853ef409fc5d4f2481768c997efea27760e67561`; the preceding three hashes are in the live + table. All four separate commits include the requested Copilot co-author trailer. + No R6-R9 blocker. Changed files: + - `src/documentdb/auth/entraErrors/classifyEntraError.ts` + - `src/documentdb/auth/entraErrors/classifyEntraError.test.ts` + - `src/utils/authTrace.test.ts` + - `src/documentdb/auth/entraErrors/entraErrorRules.ts` + - `src/documentdb/auth/entraErrors/entraTokenFailures.ts` + - `src/documentdb/auth/entraErrors/entraTokenFailures.test.ts` + - `src/documentdb/auth/MicrosoftEntraIDAuthHandler.ts` + - `src/documentdb/auth/MicrosoftEntraIDAuthHandler.test.ts` + - `src/documentdb/auth/ManagedIdentityAuthHandler.ts` + - `src/documentdb/auth/ManagedIdentityAuthHandler.test.ts` + - `src/documentdb/shell/ShellSessionManager.ts` + - `src/documentdb/shell/ShellSessionManager.test.ts` + - `src/documentdb/playground/PlaygroundEvaluator.ts` + - `docs/user-manual/sovereign-clouds.md` + - `docs/ai-and-plans/features/sovereign-clouds/README.md` + - This live review/progress document. + - R6-R9: GPT-6.1 Sol completed the expanded scope in the main worktree on `dev/tnaum/gov-clouds`, preserving the operator's 10:11 UTC scope update. Per-item timings, exact focused Vitest commands, and results are recorded inline below. @@ -664,42 +698,41 @@ No approved telemetry endpoint or database was available in the repository setti availability probe therefore never reached a server. No credential, endpoint, or database was invented. D11 remains an open shipping gate, not a passed check and not evidence of zero users. -The following read-only query uses the repository's documented `RawEventsVSCodeExt` schema, -lowercase ingested property names, and startup correlation IDs. Add the approved cluster/database -instructions before running it. Its live schema and results are **not verified**: +The following read-only query uses the repository's documented `RawEventsVSCodeExt` schema and +lowercase ingested property names. It reads `connect.getmetadata` directly: current source, +v0.11.0, and v0.10.2 emit this event after a successful connection, with `authmethod` and hashed +domain metadata. No startup event or correlation join is required. The previous join excluded +v0.11.0 (only `connectioncorrelationid`) and v0.10.2 (no `connect.startup` event). +Add the approved cluster/database instructions before running it. Its live schema and results +are **not verified**: ```kusto -let recent = materialize( - RawEventsVSCodeExt - | where ServerTimestamp > ago(30d) - | where EventName contains "vscode-documentdb" - | where EventName endswith "connect.staticmetadata" or EventName endswith "connect.startup" - | project EventName, Properties -); -let sovereignAttempts = recent - | where EventName endswith "connect.staticmetadata" - | extend l2 = tostring(Properties.domaininfo_domain_l2), - l3 = tostring(Properties.domaininfo_domain_l3), - startup = coalesce(tostring(Properties.startupcorrelationid), - tostring(Properties.connectioncorrelationid)) - | extend cloud = case(l2 == "6a619114" or l3 == "49133d63", "china", - l2 == "8bafc480" or l3 == "f3e87358", "usgov", "") - | where cloud != "" and startup != "" - | distinct startup, cloud; -recent -| where EventName endswith "connect.startup" -| extend startup = tostring(Properties.startupcorrelationid), - auth = tostring(Properties.authmethod), - outcome = tostring(Properties.startupoutcome) -| join kind=inner sovereignAttempts on startup -| summarize attempts = count() by cloud, auth, outcome -| order by cloud asc, auth asc, outcome asc +RawEventsVSCodeExt +| where ServerTimestamp > ago(30d) and ServerTimestamp <= now() +| where EventName startswith "ms-azuretools.vscode-documentdb/" +| where EventName endswith "connect.getmetadata" +| extend auth = tostring(Properties.authmethod), + l2 = tostring(Properties.domaininfo_domain_l2), + l3 = tostring(Properties.domaininfo_domain_l3) +| where auth in ("MicrosoftEntraID", "ManagedIdentity") +| extend cloud = case(l2 == "6a619114" or l3 == "49133d63", "china", + l2 == "8bafc480" or l3 == "f3e87358", "usgov", "") +| where cloud != "" +| summarize successfulObservations = count() by cloud, auth +| order by cloud asc, auth asc ``` -Restrict the reporting period to versions predating the sovereign audience change when evaluating -whether China previously accepted the public audience. Historical telemetry does not record the -requested audience directly. Count only the relevant Entra/managed-identity methods, and investigate -missing metadata/correlation rather than interpreting absent joined rows as absent connections. +An approved live schema/version filter is required before using these counts for D11. The telemetry +SDK documents `common.extversion`, but its ingested location was not verified; this query does not +invent a version field. Once verified, filter to builds predating the sovereign audience change +and add the verified extension-version field to the cloud/auth aggregation. The unversioned +30-day totals alone cannot establish historical compatibility. + +These are successful metadata observations, not distinct users, all connection attempts, or a +success rate. Missing metadata, consent, collection failures, sampling, and ingestion gaps may +exclude successful connections; absent rows are not proof of no users. Historical telemetry has +no requested-audience field, so even positive observations do not directly prove public-audience +acceptance. Investigate build provenance and missing metadata before drawing a D11 conclusion. The query returns aggregate counts only, with no machine, tenant, user, or host identifiers. ### S8. Preview-build connection diagnostics dialogs From 6c863f4a6d4888cc17d96fdc8a0787d50fa923df Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Mon, 26 Oct 2026 10:50:07 +0000 Subject: [PATCH 46/50] test: fix sovereign review verification imports Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...ware-scope-and-entra-diagnostics-review.md | 228 ++++++++++++------ .../updateCredentials.test.ts | 5 +- src/documentdb/ClustersClient.test.ts | 40 ++- .../auth/sovereignAuth.integration.test.ts | 116 ++++++--- 4 files changed, 258 insertions(+), 131 deletions(-) diff --git a/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md index 06310070e..ef45666c4 100644 --- a/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md +++ b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md @@ -34,31 +34,103 @@ Started at 09:36 UTC. Durations below are wall-clock observations, not model com Token usage is unavailable unless explicitly reported. Commits are local unless stated otherwise. The read-only attachment is unchanged; this repository copy is the live progress record. -| Item | State | Model | Result / commit | -| --- | --- | --- | --- | -| R1 | Complete | GPT-6.1 Sol | 09:38:47-09:40:20 UTC; 114 focused tests passed; `047465a2bd3b561e92d0069570dd74ba0bd48f99` | -| R2 | Complete | GPT-6.1 Sol | 09:40:20-09:41:21 UTC; 95 focused tests passed; `2e27b5583d2e622d1b06bd7ac319eb56f52dff09` | -| R3 | Complete | GPT-6.1 Sol | 09:44:40-09:48:03 UTC; 189 focused tests passed; `0de180b1e93495b3a2c9504431b7fd34079614fa` | -| R4 | Complete | GPT-6.1 Sol | 09:41:33-09:42:03 UTC; 118 focused tests passed; `f1bc8bffeb0d6106d5f4182767f5d7219884bd65` | -| R5 | Complete | GPT-6.1 Sol | 09:42:14-09:44:14 UTC; 174 focused tests passed; `ecd0995babb996863cd2bfb71b9181941a347228` | -| R6 | Complete | GPT-6.1 Sol | 10:11:43-10:12:18 UTC (35 seconds); 60 focused tests passed; `d8f2db967cc3bc2d0f16bc7554f012db2bbb551b` | -| R7 | Complete | GPT-6.1 Sol | 10:12:30-10:12:45 UTC (15 seconds); 76 focused tests passed; `439047d751d4f081fcea9003aca161715163cfc3` | -| R8 | Complete | GPT-6.1 Sol | 10:12:56-10:14:11 UTC (75 seconds); 70 focused tests passed; `6f1f507e26f9c5767cfe95b949ed163a1310e47d` | -| R9 | Complete | GPT-6.1 Sol | 10:14:30-10:16:24 UTC (114 seconds); docs-only; cluster build passed; `853ef409fc5d4f2481768c997efea27760e67561` | -| Fix review | Complete | Opus 5.5 | 09:49:45-09:53:46 UTC; no medium-or-higher findings | -| S1 | Complete | GPT-6.1 Sol | 09:56:21-09:58:33 UTC; 124 focused tests passed; `20e07a36bf6e3d294cf14bcbe1c330b5821708e6` | -| S2 | Complete | GPT-6.1 Sol | 09:58:59-09:59:39 UTC; 76 focused tests passed; `c20cc619363aafed9c6f717873fc5e718ad54b0e` | -| S3 | Complete | GPT-6.1 Sol | 09:59:50-10:00:36 UTC; 99 focused tests passed; `7537d69ac2197700ccf4576c321b1a43af5839d0` | -| S4 | Complete | GPT-6.1 Sol | 10:00:46-10:01:50 UTC; 72 focused tests passed; `f6406d8f82dac2b351aa77f6de87639f41c8efa1` | -| S5 | Complete | GPT-6.1 Sol | 10:02:01-10:02:40 UTC; 56 focused tests passed; `dd29c55eadf8415f0364a7b72ddbbd23d0896e92` | -| S6 | Complete | GPT-6.1 Sol | 10:02:55-10:06:22 UTC; 66 focused tests and cluster build passed; `bb08f47af47892c560c728222692ef2f81ef7692` | -| S7 | Externally blocked | Orchestrator | 09:54-09:55 UTC; Kusto has no configured cluster/database; query recorded below | -| S8 | In progress | GPT-6.1 Sol | Isolated worktree and `preview/sovereign-diagnostics` branch; never merge into PR | -| Enhancement review | Main complete; preview pending | Opus 5.5 | 10:18:12-10:22:43 UTC (271 seconds); one medium S7 documentation finding; no medium-or-higher product-code findings; correction below; isolated preview review pending | -| Full verification | Pending | Terra | Case 1 plus all Case 2 commands, including strict package-test types | +| Item | State | Model | Result / commit | +| ------------------ | ------------------------------ | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| R1 | Complete | GPT-6.1 Sol | 09:38:47-09:40:20 UTC; 114 focused tests passed; `047465a2bd3b561e92d0069570dd74ba0bd48f99` | +| R2 | Complete | GPT-6.1 Sol | 09:40:20-09:41:21 UTC; 95 focused tests passed; `2e27b5583d2e622d1b06bd7ac319eb56f52dff09` | +| R3 | Complete | GPT-6.1 Sol | 09:44:40-09:48:03 UTC; 189 focused tests passed; `0de180b1e93495b3a2c9504431b7fd34079614fa` | +| R4 | Complete | GPT-6.1 Sol | 09:41:33-09:42:03 UTC; 118 focused tests passed; `f1bc8bffeb0d6106d5f4182767f5d7219884bd65` | +| R5 | Complete | GPT-6.1 Sol | 09:42:14-09:44:14 UTC; 174 focused tests passed; `ecd0995babb996863cd2bfb71b9181941a347228` | +| R6 | Complete | GPT-6.1 Sol | 10:11:43-10:12:18 UTC (35 seconds); 60 focused tests passed; `d8f2db967cc3bc2d0f16bc7554f012db2bbb551b` | +| R7 | Complete | GPT-6.1 Sol | 10:12:30-10:12:45 UTC (15 seconds); 76 focused tests passed; `439047d751d4f081fcea9003aca161715163cfc3` | +| R8 | Complete | GPT-6.1 Sol | 10:12:56-10:14:11 UTC (75 seconds); 70 focused tests passed; `6f1f507e26f9c5767cfe95b949ed163a1310e47d` | +| R9 | Complete | GPT-6.1 Sol | 10:14:30-10:16:24 UTC (114 seconds); docs-only; cluster build passed; `853ef409fc5d4f2481768c997efea27760e67561` | +| Fix review | Complete | Opus 5.5 | 09:49:45-09:53:46 UTC; no medium-or-higher findings | +| S1 | Complete | GPT-6.1 Sol | 09:56:21-09:58:33 UTC; 124 focused tests passed; `20e07a36bf6e3d294cf14bcbe1c330b5821708e6` | +| S2 | Complete | GPT-6.1 Sol | 09:58:59-09:59:39 UTC; 76 focused tests passed; `c20cc619363aafed9c6f717873fc5e718ad54b0e` | +| S3 | Complete | GPT-6.1 Sol | 09:59:50-10:00:36 UTC; 99 focused tests passed; `7537d69ac2197700ccf4576c321b1a43af5839d0` | +| S4 | Complete | GPT-6.1 Sol | 10:00:46-10:01:50 UTC; 72 focused tests passed; `f6406d8f82dac2b351aa77f6de87639f41c8efa1` | +| S5 | Complete | GPT-6.1 Sol | 10:02:01-10:02:40 UTC; 56 focused tests passed; `dd29c55eadf8415f0364a7b72ddbbd23d0896e92` | +| S6 | Complete | GPT-6.1 Sol | 10:02:55-10:06:22 UTC; 66 focused tests and cluster build passed; `bb08f47af47892c560c728222692ef2f81ef7692` | +| S7 | Externally blocked | Orchestrator | 09:54-09:55 UTC; Kusto has no configured cluster/database; query recorded below | +| S8 | Implemented; review pending | GPT-6.1 Sol | Preview commit `99314464`; base merge `7e5fe7d0`, evidence `9568ac02`; 277 focused tests and build passed; preview branch only | +| Enhancement review | Main complete; preview pending | Opus 5.5 | 10:18:12-10:22:43 UTC (271 seconds); one medium S7 documentation finding; no medium-or-higher product-code findings; correction below; isolated preview review pending | +| Full verification | Local checks passed | Terra + GPT-6.1 Sol | Terra: 10:35-10:45 UTC; Sol import correction: 10:46:04-10:49:30 UTC; lint, build, and 52 focused tests passed; original lint failure historical; generated finalization commit pending | ### Execution log +- S8: GPT-6.1 Sol implemented the isolated preview during 10:09-10:30 UTC + (`9931446469d3a0d62a262f8bf04127e3b9a0b250`). A later integration pass, 10:35:43-10:40:52 UTC + (309 seconds), merged committed PR base `18060ae0` while preserving R6-R9 and the preview hooks: + merge `7e5fe7d0f2d0375c29d74c41b2756498ea43ad27`, evidence + `9568ac028a1b00ea75137064128f704e1d36dbd0`. Twelve focused files / 277 tests and an exact + `npm run build` passed. Preview code is absent from the PR branch. Opus review, preview-only + localization generation, and reporter VSIX packaging are next. +- Terra final verification, 10:35-10:45 UTC: full command results below. The earlier interrupted + agent's unreported checks were not counted. This completed run found three lint errors in test + imports; GPT-6.1 Sol resolved those narrow corrections in the follow-up below. + Formatter/localization output outside the three corrected tests remains uncommitted for a + separate generated finalization commit. PR remains a draft. + +### Final verification (Terra, first completed run) + +| Check | UTC interval / elapsed | Result | +| --- | --- | --- | +| `npm run l10n` | 10:35:57-10:37:00 / 63 seconds | Passed | +| `npm run prettier-fix` | Started 10:37:02; completion time unavailable | Passed, exit 0 | +| `npm run l10n:check` | 10:38:37-10:39:37 / 60 seconds | Passed | +| `npm run lint` | 10:39:39-10:41:35 / 116 seconds | Historical failure: three test-import errors below; corrected rerun passed | +| `npm run build` | 10:41:37-10:42:16 / 39 seconds | Passed | +| `npm run typecheck:tests` | 10:42:18-10:42:32 / 14 seconds | Passed (exact strict package-test script) | +| Focused Case 1 Vitest | 10:42:34-10:42:38 / 4 seconds | Passed: four files / 140 tests | +| `npx vitest run` | 10:42:40-10:44:10 / 90 seconds | Passed: 323 files / 5,571 passed, one skipped | +| `npm run package` | Started 10:44:12; completion time unavailable | Passed, exit 0; `vscode-documentdb-1.0.2-alpha.0.vsix` | + +Original lint blockers (historical; all resolved): + +1. `src/commands/updateCredentials/updateCredentials.test.ts:6`: wildcard import triggers + `no-restricted-imports` for the Azure utility package's non-null helpers. +2. `src/documentdb/ClustersClient.test.ts:180`: inline `import()` type annotation violates + `@typescript-eslint/consistent-type-imports`. +3. `src/documentdb/auth/sovereignAuth.integration.test.ts:37`: same inline type-import rule. + +Terra supplied command results but did not persist the requested logs or document update; this +table records its returned evidence without inventing missing timing data. Runtime success does +not replace `typecheck:tests`, which ran separately and passed. Real sovereign authentication, +D11 production data, and additional model/vendor attestation remain separate external gates. + +### Lint import correction + +- GPT-6.1 Sol, 10:46:04-10:49:30 UTC (206 seconds through validation). Fixed only the three + test import errors: named type imports for the Azure utility partial mocks and the existing + named metadata import for `vi.importActual`, following nearby typed partial-mock prior art. + Actual module exports are still spread unchanged. No production behavior, test expectations, + or TDD contracts changed; Terra's formatting in these three files is retained. +- Verification command count: **3**, one invocation each of the exact lint script, exact build + script, and combined focused Vitest command below. No failures or retries. No agents, installs, + localization generation, formatter, strict package-test rerun, full runtime rerun, package rerun, + push, amend, or preview edits. Terra's prior full-suite and packaging evidence above is retained; + these import-only changes did not require repeating those checks. + +| Check | UTC interval / elapsed | Result | +| --- | --- | --- | +| `npm run lint` | 10:46:56-10:48:50 / 114 seconds | Passed; all three original lint errors resolved | +| `npm run build` | 10:48:50-10:49:27 / 37 seconds | Passed, including workspace prebuilds and root test type checking | +| Combined focused Vitest | 10:49:27-10:49:30 / 3 seconds | Passed: three files / 52 tests | + +Exact focused command: + +```bash +npx vitest run src/commands/updateCredentials/updateCredentials.test.ts src/documentdb/ClustersClient.test.ts src/documentdb/auth/sovereignAuth.integration.test.ts +``` + +The separate correction commit contains only those three tests and this live review progress note. +The other pending formatter/localization changes are preserved for a separate finalization commit. +PR #1050 remains a draft; the earlier Case 2 results above plus this correction are recorded without +claiming a new full-suite or packaging run. + +### Earlier implementation history + - Main enhancement review: Opus 5.5, 10:18:12-10:22:43 UTC (271 seconds). One medium documentation finding: S7's static-metadata/startup join excludes v0.11.0, whose events have only `connectioncorrelationid`, and v0.10.2, which has no @@ -259,17 +331,17 @@ cloud audience table and the main token-delivery paths are supported by the insp tests, installed SDK and driver behavior, and public metadata. Successful sovereign authentication is still unverified. -| ID | Priority | Finding | Confidence | Validation | -| --- | --- | --- | --- | --- | -| R1 | P2 / medium | Invalid explicit audience overrides silently fall through | High (99%) | Code trace, local reproduction, separate reviewer confirmed, re-reproduced in second pass | -| R2 | P2 / medium | Support traces omit or misstate available cloud evidence | High (99%) | Three local reproductions, separate reviewer confirmed, re-reproduced in second pass | -| R3 | P1 / high | Government and China hosts are not recognized as DocumentDB, so managed identity is never offered in New Connection or Update Credentials | High (99%) | Code trace, local reproduction (second pass) | -| R4 | P2 / medium | A missing or incomplete custom cloud definition gets remediation that names the wrong setting | High (95%) | Code trace, installed SDK inspection, local reproduction (second pass) | -| R5 | P2 / medium | `mongoClusterHostnameSuffix` widens the token allowlist from workspace settings and accepts public suffixes | High (90%) | Code trace, installed driver inspection, local reproduction (second pass) | -| R6 | P3 / low | AADSTS code regex is unbounded and disagrees with the trace regex | High (99%) | Local reproduction (second pass) | -| R7 | P3 / low | Opaque-broker message assumes Windows | Medium (70%) | Code trace (second pass) | -| R8 | P3 / low | Token-failure registry is never cleared on success | Medium (75%) | Code trace (second pass) | -| R9 | P3 / low | User manual overstates managed identity cloud detection and scopes the host suffix to custom clouds only | High (95%) | Doc/code comparison (second pass) | +| ID | Priority | Finding | Confidence | Validation | +| --- | ----------- | ----------------------------------------------------------------------------------------------------------------------------------------- | ------------ | ----------------------------------------------------------------------------------------- | +| R1 | P2 / medium | Invalid explicit audience overrides silently fall through | High (99%) | Code trace, local reproduction, separate reviewer confirmed, re-reproduced in second pass | +| R2 | P2 / medium | Support traces omit or misstate available cloud evidence | High (99%) | Three local reproductions, separate reviewer confirmed, re-reproduced in second pass | +| R3 | P1 / high | Government and China hosts are not recognized as DocumentDB, so managed identity is never offered in New Connection or Update Credentials | High (99%) | Code trace, local reproduction (second pass) | +| R4 | P2 / medium | A missing or incomplete custom cloud definition gets remediation that names the wrong setting | High (95%) | Code trace, installed SDK inspection, local reproduction (second pass) | +| R5 | P2 / medium | `mongoClusterHostnameSuffix` widens the token allowlist from workspace settings and accepts public suffixes | High (90%) | Code trace, installed driver inspection, local reproduction (second pass) | +| R6 | P3 / low | AADSTS code regex is unbounded and disagrees with the trace regex | High (99%) | Local reproduction (second pass) | +| R7 | P3 / low | Opaque-broker message assumes Windows | Medium (70%) | Code trace (second pass) | +| R8 | P3 / low | Token-failure registry is never cleared on success | Medium (75%) | Code trace (second pass) | +| R9 | P3 / low | User manual overstates managed identity cloud detection and scopes the host suffix to custom clouds only | High (95%) | Doc/code comparison (second pass) | Enhancements that reduce the risk of shipping without sovereign access are listed separately in [Shipping without sovereign access](#shipping-without-sovereign-access-enhancements). They are @@ -327,10 +399,10 @@ public-resource override. Reasons were `customLoginHost` and `host`; warnings we with a localized, setting-specific error before fallback on paths where the override applies. Keep the intentional behavior that built-in interactive clouds ignore the custom override. -| Option | Pros | Cons | -| --- | --- | --- | -| Reject an invalid applicable override (recommended) | Makes precedence explicit; avoids requesting an unintended audience; gives a direct corrective action | A currently ignored typo becomes a visible configuration failure | -| Warn and continue with a trace value of `invalid` | Preserves permissive behavior | Still requests an audience different from the explicit configuration; warning can be missed | +| Option | Pros | Cons | +| --------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- | +| Reject an invalid applicable override (recommended) | Makes precedence explicit; avoids requesting an unintended audience; gives a direct corrective action | A currently ignored typo becomes a visible configuration failure | +| Warn and continue with a trace value of `invalid` | Preserves permissive behavior | Still requests an audience different from the explicit configuration; warning can be missed | **Regression tests:** nonempty malformed override with a recognized custom login host, a recognized managed-identity cluster host, valid ARM metadata, and a usable IMDS result; assert no @@ -394,10 +466,10 @@ candidate's safe resolution fields before the mismatch check. Distinguish an une an examined but unrecognized host. Preserve the current audience precedence; this does not propose adding a managed-identity mismatch check. Do not log cluster hosts, credentials, or raw errors. -| Option | Pros | Cons | -| --- | --- | --- | -| Populate known evidence before branching (recommended) | Accurate traces across all consumers; small central change | Requires updating trace expectations | -| Mark skipped fields `notEvaluated` and add evidence at each error site | Avoids asserting a false negative | More branch-specific handling and incomplete evidence | +| Option | Pros | Cons | +| ---------------------------------------------------------------------- | ---------------------------------------------------------- | ----------------------------------------------------- | +| Populate known evidence before branching (recommended) | Accurate traces across all consumers; small central change | Requires updating trace expectations | +| Mark skipped fields `notEvaluated` and add evidence at each error site | Avoids asserting a false negative | More branch-specific handling and incomplete evidence | **Regression tests:** override plus recognized host, unresolved custom cloud plus recognized host, and custom-resource mismatch. Assert the full resolved/unresolved trace shape, not just the error @@ -633,7 +705,7 @@ four files / 70 tests passed. (`*.azure.com` default) and needs this setting. A private-host managed identity user in Government needs **both** properties. Add a "Private endpoints and custom DNS names" subsection. - The driver's rejection (`Host '...' is not valid for OIDC authentication with ALLOWED_HOSTS of - '*.azure.com'`) is not translated by any provider. See enhancement S4. +'*.azure.com'`) is not translated by any provider. See enhancement S4. **Author decision:** accepted (2026-10-26), all three corrections. @@ -651,16 +723,16 @@ run needed. The one `npm run build` for R6-R9 passed (10:15:42-10:16:15 UTC, 33 These items are not defects. They are ordered by how much they reduce the risk of a blind release. -| ID | Enhancement | Why it matters without a sovereign cluster | -| --- | --- | --- | -| S1 | Attach the coarse resolution fields (`cloudCategory`, `tokenResourceReason`, `configuredCloud`, `hostCloud`, `errorKind`) to the existing connection-startup telemetry for Entra and managed identity attempts. | The only scalable signal for whether Government/China connections succeed after release. All values are closed enums with no identifiers. The first pass called this a separate scope decision; this pass recommends making it before shipping. | -| S2 | Set the extension `appName` for `azure.us` and `azure.cn` hosts too ([`ClustersClient`](../../../../../src/documentdb/ClustersClient.ts#L365) uses `hasAzureDomain`, which matches `azure.com` only). | The service team can then attribute VS Code connections in sovereign clusters and confirm success or failure server-side, independent of client telemetry. Same data as already sent for public clusters. | -| S3 | Trace the raw IMDS `azEnvironment` when it matches a safe shape such as `^Azure[A-Za-z]{1,40}$`; keep `other` for anything else. | Answers research open question 5 from the first Government managed identity report, which is the evidence needed to revisit D7 safely. | -| S4 | Translate the driver's `ALLOWED_HOSTS` rejection into a message naming `documentDB.customEnvironment.mongoClusterHostnameSuffix` (a rule matched on the driver text, routed through `ConnectionDiagnosticsService`). | Private endpoints with custom DNS are common in Government tenants; today the user sees a raw driver message that mentions `*.azure.com`. | -| S5 | Add `pattern` validation for both `documentDB.customEnvironment` properties in `package.json` (see R1). | Catches configuration mistakes in the Settings editor, before any remote troubleshooting round trip. | -| S6 | Add end-to-end handler tests using the real resolver, not a mocked resolution, for: Government interactive (scope passed to `getSessionFromVSCode`, `ALLOWED_HOSTS`), Government managed identity by host, China both flows, and the shell and playground `tokenRequest` path with a Government connection string. | The current handler tests mock `resolveInteractiveTokenResource`, so a wiring regression between resolver and token request would pass. | -| S7 | Use the existing hashed domain telemetry to run the D11 check. `*.azure.cn` and `*.azure.us` hosts are classified `domainInfo_isAzure = false` and hashed. Match `domainInfo_domain_l2 = 6a619114` (`azure.cn`) or `domainInfo_domain_l3 = 49133d63` (`cosmos.azure.cn`); for Government, `8bafc480` (`azure.us`) and `f3e87358` (`cosmos.azure.us`). Values are the first 8 hex characters of SHA-256, as computed by `getClusterMetadata.ts`. | Makes the D11 shipping gate answerable with data that already exists. | -| S8 | Preview-build connection diagnostics dialogs. Replaces the earlier scripted-checklist idea. See [S8 design](#s8-preview-build-connection-diagnostics-dialogs). | The reporter sees what the extension detected and decided at each stage, and can paste a redacted report with one click. | +| ID | Enhancement | Why it matters without a sovereign cluster | +| --- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| S1 | Attach the coarse resolution fields (`cloudCategory`, `tokenResourceReason`, `configuredCloud`, `hostCloud`, `errorKind`) to the existing connection-startup telemetry for Entra and managed identity attempts. | The only scalable signal for whether Government/China connections succeed after release. All values are closed enums with no identifiers. The first pass called this a separate scope decision; this pass recommends making it before shipping. | +| S2 | Set the extension `appName` for `azure.us` and `azure.cn` hosts too ([`ClustersClient`](../../../../../src/documentdb/ClustersClient.ts#L365) uses `hasAzureDomain`, which matches `azure.com` only). | The service team can then attribute VS Code connections in sovereign clusters and confirm success or failure server-side, independent of client telemetry. Same data as already sent for public clusters. | +| S3 | Trace the raw IMDS `azEnvironment` when it matches a safe shape such as `^Azure[A-Za-z]{1,40}$`; keep `other` for anything else. | Answers research open question 5 from the first Government managed identity report, which is the evidence needed to revisit D7 safely. | +| S4 | Translate the driver's `ALLOWED_HOSTS` rejection into a message naming `documentDB.customEnvironment.mongoClusterHostnameSuffix` (a rule matched on the driver text, routed through `ConnectionDiagnosticsService`). | Private endpoints with custom DNS are common in Government tenants; today the user sees a raw driver message that mentions `*.azure.com`. | +| S5 | Add `pattern` validation for both `documentDB.customEnvironment` properties in `package.json` (see R1). | Catches configuration mistakes in the Settings editor, before any remote troubleshooting round trip. | +| S6 | Add end-to-end handler tests using the real resolver, not a mocked resolution, for: Government interactive (scope passed to `getSessionFromVSCode`, `ALLOWED_HOSTS`), Government managed identity by host, China both flows, and the shell and playground `tokenRequest` path with a Government connection string. | The current handler tests mock `resolveInteractiveTokenResource`, so a wiring regression between resolver and token request would pass. | +| S7 | Use the existing hashed domain telemetry to run the D11 check. `*.azure.cn` and `*.azure.us` hosts are classified `domainInfo_isAzure = false` and hashed. Match `domainInfo_domain_l2 = 6a619114` (`azure.cn`) or `domainInfo_domain_l3 = 49133d63` (`cosmos.azure.cn`); for Government, `8bafc480` (`azure.us`) and `f3e87358` (`cosmos.azure.us`). Values are the first 8 hex characters of SHA-256, as computed by `getClusterMetadata.ts`. | Makes the D11 shipping gate answerable with data that already exists. | +| S8 | Preview-build connection diagnostics dialogs. Replaces the earlier scripted-checklist idea. See [S8 design](#s8-preview-build-connection-diagnostics-dialogs). | The reporter sees what the extension detected and decided at each stage, and can paste a redacted report with one click. | ### Follow-up todos (S1 to S7) @@ -767,11 +839,11 @@ callback. A dialog there must not be awaited, or a reporter who reads it slowly timeout that looks like a sign-in failure. Interactive sign-in on the extension connection gets its token in `configureAuth`, before the driver runs, so it can safely block. -| Dialog | Shown | Blocking? | Hook | -| --- | --- | --- | --- | -| 1 of 3: about to request a token | After cloud resolution succeeds or fails, before any token request | Yes in `configureAuth` (both handlers). No in shell and playground (`handleTokenRequest` runs inside the worker's callback) | `MicrosoftEntraIDAuthHandler`, `ManagedIdentityAuthHandler` (`configureAuth`), `ShellSessionManager` and `PlaygroundEvaluator` (`handleTokenRequest`) | -| 2 of 3: token result | After the token request succeeds or fails | Yes for interactive in `configureAuth`; no elsewhere | After `getSessionFromVSCode` or `getManagedIdentityAccessToken`, at the same four sites | -| 3 of 3: connection result | After `MongoClient.connect()` succeeds or throws, and after two probe commands | Yes (the connection is already open or has failed) | `ClustersClient.connect`, after stage 04, and in its `catch` | +| Dialog | Shown | Blocking? | Hook | +| -------------------------------- | ------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| 1 of 3: about to request a token | After cloud resolution succeeds or fails, before any token request | Yes in `configureAuth` (both handlers). No in shell and playground (`handleTokenRequest` runs inside the worker's callback) | `MicrosoftEntraIDAuthHandler`, `ManagedIdentityAuthHandler` (`configureAuth`), `ShellSessionManager` and `PlaygroundEvaluator` (`handleTokenRequest`) | +| 2 of 3: token result | After the token request succeeds or fails | Yes for interactive in `configureAuth`; no elsewhere | After `getSessionFromVSCode` or `getManagedIdentityAccessToken`, at the same four sites | +| 3 of 3: connection result | After `MongoClient.connect()` succeeds or throws, and after two probe commands | Yes (the connection is already open or has failed) | `ClustersClient.connect`, after stage 04, and in its `catch` | Show each dialog at most once per `(clusterId, surface, step)` per window. Token refreshes in the shell and playground then do not repeat it. If resolution fails, dialog 1 reports the failure and @@ -983,13 +1055,13 @@ sovereign access, field telemetry is the main way to learn whether the feature w Verification is **Case 1: still working**, because this is a review of a draft PR, not a request to mark it ready for review. -| Check | Result | -| --- | --- | -| `npm run build` | Passed, including workspace prebuilds | -| Focused Vitest run: auth resolver/scopes/allowed hosts/handlers/Entra errors, shell/playground worker integration, copy connection string, connection-string input, diagnostics service, auth traces | 15 files, 297 tests passed | -| Settings contribution suite | 11 tests passed | -| Temporary characterization probes of R1/R2 | 5 tests passed; removed after execution | -| Second pass: temporary probes for R1 to R6 and the edge cases below | 19 tests passed (`npx vitest run`, two files); removed after execution | +| Check | Result | +| ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | +| `npm run build` | Passed, including workspace prebuilds | +| Focused Vitest run: auth resolver/scopes/allowed hosts/handlers/Entra errors, shell/playground worker integration, copy connection string, connection-string input, diagnostics service, auth traces | 15 files, 297 tests passed | +| Settings contribution suite | 11 tests passed | +| Temporary characterization probes of R1/R2 | 5 tests passed; removed after execution | +| Second pass: temporary probes for R1 to R6 and the edge cases below | 19 tests passed (`npx vitest run`, two files); removed after execution | The IDE test runner did not discover these files, so the suites were run with the repository's `npx vitest run` command. One initial temporary-probe assertion incorrectly equated absent object @@ -1028,20 +1100,20 @@ Second pass: ### Second-pass edge cases examined -| Case | Observed | Assessment | -| --- | --- | --- | -| Interactive, public setting, private DNS host | Public audience, no mismatch check | Correct per D8 | -| Replica set mixing `azure.com` and `azure.us` hosts | No host cloud; `ALLOWED_HOSTS` is `*.azure.com,*.azure.us` | Acceptable; unrealistic topology | -| `mongodb+srv://` alias under a private domain whose SRV targets `azure.us` | No host cloud from the alias; `ALLOWED_HOSTS` defaults to `*.azure.com`, so the driver rejects the `azure.us` targets | **Sovereign-specific.** The same alias works in the public cloud only because the default happens to be `*.azure.com`. Consider deriving `ALLOWED_HOSTS` from the configured interactive cloud when the host is unclassified, or document it under R9; S4 makes it diagnosable | -| Copy, managed identity, private host, `USGovernment` setting | Government audience (`reason: setting`) | Documented copy policy; it can disagree with connect-time D7, which ignores the setting. Acceptable because the copy is editable | -| Text with `platform_broker_error` and AADSTS65002 | `firstPartyPreauthorization`, `brokered: true` | Correct precedence | -| Text with AADSTS50001 and AADSTS500011 | `resourceNotInTenant` (rule order, not text order) | Acceptable; document that rule order is the precedence | -| HTML-escaped preauthorization phrase only | `firstPartyPreauthorization` | Correct | -| IMDS request through a VS Code proxy or a stalled body | Resolves `undefined` on error or socket timeout | Acceptable. The managed identity token request uses the same patched `http` stack, so a proxy that breaks IMDS breaks the token anyway | -| Shell/playground token refresh | The audience is re-resolved on every worker request; managed identity with a private host repeats IMDS (2 s timeout) | Acceptable at hourly refresh; consider caching the IMDS outcome for the window | -| Diagnostics provider order and deadline | The Entra provider is registered last and is synchronous; the service deadline is shared across providers | Low risk; it depends on earlier providers declining Azure clusters quickly, which this pass did not trace in detail | -| Managed identity token errors | The provider rethrows a described message; the uncategorized branch keeps the original text, so AADSTS codes generally survive for classification | Acceptable; categorized branches drop the code, but those reasons are not Entra-classifier rules | -| Driver `ALLOWED_HOSTS` enforcement (7.2.0) | Enforced after SRV resolution for callback OIDC unless `ENVIRONMENT` is set; the extension never sets it | Confirms that `ALLOWED_HOSTS` is a real control, which is the basis of R5 | +| Case | Observed | Assessment | +| -------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Interactive, public setting, private DNS host | Public audience, no mismatch check | Correct per D8 | +| Replica set mixing `azure.com` and `azure.us` hosts | No host cloud; `ALLOWED_HOSTS` is `*.azure.com,*.azure.us` | Acceptable; unrealistic topology | +| `mongodb+srv://` alias under a private domain whose SRV targets `azure.us` | No host cloud from the alias; `ALLOWED_HOSTS` defaults to `*.azure.com`, so the driver rejects the `azure.us` targets | **Sovereign-specific.** The same alias works in the public cloud only because the default happens to be `*.azure.com`. Consider deriving `ALLOWED_HOSTS` from the configured interactive cloud when the host is unclassified, or document it under R9; S4 makes it diagnosable | +| Copy, managed identity, private host, `USGovernment` setting | Government audience (`reason: setting`) | Documented copy policy; it can disagree with connect-time D7, which ignores the setting. Acceptable because the copy is editable | +| Text with `platform_broker_error` and AADSTS65002 | `firstPartyPreauthorization`, `brokered: true` | Correct precedence | +| Text with AADSTS50001 and AADSTS500011 | `resourceNotInTenant` (rule order, not text order) | Acceptable; document that rule order is the precedence | +| HTML-escaped preauthorization phrase only | `firstPartyPreauthorization` | Correct | +| IMDS request through a VS Code proxy or a stalled body | Resolves `undefined` on error or socket timeout | Acceptable. The managed identity token request uses the same patched `http` stack, so a proxy that breaks IMDS breaks the token anyway | +| Shell/playground token refresh | The audience is re-resolved on every worker request; managed identity with a private host repeats IMDS (2 s timeout) | Acceptable at hourly refresh; consider caching the IMDS outcome for the window | +| Diagnostics provider order and deadline | The Entra provider is registered last and is synchronous; the service deadline is shared across providers | Low risk; it depends on earlier providers declining Azure clusters quickly, which this pass did not trace in detail | +| Managed identity token errors | The provider rethrows a described message; the uncategorized branch keeps the original text, so AADSTS codes generally survive for classification | Acceptable; categorized branches drop the code, but those reasons are not Entra-classifier rules | +| Driver `ALLOWED_HOSTS` enforcement (7.2.0) | Enforced after SRV resolution for callback OIDC unless `ENVIRONMENT` is set; the extension never sets it | Confirms that `ALLOWED_HOSTS` is a real control, which is the basis of R5 | ## Decisions respected and remaining rollout gates diff --git a/src/commands/updateCredentials/updateCredentials.test.ts b/src/commands/updateCredentials/updateCredentials.test.ts index 1707a35fb..c88e32879 100644 --- a/src/commands/updateCredentials/updateCredentials.test.ts +++ b/src/commands/updateCredentials/updateCredentials.test.ts @@ -3,8 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { type IActionContext } from '@microsoft/vscode-azext-utils'; -import type * as wizardUtils from '@microsoft/vscode-azext-utils'; +import { type AzureWizard, type IActionContext } from '@microsoft/vscode-azext-utils'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import * as vscode from 'vscode'; import { AuthMethodId } from '../../documentdb/auth/AuthMethod'; @@ -22,7 +21,7 @@ const mockDeleteClient = vi.fn(); const mockDeleteCredentials = vi.fn(); vi.mock('@microsoft/vscode-azext-utils', async (importOriginal) => ({ - ...(await importOriginal()), + ...(await importOriginal<{ AzureWizard: typeof AzureWizard }>()), AzureWizard: class { constructor(context: UpdateCredentialsWizardContext) { capturedContexts.push(context); diff --git a/src/documentdb/ClustersClient.test.ts b/src/documentdb/ClustersClient.test.ts index d7c9b546c..f5a3645dc 100644 --- a/src/documentdb/ClustersClient.test.ts +++ b/src/documentdb/ClustersClient.test.ts @@ -5,7 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { type IActionContext, type ITelemetryContext, UserCancelledError } from '@microsoft/vscode-azext-utils'; +import { UserCancelledError, type IActionContext, type ITelemetryContext } from '@microsoft/vscode-azext-utils'; import type * as Mongodb from 'mongodb'; import { MongoClient, type MongoClientOptions } from 'mongodb'; import { ext } from '../extensionVariables'; @@ -166,7 +166,8 @@ describe('regular connection startup diagnostics', () => { ['localhost:27017', undefined], ])('sets the same Azure appName only for a known cloud domain: %s', async (host, appName) => { vi.mocked(MicrosoftEntraIDAuthHandler.prototype.configureAuth).mockResolvedValueOnce({ - connectionString: `mongodb://${host}/`, options: {}, + connectionString: `mongodb://${host}/`, + options: {}, }); await ClustersClient.getClient(clusterId); expect(vi.mocked(MongoClient).mock.calls[0]?.[1]?.appName).toBe(appName); @@ -176,9 +177,13 @@ describe('regular connection startup diagnostics', () => { ['us', '8bafc480', 'f3e87358'], ['cn', '6a619114', '49133d63'], ])('preserves historical domain telemetry hashes for azure.%s', async (tld, level2, level3) => { - const actual = await vi.importActual('./utils/getClusterMetadata'); + const actual = await vi.importActual<{ getDomainMetadata: typeof getDomainMetadata }>( + './utils/getClusterMetadata', + ); expect(actual.getDomainMetadata([`cluster.mongocluster.cosmos.azure.${tld}`])).toMatchObject({ - domainInfo_isAzure: 'false', domainInfo_domain_l2: level2, domainInfo_domain_l3: level3, + domainInfo_isAzure: 'false', + domainInfo_domain_l2: level2, + domainInfo_domain_l3: level3, }); }); @@ -267,12 +272,22 @@ describe('regular connection startup diagnostics', () => { 'isolates %s cloud telemetry from the next attempt on the same cluster', async (authMethod) => { CredentialCache.setAuthCredentials(clusterId, authMethod, 'mongodb://private-host:27017'); - const handler = authMethod === AuthMethodId.ManagedIdentity ? ManagedIdentityAuthHandler : MicrosoftEntraIDAuthHandler; + const handler = + authMethod === AuthMethodId.ManagedIdentity ? ManagedIdentityAuthHandler : MicrosoftEntraIDAuthHandler; vi.mocked(handler.prototype.configureAuth).mockImplementationOnce(async (_timings, context) => { - traceAuthFlow('entraCloud.unresolved', { - cloudCategory: 'usgov', reason: 'host', configuredCloud: 'custom', - hostCloud: 'AzureUSGovernment', errorKind: 'cloudMismatch', tokenResource: 'private-resource', - }, undefined, context); + traceAuthFlow( + 'entraCloud.unresolved', + { + cloudCategory: 'usgov', + reason: 'host', + configuredCloud: 'custom', + hostCloud: 'AzureUSGovernment', + errorKind: 'cloudMismatch', + tokenResource: 'private-resource', + }, + undefined, + context, + ); throw new Error('private-resolution-failure'); }); await expect(ClustersClient.getClient(clusterId)).rejects.toThrow('private-resolution-failure'); @@ -281,8 +296,11 @@ describe('regular connection startup diagnostics', () => { const events = mockTelemetryEvents.filter((event) => event.eventName === 'connect.startup'); expect(events[0].telemetry.properties).toEqual(failedProperties); expect(failedProperties).toMatchObject({ - cloudCategory: 'usgov', tokenResourceReason: 'host', configuredCloud: 'custom', - hostCloud: 'AzureUSGovernment', errorKind: 'cloudMismatch', + cloudCategory: 'usgov', + tokenResourceReason: 'host', + configuredCloud: 'custom', + hostCloud: 'AzureUSGovernment', + errorKind: 'cloudMismatch', }); expect(events[1].telemetry.properties.cloudCategory).toBeUndefined(); expect(JSON.stringify(events)).not.toContain('private-'); diff --git a/src/documentdb/auth/sovereignAuth.integration.test.ts b/src/documentdb/auth/sovereignAuth.integration.test.ts index d73bce045..20e0ffe9c 100644 --- a/src/documentdb/auth/sovereignAuth.integration.test.ts +++ b/src/documentdb/auth/sovereignAuth.integration.test.ts @@ -3,7 +3,7 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import { type ITelemetryContext } from '@microsoft/vscode-azext-utils'; +import { type callWithTelemetryAndErrorHandling, type ITelemetryContext } from '@microsoft/vscode-azext-utils'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import * as vscode from 'vscode'; import { CredentialCache, type CachedClusterCredentials } from '../CredentialCache'; @@ -34,10 +34,13 @@ vi.mock('@microsoft/vscode-azext-azureauth/out/src/getSessionFromVSCode', () => vi.mock('./managedIdentityTokenProvider', () => ({ getManagedIdentityAccessToken: getManagedToken })); vi.mock('@microsoft/vscode-azext-utils', async (importOriginal) => ({ - ...(await importOriginal()), + ...(await importOriginal<{ callWithTelemetryAndErrorHandling: typeof callWithTelemetryAndErrorHandling }>()), callWithTelemetryAndErrorHandling: async ( eventName: string, - callback: (context: { telemetry: ITelemetryContext; errorHandling: Record }) => Promise, + callback: (context: { + telemetry: ITelemetryContext; + errorHandling: Record; + }) => Promise, ): Promise => { const telemetry: ITelemetryContext = { properties: {}, measurements: {} }; const result = await callback({ telemetry, errorHandling: {} }); @@ -77,7 +80,8 @@ vi.mock('worker_threads', async () => { }); } else if (message.type === 'eval') { this.reply({ - type: 'evalResult', requestId: message.requestId, + type: 'evalResult', + requestId: message.requestId, result: { type: 'number', printable: '1', durationMs: 0 }, }); } @@ -97,11 +101,17 @@ vi.mock('worker_threads', async () => { const clusterId = 'private-cluster'; const clouds = [ { - setting: 'USGovernment', suffix: 'us', category: 'usgov', cloud: 'AzureUSGovernment', + setting: 'USGovernment', + suffix: 'us', + category: 'usgov', + cloud: 'AzureUSGovernment', scope: 'https://ossrdbms-aad.database.usgovcloudapi.net/.default', }, { - setting: 'ChinaCloud', suffix: 'cn', category: 'china', cloud: 'AzureChinaCloud', + setting: 'ChinaCloud', + suffix: 'cn', + category: 'china', + cloud: 'AzureChinaCloud', scope: 'https://ossrdbms-aad.database.chinacloudapi.cn/.default', }, ] as const; @@ -128,7 +138,9 @@ describe('sovereign authentication with real audience resolvers', () => { getSession.mockResolvedValue({ accessToken: 'private-token', account: { label: 'private-account' } }); getManagedToken.mockResolvedValue({ accessToken: 'private-token', expiresOnTimestamp: Date.now() + 3_600_000 }); const configuration = { get: vi.fn(), inspect: vi.fn(), has: vi.fn(), update: vi.fn() }; - configuration.get.mockImplementation((key: string): unknown => key === 'environment' ? selectedCloud : undefined); + configuration.get.mockImplementation((key: string): unknown => + key === 'environment' ? selectedCloud : undefined, + ); vi.mocked(vscode.workspace.getConfiguration).mockReturnValue(configuration); }); @@ -146,31 +158,41 @@ describe('sovereign authentication with real audience resolvers', () => { expect(getSession).toHaveBeenCalledExactlyOnceWith([cloud.scope], 'private-tenant', { createIfNone: true }); expect(options.authMechanismProperties?.ALLOWED_HOSTS).toEqual([`*.azure.${cloud.suffix}`]); expect(startupProperties).toEqual({ - cloudCategory: cloud.category, tokenResourceReason: 'setting', configuredCloud: cloud.cloud, - hostCloud: cloud.cloud, errorKind: 'none', + cloudCategory: cloud.category, + tokenResourceReason: 'setting', + configuredCloud: cloud.cloud, + hostCloud: cloud.cloud, + errorKind: 'none', }); expect(JSON.stringify(startupProperties)).not.toMatch(/private|https|authority/); }); - it.each(clouds)('requests the real azure.$suffix managed identity scope independently of VS Code', async (cloud) => { - selectedCloud = cloud.setting === 'USGovernment' ? 'ChinaCloud' : 'USGovernment'; - const { options } = await new ManagedIdentityAuthHandler( - credentials(AuthMethodId.ManagedIdentity, cloud.suffix), - ).configureAuth(); - expect(getManagedToken).not.toHaveBeenCalled(); - expect(options.authMechanismProperties?.ALLOWED_HOSTS).toEqual([`*.azure.${cloud.suffix}`]); - const callback = options.authMechanismProperties?.OIDC_CALLBACK; - if (!callback) { - throw new Error('Expected the OIDC token callback'); - } - expect(await callback({ version: 1, timeoutContext: new AbortController().signal })).toMatchObject({ - accessToken: 'private-token', - }); - expect(getManagedToken).toHaveBeenCalledExactlyOnceWith( - [cloud.scope], 'private-client', 'private-tenant', expect.any(String), undefined, - ); - expect(getSession).not.toHaveBeenCalled(); - }); + it.each(clouds)( + 'requests the real azure.$suffix managed identity scope independently of VS Code', + async (cloud) => { + selectedCloud = cloud.setting === 'USGovernment' ? 'ChinaCloud' : 'USGovernment'; + const { options } = await new ManagedIdentityAuthHandler( + credentials(AuthMethodId.ManagedIdentity, cloud.suffix), + ).configureAuth(); + expect(getManagedToken).not.toHaveBeenCalled(); + expect(options.authMechanismProperties?.ALLOWED_HOSTS).toEqual([`*.azure.${cloud.suffix}`]); + const callback = options.authMechanismProperties?.OIDC_CALLBACK; + if (!callback) { + throw new Error('Expected the OIDC token callback'); + } + expect(await callback({ version: 1, timeoutContext: new AbortController().signal })).toMatchObject({ + accessToken: 'private-token', + }); + expect(getManagedToken).toHaveBeenCalledExactlyOnceWith( + [cloud.scope], + 'private-client', + 'private-tenant', + expect.any(String), + undefined, + ); + expect(getSession).not.toHaveBeenCalled(); + }, + ); it.each([ ['shell', AuthMethodId.MicrosoftEntraID], @@ -180,32 +202,48 @@ describe('sovereign authentication with real audience resolvers', () => { ] as const)('resolves Government from the %s %s worker tokenRequest path', async (surface, authMethod) => { const stored = credentials(authMethod, 'us'); CredentialCache.setAuthCredentials( - clusterId, authMethod, stored.connectionString, undefined, undefined, - stored.entraIdConfig, stored.managedIdentityConfig, + clusterId, + authMethod, + stored.connectionString, + undefined, + undefined, + stored.entraIdConfig, + stored.managedIdentityConfig, ); selectedCloud = authMethod === AuthMethodId.ManagedIdentity ? 'ChinaCloud' : 'USGovernment'; - const manager = surface === 'shell' - ? new ShellSessionManager({ clusterId, clusterDisplayName: 'Test cluster', databaseName: 'test' }) - : new PlaygroundEvaluator(); + const manager = + surface === 'shell' + ? new ShellSessionManager({ clusterId, clusterDisplayName: 'Test cluster', databaseName: 'test' }) + : new PlaygroundEvaluator(); try { if (manager instanceof ShellSessionManager) { await manager.initialize(); } else { await manager.evaluate({ clusterId, clusterDisplayName: 'Test cluster', databaseName: 'test' }, '1'); } - expect(postedMessages).toContainEqual(expect.objectContaining({ - type: 'init', connectionString: stored.connectionString, oidcAllowedHosts: ['*.azure.us'], - })); + expect(postedMessages).toContainEqual( + expect.objectContaining({ + type: 'init', + connectionString: stored.connectionString, + oidcAllowedHosts: ['*.azure.us'], + }), + ); expect(postedMessages).toContainEqual({ - type: 'tokenResponse', requestId: 'token-request', accessToken: 'private-token', + type: 'tokenResponse', + requestId: 'token-request', + accessToken: 'private-token', }); const provider = authMethod === AuthMethodId.ManagedIdentity ? getManagedToken : getSession; expect(provider.mock.calls[0]?.[0]).toEqual([clouds[0].scope]); const startup = events.find((event) => event.eventName === 'worker.startup'); expect(startup?.telemetry.properties).toMatchObject({ - surface, authMethod, startupOutcome: 'succeeded', cloudCategory: 'usgov', + surface, + authMethod, + startupOutcome: 'succeeded', + cloudCategory: 'usgov', tokenResourceReason: authMethod === AuthMethodId.ManagedIdentity ? 'host' : 'setting', - hostCloud: 'AzureUSGovernment', errorKind: 'none', + hostCloud: 'AzureUSGovernment', + errorKind: 'none', }); expect(JSON.stringify(startup)).not.toMatch(/private|https|authority/); } finally { From 508a1d61c092e550da0e04f58c8af0c27f7de8ea Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Mon, 26 Oct 2026 10:56:13 +0000 Subject: [PATCH 47/50] chore(sovereign-clouds): finalize localization and verified formatting Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...ware-scope-and-entra-diagnostics-review.md | 22 +++- l10n/bundle.l10n.json | 6 +- .../auth/ManagedIdentityAuthHandler.test.ts | 37 +++++-- .../auth/MicrosoftEntraIDAuthHandler.test.ts | 38 ++++--- .../auth/documentdbTokenResource.ts | 6 +- .../EntraDiagnosticsProvider.test.ts | 67 +++++++----- .../entraErrors/classifyEntraError.test.ts | 16 ++- .../playground/WorkerSessionManager.test.ts | 102 ++++++++++++------ .../shell/ShellSessionManager.test.ts | 21 ++-- src/settingsContributions.test.ts | 40 +++++-- src/utils/authTrace.test.ts | 72 ++++++++----- src/utils/authTrace.ts | 12 +-- 12 files changed, 291 insertions(+), 148 deletions(-) diff --git a/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md index ef45666c4..de7a58417 100644 --- a/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md +++ b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md @@ -53,12 +53,28 @@ The read-only attachment is unchanged; this repository copy is the live progress | S5 | Complete | GPT-6.1 Sol | 10:02:01-10:02:40 UTC; 56 focused tests passed; `dd29c55eadf8415f0364a7b72ddbbd23d0896e92` | | S6 | Complete | GPT-6.1 Sol | 10:02:55-10:06:22 UTC; 66 focused tests and cluster build passed; `bb08f47af47892c560c728222692ef2f81ef7692` | | S7 | Externally blocked | Orchestrator | 09:54-09:55 UTC; Kusto has no configured cluster/database; query recorded below | -| S8 | Implemented; review pending | GPT-6.1 Sol | Preview commit `99314464`; base merge `7e5fe7d0`, evidence `9568ac02`; 277 focused tests and build passed; preview branch only | -| Enhancement review | Main complete; preview pending | Opus 5.5 | 10:18:12-10:22:43 UTC (271 seconds); one medium S7 documentation finding; no medium-or-higher product-code findings; correction below; isolated preview review pending | -| Full verification | Local checks passed | Terra + GPT-6.1 Sol | Terra: 10:35-10:45 UTC; Sol import correction: 10:46:04-10:49:30 UTC; lint, build, and 52 focused tests passed; original lint failure historical; generated finalization commit pending | +| S8 | Reviewed and corrected; packaging pending | GPT-6.1 Sol | Preview `99314464`; cancellation fixes `488fb042`, `1120be5a`; 100 focused tests and exact build passed; preview branch only | +| Enhancement review | Complete; medium findings addressed | Opus 5.5 | Main 10:18:12-10:22:43 UTC; preview 10:46:08-10:49:55 UTC; one medium docs issue and two medium preview issues corrected; low observations documented | +| Full verification | Main local checks passed | Terra + GPT-6.1 Sol | Full Case 1/2 checks passed after import correction `6c863f4a`; preview final checks/package pending separately | ### Execution log +- S8 Opus 5.5 review, 10:46:08-10:49:55 UTC (227 seconds): two medium cancellation findings. + GPT-6.1 Sol addressed them in separate preview-only commits: + `488fb0428f8bfef1a12b5e896ec15e8d0402e4b3` preserves the original resolution error when the failure + dialog is dismissed (99 focused tests); + `1120be5a54524dd5e10f170b1bc01f61ccbeb078` skips canceled-connect result dialogs, leaving step 3 + available for a real retry (100 focused tests). Exact preview build passed in 43 seconds. + Final preview validation and packaging will follow integration of the main lint/format output. +- Four low preview observations are documented, not fixed under the medium-or-higher threshold: + the per-startup attempts map retains entries for the window; canceling VS Code sign-in can show + the token-failure dialog and consume step 2; Atlas explanation projection always uses its + TLS-rejection summary; concurrent first connections can duplicate probes before the once-only + dialog key is recorded. No private-data leak was found in the preview review. +- Main lint correction commit: `6c863f4a6d4888cc17d96fdc8a0787d50fa923df`. Finalization preserves + the generated localization bundle and formatting output as a separate commit, with no behavior + changes. Main code and all nine review items are complete; D11/live-cloud gates remain external. + - S8: GPT-6.1 Sol implemented the isolated preview during 10:09-10:30 UTC (`9931446469d3a0d62a262f8bf04127e3b9a0b250`). A later integration pass, 10:35:43-10:40:52 UTC (309 seconds), merged committed PR base `18060ae0` while preserving R6-R9 and the preview hooks: diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index aee1943d7..726333175 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -26,6 +26,7 @@ "\"{0}\" uses the unsupported URI scheme \"{1}\".": "\"{0}\" uses the unsupported URI scheme \"{1}\".", "\"{service}\" in namespace \"{namespace}\"": "\"{service}\" in namespace \"{namespace}\"", "\"{setting}\" is the public-cloud value, but VS Code is signed in to a custom cloud.": "\"{setting}\" is the public-cloud value, but VS Code is signed in to a custom cloud.", + "\"{setting}\" must be an HTTPS resource such as \"https://ossrdbms-aad.database.usgovcloudapi.net\", with no path or \"/.default\". Clear the setting to use automatic resource selection.": "\"{setting}\" must be an HTTPS resource such as \"https://ossrdbms-aad.database.usgovcloudapi.net\", with no path or \"/.default\". Clear the setting to use automatic resource selection.", "\"{user}\" signs in with {method}.": "\"{user}\" signs in with {method}.", "\"mongodb://\" or \"mongodb+srv://\" must be the prefix of the connection string.": "\"mongodb://\" or \"mongodb+srv://\" must be the prefix of the connection string.", "\"registerAzureUtilsExtensionVariables\" must be called before using the vscode-azext-azureutils package.": "\"registerAzureUtilsExtensionVariables\" must be called before using the vscode-azext-azureutils package.", @@ -1245,6 +1246,7 @@ "If VS Code telemetry is on, we record whether you continue to the survey and the rating you picked here. We don't record your name or your answers in the survey.": "If VS Code telemetry is on, we record whether you continue to the survey and the rating you picked here. We don't record your name or your answers in the survey.", "If VS Code telemetry is on, we record whether you qualified, whether this invitation was shown, what you chose, and the rating you picked when you continue to the survey. We don't record your name, your answers in the survey, or your usage count.": "If VS Code telemetry is on, we record whether you qualified, whether this invitation was shown, what you chose, and the rating you picked when you continue to the survey. We don't record your name, your answers in the survey, or your usage count.", "Ignore": "Ignore", + "Ignoring invalid \"{setting}\" while copying the connection string; using the fallback resource.": "Ignoring invalid \"{setting}\" while copying the connection string; using the fallback resource.", "Ignoring the following files that do not match the \"*.json\" file name pattern:": "Ignoring the following files that do not match the \"*.json\" file name pattern:", "Image": "Image", "Image tag": "Image tag", @@ -2331,6 +2333,7 @@ "The DocumentDB Local container was deleted, but its data volume could not be removed. You can remove it with Docker.": "The DocumentDB Local container was deleted, but its data volume could not be removed. You can remove it with Docker.", "The DocumentDB Local container was not removed because it was created outside the extension. Remove it with Docker if you no longer need it.": "The DocumentDB Local container was not removed because it was created outside the extension. Remove it with Docker if you no longer need it.", "The DocumentDB Local container was removed outside VS Code. Click the instance to recreate it (your data is preserved), or use \"Delete Container\" to remove it and its data.": "The DocumentDB Local container was removed outside VS Code. Click the instance to recreate it (your data is preserved), or use \"Delete Container\" to remove it and its data.", + "The driver does not appear to allow this cluster host for Microsoft Entra ID authentication. For a custom DNS endpoint, verify the trusted DocumentDB service hostname suffix with your cloud administrator and set \"documentDB.customEnvironment.mongoClusterHostnameSuffix\" in user settings. Only trust your service's domain, then connect again. See {link}": "The driver does not appear to allow this cluster host for Microsoft Entra ID authentication. For a custom DNS endpoint, verify the trusted DocumentDB service hostname suffix with your cloud administrator and set \"documentDB.customEnvironment.mongoClusterHostnameSuffix\" in user settings. Only trust your service's domain, then connect again. See {link}", "The dropped item could not be added as a kubeconfig source": "The dropped item could not be added as a kubeconfig source", "The earlier failure is still shown below.": "The earlier failure is still shown below.", "The entered value does not match the original.": "The entered value does not match the original.", @@ -2383,6 +2386,7 @@ "The selected paths, and every field nested under them, are excluded. All other fields are indexed.": "The selected paths, and every field nested under them, are excluded. All other fields are indexed.", "The server hostname could not be resolved. The cluster may have been deleted or the URL may be incorrect.": "The server hostname could not be resolved. The cluster may have been deleted or the URL may be incorrect.", "The sign-in application could not be found in the directory, usually because the wrong Azure cloud or tenant is selected. Check \"microsoft-sovereign-cloud.environment\" and use Update Credentials to choose the cluster's tenant. If both are correct, ask your tenant administrator or report this on GitHub. See {link}": "The sign-in application could not be found in the directory, usually because the wrong Azure cloud or tenant is selected. Check \"microsoft-sovereign-cloud.environment\" and use Update Credentials to choose the cluster's tenant. If both are correct, ask your tenant administrator or report this on GitHub. See {link}", + "The sign-in window reported an error without details. Check the error code shown in that window, or open Developer: Show Logs... > {authenticationLog} in VS Code. Your IT help desk can help interpret the code; if the problem persists, report it on GitHub. See {link}": "The sign-in window reported an error without details. Check the error code shown in that window, or open Developer: Show Logs... > {authenticationLog} in VS Code. Your IT help desk can help interpret the code; if the problem persists, report it on GitHub. See {link}", "The SORT stage exceeded the {0}MB memory limit.\n\n**Solutions:**\n1. Add .allowDiskUse(true) to allow disk-based sorting for large result sets\n2. Create an index matching the sort pattern: {1}\n3. Add filters to reduce the number of documents being sorted\n4. Increase server memory limit (requires server configuration)": "The SORT stage exceeded the {0}MB memory limit.\n\n**Solutions:**\n1. Add .allowDiskUse(true) to allow disk-based sorting for large result sets\n2. Create an index matching the sort pattern: {1}\n3. Add filters to reduce the number of documents being sorted\n4. Increase server memory limit (requires server configuration)", "The source cluster is no longer connected. Please reconnect and copy the collection again.": "The source cluster is no longer connected. Please reconnect and copy the collection again.", "The source collection \"{0}\" no longer exists in database \"{1}\". It may have been deleted or renamed.": "The source collection \"{0}\" no longer exists in database \"{1}\". It may have been deleted or renamed.", @@ -2400,7 +2404,7 @@ "The value must be {0} characters or greater.": "The value must be {0} characters or greater.", "The value must be {0} characters or less.": "The value must be {0} characters or less.", "The value must be between {0} and {1} characters long.": "The value must be between {0} and {1} characters long.", - "The Windows sign-in window reported an error without details. Check the error code shown in that window, or open Developer: Show Logs... > {authenticationLog} in VS Code. Your IT help desk can help interpret the code; if the problem persists, report it on GitHub. See {link}": "The Windows sign-in window reported an error without details. Check the error code shown in that window, or open Developer: Show Logs... > {authenticationLog} in VS Code. Your IT help desk can help interpret the code; if the problem persists, report it on GitHub. See {link}", + "The VS Code custom cloud definition could not be read. Configure \"microsoft-sovereign-cloud.customEnvironment\" with all six required fields: name, portalUrl, managementEndpointUrl, resourceManagerEndpointUrl, activeDirectoryEndpointUrl, activeDirectoryResourceId. Then connect again.": "The VS Code custom cloud definition could not be read. Configure \"microsoft-sovereign-cloud.customEnvironment\" with all six required fields: name, portalUrl, managementEndpointUrl, resourceManagerEndpointUrl, activeDirectoryEndpointUrl, activeDirectoryResourceId. Then connect again.", "The worker for this cluster is busy executing a playground": "The worker for this cluster is busy executing a playground", "There are no copyable secondary indexes in the source collection.": "There are no copyable secondary indexes in the source collection.", "There is no DocumentDB Local container to follow. Run \"Set up DocumentDB Local\" to create one.": "There is no DocumentDB Local container to follow. Run \"Set up DocumentDB Local\" to create one.", diff --git a/src/documentdb/auth/ManagedIdentityAuthHandler.test.ts b/src/documentdb/auth/ManagedIdentityAuthHandler.test.ts index 31e9b0dc6..5e60f20f9 100644 --- a/src/documentdb/auth/ManagedIdentityAuthHandler.test.ts +++ b/src/documentdb/auth/ManagedIdentityAuthHandler.test.ts @@ -92,11 +92,16 @@ describe('ManagedIdentityAuthHandler', () => { const startupProperties: Record = {}; const { options } = await handler.configureAuth(undefined, { - startupCorrelationId: 'startup-id', surface: 'extension', startupProperties, + startupCorrelationId: 'startup-id', + surface: 'extension', + startupProperties, }); expect(startupProperties).toEqual({ - cloudCategory: 'public', tokenResourceReason: 'host', configuredCloud: 'AzureCloud', - hostCloud: 'AzureCloud', errorKind: 'none', + cloudCategory: 'public', + tokenResourceReason: 'host', + configuredCloud: 'AzureCloud', + hostCloud: 'AzureCloud', + errorKind: 'none', }); expect(options.authMechanism).toBe('MONGODB-OIDC'); @@ -244,7 +249,9 @@ describe('ManagedIdentityAuthHandler', () => { const startupProperties: Record = {}; const { options } = await handler.configureAuth(undefined, { - startupCorrelationId: 'startup-id', surface: 'extension', startupProperties, + startupCorrelationId: 'startup-id', + surface: 'extension', + startupProperties, }); expect(getRecentEntraTokenFailure('cluster-1')).toBeUndefined(); expect(getManagedIdentityAccessToken).not.toHaveBeenCalled(); @@ -272,9 +279,13 @@ describe('ManagedIdentityAuthHandler', () => { const handler = new ManagedIdentityAuthHandler(buildCredentials()); const startupProperties: Record = {}; - await expect(handler.configureAuth(undefined, { - startupCorrelationId: 'startup-id', surface: 'extension', startupProperties, - })).rejects.toBe(error); + await expect( + handler.configureAuth(undefined, { + startupCorrelationId: 'startup-id', + surface: 'extension', + startupProperties, + }), + ).rejects.toBe(error); expect(startupProperties.errorKind).toBe('managedIdentityCloudUnknown'); expect(getManagedIdentityAccessToken).not.toHaveBeenCalled(); expect(getRecentEntraTokenFailure('cluster-1')).toBeUndefined(); @@ -289,17 +300,23 @@ describe('ManagedIdentityAuthHandler', () => { getManagedIdentityAccessToken.mockRejectedValueOnce(error); const failedProperties: Record = {}; const failed = await handler.configureAuth(undefined, { - startupCorrelationId: 'failed-startup', surface: 'extension', startupProperties: failedProperties, + startupCorrelationId: 'failed-startup', + surface: 'extension', + startupProperties: failedProperties, }); await expect(invokeOidcCallback(failed.options)).rejects.toBe(error); const failure = getRecentEntraTokenFailure('cluster-1'); expect(failure).toMatchObject({ cloudCategory: 'usgov', authMethod: 'managedIdentity' }); const diagnostics = new EntraDiagnosticsProvider(); - expect(await diagnostics.explain({ clusterId: 'cluster-1', error })).toContain('Sign-in failed in Azure US Government'); + expect(await diagnostics.explain({ clusterId: 'cluster-1', error })).toContain( + 'Sign-in failed in Azure US Government', + ); const successfulProperties: Record = {}; const successful = await handler.configureAuth(undefined, { - startupCorrelationId: 'successful-startup', surface: 'extension', startupProperties: successfulProperties, + startupCorrelationId: 'successful-startup', + surface: 'extension', + startupProperties: successfulProperties, }); expect(getRecentEntraTokenFailure('cluster-1')).toBe(failure); getManagedIdentityAccessToken.mockImplementationOnce(async () => { diff --git a/src/documentdb/auth/MicrosoftEntraIDAuthHandler.test.ts b/src/documentdb/auth/MicrosoftEntraIDAuthHandler.test.ts index 08e8984a6..fb65c40ce 100644 --- a/src/documentdb/auth/MicrosoftEntraIDAuthHandler.test.ts +++ b/src/documentdb/auth/MicrosoftEntraIDAuthHandler.test.ts @@ -125,9 +125,13 @@ describe('MicrosoftEntraIDAuthHandler', () => { vi.mocked(resolveInteractiveTokenResource).mockRejectedValueOnce(error); const handler = new MicrosoftEntraIDAuthHandler(buildCredentials('mongodb://cluster.azure.us/')); const startupProperties: Record = {}; - await expect(handler.configureAuth(undefined, { - startupCorrelationId: 'startup-id', surface: 'extension', startupProperties, - })).rejects.toBe(error); + await expect( + handler.configureAuth(undefined, { + startupCorrelationId: 'startup-id', + surface: 'extension', + startupProperties, + }), + ).rejects.toBe(error); expect(startupProperties.errorKind).toBe('cloudMismatch'); expect(getSessionFromVSCode).not.toHaveBeenCalled(); expect(getRecentEntraTokenFailure('cluster-1')).toBeUndefined(); @@ -135,9 +139,7 @@ describe('MicrosoftEntraIDAuthHandler', () => { it.each([false, true])('emits complete real-resolver cloud evidence (mismatch: %s)', async (mismatch) => { const actual = await vi.importActual('./documentdbTokenResource'); - const resource = mismatch - ? 'https://database.example.com' - : 'https://ossrdbms-aad.database.usgovcloudapi.net'; + const resource = mismatch ? 'https://database.example.com' : 'https://ossrdbms-aad.database.usgovcloudapi.net'; const deps: documentdbTokenResource.TokenResourceResolverDeps = { getConfiguredCloud: () => ({ name: 'custom', isCustomCloud: true }), getCustomEnvironment: () => ({ ossrdbmsEndpoint: resource }), @@ -239,13 +241,19 @@ describe('MicrosoftEntraIDAuthHandler', () => { getSessionFromVSCode.mockRejectedValueOnce(error); const handler = new MicrosoftEntraIDAuthHandler(buildCredentials('mongodb://cluster.example.com/')); const failedProperties: Record = {}; - await expect(handler.configureAuth(undefined, { - startupCorrelationId: 'failed-startup', surface: 'extension', startupProperties: failedProperties, - })).rejects.toBe(error); + await expect( + handler.configureAuth(undefined, { + startupCorrelationId: 'failed-startup', + surface: 'extension', + startupProperties: failedProperties, + }), + ).rejects.toBe(error); const failure = getRecentEntraTokenFailure('cluster-1'); expect(failure).toMatchObject({ cloudCategory: 'usgov', authMethod: 'interactive' }); const diagnostics = new EntraDiagnosticsProvider(); - expect(await diagnostics.explain({ clusterId: 'cluster-1', error })).toContain('Sign-in failed in Azure US Government'); + expect(await diagnostics.explain({ clusterId: 'cluster-1', error })).toContain( + 'Sign-in failed in Azure US Government', + ); getSessionFromVSCode.mockImplementationOnce(async () => { expect(getRecentEntraTokenFailure('cluster-1')).toBe(failure); @@ -253,7 +261,9 @@ describe('MicrosoftEntraIDAuthHandler', () => { }); const successfulProperties: Record = {}; await handler.configureAuth(undefined, { - startupCorrelationId: 'successful-startup', surface: 'extension', startupProperties: successfulProperties, + startupCorrelationId: 'successful-startup', + surface: 'extension', + startupProperties: successfulProperties, }); expect(getRecentEntraTokenFailure('cluster-1')).toBeUndefined(); expect(failedProperties.errorKind).toBe('tokenAcquisitionFailed'); @@ -304,7 +314,11 @@ describe('MicrosoftEntraIDAuthHandler', () => { const handler = new MicrosoftEntraIDAuthHandler(buildCredentials('mongodb://localhost:27017/')); const startupProperties: Record = {}; await expect( - handler.configureAuth(undefined, { startupCorrelationId: 'startup-id', surface: 'extension', startupProperties }), + handler.configureAuth(undefined, { + startupCorrelationId: 'startup-id', + surface: 'extension', + startupProperties, + }), ).rejects.toThrow('secret-token'); expect(startupProperties.errorKind).toBe('tokenAcquisitionFailed'); expect(JSON.stringify(startupProperties)).not.toContain('secret-token'); diff --git a/src/documentdb/auth/documentdbTokenResource.ts b/src/documentdb/auth/documentdbTokenResource.ts index eab6dc8f9..f5fd419f8 100644 --- a/src/documentdb/auth/documentdbTokenResource.ts +++ b/src/documentdb/auth/documentdbTokenResource.ts @@ -219,7 +219,11 @@ export function resolveTokenResourceForCopy( connectionString: string, deps: Pick< TokenResourceResolverDeps, - 'getConfiguredCloud' | 'getCustomEnvironment' | 'getCustomHostnameSuffixSource' | 'getSignInEnvironment' | 'warn' + | 'getConfiguredCloud' + | 'getCustomEnvironment' + | 'getCustomHostnameSuffixSource' + | 'getSignInEnvironment' + | 'warn' > = getDefaultDeps(), ): ResolvedTokenResource { const hostCloud = getAzureCloudFromHosts(connectionString); diff --git a/src/documentdb/auth/entraErrors/EntraDiagnosticsProvider.test.ts b/src/documentdb/auth/entraErrors/EntraDiagnosticsProvider.test.ts index bc9cb6ffb..85a006fa1 100644 --- a/src/documentdb/auth/entraErrors/EntraDiagnosticsProvider.test.ts +++ b/src/documentdb/auth/entraErrors/EntraDiagnosticsProvider.test.ts @@ -8,7 +8,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { Links } from '../../../constants'; import { ConnectionDiagnosticsService } from '../../../services/connectionDiagnosticsService'; import { EntraDiagnosticsProvider } from './EntraDiagnosticsProvider'; -import { getRecentEntraTokenFailure, recordEntraTokenFailure, resetEntraTokenFailuresForTests } from './entraTokenFailures'; +import { + getRecentEntraTokenFailure, + recordEntraTokenFailure, + resetEntraTokenFailuresForTests, +} from './entraTokenFailures'; const mockOutputChannel = vi.hoisted(() => ({ info: vi.fn() })); @@ -52,36 +56,43 @@ describe('EntraDiagnosticsProvider', () => { expect(provider.id).toBe('entra-sign-in'); }); - it.each([false, true])('explains the driver host rejection through the service without a token failure (cause=%s)', async (wrapped) => { - const rejection = "Host 'private-cluster.example.com:10255' is not valid for OIDC authentication with ALLOWED_HOSTS of '*.azure.com'"; - const cause = Object.freeze(new Error(rejection)); - const error = Object.freeze(Object.assign( - new Error(wrapped ? 'Connection failed' : rejection, wrapped ? { cause } : undefined), - { code: 'original-code' }, - )); - const descriptors = Object.getOwnPropertyDescriptors(error); - ConnectionDiagnosticsService.resetForTests(); - ConnectionDiagnosticsService.registerProvider(provider); - try { - expect(getRecentEntraTokenFailure(clusterId)).toBeUndefined(); - const diagnosis = await ConnectionDiagnosticsService.explain({ clusterId, error }); - expect(diagnosis?.providerId).toBe('entra-sign-in'); - expect(diagnosis?.message).toContain('documentDB.customEnvironment.mongoClusterHostnameSuffix'); - expect(diagnosis?.message).toContain('user settings'); - expect(diagnosis?.message).not.toMatch(/private-cluster|example.com|10255|azure.com/); - expect(Object.getOwnPropertyDescriptors(error)).toEqual(descriptors); - expect(telemetryContext.telemetry.properties.ruleId).toBe('oidcHostNotAllowed'); - expect(JSON.stringify(telemetryContext.telemetry.properties)).not.toContain('private-cluster'); - } finally { + it.each([false, true])( + 'explains the driver host rejection through the service without a token failure (cause=%s)', + async (wrapped) => { + const rejection = + "Host 'private-cluster.example.com:10255' is not valid for OIDC authentication with ALLOWED_HOSTS of '*.azure.com'"; + const cause = Object.freeze(new Error(rejection)); + const error = Object.freeze( + Object.assign(new Error(wrapped ? 'Connection failed' : rejection, wrapped ? { cause } : undefined), { + code: 'original-code', + }), + ); + const descriptors = Object.getOwnPropertyDescriptors(error); ConnectionDiagnosticsService.resetForTests(); - } - }); + ConnectionDiagnosticsService.registerProvider(provider); + try { + expect(getRecentEntraTokenFailure(clusterId)).toBeUndefined(); + const diagnosis = await ConnectionDiagnosticsService.explain({ clusterId, error }); + expect(diagnosis?.providerId).toBe('entra-sign-in'); + expect(diagnosis?.message).toContain('documentDB.customEnvironment.mongoClusterHostnameSuffix'); + expect(diagnosis?.message).toContain('user settings'); + expect(diagnosis?.message).not.toMatch(/private-cluster|example.com|10255|azure.com/); + expect(Object.getOwnPropertyDescriptors(error)).toEqual(descriptors); + expect(telemetryContext.telemetry.properties.ruleId).toBe('oidcHostNotAllowed'); + expect(JSON.stringify(telemetryContext.telemetry.properties)).not.toContain('private-cluster'); + } finally { + ConnectionDiagnosticsService.resetForTests(); + } + }, + ); it('explains a raw worker host rejection string without a token-failure record', async () => { - expect(await provider.explain({ - clusterId, - error: "Host 'private.example.com' is not valid for OIDC authentication with ALLOWED_HOSTS of '*.azure.us,*.azure.cn'", - })).toContain('documentDB.customEnvironment.mongoClusterHostnameSuffix'); + expect( + await provider.explain({ + clusterId, + error: "Host 'private.example.com' is not valid for OIDC authentication with ALLOWED_HOSTS of '*.azure.us,*.azure.cn'", + }), + ).toContain('documentDB.customEnvironment.mongoClusterHostnameSuffix'); expect(getRecentEntraTokenFailure(clusterId)).toBeUndefined(); }); diff --git a/src/documentdb/auth/entraErrors/classifyEntraError.test.ts b/src/documentdb/auth/entraErrors/classifyEntraError.test.ts index 8208f7b24..382399980 100644 --- a/src/documentdb/auth/entraErrors/classifyEntraError.test.ts +++ b/src/documentdb/auth/entraErrors/classifyEntraError.test.ts @@ -104,16 +104,12 @@ describe('classifyEntraError', () => { ); }); - it.each([ - 'AADSTS1234567890', - 'AADSTS6500200000', - 'xAADSTS65002', - '_AADSTS65002', - 'AADSTS65002x', - 'AADSTS65002_', - ])('rejects an overlong or unbounded code: %s', (text): void => { - expect(classifyEntraError(text)).toBeUndefined(); - }); + it.each(['AADSTS1234567890', 'AADSTS6500200000', 'xAADSTS65002', '_AADSTS65002', 'AADSTS65002x', 'AADSTS65002_'])( + 'rejects an overlong or unbounded code: %s', + (text): void => { + expect(classifyEntraError(text)).toBeUndefined(); + }, + ); it.each([ '', diff --git a/src/documentdb/playground/WorkerSessionManager.test.ts b/src/documentdb/playground/WorkerSessionManager.test.ts index 506ec1342..825249fb9 100644 --- a/src/documentdb/playground/WorkerSessionManager.test.ts +++ b/src/documentdb/playground/WorkerSessionManager.test.ts @@ -911,44 +911,78 @@ describe('WorkerSessionManager', () => { await initPromise; worker._emit('message', { type: 'tokenRequest', requestId: 'refresh-request' }); expect(contexts).toEqual([ - { startupCorrelationId: requestId, surface: 'shell', startupProperties: startupEvent().telemetry.properties }, - { startupCorrelationId: requestId, surface: 'shell', startupProperties: startupEvent().telemetry.properties }, + { + startupCorrelationId: requestId, + surface: 'shell', + startupProperties: startupEvent().telemetry.properties, + }, + { + startupCorrelationId: requestId, + surface: 'shell', + startupProperties: startupEvent().telemetry.properties, + }, ]); manager.dispose(); }); - it.each([true, false])('keeps cloud properties on their own worker attempt (persistent=%s)', async (persistent) => { - let firstContext: AuthTraceContext | undefined; - callbacks.onTokenRequest = vi.fn(async (_message, _postResponse, _timings, context): Promise => { - firstContext = context; - traceAuthFlow('entraCloud.unresolved', { - cloudCategory: 'china', reason: 'setting', configuredCloud: 'AzureChinaCloud', - hostCloud: 'AzureUSGovernment', errorKind: 'cloudMismatch', tokenResource: 'private-resource', - }, undefined, context); - }); - const first = await startWorker(persistent); - first.worker._emit('message', { type: 'tokenRequest', requestId: 'token-request' }); - first.worker._emit('message', { - type: 'initResult', requestId: first.requestId, success: false, error: 'private-error', - }); - await expect(first.initPromise).rejects.toThrow('private-error'); - const firstProperties = { ...startupEvent().telemetry.properties }; - const retry = await startWorker(persistent, {}, first.manager); - // A late result from the first attempt must never write into the retry. - traceAuthFlow('entraCloud.resolved', { - cloudCategory: 'usgov', reason: 'host', configuredCloud: 'AzureCloud', hostCloud: 'AzureUSGovernment', - }, undefined, firstContext); - retry.worker._emit('message', { type: 'initResult', requestId: retry.requestId, success: true }); - await retry.initPromise; - const events = mockTelemetryEvents.filter((event) => event.eventName === 'worker.startup'); - expect(firstProperties).toMatchObject({ - cloudCategory: 'china', tokenResourceReason: 'setting', configuredCloud: 'AzureChinaCloud', - hostCloud: 'AzureUSGovernment', errorKind: 'cloudMismatch', - }); - expect(events[1].telemetry.properties.cloudCategory).toBeUndefined(); - expect(JSON.stringify(events)).not.toMatch(/private-/); - retry.manager.dispose(); - }); + it.each([true, false])( + 'keeps cloud properties on their own worker attempt (persistent=%s)', + async (persistent) => { + let firstContext: AuthTraceContext | undefined; + callbacks.onTokenRequest = vi.fn(async (_message, _postResponse, _timings, context): Promise => { + firstContext = context; + traceAuthFlow( + 'entraCloud.unresolved', + { + cloudCategory: 'china', + reason: 'setting', + configuredCloud: 'AzureChinaCloud', + hostCloud: 'AzureUSGovernment', + errorKind: 'cloudMismatch', + tokenResource: 'private-resource', + }, + undefined, + context, + ); + }); + const first = await startWorker(persistent); + first.worker._emit('message', { type: 'tokenRequest', requestId: 'token-request' }); + first.worker._emit('message', { + type: 'initResult', + requestId: first.requestId, + success: false, + error: 'private-error', + }); + await expect(first.initPromise).rejects.toThrow('private-error'); + const firstProperties = { ...startupEvent().telemetry.properties }; + const retry = await startWorker(persistent, {}, first.manager); + // A late result from the first attempt must never write into the retry. + traceAuthFlow( + 'entraCloud.resolved', + { + cloudCategory: 'usgov', + reason: 'host', + configuredCloud: 'AzureCloud', + hostCloud: 'AzureUSGovernment', + }, + undefined, + firstContext, + ); + retry.worker._emit('message', { type: 'initResult', requestId: retry.requestId, success: true }); + await retry.initPromise; + const events = mockTelemetryEvents.filter((event) => event.eventName === 'worker.startup'); + expect(firstProperties).toMatchObject({ + cloudCategory: 'china', + tokenResourceReason: 'setting', + configuredCloud: 'AzureChinaCloud', + hostCloud: 'AzureUSGovernment', + errorKind: 'cloudMismatch', + }); + expect(events[1].telemetry.properties.cloudCategory).toBeUndefined(); + expect(JSON.stringify(events)).not.toMatch(/private-/); + retry.manager.dispose(); + }, + ); it('reports worker construction failures while preserving the original error', async () => { const originalError = new Error('private-construction-error'); diff --git a/src/documentdb/shell/ShellSessionManager.test.ts b/src/documentdb/shell/ShellSessionManager.test.ts index 9b263c64b..7c6341666 100644 --- a/src/documentdb/shell/ShellSessionManager.test.ts +++ b/src/documentdb/shell/ShellSessionManager.test.ts @@ -285,8 +285,11 @@ describe('ShellSessionManager', () => { { startupCorrelationId: 'startup-id', surface, startupProperties }, ); expect(startupProperties).toEqual({ - cloudCategory: 'usgov', tokenResourceReason: source === 'vscode' ? 'setting' : 'host', - configuredCloud: 'AzureUSGovernment', hostCloud: 'AzureUSGovernment', errorKind: 'none', + cloudCategory: 'usgov', + tokenResourceReason: source === 'vscode' ? 'setting' : 'host', + configuredCloud: 'AzureUSGovernment', + hostCloud: 'AzureUSGovernment', + errorKind: 'none', }); expect(resolver).toHaveBeenCalledWith(connectionString); expect(provider.mock.calls.at(-1)?.[0]).toEqual(['https://ossrdbms-aad.database.usgovcloudapi.net/.default']); @@ -406,7 +409,8 @@ describe('ShellSessionManager', () => { ] as const)( 'clears stale %s %s failure context only after token success', async (surface, source): Promise => { - const resolver = source === 'vscode' ? resolveInteractiveTokenResource : resolveManagedIdentityTokenResource; + const resolver = + source === 'vscode' ? resolveInteractiveTokenResource : resolveManagedIdentityTokenResource; vi.mocked(resolver).mockResolvedValueOnce({ resource: 'https://ossrdbms-aad.database.chinacloudapi.cn', scope: 'https://ossrdbms-aad.database.chinacloudapi.cn/.default', @@ -430,11 +434,14 @@ describe('ShellSessionManager', () => { { startupCorrelationId: 'failed-startup', surface, startupProperties: failedProperties }, ); expect(postResponse).toHaveBeenLastCalledWith({ - type: 'tokenError', requestId: 'failed-token-request', error: error.message, + type: 'tokenError', + requestId: 'failed-token-request', + error: error.message, }); const failure = getRecentEntraTokenFailure(clusterId); expect(failure).toMatchObject({ - cloudCategory: 'china', authMethod: source === 'vscode' ? 'interactive' : 'managedIdentity', + cloudCategory: 'china', + authMethod: source === 'vscode' ? 'interactive' : 'managedIdentity', }); const diagnostics = new EntraDiagnosticsProvider(); expect(await diagnostics.explain({ clusterId, error })).toContain('Sign-in failed in Azure China'); @@ -451,7 +458,9 @@ describe('ShellSessionManager', () => { { startupCorrelationId: 'successful-startup', surface, startupProperties: successfulProperties }, ); expect(postResponse).toHaveBeenLastCalledWith({ - type: 'tokenResponse', requestId: 'successful-token-request', accessToken: 'private-token', + type: 'tokenResponse', + requestId: 'successful-token-request', + accessToken: 'private-token', }); expect(getRecentEntraTokenFailure(clusterId)).toBeUndefined(); expect(failedProperties.errorKind).toBe('tokenAcquisitionFailed'); diff --git a/src/settingsContributions.test.ts b/src/settingsContributions.test.ts index 7eb29b154..0bb6aae3a 100644 --- a/src/settingsContributions.test.ts +++ b/src/settingsContributions.test.ts @@ -72,14 +72,38 @@ describe('settings contributions', () => { expect(schema.patternErrorMessage).toContain('.mongocluster.cosmos.example'); expect(schema.patternErrorMessage).toContain('com.cn'); for (const value of [ - '', ' \t\n ', '.mongocluster.cosmos.example', 'mongocluster.cosmos.example', - ' .DATABASE.EXAMPLE.COM ', '.service.example.com.cn', '.a.b', - `.${'a'.repeat(63)}.example`, '.a-b.example', - '.com', 'com', '.cn', '.com.cn', ' COM.CN ', '.Co.Uk', '.NET.CN', '.org.cn', '.gov.us', - '*.example.com', 'https://example.com', '.example.com:10255', '.example.com/path', - '.example.com/', '.example..com', '..example.com', '.example.com.', - '.-example.com', '.example-.com', '.example_com', '.éxample.com', - `.${'a'.repeat(64)}.example`, '.example. com', + '', + ' \t\n ', + '.mongocluster.cosmos.example', + 'mongocluster.cosmos.example', + ' .DATABASE.EXAMPLE.COM ', + '.service.example.com.cn', + '.a.b', + `.${'a'.repeat(63)}.example`, + '.a-b.example', + '.com', + 'com', + '.cn', + '.com.cn', + ' COM.CN ', + '.Co.Uk', + '.NET.CN', + '.org.cn', + '.gov.us', + '*.example.com', + 'https://example.com', + '.example.com:10255', + '.example.com/path', + '.example.com/', + '.example..com', + '..example.com', + '.example.com.', + '.-example.com', + '.example-.com', + '.example_com', + '.éxample.com', + `.${'a'.repeat(64)}.example`, + '.example. com', ]) { const accepted = value.trim() === '' || normalizeCustomHostnameSuffix(value) !== undefined; expect(pattern.test(value), value).toBe(accepted); diff --git a/src/utils/authTrace.test.ts b/src/utils/authTrace.test.ts index e7431b6fc..740f724f2 100644 --- a/src/utils/authTrace.test.ts +++ b/src/utils/authTrace.test.ts @@ -13,16 +13,12 @@ describe('describeAuthError', () => { expect(describeAuthError(new Error(`Sign-in failed (AADSTS${code}).`))).toContain(`aadsts=AADSTS${code};`); }); - it.each([ - 'AADSTS1234567890', - 'AADSTS6500200000', - 'xAADSTS65002', - '_AADSTS65002', - 'AADSTS65002x', - 'AADSTS65002_', - ])('does not trace an overlong or unbounded code: %s', (text): void => { - expect(describeAuthError(new Error(text))).toContain('aadsts=none;'); - }); + it.each(['AADSTS1234567890', 'AADSTS6500200000', 'xAADSTS65002', '_AADSTS65002', 'AADSTS65002x', 'AADSTS65002_'])( + 'does not trace an overlong or unbounded code: %s', + (text): void => { + expect(describeAuthError(new Error(text))).toContain('aadsts=none;'); + }, + ); it('labels platform broker errors without exposing the raw message', (): void => { expect(describeAuthError(new Error('platform_broker_error: private sign-in failure'))).toBe( @@ -33,18 +29,23 @@ describe('describeAuthError', () => { describe('startup cloud telemetry', () => { it.each(['entraCloud.resolved', 'entraCloud.unresolved'])('copies only closed enums from %s', (operation) => { const properties: Record = {}; - traceAuthFlow(operation, { - cloudCategory: 'usgov', - reason: 'setting', - configuredCloud: 'AzureUSGovernment', - hostCloud: 'AzureUSGovernment', - errorKind: 'cloudMismatch', - tokenResource: 'https://private-resource.example', - authority: 'https://private-authority.example', - tenantId: 'private-tenant', - customHostnameSuffix: 'private.example', - imdsAzEnvironment: 'AzurePrivateCloud', - }, undefined, { startupCorrelationId: 'startup-id', surface: 'extension', startupProperties: properties }); + traceAuthFlow( + operation, + { + cloudCategory: 'usgov', + reason: 'setting', + configuredCloud: 'AzureUSGovernment', + hostCloud: 'AzureUSGovernment', + errorKind: 'cloudMismatch', + tokenResource: 'https://private-resource.example', + authority: 'https://private-authority.example', + tenantId: 'private-tenant', + customHostnameSuffix: 'private.example', + imdsAzEnvironment: 'AzurePrivateCloud', + }, + undefined, + { startupCorrelationId: 'startup-id', surface: 'extension', startupProperties: properties }, + ); expect(properties).toEqual({ cloudCategory: 'usgov', tokenResourceReason: 'setting', @@ -57,20 +58,33 @@ describe('describeAuthError', () => { it('rejects dynamic values even under known property names', () => { const properties: Record = {}; - traceAuthFlow('entraCloud.resolved', { - cloudCategory: 'private-category', reason: 'private-reason', configuredCloud: 'private-cloud', - hostCloud: 'private-host', errorKind: 'private-error', - }, undefined, { startupCorrelationId: 'startup-id', surface: 'shell', startupProperties: properties }); + traceAuthFlow( + 'entraCloud.resolved', + { + cloudCategory: 'private-category', + reason: 'private-reason', + configuredCloud: 'private-cloud', + hostCloud: 'private-host', + errorKind: 'private-error', + }, + undefined, + { startupCorrelationId: 'startup-id', surface: 'shell', startupProperties: properties }, + ); expect(properties).toEqual({ - cloudCategory: 'unknown', tokenResourceReason: 'unknown', configuredCloud: 'unknown', - hostCloud: 'unknown', errorKind: 'none', + cloudCategory: 'unknown', + tokenResourceReason: 'unknown', + configuredCloud: 'unknown', + hostCloud: 'unknown', + errorKind: 'none', }); }); it('leaves unrelated auth traces unchanged', () => { const properties: Record = {}; traceAuthFlow('nativeAuth.configure', { cloudCategory: 'public' }, undefined, { - startupCorrelationId: 'startup-id', surface: 'extension', startupProperties: properties, + startupCorrelationId: 'startup-id', + surface: 'extension', + startupProperties: properties, }); expect(properties).toEqual({}); }); diff --git a/src/utils/authTrace.ts b/src/utils/authTrace.ts index 801d45afe..8c86b1044 100644 --- a/src/utils/authTrace.ts +++ b/src/utils/authTrace.ts @@ -78,12 +78,15 @@ export interface AuthTraceContext { function recordCloudResolution(details: AuthTraceDetails, properties: ITelemetryContext['properties']): void { properties.cloudCategory = - typeof details.cloudCategory === 'string' && ['public', 'usgov', 'china', 'custom'].includes(details.cloudCategory) + typeof details.cloudCategory === 'string' && + ['public', 'usgov', 'china', 'custom'].includes(details.cloudCategory) ? details.cloudCategory : 'unknown'; properties.tokenResourceReason = typeof details.reason === 'string' && - ['setting', 'customOverride', 'customLoginHost', 'armMetadata', 'host', 'imds', 'copyDefault'].includes(details.reason) + ['setting', 'customOverride', 'customLoginHost', 'armMetadata', 'host', 'imds', 'copyDefault'].includes( + details.reason, + ) ? details.reason : 'unknown'; properties.configuredCloud = @@ -123,10 +126,7 @@ export function traceAuthFlow( correlationId?: string, context?: AuthTraceContext, ): void { - if ( - context?.startupProperties && - (operation === 'entraCloud.resolved' || operation === 'entraCloud.unresolved') - ) { + if (context?.startupProperties && (operation === 'entraCloud.resolved' || operation === 'entraCloud.unresolved')) { recordCloudResolution(details, context.startupProperties); } ext.outputChannel.info( From 4ee1f8d61a5239a1d4ea20051e14f7546e81e1c0 Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Mon, 26 Oct 2026 11:25:10 +0000 Subject: [PATCH 48/50] docs(sovereign-clouds): record completed fixes and verified preview handoff Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ...ware-scope-and-entra-diagnostics-review.md | 68 ++++++++++++++++++- 1 file changed, 66 insertions(+), 2 deletions(-) diff --git a/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md index de7a58417..3c469b463 100644 --- a/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md +++ b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md @@ -24,6 +24,58 @@ evidence are preserved. ## Implementation progress (2026-10-26) +### Current outcome (11:24 UTC) + +**R1-R9 and S1-S6 are complete, separately committed, and locally verified.** S8 is complete, +reviewed, and packaged on the isolated `preview/sovereign-diagnostics` branch only. S7 was attempted +but remains **externally blocked** by the missing approved Kusto cluster/database; D11 is not passed. +All commits are local: no push or PR-state change was made. PR #1050 remains a draft. + +The requested model assignments were used: GPT-6.1 Sol for bounded coding and corrections, +Opus 5.5 for the fix review and enhancement reviews, and Terra for the full check runs. +Opus review windows total 12 minutes 19 seconds; coding/check timings are recorded per item below +and in the preview copy. Overall wall-clock elapsed time is approximately 1 hour 48 minutes, +including parallel work, reviews, interrupted verification, corrections, and packaging. +No token usage was exposed, so no token totals or costs are claimed. + +Final verification results: + +| Check | PR branch | Isolated reporter preview | +| --- | --- | --- | +| `npm run l10n` | Passed | Passed; generated strings remain preview-only | +| `npm run prettier-fix` | Passed | Passed | +| `npm run l10n:check` | Passed | Passed | +| `npm run lint` | Passed after three test-import corrections | Passed after test-file headers/import corrections | +| `npm run build` | Passed | Passed | +| `npm run typecheck:tests` | Passed separately from runtime tests | Passed separately from runtime tests | +| Focused Case 1 tests | Passed (140-test checkpoint; 52-test import-correction subset) | Passed (66 preview tests; 110-test root-config correction subset) | +| Full `npx vitest run` | 323 files, 5,571 passed, one skipped | 326 files, 5,637 passed, one skipped | +| `npm run package` | Passed | Passed; final reporter VSIX below | + +Both Case 1 and the full Case 2 command lists ran at the operator's explicit request despite the +draft PR. The historical failures and earlier deferral notes below are preserved as execution +history, not current blockers. Four low preview observations remain documented; all medium review +findings and all validation failures discovered during this work were corrected. + +Persistent delivery: + +- PR branch final generated output: `508a1d61`; narrow lint correction: `6c863f4a`. +- Preview branch final verified commit: `6c6478ac399b8ecc3acc4d913bfcd847be532759`. + It includes original S8 `99314464`, both cancellation corrections `488fb042` / `1120be5a`, + validation correction `e0ac05ac`, and final main-base integration `51d246d7`. +- Preview worktree: + `/home/tnaum/.copilot/session-state/2ab50763-84c2-482c-9a3d-dcf4de193ad2/files/sovereign-diagnostics-preview`. +- Reporter VSIX is `vscode-documentdb-1.0.2-alpha.0.vsix` in that preview worktree. + **Preview-only: never release or merge the preview branch.** + SHA-256: `992d7e082507c323eb03c4c6a0cd597c22548776615fdf6b33ed9984201c2c1b`. +- The PR worktree's same-named package does **not** contain preview dialogs and has a different hash. + Preview code is not tracked on the PR branch; both worktrees were checked independently. + +Remaining external gates: production D11 telemetry, a reporter-run sovereign token acquisition and +actual database command, and the previously recorded additional vendor-attestation requirement. +Local fixtures and successful packaging do not establish real Government/China authentication or +service-side client preauthorization. + The operator requested GPT-6.1 Sol for narrowly scoped coding, an Opus 5.5 review after the fixes and again after enhancements, separate commits per item, and a Terra final verification run. The original medium-or-higher fix threshold was expanded by the operator at 10:11 UTC: @@ -53,12 +105,24 @@ The read-only attachment is unchanged; this repository copy is the live progress | S5 | Complete | GPT-6.1 Sol | 10:02:01-10:02:40 UTC; 56 focused tests passed; `dd29c55eadf8415f0364a7b72ddbbd23d0896e92` | | S6 | Complete | GPT-6.1 Sol | 10:02:55-10:06:22 UTC; 66 focused tests and cluster build passed; `bb08f47af47892c560c728222692ef2f81ef7692` | | S7 | Externally blocked | Orchestrator | 09:54-09:55 UTC; Kusto has no configured cluster/database; query recorded below | -| S8 | Reviewed and corrected; packaging pending | GPT-6.1 Sol | Preview `99314464`; cancellation fixes `488fb042`, `1120be5a`; 100 focused tests and exact build passed; preview branch only | +| S8 | Complete (preview only) | GPT-6.1 Sol | Final `6c6478ac`; reviewed/corrected, full checks and reporter package passed; never merge | | Enhancement review | Complete; medium findings addressed | Opus 5.5 | Main 10:18:12-10:22:43 UTC; preview 10:46:08-10:49:55 UTC; one medium docs issue and two medium preview issues corrected; low observations documented | -| Full verification | Main local checks passed | Terra + GPT-6.1 Sol | Full Case 1/2 checks passed after import correction `6c863f4a`; preview final checks/package pending separately | +| Full verification | Complete, both branches | Terra + GPT-6.1 Sol | Main 5,571 tests; preview 5,637 tests; one skipped each; all Case 1/2 commands passed after corrections | ### Execution log +- Preview validation correction: GPT-6.1 Sol, 11:10-11:14:40 UTC, + `e0ac05ac093aa465d7f460cbc43ff579069fa227`. The default inherited UI mocks returned `undefined`, + correctly triggering real preview cancellation. Three non-preview auth suites now explicitly + simulate Continue; real cancellation tests remain unchanged. Added two missing standard license + headers and proper type imports. Root-config six-file / 110-test run, exact lint, and build passed. +- Terra corrected preview final run, 11:15:23-11:22:45 UTC (7 minutes 22 seconds): + `prettier-fix`, `l10n:check`, `lint`, `build`, exact `typecheck:tests`, full runtime, and `package` + all exited 0. Full suite: 326 files / 5,637 passed, one skipped. Full-runtime interval + 11:20:29-11:22:09 UTC (100 seconds wall); packaging 11:22:14-11:22:45 UTC (31 seconds). + Localization generation had already passed on this branch; no strings changed in the correction. + Final output persisted at `6c6478ac`; VSIX hash independently recomputed by the orchestrator. + - S8 Opus 5.5 review, 10:46:08-10:49:55 UTC (227 seconds): two medium cancellation findings. GPT-6.1 Sol addressed them in separate preview-only commits: `488fb0428f8bfef1a12b5e896ec15e8d0402e4b3` preserves the original resolution error when the failure From 45ec2ea9f48788c410c27ebee8c3186c90d20d42 Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Mon, 26 Oct 2026 14:32:32 +0000 Subject: [PATCH 49/50] chore: prettier-fix --- ...ware-scope-and-entra-diagnostics-review.md | 98 +++++++++---------- 1 file changed, 49 insertions(+), 49 deletions(-) diff --git a/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md index 3c469b463..ca431da1c 100644 --- a/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md +++ b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md @@ -40,17 +40,17 @@ No token usage was exposed, so no token totals or costs are claimed. Final verification results: -| Check | PR branch | Isolated reporter preview | -| --- | --- | --- | -| `npm run l10n` | Passed | Passed; generated strings remain preview-only | -| `npm run prettier-fix` | Passed | Passed | -| `npm run l10n:check` | Passed | Passed | -| `npm run lint` | Passed after three test-import corrections | Passed after test-file headers/import corrections | -| `npm run build` | Passed | Passed | -| `npm run typecheck:tests` | Passed separately from runtime tests | Passed separately from runtime tests | -| Focused Case 1 tests | Passed (140-test checkpoint; 52-test import-correction subset) | Passed (66 preview tests; 110-test root-config correction subset) | -| Full `npx vitest run` | 323 files, 5,571 passed, one skipped | 326 files, 5,637 passed, one skipped | -| `npm run package` | Passed | Passed; final reporter VSIX below | +| Check | PR branch | Isolated reporter preview | +| ------------------------- | -------------------------------------------------------------- | ----------------------------------------------------------------- | +| `npm run l10n` | Passed | Passed; generated strings remain preview-only | +| `npm run prettier-fix` | Passed | Passed | +| `npm run l10n:check` | Passed | Passed | +| `npm run lint` | Passed after three test-import corrections | Passed after test-file headers/import corrections | +| `npm run build` | Passed | Passed | +| `npm run typecheck:tests` | Passed separately from runtime tests | Passed separately from runtime tests | +| Focused Case 1 tests | Passed (140-test checkpoint; 52-test import-correction subset) | Passed (66 preview tests; 110-test root-config correction subset) | +| Full `npx vitest run` | 323 files, 5,571 passed, one skipped | 326 files, 5,637 passed, one skipped | +| `npm run package` | Passed | Passed; final reporter VSIX below | Both Case 1 and the full Case 2 command lists ran at the operator's explicit request despite the draft PR. The historical failures and earlier deferral notes below are preserved as execution @@ -86,28 +86,28 @@ Started at 09:36 UTC. Durations below are wall-clock observations, not model com Token usage is unavailable unless explicitly reported. Commits are local unless stated otherwise. The read-only attachment is unchanged; this repository copy is the live progress record. -| Item | State | Model | Result / commit | -| ------------------ | ------------------------------ | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| R1 | Complete | GPT-6.1 Sol | 09:38:47-09:40:20 UTC; 114 focused tests passed; `047465a2bd3b561e92d0069570dd74ba0bd48f99` | -| R2 | Complete | GPT-6.1 Sol | 09:40:20-09:41:21 UTC; 95 focused tests passed; `2e27b5583d2e622d1b06bd7ac319eb56f52dff09` | -| R3 | Complete | GPT-6.1 Sol | 09:44:40-09:48:03 UTC; 189 focused tests passed; `0de180b1e93495b3a2c9504431b7fd34079614fa` | -| R4 | Complete | GPT-6.1 Sol | 09:41:33-09:42:03 UTC; 118 focused tests passed; `f1bc8bffeb0d6106d5f4182767f5d7219884bd65` | -| R5 | Complete | GPT-6.1 Sol | 09:42:14-09:44:14 UTC; 174 focused tests passed; `ecd0995babb996863cd2bfb71b9181941a347228` | -| R6 | Complete | GPT-6.1 Sol | 10:11:43-10:12:18 UTC (35 seconds); 60 focused tests passed; `d8f2db967cc3bc2d0f16bc7554f012db2bbb551b` | -| R7 | Complete | GPT-6.1 Sol | 10:12:30-10:12:45 UTC (15 seconds); 76 focused tests passed; `439047d751d4f081fcea9003aca161715163cfc3` | -| R8 | Complete | GPT-6.1 Sol | 10:12:56-10:14:11 UTC (75 seconds); 70 focused tests passed; `6f1f507e26f9c5767cfe95b949ed163a1310e47d` | -| R9 | Complete | GPT-6.1 Sol | 10:14:30-10:16:24 UTC (114 seconds); docs-only; cluster build passed; `853ef409fc5d4f2481768c997efea27760e67561` | -| Fix review | Complete | Opus 5.5 | 09:49:45-09:53:46 UTC; no medium-or-higher findings | -| S1 | Complete | GPT-6.1 Sol | 09:56:21-09:58:33 UTC; 124 focused tests passed; `20e07a36bf6e3d294cf14bcbe1c330b5821708e6` | -| S2 | Complete | GPT-6.1 Sol | 09:58:59-09:59:39 UTC; 76 focused tests passed; `c20cc619363aafed9c6f717873fc5e718ad54b0e` | -| S3 | Complete | GPT-6.1 Sol | 09:59:50-10:00:36 UTC; 99 focused tests passed; `7537d69ac2197700ccf4576c321b1a43af5839d0` | -| S4 | Complete | GPT-6.1 Sol | 10:00:46-10:01:50 UTC; 72 focused tests passed; `f6406d8f82dac2b351aa77f6de87639f41c8efa1` | -| S5 | Complete | GPT-6.1 Sol | 10:02:01-10:02:40 UTC; 56 focused tests passed; `dd29c55eadf8415f0364a7b72ddbbd23d0896e92` | -| S6 | Complete | GPT-6.1 Sol | 10:02:55-10:06:22 UTC; 66 focused tests and cluster build passed; `bb08f47af47892c560c728222692ef2f81ef7692` | -| S7 | Externally blocked | Orchestrator | 09:54-09:55 UTC; Kusto has no configured cluster/database; query recorded below | -| S8 | Complete (preview only) | GPT-6.1 Sol | Final `6c6478ac`; reviewed/corrected, full checks and reporter package passed; never merge | -| Enhancement review | Complete; medium findings addressed | Opus 5.5 | Main 10:18:12-10:22:43 UTC; preview 10:46:08-10:49:55 UTC; one medium docs issue and two medium preview issues corrected; low observations documented | -| Full verification | Complete, both branches | Terra + GPT-6.1 Sol | Main 5,571 tests; preview 5,637 tests; one skipped each; all Case 1/2 commands passed after corrections | +| Item | State | Model | Result / commit | +| ------------------ | ----------------------------------- | ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | +| R1 | Complete | GPT-6.1 Sol | 09:38:47-09:40:20 UTC; 114 focused tests passed; `047465a2bd3b561e92d0069570dd74ba0bd48f99` | +| R2 | Complete | GPT-6.1 Sol | 09:40:20-09:41:21 UTC; 95 focused tests passed; `2e27b5583d2e622d1b06bd7ac319eb56f52dff09` | +| R3 | Complete | GPT-6.1 Sol | 09:44:40-09:48:03 UTC; 189 focused tests passed; `0de180b1e93495b3a2c9504431b7fd34079614fa` | +| R4 | Complete | GPT-6.1 Sol | 09:41:33-09:42:03 UTC; 118 focused tests passed; `f1bc8bffeb0d6106d5f4182767f5d7219884bd65` | +| R5 | Complete | GPT-6.1 Sol | 09:42:14-09:44:14 UTC; 174 focused tests passed; `ecd0995babb996863cd2bfb71b9181941a347228` | +| R6 | Complete | GPT-6.1 Sol | 10:11:43-10:12:18 UTC (35 seconds); 60 focused tests passed; `d8f2db967cc3bc2d0f16bc7554f012db2bbb551b` | +| R7 | Complete | GPT-6.1 Sol | 10:12:30-10:12:45 UTC (15 seconds); 76 focused tests passed; `439047d751d4f081fcea9003aca161715163cfc3` | +| R8 | Complete | GPT-6.1 Sol | 10:12:56-10:14:11 UTC (75 seconds); 70 focused tests passed; `6f1f507e26f9c5767cfe95b949ed163a1310e47d` | +| R9 | Complete | GPT-6.1 Sol | 10:14:30-10:16:24 UTC (114 seconds); docs-only; cluster build passed; `853ef409fc5d4f2481768c997efea27760e67561` | +| Fix review | Complete | Opus 5.5 | 09:49:45-09:53:46 UTC; no medium-or-higher findings | +| S1 | Complete | GPT-6.1 Sol | 09:56:21-09:58:33 UTC; 124 focused tests passed; `20e07a36bf6e3d294cf14bcbe1c330b5821708e6` | +| S2 | Complete | GPT-6.1 Sol | 09:58:59-09:59:39 UTC; 76 focused tests passed; `c20cc619363aafed9c6f717873fc5e718ad54b0e` | +| S3 | Complete | GPT-6.1 Sol | 09:59:50-10:00:36 UTC; 99 focused tests passed; `7537d69ac2197700ccf4576c321b1a43af5839d0` | +| S4 | Complete | GPT-6.1 Sol | 10:00:46-10:01:50 UTC; 72 focused tests passed; `f6406d8f82dac2b351aa77f6de87639f41c8efa1` | +| S5 | Complete | GPT-6.1 Sol | 10:02:01-10:02:40 UTC; 56 focused tests passed; `dd29c55eadf8415f0364a7b72ddbbd23d0896e92` | +| S6 | Complete | GPT-6.1 Sol | 10:02:55-10:06:22 UTC; 66 focused tests and cluster build passed; `bb08f47af47892c560c728222692ef2f81ef7692` | +| S7 | Externally blocked | Orchestrator | 09:54-09:55 UTC; Kusto has no configured cluster/database; query recorded below | +| S8 | Complete (preview only) | GPT-6.1 Sol | Final `6c6478ac`; reviewed/corrected, full checks and reporter package passed; never merge | +| Enhancement review | Complete; medium findings addressed | Opus 5.5 | Main 10:18:12-10:22:43 UTC; preview 10:46:08-10:49:55 UTC; one medium docs issue and two medium preview issues corrected; low observations documented | +| Full verification | Complete, both branches | Terra + GPT-6.1 Sol | Main 5,571 tests; preview 5,637 tests; one skipped each; all Case 1/2 commands passed after corrections | ### Execution log @@ -154,17 +154,17 @@ The read-only attachment is unchanged; this repository copy is the live progress ### Final verification (Terra, first completed run) -| Check | UTC interval / elapsed | Result | -| --- | --- | --- | -| `npm run l10n` | 10:35:57-10:37:00 / 63 seconds | Passed | -| `npm run prettier-fix` | Started 10:37:02; completion time unavailable | Passed, exit 0 | -| `npm run l10n:check` | 10:38:37-10:39:37 / 60 seconds | Passed | -| `npm run lint` | 10:39:39-10:41:35 / 116 seconds | Historical failure: three test-import errors below; corrected rerun passed | -| `npm run build` | 10:41:37-10:42:16 / 39 seconds | Passed | -| `npm run typecheck:tests` | 10:42:18-10:42:32 / 14 seconds | Passed (exact strict package-test script) | -| Focused Case 1 Vitest | 10:42:34-10:42:38 / 4 seconds | Passed: four files / 140 tests | -| `npx vitest run` | 10:42:40-10:44:10 / 90 seconds | Passed: 323 files / 5,571 passed, one skipped | -| `npm run package` | Started 10:44:12; completion time unavailable | Passed, exit 0; `vscode-documentdb-1.0.2-alpha.0.vsix` | +| Check | UTC interval / elapsed | Result | +| ------------------------- | --------------------------------------------- | -------------------------------------------------------------------------- | +| `npm run l10n` | 10:35:57-10:37:00 / 63 seconds | Passed | +| `npm run prettier-fix` | Started 10:37:02; completion time unavailable | Passed, exit 0 | +| `npm run l10n:check` | 10:38:37-10:39:37 / 60 seconds | Passed | +| `npm run lint` | 10:39:39-10:41:35 / 116 seconds | Historical failure: three test-import errors below; corrected rerun passed | +| `npm run build` | 10:41:37-10:42:16 / 39 seconds | Passed | +| `npm run typecheck:tests` | 10:42:18-10:42:32 / 14 seconds | Passed (exact strict package-test script) | +| Focused Case 1 Vitest | 10:42:34-10:42:38 / 4 seconds | Passed: four files / 140 tests | +| `npx vitest run` | 10:42:40-10:44:10 / 90 seconds | Passed: 323 files / 5,571 passed, one skipped | +| `npm run package` | Started 10:44:12; completion time unavailable | Passed, exit 0; `vscode-documentdb-1.0.2-alpha.0.vsix` | Original lint blockers (historical; all resolved): @@ -192,11 +192,11 @@ D11 production data, and additional model/vendor attestation remain separate ext push, amend, or preview edits. Terra's prior full-suite and packaging evidence above is retained; these import-only changes did not require repeating those checks. -| Check | UTC interval / elapsed | Result | -| --- | --- | --- | -| `npm run lint` | 10:46:56-10:48:50 / 114 seconds | Passed; all three original lint errors resolved | -| `npm run build` | 10:48:50-10:49:27 / 37 seconds | Passed, including workspace prebuilds and root test type checking | -| Combined focused Vitest | 10:49:27-10:49:30 / 3 seconds | Passed: three files / 52 tests | +| Check | UTC interval / elapsed | Result | +| ----------------------- | ------------------------------- | ----------------------------------------------------------------- | +| `npm run lint` | 10:46:56-10:48:50 / 114 seconds | Passed; all three original lint errors resolved | +| `npm run build` | 10:48:50-10:49:27 / 37 seconds | Passed, including workspace prebuilds and root test type checking | +| Combined focused Vitest | 10:49:27-10:49:30 / 3 seconds | Passed: three files / 52 tests | Exact focused command: From 7f2a288aa23369dbc26a95bfe7b93ec4cae0205c Mon Sep 17 00:00:00 2001 From: Tomasz Naumowicz Date: Mon, 26 Oct 2026 14:58:19 +0000 Subject: [PATCH 50/50] version bump --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 65fb950ff..5c789723b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "vscode-documentdb", - "version": "1.0.2-alpha.0", + "version": "1.0.2-sov-cloud-preview", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "vscode-documentdb", - "version": "1.0.2-alpha.0", + "version": "1.0.2-sov-cloud-preview", "license": "SEE LICENSE IN LICENSE.md", "workspaces": [ "packages/*" diff --git a/package.json b/package.json index b293c01b4..efb3118ed 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "vscode-documentdb", - "version": "1.0.2-alpha.0", + "version": "1.0.2-sov-cloud-preview", "releaseNotesUrl": "https://github.com/microsoft/vscode-documentdb/discussions/1012", "aiKey": "0c6ae279ed8443289764825290e4f9e2-1a736e7c-1324-4338-be46-fc2a58ae4d14-7255", "publisher": "ms-azuretools",