@@ -2,7 +2,7 @@ import * as Sentry from "@sentry/node";
22import { PrismaClient , AccountPermissionSyncJobStatus , Account , PermissionSyncSource } from "@sourcebot/db" ;
33import { env , createLogger , getIdentityProviderConfig , PERMISSION_SYNC_SUPPORTED_IDENTITY_PROVIDERS } from "@sourcebot/shared" ;
44import { hasEntitlement } from "../entitlements.js" ;
5- import { ensureFreshAccountToken } from "./tokenRefresh.js" ;
5+ import { ensureFreshAccountToken , TokenRefreshError } from "./tokenRefresh.js" ;
66import { DelayedError , Job , Queue , Worker } from "bullmq" ;
77import { Redis } from "ioredis" ;
88import {
@@ -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
4180export 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