import { describe, expect, it } from "vitest"; import { ApiError } from "../../common/errors.js"; import { ActorContext } from "../../common/actor-context.js"; import { actorIsAdminKey, actorLevel, actorMembership, assertCan, assertRole, effectiveLevel, LEVEL_ORDER, ROLE_LEVEL, type AuthorizePrisma, type Level, type PermissionRow } from "../../lib/authorize.js"; /** Fixture Prisma: workspaceMember / teamMember / permission delegates. */ function fixture( members: Record, // "ws:user" → role teamMembers: Record, // userId → teamIds permissions: PermissionRow[] ): AuthorizePrisma { return { workspaceMember: { async findUnique(args: { where: { workspaceId_userId: { workspaceId: string; userId: string } } }) { const key = `${args.where.workspaceId_userId.workspaceId}:${args.where.workspaceId_userId.userId}`; const m = members[key]; return m ? { id: `member:${key}`, role: m.role } : null; } }, teamMember: { async findMany(args: { where: { userId: string } }) { const userId = args.where.userId; return (teamMembers[userId] ?? []).map((teamId) => ({ teamId })); } }, permission: { async findMany(args: { where: { workspaceId: string; OR: { granteeType: "user" | "team"; granteeId: string | { in: string[] } }[]; }; }) { const where = args.where; // A faithful double: evaluate the grantee `OR` exactly as the query // writes it (user branch = the caller's id; team branch = the `in` list // of the caller's team ids, computed by the production code). The // original double filtered on workspaceId ONLY — it leaked team grants // to users not in the team (broken since sync 5e0d9c54, same commit as // the effectiveLevel TDZ bug). return permissions.filter((p) => { if (p.workspaceId !== where.workspaceId) return false; return where.OR.some((branch) => { if (branch.granteeType !== p.granteeType) return false; const gid = branch.granteeId; // The team branch writes `{ in: teamIds }`; the user branch a plain id. return typeof gid === "object" && gid !== null ? gid.in.includes(p.granteeId) : gid === p.granteeId; }); }); } } }; } const WS = "ws_1"; describe("authorize — role mapping", () => { it("maps workspace roles to baseline levels", () => { expect(ROLE_LEVEL.OWNER).toBe("FULL_ACCESS"); expect(ROLE_LEVEL.ADMIN).toBe("FULL_ACCESS"); expect(ROLE_LEVEL.MEMBER).toBe("EDIT"); expect(ROLE_LEVEL.GUEST).toBe("VIEW"); }); it("non-member has NONE", async () => { const prisma = fixture({}, {}, []); expect(await effectiveLevel(prisma, { workspaceId: WS, userId: "u1" })).toBe("NONE"); }); it("member baseline: MEMBER → EDIT, GUEST → VIEW, OWNER → FULL_ACCESS", async () => { expect( await effectiveLevel( fixture({ [`${WS}:u1`]: { role: "MEMBER" } }, {}, []), { workspaceId: WS, userId: "u1" } ) ).toBe("EDIT"); expect( await effectiveLevel( fixture({ [`${WS}:u1`]: { role: "GUEST" } }, {}, []), { workspaceId: WS, userId: "u1" } ) ).toBe("VIEW"); expect( await effectiveLevel( fixture({ [`${WS}:u1`]: { role: "OWNER" } }, {}, []), { workspaceId: WS, userId: "u1" } ) ).toBe("FULL_ACCESS"); }); }); describe("authorize — permission inheritance", () => { const grant = (over: Partial): PermissionRow => ({ id: "perm_" + Math.random().toString(36).slice(2), workspaceId: WS, granteeType: "user", granteeId: "u1", resourceType: "workspace", resourceId: WS, level: "VIEW", ...over }); it("direct user grant on the workspace raises the level (max with role)", async () => { // GUEST (VIEW) + workspace-level FULL grant → FULL const prisma = fixture( { [`${WS}:u1`]: { role: "GUEST" } }, {}, [grant({ level: "FULL_ACCESS" })] ); expect(await effectiveLevel(prisma, { workspaceId: WS, userId: "u1" })).toBe("FULL_ACCESS"); }); it("a LOWER grant never downgrades the role level (max wins)", async () => { const prisma = fixture( { [`${WS}:u1`]: { role: "MEMBER" } }, {}, [grant({ level: "VIEW" })] ); expect(await effectiveLevel(prisma, { workspaceId: WS, userId: "u1" })).toBe("EDIT"); }); it("team grants apply only when the user is in that team", async () => { const teamGrant = grant({ granteeType: "team", granteeId: "team_1", level: "COMMENT" }); const inTeam = fixture( { [`${WS}:u1`]: { role: "GUEST" } }, { u1: ["team_1"] }, [teamGrant] ); expect(await effectiveLevel(inTeam, { workspaceId: WS, userId: "u1" })).toBe("COMMENT"); const notInTeam = fixture( { [`${WS}:u1`]: { role: "GUEST" } }, { u1: ["team_2"] }, [teamGrant] ); expect(await effectiveLevel(notInTeam, { workspaceId: WS, userId: "u1" })).toBe("VIEW"); }); }); describe("authorize — resource specificity", () => { const grant = (over: Partial): PermissionRow => ({ id: "perm_" + Math.random().toString(36).slice(2), workspaceId: WS, granteeType: "user", granteeId: "u1", resourceType: "workspace", resourceId: WS, level: "VIEW", ...over }); it("the most specific applicable grant is the one that counts", async () => { // GUEST baseline (VIEW); workspace grant COMMENT; page_1 grant EDIT. const prisma = fixture( { [`${WS}:u1`]: { role: "GUEST" } }, {}, [ grant({ resourceType: "workspace", level: "COMMENT" }), grant({ resourceType: "page", resourceId: "page_1", level: "EDIT" }) ] ); // page_1 → specific grant EDIT beats the workspace COMMENT expect( await effectiveLevel(prisma, { workspaceId: WS, userId: "u1", resource: { pageId: "page_1" } }) ).toBe("EDIT"); // page_2 → only the workspace grant applies → COMMENT (maxed with GUEST VIEW) expect( await effectiveLevel(prisma, { workspaceId: WS, userId: "u1", resource: { pageId: "page_2" } }) ).toBe("COMMENT"); }); it("grants max (never downgrade) against the role baseline", async () => { const prisma = fixture( { [`${WS}:u1`]: { role: "MEMBER" } }, // EDIT {}, [grant({ resourceType: "page", resourceId: "page_1", level: "VIEW" })] ); // page grant VIEW is lower than the role EDIT → role still applies (max wins) expect( await effectiveLevel(prisma, { workspaceId: WS, userId: "u1", resource: { pageId: "page_1" } }) ).toBe("EDIT"); }); it("database-scoped grant applies to the database, not other databases", async () => { const prisma = fixture( { [`${WS}:u1`]: { role: "GUEST" } }, {}, [grant({ resourceType: "database", resourceId: "db_1", level: "EDIT" })] ); expect( await effectiveLevel(prisma, { workspaceId: WS, userId: "u1", resource: { databaseId: "db_1" } }) ).toBe("EDIT"); expect( await effectiveLevel(prisma, { workspaceId: WS, userId: "u1", resource: { databaseId: "db_2" } }) ).toBe("VIEW"); }); it("record/file/teamspace scoping resolves only for matching resources", async () => { const prisma = fixture( { [`${WS}:u1`]: { role: "GUEST" } }, {}, [ grant({ resourceType: "record", resourceId: "rec_1", level: "FULL_ACCESS" }), grant({ resourceType: "file", resourceId: "file_1", level: "EDIT" }), grant({ resourceType: "teamspace", resourceId: "team_9", level: "COMMENT" }) ] ); expect( await effectiveLevel(prisma, { workspaceId: WS, userId: "u1", resource: { recordId: "rec_1" } }) ).toBe("FULL_ACCESS"); expect( await effectiveLevel(prisma, { workspaceId: WS, userId: "u1", resource: { fileId: "file_1" } }) ).toBe("EDIT"); expect( await effectiveLevel(prisma, { workspaceId: WS, userId: "u1", resource: { teamId: "team_9" } }) ).toBe("COMMENT"); expect(await effectiveLevel(prisma, { workspaceId: WS, userId: "u1" })).toBe("VIEW"); }); }); describe("authorize — explicit NONE deny (TASK-0104)", () => { const grant = (over: Partial): PermissionRow => ({ id: "perm_" + Math.random().toString(36).slice(2), workspaceId: WS, granteeType: "user", granteeId: "u1", resourceType: "workspace", resourceId: WS, level: "VIEW", ...over }); it("explicit file NONE denies a MEMBER who inherits EDIT from the page", async () => { // MEMBER (role → EDIT); the file is inside a page the member can edit, // but a direct NONE grant on the FILE is a hard stop. const prisma = fixture( { [`${WS}:u1`]: { role: "MEMBER" } }, {}, [grant({ resourceType: "file", resourceId: "file_1", level: "NONE" })] ); // No file scope → role EDIT stands (the file grant is not named). expect( await effectiveLevel(prisma, { workspaceId: WS, userId: "u1", resource: { pageId: "page_1" } }) ).toBe("EDIT"); // Naming the denied file → NONE (deny beats inherited/role EDIT). expect( await effectiveLevel(prisma, { workspaceId: WS, userId: "u1", resource: { fileId: "file_1", pageId: "page_1" } }) ).toBe("NONE"); // A DIFFERENT file is not affected by file_1's NONE grant. expect( await effectiveLevel(prisma, { workspaceId: WS, userId: "u1", resource: { fileId: "file_2", pageId: "page_1" } }) ).toBe("EDIT"); }); it("explicit NONE beats a HIGHER grant on the SAME resource (hard stop)", async () => { // GUEST (VIEW). File has both a FULL_ACCESS grant and a NONE grant for u1 // (e.g. an old share not yet removed + a new explicit deny). The deny wins. const prisma = fixture( { [`${WS}:u1`]: { role: "GUEST" } }, {}, [ grant({ resourceType: "file", resourceId: "file_1", level: "FULL_ACCESS" }), grant({ resourceType: "file", resourceId: "file_1", level: "NONE" }) ] ); expect( await effectiveLevel(prisma, { workspaceId: WS, userId: "u1", resource: { fileId: "file_1" } }) ).toBe("NONE"); }); it("a NONE grant on a DIFFERENT resource never denies", async () => { const prisma = fixture( { [`${WS}:u1`]: { role: "MEMBER" } }, {}, [ grant({ resourceType: "file", resourceId: "file_A", level: "NONE" }), grant({ resourceType: "file", resourceId: "file_B", level: "VIEW" }) ] ); expect( await effectiveLevel(prisma, { workspaceId: WS, userId: "u1", resource: { fileId: "file_B" } }) ).toBe("EDIT"); // max(role EDIT, file_B VIEW) — file_A's NONE is not named }); it("NONE deny applies to team + role grantees too (grantAppliesToUser semantics)", async () => { // u1 ∈ team_1; a team grant NONE on the file denies u1 (the query filters // grants by user OR team membership, so the team NONE row is present). const prisma = fixture( { [`${WS}:u1`]: { role: "MEMBER" } }, { u1: ["team_1"] }, [grant({ granteeType: "team", granteeId: "team_1", resourceType: "file", resourceId: "file_1", level: "NONE" })] ); expect( await effectiveLevel(prisma, { workspaceId: WS, userId: "u1", resource: { fileId: "file_1" } }) ).toBe("NONE"); }); it("assertCan enforces the deny (403 FORBIDDEN, not a silent max)", async () => { const prisma = fixture( { [`${WS}:u1`]: { role: "MEMBER" } }, {}, [grant({ resourceType: "file", resourceId: "file_1", level: "NONE" })] ); await expect( assertCan(prisma, { workspaceId: WS, userId: "u1", resource: { fileId: "file_1" } }, "VIEW") ).rejects.toMatchObject({ code: "FORBIDDEN", httpStatus: 403 }); }); }); describe("authorize — assertCan / assertRole", () => { it("assertCan passes when the level is sufficient", async () => { const prisma = fixture({ [`${WS}:u1`]: { role: "ADMIN" } }, {}, []); const level = await assertCan(prisma, { workspaceId: WS, userId: "u1" }, "EDIT"); expect(level).toBe("FULL_ACCESS"); }); it("assertCan throws FORBIDDEN with reason details when insufficient", async () => { const prisma = fixture({ [`${WS}:u1`]: { role: "GUEST" } }, {}, []); try { await assertCan(prisma, { workspaceId: WS, userId: "u1" }, "EDIT"); expect.unreachable("should throw"); } catch (err) { expect(err).toBeInstanceOf(ApiError); const e = err as ApiError; expect(e.code).toBe("FORBIDDEN"); expect(e.httpStatus).toBe(403); expect(e.details?.[0]?.reason).toBe("requires_EDIT"); } }); it("assertRole enforces owner/admin gating", async () => { const prisma = fixture({ [`${WS}:u1`]: { role: "MEMBER" } }, {}, []); await expect(assertRole(prisma, { workspaceId: WS, userId: "u1" }, ["OWNER", "ADMIN"])).rejects.toBeInstanceOf(ApiError); expect( await assertRole(fixture({ [`${WS}:u1`]: { role: "OWNER" } }, {}, []), { workspaceId: WS, userId: "u1" }, ["OWNER", "ADMIN"]) ).toBe("OWNER"); }); }); // ───────────────────────────────────────────────────────────────────────────── // TASK-0127 — API-key scope rules (docs/api/authentication.md §7). // // The key acts as the WORKSPACE: its SCOPE (READ/WRITE/ADMIN) and its // workspace binding decide — the creator's membership is never consulted. // The actor context (AsyncLocalStorage) is set with ActorContext.run, // exactly as the global ActorContextInterceptor does per request. // ───────────────────────────────────────────────────────────────────────────── const key = (scopes: string[], workspaceId = WS) => ({ kind: "api" as const, workspaceId, apiKeyId: "ak_1", userId: "creator", // the creator — must NEVER be consulted scopes }); const readKey = key(["READ"]); const writeKey = key(["WRITE"]); const adminKey = key(["ADMIN"]); describe("authorize — API keys (TASK-0127)", () => { // The creator is the OWNER of WS AND a MEMBER of ws_other — the strongest // possible leak. Every assertion below proves the creator's rows are inert. const leakyFixture = () => fixture( { [`${WS}:creator`]: { role: "OWNER" }, "ws_other:creator": { role: "MEMBER" } }, {}, [] ); it("READ key passes VIEW asserts, is denied EDIT (api_key_scope_write)", async () => { const prisma = leakyFixture(); const level = await ActorContext.run(readKey, () => assertCan(prisma, { workspaceId: WS, userId: "creator" }, "VIEW")); expect(level).toBe("VIEW"); await expect( ActorContext.run(readKey, () => assertCan(prisma, { workspaceId: WS, userId: "creator" }, "EDIT")) ).rejects.toMatchObject({ code: "FORBIDDEN", httpStatus: 403 }); const e = await ActorContext.run(readKey, () => assertCan(prisma, { workspaceId: WS, userId: "creator" }, "EDIT").catch((err) => err) ); expect((e as ApiError).details?.[0]?.reason).toBe("api_key_scope_write"); }); it("WRITE key passes up to EDIT, is denied FULL_ACCESS", async () => { const prisma = leakyFixture(); const level = await ActorContext.run(writeKey, () => assertCan(prisma, { workspaceId: WS, userId: "creator" }, "EDIT")); expect(level).toBe("EDIT"); await expect( ActorContext.run(writeKey, () => assertCan(prisma, { workspaceId: WS, userId: "creator" }, "FULL_ACCESS")) ).rejects.toMatchObject({ code: "FORBIDDEN" }); }); it("ADMIN key passes every level", async () => { const prisma = leakyFixture(); const level = await ActorContext.run(adminKey, () => assertCan(prisma, { workspaceId: WS, userId: "creator" }, "FULL_ACCESS")); expect(level).toBe("FULL_ACCESS"); }); it("the creator's OWNERSHIP never leaks into a READ/WRITE key", async () => { // creator = OWNER of WS. A READ key must still be denied EDIT — if the // membership lookup ran against the creator, this would pass. const prisma = leakyFixture(); await expect( ActorContext.run(readKey, () => assertCan(prisma, { workspaceId: WS, userId: "creator" }, "EDIT")) ).rejects.toBeInstanceOf(ApiError); // And a WRITE key denied FULL_ACCESS even though the creator is OWNER. await expect( ActorContext.run(writeKey, () => assertCan(prisma, { workspaceId: WS, userId: "creator" }, "FULL_ACCESS")) ).rejects.toBeInstanceOf(ApiError); }); it("cross-workspace key → workspace_mismatch (403, not a membership probe)", async () => { const other = key(["ADMIN"], "ws_other"); const prisma = leakyFixture(); const e = await ActorContext.run(other, () => assertCan(prisma, { workspaceId: WS, userId: "creator" }, "VIEW").catch((err) => err) ); expect((e as ApiError).code).toBe("FORBIDDEN"); expect((e as ApiError).details?.[0]?.reason).toBe("workspace_mismatch"); }); it("assertRole: ADMIN key → ADMIN role (when ADMIN is in the gate)", async () => { const prisma = leakyFixture(); const role = await ActorContext.run(adminKey, () => assertRole(prisma, { workspaceId: WS, userId: "creator" }, ["OWNER", "ADMIN"]) ); expect(role).toBe("ADMIN"); }); it("assertRole: a key is NEVER an owner — OWNER-only gates deny every key", async () => { const prisma = leakyFixture(); await expect( ActorContext.run(adminKey, () => assertRole(prisma, { workspaceId: WS, userId: "creator" }, ["OWNER"])) ).rejects.toMatchObject({ code: "FORBIDDEN" }); }); it("assertRole: non-ADMIN key → api_key_scope_write", async () => { const prisma = leakyFixture(); const e = await ActorContext.run(writeKey, () => assertRole(prisma, { workspaceId: WS, userId: "creator" }, ["OWNER", "ADMIN"]).catch((err) => err) ); expect((e as ApiError).details?.[0]?.reason).toBe("api_key_scope_write"); }); it("actorLevel resolves the key's scope cap (never the creator's grants)", async () => { const prisma = leakyFixture(); expect(await ActorContext.run(readKey, () => actorLevel(prisma, { workspaceId: WS, userId: "creator" }))).toBe("VIEW"); expect(await ActorContext.run(writeKey, () => actorLevel(prisma, { workspaceId: WS, userId: "creator" }))).toBe("EDIT"); expect(await ActorContext.run(adminKey, () => actorLevel(prisma, { workspaceId: WS, userId: "creator" }))).toBe("FULL_ACCESS"); expect( await ActorContext.run(key(["ADMIN"], "ws_other"), () => actorLevel(prisma, { workspaceId: WS, userId: "creator" })) ).toBe("NONE"); }); it("actorMembership synthesizes a role from the key's scope", async () => { const prisma = leakyFixture(); expect(await ActorContext.run(readKey, () => actorMembership(prisma, "creator", WS))).toEqual({ id: "apikey-ak_1", role: "GUEST" }); expect(await ActorContext.run(writeKey, () => actorMembership(prisma, "creator", WS))).toEqual({ id: "apikey-ak_1", role: "MEMBER" }); expect(await ActorContext.run(adminKey, () => actorMembership(prisma, "creator", WS))).toEqual({ id: "apikey-ak_1", role: "ADMIN" }); // Cross-workspace → null (no probe of the creator's rows). expect( await ActorContext.run(key(["ADMIN"], "ws_other"), () => actorMembership(prisma, "creator", WS)) ).toBeNull(); }); it("actorIsAdminKey: only ADMIN-scope keys of the workspace", () => { expect(ActorContext.run(adminKey, () => actorIsAdminKey(WS))).toBe(true); expect(ActorContext.run(writeKey, () => actorIsAdminKey(WS))).toBe(false); expect(ActorContext.run(key(["ADMIN"], "ws_other"), () => actorIsAdminKey(WS))).toBe(false); expect(actorIsAdminKey(WS)).toBe(false); // no request context }); it("without an actor context the legacy user path is unchanged", async () => { const prisma = fixture({ [`${WS}:u1`]: { role: "MEMBER" } }, {}, []); expect(await assertCan(prisma, { workspaceId: WS, userId: "u1" }, "EDIT")).toBe("EDIT"); expect(await actorLevel(prisma, { workspaceId: WS, userId: "u1" })).toBe("EDIT"); }); });