Skip to content

Commit 2f74193

Browse files
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 <noreply@anthropic.com>
1 parent 8d171f6 commit 2f74193

File tree

8 files changed

+179
-4
lines changed

8 files changed

+179
-4
lines changed

docs/docs/features/permission-syncing.mdx

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ We are actively working on supporting more code hosts. If you'd like to see a sp
3939
|:----------|------------------------------|
4040
| [GitHub (GHEC & GHEC Server)](/docs/features/permission-syncing#github) ||
4141
| [GitLab (Self-managed & Cloud)](/docs/features/permission-syncing#gitlab) ||
42-
| Bitbucket Cloud | 🛑 |
42+
| [Bitbucket Cloud](/docs/features/permission-syncing#bitbucket-cloud) | ⚠️ Partial |
4343
| Bitbucket Data Center | 🛑 |
4444
| Gitea | 🛑 |
4545
| Gerrit | 🛑 |
@@ -78,6 +78,28 @@ Permission syncing works with **GitLab Self-managed** and **GitLab Cloud**. User
7878
- 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).
7979
- [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.
8080

81+
## Bitbucket Cloud
82+
83+
Prerequisites:
84+
- Configure Bitbucket Cloud as an [external identity provider](/docs/configuration/idp).
85+
86+
Permission syncing works with **Bitbucket Cloud**. OAuth tokens must assume the `account` and `repository` scopes.
87+
88+
<Warning>
89+
**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:
90+
91+
- Membership in a [group that is added to the repository](https://support.atlassian.com/bitbucket-cloud/docs/grant-repository-access-to-users-and-groups/)
92+
- Membership in the [project that contains the repository](https://support.atlassian.com/bitbucket-cloud/docs/configure-project-permissions-for-users-and-groups/)
93+
- Membership in a group that is part of a project containing the repository
94+
95+
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).
96+
97+
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.
98+
</Warning>
99+
100+
**Notes:**
101+
- 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).
102+
- 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).
81103

82104
# How it works
83105

packages/backend/src/bitbucket.ts

Lines changed: 85 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import * as Sentry from "@sentry/node";
88
import micromatch from "micromatch";
99
import {
1010
SchemaRepository as CloudRepository,
11+
SchemaRepositoryUserPermission as CloudRepositoryUserPermission,
12+
SchemaRepositoryPermission as CloudRepositoryPermission,
1113
} from "@coderabbitai/bitbucket/cloud/openapi";
1214
import { SchemaRestRepository as ServerRepository } from "@coderabbitai/bitbucket/server/openapi";
1315
import { processPromiseResults } from "./connectionUtils.js";
@@ -560,7 +562,7 @@ export function serverShouldExcludeRepo(repo: BitbucketRepository, config: Bitbu
560562
const repoSlug = serverRepo.slug!;
561563
const repoName = `${projectName}/${repoSlug}`;
562564
let reason = '';
563-
565+
564566
const shouldExclude = (() => {
565567
if (config.exclude?.repos) {
566568
if (micromatch.isMatch(repoName, config.exclude.repos)) {
@@ -587,4 +589,85 @@ export function serverShouldExcludeRepo(repo: BitbucketRepository, config: Bitbu
587589
return true;
588590
}
589591
return false;
590-
}
592+
}
593+
594+
/**
595+
* Returns the account IDs of users who have been *explicitly* granted permission on a Bitbucket Cloud repository.
596+
*
597+
* @note This only covers direct user-to-repo grants. It does NOT include users who have access via:
598+
* - A group that is explicitly added to the repo
599+
* - Membership in the project that contains the repo
600+
* - A group that is part of a project that contains the repo
601+
* As a result, permission syncing may under-grant access for workspaces that rely on group or
602+
* project-level permissions rather than direct user grants.
603+
*
604+
* @see https://developer.atlassian.com/cloud/bitbucket/rest/api-group-repositories/#api-repositories-workspace-repo-slug-permissions-config-users-get
605+
*/
606+
export const getExplicitUserPermissionsForCloudRepo = async (
607+
workspace: string,
608+
repoSlug: string,
609+
token: string | undefined,
610+
): Promise<Array<{ accountId: string }>> => {
611+
const apiClient = createBitbucketCloudClient({
612+
baseUrl: BITBUCKET_CLOUD_API,
613+
headers: {
614+
Accept: "application/json",
615+
...(token ? { Authorization: `Bearer ${token}` } : {}),
616+
},
617+
});
618+
619+
const path = `/repositories/${workspace}/${repoSlug}/permissions-config/users` as CloudGetRequestPath;
620+
621+
const users = await getPaginatedCloud<CloudRepositoryUserPermission>(path, async (p, query) => {
622+
const response = await apiClient.GET(p, {
623+
params: {
624+
path: { workspace, repo_slug: repoSlug },
625+
query,
626+
},
627+
});
628+
const { data, error } = response;
629+
if (error) {
630+
throw new Error(`Failed to get explicit user permissions for ${workspace}/${repoSlug}: ${JSON.stringify(error)}`);
631+
}
632+
return data;
633+
});
634+
635+
return users
636+
.filter(u => u.user?.account_id != null)
637+
.map(u => ({ accountId: u.user!.account_id as string }));
638+
};
639+
640+
/**
641+
* Returns the UUIDs of all private repositories accessible to the authenticated Bitbucket Cloud user.
642+
* Used for account-driven permission syncing.
643+
*
644+
* @see https://developer.atlassian.com/cloud/bitbucket/rest/api-group-repositories/#api-user-permissions-repositories-get
645+
*/
646+
export const getReposForAuthenticatedBitbucketCloudUser = async (
647+
accessToken: string,
648+
): Promise<Array<{ uuid: string }>> => {
649+
const apiClient = createBitbucketCloudClient({
650+
baseUrl: BITBUCKET_CLOUD_API,
651+
headers: {
652+
Accept: "application/json",
653+
Authorization: `Bearer ${accessToken}`,
654+
},
655+
});
656+
657+
const path = `/user/permissions/repositories` as CloudGetRequestPath;
658+
659+
const permissions = await getPaginatedCloud<CloudRepositoryPermission>(path, async (p, query) => {
660+
const response = await apiClient.GET(p, {
661+
params: { query },
662+
});
663+
const { data, error } = response;
664+
if (error) {
665+
throw new Error(`Failed to get user repository permissions: ${JSON.stringify(error)}`);
666+
}
667+
return data;
668+
});
669+
670+
return permissions
671+
.filter(p => p.repository?.uuid != null)
672+
.map(p => ({ uuid: p.repository!.uuid as string }));
673+
};

packages/backend/src/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,13 @@ export const SINGLE_TENANT_ORG_ID = 1;
77
export const PERMISSION_SYNC_SUPPORTED_CODE_HOST_TYPES: CodeHostType[] = [
88
'github',
99
'gitlab',
10+
'bitbucketCloud',
1011
];
1112

1213
export const PERMISSION_SYNC_SUPPORTED_IDENTITY_PROVIDERS: IdentityProviderType[] = [
1314
'github',
1415
'gitlab',
16+
'bitbucket-cloud',
1517
];
1618

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

packages/backend/src/ee/accountPermissionSyncer.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
getOAuthScopesForAuthenticatedUser as getGitLabOAuthScopesForAuthenticatedUser,
1515
getProjectsForAuthenticatedUser,
1616
} from "../gitlab.js";
17+
import { getReposForAuthenticatedBitbucketCloudUser } from "../bitbucket.js";
1718
import { Settings } from "../types.js";
1819
import { setIntervalAsync } from "../utils.js";
1920

@@ -266,6 +267,24 @@ export class AccountPermissionSyncer {
266267
}
267268
});
268269

270+
repos.forEach(repo => aggregatedRepoIds.add(repo.id));
271+
} else if (account.provider === 'bitbucket-cloud') {
272+
if (!accessToken) {
273+
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.`);
274+
}
275+
276+
const bitbucketRepos = await getReposForAuthenticatedBitbucketCloudUser(accessToken);
277+
const bitbucketRepoUuids = bitbucketRepos.map(repo => repo.uuid);
278+
279+
const repos = await this.db.repo.findMany({
280+
where: {
281+
external_codeHostType: 'bitbucketCloud',
282+
external_id: {
283+
in: bitbucketRepoUuids,
284+
}
285+
}
286+
});
287+
269288
repos.forEach(repo => aggregatedRepoIds.add(repo.id));
270289
}
271290

packages/backend/src/ee/repoPermissionSyncer.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ 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 { getExplicitUserPermissionsForCloudRepo } from "../bitbucket.js";
11+
import { repoMetadataSchema } from "@sourcebot/shared";
1012
import { Settings } from "../types.js";
1113
import { getAuthCredentialsForRepo, setIntervalAsync } from "../utils.js";
1214

@@ -234,6 +236,35 @@ export class RepoPermissionSyncer {
234236
},
235237
});
236238

239+
return accounts.map(account => account.id);
240+
} else if (repo.external_codeHostType === 'bitbucketCloud') {
241+
const parsedMetadata = repoMetadataSchema.safeParse(repo.metadata);
242+
const bitbucketCloudMetadata = parsedMetadata.success ? parsedMetadata.data.codeHostMetadata?.bitbucketCloud : undefined;
243+
if (!bitbucketCloudMetadata) {
244+
throw new Error(`Repo ${id} is missing required Bitbucket Cloud metadata (workspace/repoSlug)`);
245+
}
246+
247+
const { workspace, repoSlug } = bitbucketCloudMetadata;
248+
249+
// @note: The Bitbucket Cloud permissions API only returns users who have been *directly*
250+
// granted access to this repository. Users who have access via a group added to the repo,
251+
// via project-level membership, or via a group in a project are NOT captured here.
252+
// These users will still gain access through user-driven syncing (accountPermissionSyncer),
253+
// but there may be a delay of up to `experiment_userDrivenPermissionSyncIntervalMs` before
254+
// they see the repository in Sourcebot.
255+
// @see: https://developer.atlassian.com/cloud/bitbucket/rest/api-group-repositories/#api-repositories-workspace-repo-slug-permissions-config-users-get
256+
const users = await getExplicitUserPermissionsForCloudRepo(workspace, repoSlug, credentials.token);
257+
const userAccountIds = users.map(u => u.accountId);
258+
259+
const accounts = await this.db.account.findMany({
260+
where: {
261+
provider: 'bitbucket-cloud',
262+
providerAccountId: {
263+
in: userAccountIds,
264+
}
265+
},
266+
});
267+
237268
return accounts.map(account => account.id);
238269
}
239270

packages/backend/src/repoCompileUtils.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -510,6 +510,14 @@ export const compileBitbucketConfig = async (
510510
},
511511
branches: config.revisions?.branches ?? undefined,
512512
tags: config.revisions?.tags ?? undefined,
513+
...(codeHostType === 'bitbucketCloud' ? {
514+
codeHostMetadata: {
515+
bitbucketCloud: {
516+
workspace: (repo as BitbucketCloudRepository).full_name!.split('/')[0]!,
517+
repoSlug: (repo as BitbucketCloudRepository).full_name!.split('/')[1]!,
518+
}
519+
}
520+
} : {}),
513521
} satisfies RepoMetadata,
514522
};
515523

packages/shared/src/types.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,16 @@ export const repoMetadataSchema = z.object({
3131
* A list of revisions that were indexed for the repo.
3232
*/
3333
indexedRevisions: z.array(z.string()).optional(),
34+
35+
/**
36+
* Code host specific metadata, keyed by code host type.
37+
*/
38+
codeHostMetadata: z.object({
39+
bitbucketCloud: z.object({
40+
workspace: z.string(),
41+
repoSlug: z.string(),
42+
}).optional(),
43+
}).optional(),
3444
});
3545

3646
export type RepoMetadata = z.infer<typeof repoMetadataSchema>;

packages/web/src/app/api/(server)/ee/permissionSyncStatus/route.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export const GET = apiHandler(async () => {
3131
const accounts = await prisma.account.findMany({
3232
where: {
3333
userId: user.id,
34-
provider: { in: ['github', 'gitlab'] }
34+
provider: { in: ['github', 'gitlab', 'bitbucket-cloud'] }
3535
},
3636
include: {
3737
permissionSyncJobs: {

0 commit comments

Comments
 (0)