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:
@@ -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**
|
||||
|
||||
Reference in New Issue
Block a user