/** * AI Software Factory — Integration: Permissions (Phase 9) * * The fine-grained grant matrix on top of workspace roles: * * Permission { granteeType: user|team|role, granteeId, * resourceType: workspace|teamspace|page|database|record|file, * level: NONE|VIEW|COMMENT|EDIT|FULL_ACCESS, inheritsFrom } * * Covered: * - grant matrix: VIEW (read-only), EDIT (mutate content, not grants), * FULL_ACCESS (manage grants) — verified by effective HTTP behavior * - inheritance: a workspace-level grant propagates to child pages; a * page-level grant (linked via inheritsFrom) elevates ONE page only * - explicit NONE denies even where a role would allow * - revoke removes access * - permission changes are audited (security-sensitive operation) * - outsiders cannot list or create grants in a foreign workspace * * Authorization is ALWAYS enforced server-side — the tests drive the API * and assert on HTTP status, never on client-side behavior. */ import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { createTestApp, type TestAppHandle } from "./helpers/api"; import { addMemberViaSeam, authHeaders, countAuditEvents, createWorkspace, createUser } from "./helpers/seed"; let app: TestAppHandle; beforeAll(async () => { app = await createTestApp(); }, 180_000); afterAll(async () => { await app?.close(); }); const json = async (res: Response): Promise> => (await res.json().catch(() => ({}))) as Record; /** Add a registered user to the workspace with a given role; returns the user. * (TASK-0095 S9: via the internal test seam — the D3 unblock. The public * invite flow hashes the token and mails it, so a test cannot complete it * without a mail server.) */ async function addMember( _ownerToken: string, workspaceId: string, role: "MEMBER" | "GUEST" | "ADMIN", ): Promise<{ userId: string; email: string; accessToken: string; name: string }> { const user = await createUser(app.request, { name: `Perm ${role}` }); await addMemberViaSeam(app.request, app.internalServiceToken, workspaceId, user.email, role); return user; } /** Create a fine-grained grant; returns the grant id. */ async function grant( ownerToken: string, workspaceId: string, body: Record, ): Promise { const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/permissions`, { headers: authHeaders(ownerToken), body, }); expect( [200, 201], `grant failed (${res.status}): ${await res.text()}`, ).toContain(res.status); const grantBody = await json(res); return ( (grantBody.permission as { id: string } | undefined)?.id ?? (grantBody.grant as { id: string } | undefined)?.id ?? (grantBody.id as string | undefined) ?? "" ); } async function revoke(ownerToken: string, grantId: string): Promise { const res = await app.request("DELETE", `/api/v1/permissions/${grantId}`, { headers: authHeaders(ownerToken), }); expect([200, 204]).toContain(res.status); } /** Create a page owned by the workspace owner; returns its id. */ async function newPage(ownerToken: string, workspaceId: string, title: string): Promise { const res = await app.request("POST", `/api/v1/workspaces/${workspaceId}/pages`, { headers: authHeaders(ownerToken), body: { title }, }); expect(res.status).toBe(201); // D4 repair: the page create returns the BARE page (no `page` wrapper). return ((await json(res)) as { id: string }).id; } async function setupOwnerWorkspace(): Promise<{ ownerToken: string; workspaceId: string; ownerId: string; }> { const owner = await createUser(app.request, { name: "Perm Owner" }); const { workspaceId } = await createWorkspace( app.request, authHeaders(owner.accessToken!), "Perm WS", ); return { ownerToken: owner.accessToken!, workspaceId, ownerId: owner.userId }; } describe("Permissions integration", () => { it("grant matrix VIEW: the grantee can READ the page but not edit it", async () => { const { ownerToken, workspaceId } = await setupOwnerWorkspace(); const guest = await addMember(ownerToken, workspaceId, "GUEST"); const pageId = await newPage(ownerToken, workspaceId, "View Only"); // Baseline: a GUEST with no extra grant cannot edit (role default). const baseEdit = await app.request("PATCH", `/api/v1/pages/${pageId}`, { headers: authHeaders(guest.accessToken!), body: { title: "Guest sneak" }, }); expect(baseEdit.status).toBe(403); // Grant VIEW explicitly — still read-only, but read must succeed. await grant(ownerToken, workspaceId, { granteeType: "user", granteeId: guest.userId, resourceType: "page", resourceId: pageId, level: "VIEW", }); const read = await app.request("GET", `/api/v1/pages/${pageId}`, { headers: authHeaders(guest.accessToken!), }); expect(read.status).toBe(200); const edit = await app.request("PATCH", `/api/v1/pages/${pageId}`, { headers: authHeaders(guest.accessToken!), body: { title: "Guest sneak 2" }, }); expect(edit.status).toBe(403); }); it("grant matrix EDIT: the grantee can edit content but not manage grants", async () => { const { ownerToken, workspaceId } = await setupOwnerWorkspace(); const guest = await addMember(ownerToken, workspaceId, "GUEST"); const pageId = await newPage(ownerToken, workspaceId, "Editable"); await grant(ownerToken, workspaceId, { granteeType: "user", granteeId: guest.userId, resourceType: "page", resourceId: pageId, level: "EDIT", }); // Content edit is now allowed. const edit = await app.request("PATCH", `/api/v1/pages/${pageId}`, { headers: authHeaders(guest.accessToken!), body: { title: "Edited by grantee" }, }); expect(edit.status).toBe(200); const page = await app.prisma.page.findUniqueOrThrow({ where: { id: pageId } }); expect(page.title).toBe("Edited by grantee"); // But grant management is NOT (that requires FULL_ACCESS). const manage = await app.request( "POST", `/api/v1/workspaces/${workspaceId}/permissions`, { headers: authHeaders(guest.accessToken!), body: { granteeType: "user", granteeId: guest.userId, resourceType: "page", resourceId: pageId, level: "FULL_ACCESS", }, }, ); expect(manage.status).toBe(403); }); it("grant matrix FULL_ACCESS: the grantee can manage grants on that resource", async () => { const { ownerToken, workspaceId } = await setupOwnerWorkspace(); const guest = await addMember(ownerToken, workspaceId, "GUEST"); const pageId = await newPage(ownerToken, workspaceId, "Full Control"); await grant(ownerToken, workspaceId, { granteeType: "user", granteeId: guest.userId, resourceType: "page", resourceId: pageId, level: "FULL_ACCESS", }); // The grantee can now add a grant (on the same resource, at a lower level). const other = await addMember(ownerToken, workspaceId, "GUEST"); const create = await app.request( "POST", `/api/v1/workspaces/${workspaceId}/permissions`, { headers: authHeaders(guest.accessToken!), body: { granteeType: "user", granteeId: other.userId, resourceType: "page", resourceId: pageId, level: "VIEW", }, }, ); expect(create.status).toBe(201); }); it("inheritance: workspace VIEW grant reaches a child page; page EDIT grant elevates only that page", async () => { const { ownerToken, workspaceId, ownerId } = await setupOwnerWorkspace(); // A user who is a workspace MEMBER (so they are in-tenant) — we model // the inheritance of a workspace-level grant to its pages. const member = await addMember(ownerToken, workspaceId, "MEMBER"); const parent = await newPage(ownerToken, workspaceId, "Parent"); const childA = await newPage(ownerToken, workspaceId, "Child A"); const childB = await newPage(ownerToken, workspaceId, "Child B"); void parent; void ownerId; // Workspace-level grant: VIEW on the whole workspace. await grant(ownerToken, workspaceId, { granteeType: "user", granteeId: member.userId, resourceType: "workspace", resourceId: workspaceId, level: "VIEW", }); // Inherited: the member can read BOTH child pages with no page grant. const readA = await app.request("GET", `/api/v1/pages/${childA}`, { headers: authHeaders(member.accessToken!), }); const readB = await app.request("GET", `/api/v1/pages/${childB}`, { headers: authHeaders(member.accessToken!), }); expect(readA.status).toBe(200); expect(readB.status).toBe(200); // Page-level EDIT grant on childA (linked to the workspace grant). const wsGrant = await app.prisma.permission.findFirst({ where: { workspaceId, granteeId: member.userId, resourceType: "workspace", level: "VIEW", }, }); const pageGrantId = await grant(ownerToken, workspaceId, { granteeType: "user", granteeId: member.userId, resourceType: "page", resourceId: childA, level: "EDIT", inheritsFrom: wsGrant?.id ?? null, }); expect(pageGrantId).toBeTruthy(); // childA is now editable by the member ... const editA = await app.request("PATCH", `/api/v1/pages/${childA}`, { headers: authHeaders(member.accessToken!), body: { title: "Child A edited" }, }); expect(editA.status).toBe(200); // ... but childB remains read-only (the EDIT grant does not propagate // to siblings). const editB = await app.request("PATCH", `/api/v1/pages/${childB}`, { headers: authHeaders(member.accessToken!), body: { title: "Child B edited" }, }); expect(editB.status).toBe(403); // The inheritance link is recorded. const stored = await app.prisma.permission.findUniqueOrThrow({ where: { id: pageGrantId }, }); expect(stored.level).toBe("EDIT"); expect(stored.inheritsFrom).toBe(wsGrant?.id ?? null); }); it("explicit NONE: denies even where the role would otherwise allow", async () => { const { ownerToken, workspaceId } = await setupOwnerWorkspace(); // A MEMBER (role allows edit) is explicitly denied with NONE on a page. const member = await addMember(ownerToken, workspaceId, "MEMBER"); const pageId = await newPage(ownerToken, workspaceId, "Locked For Member"); await grant(ownerToken, workspaceId, { granteeType: "user", granteeId: member.userId, resourceType: "page", resourceId: pageId, level: "NONE", }); const edit = await app.request("PATCH", `/api/v1/pages/${pageId}`, { headers: authHeaders(member.accessToken!), body: { title: "Member override" }, }); expect(edit.status).toBe(403); }); it("revoke: removing a grant restores the role-default (denied) state", async () => { const { ownerToken, workspaceId } = await setupOwnerWorkspace(); const guest = await addMember(ownerToken, workspaceId, "GUEST"); const pageId = await newPage(ownerToken, workspaceId, "Revoke Me"); const grantId = await grant(ownerToken, workspaceId, { granteeType: "user", granteeId: guest.userId, resourceType: "page", resourceId: pageId, level: "EDIT", }); // With the grant: allowed. const allowed = await app.request("PATCH", `/api/v1/pages/${pageId}`, { headers: authHeaders(guest.accessToken!), body: { title: "While granted" }, }); expect(allowed.status).toBe(200); // Revoke it. await revoke(ownerToken, grantId); const gone = await app.prisma.permission.findUnique({ where: { id: grantId } }); expect(gone).toBeNull(); // After revoke: back to role default (guest cannot edit). const denied = await app.request("PATCH", `/api/v1/pages/${pageId}`, { headers: authHeaders(guest.accessToken!), body: { title: "After revoke" }, }); expect(denied.status).toBe(403); }); it("audit: creating and revoking a grant are audited as permission.changed", async () => { const { ownerToken, workspaceId, ownerId } = await setupOwnerWorkspace(); const guest = await addMember(ownerToken, workspaceId, "GUEST"); const pageId = await newPage(ownerToken, workspaceId, "Audited"); const before = await countAuditEvents(app.prisma, { action: "permission.changed", actorId: ownerId, }); const grantId = await grant(ownerToken, workspaceId, { granteeType: "user", granteeId: guest.userId, resourceType: "page", resourceId: pageId, level: "EDIT", }); await revoke(ownerToken, grantId); const after = await countAuditEvents(app.prisma, { action: "permission.changed", actorId: ownerId, }); expect(after).toBe(before + 2); // one for grant, one for revoke }); it("authorization: an outsider cannot list or create grants in a foreign workspace", async () => { const { ownerToken, workspaceId } = await setupOwnerWorkspace(); const outsider = await createUser(app.request, { name: "Perm Outsider" }); const oh = authHeaders(outsider.accessToken!); const list = await app.request("GET", `/api/v1/workspaces/${workspaceId}/permissions`, { headers: oh, }); expect([403, 404]).toContain(list.status); const create = await app.request("POST", `/api/v1/workspaces/${workspaceId}/permissions`, { headers: oh, body: { granteeType: "user", granteeId: outsider.userId, resourceType: "workspace", resourceId: workspaceId, level: "FULL_ACCESS", }, }); expect([403, 404]).toContain(create.status); }); });