Skip to content

Commit de6a899

Browse files
fix(cli): Name both orgs when an org auth token targets another org (#2168)
Stacked on #2165. Split out of #2160. An org auth token works only in its own org. When it targets another org, Sentry answers 403 if both orgs are on the same server, or 401 "Invalid org token" if they aren't, and neither response says why. The CLI now compares the org in the token (`sntrys_…`) with the org in the request path and names both orgs when they differ. ### Before `release create` for `other-org`, with an org auth token for `my-org` in `SENTRY_AUTH_TOKEN`: ``` Error: Failed to create release: 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: <sentry-url>/settings/account/api/auth-tokens/ ``` ``` Error: Failed to create release: 401 Unauthorized Invalid org token Your SENTRY_AUTH_TOKEN token is not recognized or has been revoked. Create a new token at: <sentry-url>/settings/account/api/auth-tokens/ ``` ### After ``` Error: Failed to create release: 403 Forbidden You do not have permission to perform this action. This org auth token belongs to organization 'my-org', but the request is for 'other-org'. Use an auth token created in 'other-org', or target 'my-org' instead. ``` ``` Error: Failed to create release: 401 Unauthorized Invalid org token This org auth token belongs to organization 'my-org', but the request is for 'other-org'. Use an auth token created in 'other-org', or target 'my-org' instead. ``` These errors are recorded with `denial_reason: org_mismatch` (#2163). Numeric org IDs are skipped, and the org comparison ignores case. <!-- junior-request-attribution:start --> via @betegon. <!-- junior-request-attribution:end --> <!-- junior-session-footer:start --> <!-- junior-conversation-id:agent%3A28e783cd48c13b214d69caf6d8977a0c --> -- [View Session in Sentry](https://sentry.sentry.io/explore/conversations/agent%3A28e783cd48c13b214d69caf6d8977a0c/?project=4510944073809921) <!-- junior-session-footer:end --> --------- Co-authored-by: sentry-junior[bot] <264270552+sentry-junior[bot]@users.noreply.github.com> Co-authored-by: bete <28983162+betegon@users.noreply.github.com>
1 parent 92ec3b3 commit de6a899

5 files changed

Lines changed: 148 additions & 8 deletions

File tree

‎packages/cli/src/lib/api/infrastructure.ts‎

Lines changed: 88 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,62 @@ type EnrichedDetail = {
7171
/** The scope org auth tokens have, and that Sentry's CI endpoints accept. */
7272
const ORG_CI_SCOPE = "org:ci";
7373

74+
/**
75+
* Org slug of an org- or project-scoped API path. Anything that isn't a
76+
* plain slug (escapes, IDs with other characters) yields no org to compare.
77+
*/
78+
const PATH_ORG_RE = /(?:^|\/)(?:organizations|projects)\/([\w-]+)\//;
79+
80+
/** Numeric org IDs can't be compared with the slug in a token. */
81+
const NUMERIC_ORG_RE = /^\d+$/;
82+
83+
/**
84+
* Organization a failed request was for: from the raw request's endpoint,
85+
* or from the response URL for SDK requests.
86+
*/
87+
function readTargetOrg(
88+
response: Response,
89+
endpoint: string | undefined,
90+
): string | undefined {
91+
const path = endpoint ?? response.url;
92+
return path ? PATH_ORG_RE.exec(path)?.[1] : undefined;
93+
}
94+
95+
/**
96+
* The org auth token's organization, when it differs from the one the
97+
* request was for. An org auth token only works in its own organization:
98+
* Sentry answers 403 when the target org is on the same server, and 401
99+
* ("Invalid org token") when the token is unknown there.
100+
*
101+
* Reads the credential requests use ({@link getCredentialContext}), not
102+
* `getAuthToken()`: a stored OAuth login that can still refresh is what the
103+
* request sent, even when an env var also holds an org auth token. A stored
104+
* `sntrys_` token (from `auth login --token`) counts too; an OAuth access
105+
* token has no claim.
106+
*/
107+
function findOrgMismatch(
108+
targetOrg: string | undefined,
109+
): { tokenOrg: string; targetOrg: string } | undefined {
110+
if (!targetOrg || NUMERIC_ORG_RE.test(targetOrg)) {
111+
return;
112+
}
113+
const tokenOrg = parseSntrysClaim(getCredentialContext()?.token)?.org;
114+
if (!tokenOrg || tokenOrg.toLowerCase() === targetOrg.toLowerCase()) {
115+
return;
116+
}
117+
return { tokenOrg, targetOrg };
118+
}
119+
120+
function orgMismatchLines(mismatch: {
121+
tokenOrg: string;
122+
targetOrg: string;
123+
}): string[] {
124+
return [
125+
`This org auth token belongs to organization '${mismatch.tokenOrg}', but the request is for '${mismatch.targetOrg}'.`,
126+
`Use an auth token created in '${mismatch.targetOrg}', or target '${mismatch.tokenOrg}' instead.`,
127+
];
128+
}
129+
74130
/** How to name the token a request sent: its env var, or the stored one. */
75131
function tokenHolder(credential: CredentialContext | undefined): string {
76132
return credential?.source.startsWith("env:")
@@ -136,16 +192,19 @@ function acceptedScopeLines(acceptedScopes: string[]): string[] {
136192
* error would be actively wrong and has caused user confusion (CLI-SERVER-E).
137193
*
138194
* Then, in order:
195+
* - an org auth token for another organization → name both organizations
139196
* - an `insufficient_scope` challenge → name the scopes the endpoint accepts
140197
* - env-var tokens → suggest checking token scopes
141198
* - OAuth tokens → suggest re-authentication
142199
*
143200
* @param acceptedScopes - Scopes from the response's `insufficient_scope`
144201
* challenge; any one of them works
202+
* @param targetOrg - Organization in the request path, when it names one
145203
*/
146204
function enrich403Detail(
147205
rawDetail: string | undefined,
148206
acceptedScopes: string[],
207+
targetOrg: string | undefined,
149208
): EnrichedDetail {
150209
// Org-level policy — re-auth and token scope advice do not apply here.
151210
if (rawDetail?.includes("disabled this feature")) {
@@ -165,6 +224,12 @@ function enrich403Detail(
165224
lines.push(rawDetail, "");
166225
}
167226

227+
const mismatch = findOrgMismatch(targetOrg);
228+
if (mismatch) {
229+
lines.push(...orgMismatchLines(mismatch));
230+
return { detail: lines.join("\n "), denialReason: "org_mismatch" };
231+
}
232+
168233
if (acceptedScopes.length > 0) {
169234
lines.push(...acceptedScopeLines(acceptedScopes));
170235
return { detail: lines.join("\n "), denialReason: "insufficient_scope" };
@@ -209,7 +274,7 @@ function enrich403Detail(
209274
* 401 means the token is missing, invalid, or expired — the identity cannot
210275
* be determined at all. Distinct from 403 (identity known, lacks permission).
211276
* Scope hints do not apply; the fix is always to re-authenticate or regenerate
212-
* the token.
277+
* the token, unless it is an org auth token for another organization.
213278
*
214279
* The Sentry API returns distinct `detail` strings we can branch on:
215280
* `"Token expired"` when the token is past its expiry date, `"Invalid token"`
@@ -222,7 +287,10 @@ function enrich403Detail(
222287
*
223288
* @see https://github.com/getsentry/sentry/blob/934f1473f198a62f9268d7140b80cd9ca1e59bb9/src/sentry/api/authentication.py#L536-L539
224289
*/
225-
function enrich401Detail(rawDetail: string | undefined): EnrichedDetail {
290+
function enrich401Detail(
291+
rawDetail: string | undefined,
292+
targetOrg: string | undefined,
293+
): EnrichedDetail {
226294
// Seat-limit lockout, not an auth failure. Sentry returns 401 with
227295
// `code: member-disabled-over-limit` when the org is over its member limit
228296
// and the caller's seat is disabled — re-authenticating cannot fix this.
@@ -242,6 +310,12 @@ function enrich401Detail(rawDetail: string | undefined): EnrichedDetail {
242310
if (rawDetail) {
243311
lines.push(rawDetail, "");
244312
}
313+
const mismatch = findOrgMismatch(targetOrg);
314+
if (mismatch) {
315+
lines.push(...orgMismatchLines(mismatch));
316+
return { detail: lines.join("\n "), denialReason: "org_mismatch" };
317+
}
318+
245319
const expired = rawDetail?.toLowerCase().includes("expired") ?? false;
246320
// Advise on the credential the request sent, which can be a stored login
247321
// even when an env var also holds a token.
@@ -290,16 +364,21 @@ function enrichDetail(
290364
response: Response,
291365
detail: string | undefined,
292366
hasUsableDetail: boolean,
367+
endpoint?: string,
293368
): { detail: string | undefined; denialReason?: ApiDenialReason } {
294369
const { status } = response;
295370
if (status === 403) {
296371
return enrich403Detail(
297372
hasUsableDetail ? detail : undefined,
298373
parseInsufficientScopeChallenge(response.headers.get("www-authenticate")),
374+
readTargetOrg(response, endpoint),
299375
);
300376
}
301377
if (status === 401) {
302-
return enrich401Detail(hasUsableDetail ? detail : undefined);
378+
return enrich401Detail(
379+
hasUsableDetail ? detail : undefined,
380+
readTargetOrg(response, endpoint),
381+
);
303382
}
304383
return { detail };
305384
}
@@ -1038,7 +1117,12 @@ export async function throwRawApiError(
10381117
"x-sentry-error": response.headers.get("x-sentry-error"),
10391118
"www-authenticate": response.headers.get("www-authenticate"),
10401119
});
1041-
const enriched = enrichDetail(response, detail, detail !== undefined);
1120+
const enriched = enrichDetail(
1121+
response,
1122+
detail,
1123+
detail !== undefined,
1124+
endpoint,
1125+
);
10421126
throw new ApiError(
10431127
`${context}: ${response.status} ${response.statusText}`,
10441128
response.status,

‎packages/cli/src/lib/error-reporting.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -253,7 +253,7 @@ function recordSilencedError(error: unknown, reason: SilenceReason): void {
253253

254254
// Structured log for user API errors — `detail` is often the most actionable
255255
// field and is searchable in Sentry Logs. A 401/403 logs its denial reason
256-
// instead: its detail is the CLI's own guidance, not the server's.
256+
// instead: its detail is the CLI's own guidance, which can name orgs.
257257
if (reason === "api_user_error" && error instanceof ApiError) {
258258
// oxlint-disable-next-line sentry-cli/no-silent-catch -- grandfathered silent catch — see #1531; drain by adding log.debug()/log.warn() or re-throwing.
259259
try {

‎packages/cli/src/lib/errors.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -180,6 +180,7 @@ export type FunctionArgument = {
180180
* words such as "auth" and "token" in new values; Sentry's data scrubbing
181181
* replaces values that contain them with `[Filtered]`.
182182
*
183+
* - `org_mismatch`: an org auth token for another organization
183184
* - `insufficient_scope`: the token lacks every scope the endpoint accepts
184185
* - `member_policy`: an org setting blocks members from the action
185186
* - `seat_limit`: the member is disabled because the org is over its limit
@@ -189,6 +190,7 @@ export type FunctionArgument = {
189190
* or created on another server)
190191
*/
191192
export type ApiDenialReason =
193+
| "org_mismatch"
192194
| "insufficient_scope"
193195
| "member_policy"
194196
| "seat_limit"

‎packages/cli/src/lib/telemetry.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -440,9 +440,9 @@ export function isUserApiError(error: unknown): boolean {
440440
* `api_error.denial_reason` (401/403) or `api_error.detail` on the span. Must
441441
* be called before `span.end()`.
442442
*
443-
* A 401/403 detail is the CLI's own guidance rather than the server's, and
444-
* Sentry's data scrubbing replaces most of them with `[Filtered]`. The denial
445-
* reason is a fixed category that tells those failures apart instead.
443+
* A 401/403 detail is the CLI's own guidance, which can name organizations,
444+
* and Sentry's data scrubbing replaces most of them with `[Filtered]`. The
445+
* denial reason is a fixed category that tells those failures apart instead.
446446
*
447447
* @internal Exported for testing
448448
*/

‎packages/cli/test/lib/api/infrastructure.test.ts‎

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -820,6 +820,60 @@ describe("throwApiError", () => {
820820
// isTextualContentType + rawApiRequest binary handling (getsentry/cli#1303)
821821
// ---------------------------------------------------------------------------
822822

823+
describe("org auth token org mismatch", () => {
824+
useTestConfigDir("api-org-mismatch-");
825+
useEnvSandbox([
826+
"SENTRY_AUTH_TOKEN",
827+
"SENTRY_TOKEN",
828+
"SENTRY_FORCE_ENV_TOKEN",
829+
]);
830+
831+
/** The detail of a denial for `path`, sent with an org auth token for `tokenOrg`. */
832+
function detailFor(
833+
tokenOrg: string,
834+
status: 401 | 403,
835+
path: string,
836+
detail = "Denied",
837+
): string | undefined {
838+
const claim = Buffer.from(
839+
JSON.stringify({ iat: 1, url: "https://sentry.io", org: tokenOrg }),
840+
).toString("base64");
841+
process.env.SENTRY_AUTH_TOKEN = `sntrys_${claim}_secret`;
842+
process.env.SENTRY_FORCE_ENV_TOKEN = "1";
843+
resetAuthTokenCache();
844+
const body = { detail };
845+
const response = Response.json(body, { status });
846+
// SDK requests carry the request URL on the response.
847+
Object.defineProperty(response, "url", {
848+
value: `https://us.sentry.io/api/0/${path}`,
849+
});
850+
try {
851+
throwApiError(body, response, "Failed");
852+
} catch (error) {
853+
return (error as ApiError).detail;
854+
}
855+
throw new Error("expected throwApiError to throw");
856+
}
857+
858+
test.each([
859+
[403, "organizations/target-org/releases/", "Denied"],
860+
[401, "projects/target-org/web/releases/", "Invalid org token"],
861+
] as const)("names both orgs on a %i", (status, path, detail) => {
862+
expect(detailFor("token-org", status, path, detail)).toContain(
863+
"This org auth token belongs to organization 'token-org', but the request is for 'target-org'.",
864+
);
865+
});
866+
867+
test.each([
868+
["token-org", "organizations/12345/releases/"],
869+
["Token-Org", "organizations/token-org/releases/"],
870+
])("skips the hint for a %s token on %s", (tokenOrg, path) => {
871+
expect(detailFor(tokenOrg, 403, path)).not.toContain(
872+
"belongs to organization",
873+
);
874+
});
875+
});
876+
823877
describe("403 scope challenge guidance", () => {
824878
useTestConfigDir("api-denial-");
825879
useEnvSandbox([

0 commit comments

Comments
 (0)