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

@ -41,11 +41,11 @@ enum ChatVisibility {
} }
model Repo { model Repo {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String
displayName String? displayName String?
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
/// When the repo was last indexed successfully. /// When the repo was last indexed successfully.
indexedAt DateTime? indexedAt DateTime?
isFork Boolean isFork Boolean
@ -55,7 +55,8 @@ model Repo {
webUrl String? webUrl String?
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
@ -75,9 +76,9 @@ model Repo {
model SearchContext { model SearchContext {
id Int @id @default(autoincrement()) id Int @id @default(autoincrement())
name String name String
description String? description String?
repos Repo[] repos Repo[]
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
orgId Int orgId Int
@ -146,7 +147,7 @@ model AccountRequest {
createdAt DateTime @default(now()) createdAt DateTime @default(now())
requestedBy User @relation(fields: [requestedById], references: [id], onDelete: Cascade) requestedBy User @relation(fields: [requestedById], references: [id], onDelete: Cascade)
requestedById String @unique requestedById String @unique
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
@ -168,7 +169,7 @@ model Org {
apiKeys ApiKey[] apiKeys ApiKey[]
isOnboarded Boolean @default(false) isOnboarded Boolean @default(false)
imageUrl String? imageUrl String?
metadata Json? // For schema see orgMetadataSchema in packages/web/src/types.ts metadata Json? // For schema see orgMetadataSchema in packages/web/src/types.ts
memberApprovalRequired Boolean @default(true) memberApprovalRequired Boolean @default(true)
@ -178,10 +179,10 @@ model Org {
/// List of pending invites to this organization /// List of pending invites to this organization
invites Invite[] invites Invite[]
/// The invite id for this organization /// The invite id for this organization
inviteLinkEnabled Boolean @default(false) inviteLinkEnabled Boolean @default(false)
inviteLinkId String? inviteLinkId String?
audits Audit[] audits Audit[]
@ -228,55 +229,53 @@ model Secret {
} }
model ApiKey { model ApiKey {
name String name String
hash String @id @unique hash String @id @unique
createdAt DateTime @default(now()) createdAt DateTime @default(now())
lastUsedAt DateTime? lastUsedAt DateTime?
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
orgId Int orgId Int
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 {
id String @id @default(cuid()) id String @id @default(cuid())
timestamp DateTime @default(now()) timestamp DateTime @default(now())
action String action String
actorId String actorId String
actorType String actorType String
targetId String targetId String
targetType String targetType String
sourcebotVersion String sourcebotVersion String
metadata Json? metadata Json?
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
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")
} }
// @see : https://authjs.dev/concepts/database-models#user // @see : https://authjs.dev/concepts/database-models#user
model User { model User {
id String @id @default(cuid()) id String @id @default(cuid())
name String? name String?
email String? @unique email String? @unique
hashedPassword String? hashedPassword String?
emailVerified DateTime? emailVerified DateTime?
image String? image String?
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())
@ -326,17 +337,17 @@ model Chat {
name String? name String?
createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade) createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade)
createdById String createdById String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) org Org @relation(fields: [orgId], references: [id], onDelete: Cascade)
orgId Int orgId Int
visibility ChatVisibility @default(PRIVATE) visibility ChatVisibility @default(PRIVATE)
isReadonly Boolean @default(false) isReadonly Boolean @default(false)
messages Json // This is a JSON array of `Message` types from @ai-sdk/ui-utils. messages Json // This is a JSON array of `Message` types from @ai-sdk/ui-utils.
} }

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,
}
}
} : {})
}, },
}); });
@ -804,7 +818,7 @@ export const experimental_addGithubRepositoryByUrl = async (repositoryUrl: strin
// Parse repository URL to extract owner/repo // Parse repository URL to extract owner/repo
const repoInfo = (() => { const repoInfo = (() => {
const url = repositoryUrl.trim(); const url = repositoryUrl.trim();
// Handle various GitHub URL formats // Handle various GitHub URL formats
const patterns = [ const patterns = [
// https://github.com/owner/repo or https://github.com/owner/repo.git // https://github.com/owner/repo or https://github.com/owner/repo.git
@ -814,7 +828,7 @@ export const experimental_addGithubRepositoryByUrl = async (repositoryUrl: strin
// owner/repo // owner/repo
/^([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)$/ /^([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)$/
]; ];
for (const pattern of patterns) { for (const pattern of patterns) {
const match = url.match(pattern); const match = url.match(pattern);
if (match) { if (match) {
@ -824,7 +838,7 @@ export const experimental_addGithubRepositoryByUrl = async (repositoryUrl: strin
}; };
} }
} }
return null; return null;
})(); })();
@ -837,7 +851,7 @@ export const experimental_addGithubRepositoryByUrl = async (repositoryUrl: strin
} }
const { owner, repo } = repoInfo; const { owner, repo } = repoInfo;
// Use GitHub API to fetch repository information and get the external_id // Use GitHub API to fetch repository information and get the external_id
const octokit = new Octokit({ const octokit = new Octokit({
auth: env.EXPERIMENT_SELF_SERVE_REPO_INDEXING_GITHUB_TOKEN auth: env.EXPERIMENT_SELF_SERVE_REPO_INDEXING_GITHUB_TOKEN
@ -866,7 +880,7 @@ export const experimental_addGithubRepositoryByUrl = async (repositoryUrl: strin
message: `Access to repository '${owner}/${repo}' is forbidden. Only public repositories can be added.`, message: `Access to repository '${owner}/${repo}' is forbidden. Only public repositories can be added.`,
} satisfies ServiceError; } satisfies ServiceError;
} }
return { return {
statusCode: StatusCodes.INTERNAL_SERVER_ERROR, statusCode: StatusCodes.INTERNAL_SERVER_ERROR,
errorCode: ErrorCode.INVALID_REQUEST_BODY, errorCode: ErrorCode.INVALID_REQUEST_BODY,
@ -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;
} }
@ -349,4 +358,4 @@ export const search = async ({ query, matches, contextLines, whole }: SearchRequ
return parser.parseAsync(searchBody); return parser.parseAsync(searchBody);
}, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true, apiKey ? { apiKey, domain } : undefined) }, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true, apiKey ? { apiKey, domain } : undefined)
); );