1import { describe, expect, it } from "vitest";
2import { ApiError } from "../../common/errors.js";
3import { ActorContext } from "../../common/actor-context.js";
4import {
5 actorIsAdminKey,
6 actorLevel,
7 actorMembership,
8 assertCan,
9 assertRole,
10 effectiveLevel,
11 LEVEL_ORDER,
12 ROLE_LEVEL,
13 type AuthorizePrisma,
14 type Level,
15 type PermissionRow
16} from "../../lib/authorize.js";
17
18
19function fixture(
20 members: Record<string, { role: string }>,
21 teamMembers: Record<string, string[]>,
22 permissions: PermissionRow[]
23): AuthorizePrisma {
24 return {
25 workspaceMember: {
26 async findUnique(args: { where: { workspaceId_userId: { workspaceId: string; userId: string } } }) {
27 const key = `${args.where.workspaceId_userId.workspaceId}:${args.where.workspaceId_userId.userId}`;
28 const m = members[key];
29 return m ? { id: `member:${key}`, role: m.role } : null;
30 }
31 },
32 teamMember: {
33 async findMany(args: { where: { userId: string } }) {
34 const userId = args.where.userId;
35 return (teamMembers[userId] ?? []).map((teamId) => ({ teamId }));
36 }
37 },
38 permission: {
39 async findMany(args: {
40 where: {
41 workspaceId: string;
42 OR: { granteeType: "user" | "team"; granteeId: string | { in: string[] } }[];
43 };
44 }) {
45 const where = args.where;
46
47
48
49
50
51
52 return permissions.filter((p) => {
53 if (p.workspaceId !== where.workspaceId) return false;
54 return where.OR.some((branch) => {
55 if (branch.granteeType !== p.granteeType) return false;
56 const gid = branch.granteeId;
57
58 return typeof gid === "object" && gid !== null
59 ? gid.in.includes(p.granteeId)
60 : gid === p.granteeId;
61 });
62 });
63 }
64 }
65 };
66}
67
68const WS = "ws_1";
69
70describe("authorize — role mapping", () => {
71 it("maps workspace roles to baseline levels", () => {
72 expect(ROLE_LEVEL.OWNER).toBe("FULL_ACCESS");
73 expect(ROLE_LEVEL.ADMIN).toBe("FULL_ACCESS");
74 expect(ROLE_LEVEL.MEMBER).toBe("EDIT");
75 expect(ROLE_LEVEL.GUEST).toBe("VIEW");
76 });
77
78 it("non-member has NONE", async () => {
79 const prisma = fixture({}, {}, []);
80 expect(await effectiveLevel(prisma, { workspaceId: WS, userId: "u1" })).toBe("NONE");
81 });
82
83 it("member baseline: MEMBER → EDIT, GUEST → VIEW, OWNER → FULL_ACCESS", async () => {
84 expect(
85 await effectiveLevel(
86 fixture({ [`${WS}:u1`]: { role: "MEMBER" } }, {}, []),
87 { workspaceId: WS, userId: "u1" }
88 )
89 ).toBe("EDIT");
90 expect(
91 await effectiveLevel(
92 fixture({ [`${WS}:u1`]: { role: "GUEST" } }, {}, []),
93 { workspaceId: WS, userId: "u1" }
94 )
95 ).toBe("VIEW");
96 expect(
97 await effectiveLevel(
98 fixture({ [`${WS}:u1`]: { role: "OWNER" } }, {}, []),
99 { workspaceId: WS, userId: "u1" }
100 )
101 ).toBe("FULL_ACCESS");
102 });
103});
104
105describe("authorize — permission inheritance", () => {
106 const grant = (over: Partial<PermissionRow>): PermissionRow => ({
107 id: "perm_" + Math.random().toString(36).slice(2),
108 workspaceId: WS,
109 granteeType: "user",
110 granteeId: "u1",
111 resourceType: "workspace",
112 resourceId: WS,
113 level: "VIEW",
114 ...over
115 });
116
117 it("direct user grant on the workspace raises the level (max with role)", async () => {
118
119 const prisma = fixture(
120 { [`${WS}:u1`]: { role: "GUEST" } },
121 {},
122 [grant({ level: "FULL_ACCESS" })]
123 );
124 expect(await effectiveLevel(prisma, { workspaceId: WS, userId: "u1" })).toBe("FULL_ACCESS");
125 });
126
127 it("a LOWER grant never downgrades the role level (max wins)", async () => {
128 const prisma = fixture(
129 { [`${WS}:u1`]: { role: "MEMBER" } },
130 {},
131 [grant({ level: "VIEW" })]
132 );
133 expect(await effectiveLevel(prisma, { workspaceId: WS, userId: "u1" })).toBe("EDIT");
134 });
135
136 it("team grants apply only when the user is in that team", async () => {
137 const teamGrant = grant({
138 granteeType: "team",
139 granteeId: "team_1",
140 level: "COMMENT"
141 });
142 const inTeam = fixture(
143 { [`${WS}:u1`]: { role: "GUEST" } },
144 { u1: ["team_1"] },
145 [teamGrant]
146 );
147 expect(await effectiveLevel(inTeam, { workspaceId: WS, userId: "u1" })).toBe("COMMENT");
148
149 const notInTeam = fixture(
150 { [`${WS}:u1`]: { role: "GUEST" } },
151 { u1: ["team_2"] },
152 [teamGrant]
153 );
154 expect(await effectiveLevel(notInTeam, { workspaceId: WS, userId: "u1" })).toBe("VIEW");
155 });
156});
157
158describe("authorize — resource specificity", () => {
159 const grant = (over: Partial<PermissionRow>): PermissionRow => ({
160 id: "perm_" + Math.random().toString(36).slice(2),
161 workspaceId: WS,
162 granteeType: "user",
163 granteeId: "u1",
164 resourceType: "workspace",
165 resourceId: WS,
166 level: "VIEW",
167 ...over
168 });
169
170 it("the most specific applicable grant is the one that counts", async () => {
171
172 const prisma = fixture(
173 { [`${WS}:u1`]: { role: "GUEST" } },
174 {},
175 [
176 grant({ resourceType: "workspace", level: "COMMENT" }),
177 grant({ resourceType: "page", resourceId: "page_1", level: "EDIT" })
178 ]
179 );
180
181 expect(
182 await effectiveLevel(prisma, { workspaceId: WS, userId: "u1", resource: { pageId: "page_1" } })
183 ).toBe("EDIT");
184
185 expect(
186 await effectiveLevel(prisma, { workspaceId: WS, userId: "u1", resource: { pageId: "page_2" } })
187 ).toBe("COMMENT");
188 });
189
190 it("grants max (never downgrade) against the role baseline", async () => {
191 const prisma = fixture(
192 { [`${WS}:u1`]: { role: "MEMBER" } },
193 {},
194 [grant({ resourceType: "page", resourceId: "page_1", level: "VIEW" })]
195 );
196
197 expect(
198 await effectiveLevel(prisma, { workspaceId: WS, userId: "u1", resource: { pageId: "page_1" } })
199 ).toBe("EDIT");
200 });
201
202 it("database-scoped grant applies to the database, not other databases", async () => {
203 const prisma = fixture(
204 { [`${WS}:u1`]: { role: "GUEST" } },
205 {},
206 [grant({ resourceType: "database", resourceId: "db_1", level: "EDIT" })]
207 );
208 expect(
209 await effectiveLevel(prisma, { workspaceId: WS, userId: "u1", resource: { databaseId: "db_1" } })
210 ).toBe("EDIT");
211 expect(
212 await effectiveLevel(prisma, { workspaceId: WS, userId: "u1", resource: { databaseId: "db_2" } })
213 ).toBe("VIEW");
214 });
215
216 it("record/file/teamspace scoping resolves only for matching resources", async () => {
217 const prisma = fixture(
218 { [`${WS}:u1`]: { role: "GUEST" } },
219 {},
220 [
221 grant({ resourceType: "record", resourceId: "rec_1", level: "FULL_ACCESS" }),
222 grant({ resourceType: "file", resourceId: "file_1", level: "EDIT" }),
223 grant({ resourceType: "teamspace", resourceId: "team_9", level: "COMMENT" })
224 ]
225 );
226 expect(
227 await effectiveLevel(prisma, { workspaceId: WS, userId: "u1", resource: { recordId: "rec_1" } })
228 ).toBe("FULL_ACCESS");
229 expect(
230 await effectiveLevel(prisma, { workspaceId: WS, userId: "u1", resource: { fileId: "file_1" } })
231 ).toBe("EDIT");
232 expect(
233 await effectiveLevel(prisma, { workspaceId: WS, userId: "u1", resource: { teamId: "team_9" } })
234 ).toBe("COMMENT");
235 expect(await effectiveLevel(prisma, { workspaceId: WS, userId: "u1" })).toBe("VIEW");
236 });
237});
238
239describe("authorize — explicit NONE deny (TASK-0104)", () => {
240 const grant = (over: Partial<PermissionRow>): PermissionRow => ({
241 id: "perm_" + Math.random().toString(36).slice(2),
242 workspaceId: WS,
243 granteeType: "user",
244 granteeId: "u1",
245 resourceType: "workspace",
246 resourceId: WS,
247 level: "VIEW",
248 ...over
249 });
250
251 it("explicit file NONE denies a MEMBER who inherits EDIT from the page", async () => {
252
253
254 const prisma = fixture(
255 { [`${WS}:u1`]: { role: "MEMBER" } },
256 {},
257 [grant({ resourceType: "file", resourceId: "file_1", level: "NONE" })]
258 );
259
260 expect(
261 await effectiveLevel(prisma, { workspaceId: WS, userId: "u1", resource: { pageId: "page_1" } })
262 ).toBe("EDIT");
263
264 expect(
265 await effectiveLevel(prisma, { workspaceId: WS, userId: "u1", resource: { fileId: "file_1", pageId: "page_1" } })
266 ).toBe("NONE");
267
268 expect(
269 await effectiveLevel(prisma, { workspaceId: WS, userId: "u1", resource: { fileId: "file_2", pageId: "page_1" } })
270 ).toBe("EDIT");
271 });
272
273 it("explicit NONE beats a HIGHER grant on the SAME resource (hard stop)", async () => {
274
275
276 const prisma = fixture(
277 { [`${WS}:u1`]: { role: "GUEST" } },
278 {},
279 [
280 grant({ resourceType: "file", resourceId: "file_1", level: "FULL_ACCESS" }),
281 grant({ resourceType: "file", resourceId: "file_1", level: "NONE" })
282 ]
283 );
284 expect(
285 await effectiveLevel(prisma, { workspaceId: WS, userId: "u1", resource: { fileId: "file_1" } })
286 ).toBe("NONE");
287 });
288
289 it("a NONE grant on a DIFFERENT resource never denies", async () => {
290 const prisma = fixture(
291 { [`${WS}:u1`]: { role: "MEMBER" } },
292 {},
293 [
294 grant({ resourceType: "file", resourceId: "file_A", level: "NONE" }),
295 grant({ resourceType: "file", resourceId: "file_B", level: "VIEW" })
296 ]
297 );
298 expect(
299 await effectiveLevel(prisma, { workspaceId: WS, userId: "u1", resource: { fileId: "file_B" } })
300 ).toBe("EDIT");
301 });
302
303 it("NONE deny applies to team + role grantees too (grantAppliesToUser semantics)", async () => {
304
305
306 const prisma = fixture(
307 { [`${WS}:u1`]: { role: "MEMBER" } },
308 { u1: ["team_1"] },
309 [grant({ granteeType: "team", granteeId: "team_1", resourceType: "file", resourceId: "file_1", level: "NONE" })]
310 );
311 expect(
312 await effectiveLevel(prisma, { workspaceId: WS, userId: "u1", resource: { fileId: "file_1" } })
313 ).toBe("NONE");
314 });
315
316 it("assertCan enforces the deny (403 FORBIDDEN, not a silent max)", async () => {
317 const prisma = fixture(
318 { [`${WS}:u1`]: { role: "MEMBER" } },
319 {},
320 [grant({ resourceType: "file", resourceId: "file_1", level: "NONE" })]
321 );
322 await expect(
323 assertCan(prisma, { workspaceId: WS, userId: "u1", resource: { fileId: "file_1" } }, "VIEW")
324 ).rejects.toMatchObject({ code: "FORBIDDEN", httpStatus: 403 });
325 });
326});
327
328describe("authorize — assertCan / assertRole", () => {
329 it("assertCan passes when the level is sufficient", async () => {
330 const prisma = fixture({ [`${WS}:u1`]: { role: "ADMIN" } }, {}, []);
331 const level = await assertCan(prisma, { workspaceId: WS, userId: "u1" }, "EDIT");
332 expect(level).toBe("FULL_ACCESS");
333 });
334
335 it("assertCan throws FORBIDDEN with reason details when insufficient", async () => {
336 const prisma = fixture({ [`${WS}:u1`]: { role: "GUEST" } }, {}, []);
337 try {
338 await assertCan(prisma, { workspaceId: WS, userId: "u1" }, "EDIT");
339 expect.unreachable("should throw");
340 } catch (err) {
341 expect(err).toBeInstanceOf(ApiError);
342 const e = err as ApiError;
343 expect(e.code).toBe("FORBIDDEN");
344 expect(e.httpStatus).toBe(403);
345 expect(e.details?.[0]?.reason).toBe("requires_EDIT");
346 }
347 });
348
349 it("assertRole enforces owner/admin gating", async () => {
350 const prisma = fixture({ [`${WS}:u1`]: { role: "MEMBER" } }, {}, []);
351 await expect(assertRole(prisma, { workspaceId: WS, userId: "u1" }, ["OWNER", "ADMIN"])).rejects.toBeInstanceOf(ApiError);
352 expect(
353 await assertRole(fixture({ [`${WS}:u1`]: { role: "OWNER" } }, {}, []), { workspaceId: WS, userId: "u1" }, ["OWNER", "ADMIN"])
354 ).toBe("OWNER");
355 });
356});
357
358
359
360
361
362
363
364
365
366
367const key = (scopes: string[], workspaceId = WS) => ({
368 kind: "api" as const,
369 workspaceId,
370 apiKeyId: "ak_1",
371 userId: "creator",
372 scopes
373});
374
375const readKey = key(["READ"]);
376const writeKey = key(["WRITE"]);
377const adminKey = key(["ADMIN"]);
378
379describe("authorize — API keys (TASK-0127)", () => {
380
381
382 const leakyFixture = () =>
383 fixture(
384 { [`${WS}:creator`]: { role: "OWNER" }, "ws_other:creator": { role: "MEMBER" } },
385 {},
386 []
387 );
388
389 it("READ key passes VIEW asserts, is denied EDIT (api_key_scope_write)", async () => {
390 const prisma = leakyFixture();
391 const level = await ActorContext.run(readKey, () => assertCan(prisma, { workspaceId: WS, userId: "creator" }, "VIEW"));
392 expect(level).toBe("VIEW");
393 await expect(
394 ActorContext.run(readKey, () => assertCan(prisma, { workspaceId: WS, userId: "creator" }, "EDIT"))
395 ).rejects.toMatchObject({ code: "FORBIDDEN", httpStatus: 403 });
396 const e = await ActorContext.run(readKey, () =>
397 assertCan(prisma, { workspaceId: WS, userId: "creator" }, "EDIT").catch((err) => err)
398 );
399 expect((e as ApiError).details?.[0]?.reason).toBe("api_key_scope_write");
400 });
401
402 it("WRITE key passes up to EDIT, is denied FULL_ACCESS", async () => {
403 const prisma = leakyFixture();
404 const level = await ActorContext.run(writeKey, () => assertCan(prisma, { workspaceId: WS, userId: "creator" }, "EDIT"));
405 expect(level).toBe("EDIT");
406 await expect(
407 ActorContext.run(writeKey, () => assertCan(prisma, { workspaceId: WS, userId: "creator" }, "FULL_ACCESS"))
408 ).rejects.toMatchObject({ code: "FORBIDDEN" });
409 });
410
411 it("ADMIN key passes every level", async () => {
412 const prisma = leakyFixture();
413 const level = await ActorContext.run(adminKey, () => assertCan(prisma, { workspaceId: WS, userId: "creator" }, "FULL_ACCESS"));
414 expect(level).toBe("FULL_ACCESS");
415 });
416
417 it("the creator's OWNERSHIP never leaks into a READ/WRITE key", async () => {
418
419
420 const prisma = leakyFixture();
421 await expect(
422 ActorContext.run(readKey, () => assertCan(prisma, { workspaceId: WS, userId: "creator" }, "EDIT"))
423 ).rejects.toBeInstanceOf(ApiError);
424
425 await expect(
426 ActorContext.run(writeKey, () => assertCan(prisma, { workspaceId: WS, userId: "creator" }, "FULL_ACCESS"))
427 ).rejects.toBeInstanceOf(ApiError);
428 });
429
430 it("cross-workspace key → workspace_mismatch (403, not a membership probe)", async () => {
431 const other = key(["ADMIN"], "ws_other");
432 const prisma = leakyFixture();
433 const e = await ActorContext.run(other, () =>
434 assertCan(prisma, { workspaceId: WS, userId: "creator" }, "VIEW").catch((err) => err)
435 );
436 expect((e as ApiError).code).toBe("FORBIDDEN");
437 expect((e as ApiError).details?.[0]?.reason).toBe("workspace_mismatch");
438 });
439
440 it("assertRole: ADMIN key → ADMIN role (when ADMIN is in the gate)", async () => {
441 const prisma = leakyFixture();
442 const role = await ActorContext.run(adminKey, () =>
443 assertRole(prisma, { workspaceId: WS, userId: "creator" }, ["OWNER", "ADMIN"])
444 );
445 expect(role).toBe("ADMIN");
446 });
447
448 it("assertRole: a key is NEVER an owner — OWNER-only gates deny every key", async () => {
449 const prisma = leakyFixture();
450 await expect(
451 ActorContext.run(adminKey, () => assertRole(prisma, { workspaceId: WS, userId: "creator" }, ["OWNER"]))
452 ).rejects.toMatchObject({ code: "FORBIDDEN" });
453 });
454
455 it("assertRole: non-ADMIN key → api_key_scope_write", async () => {
456 const prisma = leakyFixture();
457 const e = await ActorContext.run(writeKey, () =>
458 assertRole(prisma, { workspaceId: WS, userId: "creator" }, ["OWNER", "ADMIN"]).catch((err) => err)
459 );
460 expect((e as ApiError).details?.[0]?.reason).toBe("api_key_scope_write");
461 });
462
463 it("actorLevel resolves the key's scope cap (never the creator's grants)", async () => {
464 const prisma = leakyFixture();
465 expect(await ActorContext.run(readKey, () => actorLevel(prisma, { workspaceId: WS, userId: "creator" }))).toBe("VIEW");
466 expect(await ActorContext.run(writeKey, () => actorLevel(prisma, { workspaceId: WS, userId: "creator" }))).toBe("EDIT");
467 expect(await ActorContext.run(adminKey, () => actorLevel(prisma, { workspaceId: WS, userId: "creator" }))).toBe("FULL_ACCESS");
468 expect(
469 await ActorContext.run(key(["ADMIN"], "ws_other"), () => actorLevel(prisma, { workspaceId: WS, userId: "creator" }))
470 ).toBe("NONE");
471 });
472
473 it("actorMembership synthesizes a role from the key's scope", async () => {
474 const prisma = leakyFixture();
475 expect(await ActorContext.run(readKey, () => actorMembership(prisma, "creator", WS))).toEqual({
476 id: "apikey-ak_1",
477 role: "GUEST"
478 });
479 expect(await ActorContext.run(writeKey, () => actorMembership(prisma, "creator", WS))).toEqual({
480 id: "apikey-ak_1",
481 role: "MEMBER"
482 });
483 expect(await ActorContext.run(adminKey, () => actorMembership(prisma, "creator", WS))).toEqual({
484 id: "apikey-ak_1",
485 role: "ADMIN"
486 });
487
488 expect(
489 await ActorContext.run(key(["ADMIN"], "ws_other"), () => actorMembership(prisma, "creator", WS))
490 ).toBeNull();
491 });
492
493 it("actorIsAdminKey: only ADMIN-scope keys of the workspace", () => {
494 expect(ActorContext.run(adminKey, () => actorIsAdminKey(WS))).toBe(true);
495 expect(ActorContext.run(writeKey, () => actorIsAdminKey(WS))).toBe(false);
496 expect(ActorContext.run(key(["ADMIN"], "ws_other"), () => actorIsAdminKey(WS))).toBe(false);
497 expect(actorIsAdminKey(WS)).toBe(false);
498 });
499
500 it("without an actor context the legacy user path is unchanged", async () => {
501 const prisma = fixture({ [`${WS}:u1`]: { role: "MEMBER" } }, {}, []);
502 expect(await assertCan(prisma, { workspaceId: WS, userId: "u1" }, "EDIT")).toBe("EDIT");
503 expect(await actorLevel(prisma, { workspaceId: WS, userId: "u1" })).toBe("EDIT");
504 });
505});
506