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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 }));
};
Comment thread
brendan-kellam marked this conversation as resolved.
2 changes: 2 additions & 0 deletions packages/backend/src/constants.ts
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand Up@@ -266,6 +267,27 @@ export class AccountPermissionSyncer {
}
});

repos.forEach(repo => aggregatedRepoIds.add(repo.id));
Comment thread
brendan-kellam marked this conversation as resolved.
} 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));
}
Comment thread
brendan-kellam marked this conversation as resolved.

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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 }));
};
Comment thread
brendan-kellam marked this conversation as resolved.
2 changes: 2 additions & 0 deletions packages/backend/src/constants.ts
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand Up@@ -266,6 +267,27 @@ export class AccountPermissionSyncer {
}
});

repos.forEach(repo => aggregatedRepoIds.add(repo.id));
Comment thread
brendan-kellam marked this conversation as resolved.
} 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));
}
Comment thread
brendan-kellam marked this conversation as resolved.

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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 }));
};
Comment thread
brendan-kellam marked this conversation as resolved.
2 changes: 2 additions & 0 deletions packages/backend/src/constants.ts
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand Up@@ -266,6 +267,27 @@ export class AccountPermissionSyncer {
}
});

repos.forEach(repo => aggregatedRepoIds.add(repo.id));
Comment thread
brendan-kellam marked this conversation as resolved.
} 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));
}
Comment thread
brendan-kellam marked this conversation as resolved.

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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 }));
};
Comment thread
brendan-kellam marked this conversation as resolved.
2 changes: 2 additions & 0 deletions packages/backend/src/constants.ts
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand Up@@ -266,6 +267,27 @@ export class AccountPermissionSyncer {
}
});

repos.forEach(repo => aggregatedRepoIds.add(repo.id));
Comment thread
brendan-kellam marked this conversation as resolved.
} 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));
}
Comment thread
brendan-kellam marked this conversation as resolved.

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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 }));
};
Comment thread
brendan-kellam marked this conversation as resolved.
2 changes: 2 additions & 0 deletions packages/backend/src/constants.ts
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand Up@@ -266,6 +267,27 @@ export class AccountPermissionSyncer {
}
});

repos.forEach(repo => aggregatedRepoIds.add(repo.id));
Comment thread
brendan-kellam marked this conversation as resolved.
} 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));
}
Comment thread
brendan-kellam marked this conversation as resolved.

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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 }));
};
Comment thread
brendan-kellam marked this conversation as resolved.
2 changes: 2 additions & 0 deletions packages/backend/src/constants.ts
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand Up@@ -266,6 +267,27 @@ export class AccountPermissionSyncer {
}
});

repos.forEach(repo => aggregatedRepoIds.add(repo.id));
Comment thread
brendan-kellam marked this conversation as resolved.
} 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));
}
Comment thread
brendan-kellam marked this conversation as resolved.

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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 }));
};
Comment thread
brendan-kellam marked this conversation as resolved.
2 changes: 2 additions & 0 deletions packages/backend/src/constants.ts
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand Up@@ -266,6 +267,27 @@ export class AccountPermissionSyncer {
}
});

repos.forEach(repo => aggregatedRepoIds.add(repo.id));
Comment thread
brendan-kellam marked this conversation as resolved.
} 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));
}
Comment thread
brendan-kellam marked this conversation as resolved.

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
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
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 numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand 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 numberDiff line numberDiff 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 DownExpand 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 DownExpand 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 DownExpand 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 DownExpand 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 }));
};
Comment thread
brendan-kellam marked this conversation as resolved.
2 changes: 2 additions & 0 deletions packages/backend/src/constants.ts
Original file line numberDiff line numberDiff 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 numberDiff line numberDiff 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 DownExpand Up@@ -266,6 +267,27 @@ export class AccountPermissionSyncer {
}
});

repos.forEach(repo => aggregatedRepoIds.add(repo.id));
Comment thread
brendan-kellam marked this conversation as resolved.
} 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));
}
Comment thread
brendan-kellam marked this conversation as resolved.

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