From d14694ceabaf3665a0d2a97580dc6f4814b044f3 Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 16 Sep 2026 09:42:02 +0200 Subject: [PATCH] feat(auth): pure access predicates for role and client scoping --- src/lib/auth/access.test.ts | 81 +++++++++++++++++++++++++++++++++++++ src/lib/auth/access.ts | 64 +++++++++++++++++++++++++++++ 2 files changed, 145 insertions(+) create mode 100644 src/lib/auth/access.test.ts create mode 100644 src/lib/auth/access.ts diff --git a/src/lib/auth/access.test.ts b/src/lib/auth/access.test.ts new file mode 100644 index 0000000..5771665 --- /dev/null +++ b/src/lib/auth/access.test.ts @@ -0,0 +1,81 @@ +import { describe, it, expect } from "vitest"; +import { + AccessError, + type Session, + canReadClient, + canWriteClientFields, + assertClientOwnsProject, +} from "@/lib/auth/access"; + +const owner: Session = { userId: "u_owner", role: "owner", clientId: null }; +const wfManager: Session = { + userId: "u_wf", + role: "client_manager", + clientId: "c_wf", +}; +const megaManager: Session = { + userId: "u_mega", + role: "client_manager", + clientId: "c_mega", +}; + +describe("canReadClient", () => { + it("lets the owner read any client", () => { + expect(canReadClient(owner, "c_wf")).toBe(true); + expect(canReadClient(owner, "c_mega")).toBe(true); + }); + + it("lets a manager read only their own client", () => { + expect(canReadClient(wfManager, "c_wf")).toBe(true); + expect(canReadClient(wfManager, "c_mega")).toBe(false); + }); + + it("refuses a manager with no client assigned", () => { + const orphan: Session = { + userId: "u_x", + role: "client_manager", + clientId: null, + }; + expect(canReadClient(orphan, "c_wf")).toBe(false); + }); +}); + +describe("canWriteClientFields", () => { + it("is true for a manager on their own client", () => { + expect(canWriteClientFields(wfManager, "c_wf")).toBe(true); + }); + + it("is false for a manager on someone else's client", () => { + expect(canWriteClientFields(wfManager, "c_mega")).toBe(false); + }); + + it("is false for the owner, who writes through PocketBase directly", () => { + expect(canWriteClientFields(owner, "c_wf")).toBe(false); + }); +}); + +describe("assertClientOwnsProject", () => { + it("passes when the project belongs to the manager's client", () => { + expect(() => + assertClientOwnsProject(wfManager, { id: "p1", client: "c_wf" }) + ).not.toThrow(); + }); + + it("throws for another client's project", () => { + expect(() => + assertClientOwnsProject(megaManager, { id: "p1", client: "c_wf" }) + ).toThrow(/not found/i); + }); + + it("reports 'not found' rather than 'forbidden'", () => { + // Saying "forbidden" would confirm the project exists, which tells one + // client that another client's project is real. + try { + assertClientOwnsProject(megaManager, { id: "p1", client: "c_wf" }); + expect.unreachable("should have thrown"); + } catch (e) { + expect(e).toBeInstanceOf(AccessError); + expect((e as AccessError).status).toBe(404); + } + }); +}); \ No newline at end of file diff --git a/src/lib/auth/access.ts b/src/lib/auth/access.ts new file mode 100644 index 0000000..5b7d798 --- /dev/null +++ b/src/lib/auth/access.ts @@ -0,0 +1,64 @@ +/** + * Pure access predicates. No database, no framework — so the permission rules + * can be read in one screen and tested exhaustively. + * + * Route handlers call these. They never re-derive the logic inline, because + * a rule duplicated in five handlers is a rule that will disagree with itself. + */ + +import type { Role } from "@/lib/pb/types"; + +export interface Session { + userId: string; + role: Role; + /** null for the owner; the client id for a client_manager. */ + clientId: string | null; +} + +export class AccessError extends Error { + constructor( + message: string, + public readonly status: number + ) { + super(message); + this.name = "AccessError"; + } +} + +/** May this session read records belonging to `clientId`? */ +export function canReadClient(session: Session, clientId: string): boolean { + if (session.role === "owner") return true; + if (!session.clientId) return false; + return session.clientId === clientId; +} + +/** + * May this session use the /api/client/* routes against `clientId`? + * + * False for the owner by design: those routes exist to grant a *narrower* + * permission than PocketBase can express. The owner writes to PocketBase + * directly, under the owner-only write rules. + */ +export function canWriteClientFields( + session: Session, + clientId: string +): boolean { + if (session.role !== "client_manager") return false; + if (!session.clientId) return false; + return session.clientId === clientId; +} + +/** + * Throw unless the project belongs to this session's client. + * + * Deliberately reports 404, not 403. A 403 would confirm the project exists, + * which tells one customer that another customer's project is real. + */ +export function assertClientOwnsProject( + session: Session, + project: { id: string; client: string } +): void { + if (!canReadClient(session, project.client)) { + throw new AccessError("Project not found", 404); + } +} \ No newline at end of file