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:
+11
-7
@@ -294,13 +294,17 @@ manifest (Car Wash → `carwash`; a future Bar → `bar`). Rules:
|
||||
- "Take money at the bay" requires the **carwash** shift, not the booth's; the wash desk
|
||||
carries its own shift control. The header button stays the booth's. The shift hub lists
|
||||
every open shift with a till badge; the drawer hub switches tills.
|
||||
- **Working a till needs that till's module permission** (added 2026-09-05 after the user
|
||||
found a wash user could open the *booth's* shift): the manifest names it
|
||||
(`tillPermission` — booth: `session:read`, carwash: `carwash:read`), `tillsFor()` in
|
||||
`@parking/shared` resolves a role's tills, the shift/drawer routes refuse the rest with
|
||||
`403 till_forbidden`, and `/api/shift/tills` + `current.tills` return only the role's
|
||||
tills — so the header button, the hub's start buttons and the drawer switch never offer a
|
||||
till the server would refuse. `shift:create` alone opens nothing.
|
||||
- **Each desk's money is guarded by that desk's own permissions** (2026-09-05, after the
|
||||
user found a wash user could open the *booth's* shift; design on [[venue-modules]]
|
||||
§"Permissions matrix"). The manifest declares `tillGuards { read, shift, cash }`: booth =
|
||||
`shift:read` / `shift:create` / `drawer:create` (unchanged), carwash = `carwash:read` /
|
||||
`carwash:cash` / `carwash:cash`. The shift + drawer routes resolve the guard FROM THE TILL
|
||||
(`requireTill(kind)`; `403 till_forbidden`), `/api/shift/tills` lists the tills a role may
|
||||
read with a `canWork` flag, and history / movements without a till filter return the
|
||||
union of the role's readable tills. So a wash role holds no `shift:*` at all and cannot
|
||||
touch the booth by construction; the header button, the hub's start buttons and the
|
||||
drawer switch never offer a till the server would refuse. (A first cut that borrowed
|
||||
`session:read` as "works the booth till" lived for a few hours and is gone.)
|
||||
- Not done: the per-shift *activity log* is still a time window over the whole chain (money
|
||||
figures are per till, the event list is not); bay slips print on the booth printer.
|
||||
|
||||
|
||||
@@ -138,3 +138,11 @@ procurement. (See [[parking-system-architecture]] §10.)
|
||||
payment lands on the wash operator's own till, never the booth's). Open: vision category
|
||||
flag, bay camera, the Bar's scope. Full design and the remaining questions on
|
||||
[[venue-modules]].
|
||||
|
||||
16. **Permissions matrix after venue modules.** _(Raised by the user, 2026-09-05.)_ The flat
|
||||
`resource:action` grid was composed for one desk; a second desk (Car Wash) exposed borrowed
|
||||
meanings (`session:read` as "works the booth till", `report:read` as "may open the socket")
|
||||
and a composer at the wrong altitude. Decision + three moves (per-desk till guards, jobs on
|
||||
top of the grid, a permission-scoped live feed) on [[venue-modules]] §"Permissions matrix";
|
||||
moves built 2026-09-05. Open: default supervisor bundle, re-applying jobs after a module
|
||||
update, signing role edits.
|
||||
|
||||
@@ -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**
|
||||
|
||||
@@ -32,6 +32,11 @@ Authentication and authorization, kept **fully local** — a direct consequence
|
||||
name. The JWT carries `roleId` (not the permission list); the guard resolves the role's permission
|
||||
set per-request from an **in-memory cache** (`bumpPermsCache()` on any role write), so editing a
|
||||
role applies immediately — no re-login, no token bloat. No Casbin/engine needed at this scale.
|
||||
**The token's `roleId` is only a hint (2026-09-05):** after every `jwtVerify` the guard replaces it
|
||||
with the user's CURRENT role from the DB (`refreshRole()`; cached per user, cleared by the same
|
||||
`bumpPermsCache()`, which user update/delete now call), so REASSIGNING a user's role — or deleting
|
||||
the user (→ 401 on their next request) — applies immediately too. Found when a user moved to a new
|
||||
wash role kept the old role's rights until logout.
|
||||
- **Protected built-in `admin` role** (`id='admin'`, `builtin=1`): non-editable, non-deletable, and
|
||||
always resolves to the FULL permission set in code. The app refuses to delete or downgrade the
|
||||
**last user holding admin** — administration can never be locked out of the appliance.
|
||||
|
||||
+26
@@ -3003,3 +3003,29 @@ landing, all guards bounce to `/`, `/booth` needs `session:read`. Diagnosed the
|
||||
`session:create`) and lacks `carwash:create/update` — a role problem, not a code one. The
|
||||
WebSocket stays `report:read`-only by design; the desk polls. Recorded on [[shift]] §Tills
|
||||
and [[venue-modules]] §Tills → As-built.
|
||||
|
||||
## [2026-09-05] ingest | Permissions matrix rethink — three moves built
|
||||
|
||||
User: "we opened Pandora's box with this car wash module … rethink the permissions matrix";
|
||||
and "the user should have websocket for live events — this does not mean it can read
|
||||
/reports". Decision recorded on [[venue-modules]] §"Permissions matrix" (open-questions
|
||||
#16), then built: (1) per-desk till guards — manifest `tillGuards`, new `carwash:cash`,
|
||||
`requireTill(kind)` resolves the guard from the till, `tillPermission`/`session:read`
|
||||
borrowing removed; (2) jobs — manifest `jobs[]` (booth-operator, booth-supervisor,
|
||||
merchant, wash-operator) as one-click chips in Setup → Roles with "mixes desks" / "partial
|
||||
job" lints; (3) the live feed 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. 352/352 server tests; lavazhier (event:read) now shows LIVE. Their dev role
|
||||
still needs `carwash:cash` (+ create/update) and should drop the booth permissions — the
|
||||
"Wash operator" chip is exactly that.
|
||||
|
||||
## [2026-09-05] ingest | Role reassignment now takes effect without re-login
|
||||
|
||||
User: a user moved to a new "Lavazh NEW" role kept getting `403` on `POST /api/carwash/orders`.
|
||||
Cause: the login token pins the `roleId` current at LOGIN; `/api/auth/me` read the user row (new
|
||||
role) while every guard read the token (old role). Editing a role already took effect per
|
||||
request (the permission cache); reassigning one did not. Fix in `auth.ts`: `refreshRole()` after
|
||||
every `jwtVerify` resolves the user's CURRENT role from the DB (cached per user, cleared by
|
||||
`bumpPermsCache()`, which the user update/delete routes now call); a deleted user's session
|
||||
ends with 401 on its next request; the WS cookie path uses the same. Test: moved user creates
|
||||
an order on the next request with the same cookie. Recorded on [[local-jwt-auth]].
|
||||
|
||||
Reference in New Issue
Block a user