feat(auth): pure access predicates for role and client scoping

This commit is contained in:
Dave 2026-09-16 09:42:02 +02:00
parent f9e3e576f0
commit d14694ceab
2 changed files with 145 additions and 0 deletions

View file

@ -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);
}
});
});

64
src/lib/auth/access.ts Normal file
View file

@ -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);
}
}