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
3 changes: 2 additions & 1 deletion apps/cli-docs/src/fragments/commands/auth.md
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ that fits what they target, without changing the active account:
account's organization list (cached at login and by `sentry org list`,
which lists every stored account's organizations) but another stored
account has used it, that account is used. This includes `oNNNN` IDs from
DSNs.
DSNs, and the organization in a `sentry api` path such as
`organizations/acme-corp/issues/`, among accounts on that request's 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
49 changes: 9 additions & 40 deletions packages/cli/src/commands/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import { access, readFile } from "node:fs/promises";
// oxlint-disable-next-line sentry-cli/no-namespace-import -- Sentry SDK recommends namespace import
import * as Sentry from "@sentry/node";
import { parseApiPathScope } from "@sentry/api/client";
import type { SentryContext } from "../context.js";
import {
appendSearchParams,
Expand Down Expand Up @@ -262,23 +263,12 @@ function resolveApiTarget(endpoint: string): {
};
}

/**
* Org/project slugs or numeric IDs in API paths. Rejects schema placeholders
* (`{organization_id_or_slug}`) and empty/dot segments so we never tag junk.
*/
const API_PATH_SLUG_RE = /^[a-zA-Z0-9][a-zA-Z0-9._-]*$/;

function isApiPathSlug(segment: string | undefined): segment is string {
return segment !== undefined && API_PATH_SLUG_RE.test(segment);
}

