Enforce path permissions for PR impact scans

swadhinbiswas committed 8 months ago
8f48521
1 parent 677e3c0
commit 8f48521
3 files changed
+255 -1
▼doc/feature_audit.md
+1-1
36
36
| Branch protection rules | ✅ | Schema + UI + API in place |
37
37
| Repository templates | ⚠️ | Template creation and listing are implemented with access-aware discovery filters (`/repos/templates?q=&owner=&visibility=`) including collaborator-visible private templates; dedicated template governance UX remains partial |
38
38
| Repository Wiki | ✅ | Implemented (Phase 8) |
39
-| File-level permissions | ⚠️ | Path-scoped enforcement now covers PR comment create/edit/delete, single-review inline comments, batch-review comment submission, suggestion-apply, partial file-approval create/revoke flows, and PR rewrite operations; broader endpoint coverage still remains |
39
+| File-level permissions | ⚠️ | Path-scoped enforcement now covers PR comment create/edit/delete, single-review inline comments, batch-review comment submission, suggestion-apply, partial file-approval create/revoke flows, PR rewrite operations, and PR impact scans; broader endpoint coverage still remains |
40
40
| Commit signing (GPG) | ✅ | GPG key management UI exists |
41
41
| Commit signing (SSH) | ✅ | SSH key management UI exists |
42
42
43
43
▼src/pages/api/repos/[owner]/[repo]/pulls/[number]/impact.ts
+120-0
1
+import type { APIRoute } from "astro";
2
+import type { NodePgDatabase } from "drizzle-orm/node-postgres";
3
+import { and, eq } from "drizzle-orm";
4
+import { z } from "zod";
5
+import { getDatabase, schema } from "@/db";
6
+import { badRequest, forbidden, notFound, parseBody, success, unauthorized } from "@/lib/api";
7
+import { withErrorHandler } from "@/lib/errors";
8
+import { canReadRepo, canWriteRepo } from "@/lib/permissions";
9
+import { compareBranches } from "@/lib/git";
10
+import { resolveRepoPath } from "@/lib/git-storage";
11
+import { checkPathPermissions } from "@/lib/path-scoping";
12
+import {
13
+ analyzeImpact,
14
+ detectBreakingChanges,
15
+ detectMigrations,
16
+} from "@/lib/dependency-awareness";
17
+import { detectIaCFiles, triggerIaCHooks } from "@/lib/iac-hooks";
18
+
19
+const scanSchema = z.object({
20
+ persist: z.boolean().optional().default(true),
21
+ runIaCHooks: z.boolean().optional().default(false),
22
+ iacAction: z.enum(["plan", "apply"]).optional().default("plan"),
23
+ iacRunId: z.string().optional(),
24
+});
25
+
26
+async function resolveRepoAndPr(owner: string, repoName: string, number: number) {
27
+ const db = getDatabase() as NodePgDatabase<typeof schema>;
28
+ const repoOwner = await db.query.users.findFirst({
29
+ where: eq(schema.users.username, owner),
30
+ });
31
+ if (!repoOwner) return null;
32
+ const repository = await db.query.repositories.findFirst({
33
+ where: and(
34
+ eq(schema.repositories.ownerId, repoOwner.id),
35
+ eq(schema.repositories.name, repoName)
36
+ ),
37
+ });
38
+ if (!repository) return null;
39
+ const pr = await db.query.pullRequests.findFirst({
40
+ where: and(
41
+ eq(schema.pullRequests.repositoryId, repository.id),
42
+ eq(schema.pullRequests.number, number)
43
+ ),
44
+ });
45
+ if (!pr) return null;
46
+ return { repository, pr };
47
+}
48
+
49
+export const GET: APIRoute = withErrorHandler(async ({ params, locals }) => {
50
+ const owner = params.owner;
51
+ const repoName = params.repo;
52
+ const number = Number(params.number);
53
+ if (!owner || !repoName || Number.isNaN(number)) return badRequest("Invalid route parameters");
54
+
55
+ const resolved = await resolveRepoAndPr(owner, repoName, number);
56
+ if (!resolved) return notFound("Pull request not found");
57
+
58
+ if (!(await canReadRepo(locals.user?.id, resolved.repository, { isAdmin: locals.user?.isAdmin }))) {
59
+ return notFound("Pull request not found");
60
+ }
61
+
62
+ const impact = await analyzeImpact(resolved.pr.id);
63
+ return success(impact);
64
+});
65
+
66
+export const POST: APIRoute = withErrorHandler(async ({ params, locals, request }) => {
67
+ const owner = params.owner;
68
+ const repoName = params.repo;
69
+ const number = Number(params.number);
70
+ if (!owner || !repoName || Number.isNaN(number)) return badRequest("Invalid route parameters");
71
+ if (!locals.user) return unauthorized();
72
+
73
+ const parsed = await parseBody(request, scanSchema);
74
+ if ("error" in parsed) return parsed.error;
75
+
76
+ const resolved = await resolveRepoAndPr(owner, repoName, number);
77
+ if (!resolved) return notFound("Pull request not found");
78
+ if (!(await canWriteRepo(locals.user.id, resolved.repository, { isAdmin: locals.user.isAdmin }))) {
79
+ return forbidden();
80
+ }
81
+
82
+ const repoPath = await resolveRepoPath(resolved.repository.diskPath);
83
+ const { diffs } = await compareBranches(repoPath, resolved.pr.baseBranch, resolved.pr.headBranch);
84
+ const changedFiles = diffs.map((d) => d.file).filter(Boolean);
85
+
86
+ if (changedFiles.length > 0) {
87
+ const permission = await checkPathPermissions(
88
+ locals.user.id,
89
+ resolved.repository.id,
90
+ changedFiles,
91
+ "write"
92
+ );
93
+ if (!permission.allowed) {
94
+ return forbidden(permission.reason || "Insufficient path permissions for one or more changed files");
95
+ }
96
+ }
97
+
98
+ const breaking = await detectBreakingChanges(resolved.pr.id);
99
+ const migrations = await detectMigrations(resolved.pr.id, changedFiles);
100
+ const impact = await analyzeImpact(resolved.pr.id);
101
+ const iacFiles = detectIaCFiles(changedFiles);
102
+ const iacHookResults =
103
+ parsed.data.runIaCHooks && iacFiles.length > 0
104
+ ? await triggerIaCHooks({
105
+ repositoryId: resolved.repository.id,
106
+ action: parsed.data.iacAction ?? "plan",
107
+ runId: parsed.data.iacRunId,
108
+ message: `Triggered from PR #${resolved.pr.number} impact scan`,
109
+ })
110
+ : [];
111
+
112
+ return success({
113
+ breakingDetected: breaking.length,
114
+ migrationsDetected: migrations.length,
115
+ iacFilesDetected: iacFiles.length,
116
+ iacHookRuns: iacHookResults,
117
+ impact,
118
+ persisted: parsed.data.persist,
119
+ });
120
+});
0
121
▼tests/integration/pr-impact-path-permissions.test.ts
+134-0
1
+import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+const {
4
+ canWriteRepoMock,
5
+ checkPathPermissionsMock,
6
+ compareBranchesMock,
7
+ analyzeImpactMock,
8
+ detectBreakingChangesMock,
9
+ detectMigrationsMock,
10
+ detectIaCFilesMock,
11
+ triggerIaCHooksMock,
12
+ fakeSchema,
13
+} = vi.hoisted(() => ({
14
+ canWriteRepoMock: vi.fn(async () => true),
15
+ checkPathPermissionsMock: vi.fn(async () => ({ allowed: true, deniedPaths: [] })),
16
+ compareBranchesMock: vi.fn(async () => ({ diffs: [{ file: "src/app.ts" }] })),
17
+ analyzeImpactMock: vi.fn(async () => ({ breakingChanges: [], migrations: [] })),
18
+ detectBreakingChangesMock: vi.fn(async () => []),
19
+ detectMigrationsMock: vi.fn(async () => []),
20
+ detectIaCFilesMock: vi.fn(() => []),
21
+ triggerIaCHooksMock: vi.fn(async () => []),
22
+ fakeSchema: {
23
+ users: { username: {} },
24
+ repositories: { ownerId: {}, name: {}, id: {} },
25
+ pullRequests: { repositoryId: {}, number: {}, id: {} },
26
+ } as any,
27
+}));
28
+
29
+let mockDb: any;
30
+
31
+vi.mock("@/db", () => ({
32
+ getDatabase: () => mockDb,
33
+ schema: fakeSchema,
34
+}));
35
+
36
+vi.mock("@/lib/permissions", () => ({
37
+ canReadRepo: vi.fn(async () => true),
38
+ canWriteRepo: canWriteRepoMock,
39
+}));
40
+
41
+vi.mock("@/lib/git-storage", () => ({
42
+ resolveRepoPath: vi.fn(async () => "/tmp/repo"),
43
+}));
44
+
45
+vi.mock("@/lib/git", () => ({
46
+ compareBranches: compareBranchesMock,
47
+}));
48
+
49
+vi.mock("@/lib/path-scoping", () => ({
50
+ checkPathPermissions: checkPathPermissionsMock,
51
+}));
52
+
53
+vi.mock("@/lib/dependency-awareness", () => ({
54
+ analyzeImpact: analyzeImpactMock,
55
+ detectBreakingChanges: detectBreakingChangesMock,
56
+ detectMigrations: detectMigrationsMock,
57
+}));
58
+
59
+vi.mock("@/lib/iac-hooks", () => ({
60
+ detectIaCFiles: detectIaCFilesMock,
61
+ triggerIaCHooks: triggerIaCHooksMock,
62
+}));
63
+
64
+import { POST as impactPost } from "@/pages/api/repos/[owner]/[repo]/pulls/[number]/impact";
65
+
66
+function makeDb() {
67
+ return {
68
+ query: {
69
+ users: {
70
+ findFirst: vi.fn(async () => ({ id: "owner-1" })),
71
+ },
72
+ repositories: {
73
+ findFirst: vi.fn(async () => ({ id: "repo-1", ownerId: "owner-1", name: "demo", diskPath: "/tmp/repo" })),
74
+ },
75
+ pullRequests: {
76
+ findFirst: vi.fn(async () => ({
77
+ id: "pr-1",
78
+ repositoryId: "repo-1",
79
+ number: 42,
80
+ baseBranch: "main",
81
+ headBranch: "feature",
82
+ })),
83
+ },
84
+ },
85
+ };
86
+}
87
+
88
+describe("PR impact path permissions", () => {
89
+ beforeEach(() => {
90
+ vi.clearAllMocks();
91
+ mockDb = makeDb();
92
+ canWriteRepoMock.mockResolvedValue(true);
93
+ checkPathPermissionsMock.mockResolvedValue({ allowed: true, deniedPaths: [] });
94
+ compareBranchesMock.mockResolvedValue({ diffs: [{ file: "src/app.ts" }] });
95
+ });
96
+
97
+ it("blocks impact scan when changed files are not path-authorized", async () => {
98
+ checkPathPermissionsMock.mockResolvedValue({
99
+ allowed: false,
100
+ deniedPaths: ["src/secret.ts"],
101
+ reason: "Insufficient permissions for paths: src/secret.ts",
102
+ });
103
+ compareBranchesMock.mockResolvedValue({ diffs: [{ file: "src/secret.ts" }] });
104
+
105
+ const response = await impactPost({
106
+ params: { owner: "owner-1", repo: "demo", number: "42" },
107
+ locals: { user: { id: "user-1", isAdmin: false } },
108
+ request: new Request("http://localhost/api/repos/owner-1/demo/pulls/42/impact", {
109
+ method: "POST",
110
+ headers: { "content-type": "application/json" },
111
+ body: JSON.stringify({}),
112
+ }),
113
+ } as any);
114
+
115
+ expect(response.status).toBe(403);
116
+ expect(detectBreakingChangesMock).not.toHaveBeenCalled();
117
+ });
118
+
119
+ it("runs impact scan when path permissions pass", async () => {
120
+ const response = await impactPost({
121
+ params: { owner: "owner-1", repo: "demo", number: "42" },
122
+ locals: { user: { id: "user-1", isAdmin: false } },
123
+ request: new Request("http://localhost/api/repos/owner-1/demo/pulls/42/impact", {
124
+ method: "POST",
125
+ headers: { "content-type": "application/json" },
126
+ body: JSON.stringify({}),
127
+ }),
128
+ } as any);
129
+
130
+ expect(response.status).toBe(200);
131
+ expect(checkPathPermissionsMock).toHaveBeenCalledWith("user-1", "repo-1", ["src/app.ts"], "write");
132
+ expect(detectBreakingChangesMock).toHaveBeenCalledWith("pr-1");
133
+ });
134
+});
0
135