Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### Added
- Added PostHog events for chat UI interactions (details card expand/collapse, copy answer, table of contents toggle) and repo tracking in `wa_chat_message_sent`. [#922](https://github.com/sourcebot-dev/sourcebot/pull/922)
- Added Bitbucket Cloud OAuth identity provider support (`provider: "bitbucket-cloud"`) for SSO and account-linked permission syncing. [#924](https://github.com/sourcebot-dev/sourcebot/pull/924)
- Added permission syncing support for Bitbucket Cloud. [#925](https://github.com/sourcebot-dev/sourcebot/pull/925)

### Changed
- Hide version upgrade toast for askgithub deployment (`EXPERIMENT_ASK_GH_ENABLED`). [#931](https://github.com/sourcebot-dev/sourcebot/pull/931)
Expand Down
24 changes: 23 additions & 1 deletion docs/docs/features/permission-syncing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ We are actively working on supporting more code hosts. If you'd like to see a sp
|:----------|------------------------------|
| [GitHub (GHEC & GHEC Server)](/docs/features/permission-syncing#github) | ✅ |
| [GitLab (Self-managed & Cloud)](/docs/features/permission-syncing#gitlab) | ✅ |
| Bitbucket Cloud | 🛑 |
| [Bitbucket Cloud](/docs/features/permission-syncing#bitbucket-cloud) | 🟠 Partial |
| Bitbucket Data Center | 🛑 |
| Gitea | 🛑 |
| Gerrit | 🛑 |
Expand Down Expand Up @@ -78,6 +78,28 @@ Permission syncing works with **GitLab Self-managed** and **GitLab Cloud**. User
- OAuth tokens require the `read_api` scope in order to use the [List projects for the authenticated user API](https://docs.gitlab.com/ee/api/projects.html#list-all-projects) during [User driven syncing](/docs/features/permission-syncing#how-it-works).
- [Internal GitLab projects](https://docs.gitlab.com/user/public_access/#internal-projects-and-groups) are **not** enforced by permission syncing and therefore are visible to all users. Only [private projects](https://docs.gitlab.com/user/public_access/#private-projects-and-groups) are enforced.

## Bitbucket Cloud

Prerequisites:
- Configure Bitbucket Cloud as an [external identity provider](/docs/configuration/idp).

Permission syncing works with **Bitbucket Cloud**. OAuth tokens must assume the `account` and `repository` scopes.

<Warning>
**Partial coverage for repo-driven syncing.** Bitbucket Cloud's [repository user permissions API](https://developer.atlassian.com/cloud/bitbucket/rest/api-group-repositories/#api-repositories-workspace-repo-slug-permissions-config-users-get) only returns users who have been **directly and explicitly** granted access to a repository. Users who have access via any of the following are **not** captured by repo-driven syncing:

- Membership in a [group that is added to the repository](https://support.atlassian.com/bitbucket-cloud/docs/grant-repository-access-to-users-and-groups/)
- Membership in the [project that contains the repository](https://support.atlassian.com/bitbucket-cloud/docs/configure-project-permissions-for-users-and-groups/)
- Membership in a group that is part of a project containing the repository

These users **will** still gain access via [user-driven syncing](/docs/features/permission-syncing#how-it-works), which fetches all private repositories accessible to each authenticated user. However, there may be a delay between when a repository is added and when affected users gain access in Sourcebot (up to the `experiment_userDrivenPermissionSyncIntervalMs` interval, which defaults to 24 hours).

If your workspace relies heavily on group or project-level permissions rather than direct user grants, we recommend reducing the `experiment_userDrivenPermissionSyncIntervalMs` interval to limit the window of delay.
</Warning>

**Notes:**
- A Bitbucket Cloud [external identity provider](/docs/configuration/idp) must be configured to (1) correlate a Sourcebot user with a Bitbucket Cloud user, and (2) to list repositories that the user has access to for [User driven syncing](/docs/features/permission-syncing#how-it-works).
- OAuth tokens require the `account` and `repository` scopes. The `repository` scope is required to list private repositories during [User driven syncing](/docs/features/permission-syncing#how-it-works).

# How it works

Expand Down
94 changes: 80 additions & 14 deletions packages/backend/src/bitbucket.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createBitbucketCloudClient } from "@coderabbitai/bitbucket/cloud";
import { createBitbucketServerClient } from "@coderabbitai/bitbucket/server";
import { createBitbucketCloudClient as createBitbucketCloudClientBase } from "@coderabbitai/bitbucket/cloud";
import { createBitbucketServerClient as createBitbucketServerClientBase } from "@coderabbitai/bitbucket/server";
import { BitbucketConnectionConfig } from "@sourcebot/schemas/v3/bitbucket.type";
import type { ClientOptions, ClientPathsWithMethod } from "openapi-fetch";
import { createLogger } from "@sourcebot/shared";
Expand All @@ -8,6 +8,8 @@ import * as Sentry from "@sentry/node";
import micromatch from "micromatch";
import {
SchemaRepository as CloudRepository,
SchemaRepositoryUserPermission as CloudRepositoryUserPermission,
SchemaRepositoryPermission as CloudRepositoryPermission,
} from "@coderabbitai/bitbucket/cloud/openapi";
import { SchemaRestRepository as ServerRepository } from "@coderabbitai/bitbucket/server/openapi";
import { processPromiseResults } from "./connectionUtils.js";
Expand All @@ -34,10 +36,10 @@ interface BitbucketClient {
shouldExcludeRepo: (repo: BitbucketRepository, config: BitbucketConnectionConfig) => boolean;
}

type CloudAPI = ReturnType<typeof createBitbucketCloudClient>;
type CloudAPI = ReturnType<typeof createBitbucketCloudClientBase>;
type CloudGetRequestPath = ClientPathsWithMethod<CloudAPI, "get">;

type ServerAPI = ReturnType<typeof createBitbucketServerClient>;
type ServerAPI = ReturnType<typeof createBitbucketServerClientBase>;
type ServerGetRequestPath = ClientPathsWithMethod<ServerAPI, "get">;

type CloudPaginatedResponse<T> = {
Expand Down Expand Up @@ -68,8 +70,8 @@ export const getBitbucketReposFromConfig = async (config: BitbucketConnectionCon
}

const client = config.deploymentType === 'server' ?
serverClient(config.url!, config.user, token) :
cloudClient(config.user, token);
createBitbucketServerClient(config.url!, config.user, token) :
createBitbucketCloudClient(config.user, token);

let allRepos: BitbucketRepository[] = [];
let allWarnings: string[] = [];
Expand Down Expand Up @@ -102,11 +104,10 @@ export const getBitbucketReposFromConfig = async (config: BitbucketConnectionCon
};
}

function cloudClient(user: string | undefined, token: string | undefined): BitbucketClient {

export function createBitbucketCloudClient(user: string | undefined, token: string | undefined): BitbucketClient {
const authorizationString =
token
? !user || user == "x-token-auth"
? (!user || user === "x-token-auth")
? `Bearer ${token}`
: `Basic ${Buffer.from(`${user}:${token}`).toString('base64')}`
: undefined;
Expand All @@ -119,7 +120,7 @@ function cloudClient(user: string | undefined, token: string | undefined): Bitbu
},
};

const apiClient = createBitbucketCloudClient(clientOptions);
const apiClient = createBitbucketCloudClientBase(clientOptions);
var client: BitbucketClient = {
deploymentType: BITBUCKET_CLOUD,
token: token,
Expand Down Expand Up @@ -378,7 +379,7 @@ export function cloudShouldExcludeRepo(repo: BitbucketRepository, config: Bitbuc
return false;
}

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

const apiClient = createBitbucketServerClient(clientOptions);
const apiClient = createBitbucketServerClientBase(clientOptions);
var client: BitbucketClient = {
deploymentType: BITBUCKET_SERVER,
token: token,
Expand Down Expand Up @@ -560,7 +561,7 @@ export function serverShouldExcludeRepo(repo: BitbucketRepository, config: Bitbu
const repoSlug = serverRepo.slug!;
const repoName = `${projectName}/${repoSlug}`;
let reason = '';

const shouldExclude = (() => {
if (config.exclude?.repos) {
if (micromatch.isMatch(repoName, config.exclude.repos)) {
Expand All @@ -587,4 +588,69 @@ export function serverShouldExcludeRepo(repo: BitbucketRepository, config: Bitbu
return true;
}
return false;
}
}

/**
* Returns the account IDs of users who have been *explicitly* granted permission on a Bitbucket Cloud repository.
*
* @note This only covers direct user-to-repo grants. It does NOT include users who have access via:
* - A group that is explicitly added to the repo
* - Membership in the project that contains the repo
* - A group that is part of a project that contains the repo
* As a result, permission syncing may under-grant access for workspaces that rely on group or
* project-level permissions rather than direct user grants.
*
* @see https://developer.atlassian.com/cloud/bitbucket/rest/api-group-repositories/#api-repositories-workspace-repo-slug-permissions-config-users-get
*/
export const getExplicitUserPermissionsForCloudRepo = async (
client: BitbucketClient,
workspace: string,
repoSlug: string,
): Promise<Array<{ accountId: string }>> => {
const path = `/repositories/${workspace}/${repoSlug}/permissions-config/users` as CloudGetRequestPath;

const users = await getPaginatedCloud<CloudRepositoryUserPermission>(path, async (p, query) => {
const response = await client.apiClient.GET(p, {
params: {
path: { workspace, repo_slug: repoSlug },
query,
},
});
const { data, error } = response;
if (error) {
throw new Error(`Failed to get explicit user permissions for ${workspace}/${repoSlug}: ${JSON.stringify(error)}`);
}
return data;
});

return users
.filter(u => u.user?.account_id != null)
.map(u => ({ accountId: u.user!.account_id as string }));
};

/**
* Returns the UUIDs of all private repositories accessible to the authenticated Bitbucket Cloud user.
* Used for account-driven permission syncing.
*
* @see https://developer.atlassian.com/cloud/bitbucket/rest/api-group-repositories/#api-user-permissions-repositories-get
*/
export const getReposForAuthenticatedBitbucketCloudUser = async (
client: BitbucketClient,
): Promise<Array<{ uuid: string }>> => {
const path = `/user/permissions/repositories` as CloudGetRequestPath;

const permissions = await getPaginatedCloud<CloudRepositoryPermission>(path, async (p, query) => {
const response = await client.apiClient.GET(p, {
params: { query },
});
const { data, error } = response;
if (error) {
throw new Error(`Failed to get user repository permissions: ${JSON.stringify(error)}`);
}
return data;
});

return permissions
.filter(p => p.repository?.uuid != null)
.map(p => ({ uuid: p.repository!.uuid as string }));
};
2 changes: 2 additions & 0 deletions packages/backend/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,13 @@ export const SINGLE_TENANT_ORG_ID = 1;
export const PERMISSION_SYNC_SUPPORTED_CODE_HOST_TYPES: CodeHostType[] = [
'github',
'gitlab',
'bitbucketCloud',
];

export const PERMISSION_SYNC_SUPPORTED_IDENTITY_PROVIDERS: IdentityProviderType[] = [
'github',
'gitlab',
'bitbucket-cloud',
];

export const REPOS_CACHE_DIR = path.join(env.DATA_CACHE_DIR, 'repos');
Expand Down
25 changes: 24 additions & 1 deletion packages/backend/src/ee/accountPermissionSyncer.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import * as Sentry from "@sentry/node";
import { PrismaClient, AccountPermissionSyncJobStatus, Account} from "@sourcebot/db";
import { PrismaClient, AccountPermissionSyncJobStatus, Account, PermissionSyncSource} from "@sourcebot/db";
import { env, hasEntitlement, createLogger, loadConfig, decryptOAuthToken } from "@sourcebot/shared";
import { Job, Queue, Worker } from "bullmq";
import { Redis } from "ioredis";
Expand All @@ -14,6 +14,7 @@ import {
getOAuthScopesForAuthenticatedUser as getGitLabOAuthScopesForAuthenticatedUser,
getProjectsForAuthenticatedUser,
} from "../gitlab.js";
import { createBitbucketCloudClient, getReposForAuthenticatedBitbucketCloudUser } from "../bitbucket.js";
import { Settings } from "../types.js";
import { setIntervalAsync } from "../utils.js";

Expand Down Expand Up @@ -266,6 +267,27 @@ export class AccountPermissionSyncer {
}
});

repos.forEach(repo => aggregatedRepoIds.add(repo.id));
} else if (account.provider === 'bitbucket-cloud') {
if (!accessToken) {
throw new Error(`User '${account.user.email}' does not have a Bitbucket Cloud OAuth access token associated with their account. Please re-authenticate with Bitbucket Cloud to refresh the token.`);
}

// @note: we don't pass a user here since we want to use a bearer token
// for authentication.
const client = createBitbucketCloudClient(/* user = */ undefined, accessToken)
const bitbucketRepos = await getReposForAuthenticatedBitbucketCloudUser(client);
const bitbucketRepoUuids = bitbucketRepos.map(repo => repo.uuid);

const repos = await this.db.repo.findMany({
where: {
external_codeHostType: 'bitbucketCloud',
external_id: {
in: bitbucketRepoUuids,
}
}
});

repos.forEach(repo => aggregatedRepoIds.add(repo.id));
}

Expand All @@ -287,6 +309,7 @@ export class AccountPermissionSyncer {
data: repoIds.map(repoId => ({
accountId: account.id,
repoId,
source: PermissionSyncSource.ACCOUNT_DRIVEN,
})),
skipDuplicates: true,
})
Expand Down
Loading