Skip to content

Commit 682a44d

Browse files
feat: add cached external account IDs to Repo table for multi-provider support
- Add cachedPermittedExternalAccounts JSON field to Repo table - Update repoPermissionSyncer to cache external account IDs during sync - Add TypeScript schema for cached external accounts structure - Create utility functions to rebuild join table from cached data - Integrate permission rebuild into auth flow for new account linking - Support multiple provider types (GitHub, GitLab, etc.) Co-authored-by: brendan <brendan@sourcebot.dev>
1 parent 6b79cbf commit 682a44d

8 files changed

Lines changed: 233 additions & 7 deletions

File tree

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
import { PrismaClient } from "@sourcebot/db";
2+
import { CachedPermittedExternalAccounts, cachedPermittedExternalAccountsSchema, createLogger } from "@sourcebot/shared";
3+
4+
const logger = createLogger('permission-utils');
5+
6+
/**
7+
* Rebuilds the AccountToRepoPermission join table for a given account
8+
* based on the cached external account IDs stored in repos.
9+
*
10+
* This is useful when a new account is created and we want to grant
11+
* access to repos without waiting for a full permission sync.
12+
*
13+
* @param db - Prisma client instance
14+
* @param accountId - The internal account ID
15+
* @param provider - The OAuth provider (e.g., 'github', 'gitlab')
16+
* @param providerAccountId - The external account ID from the provider
17+
*/
18+
export async function rebuildPermissionsFromCache(
19+
db: PrismaClient,
20+
accountId: string,
21+
provider: string,
22+
providerAccountId: string
23+
): Promise<void> {
24+
logger.info(`Rebuilding permissions from cache for account ${accountId} (${provider}:${providerAccountId})`);
25+
26+
// Find all repos that have this external account ID in their cached permissions
27+
const repos = await db.repo.findMany({
28+
where: {
29+
cachedPermittedExternalAccounts: {
30+
not: null,
31+
},
32+
},
33+
select: {
34+
id: true,
35+
cachedPermittedExternalAccounts: true,
36+
},
37+
});
38+
39+
// Filter repos that include this specific external account ID for this provider
40+
const reposWithAccess = repos.filter(repo => {
41+
try {
42+
const cached = cachedPermittedExternalAccountsSchema.parse(
43+
repo.cachedPermittedExternalAccounts
44+
);
45+
46+
const providerAccountIds = cached[provider as keyof CachedPermittedExternalAccounts];
47+
return providerAccountIds?.includes(providerAccountId) ?? false;
48+
} catch (error) {
49+
logger.warn(`Failed to parse cachedPermittedExternalAccounts for repo ${repo.id}:`, error);
50+
return false;
51+
}
52+
});
53+
54+
if (reposWithAccess.length === 0) {
55+
logger.info(`No repos found with cached permissions for account ${accountId}`);
56+
return;
57+
}
58+
59+
// Create AccountToRepoPermission entries
60+
await db.accountToRepoPermission.createMany({
61+
data: reposWithAccess.map(repo => ({
62+
accountId,
63+
repoId: repo.id,
64+
})),
65+
skipDuplicates: true,
66+
});
67+
68+
logger.info(`Rebuilt permissions for ${reposWithAccess.length} repos for account ${accountId}`);
69+
}
70+
71+
/**
72+
* Synchronizes permissions for all existing accounts based on cached external account IDs.
73+
*
74+
* This can be used as a migration script or maintenance task to ensure the join table
75+
* is in sync with the cached data.
76+
*
77+
* @param db - Prisma client instance
78+
*/
79+
export async function syncAllPermissionsFromCache(db: PrismaClient): Promise<void> {
80+
logger.info('Starting full permission sync from cache');
81+
82+
const accounts = await db.account.findMany({
83+
select: {
84+
id: true,
85+
provider: true,
86+
providerAccountId: true,
87+
},
88+
});
89+
90+
let totalUpdated = 0;
91+
92+
for (const account of accounts) {
93+
try {
94+
await rebuildPermissionsFromCache(
95+
db,
96+
account.id,
97+
account.provider,
98+
account.providerAccountId
99+
);
100+
totalUpdated++;
101+
} catch (error) {
102+
logger.error(`Failed to rebuild permissions for account ${account.id}:`, error);
103+
}
104+
}
105+
106+
logger.info(`Completed full permission sync from cache. Updated ${totalUpdated}/${accounts.length} accounts`);
107+
}

packages/backend/src/ee/repoPermissionSyncer.ts

