diff --git a/CHANGELOG.md b/CHANGELOG.md
index 339a669e5..0a6fbd7ff 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added `wa_askgh_login_wall_prompted` PostHog event fired when an unauthenticated user attempts to ask a question on Ask GitHub. [#933](https://github.com/sourcebot-dev/sourcebot/pull/933)
- Added Bitbucket Server (Data Center) OAuth 2.0 SSO identity provider support (`provider: "bitbucket-server"`). [#934](https://github.com/sourcebot-dev/sourcebot/pull/934)
- Added Bitbucket Server (Data Center) sync all repositories support. [#927](https://github.com/sourcebot-dev/sourcebot/pull/927)
+- Added permission syncing support for Bitbucket Server (Data Center), including account-driven and repo-driven sync. [#938](https://github.com/sourcebot-dev/sourcebot/pull/938)
### Changed
- Hide version upgrade toast for askgithub deployment (`EXPERIMENT_ASK_GH_ENABLED`). [#931](https://github.com/sourcebot-dev/sourcebot/pull/931)
diff --git a/docs/docs/configuration/idp.mdx b/docs/docs/configuration/idp.mdx
index 071b51a42..38e8b3fd3 100644
--- a/docs/docs/configuration/idp.mdx
+++ b/docs/docs/configuration/idp.mdx
@@ -74,9 +74,11 @@ in the GitHub identity provider config.
- `"Metadata" repository permissions (read)` (only needed if using permission syncing)
- Follow [this guide](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app) by GitHub to create an OAuth App.
-
+ Follow [this guide](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app) by GitHub to create an OAuth App.
+
When asked to provide a callback url, provide `/api/auth/callback/github` (ex. https://sourcebot.coolcorp.com/api/auth/callback/github)
+
+ If [permission syncing](/docs/features/permission-syncing#github) is enabled, also enable the `repo` scope.
@@ -128,7 +130,7 @@ in the GitLab identity provider config.
When configuring your application:
- Set the callback URL to `/api/auth/callback/gitlab` (ex. https://sourcebot.coolcorp.com/api/auth/callback/gitlab)
- Enable the `read_user` scope
- - If using for permission syncing, also enable the `read_api` scope
+ - If [permission syncing](/docs/features/permission-syncing#gitlab) is enabled, also enable the `read_api` scope
The result of registering an OAuth application is an `APPLICATION_ID` (`CLIENT_ID`) and `SECRET` (`CLIENT_SECRET`) which you'll provide to Sourcebot.
@@ -182,7 +184,7 @@ in the Bitbucket Cloud identity provider config.
When configuring your consumer:
- Set the callback URL to `/api/auth/callback/bitbucket-cloud` (ex. https://sourcebot.coolcorp.com/api/auth/callback/bitbucket-cloud)
- Enable **Account: Read**
- - If using for permission syncing, also enable **Repositories: Read**
+ - If [permission syncing](/docs/features/permission-syncing#bitbucket-cloud) is enabled, also enable **Repositories: Read**
The result of creating an OAuth consumer is a `Key` (`CLIENT_ID`) and `Secret` (`CLIENT_SECRET`) which you'll provide to Sourcebot.
@@ -220,7 +222,8 @@ in the Bitbucket Cloud identity provider config.
### Bitbucket Server
-A Bitbucket Server (Data Center) connection can be used for [authentication](/docs/configuration/auth).
+A Bitbucket Server (Data Center) connection can be used for [authentication](/docs/configuration/auth) and/or [permission syncing](/docs/features/permission-syncing). This is controlled using the `purpose` field
+in the Bitbucket Server identity provider config.
@@ -231,6 +234,7 @@ A Bitbucket Server (Data Center) connection can be used for [authentication](/do
When configuring your application:
- Set the redirect URL to `/api/auth/callback/bitbucket-server` (ex. https://sourcebot.coolcorp.com/api/auth/callback/bitbucket-server)
+ - If [permission syncing](/docs/features/permission-syncing#bitbucket-data-center) is enabled, also enable the `REPO_READ` scope
The result of creating the application is a `CLIENT_ID` and `CLIENT_SECRET` which you'll provide to Sourcebot.
@@ -247,7 +251,10 @@ A Bitbucket Server (Data Center) connection can be used for [authentication](/do
"identityProviders": [
{
"provider": "bitbucket-server",
- "purpose": "sso",
+ // "sso" for auth + perm sync, "account_linking" for only perm sync
+ "purpose": "account_linking",
+ // if purpose == "account_linking" this controls if a user must connect to the IdP
+ "accountLinkingRequired": true,
"baseUrl": "https://bitbucket.example.com",
"clientId": {
"env": "YOUR_CLIENT_ID_ENV_VAR"
diff --git a/docs/docs/connections/bitbucket-data-center.mdx b/docs/docs/connections/bitbucket-data-center.mdx
index 69e415d48..72d11b389 100644
--- a/docs/docs/connections/bitbucket-data-center.mdx
+++ b/docs/docs/connections/bitbucket-data-center.mdx
@@ -80,35 +80,75 @@ If you're not familiar with Sourcebot [connections](/docs/connections/overview),
## Authenticating with Bitbucket Data Center
-In order to index private repositories, you'll need to provide an access token to Sourcebot via a [token](/docs/configuration/config-file#tokens).
-
-Create an access token for the desired scope (repo, project, or workspace). Visit the official [Bitbucket Data Center docs](https://confluence.atlassian.com/bitbucketserver/http-access-tokens-939515499.html)
-for more info.
-
-1. Add the `token` property to your connection config:
-
-```json
-{
- "type": "bitbucket",
- "deploymentType": "server",
- "url": "https://mybitbucketdeployment.com",
- "token": {
- // note: this env var can be named anything. It
- // doesn't need to be `BITBUCKET_TOKEN`.
- "env": "BITBUCKET_TOKEN"
- }
- // .. rest of config ..
-}
-```
-
-2. Pass this environment variable each time you run Sourcebot:
-
-```bash
-docker run \
- -e BITBUCKET_TOKEN= \
- /* additional args */ \
- ghcr.io/sourcebot-dev/sourcebot:latest
-```
+In order to index private repositories, you'll need to provide a [HTTP Access Token](https://confluence.atlassian.com/bitbucketserver/http-access-tokens-939515499.html). Tokens can be scoped to a user account, a project, or an individual repository. Only repositories visible to the token will be able to be indexed by Sourcebot.
+
+
+ If [permission syncing](/docs/features/permission-syncing#bitbucket-data-center) is enabled, the token must have **Repository Admin** permissions so Sourcebot can read repository-level user permissions.
+
+
+
+
+ User account tokens grant access to all repositories the user can access. Because these are tied to a specific user account, you must also set the `user` field to that user's username.
+
+ 1. In Bitbucket Data Center, navigate to your profile → **Manage account** → **HTTP access tokens** and click **Create token**. Give it a name and grant it **Project read** and **Repository read** permissions.
+
+ 2. Add the `user` (your Bitbucket username) and `token` properties to your connection config:
+
+ ```json
+ {
+ "type": "bitbucket",
+ "deploymentType": "server",
+ "url": "https://mybitbucketdeployment.com",
+ "user": "myusername",
+ "token": {
+ // note: this env var can be named anything. It
+ // doesn't need to be `BITBUCKET_TOKEN`.
+ "env": "BITBUCKET_TOKEN"
+ }
+ // .. rest of config ..
+ }
+ ```
+
+ 3. Pass this environment variable each time you run Sourcebot:
+
+ ```bash
+ docker run \
+ -e BITBUCKET_TOKEN= \
+ /* additional args */ \
+ ghcr.io/sourcebot-dev/sourcebot:latest
+ ```
+
+
+ Project and repository tokens are scoped to a specific project or repository.
+
+ 1. In Bitbucket Data Center, navigate to the project or repository → **Settings** → **HTTP access tokens** and click **Create token**. Give it a name and grant it **Repository read** and **Project read** permissions.
+
+ 2. Add the `token` property to your connection config:
+
+ ```json
+ {
+ "type": "bitbucket",
+ "deploymentType": "server",
+ "url": "https://mybitbucketdeployment.com",
+ "token": {
+ // note: this env var can be named anything. It
+ // doesn't need to be `BITBUCKET_TOKEN`.
+ "env": "BITBUCKET_TOKEN"
+ }
+ // .. rest of config ..
+ }
+ ```
+
+ 3. Pass this environment variable each time you run Sourcebot:
+
+ ```bash
+ docker run \
+ -e BITBUCKET_TOKEN= \
+ /* additional args */ \
+ ghcr.io/sourcebot-dev/sourcebot:latest
+ ```
+
+
## Troubleshooting
If you're seeing errors like `TypeError: fetch failed` when fetching repo info, it may be that Sourcebot is refusing to connect to your self-hosted Bitbucket instance due to unrecognized SSL certs. Try setting the `NODE_TLS_REJECT_UNAUTHORIZED=0` environment variable or providing Sourcebot your certs through the `NODE_EXTRA_CA_CERTS` environment variable.
diff --git a/docs/docs/features/permission-syncing.mdx b/docs/docs/features/permission-syncing.mdx
index 12ddf3645..5598ffcd6 100644
--- a/docs/docs/features/permission-syncing.mdx
+++ b/docs/docs/features/permission-syncing.mdx
@@ -40,7 +40,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](/docs/features/permission-syncing#bitbucket-cloud) | 🟠 Partial |
-| Bitbucket Data Center | 🛑 |
+| [Bitbucket Data Center](/docs/features/permission-syncing#bitbucket-data-center) | 🟠 Partial |
| Gitea | 🛑 |
| Gerrit | 🛑 |
| Generic git host | 🛑 |
@@ -50,7 +50,8 @@ We are actively working on supporting more code hosts. If you'd like to see a sp
## GitHub
Prerequisites:
-- Configure GitHub as an [external identity provider](/docs/configuration/idp).
+- Configure a [GitHub connection](/docs/connections/github).
+- Configure GitHub as an [external identity provider](/docs/configuration/idp#github).
- **If you are using a self-hosted GitHub instance**, you must also set the `baseUrl` property of the `github` identity provider in the [config file](/docs/configuration/config-file) to the base URL of your GitHub instance (e.g. `https://github.example.com`).
Permission syncing works with **GitHub.com**, **GitHub Enterprise Cloud**, and **GitHub Enterprise Server**. For organization-owned repositories, users that have **read-only** access (or above) via the following methods will have their access synced to Sourcebot:
@@ -61,27 +62,29 @@ Permission syncing works with **GitHub.com**, **GitHub Enterprise Cloud**, and *
- Organization owners.
**Notes:**
-- A GitHub [external identity provider](/docs/configuration/idp) must be configured to (1) correlate a Sourcebot user with a GitHub user, and (2) to list repositories that the user has access to for [User driven syncing](/docs/features/permission-syncing#how-it-works).
+- A GitHub [external identity provider](/docs/configuration/idp#github) must be configured to (1) correlate a Sourcebot user with a GitHub 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 must assume the `repo` scope in order to use the [List repositories for the authenticated user API](https://docs.github.com/en/rest/repos/repos?apiVersion=2022-11-28#list-repositories-for-the-authenticated-user) during [User driven syncing](/docs/features/permission-syncing#how-it-works). Sourcebot **will only** use this token for **reads**.
## GitLab
Prerequisites:
-- Configure GitLab as an [external identity provider](/docs/configuration/idp).
+- Configure a [GitLab connection](/docs/connections/gitlab).
+- Configure GitLab as an [external identity provider](/docs/configuration/idp#gitlab).
- **If you are using a self-hosted GitLab instance**, you must also set the `baseUrl` property of the `gitlab` identity provider in the [config file](/docs/configuration/config-file) to the base URL of your GitLab instance (e.g. `https://gitlab.example.com`).
Permission syncing works with **GitLab Self-managed** and **GitLab Cloud**. Users with **Guest** role or above with membership to a group or project will have their access synced to Sourcebot. Both direct and indirect membership to a group or project will be synced with Sourcebot. For more details, see the [GitLab docs](https://docs.gitlab.com/user/project/members/#membership-types).
**Notes:**
-- A GitLab [external identity provider](/docs/configuration/idp) must be configured to (1) correlate a Sourcebot user with a GitLab user, and (2) to list repositories that the user has access to for [User driven syncing](/docs/features/permission-syncing#how-it-works).
+- A GitLab [external identity provider](/docs/configuration/idp#gitlab) must be configured to (1) correlate a Sourcebot user with a GitLab 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 `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).
+- Configure a [Bitbucket Cloud connection](/docs/connections/bitbucket-cloud).
+- Configure Bitbucket Cloud as an [external identity provider](/docs/configuration/idp#bitbucket-cloud).
Permission syncing works with **Bitbucket Cloud**. OAuth tokens must assume the `account` and `repository` scopes.
@@ -98,9 +101,33 @@ If your workspace relies heavily on group or project-level permissions rather th
**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).
+- A Bitbucket Cloud [external identity provider](/docs/configuration/idp#bitbucket-cloud) 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).
+## Bitbucket Data Center
+
+Prerequisites:
+- Configure a [Bitbucket Data Center connection](/docs/connections/bitbucket-data-center).
+- Configure Bitbucket Data Center as an [external identity provider](/docs/configuration/idp#bitbucket-server).
+
+Permission syncing works with **Bitbucket Data Center**. OAuth tokens must assume the `PUBLIC_REPOS` and `REPO_READ` scopes.
+
+
+**Partial coverage for repo-driven syncing.** Repo-driven syncing only captures users who have been **directly and explicitly** granted access to the repository. Users who have access via any of the following are **not** captured by repo-driven syncing:
+
+- Project-level permissions (inherited by all repos in the project)
+- Group membership
+
+These users **will** still gain access via [user-driven syncing](/docs/features/permission-syncing#how-it-works), which fetches all repositories accessible to each authenticated user using the `REPO_READ` scope. However, there may be a delay between when access is granted and when affected users see the repository in Sourcebot (up to the `experiment_userDrivenPermissionSyncIntervalMs` interval, which defaults to 24 hours).
+
+If your instance relies heavily on project or group-level permissions, we recommend reducing the `experiment_userDrivenPermissionSyncIntervalMs` interval to limit the window of delay.
+
+
+**Notes:**
+- A Bitbucket Data Center [external identity provider](/docs/configuration/idp#bitbucket-server) must be configured to (1) correlate a Sourcebot user with a Bitbucket Data Center user, and (2) to list repositories that the user has access to for [User driven syncing](/docs/features/permission-syncing#how-it-works).
+- The connection token must have **Repository Read** permissions so Sourcebot can read repository-level user permissions for [Repo driven syncing](/docs/features/permission-syncing#how-it-works).
+- OAuth tokens require the `REPO_READ` scope to list accessible repositories during [User driven syncing](/docs/features/permission-syncing#how-it-works).
+
# How it works
Permission syncing works by periodically syncing ACLs from the code host(s) to Sourcebot to build an internal mapping between Users and Repositories. This mapping is hydrated in two directions:
diff --git a/docs/snippets/schemas/v3/identityProvider.schema.mdx b/docs/snippets/schemas/v3/identityProvider.schema.mdx
index 67b55ff51..fcd924308 100644
--- a/docs/snippets/schemas/v3/identityProvider.schema.mdx
+++ b/docs/snippets/schemas/v3/identityProvider.schema.mdx
@@ -850,7 +850,10 @@
"const": "bitbucket-server"
},
"purpose": {
- "const": "sso"
+ "enum": [
+ "sso",
+ "account_linking"
+ ]
},
"clientId": {
"anyOf": [
@@ -919,6 +922,10 @@
"https://bitbucket.example.com"
],
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$"
+ },
+ "accountLinkingRequired": {
+ "type": "boolean",
+ "default": false
}
},
"required": [
@@ -1777,7 +1784,10 @@
"const": "bitbucket-server"
},
"purpose": {
- "const": "sso"
+ "enum": [
+ "sso",
+ "account_linking"
+ ]
},
"clientId": {
"anyOf": [
@@ -1846,6 +1856,10 @@
"https://bitbucket.example.com"
],
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$"
+ },
+ "accountLinkingRequired": {
+ "type": "boolean",
+ "default": false
}
},
"required": [
diff --git a/docs/snippets/schemas/v3/index.schema.mdx b/docs/snippets/schemas/v3/index.schema.mdx
index e6d4e02c5..d5e6721d1 100644
--- a/docs/snippets/schemas/v3/index.schema.mdx
+++ b/docs/snippets/schemas/v3/index.schema.mdx
@@ -5395,7 +5395,10 @@
"const": "bitbucket-server"
},
"purpose": {
- "const": "sso"
+ "enum": [
+ "sso",
+ "account_linking"
+ ]
},
"clientId": {
"anyOf": [
@@ -5464,6 +5467,10 @@
"https://bitbucket.example.com"
],
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$"
+ },
+ "accountLinkingRequired": {
+ "type": "boolean",
+ "default": false
}
},
"required": [
@@ -6322,7 +6329,10 @@
"const": "bitbucket-server"
},
"purpose": {
- "const": "sso"
+ "enum": [
+ "sso",
+ "account_linking"
+ ]
},
"clientId": {
"anyOf": [
@@ -6391,6 +6401,10 @@
"https://bitbucket.example.com"
],
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$"
+ },
+ "accountLinkingRequired": {
+ "type": "boolean",
+ "default": false
}
},
"required": [
diff --git a/packages/backend/src/bitbucket.ts b/packages/backend/src/bitbucket.ts
index b2797679c..b768c15a7 100644
--- a/packages/backend/src/bitbucket.ts
+++ b/packages/backend/src/bitbucket.ts
@@ -260,26 +260,29 @@ async function cloudGetReposForProjects(client: BitbucketClient, projects: strin
logger.debug(`Fetching all repos for project ${project} for workspace ${workspace}...`);
try {
- const repos = await getPaginatedCloud(`/repositories/${workspace}` as CloudGetRequestPath, async (path, query) => {
- const response = await client.apiClient.GET(path, {
- params: {
- path: {
- workspace,
- },
- query: {
- ...query,
- q: `project.key="${project_name}"`
+ const { durationMs, data: repos } = await measure(async () => {
+ const fetchFn = () => getPaginatedCloud(`/repositories/${workspace}` as CloudGetRequestPath, async (path, query) => {
+ const response = await client.apiClient.GET(path, {
+ params: {
+ path: {
+ workspace,
+ },
+ query: {
+ ...query,
+ q: `project.key="${project_name}"`
+ }
}
+ });
+ const { data, error } = response;
+ if (error) {
+ throw new Error(`Failed to fetch projects for workspace ${workspace}: ${JSON.stringify(error)}`);
}
+ return data;
});
- const { data, error } = response;
- if (error) {
- throw new Error (`Failed to fetch projects for workspace ${workspace}: ${error.type}`);
- }
- return data;
+ return fetchWithRetry(fetchFn, `project ${project_name} in workspace ${workspace}`, logger);
});
- logger.debug(`Found ${repos.length} repos for project ${project_name} for workspace ${workspace}.`);
+ logger.debug(`Found ${repos.length} repos for project ${project_name} for workspace ${workspace} in ${durationMs}ms.`);
return {
type: 'valid' as const,
data: repos
@@ -324,11 +327,14 @@ async function cloudGetRepos(client: BitbucketClient, repoList: string[]): Promi
logger.debug(`Fetching repo ${repo_slug} for workspace ${workspace}...`);
try {
const path = `/repositories/${workspace}/${repo_slug}` as CloudGetRequestPath;
- const response = await client.apiClient.GET(path);
- const { data, error } = response;
- if (error) {
- throw new Error(`Failed to fetch repo ${repo}: ${error.type}`);
- }
+ const data = await fetchWithRetry(async () => {
+ const response = await client.apiClient.GET(path);
+ const { data, error } = response;
+ if (error) {
+ throw new Error(`Failed to fetch repo ${repo}: ${JSON.stringify(error)}`);
+ }
+ return data;
+ }, `repo ${repo}`, logger);
return {
type: 'valid' as const,
data: [data]
@@ -391,7 +397,7 @@ export function cloudShouldExcludeRepo(repo: BitbucketRepository, config: Bitbuc
return false;
}
-function createBitbucketServerClient(url: string, user: string | undefined, token: string | undefined): BitbucketClient {
+export 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) {
@@ -532,11 +538,14 @@ async function serverGetRepos(client: BitbucketClient, repoList: string[]): Prom
logger.debug(`Fetching repo ${repo_slug} for project ${project}...`);
try {
const path = `/rest/api/1.0/projects/${project}/repos/${repo_slug}` as ServerGetRequestPath;
- const response = await client.apiClient.GET(path);
- const { data, error } = response;
- if (error) {
- throw new Error(`Failed to fetch repo ${repo}: ${error.type}`);
- }
+ const data = await fetchWithRetry(async () => {
+ const response = await client.apiClient.GET(path);
+ const { data, error } = response;
+ if (error) {
+ throw new Error(`Failed to fetch repo ${repo}: ${JSON.stringify(error)}`);
+ }
+ return data;
+ }, `repo ${repo}`, logger);
return {
type: 'valid' as const,
data: [data]
@@ -641,7 +650,7 @@ export const getExplicitUserPermissionsForCloudRepo = async (
): Promise> => {
const path = `/repositories/${workspace}/${repoSlug}/permissions-config/users` as CloudGetRequestPath;
- const users = await getPaginatedCloud(path, async (p, query) => {
+ const users = await fetchWithRetry(() => getPaginatedCloud(path, async (p, query) => {
const response = await client.apiClient.GET(p, {
params: {
path: { workspace, repo_slug: repoSlug },
@@ -653,7 +662,7 @@ export const getExplicitUserPermissionsForCloudRepo = async (
throw new Error(`Failed to get explicit user permissions for ${workspace}/${repoSlug}: ${JSON.stringify(error)}`);
}
return data;
- });
+ }), `permissions for ${workspace}/${repoSlug}`, logger);
return users
.filter(u => u.user?.account_id != null)
@@ -671,7 +680,7 @@ export const getReposForAuthenticatedBitbucketCloudUser = async (
): Promise> => {
const path = `/user/permissions/repositories` as CloudGetRequestPath;
- const permissions = await getPaginatedCloud(path, async (p, query) => {
+ const permissions = await fetchWithRetry(() => getPaginatedCloud(path, async (p, query) => {
const response = await client.apiClient.GET(p, {
params: { query },
});
@@ -680,9 +689,75 @@ export const getReposForAuthenticatedBitbucketCloudUser = async (
throw new Error(`Failed to get user repository permissions: ${JSON.stringify(error)}`);
}
return data;
- });
+ }), 'user repository permissions', logger);
return permissions
.filter(p => p.repository?.uuid != null)
.map(p => ({ uuid: p.repository!.uuid as string }));
+};
+
+/**
+ * Returns the IDs of all repositories accessible to the authenticated Bitbucket Server user.
+ * Used for account-driven permission syncing.
+ *
+ * @see https://developer.atlassian.com/server/bitbucket/rest/v906/api-group-repository/#api-rest-api-latest-repos-get
+ */
+export const getReposForAuthenticatedBitbucketServerUser = async (
+ client: BitbucketClient,
+): Promise> => {
+ const repos = await fetchWithRetry(() => getPaginatedServer<{ id: number }>(
+ `/rest/api/1.0/repos` as ServerGetRequestPath,
+ async (url, start) => {
+ const response = await client.apiClient.GET(url, {
+ params: {
+ query: {
+ permission: 'REPO_READ',
+ limit: 100,
+ start,
+ },
+ },
+ });
+ const { data, error } = response;
+ if (error) {
+ throw new Error(`Failed to fetch Bitbucket Server repos for authenticated user: ${JSON.stringify(error)}`);
+ }
+ return data;
+ }
+ ), 'repos for authenticated Bitbucket Server user', logger);
+
+ return repos.map(r => ({ id: String(r.id) }));
+};
+
+/**
+ * Returns the user IDs of users who have been explicitly granted direct access to a Bitbucket Server repository.
+ *
+ * @note This only covers direct user-to-repo grants. It does NOT include users who have access via:
+ * - Project-level permissions (inherited by all repos in the project)
+ * - Group membership
+ * These users will still gain access through account-driven syncing (accountPermissionSyncer).
+ *
+ * @see https://developer.atlassian.com/server/bitbucket/rest/v906/api-group-repository/#api-rest-api-latest-projects-projectkey-repos-reposlug-permissions-users-get
+ */
+export const getUserPermissionsForServerRepo = async (
+ client: BitbucketClient,
+ projectKey: string,
+ repoSlug: string,
+): Promise> => {
+ const repoUsers = await fetchWithRetry(() => getPaginatedServer<{ user: { id: number } }>(
+ `/rest/api/1.0/projects/${projectKey}/repos/${repoSlug}/permissions/users` as ServerGetRequestPath,
+ async (url, start) => {
+ const response = await client.apiClient.GET(url, {
+ params: { query: { limit: 100, start } },
+ });
+ const { data, error } = response;
+ if (error) {
+ throw new Error(`Failed to fetch repo-level permissions for ${projectKey}/${repoSlug}: ${JSON.stringify(error)}`);
+ }
+ return data;
+ }
+ ), `repo-level permissions for ${projectKey}/${repoSlug}`, logger);
+
+ return repoUsers
+ .filter(entry => entry.user?.id != null)
+ .map(entry => ({ userId: String(entry.user.id) }));
};
\ No newline at end of file
diff --git a/packages/backend/src/constants.ts b/packages/backend/src/constants.ts
index 76e865ed7..dd55063dc 100644
--- a/packages/backend/src/constants.ts
+++ b/packages/backend/src/constants.ts
@@ -8,12 +8,14 @@ export const PERMISSION_SYNC_SUPPORTED_CODE_HOST_TYPES: CodeHostType[] = [
'github',
'gitlab',
'bitbucketCloud',
+ 'bitbucketServer',
];
export const PERMISSION_SYNC_SUPPORTED_IDENTITY_PROVIDERS: IdentityProviderType[] = [
'github',
'gitlab',
'bitbucket-cloud',
+ 'bitbucket-server',
];
export const REPOS_CACHE_DIR = path.join(env.DATA_CACHE_DIR, 'repos');
diff --git a/packages/backend/src/ee/accountPermissionSyncer.ts b/packages/backend/src/ee/accountPermissionSyncer.ts
index 3cbc02639..b3ad69b48 100644
--- a/packages/backend/src/ee/accountPermissionSyncer.ts
+++ b/packages/backend/src/ee/accountPermissionSyncer.ts
@@ -14,7 +14,7 @@ import {
getOAuthScopesForAuthenticatedUser as getGitLabOAuthScopesForAuthenticatedUser,
getProjectsForAuthenticatedUser,
} from "../gitlab.js";
-import { createBitbucketCloudClient, getReposForAuthenticatedBitbucketCloudUser } from "../bitbucket.js";
+import { createBitbucketCloudClient, createBitbucketServerClient, getReposForAuthenticatedBitbucketCloudUser, getReposForAuthenticatedBitbucketServerUser } from "../bitbucket.js";
import { Settings } from "../types.js";
import { setIntervalAsync } from "../utils.js";
@@ -288,6 +288,32 @@ export class AccountPermissionSyncer {
}
});
+ repos.forEach(repo => aggregatedRepoIds.add(repo.id));
+ } else if (account.provider === 'bitbucket-server') {
+ if (!accessToken) {
+ throw new Error(`User '${account.user.email}' does not have a Bitbucket Server OAuth access token associated with their account. Please re-authenticate with Bitbucket Server to refresh the token.`);
+ }
+
+ // @hack: we don't have a way of identifying specific identity providers in the config file.
+ // Instead, we'll use the first Bitbucket Server connection's URL as the base URL.
+ const baseUrl = Array.from(Object.values(config.connections ?? {}))
+ .find(connection => connection.type === 'bitbucket' && connection.deploymentType === 'server')?.url;
+
+ if (!baseUrl) {
+ throw new Error(`No Bitbucket Server connection URL found in config for account ${account.id}`);
+ }
+
+ const client = createBitbucketServerClient(baseUrl, /* user = */ undefined, accessToken);
+ const serverRepos = await getReposForAuthenticatedBitbucketServerUser(client);
+ const serverRepoIds = serverRepos.map(r => r.id);
+
+ const repos = await this.db.repo.findMany({
+ where: {
+ external_codeHostType: 'bitbucketServer',
+ external_id: { in: serverRepoIds },
+ }
+ });
+
repos.forEach(repo => aggregatedRepoIds.add(repo.id));
}
diff --git a/packages/backend/src/ee/repoPermissionSyncer.ts b/packages/backend/src/ee/repoPermissionSyncer.ts
index dfae24ae9..7d164079e 100644
--- a/packages/backend/src/ee/repoPermissionSyncer.ts
+++ b/packages/backend/src/ee/repoPermissionSyncer.ts
@@ -7,7 +7,7 @@ import { Redis } from 'ioredis';
import { PERMISSION_SYNC_SUPPORTED_CODE_HOST_TYPES } from "../constants.js";
import { createOctokitFromToken, getRepoCollaborators, GITHUB_CLOUD_HOSTNAME } from "../github.js";
import { createGitLabFromPersonalAccessToken, getProjectMembers } from "../gitlab.js";
-import { createBitbucketCloudClient, getExplicitUserPermissionsForCloudRepo } from "../bitbucket.js";
+import { createBitbucketCloudClient, createBitbucketServerClient, getExplicitUserPermissionsForCloudRepo, getUserPermissionsForServerRepo } from "../bitbucket.js";
import { repoMetadataSchema } from "@sourcebot/shared";
import { Settings } from "../types.js";
import { getAuthCredentialsForRepo, setIntervalAsync } from "../utils.js";
@@ -292,6 +292,41 @@ export class RepoPermissionSyncer {
// this is a partial sync.
isPartialSync: true,
}
+ } else if (repo.external_codeHostType === 'bitbucketServer') {
+ const parsedMetadata = repoMetadataSchema.safeParse(repo.metadata);
+ if (!parsedMetadata.success) {
+ throw new Error(`Repo ${id} has invalid metadata: ${JSON.stringify(parsedMetadata.error.errors)}`);
+ }
+ const bitbucketServerMetadata = parsedMetadata.data.codeHostMetadata?.bitbucketServer;
+ if (!bitbucketServerMetadata) {
+ throw new Error(`Repo ${id} is missing required Bitbucket Server metadata (projectKey/repoSlug)`);
+ }
+
+ const { projectKey, repoSlug } = bitbucketServerMetadata;
+ const hostUrl = credentials.hostUrl;
+
+ if (!hostUrl) {
+ throw new Error(`No host URL found for Bitbucket Server repo ${id}`);
+ }
+
+ // @note: This covers users with direct repo-level and project-level permissions.
+ // Users with access only via groups are NOT captured here. Those users will
+ // still gain access through account-driven syncing (accountPermissionSyncer).
+ const client = createBitbucketServerClient(hostUrl, /* user = */ undefined, credentials.token);
+ const users = await getUserPermissionsForServerRepo(client, projectKey, repoSlug);
+ const userIds = users.map(u => u.userId);
+
+ const accounts = await this.db.account.findMany({
+ where: {
+ provider: 'bitbucket-server',
+ providerAccountId: { in: userIds },
+ }
+ });
+
+ return {
+ accountIds: accounts.map(account => account.id),
+ isPartialSync: true,
+ }
}
return {
diff --git a/packages/backend/src/repoCompileUtils.ts b/packages/backend/src/repoCompileUtils.ts
index 0365758a2..ab8794e68 100644
--- a/packages/backend/src/repoCompileUtils.ts
+++ b/packages/backend/src/repoCompileUtils.ts
@@ -517,6 +517,13 @@ export const compileBitbucketConfig = async (
repoSlug: (repo as BitbucketCloudRepository).full_name!.split('/')[1]!,
}
}
+ } : codeHostType === 'bitbucketServer' ? {
+ codeHostMetadata: {
+ bitbucketServer: {
+ projectKey: (repo as BitbucketServerRepository).project!.key!,
+ repoSlug: (repo as BitbucketServerRepository).slug!,
+ }
+ }
} : {}),
} satisfies RepoMetadata,
};
diff --git a/packages/schemas/src/v3/identityProvider.schema.ts b/packages/schemas/src/v3/identityProvider.schema.ts
index 28b540ed7..555284887 100644
--- a/packages/schemas/src/v3/identityProvider.schema.ts
+++ b/packages/schemas/src/v3/identityProvider.schema.ts
@@ -849,7 +849,10 @@ const schema = {
"const": "bitbucket-server"
},
"purpose": {
- "const": "sso"
+ "enum": [
+ "sso",
+ "account_linking"
+ ]
},
"clientId": {
"anyOf": [
@@ -918,6 +921,10 @@ const schema = {
"https://bitbucket.example.com"
],
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$"
+ },
+ "accountLinkingRequired": {
+ "type": "boolean",
+ "default": false
}
},
"required": [
@@ -1776,7 +1783,10 @@ const schema = {
"const": "bitbucket-server"
},
"purpose": {
- "const": "sso"
+ "enum": [
+ "sso",
+ "account_linking"
+ ]
},
"clientId": {
"anyOf": [
@@ -1845,6 +1855,10 @@ const schema = {
"https://bitbucket.example.com"
],
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$"
+ },
+ "accountLinkingRequired": {
+ "type": "boolean",
+ "default": false
}
},
"required": [
diff --git a/packages/schemas/src/v3/identityProvider.type.ts b/packages/schemas/src/v3/identityProvider.type.ts
index 17aea05e6..f409e96c9 100644
--- a/packages/schemas/src/v3/identityProvider.type.ts
+++ b/packages/schemas/src/v3/identityProvider.type.ts
@@ -334,7 +334,7 @@ export interface BitbucketCloudIdentityProviderConfig {
}
export interface BitbucketServerIdentityProviderConfig {
provider: "bitbucket-server";
- purpose: "sso";
+ purpose: "sso" | "account_linking";
clientId:
| {
/**
@@ -365,4 +365,5 @@ export interface BitbucketServerIdentityProviderConfig {
* The URL of the Bitbucket Server/Data Center host.
*/
baseUrl: string;
+ accountLinkingRequired?: boolean;
}
diff --git a/packages/schemas/src/v3/index.schema.ts b/packages/schemas/src/v3/index.schema.ts
index 6b09080a7..52685c591 100644
--- a/packages/schemas/src/v3/index.schema.ts
+++ b/packages/schemas/src/v3/index.schema.ts
@@ -5394,7 +5394,10 @@ const schema = {
"const": "bitbucket-server"
},
"purpose": {
- "const": "sso"
+ "enum": [
+ "sso",
+ "account_linking"
+ ]
},
"clientId": {
"anyOf": [
@@ -5463,6 +5466,10 @@ const schema = {
"https://bitbucket.example.com"
],
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$"
+ },
+ "accountLinkingRequired": {
+ "type": "boolean",
+ "default": false
}
},
"required": [
@@ -6321,7 +6328,10 @@ const schema = {
"const": "bitbucket-server"
},
"purpose": {
- "const": "sso"
+ "enum": [
+ "sso",
+ "account_linking"
+ ]
},
"clientId": {
"anyOf": [
@@ -6390,6 +6400,10 @@ const schema = {
"https://bitbucket.example.com"
],
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$"
+ },
+ "accountLinkingRequired": {
+ "type": "boolean",
+ "default": false
}
},
"required": [
diff --git a/packages/schemas/src/v3/index.type.ts b/packages/schemas/src/v3/index.type.ts
index 8878a2c96..ac5c5d0c0 100644
--- a/packages/schemas/src/v3/index.type.ts
+++ b/packages/schemas/src/v3/index.type.ts
@@ -1496,7 +1496,7 @@ export interface BitbucketCloudIdentityProviderConfig {
}
export interface BitbucketServerIdentityProviderConfig {
provider: "bitbucket-server";
- purpose: "sso";
+ purpose: "sso" | "account_linking";
clientId:
| {
/**
@@ -1527,4 +1527,5 @@ export interface BitbucketServerIdentityProviderConfig {
* The URL of the Bitbucket Server/Data Center host.
*/
baseUrl: string;
+ accountLinkingRequired?: boolean;
}
diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts
index 4e69c42fc..51aa71c75 100644
--- a/packages/shared/src/types.ts
+++ b/packages/shared/src/types.ts
@@ -40,6 +40,10 @@ export const repoMetadataSchema = z.object({
workspace: z.string(),
repoSlug: z.string(),
}).optional(),
+ bitbucketServer: z.object({
+ projectKey: z.string(),
+ repoSlug: z.string(),
+ }).optional(),
}).optional(),
});
diff --git a/packages/web/src/ee/features/permissionSyncing/tokenRefresh.ts b/packages/web/src/ee/features/permissionSyncing/tokenRefresh.ts
index 60ffca09b..60ce7e955 100644
--- a/packages/web/src/ee/features/permissionSyncing/tokenRefresh.ts
+++ b/packages/web/src/ee/features/permissionSyncing/tokenRefresh.ts
@@ -1,6 +1,6 @@
import { loadConfig, decryptOAuthToken } from "@sourcebot/shared";
import { getTokenFromConfig, createLogger, env, encryptOAuthToken } from "@sourcebot/shared";
-import { BitbucketCloudIdentityProviderConfig, GitHubIdentityProviderConfig, GitLabIdentityProviderConfig } from "@sourcebot/schemas/v3/index.type";
+import { BitbucketCloudIdentityProviderConfig, BitbucketServerIdentityProviderConfig, GitHubIdentityProviderConfig, GitLabIdentityProviderConfig } from "@sourcebot/schemas/v3/index.type";
import { IdentityProviderType } from "@sourcebot/shared";
import { z } from 'zod';
import { prisma } from '@/prisma';
@@ -10,7 +10,8 @@ const logger = createLogger('web-ee-token-refresh');
const SUPPORTED_PROVIDERS = [
'github',
'gitlab',
- 'bitbucket-cloud'
+ 'bitbucket-cloud',
+ 'bitbucket-server',
] as const satisfies IdentityProviderType[];
type SupportedProvider = (typeof SUPPORTED_PROVIDERS)[number];
@@ -165,7 +166,8 @@ const refreshOAuthToken = async (
const linkedAccountProviderConfig = providerConfig as
GitHubIdentityProviderConfig |
GitLabIdentityProviderConfig |
- BitbucketCloudIdentityProviderConfig;
+ BitbucketCloudIdentityProviderConfig |
+ BitbucketServerIdentityProviderConfig;
// Get client credentials from config
const clientId = await getTokenFromConfig(linkedAccountProviderConfig.clientId);
@@ -216,9 +218,16 @@ const tryRefreshToken = async (
let url: string;
if (baseUrl) {
- url = provider === 'github'
- ? new URL('/login/oauth/access_token', baseUrl).toString()
- : new URL('/oauth/token', baseUrl).toString();
+ // Use a trailing-slash-normalized base so relative paths append correctly,
+ // preserving any context path (e.g. https://example.com/bitbucket/).
+ const base = baseUrl.endsWith('/') ? baseUrl : baseUrl + '/';
+ if (provider === 'github') {
+ url = new URL('login/oauth/access_token', base).toString();
+ } else if (provider === 'bitbucket-server') {
+ url = new URL('rest/oauth2/latest/token', base).toString();
+ } else {
+ url = new URL('oauth/token', base).toString();
+ }
} else if (provider === 'github') {
url = 'https://github.com/login/oauth/access_token';
} else if (provider === 'gitlab') {
diff --git a/packages/web/src/ee/features/sso/sso.ts b/packages/web/src/ee/features/sso/sso.ts
index 53761c3c5..83c5d1551 100644
--- a/packages/web/src/ee/features/sso/sso.ts
+++ b/packages/web/src/ee/features/sso/sso.ts
@@ -93,7 +93,7 @@ export const getEEIdentityProviders = async (): Promise => {
const clientId = await getTokenFromConfig(providerConfig.clientId);
const clientSecret = await getTokenFromConfig(providerConfig.clientSecret);
const baseUrl = providerConfig.baseUrl;
- providers.push({ provider: createBitbucketServerProvider(clientId, clientSecret, baseUrl), purpose: providerConfig.purpose });
+ providers.push({ provider: createBitbucketServerProvider(clientId, clientSecret, baseUrl), purpose: providerConfig.purpose, required: providerConfig.accountLinkingRequired ?? false });
}
}
@@ -266,7 +266,10 @@ const createBitbucketServerProvider = (clientId: string, clientSecret: string, b
response_type: "code",
// @see: https://confluence.atlassian.com/bitbucketserver/bitbucket-oauth-2-0-provider-api-1108483661.html
scope: [
- "PUBLIC_REPOS"
+ "PUBLIC_REPOS",
+ ...(env.EXPERIMENT_EE_PERMISSION_SYNC_ENABLED === 'true' && hasEntitlement('permission-syncing')
+ ? ['REPO_READ']
+ : []),
].join(' ')
},
},
diff --git a/schemas/v3/identityProvider.json b/schemas/v3/identityProvider.json
index b6b913066..0f288703a 100644
--- a/schemas/v3/identityProvider.json
+++ b/schemas/v3/identityProvider.json
@@ -224,7 +224,7 @@
"const": "bitbucket-server"
},
"purpose": {
- "const": "sso"
+ "enum": ["sso", "account_linking"]
},
"clientId": {
"$ref": "./shared.json#/definitions/Token"
@@ -237,6 +237,10 @@
"description": "The URL of the Bitbucket Server/Data Center host.",
"examples": ["https://bitbucket.example.com"],
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$"
+ },
+ "accountLinkingRequired": {
+ "type": "boolean",
+ "default": false
}
},
"required": ["provider", "purpose", "clientId", "clientSecret", "baseUrl"]