feat(permissions): per-desk till guards, jobs in the role composer, permission-scoped live feed; role reassignment applies without re-login
CI / check (push) Successful in 46s
Build & push images / images (push) Successful in 2m58s
Build desktop / desktop (push) Successful in 4m53s

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:
2026-09-05 14:45:48 +02:00
parent a9ccf9e20c
commit 55d6242c7d
24 changed files with 654 additions and 206 deletions
+57
View File
@@ -374,6 +374,63 @@ at the two seams the design names, and the registry earned its keep: **one manif
the next increment, as designed. Receipt label for a booth-paid wash is `Lavazh — <category> ·
<service>` (Albanian, frozen on the payment).
## Permissions matrix — rethink (OPEN DECISION, raised 2026-09-05; moves 1–2 built same day)
**Why (user: "I feel we opened Pandora's box with this car wash module. We need to rethink
the permissions matrix.").** The flat `resource:action` grid was composed for ONE desk. Three
things broke once a second desk existed:
1. **Permissions named data, not jobs, and their meanings got borrowed.** `session:read` meant
"may use the booth screen"; on the first tills cut it also decided who may work the booth
till. `report:read` meant "may open the live socket". `shift:create` opened *the* shift. Each
was a proxy for a job, and proxies are how the dev `Lavazhier` role ended up with booth
rights and without `carwash:create`.
2. **Cross-cutting resources have no owner.** Shifts, drawer, events, the feed are core, but
every *instance* now belongs to a desk; the grid cannot say "shifts, but only the wash's".
3. **The composer is at the wrong altitude.** ~60 checkboxes of nouns and verbs ask the admin to
reconstruct a job from parts; at a site where the operator is the adversary a mis-composed
role is a security bug.
**Decision (three moves; the grid stays the enforcement layer — no guard semantics change for
the booth).**
- **Move 1 — each desk's money is guarded by that desk's own permissions.** The manifest
declares `tillGuards { read, shift, cash }`: booth = `shift:read` / `shift:create` /
`drawer:create` (parking's own, unchanged); carwash = `carwash:read` / **`carwash:cash`** (new)
/ `carwash:cash`. Shift + drawer routes resolve the guard FROM THE TILL
(`requireTill(kind)`), so a wash role holds no `shift:*` at all and cannot touch the booth by
construction; a role that should work both simply holds both. Replaces the one-day-old
`session:read` borrowing (`tillPermission`), which is deleted. `/api/shift/tills` lists the
tills a role may *read* with a `canWork` flag; history and movements without a till filter
return the union of the role's readable tills (admin scopes `shift:cash` / `drawer:review`
unchanged).
- **Move 2 — jobs on top of the grid.** Manifest `jobs[]` = named permission bundles: parking →
*Booth operator*, *Booth supervisor*; validation → *Merchant*; carwash → *Wash operator*. The
roles composer offers the jobs of the EFFECTIVE modules as one-click chips (add / remove the
bundle), with the grid kept as the fine-tune view, and LINTS the result: **mixes desks** (the
role may open more than one till) and **partial job** (holds a module's read permission but
not the rest of its job — e.g. a desk that can look but not create). Warnings, not blocks: the
admin is not the adversary, but must see what they composed.
- **Move 3 — the live feed follows the same rule (user: "The user should have websocket for
live events. This does not mean it can read the /reports section.").** The socket is no longer
gated on `report:read`. A role may connect if it holds ANY watch permission
(`event:read`, `session:read`, `device:read`, or an effective module's `feedPermission` —
carwash: `carwash:read`), and each pushed message is FILTERED per role: a ledger event needs
`feedPermissionFor(type)` (the owning module's, else `event:read`); occupancy needs
`session:read`; device / printer / lane / radar need `device:read`; plate backfill needs
`session:read`. So the wash desk gets a live queue without the booth's ledger, and the booth
operator keeps a feed without reports. `report:read` now means exactly the reports screen.
**Rejected.** Scoped permission strings (`shift:create@carwash`) — changes the `Permission`
type everywhere for what a manifest lookup expresses; a per-module copy of the shift/drawer
resources — the till already IS that copy. Role *templates stored in the DB* — jobs are code
(they change with the module), roles are data; keep that line.
**Status.** Moves 1, 2 and 3 built 2026-09-05 (see the Tills as-built below and [[shift]]
§Tills). Open: whether `booth-supervisor` should carry `subscription:*` by default; whether a
job should be *re-applicable* after a module update (today a chip only adds/removes the bundle
as it is now); an audit `config_change` on role edits.
## Tills: shifts per money-taking module — BUILT (raised + built 2026-09-05)
**The problem, found on the first wash-desk review.** [[shift]] is a single **site-wide**