From 1eaacd72e5b51e08031afc8d24631f29f9a340f6 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:04:46 +0000 Subject: [PATCH 1/7] feat(cli): Treat SENTRY_AUTH_TOKEN as an account next to stored logins Closes #1553. An env token whose server is known from a trusted source (its sntrys_ claim, SENTRY_HOST/SENTRY_URL from the boot env, or SaaS) now acts as one more, never stored, read-only account instead of being all-or-nothing: - Org routing uses it for organizations it knows, ahead of stored accounts (the active one included), and server-based selection uses it when no stored account is on the configured server. - `sentry org list` lists its orgs under `env:SENTRY_AUTH_TOKEN`, labeled with its `/auth/` user when available; `--account env:SENTRY_AUTH_TOKEN` selects it. - `sentry auth list` and `sentry auth status` show it with its server and user. `sentry auth switch` can't select it. Tokens set after boot (a `.sentryclirc` token) keep today's fallback-only behavior, and SENTRY_FORCE_ENV_TOKEN still means "use only the env token". Co-Authored-By: byk --- apps/cli-docs/src/fragments/commands/auth.md | 16 +- .../skills/sentry-cli/references/org.md | 2 +- packages/cli/src/commands/auth/list.ts | 23 ++- packages/cli/src/commands/auth/status.ts | 41 +++- packages/cli/src/commands/org/list.ts | 94 +++++++-- packages/cli/src/lib/db/account-scope.ts | 6 + packages/cli/src/lib/db/accounts.ts | 70 ++++++- packages/cli/src/lib/db/auth.ts | 169 +++++++++++++++- packages/cli/src/lib/env-account.ts | 45 +++++ packages/cli/src/lib/env-registry.ts | 4 +- packages/cli/src/lib/env-token-host.ts | 31 ++- packages/cli/src/lib/formatters/accounts.ts | 26 ++- packages/cli/src/lib/formatters/human.ts | 22 ++- .../cli/test/commands/auth/status.test.ts | 37 ++++ .../cli/test/commands/auth/switch.test.ts | 91 ++++++++- packages/cli/test/commands/org/list.test.ts | 170 +++++++++++++++- .../cli/test/lib/db/account-routing.test.ts | 185 +++++++++++++++++- 17 files changed, 978 insertions(+), 54 deletions(-) create mode 100644 packages/cli/src/lib/env-account.ts diff --git a/apps/cli-docs/src/fragments/commands/auth.md b/apps/cli-docs/src/fragments/commands/auth.md index e761524dd..9232946dd 100644 --- a/apps/cli-docs/src/fragments/commands/auth.md +++ b/apps/cli-docs/src/fragments/commands/auth.md @@ -107,6 +107,16 @@ that fits what they target, without changing the active account: - **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. +- **Environment token** — `SENTRY_AUTH_TOKEN` (or `SENTRY_TOKEN`) acts as one + more account, never stored, when its server is known: an organization auth + token's own URL, else `SENTRY_HOST`/`SENTRY_URL` from the environment, else + sentry.io (never `.sentryclirc` or a pasted URL). `sentry org list` lists its + organizations under `env:SENTRY_AUTH_TOKEN` and caches them; commands + targeting one of them then use the env token, even when a stored account + knows the organization too. It also serves its + server when no stored account is on it. `sentry auth list` and + `sentry auth status` show it with its user when the server reports one. It + can't be selected with `sentry auth switch`. ```bash # Active account is personal; this uses the work account that knows acme-corp @@ -204,8 +214,10 @@ By default, the CLI checks for auth tokens in the following order: 3. `SENTRY_TOKEN` environment variable (legacy alias) The stored credential takes priority. Stored OAuth credentials support -automatic refresh; manually provided API tokens do not use a refresh token. To -override this precedence and force environment tokens to win, set +automatic refresh; manually provided API tokens do not use a refresh token. +An environment token with a known server is still used for the organizations it +can access and for its own server (see account selection above). To override +this precedence and use only the environment token, set `SENTRY_FORCE_ENV_TOKEN=1`. When a token comes from an environment variable, the CLI skips expiry checks and automatic refresh. diff --git a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/org.md b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/org.md index d9a4205a7..fc4ed42e9 100644 --- a/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/org.md +++ b/packages/cli/plugins/sentry-cli/skills/sentry-cli/references/org.md @@ -18,7 +18,7 @@ List organizations **Flags:** - `-n, --limit - Maximum number of organizations to list - (default: "25")` - `-f, --fresh - Bypass cache, re-detect projects, and fetch fresh data` -- `-a, --account - Only list organizations of this stored account (email, username, or account id; : for one server)` +- `-a, --account - Only list organizations of this account (email, username, or account id; : for one server; env:SENTRY_AUTH_TOKEN)` ### `sentry org view []` diff --git a/packages/cli/src/commands/auth/list.ts b/packages/cli/src/commands/auth/list.ts index 9c007d016..66cd92871 100644 --- a/packages/cli/src/commands/auth/list.ts +++ b/packages/cli/src/commands/auth/list.ts @@ -7,7 +7,12 @@ import type { SentryContext } from "../../context.js"; import { buildCommand } from "../../lib/command.js"; import { listAccounts } from "../../lib/db/accounts.js"; -import { getActiveEnvVarName, getAuthConfig } from "../../lib/db/auth.js"; +import { + getActiveEnvVarName, + getAuthConfig, + getEnvAccount, +} from "../../lib/db/auth.js"; +import { describeEnvAccount } from "../../lib/env-account.js"; import { type AccountListResult, formatAccountList, @@ -22,7 +27,11 @@ export const listCommand = buildCommand({ fullDescription: "List every Sentry account stored by 'sentry auth login', across servers.\n\n" + "The active account (marked with *) is used for commands. Log in again to add\n" + - "another account, and use 'sentry auth switch' to change the active one.", + "another account, and use 'sentry auth switch' to change the active one.\n\n" + + "SENTRY_AUTH_TOKEN (or SENTRY_TOKEN) is listed too when its server is known\n" + + "(SENTRY_HOST/SENTRY_URL from the environment, or sentry.io), with the user\n" + + "it belongs to when the server says. It is never stored and can't be\n" + + "switched to: commands targeting an organization it can access use it.", }, output: { human: formatAccountList, @@ -32,7 +41,11 @@ export const listCommand = buildCommand({ flags: {}, }, async *func(this: SentryContext) { - const accounts = listAccounts(); + const stored = listAccounts(); + const envAccount = getEnvAccount(); + const accounts = envAccount + ? [...stored, await describeEnvAccount(envAccount)] + : stored; yield new CommandOutput({ accounts }); if (getAuthConfig()?.source.startsWith("env:")) { @@ -40,10 +53,10 @@ export const listCommand = buildCommand({ hint: `${getActiveEnvVarName()} is in use and overrides stored accounts.`, }; } - if (accounts.length > 1) { + if (stored.length > 1) { return { hint: "Switch accounts with 'sentry auth switch '." }; } - if (accounts.length === 0) { + if (stored.length === 0) { return { hint: "Log in with 'sentry auth login'." }; } return { hint: "Add another account with 'sentry auth login'." }; diff --git a/packages/cli/src/commands/auth/status.ts b/packages/cli/src/commands/auth/status.ts index 83538aefc..cea9a17c5 100644 --- a/packages/cli/src/commands/auth/status.ts +++ b/packages/cli/src/commands/auth/status.ts @@ -15,9 +15,11 @@ import { getActiveEnvVarName, getAuthConfig, getAuthToken, + getEnvAccount, getRawEnvToken, isAuthenticated, } from "../../lib/db/auth.js"; +import { fetchEnvAccountUser } from "../../lib/env-account.js"; import { listAccounts } from "../../lib/db/accounts.js"; import { getDefaultOrganization, @@ -93,6 +95,14 @@ export type AuthStatusData = { envVar: string; /** Whether the env token is the effective auth source (vs bypassed for OAuth) */ active: boolean; + /** + * Server the token is scoped to, when known from a trusted source. Then + * the token also acts as an account of its own: commands targeting an + * organization it can access use it even when stored OAuth is in effect. + */ + host?: string; + /** The token's user, from `/auth/` (not for organization auth tokens) */ + user?: { name?: string; email?: string; username?: string }; }; }; @@ -154,6 +164,35 @@ async function verifyCredentials(): Promise { } } +/** + * Collect the env token's status, server, and user. The user is fetched + * from its own server only when that server is known from a trusted source. + */ +async function collectEnvToken( + active: boolean, +): Promise> { + const envVar = getActiveEnvVarName(); + const envAccount = getEnvAccount(); + if (!envAccount) { + return { envVar, active }; + } + const user = await fetchEnvAccountUser(envAccount); + return { + envVar, + active, + host: envAccount.host, + ...(user + ? { + user: { + name: user.name ?? undefined, + email: user.email ?? undefined, + username: user.username ?? undefined, + }, + } + : {}), + }; +} + export const statusCommand = buildCommand({ auth: false, docs: { @@ -207,7 +246,7 @@ export const statusCommand = buildCommand({ // (it may be set but bypassed because stored OAuth takes priority) const rawEnv = getRawEnvToken(); const envTokenData: AuthStatusData["envToken"] = rawEnv - ? { envVar: getActiveEnvVarName(), active: fromEnv } + ? await collectEnvToken(fromEnv) : undefined; const data: AuthStatusData = { diff --git a/packages/cli/src/commands/org/list.ts b/packages/cli/src/commands/org/list.ts index 7e66b133b..b88bf7c58 100644 --- a/packages/cli/src/commands/org/list.ts +++ b/packages/cli/src/commands/org/list.ts @@ -16,11 +16,16 @@ import { accountServerOf, serverKey, toAccountSummary, + toEnvAccountSummary, } from "../../lib/db/accounts.js"; import { + getEnvAccount, + getEnvAccountInScope, listUsableAccountsInScope, + withEnvAccount, withStoredAccount, } from "../../lib/db/auth.js"; +import { fetchEnvAccountUser } from "../../lib/env-account.js"; import { getAllOrgRegions } from "../../lib/db/regions.js"; import { ApiError, AuthError } from "../../lib/errors.js"; import { formatAccountLabel } from "../../lib/formatters/accounts.js"; @@ -47,11 +52,15 @@ type ListFlags = { readonly fields?: string[]; }; -/** The stored account an organization was listed under (no secrets). */ +/** + * The account an organization was listed under (no secrets): a stored + * account, or the env token's virtual account. + */ type OrgListAccount = { /** - * What `--account` and `sentry auth login` accept: `:`, e.g. - * `jane@acme.com:sentry.io` (just `` when no server is recorded). + * What `--account` accepts: `:`, e.g. + * `jane@acme.com:sentry.io` (just `` when no server is recorded), as + * `sentry auth login` does too; `env:SENTRY_AUTH_TOKEN` for the env token. */ name: string; id: string; @@ -66,6 +75,8 @@ type OrgListAccount = { host?: string; /** Human label, e.g. `Jane on sentry.io`. */ label: string; + /** The variable providing the token, for the env token's account. */ + envVar?: string; }; /** @@ -186,6 +197,7 @@ function toOrgListAccount(account: AccountSummary): OrgListAccount { username: account.username, server: accountServerOf(account), host: account.host, + envVar: account.envVar, }; const entry: OrgListAccount = { name: accountSelectorOf(account), @@ -206,8 +218,36 @@ async function fetchOrgs(account?: AccountSummary): Promise { return { account, orgs, regions: getAllOrgRegions() }; } +/** + * Fetch the env token's orgs with only the env token, on its own server, + * and ask the server whose token it is (best-effort) to label it. + */ +async function fetchEnvAccountOrgs( + account: AccountSummary, +): Promise { + const envAccount = getEnvAccount(); + if (!envAccount) { + throw new AuthError( + "not_authenticated", + `${account.envVar ?? "The env token"} is no longer usable.`, + { skipAutoAuth: true }, + ); + } + const [fetched, user] = await Promise.all([ + withEnvAccount(envAccount, () => fetchOrgs()), + fetchEnvAccountUser(envAccount), + ]); + return { + ...fetched, + account: toEnvAccountSummary(envAccount.envVar, envAccount.host, user), + }; +} + /** Fetch `account`'s orgs with its own credentials, on its own server. */ async function fetchAccountOrgs(account: AccountSummary): Promise { + if (account.envVar) { + return await fetchEnvAccountOrgs(account); + } const host = account.host; if (!host) { throw new AuthError( @@ -256,7 +296,8 @@ async function fetchOrgsAcrossAccounts( /** * Flatten per-account results into list entries. An org reachable from * several accounts on the same server is listed once, under the first of - * them (the active account, else the most recently used). + * them (the env token, which org routing prefers, then the active + * account, then the most recently used). */ function mergeAccountOrgs(results: AccountOrgs[]): { orgs: SentryOrganization[]; @@ -300,15 +341,27 @@ function reloginCommand(account: AccountSummary): string { } /** - * Which stored accounts to list: the one named by `--account`, else every - * usable account in scope. `undefined` means "the credentials in effect" - * (no usable stored account, only the active one, or a forced env token), - * which keeps the single-account output unchanged. A single usable account - * that isn't the active one (say, the active login was logged out) is - * listed with its own credentials. + * Which accounts to list: the one named by `--account`, else the env + * token's virtual account (when its server is trusted, see + * `getEnvAccount`) and every usable stored account in scope. `undefined` + * means "the credentials in effect" (no usable account, only the active + * one or only the env token, or a forced env token), which keeps the + * single-account output unchanged. A single usable account that isn't the + * active one (say, the active login was logged out) is listed with its own + * credentials. */ function resolveAccounts(selector?: string): AccountSummary[] | undefined { if (selector !== undefined) { + const envAccount = getEnvAccount(); + const envSummary = envAccount + ? toEnvAccountSummary(envAccount.envVar, envAccount.host) + : undefined; + if ( + envSummary && + selector.trim().toLowerCase() === envSummary.id.toLowerCase() + ) { + return [envSummary]; + } const account = resolveAccountSelector( selector, "sentry org list --account", @@ -325,13 +378,19 @@ function resolveAccounts(selector?: string): AccountSummary[] | undefined { } return [account]; } - // The active account first, then most recently used: its orgs lead the - // list, and an org it shares with another account is listed under it. - const accounts = listUsableAccountsInScope() + // The env token, then the active account, then most recently used: an + // org several of them share is listed under the one commands use for it. + const stored = listUsableAccountsInScope() .map(toAccountSummary) .sort((a, b) => Number(b.active) - Number(a.active)); + const envAccount = getEnvAccountInScope(); + const accounts = envAccount + ? [toEnvAccountSummary(envAccount.envVar, envAccount.host), ...stored] + : stored; const [only] = accounts; - return accounts.length > 1 || (only && !only.active) ? accounts : undefined; + return accounts.length > 1 || (only && !only.active && !only.envVar) + ? accounts + : undefined; } export const listCommand = buildCommand({ @@ -348,6 +407,11 @@ export const listCommand = buildCommand({ "list to accounts on that server; --account limits it to one account\n" + "(an email, username, or account id; add : to pick one server's\n" + "login, e.g. jane@example.com:sentry.example.com, as shown in ACCOUNT).\n\n" + + "SENTRY_AUTH_TOKEN (or SENTRY_TOKEN) is listed as one more account,\n" + + "env:SENTRY_AUTH_TOKEN, when its server is known: SENTRY_HOST/SENTRY_URL\n" + + "from the environment, or sentry.io when neither is set. Organizations it\n" + + "can access are listed under it, and commands targeting them use it.\n" + + "With SENTRY_FORCE_ENV_TOKEN=1, only the env token is used.\n\n" + "Pass a filter to only list organizations whose slug or name matches it,\n" + "ignoring case: exactly, as a prefix or substring, or with a small typo\n" + "(best matches first), or as a glob like 'acme-*'.\n\n" + @@ -370,7 +434,7 @@ export const listCommand = buildCommand({ kind: "parsed", parse: String, brief: - "Only list organizations of this stored account (email, username, or account id; : for one server)", + "Only list organizations of this account (email, username, or account id; : for one server; env:SENTRY_AUTH_TOKEN)", optional: true, }, }, diff --git a/packages/cli/src/lib/db/account-scope.ts b/packages/cli/src/lib/db/account-scope.ts index 75cd439d4..237dccc91 100644 --- a/packages/cli/src/lib/db/account-scope.ts +++ b/packages/cli/src/lib/db/account-scope.ts @@ -33,6 +33,12 @@ import { getEnv } from "../env.js"; type AccountScope = { /** Default account for the rest of this invocation, chosen from its target. */ accountId?: string; + /** + * The env token (`SENTRY_AUTH_TOKEN`/`SENTRY_TOKEN`) was chosen for the + * rest of this invocation instead of a stored account, because it knows + * the targeted organization. Exclusive with `accountId`. + */ + envToken?: boolean; }; /** One scope per invocation env; see the module comment for why. */ diff --git a/packages/cli/src/lib/db/accounts.ts b/packages/cli/src/lib/db/accounts.ts index 19e427da2..7a7a3076f 100644 --- a/packages/cli/src/lib/db/accounts.ts +++ b/packages/cli/src/lib/db/accounts.ts @@ -46,8 +46,45 @@ export type AccountSummary = { /** Whether the stored credential can still be used (or refreshed). */ usable: boolean; expiresAt: number | undefined; + /** + * Set for the virtual account of an env token (`SENTRY_AUTH_TOKEN` or + * `SENTRY_TOKEN`), which is never stored: the variable providing it. + */ + envVar?: string; }; +/** Prefix of an env token's virtual account id, e.g. `env:SENTRY_AUTH_TOKEN`. */ +export const ENV_ACCOUNT_ID_PREFIX = "env:"; + +/** + * Secret-free summary of an env token's virtual account on `host`, with the + * token's user when known (from `/auth/`). Never active: `sentry auth + * switch` only selects stored accounts. + */ +export function toEnvAccountSummary( + envVar: string, + host: string, + user?: { + id?: string; + email?: string | null; + username?: string | null; + name?: string | null; + }, +): AccountSummary { + return { + id: `${ENV_ACCOUNT_ID_PREFIX}${envVar}`, + active: false, + host, + userId: user?.id || undefined, + email: user?.email || undefined, + username: user?.username || undefined, + name: user?.name || undefined, + usable: true, + expiresAt: undefined, + envVar, + }; +} + /** Read the active account row. Returns `undefined` when none is active. */ export function getActiveAccountRow(): AccountRow | undefined { const db = getDatabase(); @@ -217,9 +254,13 @@ export function splitAccountSelector( * The `:` selector naming an account, e.g. * `jane@acme.com:sentry.acme.com`: what `--account` and * `sentry auth login` accept. Uses the email, else the username, else the - * account id; the server part is omitted when no host is recorded. + * account id; the server part is omitted when no host is recorded. An env + * token's account is named by its id (`env:SENTRY_AUTH_TOKEN`). */ export function accountSelectorOf(account: AccountSummary): string { + if (account.envVar) { + return account.id; + } const who = account.email ?? account.username ?? account.id; const server = accountServerOf(account); return server ? `${who}:${server}` : who; @@ -467,3 +508,30 @@ export function hasCachedOrgList(accountId: string): boolean { | undefined; return Boolean(row); } + +/** + * Whether the org cache shows `identity` (a credential fingerprint, see + * `getIdentityFingerprint`) has seen an organization, by slug or numeric + * id. Mirrors {@link listUsableAccountRowsForOrg} for a credential that is + * not a stored account: the env token. + */ +export function hasCachedOrgForIdentity( + identity: string, + orgSlug: string, + orgId?: string, +): boolean { + const id = orgId ?? null; + const row = getDatabase() + .query( + `SELECT 1 AS found FROM org_regions r + WHERE r.credential_identity = ? AND ( + r.org_slug = ? OR r.org_id = ? OR EXISTS ( + SELECT 1 FROM org_regions m + WHERE m.org_id = ? AND m.org_slug = r.org_slug + AND m.source_origin = r.source_origin + ) + ) LIMIT 1`, + ) + .get(identity, orgSlug, id, id) as { found: number } | null | undefined; + return Boolean(row); +} diff --git a/packages/cli/src/lib/db/auth.ts b/packages/cli/src/lib/db/auth.ts index 06e0682c4..9daa39d81 100644 --- a/packages/cli/src/lib/db/auth.ts +++ b/packages/cli/src/lib/db/auth.ts @@ -13,6 +13,13 @@ * `--url`, a pasted Sentry URL, or `.sentryclirc`) when the active * account is on a different one. * 3. The active account (`sentry auth login` / `sentry auth switch`). + * + * An env token (`SENTRY_AUTH_TOKEN`/`SENTRY_TOKEN`) whose server is known + * from a trusted source acts as one more, never stored, account (see + * {@link getEnvAccount}): it wins step 1 for organizations it knows, and + * step 2 when no stored account is on the configured server. Otherwise it + * is the fallback when no stored credential is usable, and with + * `SENTRY_FORCE_ENV_TOKEN` it is the only credential used. */ import { normalizeAuthToken, trimAuthToken } from "../auth-header.js"; @@ -27,6 +34,7 @@ import { getEnv, withEnv } from "../env.js"; import { getBootConfiguredSentryUrl, getEnvTokenHost, + getTrustedEnvTokenHost, } from "../env-token-host.js"; import { AuthError, ConfigError } from "../errors.js"; import { logger } from "../logger.js"; @@ -38,9 +46,11 @@ import { activateAccountRow, deleteAccountOrgRegions, deleteAccountRows, + ENV_ACCOUNT_ID_PREFIX, getAccountRow, getActiveAccountRow, getMostRecentAccountRow, + hasCachedOrgForIdentity, hasCachedOrgList, insertActiveAccountRow, isAccountOnServer, @@ -108,9 +118,11 @@ function findUsableAccountOnServer(url: string): AuthRow | undefined { /** * Prefer an account on the configured server when the active account is - * on another one. Without a configured server, or without a stored - * account for it, the active account stays in effect (and the host trust - * checks report the mismatch exactly as before). + * on another one, else the env token when it is scoped to that server + * (`undefined`: no stored row, so reads fall back to the env token). + * Without a configured server, or without a credential for it, the active + * account stays in effect (and the host trust checks report the mismatch + * exactly as before). */ function selectAccountForConfiguredHost( active: AuthRow | undefined, @@ -123,7 +135,17 @@ function selectAccountForConfiguredHost( if (active && (!active.host || isAccountOnServer(active, configured))) { return active; } - return findUsableAccountOnServer(configured) ?? active; + const onServer = findUsableAccountOnServer(configured); + if (onServer) { + return onServer; + } + // The env token is scoped to the configured server and no stored + // account is: no stored row, so credential reads fall back to it. + const envAccount = getEnvAccount(); + if (envAccount && isSameServer(envAccount.host, configured)) { + return; + } + return active; } /** @@ -132,7 +154,11 @@ function selectAccountForConfiguredHost( * Account management (switch, logout) uses the stored active row directly. */ function getAuthRow(): AuthRow | undefined { - const defaultAccountId = getAccountScope().accountId; + const scope = getAccountScope(); + if (scope.envToken) { + return; + } + const defaultAccountId = scope.accountId; if (defaultAccountId) { const row = getAccountRow(defaultAccountId); if (row) { @@ -152,7 +178,8 @@ export function getEffectiveAccountRow(): AccountRow | undefined { * invocation's default account and configured server, so the memo must too. */ function selectionKey(): string { - return `${getAccountScope().accountId ?? ""}\0${getConfiguredUrlOrUndefined() ?? ""}`; + const scope = getAccountScope(); + return `${scope.envToken ? ENV_ACCOUNT_ID_PREFIX : (scope.accountId ?? "")}\0${getConfiguredUrlOrUndefined() ?? ""}`; } /** @@ -285,6 +312,56 @@ export function getActiveEnvVarName(): string { : "SENTRY_AUTH_TOKEN"; } +/** Whether `SENTRY_FORCE_ENV_TOKEN` makes a set env token the only credential. */ +export function isEnvTokenForced(): boolean { + return Boolean(getEnv().SENTRY_FORCE_ENV_TOKEN?.trim() && getEnvToken()); +} + +/** + * The env token as a virtual account: never stored, read-only, and only + * ever sent to its own server. + */ +export type EnvAccount = Readonly<{ + /** The variable providing the token. */ + envVar: "SENTRY_AUTH_TOKEN" | "SENTRY_TOKEN"; + /** Origin of the server the token is scoped to. */ + host: string; + /** Credential fingerprint (see {@link getIdentityFingerprint}). */ + identity: string; +}>; + +/** + * The env token's virtual account, when its server is known from a trusted + * source: its `sntrys_` claim, `SENTRY_HOST`/`SENTRY_URL` from the boot + * env, or SaaS when neither is set (see `getTrustedEnvTokenHost`). Never + * from `.sentryclirc` or a URL argument. `undefined` without an env token + * or a trusted server; the env token then keeps its fallback role only. + */ +export function getEnvAccount(): EnvAccount | undefined { + const envToken = getEnvToken(); + if (!envToken) { + return; + } + let host: string | undefined; + try { + host = normalizeHttpOrigin(getTrustedEnvTokenHost()); + } catch (error) { + log.debug("Could not resolve the env token's server", error); + return; + } + if (!host) { + return; + } + return { + envVar: + envToken.source === "env:SENTRY_TOKEN" + ? "SENTRY_TOKEN" + : "SENTRY_AUTH_TOKEN", + host, + identity: hashIdentity("env", envToken.token), + }; +} + export function getAuthConfig(): AuthConfig | undefined { // When SENTRY_FORCE_ENV_TOKEN is set, check env first (old behavior). // Otherwise, check the DB first — stored OAuth takes priority over env tokens. @@ -587,7 +664,9 @@ export function setAuthToken( // A login, not a refresh: it targets the stored active account (or a // new one), never the account this invocation picked for its target // org, and the new credentials are what the rest of the invocation uses. - getAccountScope().accountId = undefined; + const scope = getAccountScope(); + scope.accountId = undefined; + scope.envToken = undefined; } const accountId = withDbSpan("setAuthToken", () => { const db = getDatabase(); @@ -885,16 +964,22 @@ function followDefaultAccountHost(host: string): void { * never mixes identities. An explicit server (`SENTRY_HOST`/`SENTRY_URL`, * `--url`, `.sentryclirc`) limits candidates to accounts on it; when several * accounts qualify, the most recently used wins. Forced env tokens (`SENTRY_FORCE_ENV_TOKEN`) are never overridden. + * + * An env token that knows the org wins over every stored account, the + * active one included (see {@link getEnvAccount}). */ export function selectAccountForOrg(orgSlug: string, orgId?: string): void { - if (getEnv().SENTRY_FORCE_ENV_TOKEN?.trim() && getRawEnvToken()) { + if (isEnvTokenForced()) { return; } const scope = getAccountScope(); - if (scope.accountId) { + if (scope.accountId || scope.envToken) { return; } try { + if (selectEnvAccountForOrg(orgSlug, orgId)) { + return; + } // Usable accounts that know the org, most recently used first. const owners = listUsableAccountRowsForOrg(orgSlug, orgId); if (owners.length === 0) { @@ -936,6 +1021,49 @@ export function selectAccountForOrg(orgSlug: string, orgId?: string): void { } } +/** + * Use the env token for the rest of this invocation when its org cache + * shows it knows the org and it is on the explicit server, if any. 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 { + const envAccount = getEnvAccountInScope(); + if ( + !( + envAccount && hasCachedOrgForIdentity(envAccount.identity, orgSlug, orgId) + ) + ) { + return false; + } + getAccountScope().envToken = true; + followDefaultAccountHost(envAccount.host); + log.debug( + `Using ${envAccount.envVar} (${envAccount.host}) for organization '${orgSlug}'`, + ); + return true; +} + +/** + * The env token's virtual account when cross-account commands and org + * routing may use it next to stored accounts: not when a forced env token + * (`SENTRY_FORCE_ENV_TOKEN`) is already the only credential, and only on + * the explicit server (`SENTRY_HOST`/`SENTRY_URL`, `--url`, `.sentryclirc`) + * when one is set — the same rule as {@link listUsableAccountsInScope}. + */ +export function getEnvAccountInScope(): EnvAccount | undefined { + if (isEnvTokenForced()) { + return; + } + const envAccount = getEnvAccount(); + const explicitHost = getExplicitServerUrl(); + return envAccount && + (!explicitHost || isSameServer(envAccount.host, explicitHost)) + ? envAccount + : undefined; +} + /** A stored account a command can run as: its id and server. */ export type StoredAccountRef = Readonly<{ id: string; host: string }>; @@ -947,7 +1075,7 @@ export type StoredAccountRef = Readonly<{ id: string; host: string }>; * (`SENTRY_FORCE_ENV_TOKEN`) overrides stored accounts. */ export function listUsableAccountsInScope(): AccountRow[] { - if (getEnv().SENTRY_FORCE_ENV_TOKEN?.trim() && getRawEnvToken()) { + if (isEnvTokenForced()) { return []; } const explicitHost = getExplicitServerUrl(); @@ -989,6 +1117,27 @@ export function withStoredAccount( }); } +/** + * Run `fn` with only the env token, on its own server: the counterpart of + * {@link withStoredAccount} for the env token's virtual account. + * + * `fn` runs under a copy of the invocation env with the token forced + * (`SENTRY_FORCE_ENV_TOKEN`), so no stored credential is read or sent, and + * the URL pinned to `account.host`, the server the token is scoped to. + * Pinning also keeps the copy's env-token host snapshot on that server, + * whatever `.sentryclirc` or a pasted URL set in the invocation env. + */ +export function withEnvAccount( + account: EnvAccount, + fn: () => Promise, +): Promise { + const env: NodeJS.ProcessEnv = { ...getEnv() }; + delete env.SENTRY_HOST; + env.SENTRY_URL = account.host; + env.SENTRY_FORCE_ENV_TOKEN = "1"; + return withEnv(env, fn); +} + /** * The stored account whose credentials hash to `identity` (following * in-process refresh-token rotations), so cached org data can record which diff --git a/packages/cli/src/lib/env-account.ts b/packages/cli/src/lib/env-account.ts new file mode 100644 index 000000000..80f2fa7b3 --- /dev/null +++ b/packages/cli/src/lib/env-account.ts @@ -0,0 +1,45 @@ +/** + * Display helpers for the env token's virtual account. + * + * `SENTRY_AUTH_TOKEN`/`SENTRY_TOKEN` with a trusted server acts as one + * more account next to stored logins (see `getEnvAccount` in `db/auth.ts`). + * It is never stored, so nothing records whose token it is; commands that + * list accounts ask the server (`/auth/`) when they show it. + */ + +import { getCurrentUser } from "./api-client.js"; +import { type AccountSummary, toEnvAccountSummary } from "./db/accounts.js"; +import { type EnvAccount, getRawEnvToken, withEnvAccount } from "./db/auth.js"; +import { logger } from "./logger.js"; +import { classifySentryToken } from "./token-type.js"; +import type { SentryUser } from "../types/index.js"; + +const log = logger.withTag("auth.env-account"); + +/** + * The user behind the env token, from `/auth/` on its own server. + * `undefined` for organization auth tokens (`sntrys_`), which belong to no + * user, and when the request fails: identifying the token is best-effort. + */ +export async function fetchEnvAccountUser( + account: EnvAccount, +): Promise { + const token = getRawEnvToken(); + if (!token || classifySentryToken(token) === "org-auth-token") { + return; + } + try { + return await withEnvAccount(account, () => getCurrentUser()); + } catch (error) { + log.debug(`Could not identify the ${account.envVar} user`, error); + return; + } +} + +/** The env token's account summary, with its user when the server says. */ +export async function describeEnvAccount( + account: EnvAccount, +): Promise { + const user = await fetchEnvAccountUser(account); + return toEnvAccountSummary(account.envVar, account.host, user); +} diff --git a/packages/cli/src/lib/env-registry.ts b/packages/cli/src/lib/env-registry.ts index 2a0ff95f0..587d8161d 100644 --- a/packages/cli/src/lib/env-registry.ts +++ b/packages/cli/src/lib/env-registry.ts @@ -44,7 +44,7 @@ export const ENV_VAR_REGISTRY: readonly EnvVarEntry[] = [ { name: "SENTRY_AUTH_TOKEN", description: - "Authentication token for the Sentry API. This is the primary way to authenticate in CI/CD pipelines and scripts where interactive login is not possible.\n\nYou can create auth tokens in your [Sentry account settings](https://sentry.io/settings/account/api/auth-tokens/). When a stored OAuth login from `sentry auth login` also exists, the stored login takes priority — set `SENTRY_FORCE_ENV_TOKEN=1` to override.", + "Authentication token for the Sentry API. This is the primary way to authenticate in CI/CD pipelines and scripts where interactive login is not possible.\n\nYou can create auth tokens in your [Sentry account settings](https://sentry.io/settings/account/api/auth-tokens/). When a stored OAuth login from `sentry auth login` also exists, the stored login is used by default and the env token acts as one more account: commands targeting an organization it can access (as listed by `sentry org list`) use it, and so does its own server when no stored login is on it. That requires its server to be known: `SENTRY_HOST`/`SENTRY_URL` from the environment, or sentry.io when neither is set. Set `SENTRY_FORCE_ENV_TOKEN=1` to use only the env token.", example: "sntrys_YOUR_TOKEN_HERE", topLevel: true, briefDescription: "Auth token used for API requests (CI, scripts).", @@ -59,7 +59,7 @@ export const ENV_VAR_REGISTRY: readonly EnvVarEntry[] = [ { name: "SENTRY_FORCE_ENV_TOKEN", description: - "When set, environment variable tokens (`SENTRY_AUTH_TOKEN` / `SENTRY_TOKEN`) take precedence over the stored OAuth token from `sentry auth login`. By default, the stored OAuth token takes priority because it supports automatic refresh. Set this if you want to ensure the environment variable token is always used, which is useful for self-hosted setups or CI environments.", + "When set, environment variable tokens (`SENTRY_AUTH_TOKEN` / `SENTRY_TOKEN`) are the only credential used, overriding the stored OAuth token from `sentry auth login` and account selection. By default, the stored OAuth token takes priority because it supports automatic refresh, and the env token is only used for organizations it can access and for its own server. Set this if you want to ensure the environment variable token is always used, which is useful for self-hosted setups or CI environments.", example: "1", topLevel: true, briefDescription: "Prefer the env-var token over a stored OAuth login.", diff --git a/packages/cli/src/lib/env-token-host.ts b/packages/cli/src/lib/env-token-host.ts index ef655404a..aff01fe6b 100644 --- a/packages/cli/src/lib/env-token-host.ts +++ b/packages/cli/src/lib/env-token-host.ts @@ -44,6 +44,12 @@ type HostSnapshot = { host: string; configuredHost: string | null; claimError?: ConfigError; + /** + * The env token present when the snapshot was taken. A token set later + * (the `.sentryclirc` shim copies `[auth] token` into `SENTRY_AUTH_TOKEN`) + * was not scoped by this snapshot. + */ + token?: string; }; const EXPLICIT_SCHEME_RE = /^([a-z][a-z\d+.-]*):\/\//i; @@ -123,12 +129,14 @@ export function captureEnvTokenHost(): void { env.SENTRY_HOST?.trim() || env.SENTRY_URL?.trim(), env.SENTRY_HOST?.trim() ? "SENTRY_HOST" : "SENTRY_URL", ) ?? null; + const token = getRawEnvToken(); // Claim first: for sntrys_ tokens, the embedded url is authoritative. - const claim = captureClaimHost(getRawEnvToken()); + const claim = captureClaimHost(token); snapshotState.byEnv.set(env, { configuredHost, host: claim.host ?? configuredHost ?? DEFAULT_SENTRY_URL, ...(claim.error ? { claimError: claim.error } : {}), + ...(token ? { token } : {}), }); } @@ -149,6 +157,27 @@ export function getEnvTokenHost(): string { return snapshot?.host ?? DEFAULT_SENTRY_URL; } +/** + * The env token's host when it is known from a trusted source: the token's + * `sntrys_` claim, or `SENTRY_HOST`/`SENTRY_URL` from the boot env, else + * SaaS. `undefined` when the current env token was not present at the + * snapshot (a `.sentryclirc` token, whose server is unknown) or its claim + * is malformed. Gates using the env token as an account of its own (see + * `getEnvAccount` in `db/auth.ts`). + */ +export function getTrustedEnvTokenHost(): string | undefined { + const env = getEnv(); + if (!snapshotState.byEnv.has(env)) { + captureEnvTokenHost(); + } + const snapshot = snapshotState.byEnv.get(env); + const token = getRawEnvToken(); + if (!(snapshot && token) || snapshot.claimError || snapshot.token !== token) { + return; + } + return snapshot.host; +} + /** Only the explicit URL captured at boot may migrate a legacy stored login. */ export function getBootConfiguredSentryUrl(): string | undefined { const env = getEnv(); diff --git a/packages/cli/src/lib/formatters/accounts.ts b/packages/cli/src/lib/formatters/accounts.ts index e6bebf24b..9ab42bade 100644 --- a/packages/cli/src/lib/formatters/accounts.ts +++ b/packages/cli/src/lib/formatters/accounts.ts @@ -7,17 +7,26 @@ import { type AccountSummary, hostnameOf } from "../db/accounts.js"; import { colorTag, escapeMarkdownInline, renderMarkdown } from "./markdown.js"; import { formatTable } from "./table.js"; -/** Short human label for an account, e.g. `Jane `. */ +/** + * Short human label for an account, e.g. `Jane `. The env + * token's account names its variable, after its user when known: + * `Jane via SENTRY_AUTH_TOKEN`. + */ export function formatAccountIdentity(account: AccountSummary): string { const displayName = account.name ?? account.username; + let who: string | undefined; if (displayName && account.email) { - return `${displayName} <${account.email}>`; + who = `${displayName} <${account.email}>`; + } else { + who = + displayName ?? + account.email ?? + (account.userId ? `user ${account.userId}` : undefined); } - return ( - displayName ?? - account.email ?? - (account.userId ? `user ${account.userId}` : "unknown user") - ); + if (account.envVar) { + return who ? `${who} via ${account.envVar}` : account.envVar; + } + return who ?? "unknown user"; } /** Label including the server, e.g. `jane@acme.com on sentry.io`. */ @@ -28,6 +37,9 @@ export function formatAccountLabel(account: AccountSummary): string { } function accountStatus(account: AccountSummary): string { + if (account.envVar) { + return "env token"; + } if (!account.usable) { return colorTag("red", "logged out"); } diff --git a/packages/cli/src/lib/formatters/human.ts b/packages/cli/src/lib/formatters/human.ts index 6ddd0a3c9..2ce0fb69e 100644 --- a/packages/cli/src/lib/formatters/human.ts +++ b/packages/cli/src/lib/formatters/human.ts @@ -1961,13 +1961,29 @@ export function formatAuthStatus(data: AuthStatusData): string { function formatEnvTokenSection( envToken: NonNullable, ): string { - const status = envToken.active - ? "active" - : "set but not used (using OAuth credentials)"; + let status = "active"; + if (!envToken.active) { + status = envToken.host + ? "used for organizations it can access (OAuth credentials otherwise)" + : "set but not used (using OAuth credentials)"; + } const rows: [string, string][] = [ ["Env var", safeCodeSpan(envToken.envVar)], ["Status", status], ]; + if (envToken.host) { + rows.push(["Server", escapeMarkdownInline(envToken.host)]); + } + const user = envToken.user; + const who = user?.name ?? user?.username ?? user?.email; + if (who) { + rows.push([ + "User", + escapeMarkdownInline( + user?.email && user.email !== who ? `${who} <${user.email}>` : who, + ), + ]); + } return `\n${mdKvTable(rows, "Environment Token")}`; } diff --git a/packages/cli/test/commands/auth/status.test.ts b/packages/cli/test/commands/auth/status.test.ts index 48f55043b..8c4a9ced6 100644 --- a/packages/cli/test/commands/auth/status.test.ts +++ b/packages/cli/test/commands/auth/status.test.ts @@ -283,6 +283,43 @@ describe("statusCommand.func", () => { expect(getOutput()).toContain("environment variable"); }); + test("shows the server and user of an env token next to stored OAuth", async () => { + getAuthConfigSpy.mockReturnValue({ + token: "oauth-token", + source: "oauth", + }); + isAuthenticatedSpy.mockReturnValue(true); + const rawEnvSpy = vi + .spyOn(dbAuth, "getRawEnvToken") + .mockReturnValue("env-pat"); + const envAccountSpy = vi.spyOn(dbAuth, "getEnvAccount").mockReturnValue({ + envVar: "SENTRY_AUTH_TOKEN", + host: "https://sentry.io", + identity: "env-identity", + }); + const userSpy = vi.spyOn(apiClient, "getCurrentUser").mockResolvedValue({ + id: "7", + name: "CI Bot", + email: "ci@example.com", + } as Awaited>); + + try { + const { context, getOutput } = createContext(); + await func.call(context, jsonFlags); + + expect(JSON.parse(getOutput()).envToken).toEqual({ + envVar: "SENTRY_AUTH_TOKEN", + active: false, + host: "https://sentry.io", + user: { name: "CI Bot", email: "ci@example.com" }, + }); + } finally { + rawEnvSpy.mockRestore(); + envAccountSpy.mockRestore(); + userSpy.mockRestore(); + } + }); + test("does not show expiration or auto-refresh for env tokens", async () => { getAuthConfigSpy.mockReturnValue({ token: "sntrys_env_token_123", diff --git a/packages/cli/test/commands/auth/switch.test.ts b/packages/cli/test/commands/auth/switch.test.ts index 00b9f228a..09ef25024 100644 --- a/packages/cli/test/commands/auth/switch.test.ts +++ b/packages/cli/test/commands/auth/switch.test.ts @@ -3,19 +3,24 @@ * test database. */ -import { describe, expect, test, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { listCommand } from "../../../src/commands/auth/list.js"; import { switchCommand } from "../../../src/commands/auth/switch.js"; import { listAccounts } from "../../../src/lib/db/accounts.js"; import { getAuthToken, setAuthToken } from "../../../src/lib/db/auth.js"; import { getDefaultUrl, setDefaultUrl } from "../../../src/lib/db/defaults.js"; import { setUserInfo } from "../../../src/lib/db/user.js"; +import { getEnvTokenHost } from "../../../src/lib/env-token-host.js"; import { AuthError, ContextError, ResolutionError, } from "../../../src/lib/errors.js"; -import { useEnvSandbox, useTestConfigDir } from "../../helpers.js"; +import { + resetHostScopingState, + useEnvSandbox, + useTestConfigDir, +} from "../../helpers.js"; type ListFunc = (this: unknown, flags: { json?: boolean }) => Promise; type SwitchFunc = ( @@ -127,6 +132,88 @@ describe("auth list", () => { expect(getOutput()).toContain("No stored accounts"); }); + + describe("with an env token", () => { + let originalFetch: typeof globalThis.fetch; + /** ` ` of every `/auth/` request. */ + let requests: string[]; + + beforeEach(async () => { + await resetHostScopingState(); + requests = []; + originalFetch = globalThis.fetch; + globalThis.fetch = (async ( + input: RequestInfo | URL, + init?: RequestInit, + ) => { + const req = new Request(input, init); + const url = new URL(req.url); + const token = req.headers.get("Authorization")?.replace(/^Bearer /, ""); + requests.push(`${url.origin} ${token}`); + if (url.pathname === "/api/0/auth/" && token === "env-token") { + return Response.json({ + id: "7", + email: "ci@example.com", + name: "CI Bot", + }); + } + return Response.json({ detail: "Invalid token" }, { status: 401 }); + }) as typeof globalThis.fetch; + }); + + afterEach(async () => { + globalThis.fetch = originalFetch; + await resetHostScopingState(); + }); + + test("lists it after the stored accounts, with its user", async () => { + process.env.SENTRY_AUTH_TOKEN = "env-token"; + seedTwoAccounts(); + const func = (await listCommand.loader()) as unknown as ListFunc; + const { context, getOutput } = createContext(); + + await func.call(context, { json: true }); + + const parsed = JSON.parse(getOutput()) as Array>; + expect(parsed.at(-1)).toEqual({ + id: "env:SENTRY_AUTH_TOKEN", + active: false, + host: "https://sentry.io", + userId: "7", + email: "ci@example.com", + name: "CI Bot", + usable: true, + envVar: "SENTRY_AUTH_TOKEN", + }); + expect(getOutput()).not.toContain("env-token"); + expect(requests).toEqual(["https://sentry.io env-token"]); + }); + + test("labels it by its variable when the server doesn't say whose it is", async () => { + process.env.SENTRY_TOKEN = "other-token"; + const func = (await listCommand.loader()) as unknown as ListFunc; + const { context, getOutput } = createContext(); + + await func.call(context, { json: false }); + + expect(getOutput()).toContain("SENTRY_TOKEN"); + expect(getOutput()).toContain("env token"); + }); + + test("leaves out a token set after boot, e.g. from .sentryclirc", async () => { + getEnvTokenHost(); + process.env.SENTRY_AUTH_TOKEN = "env-token"; + seedTwoAccounts(); + const func = (await listCommand.loader()) as unknown as ListFunc; + const { context, getOutput } = createContext(); + + await func.call(context, { json: true }); + + const parsed = JSON.parse(getOutput()) as Array>; + expect(parsed).toHaveLength(2); + expect(requests).toEqual([]); + }); + }); }); describe("auth switch", () => { diff --git a/packages/cli/test/commands/org/list.test.ts b/packages/cli/test/commands/org/list.test.ts index 40b11d4cd..1e7971ca2 100644 --- a/packages/cli/test/commands/org/list.test.ts +++ b/packages/cli/test/commands/org/list.test.ts @@ -14,9 +14,14 @@ import { } from "../../../src/lib/db/auth.js"; import { resetAccountScopes } from "../../../src/lib/db/account-scope.js"; import { getDatabase } from "../../../src/lib/db/index.js"; +import { getEnvTokenHost } from "../../../src/lib/env-token-host.js"; import { setUserInfo } from "../../../src/lib/db/user.js"; import { AuthError, ResolutionError } from "../../../src/lib/errors.js"; -import { useEnvSandbox, useTestConfigDir } from "../../helpers.js"; +import { + resetHostScopingState, + useEnvSandbox, + useTestConfigDir, +} from "../../helpers.js"; type ListFunc = ( this: unknown, @@ -45,6 +50,8 @@ type Org = { id: string; slug: string; name: string; regionUrl: string }; /** Orgs each token can see; a token missing here gets a 401. */ let orgsByToken: Record; +/** The `/auth/` user of each token; a token missing here gets a 401. */ +let usersByToken: Record; /** ` ` for every org-list request, to check token routing. */ let requests: string[]; let originalFetch: typeof globalThis.fetch; @@ -53,14 +60,24 @@ function org(slug: string, regionUrl = SAAS, id = slug): Org { return { id, slug, name: `${slug} name`, regionUrl }; } -beforeEach(() => { +beforeEach(async () => { + // Each case boots afresh: the env token's server is snapshotted once. + await resetHostScopingState(); orgsByToken = {}; + usersByToken = {}; requests = []; originalFetch = globalThis.fetch; globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { const req = new Request(input, init); const url = new URL(req.url); const token = req.headers.get("Authorization")?.replace(/^Bearer /, ""); + if (url.pathname === "/api/0/auth/") { + requests.push(`${url.origin} ${token} /auth/`); + const user = token ? usersByToken[token] : undefined; + return user + ? Response.json(user) + : Response.json({ detail: "Invalid token" }, { status: 401 }); + } if (!url.pathname.startsWith("/api/0/organizations/")) { return Response.json({ detail: "Not found" }, { status: 404 }); } @@ -477,3 +494,152 @@ describe("org list --account", () => { ); }); }); + +describe("org list with an env token", () => { + test("lists it as one more account, first, fetched only on its own server", async () => { + process.env.SENTRY_AUTH_TOKEN = "env-token"; + addAccount("acme-token", ACME, "jane@acme.com"); + addAccount("saas-token", SAAS, "jane@example.com"); + orgsByToken["env-token"] = [org("ci-org")]; + orgsByToken["saas-token"] = [org("alpha")]; + orgsByToken["acme-token"] = [org("acme-internal", ACME)]; + usersByToken["env-token"] = { + id: "7", + email: "ci@example.com", + name: "CI Bot", + }; + + const entries = await runList(); + + expect(entries.map((e) => e.slug)).toEqual([ + "ci-org", + "alpha", + "acme-internal", + ]); + expect(entries[0]?.account).toEqual({ + name: "env:SENTRY_AUTH_TOKEN", + id: "env:SENTRY_AUTH_TOKEN", + email: "ci@example.com", + server: "sentry.io", + host: SAAS, + label: "CI Bot via SENTRY_AUTH_TOKEN on sentry.io", + envVar: "SENTRY_AUTH_TOKEN", + }); + // Never sent to a stored account's server, nor stored tokens to SaaS + // on its behalf. + expect(requests.filter((r) => r.includes("env-token")).sort()).toEqual( + [`${SAAS} env-token`, `${SAAS} env-token /auth/`].sort(), + ); + expect(requests).toContain(`${ACME} acme-token`); + }); + + test("an org it shares with a login is listed under it, and commands then use it", async () => { + process.env.SENTRY_AUTH_TOKEN = "env-token"; + addAccount("saas-token", SAAS, "jane@example.com"); + orgsByToken["env-token"] = [org("shared")]; + orgsByToken["saas-token"] = [org("shared"), org("alpha")]; + + const entries = await runList(); + resetAccountScopes(); + selectAccountForOrg("shared"); + + expect(slugsWithAccount(entries)).toEqual([ + ["shared", ""], + ["alpha", "jane@example.com"], + ]); + expect(entries[0]?.account).toMatchObject({ + name: "env:SENTRY_AUTH_TOKEN", + }); + expect(getAuthToken()).toBe("env-token"); + }); + + test("is labeled by its variable when the server doesn't say whose it is", async () => { + process.env.SENTRY_TOKEN = "env-token"; + addAccount("saas-token", SAAS, "jane@example.com"); + orgsByToken["env-token"] = [org("ci-org")]; + orgsByToken["saas-token"] = [org("alpha")]; + + const out = await runListHuman(); + + expect(out).toContain("env:SENTRY_TOKEN"); + }); + + test("uses the server configured at boot", async () => { + process.env.SENTRY_AUTH_TOKEN = "env-token"; + process.env.SENTRY_URL = ACME; + addAccount("acme-token", ACME, "jane@acme.com"); + orgsByToken["env-token"] = [org("ci-org", ACME)]; + orgsByToken["acme-token"] = [org("acme-internal", ACME)]; + + const entries = await runList(); + + expect(entries.map((e) => e.slug)).toEqual(["ci-org", "acme-internal"]); + expect(requests).toContain(`${ACME} env-token`); + expect(requests.some((r) => r.startsWith(`${SAAS} `))).toBe(false); + }); + + test("an explicit server set after boot on another server leaves it out", async () => { + process.env.SENTRY_AUTH_TOKEN = "env-token"; + getEnvTokenHost(); + addAccount("acme-token", ACME, "jane@acme.com"); + addAccount("saas-token", SAAS, "jane@example.com"); + process.env.SENTRY_URL = ACME; + orgsByToken["env-token"] = [org("ci-org")]; + orgsByToken["acme-token"] = [org("acme-internal", ACME)]; + + const entries = await runList(); + + expect(entries.map((e) => e.slug)).toEqual(["acme-internal"]); + expect(requests.some((r) => r.includes("env-token"))).toBe(false); + }); + + test("alone, keeps the single-account output", async () => { + process.env.SENTRY_AUTH_TOKEN = "env-token"; + orgsByToken["env-token"] = [org("ci-org")]; + + const entries = await runList(); + + expect(entries.map((e) => e.slug)).toEqual(["ci-org"]); + expect(entries[0]?.account).toBeUndefined(); + }); + + test("forced, it is the only account listed", async () => { + process.env.SENTRY_AUTH_TOKEN = "env-token"; + process.env.SENTRY_FORCE_ENV_TOKEN = "1"; + addAccount("saas-token", SAAS, "jane@example.com"); + orgsByToken["env-token"] = [org("ci-org")]; + orgsByToken["saas-token"] = [org("alpha")]; + + const entries = await runList(); + + expect(entries.map((e) => e.slug)).toEqual(["ci-org"]); + expect(requests).toEqual([`${SAAS} env-token`]); + }); + + test("--account env:SENTRY_AUTH_TOKEN lists only its orgs", async () => { + process.env.SENTRY_AUTH_TOKEN = "env-token"; + addAccount("saas-token", SAAS, "jane@example.com"); + orgsByToken["env-token"] = [org("ci-org")]; + orgsByToken["saas-token"] = [org("alpha")]; + + const entries = await runList({ account: "env:SENTRY_AUTH_TOKEN" }); + + expect(entries.map((e) => e.slug)).toEqual(["ci-org"]); + expect(requests.some((r) => r.includes("saas-token"))).toBe(false); + }); + + test("is not an account when it came from .sentryclirc after boot", async () => { + getEnvTokenHost(); + process.env.SENTRY_AUTH_TOKEN = "rc-token"; + addAccount("acme-token", ACME, "jane@acme.com"); + addAccount("saas-token", SAAS, "jane@example.com"); + orgsByToken["rc-token"] = [org("rc-org")]; + orgsByToken["saas-token"] = [org("alpha")]; + orgsByToken["acme-token"] = [org("acme-internal", ACME)]; + + const entries = await runList(); + + expect(entries.map((e) => e.slug)).toEqual(["alpha", "acme-internal"]); + expect(requests.some((r) => r.includes("rc-token"))).toBe(false); + }); +}); diff --git a/packages/cli/test/lib/db/account-routing.test.ts b/packages/cli/test/lib/db/account-routing.test.ts index 411d1b095..ecd4097fc 100644 --- a/packages/cli/test/lib/db/account-routing.test.ts +++ b/packages/cli/test/lib/db/account-routing.test.ts @@ -9,12 +9,13 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; -import { describe, expect, test, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, test, vi } from "vitest"; import { markPersistedDefaultUrl } from "../../../src/lib/constants.js"; import { getActiveAccountId, getAuthToken, getEffectiveAccountRow, + getEnvAccount, getReauthorizationAccountId, refreshToken, resetAccountSelection, @@ -22,6 +23,7 @@ import { setActiveAccount, setAuthToken, } from "../../../src/lib/db/auth.js"; +import { hashIdentity } from "../../../src/lib/db/identity-hash.js"; import { closeDatabase, getDatabase } from "../../../src/lib/db/index.js"; import { getEnvTokenHost } from "../../../src/lib/env-token-host.js"; import { AuthError } from "../../../src/lib/errors.js"; @@ -40,7 +42,11 @@ import { assertRcUrlTrusted, clearSentryCliRcCache, } from "../../../src/lib/sentryclirc.js"; -import { useEnvSandbox, useTestConfigDir } from "../../helpers.js"; +import { + resetHostScopingState, + useEnvSandbox, + useTestConfigDir, +} from "../../helpers.js"; useTestConfigDir("account-routing-"); useEnvSandbox([ @@ -415,6 +421,181 @@ describe("organization-based selection", () => { }); }); +describe("the env token as an account", () => { + // The env token's server is snapshotted once per env: start each case + // from a fresh boot. + beforeEach(resetHostScopingState); + afterEach(resetHostScopingState); + + /** Cache `org` on `host` as seen by the env token `token`. */ + function envSeesOrg(token: string, org: string, host: string): void { + setOrgRegion(org, host, host, host, hashIdentity("env", token)); + } + + test("is an account on SaaS when no server is configured", () => { + process.env.SENTRY_AUTH_TOKEN = "env-token"; + + expect(getEnvAccount()).toEqual({ + envVar: "SENTRY_AUTH_TOKEN", + host: SAAS, + identity: hashIdentity("env", "env-token"), + }); + }); + + test("is an account on the server configured at boot", () => { + process.env.SENTRY_TOKEN = "env-token"; + process.env.SENTRY_URL = ACME; + + expect(getEnvAccount()?.host).toBe(ACME); + expect(getEnvAccount()?.envVar).toBe("SENTRY_TOKEN"); + }); + + test("is no account when the token appeared after boot, e.g. from .sentryclirc", () => { + login("saas-token", SAAS, "saas"); + // The boot snapshot saw no env token. + getEnvTokenHost(); + process.env.SENTRY_AUTH_TOKEN = "rc-token"; + envSeesOrg("rc-token", "rc-org", SAAS); + + selectAccountForOrg("rc-org"); + + expect(getEnvAccount()).toBeUndefined(); + expect(getAuthToken()).toBe("saas-token"); + }); + + test("wins over the active account for an org both know", () => { + process.env.SENTRY_AUTH_TOKEN = "env-token"; + const saas = login("saas-token", SAAS, "saas"); + seeOrgList(SAAS, ["shared"]); + envSeesOrg("env-token", "shared", SAAS); + + selectAccountForOrg("shared"); + + expect(getAuthToken()).toBe("env-token"); + expect(getEffectiveAccountRow()).toBeUndefined(); + expect(getActiveAccountId()).toBe(saas); + }); + + test("wins over another stored account that knows the org", () => { + process.env.SENTRY_AUTH_TOKEN = "env-token"; + login("work-token", SAAS, "work"); + seeOrg("env-org", SAAS); + login("personal-token", SAAS, "personal"); + envSeesOrg("env-token", "env-org", SAAS); + + selectAccountForOrg("env-org"); + + expect(getAuthToken()).toBe("env-token"); + }); + + test("matches DSN-style numeric org ids", () => { + process.env.SENTRY_AUTH_TOKEN = "env-token"; + login("saas-token", SAAS, "saas"); + setOrgRegions([ + { + slug: "env-org", + regionUrl: SAAS, + sourceOrigin: SAAS, + identity: hashIdentity("env", "env-token"), + orgId: "4242", + orgName: "Env Org", + }, + ]); + + selectAccountForOrg("o4242", "4242"); + + expect(getAuthToken()).toBe("env-token"); + }); + + test("leaves orgs it doesn't know to the stored login", () => { + process.env.SENTRY_AUTH_TOKEN = "env-token"; + login("saas-token", SAAS, "saas"); + envSeesOrg("env-token", "env-org", SAAS); + + selectAccountForOrg("other-org"); + + expect(getAuthToken()).toBe("saas-token"); + }); + + test("an explicit server set after boot on another server rules it out", () => { + process.env.SENTRY_AUTH_TOKEN = "env-token"; + // Boot: the env token is scoped to SaaS before `--url` or + // `.sentryclirc` point the invocation at ACME. + getEnvTokenHost(); + login("acme-token", ACME, "acme"); + seeOrg("shared", ACME); + envSeesOrg("env-token", "shared", SAAS); + process.env.SENTRY_URL = ACME; + + selectAccountForOrg("shared"); + + expect(getAuthToken()).toBe("acme-token"); + }); + + test("brings its server along when the persisted default URL is another", () => { + process.env.SENTRY_AUTH_TOKEN = "env-token"; + // Boot: the persisted default URL is applied after the snapshot. + getEnvTokenHost(); + login("acme-token", ACME, "acme"); + envSeesOrg("env-token", "env-org", SAAS); + process.env.SENTRY_URL = ACME; + markPersistedDefaultUrl(ACME); + + selectAccountForOrg("env-org"); + + expect(process.env.SENTRY_URL).toBe(SAAS); + expect(getAuthToken()).toBe("env-token"); + }); + + test("never mixes with a stored account within one invocation", () => { + process.env.SENTRY_AUTH_TOKEN = "env-token"; + login("work-token", SAAS, "work"); + seeOrg("work-org", SAAS); + login("personal-token", SAAS, "personal"); + envSeesOrg("env-token", "env-org", SAAS); + + selectAccountForOrg("env-org"); + selectAccountForOrg("work-org"); + + expect(getAuthToken()).toBe("env-token"); + }); + + test("a login during the invocation takes over from it", () => { + process.env.SENTRY_AUTH_TOKEN = "env-token"; + login("saas-token", SAAS, "saas"); + envSeesOrg("env-token", "env-org", SAAS); + selectAccountForOrg("env-org"); + + setAuthToken("fresh-token", undefined, undefined, { host: SAAS }); + + expect(getAuthToken()).toBe("fresh-token"); + }); + + test("serves its configured server when no stored account is on it", () => { + process.env.SENTRY_AUTH_TOKEN = "env-token"; + process.env.SENTRY_URL = ACME; + login("saas-token", SAAS, "saas"); + + expect(getAuthToken()).toBe("env-token"); + }); + + test("yields its configured server to a stored account on it", () => { + process.env.SENTRY_AUTH_TOKEN = "env-token"; + process.env.SENTRY_URL = ACME; + login("acme-token", ACME, "acme"); + login("saas-token", SAAS, "saas"); + + expect(getAuthToken()).toBe("acme-token"); + }); + + test("still yields to the active stored login by default", () => { + process.env.SENTRY_AUTH_TOKEN = "env-token"; + login("saas-token", SAAS, "saas"); + + expect(getAuthToken()).toBe("saas-token"); + }); +}); + describe("logging in after an org picked the account", () => { function pinWorkAccount(): { work: string; personal: string } { const work = login("work-token", SAAS, "work"); From 2e2627b270465feb1fdf54601aaa26f5404b4ecd Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:14:28 +0000 Subject: [PATCH 2/7] fix(cli): Only route to the env token for orgs seen on its server The env token's credential fingerprint doesn't depend on its server, so orgs it cached while scoped to another SENTRY_URL could select it for a same-slug org on its current server. Match cached orgs against the token's trusted host (SaaS regions count as SaaS). --- packages/cli/src/lib/db/accounts.ts | 21 ++++++++++------ packages/cli/src/lib/db/auth.ts | 11 ++++++-- .../cli/test/lib/db/account-routing.test.ts | 25 +++++++++++++++++++ 3 files changed, 47 insertions(+), 10 deletions(-) diff --git a/packages/cli/src/lib/db/accounts.ts b/packages/cli/src/lib/db/accounts.ts index 7a7a3076f..618781e12 100644 --- a/packages/cli/src/lib/db/accounts.ts +++ b/packages/cli/src/lib/db/accounts.ts @@ -511,27 +511,32 @@ export function hasCachedOrgList(accountId: string): boolean { /** * Whether the org cache shows `identity` (a credential fingerprint, see - * `getIdentityFingerprint`) has seen an organization, by slug or numeric - * id. Mirrors {@link listUsableAccountRowsForOrg} for a credential that is - * not a stored account: the env token. + * `getIdentityFingerprint`) has seen an organization on `host`'s server, by + * slug or numeric id. Mirrors {@link listUsableAccountRowsForOrg} for a + * credential that is not a stored account: the env token. Its fingerprint + * doesn't depend on the server, so rows cached while it was scoped to + * another server (a different `SENTRY_URL`) must not count: that server's + * org with the same slug is a different org. */ export function hasCachedOrgForIdentity( identity: string, + host: string, orgSlug: string, orgId?: string, ): boolean { const id = orgId ?? null; - const row = getDatabase() + const rows = getDatabase() .query( - `SELECT 1 AS found FROM org_regions r + `SELECT DISTINCT r.source_origin FROM org_regions r WHERE r.credential_identity = ? AND ( r.org_slug = ? OR r.org_id = ? OR EXISTS ( SELECT 1 FROM org_regions m WHERE m.org_id = ? AND m.org_slug = r.org_slug AND m.source_origin = r.source_origin ) - ) LIMIT 1`, + )`, ) - .get(identity, orgSlug, id, id) as { found: number } | null | undefined; - return Boolean(row); + .all(identity, orgSlug, id, id) as { source_origin: string }[]; + // Same-server matching (`*.sentry.io` as one server) stays in TypeScript. + return rows.some((row) => isSameServer(row.source_origin, host)); } diff --git a/packages/cli/src/lib/db/auth.ts b/packages/cli/src/lib/db/auth.ts index 9daa39d81..fc090f612 100644 --- a/packages/cli/src/lib/db/auth.ts +++ b/packages/cli/src/lib/db/auth.ts @@ -1023,7 +1023,8 @@ export function selectAccountForOrg(orgSlug: string, orgId?: string): void { /** * Use the env token for the rest of this invocation when its org cache - * shows it knows the org and it is on the explicit server, if any. The + * 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. * * @returns whether the env token was chosen @@ -1032,7 +1033,13 @@ function selectEnvAccountForOrg(orgSlug: string, orgId?: string): boolean { const envAccount = getEnvAccountInScope(); if ( !( - envAccount && hasCachedOrgForIdentity(envAccount.identity, orgSlug, orgId) + envAccount && + hasCachedOrgForIdentity( + envAccount.identity, + envAccount.host, + orgSlug, + orgId, + ) ) ) { return false; diff --git a/packages/cli/test/lib/db/account-routing.test.ts b/packages/cli/test/lib/db/account-routing.test.ts index ecd4097fc..e6002db4e 100644 --- a/packages/cli/test/lib/db/account-routing.test.ts +++ b/packages/cli/test/lib/db/account-routing.test.ts @@ -507,6 +507,31 @@ describe("the env token as an account", () => { expect(getAuthToken()).toBe("env-token"); }); + test("ignores orgs it saw while scoped to another server", () => { + process.env.SENTRY_AUTH_TOKEN = "env-token"; + process.env.SENTRY_URL = ACME; + login("acme-token", ACME, "acme"); + seeOrg("shared", ACME); + // Cached by an earlier invocation where the same token was on SaaS: + // ACME's `shared` is a different org. + envSeesOrg("env-token", "shared", SAAS); + + selectAccountForOrg("shared"); + + expect(getEnvAccount()?.host).toBe(ACME); + expect(getAuthToken()).toBe("acme-token"); + }); + + test("matches orgs it saw on a SaaS region of its SaaS server", () => { + process.env.SENTRY_AUTH_TOKEN = "env-token"; + login("saas-token", SAAS, "saas"); + envSeesOrg("env-token", "eu-org", "https://de.sentry.io"); + + selectAccountForOrg("eu-org"); + + expect(getAuthToken()).toBe("env-token"); + }); + test("leaves orgs it doesn't know to the stored login", () => { process.env.SENTRY_AUTH_TOKEN = "env-token"; login("saas-token", SAAS, "saas"); From 18304a44652dc11bf3c8e196e259188bcca11bac Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:12:02 +0000 Subject: [PATCH 3/7] ref(cli): Parse SENTRY_FORCE_ENV_TOKEN as a boolean and match env-token orgs by server in SQL Read SENTRY_FORCE_ENV_TOKEN through parseBoolValue (adapted from the SDK's envToBool) in one helper, so 0/false/no/off no longer force the env token. Move the env token's same-server org cache check into the query: SaaS origins match as one server, others by exact normalized origin, with LIMIT 1 instead of fetching rows to filter in TypeScript. --- packages/cli/src/lib/auth-hint.ts | 4 +- packages/cli/src/lib/db/accounts.ts | 47 ++++++++++++++++--- packages/cli/src/lib/db/auth.ts | 23 +++++---- packages/cli/src/lib/env-registry.ts | 2 +- .../cli/test/lib/db/account-routing.test.ts | 23 +++++++++ 5 files changed, 81 insertions(+), 18 deletions(-) diff --git a/packages/cli/src/lib/auth-hint.ts b/packages/cli/src/lib/auth-hint.ts index 906af8fd3..87ca85afd 100644 --- a/packages/cli/src/lib/auth-hint.ts +++ b/packages/cli/src/lib/auth-hint.ts @@ -29,9 +29,9 @@ import { getActiveEnvVarName, getRawEnvToken, hasStoredAuthCredentials, + isForceEnvTokenSet, } from "./db/auth.js"; import { getUserInfo } from "./db/user.js"; -import { getEnv } from "./env.js"; import { logger } from "./logger.js"; import { getRcInjectedTokenSource } from "./sentryclirc.js"; @@ -60,7 +60,7 @@ export function maybeWarnEnvTokenIgnored(): void { } // Fast path: user opted into the env token explicitly. - if (getEnv().SENTRY_FORCE_ENV_TOKEN?.trim()) { + if (isForceEnvTokenSet()) { return; } diff --git a/packages/cli/src/lib/db/accounts.ts b/packages/cli/src/lib/db/accounts.ts index 618781e12..e7f794de4 100644 --- a/packages/cli/src/lib/db/accounts.ts +++ b/packages/cli/src/lib/db/accounts.ts @@ -524,19 +524,52 @@ export function hasCachedOrgForIdentity( orgSlug: string, orgId?: string, ): boolean { + const server = sameServerOriginSql("r.source_origin", host); + if (!server) { + return false; + } const id = orgId ?? null; - const rows = getDatabase() + const row = getDatabase() .query( - `SELECT DISTINCT r.source_origin FROM org_regions r - WHERE r.credential_identity = ? AND ( + `SELECT 1 AS found FROM org_regions r + WHERE r.credential_identity = ? AND ${server.sql} AND ( r.org_slug = ? OR r.org_id = ? OR EXISTS ( SELECT 1 FROM org_regions m WHERE m.org_id = ? AND m.org_slug = r.org_slug AND m.source_origin = r.source_origin ) - )`, + ) + LIMIT 1`, ) - .all(identity, orgSlug, id, id) as { source_origin: string }[]; - // Same-server matching (`*.sentry.io` as one server) stays in TypeScript. - return rows.some((row) => isSameServer(row.source_origin, host)); + .get(identity, ...server.params, orgSlug, id, id) as + | { found: number } + | null + | undefined; + return Boolean(row); +} + +/** + * SQL form of {@link isSameServer} for a column of normalized origins (as + * `org_regions.source_origin` stores them): `{@link serverKey}`'s + * canonicalization, so every SaaS origin (`https://sentry.io` and + * `https://*.sentry.io`) matches as one server and any other server only by + * its exact origin. `undefined` when `host` has no server key. + */ +function sameServerOriginSql( + column: string, + host: string, +): { sql: string; params: string[] } | undefined { + const key = serverKey(host); + if (key === undefined) { + return; + } + if (key === SAAS_SERVER_KEY) { + // Normalized origins carry no path, credentials, or default port, so + // this is exactly `isSaaSTrustOrigin` for them. + return { + sql: `(${column} = 'https://sentry.io' OR ${column} LIKE 'https://%.sentry.io')`, + params: [], + }; + } + return { sql: `${column} = ?`, params: [key] }; } diff --git a/packages/cli/src/lib/db/auth.ts b/packages/cli/src/lib/db/auth.ts index fc090f612..53404cf10 100644 --- a/packages/cli/src/lib/db/auth.ts +++ b/packages/cli/src/lib/db/auth.ts @@ -38,6 +38,7 @@ import { } from "../env-token-host.js"; import { AuthError, ConfigError } from "../errors.js"; import { logger } from "../logger.js"; +import { parseBoolValue } from "../parse-bool.js"; import { normalizeHttpOrigin } from "../sentry-urls.js"; import { withDbSpan } from "../telemetry.js"; import { @@ -312,9 +313,17 @@ export function getActiveEnvVarName(): string { : "SENTRY_AUTH_TOKEN"; } +/** + * Whether `SENTRY_FORCE_ENV_TOKEN` is set to an affirmative value (`1`, + * `true`, `yes`, `on`, ...). Negative or unrecognized values leave it off. + */ +export function isForceEnvTokenSet(): boolean { + return parseBoolValue(getEnv().SENTRY_FORCE_ENV_TOKEN ?? "") === true; +} + /** Whether `SENTRY_FORCE_ENV_TOKEN` makes a set env token the only credential. */ export function isEnvTokenForced(): boolean { - return Boolean(getEnv().SENTRY_FORCE_ENV_TOKEN?.trim() && getEnvToken()); + return isForceEnvTokenSet() && getEnvToken() !== undefined; } /** @@ -367,8 +376,7 @@ export function getAuthConfig(): AuthConfig | undefined { // Otherwise, check the DB first — stored OAuth takes priority over env tokens. // This is the core fix for #646: wizard-generated build tokens no longer // silently override the user's interactive login. - const forceEnv = getEnv().SENTRY_FORCE_ENV_TOKEN?.trim(); - if (forceEnv) { + if (isForceEnvTokenSet()) { const envToken = getEnvToken(); if (envToken) { return { token: envToken.token, source: envToken.source }; @@ -539,8 +547,7 @@ export function getAuthToken(): string | undefined { } function computeAuthToken(): string | undefined { - const forceEnv = getEnv().SENTRY_FORCE_ENV_TOKEN?.trim(); - if (forceEnv) { + if (isForceEnvTokenSet()) { const envToken = getEnvToken(); if (envToken) { return envToken.token; @@ -1265,7 +1272,7 @@ export function resetIdentityFingerprintCache(): void { function computeIdentityFingerprint(): string { // Forced env-token: matches what `refreshToken()` will actually send. - if (getEnv().SENTRY_FORCE_ENV_TOKEN?.trim()) { + if (isForceEnvTokenSet()) { const envToken = getRawEnvToken(); if (envToken) { return hashIdentity("env", envToken); @@ -1315,7 +1322,7 @@ function envCredentialContext(): CredentialContext | undefined { } export function getCredentialContext(): CredentialContext | undefined { - if (getEnv().SENTRY_FORCE_ENV_TOKEN?.trim()) { + if (isForceEnvTokenSet()) { const forced = envCredentialContext(); if (forced) { return forced; @@ -1627,7 +1634,7 @@ export async function refreshToken( options: RefreshTokenOptions = {}, ): Promise { // With SENTRY_FORCE_ENV_TOKEN, env token takes priority (no refresh needed). - const forced = getEnv().SENTRY_FORCE_ENV_TOKEN?.trim() + const forced = isForceEnvTokenSet() ? getEnvRefreshResult(options.expectedCredential) : undefined; if (forced) { diff --git a/packages/cli/src/lib/env-registry.ts b/packages/cli/src/lib/env-registry.ts index 587d8161d..cba75d3b6 100644 --- a/packages/cli/src/lib/env-registry.ts +++ b/packages/cli/src/lib/env-registry.ts @@ -59,7 +59,7 @@ export const ENV_VAR_REGISTRY: readonly EnvVarEntry[] = [ { name: "SENTRY_FORCE_ENV_TOKEN", description: - "When set, environment variable tokens (`SENTRY_AUTH_TOKEN` / `SENTRY_TOKEN`) are the only credential used, overriding the stored OAuth token from `sentry auth login` and account selection. By default, the stored OAuth token takes priority because it supports automatic refresh, and the env token is only used for organizations it can access and for its own server. Set this if you want to ensure the environment variable token is always used, which is useful for self-hosted setups or CI environments.", + "When set to a truthy value (`1`, `true`, `yes`, `on`), environment variable tokens (`SENTRY_AUTH_TOKEN` / `SENTRY_TOKEN`) are the only credential used, overriding the stored OAuth token from `sentry auth login` and account selection. By default, the stored OAuth token takes priority because it supports automatic refresh, and the env token is only used for organizations it can access and for its own server. Set this if you want to ensure the environment variable token is always used, which is useful for self-hosted setups or CI environments.", example: "1", topLevel: true, briefDescription: "Prefer the env-var token over a stored OAuth login.", diff --git a/packages/cli/test/lib/db/account-routing.test.ts b/packages/cli/test/lib/db/account-routing.test.ts index e6002db4e..b02034824 100644 --- a/packages/cli/test/lib/db/account-routing.test.ts +++ b/packages/cli/test/lib/db/account-routing.test.ts @@ -288,6 +288,17 @@ describe("organization-based selection", () => { expect(getAuthToken()).toBe("env-token"); }); + test.each(["0", "false", "no", "off"])( + "SENTRY_FORCE_ENV_TOKEN=%s doesn't force the env token", + (value) => { + login("work-token", SAAS, "work"); + process.env.SENTRY_AUTH_TOKEN = "env-token"; + process.env.SENTRY_FORCE_ENV_TOKEN = value; + + expect(getAuthToken()).toBe("work-token"); + }, + ); + test("skips accounts whose credentials were invalidated", () => { const work = login("work-token", SAAS, "work"); seeOrg("work-org", SAAS); @@ -532,6 +543,18 @@ describe("the env token as an account", () => { expect(getAuthToken()).toBe("env-token"); }); + test("ignores orgs it saw on look-alike SaaS servers", () => { + process.env.SENTRY_AUTH_TOKEN = "env-token"; + login("saas-token", SAAS, "saas"); + envSeesOrg("env-token", "lookalike", "https://sentry.io.example.com"); + envSeesOrg("env-token", "lookalike", "https://notsentry.io"); + envSeesOrg("env-token", "lookalike", "http://us.sentry.io"); + + selectAccountForOrg("lookalike"); + + expect(getAuthToken()).toBe("saas-token"); + }); + test("leaves orgs it doesn't know to the stored login", () => { process.env.SENTRY_AUTH_TOKEN = "env-token"; login("saas-token", SAAS, "saas"); From ae71467f42fc3d9b113404dd414951d99bc50edd Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:28:32 +0000 Subject: [PATCH 4/7] ref(cli): Use SELECT EXISTS for the env token's org cache check --- packages/cli/src/lib/db/accounts.ts | 24 +++++++++++------------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/packages/cli/src/lib/db/accounts.ts b/packages/cli/src/lib/db/accounts.ts index e7f794de4..20c8c88ca 100644 --- a/packages/cli/src/lib/db/accounts.ts +++ b/packages/cli/src/lib/db/accounts.ts @@ -531,21 +531,19 @@ export function hasCachedOrgForIdentity( const id = orgId ?? null; const row = getDatabase() .query( - `SELECT 1 AS found FROM org_regions r - WHERE r.credential_identity = ? AND ${server.sql} AND ( - r.org_slug = ? OR r.org_id = ? OR EXISTS ( - SELECT 1 FROM org_regions m - WHERE m.org_id = ? AND m.org_slug = r.org_slug - AND m.source_origin = r.source_origin + `SELECT EXISTS ( + SELECT 1 FROM org_regions r + WHERE r.credential_identity = ? AND ${server.sql} AND ( + r.org_slug = ? OR r.org_id = ? OR EXISTS ( + SELECT 1 FROM org_regions m + WHERE m.org_id = ? AND m.org_slug = r.org_slug + AND m.source_origin = r.source_origin + ) ) - ) - LIMIT 1`, + ) AS found`, ) - .get(identity, ...server.params, orgSlug, id, id) as - | { found: number } - | null - | undefined; - return Boolean(row); + .get(identity, ...server.params, orgSlug, id, id) as { found: number }; + return row.found === 1; } /** From b655738a197e2013c82e61d08ebe1e2d042eed3a Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:30:49 +0000 Subject: [PATCH 5/7] test(cli): Pin account recency in the configured-server org selection test Back-to-back logins can share a millisecond, tying last_used_at so the older account won the tie-break and the test failed intermittently. Co-Authored-By: byk --- packages/cli/test/lib/db/account-routing.test.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/packages/cli/test/lib/db/account-routing.test.ts b/packages/cli/test/lib/db/account-routing.test.ts index b02034824..326a8b24a 100644 --- a/packages/cli/test/lib/db/account-routing.test.ts +++ b/packages/cli/test/lib/db/account-routing.test.ts @@ -369,8 +369,15 @@ describe("organization-based selection", () => { const peer = login("peer-token", ACME, "peer"); seeOrg("acme-org", ACME); // More recently used on ACME, but has never seen the org. - login("other-token", ACME, "other"); + const other = login("other-token", ACME, "other"); const saas = login("saas-token", SAAS, "saas"); + // Back-to-back logins can share a millisecond; pin the recency so the + // configured server deterministically picks `other`. + const setLastUsed = getDatabase().query( + "UPDATE accounts SET last_used_at = ? WHERE account_id = ?", + ); + setLastUsed.run(1000, peer); + setLastUsed.run(2000, other); process.env.SENTRY_URL = ACME; expect(getAuthToken()).toBe("other-token"); From 700dcdf9e5224cae9718059a3c9e30e19aab146f Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:03:17 +0000 Subject: [PATCH 6/7] ref(cli): Flatten the env token's org cache query Use a plain SELECT 1 ... LIMIT 1 (same short-circuit as EXISTS) and inline the single-use same-server SQL helper. Co-Authored-By: byk --- packages/cli/src/lib/db/accounts.ts | 55 +++++++++-------------------- 1 file changed, 17 insertions(+), 38 deletions(-) diff --git a/packages/cli/src/lib/db/accounts.ts b/packages/cli/src/lib/db/accounts.ts index 20c8c88ca..a9eb90c36 100644 --- a/packages/cli/src/lib/db/accounts.ts +++ b/packages/cli/src/lib/db/accounts.ts @@ -524,50 +524,29 @@ export function hasCachedOrgForIdentity( orgSlug: string, orgId?: string, ): boolean { - const server = sameServerOriginSql("r.source_origin", host); + const server = serverKey(host); if (!server) { return false; } + // `source_origin` holds normalized origins: every SaaS origin is one + // server (as in `serverKey`), anything else must match exactly. + const saas = server === SAAS_SERVER_KEY; + const sameServer = saas + ? "(r.source_origin = 'https://sentry.io' OR r.source_origin LIKE 'https://%.sentry.io')" + : "r.source_origin = ?"; const id = orgId ?? null; const row = getDatabase() .query( - `SELECT EXISTS ( - SELECT 1 FROM org_regions r - WHERE r.credential_identity = ? AND ${server.sql} AND ( - r.org_slug = ? OR r.org_id = ? OR EXISTS ( - SELECT 1 FROM org_regions m - WHERE m.org_id = ? AND m.org_slug = r.org_slug - AND m.source_origin = r.source_origin - ) + `SELECT 1 FROM org_regions r + WHERE r.credential_identity = ? AND ${sameServer} AND ( + r.org_slug = ? OR r.org_id = ? OR EXISTS ( + SELECT 1 FROM org_regions m + WHERE m.org_id = ? AND m.org_slug = r.org_slug + AND m.source_origin = r.source_origin ) - ) AS found`, + ) + LIMIT 1`, ) - .get(identity, ...server.params, orgSlug, id, id) as { found: number }; - return row.found === 1; -} - -/** - * SQL form of {@link isSameServer} for a column of normalized origins (as - * `org_regions.source_origin` stores them): `{@link serverKey}`'s - * canonicalization, so every SaaS origin (`https://sentry.io` and - * `https://*.sentry.io`) matches as one server and any other server only by - * its exact origin. `undefined` when `host` has no server key. - */ -function sameServerOriginSql( - column: string, - host: string, -): { sql: string; params: string[] } | undefined { - const key = serverKey(host); - if (key === undefined) { - return; - } - if (key === SAAS_SERVER_KEY) { - // Normalized origins carry no path, credentials, or default port, so - // this is exactly `isSaaSTrustOrigin` for them. - return { - sql: `(${column} = 'https://sentry.io' OR ${column} LIKE 'https://%.sentry.io')`, - params: [], - }; - } - return { sql: `${column} = ?`, params: [key] }; + .get(identity, ...(saas ? [] : [server]), orgSlug, id, id); + return Boolean(row); } From a6e1314c6a362ad8561b6d4cd35691dd899c473c Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 15:19:01 +0000 Subject: [PATCH 7/7] fix(cli): Only treat the env token as an account on a trusted server A self-hosted SENTRY_HOST/SENTRY_URL can be injected in layered CI without access to the token, so it alone must not make a plain env token an account next to stored logins: that would send the token to the injected server. The env token is now an account only on its sntrys_ claim's server or on SaaS; other tokens keep the stored-login-first behavior, and SENTRY_FORCE_ENV_TOKEN opts in. Co-Authored-By: byk --- apps/cli-docs/src/fragments/commands/auth.md | 9 ++-- packages/cli/src/commands/auth/list.ts | 2 +- packages/cli/src/commands/org/list.ts | 6 +-- packages/cli/src/lib/db/auth.ts | 6 +-- packages/cli/src/lib/env-registry.ts | 2 +- packages/cli/src/lib/env-token-host.ts | 23 +++++++++-- packages/cli/test/commands/org/list.test.ts | 23 +++++++++-- .../cli/test/lib/db/account-routing.test.ts | 41 +++++++++++++++---- 8 files changed, 86 insertions(+), 26 deletions(-) diff --git a/apps/cli-docs/src/fragments/commands/auth.md b/apps/cli-docs/src/fragments/commands/auth.md index 9232946dd..f4318a80e 100644 --- a/apps/cli-docs/src/fragments/commands/auth.md +++ b/apps/cli-docs/src/fragments/commands/auth.md @@ -108,9 +108,12 @@ that fits what they target, without changing the active account: URL for another account's server (for example a self-hosted issue link) uses the account stored for that server. - **Environment token** — `SENTRY_AUTH_TOKEN` (or `SENTRY_TOKEN`) acts as one - more account, never stored, when its server is known: an organization auth - token's own URL, else `SENTRY_HOST`/`SENTRY_URL` from the environment, else - sentry.io (never `.sentryclirc` or a pasted URL). `sentry org list` lists its + more account, never stored, when its server is known from a trusted source: + an organization auth token's own URL, else sentry.io (including a sentry.io + `SENTRY_HOST`/`SENTRY_URL`; never `.sentryclirc` or a pasted URL). A + self-hosted `SENTRY_HOST`/`SENTRY_URL` alone doesn't count, since CI steps + can set it without access to the token: use an organization auth token, or + `SENTRY_FORCE_ENV_TOKEN=1`. `sentry org list` lists its organizations under `env:SENTRY_AUTH_TOKEN` and caches them; commands targeting one of them then use the env token, even when a stored account knows the organization too. It also serves its diff --git a/packages/cli/src/commands/auth/list.ts b/packages/cli/src/commands/auth/list.ts index 66cd92871..d2b018fd1 100644 --- a/packages/cli/src/commands/auth/list.ts +++ b/packages/cli/src/commands/auth/list.ts @@ -29,7 +29,7 @@ export const listCommand = buildCommand({ "The active account (marked with *) is used for commands. Log in again to add\n" + "another account, and use 'sentry auth switch' to change the active one.\n\n" + "SENTRY_AUTH_TOKEN (or SENTRY_TOKEN) is listed too when its server is known\n" + - "(SENTRY_HOST/SENTRY_URL from the environment, or sentry.io), with the user\n" + + "(an organization auth token's own URL, or sentry.io), with the user\n" + "it belongs to when the server says. It is never stored and can't be\n" + "switched to: commands targeting an organization it can access use it.", }, diff --git a/packages/cli/src/commands/org/list.ts b/packages/cli/src/commands/org/list.ts index b88bf7c58..ccc5e43f7 100644 --- a/packages/cli/src/commands/org/list.ts +++ b/packages/cli/src/commands/org/list.ts @@ -408,9 +408,9 @@ export const listCommand = buildCommand({ "(an email, username, or account id; add : to pick one server's\n" + "login, e.g. jane@example.com:sentry.example.com, as shown in ACCOUNT).\n\n" + "SENTRY_AUTH_TOKEN (or SENTRY_TOKEN) is listed as one more account,\n" + - "env:SENTRY_AUTH_TOKEN, when its server is known: SENTRY_HOST/SENTRY_URL\n" + - "from the environment, or sentry.io when neither is set. Organizations it\n" + - "can access are listed under it, and commands targeting them use it.\n" + + "env:SENTRY_AUTH_TOKEN, when its server is known: an organization auth\n" + + "token's own URL, or sentry.io. Organizations it can access are listed\n" + + "under it, and commands targeting them use it.\n" + "With SENTRY_FORCE_ENV_TOKEN=1, only the env token is used.\n\n" + "Pass a filter to only list organizations whose slug or name matches it,\n" + "ignoring case: exactly, as a prefix or substring, or with a small typo\n" + diff --git a/packages/cli/src/lib/db/auth.ts b/packages/cli/src/lib/db/auth.ts index 53404cf10..da16edf19 100644 --- a/packages/cli/src/lib/db/auth.ts +++ b/packages/cli/src/lib/db/auth.ts @@ -341,9 +341,9 @@ export type EnvAccount = Readonly<{ /** * The env token's virtual account, when its server is known from a trusted - * source: its `sntrys_` claim, `SENTRY_HOST`/`SENTRY_URL` from the boot - * env, or SaaS when neither is set (see `getTrustedEnvTokenHost`). Never - * from `.sentryclirc` or a URL argument. `undefined` without an env token + * source: its `sntrys_` claim, or SaaS (see `getTrustedEnvTokenHost`). A + * self-hosted `SENTRY_HOST`/`SENTRY_URL` alone never qualifies, nor does + * `.sentryclirc` or a URL argument. `undefined` without an env token * or a trusted server; the env token then keeps its fallback role only. */ export function getEnvAccount(): EnvAccount | undefined { diff --git a/packages/cli/src/lib/env-registry.ts b/packages/cli/src/lib/env-registry.ts index cba75d3b6..819266b9b 100644 --- a/packages/cli/src/lib/env-registry.ts +++ b/packages/cli/src/lib/env-registry.ts @@ -44,7 +44,7 @@ export const ENV_VAR_REGISTRY: readonly EnvVarEntry[] = [ { name: "SENTRY_AUTH_TOKEN", description: - "Authentication token for the Sentry API. This is the primary way to authenticate in CI/CD pipelines and scripts where interactive login is not possible.\n\nYou can create auth tokens in your [Sentry account settings](https://sentry.io/settings/account/api/auth-tokens/). When a stored OAuth login from `sentry auth login` also exists, the stored login is used by default and the env token acts as one more account: commands targeting an organization it can access (as listed by `sentry org list`) use it, and so does its own server when no stored login is on it. That requires its server to be known: `SENTRY_HOST`/`SENTRY_URL` from the environment, or sentry.io when neither is set. Set `SENTRY_FORCE_ENV_TOKEN=1` to use only the env token.", + "Authentication token for the Sentry API. This is the primary way to authenticate in CI/CD pipelines and scripts where interactive login is not possible.\n\nYou can create auth tokens in your [Sentry account settings](https://sentry.io/settings/account/api/auth-tokens/). When a stored OAuth login from `sentry auth login` also exists, the stored login is used by default and the env token acts as one more account: commands targeting an organization it can access (as listed by `sentry org list`) use it, and so does its own server when no stored login is on it. That requires its server to be known from a trusted source: an organization auth token's own URL, or sentry.io (a `SENTRY_HOST`/`SENTRY_URL` from the environment counts only when it is a sentry.io URL). For any other token on a self-hosted server, set `SENTRY_FORCE_ENV_TOKEN=1`. Set `SENTRY_FORCE_ENV_TOKEN=1` to use only the env token.", example: "sntrys_YOUR_TOKEN_HERE", topLevel: true, briefDescription: "Auth token used for API requests (CI, scripts).", diff --git a/packages/cli/src/lib/env-token-host.ts b/packages/cli/src/lib/env-token-host.ts index aff01fe6b..9176120c9 100644 --- a/packages/cli/src/lib/env-token-host.ts +++ b/packages/cli/src/lib/env-token-host.ts @@ -34,6 +34,7 @@ * 4. getDefaultUrl() fallback ← may write env.SENTRY_URL */ +import { isSaaSTrustOrigin } from "@sentry/toolkit-core/sentry-origin"; import { DEFAULT_SENTRY_URL, normalizeUrl } from "./constants.js"; import { getRawEnvToken } from "./db/auth.js"; import { getEnv } from "./env.js"; @@ -42,6 +43,8 @@ import { parseSntrysClaim } from "./token-claims.js"; type HostSnapshot = { host: string; + /** Whether `host` came from the token's `sntrys_` claim. */ + hostFromClaim: boolean; configuredHost: string | null; claimError?: ConfigError; /** @@ -135,6 +138,7 @@ export function captureEnvTokenHost(): void { snapshotState.byEnv.set(env, { configuredHost, host: claim.host ?? configuredHost ?? DEFAULT_SENTRY_URL, + hostFromClaim: claim.host !== undefined, ...(claim.error ? { claimError: claim.error } : {}), ...(token ? { token } : {}), }); @@ -159,11 +163,19 @@ export function getEnvTokenHost(): string { /** * The env token's host when it is known from a trusted source: the token's - * `sntrys_` claim, or `SENTRY_HOST`/`SENTRY_URL` from the boot env, else - * SaaS. `undefined` when the current env token was not present at the + * `sntrys_` claim, or a Sentry SaaS origin (the default when no server is + * configured). Gates using the env token as an account of its own, next to + * stored logins (see `getEnvAccount` in `db/auth.ts`). + * + * A self-hosted `SENTRY_HOST`/`SENTRY_URL` alone is not enough: in layered + * CI it can be injected without access to the token (see the trust model + * above), and treating it as the token's server would send the token there + * even when a stored login exists. Such tokens keep the stored-login-first + * behavior; `SENTRY_FORCE_ENV_TOKEN` opts into them explicitly. + * + * `undefined` as well when the current env token was not present at the * snapshot (a `.sentryclirc` token, whose server is unknown) or its claim - * is malformed. Gates using the env token as an account of its own (see - * `getEnvAccount` in `db/auth.ts`). + * is malformed. */ export function getTrustedEnvTokenHost(): string | undefined { const env = getEnv(); @@ -175,6 +187,9 @@ export function getTrustedEnvTokenHost(): string | undefined { if (!(snapshot && token) || snapshot.claimError || snapshot.token !== token) { return; } + if (!(snapshot.hostFromClaim || isSaaSTrustOrigin(snapshot.host))) { + return; + } return snapshot.host; } diff --git a/packages/cli/test/commands/org/list.test.ts b/packages/cli/test/commands/org/list.test.ts index 1e7971ca2..cf35041cd 100644 --- a/packages/cli/test/commands/org/list.test.ts +++ b/packages/cli/test/commands/org/list.test.ts @@ -18,6 +18,7 @@ import { getEnvTokenHost } from "../../../src/lib/env-token-host.js"; import { setUserInfo } from "../../../src/lib/db/user.js"; import { AuthError, ResolutionError } from "../../../src/lib/errors.js"; import { + mintSntrysToken, resetHostScopingState, useEnvSandbox, useTestConfigDir, @@ -564,20 +565,34 @@ describe("org list with an env token", () => { expect(out).toContain("env:SENTRY_TOKEN"); }); - test("uses the server configured at boot", async () => { - process.env.SENTRY_AUTH_TOKEN = "env-token"; + test("uses its org auth token's server", async () => { + const token = mintSntrysToken({ iat: 1, url: ACME, org: "ci-org" }); + process.env.SENTRY_AUTH_TOKEN = token; process.env.SENTRY_URL = ACME; addAccount("acme-token", ACME, "jane@acme.com"); - orgsByToken["env-token"] = [org("ci-org", ACME)]; + orgsByToken[token] = [org("ci-org", ACME)]; orgsByToken["acme-token"] = [org("acme-internal", ACME)]; const entries = await runList(); expect(entries.map((e) => e.slug)).toEqual(["ci-org", "acme-internal"]); - expect(requests).toContain(`${ACME} env-token`); + expect(requests).toContain(`${ACME} ${token}`); expect(requests.some((r) => r.startsWith(`${SAAS} `))).toBe(false); }); + test("leaves out a token on a self-hosted server only the env names", async () => { + process.env.SENTRY_AUTH_TOKEN = "env-token"; + process.env.SENTRY_URL = ACME; + addAccount("acme-token", ACME, "jane@acme.com"); + orgsByToken["env-token"] = [org("ci-org", ACME)]; + orgsByToken["acme-token"] = [org("acme-internal", ACME)]; + + const entries = await runList(); + + expect(entries.map((e) => e.slug)).toEqual(["acme-internal"]); + expect(requests.some((r) => r.includes("env-token"))).toBe(false); + }); + test("an explicit server set after boot on another server leaves it out", async () => { process.env.SENTRY_AUTH_TOKEN = "env-token"; getEnvTokenHost(); diff --git a/packages/cli/test/lib/db/account-routing.test.ts b/packages/cli/test/lib/db/account-routing.test.ts index 326a8b24a..1ef461024 100644 --- a/packages/cli/test/lib/db/account-routing.test.ts +++ b/packages/cli/test/lib/db/account-routing.test.ts @@ -43,6 +43,7 @@ import { clearSentryCliRcCache, } from "../../../src/lib/sentryclirc.js"; import { + mintSntrysToken, resetHostScopingState, useEnvSandbox, useTestConfigDir, @@ -59,6 +60,8 @@ useEnvSandbox([ const SAAS = "https://sentry.io"; const ACME = "https://sentry.acme.com"; +/** An org auth token issued by ACME: its claim names its server. */ +const ACME_ORG_TOKEN = mintSntrysToken({ iat: 1, url: ACME, org: "ci" }); function login(token: string, host: string, userId: string): string { const id = setAuthToken(token, undefined, undefined, { @@ -460,14 +463,30 @@ describe("the env token as an account", () => { }); }); - test("is an account on the server configured at boot", () => { - process.env.SENTRY_TOKEN = "env-token"; + test("is an account on its org auth token's server", () => { + process.env.SENTRY_TOKEN = ACME_ORG_TOKEN; process.env.SENTRY_URL = ACME; expect(getEnvAccount()?.host).toBe(ACME); expect(getEnvAccount()?.envVar).toBe("SENTRY_TOKEN"); }); + test("is no account on a self-hosted server only the env names", () => { + // In layered CI, SENTRY_URL can be injected without access to the + // token: it alone must not make the token an account sent there. + process.env.SENTRY_TOKEN = "env-token"; + process.env.SENTRY_URL = ACME; + + expect(getEnvAccount()).toBeUndefined(); + }); + + test("is an account on a SaaS server named by the env", () => { + process.env.SENTRY_TOKEN = "env-token"; + process.env.SENTRY_URL = "https://us.sentry.io"; + + expect(getEnvAccount()?.host).toBe("https://us.sentry.io"); + }); + test("is no account when the token appeared after boot, e.g. from .sentryclirc", () => { login("saas-token", SAAS, "saas"); // The boot snapshot saw no env token. @@ -526,13 +545,13 @@ describe("the env token as an account", () => { }); test("ignores orgs it saw while scoped to another server", () => { - process.env.SENTRY_AUTH_TOKEN = "env-token"; + process.env.SENTRY_AUTH_TOKEN = ACME_ORG_TOKEN; process.env.SENTRY_URL = ACME; login("acme-token", ACME, "acme"); seeOrg("shared", ACME); - // Cached by an earlier invocation where the same token was on SaaS: - // ACME's `shared` is a different org. - envSeesOrg("env-token", "shared", SAAS); + // Cached under the same identity on SaaS: ACME's `shared` is a + // different org. + envSeesOrg(ACME_ORG_TOKEN, "shared", SAAS); selectAccountForOrg("shared"); @@ -627,11 +646,19 @@ describe("the env token as an account", () => { }); test("serves its configured server when no stored account is on it", () => { + process.env.SENTRY_AUTH_TOKEN = ACME_ORG_TOKEN; + process.env.SENTRY_URL = ACME; + login("saas-token", SAAS, "saas"); + + expect(getAuthToken()).toBe(ACME_ORG_TOKEN); + }); + + test("never serves a self-hosted server only the env names", () => { process.env.SENTRY_AUTH_TOKEN = "env-token"; process.env.SENTRY_URL = ACME; login("saas-token", SAAS, "saas"); - expect(getAuthToken()).toBe("env-token"); + expect(getAuthToken()).not.toBe("env-token"); }); test("yields its configured server to a stored account on it", () => {