Skip to content

Commit 9c3e6f2

Browse files
committed
Merge remote-tracking branch 'origin/main' into msukkari/fix-bitbucket-cloud-change-2770
# Conflicts: # CHANGELOG.md
2 parents 8b65564 + d0f7c18 commit 9c3e6f2

5 files changed

Lines changed: 70 additions & 43 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] 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)
1213
- [EE] Fixed Bitbucket Cloud account-driven permission sync after Atlassian's CHANGE-2770 removed `GET /2.0/user/permissions/repositories`. [#1217](https://github.com/sourcebot-dev/sourcebot/pull/1217)
1314

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

packages/backend/src/bitbucket.ts

Lines changed: 0 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -660,38 +660,12 @@ export const getExplicitUserPermissionsForCloudRepo = async (
660660
* Returns the UUIDs of all repositories accessible to the authenticated Bitbucket Cloud user.
661661
* Used for account-driven permission syncing.
662662
*
663-
* @note Atlassian's CHANGE-2770 removed `GET /2.0/user/permissions/repositories`
664-
* (the previous single-call cross-workspace endpoint) and has stated there is no
665-
* direct replacement. The recommended path is two-step: enumerate the workspaces
666-
* the user is a member of, then enumerate the repositories the user can see
667-
* within each workspace.
668-
*
669-
* `?role=member` returns repositories where the user has at least explicit read
670-
* access (including access inherited from workspace admin, project membership,
671-
* or group membership). Bitbucket treats workspace admins as having implicit
672-
* read on every repo in the workspace, so this query naturally returns the
673-
* admin's full set without needing a special case. Conversely, a user with no
674-
* grant on a given repo gets nothing back for it, even if the workspace is
675-
* shared — Bitbucket enforces the access filter server-side.
676-
*
677-
* Visibility (public vs. private) is intentionally not filtered server-side.
678-
* Sourcebot's read-side prisma extension already short-circuits public repos to
679-
* org-wide access, so an ACCOUNT_DRIVEN row on a public repo is harmless; but
680-
* not filtering means that during a public↔private visibility flip, the user
681-
* never has a window where they're missing an ACCOUNT_DRIVEN row for a repo
682-
* they can see upstream.
683-
*
684-
* @see https://developer.atlassian.com/cloud/bitbucket/changelog/#CHANGE-2770
685663
* @see https://developer.atlassian.com/cloud/bitbucket/rest/api-group-workspaces/#api-user-workspaces-get
686664
* @see https://developer.atlassian.com/cloud/bitbucket/rest/api-group-repositories/#api-repositories-workspace-get
687665
*/
688666
export const getReposForAuthenticatedBitbucketCloudUser = async (
689667
client: BitbucketClient,
690668
): Promise<Array<{ uuid: string }>> => {
691-
// The `/user/workspaces` path is not in the openapi-fetch typed surface
692-
// (the package's bundled types still describe the deprecated paths under
693-
// /user/permissions/*), so we cast to CloudGetRequestPath and narrow the
694-
// response shape locally to just the fields we use.
695669
interface CloudUserWorkspaceAccess {
696670
readonly workspace?: { readonly slug?: string };
697671
}

packages/backend/src/ee/accountPermissionSyncer.ts

Lines changed: 17 additions & 16 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,22 +191,23 @@ 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.
197-
if (
198-
isUnauthorized(error) ||
199-
isForbidden(error) ||
200-
error instanceof RefreshTokenError
201-
) {
202-
await this.db.account.update({
203-
where: { id: account.id },
204-
data: {
205-
accessibleRepos: {
206-
deleteMany: {},
207-
},
208-
},
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.
198+
const reason =
199+
error instanceof RefreshTokenError ? 'token refresh failure' :
200+
isUnauthorized(error) ? 'HTTP 401 Unauthorized' :
201+
isForbidden(error) ? 'HTTP 403 Forbidden' :
202+
isGone(error) ? 'HTTP 410 Gone' :
203+
null;
204+
205+
if (reason !== null) {
206+
const { count } = await this.db.accountToRepoPermission.deleteMany({
207+
where: { accountId: account.id },
209208
});
209+
const message = error instanceof Error ? error.message : String(error);
210+
logger.warn(`Cleared ${count} permission row(s) for account ${account.id} (user ${account.user.email ?? 'unknown'}) — fail-closed cleanup triggered by ${reason}: ${message}`);
210211
}
211212
throw error;
212213
}

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)