/**
* Parse org (and project when present) from a Sentry API endpoint path.
*
* `sentry api` never goes through target resolve, so the slug lives only in
* the URL. `organizations/`, `projects/`, and `teams/` prefixes carry the
* org; `projects/` and `organizations/{org}/projects/{project}/` also carry
* a project when that segment is a real slug.
* the URL. The transport reads the same scope to pick the stored account
* that knows the org; see `parseApiPathScope` in `@sentry/api/client`.
*
* @param endpoint - Path relative to `/api/0/`, optionally with a query string
* or a leading `/api/0/` prefix
Expand All @@ -289,34 +279,13 @@ function isApiPathSlug(segment: string | undefined): segment is string {
export function parseOrgProjectFromApiPath(
endpoint: string,
): { org: string; project?: string } | undefined {
const path = endpoint.split("?", 1)[0] ?? "";
const segments = path.split("/").filter((segment) => segment.length > 0);

if (segments[0] === "api" && segments[1] === "0") {
segments.splice(0, 2);
}

if (segments[0] === "organizations" && isApiPathSlug(segments[1])) {
const org = segments[1];
if (segments[2] === "projects" && isApiPathSlug(segments[3])) {
return { org, project: segments[3] };
}
return { org };
}

if (segments[0] === "projects" && isApiPathSlug(segments[1])) {
const org = segments[1];
if (isApiPathSlug(segments[2])) {
return { org, project: segments[2] };
}
return { org };
}

if (segments[0] === "teams" && isApiPathSlug(segments[1])) {
return { org: segments[1] };
const scope = parseApiPathScope(endpoint);
if (!scope) {
return;
}

return;
return scope.project
? { org: scope.organization, project: scope.project }
: { org: scope.organization };
}

function tagOrgFromApiPath(endpoint: string): void {
Expand Down
25 changes: 19 additions & 6 deletions packages/cli/src/lib/db/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -996,8 +996,16 @@ function followDefaultAccountHost(host: string): void {
*
* An env token that knows the org wins over every stored account, the
* active one included (see {@link getEnvAccount}).
*
* `server` limits candidates (the env token included) to that server, for a
* request whose URL is already built: switching servers would send it to
* the wrong one.
*/
export function selectAccountForOrg(orgSlug: string, orgId?: string): void {
export function selectAccountForOrg(
orgSlug: string,
orgId?: string,
options: { server?: string } = {},
): void {
if (isEnvTokenForced()) {
return;
}
Expand All @@ -1006,13 +1014,13 @@ export function selectAccountForOrg(orgSlug: string, orgId?: string): void {
return;
}
try {
if (selectEnvAccountForOrg(orgSlug, orgId)) {
if (selectEnvAccountForOrg(orgSlug, orgId, options.server)) {
return;
}
const current = getAuthRow();
const pick = findAccountForOrg(orgSlug, orgId, {
current,
host: getExplicitServerUrl(),
host: getExplicitServerUrl() ?? options.server,
});
if (!pick?.host || pick.account_id === current?.account_id) {
return;
Expand Down Expand Up @@ -1079,16 +1087,21 @@ export function findAccountForOrg(
/**
* Use the env token for the rest of this invocation when its org cache
* shows it knows the org on its current server, and that server is the
* explicit one, if any. The
* env token wins over stored accounts that know the org too.
* explicit one and `server`, when set. The env token wins over stored
* accounts that know the org too.
*
* @returns whether the env token was chosen
*/
function selectEnvAccountForOrg(orgSlug: string, orgId?: string): boolean {
function selectEnvAccountForOrg(
orgSlug: string,
orgId: string | undefined,
server: string | undefined,
): boolean {
const envAccount = getEnvAccountInScope();
if (
!(
envAccount &&
(!server || isSameServer(envAccount.host, server)) &&
hasCachedOrgForIdentity(
envAccount.identity,
envAccount.host,
Expand Down
35 changes: 32 additions & 3 deletions packages/cli/src/lib/sentry-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { getTraceData } from "@sentry/node";
import {
computeInvalidationPrefixes,
createApiTransport,
type CredentialRequest,
extractUrlPath,
getValidatedResponseSource,
type ResponseBodyMode,
Expand Down Expand Up @@ -40,6 +41,7 @@ import {
getRemainingLifetimeRatio,
REFRESH_THRESHOLD,
refreshToken,
selectAccountForOrg,
} from "./db/auth.js";
import {
ApiError,
Expand Down Expand Up @@ -317,6 +319,27 @@ async function withHttpSpan(
);
}

/** Sentry org slugs are never entirely numeric, so digits are an org id. */
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.
*/
function selectAccountForRequest({ url, scope }: CredentialRequest): void {
if (!scope) {
return;
}
const { organization } = scope;
selectAccountForOrg(
organization,
NUMERIC_ORG_ID.test(organization) ? organization : undefined,
{ server: 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 All @@ -325,7 +348,10 @@ async function withHttpSpan(
function createCliTransport(credential: CredentialContext | undefined) {
return createApiTransport<CredentialContext>({
credentials: {
current: async () => {
current: async (request) => {
if (!credential) {
selectAccountForRequest(request);
}
const current = credential ?? getCredentialContext();
if (!current) {
await refreshToken();
Expand Down Expand Up @@ -532,12 +558,15 @@ export function getSdkConfig(

/**
* Get SDK config for the default API (control silo or self-hosted).
*
* 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"> = {},
) {
const credential = getCredentialContext();
return getSdkConfig(getApiBaseUrl(credential), { ...options, credential });
return getSdkConfig(getApiBaseUrl(), options);
}

/**
Expand Down
57 changes: 57 additions & 0 deletions packages/cli/test/lib/db/account-routing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { rawApiRequest } from "../../../src/lib/api/infrastructure.js";
import { markPersistedDefaultUrl } from "../../../src/lib/constants.js";
import {
getActiveAccountId,
Expand Down Expand Up @@ -44,6 +45,7 @@ import {
} from "../../../src/lib/sentryclirc.js";
import {
mintSntrysToken,
mockFetch,
resetHostScopingState,
useEnvSandbox,
useTestConfigDir,
Expand Down Expand Up @@ -442,6 +444,61 @@ describe("organization-based selection", () => {
});
});

describe("requests whose path names an organization", () => {
let originalFetch: typeof globalThis.fetch;
let sent: Array<string | null>;

beforeEach(() => {
originalFetch = globalThis.fetch;
sent = [];
globalThis.fetch = mockFetch(async (input, init) => {
sent.push(new Request(input, init).headers.get("Authorization"));
return Response.json({});
});
});

afterEach(() => {
globalThis.fetch = originalFetch;
});

test("use the account that knows the org, like `sentry api`", async () => {
login("work-token", SAAS, "work");
seeOrg("work-org", SAAS);
const personal = login("personal-token", SAAS, "personal");
seeOrgList(SAAS, ["personal-org"]);

await rawApiRequest("organizations/work-org/issues/");
await rawApiRequest("projects/personal-org/web/");

// One invocation never mixes accounts.
expect(sent).toEqual(["Bearer work-token", "Bearer work-token"]);
expect(getActiveAccountId()).toBe(personal);
});

test("leave unscoped requests on the active account", async () => {
login("work-token", SAAS, "work");
seeOrg("work-org", SAAS);
login("personal-token", SAAS, "personal");
seeOrgList(SAAS, ["personal-org"]);

await rawApiRequest("users/me/");

expect(sent).toEqual(["Bearer personal-token"]);
});

test("only consider accounts on the request'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/");

expect(sent).toEqual(["Bearer saas-token"]);
expect(getEffectiveAccountRow()?.host).toBe(SAAS);
});
});

describe("the env token as an account", () => {
// The env token's server is snapshotted once per env: start each case
// from a fresh boot.
Expand Down
16 changes: 8 additions & 8 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ catalog:
"@modelcontextprotocol/server": 2.4.0
"@radix-ui/react-accordion": ^1.2.20
"@radix-ui/react-slot": ^1.2.3
"@sentry/api": 1.20.0
"@sentry/api": 1.26.0
"@sentry/cloudflare": 11.6.0
"@sentry/core": 11.6.0
"@sentry/node": 11.6.0
Expand Down Expand Up @@ -167,7 +167,7 @@ minimumReleaseAgeExclude:
- "@oxlint/binding-win32-x64-msvc@1.87.0"
- oxfmt@0.72.0
- oxlint@1.87.0
- "@sentry/api@1.20.0"
- "@sentry/api@1.26.0"
- miniflare@5.20261101.0-beta
- wrangler@4.156.1
- "@sentry/symbolic@14.0.0-alpha.5"
Expand Down
Loading