From 1665d59388a2af0ad9efe0dc323f28efc59fc20a Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Thu, 12 Mar 2026 16:00:56 -0700 Subject: [PATCH 1/4] fix(worker): guard against anonymous Bitbucket Server token fallback in account permission sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bitbucket Server instances with anonymous access enabled silently treat expired/invalid OAuth tokens as anonymous rather than returning a 401. This caused account-driven permission syncing to receive an empty repo list (200 OK) and wipe all AccountToRepoPermission records. Added isBitbucketServerUserAuthenticated() which calls /rest/api/1.0/profile/recent/repos — an endpoint that always requires authentication even when anonymous access is enabled — to detect this condition before fetching repos. Also added explicit throws for unsupported provider/code host types instead of silently returning empty results. Co-Authored-By: Claude Sonnet 4.6 --- packages/backend/src/bitbucket.ts | 34 +++++++++++++++++-- .../backend/src/ee/accountPermissionSyncer.ts | 2 ++ .../backend/src/ee/repoPermissionSyncer.ts | 4 +-- 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/packages/backend/src/bitbucket.ts b/packages/backend/src/bitbucket.ts index 708f55bc3..d7c8d75fb 100644 --- a/packages/backend/src/bitbucket.ts +++ b/packages/backend/src/bitbucket.ts @@ -700,12 +700,31 @@ export const getReposForAuthenticatedBitbucketCloudUser = async ( /** * Returns the IDs of all repositories accessible to the authenticated Bitbucket Server user. * Used for account-driven permission syncing. - * - * @see https://developer.atlassian.com/server/bitbucket/rest/v906/api-group-repository/#api-rest-api-latest-repos-get + * + * @see https://developer.atlassian.com/server/bitbucket/rest/v906/api-group-repository/#api-api-latest-repos-get */ export const getReposForAuthenticatedBitbucketServerUser = async ( client: BitbucketClient, ): Promise> => { + + /** + * @note We need to explicitly check if the user is authenticated here because + * /rest/api/1.0/repos?permission=REPO_READ will return an empty list if the + * following conditions are met: + * 1. Anonymous access is enabled via `feature.public.access` + * 2. The token is expired or invalid. + * + * This check ensures we will not hit this condition and instead fail with a + * explicit error. + * + * @see https://developer.atlassian.com/server/bitbucket/rest/v906/api-group-repository/#api-api-latest-repos-get + * @see https://confluence.atlassian.com/bitbucketserver/configuration-properties-776640155.html + */ + const isAuthenticated = await isBitbucketServerUserAuthenticated(client); + if (!isAuthenticated) { + throw new Error(`Bitbucket Server authentication check failed. The OAuth token may be expired and the server may be treating the request as anonymous. Please re-authenticate with Bitbucket Server.`); + } + const repos = await fetchWithRetry(() => getPaginatedServer<{ id: number }>( `/rest/api/1.0/repos` as ServerGetRequestPath, async (url, start) => { @@ -761,4 +780,15 @@ export const getUserPermissionsForServerRepo = async ( return repoUsers .filter(entry => entry.user?.id != null) .map(entry => ({ userId: String(entry.user.id) })); +}; + +/** + * Returns true if the Bitbucket Server client is authenticated as a real user, + * false if the token is expired, invalid, or the request is being treated as anonymous. + */ +export const isBitbucketServerUserAuthenticated = async ( + client: BitbucketClient, +): Promise => { + const { error } = await client.apiClient.GET(`/rest/api/1.0/profile/recent/repos` as ServerGetRequestPath, {}); + return !error; }; \ No newline at end of file diff --git a/packages/backend/src/ee/accountPermissionSyncer.ts b/packages/backend/src/ee/accountPermissionSyncer.ts index bddf10df7..87a49cc42 100644 --- a/packages/backend/src/ee/accountPermissionSyncer.ts +++ b/packages/backend/src/ee/accountPermissionSyncer.ts @@ -330,6 +330,8 @@ export class AccountPermissionSyncer { }); repos.forEach(repo => aggregatedRepoIds.add(repo.id)); + } else { + throw new Error(`Unsupported code host type: ${account.provider}`); } return Array.from(aggregatedRepoIds); diff --git a/packages/backend/src/ee/repoPermissionSyncer.ts b/packages/backend/src/ee/repoPermissionSyncer.ts index 406dbba45..a6f42bf17 100644 --- a/packages/backend/src/ee/repoPermissionSyncer.ts +++ b/packages/backend/src/ee/repoPermissionSyncer.ts @@ -338,9 +338,7 @@ export class RepoPermissionSyncer { } } - return { - accountIds: [], - } + throw new Error(`Unsupported code host type: ${repo.external_codeHostType}`); })(); await this.db.$transaction([ From a2ae24aa4ab50e4c304c2a074436af5261c19bad Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Thu, 12 Mar 2026 16:01:28 -0700 Subject: [PATCH 2/4] chore: update CHANGELOG for #998 Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 403cda7e6..cb87fb251 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added generated OpenAPI documentation for the public search, repo, and file browsing API surface. [#996](https://github.com/sourcebot-dev/sourcebot/pull/996) +### Fixed +- [EE] Fixed account-driven permission sync silently wiping all Bitbucket Server repository permissions when the OAuth token expires on instances with anonymous access enabled. [#998](https://github.com/sourcebot-dev/sourcebot/pull/998) + ## [4.15.5] - 2026-03-12 ### Added From 3e387353f699e274c2ebc35ada1ef809376f9242 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Thu, 12 Mar 2026 16:03:33 -0700 Subject: [PATCH 3/4] nit --- packages/backend/src/bitbucket.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/backend/src/bitbucket.ts b/packages/backend/src/bitbucket.ts index d7c8d75fb..edb6e4513 100644 --- a/packages/backend/src/bitbucket.ts +++ b/packages/backend/src/bitbucket.ts @@ -700,7 +700,7 @@ export const getReposForAuthenticatedBitbucketCloudUser = async ( /** * Returns the IDs of all repositories accessible to the authenticated Bitbucket Server user. * Used for account-driven permission syncing. - * + * * @see https://developer.atlassian.com/server/bitbucket/rest/v906/api-group-repository/#api-api-latest-repos-get */ export const getReposForAuthenticatedBitbucketServerUser = async ( From f4feeba0f688bd4f0d40f9abd2ad5d09f1ff8a26 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Thu, 12 Mar 2026 16:16:03 -0700 Subject: [PATCH 4/4] feedback --- packages/backend/src/bitbucket.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/bitbucket.ts b/packages/backend/src/bitbucket.ts index edb6e4513..af606108d 100644 --- a/packages/backend/src/bitbucket.ts +++ b/packages/backend/src/bitbucket.ts @@ -789,6 +789,20 @@ export const getUserPermissionsForServerRepo = async ( export const isBitbucketServerUserAuthenticated = async ( client: BitbucketClient, ): Promise => { - const { error } = await client.apiClient.GET(`/rest/api/1.0/profile/recent/repos` as ServerGetRequestPath, {}); - return !error; + try { + const { error, response } = await client.apiClient.GET(`/rest/api/1.0/profile/recent/repos` as ServerGetRequestPath, {}); + if (error) { + if (response.status === 401 || response.status === 403) { + return false; + } + throw new Error(`Unexpected error when verifying Bitbucket Server authentication status: ${JSON.stringify(error)}`); + } + return true; + } catch (e: any) { + // Handle the case where openapi-fetch throws directly for auth errors + if (e?.status === 401 || e?.status === 403) { + return false; + } + throw e; + } }; \ No newline at end of file