Skip to content
Merged
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
1 change: 0 additions & 1 deletion docs/docs/configuration/environment-variables.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ 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> |


### Review Agent Environment Variables
Expand Down
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 });
Comment thread
brendan-kellam marked this conversation as resolved.
}

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',
];
14 changes: 9 additions & 5 deletions packages/shared/src/env.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,11 +140,6 @@ export const env = createEnv({
AUTH_CREDENTIALS_LOGIN_ENABLED: booleanSchema.default('true'),
AUTH_EMAIL_CODE_LOGIN_ENABLED: booleanSchema.default('false'),

// Enterprise Auth
AUTH_EE_ALLOW_EMAIL_ACCOUNT_LINKING:
booleanSchema
.default('false')
.describe('When enabled, different SSO accounts with the same email address will automatically be linked.'),

AUTH_EE_GCP_IAP_ENABLED: booleanSchema.default('false'),
AUTH_EE_GCP_IAP_AUDIENCE: z.string().optional(),
Expand Down Expand Up @@ -289,6 +284,15 @@ export const env = createEnv({

//// DEPRECATED ////


/**
* @deprecated This setting is deprecated. Email account linking is now always enabled.
*/
AUTH_EE_ALLOW_EMAIL_ACCOUNT_LINKING:
booleanSchema
.default('false')
.describe('This setting is deprecated. Email account linking is now always enabled.'),

/**
* @deprecated This setting is deprecated. Please use the `identityProviders` section of the config file instead.
*/
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
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
import { ShieldCheck } from "lucide-react";
import { getLinkedAccountProviderStates } from "@/ee/features/permissionSyncing/actions"
import { Card, CardContent } from "@/components/ui/card";
import { LinkedAccountProviderCard } from "./linkedAccountProviderCard";
import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch";
import { getLinkedAccounts } from "@/ee/features/sso/actions";
import { isServiceError } from "@/lib/utils";
import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch";
import { ShieldCheck } from "lucide-react";
import { LinkedAccountProviderCard } from "@/ee/features/sso/components/linkedAccountProviderCard";

export async function LinkedAccountsSettings() {
const linkedAccountProviderStates = await getLinkedAccountProviderStates();
if (isServiceError(linkedAccountProviderStates)) {
export default async function LinkedAccountsPage() {
const linkedAccounts = await getLinkedAccounts();
if (isServiceError(linkedAccounts)) {
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
{typeof linkedAccounts.message === 'string'
? linkedAccounts.message
: "A server error occurred while checking your account status. Please try again or contact support."}
</p>
</div>
Expand All @@ -30,30 +30,28 @@ export async function LinkedAccountsSettings() {
</p>
</div>

{linkedAccountProviderStates.length === 0 ? (
{linkedAccounts.length === 0 ? (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12 text-center">
<div className="rounded-full bg-muted p-3 mb-4">
<ShieldCheck className="h-6 w-6 text-muted-foreground" />
</div>
<p className="text-sm font-medium text-foreground mb-1">No linked accounts configured</p>
<p className="text-sm font-medium text-foreground mb-1">No linked accounts</p>
<p className="text-sm text-muted-foreground max-w-sm">
Contact your administrator to configure linked account providers for your organization.
Sign in with an OAuth provider to see your linked accounts here.
</p>
</CardContent>
</Card>
) : (
<div className="space-y-4">
{linkedAccountProviderStates
{linkedAccounts
.sort((a, b) => (b.required ? 1 : 0) - (a.required ? 1 : 0))
.map((state) => {
return (
<LinkedAccountProviderCard
key={state.id}
linkedAccountProviderState={state}
/>
);
})}
.map((account) => (
<LinkedAccountProviderCard
key={account.provider}
linkedAccount={account}
/>
))}
</div>
)}
</div>
Expand Down
12 changes: 0 additions & 12 deletions packages/web/src/app/[domain]/settings/permission-syncing/page.tsx

This file was deleted.

Loading