Skip to content
Draft
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
107 changes: 107 additions & 0 deletions packages/backend/src/ee/permissionUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
import { PrismaClient } from "@sourcebot/db";
import { CachedPermittedExternalAccounts, cachedPermittedExternalAccountsSchema, createLogger } from "@sourcebot/shared";

const logger = createLogger('permission-utils');

/**
* Rebuilds the AccountToRepoPermission join table for a given account
* based on the cached external account IDs stored in repos.
*
* This is useful when a new account is created and we want to grant
* access to repos without waiting for a full permission sync.
*
* @param db - Prisma client instance
* @param accountId - The internal account ID
* @param provider - The OAuth provider (e.g., 'github', 'gitlab')
* @param providerAccountId - The external account ID from the provider
*/
export async function rebuildPermissionsFromCache(
db: PrismaClient,
accountId: string,
provider: string,
providerAccountId: string
): Promise<void> {
logger.info(`Rebuilding permissions from cache for account ${accountId} (${provider}:${providerAccountId})`);

// Find all repos that have this external account ID in their cached permissions
const repos = await db.repo.findMany({
where: {
cachedPermittedExternalAccounts: {
not: null,
},
},
select: {
id: true,
cachedPermittedExternalAccounts: true,
},
});

// Filter repos that include this specific external account ID for this provider
const reposWithAccess = repos.filter(repo => {
try {
const cached = cachedPermittedExternalAccountsSchema.parse(
repo.cachedPermittedExternalAccounts
);

const providerAccountIds = cached[provider as keyof CachedPermittedExternalAccounts];
return providerAccountIds?.includes(providerAccountId) ?? false;
} catch (error) {
logger.warn(`Failed to parse cachedPermittedExternalAccounts for repo ${repo.id}:`, error);
return false;
}
});

if (reposWithAccess.length === 0) {
logger.info(`No repos found with cached permissions for account ${accountId}`);
return;
}

// Create AccountToRepoPermission entries
await db.accountToRepoPermission.createMany({
data: reposWithAccess.map(repo => ({
accountId,
repoId: repo.id,
})),
skipDuplicates: true,
});

logger.info(`Rebuilt permissions for ${reposWithAccess.length} repos for account ${accountId}`);
}

/**
* Synchronizes permissions for all existing accounts based on cached external account IDs.
*
* This can be used as a migration script or maintenance task to ensure the join table
* is in sync with the cached data.
*
* @param db - Prisma client instance
*/
export async function syncAllPermissionsFromCache(db: PrismaClient): Promise<void> {
logger.info('Starting full permission sync from cache');

const accounts = await db.account.findMany({
select: {
id: true,
provider: true,
providerAccountId: true,
},
});

let totalUpdated = 0;

for (const account of accounts) {
try {
await rebuildPermissionsFromCache(
db,
account.id,
account.provider,
account.providerAccountId
);
totalUpdated++;
} catch (error) {
logger.error(`Failed to rebuild permissions for account ${account.id}:`, error);
}
}

logger.info(`Completed full permission sync from cache. Updated ${totalUpdated}/${accounts.length} accounts`);
}
29 changes: 24 additions & 5 deletions packages/backend/src/ee/repoPermissionSyncer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as Sentry from "@sentry/node";
import { PrismaClient, Repo, RepoPermissionSyncJobStatus } from "@sourcebot/db";
import { createLogger } from "@sourcebot/shared";
import { env, hasEntitlement } from "@sourcebot/shared";
import { CachedPermittedExternalAccounts } from "@sourcebot/shared";
import { Job, Queue, Worker } from 'bullmq';
import { Redis } from 'ioredis';
import { PERMISSION_SYNC_SUPPORTED_CODE_HOST_TYPES } from "../constants.js";
Expand Down Expand Up @@ -175,7 +176,11 @@ export class RepoPermissionSyncer {
throw new Error(`No credentials found for repo ${id}`);
}

