From ba33b158d265ee03377018349c861f717418e7c2 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Thu, 5 Nov 2026 11:15:43 +0000 Subject: [PATCH] fix(cli): Route `sentry api` org requests to accounts on other servers #1976 routed `sentry api organizations//...` to the stored account that knows the org, but only among accounts on the server the request URL already named, so a self-hosted org stayed unreachable while a SaaS login was active. Pick the account before the URL is built: `getDefaultSdkConfig` takes the request path and runs `selectAccountForOrg` without a server limit, so the base URL follows the picked account's server. A full URL passed to `sentry api` and an explicit server still keep the request on that server. Co-Authored-By: BYK <126780+BYK@users.noreply.github.com> --- apps/cli-docs/src/fragments/commands/auth.md | 4 +- packages/cli/src/lib/api/infrastructure.ts | 2 +- packages/cli/src/lib/sentry-client.ts | 45 +++++++++++++++---- .../cli/test/lib/db/account-routing.test.ts | 34 +++++++++++++- 4 files changed, 73 insertions(+), 12 deletions(-) diff --git a/apps/cli-docs/src/fragments/commands/auth.md b/apps/cli-docs/src/fragments/commands/auth.md index a9f820353..dabdf3bac 100644 --- a/apps/cli-docs/src/fragments/commands/auth.md +++ b/apps/cli-docs/src/fragments/commands/auth.md @@ -63,7 +63,9 @@ that fits what they target, without changing the active account: which lists every stored account's organizations) but another stored account has used it, that account is used. This includes `oNNNN` IDs from DSNs, and the organization in a `sentry api` path such as - `organizations/acme-corp/issues/`, among accounts on that request's server. + `organizations/acme-corp/issues/`, even when that account is on another + server. A full URL passed to `sentry api`, or an explicit server, keeps + the request on that server. - **Server** — `SENTRY_HOST`/`SENTRY_URL`, `.sentryclirc`, or a pasted Sentry URL for another account's server (for example a self-hosted issue link) uses the account stored for that server. diff --git a/packages/cli/src/lib/api/infrastructure.ts b/packages/cli/src/lib/api/infrastructure.ts index 9acb86d95..d0c4acf56 100644 --- a/packages/cli/src/lib/api/infrastructure.ts +++ b/packages/cli/src/lib/api/infrastructure.ts @@ -1010,7 +1010,7 @@ export async function prepareRawApiRequest( const { method = "GET", body, params, headers, baseUrl } = options; const config = baseUrl ? getSdkConfig(baseUrl, { bufferResponseBody: true }) - : getDefaultSdkConfig({ bufferResponseBody: true }); + : getDefaultSdkConfig({ bufferResponseBody: true, path: endpoint }); const isStringBody = typeof body === "string"; const request = await createSentryClient(config).client.prepareRequest({ method, diff --git a/packages/cli/src/lib/sentry-client.ts b/packages/cli/src/lib/sentry-client.ts index edeed702c..6805b093a 100644 --- a/packages/cli/src/lib/sentry-client.ts +++ b/packages/cli/src/lib/sentry-client.ts @@ -13,9 +13,11 @@ import { getTraceData } from "@sentry/node"; import { computeInvalidationPrefixes, createApiTransport, + type ApiPathScope, type CredentialRequest, extractUrlPath, getValidatedResponseSource, + parseApiPathScope, type ResponseBodyMode, type RetryPolicy, type TransportCache, @@ -323,12 +325,15 @@ async function withHttpSpan( const NUMERIC_ORG_ID = /^\d+$/; /** - * Use the stored account that knows the organization the request's path - * targets (`selectAccountForOrg`), so requests no command resolved an org - * for, such as `sentry api organizations//...`, still reach it. Only - * accounts on the request's server qualify: its URL is already built. + * Use the stored account that knows the organization `scope` names + * (`selectAccountForOrg`). `server` limits the candidates to accounts on + * it; without it, an account on another server qualifies and brings its + * server along. */ -function selectAccountForRequest({ url, scope }: CredentialRequest): void { +function selectAccountForScope( + scope: ApiPathScope | undefined, + server?: string, +): void { if (!scope) { return; } @@ -336,10 +341,21 @@ function selectAccountForRequest({ url, scope }: CredentialRequest): void { selectAccountForOrg( organization, NUMERIC_ORG_ID.test(organization) ? organization : undefined, - { server: url }, + { server }, ); } +/** + * Use the stored account that knows the organization the request's path + * targets, so requests no command resolved an org for still reach it. Only + * accounts on the request's server qualify: its URL is already built. + * Callers that build the URL from {@link getDefaultSdkConfig} with a `path` + * have already picked an account on any server. + */ +function selectAccountForRequest({ url, scope }: CredentialRequest): void { + selectAccountForScope(scope, url); +} + /** * The CLI's transport options: stored credentials, host-scope checks, the * response cache, and the CLI's error classes. Retries, timeouts, the 401 @@ -559,14 +575,27 @@ export function getSdkConfig( /** * Get SDK config for the default API (control silo or self-hosted). * + * When `path` names an organization, the stored account that knows it is + * picked first, on any server, so the base URL follows that account's + * server: `sentry api organizations//` reaches a self-hosted org while + * a SaaS login is active. An explicit server still limits the candidates + * to accounts on it (see `selectAccountForOrg`). + * * The credential is not pinned, so the transport can still pick the stored * account that knows the organization a request's path names; it only picks * among accounts on the server this base URL belongs to. */ export function getDefaultSdkConfig( - options: Pick = {}, + options: Pick & { + /** API path relative to `/api/0/`, optionally with a query string. */ + path?: string; + } = {}, ) { - return getSdkConfig(getApiBaseUrl(), options); + const { path, ...fetchOptions } = options; + if (path !== undefined) { + selectAccountForScope(parseApiPathScope(path)); + } + return getSdkConfig(getApiBaseUrl(), fetchOptions); } /** diff --git a/packages/cli/test/lib/db/account-routing.test.ts b/packages/cli/test/lib/db/account-routing.test.ts index a41783009..8f6d27b98 100644 --- a/packages/cli/test/lib/db/account-routing.test.ts +++ b/packages/cli/test/lib/db/account-routing.test.ts @@ -447,12 +447,16 @@ describe("organization-based selection", () => { describe("requests whose path names an organization", () => { let originalFetch: typeof globalThis.fetch; let sent: Array; + let urls: string[]; beforeEach(() => { originalFetch = globalThis.fetch; sent = []; + urls = []; globalThis.fetch = mockFetch(async (input, init) => { - sent.push(new Request(input, init).headers.get("Authorization")); + const request = new Request(input, init); + sent.push(request.headers.get("Authorization")); + urls.push(request.url); return Response.json({}); }); }); @@ -486,7 +490,7 @@ describe("requests whose path names an organization", () => { expect(sent).toEqual(["Bearer personal-token"]); }); - test("only consider accounts on the request's server", async () => { + test("reach an org whose account is on another server", async () => { login("acme-token", ACME, "acme"); seeOrg("acme-org", ACME); login("saas-token", SAAS, "saas"); @@ -494,6 +498,32 @@ describe("requests whose path names an organization", () => { await rawApiRequest("organizations/acme-org/"); + expect(sent).toEqual(["Bearer acme-token"]); + expect(urls).toEqual([`${ACME}/api/0/organizations/acme-org/`]); + expect(getEffectiveAccountRow()?.host).toBe(ACME); + }); + + test("stay on an explicitly configured server", async () => { + login("acme-token", ACME, "acme"); + seeOrg("acme-org", ACME); + login("saas-token", SAAS, "saas"); + seeOrgList(SAAS, ["saas-org"]); + process.env.SENTRY_URL = SAAS; + + await rawApiRequest("organizations/acme-org/"); + + expect(sent).toEqual(["Bearer saas-token"]); + expect(urls).toEqual([`${SAAS}/api/0/organizations/acme-org/`]); + }); + + test("only consider accounts on an absolute URL's server", async () => { + login("acme-token", ACME, "acme"); + seeOrg("acme-org", ACME); + login("saas-token", SAAS, "saas"); + seeOrgList(SAAS, ["saas-org"]); + + await rawApiRequest("organizations/acme-org/", { baseUrl: SAAS }); + expect(sent).toEqual(["Bearer saas-token"]); expect(getEffectiveAccountRow()?.host).toBe(SAAS); });