From 2f741938343150ba13b25f90de796376ea610767 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Mon, 23 Feb 2026 21:44:40 -0800 Subject: [PATCH 1/7] feat(backend): add Bitbucket Cloud permission syncing Implements both repo-driven and user-driven permission syncing for Bitbucket Cloud repositories. Repo-driven syncing uses the explicit user permissions API; user-driven syncing fetches all private repos accessible to the authenticated user via their OAuth token. Also refactors RepoMetadata.codeHostMetadata to use a provider-keyed object (e.g. codeHostMetadata.bitbucketCloud) instead of a discriminated union with a redundant type field. Co-Authored-By: Claude Sonnet 4.6 --- docs/docs/features/permission-syncing.mdx | 24 ++++- packages/backend/src/bitbucket.ts | 87 ++++++++++++++++++- packages/backend/src/constants.ts | 2 + .../backend/src/ee/accountPermissionSyncer.ts | 19 ++++ .../backend/src/ee/repoPermissionSyncer.ts | 31 +++++++ packages/backend/src/repoCompileUtils.ts | 8 ++ packages/shared/src/types.ts | 10 +++ .../(server)/ee/permissionSyncStatus/route.ts | 2 +- 8 files changed, 179 insertions(+), 4 deletions(-) diff --git a/docs/docs/features/permission-syncing.mdx b/docs/docs/features/permission-syncing.mdx index cb801288d..9fdf617c6 100644 --- a/docs/docs/features/permission-syncing.mdx +++ b/docs/docs/features/permission-syncing.mdx @@ -39,7 +39,7 @@ We are actively working on supporting more code hosts. If you'd like to see a sp |:----------|------------------------------| | [GitHub (GHEC & GHEC Server)](/docs/features/permission-syncing#github) | ✅ | | [GitLab (Self-managed & Cloud)](/docs/features/permission-syncing#gitlab) | ✅ | -| Bitbucket Cloud | 🛑 | +| [Bitbucket Cloud](/docs/features/permission-syncing#bitbucket-cloud) | ⚠️ Partial | | Bitbucket Data Center | 🛑 | | Gitea | 🛑 | | Gerrit | 🛑 | @@ -78,6 +78,28 @@ Permission syncing works with **GitLab Self-managed** and **GitLab Cloud**. User - OAuth tokens require the `read_api` scope in order to use the [List projects for the authenticated user API](https://docs.gitlab.com/ee/api/projects.html#list-all-projects) during [User driven syncing](/docs/features/permission-syncing#how-it-works). - [Internal GitLab projects](https://docs.gitlab.com/user/public_access/#internal-projects-and-groups) are **not** enforced by permission syncing and therefore are visible to all users. Only [private projects](https://docs.gitlab.com/user/public_access/#private-projects-and-groups) are enforced. +## Bitbucket Cloud + +Prerequisites: +- Configure Bitbucket Cloud as an [external identity provider](/docs/configuration/idp). + +Permission syncing works with **Bitbucket Cloud**. OAuth tokens must assume the `account` and `repository` scopes. + + +**Partial coverage for repo-driven syncing.** Bitbucket Cloud's [repository user permissions API](https://developer.atlassian.com/cloud/bitbucket/rest/api-group-repositories/#api-repositories-workspace-repo-slug-permissions-config-users-get) only returns users who have been **directly and explicitly** granted access to a repository. Users who have access via any of the following are **not** captured by repo-driven syncing: + +- Membership in a [group that is added to the repository](https://support.atlassian.com/bitbucket-cloud/docs/grant-repository-access-to-users-and-groups/) +- Membership in the [project that contains the repository](https://support.atlassian.com/bitbucket-cloud/docs/configure-project-permissions-for-users-and-groups/) +- Membership in a group that is part of a project containing the repository + +These users **will** still gain access via [user-driven syncing](/docs/features/permission-syncing#how-it-works), which fetches all private repositories accessible to each authenticated user. However, there may be a delay between when a repository is added and when affected users gain access in Sourcebot (up to the `experiment_userDrivenPermissionSyncIntervalMs` interval, which defaults to 24 hours). + +If your workspace relies heavily on group or project-level permissions rather than direct user grants, we recommend reducing the `experiment_userDrivenPermissionSyncIntervalMs` interval to limit the window of delay. + + +**Notes:** +- A Bitbucket Cloud [external identity provider](/docs/configuration/idp) must be configured to (1) correlate a Sourcebot user with a Bitbucket Cloud user, and (2) to list repositories that the user has access to for [User driven syncing](/docs/features/permission-syncing#how-it-works). +- OAuth tokens require the `account` and `repository` scopes. The `repository` scope is required to list private repositories during [User driven syncing](/docs/features/permission-syncing#how-it-works). # How it works diff --git a/packages/backend/src/bitbucket.ts b/packages/backend/src/bitbucket.ts index ef0b547e6..17192bfaa 100644 --- a/packages/backend/src/bitbucket.ts +++ b/packages/backend/src/bitbucket.ts @@ -8,6 +8,8 @@ import * as Sentry from "@sentry/node"; import micromatch from "micromatch"; import { SchemaRepository as CloudRepository, + SchemaRepositoryUserPermission as CloudRepositoryUserPermission, + SchemaRepositoryPermission as CloudRepositoryPermission, } from "@coderabbitai/bitbucket/cloud/openapi"; import { SchemaRestRepository as ServerRepository } from "@coderabbitai/bitbucket/server/openapi"; import { processPromiseResults } from "./connectionUtils.js"; @@ -560,7 +562,7 @@ export function serverShouldExcludeRepo(repo: BitbucketRepository, config: Bitbu const repoSlug = serverRepo.slug!; const repoName = `${projectName}/${repoSlug}`; let reason = ''; - + const shouldExclude = (() => { if (config.exclude?.repos) { if (micromatch.isMatch(repoName, config.exclude.repos)) { @@ -587,4 +589,85 @@ export function serverShouldExcludeRepo(repo: BitbucketRepository, config: Bitbu return true; } return false; -} \ No newline at end of file +} + +/** + * Returns the account IDs of users who have been *explicitly* granted permission on a Bitbucket Cloud repository. + * + * @note This only covers direct user-to-repo grants. It does NOT include users who have access via: + * - A group that is explicitly added to the repo + * - Membership in the project that contains the repo + * - A group that is part of a project that contains the repo + * As a result, permission syncing may under-grant access for workspaces that rely on group or + * project-level permissions rather than direct user grants. + * + * @see https://developer.atlassian.com/cloud/bitbucket/rest/api-group-repositories/#api-repositories-workspace-repo-slug-permissions-config-users-get + */ +export const getExplicitUserPermissionsForCloudRepo = async ( + workspace: string, + repoSlug: string, + token: string | undefined, +): Promise> => { + const apiClient = createBitbucketCloudClient({ + baseUrl: BITBUCKET_CLOUD_API, + headers: { + Accept: "application/json", + ...(token ? { Authorization: `Bearer ${token}` } : {}), + }, + }); + + const path = `/repositories/${workspace}/${repoSlug}/permissions-config/users` as CloudGetRequestPath; + + const users = await getPaginatedCloud(path, async (p, query) => { + const response = await apiClient.GET(p, { + params: { + path: { workspace, repo_slug: repoSlug }, + query, + }, + }); + const { data, error } = response; + if (error) { + throw new Error(`Failed to get explicit user permissions for ${workspace}/${repoSlug}: ${JSON.stringify(error)}`); + } + return data; + }); + + return users + .filter(u => u.user?.account_id != null) + .map(u => ({ accountId: u.user!.account_id as string })); +}; + +/** + * Returns the UUIDs of all private repositories accessible to the authenticated Bitbucket Cloud user. + * Used for account-driven permission syncing. + * + * @see https://developer.atlassian.com/cloud/bitbucket/rest/api-group-repositories/#api-user-permissions-repositories-get + */ +export const getReposForAuthenticatedBitbucketCloudUser = async ( + accessToken: string, +): Promise> => { + const apiClient = createBitbucketCloudClient({ + baseUrl: BITBUCKET_CLOUD_API, + headers: { + Accept: "application/json", + Authorization: `Bearer ${accessToken}`, + }, + }); + + const path = `/user/permissions/repositories` as CloudGetRequestPath; + + const permissions = await getPaginatedCloud(path, async (p, query) => { + const response = await apiClient.GET(p, { + params: { query }, + }); + const { data, error } = response; + if (error) { + throw new Error(`Failed to get user repository permissions: ${JSON.stringify(error)}`); + } + return data; + }); + + return permissions + .filter(p => p.repository?.uuid != null) + .map(p => ({ uuid: p.repository!.uuid as string })); +}; \ No newline at end of file diff --git a/packages/backend/src/constants.ts b/packages/backend/src/constants.ts index dc3d7fcce..76e865ed7 100644 --- a/packages/backend/src/constants.ts +++ b/packages/backend/src/constants.ts @@ -7,11 +7,13 @@ export const SINGLE_TENANT_ORG_ID = 1; export const PERMISSION_SYNC_SUPPORTED_CODE_HOST_TYPES: CodeHostType[] = [ 'github', 'gitlab', + 'bitbucketCloud', ]; export const PERMISSION_SYNC_SUPPORTED_IDENTITY_PROVIDERS: IdentityProviderType[] = [ 'github', 'gitlab', + 'bitbucket-cloud', ]; export const REPOS_CACHE_DIR = path.join(env.DATA_CACHE_DIR, 'repos'); diff --git a/packages/backend/src/ee/accountPermissionSyncer.ts b/packages/backend/src/ee/accountPermissionSyncer.ts index 283a0fcf9..877a73f50 100644 --- a/packages/backend/src/ee/accountPermissionSyncer.ts +++ b/packages/backend/src/ee/accountPermissionSyncer.ts @@ -14,6 +14,7 @@ import { getOAuthScopesForAuthenticatedUser as getGitLabOAuthScopesForAuthenticatedUser, getProjectsForAuthenticatedUser, } from "../gitlab.js"; +import { getReposForAuthenticatedBitbucketCloudUser } from "../bitbucket.js"; import { Settings } from "../types.js"; import { setIntervalAsync } from "../utils.js"; @@ -266,6 +267,24 @@ export class AccountPermissionSyncer { } }); + repos.forEach(repo => aggregatedRepoIds.add(repo.id)); + } else if (account.provider === 'bitbucket-cloud') { + if (!accessToken) { + throw new Error(`User '${account.user.email}' does not have a Bitbucket Cloud OAuth access token associated with their account. Please re-authenticate with Bitbucket Cloud to refresh the token.`); + } + + const bitbucketRepos = await getReposForAuthenticatedBitbucketCloudUser(accessToken); + const bitbucketRepoUuids = bitbucketRepos.map(repo => repo.uuid); + + const repos = await this.db.repo.findMany({ + where: { + external_codeHostType: 'bitbucketCloud', + external_id: { + in: bitbucketRepoUuids, + } + } + }); + repos.forEach(repo => aggregatedRepoIds.add(repo.id)); } diff --git a/packages/backend/src/ee/repoPermissionSyncer.ts b/packages/backend/src/ee/repoPermissionSyncer.ts index 618746d59..bcdb954e9 100644 --- a/packages/backend/src/ee/repoPermissionSyncer.ts +++ b/packages/backend/src/ee/repoPermissionSyncer.ts @@ -7,6 +7,8 @@ import { Redis } from 'ioredis'; import { PERMISSION_SYNC_SUPPORTED_CODE_HOST_TYPES } from "../constants.js"; import { createOctokitFromToken, getRepoCollaborators, GITHUB_CLOUD_HOSTNAME } from "../github.js"; import { createGitLabFromPersonalAccessToken, getProjectMembers } from "../gitlab.js"; +import { getExplicitUserPermissionsForCloudRepo } from "../bitbucket.js"; +import { repoMetadataSchema } from "@sourcebot/shared"; import { Settings } from "../types.js"; import { getAuthCredentialsForRepo, setIntervalAsync } from "../utils.js"; @@ -234,6 +236,35 @@ export class RepoPermissionSyncer { }, }); + return accounts.map(account => account.id); + } else if (repo.external_codeHostType === 'bitbucketCloud') { + const parsedMetadata = repoMetadataSchema.safeParse(repo.metadata); + const bitbucketCloudMetadata = parsedMetadata.success ? parsedMetadata.data.codeHostMetadata?.bitbucketCloud : undefined; + if (!bitbucketCloudMetadata) { + throw new Error(`Repo ${id} is missing required Bitbucket Cloud metadata (workspace/repoSlug)`); + } + + const { workspace, repoSlug } = bitbucketCloudMetadata; + + // @note: The Bitbucket Cloud permissions API only returns users who have been *directly* + // granted access to this repository. Users who have access via a group added to the repo, + // via project-level membership, or via a group in a project are NOT captured here. + // These users will still gain access through user-driven syncing (accountPermissionSyncer), + // but there may be a delay of up to `experiment_userDrivenPermissionSyncIntervalMs` before + // they see the repository in Sourcebot. + // @see: https://developer.atlassian.com/cloud/bitbucket/rest/api-group-repositories/#api-repositories-workspace-repo-slug-permissions-config-users-get + const users = await getExplicitUserPermissionsForCloudRepo(workspace, repoSlug, credentials.token); + const userAccountIds = users.map(u => u.accountId); + + const accounts = await this.db.account.findMany({ + where: { + provider: 'bitbucket-cloud', + providerAccountId: { + in: userAccountIds, + } + }, + }); + return accounts.map(account => account.id); } diff --git a/packages/backend/src/repoCompileUtils.ts b/packages/backend/src/repoCompileUtils.ts index a6707b4df..0365758a2 100644 --- a/packages/backend/src/repoCompileUtils.ts +++ b/packages/backend/src/repoCompileUtils.ts @@ -510,6 +510,14 @@ export const compileBitbucketConfig = async ( }, branches: config.revisions?.branches ?? undefined, tags: config.revisions?.tags ?? undefined, + ...(codeHostType === 'bitbucketCloud' ? { + codeHostMetadata: { + bitbucketCloud: { + workspace: (repo as BitbucketCloudRepository).full_name!.split('/')[0]!, + repoSlug: (repo as BitbucketCloudRepository).full_name!.split('/')[1]!, + } + } + } : {}), } satisfies RepoMetadata, }; diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 2a556491a..4e69c42fc 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -31,6 +31,16 @@ export const repoMetadataSchema = z.object({ * A list of revisions that were indexed for the repo. */ indexedRevisions: z.array(z.string()).optional(), + + /** + * Code host specific metadata, keyed by code host type. + */ + codeHostMetadata: z.object({ + bitbucketCloud: z.object({ + workspace: z.string(), + repoSlug: z.string(), + }).optional(), + }).optional(), }); export type RepoMetadata = z.infer; diff --git a/packages/web/src/app/api/(server)/ee/permissionSyncStatus/route.ts b/packages/web/src/app/api/(server)/ee/permissionSyncStatus/route.ts index 6ea5849ac..234a272fa 100644 --- a/packages/web/src/app/api/(server)/ee/permissionSyncStatus/route.ts +++ b/packages/web/src/app/api/(server)/ee/permissionSyncStatus/route.ts @@ -31,7 +31,7 @@ export const GET = apiHandler(async () => { const accounts = await prisma.account.findMany({ where: { userId: user.id, - provider: { in: ['github', 'gitlab'] } + provider: { in: ['github', 'gitlab', 'bitbucket-cloud'] } }, include: { permissionSyncJobs: { From 7053a4d4319d2ae03d5de1775e878e608d3e770c Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Mon, 23 Feb 2026 21:45:08 -0800 Subject: [PATCH 2/7] chore: update CHANGELOG for #925 Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ccd12b250..d5550c79c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added PostHog events for chat UI interactions (details card expand/collapse, copy answer, table of contents toggle) and repo tracking in `wa_chat_message_sent`. [#922](https://github.com/sourcebot-dev/sourcebot/pull/922) - Added Bitbucket Cloud OAuth identity provider support (`provider: "bitbucket-cloud"`) for SSO and account-linked permission syncing. [#924](https://github.com/sourcebot-dev/sourcebot/pull/924) +- Added permission syncing support for Bitbucket Cloud (repo-driven and user-driven). [#925](https://github.com/sourcebot-dev/sourcebot/pull/925) ## [4.11.7] - 2026-02-23 From 5ab973bc67b551f52a164528adefeefe5101cfb9 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Mon, 23 Feb 2026 21:46:13 -0800 Subject: [PATCH 3/7] changelog nit --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d5550c79c..6df0b8801 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Added PostHog events for chat UI interactions (details card expand/collapse, copy answer, table of contents toggle) and repo tracking in `wa_chat_message_sent`. [#922](https://github.com/sourcebot-dev/sourcebot/pull/922) - Added Bitbucket Cloud OAuth identity provider support (`provider: "bitbucket-cloud"`) for SSO and account-linked permission syncing. [#924](https://github.com/sourcebot-dev/sourcebot/pull/924) -- Added permission syncing support for Bitbucket Cloud (repo-driven and user-driven). [#925](https://github.com/sourcebot-dev/sourcebot/pull/925) +- Added permission syncing support for Bitbucket Cloud. [#925](https://github.com/sourcebot-dev/sourcebot/pull/925) ## [4.11.7] - 2026-02-23 From adceff11709f89d73104b6e3597ecdea83b471df Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Tue, 24 Feb 2026 11:09:59 -0800 Subject: [PATCH 4/7] nits w/ authentication --- packages/backend/src/bitbucket.ts | 47 ++++++------------- .../backend/src/ee/accountPermissionSyncer.ts | 7 ++- .../backend/src/ee/repoPermissionSyncer.ts | 12 ++++- packages/backend/src/types.ts | 5 ++ packages/backend/src/utils.ts | 5 ++ 5 files changed, 40 insertions(+), 36 deletions(-) diff --git a/packages/backend/src/bitbucket.ts b/packages/backend/src/bitbucket.ts index 17192bfaa..fd4c03ed3 100644 --- a/packages/backend/src/bitbucket.ts +++ b/packages/backend/src/bitbucket.ts @@ -1,5 +1,5 @@ -import { createBitbucketCloudClient } from "@coderabbitai/bitbucket/cloud"; -import { createBitbucketServerClient } from "@coderabbitai/bitbucket/server"; +import { createBitbucketCloudClient as createBitbucketCloudClientBase } from "@coderabbitai/bitbucket/cloud"; +import { createBitbucketServerClient as createBitbucketServerClientBase } from "@coderabbitai/bitbucket/server"; import { BitbucketConnectionConfig } from "@sourcebot/schemas/v3/bitbucket.type"; import type { ClientOptions, ClientPathsWithMethod } from "openapi-fetch"; import { createLogger } from "@sourcebot/shared"; @@ -36,10 +36,10 @@ interface BitbucketClient { shouldExcludeRepo: (repo: BitbucketRepository, config: BitbucketConnectionConfig) => boolean; } -type CloudAPI = ReturnType; +type CloudAPI = ReturnType; type CloudGetRequestPath = ClientPathsWithMethod; -type ServerAPI = ReturnType; +type ServerAPI = ReturnType; type ServerGetRequestPath = ClientPathsWithMethod; type CloudPaginatedResponse = { @@ -70,8 +70,8 @@ export const getBitbucketReposFromConfig = async (config: BitbucketConnectionCon } const client = config.deploymentType === 'server' ? - serverClient(config.url!, config.user, token) : - cloudClient(config.user, token); + createBitbucketServerClient(config.url!, config.user, token) : + createBitbucketCloudClient(config.user, token); let allRepos: BitbucketRepository[] = []; let allWarnings: string[] = []; @@ -104,11 +104,10 @@ export const getBitbucketReposFromConfig = async (config: BitbucketConnectionCon }; } -function cloudClient(user: string | undefined, token: string | undefined): BitbucketClient { - +export function createBitbucketCloudClient(user: string | undefined, token: string | undefined): BitbucketClient { const authorizationString = token - ? !user || user == "x-token-auth" + ? (!user || user === "x-token-auth") ? `Bearer ${token}` : `Basic ${Buffer.from(`${user}:${token}`).toString('base64')}` : undefined; @@ -121,7 +120,7 @@ function cloudClient(user: string | undefined, token: string | undefined): Bitbu }, }; - const apiClient = createBitbucketCloudClient(clientOptions); + const apiClient = createBitbucketCloudClientBase(clientOptions); var client: BitbucketClient = { deploymentType: BITBUCKET_CLOUD, token: token, @@ -380,7 +379,7 @@ export function cloudShouldExcludeRepo(repo: BitbucketRepository, config: Bitbuc return false; } -function serverClient(url: string, user: string | undefined, token: string | undefined): BitbucketClient { +function createBitbucketServerClient(url: string, user: string | undefined, token: string | undefined): BitbucketClient { const authorizationString = (() => { // If we're not given any credentials we return an empty auth string. This will only work if the project/repos are public if(!user && !token) { @@ -402,7 +401,7 @@ function serverClient(url: string, user: string | undefined, token: string | und }, }; - const apiClient = createBitbucketServerClient(clientOptions); + const apiClient = createBitbucketServerClientBase(clientOptions); var client: BitbucketClient = { deploymentType: BITBUCKET_SERVER, token: token, @@ -604,22 +603,14 @@ export function serverShouldExcludeRepo(repo: BitbucketRepository, config: Bitbu * @see https://developer.atlassian.com/cloud/bitbucket/rest/api-group-repositories/#api-repositories-workspace-repo-slug-permissions-config-users-get */ export const getExplicitUserPermissionsForCloudRepo = async ( + client: BitbucketClient, workspace: string, repoSlug: string, - token: string | undefined, ): Promise> => { - const apiClient = createBitbucketCloudClient({ - baseUrl: BITBUCKET_CLOUD_API, - headers: { - Accept: "application/json", - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - }); - const path = `/repositories/${workspace}/${repoSlug}/permissions-config/users` as CloudGetRequestPath; const users = await getPaginatedCloud(path, async (p, query) => { - const response = await apiClient.GET(p, { + const response = await client.apiClient.GET(p, { params: { path: { workspace, repo_slug: repoSlug }, query, @@ -644,20 +635,12 @@ export const getExplicitUserPermissionsForCloudRepo = async ( * @see https://developer.atlassian.com/cloud/bitbucket/rest/api-group-repositories/#api-user-permissions-repositories-get */ export const getReposForAuthenticatedBitbucketCloudUser = async ( - accessToken: string, + client: BitbucketClient, ): Promise> => { - const apiClient = createBitbucketCloudClient({ - baseUrl: BITBUCKET_CLOUD_API, - headers: { - Accept: "application/json", - Authorization: `Bearer ${accessToken}`, - }, - }); - const path = `/user/permissions/repositories` as CloudGetRequestPath; const permissions = await getPaginatedCloud(path, async (p, query) => { - const response = await apiClient.GET(p, { + const response = await client.apiClient.GET(p, { params: { query }, }); const { data, error } = response; diff --git a/packages/backend/src/ee/accountPermissionSyncer.ts b/packages/backend/src/ee/accountPermissionSyncer.ts index 877a73f50..6b093402f 100644 --- a/packages/backend/src/ee/accountPermissionSyncer.ts +++ b/packages/backend/src/ee/accountPermissionSyncer.ts @@ -14,7 +14,7 @@ import { getOAuthScopesForAuthenticatedUser as getGitLabOAuthScopesForAuthenticatedUser, getProjectsForAuthenticatedUser, } from "../gitlab.js"; -import { getReposForAuthenticatedBitbucketCloudUser } from "../bitbucket.js"; +import { createBitbucketCloudClient, getReposForAuthenticatedBitbucketCloudUser } from "../bitbucket.js"; import { Settings } from "../types.js"; import { setIntervalAsync } from "../utils.js"; @@ -273,7 +273,10 @@ export class AccountPermissionSyncer { throw new Error(`User '${account.user.email}' does not have a Bitbucket Cloud OAuth access token associated with their account. Please re-authenticate with Bitbucket Cloud to refresh the token.`); } - const bitbucketRepos = await getReposForAuthenticatedBitbucketCloudUser(accessToken); + // @note: we don't pass a user here since we want to use a bearer token + // for authentication. + const client = createBitbucketCloudClient(/* user = */ undefined, accessToken) + const bitbucketRepos = await getReposForAuthenticatedBitbucketCloudUser(client); const bitbucketRepoUuids = bitbucketRepos.map(repo => repo.uuid); const repos = await this.db.repo.findMany({ diff --git a/packages/backend/src/ee/repoPermissionSyncer.ts b/packages/backend/src/ee/repoPermissionSyncer.ts index bcdb954e9..684eadea7 100644 --- a/packages/backend/src/ee/repoPermissionSyncer.ts +++ b/packages/backend/src/ee/repoPermissionSyncer.ts @@ -7,10 +7,11 @@ import { Redis } from 'ioredis'; import { PERMISSION_SYNC_SUPPORTED_CODE_HOST_TYPES } from "../constants.js"; import { createOctokitFromToken, getRepoCollaborators, GITHUB_CLOUD_HOSTNAME } from "../github.js"; import { createGitLabFromPersonalAccessToken, getProjectMembers } from "../gitlab.js"; -import { getExplicitUserPermissionsForCloudRepo } from "../bitbucket.js"; +import { createBitbucketCloudClient, getExplicitUserPermissionsForCloudRepo } from "../bitbucket.js"; import { repoMetadataSchema } from "@sourcebot/shared"; import { Settings } from "../types.js"; import { getAuthCredentialsForRepo, setIntervalAsync } from "../utils.js"; +import { BitbucketConnectionConfig } from "@sourcebot/schemas/v3/index.type"; type RepoPermissionSyncJob = { jobId: string; @@ -238,6 +239,13 @@ export class RepoPermissionSyncer { return accounts.map(account => account.id); } else if (repo.external_codeHostType === 'bitbucketCloud') { + const config = credentials.connectionConfig as BitbucketConnectionConfig | undefined; + if (!config) { + throw new Error(`No connection config found for repo ${id}`); + } + + const client = createBitbucketCloudClient(config.user, credentials.token); + const parsedMetadata = repoMetadataSchema.safeParse(repo.metadata); const bitbucketCloudMetadata = parsedMetadata.success ? parsedMetadata.data.codeHostMetadata?.bitbucketCloud : undefined; if (!bitbucketCloudMetadata) { @@ -253,7 +261,7 @@ export class RepoPermissionSyncer { // but there may be a delay of up to `experiment_userDrivenPermissionSyncIntervalMs` before // they see the repository in Sourcebot. // @see: https://developer.atlassian.com/cloud/bitbucket/rest/api-group-repositories/#api-repositories-workspace-repo-slug-permissions-config-users-get - const users = await getExplicitUserPermissionsForCloudRepo(workspace, repoSlug, credentials.token); + const users = await getExplicitUserPermissionsForCloudRepo(client, workspace, repoSlug); const userAccountIds = users.map(u => u.accountId); const accounts = await this.db.account.findMany({ diff --git a/packages/backend/src/types.ts b/packages/backend/src/types.ts index 70e16c05d..8803b48b9 100644 --- a/packages/backend/src/types.ts +++ b/packages/backend/src/types.ts @@ -1,4 +1,5 @@ import { Connection, Repo, RepoToConnection } from "@sourcebot/db"; +import { ConnectionConfig } from "@sourcebot/schemas/v3/connection.type"; import { Settings as SettingsSchema } from "@sourcebot/schemas/v3/index.type"; export type Settings = Required; @@ -19,4 +20,8 @@ export type RepoAuthCredentials = { token: string; cloneUrlWithToken?: string; authHeader?: string; + /** The connection that configured the + * credentials for this repo. + */ + connectionConfig?: ConnectionConfig; } \ No newline at end of file diff --git a/packages/backend/src/utils.ts b/packages/backend/src/utils.ts index f897e6fb7..79b7c8b6c 100644 --- a/packages/backend/src/utils.ts +++ b/packages/backend/src/utils.ts @@ -145,6 +145,7 @@ export const getAuthCredentialsForRepo = async (repo: RepoWithConnections, logge password: token, } ), + connectionConfig: config, } } } else if (connection.connectionType === 'gitlab') { @@ -161,6 +162,7 @@ export const getAuthCredentialsForRepo = async (repo: RepoWithConnections, logge password: token } ), + connectionConfig: config, } } } else if (connection.connectionType === 'gitea') { @@ -176,6 +178,7 @@ export const getAuthCredentialsForRepo = async (repo: RepoWithConnections, logge password: token } ), + connectionConfig: config, } } } else if (connection.connectionType === 'bitbucket') { @@ -193,6 +196,7 @@ export const getAuthCredentialsForRepo = async (repo: RepoWithConnections, logge password: token } ), + connectionConfig: config, } } } else if (connection.connectionType === 'azuredevops') { @@ -223,6 +227,7 @@ export const getAuthCredentialsForRepo = async (repo: RepoWithConnections, logge password: token } ), + connectionConfig: config, } } } From d694641d8b7d8c7cb95ce108eefc6a0739de4705 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Tue, 24 Feb 2026 12:43:47 -0800 Subject: [PATCH 5/7] feat(backend): add PermissionSyncSource to AccountToRepoPermission - Adds `PermissionSyncSource` enum (`ACCOUNT_DRIVEN` / `REPO_DRIVEN`) and `source` field to `AccountToRepoPermission` table (non-nullable, defaults to `ACCOUNT_DRIVEN`) - Both syncers now set `source` on all created permission records - Repo-driven syncer uses `isPartialSync` flag (true for Bitbucket Cloud) to only delete `REPO_DRIVEN` records on cleanup, preserving `ACCOUNT_DRIVEN` records from the account syncer - Adds `skipDuplicates: true` to repo-driven `createMany` to handle overlap between the two sync paths Co-Authored-By: Claude Sonnet 4.6 --- .../backend/src/ee/accountPermissionSyncer.ts | 3 +- .../backend/src/ee/repoPermissionSyncer.ts | 37 +++++++++++++++---- .../migration.sql | 5 +++ packages/db/prisma/schema.prisma | 7 ++++ 4 files changed, 44 insertions(+), 8 deletions(-) create mode 100644 packages/db/prisma/migrations/20260224202008_add_source_field_to_account_to_repo_permission_table/migration.sql diff --git a/packages/backend/src/ee/accountPermissionSyncer.ts b/packages/backend/src/ee/accountPermissionSyncer.ts index 6b093402f..3cbc02639 100644 --- a/packages/backend/src/ee/accountPermissionSyncer.ts +++ b/packages/backend/src/ee/accountPermissionSyncer.ts @@ -1,5 +1,5 @@ import * as Sentry from "@sentry/node"; -import { PrismaClient, AccountPermissionSyncJobStatus, Account} from "@sourcebot/db"; +import { PrismaClient, AccountPermissionSyncJobStatus, Account, PermissionSyncSource} from "@sourcebot/db"; import { env, hasEntitlement, createLogger, loadConfig, decryptOAuthToken } from "@sourcebot/shared"; import { Job, Queue, Worker } from "bullmq"; import { Redis } from "ioredis"; @@ -309,6 +309,7 @@ export class AccountPermissionSyncer { data: repoIds.map(repoId => ({ accountId: account.id, repoId, + source: PermissionSyncSource.ACCOUNT_DRIVEN, })), skipDuplicates: true, }) diff --git a/packages/backend/src/ee/repoPermissionSyncer.ts b/packages/backend/src/ee/repoPermissionSyncer.ts index 684eadea7..5ea31a9f4 100644 --- a/packages/backend/src/ee/repoPermissionSyncer.ts +++ b/packages/backend/src/ee/repoPermissionSyncer.ts @@ -1,5 +1,5 @@ import * as Sentry from "@sentry/node"; -import { PrismaClient, Repo, RepoPermissionSyncJobStatus } from "@sourcebot/db"; +import { PermissionSyncSource, PrismaClient, Repo, RepoPermissionSyncJobStatus } from "@sourcebot/db"; import { createLogger } from "@sourcebot/shared"; import { env, hasEntitlement } from "@sourcebot/shared"; import { Job, Queue, Worker } from 'bullmq'; @@ -184,7 +184,13 @@ export class RepoPermissionSyncer { throw new Error(`No credentials found for repo ${id}`); } - const accountIds = await (async () => { + const { + accountIds, + isPartialSync = false, + } = await (async (): Promise<{ + accountIds: string[], + isPartialSync?: boolean + }> => { if (repo.external_codeHostType === 'github') { const isGitHubCloud = credentials.hostUrl ? new URL(credentials.hostUrl).hostname === GITHUB_CLOUD_HOSTNAME : true; const { octokit } = await createOctokitFromToken({ @@ -213,7 +219,9 @@ export class RepoPermissionSyncer { }, }); - return accounts.map(account => account.id); + return { + accountIds: accounts.map(account => account.id), + } } else if (repo.external_codeHostType === 'gitlab') { const api = await createGitLabFromPersonalAccessToken({ token: credentials.token, @@ -237,7 +245,9 @@ export class RepoPermissionSyncer { }, }); - return accounts.map(account => account.id); + return { + accountIds: accounts.map(account => account.id), + } } else if (repo.external_codeHostType === 'bitbucketCloud') { const config = credentials.connectionConfig as BitbucketConnectionConfig | undefined; if (!config) { @@ -273,10 +283,17 @@ export class RepoPermissionSyncer { }, }); - return accounts.map(account => account.id); + return { + accountIds: accounts.map(account => account.id), + // Since we only fetch users who have been explicitly granted accesss to the repo, + // this is a partial sync. + isPartialSync: true, + } } - return []; + return { + accountIds: [], + } })(); await this.db.$transaction([ @@ -286,7 +303,11 @@ export class RepoPermissionSyncer { }, data: { permittedAccounts: { - deleteMany: {}, + // @note: if this is a partial sync, we only want to delete the repo-driven permissions + // since we don't want to overwrite the account-driven permissions. + deleteMany: isPartialSync ? { + source: PermissionSyncSource.REPO_DRIVEN, + } : {}, } } }), @@ -294,7 +315,9 @@ export class RepoPermissionSyncer { data: accountIds.map(accountId => ({ accountId, repoId: repo.id, + source: PermissionSyncSource.REPO_DRIVEN, })), + skipDuplicates: true, }) ]); } diff --git a/packages/db/prisma/migrations/20260224202008_add_source_field_to_account_to_repo_permission_table/migration.sql b/packages/db/prisma/migrations/20260224202008_add_source_field_to_account_to_repo_permission_table/migration.sql new file mode 100644 index 000000000..c61add7ee --- /dev/null +++ b/packages/db/prisma/migrations/20260224202008_add_source_field_to_account_to_repo_permission_table/migration.sql @@ -0,0 +1,5 @@ +-- CreateEnum +CREATE TYPE "PermissionSyncSource" AS ENUM ('ACCOUNT_DRIVEN', 'REPO_DRIVEN'); + +-- AlterTable +ALTER TABLE "AccountToRepoPermission" ADD COLUMN "source" "PermissionSyncSource" NOT NULL DEFAULT 'ACCOUNT_DRIVEN'; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 16c2460eb..6421e7c27 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -390,6 +390,11 @@ model AccountPermissionSyncJob { accountId String } +enum PermissionSyncSource { + ACCOUNT_DRIVEN + REPO_DRIVEN +} + model AccountToRepoPermission { createdAt DateTime @default(now()) @@ -399,6 +404,8 @@ model AccountToRepoPermission { account Account @relation(fields: [accountId], references: [id], onDelete: Cascade) accountId String + source PermissionSyncSource @default(ACCOUNT_DRIVEN) + @@id([repoId, accountId]) } From 8f72ea3d05b3138cc7a8b28c73de23c216ce2ea8 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Tue, 24 Feb 2026 12:56:36 -0800 Subject: [PATCH 6/7] feedback --- packages/backend/src/ee/repoPermissionSyncer.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/backend/src/ee/repoPermissionSyncer.ts b/packages/backend/src/ee/repoPermissionSyncer.ts index 5ea31a9f4..dfae24ae9 100644 --- a/packages/backend/src/ee/repoPermissionSyncer.ts +++ b/packages/backend/src/ee/repoPermissionSyncer.ts @@ -257,7 +257,10 @@ export class RepoPermissionSyncer { const client = createBitbucketCloudClient(config.user, credentials.token); const parsedMetadata = repoMetadataSchema.safeParse(repo.metadata); - const bitbucketCloudMetadata = parsedMetadata.success ? parsedMetadata.data.codeHostMetadata?.bitbucketCloud : undefined; + if (!parsedMetadata.success) { + throw new Error(`Repo ${id} has invalid metadata: ${JSON.stringify(parsedMetadata.error.errors)}`); + } + const bitbucketCloudMetadata = parsedMetadata.data.codeHostMetadata?.bitbucketCloud; if (!bitbucketCloudMetadata) { throw new Error(`Repo ${id} is missing required Bitbucket Cloud metadata (workspace/repoSlug)`); } @@ -285,7 +288,7 @@ export class RepoPermissionSyncer { return { accountIds: accounts.map(account => account.id), - // Since we only fetch users who have been explicitly granted accesss to the repo, + // Since we only fetch users who have been explicitly granted access to the repo, // this is a partial sync. isPartialSync: true, } From 38e192d6b57470c2e0c77b502bcb31d02fad92a1 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Tue, 24 Feb 2026 12:58:42 -0800 Subject: [PATCH 7/7] docs nit --- docs/docs/features/permission-syncing.mdx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/docs/features/permission-syncing.mdx b/docs/docs/features/permission-syncing.mdx index 9fdf617c6..12ddf3645 100644 --- a/docs/docs/features/permission-syncing.mdx +++ b/docs/docs/features/permission-syncing.mdx @@ -39,7 +39,7 @@ We are actively working on supporting more code hosts. If you'd like to see a sp |:----------|------------------------------| | [GitHub (GHEC & GHEC Server)](/docs/features/permission-syncing#github) | ✅ | | [GitLab (Self-managed & Cloud)](/docs/features/permission-syncing#gitlab) | ✅ | -| [Bitbucket Cloud](/docs/features/permission-syncing#bitbucket-cloud) | ⚠️ Partial | +| [Bitbucket Cloud](/docs/features/permission-syncing#bitbucket-cloud) | 🟠 Partial | | Bitbucket Data Center | 🛑 | | Gitea | 🛑 | | Gerrit | 🛑 |