const accountIds = await (async () => {
// Fetch the external account IDs and map them to internal account IDs
const { accountIds, cachedExternalAccounts } = await (async (): Promise<{
accountIds: string[];
cachedExternalAccounts: CachedPermittedExternalAccounts;
}> => {
if (repo.external_codeHostType === 'github') {
const isGitHubCloud = credentials.hostUrl ? new URL(credentials.hostUrl).hostname === GITHUB_CLOUD_HOSTNAME : true;
const { octokit } = await createOctokitFromToken({
Expand Down Expand Up @@ -204,7 +209,12 @@ export class RepoPermissionSyncer {
},
});

return accounts.map(account => account.id);
return {
accountIds: accounts.map(account => account.id),
cachedExternalAccounts: {
github: githubUserIds,
},
};
} else if (repo.external_codeHostType === 'gitlab') {
const api = await createGitLabFromPersonalAccessToken({
token: credentials.token,
Expand All @@ -228,10 +238,18 @@ export class RepoPermissionSyncer {
},
});

return accounts.map(account => account.id);
return {
accountIds: accounts.map(account => account.id),
cachedExternalAccounts: {
gitlab: gitlabUserIds,
},
};
}

return [];
return {
accountIds: [],
cachedExternalAccounts: {},
};
})();

await this.db.$transaction([
Expand All @@ -242,7 +260,8 @@ export class RepoPermissionSyncer {
data: {
permittedAccounts: {
deleteMany: {},
}
},
cachedPermittedExternalAccounts: cachedExternalAccounts,
}
}),
this.db.accountToRepoPermission.createMany({
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Repo" ADD COLUMN "cachedPermittedExternalAccounts" JSONB;
1 change: 1 addition & 0 deletions packages/db/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ model Repo {
permittedAccounts AccountToRepoPermission[]
permissionSyncJobs RepoPermissionSyncJob[]
permissionSyncedAt DateTime? /// When the permissions were last synced successfully.
cachedPermittedExternalAccounts Json? /// Cached mapping of provider -> external account IDs that have access to this repo. For schema see cachedPermittedExternalAccountsSchema in packages/shared/src/types.ts

jobs RepoIndexingJob[]
indexedAt DateTime? /// When the repo was last indexed successfully.
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/index.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,12 @@ export type {
export type {
RepoMetadata,
RepoIndexingJobMetadata,
CachedPermittedExternalAccounts,
} from "./types.js";
export {
repoMetadataSchema,
repoIndexingJobMetadataSchema,
cachedPermittedExternalAccountsSchema,
tenancyModeSchema,
} from "./types.js";
export {
Expand Down
13 changes: 13 additions & 0 deletions packages/shared/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,4 +44,17 @@ export const repoIndexingJobMetadataSchema = z.object({

export type RepoIndexingJobMetadata = z.infer<typeof repoIndexingJobMetadataSchema>;

// Structure of the `cachedPermittedExternalAccounts` field in the `Repo` table.
//
// @WARNING: If you modify this schema, please make sure it is backwards
// compatible with any prior versions of the schema!!
// @NOTE: If you move this schema, please update the comment in schema.prisma
// to point to the new location.
export const cachedPermittedExternalAccountsSchema = z.record(
z.enum(["github", "gitlab", "gitea", "gerrit", "bitbucket", "azuredevops"]),
z.array(z.string())
);

export type CachedPermittedExternalAccounts = z.infer<typeof cachedPermittedExternalAccountsSchema>;

export const tenancyModeSchema = z.enum(["multi", "single"]);
17 changes: 15 additions & 2 deletions packages/web/src/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { onCreateUser } from '@/lib/authUtils';
import { getAuditService } from '@/ee/features/audit/factory';
import { SINGLE_TENANT_ORG_ID } from './lib/constants';
import { refreshLinkedAccountTokens } from '@/ee/features/permissionSyncing/tokenRefresh';
import { rebuildPermissionsFromCache } from '@/ee/features/permissionSyncing/permissionUtils';

const auditService = getAuditService();
const eeIdentityProviders = hasEntitlement("sso") ? await getEEIdentityProviders() : [];
Expand Down Expand Up @@ -165,7 +166,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
// This is necessary to update the access token when the user
// re-authenticates.
if (account && account.provider && account.provider !== 'credentials' && account.providerAccountId) {
await prisma.account.update({
const updatedAccount = await prisma.account.update({
where: {
provider_providerAccountId: {
provider: account.provider,
Expand All @@ -180,7 +181,19 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
scope: account.scope,
id_token: account.id_token,
}
})
});

// Rebuild permissions from cache if permission syncing is enabled
if (hasEntitlement('permission-syncing') && env.EXPERIMENT_EE_PERMISSION_SYNC_ENABLED === 'true') {
await rebuildPermissionsFromCache(
updatedAccount.id,
account.provider,
account.providerAccountId
).catch(error => {
// Don't fail sign-in if permission rebuild fails
console.error('Failed to rebuild permissions from cache:', error);
});
}
}

if (user.id) {
Expand Down
69 changes: 69 additions & 0 deletions packages/web/src/ee/features/permissionSyncing/permissionUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"use server";

import { prisma } from "@/prisma";
import { CachedPermittedExternalAccounts, cachedPermittedExternalAccountsSchema, createLogger } from "@sourcebot/shared";

const logger = createLogger('permission-utils');

/**
* Rebuilds the AccountToRepoPermission join table for a given account
* based on the cached external account IDs stored in repos.
*
* This is useful when a new account is created and we want to grant
* access to repos without waiting for a full permission sync.
*
* @param accountId - The internal account ID
* @param provider - The OAuth provider (e.g., 'github', 'gitlab')
* @param providerAccountId - The external account ID from the provider
*/
export async function rebuildPermissionsFromCache(
accountId: string,
provider: string,
providerAccountId: string
): Promise<void> {
logger.info(`Rebuilding permissions from cache for account ${accountId} (${provider}:${providerAccountId})`);

// Find all repos that have this external account ID in their cached permissions
const repos = await prisma.repo.findMany({
where: {
cachedPermittedExternalAccounts: {
not: null,
},
},
select: {
id: true,
cachedPermittedExternalAccounts: true,
},
});

// Filter repos that include this specific external account ID for this provider
const reposWithAccess = repos.filter(repo => {
try {
const cached = cachedPermittedExternalAccountsSchema.parse(
repo.cachedPermittedExternalAccounts
);

const providerAccountIds = cached[provider as keyof CachedPermittedExternalAccounts];
return providerAccountIds?.includes(providerAccountId) ?? false;
} catch (error) {
logger.warn(`Failed to parse cachedPermittedExternalAccounts for repo ${repo.id}:`, error);
return false;
}
});

if (reposWithAccess.length === 0) {
logger.info(`No repos found with cached permissions for account ${accountId}`);
return;
}

// Create AccountToRepoPermission entries
await prisma.accountToRepoPermission.createMany({
data: reposWithAccess.map(repo => ({
accountId,
repoId: repo.id,
})),
skipDuplicates: true,
});

logger.info(`Rebuilt permissions for ${reposWithAccess.length} repos for account ${accountId}`);
}
Loading