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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion apps/cli-docs/src/content/docs/self-hosted.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
<!-- GENERATED:START oauth-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`
<!-- GENERATED:END oauth-scopes -->
3. Pass it to the CLI:

Expand Down
2 changes: 1 addition & 1 deletion packages/cli/DEVELOPMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
<!-- GENERATED:END oauth-scopes -->

Expand Down
96 changes: 7 additions & 89 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -243,9 +243,7 @@ export async function runCli(cliArgs: string[]): Promise<void> {
);
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"
Expand Down Expand Up @@ -435,97 +433,17 @@ export async function runCli(cliArgs: string[]): Promise<void> {
}
};

/**
* 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<string[] | null> {
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 <scope>`.
*
* 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);
};

/**
Expand Down
14 changes: 14 additions & 0 deletions packages/cli/src/lib/api-scope.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>);
if (fromFields.length > 0) {
Expand All @@ -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, unknown>): string[] {
for (const field of SCOPE_FIELD_NAMES) {
const value = record[field];
Expand Down
1 change: 1 addition & 0 deletions packages/cli/src/lib/oauth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export const OAUTH_SCOPES: readonly string[] = [
"member:read",
"team:read",
"team:write",
"team:admin",
"alerts:read",
"alerts:write",
];
Expand Down
124 changes: 124 additions & 0 deletions packages/cli/src/lib/scope-recovery.ts
Original file line number Diff line number Diff line change
@@ -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<LoginResult | null>;

export type ScopeRecoveryRuntime = {
assertTrustedHost: () => void;
confirm: (message: string) => Promise<unknown>;
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<void>,
argv: string[],
runInteractiveLogin: InteractiveLogin,
runtime: ScopeRecoveryRuntime = defaultRuntime
): Promise<void> {
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);
}
}
10 changes: 10 additions & 0 deletions packages/cli/test/lib/api-scope.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
4 changes: 4 additions & 0 deletions packages/cli/test/lib/oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(" "));
Expand Down
Loading
Loading