Skip to content

Commit 9424ec7

Browse files
fix(worker): classify permission sync failures by provider context
Replace generic HTTP status fail-closed handling with typed upstream permission-sync errors so ambiguous forbidden and gone responses preserve cached permissions. Fixes SOU-1560 Fixes SOU-1177
1 parent 4d6cdd0 commit 9424ec7

5 files changed

Lines changed: 347 additions & 42 deletions

File tree

packages/backend/src/ee/accountPermissionSyncer.test.ts

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
import { describe, expect, test } from 'vitest';
22
import { classifyPermissionSyncFailure } from './accountPermissionSyncer.js';
3+
import {
4+
PermissionSyncUpstreamError,
5+
type PermissionSyncUpstreamErrorKind,
6+
} from './permissionSyncError.js';
37
import { TokenRefreshError, type TokenRefreshErrorKind } from './tokenRefresh.js';
48

59
const tokenRefreshError = (
@@ -10,6 +14,14 @@ const tokenRefreshError = (
1014
status,
1115
});
1216

17+
const upstreamError = (
18+
kind: PermissionSyncUpstreamErrorKind,
19+
): PermissionSyncUpstreamError => new PermissionSyncUpstreamError(`Permission sync failed: ${kind}`, {
20+
kind,
21+
provider: 'github',
22+
operation: 'list_accessible_repositories',
23+
});
24+
1325
describe('classifyPermissionSyncFailure', () => {
1426
test('fails closed when the refresh token is rejected', () => {
1527
expect(classifyPermissionSyncFailure(tokenRefreshError('refresh_token_rejected', 400))).toEqual({
@@ -36,19 +48,29 @@ describe('classifyPermissionSyncFailure', () => {
3648
});
3749

3850
test.each([
39-
[401, 'http_unauthorized'],
40-
[403, 'http_forbidden'],
41-
[410, 'http_gone'],
42-
] as const)('preserves fail-closed behavior for an API HTTP %s response', (status, reason) => {
43-
const error = Object.assign(new Error(reason), { status });
44-
expect(classifyPermissionSyncFailure(error)).toEqual({
51+
['credential_rejected', 'upstream_credential_rejected'],
52+
['insufficient_scope', 'upstream_insufficient_scope'],
53+
['permission_endpoint_removed', 'permission_endpoint_removed'],
54+
] as const)('fails closed for a classified %s upstream failure', (kind, reason) => {
55+
expect(classifyPermissionSyncFailure(upstreamError(kind))).toEqual({
4556
action: 'clear_permissions',
4657
reason,
4758
});
4859
});
4960

50-
test('keeps permissions for an unrelated API failure', () => {
51-
const error = Object.assign(new Error('Internal Server Error'), { status: 500 });
61+
test.each([
62+
'rate_limited',
63+
'upstream_unavailable',
64+
'forbidden',
65+
'unknown',
66+
] satisfies PermissionSyncUpstreamErrorKind[])('keeps permissions for a classified %s upstream failure', (kind) => {
67+
expect(classifyPermissionSyncFailure(upstreamError(kind))).toEqual({
68+
action: 'preserve_permissions',
69+
});
70+
});
71+
72+
test.each([401, 403, 410])('does not fail closed on an unclassified HTTP %s error', (status) => {
73+
const error = Object.assign(new Error(`HTTP ${status}`), { status });
5274
expect(classifyPermissionSyncFailure(error)).toEqual({
5375
action: 'preserve_permissions',
5476
});

packages/backend/src/ee/accountPermissionSyncer.ts

Lines changed: 66 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
import { createBitbucketCloudClient, createBitbucketServerClient, getReposForAuthenticatedBitbucketCloudUser, getReposForAuthenticatedBitbucketServerUser } from "../bitbucket.js";
1919
import { Settings } from "../types.js";
2020
import { setIntervalAsync } from "../utils.js";
21-
import { isUnauthorized, isForbidden, isGone } from "../errors.js";
21+
import { PermissionSyncUpstreamError, withPermissionSyncUpstreamError } from "./permissionSyncError.js";
2222

2323
const LOG_TAG = 'user-permission-syncer';
2424
const logger = createLogger(LOG_TAG);
@@ -34,9 +34,9 @@ type AccountPermissionSyncJob = {
3434

3535
export type PermissionCleanupReason =
3636
| 'oauth_refresh_token_rejected'
37-
| 'http_unauthorized'
38-
| 'http_forbidden'
39-
| 'http_gone';
37+
| 'upstream_credential_rejected'
38+
| 'upstream_insufficient_scope'
39+
| 'permission_endpoint_removed';
4040

4141
export type PermissionCleanupDecision =
4242
| {
@@ -49,9 +49,9 @@ export type PermissionCleanupDecision =
4949

5050
const PERMISSION_CLEANUP_REASON_MESSAGES: Record<PermissionCleanupReason, string> = {
5151
oauth_refresh_token_rejected: 'OAuth refresh token rejection',
52-
http_unauthorized: 'HTTP 401 Unauthorized',
53-
http_forbidden: 'HTTP 403 Forbidden',
54-
http_gone: 'HTTP 410 Gone',
52+
upstream_credential_rejected: 'upstream credential rejection',
53+
upstream_insufficient_scope: 'insufficient OAuth scope',
54+
permission_endpoint_removed: 'permission endpoint removed',
5555
};
5656

5757
export const classifyPermissionSyncFailure = (error: unknown): PermissionCleanupDecision => {
@@ -64,14 +64,16 @@ export const classifyPermissionSyncFailure = (error: unknown): PermissionCleanup
6464
: { action: 'preserve_permissions' };
6565
}
6666

67-
if (isUnauthorized(error)) {
68-
return { action: 'clear_permissions', reason: 'http_unauthorized' };
69-
}
70-
if (isForbidden(error)) {
71-
return { action: 'clear_permissions', reason: 'http_forbidden' };
72-
}
73-
if (isGone(error)) {
74-
return { action: 'clear_permissions', reason: 'http_gone' };
67+
if (error instanceof PermissionSyncUpstreamError) {
68+
if (error.kind === 'credential_rejected') {
69+
return { action: 'clear_permissions', reason: 'upstream_credential_rejected' };
70+
}
71+
if (error.kind === 'insufficient_scope') {
72+
return { action: 'clear_permissions', reason: 'upstream_insufficient_scope' };
73+
}
74+
if (error.kind === 'permission_endpoint_removed') {
75+
return { action: 'clear_permissions', reason: 'permission_endpoint_removed' };
76+
}
7577
}
7678

7779
return { action: 'preserve_permissions' };
@@ -235,12 +237,9 @@ export class AccountPermissionSyncer {
235237
try {
236238
await this.syncAccountPermissions(account, logger);
237239
} catch (error) {
238-
// Fail-closed: when the code-host layer signals that the upstream
239-
// account is permanently unauthorized (token revoked, user
240-
// deprovisioned, OAuth grant dead) or that the endpoint we depend
241-
// on is gone (e.g. Bitbucket Cloud's CHANGE-2770), clear the
242-
// account's existing permission rows so the read-side filter stops
243-
// matching through them.
240+
// Clear cached permissions only for classified permanent failures.
241+
// Ambiguous HTTP errors and transient upstream failures preserve the
242+
// last successful permission state.
244243
const cleanupDecision = classifyPermissionSyncFailure(error);
245244

246245
if (cleanupDecision.action === 'clear_permissions') {
@@ -280,19 +279,34 @@ export class AccountPermissionSyncer {
280279
url: idpConfig.baseUrl,
281280
});
282281

283-
const scopes = await getGitHubOAuthScopesForAuthenticatedUser(octokit, accessToken);
282+
const scopes = await withPermissionSyncUpstreamError(
283+
'github',
284+
'inspect_token_scopes',
285+
() => getGitHubOAuthScopesForAuthenticatedUser(octokit, accessToken),
286+
);
284287

285288
// Token supports scope introspection (classic PAT or OAuth app token)
286289
if (scopes !== null) {
287290
if (!scopes.includes('repo')) {
288-
throw new Error(`OAuth token with scopes [${scopes.join(', ')}] is missing the 'repo' scope required for permission syncing. Please re-authorize with GitHub to grant the required scope.`);
291+
throw new PermissionSyncUpstreamError(
292+
`OAuth token with scopes [${scopes.join(', ')}] is missing the 'repo' scope required for permission syncing. Please re-authorize with GitHub to grant the required scope.`,
293+
{
294+
kind: 'insufficient_scope',
295+
provider: 'github',
296+
operation: 'inspect_token_scopes',
297+
},
298+
);
289299
}
290300
}
291301

292302
// @note: we only care about the private repos since we don't need to build a mapping
293303
// for public repos.
294304
// @see: packages/web/src/prisma.ts
295-
const githubRepos = await getReposForAuthenticatedUser(/* visibility = */ 'private', octokit);
305+
const githubRepos = await withPermissionSyncUpstreamError(
306+
'github',
307+
'list_accessible_repositories',
308+
() => getReposForAuthenticatedUser(/* visibility = */ 'private', octokit),
309+
);
296310
const gitHubRepoIds = githubRepos.map(repo => repo.id.toString());
297311

298312
const repos = await this.db.repo.findMany({
@@ -314,9 +328,20 @@ export class AccountPermissionSyncer {
314328
url: idpConfig.baseUrl,
315329
});
316330

317-
const scopes = await getGitLabOAuthScopesForAuthenticatedUser(api);
331+
const scopes = await withPermissionSyncUpstreamError(
332+
'gitlab',
333+
'inspect_token_scopes',
334+
() => getGitLabOAuthScopesForAuthenticatedUser(api),
335+
);
318336
if (!scopes.includes('read_api')) {
319-
throw new Error(`OAuth token with scopes [${scopes.join(', ')}] is missing the 'read_api' scope required for permission syncing.`);
337+
throw new PermissionSyncUpstreamError(
338+
`OAuth token with scopes [${scopes.join(', ')}] is missing the 'read_api' scope required for permission syncing.`,
339+
{
340+
kind: 'insufficient_scope',
341+
provider: 'gitlab',
342+
operation: 'inspect_token_scopes',
343+
},
344+
);
320345
}
321346

322347
// @note: we only care about the private repos since we don't need to build a
@@ -326,7 +351,11 @@ export class AccountPermissionSyncer {
326351
//
327352
// @see: packages/web/src/prisma.ts
328353
const gitLabProjectIds = (
329-
await getProjectsForAuthenticatedUser('private', api)
354+
await withPermissionSyncUpstreamError(
355+
'gitlab',
356+
'list_accessible_repositories',
357+
() => getProjectsForAuthenticatedUser('private', api),
358+
)
330359
).map(project => project.id.toString());
331360

332361
const repos = await this.db.repo.findMany({
@@ -346,7 +375,11 @@ export class AccountPermissionSyncer {
346375
// @note: we don't pass a user here since we want to use a bearer token
347376
// for authentication.
348377
const client = createBitbucketCloudClient(/* user = */ undefined, accessToken)
349-
const bitbucketRepos = await getReposForAuthenticatedBitbucketCloudUser(client);
378+
const bitbucketRepos = await withPermissionSyncUpstreamError(
379+
'bitbucket-cloud',
380+
'list_accessible_repositories',
381+
() => getReposForAuthenticatedBitbucketCloudUser(client),
382+
);
350383
const bitbucketRepoUuids = bitbucketRepos.map(repo => repo.uuid);
351384

352385
const repos = await this.db.repo.findMany({
@@ -364,7 +397,11 @@ export class AccountPermissionSyncer {
364397
repos.forEach(repo => aggregatedRepoIds.add(repo.id));
365398
} else if (idpConfig.provider === 'bitbucket-server') {
366399
const client = createBitbucketServerClient(idpConfig.baseUrl, /* user = */ undefined, accessToken);
367-
const serverRepos = await getReposForAuthenticatedBitbucketServerUser(client);
400+
const serverRepos = await withPermissionSyncUpstreamError(
401+
'bitbucket-server',
402+
'list_accessible_repositories',
403+
() => getReposForAuthenticatedBitbucketServerUser(client),
404+
);
368405
const serverRepoIds = serverRepos.map(r => r.id);
369406

370407
const repos = await this.db.repo.findMany({
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
import { describe, expect, test } from 'vitest';
2+
import {
3+
classifyPermissionSyncUpstreamError,
4+
PermissionSyncUpstreamError,
5+
withPermissionSyncUpstreamError,
6+
} from './permissionSyncError.js';
7+
8+
describe('classifyPermissionSyncUpstreamError', () => {
9+
test('classifies a 401 from an authenticated operation as a credential rejection', () => {
10+
const cause = Object.assign(new Error('Unauthorized'), { status: 401 });
11+
12+
expect(classifyPermissionSyncUpstreamError(
13+
cause,
14+
'bitbucket-server',
15+
'list_accessible_repositories',
16+
)).toMatchObject({
17+
kind: 'credential_rejected',
18+
provider: 'bitbucket-server',
19+
operation: 'list_accessible_repositories',
20+
status: 401,
21+
cause,
22+
});
23+
});
24+
25+
test('classifies a GitHub rate-limit 403 separately from an ambiguous forbidden response', () => {
26+
const rateLimitError = Object.assign(new Error('Rate limited'), {
27+
status: 403,
28+
response: { headers: { 'x-ratelimit-remaining': '0' } },
29+
});
30+
const forbiddenError = Object.assign(new Error('Forbidden'), { status: 403 });
31+
32+
expect(classifyPermissionSyncUpstreamError(
33+
rateLimitError,
34+
'github',
35+
'list_accessible_repositories',
36+
).kind).toBe('rate_limited');
37+
expect(classifyPermissionSyncUpstreamError(
38+
forbiddenError,
39+
'github',
40+
'list_accessible_repositories',
41+
).kind).toBe('forbidden');
42+
});
43+
44+
test('classifies HTTP 429 as rate limited', () => {
45+
const cause = Object.assign(new Error('Too Many Requests'), { status: 429 });
46+
47+
expect(classifyPermissionSyncUpstreamError(
48+
cause,
49+
'gitlab',
50+
'list_accessible_repositories',
51+
).kind).toBe('rate_limited');
52+
});
53+
54+
test('classifies Bitbucket Cloud 410 from the repository-list operation as a removed endpoint', () => {
55+
const cause = Object.assign(new Error('Gone'), { status: 410 });
56+
57+
expect(classifyPermissionSyncUpstreamError(
58+
cause,
59+
'bitbucket-cloud',
60+
'list_accessible_repositories',
61+
).kind).toBe('permission_endpoint_removed');
62+
});
63+
64+
test('does not generalize HTTP 410 from another provider to a removed permission endpoint', () => {
65+
const cause = Object.assign(new Error('Gone'), { status: 410 });
66+
67+
expect(classifyPermissionSyncUpstreamError(
68+
cause,
69+
'github',
70+
'list_accessible_repositories',
71+
).kind).toBe('unknown');
72+
});
73+
74+
test.each([
75+
Object.assign(new Error('Internal Server Error'), { status: 500 }),
76+
new TypeError('fetch failed'),
77+
Object.assign(new Error('request timed out'), { name: 'TimeoutError' }),
78+
])('classifies an unavailable provider as transient', (cause) => {
79+
expect(classifyPermissionSyncUpstreamError(
80+
cause,
81+
'bitbucket-server',
82+
'list_accessible_repositories',
83+
).kind).toBe('upstream_unavailable');
84+
});
85+
86+
test('does not reclassify an existing permission sync error', async () => {
87+
const error = new PermissionSyncUpstreamError('Missing scope', {
88+
kind: 'insufficient_scope',
89+
provider: 'github',
90+
operation: 'inspect_token_scopes',
91+
});
92+
93+
const caught = await withPermissionSyncUpstreamError(
94+
'github',
95+
'inspect_token_scopes',
96+
() => Promise.reject(error),
97+
).catch(error => error);
98+
99+
expect(caught).toBe(error);
100+
});
101+
});

0 commit comments

Comments
 (0)