Enforce path permissions for PR rewrite operations

swadhinbiswas committed 8 months ago
677e3c0
1 parent bae5051
commit 677e3c0
4 files changed
+170 -2
▼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, and partial file-approval create/revoke flows; 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, and PR rewrite operations; 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/lib/git-rewrite.ts
+25-0
87
87
}
88
88
}
89
89
90
+export async function getRewriteOperationFiles(
91
+ repoOwner: string,
92
+ repoName: string,
93
+ operations: RewriteOperation[]
94
+): Promise<string[]> {
95
+ const repoPath = await acquireRepo(repoOwner, repoName);
96
+ const git = simpleGit(repoPath);
97
+
98
+ try {
99
+ const files = new Set<string>();
100
+
101
+ for (const op of operations) {
102
+ const output = await git.raw(["show", "--pretty=format:", "--name-only", op.hash]);
103
+ for (const line of output.split("\n")) {
104
+ const path = line.trim();
105
+ if (path.length > 0) files.add(path);
106
+ }
107
+ }
108
+
109
+ return [...files.values()];
110
+ } finally {
111
+ await releaseRepo(repoOwner, repoName, false);
112
+ }
113
+}
114
+
90
115
async function readCommitMessage(git: SimpleGit, rev: string): Promise<string> {
91
116
return (await git.raw(["show", "-s", "--format=%B", rev])).trim();
92
117
}
93
118
▼src/pages/api/repos/[owner]/[repo]/pulls/[number]/rewrite.ts
+24-1
3
3
import { getDatabase, schema } from "@/db";
4
4
import { eq, and } from "drizzle-orm";
5
5
import { canWriteRepo } from "@/lib/permissions";
6
-import { rewriteBranchHistory, type RewriteOperation } from "@/lib/git-rewrite";
6
+import { checkPathPermissions } from "@/lib/path-scoping";
7
+import {
8
+ getRewriteOperationFiles,
9
+ rewriteBranchHistory,
10
+ type RewriteOperation
11
+} from "@/lib/git-rewrite";
7
12
8
13
export const POST: APIRoute = async ({ params, request, locals }) => {
9
14
const { owner, repo: repoName, number } = params;
52
57
return new Response("Invalid operations", { status: 400 });
53
58
}
54
59
60
+ const touchedFiles = await getRewriteOperationFiles(
61
+ repository.owner.username,
62
+ repository.name,
63
+ operations
64
+ );
65
+
66
+ if (touchedFiles.length > 0) {
67
+ const permission = await checkPathPermissions(
68
+ user.id,
69
+ repository.id,
70
+ touchedFiles,
71
+ "write"
72
+ );
73
+ if (!permission.allowed) {
74
+ return new Response(permission.reason || "Insufficient path permissions", { status: 403 });
75
+ }
76
+ }
77
+
55
78
// 3. Execute Rewrite
56
79
await rewriteBranchHistory(
57
80
repository.owner.username,
58
81
▼tests/integration/pulls-rewrite-path-permissions.test.ts
+120-0
1
+import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+const {
4
+ canWriteRepoMock,
5
+ checkPathPermissionsMock,
6
+ getRewriteOperationFilesMock,
7
+ rewriteBranchHistoryMock,
8
+ fakeSchema,
9
+} = vi.hoisted(() => ({
10
+ canWriteRepoMock: vi.fn(async () => true),
11
+ checkPathPermissionsMock: vi.fn(async () => ({ allowed: true, deniedPaths: [] })),
12
+ getRewriteOperationFilesMock: vi.fn(async () => ["src/app.ts"]),
13
+ rewriteBranchHistoryMock: vi.fn(async () => undefined),
14
+ fakeSchema: {
15
+ repositories: { name: {}, ownerId: {}, id: {} },
16
+ users: { username: {} },
17
+ pullRequests: { repositoryId: {}, number: {} },
18
+ } as any,
19
+}));
20
+
21
+let mockDb: any;
22
+
23
+vi.mock("@/db", () => ({
24
+ getDatabase: () => mockDb,
25
+ schema: fakeSchema,
26
+}));
27
+
28
+vi.mock("@/lib/permissions", () => ({
29
+ canWriteRepo: canWriteRepoMock,
30
+}));
31
+
32
+vi.mock("@/lib/path-scoping", () => ({
33
+ checkPathPermissions: checkPathPermissionsMock,
34
+}));
35
+
36
+vi.mock("@/lib/git-rewrite", () => ({
37
+ getRewriteOperationFiles: getRewriteOperationFilesMock,
38
+ rewriteBranchHistory: rewriteBranchHistoryMock,
39
+}));
40
+
41
+import { POST as rewritePost } from "@/pages/api/repos/[owner]/[repo]/pulls/[number]/rewrite";
42
+
43
+function makeDb() {
44
+ return {
45
+ query: {
46
+ users: {
47
+ findFirst: vi.fn(async () => ({ id: "owner-1" })),
48
+ },
49
+ repositories: {
50
+ findFirst: vi.fn(async () => ({
51
+ id: "repo-1",
52
+ name: "demo",
53
+ ownerId: "owner-1",
54
+ owner: { username: "owner-1" },
55
+ })),
56
+ },
57
+ pullRequests: {
58
+ findFirst: vi.fn(async () => ({
59
+ id: "pr-1",
60
+ number: 42,
61
+ repositoryId: "repo-1",
62
+ baseBranch: "main",
63
+ headBranch: "feature",
64
+ })),
65
+ },
66
+ },
67
+ };
68
+}
69
+
70
+describe("pull rewrite path permissions", () => {
71
+ beforeEach(() => {
72
+ vi.clearAllMocks();
73
+ mockDb = makeDb();
74
+ canWriteRepoMock.mockResolvedValue(true);
75
+ checkPathPermissionsMock.mockResolvedValue({ allowed: true, deniedPaths: [] });
76
+ getRewriteOperationFilesMock.mockResolvedValue(["src/app.ts"]);
77
+ });
78
+
79
+ it("blocks rewrite when touched paths are denied", async () => {
80
+ checkPathPermissionsMock.mockResolvedValue({
81
+ allowed: false,
82
+ deniedPaths: ["src/secret.ts"],
83
+ reason: "Insufficient permissions for paths: src/secret.ts",
84
+ });
85
+
86
+ const response = await rewritePost({
87
+ params: { owner: "owner-1", repo: "demo", number: "42" },
88
+ locals: { user: { id: "user-1" } },
89
+ request: new Request("http://localhost/api/repos/owner-1/demo/pulls/42/rewrite", {
90
+ method: "POST",
91
+ headers: { "content-type": "application/json" },
92
+ body: JSON.stringify({
93
+ operations: [{ type: "pick", hash: "abc123" }],
94
+ }),
95
+ }),
96
+ } as any);
97
+
98
+ expect(response.status).toBe(403);
99
+ expect(rewriteBranchHistoryMock).not.toHaveBeenCalled();
100
+ });
101
+
102
+ it("executes rewrite when touched paths are allowed", async () => {
103
+ const response = await rewritePost({
104
+ params: { owner: "owner-1", repo: "demo", number: "42" },
105
+ locals: { user: { id: "user-1" } },
106
+ request: new Request("http://localhost/api/repos/owner-1/demo/pulls/42/rewrite", {
107
+ method: "POST",
108
+ headers: { "content-type": "application/json" },
109
+ body: JSON.stringify({
110
+ operations: [{ type: "pick", hash: "abc123" }],
111
+ }),
112
+ }),
113
+ } as any);
114
+
115
+ expect(response.status).toBe(200);
116
+ expect(getRewriteOperationFilesMock).toHaveBeenCalled();
117
+ expect(checkPathPermissionsMock).toHaveBeenCalledWith("user-1", "repo-1", ["src/app.ts"], "write");
118
+ expect(rewriteBranchHistoryMock).toHaveBeenCalled();
119
+ });
120
+});
0
121