From ca29e5aa37a54e199c0c029aca5765f1e48bbab4 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Tue, 24 Feb 2026 19:46:29 -0800 Subject: [PATCH 1/9] feat(backend): add Bitbucket Server permission syncing Adds account-driven and repo-driven permission sync support for Bitbucket Server (Data Center), mirroring the existing GitHub, GitLab, and Bitbucket Cloud implementations. - Extend BitbucketServerIdentityProviderConfig to support purpose: "account_linking" and accountLinkingRequired field - Request REPO_READ OAuth scope when permission syncing is enabled - Add bitbucket-server token refresh support via /rest/oauth2/latest/token - Add bitbucketServer/bitbucket-server to permission sync constants - Add getReposForAuthenticatedBitbucketServerUser and getUserPermissionsForServerRepo to bitbucket.ts - Add bitbucket-server branch to accountPermissionSyncer - Add bitbucketServer branch to repoPermissionSyncer - Update docs to reflect new account_linking purpose support Co-Authored-By: Claude Sonnet 4.6 --- docs/docs/configuration/idp.mdx | 9 +- .../schemas/v3/identityProvider.schema.mdx | 18 +++- docs/snippets/schemas/v3/index.schema.mdx | 18 +++- packages/backend/src/bitbucket.ts | 95 ++++++++++++++++++- packages/backend/src/constants.ts | 2 + .../backend/src/ee/accountPermissionSyncer.ts | 28 +++++- .../backend/src/ee/repoPermissionSyncer.ts | 32 ++++++- .../schemas/src/v3/identityProvider.schema.ts | 18 +++- .../schemas/src/v3/identityProvider.type.ts | 3 +- packages/schemas/src/v3/index.schema.ts | 18 +++- packages/schemas/src/v3/index.type.ts | 3 +- .../permissionSyncing/tokenRefresh.ts | 18 ++-- packages/web/src/ee/features/sso/sso.ts | 7 +- schemas/v3/identityProvider.json | 6 +- 14 files changed, 251 insertions(+), 24 deletions(-) diff --git a/docs/docs/configuration/idp.mdx b/docs/docs/configuration/idp.mdx index 071b51a42..64e62b6a6 100644 --- a/docs/docs/configuration/idp.mdx +++ b/docs/docs/configuration/idp.mdx @@ -220,7 +220,8 @@ in the Bitbucket Cloud identity provider config. ### Bitbucket Server -A Bitbucket Server (Data Center) connection can be used for [authentication](/docs/configuration/auth). +A Bitbucket Server (Data Center) connection can be used for [authentication](/docs/configuration/auth) and/or [permission syncing](/docs/features/permission-syncing). This is controlled using the `purpose` field +in the Bitbucket Server identity provider config. @@ -231,6 +232,7 @@ A Bitbucket Server (Data Center) connection can be used for [authentication](/do When configuring your application: - Set the redirect URL to `/api/auth/callback/bitbucket-server` (ex. https://sourcebot.coolcorp.com/api/auth/callback/bitbucket-server) + - If using for permission syncing, ensure the OAuth application requests the `REPO_READ` scope The result of creating the application is a `CLIENT_ID` and `CLIENT_SECRET` which you'll provide to Sourcebot. @@ -247,7 +249,10 @@ A Bitbucket Server (Data Center) connection can be used for [authentication](/do "identityProviders": [ { "provider": "bitbucket-server", - "purpose": "sso", + // "sso" for auth + perm sync, "account_linking" for only perm sync + "purpose": "account_linking", + // if purpose == "account_linking" this controls if a user must connect to the IdP + "accountLinkingRequired": true, "baseUrl": "https://bitbucket.example.com", "clientId": { "env": "YOUR_CLIENT_ID_ENV_VAR" diff --git a/docs/snippets/schemas/v3/identityProvider.schema.mdx b/docs/snippets/schemas/v3/identityProvider.schema.mdx index 67b55ff51..fcd924308 100644 --- a/docs/snippets/schemas/v3/identityProvider.schema.mdx +++ b/docs/snippets/schemas/v3/identityProvider.schema.mdx @@ -850,7 +850,10 @@ "const": "bitbucket-server" }, "purpose": { - "const": "sso" + "enum": [ + "sso", + "account_linking" + ] }, "clientId": { "anyOf": [ @@ -919,6 +922,10 @@ "https://bitbucket.example.com" ], "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "accountLinkingRequired": { + "type": "boolean", + "default": false } }, "required": [ @@ -1777,7 +1784,10 @@ "const": "bitbucket-server" }, "purpose": { - "const": "sso" + "enum": [ + "sso", + "account_linking" + ] }, "clientId": { "anyOf": [ @@ -1846,6 +1856,10 @@ "https://bitbucket.example.com" ], "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "accountLinkingRequired": { + "type": "boolean", + "default": false } }, "required": [ diff --git a/docs/snippets/schemas/v3/index.schema.mdx b/docs/snippets/schemas/v3/index.schema.mdx index 4c65b7477..35b126bbc 100644 --- a/docs/snippets/schemas/v3/index.schema.mdx +++ b/docs/snippets/schemas/v3/index.schema.mdx @@ -5390,7 +5390,10 @@ "const": "bitbucket-server" }, "purpose": { - "const": "sso" + "enum": [ + "sso", + "account_linking" + ] }, "clientId": { "anyOf": [ @@ -5459,6 +5462,10 @@ "https://bitbucket.example.com" ], "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "accountLinkingRequired": { + "type": "boolean", + "default": false } }, "required": [ @@ -6317,7 +6324,10 @@ "const": "bitbucket-server" }, "purpose": { - "const": "sso" + "enum": [ + "sso", + "account_linking" + ] }, "clientId": { "anyOf": [ @@ -6386,6 +6396,10 @@ "https://bitbucket.example.com" ], "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "accountLinkingRequired": { + "type": "boolean", + "default": false } }, "required": [ diff --git a/packages/backend/src/bitbucket.ts b/packages/backend/src/bitbucket.ts index fd4c03ed3..e312a4fb5 100644 --- a/packages/backend/src/bitbucket.ts +++ b/packages/backend/src/bitbucket.ts @@ -379,7 +379,7 @@ export function cloudShouldExcludeRepo(repo: BitbucketRepository, config: Bitbuc return false; } -function createBitbucketServerClient(url: string, user: string | undefined, token: string | undefined): BitbucketClient { +export 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) { @@ -653,4 +653,97 @@ export const getReposForAuthenticatedBitbucketCloudUser = async ( return permissions .filter(p => p.repository?.uuid != null) .map(p => ({ uuid: p.repository!.uuid as string })); +}; + +/** + * 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 + */ +export const getReposForAuthenticatedBitbucketServerUser = async ( + client: BitbucketClient, +): Promise> => { + const repos = await getPaginatedServer<{ id: number }>( + `/rest/api/1.0/repos` as ServerGetRequestPath, + async (url, start) => { + const response = await client.apiClient.GET(url, { + params: { + query: { + permission: 'REPO_READ', + limit: 100, + start, + }, + }, + }); + const { data, error } = response; + if (error) { + throw new Error(`Failed to fetch Bitbucket Server repos for authenticated user: ${JSON.stringify(error)}`); + } + return data; + } + ); + + return repos.map(r => ({ id: String(r.id) })); +}; + +/** + * Returns the user IDs of users who have been explicitly granted permission on a Bitbucket Server repository + * at the repo level (direct grants) or project level (inherited by all repos in the project). + * + * @note This does NOT include users who have access via groups. As a result, permission syncing + * may under-grant access for instances that rely heavily on group-level permissions. Those users + * will still gain access through account-driven syncing (accountPermissionSyncer). + * + * @see https://developer.atlassian.com/server/bitbucket/rest/v906/api-group-repository/#api-rest-api-latest-projects-projectkey-repos-reposlug-permissions-users-get + * @see https://developer.atlassian.com/server/bitbucket/rest/v906/api-group-project/#api-rest-api-latest-projects-projectkey-permissions-users-get + */ +export const getUserPermissionsForServerRepo = async ( + client: BitbucketClient, + projectKey: string, + repoSlug: string, +): Promise> => { + const userIdSet = new Set(); + + // Fetch repo-level permissions + const repoUsers = await getPaginatedServer<{ user: { id: number } }>( + `/rest/api/1.0/projects/${projectKey}/repos/${repoSlug}/permissions/users` as ServerGetRequestPath, + async (url, start) => { + const response = await client.apiClient.GET(url, { + params: { query: { limit: 100, start } }, + }); + const { data, error } = response; + if (error) { + throw new Error(`Failed to fetch repo-level permissions for ${projectKey}/${repoSlug}: ${JSON.stringify(error)}`); + } + return data; + } + ); + for (const entry of repoUsers) { + if (entry.user?.id != null) { + userIdSet.add(String(entry.user.id)); + } + } + + // Fetch project-level permissions (inherited by all repos in the project) + const projectUsers = await getPaginatedServer<{ user: { id: number } }>( + `/rest/api/1.0/projects/${projectKey}/permissions/users` as ServerGetRequestPath, + async (url, start) => { + const response = await client.apiClient.GET(url, { + params: { query: { limit: 100, start } }, + }); + const { data, error } = response; + if (error) { + throw new Error(`Failed to fetch project-level permissions for ${projectKey}: ${JSON.stringify(error)}`); + } + return data; + } + ); + for (const entry of projectUsers) { + if (entry.user?.id != null) { + userIdSet.add(String(entry.user.id)); + } + } + + return Array.from(userIdSet).map(userId => ({ userId })); }; \ No newline at end of file diff --git a/packages/backend/src/constants.ts b/packages/backend/src/constants.ts index 76e865ed7..dd55063dc 100644 --- a/packages/backend/src/constants.ts +++ b/packages/backend/src/constants.ts @@ -8,12 +8,14 @@ export const PERMISSION_SYNC_SUPPORTED_CODE_HOST_TYPES: CodeHostType[] = [ 'github', 'gitlab', 'bitbucketCloud', + 'bitbucketServer', ]; export const PERMISSION_SYNC_SUPPORTED_IDENTITY_PROVIDERS: IdentityProviderType[] = [ 'github', 'gitlab', 'bitbucket-cloud', + 'bitbucket-server', ]; 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 3cbc02639..b3ad69b48 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 { createBitbucketCloudClient, getReposForAuthenticatedBitbucketCloudUser } from "../bitbucket.js"; +import { createBitbucketCloudClient, createBitbucketServerClient, getReposForAuthenticatedBitbucketCloudUser, getReposForAuthenticatedBitbucketServerUser } from "../bitbucket.js"; import { Settings } from "../types.js"; import { setIntervalAsync } from "../utils.js"; @@ -288,6 +288,32 @@ export class AccountPermissionSyncer { } }); + repos.forEach(repo => aggregatedRepoIds.add(repo.id)); + } else if (account.provider === 'bitbucket-server') { + if (!accessToken) { + throw new Error(`User '${account.user.email}' does not have a Bitbucket Server OAuth access token associated with their account. Please re-authenticate with Bitbucket Server to refresh the token.`); + } + + // @hack: we don't have a way of identifying specific identity providers in the config file. + // Instead, we'll use the first Bitbucket Server connection's URL as the base URL. + const baseUrl = Array.from(Object.values(config.connections ?? {})) + .find(connection => connection.type === 'bitbucket' && connection.deploymentType === 'server')?.url; + + if (!baseUrl) { + throw new Error(`No Bitbucket Server connection URL found in config for account ${account.id}`); + } + + const client = createBitbucketServerClient(baseUrl, /* user = */ undefined, accessToken); + const serverRepos = await getReposForAuthenticatedBitbucketServerUser(client); + const serverRepoIds = serverRepos.map(r => r.id); + + const repos = await this.db.repo.findMany({ + where: { + external_codeHostType: 'bitbucketServer', + external_id: { in: serverRepoIds }, + } + }); + repos.forEach(repo => aggregatedRepoIds.add(repo.id)); } diff --git a/packages/backend/src/ee/repoPermissionSyncer.ts b/packages/backend/src/ee/repoPermissionSyncer.ts index dfae24ae9..8aff71301 100644 --- a/packages/backend/src/ee/repoPermissionSyncer.ts +++ b/packages/backend/src/ee/repoPermissionSyncer.ts @@ -7,7 +7,7 @@ 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 { createBitbucketCloudClient, getExplicitUserPermissionsForCloudRepo } from "../bitbucket.js"; +import { createBitbucketCloudClient, createBitbucketServerClient, getExplicitUserPermissionsForCloudRepo, getUserPermissionsForServerRepo } from "../bitbucket.js"; import { repoMetadataSchema } from "@sourcebot/shared"; import { Settings } from "../types.js"; import { getAuthCredentialsForRepo, setIntervalAsync } from "../utils.js"; @@ -292,6 +292,36 @@ export class RepoPermissionSyncer { // this is a partial sync. isPartialSync: true, } + } else if (repo.external_codeHostType === 'bitbucketServer') { + if (!repo.displayName) { + throw new Error(`Repo ${id} does not have a displayName`); + } + + const [projectKey, repoSlug] = repo.displayName.split('/'); + const hostUrl = credentials.hostUrl; + + if (!hostUrl) { + throw new Error(`No host URL found for Bitbucket Server repo ${id}`); + } + + // @note: This covers users with direct repo-level and project-level permissions. + // Users with access only via groups are NOT captured here. Those users will + // still gain access through account-driven syncing (accountPermissionSyncer). + const client = createBitbucketServerClient(hostUrl, /* user = */ undefined, credentials.token); + const users = await getUserPermissionsForServerRepo(client, projectKey, repoSlug); + const userIds = users.map(u => u.userId); + + const accounts = await this.db.account.findMany({ + where: { + provider: 'bitbucket-server', + providerAccountId: { in: userIds }, + } + }); + + return { + accountIds: accounts.map(account => account.id), + isPartialSync: true, + } } return { diff --git a/packages/schemas/src/v3/identityProvider.schema.ts b/packages/schemas/src/v3/identityProvider.schema.ts index 28b540ed7..555284887 100644 --- a/packages/schemas/src/v3/identityProvider.schema.ts +++ b/packages/schemas/src/v3/identityProvider.schema.ts @@ -849,7 +849,10 @@ const schema = { "const": "bitbucket-server" }, "purpose": { - "const": "sso" + "enum": [ + "sso", + "account_linking" + ] }, "clientId": { "anyOf": [ @@ -918,6 +921,10 @@ const schema = { "https://bitbucket.example.com" ], "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "accountLinkingRequired": { + "type": "boolean", + "default": false } }, "required": [ @@ -1776,7 +1783,10 @@ const schema = { "const": "bitbucket-server" }, "purpose": { - "const": "sso" + "enum": [ + "sso", + "account_linking" + ] }, "clientId": { "anyOf": [ @@ -1845,6 +1855,10 @@ const schema = { "https://bitbucket.example.com" ], "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "accountLinkingRequired": { + "type": "boolean", + "default": false } }, "required": [ diff --git a/packages/schemas/src/v3/identityProvider.type.ts b/packages/schemas/src/v3/identityProvider.type.ts index 17aea05e6..f409e96c9 100644 --- a/packages/schemas/src/v3/identityProvider.type.ts +++ b/packages/schemas/src/v3/identityProvider.type.ts @@ -334,7 +334,7 @@ export interface BitbucketCloudIdentityProviderConfig { } export interface BitbucketServerIdentityProviderConfig { provider: "bitbucket-server"; - purpose: "sso"; + purpose: "sso" | "account_linking"; clientId: | { /** @@ -365,4 +365,5 @@ export interface BitbucketServerIdentityProviderConfig { * The URL of the Bitbucket Server/Data Center host. */ baseUrl: string; + accountLinkingRequired?: boolean; } diff --git a/packages/schemas/src/v3/index.schema.ts b/packages/schemas/src/v3/index.schema.ts index 0c823a600..f4e44bcfe 100644 --- a/packages/schemas/src/v3/index.schema.ts +++ b/packages/schemas/src/v3/index.schema.ts @@ -5389,7 +5389,10 @@ const schema = { "const": "bitbucket-server" }, "purpose": { - "const": "sso" + "enum": [ + "sso", + "account_linking" + ] }, "clientId": { "anyOf": [ @@ -5458,6 +5461,10 @@ const schema = { "https://bitbucket.example.com" ], "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "accountLinkingRequired": { + "type": "boolean", + "default": false } }, "required": [ @@ -6316,7 +6323,10 @@ const schema = { "const": "bitbucket-server" }, "purpose": { - "const": "sso" + "enum": [ + "sso", + "account_linking" + ] }, "clientId": { "anyOf": [ @@ -6385,6 +6395,10 @@ const schema = { "https://bitbucket.example.com" ], "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "accountLinkingRequired": { + "type": "boolean", + "default": false } }, "required": [ diff --git a/packages/schemas/src/v3/index.type.ts b/packages/schemas/src/v3/index.type.ts index ba064cd57..004f639f5 100644 --- a/packages/schemas/src/v3/index.type.ts +++ b/packages/schemas/src/v3/index.type.ts @@ -1492,7 +1492,7 @@ export interface BitbucketCloudIdentityProviderConfig { } export interface BitbucketServerIdentityProviderConfig { provider: "bitbucket-server"; - purpose: "sso"; + purpose: "sso" | "account_linking"; clientId: | { /** @@ -1523,4 +1523,5 @@ export interface BitbucketServerIdentityProviderConfig { * The URL of the Bitbucket Server/Data Center host. */ baseUrl: string; + accountLinkingRequired?: boolean; } diff --git a/packages/web/src/ee/features/permissionSyncing/tokenRefresh.ts b/packages/web/src/ee/features/permissionSyncing/tokenRefresh.ts index 60ffca09b..7a5053fb6 100644 --- a/packages/web/src/ee/features/permissionSyncing/tokenRefresh.ts +++ b/packages/web/src/ee/features/permissionSyncing/tokenRefresh.ts @@ -1,6 +1,6 @@ import { loadConfig, decryptOAuthToken } from "@sourcebot/shared"; import { getTokenFromConfig, createLogger, env, encryptOAuthToken } from "@sourcebot/shared"; -import { BitbucketCloudIdentityProviderConfig, GitHubIdentityProviderConfig, GitLabIdentityProviderConfig } from "@sourcebot/schemas/v3/index.type"; +import { BitbucketCloudIdentityProviderConfig, BitbucketServerIdentityProviderConfig, GitHubIdentityProviderConfig, GitLabIdentityProviderConfig } from "@sourcebot/schemas/v3/index.type"; import { IdentityProviderType } from "@sourcebot/shared"; import { z } from 'zod'; import { prisma } from '@/prisma'; @@ -10,7 +10,8 @@ const logger = createLogger('web-ee-token-refresh'); const SUPPORTED_PROVIDERS = [ 'github', 'gitlab', - 'bitbucket-cloud' + 'bitbucket-cloud', + 'bitbucket-server', ] as const satisfies IdentityProviderType[]; type SupportedProvider = (typeof SUPPORTED_PROVIDERS)[number]; @@ -165,7 +166,8 @@ const refreshOAuthToken = async ( const linkedAccountProviderConfig = providerConfig as GitHubIdentityProviderConfig | GitLabIdentityProviderConfig | - BitbucketCloudIdentityProviderConfig; + BitbucketCloudIdentityProviderConfig | + BitbucketServerIdentityProviderConfig; // Get client credentials from config const clientId = await getTokenFromConfig(linkedAccountProviderConfig.clientId); @@ -216,9 +218,13 @@ const tryRefreshToken = async ( let url: string; if (baseUrl) { - url = provider === 'github' - ? new URL('/login/oauth/access_token', baseUrl).toString() - : new URL('/oauth/token', baseUrl).toString(); + if (provider === 'github') { + url = new URL('/login/oauth/access_token', baseUrl).toString(); + } else if (provider === 'bitbucket-server') { + url = new URL('/rest/oauth2/latest/token', baseUrl).toString(); + } else { + url = new URL('/oauth/token', baseUrl).toString(); + } } else if (provider === 'github') { url = 'https://github.com/login/oauth/access_token'; } else if (provider === 'gitlab') { diff --git a/packages/web/src/ee/features/sso/sso.ts b/packages/web/src/ee/features/sso/sso.ts index 53761c3c5..83c5d1551 100644 --- a/packages/web/src/ee/features/sso/sso.ts +++ b/packages/web/src/ee/features/sso/sso.ts @@ -93,7 +93,7 @@ export const getEEIdentityProviders = async (): Promise => { const clientId = await getTokenFromConfig(providerConfig.clientId); const clientSecret = await getTokenFromConfig(providerConfig.clientSecret); const baseUrl = providerConfig.baseUrl; - providers.push({ provider: createBitbucketServerProvider(clientId, clientSecret, baseUrl), purpose: providerConfig.purpose }); + providers.push({ provider: createBitbucketServerProvider(clientId, clientSecret, baseUrl), purpose: providerConfig.purpose, required: providerConfig.accountLinkingRequired ?? false }); } } @@ -266,7 +266,10 @@ const createBitbucketServerProvider = (clientId: string, clientSecret: string, b response_type: "code", // @see: https://confluence.atlassian.com/bitbucketserver/bitbucket-oauth-2-0-provider-api-1108483661.html scope: [ - "PUBLIC_REPOS" + "PUBLIC_REPOS", + ...(env.EXPERIMENT_EE_PERMISSION_SYNC_ENABLED === 'true' && hasEntitlement('permission-syncing') + ? ['REPO_READ'] + : []), ].join(' ') }, }, diff --git a/schemas/v3/identityProvider.json b/schemas/v3/identityProvider.json index b6b913066..0f288703a 100644 --- a/schemas/v3/identityProvider.json +++ b/schemas/v3/identityProvider.json @@ -224,7 +224,7 @@ "const": "bitbucket-server" }, "purpose": { - "const": "sso" + "enum": ["sso", "account_linking"] }, "clientId": { "$ref": "./shared.json#/definitions/Token" @@ -237,6 +237,10 @@ "description": "The URL of the Bitbucket Server/Data Center host.", "examples": ["https://bitbucket.example.com"], "pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$" + }, + "accountLinkingRequired": { + "type": "boolean", + "default": false } }, "required": ["provider", "purpose", "clientId", "clientSecret", "baseUrl"] From e5c0fd32f28f708c67eeca9838766cb1eacbfe7d Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Tue, 24 Feb 2026 19:47:27 -0800 Subject: [PATCH 2/9] chore: update CHANGELOG for Bitbucket Server permission syncing Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 44d267999..73e0c8ed8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added `wa_user_created` PostHog event fired on successful user sign-up. [#933](https://github.com/sourcebot-dev/sourcebot/pull/933) - Added `wa_askgh_login_wall_prompted` PostHog event fired when an unauthenticated user attempts to ask a question on Ask GitHub. [#933](https://github.com/sourcebot-dev/sourcebot/pull/933) - Added Bitbucket Server (Data Center) OAuth 2.0 SSO identity provider support (`provider: "bitbucket-server"`). [#934](https://github.com/sourcebot-dev/sourcebot/pull/934) +- Added permission syncing support for Bitbucket Server (Data Center), including account-driven and repo-driven sync. [#938](https://github.com/sourcebot-dev/sourcebot/pull/938) ### Changed - Hide version upgrade toast for askgithub deployment (`EXPERIMENT_ASK_GH_ENABLED`). [#931](https://github.com/sourcebot-dev/sourcebot/pull/931) From 35d7f6100e44be5c260ab8880820bfb022035efa Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Wed, 25 Feb 2026 10:28:52 -0800 Subject: [PATCH 3/9] feat(backend): store Bitbucket Server projectKey/repoSlug in codeHostMetadata Instead of deriving projectKey and repoSlug from the displayName at permission sync time, write them into codeHostMetadata.bitbucketServer during connection compile, and read from there in repoPermissionSyncer. Co-Authored-By: Claude Sonnet 4.6 --- packages/backend/src/ee/repoPermissionSyncer.ts | 11 ++++++++--- packages/backend/src/repoCompileUtils.ts | 7 +++++++ packages/shared/src/types.ts | 4 ++++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/ee/repoPermissionSyncer.ts b/packages/backend/src/ee/repoPermissionSyncer.ts index 8aff71301..7d164079e 100644 --- a/packages/backend/src/ee/repoPermissionSyncer.ts +++ b/packages/backend/src/ee/repoPermissionSyncer.ts @@ -293,11 +293,16 @@ export class RepoPermissionSyncer { isPartialSync: true, } } else if (repo.external_codeHostType === 'bitbucketServer') { - if (!repo.displayName) { - throw new Error(`Repo ${id} does not have a displayName`); + const parsedMetadata = repoMetadataSchema.safeParse(repo.metadata); + if (!parsedMetadata.success) { + throw new Error(`Repo ${id} has invalid metadata: ${JSON.stringify(parsedMetadata.error.errors)}`); + } + const bitbucketServerMetadata = parsedMetadata.data.codeHostMetadata?.bitbucketServer; + if (!bitbucketServerMetadata) { + throw new Error(`Repo ${id} is missing required Bitbucket Server metadata (projectKey/repoSlug)`); } - const [projectKey, repoSlug] = repo.displayName.split('/'); + const { projectKey, repoSlug } = bitbucketServerMetadata; const hostUrl = credentials.hostUrl; if (!hostUrl) { diff --git a/packages/backend/src/repoCompileUtils.ts b/packages/backend/src/repoCompileUtils.ts index 0365758a2..ab8794e68 100644 --- a/packages/backend/src/repoCompileUtils.ts +++ b/packages/backend/src/repoCompileUtils.ts @@ -517,6 +517,13 @@ export const compileBitbucketConfig = async ( repoSlug: (repo as BitbucketCloudRepository).full_name!.split('/')[1]!, } } + } : codeHostType === 'bitbucketServer' ? { + codeHostMetadata: { + bitbucketServer: { + projectKey: (repo as BitbucketServerRepository).project!.key!, + repoSlug: (repo as BitbucketServerRepository).slug!, + } + } } : {}), } satisfies RepoMetadata, }; diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 4e69c42fc..51aa71c75 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -40,6 +40,10 @@ export const repoMetadataSchema = z.object({ workspace: z.string(), repoSlug: z.string(), }).optional(), + bitbucketServer: z.object({ + projectKey: z.string(), + repoSlug: z.string(), + }).optional(), }).optional(), }); From e7667a43c3fc75fd007af5279fa244724e225b1f Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Wed, 25 Feb 2026 10:37:26 -0800 Subject: [PATCH 4/9] fix(backend): instrument all Bitbucket API calls with fetchWithRetry Co-Authored-By: Claude Sonnet 4.6 --- packages/backend/src/bitbucket.ts | 79 +++++++++++++++++-------------- 1 file changed, 44 insertions(+), 35 deletions(-) diff --git a/packages/backend/src/bitbucket.ts b/packages/backend/src/bitbucket.ts index e312a4fb5..4cc57cabf 100644 --- a/packages/backend/src/bitbucket.ts +++ b/packages/backend/src/bitbucket.ts @@ -248,26 +248,29 @@ async function cloudGetReposForProjects(client: BitbucketClient, projects: strin logger.debug(`Fetching all repos for project ${project} for workspace ${workspace}...`); try { - const repos = await getPaginatedCloud(`/repositories/${workspace}` as CloudGetRequestPath, async (path, query) => { - const response = await client.apiClient.GET(path, { - params: { - path: { - workspace, - }, - query: { - ...query, - q: `project.key="${project_name}"` + const { durationMs, data: repos } = await measure(async () => { + const fetchFn = () => getPaginatedCloud(`/repositories/${workspace}` as CloudGetRequestPath, async (path, query) => { + const response = await client.apiClient.GET(path, { + params: { + path: { + workspace, + }, + query: { + ...query, + q: `project.key="${project_name}"` + } } + }); + const { data, error } = response; + if (error) { + throw new Error (`Failed to fetch projects for workspace ${workspace}: ${error.type}`); } + return data; }); - const { data, error } = response; - if (error) { - throw new Error (`Failed to fetch projects for workspace ${workspace}: ${error.type}`); - } - return data; + return fetchWithRetry(fetchFn, `project ${project_name} in workspace ${workspace}`, logger); }); - logger.debug(`Found ${repos.length} repos for project ${project_name} for workspace ${workspace}.`); + logger.debug(`Found ${repos.length} repos for project ${project_name} for workspace ${workspace} in ${durationMs}ms.`); return { type: 'valid' as const, data: repos @@ -312,11 +315,14 @@ async function cloudGetRepos(client: BitbucketClient, repoList: string[]): Promi logger.debug(`Fetching repo ${repo_slug} for workspace ${workspace}...`); try { const path = `/repositories/${workspace}/${repo_slug}` as CloudGetRequestPath; - const response = await client.apiClient.GET(path); - const { data, error } = response; - if (error) { - throw new Error(`Failed to fetch repo ${repo}: ${error.type}`); - } + const data = await fetchWithRetry(async () => { + const response = await client.apiClient.GET(path); + const { data, error } = response; + if (error) { + throw new Error(`Failed to fetch repo ${repo}: ${error.type}`); + } + return data; + }, `repo ${repo}`, logger); return { type: 'valid' as const, data: [data] @@ -520,11 +526,14 @@ async function serverGetRepos(client: BitbucketClient, repoList: string[]): Prom logger.debug(`Fetching repo ${repo_slug} for project ${project}...`); try { const path = `/rest/api/1.0/projects/${project}/repos/${repo_slug}` as ServerGetRequestPath; - const response = await client.apiClient.GET(path); - const { data, error } = response; - if (error) { - throw new Error(`Failed to fetch repo ${repo}: ${error.type}`); - } + const data = await fetchWithRetry(async () => { + const response = await client.apiClient.GET(path); + const { data, error } = response; + if (error) { + throw new Error(`Failed to fetch repo ${repo}: ${error.type}`); + } + return data; + }, `repo ${repo}`, logger); return { type: 'valid' as const, data: [data] @@ -609,7 +618,7 @@ export const getExplicitUserPermissionsForCloudRepo = async ( ): Promise> => { const path = `/repositories/${workspace}/${repoSlug}/permissions-config/users` as CloudGetRequestPath; - const users = await getPaginatedCloud(path, async (p, query) => { + const users = await fetchWithRetry(() => getPaginatedCloud(path, async (p, query) => { const response = await client.apiClient.GET(p, { params: { path: { workspace, repo_slug: repoSlug }, @@ -621,7 +630,7 @@ export const getExplicitUserPermissionsForCloudRepo = async ( throw new Error(`Failed to get explicit user permissions for ${workspace}/${repoSlug}: ${JSON.stringify(error)}`); } return data; - }); + }), `permissions for ${workspace}/${repoSlug}`, logger); return users .filter(u => u.user?.account_id != null) @@ -639,7 +648,7 @@ export const getReposForAuthenticatedBitbucketCloudUser = async ( ): Promise> => { const path = `/user/permissions/repositories` as CloudGetRequestPath; - const permissions = await getPaginatedCloud(path, async (p, query) => { + const permissions = await fetchWithRetry(() => getPaginatedCloud(path, async (p, query) => { const response = await client.apiClient.GET(p, { params: { query }, }); @@ -648,7 +657,7 @@ export const getReposForAuthenticatedBitbucketCloudUser = async ( throw new Error(`Failed to get user repository permissions: ${JSON.stringify(error)}`); } return data; - }); + }), 'user repository permissions', logger); return permissions .filter(p => p.repository?.uuid != null) @@ -664,7 +673,7 @@ export const getReposForAuthenticatedBitbucketCloudUser = async ( export const getReposForAuthenticatedBitbucketServerUser = async ( client: BitbucketClient, ): Promise> => { - const repos = await getPaginatedServer<{ id: number }>( + const repos = await fetchWithRetry(() => getPaginatedServer<{ id: number }>( `/rest/api/1.0/repos` as ServerGetRequestPath, async (url, start) => { const response = await client.apiClient.GET(url, { @@ -682,7 +691,7 @@ export const getReposForAuthenticatedBitbucketServerUser = async ( } return data; } - ); + ), 'repos for authenticated Bitbucket Server user', logger); return repos.map(r => ({ id: String(r.id) })); }; @@ -706,7 +715,7 @@ export const getUserPermissionsForServerRepo = async ( const userIdSet = new Set(); // Fetch repo-level permissions - const repoUsers = await getPaginatedServer<{ user: { id: number } }>( + const repoUsers = await fetchWithRetry(() => getPaginatedServer<{ user: { id: number } }>( `/rest/api/1.0/projects/${projectKey}/repos/${repoSlug}/permissions/users` as ServerGetRequestPath, async (url, start) => { const response = await client.apiClient.GET(url, { @@ -718,7 +727,7 @@ export const getUserPermissionsForServerRepo = async ( } return data; } - ); + ), `repo-level permissions for ${projectKey}/${repoSlug}`, logger); for (const entry of repoUsers) { if (entry.user?.id != null) { userIdSet.add(String(entry.user.id)); @@ -726,7 +735,7 @@ export const getUserPermissionsForServerRepo = async ( } // Fetch project-level permissions (inherited by all repos in the project) - const projectUsers = await getPaginatedServer<{ user: { id: number } }>( + const projectUsers = await fetchWithRetry(() => getPaginatedServer<{ user: { id: number } }>( `/rest/api/1.0/projects/${projectKey}/permissions/users` as ServerGetRequestPath, async (url, start) => { const response = await client.apiClient.GET(url, { @@ -738,7 +747,7 @@ export const getUserPermissionsForServerRepo = async ( } return data; } - ); + ), `project-level permissions for ${projectKey}`, logger); for (const entry of projectUsers) { if (entry.user?.id != null) { userIdSet.add(String(entry.user.id)); From 711dbfead3ed45bf9cdbf2ae2c7e87838cb3f2c4 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Wed, 25 Feb 2026 11:15:25 -0800 Subject: [PATCH 5/9] docs: update Bitbucket Data Center authentication docs with token type tabs Co-Authored-By: Claude Sonnet 4.6 --- .../connections/bitbucket-data-center.mdx | 94 +++++++++++++------ 1 file changed, 65 insertions(+), 29 deletions(-) diff --git a/docs/docs/connections/bitbucket-data-center.mdx b/docs/docs/connections/bitbucket-data-center.mdx index 1d6979196..b56b9969c 100644 --- a/docs/docs/connections/bitbucket-data-center.mdx +++ b/docs/docs/connections/bitbucket-data-center.mdx @@ -68,35 +68,71 @@ If you're not familiar with Sourcebot [connections](/docs/connections/overview), ## Authenticating with Bitbucket Data Center -In order to index private repositories, you'll need to provide an access token to Sourcebot via a [token](/docs/configuration/config-file#tokens). - -Create an access token for the desired scope (repo, project, or workspace). Visit the official [Bitbucket Data Center docs](https://confluence.atlassian.com/bitbucketserver/http-access-tokens-939515499.html) -for more info. - -1. Add the `token` property to your connection config: - -```json -{ - "type": "bitbucket", - "deploymentType": "server", - "url": "https://mybitbucketdeployment.com", - "token": { - // note: this env var can be named anything. It - // doesn't need to be `BITBUCKET_TOKEN`. - "env": "BITBUCKET_TOKEN" - } - // .. rest of config .. -} -``` - -2. Pass this environment variable each time you run Sourcebot: - -```bash -docker run \ - -e BITBUCKET_TOKEN= \ - /* additional args */ \ - ghcr.io/sourcebot-dev/sourcebot:latest -``` +In order to index private repositories, you'll need to provide a [HTTP Access Token](https://confluence.atlassian.com/bitbucketserver/http-access-tokens-939515499.html). Tokens can be scoped to a user account, a project, or an individual repository. + + + + User account tokens grant access to all repositories the user can access. Because these are tied to a specific user account, you must also set the `user` field to that user's username. + + 1. In Bitbucket Data Center, navigate to your profile → **Manage account** → **HTTP access tokens** and click **Create token**. Give it a name and grant it **Project read** and **Repository read** permissions. + + 2. Add the `user` (your Bitbucket username) and `token` properties to your connection config: + + ```json + { + "type": "bitbucket", + "deploymentType": "server", + "url": "https://mybitbucketdeployment.com", + "user": "myusername", + "token": { + // note: this env var can be named anything. It + // doesn't need to be `BITBUCKET_TOKEN`. + "env": "BITBUCKET_TOKEN" + } + // .. rest of config .. + } + ``` + + 3. Pass this environment variable each time you run Sourcebot: + + ```bash + docker run \ + -e BITBUCKET_TOKEN= \ + /* additional args */ \ + ghcr.io/sourcebot-dev/sourcebot:latest + ``` + + + Project and repository tokens are scoped to a specific project or repository. + + 1. In Bitbucket Data Center, navigate to the project or repository → **Settings** → **HTTP access tokens** and click **Create token**. Give it a name and grant it **Repository read** and **Project read** permissions. + + 2. Add the `token` property to your connection config: + + ```json + { + "type": "bitbucket", + "deploymentType": "server", + "url": "https://mybitbucketdeployment.com", + "token": { + // note: this env var can be named anything. It + // doesn't need to be `BITBUCKET_TOKEN`. + "env": "BITBUCKET_TOKEN" + } + // .. rest of config .. + } + ``` + + 3. Pass this environment variable each time you run Sourcebot: + + ```bash + docker run \ + -e BITBUCKET_TOKEN= \ + /* additional args */ \ + ghcr.io/sourcebot-dev/sourcebot:latest + ``` + + ## Troubleshooting If you're seeing errors like `TypeError: fetch failed` when fetching repo info, it may be that Sourcebot is refusing to connect to your self-hosted Bitbucket instance due to unrecognized SSL certs. Try setting the `NODE_TLS_REJECT_UNAUTHORIZED=0` environment variable or providing Sourcebot your certs through the `NODE_EXTRA_CA_CERTS` environment variable. From 6947303933b0f00acf7ccf3a79a89ed8d854ddab Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Wed, 25 Feb 2026 11:38:36 -0800 Subject: [PATCH 6/9] docs: update permission syncing and IDP docs for Bitbucket Data Center MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add Bitbucket Data Center to permission-syncing.mdx with prerequisites, partial coverage warning, and notes - Update platform support table to show 🟠 Partial for Bitbucket Data Center - Add connection prerequisites to all code host sections - Update IDP links to point to specific subsections per code host - Update idp.mdx so all code hosts consistently mention additional OAuth scopes needed for permission syncing (matching GitLab's style) - Update bitbucket-data-center.mdx auth section with tabs for user account vs project/repository tokens, and add Note about admin permissions required Co-Authored-By: Claude Sonnet 4.6 --- docs/docs/configuration/idp.mdx | 12 +++--- .../connections/bitbucket-data-center.mdx | 6 ++- docs/docs/features/permission-syncing.mdx | 38 +++++++++++++++---- 3 files changed, 43 insertions(+), 13 deletions(-) diff --git a/docs/docs/configuration/idp.mdx b/docs/docs/configuration/idp.mdx index 64e62b6a6..38e8b3fd3 100644 --- a/docs/docs/configuration/idp.mdx +++ b/docs/docs/configuration/idp.mdx @@ -74,9 +74,11 @@ in the GitHub identity provider config. - `"Metadata" repository permissions (read)` (only needed if using permission syncing) - Follow [this guide](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app) by GitHub to create an OAuth App. - + Follow [this guide](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app) by GitHub to create an OAuth App. + When asked to provide a callback url, provide `/api/auth/callback/github` (ex. https://sourcebot.coolcorp.com/api/auth/callback/github) + + If [permission syncing](/docs/features/permission-syncing#github) is enabled, also enable the `repo` scope. @@ -128,7 +130,7 @@ in the GitLab identity provider config. When configuring your application: - Set the callback URL to `/api/auth/callback/gitlab` (ex. https://sourcebot.coolcorp.com/api/auth/callback/gitlab) - Enable the `read_user` scope - - If using for permission syncing, also enable the `read_api` scope + - If [permission syncing](/docs/features/permission-syncing#gitlab) is enabled, also enable the `read_api` scope The result of registering an OAuth application is an `APPLICATION_ID` (`CLIENT_ID`) and `SECRET` (`CLIENT_SECRET`) which you'll provide to Sourcebot. @@ -182,7 +184,7 @@ in the Bitbucket Cloud identity provider config. When configuring your consumer: - Set the callback URL to `/api/auth/callback/bitbucket-cloud` (ex. https://sourcebot.coolcorp.com/api/auth/callback/bitbucket-cloud) - Enable **Account: Read** - - If using for permission syncing, also enable **Repositories: Read** + - If [permission syncing](/docs/features/permission-syncing#bitbucket-cloud) is enabled, also enable **Repositories: Read** The result of creating an OAuth consumer is a `Key` (`CLIENT_ID`) and `Secret` (`CLIENT_SECRET`) which you'll provide to Sourcebot. @@ -232,7 +234,7 @@ in the Bitbucket Server identity provider config. When configuring your application: - Set the redirect URL to `/api/auth/callback/bitbucket-server` (ex. https://sourcebot.coolcorp.com/api/auth/callback/bitbucket-server) - - If using for permission syncing, ensure the OAuth application requests the `REPO_READ` scope + - If [permission syncing](/docs/features/permission-syncing#bitbucket-data-center) is enabled, also enable the `REPO_READ` scope The result of creating the application is a `CLIENT_ID` and `CLIENT_SECRET` which you'll provide to Sourcebot. diff --git a/docs/docs/connections/bitbucket-data-center.mdx b/docs/docs/connections/bitbucket-data-center.mdx index b56b9969c..4ac357b7b 100644 --- a/docs/docs/connections/bitbucket-data-center.mdx +++ b/docs/docs/connections/bitbucket-data-center.mdx @@ -68,7 +68,11 @@ If you're not familiar with Sourcebot [connections](/docs/connections/overview), ## Authenticating with Bitbucket Data Center -In order to index private repositories, you'll need to provide a [HTTP Access Token](https://confluence.atlassian.com/bitbucketserver/http-access-tokens-939515499.html). Tokens can be scoped to a user account, a project, or an individual repository. +In order to index private repositories, you'll need to provide a [HTTP Access Token](https://confluence.atlassian.com/bitbucketserver/http-access-tokens-939515499.html). Tokens can be scoped to a user account, a project, or an individual repository. Only repositories visible to the token will be able to be indexed by Sourcebot. + + + If [permission syncing](/docs/features/permission-syncing#bitbucket-data-center) is enabled, the token must have **Repository Admin** and **Project Admin** permissions so Sourcebot can read repository and project-level user permissions. + diff --git a/docs/docs/features/permission-syncing.mdx b/docs/docs/features/permission-syncing.mdx index 12ddf3645..a2413f6f6 100644 --- a/docs/docs/features/permission-syncing.mdx +++ b/docs/docs/features/permission-syncing.mdx @@ -40,7 +40,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 Data Center | 🛑 | +| [Bitbucket Data Center](/docs/features/permission-syncing#bitbucket-data-center) | 🟠 Partial | | Gitea | 🛑 | | Gerrit | 🛑 | | Generic git host | 🛑 | @@ -50,7 +50,8 @@ We are actively working on supporting more code hosts. If you'd like to see a sp ## GitHub Prerequisites: -- Configure GitHub as an [external identity provider](/docs/configuration/idp). +- Configure a [GitHub connection](/docs/connections/github). +- Configure GitHub as an [external identity provider](/docs/configuration/idp#github). - **If you are using a self-hosted GitHub instance**, you must also set the `baseUrl` property of the `github` identity provider in the [config file](/docs/configuration/config-file) to the base URL of your GitHub instance (e.g. `https://github.example.com`). Permission syncing works with **GitHub.com**, **GitHub Enterprise Cloud**, and **GitHub Enterprise Server**. For organization-owned repositories, users that have **read-only** access (or above) via the following methods will have their access synced to Sourcebot: @@ -61,27 +62,29 @@ Permission syncing works with **GitHub.com**, **GitHub Enterprise Cloud**, and * - Organization owners. **Notes:** -- A GitHub [external identity provider](/docs/configuration/idp) must be configured to (1) correlate a Sourcebot user with a GitHub user, and (2) to list repositories that the user has access to for [User driven syncing](/docs/features/permission-syncing#how-it-works). +- A GitHub [external identity provider](/docs/configuration/idp#github) must be configured to (1) correlate a Sourcebot user with a GitHub 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 must assume the `repo` scope in order to use the [List repositories for the authenticated user API](https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#list-repositories-for-the-authenticated-user) during [User driven syncing](/docs/features/permission-syncing#how-it-works). Sourcebot **will only** use this token for **reads**. ## GitLab Prerequisites: -- Configure GitLab as an [external identity provider](/docs/configuration/idp). +- Configure a [GitLab connection](/docs/connections/gitlab). +- Configure GitLab as an [external identity provider](/docs/configuration/idp#gitlab). - **If you are using a self-hosted GitLab instance**, you must also set the `baseUrl` property of the `gitlab` identity provider in the [config file](/docs/configuration/config-file) to the base URL of your GitLab instance (e.g. `https://gitlab.example.com`). Permission syncing works with **GitLab Self-managed** and **GitLab Cloud**. Users with **Guest** role or above with membership to a group or project will have their access synced to Sourcebot. Both direct and indirect membership to a group or project will be synced with Sourcebot. For more details, see the [GitLab docs](https://docs.gitlab.com/user/project/members/#membership-types). **Notes:** -- A GitLab [external identity provider](/docs/configuration/idp) must be configured to (1) correlate a Sourcebot user with a GitLab user, and (2) to list repositories that the user has access to for [User driven syncing](/docs/features/permission-syncing#how-it-works). +- A GitLab [external identity provider](/docs/configuration/idp#gitlab) must be configured to (1) correlate a Sourcebot user with a GitLab 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 `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). +- Configure a [Bitbucket Cloud connection](/docs/connections/bitbucket-cloud). +- Configure Bitbucket Cloud as an [external identity provider](/docs/configuration/idp#bitbucket-cloud). Permission syncing works with **Bitbucket Cloud**. OAuth tokens must assume the `account` and `repository` scopes. @@ -98,9 +101,30 @@ If your workspace relies heavily on group or project-level permissions rather th **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). +- A Bitbucket Cloud [external identity provider](/docs/configuration/idp#bitbucket-cloud) 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). +## Bitbucket Data Center + +Prerequisites: +- Configure a [Bitbucket Data Center connection](/docs/connections/bitbucket-data-center). +- Configure Bitbucket Data Center as an [external identity provider](/docs/configuration/idp#bitbucket-server). + +Permission syncing works with **Bitbucket Data Center**. OAuth tokens must assume the `PUBLIC_REPOS` and `REPO_READ` scopes. + + +**Partial coverage for repo-driven syncing.** Bitbucket Data Center's permissions APIs only return users who have been **directly and explicitly** granted access at the repository or project level. Users who have access via group membership are **not** captured by repo-driven syncing. + +These users **will** still gain access via [user-driven syncing](/docs/features/permission-syncing#how-it-works), which fetches all repositories accessible to each authenticated user using the `REPO_READ` scope. However, there may be a delay between when access is granted and when affected users see the repository in Sourcebot (up to the `experiment_userDrivenPermissionSyncIntervalMs` interval, which defaults to 24 hours). + +If your instance relies heavily on group-level permissions, we recommend reducing the `experiment_userDrivenPermissionSyncIntervalMs` interval to limit the window of delay. + + +**Notes:** +- A Bitbucket Data Center [external identity provider](/docs/configuration/idp#bitbucket-server) must be configured to (1) correlate a Sourcebot user with a Bitbucket Data Center user, and (2) to list repositories that the user has access to for [User driven syncing](/docs/features/permission-syncing#how-it-works). +- The connection token must have **Repository Admin** and **Project Admin** permissions so Sourcebot can read repository and project-level user permissions for [Repo driven syncing](/docs/features/permission-syncing#how-it-works). +- OAuth tokens require the `REPO_READ` scope to list accessible repositories during [User driven syncing](/docs/features/permission-syncing#how-it-works). + # How it works Permission syncing works by periodically syncing ACLs from the code host(s) to Sourcebot to build an internal mapping between Users and Repositories. This mapping is hydrated in two directions: From 2e3039559bb4143ca75d3ca9ef5698542f203215 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Wed, 25 Feb 2026 14:15:09 -0800 Subject: [PATCH 7/9] fix(backend): remove project-level permission call from Bitbucket Server repo-driven sync Repo-driven syncing for Bitbucket Server now only covers users with direct repo-level grants. Project-level and group-level access remains covered by account-driven syncing, consistent with the Bitbucket Cloud approach. This avoids redundant API calls (one per repo for the same project) that could cause rate limiting issues at scale. Co-Authored-By: Claude Sonnet 4.6 --- .../connections/bitbucket-data-center.mdx | 2 +- docs/docs/features/permission-syncing.mdx | 9 ++-- packages/backend/src/bitbucket.ts | 43 ++++--------------- 3 files changed, 15 insertions(+), 39 deletions(-) diff --git a/docs/docs/connections/bitbucket-data-center.mdx b/docs/docs/connections/bitbucket-data-center.mdx index 4ac357b7b..c4a958f3a 100644 --- a/docs/docs/connections/bitbucket-data-center.mdx +++ b/docs/docs/connections/bitbucket-data-center.mdx @@ -71,7 +71,7 @@ If you're not familiar with Sourcebot [connections](/docs/connections/overview), In order to index private repositories, you'll need to provide a [HTTP Access Token](https://confluence.atlassian.com/bitbucketserver/http-access-tokens-939515499.html). Tokens can be scoped to a user account, a project, or an individual repository. Only repositories visible to the token will be able to be indexed by Sourcebot. - If [permission syncing](/docs/features/permission-syncing#bitbucket-data-center) is enabled, the token must have **Repository Admin** and **Project Admin** permissions so Sourcebot can read repository and project-level user permissions. + If [permission syncing](/docs/features/permission-syncing#bitbucket-data-center) is enabled, the token must have **Repository Admin** permissions so Sourcebot can read repository-level user permissions. diff --git a/docs/docs/features/permission-syncing.mdx b/docs/docs/features/permission-syncing.mdx index a2413f6f6..5598ffcd6 100644 --- a/docs/docs/features/permission-syncing.mdx +++ b/docs/docs/features/permission-syncing.mdx @@ -113,16 +113,19 @@ Prerequisites: Permission syncing works with **Bitbucket Data Center**. OAuth tokens must assume the `PUBLIC_REPOS` and `REPO_READ` scopes. -**Partial coverage for repo-driven syncing.** Bitbucket Data Center's permissions APIs only return users who have been **directly and explicitly** granted access at the repository or project level. Users who have access via group membership are **not** captured by repo-driven syncing. +**Partial coverage for repo-driven syncing.** Repo-driven syncing only captures users who have been **directly and explicitly** granted access to the repository. Users who have access via any of the following are **not** captured by repo-driven syncing: + +- Project-level permissions (inherited by all repos in the project) +- Group membership These users **will** still gain access via [user-driven syncing](/docs/features/permission-syncing#how-it-works), which fetches all repositories accessible to each authenticated user using the `REPO_READ` scope. However, there may be a delay between when access is granted and when affected users see the repository in Sourcebot (up to the `experiment_userDrivenPermissionSyncIntervalMs` interval, which defaults to 24 hours). -If your instance relies heavily on group-level permissions, we recommend reducing the `experiment_userDrivenPermissionSyncIntervalMs` interval to limit the window of delay. +If your instance relies heavily on project or group-level permissions, we recommend reducing the `experiment_userDrivenPermissionSyncIntervalMs` interval to limit the window of delay. **Notes:** - A Bitbucket Data Center [external identity provider](/docs/configuration/idp#bitbucket-server) must be configured to (1) correlate a Sourcebot user with a Bitbucket Data Center user, and (2) to list repositories that the user has access to for [User driven syncing](/docs/features/permission-syncing#how-it-works). -- The connection token must have **Repository Admin** and **Project Admin** permissions so Sourcebot can read repository and project-level user permissions for [Repo driven syncing](/docs/features/permission-syncing#how-it-works). +- The connection token must have **Repository Read** permissions so Sourcebot can read repository-level user permissions for [Repo driven syncing](/docs/features/permission-syncing#how-it-works). - OAuth tokens require the `REPO_READ` scope to list accessible 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 4cc57cabf..9da20f92a 100644 --- a/packages/backend/src/bitbucket.ts +++ b/packages/backend/src/bitbucket.ts @@ -697,24 +697,20 @@ export const getReposForAuthenticatedBitbucketServerUser = async ( }; /** - * Returns the user IDs of users who have been explicitly granted permission on a Bitbucket Server repository - * at the repo level (direct grants) or project level (inherited by all repos in the project). + * Returns the user IDs of users who have been explicitly granted direct access to a Bitbucket Server repository. * - * @note This does NOT include users who have access via groups. As a result, permission syncing - * may under-grant access for instances that rely heavily on group-level permissions. Those users - * will still gain access through account-driven syncing (accountPermissionSyncer). + * @note This only covers direct user-to-repo grants. It does NOT include users who have access via: + * - Project-level permissions (inherited by all repos in the project) + * - Group membership + * These users will still gain access through account-driven syncing (accountPermissionSyncer). * * @see https://developer.atlassian.com/server/bitbucket/rest/v906/api-group-repository/#api-rest-api-latest-projects-projectkey-repos-reposlug-permissions-users-get - * @see https://developer.atlassian.com/server/bitbucket/rest/v906/api-group-project/#api-rest-api-latest-projects-projectkey-permissions-users-get */ export const getUserPermissionsForServerRepo = async ( client: BitbucketClient, projectKey: string, repoSlug: string, ): Promise> => { - const userIdSet = new Set(); - - // Fetch repo-level permissions const repoUsers = await fetchWithRetry(() => getPaginatedServer<{ user: { id: number } }>( `/rest/api/1.0/projects/${projectKey}/repos/${repoSlug}/permissions/users` as ServerGetRequestPath, async (url, start) => { @@ -728,31 +724,8 @@ export const getUserPermissionsForServerRepo = async ( return data; } ), `repo-level permissions for ${projectKey}/${repoSlug}`, logger); - for (const entry of repoUsers) { - if (entry.user?.id != null) { - userIdSet.add(String(entry.user.id)); - } - } - - // Fetch project-level permissions (inherited by all repos in the project) - const projectUsers = await fetchWithRetry(() => getPaginatedServer<{ user: { id: number } }>( - `/rest/api/1.0/projects/${projectKey}/permissions/users` as ServerGetRequestPath, - async (url, start) => { - const response = await client.apiClient.GET(url, { - params: { query: { limit: 100, start } }, - }); - const { data, error } = response; - if (error) { - throw new Error(`Failed to fetch project-level permissions for ${projectKey}: ${JSON.stringify(error)}`); - } - return data; - } - ), `project-level permissions for ${projectKey}`, logger); - for (const entry of projectUsers) { - if (entry.user?.id != null) { - userIdSet.add(String(entry.user.id)); - } - } - return Array.from(userIdSet).map(userId => ({ userId })); + return repoUsers + .filter(entry => entry.user?.id != null) + .map(entry => ({ userId: String(entry.user.id) })); }; \ No newline at end of file From 4bf5113f3cffc3fd490ccc70c65e5216366775f3 Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Wed, 25 Feb 2026 14:30:11 -0800 Subject: [PATCH 8/9] fix(web): preserve context path in OAuth token refresh URL construction Using absolute paths (e.g. '/rest/...') with new URL() drops any pathname from the base URL. Normalize the base to end with '/' and use relative paths so deployments with a context path (e.g. https://example.com/bitbucket) resolve correctly. Co-Authored-By: Claude Sonnet 4.6 --- .../src/ee/features/permissionSyncing/tokenRefresh.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/web/src/ee/features/permissionSyncing/tokenRefresh.ts b/packages/web/src/ee/features/permissionSyncing/tokenRefresh.ts index 7a5053fb6..60ce7e955 100644 --- a/packages/web/src/ee/features/permissionSyncing/tokenRefresh.ts +++ b/packages/web/src/ee/features/permissionSyncing/tokenRefresh.ts @@ -218,12 +218,15 @@ const tryRefreshToken = async ( let url: string; if (baseUrl) { + // Use a trailing-slash-normalized base so relative paths append correctly, + // preserving any context path (e.g. https://example.com/bitbucket/). + const base = baseUrl.endsWith('/') ? baseUrl : baseUrl + '/'; if (provider === 'github') { - url = new URL('/login/oauth/access_token', baseUrl).toString(); + url = new URL('login/oauth/access_token', base).toString(); } else if (provider === 'bitbucket-server') { - url = new URL('/rest/oauth2/latest/token', baseUrl).toString(); + url = new URL('rest/oauth2/latest/token', base).toString(); } else { - url = new URL('/oauth/token', baseUrl).toString(); + url = new URL('oauth/token', base).toString(); } } else if (provider === 'github') { url = 'https://github.com/login/oauth/access_token'; From 6fbbfe69a2cd104123fb5e01ca5ff590bac435cd Mon Sep 17 00:00:00 2001 From: Brendan Kellam Date: Wed, 25 Feb 2026 14:34:43 -0800 Subject: [PATCH 9/9] fix(backend): serialize error objects with JSON.stringify in bitbucket error messages Replace error.type interpolation with JSON.stringify(error) for consistent and complete error serialization. Also remove stray space before parenthesis in one throw expression. Co-Authored-By: Claude Sonnet 4.6 --- packages/backend/src/bitbucket.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/backend/src/bitbucket.ts b/packages/backend/src/bitbucket.ts index 4aacb38d8..b768c15a7 100644 --- a/packages/backend/src/bitbucket.ts +++ b/packages/backend/src/bitbucket.ts @@ -275,7 +275,7 @@ async function cloudGetReposForProjects(client: BitbucketClient, projects: strin }); const { data, error } = response; if (error) { - throw new Error (`Failed to fetch projects for workspace ${workspace}: ${error.type}`); + throw new Error(`Failed to fetch projects for workspace ${workspace}: ${JSON.stringify(error)}`); } return data; }); @@ -331,7 +331,7 @@ async function cloudGetRepos(client: BitbucketClient, repoList: string[]): Promi const response = await client.apiClient.GET(path); const { data, error } = response; if (error) { - throw new Error(`Failed to fetch repo ${repo}: ${error.type}`); + throw new Error(`Failed to fetch repo ${repo}: ${JSON.stringify(error)}`); } return data; }, `repo ${repo}`, logger); @@ -542,7 +542,7 @@ async function serverGetRepos(client: BitbucketClient, repoList: string[]): Prom const response = await client.apiClient.GET(path); const { data, error } = response; if (error) { - throw new Error(`Failed to fetch repo ${repo}: ${error.type}`); + throw new Error(`Failed to fetch repo ${repo}: ${JSON.stringify(error)}`); } return data; }, `repo ${repo}`, logger);