Skip to content

Commit 84782b0

Browse files
fix(worker): preserve permissions on transient token refresh failures
Classify OAuth token refresh failures so transient provider outages are retried without clearing cached permissions, while invalid_grant remains fail-closed. Fixes SOU-1560 Fixes SOU-1177
1 parent e1ce46b commit 84782b0

4 files changed

Lines changed: 595 additions & 71 deletions

File tree

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
import { describe, expect, test } from 'vitest';
2+
import { classifyPermissionSyncFailure } from './accountPermissionSyncer.js';
3+
import { TokenRefreshError, type TokenRefreshErrorKind } from './tokenRefresh.js';
4+
5+
const tokenRefreshError = (
6+
kind: TokenRefreshErrorKind,
7+
status?: number,
8+
): TokenRefreshError => new TokenRefreshError(`Token refresh failed: ${kind}`, {
9+
kind,
10+
status,
11+
});
12+
13+
describe('classifyPermissionSyncFailure', () => {
14+
test('fails closed for invalid_grant', () => {
15+
expect(classifyPermissionSyncFailure(tokenRefreshError('invalid_grant', 400))).toEqual({
16+
action: 'clear_permissions',
17+
reason: 'oauth_invalid_grant',
18+
});
19+
});
20+
21+
test.each([
22+
['transient', 500],
23+
['configuration', 400],
24+
['invalid_response', undefined],
25+
['local_credential', undefined],
26+
] satisfies Array<[TokenRefreshErrorKind, number | undefined]>)('keeps permissions for a %s token refresh failure', (kind, status) => {
27+
expect(classifyPermissionSyncFailure(tokenRefreshError(kind, status))).toEqual({
28+
action: 'preserve_permissions',
29+
});
30+
});
31+
32+
test('does not treat a token refresh configuration error with HTTP 401 as an API authorization failure', () => {
33+
expect(classifyPermissionSyncFailure(tokenRefreshError('configuration', 401))).toEqual({
34+
action: 'preserve_permissions',
35+
});
36+
});
37+
38+
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({
45+
action: 'clear_permissions',
46+
reason,
47+
});
48+
});
49+
50+
test('keeps permissions for an unrelated API failure', () => {
51+
const error = Object.assign(new Error('Internal Server Error'), { status: 500 });
52+
expect(classifyPermissionSyncFailure(error)).toEqual({
53+
action: 'preserve_permissions',
54+
});
55+
});
56+
});

packages/backend/src/ee/accountPermissionSyncer.ts

Lines changed: 49 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import * as Sentry from "@sentry/node";
22
import { PrismaClient, AccountPermissionSyncJobStatus, Account, PermissionSyncSource} from "@sourcebot/db";
33
import { env, createLogger, getIdentityProviderConfig, PERMISSION_SYNC_SUPPORTED_IDENTITY_PROVIDERS } from "@sourcebot/shared";
44
import { hasEntitlement } from "../entitlements.js";
5-
import { ensureFreshAccountToken } from "./tokenRefresh.js";
5+
import { ensureFreshAccountToken, TokenRefreshError } from "./tokenRefresh.js";
66
import { DelayedError, Job, Queue, Worker } from "bullmq";
77
import { Redis } from "ioredis";
88
import {
@@ -32,11 +32,50 @@ type AccountPermissionSyncJob = {
3232
jobId: string;
3333
}
3434

35-
class RefreshTokenError extends Error {
36-
constructor(message: string) {
37-
super(message);
35+
export type PermissionCleanupReason =
36+
| 'oauth_invalid_grant'
37+
| 'http_unauthorized'
38+
| 'http_forbidden'
39+
| 'http_gone';
40+
41+
export type PermissionCleanupDecision =
42+
| {
43+
action: 'clear_permissions';
44+
reason: PermissionCleanupReason;
3845
}
39-
}
46+
| {
47+
action: 'preserve_permissions';
48+
};
49+
50+
const PERMISSION_CLEANUP_REASON_MESSAGES: Record<PermissionCleanupReason, string> = {
51+
oauth_invalid_grant: 'OAuth invalid_grant',
52+
http_unauthorized: 'HTTP 401 Unauthorized',
53+
http_forbidden: 'HTTP 403 Forbidden',
54+
http_gone: 'HTTP 410 Gone',
55+
};
56+
57+
export const classifyPermissionSyncFailure = (error: unknown): PermissionCleanupDecision => {
58+
// Token refresh failures have their own classification. Do not fall through
59+
// to the generic HTTP checks because a non-invalid_grant response may also
60+
// carry a 401 or 403 status.
61+
if (error instanceof TokenRefreshError) {
62+
return error.kind === 'invalid_grant'
63+
? { action: 'clear_permissions', reason: 'oauth_invalid_grant' }
64+
: { action: 'preserve_permissions' };
65+
}
66+
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' };
75+
}
76+
77+
return { action: 'preserve_permissions' };
78+
};
4079

4180
export class AccountPermissionSyncer {
4281
private queue: Queue<AccountPermissionSyncJob>;
@@ -202,18 +241,14 @@ export class AccountPermissionSyncer {
202241
// on is gone (e.g. Bitbucket Cloud's CHANGE-2770), clear the
203242
// account's existing permission rows so the read-side filter stops
204243
// matching through them.
205-
const reason =
206-
error instanceof RefreshTokenError ? 'token refresh failure' :
207-
isUnauthorized(error) ? 'HTTP 401 Unauthorized' :
208-
isForbidden(error) ? 'HTTP 403 Forbidden' :
209-
isGone(error) ? 'HTTP 410 Gone' :
210-
null;
211-
212-
if (reason !== null) {
244+
const cleanupDecision = classifyPermissionSyncFailure(error);
245+
246+
if (cleanupDecision.action === 'clear_permissions') {
213247
const { count } = await this.db.accountToRepoPermission.deleteMany({
214248
where: { accountId: account.id },
215249
});
216250
const message = error instanceof Error ? error.message : String(error);
251+
const reason = PERMISSION_CLEANUP_REASON_MESSAGES[cleanupDecision.reason];
217252
logger.warn(`Cleared ${count} permission row(s) for account ${account.id} (user ${account.user.email ?? 'unknown'}) — fail-closed cleanup triggered by ${reason}: ${message}`);
218253
}
219254
throw error;
@@ -227,20 +262,7 @@ export class AccountPermissionSyncer {
227262
logger.debug(`Syncing permissions for ${account.providerId} account (id: ${account.id}) for user ${account.user.email}...`);
228263

229264
// Ensure the OAuth token is fresh, refreshing it if it is expired or near expiry.
230-
//
231-
// @note(SOU-1177) re-throwing as a RefreshTokenError here is required to flag to the caller
232-
// (runJob) that the account's permissions should be cleared. The side-effect with this
233-
// approach is that permissions will be cleared for any error thrown in the
234-
// ensureFreshAccountToken path. A better approach would be to look at the response
235-
// from the oauth call and determining if the host returned a invalid_grant.
236-
//
237-
// @see: https://datatracker.ietf.org/doc/html/rfc6749#section-5.2
238-
let accessToken;
239-
try {
240-
accessToken = await ensureFreshAccountToken(account, this.db);
241-
} catch (error) {
242-
throw new RefreshTokenError(error instanceof Error ? error.message : 'Failed to refresh token with unknown error.');
243-
}
265+
const accessToken = await ensureFreshAccountToken(account, this.db);
244266

245267
// Get a list of all repos that the user has access to from all connected accounts.
246268
const repoIds = await (async () => {

0 commit comments

Comments
 (0)