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 {
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)
@ -181,7 +182,7 @@ model Org {
/// 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,7 +337,7 @@ 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())
@ -336,7 +347,7 @@ model Chat {
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.
}

View file

@ -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,
}
}
} : {})
},
});
@ -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<Arra
where: {
id: repoId,
orgId: org.id,
...(env.EXPERIMENT_PERMISSION_SYNC_ENABLED === 'true' ? {
permittedUsers: {
some: {
userId: userId,
}
}
} : {})
},
include: {
connections: {
@ -2028,7 +2063,7 @@ export const getRepoImage = async (repoId: number, domain: string): Promise<Arra
connection: true,
}
}
}
},
});
if (!repo || !repo.imageUrl) {

View file

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

View file

@ -26,13 +26,20 @@ export type FileTreeNode = FileTreeItem & {
* at a given revision.
*/
export const getTree = 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,
}
}
} : {})
},
});
@ -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,
}
}
} : {})
},
});

View file

@ -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)
);
);