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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Added login wall when anonymous users try to send messages on duplicated chats (askgh experiment). [#939](https://github.com/sourcebot-dev/sourcebot/pull/939)
- Added `GET /api/ee/user` endpoint that returns the authenticated owner's user info (name, email, createdAt, updatedAt). [#940](https://github.com/sourcebot-dev/sourcebot/pull/940)
- Added `selectedReposCount` to the `wa_chat_message_sent` PostHog event to track the number of selected repositories when users ask questions. [#941](https://github.com/sourcebot-dev/sourcebot/pull/941)
- Added ability to re-sync repo permissions from the "linked accounts" settings page. [#945](https://github.com/sourcebot-dev/sourcebot/pull/945)

### Changed
- Hide version upgrade toast for askgithub deployment (`EXPERIMENT_ASK_GH_ENABLED`). [#931](https://github.com/sourcebot-dev/sourcebot/pull/931)
Expand Down
10 changes: 10 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,16 @@ export const GET = apiHandler(async (request: NextRequest) => {
});
```

## Docs Images

Images added to `.mdx` files in `docs/` should be wrapped in a `<Frame>` component:

```mdx
<Frame>
<img src="/images/my_image.png" alt="Description" />
</Frame>
```

## Branches and Pull Requests

When creating a branch or opening a PR, ask the user for:
Expand Down
2 changes: 1 addition & 1 deletion docs/docs/configuration/environment-variables.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ The following environment variables allow you to configure your Sourcebot deploy
| `AUTH_EE_GCP_IAP_ENABLED` | `false` | <p>When enabled, allows Sourcebot to automatically register/login from a successful GCP IAP redirect</p> |
| `AUTH_EE_GCP_IAP_AUDIENCE` | - | <p>The GCP IAP audience to use when verifying JWT tokens. Must be set to enable GCP IAP JIT provisioning</p> |
| `EXPERIMENT_EE_PERMISSION_SYNC_ENABLED` | `false` | <p>Enables [permission syncing](/docs/features/permission-syncing).</p> |
| `AUTH_EE_ALLOW_EMAIL_ACCOUNT_LINKING` | `false` | <p>When enabled, different SSO accounts with the same email address will automatically be linked.</p> |
| `AUTH_EE_ALLOW_EMAIL_ACCOUNT_LINKING` | `true` | <p>When enabled, different SSO accounts with the same email address will automatically be linked.</p> |


### Review Agent Environment Variables
Expand Down
24 changes: 19 additions & 5 deletions docs/docs/features/permission-syncing.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ We are actively working on supporting more code hosts. If you'd like to see a sp

# Getting started

## GitHub
### GitHub

Prerequisites:
- Configure a [GitHub connection](/docs/connections/github).
Expand All @@ -65,7 +65,7 @@ Permission syncing works with **GitHub.com**, **GitHub Enterprise Cloud**, and *
- 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
### GitLab

Prerequisites:
- Configure a [GitLab connection](/docs/connections/gitlab).
Expand All @@ -80,7 +80,7 @@ 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
### Bitbucket Cloud

Prerequisites:
- Configure a [Bitbucket Cloud connection](/docs/connections/bitbucket-cloud).
Expand All @@ -104,7 +104,7 @@ If your workspace relies heavily on group or project-level permissions rather th
- 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
### Bitbucket Data Center

Prerequisites:
- Configure a [Bitbucket Data Center connection](/docs/connections/bitbucket-data-center).
Expand Down Expand Up @@ -138,4 +138,18 @@ User driven and repo driven syncing occurs every 24 hours by default. These inte
| Setting | Type | Default | Minimum |
|-------------------------------------------------|---------|------------|---------|
| `experiment_repoDrivenPermissionSyncIntervalMs` | number | 24 hours | 1 |
| `experiment_userDrivenPermissionSyncIntervalMs` | number | 24 hours | 1 |
| `experiment_userDrivenPermissionSyncIntervalMs` | number | 24 hours | 1 |

## Manually refreshing permissions

If a user's permissions have changed and they need access updated immediately (without waiting for the next scheduled sync), they can trigger a manual refresh from the **Linked Accounts** page:

1. Navigate to **Settings → Linked Accounts**.
2. Click the **Connected** button next to the relevant code host account.
3. Select **Refresh Permissions** from the dropdown.

<Frame>
<img src="/images/linked_accounts_refresh_permissions.png" alt="Linked Accounts - Refresh Permissions" />
</Frame>

The button will show a spinner while the sync is in progress and display a confirmation once it completes.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
40 changes: 39 additions & 1 deletion packages/backend/src/api.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { PrismaClient, RepoIndexingJobType } from '@sourcebot/db';
import { createLogger } from '@sourcebot/shared';
import { createLogger, env, hasEntitlement, PERMISSION_SYNC_SUPPORTED_IDENTITY_PROVIDERS } from '@sourcebot/shared';
import express, { Request, Response } from 'express';
import 'express-async-errors';
import * as http from "http";
import z from 'zod';
import { ConnectionManager } from './connectionManager.js';
import { AccountPermissionSyncer } from './ee/accountPermissionSyncer.js';
import { PromClient } from './promClient.js';
import { RepoIndexManager } from './repoIndexManager.js';
import { createGitHubRepoRecord } from './repoCompileUtils.js';
Expand All @@ -22,6 +23,7 @@ export class Api {
private prisma: PrismaClient,
private connectionManager: ConnectionManager,
private repoIndexManager: RepoIndexManager,
private accountPermissionSyncer: AccountPermissionSyncer,
) {
const app = express();
app.use(express.json());
Expand All @@ -36,6 +38,7 @@ export class Api {

app.post('/api/sync-connection', this.syncConnection.bind(this));
app.post('/api/index-repo', this.indexRepo.bind(this));
app.post('/api/trigger-account-permission-sync', this.triggerAccountPermissionSync.bind(this));
app.post(`/api/experimental/add-github-repo`, this.experimental_addGithubRepo.bind(this));

this.server = app.listen(PORT, () => {
Expand Down Expand Up @@ -96,6 +99,41 @@ export class Api {
res.status(200).json({ jobId });
}

private async triggerAccountPermissionSync(req: Request, res: Response) {
if (env.EXPERIMENT_EE_PERMISSION_SYNC_ENABLED !== 'true' || !hasEntitlement('permission-syncing')) {
res.status(403).json({ error: 'Permission syncing is not enabled.' });
return;
}

const schema = z.object({
accountId: z.string(),
}).strict();

const parsed = schema.safeParse(req.body);
if (!parsed.success) {
res.status(400).json({ error: parsed.error.message });
return;
}

const { accountId } = parsed.data;
const account = await this.prisma.account.findUnique({
where: { id: accountId },
});

if (!account) {
res.status(404).json({ error: 'Account not found' });
return;
}

if (!PERMISSION_SYNC_SUPPORTED_IDENTITY_PROVIDERS.includes(account.provider as typeof PERMISSION_SYNC_SUPPORTED_IDENTITY_PROVIDERS[number])) {
res.status(400).json({ error: `Provider '${account.provider}' does not support permission syncing.` });
return;
}

const jobId = await this.accountPermissionSyncer.schedulePermissionSyncForAccount(account);
res.status(200).json({ jobId });
}

private async experimental_addGithubRepo(req: Request, res: Response) {
const schema = z.object({
owner: z.string(),
Expand Down
17 changes: 1 addition & 16 deletions packages/backend/src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,8 @@
import { CodeHostType } from "@sourcebot/db";
import { env, IdentityProviderType } from "@sourcebot/shared";
import { env } from "@sourcebot/shared";
import path from "path";

export const SINGLE_TENANT_ORG_ID = 1;

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');
export const INDEX_CACHE_DIR = path.join(env.DATA_CACHE_DIR, 'index');

Expand Down
19 changes: 17 additions & 2 deletions packages/backend/src/ee/accountPermissionSyncer.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,8 @@
import * as Sentry from "@sentry/node";
import { PrismaClient, AccountPermissionSyncJobStatus, Account, PermissionSyncSource} from "@sourcebot/db";
import { env, hasEntitlement, createLogger, loadConfig, decryptOAuthToken } from "@sourcebot/shared";
import { env, hasEntitlement, createLogger, loadConfig, decryptOAuthToken, PERMISSION_SYNC_SUPPORTED_IDENTITY_PROVIDERS } from "@sourcebot/shared";
import { Job, Queue, Worker } from "bullmq";
import { Redis } from "ioredis";
import { PERMISSION_SYNC_SUPPORTED_IDENTITY_PROVIDERS } from "../constants.js";
import {
createOctokitFromToken,
getOAuthScopesForAuthenticatedUser as getGitHubOAuthScopesForAuthenticatedUser,
Expand Down Expand Up @@ -116,6 +115,22 @@ export class AccountPermissionSyncer {
await this.queue.close();
}

public async schedulePermissionSyncForAccount(account: Account) {
const [job] = await this.db.accountPermissionSyncJob.createManyAndReturn({
data: [{ accountId: account.id }],
});

await this.queue.add('accountPermissionSyncJob', {
jobId: job.id,
}, {
removeOnComplete: env.REDIS_REMOVE_ON_COMPLETE,
removeOnFail: env.REDIS_REMOVE_ON_FAIL,
priority: 1,
});

return job.id;
}

private async schedulePermissionSync(accounts: Account[]) {
// @note: we don't perform this in a transaction because
// we want to avoid the situation where a job is created and run
Expand Down
3 changes: 1 addition & 2 deletions packages/backend/src/ee/repoPermissionSyncer.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import * as Sentry from "@sentry/node";
import { PermissionSyncSource, PrismaClient, Repo, RepoPermissionSyncJobStatus } from "@sourcebot/db";
import { createLogger } from "@sourcebot/shared";
import { createLogger, PERMISSION_SYNC_SUPPORTED_CODE_HOST_TYPES } from "@sourcebot/shared";
import { env, hasEntitlement } from "@sourcebot/shared";
import { Job, Queue, Worker } from 'bullmq';
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, createBitbucketServerClient, getExplicitUserPermissionsForCloudRepo, getUserPermissionsForServerRepo } from "../bitbucket.js";
Expand Down
1 change: 1 addition & 0 deletions packages/backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ const api = new Api(
prisma,
connectionManager,
repoIndexManager,
accountPermissionSyncer,
);

logger.info('Worker started.');
Expand Down
17 changes: 16 additions & 1 deletion packages/shared/src/constants.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ConfigSettings } from "./types.js";
import { CodeHostType } from "@sourcebot/db";
import { ConfigSettings, IdentityProviderType } from "./types.js";

export const SOURCEBOT_SUPPORT_EMAIL = 'team@sourcebot.dev';

Expand All @@ -25,3 +26,17 @@ export const DEFAULT_CONFIG_SETTINGS: ConfigSettings = {
maxAccountPermissionSyncJobConcurrency: 8,
maxRepoPermissionSyncJobConcurrency: 8,
}

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',
];
2 changes: 1 addition & 1 deletion packages/shared/src/env.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ export const env = createEnv({
// Enterprise Auth
AUTH_EE_ALLOW_EMAIL_ACCOUNT_LINKING:
booleanSchema
.default('false')
.default('true')
.describe('When enabled, different SSO accounts with the same email address will automatically be linked.'),

AUTH_EE_GCP_IAP_ENABLED: booleanSchema.default('false'),
Expand Down
37 changes: 13 additions & 24 deletions packages/web/src/app/[domain]/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,10 +23,11 @@ import { JoinOrganizationCard } from "@/app/components/joinOrganizationCard";
import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch";
import { GitHubStarToast } from "./components/githubStarToast";
import { UpgradeToast } from "./components/upgradeToast";
import { getLinkedAccountProviderStates } from "@/ee/features/permissionSyncing/actions";
import { LinkAccounts } from "@/ee/features/permissionSyncing/components/linkAccounts";
import { getLinkedAccounts } from "@/ee/features/sso/actions";
import { PermissionSyncBanner } from "./components/permissionSyncBanner";
import { getPermissionSyncStatus } from "../api/(server)/ee/permissionSyncStatus/api";
import { ServiceErrorException } from "@/lib/serviceError";
import { ConnectAccountsCard } from "@/ee/features/sso/components/connectAccountsCard";

interface LayoutProps {
children: React.ReactNode,
Expand Down Expand Up @@ -127,36 +128,24 @@ export default async function Layout(props: LayoutProps) {
)
}

if (session && hasEntitlement("permission-syncing")) {
const linkedAccountProviderStates = await getLinkedAccountProviderStates();
if (isServiceError(linkedAccountProviderStates)) {
return (
<div className="min-h-screen flex flex-col items-center justify-center p-6">
<LogoutEscapeHatch className="absolute top-0 right-0 p-6" />
<div className="bg-red-50 border border-red-200 rounded-md p-6 max-w-md w-full text-center">
<h2 className="text-lg font-semibold text-red-800 mb-2">An error occurred</h2>
<p className="text-red-700 mb-1">
{typeof linkedAccountProviderStates.message === 'string'
? linkedAccountProviderStates.message
: "A server error occurred while checking your account status. Please try again or contact support."}
</p>
</div>
</div>
)
if (session && hasEntitlement("sso")) {
const linkedAccounts = await getLinkedAccounts();
if (isServiceError(linkedAccounts)) {
throw new ServiceErrorException(linkedAccounts);
}

const hasUnlinkedProviders = linkedAccountProviderStates.some(state => state.isLinked === false);
if (hasUnlinkedProviders) {
// First, grab a list of all unlinked providers.
const unlinkedProviders = linkedAccounts.filter(a => !a.isLinked && a.isAccountLinkingProvider);
if (unlinkedProviders.length > 0) {
const cookieStore = await cookies();
const hasSkippedOptional = cookieStore.has(OPTIONAL_PROVIDERS_LINK_SKIPPED_COOKIE_NAME);

const hasUnlinkedRequiredProviders = linkedAccountProviderStates.some(state => state.required && !state.isLinked)
const shouldShowLinkAccounts = hasUnlinkedRequiredProviders || !hasSkippedOptional;
if (shouldShowLinkAccounts) {
const hasRequiredUnlinkedProviders = unlinkedProviders.some(a => a.required);
if (hasRequiredUnlinkedProviders || !hasSkippedOptional) {
return (
<div className="min-h-screen flex items-center justify-center p-6">
<LogoutEscapeHatch className="absolute top-0 right-0 p-6" />
<LinkAccounts linkedAccountProviderStates={linkedAccountProviderStates} callbackUrl={`/${domain}`} />
<ConnectAccountsCard linkedAccounts={linkedAccounts} callbackUrl={`/${domain}`} />
</div>
)
}
Expand Down
8 changes: 3 additions & 5 deletions packages/web/src/app/[domain]/settings/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,8 +68,6 @@ export default async function SettingsLayout(
throw new ServiceErrorException(connectionStats);
}

const hasPermissionSyncingEntitlement = hasEntitlement("permission-syncing");

const sidebarNavItems: SidebarNavItem[] = [
{
title: "General",
Expand All @@ -88,7 +86,7 @@ export default async function SettingsLayout(
}
] : []),
...(userRoleInOrg === OrgRole.OWNER ? [{
title:"Members",
title: "Members",
isNotificationDotVisible: numJoinRequests !== undefined && numJoinRequests > 0,
href: `/${domain}/settings/members`,
}] : []),
Expand All @@ -108,10 +106,10 @@ export default async function SettingsLayout(
title: "Analytics",
href: `/${domain}/settings/analytics`,
},
...(hasPermissionSyncingEntitlement ? [
...(hasEntitlement("sso") ? [
{
title: "Linked Accounts",
href: `/${domain}/settings/permission-syncing`,
href: `/${domain}/settings/linked-accounts`,
}
] : []),
{
Expand Down
Loading