Skip to content

Commit 6869a4a

Browse files
msukkariclaude
andcommitted
fix(worker): extend permission-sync fail-closed to HTTP 410
PR #1215 added a fail-closed branch in the account-driven permission syncer that clears an account's AccountToRepoPermission rows when the upstream call throws 401, 403, or a token-refresh error. The predicate set is too narrow for endpoint deprecations: Bitbucket Cloud's CHANGE-2770 removed GET /2.0/user/permissions/repositories and the endpoint now returns HTTP 410 Gone for every caller. With the existing predicate, the syncer aborts without clearing the account's rows, so stale permissions persist indefinitely. Add `isGone` (status 410) alongside `isUnauthorized` (401) and `isForbidden` (403), and include it in the catch in accountPermissionSyncer.runJob. The cleanup is the same scorched-earth deleteMany used by the existing predicates. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6deb1fa commit 6869a4a

4 files changed

Lines changed: 59 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
99

1010
### Fixed
1111
- 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)
12+
- [EE] Account-driven permission syncer now also cleans up an account's permission rows when an upstream endpoint returns HTTP 410 Gone, so permissions don't get stuck after an API is removed (e.g. Bitbucket Cloud's CHANGE-2770).
1213

1314
## [4.17.2] - 2026-05-16
1415

packages/backend/src/ee/accountPermissionSyncer.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
import { createBitbucketCloudClient, createBitbucketServerClient, getReposForAuthenticatedBitbucketCloudUser, getReposForAuthenticatedBitbucketServerUser } from "../bitbucket.js";
1818
import { Settings } from "../types.js";
1919
import { setIntervalAsync } from "../utils.js";
20-
import { isUnauthorized, isForbidden } from "../errors.js";
20+
import { isUnauthorized, isForbidden, isGone } from "../errors.js";
2121

2222
const LOG_TAG = 'user-permission-syncer';
2323
const logger = createLogger(LOG_TAG);
@@ -191,12 +191,14 @@ export class AccountPermissionSyncer {
191191
} catch (error) {
192192
// Fail-closed: when the code-host layer signals that the upstream
193193
// account is permanently unauthorized (token revoked, user
194-
// deprovisioned, OAuth grant dead), clear the account's existing
195-
// permission rows so the read-side filter stops matching through
196-
// them.
194+
// deprovisioned, OAuth grant dead) or that the endpoint we depend
195+
// on is gone (e.g. Bitbucket Cloud's CHANGE-2770), clear the
196+
// account's existing permission rows so the read-side filter stops
197+
// matching through them.
197198
if (
198199
isUnauthorized(error) ||
199200
isForbidden(error) ||
201+
isGone(error) ||
200202
error instanceof RefreshTokenError
201203
) {
202204
await this.db.account.update({

packages/backend/src/errors.test.ts

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { describe, expect, test } from 'vitest';
22
import { RequestError } from '@octokit/request-error';
33
import { GitbeakerRequestError } from '@gitbeaker/requester-utils';
4-
import { isForbidden, isUnauthorized } from './errors';
4+
import { isForbidden, isGone, isUnauthorized } from './errors';
55
import { throwOnHttpError } from './bitbucket';
66

77
// Helper: invoke the openapi-fetch middleware against a synthetic Response and
@@ -148,6 +148,56 @@ describe('isForbidden', () => {
148148
});
149149
});
150150

151+
describe('isGone', () => {
152+
test('Octokit RequestError with status 410', () => {
153+
const err = new RequestError('Gone', 410, {
154+
request: { method: 'GET', url: 'https://api.github.com/user/repos', headers: {} },
155+
});
156+
expect(isGone(err)).toBe(true);
157+
});
158+
159+
test('Octokit RequestError with status 401 is NOT gone', () => {
160+
const err = new RequestError('Unauthorized', 401, {
161+
request: { method: 'GET', url: 'https://api.github.com/user/repos', headers: {} },
162+
});
163+
expect(isGone(err)).toBe(false);
164+
});
165+
166+
test('Bitbucket middleware throws an isGone error on 410 Response', async () => {
167+
// Real-world case: Bitbucket Cloud's CHANGE-2770 removed
168+
// /2.0/user/permissions/repositories and now returns 410 Gone.
169+
const err = await invokeMiddleware(new Response('CHANGE-2770 - Functionality has been deprecated', { status: 410 }));
170+
expect(err).toBeInstanceOf(Error);
171+
expect(isGone(err)).toBe(true);
172+
});
173+
174+
test('real GitbeakerRequestError with response status 410', () => {
175+
const err = new GitbeakerRequestError('Gone', {
176+
cause: {
177+
description: 'Gone',
178+
request: new Request('https://gitlab.com/api/v4/projects'),
179+
response: new Response(null, { status: 410 }),
180+
},
181+
});
182+
expect(isGone(err)).toBe(true);
183+
});
184+
185+
test('plain Error without status is NOT gone', () => {
186+
expect(isGone(new Error('Missing required scope'))).toBe(false);
187+
});
188+
189+
test('null is NOT gone', () => {
190+
expect(isGone(null)).toBe(false);
191+
});
192+
193+
test('Octokit RequestError with status 500 is NOT gone', () => {
194+
const err = new RequestError('Internal Server Error', 500, {
195+
request: { method: 'GET', url: 'https://api.github.com/user/repos', headers: {} },
196+
});
197+
expect(isGone(err)).toBe(false);
198+
});
199+
});
200+
151201
describe('throwOnHttpError middleware contract', () => {
152202
test('does not throw on 2xx Response', async () => {
153203
const err = await invokeMiddleware(new Response('ok', { status: 200 }));

packages/backend/src/errors.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,3 +27,4 @@ const getStatus = (err: unknown): number | null => {
2727

2828
export const isUnauthorized = (err: unknown): boolean => getStatus(err) === 401;
2929
export const isForbidden = (err: unknown): boolean => getStatus(err) === 403;
30+
export const isGone = (err: unknown): boolean => getStatus(err) === 410;

0 commit comments

Comments
 (0)