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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added `wa_user_created` PostHog event fired on successful user sign-up. [#933](https://github.com/sourcebot-dev/sourcebot/pull/933)
- 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 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)
Expand Down
9 changes: 7 additions & 2 deletions docs/docs/configuration/idp.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,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.

<Accordion title="instructions">
<Steps>
Expand All @@ -231,6 +232,7 @@ A Bitbucket Server (Data Center) connection can be used for [authentication](/do

When configuring your application:
- Set the redirect URL to `<sourcebot_url>/api/auth/callback/bitbucket-server` (ex. https://sourcebot.coolcorp.com/api/auth/callback/bitbucket-server)
- If using for permission syncing, ensure the OAuth application requests the `REPO_READ` scope

The result of creating the application is a `CLIENT_ID` and `CLIENT_SECRET` which you'll provide to Sourcebot.
</Step>
Expand All @@ -247,7 +249,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"
Expand Down
18 changes: 16 additions & 2 deletions docs/snippets/schemas/v3/identityProvider.schema.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -850,7 +850,10 @@
"const": "bitbucket-server"
},
"purpose": {
"const": "sso"
"enum": [
"sso",
"account_linking"
]
},
"clientId": {
"anyOf": [
Expand Down Expand Up @@ -919,6 +922,10 @@
"https://bitbucket.example.com"
],
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$"
},
"accountLinkingRequired": {
"type": "boolean",
"default": false
}
},
"required": [
Expand Down Expand Up @@ -1777,7 +1784,10 @@
"const": "bitbucket-server"
},
"purpose": {
"const": "sso"
"enum": [
"sso",
"account_linking"
]
},
"clientId": {
"anyOf": [
Expand Down Expand Up @@ -1846,6 +1856,10 @@
"https://bitbucket.example.com"
],
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$"
},
"accountLinkingRequired": {
"type": "boolean",
"default": false
}
},
"required": [
Expand Down
18 changes: 16 additions & 2 deletions docs/snippets/schemas/v3/index.schema.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -5390,7 +5390,10 @@
"const": "bitbucket-server"
},
"purpose": {
"const": "sso"
"enum": [
"sso",
"account_linking"
]
},
"clientId": {
"anyOf": [
Expand Down Expand Up @@ -5459,6 +5462,10 @@
"https://bitbucket.example.com"
],
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$"
},
"accountLinkingRequired": {
"type": "boolean",
"default": false
}
},
"required": [
Expand Down Expand Up @@ -6317,7 +6324,10 @@
"const": "bitbucket-server"
},
"purpose": {
"const": "sso"
"enum": [
"sso",
"account_linking"
]
},
"clientId": {
"anyOf": [
Expand Down Expand Up @@ -6386,6 +6396,10 @@
"https://bitbucket.example.com"
],
"pattern": "^https?:\\/\\/[^\\s/$.?#].[^\\s]*$"
},
"accountLinkingRequired": {
"type": "boolean",
"default": false
}
},
"required": [
Expand Down
162 changes: 132 additions & 30 deletions packages/backend/src/bitbucket.ts
Original file line number Diff line number Diff line change
Expand Up @@ -248,26 +248,29 @@ async function cloudGetReposForProjects(client: BitbucketClient, projects: strin

logger.debug(`Fetching all repos for project ${project} for workspace ${workspace}...`);
try {
const repos = await getPaginatedCloud<CloudRepository>(`/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<CloudRepository>(`/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}: ${error.type}`);
Comment thread
brendan-kellam marked this conversation as resolved.
Outdated
}
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
Expand Down Expand Up @@ -312,11 +315,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}: ${error.type}`);
}
return data;
}, `repo ${repo}`, logger);
return {
type: 'valid' as const,
data: [data]
Expand Down Expand Up @@ -379,7 +385,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) {
Expand Down Expand Up @@ -520,11 +526,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}: ${error.type}`);
}
return data;
}, `repo ${repo}`, logger);
return {
type: 'valid' as const,
data: [data]
Expand Down Expand Up @@ -609,7 +618,7 @@ export const getExplicitUserPermissionsForCloudRepo = async (
): Promise<Array<{ accountId: string }>> => {
const path = `/repositories/${workspace}/${repoSlug}/permissions-config/users` as CloudGetRequestPath;

const users = await getPaginatedCloud<CloudRepositoryUserPermission>(path, async (p, query) => {
const users = await fetchWithRetry(() => getPaginatedCloud<CloudRepositoryUserPermission>(path, async (p, query) => {
const response = await client.apiClient.GET(p, {
params: {
path: { workspace, repo_slug: repoSlug },
Expand All @@ -621,7 +630,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)
Expand All @@ -639,7 +648,7 @@ export const getReposForAuthenticatedBitbucketCloudUser = async (
): Promise<Array<{ uuid: string }>> => {
const path = `/user/permissions/repositories` as CloudGetRequestPath;

const permissions = await getPaginatedCloud<CloudRepositoryPermission>(path, async (p, query) => {
const permissions = await fetchWithRetry(() => getPaginatedCloud<CloudRepositoryPermission>(path, async (p, query) => {
const response = await client.apiClient.GET(p, {
params: { query },
});
Expand All @@ -648,9 +657,102 @@ 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<Array<{ id: string }>> => {
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 permission on a Bitbucket Server repository
* at the repo level (direct grants) or project level (inherited by all repos in the project).
*
* @note This does NOT include users who have access via groups. As a result, permission syncing
* may under-grant access for instances that rely heavily on group-level permissions. Those 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
* @see https://developer.atlassian.com/server/bitbucket/rest/v906/api-group-project/#api-rest-api-latest-projects-projectkey-permissions-users-get
*/
export const getUserPermissionsForServerRepo = async (
client: BitbucketClient,
projectKey: string,
repoSlug: string,
): Promise<Array<{ userId: string }>> => {
const userIdSet = new Set<string>();

// Fetch repo-level permissions
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);
for (const entry of repoUsers) {
if (entry.user?.id != null) {
userIdSet.add(String(entry.user.id));
}
}

// Fetch project-level permissions (inherited by all repos in the project)
const projectUsers = await fetchWithRetry(() => getPaginatedServer<{ user: { id: number } }>(
`/rest/api/1.0/projects/${projectKey}/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 project-level permissions for ${projectKey}: ${JSON.stringify(error)}`);
}
return data;
}
), `project-level permissions for ${projectKey}`, logger);
for (const entry of projectUsers) {
if (entry.user?.id != null) {
userIdSet.add(String(entry.user.id));
}
}

return Array.from(userIdSet).map(userId => ({ userId }));
};
2 changes: 2 additions & 0 deletions packages/backend/src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
Loading
Loading