diff --git a/CHANGELOG.md b/CHANGELOG.md index 55683269b..b70d11c4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed - Fixed issue where repo permissions could go stale when authentication or token refresh related errors occured. [#1215](https://github.com/sourcebot-dev/sourcebot/pull/1215) +- [EE] Fixed issue where repo permissions could go stale when an upstream endpoint returned HTTP 410 Gone (e.g. Bitbucket Cloud's CHANGE-2770). [#1216](https://github.com/sourcebot-dev/sourcebot/pull/1216) ## [4.17.2] - 2026-05-16 diff --git a/packages/backend/src/ee/accountPermissionSyncer.ts b/packages/backend/src/ee/accountPermissionSyncer.ts index 5f524d829..031105dbc 100644 --- a/packages/backend/src/ee/accountPermissionSyncer.ts +++ b/packages/backend/src/ee/accountPermissionSyncer.ts @@ -17,7 +17,7 @@ import { import { createBitbucketCloudClient, createBitbucketServerClient, getReposForAuthenticatedBitbucketCloudUser, getReposForAuthenticatedBitbucketServerUser } from "../bitbucket.js"; import { Settings } from "../types.js"; import { setIntervalAsync } from "../utils.js"; -import { isUnauthorized, isForbidden } from "../errors.js"; +import { isUnauthorized, isForbidden, isGone } from "../errors.js"; const LOG_TAG = 'user-permission-syncer'; const logger = createLogger(LOG_TAG); @@ -191,22 +191,23 @@ export class AccountPermissionSyncer { } catch (error) { // Fail-closed: when the code-host layer signals that the upstream // account is permanently unauthorized (token revoked, user - // deprovisioned, OAuth grant dead), clear the account's existing - // permission rows so the read-side filter stops matching through - // them. - if ( - isUnauthorized(error) || - isForbidden(error) || - error instanceof RefreshTokenError - ) { - await this.db.account.update({ - where: { id: account.id }, - data: { - accessibleRepos: { - deleteMany: {}, - }, - }, + // deprovisioned, OAuth grant dead) or that the endpoint we depend + // on is gone (e.g. Bitbucket Cloud's CHANGE-2770), clear the + // account's existing permission rows so the read-side filter stops + // matching through them. + const reason = + error instanceof RefreshTokenError ? 'token refresh failure' : + isUnauthorized(error) ? 'HTTP 401 Unauthorized' : + isForbidden(error) ? 'HTTP 403 Forbidden' : + isGone(error) ? 'HTTP 410 Gone' : + null; + + if (reason !== null) { + const { count } = await this.db.accountToRepoPermission.deleteMany({ + where: { accountId: account.id }, }); + const message = error instanceof Error ? error.message : String(error); + logger.warn(`Cleared ${count} permission row(s) for account ${account.id} (user ${account.user.email ?? 'unknown'}) — fail-closed cleanup triggered by ${reason}: ${message}`); } throw error; } diff --git a/packages/backend/src/errors.test.ts b/packages/backend/src/errors.test.ts index ac6a00dd4..c96b4f694 100644 --- a/packages/backend/src/errors.test.ts +++ b/packages/backend/src/errors.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from 'vitest'; import { RequestError } from '@octokit/request-error'; import { GitbeakerRequestError } from '@gitbeaker/requester-utils'; -import { isForbidden, isUnauthorized } from './errors'; +import { isForbidden, isGone, isUnauthorized } from './errors'; import { throwOnHttpError } from './bitbucket'; // Helper: invoke the openapi-fetch middleware against a synthetic Response and @@ -148,6 +148,56 @@ describe('isForbidden', () => { }); }); +describe('isGone', () => { + test('Octokit RequestError with status 410', () => { + const err = new RequestError('Gone', 410, { + request: { method: 'GET', url: 'https://api.github.com/user/repos', headers: {} }, + }); + expect(isGone(err)).toBe(true); + }); + + test('Octokit RequestError with status 401 is NOT gone', () => { + const err = new RequestError('Unauthorized', 401, { + request: { method: 'GET', url: 'https://api.github.com/user/repos', headers: {} }, + }); + expect(isGone(err)).toBe(false); + }); + + test('Bitbucket middleware throws an isGone error on 410 Response', async () => { + // Real-world case: Bitbucket Cloud's CHANGE-2770 removed + // /2.0/user/permissions/repositories and now returns 410 Gone. + const err = await invokeMiddleware(new Response('CHANGE-2770 - Functionality has been deprecated', { status: 410 })); + expect(err).toBeInstanceOf(Error); + expect(isGone(err)).toBe(true); + }); + + test('real GitbeakerRequestError with response status 410', () => { + const err = new GitbeakerRequestError('Gone', { + cause: { + description: 'Gone', + request: new Request('https://gitlab.com/api/v4/projects'), + response: new Response(null, { status: 410 }), + }, + }); + expect(isGone(err)).toBe(true); + }); + + test('plain Error without status is NOT gone', () => { + expect(isGone(new Error('Missing required scope'))).toBe(false); + }); + + test('null is NOT gone', () => { + expect(isGone(null)).toBe(false); + }); + + test('Octokit RequestError with status 500 is NOT gone', () => { + const err = new RequestError('Internal Server Error', 500, { + request: { method: 'GET', url: 'https://api.github.com/user/repos', headers: {} }, + }); + expect(isGone(err)).toBe(false); + }); +}); + describe('throwOnHttpError middleware contract', () => { test('does not throw on 2xx Response', async () => { const err = await invokeMiddleware(new Response('ok', { status: 200 })); diff --git a/packages/backend/src/errors.ts b/packages/backend/src/errors.ts index 341c76736..d14760a49 100644 --- a/packages/backend/src/errors.ts +++ b/packages/backend/src/errors.ts @@ -27,3 +27,4 @@ const getStatus = (err: unknown): number | null => { export const isUnauthorized = (err: unknown): boolean => getStatus(err) === 401; export const isForbidden = (err: unknown): boolean => getStatus(err) === 403; +export const isGone = (err: unknown): boolean => getStatus(err) === 410;