Lines changed: 24 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import * as Sentry from "@sentry/node";
22
import { PrismaClient, Repo, RepoPermissionSyncJobStatus } from "@sourcebot/db";
33
import { createLogger } from "@sourcebot/shared";
44
import { env, hasEntitlement } from "@sourcebot/shared";
5+
import { CachedPermittedExternalAccounts } from "@sourcebot/shared";
56
import { Job, Queue, Worker } from 'bullmq';
67
import { Redis } from 'ioredis';
78
import { PERMISSION_SYNC_SUPPORTED_CODE_HOST_TYPES } from "../constants.js";
@@ -175,7 +176,11 @@ export class RepoPermissionSyncer {
175176
throw new Error(`No credentials found for repo ${id}`);
176177
}
177178

178-
const accountIds = await (async () => {
179+
// Fetch the external account IDs and map them to internal account IDs
180+
const { accountIds, cachedExternalAccounts } = await (async (): Promise<{
181+
accountIds: string[];
182+
cachedExternalAccounts: CachedPermittedExternalAccounts;
183+
}> => {
179184
if (repo.external_codeHostType === 'github') {
180185
const isGitHubCloud = credentials.hostUrl ? new URL(credentials.hostUrl).hostname === GITHUB_CLOUD_HOSTNAME : true;
181186
const { octokit } = await createOctokitFromToken({
@@ -204,7 +209,12 @@ export class RepoPermissionSyncer {
204209
},
205210
});
206211

207-
return accounts.map(account => account.id);
212+
return {
213+
accountIds: accounts.map(account => account.id),
214+
cachedExternalAccounts: {
215+
github: githubUserIds,
216+
},
217+
};
208218
} else if (repo.external_codeHostType === 'gitlab') {
209219
const api = await createGitLabFromPersonalAccessToken({
210220
token: credentials.token,
@@ -228,10 +238,18 @@ export class RepoPermissionSyncer {
228238
},
229239
});
230240

231-
return accounts.map(account => account.id);
241+
return {
242+
accountIds: accounts.map(account => account.id),
243+
cachedExternalAccounts: {
244+
gitlab: gitlabUserIds,
245+
},
246+
};
232247
}
233248

234-
return [];
249+
return {
250+
accountIds: [],
251+
cachedExternalAccounts: {},
252+
};
235253
})();
236254

237255
await this.db.$transaction([
@@ -242,7 +260,8 @@ export class RepoPermissionSyncer {
242260
data: {
243261
permittedAccounts: {
244262
deleteMany: {},
245-
}
263+
},
264+
cachedPermittedExternalAccounts: cachedExternalAccounts,
246265
}
247266
}),
248267
this.db.accountToRepoPermission.createMany({
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
-- AlterTable
2+
ALTER TABLE "Repo" ADD COLUMN "cachedPermittedExternalAccounts" JSONB;

packages/db/prisma/schema.prisma

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ model Repo {
6464
permittedAccounts AccountToRepoPermission[]
6565
permissionSyncJobs RepoPermissionSyncJob[]
6666
permissionSyncedAt DateTime? /// When the permissions were last synced successfully.
67+
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
6768
6869
jobs RepoIndexingJob[]
6970
indexedAt DateTime? /// When the repo was last indexed successfully.

packages/shared/src/index.server.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,12 @@ export type {
1212
export type {
1313
RepoMetadata,
1414
RepoIndexingJobMetadata,
15+
CachedPermittedExternalAccounts,
1516
} from "./types.js";
1617
export {
1718
repoMetadataSchema,
1819
repoIndexingJobMetadataSchema,
20+
cachedPermittedExternalAccountsSchema,
1921
tenancyModeSchema,
2022
} from "./types.js";
2123
export {

packages/shared/src/types.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,4 +44,17 @@ export const repoIndexingJobMetadataSchema = z.object({
4444

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

47+
// Structure of the `cachedPermittedExternalAccounts` field in the `Repo` table.
48+
//
49+
// @WARNING: If you modify this schema, please make sure it is backwards
50+
// compatible with any prior versions of the schema!!
51+
// @NOTE: If you move this schema, please update the comment in schema.prisma
52+
// to point to the new location.
53+
export const cachedPermittedExternalAccountsSchema = z.record(
54+
z.enum(["github", "gitlab", "gitea", "gerrit", "bitbucket", "azuredevops"]),
55+
z.array(z.string())
56+
);
57+
58+
export type CachedPermittedExternalAccounts = z.infer<typeof cachedPermittedExternalAccountsSchema>;
59+
4760
export const tenancyModeSchema = z.enum(["multi", "single"]);

packages/web/src/auth.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { onCreateUser } from '@/lib/authUtils';
1919
import { getAuditService } from '@/ee/features/audit/factory';
2020
import { SINGLE_TENANT_ORG_ID } from './lib/constants';
2121
import { refreshLinkedAccountTokens } from '@/ee/features/permissionSyncing/tokenRefresh';
22+
import { rebuildPermissionsFromCache } from '@/ee/features/permissionSyncing/permissionUtils';
2223

2324
const auditService = getAuditService();
2425
const eeIdentityProviders = hasEntitlement("sso") ? await getEEIdentityProviders() : [];
@@ -165,7 +166,7 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
165166
// This is necessary to update the access token when the user
166167
// re-authenticates.
167168
if (account && account.provider && account.provider !== 'credentials' && account.providerAccountId) {
168-
await prisma.account.update({
169+
const updatedAccount = await prisma.account.update({
169170
where: {
170171
provider_providerAccountId: {
171172
provider: account.provider,
@@ -180,7 +181,19 @@ export const { handlers, signIn, signOut, auth } = NextAuth({
180181
scope: account.scope,
181182
id_token: account.id_token,
182183
}
183-
})
184+
});
185+
186+
// Rebuild permissions from cache if permission syncing is enabled
187+
if (hasEntitlement('permission-syncing') && env.EXPERIMENT_EE_PERMISSION_SYNC_ENABLED === 'true') {
188+
await rebuildPermissionsFromCache(
189+
updatedAccount.id,
190+
account.provider,
191+
account.providerAccountId
192+
).catch(error => {
193+
// Don't fail sign-in if permission rebuild fails
194+
console.error('Failed to rebuild permissions from cache:', error);
195+
});
196+
}
184197
}
185198

186199
if (user.id) {
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
"use server";
2+
3+
import { prisma } from "@/prisma";
4+
import { CachedPermittedExternalAccounts, cachedPermittedExternalAccountsSchema, createLogger } from "@sourcebot/shared";
5+
6+
const logger = createLogger('permission-utils');
7+
8+
/**
9+
* Rebuilds the AccountToRepoPermission join table for a given account
10+
* based on the cached external account IDs stored in repos.
11+
*
12+
* This is useful when a new account is created and we want to grant
13+
* access to repos without waiting for a full permission sync.
14+
*
15+
* @param accountId - The internal account ID
16+
* @param provider - The OAuth provider (e.g., 'github', 'gitlab')
17+
* @param providerAccountId - The external account ID from the provider
18+
*/
19+
export async function rebuildPermissionsFromCache(
20+
accountId: string,
21+
provider: string,
22+
providerAccountId: string
23+
): Promise<void> {
24+
logger.info(`Rebuilding permissions from cache for account ${accountId} (${provider}:${providerAccountId})`);
25+
26+
// Find all repos that have this external account ID in their cached permissions
27+
const repos = await prisma.repo.findMany({
28+
where: {
29+
cachedPermittedExternalAccounts: {
30+
not: null,
31+
},
32+
},
33+
select: {
34+
id: true,
35+
cachedPermittedExternalAccounts: true,
36+
},
37+
});
38+
39+
// Filter repos that include this specific external account ID for this provider
40+
const reposWithAccess = repos.filter(repo => {
41+
try {
42+
const cached = cachedPermittedExternalAccountsSchema.parse(
43+
repo.cachedPermittedExternalAccounts
44+
);
45+
46+
const providerAccountIds = cached[provider as keyof CachedPermittedExternalAccounts];
47+
return providerAccountIds?.includes(providerAccountId) ?? false;
48+
} catch (error) {
49+
logger.warn(`Failed to parse cachedPermittedExternalAccounts for repo ${repo.id}:`, error);
50+
return false;
51+
}
52+
});
53+
54+
if (reposWithAccess.length === 0) {
55+
logger.info(`No repos found with cached permissions for account ${accountId}`);
56+
return;
57+
}
58+
59+
// Create AccountToRepoPermission entries
60+
await prisma.accountToRepoPermission.createMany({
61+
data: reposWithAccess.map(repo => ({
62+
accountId,
63+
repoId: repo.id,
64+
})),
65+
skipDuplicates: true,
66+
});
67+
68+
logger.info(`Rebuilt permissions for ${reposWithAccess.length} repos for account ${accountId}`);
69+
}

0 commit comments

Comments
 (0)