diff --git a/apps/cli-docs/src/content/docs/self-hosted.md b/apps/cli-docs/src/content/docs/self-hosted.md index 021622fd4..4301ef43e 100644 --- a/apps/cli-docs/src/content/docs/self-hosted.md +++ b/apps/cli-docs/src/content/docs/self-hosted.md @@ -56,7 +56,7 @@ If your instance is on an older version or you prefer not to create an OAuth app 1. Go to **Settings → Developer Settings → Personal Tokens** in your Sentry instance (or visit `https://sentry.example.com/settings/account/api/auth-tokens/new-token/`) 2. Create a new token with the following scopes: -`project:read`, `project:write`, `project:admin`, `org:read`, `event:read`, `event:write`, `member:read`, `team:read`, `team:write`, `alerts:read`, `alerts:write` +`project:read`, `project:write`, `project:admin`, `org:read`, `event:read`, `event:write`, `member:read`, `team:read`, `team:write`, `team:admin`, `alerts:read`, `alerts:write` 3. Pass it to the CLI: diff --git a/packages/cli/DEVELOPMENT.md b/packages/cli/DEVELOPMENT.md index 98a3630f9..4bc3f3a3d 100644 --- a/packages/cli/DEVELOPMENT.md +++ b/packages/cli/DEVELOPMENT.md @@ -69,7 +69,7 @@ When creating your Sentry OAuth application: - `org:read` - `event:read`, `event:write` - `member:read` - - `team:read`, `team:write` + - `team:read`, `team:write`, `team:admin` - `alerts:read`, `alerts:write` diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 9d9169f31..d7e8a811b 100644 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -243,9 +243,7 @@ export async function runCli(cliArgs: string[]): Promise { ); const { error } = await import("./lib/formatters/colors.js"); const { runInteractiveLogin } = await import("./lib/interactive-login.js"); - const { assertAutoLoginHostTrusted, recoverWithAutoLogin } = await import( - "./lib/auto-auth.js" - ); + const { recoverWithAutoLogin } = await import("./lib/auto-auth.js"); const { getEnvLogLevel, setLogLevel } = await import("./lib/logger.js"); const { isTrialEligible, promptAndStartTrial } = await import( "./lib/seer-trial.js" @@ -435,97 +433,17 @@ export async function runCli(cliArgs: string[]): Promise { } }; - /** - * Check whether a caught error is a recoverable 403 missing-scope error. - * - * Returns the extracted scope names when all conditions are met: - * - Interactive TTY (stdin) - * - Error is an `ApiError` with status 403 - * - Token is an OAuth token (not env-var — those can't be re-scoped via CLI) - * - The 403 detail mentions specific missing scopes - * - * Returns `null` when recovery is not possible, signaling the caller to - * re-throw. - */ - async function extractRecoverableScopes( - err: unknown - ): Promise { - if (!isatty(0)) { - return null; - } - const { ApiError } = await import("./lib/errors.js"); - if (!(err instanceof ApiError) || err.status !== 403) { - return null; - } - const { isEnvTokenActive } = await import("./lib/db/auth.js"); - if (isEnvTokenActive()) { - return null; - } - const { extractRequiredScopes } = await import("./lib/api-scope.js"); - const scopes = extractRequiredScopes(err.detail); - return scopes.length > 0 ? scopes : null; - } - /** * Scope recovery middleware. * - * Catches 403 Forbidden errors for OAuth tokens (not env-var tokens) in - * interactive TTYs. When specific missing scopes are detected in the API - * response, offers to re-authenticate with those scopes and retries the - * command — mirroring `gh auth refresh -s `. - * - * Env-var tokens are excluded: the user must regenerate those manually - * via the Sentry web UI (the 403 enrichment already directs them there). + * Existing stored OAuth grants may predate the current standard scope set. + * On a scope-specific 403, offer one refresh with today's defaults and retry + * exactly once. Non-interactive and explicitly unattended commands never + * enter a device flow. */ const scopeRecoveryMiddleware: ErrorMiddleware = async (next, argv) => { - try { - await next(argv); - } catch (err) { - const scopes = await extractRecoverableScopes(err); - if (!scopes) { - throw err; - } - - // Same host-trust gate as auto-login: re-authenticating to add scopes - // also runs the OAuth device flow, so refuse an unconfirmed self-hosted - // host before prompting (an injected env.SENTRY_URL must not steer the - // browser to an attacker's login page). - assertAutoLoginHostTrusted(); - - const scopeList = scopes.map((s) => `'${s}'`).join(", "); - const { logger: logModule } = await import("./lib/logger.js"); - const confirmed = await logModule - .withTag("auth") - .prompt( - `Missing scope(s): ${scopeList}. Re-authenticate with default scopes?`, - { type: "confirm", initial: true } - ); - - // Symbol(clack:cancel) is truthy — strict equality check - if (confirmed !== true) { - throw err; - } - - process.stderr.write("\n"); - // Merge missing scopes with the default set so the new token retains - // all previously-held scopes plus the ones the API requested. - const { OAUTH_SCOPES, resolveOAuthScopeString } = await import( - "./lib/oauth.js" - ); - const merged = [...new Set([...OAUTH_SCOPES, ...scopes])]; - const scope = resolveOAuthScopeString({ scopes: merged }); - const loginSuccess = await runInteractiveLogin({ scope }); - - if (loginSuccess) { - process.stderr.write("\nRetrying command...\n\n"); - await next(argv); - return; - } - - // Login failed or was cancelled — re-throw so the user sees the - // original 403 message with the scope hint. - throw err; - } + const { runWithScopeRecovery } = await import("./lib/scope-recovery.js"); + await runWithScopeRecovery(next, argv, runInteractiveLogin); }; /** diff --git a/packages/cli/src/lib/api-scope.ts b/packages/cli/src/lib/api-scope.ts index a5049d5f1..3228669c0 100644 --- a/packages/cli/src/lib/api-scope.ts +++ b/packages/cli/src/lib/api-scope.ts @@ -61,6 +61,11 @@ export function extractRequiredScopes(detail: unknown): string[] { if (!detail) { return []; } + const serializedDetail = + typeof detail === "string" ? detail : JSON.stringify(detail); + if (isMemberProjectCreationPolicy(serializedDetail)) { + return []; + } if (typeof detail === "object") { const fromFields = extractFromRecord(detail as Record); if (fromFields.length > 0) { @@ -75,6 +80,15 @@ export function extractRequiredScopes(detail: unknown): string[] { return []; } +/** A role/policy denial can mention scope names without a token lacking them. */ +function isMemberProjectCreationPolicy(detail: string): boolean { + const normalized = detail.toLowerCase(); + return ( + normalized.includes("disabled this feature for members") || + normalized.includes("org-level policy setting, not an auth issue") + ); +} + function extractFromRecord(record: Record): string[] { for (const field of SCOPE_FIELD_NAMES) { const value = record[field]; diff --git a/packages/cli/src/lib/oauth.ts b/packages/cli/src/lib/oauth.ts index b86cbfb2a..05e54d920 100644 --- a/packages/cli/src/lib/oauth.ts +++ b/packages/cli/src/lib/oauth.ts @@ -90,6 +90,7 @@ export const OAUTH_SCOPES: readonly string[] = [ "member:read", "team:read", "team:write", + "team:admin", "alerts:read", "alerts:write", ]; diff --git a/packages/cli/src/lib/scope-recovery.ts b/packages/cli/src/lib/scope-recovery.ts new file mode 100644 index 000000000..91133094f --- /dev/null +++ b/packages/cli/src/lib/scope-recovery.ts @@ -0,0 +1,124 @@ +/** + * One-time recovery for stored OAuth grants that predate the CLI's current + * standard scope set. + */ + +import { isatty } from "node:tty"; +import { extractRequiredScopes } from "./api-scope.js"; +import { assertAutoLoginHostTrusted } from "./auto-auth.js"; +import { type AuthSource, getAuthConfig } from "./db/auth.js"; +import { ApiError } from "./errors.js"; +import type { + InteractiveLoginOptions, + LoginResult, +} from "./interactive-login.js"; +import { interactivePromptsAllowed } from "./interactive-prompts.js"; +import { logger } from "./logger.js"; +import { OAUTH_SCOPES, resolveOAuthScopeString } from "./oauth.js"; + +type InteractiveLogin = ( + options?: InteractiveLoginOptions +) => Promise; + +export type ScopeRecoveryRuntime = { + assertTrustedHost: () => void; + confirm: (message: string) => Promise; + getAuthSource: () => AuthSource | undefined; + inputIsTty: () => boolean; + promptsAllowed: () => boolean; + write: (message: string) => void; +}; + +const defaultRuntime: ScopeRecoveryRuntime = { + assertTrustedHost: assertAutoLoginHostTrusted, + confirm: (message) => + logger.withTag("auth").prompt(message, { + type: "confirm", + initial: true, + }), + getAuthSource: () => getAuthConfig()?.source, + inputIsTty: () => isatty(0), + promptsAllowed: interactivePromptsAllowed, + write: (message) => { + process.stderr.write(message); + }, +}; + +function disablesInteractiveRecovery(argv: string[]): boolean { + return argv.some( + (arg) => + arg === "--yes" || + arg === "-y" || + arg.startsWith("--yes=") || + arg === "--dry-run" || + arg.startsWith("--dry-run=") + ); +} + +function recoverableScopes( + error: unknown, + argv: string[], + runtime: ScopeRecoveryRuntime +): string[] | null { + let authSource: AuthSource | undefined; + try { + authSource = runtime.getAuthSource(); + } catch { + // Recovery must never replace the command's original 403 with a local + // credential-store read failure. + return null; + } + + if ( + !(runtime.inputIsTty() && runtime.promptsAllowed()) || + disablesInteractiveRecovery(argv) || + authSource !== "oauth" || + !(error instanceof ApiError) || + error.status !== 403 + ) { + return null; + } + + const scopes = extractRequiredScopes(error.detail); + return scopes.length > 0 ? scopes : null; +} + +/** + * Run a command once and, for an old interactive OAuth grant, refresh it with + * the current standard scopes before retrying the command exactly once. + */ +export async function runWithScopeRecovery( + proceed: (commandArgs: string[]) => Promise, + argv: string[], + runInteractiveLogin: InteractiveLogin, + runtime: ScopeRecoveryRuntime = defaultRuntime +): Promise { + try { + await proceed(argv); + } catch (error) { + const scopes = recoverableScopes(error, argv, runtime); + if (!scopes) { + throw error; + } + + runtime.assertTrustedHost(); + const scopeList = scopes.map((scopeName) => `'${scopeName}'`).join(", "); + const confirmed = await runtime.confirm( + `Your existing CLI authorization is missing standard scope(s) ${scopeList}. Refresh it with the current defaults?` + ); + if (confirmed !== true) { + throw error; + } + + runtime.write("\n"); + const merged = [...new Set([...OAUTH_SCOPES, ...scopes])]; + const requestedScope = resolveOAuthScopeString({ scopes: merged }); + const loginResult = await runInteractiveLogin({ scope: requestedScope }); + if (!loginResult) { + throw error; + } + + runtime.write("\nRetrying command...\n\n"); + await proceed(argv); + } +} diff --git a/packages/cli/test/lib/api-scope.test.ts b/packages/cli/test/lib/api-scope.test.ts index db2e7d50a..e983e843e 100644 --- a/packages/cli/test/lib/api-scope.test.ts +++ b/packages/cli/test/lib/api-scope.test.ts @@ -21,6 +21,16 @@ describe("extractRequiredScopes", () => { ).toEqual([]); }); + test("ignores role scopes mentioned in member-project policy guidance", () => { + expect( + extractRequiredScopes( + "Your organization has disabled this feature for members. " + + "This is an org-level policy setting, not an auth issue. " + + "You need org:admin/manager/owner role, or team:admin role on the team." + ) + ).toEqual([]); + }); + test("extracts a single scope from a detail string", () => { expect( extractRequiredScopes( diff --git a/packages/cli/test/lib/oauth.test.ts b/packages/cli/test/lib/oauth.test.ts index e0a990db2..4d32db281 100644 --- a/packages/cli/test/lib/oauth.test.ts +++ b/packages/cli/test/lib/oauth.test.ts @@ -23,6 +23,10 @@ import { DEFAULT_NUM_RUNS } from "../model-based/helpers.js"; const knownScopeArb = constantFrom(...SENTRY_SCOPES); describe("resolveOAuthScopeString", () => { + test("default scopes include Team Admin for project creation", () => { + expect(OAUTH_SCOPES).toContain("team:admin"); + }); + test("default (no selection) returns the full OAUTH_SCOPES set", () => { expect(resolveOAuthScopeString()).toBe(OAUTH_SCOPES.join(" ")); expect(resolveOAuthScopeString({})).toBe(OAUTH_SCOPES.join(" ")); diff --git a/packages/cli/test/lib/scope-recovery.test.ts b/packages/cli/test/lib/scope-recovery.test.ts new file mode 100644 index 000000000..2e5a2b6f4 --- /dev/null +++ b/packages/cli/test/lib/scope-recovery.test.ts @@ -0,0 +1,164 @@ +import { describe, expect, test, vi } from "vitest"; +import { ApiError } from "../../src/lib/errors.js"; +import { + runWithScopeRecovery, + type ScopeRecoveryRuntime, +} from "../../src/lib/scope-recovery.js"; + +function missingScopeError(): ApiError { + return new ApiError( + "Forbidden", + 403, + "You do not have the required scope: team:admin" + ); +} + +function runtime( + overrides: Partial = {} +): ScopeRecoveryRuntime { + return { + assertTrustedHost: vi.fn(), + confirm: vi.fn().mockResolvedValue(true), + getAuthSource: () => "oauth", + inputIsTty: () => true, + promptsAllowed: () => true, + write: vi.fn(), + ...overrides, + }; +} + +describe("runWithScopeRecovery", () => { + test("refreshes an old OAuth grant with current scopes and retries once", async () => { + const originalError = missingScopeError(); + const proceed = vi + .fn<(argv: string[]) => Promise>() + .mockRejectedValueOnce(originalError) + .mockResolvedValueOnce(); + const login = vi.fn().mockResolvedValue({ + method: "oauth", + configPath: "/tmp/config", + }); + const testRuntime = runtime(); + + await runWithScopeRecovery( + proceed, + ["project", "create"], + login, + testRuntime + ); + + expect(proceed).toHaveBeenCalledTimes(2); + expect(testRuntime.assertTrustedHost).toHaveBeenCalledOnce(); + expect(testRuntime.confirm).toHaveBeenCalledOnce(); + expect(login).toHaveBeenCalledOnce(); + const scope = login.mock.calls[0]?.[0]?.scope; + expect(scope?.split(" ")).toEqual( + expect.arrayContaining(["org:read", "project:write", "team:admin"]) + ); + }); + + test.each([ + [["init", "--yes"], "oauth" as const], + [["init", "-y"], "oauth" as const], + [["init", "--dry-run"], "oauth" as const], + [["project", "create"], "env:SENTRY_AUTH_TOKEN" as const], + ])("does not refresh unattended commands or env tokens", async (argv, source) => { + const originalError = missingScopeError(); + const proceed = vi.fn().mockRejectedValue(originalError); + const login = vi.fn(); + + await expect( + runWithScopeRecovery( + proceed, + argv, + login, + runtime({ getAuthSource: () => source }) + ) + ).rejects.toBe(originalError); + + expect(proceed).toHaveBeenCalledOnce(); + expect(login).not.toHaveBeenCalled(); + }); + + test("preserves the original error when the credential store cannot be read", async () => { + const originalError = missingScopeError(); + const proceed = vi.fn().mockRejectedValue(originalError); + const login = vi.fn(); + + await expect( + runWithScopeRecovery( + proceed, + [], + login, + runtime({ + getAuthSource: () => { + throw new Error("database unavailable"); + }, + }) + ) + ).rejects.toBe(originalError); + expect(login).not.toHaveBeenCalled(); + }); + + test.each([ + { inputIsTty: () => false }, + { promptsAllowed: () => false }, + ])("does not refresh outside an interactive prompt context", async (overrides) => { + const originalError = missingScopeError(); + const proceed = vi.fn().mockRejectedValue(originalError); + const login = vi.fn(); + + await expect( + runWithScopeRecovery(proceed, [], login, runtime(overrides)) + ).rejects.toBe(originalError); + expect(login).not.toHaveBeenCalled(); + }); + + test("preserves the original error when refresh is declined", async () => { + const originalError = missingScopeError(); + const proceed = vi.fn().mockRejectedValue(originalError); + const login = vi.fn(); + + await expect( + runWithScopeRecovery( + proceed, + [], + login, + runtime({ confirm: vi.fn().mockResolvedValue(false) }) + ) + ).rejects.toBe(originalError); + expect(login).not.toHaveBeenCalled(); + }); + + test("preserves the original error when login is cancelled", async () => { + const originalError = missingScopeError(); + const proceed = vi.fn().mockRejectedValue(originalError); + const login = vi.fn().mockResolvedValue(null); + + await expect( + runWithScopeRecovery(proceed, [], login, runtime()) + ).rejects.toBe(originalError); + expect(proceed).toHaveBeenCalledOnce(); + }); + + test("does not attempt a second recovery when the retry fails", async () => { + const firstError = missingScopeError(); + const retryError = missingScopeError(); + const proceed = vi + .fn<(argv: string[]) => Promise>() + .mockRejectedValueOnce(firstError) + .mockRejectedValueOnce(retryError); + const login = vi.fn().mockResolvedValue({ + method: "oauth", + configPath: "/tmp/config", + }); + const testRuntime = runtime(); + + await expect( + runWithScopeRecovery(proceed, [], login, testRuntime) + ).rejects.toBe(retryError); + expect(proceed).toHaveBeenCalledTimes(2); + expect(testRuntime.confirm).toHaveBeenCalledOnce(); + expect(login).toHaveBeenCalledOnce(); + }); +});