Add advanced monorepo path-scope management and enforcement

swadhinbiswas committed 8 months ago
f321d69
1 parent 75ea0df
commit f321d69
5 files changed
+461 -33
▼doc/feature_audit.md
+1-1
30
30
| Repository hosting | ✅ | Multiple storage adapters (S3, R2, local, GDrive) |
31
31
| Repository mirroring | ⚠️ | Background sync cron plus repository mirror config/manual-sync APIs are implemented (`settings/mirror`, `settings/mirror/sync`) with derived health telemetry (`isHealthy`, `isStale`, `lastSyncAgeMinutes`); scheduled sync automation now supports stale-only windows, sync-interval/max-repo controls, and batch monitoring metrics (`eligible`, `skipped`, `stale`, `failedRepoIds`, `durationMs`) via `cron/mirror-sync`, while advanced alerting/runbook workflows remain partial |
32
32
| Forks & pull-based workflows | ✅ | `fork.ts` API, UI implemented |
33
-| Monorepo support | ⚠️ | Basic support, no advanced path scoping |
33
+| Monorepo support | ✅ | Advanced path scoping is implemented: repository settings CRUD APIs for path rules (`settings/path-permissions`), read/write enforcement across PR/review/comment/impact/template flows, and pre-receive push checks now use centralized permission-level evaluation (`checkPathPermissions`) for protected monorepo paths |
34
34
| Git LFS | ✅ | `lfs.ts` with batch API (upload/download) |
35
35
| Submodules | ✅ | Native Git support, no special handling needed |
36
36
| Branch protection rules | ✅ | Schema + UI + API in place |
37
37
▼src/pages/api/internal/hooks/pre-receive.ts
+4-32
3
3
import { logger } from "@/lib/logger";
4
4
import type { APIRoute } from "astro";
5
5
import { eq, and } from "drizzle-orm";
6
-import micromatch from "micromatch";
7
6
import { resolveRepoPath } from "@/lib/git-storage";
7
+import { checkPathPermissions } from "@/lib/path-scoping";
8
8
9
9
export const POST: APIRoute = async ({ request, url }) => {
10
10
const hookSecret = process.env.INTERNAL_HOOK_SECRET;
227
227
}
228
228
229
229
if (changedFiles.length > 0) {
230
- // Check permissions
231
- // 1. Get user's teams
232
- const userTeams = await db.query.teamMembers.findMany({
233
- where: eq(schema.teamMembers.userId, userId),
234
- with: { team: true }
235
- });
236
- const userTeamIds = userTeams.map(tm => tm.teamId);
237
-
238
- // 2. Identify rules that grant access to this user
239
- const userAllowedRules = strictPathPermissions.filter(rule =>
240
- (rule.userId === userId) || (rule.teamId && userTeamIds.includes(rule.teamId))
241
- );
242
-
243
- // 3. Check each file
244
- for (const file of changedFiles) {
245
- // Find all rules that 'protect' this file (match the path)
246
- const protectingRules = strictPathPermissions.filter(rule =>
247
- micromatch.isMatch(file, rule.pathPattern)
248
- );
249
-
250
- if (protectingRules.length > 0) {
251
- // File is protected. User must match at least one ALLOWED rule that covers this file.
252
- // (i.e. one of userAllowedRules must match the file)
253
- const hasAccess = userAllowedRules.some(allowed =>
254
- micromatch.isMatch(file, allowed.pathPattern)
255
- );
256
-
257
- if (!hasAccess) {
258
- return new Response(`Permission denied for ${file}. Protected path.`, { status: 403 });
259
- }
260
- }
230
+ const permission = await checkPathPermissions(userId, repo.id, changedFiles, "write");
231
+ if (!permission.allowed) {
232
+ return new Response(permission.reason || "Write access denied by path permissions.", { status: 403 });
261
233
}
262
234
}
263
235
}
264
236
▼src/pages/api/repos/[owner]/[repo]/settings/path-permissions.ts
+118-0
1
+import type { APIRoute } from "astro";
2
+import type { NodePgDatabase } from "drizzle-orm/node-postgres";
3
+import { and, desc, eq } from "drizzle-orm";
4
+import { z } from "zod";
5
+import { getDatabase, schema } from "@/db";
6
+import { badRequest, created, forbidden, notFound, parseBody, success, unauthorized } from "@/lib/api";
7
+import { withErrorHandler } from "@/lib/errors";
8
+import { getUserFromRequest } from "@/lib/auth";
9
+import { canAdminRepo, canReadRepo } from "@/lib/permissions";
10
+import { getPathPermissions } from "@/lib/path-scoping";
11
+
12
+const createPathPermissionSchema = z.object({
13
+ pathPattern: z.string().min(1).max(500),
14
+ userId: z.string().min(1).max(128).optional(),
15
+ teamId: z.string().min(1).max(128).optional(),
16
+ permission: z.enum(["read", "write", "admin"]),
17
+ requireApproval: z.boolean().optional(),
18
+}).superRefine((value, ctx) => {
19
+ if (!value.userId && !value.teamId) {
20
+ ctx.addIssue({
21
+ code: "custom",
22
+ path: ["userId"],
23
+ message: "Either userId or teamId is required",
24
+ });
25
+ }
26
+ if (value.userId && value.teamId) {
27
+ ctx.addIssue({
28
+ code: "custom",
29
+ path: ["teamId"],
30
+ message: "Provide only one of userId or teamId",
31
+ });
32
+ }
33
+});
34
+
35
+async function resolveRepository(owner: string, repoName: string) {
36
+ const db = getDatabase() as NodePgDatabase<typeof schema>;
37
+ const ownerUser = await db.query.users.findFirst({
38
+ where: eq(schema.users.username, owner),
39
+ });
40
+ if (!ownerUser) return null;
41
+
42
+ return db.query.repositories.findFirst({
43
+ where: and(
44
+ eq(schema.repositories.ownerId, ownerUser.id),
45
+ eq(schema.repositories.name, repoName)
46
+ ),
47
+ });
48
+}
49
+
50
+export const GET: APIRoute = withErrorHandler(async ({ params, request }) => {
51
+ const owner = params.owner;
52
+ const repoName = params.repo;
53
+ if (!owner || !repoName) return badRequest("Missing route parameters");
54
+
55
+ const user = await getUserFromRequest(request);
56
+ const repository = await resolveRepository(owner, repoName);
57
+ if (!repository) return notFound("Repository not found");
58
+
59
+ if (!(await canReadRepo(user?.userId, repository, { isAdmin: user?.isAdmin }))) {
60
+ return notFound("Repository not found");
61
+ }
62
+
63
+ const rules = await getPathPermissions(repository.id);
64
+
65
+ return success({
66
+ rules,
67
+ summary: {
68
+ totalRules: rules.length,
69
+ requireApprovalRules: rules.filter((rule) => rule.requireApproval === "true").length,
70
+ },
71
+ });
72
+});
73
+
74
+export const POST: APIRoute = withErrorHandler(async ({ params, request }) => {
75
+ const owner = params.owner;
76
+ const repoName = params.repo;
77
+ if (!owner || !repoName) return badRequest("Missing route parameters");
78
+
79
+ const user = await getUserFromRequest(request);
80
+ if (!user) return unauthorized();
81
+
82
+ const db = getDatabase() as NodePgDatabase<typeof schema>;
83
+ const repository = await resolveRepository(owner, repoName);
84
+ if (!repository) return notFound("Repository not found");
85
+
86
+ if (!(await canAdminRepo(user.userId, repository, { isAdmin: user.isAdmin }))) {
87
+ return forbidden();
88
+ }
89
+
90
+ const parsed = await parseBody(request, createPathPermissionSchema);
91
+ if ("error" in parsed) return parsed.error;
92
+
93
+ const payload = parsed.data;
94
+ const now = new Date();
95
+ const id = crypto.randomUUID();
96
+
97
+ await db.insert(schema.repositoryPathPermissions).values({
98
+ id,
99
+ repositoryId: repository.id,
100
+ pathPattern: payload.pathPattern,
101
+ userId: payload.userId ?? null,
102
+ teamId: payload.teamId ?? null,
103
+ permission: payload.permission,
104
+ requireApproval: payload.requireApproval ? "true" : "false",
105
+ createdAt: now,
106
+ updatedAt: now,
107
+ });
108
+
109
+ return created({
110
+ id,
111
+ repositoryId: repository.id,
112
+ pathPattern: payload.pathPattern,
113
+ userId: payload.userId ?? null,
114
+ teamId: payload.teamId ?? null,
115
+ permission: payload.permission,
116
+ requireApproval: payload.requireApproval ? "true" : "false",
117
+ });
118
+});
0
119
▼src/pages/api/repos/[owner]/[repo]/settings/path-permissions/[id].ts
+130-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 { getUserFromRequest } from "@/lib/auth";
9
+import { canAdminRepo } from "@/lib/permissions";
10
+
11
+const updatePathPermissionSchema = z.object({
12
+ pathPattern: z.string().min(1).max(500).optional(),
13
+ userId: z.string().min(1).max(128).nullable().optional(),
14
+ teamId: z.string().min(1).max(128).nullable().optional(),
15
+ permission: z.enum(["read", "write", "admin"]).optional(),
16
+ requireApproval: z.boolean().optional(),
17
+}).superRefine((value, ctx) => {
18
+ if (value.userId !== undefined && value.teamId !== undefined) {
19
+ const hasUser = value.userId !== null && value.userId.length > 0;
20
+ const hasTeam = value.teamId !== null && value.teamId.length > 0;
21
+ if (hasUser === hasTeam) {
22
+ ctx.addIssue({
23
+ code: "custom",
24
+ path: ["teamId"],
25
+ message: "When updating assignee target, provide exactly one of userId or teamId",
26
+ });
27
+ }
28
+ }
29
+});
30
+
31
+async function resolveRepository(owner: string, repoName: string) {
32
+ const db = getDatabase() as NodePgDatabase<typeof schema>;
33
+ const ownerUser = await db.query.users.findFirst({
34
+ where: eq(schema.users.username, owner),
35
+ });
36
+ if (!ownerUser) return null;
37
+
38
+ return db.query.repositories.findFirst({
39
+ where: and(
40
+ eq(schema.repositories.ownerId, ownerUser.id),
41
+ eq(schema.repositories.name, repoName)
42
+ ),
43
+ });
44
+}
45
+
46
+export const PUT: APIRoute = withErrorHandler(async ({ params, request }) => {
47
+ const owner = params.owner;
48
+ const repoName = params.repo;
49
+ const id = params.id;
50
+ if (!owner || !repoName || !id) return badRequest("Missing route parameters");
51
+
52
+ const user = await getUserFromRequest(request);
53
+ if (!user) return unauthorized();
54
+
55
+ const db = getDatabase() as NodePgDatabase<typeof schema>;
56
+ const repository = await resolveRepository(owner, repoName);
57
+ if (!repository) return notFound("Repository not found");
58
+
59
+ if (!(await canAdminRepo(user.userId, repository, { isAdmin: user.isAdmin }))) {
60
+ return forbidden();
61
+ }
62
+
63
+ const existing = await db.query.repositoryPathPermissions.findFirst({
64
+ where: and(
65
+ eq(schema.repositoryPathPermissions.id, id),
66
+ eq(schema.repositoryPathPermissions.repositoryId, repository.id)
67
+ ),
68
+ });
69
+ if (!existing) return notFound("Path permission not found");
70
+
71
+ const parsed = await parseBody(request, updatePathPermissionSchema);
72
+ if ("error" in parsed) return parsed.error;
73
+
74
+ const payload = parsed.data;
75
+ const updateData: Record<string, unknown> = {
76
+ updatedAt: new Date(),
77
+ };
78
+
79
+ if (payload.pathPattern !== undefined) updateData.pathPattern = payload.pathPattern;
80
+ if (payload.permission !== undefined) updateData.permission = payload.permission;
81
+ if (payload.requireApproval !== undefined) updateData.requireApproval = payload.requireApproval ? "true" : "false";
82
+
83
+ if (payload.userId !== undefined || payload.teamId !== undefined) {
84
+ updateData.userId = payload.userId ?? null;
85
+ updateData.teamId = payload.teamId ?? null;
86
+ }
87
+
88
+ await db.update(schema.repositoryPathPermissions)
89
+ .set(updateData)
90
+ .where(and(
91
+ eq(schema.repositoryPathPermissions.id, id),
92
+ eq(schema.repositoryPathPermissions.repositoryId, repository.id)
93
+ ));
94
+
95
+ return success({ updated: true });
96
+});
97
+
98
+export const DELETE: APIRoute = withErrorHandler(async ({ params, request }) => {
99
+ const owner = params.owner;
100
+ const repoName = params.repo;
101
+ const id = params.id;
102
+ if (!owner || !repoName || !id) return badRequest("Missing route parameters");
103
+
104
+ const user = await getUserFromRequest(request);
105
+ if (!user) return unauthorized();
106
+
107
+ const db = getDatabase() as NodePgDatabase<typeof schema>;
108
+ const repository = await resolveRepository(owner, repoName);
109
+ if (!repository) return notFound("Repository not found");
110
+
111
+ if (!(await canAdminRepo(user.userId, repository, { isAdmin: user.isAdmin }))) {
112
+ return forbidden();
113
+ }
114
+
115
+ const existing = await db.query.repositoryPathPermissions.findFirst({
116
+ where: and(
117
+ eq(schema.repositoryPathPermissions.id, id),
118
+ eq(schema.repositoryPathPermissions.repositoryId, repository.id)
119
+ ),
120
+ });
121
+ if (!existing) return notFound("Path permission not found");
122
+
123
+ await db.delete(schema.repositoryPathPermissions)
124
+ .where(and(
125
+ eq(schema.repositoryPathPermissions.id, id),
126
+ eq(schema.repositoryPathPermissions.repositoryId, repository.id)
127
+ ));
128
+
129
+ return success({ deleted: true });
130
+});
0
131
▼tests/integration/repo-path-permissions-settings-route.test.ts
+208-0
1
+import { beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+const {
4
+ getUserFromRequestMock,
5
+ canReadRepoMock,
6
+ canAdminRepoMock,
7
+ getPathPermissionsMock,
8
+ fakeSchema,
9
+} = vi.hoisted(() => ({
10
+ getUserFromRequestMock: vi.fn(async () => ({ userId: "user-1", isAdmin: false })),
11
+ canReadRepoMock: vi.fn(async () => true),
12
+ canAdminRepoMock: vi.fn(async () => true),
13
+ getPathPermissionsMock: vi.fn(async () => ([
14
+ {
15
+ id: "perm-1",
16
+ repositoryId: "repo-1",
17
+ pathPattern: "packages/web/**",
18
+ userId: "user-1",
19
+ teamId: null,
20
+ permission: "write",
21
+ requireApproval: "false",
22
+ createdAt: new Date("2026-02-19T00:00:00Z"),
23
+ updatedAt: new Date("2026-02-19T00:00:00Z"),
24
+ },
25
+ ])),
26
+ fakeSchema: {
27
+ users: { username: {} },
28
+ repositories: { ownerId: {}, name: {}, id: {} },
29
+ repositoryPathPermissions: { id: {}, repositoryId: {} },
30
+ } as any,
31
+}));
32
+
33
+let mockDb: any;
34
+
35
+vi.mock("@/db", () => ({
36
+ getDatabase: () => mockDb,
37
+ schema: fakeSchema,
38
+}));
39
+
40
+vi.mock("@/lib/auth", () => ({
41
+ getUserFromRequest: getUserFromRequestMock,
42
+}));
43
+
44
+vi.mock("@/lib/permissions", () => ({
45
+ canReadRepo: canReadRepoMock,
46
+ canAdminRepo: canAdminRepoMock,
47
+}));
48
+
49
+vi.mock("@/lib/path-scoping", () => ({
50
+ getPathPermissions: getPathPermissionsMock,
51
+}));
52
+
53
+import { GET as pathPermissionsGet, POST as pathPermissionsPost } from "@/pages/api/repos/[owner]/[repo]/settings/path-permissions";
54
+import { PUT as pathPermissionsPut, DELETE as pathPermissionsDelete } from "@/pages/api/repos/[owner]/[repo]/settings/path-permissions/[id]";
55
+
56
+function makeDb() {
57
+ const owner = { id: "owner-1", username: "acme" };
58
+ const repo = { id: "repo-1", ownerId: "owner-1", name: "demo" };
59
+ const pathPermission = {
60
+ id: "perm-1",
61
+ repositoryId: "repo-1",
62
+ pathPattern: "packages/web/**",
63
+ userId: "user-1",
64
+ teamId: null,
65
+ permission: "write",
66
+ requireApproval: "false",
67
+ };
68
+
69
+ const insertCalls: Array<unknown> = [];
70
+ const updateCalls: Array<unknown> = [];
71
+ const deleteCalls: Array<unknown> = [];
72
+
73
+ return {
74
+ query: {
75
+ users: {
76
+ findFirst: vi.fn(async () => owner),
77
+ },
78
+ repositories: {
79
+ findFirst: vi.fn(async () => repo),
80
+ },
81
+ repositoryPathPermissions: {
82
+ findFirst: vi.fn(async () => pathPermission),
83
+ },
84
+ },
85
+ insert: vi.fn(() => ({
86
+ values: vi.fn(async (value: unknown) => {
87
+ insertCalls.push(value);
88
+ }),
89
+ })),
90
+ update: vi.fn(() => ({
91
+ set: vi.fn((value: unknown) => {
92
+ updateCalls.push(value);
93
+ return {
94
+ where: vi.fn(async () => undefined),
95
+ };
96
+ }),
97
+ })),
98
+ delete: vi.fn(() => ({
99
+ where: vi.fn(async (value: unknown) => {
100
+ deleteCalls.push(value);
101
+ }),
102
+ })),
103
+ __state: {
104
+ insertCalls,
105
+ updateCalls,
106
+ deleteCalls,
107
+ },
108
+ };
109
+}
110
+
111
+async function readJson(response: Response): Promise<any> {
112
+ return response.json();
113
+}
114
+
115
+describe("repository path permissions settings routes", () => {
116
+ beforeEach(() => {
117
+ mockDb = makeDb();
118
+ getUserFromRequestMock.mockResolvedValue({ userId: "user-1", isAdmin: false });
119
+ canReadRepoMock.mockResolvedValue(true);
120
+ canAdminRepoMock.mockResolvedValue(true);
121
+ });
122
+
123
+ it("lists path permission rules for readers", async () => {
124
+ const response = await pathPermissionsGet({
125
+ params: { owner: "acme", repo: "demo" },
126
+ request: new Request("http://localhost/api/repos/acme/demo/settings/path-permissions"),
127
+ } as any);
128
+
129
+ const body = await readJson(response);
130
+ expect(response.status).toBe(200);
131
+ expect(body?.data?.rules).toHaveLength(1);
132
+ expect(body?.data?.summary?.totalRules).toBe(1);
133
+ });
134
+
135
+ it("creates a path permission for repo admins", async () => {
136
+ const response = await pathPermissionsPost({
137
+ params: { owner: "acme", repo: "demo" },
138
+ request: new Request("http://localhost/api/repos/acme/demo/settings/path-permissions", {
139
+ method: "POST",
140
+ headers: { "content-type": "application/json" },
141
+ body: JSON.stringify({
142
+ pathPattern: "packages/api/**",
143
+ teamId: "team-1",
144
+ permission: "write",
145
+ requireApproval: true,
146
+ }),
147
+ }),
148
+ } as any);
149
+
150
+ const body = await readJson(response);
151
+ expect(response.status).toBe(201);
152
+ expect(body?.data?.pathPattern).toBe("packages/api/**");
153
+ expect(body?.data?.teamId).toBe("team-1");
154
+ expect(body?.data?.requireApproval).toBe("true");
155
+ expect(mockDb.__state.insertCalls).toHaveLength(1);
156
+ });
157
+
158
+ it("rejects creation when neither userId nor teamId is provided", async () => {
159
+ const response = await pathPermissionsPost({
160
+ params: { owner: "acme", repo: "demo" },
161
+ request: new Request("http://localhost/api/repos/acme/demo/settings/path-permissions", {
162
+ method: "POST",
163
+ headers: { "content-type": "application/json" },
164
+ body: JSON.stringify({
165
+ pathPattern: "packages/shared/**",
166
+ permission: "read",
167
+ }),
168
+ }),
169
+ } as any);
170
+
171
+ expect(response.status).toBe(400);
172
+ expect(mockDb.__state.insertCalls).toHaveLength(0);
173
+ });
174
+
175
+ it("updates a rule for repo admins", async () => {
176
+ const response = await pathPermissionsPut({
177
+ params: { owner: "acme", repo: "demo", id: "perm-1" },
178
+ request: new Request("http://localhost/api/repos/acme/demo/settings/path-permissions/perm-1", {
179
+ method: "PUT",
180
+ headers: { "content-type": "application/json" },
181
+ body: JSON.stringify({
182
+ pathPattern: "packages/mobile/**",
183
+ permission: "admin",
184
+ requireApproval: true,
185
+ }),
186
+ }),
187
+ } as any);
188
+
189
+ const body = await readJson(response);
190
+ expect(response.status).toBe(200);
191
+ expect(body?.data?.updated).toBe(true);
192
+ expect(mockDb.__state.updateCalls).toHaveLength(1);
193
+ });
194
+
195
+ it("deletes a rule for repo admins", async () => {
196
+ const response = await pathPermissionsDelete({
197
+ params: { owner: "acme", repo: "demo", id: "perm-1" },
198
+ request: new Request("http://localhost/api/repos/acme/demo/settings/path-permissions/perm-1", {
199
+ method: "DELETE",
200
+ }),
201
+ } as any);
202
+
203
+ const body = await readJson(response);
204
+ expect(response.status).toBe(200);
205
+ expect(body?.data?.deleted).toBe(true);
206
+ expect(mockDb.__state.deleteCalls).toHaveLength(1);
207
+ });
208
+});
0
209