diff --git a/packages/db/prisma/migrations/20250827010055_repo_to_user_join_table/migration.sql b/packages/db/prisma/migrations/20250827010055_repo_to_user_join_table/migration.sql new file mode 100644 index 00000000..62a56d49 --- /dev/null +++ b/packages/db/prisma/migrations/20250827010055_repo_to_user_join_table/migration.sql @@ -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; diff --git a/packages/db/prisma/schema.prisma b/packages/db/prisma/schema.prisma index 20454ddf..87e82749 100644 --- a/packages/db/prisma/schema.prisma +++ b/packages/db/prisma/schema.prisma @@ -41,11 +41,11 @@ enum ChatVisibility { } model Repo { - id Int @id @default(autoincrement()) + id Int @id @default(autoincrement()) name String displayName String? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt /// When the repo was last indexed successfully. indexedAt DateTime? isFork Boolean @@ -55,7 +55,8 @@ model Repo { webUrl String? connections RepoToConnection[] imageUrl String? - repoIndexingStatus RepoIndexingStatus @default(NEW) + repoIndexingStatus RepoIndexingStatus @default(NEW) + permittedUsers UserToRepoPermission[] // The id of the repo in the external service external_id String @@ -75,9 +76,9 @@ model Repo { model SearchContext { id Int @id @default(autoincrement()) - name String + name String description String? - repos Repo[] + repos Repo[] org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) orgId Int @@ -146,7 +147,7 @@ model AccountRequest { 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 org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) @@ -168,7 +169,7 @@ model Org { apiKeys ApiKey[] isOnboarded Boolean @default(false) 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) @@ -178,10 +179,10 @@ model Org { /// List of pending invites to this organization invites Invite[] - + /// The invite id for this organization inviteLinkEnabled Boolean @default(false) - inviteLinkId String? + inviteLinkId String? audits Audit[] @@ -228,55 +229,53 @@ model Secret { } model ApiKey { - name String - hash String @id @unique + name String + hash String @id @unique - createdAt DateTime @default(now()) + createdAt DateTime @default(now()) lastUsedAt DateTime? org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) orgId Int - createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade) + createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade) createdById String - } model Audit { - id String @id @default(cuid()) + id String @id @default(cuid()) timestamp DateTime @default(now()) - - action String - actorId String - actorType String - targetId String - targetType String + + action String + actorId String + actorType String + targetId String + targetType String sourcebotVersion String - metadata Json? + metadata Json? org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) orgId Int @@index([actorId, actorType, targetId, targetType, orgId]) - // 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") - // Fast path for analytics queries for a specific user @@index([actorId, timestamp], map: "idx_audit_actor_time_full") } // @see : https://authjs.dev/concepts/database-models#user model User { - id String @id @default(cuid()) + id String @id @default(cuid()) name String? - email String? @unique + email String? @unique hashedPassword String? emailVerified DateTime? image String? accounts Account[] orgs UserToOrg[] accountRequest AccountRequest? + accessibleRepos UserToRepoPermission[] /// List of pending invites that the user has created invites Invite[] @@ -289,6 +288,18 @@ model User { 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 model Account { id String @id @default(cuid()) @@ -326,17 +337,17 @@ model Chat { name String? - createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade) + createdBy User @relation(fields: [createdById], references: [id], onDelete: Cascade) createdById String createdAt DateTime @default(now()) updatedAt DateTime @updatedAt - + org Org @relation(fields: [orgId], references: [id], onDelete: Cascade) orgId Int 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. -} \ No newline at end of file +} diff --git a/packages/web/src/actions.ts b/packages/web/src/actions.ts index 80094989..5ed729d9 100644 --- a/packages/web/src/actions.ts +++ b/packages/web/src/actions.ts @@ -639,7 +639,7 @@ export const getConnectionInfo = async (connectionId: number, domain: string) => }))); export const getRepos = async (filter: { status?: RepoIndexingStatus[], connectionId?: number } = {}) => sew(() => - withOptionalAuthV2(async ({ org }) => { + withOptionalAuthV2(async ({ org, user }) => { const repos = await prisma.repo.findMany({ where: { 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: { connections: { @@ -722,6 +729,13 @@ export const getRepoInfoByName = async (repoName: string, domain: string) => sew where: { name: repoName, 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 const repoInfo = (() => { const url = repositoryUrl.trim(); - + // Handle various GitHub URL formats const patterns = [ // 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 /^([a-zA-Z0-9_.-]+)\/([a-zA-Z0-9_.-]+)$/ ]; - + for (const pattern of patterns) { const match = url.match(pattern); if (match) { @@ -824,7 +838,7 @@ export const experimental_addGithubRepositoryByUrl = async (repositoryUrl: strin }; } } - + return null; })(); @@ -837,7 +851,7 @@ export const experimental_addGithubRepositoryByUrl = async (repositoryUrl: strin } const { owner, repo } = repoInfo; - + // Use GitHub API to fetch repository information and get the external_id const octokit = new Octokit({ 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.`, } satisfies ServiceError; } - + return { statusCode: StatusCodes.INTERNAL_SERVER_ERROR, errorCode: ErrorCode.INVALID_REQUEST_BODY, @@ -889,6 +903,13 @@ export const experimental_addGithubRepositoryByUrl = async (repositoryUrl: strin external_id: githubRepo.id.toString(), external_codeHostType: 'github', 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: { id: { in: repoIds }, orgId: org.id, + ...(env.EXPERIMENT_PERMISSION_SYNC_ENABLED === 'true' ? { + permittedUsers: { + some: { + userId: userId, + } + } + } : {}) }, data: { repoIndexingStatus: RepoIndexingStatus.NEW, @@ -2021,6 +2049,13 @@ export const getRepoImage = async (repoId: number, domain: string): Promise sew(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ org }) => { + withAuth((userId) => + withOrgMembership(userId, domain, async ({ org }) => { const { repoName, revisionName } = params; const repo = await prisma.repo.findFirst({ where: { name: repoName, 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. */ export const getFolderContents = async (params: { repoName: string, revisionName: string, path: string }, domain: string) => sew(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ org }) => { + withAuth((userId) => + withOrgMembership(userId, domain, async ({ org }) => { const { repoName, revisionName, path } = params; const repo = await prisma.repo.findFirst({ where: { name: repoName, 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(() => - withAuth((session) => - withOrgMembership(session, domain, async ({ org }) => { + withAuth((userId) => + withOrgMembership(userId, domain, async ({ org }) => { const { repoName, revisionName } = params; const repo = await prisma.repo.findFirst({ where: { name: repoName, orgId: org.id, + ...(env.EXPERIMENT_PERMISSION_SYNC_ENABLED === 'true' ? { + permittedUsers: { + some: { + userId: userId, + } + } + } : {}) }, }); diff --git a/packages/web/src/features/search/searchApi.ts b/packages/web/src/features/search/searchApi.ts index 6d006bbd..65640fe6 100644 --- a/packages/web/src/features/search/searchApi.ts +++ b/packages/web/src/features/search/searchApi.ts @@ -10,7 +10,6 @@ import { StatusCodes } from "http-status-codes"; import { zoektSearchResponseSchema } from "./zoektSchema"; import { SearchRequest, SearchResponse, SourceRange } from "./types"; import { OrgRole, Repo } from "@sourcebot/db"; -import * as Sentry from "@sentry/nextjs"; import { sew, withAuth, withOrgMembership } from "@/actions"; 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"), }, orgId: org.id, + ...(env.EXPERIMENT_PERMISSION_SYNC_ENABLED === 'true' ? { + permittedUsers: { + some: { + userId, + } + } + } : {}) } })).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"), }, orgId: org.id, + ...(env.EXPERIMENT_PERMISSION_SYNC_ENABLED === 'true' ? { + permittedUsers: { + some: { + userId, + } + } + } : {}) } })).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 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) { - Sentry.captureMessage( - `Repository not found for identifier: ${identifier}; skipping file "${file.FileName}"`, - 'warning' - ); return undefined; } @@ -349,4 +358,4 @@ export const search = async ({ query, matches, contextLines, whole }: SearchRequ return parser.parseAsync(searchBody); }, /* minRequiredRole = */ OrgRole.GUEST), /* allowAnonymousAccess = */ true, apiKey ? { apiKey, domain } : undefined) - ); +);