diff --git a/packages/backend/src/ee/permissionUtils.ts b/packages/backend/src/ee/permissionUtils.ts new file mode 100644 index 000000000..4ea5f8126 --- /dev/null +++ b/packages/backend/src/ee/permissionUtils.ts @@ -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 { + 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 { + 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`); +} diff --git a/packages/backend/src/ee/repoPermissionSyncer.ts b/packages/backend/src/ee/repoPermissionSyncer.ts index eb9edc67c..53bab8e44 100644 --- a/packages/backend/src/ee/repoPermissionSyncer.ts +++ b/packages/backend/src/ee/repoPermissionSyncer.ts @@ -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"; @@ -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({ @@ -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, @@ -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([ @@ -242,7 +260,8 @@ export class RepoPermissionSyncer { data: { permittedAccounts: { deleteMany: {}, - } + }, + cachedPermittedExternalAccounts: cachedExternalAccounts, } }), this.db.accountToRepoPermission.createMany({ diff --git a/packages/db/prisma/migrations/20260202004440_add_cached_permitted_external_accounts_to_repo/migration.sql b/packages/db/prisma/migrations/20260202004440_add_cached_permitted_external_accounts_to_repo/migration.sql new file mode 100644 index 000000000..05d46cde7 --- /dev/null +++ b/packages/db/prisma/migrations/20260202004440_add_cached_permitted_external_accounts_to_repo/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "Repo" ADD COLUMN "cachedPermittedExternalAccounts" JSONB; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 423fb5960..7c11033c1 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -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. diff --git a/packages/shared/src/index.server.ts b/packages/shared/src/index.server.ts index 1002400b3..42190435e 100644 --- a/packages/shared/src/index.server.ts +++ b/packages/shared/src/index.server.ts @@ -12,10 +12,12 @@ export type { export type { RepoMetadata, RepoIndexingJobMetadata, + CachedPermittedExternalAccounts, } from "./types.js"; export { repoMetadataSchema, repoIndexingJobMetadataSchema, + cachedPermittedExternalAccountsSchema, tenancyModeSchema, } from "./types.js"; export { diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 0d5fb5209..ba0bec33a 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -44,4 +44,17 @@ export const repoIndexingJobMetadataSchema = z.object({ export type RepoIndexingJobMetadata = z.infer; +// 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; + export const tenancyModeSchema = z.enum(["multi", "single"]); \ No newline at end of file diff --git a/packages/web/src/auth.ts b/packages/web/src/auth.ts index b1f9c720b..d94291150 100644 --- a/packages/web/src/auth.ts +++ b/packages/web/src/auth.ts @@ -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() : []; @@ -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, @@ -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) { diff --git a/packages/web/src/ee/features/permissionSyncing/permissionUtils.ts b/packages/web/src/ee/features/permissionSyncing/permissionUtils.ts new file mode 100644 index 000000000..535d12676 --- /dev/null +++ b/packages/web/src/ee/features/permissionSyncing/permissionUtils.ts @@ -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 { + 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}`); +}