Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion apps/cli-docs/src/fragments/commands/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion packages/cli/src/lib/api/infrastructure.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
45 changes: 37 additions & 8 deletions packages/cli/src/lib/sentry-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -323,23 +325,37 @@ 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/<org>/...`, 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;
}
const { organization } = scope;
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
Expand Down Expand Up @@ -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/<org>/` 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<AuthenticatedFetchOptions, "bufferResponseBody"> = {},
options: Pick<AuthenticatedFetchOptions, "bufferResponseBody"> & {
/** 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);
}

/**
Expand Down
34 changes: 32 additions & 2 deletions packages/cli/test/lib/db/account-routing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -447,12 +447,16 @@ describe("organization-based selection", () => {
describe("requests whose path names an organization", () => {
let originalFetch: typeof globalThis.fetch;
let sent: Array<string | null>;
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({});
});
});
Expand Down Expand Up @@ -486,14 +490,40 @@ 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");
seeOrgList(SAAS, ["saas-org"]);

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);
});
Expand Down
Loading