Enforce repo and path permissions for comment and review mutations

swadhinbiswas committed 8 months ago
93f6489
1 parent 2de61fa
commit 93f6489
5 files changed
+237 -3
▼src/lib/graphql/resolvers.ts
+50-2
566
566
if (!ctx.userId) throw new Error("Authentication required");
567
567
568
568
const { generateId } = await import("@/lib/utils");
569
+ const { checkPathPermissions } = await import("@/lib/path-scoping");
570
+
571
+ const pullRequest = await ctx.db.query.pullRequests.findFirst({
572
+ where: eq(schema.pullRequests.id, input.pullRequestId),
573
+ columns: {
574
+ id: true,
575
+ repositoryId: true,
576
+ },
577
+ });
578
+ if (!pullRequest) throw new Error("Pull request not found");
579
+
580
+ const repository = await ctx.db.query.repositories.findFirst({
581
+ where: eq(schema.repositories.id, pullRequest.repositoryId),
582
+ });
583
+ if (!repository || !(await canWriteRepo(ctx.userId, repository, { isAdmin: ctx.user?.isAdmin ?? undefined }))) {
584
+ throw new Error("Insufficient repository permissions");
585
+ }
586
+
587
+ const scopedPaths = [...new Set(
588
+ (input.comments || [])
589
+ .map((comment: { path?: string }) => comment.path)
590
+ .filter(Boolean) as string[]
591
+ )];
592
+ if (scopedPaths.length > 0) {
593
+ const permission = await checkPathPermissions(
594
+ ctx.userId,
595
+ repository.id,
596
+ scopedPaths,
597
+ "write"
598
+ );
599
+ if (!permission.allowed) {
600
+ throw new Error(permission.reason || "Insufficient path permissions");
601
+ }
602
+ }
569
603
570
604
const reviewId = generateId();
571
605
572
606
await ctx.db.insert(schema.pullRequestReviews).values({
573
607
id: reviewId,
574
- pullRequestId: input.pullRequestId,
608
+ pullRequestId: pullRequest.id,
575
609
reviewerId: ctx.userId,
576
610
state: input.event.toLowerCase(), // APPROVED, REQUEST_CHANGES, COMMENT, etc.
577
611
body: input.body,
585
619
for (const comment of input.comments) {
586
620
await ctx.db.insert(schema.pullRequestComments).values({
587
621
id: generateId(),
588
- pullRequestId: input.pullRequestId,
622
+ pullRequestId: pullRequest.id,
589
623
reviewId: reviewId,
590
624
authorId: ctx.userId,
591
625
body: comment.body,
621
655
});
622
656
623
657
if (issue) {
658
+ const repository = await ctx.db.query.repositories.findFirst({
659
+ where: eq(schema.repositories.id, issue.repositoryId),
660
+ });
661
+ if (!repository || !(await canWriteRepo(ctx.userId, repository, { isAdmin: ctx.user?.isAdmin ?? undefined }))) {
662
+ throw new Error("Insufficient repository permissions");
663
+ }
664
+
624
665
await ctx.db.insert(schema.issueComments).values({
625
666
id,
626
667
issueId: input.subjectId,
643
684
});
644
685
645
686
if (pr) {
687
+ const repository = await ctx.db.query.repositories.findFirst({
688
+ where: eq(schema.repositories.id, pr.repositoryId),
689
+ });
690
+ if (!repository || !(await canWriteRepo(ctx.userId, repository, { isAdmin: ctx.user?.isAdmin ?? undefined }))) {
691
+ throw new Error("Insufficient repository permissions");
692
+ }
693
+
646
694
await ctx.db.insert(schema.pullRequestComments).values({
647
695
id,
648
696
pullRequestId: input.subjectId,
649
697
▼src/pages/api/repos/[owner]/[repo]/pulls/[number]/comments.ts
+13-0
12
12
import { z } from "zod";
13
13
import crypto from "crypto";
14
14
import { checkPathPermissions } from "@/lib/path-scoping";
15
+import { canReadRepo, canWriteRepo } from "@/lib/permissions";
15
16
16
17
const createCommentSchema = z.object({
17
18
body: z.string().min(1),
62
63
const db = getDatabase() as NodePgDatabase<typeof schema>;
63
64
const repository = await getRepositoryByOwnerAndName(db, owner as string, repo as string);
64
65
if (!repository) return notFound("Repository not found");
66
+ if (!(await canReadRepo(tokenPayload.userId, repository, { isAdmin: tokenPayload.isAdmin }))) {
67
+ return notFound("Repository not found");
68
+ }
65
69
66
70
// Find PR
67
71
const pr = await db.query.pullRequests.findFirst({
122
126
const db = getDatabase() as NodePgDatabase<typeof schema>;
123
127
const repository = await getRepositoryByOwnerAndName(db, owner as string, repo as string);
124
128
if (!repository) return notFound("Repository not found");
129
+ if (!(await canWriteRepo(tokenPayload.userId, repository, { isAdmin: tokenPayload.isAdmin }))) {
130
+ return forbidden();
131
+ }
125
132
126
133
// Find PR
127
134
const pr = await db.query.pullRequests.findFirst({
198
205
const db = getDatabase() as NodePgDatabase<typeof schema>;
199
206
const repository = await getRepositoryByOwnerAndName(db, owner as string, repo as string);
200
207
if (!repository) return notFound("Repository not found");
208
+ if (!(await canWriteRepo(tokenPayload.userId, repository, { isAdmin: tokenPayload.isAdmin }))) {
209
+ return forbidden();
210
+ }
201
211
202
212
// Find comment
203
213
const comment = await db.query.pullRequestComments.findFirst({
256
266
const db = getDatabase() as NodePgDatabase<typeof schema>;
257
267
const repository = await getRepositoryByOwnerAndName(db, owner as string, repo as string);
258
268
if (!repository) return notFound("Repository not found");
269
+ if (!(await canWriteRepo(tokenPayload.userId, repository, { isAdmin: tokenPayload.isAdmin }))) {
270
+ return forbidden();
271
+ }
259
272
260
273
// Find comment
261
274
const comment = await db.query.pullRequestComments.findFirst({
262
275
▼tests/integration/pr-comments-path-permissions.test.ts
+28-1
1
1
import { beforeEach, describe, expect, it, vi } from "vitest";
2
2
3
-const { getUserFromRequestMock, checkPathPermissionsMock, fakeSchema } = vi.hoisted(() => ({
3
+const { getUserFromRequestMock, checkPathPermissionsMock, canReadRepoMock, canWriteRepoMock, fakeSchema } = vi.hoisted(() => ({
4
4
getUserFromRequestMock: vi.fn(async () => ({ userId: "user-1", isAdmin: false })),
5
5
checkPathPermissionsMock: vi.fn(async () => ({ allowed: true, deniedPaths: [] })),
6
+ canReadRepoMock: vi.fn(async () => true),
7
+ canWriteRepoMock: vi.fn(async () => true),
6
8
fakeSchema: {
7
9
users: { username: {}, id: {} },
8
10
repositories: { ownerId: {}, name: {}, id: {} },
26
28
checkPathPermissions: checkPathPermissionsMock,
27
29
}));
28
30
31
+vi.mock("@/lib/permissions", () => ({
32
+ canReadRepo: canReadRepoMock,
33
+ canWriteRepo: canWriteRepoMock,
34
+}));
35
+
29
36
import { POST as createCommentPost } from "@/pages/api/repos/[owner]/[repo]/pulls/[number]/comments";
30
37
import { PATCH as updateCommentPatch } from "@/pages/api/repos/[owner]/[repo]/pulls/[number]/comments";
31
38
import { DELETE as deleteCommentDelete } from "@/pages/api/repos/[owner]/[repo]/pulls/[number]/comments";
98
105
mockDb = makeDb();
99
106
getUserFromRequestMock.mockResolvedValue({ userId: "user-1", isAdmin: false });
100
107
checkPathPermissionsMock.mockResolvedValue({ allowed: true, deniedPaths: [] });
108
+ canReadRepoMock.mockResolvedValue(true);
109
+ canWriteRepoMock.mockResolvedValue(true);
101
110
});
102
111
103
112
it("blocks inline comment creation when path write permission is denied", async () => {
122
131
expect(mockDb.__state.insertCalls).toHaveLength(0);
123
132
});
124
133
134
+ it("blocks comment creation when repository write permission is denied", async () => {
135
+ canWriteRepoMock.mockResolvedValue(false);
136
+
137
+ const response = await createCommentPost({
138
+ params: { owner: "owner-1", repo: "demo", number: "42" },
139
+ request: new Request("http://localhost/api/repos/owner-1/demo/pulls/42/comments", {
140
+ method: "POST",
141
+ headers: { "content-type": "application/json" },
142
+ body: JSON.stringify({ body: "blocked", path: "src/app.ts", line: 1 }),
143
+ }),
144
+ } as any);
145
+
146
+ const body = await readJson(response);
147
+ expect(response.status).toBe(403);
148
+ expect(body?.error?.code).toBe("FORBIDDEN");
149
+ expect(mockDb.__state.insertCalls).toHaveLength(0);
150
+ });
151
+
125
152
it("allows comment creation when path permission check passes", async () => {
126
153
const response = await createCommentPost({
127
154
params: { owner: "owner-1", repo: "demo", number: "42" },
128
155
▼tests/unit/graphql-add-comment-permissions.test.ts
+66-0
1
+import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+const { canWriteRepoMock } = vi.hoisted(() => ({
4
+ canWriteRepoMock: vi.fn(async () => true),
5
+}));
6
+
7
+vi.mock("@/lib/permissions", async () => {
8
+ const actual = await vi.importActual<any>("@/lib/permissions");
9
+ return {
10
+ ...actual,
11
+ canWriteRepo: canWriteRepoMock,
12
+ };
13
+});
14
+
15
+import { resolvers } from "@/lib/graphql/resolvers";
16
+
17
+function makeContext() {
18
+ const insertCalls: unknown[] = [];
19
+ return {
20
+ db: {
21
+ query: {
22
+ issues: {
23
+ findFirst: vi.fn(async () => null),
24
+ },
25
+ pullRequests: {
26
+ findFirst: vi.fn(async () => ({ id: "pr-1", repositoryId: "repo-1" })),
27
+ },
28
+ repositories: {
29
+ findFirst: vi.fn(async () => ({ id: "repo-1" })),
30
+ },
31
+ pullRequestComments: {
32
+ findFirst: vi.fn(async () => ({ id: "comment-1" })),
33
+ },
34
+ },
35
+ insert: vi.fn(() => ({
36
+ values: vi.fn(async (value: unknown) => {
37
+ insertCalls.push(value);
38
+ }),
39
+ })),
40
+ __state: { insertCalls },
41
+ },
42
+ userId: "user-1",
43
+ user: { isAdmin: false },
44
+ } as any;
45
+}
46
+
47
+describe("GraphQL addComment permission enforcement", () => {
48
+ beforeEach(() => {
49
+ canWriteRepoMock.mockResolvedValue(true);
50
+ });
51
+
52
+ it("blocks PR comment creation when user lacks repository write permission", async () => {
53
+ const ctx = makeContext();
54
+ canWriteRepoMock.mockResolvedValue(false);
55
+
56
+ await expect(
57
+ resolvers.Mutation.addComment(
58
+ {},
59
+ { input: { subjectId: "pr-1", body: "test comment" } },
60
+ ctx
61
+ )
62
+ ).rejects.toThrow("Insufficient repository permissions");
63
+
64
+ expect(ctx.db.__state.insertCalls).toHaveLength(0);
65
+ });
66
+});
0
67
▼tests/unit/graphql-review-permissions.test.ts
+80-0
1
+import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+const { canWriteRepoMock, checkPathPermissionsMock } = vi.hoisted(() => ({
4
+ canWriteRepoMock: vi.fn(async () => true),
5
+ checkPathPermissionsMock: vi.fn(async () => ({ allowed: true, deniedPaths: [] })),
6
+}));
7
+
8
+vi.mock("@/lib/permissions", async () => {
9
+ const actual = await vi.importActual<any>("@/lib/permissions");
10
+ return {
11
+ ...actual,
12
+ canWriteRepo: canWriteRepoMock,
13
+ };
14
+});
15
+
16
+vi.mock("@/lib/path-scoping", () => ({
17
+ checkPathPermissions: checkPathPermissionsMock,
18
+}));
19
+
20
+import { resolvers } from "@/lib/graphql/resolvers";
21
+
22
+function makeContext() {
23
+ const insertCalls: unknown[] = [];
24
+ return {
25
+ db: {
26
+ query: {
27
+ pullRequests: {
28
+ findFirst: vi.fn(async () => ({ id: "pr-1", repositoryId: "repo-1" })),
29
+ },
30
+ repositories: {
31
+ findFirst: vi.fn(async () => ({ id: "repo-1" })),
32
+ },
33
+ pullRequestReviews: {
34
+ findFirst: vi.fn(async () => ({ id: "review-1" })),
35
+ },
36
+ },
37
+ insert: vi.fn(() => ({
38
+ values: vi.fn(async (value: unknown) => {
39
+ insertCalls.push(value);
40
+ }),
41
+ })),
42
+ __state: { insertCalls },
43
+ },
44
+ userId: "user-1",
45
+ user: { isAdmin: false },
46
+ } as any;
47
+}
48
+
49
+describe("GraphQL addPullRequestReview permission enforcement", () => {
50
+ beforeEach(() => {
51
+ canWriteRepoMock.mockResolvedValue(true);
52
+ checkPathPermissionsMock.mockResolvedValue({ allowed: true, deniedPaths: [] });
53
+ });
54
+
55
+ it("blocks review submission when path write permission is denied", async () => {
56
+ const ctx = makeContext();
57
+ checkPathPermissionsMock.mockResolvedValue({
58
+ allowed: false,
59
+ deniedPaths: ["secure/config.yml"],
60
+ reason: "Insufficient permissions for paths: secure/config.yml",
61
+ });
62
+
63
+ await expect(
64
+ resolvers.Mutation.addPullRequestReview(
65
+ {},
66
+ {
67
+ input: {
68
+ pullRequestId: "pr-1",
69
+ event: "COMMENT",
70
+ body: "review",
71
+ comments: [{ body: "inline", path: "secure/config.yml", line: 3 }],
72
+ },
73
+ },
74
+ ctx
75
+ )
76
+ ).rejects.toThrow("Insufficient permissions for paths: secure/config.yml");
77
+
78
+ expect(ctx.db.__state.insertCalls).toHaveLength(0);
79
+ });
80
+});
0
81