Skip to content

Commit 3fd5f49

Browse files
add repo_sets filter for repositories a user has access to
1 parent aad3507 commit 3fd5f49

4 files changed

Lines changed: 84 additions & 34 deletions

File tree

packages/db/src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,3 @@
1+
import type { User, Account } from ".prisma/client";
2+
export type UserWithAccounts = User & { accounts: Account[] };
13
export * from ".prisma/client";

packages/web/src/features/search/searchApi.ts

Lines changed: 48 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -12,37 +12,45 @@ import { withOptionalAuthV2 } from "@/withAuthV2";
1212
import * as grpc from '@grpc/grpc-js';
1313
import * as protoLoader from '@grpc/proto-loader';
1414
import * as Sentry from '@sentry/nextjs';
15-
import { PrismaClient, Repo } from "@sourcebot/db";
16-
import { createLogger, env } from "@sourcebot/shared";
15+
import { PrismaClient, Repo, UserWithAccounts } from "@sourcebot/db";
16+
import { createLogger, env, hasEntitlement } from "@sourcebot/shared";
1717
import path from 'path';
1818
import { parseQueryIntoLezerTree, transformLezerTreeToZoektGrpcQuery } from './query';
1919
import { RepositoryInfo, SearchRequest, SearchResponse, SearchResultFile, SearchStats, SourceRange, StreamedSearchResponse } from "./types";
2020
import { FlushReason as ZoektFlushReason } from "@/proto/zoekt/webserver/v1/FlushReason";
2121
import { RevisionExpr } from "@sourcebot/query-language";
2222
import { getCodeHostBrowseFileAtBranchUrl } from "@/lib/utils";
23+
import { getRepoPermissionFilterForUser } from "@/prisma";
2324

2425
const logger = createLogger("searchApi");
2526

2627
export const search = (searchRequest: SearchRequest) => sew(() =>
27-
withOptionalAuthV2(async ({ prisma }) => {
28+
withOptionalAuthV2(async ({ prisma, user }) => {
29+
const repoSearchScope = await getAccessibleRepoNamesForUser({ user, prisma });
30+
2831
const zoektSearchRequest = await createZoektSearchRequest({
2932
searchRequest,
3033
prisma,
34+
repoSearchScope,
3135
});
3236

33-
logger.debug('zoektSearchRequest:', JSON.stringify(zoektSearchRequest, null, 2));
37+
38+
logger.debug(`zoektSearchRequest:\n${JSON.stringify(zoektSearchRequest, null, 2)}`);
3439

3540
return zoektSearch(zoektSearchRequest, prisma);
3641
}));
3742

3843
export const streamSearch = (searchRequest: SearchRequest) => sew(() =>
39-
withOptionalAuthV2(async ({ prisma }) => {
44+
withOptionalAuthV2(async ({ prisma, user }) => {
45+
const repoSearchScope = await getAccessibleRepoNamesForUser({ user, prisma });
46+
4047
const zoektSearchRequest = await createZoektSearchRequest({
4148
searchRequest,
4249
prisma,
50+
repoSearchScope,
4351
});
4452

45-
logger.debug('zoektStreamSearchRequest:', JSON.stringify(zoektSearchRequest, null, 2));
53+
console.log(`zoektStreamSearchRequest:\n${JSON.stringify(zoektSearchRequest, null, 2)}`);
4654

4755
return zoektStreamSearch(zoektSearchRequest, prisma);
4856
}));
@@ -296,9 +304,9 @@ const transformZoektSearchResponse = async (response: ZoektGrpcSearchResponse, r
296304
const repoId = getRepoIdForFile(file);
297305
const repo = reposMapCache.get(repoId);
298306

299-
// This can happen if the user doesn't have access to the repository.
307+
// This should never happen.
300308
if (!repo) {
301-
return undefined;
309+
throw new Error(`Repository not found for file: ${file.file_name}`);
302310
}
303311

304312
// @todo: address "file_name might not be a valid UTF-8 string" warning.
@@ -432,9 +440,12 @@ const getRepoIdForFile = (file: ZoektGrpcFileMatch): string | number => {
432440
const createZoektSearchRequest = async ({
433441
searchRequest,
434442
prisma,
443+
repoSearchScope,
435444
}: {
436445
searchRequest: SearchRequest;
437446
prisma: PrismaClient;
447+
// Allows the caller to scope the search to a specific set of repositories.
448+
repoSearchScope?: string[];
438449
}) => {
439450
const tree = parseQueryIntoLezerTree(searchRequest.query);
440451
const zoektQuery = await transformLezerTreeToZoektGrpcQuery({
@@ -487,6 +498,14 @@ const createZoektSearchRequest = async ({
487498
exact: true,
488499
}
489500
}] : []),
501+
...(repoSearchScope ? [{
502+
repo_set: {
503+
set: repoSearchScope.reduce((acc, repo) => {
504+
acc[repo] = true;
505+
return acc;
506+
}, {} as Record<string, boolean>)
507+
}
508+
}] : []),
490509
]
491510
}
492511
},
@@ -542,6 +561,27 @@ const createZoektSearchRequest = async ({
542561
return zoektSearchRequest;
543562
}
544563

564+
/**
565+
* Returns a list of repository names that the user has access to.
566+
* If permission syncing is disabled, returns undefined.
567+
*/
568+
const getAccessibleRepoNamesForUser = async ({ user, prisma }: { user?: UserWithAccounts, prisma: PrismaClient }) => {
569+
if (
570+
env.EXPERIMENT_EE_PERMISSION_SYNC_ENABLED !== 'true' ||
571+
!hasEntitlement('permission-syncing')
572+
) {
573+
return undefined;
574+
}
575+
576+
const accessibleRepos = await prisma.repo.findMany({
577+
where: getRepoPermissionFilterForUser(user),
578+
select: {
579+
name: true,
580+
}
581+
});
582+
return accessibleRepos.map(repo => repo.name);
583+
}
584+
545585
const createGrpcClient = (): WebserverServiceClient => {
546586
// Path to proto files - these should match your monorepo structure
547587
const protoBasePath = path.join(process.cwd(), '../../vendor/zoekt/grpc/protos');

packages/web/src/prisma.ts

Lines changed: 30 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import 'server-only';
22
import { env, getDBConnectionString } from "@sourcebot/shared";
3-
import { Prisma, PrismaClient } from "@sourcebot/db";
3+
import { Prisma, PrismaClient, UserWithAccounts } from "@sourcebot/db";
44
import { hasEntitlement } from "@sourcebot/shared";
55

66
// @see: https://authjs.dev/getting-started/adapters/prisma
@@ -24,15 +24,15 @@ export const prisma = globalForPrisma.prisma || new PrismaClient({
2424
url: dbConnectionString,
2525
},
2626
}
27-
}: {}),
27+
} : {}),
2828
})
2929
if (env.NODE_ENV !== "production") globalForPrisma.prisma = prisma
3030

3131
/**
3232
* Creates a prisma client extension that scopes queries to striclty information
3333
* a given user should be able to access.
3434
*/
35-
export const userScopedPrismaClientExtension = (accountIds?: string[]) => {
35+
export const userScopedPrismaClientExtension = (user?: UserWithAccounts) => {
3636
return Prisma.defineExtension(
3737
(prisma) => {
3838
return prisma.$extends({
@@ -46,24 +46,7 @@ export const userScopedPrismaClientExtension = (accountIds?: string[]) => {
4646

4747
argsWithWhere.where = {
4848
...(argsWithWhere.where || {}),
49-
OR: [
50-
// Only include repos that are permitted to the user
51-
...(accountIds ? [
52-
{
53-
permittedAccounts: {
54-
some: {
55-
accountId: {
56-
in: accountIds,
57-
}
58-
}
59-
}
60-
},
61-
] : []),
62-
// or are public.
63-
{
64-
isPublic: true,
65-
}
66-
]
49+
...getRepoPermissionFilterForUser(user),
6750
};
6851

6952
return query(args);
@@ -74,3 +57,29 @@ export const userScopedPrismaClientExtension = (accountIds?: string[]) => {
7457
})
7558
})
7659
}
60+
61+
/**
62+
* Returns a filter for repositories that the user has access to.
63+
*/
64+
export const getRepoPermissionFilterForUser = (user?: UserWithAccounts): Prisma.RepoWhereInput => {
65+
return {
66+
OR: [
67+
// Only include repos that are permitted to the user
68+
...((user && user.accounts.length > 0) ? [
69+
{
70+
permittedAccounts: {
71+
some: {
72+
accountId: {
73+
in: user.accounts.map(account => account.id),
74+
}
75+
}
76+
}
77+
},
78+
] : []),
79+
// or are public.
80+
{
81+
isPublic: true,
82+
}
83+
]
84+
}
85+
}

packages/web/src/withAuthV2.ts

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { prisma as __unsafePrisma, userScopedPrismaClientExtension } from "@/prisma";
22
import { hashSecret } from "@sourcebot/shared";
3-
import { ApiKey, Org, OrgRole, PrismaClient, User } from "@sourcebot/db";
3+
import { ApiKey, Org, OrgRole, PrismaClient, UserWithAccounts } from "@sourcebot/db";
44
import { headers } from "next/headers";
55
import { auth } from "./auth";
66
import { notAuthenticated, notFound, ServiceError } from "./lib/serviceError";
@@ -11,14 +11,14 @@ import { getOrgMetadata, isServiceError } from "./lib/utils";
1111
import { hasEntitlement } from "@sourcebot/shared";
1212

1313
interface OptionalAuthContext {
14-
user?: User;
14+
user?: UserWithAccounts;
1515
org: Org;
1616
role: OrgRole;
1717
prisma: PrismaClient;
1818
}
1919

2020
interface RequiredAuthContext {
21-
user: User;
21+
user: UserWithAccounts;
2222
org: Org;
2323
role: Exclude<OrgRole, 'GUEST'>;
2424
prisma: PrismaClient;
@@ -88,8 +88,7 @@ export const getAuthContext = async (): Promise<OptionalAuthContext | ServiceErr
8888
},
8989
}) : null;
9090

91-
const accountIds = user?.accounts.map(account => account.id);
92-
const prisma = __unsafePrisma.$extends(userScopedPrismaClientExtension(accountIds)) as PrismaClient;
91+
const prisma = __unsafePrisma.$extends(userScopedPrismaClientExtension(user)) as PrismaClient;
9392

9493
return {
9594
user: user ?? undefined,

0 commit comments

Comments
 (0)