Skip to content

Commit ca29e5a

Browse files
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 <noreply@anthropic.com>
1 parent 6208955 commit ca29e5a

File tree

14 files changed

+251
-24
lines changed

14 files changed

+251
-24
lines changed

docs/docs/configuration/idp.mdx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -220,7 +220,8 @@ in the Bitbucket Cloud identity provider config.
220220

221221
### Bitbucket Server
222222

223-
A Bitbucket Server (Data Center) connection can be used for [authentication](/docs/configuration/auth).
223+
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
224+
in the Bitbucket Server identity provider config.
224225

225226
<Accordion title="instructions">
226227
<Steps>
@@ -231,6 +232,7 @@ A Bitbucket Server (Data Center) connection can be used for [authentication](/do
231232

232233
When configuring your application:
233234
- Set the redirect URL to `<sourcebot_url>/api/auth/callback/bitbucket-server` (ex. https://sourcebot.coolcorp.com/api/auth/callback/bitbucket-server)
235+
- If using for permission syncing, ensure the OAuth application requests the `REPO_READ` scope
234236

235237
The result of creating the application is a `CLIENT_ID` and `CLIENT_SECRET` which you'll provide to Sourcebot.
236238
</Step>
@@ -247,7 +249,10 @@ A Bitbucket Server (Data Center) connection can be used for [authentication](/do
247249
"identityProviders": [
248250
{
249251
"provider": "bitbucket-server",
250-
"purpose": "sso",
252+
// "sso" for auth + perm sync, "account_linking" for only perm sync
253+
"purpose": "account_linking",
254+
// if purpose == "account_linking" this controls if a user must connect to the IdP
255+
"accountLinkingRequired": true,
251256
"baseUrl": "https://bitbucket.example.com",
252257
"clientId": {
253258
"env": "YOUR_CLIENT_ID_ENV_VAR"

docs/snippets/schemas/v3/identityProvider.schema.mdx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -850,7 +850,10 @@
850850
"const": "bitbucket-server"
851851
},
852852
"purpose": {
853-
"const": "sso"
853+
"enum": [
854+
"sso",
855+
"account_linking"
856+
]
854857
},
855858
"clientId": {
856859
"anyOf": [
@@ -919,6 +922,10 @@
919922
"https://bitbucket.example.com"
920923
],
921924
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$"
925+
},
926+
"accountLinkingRequired": {
927+
"type": "boolean",
928+
"default": false
922929
}
923930
},
924931
"required": [
@@ -1777,7 +1784,10 @@
17771784
"const": "bitbucket-server"
17781785
},
17791786
"purpose": {
1780-
"const": "sso"
1787+
"enum": [
1788+
"sso",
1789+
"account_linking"
1790+
]
17811791
},
17821792
"clientId": {
17831793
"anyOf": [
@@ -1846,6 +1856,10 @@
18461856
"https://bitbucket.example.com"
18471857
],
18481858
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$"
1859+
},
1860+
"accountLinkingRequired": {
1861+
"type": "boolean",
1862+
"default": false
18491863
}
18501864
},
18511865
"required": [

docs/snippets/schemas/v3/index.schema.mdx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5390,7 +5390,10 @@
53905390
"const": "bitbucket-server"
53915391
},
53925392
"purpose": {
5393-
"const": "sso"
5393+
"enum": [
5394+
"sso",
5395+
"account_linking"
5396+
]
53945397
},
53955398
"clientId": {
53965399
"anyOf": [
@@ -5459,6 +5462,10 @@
54595462
"https://bitbucket.example.com"
54605463
],
54615464
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$"
5465+
},
5466+
"accountLinkingRequired": {
5467+
"type": "boolean",
5468+
"default": false
54625469
}
54635470
},
54645471
"required": [
@@ -6317,7 +6324,10 @@
63176324
"const": "bitbucket-server"
63186325
},
63196326
"purpose": {
6320-
"const": "sso"
6327+
"enum": [
6328+
"sso",
6329+
"account_linking"
6330+
]
63216331
},
63226332
"clientId": {
63236333
"anyOf": [
@@ -6386,6 +6396,10 @@
63866396
"https://bitbucket.example.com"
63876397
],
63886398
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$"
6399+
},
6400+
"accountLinkingRequired": {
6401+
"type": "boolean",
6402+
"default": false
63896403
}
63906404
},
63916405
"required": [

packages/backend/src/bitbucket.ts

Lines changed: 94 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -379,7 +379,7 @@ export function cloudShouldExcludeRepo(repo: BitbucketRepository, config: Bitbuc
379379
return false;
380380
}
381381

382-
function createBitbucketServerClient(url: string, user: string | undefined, token: string | undefined): BitbucketClient {
382+
export function createBitbucketServerClient(url: string, user: string | undefined, token: string | undefined): BitbucketClient {
383383
const authorizationString = (() => {
384384
// If we're not given any credentials we return an empty auth string. This will only work if the project/repos are public
385385
if(!user && !token) {
@@ -653,4 +653,97 @@ export const getReposForAuthenticatedBitbucketCloudUser = async (
653653
return permissions
654654
.filter(p => p.repository?.uuid != null)
655655
.map(p => ({ uuid: p.repository!.uuid as string }));
656+
};
657+
658+
/**
659+
* Returns the IDs of all repositories accessible to the authenticated Bitbucket Server user.
660+
* Used for account-driven permission syncing.
661+
*
662+
* @see https://developer.atlassian.com/server/bitbucket/rest/v906/api-group-repository/#api-rest-api-latest-repos-get
663+
*/
664+
export const getReposForAuthenticatedBitbucketServerUser = async (
665+
client: BitbucketClient,
666+
): Promise<Array<{ id: string }>> => {
667+
const repos = await getPaginatedServer<{ id: number }>(
668+
`/rest/api/1.0/repos` as ServerGetRequestPath,
669+
async (url, start) => {
670+
const response = await client.apiClient.GET(url, {
671+
params: {
672+
query: {
673+
permission: 'REPO_READ',
674+
limit: 100,
675+
start,
676+
},
677+
},
678+
});
679+
const { data, error } = response;
680+
if (error) {
681+
throw new Error(`Failed to fetch Bitbucket Server repos for authenticated user: ${JSON.stringify(error)}`);
682+
}
683+
return data;
684+
}
685+
);
686+
687+
return repos.map(r => ({ id: String(r.id) }));
688+
};
689+
690+
/**
691+
* Returns the user IDs of users who have been explicitly granted permission on a Bitbucket Server repository
692+
* at the repo level (direct grants) or project level (inherited by all repos in the project).
693+
*
694+
* @note This does NOT include users who have access via groups. As a result, permission syncing
695+
* may under-grant access for instances that rely heavily on group-level permissions. Those users
696+
* will still gain access through account-driven syncing (accountPermissionSyncer).
697+
*
698+
* @see https://developer.atlassian.com/server/bitbucket/rest/v906/api-group-repository/#api-rest-api-latest-projects-projectkey-repos-reposlug-permissions-users-get
699+
* @see https://developer.atlassian.com/server/bitbucket/rest/v906/api-group-project/#api-rest-api-latest-projects-projectkey-permissions-users-get
700+
*/
701+
export const getUserPermissionsForServerRepo = async (
702+
client: BitbucketClient,
703+
projectKey: string,
704+
repoSlug: string,
705+
): Promise<Array<{ userId: string }>> => {
706+
const userIdSet = new Set<string>();
707+
708+
// Fetch repo-level permissions
709+
const repoUsers = await getPaginatedServer<{ user: { id: number } }>(
710+
`/rest/api/1.0/projects/${projectKey}/repos/${repoSlug}/permissions/users` as ServerGetRequestPath,
711+
async (url, start) => {
712+
const response = await client.apiClient.GET(url, {
713+
params: { query: { limit: 100, start } },
714+
});
715+
const { data, error } = response;
716+
if (error) {
717+
throw new Error(`Failed to fetch repo-level permissions for ${projectKey}/${repoSlug}: ${JSON.stringify(error)}`);
718+
}
719+
return data;
720+
}
721+
);
722+
for (const entry of repoUsers) {
723+
if (entry.user?.id != null) {
724+
userIdSet.add(String(entry.user.id));
725+
}
726+
}
727+
728+
// Fetch project-level permissions (inherited by all repos in the project)
729+
const projectUsers = await getPaginatedServer<{ user: { id: number } }>(
730+
`/rest/api/1.0/projects/${projectKey}/permissions/users` as ServerGetRequestPath,
731+
async (url, start) => {
732+
const response = await client.apiClient.GET(url, {
733+
params: { query: { limit: 100, start } },
734+
});
735+
const { data, error } = response;
736+
if (error) {
737+
throw new Error(`Failed to fetch project-level permissions for ${projectKey}: ${JSON.stringify(error)}`);
738+
}
739+
return data;
740+
}
741+
);
742+
for (const entry of projectUsers) {
743+
if (entry.user?.id != null) {
744+
userIdSet.add(String(entry.user.id));
745+
}
746+
}
747+
748+
return Array.from(userIdSet).map(userId => ({ userId }));
656749
};

packages/backend/src/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,12 +8,14 @@ export const PERMISSION_SYNC_SUPPORTED_CODE_HOST_TYPES: CodeHostType[] = [
88
'github',
99
'gitlab',
1010
'bitbucketCloud',
11+
'bitbucketServer',
1112
];
1213

1314
export const PERMISSION_SYNC_SUPPORTED_IDENTITY_PROVIDERS: IdentityProviderType[] = [
1415
'github',
1516
'gitlab',
1617
'bitbucket-cloud',
18+
'bitbucket-server',
1719
];
1820

1921
export const REPOS_CACHE_DIR = path.join(env.DATA_CACHE_DIR, 'repos');

packages/backend/src/ee/accountPermissionSyncer.ts

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ import {
1414
getOAuthScopesForAuthenticatedUser as getGitLabOAuthScopesForAuthenticatedUser,
1515
getProjectsForAuthenticatedUser,
1616
} from "../gitlab.js";
17-
import { createBitbucketCloudClient, getReposForAuthenticatedBitbucketCloudUser } from "../bitbucket.js";
17+
import { createBitbucketCloudClient, createBitbucketServerClient, getReposForAuthenticatedBitbucketCloudUser, getReposForAuthenticatedBitbucketServerUser } from "../bitbucket.js";
1818
import { Settings } from "../types.js";
1919
import { setIntervalAsync } from "../utils.js";
2020

@@ -288,6 +288,32 @@ export class AccountPermissionSyncer {
288288
}
289289
});
290290

291+
repos.forEach(repo => aggregatedRepoIds.add(repo.id));
292+
} else if (account.provider === 'bitbucket-server') {
293+
if (!accessToken) {
294+
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.`);
295+
}
296+
297+
// @hack: we don't have a way of identifying specific identity providers in the config file.
298+
// Instead, we'll use the first Bitbucket Server connection's URL as the base URL.
299+
const baseUrl = Array.from(Object.values(config.connections ?? {}))
300+
.find(connection => connection.type === 'bitbucket' && connection.deploymentType === 'server')?.url;
301+
302+
if (!baseUrl) {
303+
throw new Error(`No Bitbucket Server connection URL found in config for account ${account.id}`);
304+
}
305+
306+
const client = createBitbucketServerClient(baseUrl, /* user = */ undefined, accessToken);
307+
const serverRepos = await getReposForAuthenticatedBitbucketServerUser(client);
308+
const serverRepoIds = serverRepos.map(r => r.id);
309+
310+
const repos = await this.db.repo.findMany({
311+
where: {
312+
external_codeHostType: 'bitbucketServer',
313+
external_id: { in: serverRepoIds },
314+
}
315+
});
316+
291317
repos.forEach(repo => aggregatedRepoIds.add(repo.id));
292318
}
293319

packages/backend/src/ee/repoPermissionSyncer.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ import { Redis } from 'ioredis';
77
import { PERMISSION_SYNC_SUPPORTED_CODE_HOST_TYPES } from "../constants.js";
88
import { createOctokitFromToken, getRepoCollaborators, GITHUB_CLOUD_HOSTNAME } from "../github.js";
99
import { createGitLabFromPersonalAccessToken, getProjectMembers } from "../gitlab.js";
10-
import { createBitbucketCloudClient, getExplicitUserPermissionsForCloudRepo } from "../bitbucket.js";
10+
import { createBitbucketCloudClient, createBitbucketServerClient, getExplicitUserPermissionsForCloudRepo, getUserPermissionsForServerRepo } from "../bitbucket.js";
1111
import { repoMetadataSchema } from "@sourcebot/shared";
1212
import { Settings } from "../types.js";
1313
import { getAuthCredentialsForRepo, setIntervalAsync } from "../utils.js";
@@ -292,6 +292,36 @@ export class RepoPermissionSyncer {
292292
// this is a partial sync.
293293
isPartialSync: true,
294294
}
295+
} else if (repo.external_codeHostType === 'bitbucketServer') {
296+
if (!repo.displayName) {
297+
throw new Error(`Repo ${id} does not have a displayName`);
298+
}
299+
300+
const [projectKey, repoSlug] = repo.displayName.split('/');
301+
const hostUrl = credentials.hostUrl;
302+
303+
if (!hostUrl) {
304+
throw new Error(`No host URL found for Bitbucket Server repo ${id}`);
305+
}
306+
307+
// @note: This covers users with direct repo-level and project-level permissions.
308+
// Users with access only via groups are NOT captured here. Those users will
309+
// still gain access through account-driven syncing (accountPermissionSyncer).
310+
const client = createBitbucketServerClient(hostUrl, /* user = */ undefined, credentials.token);
311+
const users = await getUserPermissionsForServerRepo(client, projectKey, repoSlug);
312+
const userIds = users.map(u => u.userId);
313+
314+
const accounts = await this.db.account.findMany({
315+
where: {
316+
provider: 'bitbucket-server',
317+
providerAccountId: { in: userIds },
318+
}
319+
});
320+
321+
return {
322+
accountIds: accounts.map(account => account.id),
323+
isPartialSync: true,
324+
}
295325
}
296326

297327
return {

packages/schemas/src/v3/identityProvider.schema.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -849,7 +849,10 @@ const schema = {
849849
"const": "bitbucket-server"
850850
},
851851
"purpose": {
852-
"const": "sso"
852+
"enum": [
853+
"sso",
854+
"account_linking"
855+
]
853856
},
854857
"clientId": {
855858
"anyOf": [
@@ -918,6 +921,10 @@ const schema = {
918921
"https://bitbucket.example.com"
919922
],
920923
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$"
924+
},
925+
"accountLinkingRequired": {
926+
"type": "boolean",
927+
"default": false
921928
}
922929
},
923930
"required": [
@@ -1776,7 +1783,10 @@ const schema = {
17761783
"const": "bitbucket-server"
17771784
},
17781785
"purpose": {
1779-
"const": "sso"
1786+
"enum": [
1787+
"sso",
1788+
"account_linking"
1789+
]
17801790
},
17811791
"clientId": {
17821792
"anyOf": [
@@ -1845,6 +1855,10 @@ const schema = {
18451855
"https://bitbucket.example.com"
18461856
],
18471857
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$"
1858+
},
1859+
"accountLinkingRequired": {
1860+
"type": "boolean",
1861+
"default": false
18481862
}
18491863
},
18501864
"required": [

packages/schemas/src/v3/identityProvider.type.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -334,7 +334,7 @@ export interface BitbucketCloudIdentityProviderConfig {
334334
}
335335
export interface BitbucketServerIdentityProviderConfig {
336336
provider: "bitbucket-server";
337-
purpose: "sso";
337+
purpose: "sso" | "account_linking";
338338
clientId:
339339
| {
340340
/**
@@ -365,4 +365,5 @@ export interface BitbucketServerIdentityProviderConfig {
365365
* The URL of the Bitbucket Server/Data Center host.
366366
*/
367367
baseUrl: string;
368+
accountLinkingRequired?: boolean;
368369
}

0 commit comments

Comments
 (0)