feat(auth): pure access predicates for role and client scoping
This commit is contained in:
parent
f9e3e576f0
commit
d14694ceab
2 changed files with 145 additions and 0 deletions
81
src/lib/auth/access.test.ts
Normal file
81
src/lib/auth/access.test.ts
Normal 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
64
src/lib/auth/access.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue