Add data model for user <> repo permission link

This commit is contained in:
bkellam 2025-08-26 22:04:52 -04:00
parent 83a8d306db
commit b9a91c20fe
6 changed files with 143 additions and 51 deletions

View file

@ -0,0 +1,14 @@
-- CreateTable
CREATE TABLE "UserToRepoPermission" (
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"repoId" INTEGER NOT NULL,
"userId" TEXT NOT NULL,
CONSTRAINT "UserToRepoPermission_pkey" PRIMARY KEY ("repoId","userId")
);
-- AddForeignKey
ALTER TABLE "UserToRepoPermission" ADD CONSTRAINT "UserToRepoPermission_repoId_fkey" FOREIGN KEY ("repoId") REFERENCES "Repo"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserToRepoPermission" ADD CONSTRAINT "UserToRepoPermission_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View file

@ -56,6 +56,7 @@ model Repo {
connections RepoToConnection[] connections RepoToConnection[]
imageUrl String? imageUrl String?
repoIndexingStatus RepoIndexingStatus @default(NEW) repoIndexingStatus RepoIndexingStatus @default(NEW)
permittedUsers UserToRepoPermission[]
// The id of the repo in the external service // The id of the repo in the external service
external_id String external_id String
@ -239,7 +240,6 @@ model ApiKey {
createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade) createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade)
createdById String createdById String
} }
model Audit { model Audit {
@ -258,10 +258,8 @@ model Audit {
orgId Int orgId Int
@@index([actorId, actorType, targetId, targetType, orgId]) @@index([actorId, actorType, targetId, targetType, orgId])
// Fast path for analytics queries orgId is first because we assume most deployments are single tenant // Fast path for analytics queries orgId is first because we assume most deployments are single tenant
@@index([orgId, timestamp, action, actorId], map: "idx_audit_core_actions_full") @@index([orgId, timestamp, action, actorId], map: "idx_audit_core_actions_full")
// Fast path for analytics queries for a specific user // Fast path for analytics queries for a specific user
@@index([actorId, timestamp], map: "idx_audit_actor_time_full") @@index([actorId, timestamp], map: "idx_audit_actor_time_full")
} }
@ -277,6 +275,7 @@ model User {
accounts Account[] accounts Account[]
orgs UserToOrg[] orgs UserToOrg[]
accountRequest AccountRequest? accountRequest AccountRequest?
accessibleRepos UserToRepoPermission[]
/// List of pending invites that the user has created /// List of pending invites that the user has created
invites Invite[] invites Invite[]
@ -289,6 +288,18 @@ model User {
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
} }
model UserToRepoPermission {
createdAt DateTime @default(now())
repo Repo @relation(fields: [repoId], references: [id], onDelete: Cascade)
repoId Int
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
userId String
@@id([repoId, userId])
}
// @see : https://authjs.dev/concepts/database-models#account // @see : https://authjs.dev/concepts/database-models#account
model Account { model Account {
id String @id @default(cuid()) id String @id @default(cuid())

View file

@ -639,7 +639,7 @@ export const getConnectionInfo = async (connectionId: number, domain: string) =>
}))); })));
export const getRepos = async (filter: { status?: RepoIndexingStatus[], connectionId?: number } = {}) => sew(() => export const getRepos = async (filter: { status?: RepoIndexingStatus[], connectionId?: number } = {}) => sew(() =>
withOptionalAuthV2(async ({ org }) => { withOptionalAuthV2(async ({ org, user }) => {
const repos = await prisma.repo.findMany({ const repos = await prisma.repo.findMany({
where: { where: {
orgId: org.id, orgId: org.id,
@ -653,6 +653,13 @@ export const getRepos = async (filter: { status?: RepoIndexingStatus[], connecti
} }
} }
} : {}), } : {}),
...(env.EXPERIMENT_PERMISSION_SYNC_ENABLED === 'true' ? {
permittedUsers: {
some: {
userId: user?.id,
}
}
} : {})
}, },
include: { include: {
connections: { connections: {
@ -722,6 +729,13 @@ export const getRepoInfoByName = async (repoName: string, domain: string) => sew
where: { where: {
name: repoName, name: repoName,
orgId: org.id, orgId: org.id,
...(env.EXPERIMENT_PERMISSION_SYNC_ENABLED === 'true' ? {
permittedUsers: {
some: {
userId: userId,
}
}
} : {})
}, },
}); });
@ -889,6 +903,13 @@ export const experimental_addGithubRepositoryByUrl = async (repositoryUrl: strin
external_id: githubRepo.id.toString(), external_id: githubRepo.id.toString(),
external_codeHostType: 'github', external_codeHostType: 'github',
external_codeHostUrl: 'https://github.com', external_codeHostUrl: 'https://github.com',
...(env.EXPERIMENT_PERMISSION_SYNC_ENABLED === 'true' ? {
permittedUsers: {
some: {
userId: userId,
}
}
} : {})
} }
}); });
@ -1039,6 +1060,13 @@ export const flagReposForIndex = async (repoIds: number[], domain: string) => se
where: { where: {
id: { in: repoIds }, id: { in: repoIds },
orgId: org.id, orgId: org.id,
...(env.EXPERIMENT_PERMISSION_SYNC_ENABLED === 'true' ? {
permittedUsers: {
some: {
userId: userId,
}
}
} : {})
}, },
data: { data: {
repoIndexingStatus: RepoIndexingStatus.NEW, repoIndexingStatus: RepoIndexingStatus.NEW,
@ -2021,6 +2049,13 @@ export const getRepoImage = async (repoId: number, domain: string): Promise<Arra
where: { where: {
id: repoId, id: repoId,
orgId: org.id, orgId: org.id,
...(env.EXPERIMENT_PERMISSION_SYNC_ENABLED === 'true' ? {
permittedUsers: {
some: {
userId: userId,
}
}
} : {})
}, },
include: { include: {
connections: { connections: {
@ -2028,7 +2063,7 @@ export const getRepoImage = async (repoId: number, domain: string): Promise<Arra
connection: true, connection: true,
} }
} }
} },
}); });
if (!repo || !repo.imageUrl) { if (!repo || !repo.imageUrl) {

View file

@ -136,6 +136,8 @@ export const env = createEnv({
EXPERIMENT_SELF_SERVE_REPO_INDEXING_ENABLED: booleanSchema.default('false'), EXPERIMENT_SELF_SERVE_REPO_INDEXING_ENABLED: booleanSchema.default('false'),
// @NOTE: Take care to update actions.ts when changing the name of this. // @NOTE: Take care to update actions.ts when changing the name of this.
EXPERIMENT_SELF_SERVE_REPO_INDEXING_GITHUB_TOKEN: z.string().optional(), EXPERIMENT_SELF_SERVE_REPO_INDEXING_GITHUB_TOKEN: z.string().optional(),
EXPERIMENT_PERMISSION_SYNC_ENABLED: booleanSchema.default('false'),
}, },
// @NOTE: Please make sure of the following: // @NOTE: Please make sure of the following:
// - Make sure you destructure all client variables in // - Make sure you destructure all client variables in

View file

@ -26,13 +26,20 @@ export type FileTreeNode = FileTreeItem & {
* at a given revision. * at a given revision.
*/ */
export const getTree = async (params: { repoName: string, revisionName: string }, domain: string) => sew(() => export const getTree = async (params: { repoName: string, revisionName: string }, domain: string) => sew(() =>
withAuth((session) => withAuth((userId) =>
withOrgMembership(session, domain, async ({ org }) => { withOrgMembership(userId, domain, async ({ org }) => {
const { repoName, revisionName } = params; const { repoName, revisionName } = params;
const repo = await prisma.repo.findFirst({ const repo = await prisma.repo.findFirst({
where: { where: {
name: repoName, name: repoName,
orgId: org.id, orgId: org.id,
...(env.EXPERIMENT_PERMISSION_SYNC_ENABLED === 'true' ? {
permittedUsers: {
some: {
userId: userId,
}
}
} : {})
}, },
}); });
@ -85,13 +92,20 @@ export const getTree = async (params: { repoName: string, revisionName: string }
* at a given revision. * at a given revision.
*/ */
export const getFolderContents = async (params: { repoName: string, revisionName: string, path: string }, domain: string) => sew(() => export const getFolderContents = async (params: { repoName: string, revisionName: string, path: string }, domain: string) => sew(() =>
withAuth((session) => withAuth((userId) =>
withOrgMembership(session, domain, async ({ org }) => { withOrgMembership(userId, domain, async ({ org }) => {
const { repoName, revisionName, path } = params; const { repoName, revisionName, path } = params;
const repo = await prisma.repo.findFirst({ const repo = await prisma.repo.findFirst({
where: { where: {
name: repoName, name: repoName,
orgId: org.id, orgId: org.id,
...(env.EXPERIMENT_PERMISSION_SYNC_ENABLED === 'true' ? {
permittedUsers: {
some: {
userId: userId,
}
}
} : {})
}, },
}); });
@ -158,14 +172,21 @@ export const getFolderContents = async (params: { repoName: string, revisionName
); );
export const getFiles = async (params: { repoName: string, revisionName: string }, domain: string) => sew(() => export const getFiles = async (params: { repoName: string, revisionName: string }, domain: string) => sew(() =>
withAuth((session) => withAuth((userId) =>
withOrgMembership(session, domain, async ({ org }) => { withOrgMembership(userId, domain, async ({ org }) => {
const { repoName, revisionName } = params; const { repoName, revisionName } = params;
const repo = await prisma.repo.findFirst({ const repo = await prisma.repo.findFirst({
where: { where: {
name: repoName, name: repoName,
orgId: org.id, orgId: org.id,
...(env.EXPERIMENT_PERMISSION_SYNC_ENABLED === 'true' ? {
permittedUsers: {
some: {
userId: userId,
}
}
} : {})
}, },
}); });

View file

@ -10,7 +10,6 @@ import { StatusCodes } from "http-status-codes";
import { zoektSearchResponseSchema } from "./zoektSchema"; import { zoektSearchResponseSchema } from "./zoektSchema";
import { SearchRequest, SearchResponse, SourceRange } from "./types"; import { SearchRequest, SearchResponse, SourceRange } from "./types";
import { OrgRole, Repo } from "@sourcebot/db"; import { OrgRole, Repo } from "@sourcebot/db";
import * as Sentry from "@sentry/nextjs";
import { sew, withAuth, withOrgMembership } from "@/actions"; import { sew, withAuth, withOrgMembership } from "@/actions";
import { base64Decode } from "@sourcebot/shared"; import { base64Decode } from "@sourcebot/shared";
@ -204,6 +203,13 @@ export const search = async ({ query, matches, contextLines, whole }: SearchRequ
in: Array.from(repoIdentifiers).filter((id) => typeof id === "number"), in: Array.from(repoIdentifiers).filter((id) => typeof id === "number"),
}, },
orgId: org.id, orgId: org.id,
...(env.EXPERIMENT_PERMISSION_SYNC_ENABLED === 'true' ? {
permittedUsers: {
some: {
userId,
}
}
} : {})
} }
})).forEach(repo => repos.set(repo.id, repo)); })).forEach(repo => repos.set(repo.id, repo));
@ -213,6 +219,13 @@ export const search = async ({ query, matches, contextLines, whole }: SearchRequ
in: Array.from(repoIdentifiers).filter((id) => typeof id === "string"), in: Array.from(repoIdentifiers).filter((id) => typeof id === "string"),
}, },
orgId: org.id, orgId: org.id,
...(env.EXPERIMENT_PERMISSION_SYNC_ENABLED === 'true' ? {
permittedUsers: {
some: {
userId,
}
}
} : {})
} }
})).forEach(repo => repos.set(repo.name, repo)); })).forEach(repo => repos.set(repo.name, repo));
@ -234,12 +247,8 @@ export const search = async ({ query, matches, contextLines, whole }: SearchRequ
const identifier = file.RepositoryID ?? file.Repository; const identifier = file.RepositoryID ?? file.Repository;
const repo = repos.get(identifier); const repo = repos.get(identifier);
// This should never happen... but if it does, we skip the file. // This can happen if the user doesn't have access to the repository.
if (!repo) { if (!repo) {
Sentry.captureMessage(
`Repository not found for identifier: ${identifier}; skipping file "${file.FileName}"`,
'warning'
);
return undefined; return undefined;
} }