feat(permissions): per-desk till guards, jobs in the role composer, permission-scoped live feed; role reassignment applies without re-login
Permissions matrix rethink (wiki/decisions/venue-modules.md §"Permissions matrix", open-questions #16) — the grid stays the enforcement layer: - Move 1: each desk's money is guarded by that desk's own permissions. Manifest tillGuards {read, shift, cash}: booth = shift:read / shift:create / drawer:create (unchanged), carwash = carwash:read / carwash:cash (new). Shift + drawer routes resolve the guard FROM THE TILL (requireTill); a wash role holds no shift:* and cannot touch the booth by construction. Replaces the session:read borrowing (tillPermission). /api/shift/tills lists the role's readable tills with canWork; history/movements without a till filter return the union of readable tills. - Move 2: jobs — manifest permission bundles (booth-operator, booth-supervisor, merchant, wash-operator) as one-click chips in Setup → Roles, with "mixes desks" and "partial job" lints (warnings, never blocks). - Move 3: the live WebSocket admits any watch permission (event/session/device read or a module's feedPermission) and filters every push per role; report:read is the reports screen only. Auth: the token's roleId is only a hint — refreshRole() after every jwtVerify resolves the user's CURRENT role (cached, bumped on role/user writes), so reassigning a user's role applies on the next request and a deleted user's session ends with 401. Tests: till guards + look-only role, feed rules, every job's permissions exist, role reassignment without re-login. 353/353. Claude-Session: https://claude.ai/code/session_01FWncR69HgGPuei1dLrW3cU
This commit is contained in:
@@ -176,3 +176,30 @@ describe("entitlement (vendor env)", () => {
|
||||
expect(cfg.json().modules).toEqual(["parking", "validation"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("permissions matrix helpers (venue-modules.md §Permissions matrix)", async () => {
|
||||
const shared = await import("@parking/shared");
|
||||
it("each till is guarded by its own module's permissions", () => {
|
||||
expect(shared.tillGuards("booth")).toEqual({ read: "shift:read", shift: "shift:create", cash: "drawer:create" });
|
||||
expect(shared.tillGuards("carwash")).toEqual({ read: "carwash:read", shift: "carwash:cash", cash: "carwash:cash" });
|
||||
const wash = new Set(["carwash:read", "carwash:cash"]);
|
||||
expect(shared.tillsFor(["parking", "validation", "carwash"], (p) => wash.has(p))).toEqual(["carwash"]);
|
||||
expect(shared.tillsFor(["parking", "validation", "carwash"], (p) => wash.has(p), "shift")).toEqual(["carwash"]);
|
||||
expect(shared.tillsFor(["parking", "validation", "carwash"], (p) => p === "carwash:read", "shift")).toEqual([]);
|
||||
// Module off → its till is not even addressable.
|
||||
expect(shared.tillsFor(["parking"], () => true)).toEqual(["booth"]);
|
||||
});
|
||||
it("the live feed admits by watch permission and filters ledger events by their module", () => {
|
||||
expect(shared.watchPermissions(["parking", "validation", "carwash"])).toEqual(
|
||||
expect.arrayContaining(["event:read", "session:read", "device:read", "carwash:read"]),
|
||||
);
|
||||
expect(shared.watchPermissions(["parking", "validation", "carwash"])).not.toContain("report:read");
|
||||
expect(shared.watchPermissions(["parking"])).not.toContain("carwash:read");
|
||||
expect(shared.feedPermissionFor("carwash_payment")).toBe("carwash:read");
|
||||
expect(shared.feedPermissionFor("payment")).toBe("event:read");
|
||||
expect(shared.feedPermissionFor("validation")).toBe("event:read");
|
||||
});
|
||||
it("every job's permissions exist in the grid", () => {
|
||||
for (const m of shared.MODULES) for (const j of m.jobs) for (const p of j.permissions) expect(shared.PERMISSIONS).toContain(p);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user