Skip to content

Commit e37329b

Browse files
authored
fix(api): centralize 403 Forbidden enrichment with actionable hints (CLI-1JG) (#892)
## Summary - Centralize 403 Forbidden error enrichment in `throwApiError()` / `throwRawApiError()` so **all 60+ API endpoints** get actionable error messages instead of the raw `"You do not have permission to perform this action."` - For env-var tokens: extract specific missing scope names from the API response and link to `https://sentry.io/settings/auth-tokens/` - For OAuth tokens: suggest re-authentication with `sentry auth login` - Add `enriched403` flag to `ApiError` to prevent double-enrichment where command-layer code already has specialized 403 handling (issue list, dashboard, project delete) Fixes CLI-1JG — a user hit `sentry org view` with an auto-detected org and got a completely unhelpful 403 error. ## Before ``` Error: Failed to get organization: 403 Forbidden You do not have permission to perform this action. ``` ## After (env-var token) ``` Error: Failed to get organization: 403 Forbidden You do not have permission to perform this action. Your SENTRY_AUTH_TOKEN token may lack the required scope for this operation. Check token scopes at: https://sentry.io/settings/auth-tokens/ ``` ## After (OAuth token) ``` Error: Failed to get organization: 403 Forbidden You do not have permission to perform this action. You may not have access to this resource. Re-authenticate with: sentry auth login ```
1 parent 046936a commit e37329b

6 files changed

Lines changed: 358 additions & 104 deletions

File tree

src/commands/dashboard/resolve.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -636,6 +636,13 @@ export function enrichDashboardError(
636636
}
637637

638638
if (error.status === 403) {
639+
// Centralized 403 enrichment (infrastructure.ts) already added
640+
// scope/token hints. Re-throw the enriched ApiError directly —
641+
// passing the multi-line enriched detail into build403Error would
642+
// nest it as a single messy bullet in a ResolutionError.
643+
if (error.enriched403) {
644+
throw error;
645+
}
639646
build403Error(ctx, org, error.detail);
640647
}
641648

src/commands/issue/list.ts

Lines changed: 24 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1101,11 +1101,17 @@ function enrichIssueListError(
11011101
);
11021102
}
11031103
if (error.status === 403) {
1104+
// Centralized 403 enrichment (infrastructure.ts) already added
1105+
// scope/token hints. Only append the project-membership hint.
1106+
const detail = error.enriched403
1107+
? appendProjectMembershipHint(error.detail)
1108+
: build403Detail(error.detail);
11041109
throw new ApiError(
11051110
error.message,
11061111
error.status,
1107-
build403Detail(error.detail),
1108-
error.endpoint
1112+
detail,
1113+
error.endpoint,
1114+
true
11091115
);
11101116
}
11111117
}
@@ -1170,6 +1176,17 @@ function build403Detail(originalDetail: unknown): string {
11701176
return lines.join("\n ");
11711177
}
11721178

