diff --git a/docs/ai-and-plans/README.md b/docs/ai-and-plans/README.md index 39d8756db..f6ef96779 100644 --- a/docs/ai-and-plans/README.md +++ b/docs/ai-and-plans/README.md @@ -22,33 +22,34 @@ 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 new file mode 100644 index 000000000..bdd4034bc --- /dev/null +++ b/docs/ai-and-plans/features/sovereign-clouds/README.md @@ -0,0 +1,133 @@ +--- +feature: sovereign-clouds +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 + - 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 + +**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). + +## 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 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 was that we always requested +the public-cloud audience `https://ossrdbms-aad.database.windows.net`, whatever cloud the user +selected. Part A now resolves the audience for the cloud. + +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 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 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 and requests the resolved cloud's scope. +- [ManagedIdentityAuthHandler.ts](../../../../src/documentdb/auth/ManagedIdentityAuthHandler.ts): + 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): + 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). Custom suffixes come from the machine-scoped + `documentDB.customEnvironment` object, not workspace overrides; bare TLDs and common two-label + 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 + 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. + +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). + +`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. + +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. + +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. + +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. + +`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 + 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. +- [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 + +- 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). 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..52aa0cbaf --- /dev/null +++ b/docs/ai-and-plans/features/sovereign-clouds/decisions.md @@ -0,0 +1,166 @@ +--- +feature: sovereign-clouds +kind: decisions +status: active +--- + +# Sovereign Clouds: Decisions + +"Accepted" entries were approved by the operator. Superseded entries are kept for their rejected +alternatives. + +| ID | Status | Summary | +| --- | --------------------------- | -------------------------------------------------------------------------------------------------- | +| 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 | **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 + +**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 +(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 + +**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. + +| 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 + +**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. 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". +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 + +**Accepted** by the operator. + +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 + +**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 +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 + +**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 +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 + +**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 +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-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..ca431da1c --- /dev/null +++ b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics-review.md @@ -0,0 +1,1212 @@ +--- +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. 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 + 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. + +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 +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: +**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. +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 | + +### 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 + 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: + 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 + `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. + 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 + 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, + 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). 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 + 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. + 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. + 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. + 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. + 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: + 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 + 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. 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" + 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. + 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: `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: `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: `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. + 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` + 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. +- 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: + +- `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. + +### 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 + 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 +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, 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 + +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: recorded on 2026-10-26 for R1 to R9 (all recommended options; R7 neutral + 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 + (`@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 + +### 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. + +**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. + +**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) +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. + +**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. + +**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 + +**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. + +**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) +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. + +**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) +(`"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`. + +**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). + +`/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. + +**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). + +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. + +**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). + +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. + +**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). + +- "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. + +**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. + +| 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. + +- [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. Implemented through existing attempt-bound trace contexts, never a + cluster-keyed registry or raw trace spread. +- [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. +- [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. +- [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. +- [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. +- [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`). + +### 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 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 +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 +``` + +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 + +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 + +- 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. +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 + +### 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 | +| 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 +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. + +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 + 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. + 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. 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..fcff64434 --- /dev/null +++ b/docs/ai-and-plans/features/sovereign-clouds/iterations/01-cloud-aware-scope-and-entra-diagnostics.md @@ -0,0 +1,295 @@ +--- +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; 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 +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`. + +**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`: + +```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): 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. + +**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?)`. + +- 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. + +**Done** in `b1e39d1b`. + +## 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. + +**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`: + +| 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). + +**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 + 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. + +**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 +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, cloudCategory, recentTokenFailure }`. +Every message names who can fix the problem and links to the public troubleshooting page +(`Links.SovereignClouds`). + +| 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. +The auth method comes from the recent token-failure registry, not from the error text. + +## 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.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`. + +--- + +# 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. +- 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). + +# Outcome + +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. 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..9b8c43bef --- /dev/null +++ b/docs/ai-and-plans/features/sovereign-clouds/research-findings.md @@ -0,0 +1,244 @@ +--- +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 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? + +Answered: on Windows with the broker, only `platform_broker_error` reaches the extension (§3). 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/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/docs/user-manual/sovereign-clouds.md b/docs/user-manual/sovereign-clouds.md new file mode 100644 index 000000000..122ff0648 --- /dev/null +++ b/docs/user-manual/sovereign-clouds.md @@ -0,0 +1,176 @@ +--- +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 and asks +you to change `microsoft-sovereign-cloud.environment` to the cluster's cloud. + +## Managed identity + +For a managed identity, the extension selects the token resource in this order: + +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 + +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. + +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`. For a custom cloud, for example: + +```json +{ + "documentDB.customEnvironment": { + "ossrdbmsEndpoint": "https://ossrdbms-aad.database.example", + "mongoClusterHostnameSuffix": ".mongocluster.cosmos.example" + } +} +``` + +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 +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. + +## 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 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 +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. diff --git a/l10n/bundle.l10n.json b/l10n/bundle.l10n.json index b121ad864..726333175 100644 --- a/l10n/bundle.l10n.json +++ b/l10n/bundle.l10n.json @@ -25,6 +25,8 @@ "\"{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.", + "\"{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.", @@ -284,6 +286,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 +409,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 +430,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}\".", @@ -1240,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", @@ -1490,6 +1497,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 +1581,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 +2118,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}\".", @@ -2320,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.", @@ -2362,13 +2376,17 @@ "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 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.", @@ -2380,11 +2398,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 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.", @@ -2394,6 +2414,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 +2429,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 +2655,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 +2675,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 +2771,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.", 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 3ad946931..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", @@ -1986,6 +1986,28 @@ "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", + "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", + "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": { + "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." + } + }, + "additionalProperties": false } } }, diff --git a/src/commands/copyConnectionString/copyConnectionString.test.ts b/src/commands/copyConnectionString/copyConnectionString.test.ts index f41a13645..cd0eaf162 100644 --- a/src/commands/copyConnectionString/copyConnectionString.test.ts +++ b/src/commands/copyConnectionString/copyConnectionString.test.ts @@ -3,7 +3,9 @@ * Licensed under the MIT License. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +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(); const mockShowQuickPick = vi.fn(); @@ -29,6 +31,12 @@ vi.mock('vscode', () => ({ l10n: { t: vi.fn((message: string) => message), }, + workspace: { + getConfiguration: vi.fn(() => ({ + get: vi.fn().mockReturnValue(undefined), + inspect: vi.fn().mockReturnValue(undefined), + })), + }, })); vi.mock('@vscode/l10n', () => ({ @@ -453,6 +461,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 +534,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..dfd93d888 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,12 @@ 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..cd0761fbe 100644 --- a/src/commands/newConnection/PromptConnectionStringStep.test.ts +++ b/src/commands/newConnection/PromptConnectionStringStep.test.ts @@ -4,13 +4,17 @@ *--------------------------------------------------------------------------------------------*/ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import * as vscode from 'vscode'; 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 { SelectEntraTokenSourceStep } from '../../documentdb/wizards/authenticate/SelectEntraTokenSourceStep'; import { type NewConnectionWizardContext } from './NewConnectionWizardContext'; import { PromptConnectionStringStep } from './PromptConnectionStringStep'; const mockInfo = vi.fn(); +const publicAuthMechanismProperties = getManagedIdentityAuthMechanismProperties(DOCUMENTDB_TOKEN_RESOURCE); vi.mock('../../extensionVariables', () => ({ ext: { outputChannel: { @@ -23,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); @@ -48,10 +55,56 @@ 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( - `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 +135,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 +151,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/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: typeof AzureWizard }>()), + 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/constants.ts b/src/constants.ts index d8695667c..95beed9ee 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -14,6 +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 doubleClickDebounceDelay = 500; //milliseconds diff --git a/src/documentdb/ClustersClient.test.ts b/src/documentdb/ClustersClient.test.ts index 06bb63499..f5a3645dc 100644 --- a/src/documentdb/ClustersClient.test.ts +++ b/src/documentdb/ClustersClient.test.ts @@ -5,11 +5,11 @@ 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'; -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', () => ({ @@ -153,6 +153,40 @@ 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<{ 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, + }); + }); + it.each([ AuthMethodId.MicrosoftEntraID, AuthMethodId.ManagedIdentity, @@ -227,9 +261,52 @@ 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..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'; @@ -358,11 +359,12 @@ export class ClustersClient { const { connectionString, options } = await authHandler.configureAuth(startup.costTimings, { startupCorrelationId: startup.id, surface: 'extension', + startupProperties: startup.telemetry.properties, }); 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/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/ManagedIdentityAuthHandler.test.ts b/src/documentdb/auth/ManagedIdentityAuthHandler.test.ts index 1761593a5..5e60f20f9 100644 --- a/src/documentdb/auth/ManagedIdentityAuthHandler.test.ts +++ b/src/documentdb/auth/ManagedIdentityAuthHandler.test.ts @@ -3,12 +3,17 @@ * 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'; +import { ext } from '../../extensionVariables'; 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'; @@ -64,6 +69,12 @@ describe('expiresInSecondsFromTimestamp', () => { describe('ManagedIdentityAuthHandler', () => { beforeEach(() => { + vi.clearAllMocks(); + 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({ accessToken: 'a-token', @@ -71,10 +82,27 @@ describe('ManagedIdentityAuthHandler', () => { }); }); + afterEach((): void => { + vi.restoreAllMocks(); + resetEntraTokenFailuresForTests(); + }); + 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); @@ -130,6 +158,43 @@ 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', + }), + 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( + 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' }; @@ -152,6 +217,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 () => { @@ -173,7 +239,108 @@ 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 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', + 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', + { + flow: 'managedIdentity', + errorKind: 'managedIdentityCloudUnknown', + }, + ); + vi.spyOn(tokenResources, 'resolveManagedIdentityTokenResource').mockRejectedValueOnce(error); + const handler = new ManagedIdentityAuthHandler(buildCredentials()); + + 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(); + }); + + 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' }), + inspect: vi.fn().mockReturnValue(undefined), + } 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..b375eeb3c 100644 --- a/src/documentdb/auth/ManagedIdentityAuthHandler.ts +++ b/src/documentdb/auth/ManagedIdentityAuthHandler.ts @@ -5,13 +5,20 @@ 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, + TokenResourceResolutionError, + type DocumentDBCustomEnvironment, +} from './documentdbTokenResource'; +import { clearEntraTokenFailure, recordEntraTokenFailure } from './entraErrors/entraTokenFailures'; import { getManagedIdentityAccessToken } from './managedIdentityTokenProvider'; import { getOidcAllowedHosts } from './oidcAllowedHosts'; import { expiresInSecondsFromTimestamp } from './tokenExpiry'; @@ -36,12 +43,29 @@ export class ManagedIdentityAuthHandler implements AuthHandler { ): Promise { const clientId = this.clusterCredentials.managedIdentityConfig?.clientId; const tenantId = this.clusterCredentials.managedIdentityConfig?.tenantId; + 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', { identityKind: clientId ? 'userAssigned' : 'systemAssigned', clusterTenantKnown: !!tenantId, tokenAcquisition: 'deferredUntilDriverCallback', + cloudCategory: resolved.cloudCategory, + tokenResourceReason: resolved.reason, + allowedHosts: allowedHosts.join(','), }, this.tokenCorrelationId, traceContext, @@ -60,29 +84,41 @@ export class ManagedIdentityAuthHandler implements AuthHandler { authMechanism: 'MONGODB-OIDC', tls: true, authMechanismProperties: { - ALLOWED_HOSTS: getOidcAllowedHosts(this.clusterCredentials.connectionString), + ALLOWED_HOSTS: allowedHosts, OIDC_CALLBACK: async (): Promise => { - const token = await traceAuthOperation( - 'managedIdentity.oidcCallback', - () => - withTokenAcquisitionTiming(timings, () => - getManagedIdentityAccessToken( - [DOCUMENTDB_ENTRA_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), - }; + clearEntraTokenFailure(this.clusterCredentials.clusterId); + return { + accessToken: token.accessToken, + expiresInSeconds: expiresInSecondsFromTimestamp(token.expiresOnTimestamp), + }; + } catch (error) { + if (traceContext?.startupProperties) { + traceContext.startupProperties.errorKind = 'tokenAcquisitionFailed'; + } + 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 f661078f9..fb65c40ce 100644 --- a/src/documentdb/auth/MicrosoftEntraIDAuthHandler.test.ts +++ b/src/documentdb/auth/MicrosoftEntraIDAuthHandler.test.ts @@ -3,7 +3,9 @@ * 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'; +import type * as documentdbTokenResource from './documentdbTokenResource'; const getSessionFromVSCode = vi.fn(); const mockOutputChannel = { info: vi.fn(), error: vi.fn() }; @@ -19,9 +21,17 @@ 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 { EntraDiagnosticsProvider } from './entraErrors/EntraDiagnosticsProvider'; +import { getRecentEntraTokenFailure, resetEntraTokenFailuresForTests } from './entraErrors/entraTokenFailures'; import { MicrosoftEntraIDAuthHandler } from './MicrosoftEntraIDAuthHandler'; function buildCredentials(connectionString: string): CachedClusterCredentials { @@ -37,8 +47,26 @@ function buildCredentials(connectionString: string): CachedClusterCredentials { describe('MicrosoftEntraIDAuthHandler', () => { beforeEach(() => { vi.clearAllMocks(); + resetEntraTokenFailuresForTests(); 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', + details: { + flow: 'interactive', + tokenResource: 'https://ossrdbms-aad.database.windows.net', + }, + }); + vi.mocked(vscode.workspace.getConfiguration).mockReturnValue({ + get: vi.fn().mockReturnValue(undefined), + } as unknown as vscode.WorkspaceConfiguration); + }); + + afterEach((): void => { + resetEntraTokenFailuresForTests(); }); it('strips credentials and stale OIDC markers from the connection string', async () => { @@ -53,10 +81,209 @@ 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'); + 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(); + }); + + 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', + 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 = 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(4); + }); + + it('propagates a cloud mismatch unchanged before requesting a session', async () => { + 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/')); + 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(); + }); + + 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 }), + getCustomHostnameSuffixSource: () => 'none', + 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, + customHostnameSuffixSource: 'none', + 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 => { + vi.mocked(resolveInteractiveTokenResource).mockResolvedValueOnce({ + resource: 'https://database.example.com', + 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' }), + ); + 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('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' }), + } 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 () => { @@ -74,18 +301,27 @@ 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\.)/, + ); } }); 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 4079525a7..58f83554d 100644 --- a/src/documentdb/auth/MicrosoftEntraIDAuthHandler.ts +++ b/src/documentdb/auth/MicrosoftEntraIDAuthHandler.ts @@ -8,13 +8,20 @@ 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, + TokenResourceResolutionError, + type DocumentDBCustomEnvironment, +} from './documentdbTokenResource'; +import { clearEntraTokenFailure, recordEntraTokenFailure } from './entraErrors/entraTokenFailures'; import { getOidcAllowedHosts } from './oidcAllowedHosts'; /** @@ -28,30 +35,62 @@ export class MicrosoftEntraIDAuthHandler implements AuthHandler { traceContext?: AuthTraceContext, ): Promise { const authCorrelationId = randomUUID(); + 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; // Get Microsoft Entra ID token - const session = await traceAuthOperation( - 'interactiveEntra.getSession', - () => - withTokenAcquisitionTiming(timings, () => - getSessionFromVSCode([DOCUMENTDB_ENTRA_SCOPE], this.clusterCredentials.entraIdConfig?.tenantId, { - createIfNone: true, - }), - ), - { tenantSpecified: !!this.clusterCredentials.entraIdConfig?.tenantId, createIfNone: true }, - 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) { + if (traceContext?.startupProperties) { + traceContext.startupProperties.errorKind = 'tokenAcquisitionFailed'; + } + recordEntraTokenFailure(this.clusterCredentials.clusterId, { + cloudCategory: resolved.cloudCategory, + authMethod: 'interactive', + }); + 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 @@ -61,11 +100,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), + ALLOWED_HOSTS: allowedHosts, OIDC_CALLBACK: (_params: OIDCCallbackParams): Promise => Promise.resolve({ accessToken: session.accessToken, @@ -89,7 +129,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 new file mode 100644 index 000000000..418fce309 --- /dev/null +++ b/src/documentdb/auth/documentdbTokenResource.test.ts @@ -0,0 +1,645 @@ +/*--------------------------------------------------------------------------------------------- + * 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(() => ({})), + getCustomHostnameSuffixSource: vi.fn((): 'none' => 'none'), + fetchArmMetadata: vi.fn().mockResolvedValue(undefined), + readImdsAzEnvironment: vi.fn().mockResolvedValue(undefined), + getSignInEnvironment: vi.fn(() => ({ authProvider: 'microsoft', brokerImplementation: 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)).toMatchObject({ + resource, + scope: `${resource}/.default`, + cloudCategory: category, + reason: 'setting', + }); + expect(deps.fetchArmMetadata).not.toHaveBeenCalled(); + 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', + customOssrdbmsEndpointState: 'absent', + customOssrdbmsEndpointApplicable: false, + customHostnameSuffixSet: false, + customHostnameSuffixSource: 'none', + 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)).toMatchObject({ + resource: customResource, + scope: `${customResource}/.default`, + cloudCategory: 'custom', + reason: 'customOverride', + details: { customOssrdbmsEndpointState: 'valid', customOssrdbmsEndpointApplicable: true }, + }); + expect(deps.fetchArmMetadata).not.toHaveBeenCalled(); + }); + + 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], + ['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}/` }); + + 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', + ); + }); + + 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'])( + '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: '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(() => { + throw new Error('Custom cloud not configured'); + }); + await expect(resolveInteractiveTokenResource(privateConnection, deps)).rejects.toBeInstanceOf( + TokenResourceResolutionError, + ); + await expect(resolveInteractiveTokenResource(privateConnection, deps)).rejects.toMatchObject({ + 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); + 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'), + 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, + ); + 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('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); + vi.mocked(deps.getCustomEnvironment).mockReturnValue({ ossrdbmsEndpoint: publicResource }); + await resolveInteractiveTokenResource(privateConnection, deps); + expect(deps.warn).toHaveBeenCalledWith( + expect.stringContaining('documentDB.customEnvironment.ossrdbmsEndpoint'), + ); + }); +}); + +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) => { + 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}/` }); + 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(); + }); + + 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).toHaveBeenCalledTimes(1); + 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).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([ + ['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) => { + 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.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` }); + 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 }); + expect(resolveTokenResourceForCopy('mongodb://cluster.azure.us/', deps)).toMatchObject({ + resource: customResource, + reason: 'customOverride', + details: { hostCloud: 'AzureUSGovernment' }, + }); + }); + + 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..f5fd419f8 --- /dev/null +++ b/src/documentdb/auth/documentdbTokenResource.ts @@ -0,0 +1,575 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { getConfiguredAuthProviderId, getConfiguredAzureEnv } from '@microsoft/vscode-azext-azureauth'; +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, normalizeCustomHostnameSuffix } 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; + readonly details: Readonly>; +} + +/** 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; + 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. */ + readImdsAzEnvironment(): Promise; + getSignInEnvironment(): { authProvider: string; brokerImplementation: string | undefined }; + warn(message: string): void; +} + +export type TokenResourceErrorKind = + | 'customCloudUnresolved' + | 'customCloudDefinitionInvalid' + | 'invalidCustomOverride' + | '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, + public readonly details: Readonly>, + ) { + 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 { + const hostCloud = getAzureCloudFromHosts(connectionString); + const customEnvironment = deps.getCustomEnvironment(); + const details = createResolutionDetails('interactive', customEnvironment, deps); + details.hostCloud = hostCloud ?? 'none'; + const configured = getConfiguredCloudForDetails(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 && + 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 audienceCloud = getCloudForTokenResource(resolved.resource); + if (hostCloud && hostCloud !== audienceCloud) { + 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.', + { + clusterCloud: getCloudDisplayName(hostCloud), + signedInCloud: audienceCloud ? getCloudDisplayName(audienceCloud) : l10n.t('a custom cloud'), + setting: SOVEREIGN_CLOUD_SETTING, + }, + ), + 'cloudMismatch', + withDetails(resolved, details).details, + ); + } + + return withDetails(resolved, details); +} + +/** + * 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 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); + } + + if (hostCloud) { + return withDetails(fromBuiltInCloud(hostCloud, 'host'), details); + } + + const azEnvironment = await deps.readImdsAzEnvironment(); + details.imdsAzEnvironment = classifyImdsAzEnvironment(azEnvironment); + if (azEnvironment?.trim().toUpperCase() === 'AZUREPUBLICCLOUD') { + return withDetails(fromBuiltInCloud('AzureCloud', 'imds'), details); + } + + 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, + ); +} + +/** + * 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< + TokenResourceResolverDeps, + | 'getConfiguredCloud' + | 'getCustomEnvironment' + | 'getCustomHostnameSuffixSource' + | '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) { + 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, + }), + ); + } + + if (hostCloud) { + return withDetails(fromBuiltInCloud(hostCloud, 'host'), details); + } + + const configured = getConfiguredCloudForDetails(deps, details); + if (configured && !configured.isCustomCloud) { + return withDetails(fromBuiltInCloud(toBuiltInCloud(configured.name), 'setting'), details); + } + + return withDetails(fromBuiltInCloud('AzureCloud', 'copyDefault'), details); +} + +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, + customEnvironment: DocumentDBCustomEnvironment, + deps: TokenResourceResolverDeps, + details: Record, +): Promise> { + const override = getApplicableOverride(customEnvironment, details); + 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); + details.armMetadata = fromMetadata.status; + if (fromMetadata.resource) { + return fromResource(fromMetadata.resource, 'armMetadata'); + } + + 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, + ); +} + +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, +): Promise<{ resource: string | undefined; status: 'cached' | 'found' | 'missingKey' | 'unavailable' }> { + const base = parseTokenResource(resourceManagerEndpointUrl); + if (!base) { + return { resource: undefined, status: 'unavailable' }; + } + + const cached = armMetadataCache.get(base); + if (cached) { + return { resource: cached, status: 'cached' }; + } + + 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 { resource: undefined, status: 'unavailable' }; + } + + 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, status: 'found' }; + } + return { resource: undefined, status: 'missingKey' }; +} + +function toBuiltInCloud(name: string): DocumentDBEntraCloud { + switch (name) { + case 'AzureUSGovernment': + return 'AzureUSGovernment'; + case 'AzureChinaCloud': + return 'AzureChinaCloud'; + default: + return 'AzureCloud'; + } +} + +function fromBuiltInCloud( + cloud: DocumentDBEntraCloud, + reason: TokenResourceReason, +): Omit { + return fromResource(getTokenResourceForCloud(cloud), reason); +} + +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', + customOssrdbmsEndpointState: !customEnvironment.ossrdbmsEndpoint?.trim() + ? 'absent' + : parseTokenResource(customEnvironment.ossrdbmsEndpoint) + ? 'valid' + : 'invalid', + customOssrdbmsEndpointApplicable: false, + customHostnameSuffixSet: !!normalizeCustomHostnameSuffix(customEnvironment.mongoClusterHostnameSuffix), + customHostnameSuffixSource: deps.getCustomHostnameSuffixSource(), + 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'; + } + 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 { + 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) ?? {}, + 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: () => ({ + authProvider: getConfiguredAuthProviderId(), + brokerImplementation: vscode.workspace + .getConfiguration('microsoft-authentication') + .get('implementation'), + }), + 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)); + }); +} diff --git a/src/documentdb/auth/entraErrors/EntraDiagnosticsProvider.test.ts b/src/documentdb/auth/entraErrors/EntraDiagnosticsProvider.test.ts new file mode 100644 index 000000000..85a006fa1 --- /dev/null +++ b/src/documentdb/auth/entraErrors/EntraDiagnosticsProvider.test.ts @@ -0,0 +1,250 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { 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'; + +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 }, +})); + +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([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 => { + 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', + }); + 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 => { + 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..24f8c9069 --- /dev/null +++ b/src/documentdb/auth/entraErrors/EntraDiagnosticsProvider.ts @@ -0,0 +1,73 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { traceAuthFlow } from '../../../utils/authTrace'; +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); + 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, + 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'); +} diff --git a/src/documentdb/auth/entraErrors/classifyEntraError.test.ts b/src/documentdb/auth/entraErrors/classifyEntraError.test.ts new file mode 100644 index 000000000..382399980 --- /dev/null +++ b/src/documentdb/auth/entraErrors/classifyEntraError.test.ts @@ -0,0 +1,204 @@ +/*--------------------------------------------------------------------------------------------- + * 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: "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' }, +]; + +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(['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', + '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(); + }); +}); + +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 ?? []); + 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..ae591226c --- /dev/null +++ b/src/documentdb/auth/entraErrors/classifyEntraError.ts @@ -0,0 +1,48 @@ +/*--------------------------------------------------------------------------------------------- + * 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'); + 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; + } + + 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) { + 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..3817f2159 --- /dev/null +++ b/src/documentdb/auth/entraErrors/entraErrorRules.ts @@ -0,0 +1,157 @@ +/*--------------------------------------------------------------------------------------------- + * 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[]; + readonly pattern?: RegExp; + 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 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: '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 => + 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'); + } +} diff --git a/src/documentdb/auth/entraErrors/entraTokenFailures.test.ts b/src/documentdb/auth/entraErrors/entraTokenFailures.test.ts new file mode 100644 index 000000000..a7efe7d9e --- /dev/null +++ b/src/documentdb/auth/entraErrors/entraTokenFailures.test.ts @@ -0,0 +1,84 @@ +/*--------------------------------------------------------------------------------------------- + * 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 { + clearEntraTokenFailure, + 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('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' }); + 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..20c008a62 --- /dev/null +++ b/src/documentdb/auth/entraErrors/entraTokenFailures.ts @@ -0,0 +1,42 @@ +/*--------------------------------------------------------------------------------------------- + * 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 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) { + failures.delete(clusterId); + return undefined; + } + return failure; +} + +export function resetEntraTokenFailuresForTests(): void { + failures.clear(); +} 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/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; diff --git a/src/documentdb/auth/oidcAllowedHosts.test.ts b/src/documentdb/auth/oidcAllowedHosts.test.ts index 8c8a0ebdb..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 { getOidcAllowedHosts } 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', () => { @@ -34,12 +64,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 +95,63 @@ 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(); + } + }); + + 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', () => { + 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..36c6a11eb 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, @@ -15,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. * @@ -30,12 +45,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 +62,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 +81,74 @@ 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. Bare TLDs and common two-label public suffixes 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; + } + if (/^(?:com|co|net|org|gov)\.[a-z]{2}$/.test(suffix)) { + 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. @@ -65,14 +157,8 @@ export function getOidcAllowedHosts(connectionString: string): string[] { * 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 { - // `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(); +export function getAzureHostSuffix(host: string): string | undefined { + const hostname = getHostname(host); if (!hostname) { return undefined; diff --git a/src/documentdb/auth/sovereignAuth.integration.test.ts b/src/documentdb/auth/sovereignAuth.integration.test.ts new file mode 100644 index 000000000..20e0ffe9c --- /dev/null +++ b/src/documentdb/auth/sovereignAuth.integration.test.ts @@ -0,0 +1,253 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +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'; +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: typeof callWithTelemetryAndErrorHandling }>()), + 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(); + } + }); +}); diff --git a/src/documentdb/playground/PlaygroundEvaluator.ts b/src/documentdb/playground/PlaygroundEvaluator.ts index cd8cc3c35..431f96655 100644 --- a/src/documentdb/playground/PlaygroundEvaluator.ts +++ b/src/documentdb/playground/PlaygroundEvaluator.ts @@ -14,9 +14,15 @@ 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, + TokenResourceResolutionError, +} from '../auth/documentdbTokenResource'; +import { clearEntraTokenFailure, recordEntraTokenFailure } from '../auth/entraErrors/entraTokenFailures'; import { CredentialCache } from '../CredentialCache'; import { withTokenAcquisitionTiming, type ConnectionStartupTimings } from '../utils/ConnectionStartupTimings'; import { resolveAllowInvalidCertificates } from '../utils/tlsException'; @@ -374,45 +380,92 @@ export class PlaygroundEvaluator implements vscode.Disposable { traceContext?: AuthTraceContext, ): Promise { try { + const clusterId = this._workerManager.workerClusterId; + const credentials = clusterId ? CredentialCache.getCredentials(clusterId) : undefined; + if (!clusterId || !credentials) { + throw new Error(l10n.t('No credentials found for the query playground connection.')); + } + 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') { const { getManagedIdentityAccessToken } = await import('../auth/managedIdentityTokenProvider'); this._sessionId ??= randomUUID(); - accessToken = ( - await withTokenAcquisitionTiming(timings, () => - getManagedIdentityAccessToken( - msg.scopes as string[], - 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) { + if (traceContext?.startupProperties) { + traceContext.startupProperties.errorKind = 'tokenAcquisitionFailed'; + } + 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(msg.scopes as string[], msg.tenantId, { createIfNone: true }), - ), - { tenantSpecified: !!msg.tenantId, createIfNone: true }, - msg.requestId, - traceContext, - ); - - if (!session) { - throw new Error(l10n.t('Failed to obtain Entra ID token.')); + 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) { + if (traceContext?.startupProperties) { + traceContext.startupProperties.errorKind = 'tokenAcquisitionFailed'; + } + recordEntraTokenFailure(clusterId, { + cloudCategory: resolved.cloudCategory, + authMethod: 'interactive', + }); + throw error; } accessToken = session.accessToken; } + clearEntraTokenFailure(clusterId); postResponse({ type: 'tokenResponse', requestId: msg.requestId, diff --git a/src/documentdb/playground/WorkerSessionManager.test.ts b/src/documentdb/playground/WorkerSessionManager.test.ts index 9bd74b018..825249fb9 100644 --- a/src/documentdb/playground/WorkerSessionManager.test.ts +++ b/src/documentdb/playground/WorkerSessionManager.test.ts @@ -7,10 +7,11 @@ 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'; -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'; @@ -78,6 +79,7 @@ vi.mock('../../extensionVariables', () => ({ context: { extensionPath: '/extension-root' }, outputChannel: { trace: vi.fn(), + info: vi.fn(), warn: vi.fn(), error: vi.fn(), }, @@ -142,6 +144,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 +275,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 +760,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 +816,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,17 +906,84 @@ 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' }, + { + 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 ab1742ac4..66308ee0e 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(); @@ -361,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/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..7c6341666 100644 --- a/src/documentdb/shell/ShellSessionManager.test.ts +++ b/src/documentdb/shell/ShellSessionManager.test.ts @@ -8,6 +8,14 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { type AuthenticationSession } from 'vscode'; import { ext } from '../../extensionVariables'; import { AuthMethodId } from '../auth/AuthMethod'; +import type * as documentdbTokenResource from '../auth/documentdbTokenResource'; +import { + resolveInteractiveTokenResource, + 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'; import { type WorkerSessionCallbacks } from '../playground/WorkerSessionManager'; @@ -18,6 +26,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 +48,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 +68,6 @@ vi.mock('../playground/WorkerSessionManager', () => ({ const tokenRequest: Extract = { type: 'tokenRequest', requestId: 'token-request', - scopes: ['scope'], source: 'vscode', tenantId: initMessage.tenantId, }; @@ -68,6 +83,9 @@ vi.mock('../playground/WorkerSessionManager', () => ({ get workerState(): string { return 'ready'; }, + get workerClusterId(): string | undefined { + return workerClusterId; + }, dispose: vi.fn(), }; }), @@ -78,6 +96,20 @@ describe('ShellSessionManager', () => { beforeEach(() => { vi.clearAllMocks(); + resetEntraTokenFailuresForTests(); + workerClusterId = clusterId; + const publicResolution = { + resource: 'https://ossrdbms-aad.database.windows.net', + 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' }); CredentialCache.setAuthCredentials( clusterId, AuthMethodId.MicrosoftEntraID, @@ -90,6 +122,7 @@ describe('ShellSessionManager', () => { afterEach(() => { CredentialCache.deleteCredentials(clusterId); + resetEntraTokenFailuresForTests(); }); it('returns the optional Microsoft Entra account display name', async () => { @@ -171,7 +204,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, @@ -186,6 +219,7 @@ describe('ShellSessionManager', () => { requestId: 'token-request', accessToken: 'private-token', }); + expect(getRecentEntraTokenFailure(clusterId)).toBeUndefined(); expect(timings.finish()).toEqual({ databaseConnectDurationMs: 25, tokenAcquireDurationMs: 25, @@ -193,7 +227,7 @@ describe('ShellSessionManager', () => { }); if (source === 'managedIdentity') { expect(mockGetManagedIdentityAccessToken).toHaveBeenCalledWith( - ['scope'], + ['https://ossrdbms-aad.database.windows.net/.default'], undefined, undefined, expect.any(String), @@ -208,4 +242,287 @@ 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', + details: { + flow: source === 'vscode' ? 'interactive' : 'managedIdentity', + cloudCategory: 'usgov', + reason: source === 'vscode' ? 'setting' : 'host', + configuredCloud: 'AzureUSGovernment', + hostCloud: 'AzureUSGovernment', + tokenResource: 'https://ossrdbms-aad.database.usgovcloudapi.net', + }, + }); + 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(); + 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({ + type: 'tokenResponse', + requestId: 'token-request', + accessToken: 'private-token', + }); + manager.dispose(); + }); + + 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', + { + flow: source === 'vscode' ? 'interactive' : 'managedIdentity', + errorKind: 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(); + 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(); + expect(postResponse).toHaveBeenCalledWith({ + type: 'tokenError', + requestId: 'token-request', + error: error.message, + }); + 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', + 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(); + 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', + 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', '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 => { + 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 51fb4890e..16e72b965 100644 --- a/src/documentdb/shell/ShellSessionManager.ts +++ b/src/documentdb/shell/ShellSessionManager.ts @@ -7,10 +7,16 @@ 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, + TokenResourceResolutionError, +} from '../auth/documentdbTokenResource'; +import { clearEntraTokenFailure, recordEntraTokenFailure } from '../auth/entraErrors/entraTokenFailures'; import { WorkerSessionManager, type WorkerSessionCallbacks, @@ -358,45 +364,91 @@ 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)); + } + 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') { const { getManagedIdentityAccessToken } = await import('../auth/managedIdentityTokenProvider'); - accessToken = ( - await withTokenAcquisitionTiming(timings, () => - getManagedIdentityAccessToken( - msg.scopes as string[], - 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) { + if (traceContext?.startupProperties) { + traceContext.startupProperties.errorKind = 'tokenAcquisitionFailed'; + } + 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(msg.scopes as string[], msg.tenantId, { createIfNone: true }), - ), - { tenantSpecified: !!msg.tenantId, createIfNone: true }, - msg.requestId, - traceContext, - ); - - if (!session) { - throw new Error(l10n.t('Failed to obtain Entra ID token.')); + 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) { + if (traceContext?.startupProperties) { + traceContext.startupProperties.errorKind = 'tokenAcquisitionFailed'; + } + recordEntraTokenFailure(this._connectionInfo.clusterId, { + cloudCategory: resolved.cloudCategory, + authMethod: 'interactive', + }); + throw error; } accessToken = session.accessToken; this._displayName = session.account.label || undefined; } + clearEntraTokenFailure(this._connectionInfo.clusterId); postResponse({ type: 'tokenResponse', requestId: msg.requestId, diff --git a/src/documentdb/utils/connection-string-parameters.md b/src/documentdb/utils/connection-string-parameters.md index 418f2768c..a59c858c9 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 diff --git a/src/settingsContributions.test.ts b/src/settingsContributions.test.ts index 10180c60c..0bb6aae3a 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 = { @@ -35,6 +36,80 @@ 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)) + .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); + } + }); + + 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. 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'; diff --git a/src/utils/authTrace.test.ts b/src/utils/authTrace.test.ts new file mode 100644 index 000000000..740f724f2 --- /dev/null +++ b/src/utils/authTrace.test.ts @@ -0,0 +1,98 @@ +/*--------------------------------------------------------------------------------------------- + * 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, vi } from 'vitest'; +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', + ); + }); + + 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 36816873c..8c86b1044 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'; @@ -61,7 +62,9 @@ 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>; @@ -70,6 +73,43 @@ 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. */ @@ -86,6 +126,9 @@ 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)), );