Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
33 changes: 17 additions & 16 deletions packages/backend/src/ee/accountPermissionSyncer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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}`);
Comment thread
msukkari marked this conversation as resolved.
}
throw error;
}
Expand Down
52 changes: 51 additions & 1 deletion packages/backend/src/errors.test.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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 }));
Expand Down
1 change: 1 addition & 0 deletions packages/backend/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Loading