1179+
/**
1180+
* Append a project membership verification hint to an already-enriched
1181+
* 403 detail string. Used when centralized enrichment (infrastructure.ts)
1182+
* has already added scope/token hints and we only need the issue-list-specific
1183+
* suggestion.
1184+
*/
1185+
function appendProjectMembershipHint(detail: string | undefined): string {
1186+
const base = detail ?? "You do not have permission to perform this action.";
1187+
return `${base}\n Verify project membership: sentry project list <org>/`;
1188+
}
1189+
11731190
/**
11741191
* Handle auto-detect, explicit, and project-search modes.
11751192
*
@@ -1327,13 +1344,16 @@ async function handleResolvedTargets(
13271344
if (first.status === 400) {
13281345
detail = build400Detail(first.detail, flags);
13291346
} else if (first.status === 403) {
1330-
detail = build403Detail(first.detail);
1347+
detail = first.enriched403
1348+
? appendProjectMembershipHint(first.detail)
1349+
: build403Detail(first.detail);
13311350
}
13321351
throw new ApiError(
13331352
`${prefix}: ${first.message}`,
13341353
first.status,
13351354
detail,
1336-
first.endpoint
1355+
first.endpoint,
1356+
first.enriched403 || first.status === 403
13371357
);
13381358
}
13391359

src/lib/api/infrastructure.ts

Lines changed: 115 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ import { parseSentryLinkHeader } from "@sentry/api";
1111
import * as Sentry from "@sentry/node-core/light";
1212
import type { z } from "zod";
1313

14+
import { extractRequiredScopes } from "../api-scope.js";
15+
import { getActiveEnvVarName, isEnvTokenActive } from "../db/auth.js";
1416
import { getEnv } from "../env.js";
1517
import { ApiError, AuthError, stringifyUnknown } from "../errors.js";
1618
import { resolveOrgRegion } from "../region.js";
@@ -20,6 +22,47 @@ import {
2022
getSdkConfig,
2123
} from "../sentry-client.js";
2224

25+
/**
26+
* Enrich a 403 Forbidden error detail with actionable guidance.
27+
*
28+
* For env-var tokens (SENTRY_AUTH_TOKEN / SENTRY_TOKEN): extracts the specific
29+
* missing scope from the API response when available, otherwise suggests
30+
* checking token scopes. Includes a link to the token settings page.
31+
*
32+
* For OAuth tokens: suggests the user may lack access and should re-authenticate.
33+
*
34+
* @param rawDetail - The original detail string from the API 403 response
35+
* @returns Enriched detail string with actionable suggestions
36+
*/
37+
function enrich403Detail(rawDetail: string | undefined): string {
38+
const lines: string[] = [];
39+
if (rawDetail) {
40+
lines.push(rawDetail, "");
41+
}
42+
43+
if (isEnvTokenActive()) {
44+
const scopes = extractRequiredScopes(rawDetail);
45+
if (scopes.length > 0) {
46+
lines.push(
47+
`Your ${getActiveEnvVarName()} token is missing the required scope(s) '${scopes.join("', '")}'.`
48+
);
49+
} else {
50+
lines.push(
51+
`Your ${getActiveEnvVarName()} token may lack the required scope for this operation.`
52+
);
53+
}
54+
lines.push(
55+
"Check token scopes at: https://sentry.io/settings/auth-tokens/"
56+
);
57+
} else {
58+
lines.push(
59+
"You may not have access to this resource.",
60+
"Re-authenticate with: sentry auth login"
61+
);
62+
}
63+
return lines.join("\n ");
64+
}
65+
2366
/**
2467
* Parse Sentry's RFC 5988 Link response header to extract pagination cursors.
2568
*
@@ -67,15 +110,26 @@ export function throwApiError(
67110
}
68111

69112
const status = response.status;
70-
const detail =
113+
const rawDetail =
71114
error && typeof error === "object" && "detail" in error
72-
? stringifyUnknown((error as { detail: unknown }).detail)
73-
: stringifyUnknown(error);
74-
115+
? (error as { detail: unknown }).detail
116+
: undefined;
117+
const hasUsableDetail = rawDetail !== null && rawDetail !== undefined;
118+
// When the API returns `{ detail: null }` or `{ detail: undefined }`,
119+
// fall back to stringifying the whole error object for non-403 errors
120+
// (useful for debugging). For 403s, pass undefined to enrich403Detail
121+
// so the enrichment stands alone without a noisy `{}` prefix.
122+
const detail = hasUsableDetail
123+
? stringifyUnknown(rawDetail)
124+
: stringifyUnknown(error);
125+
126+
const is403 = status === 403;
75127
throw new ApiError(
76128
`${context}: ${status} ${response.statusText ?? "Unknown"}`,
77129
status,
78-
detail
130+
is403 ? enrich403Detail(hasUsableDetail ? detail : undefined) : detail,
131+
undefined,
132+
is403
79133
);
80134
}
81135

@@ -303,38 +357,7 @@ export async function apiRequestToRegion<T>(
303357
});
304358

305359
if (!response.ok) {
306-
let detail: string | undefined;
307-
try {
308-
const text = await response.text();
309-
try {
310-
const parsed = JSON.parse(text) as { detail?: string };
311-
detail = parsed.detail ?? JSON.stringify(parsed);
312-
} catch {
313-
detail = text;
314-
}
315-
} catch {
316-
detail = response.statusText;
317-
}
318-
// Attach a small allowlisted subset of response headers to the Sentry
319-
// event as context. This lets us distinguish Sentry-app 4xx/5xx (which
320-
// ship a `{"detail": "..."}` JSON body and `content-type: application/json`)
321-
// from CDN / WAF / edge 4xx (Cloudflare / proxy) that return empty or HTML
322-
// bodies — a gap that previously made empty-`detail` events like CLI-1AZ
323-
// impossible to triage without user-side repro.
324-
Sentry.setContext("api_response_headers", {
325-
"content-type": response.headers.get("content-type"),
326-
"content-length": response.headers.get("content-length"),
327-
server: response.headers.get("server"),
328-
"cf-ray": response.headers.get("cf-ray"),
329-
"x-sentry-error": response.headers.get("x-sentry-error"),
330-
"www-authenticate": response.headers.get("www-authenticate"),
331-
});
332-
throw new ApiError(
333-
`API request failed: ${response.status} ${response.statusText}`,
334-
response.status,
335-
detail,
336-
endpoint
337-
);
360+
await throwRawApiError(response, endpoint);
338361
}
339362

340363
// 204 No Content / 205 Reset Content have no body by spec — calling
@@ -377,6 +400,61 @@ export async function apiRequestToRegion<T>(
377400
return { data: data as T, headers: response.headers };
378401
}
379402

403+
/**
404+
* Extract error detail from a failed HTTP response, attach diagnostic
405+
* headers to the Sentry scope, and throw an enriched {@link ApiError}.
406+
*
407+
* Extracted from `apiRequestToRegion` to keep the main function's
408+
* cognitive complexity under the lint threshold.
409+
*/
410+
async function throwRawApiError(
411+
response: Response,
412+
endpoint: string
413+
): Promise<never> {
414+
let detail: string | undefined;
415+
try {
416+
const text = await response.text();
417+
try {
418+
const parsed = JSON.parse(text) as { detail?: string };
419+
// Prefer the explicit `detail` field; fall back to the full JSON
420+
// for non-403 errors (useful for debugging). For 403s, pass
421+
// undefined so enrich403Detail stands alone without a noisy
422+
// `{"detail":null}` prefix.
423+
if (typeof parsed.detail === "string") {
424+
detail = parsed.detail;
425+
} else if (response.status !== 403) {
426+
detail = JSON.stringify(parsed);
427+
}
428+
} catch {
429+
detail = text || undefined;
430+
}
431+
} catch {
432+
detail = response.statusText;
433+
}
434+
// Attach a small allowlisted subset of response headers to the Sentry
435+
// event as context. This lets us distinguish Sentry-app 4xx/5xx (which
436+
// ship a `{"detail": "..."}` JSON body and `content-type: application/json`)
437+
// from CDN / WAF / edge 4xx (Cloudflare / proxy) that return empty or HTML
438+
// bodies — a gap that previously made empty-`detail` events like CLI-1AZ
439+
// impossible to triage without user-side repro.
440+
Sentry.setContext("api_response_headers", {
441+
"content-type": response.headers.get("content-type"),
442+
"content-length": response.headers.get("content-length"),
443+
server: response.headers.get("server"),
444+
"cf-ray": response.headers.get("cf-ray"),
445+
"x-sentry-error": response.headers.get("x-sentry-error"),
446+
"www-authenticate": response.headers.get("www-authenticate"),
447+
});
448+
const is403 = response.status === 403;
449+
throw new ApiError(
450+
`API request failed: ${response.status} ${response.statusText}`,
451+
response.status,
452+
is403 ? enrich403Detail(detail) : detail,
453+
endpoint,
454+
is403
455+
);
456+
}
457+
380458
/**
381459
* Make an authenticated request to the default Sentry API.
382460
*

src/lib/api/organizations.ts

Lines changed: 10 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,6 @@ import {
1616
UserRegionsResponseSchema,
1717
} from "../../types/index.js";
1818

19-
import { extractRequiredScopes } from "../api-scope.js";
20-
import { getActiveEnvVarName, isEnvTokenActive } from "../db/auth.js";
2119
import { ApiError, withAuthGuard } from "../errors.js";
2220
import {
2321
getApiBaseUrl,
@@ -62,67 +60,18 @@ export async function listOrganizationsInRegion(
6260
...config,
6361
});
6462

65-
try {
66-
const data = unwrapResult(result, "Failed to list organizations");
67-
if (!Array.isArray(data)) {
68-
throw new ApiError(
69-
"Failed to list organizations: unexpected response format",
70-
0,
71-
`Expected an array from ${regionUrl}/api/0/organizations/ but received ${typeof data}. ` +
72-
"This may indicate an incompatible self-hosted Sentry version or a proxy interfering with the response."
73-
);
74-
}
75-
return data as unknown as SentryOrganization[];
76-
} catch (error) {
77-
// Enrich 403 errors with contextual guidance (CLI-89, 24 users).
78-
// Only mention token scopes when using a custom env-var token —
79-
// the regular `sentry auth login` OAuth flow always grants org:read.
80-
if (error instanceof ApiError && error.status === 403) {
81-
throw enrichListOrgsForbidden(error);
82-
}
83-
throw error;
84-
}
85-
}
86-
87-
/**
88-
* Enrich a 403 from the list-organizations endpoint with actionable
89-
* hints. Prefers scope(s) named explicitly in the API response over
90-
* the hardcoded `'org:read'` fallback (getsentry/cli#785 item #9).
91-
*/
92-
function enrichListOrgsForbidden(error: ApiError): ApiError {
93-
const lines: string[] = [];
94-
if (error.detail) {
95-
lines.push(error.detail, "");
96-
}
97-
if (isEnvTokenActive()) {
98-
lines.push(buildEnvTokenScopeHint(error.detail));
99-
lines.push(
100-
"Check token scopes at: https://sentry.io/settings/auth-tokens/"
63+
// 403 enrichment (CLI-89, 24 users) is now handled centrally by
64+
// throwApiError() in infrastructure.ts — no per-endpoint catch needed.
65+
const data = unwrapResult(result, "Failed to list organizations");
66+
if (!Array.isArray(data)) {
67+
throw new ApiError(
68+
"Failed to list organizations: unexpected response format",
69+
0,
70+
`Expected an array from ${regionUrl}/api/0/organizations/ but received ${typeof data}. ` +
71+
"This may indicate an incompatible self-hosted Sentry version or a proxy interfering with the response."
10172
);
102-
} else {
103-
lines.push(
104-
"You may not have access to this organization.",
105-
"Re-authenticate with: sentry auth login"
106-
);
107-
}
108-
return new ApiError(
109-
error.message,
110-
error.status,
111-
lines.join("\n "),
112-
error.endpoint
113-
);
114-
}
115-
116-
/**
117-
* Build a single-line hint mentioning the scope(s) the env-var token
118-
* is missing, preferring the API-provided list when available.
119-
*/
120-
function buildEnvTokenScopeHint(detail: unknown): string {
121-
const scopes = extractRequiredScopes(detail);
122-
if (scopes.length > 0) {
123-
return `Your ${getActiveEnvVarName()} token is missing the required scope(s) '${scopes.join("', '")}'.`;
12473
}
125-
return `Your ${getActiveEnvVarName()} token may lack the required scope 'org:read'.`;
74+
return data as unknown as SentryOrganization[];
12675
}
12776

12877
/**

src/lib/errors.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,17 +177,27 @@ export class ApiError extends CliError {
177177
readonly detail?: string;
178178
readonly endpoint?: string;
179179

180+
/**
181+
* Set by centralized 403 enrichment in `infrastructure.ts`.
182+
* Command-layer code can check this to avoid double-enriching
183+
* the error detail with scope/token hints.
184+
*/
185+
readonly enriched403: boolean;
186+
187+
// biome-ignore lint/nursery/useMaxParams: established 4-param shape; enriched403 is a defaulted extension
180188
constructor(
181189
message: string,
182190
status: number,
183191
detail?: string,
184-
endpoint?: string
192+
endpoint?: string,
193+
enriched403 = false
185194
) {
186195
super(message, EXIT.API);
187196
this.name = "ApiError";
188197
this.status = status;
189198
this.detail = detail;
190199
this.endpoint = endpoint;
200+
this.enriched403 = enriched403;
191201
}
192202

193203
override format(): string {

0 commit comments

Comments
 (0)