Skip to content

Commit 649671b

Browse files
feat(backend): add Bitbucket Cloud permission syncing (#925)
* 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> * chore: update CHANGELOG for #925 Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * changelog nit * nits w/ authentication * feat(backend): add PermissionSyncSource to AccountToRepoPermission - Adds `PermissionSyncSource` enum (`ACCOUNT_DRIVEN` / `REPO_DRIVEN`) and `source` field to `AccountToRepoPermission` table (non-nullable, defaults to `ACCOUNT_DRIVEN`) - Both syncers now set `source` on all created permission records - Repo-driven syncer uses `isPartialSync` flag (true for Bitbucket Cloud) to only delete `REPO_DRIVEN` records on cleanup, preserving `ACCOUNT_DRIVEN` records from the account syncer - Adds `skipDuplicates: true` to repo-driven `createMany` to handle overlap between the two sync paths Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * feedback * docs nit --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent d4354cc commit 649671b

File tree

13 files changed

+242
-23
lines changed

13 files changed

+242
-23
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
1010
### Added
1111
- Added PostHog events for chat UI interactions (details card expand/collapse, copy answer, table of contents toggle) and repo tracking in `wa_chat_message_sent`. [#922](https://github.com/sourcebot-dev/sourcebot/pull/922)
1212
- Added Bitbucket Cloud OAuth identity provider support (`provider: "bitbucket-cloud"`) for SSO and account-linked permission syncing. [#924](https://github.com/sourcebot-dev/sourcebot/pull/924)
13+
- Added permission syncing support for Bitbucket Cloud. [#925](https://github.com/sourcebot-dev/sourcebot/pull/925)
1314

1415
### Changed
1516
- Hide version upgrade toast for askgithub deployment (`EXPERIMENT_ASK_GH_ENABLED`). [#931](https://github.com/sourcebot-dev/sourcebot/pull/931)

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: 80 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { createBitbucketCloudClient } from "@coderabbitai/bitbucket/cloud";
2-
import { createBitbucketServerClient } from "@coderabbitai/bitbucket/server";
1+
import { createBitbucketCloudClient as createBitbucketCloudClientBase } from "@coderabbitai/bitbucket/cloud";
2+
import { createBitbucketServerClient as createBitbucketServerClientBase } from "@coderabbitai/bitbucket/server";
33
import { BitbucketConnectionConfig } from "@sourcebot/schemas/v3/bitbucket.type";
44
import type { ClientOptions, ClientPathsWithMethod } from "openapi-fetch";
55
import { createLogger } from "@sourcebot/shared";
@@ -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";
@@ -34,10 +36,10 @@ interface BitbucketClient {
3436
shouldExcludeRepo: (repo: BitbucketRepository, config: BitbucketConnectionConfig) => boolean;
3537
}
3638

37-
type CloudAPI = ReturnType<typeof createBitbucketCloudClient>;
39+
type CloudAPI = ReturnType<typeof createBitbucketCloudClientBase>;
3840
type CloudGetRequestPath = ClientPathsWithMethod<CloudAPI, "get">;
3941

40-
type ServerAPI = ReturnType<typeof createBitbucketServerClient>;
42+
type ServerAPI = ReturnType<typeof createBitbucketServerClientBase>;
4143
type ServerGetRequestPath = ClientPathsWithMethod<ServerAPI, "get">;
4244

4345
type CloudPaginatedResponse<T> = {
@@ -68,8 +70,8 @@ export const getBitbucketReposFromConfig = async (config: BitbucketConnectionCon
6870
}
6971

7072
const client = config.deploymentType === 'server' ?
71-
serverClient(config.url!, config.user, token) :
72-
cloudClient(config.user, token);
73+
createBitbucketServerClient(config.url!, config.user, token) :
74+
createBitbucketCloudClient(config.user, token);
7375

7476
let allRepos: BitbucketRepository[] = [];
7577
let allWarnings: string[] = [];
@@ -102,11 +104,10 @@ export const getBitbucketReposFromConfig = async (config: BitbucketConnectionCon
102104
};
103105
}
104106

105-
function cloudClient(user: string | undefined, token: string | undefined): BitbucketClient {
106-
107+
export function createBitbucketCloudClient(user: string | undefined, token: string | undefined): BitbucketClient {
107108
const authorizationString =
108109
token
109-
? !user || user == "x-token-auth"
110+
? (!user || user === "x-token-auth")
110111
? `Bearer ${token}`
111112
: `Basic ${Buffer.from(`${user}:${token}`).toString('base64')}`
112113
: undefined;
@@ -119,7 +120,7 @@ function cloudClient(user: string | undefined, token: string | undefined): Bitbu
119120
},
120121
};
121122

122-
const apiClient = createBitbucketCloudClient(clientOptions);
123+
const apiClient = createBitbucketCloudClientBase(clientOptions);
123124
var client: BitbucketClient = {
124125
deploymentType: BITBUCKET_CLOUD,
125126
token: token,
@@ -378,7 +379,7 @@ export function cloudShouldExcludeRepo(repo: BitbucketRepository, config: Bitbuc
378379
return false;
379380
}
380381

381-
function serverClient(url: string, user: string | undefined, token: string | undefined): BitbucketClient {
382+
function createBitbucketServerClient(url: string, user: string | undefined, token: string | undefined): BitbucketClient {
382383
const authorizationString = (() => {
383384
// If we're not given any credentials we return an empty auth string. This will only work if the project/repos are public
384385
if(!user && !token) {
@@ -400,7 +401,7 @@ function serverClient(url: string, user: string | undefined, token: string | und
400401
},
401402
};
402403

403-
const apiClient = createBitbucketServerClient(clientOptions);
404+
const apiClient = createBitbucketServerClientBase(clientOptions);
404405
var client: BitbucketClient = {
405406
deploymentType: BITBUCKET_SERVER,
406407
token: token,
@@ -560,7 +561,7 @@ export function serverShouldExcludeRepo(repo: BitbucketRepository, config: Bitbu
560561
const repoSlug = serverRepo.slug!;
561562
const repoName = `${projectName}/${repoSlug}`;
562563
let reason = '';
563-
564+
564565
const shouldExclude = (() => {
565566
if (config.exclude?.repos) {
566567
if (micromatch.isMatch(repoName, config.exclude.repos)) {
@@ -587,4 +588,69 @@ export function serverShouldExcludeRepo(repo: BitbucketRepository, config: Bitbu
587588
return true;
588589
}
589590
return false;
590-
}
591+
}
592+
593+
/**
594+
* Returns the account IDs of users who have been *explicitly* granted permission on a Bitbucket Cloud repository.
595+
*
596+
* @note This only covers direct user-to-repo grants. It does NOT include users who have access via:
597+
* - A group that is explicitly added to the repo
598+
* - Membership in the project that contains the repo
599+
* - A group that is part of a project that contains the repo
600+
* As a result, permission syncing may under-grant access for workspaces that rely on group or
601+
* project-level permissions rather than direct user grants.
602+
*
603+
* @see https://developer.atlassian.com/cloud/bitbucket/rest/api-group-repositories/#api-repositories-workspace-repo-slug-permissions-config-users-get
604+
*/
605+
export const getExplicitUserPermissionsForCloudRepo = async (
606+
client: BitbucketClient,
607+
workspace: string,
608+
repoSlug: string,
609+
): Promise<Array<{ accountId: string }>> => {
610+
const path = `/repositories/${workspace}/${repoSlug}/permissions-config/users` as CloudGetRequestPath;
611+
612+
const users = await getPaginatedCloud<CloudRepositoryUserPermission>(path, async (p, query) => {
613+
const response = await client.apiClient.GET(p, {
614+
params: {
615+
path: { workspace, repo_slug: repoSlug },
616+
query,
617+
},
618+
});
619+
const { data, error } = response;
620+
if (error) {
621+
throw new Error(`Failed to get explicit user permissions for ${workspace}/${repoSlug}: ${JSON.stringify(error)}`);
622+
}
623+
return data;
624+
});
625+
626+
return users
627+
.filter(u => u.user?.account_id != null)
628+
.map(u => ({ accountId: u.user!.account_id as string }));
629+
};
630+
631+
/**
632+
* Returns the UUIDs of all private repositories accessible to the authenticated Bitbucket Cloud user.
633+
* Used for account-driven permission syncing.
634+
*
635+
* @see https://developer.atlassian.com/cloud/bitbucket/rest/api-group-repositories/#api-user-permissions-repositories-get
636+
*/
637+
export const getReposForAuthenticatedBitbucketCloudUser = async (
638+
client: BitbucketClient,
639+
): Promise<Array<{ uuid: string }>> => {
640+
const path = `/user/permissions/repositories` as CloudGetRequestPath;
641+
642+
const permissions = await getPaginatedCloud<CloudRepositoryPermission>(path, async (p, query) => {
643+
const response = await client.apiClient.GET(p, {
644+
params: { query },
645+
});
646+
const { data, error } = response;
647+
if (error) {
648+
throw new Error(`Failed to get user repository permissions: ${JSON.stringify(error)}`);
649+
}
650+
return data;
651+
});
652+
653+
return permissions
654+
.filter(p => p.repository?.uuid != null)
655+
.map(p => ({ uuid: p.repository!.uuid as string }));
656+
};

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: 24 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import * as Sentry from "@sentry/node";
2-
import { PrismaClient, AccountPermissionSyncJobStatus, Account} from "@sourcebot/db";
2+
import { PrismaClient, AccountPermissionSyncJobStatus, Account, PermissionSyncSource} from "@sourcebot/db";
33
import { env, hasEntitlement, createLogger, loadConfig, decryptOAuthToken } from "@sourcebot/shared";
44
import { Job, Queue, Worker } from "bullmq";
55
import { Redis } from "ioredis";
@@ -14,6 +14,7 @@ import {
1414
getOAuthScopesForAuthenticatedUser as getGitLabOAuthScopesForAuthenticatedUser,
1515
getProjectsForAuthenticatedUser,
1616
} from "../gitlab.js";
17+
import { createBitbucketCloudClient, getReposForAuthenticatedBitbucketCloudUser } from "../bitbucket.js";
1718
import { Settings } from "../types.js";
1819
import { setIntervalAsync } from "../utils.js";
1920

@@ -266,6 +267,27 @@ 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+
// @note: we don't pass a user here since we want to use a bearer token
277+
// for authentication.
278+
const client = createBitbucketCloudClient(/* user = */ undefined, accessToken)
279+
const bitbucketRepos = await getReposForAuthenticatedBitbucketCloudUser(client);
280+
const bitbucketRepoUuids = bitbucketRepos.map(repo => repo.uuid);
281+
282+
const repos = await this.db.repo.findMany({
283+
where: {
284+
external_codeHostType: 'bitbucketCloud',
285+
external_id: {
286+
in: bitbucketRepoUuids,
287+
}
288+
}
289+
});
290+
269291
repos.forEach(repo => aggregatedRepoIds.add(repo.id));
270292
}
271293

@@ -287,6 +309,7 @@ export class AccountPermissionSyncer {
287309
data: repoIds.map(repoId => ({
288310
accountId: account.id,
289311
repoId,
312+
source: PermissionSyncSource.ACCOUNT_DRIVEN,
290313
})),
291314
skipDuplicates: true,
292315
})

0 commit comments

Comments
 (0)