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:
@@ -422,15 +422,17 @@ describe("tills are gated by the module permission", () => {
|
||||
const a = await admin();
|
||||
seedTariff(db);
|
||||
await seedSettings(a);
|
||||
// The wash-operator JOB: no shift:* / drawer:* at all — the wash till is guarded by
|
||||
// carwash:read / carwash:cash (venue-modules.md §"Permissions matrix").
|
||||
const washer = await seedUser(db, {
|
||||
username: "lavazhier", roleId: "washer",
|
||||
permissions: ["carwash:read", "carwash:create", "carwash:update", "shift:read", "shift:create", "drawer:create"],
|
||||
permissions: ["carwash:read", "carwash:create", "carwash:update", "carwash:cash"],
|
||||
});
|
||||
const w = await login(app, washer.username, washer.password);
|
||||
// What the UI offers: only the wash till.
|
||||
const tills = await app.inject({ method: "GET", url: "/api/shift/tills", headers: { cookie: w.cookie } });
|
||||
expect(tills.json().tills.map((t: { till: string }) => t.till)).toEqual(["carwash"]);
|
||||
// The booth's shift is refused outright (the role lacks session:read).
|
||||
// The booth's shift is refused outright (the role holds no shift:*).
|
||||
const booth = await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(w) });
|
||||
expect(booth.statusCode).toBe(403);
|
||||
expect(booth.json()).toMatchObject({ code: "till_forbidden", till: "booth" });
|
||||
@@ -445,7 +447,15 @@ describe("tills are gated by the module permission", () => {
|
||||
const washCash = await app.inject({ method: "POST", url: "/api/drawer/movement", headers: hdrs(w), payload: { type: "cash_in", amountMinor: 100, till: "carwash" } });
|
||||
expect(washCash.statusCode).toBe(200);
|
||||
|
||||
// A booth operator (session:read, no carwash:read) cannot touch the wash till.
|
||||
// A wash user who may look (carwash:read) but not work the till (no carwash:cash)
|
||||
// sees the state and gets canWork=false; opening is refused.
|
||||
const looker = await seedUser(db, { username: "looker", roleId: "wash-look", permissions: ["carwash:read"] });
|
||||
const l = await login(app, looker.username, looker.password);
|
||||
const lookTills = (await app.inject({ method: "GET", url: "/api/shift/tills", headers: { cookie: l.cookie } })).json();
|
||||
expect(lookTills.tills).toMatchObject([{ till: "carwash", canWork: false }]);
|
||||
expect((await app.inject({ method: "POST", url: "/api/shift/open", headers: hdrs(l), payload: { till: "carwash" } })).statusCode).toBe(403);
|
||||
|
||||
// A booth operator (shift:*, no carwash:*) cannot touch the wash till.
|
||||
const booth1 = await seedUser(db, {
|
||||
username: "boothie", roleId: "booth-op",
|
||||
permissions: ["session:read", "payment:create", "shift:read", "shift:create"],
|
||||
@@ -456,3 +466,29 @@ describe("tills are gated by the module permission", () => {
|
||||
expect((await app.inject({ method: "GET", url: "/api/shift/tills", headers: { cookie: b.cookie } })).json().tills.map((t: { till: string }) => t.till)).toEqual(["booth"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("a role reassignment takes effect without re-login", () => {
|
||||
it("a user moved from a look-only role to the wash-operator role can create an order on the next request", async () => {
|
||||
const a = await admin();
|
||||
seedTariff(db);
|
||||
const ids = await seedSettings(a);
|
||||
await openSession("T-R");
|
||||
const looker = await seedUser(db, { username: "moved", roleId: "wash-look", permissions: ["carwash:read"] });
|
||||
// Materialise the target role (seedUser creates the role rows; the user itself is a throwaway).
|
||||
await seedUser(db, { username: "throwaway", roleId: "wash-op", permissions: ["carwash:read", "carwash:create", "carwash:update", "carwash:cash"] });
|
||||
const l = await login(app, looker.username, looker.password);
|
||||
const before = await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs(l), payload: { identity: "T-R", categoryId: ids.car, serviceId: ids.std } });
|
||||
expect(before.statusCode).toBe(403);
|
||||
|
||||
const list = (await app.inject({ method: "GET", url: "/api/users", headers: { cookie: a.cookie } })).json();
|
||||
const id = list.users.find((u: { username: string }) => u.username === "moved").id;
|
||||
const moved = await app.inject({ method: "PUT", url: `/api/users/${id}`, headers: hdrs(a), payload: { roleId: "wash-op" } });
|
||||
expect(moved.statusCode).toBe(200);
|
||||
|
||||
// Same cookie, no re-login: the token's pinned role is refreshed per request.
|
||||
const after = await app.inject({ method: "POST", url: "/api/carwash/orders", headers: hdrs(l), payload: { identity: "T-R", categoryId: ids.car, serviceId: ids.std } });
|
||||
expect(after.statusCode).toBe(201);
|
||||
const me = (await app.inject({ method: "GET", url: "/api/auth/me", headers: { cookie: l.cookie } })).json();
|
||||
expect(me.roleId).toBe("wash-op");
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user