feat(auth): dynamic RBAC — composable roles + resource×CRUD permissions

Replace the hardcoded role enum (admin/operator/cashier/readonly, checked
literally as requireRole("admin",...) across ~15 routes) with dynamic RBAC:
roles are DATA, route guards check a PERMISSION.

@parking/shared defines a code-defined grid: RESOURCES (user/role/tariff/
subscription/site/device/shift/payment/session/event/report) × Action
(create/read/update/delete + domain verbs void/cash) -> PERMISSIONS
(resource:action, e.g. tariff:update, payment:create, event:void).

DB: new roles + role_permissions tables; users.role enum -> role_id FK;
migration 0007_rbac (create tables, seed the builtin admin role + all 26
perms, seed operator/cashier/readonly composable roles matching old
behaviour, rebuild users to swap the column copying all rows).

auth.ts: JWT payload role -> roleId; permissionsFor(roleId) with an
in-memory cache + bumpPermsCache(); requirePermission(...perms) preHandler;
requireAuth for /me & /language; initAuth(db) wires the resolver once. Every
route guard mapped to a permission; device ingress (devices/qr-reader) stays
auth-free by design. New routes/users.ts (user:* CRUD, bcrypt 12, last-admin
guard) + routes/roles.ts (role:* CRUD, builtin-protected, perms validated
against the grid, cache bump on write). auth/me + /login return
{roleId, roleName, permissions, language}. seed-admin -> roleId:'admin'.

Frontend: SessionUser carries permissions + can() helper; router nav/route
guards gate by permission (requirePerm replaces adminOnly); SiteSettings
edit gated by site:update; new UsersManager + RolesManager (permission
checkbox grid; admin role locked); i18n nav.users/roles + blocks (sq+en).

Decisions: one role per user; protected built-in admin (no-lockout: the last
admin can't be deleted/downgraded); JWT carries roleId, perms resolved
per-request so role edits apply immediately (no re-login).

Verified: full build green; 20-assertion inject test passes (cashier 403s on
tariff publish + user list, admin passes, granting a perm applies on the next
request, last-admin + builtin-role protections return 409); migration 0007
applied to a copy of the live DB (incl WAL/shm) — existing admin maps to
role_id='admin', all rows preserved. Append-only event chain untouched
(event:void gates appending a void, not a delete).

Claude-Session: https://claude.ai/code/session_01Xcm6ikLgGoCxxHrxtjkk5V
This commit is contained in:
2026-06-19 01:19:28 +02:00
parent d71ba82999
commit d0841c8601
29 changed files with 1301 additions and 104 deletions
+4
View File
@@ -876,3 +876,7 @@ After a completed payment the customer now always gets a transparency record: EN
## [2026-06-18] fix | Receipt/voucher misprint — CP852 `Ë` byte + Intl NBSP (found on a real printout)
A printed exit voucher (photo from the booth) surfaced three glitches in the new payment receipt, all fixed in `printer-escpos.ts`. (1) **Title garbled**: uppercase `Ë` was mapped to CP852 `0xEB` — wrong (that's `ű`); the correct byte is `0xD3` (U+00CB). `BILETË DALJE`/`FATURË PAGESE` now print correctly (lowercase `ë`=0x89 was always fine). (2) **`1000?Lekë`**: `Intl.NumberFormat("sq-AL", currency:"ALL")` separates amount from currency with a NO-BREAK SPACE (U+00A0; some locales U+202F narrow NBSP), which isn't in CP852 → printed as `?`. `line()` now normalises U+00A0/U+202F → plain space before encoding, so any Intl-formatted value on a ticket is safe, not just money. (3) **Grace line wrapped mid-word** ("…në dali / 8."): split `graceLine` into two short lines (`graceLines`) that each fit 80mm, emitted as two centered line() calls. Re-rendered & byte-verified: 0xD3 present, no 0x3f (`?`) byte, two clean grace lines, `1000 Lekë`. Full build green. Documented the CP852 gotchas in [[rongta-printer]]. NB: verify CP852 bytes against Unicode.org CP852.TXT, never guess. No schema/event-chain change.
## [2026-06-18] feat | Dynamic RBAC — composable roles + resource×CRUD permissions
Replaced the hardcoded role enum (admin/operator/cashier/readonly, checked literally as requireRole("admin",...) across ~15 routes) with DYNAMIC RBAC: roles are DATA, route guards check a PERMISSION. @parking/shared now defines a code-defined grid — RESOURCES (user/role/tariff/subscription/site/device/shift/payment/session/event/report) × Action (create/read/update/delete + domain verbs void/cash) → PERMISSIONS (resource:action). DB: new `roles` + `role_permissions` tables; users.role enum → role_id FK; migration 0007_rbac (create tables, seed builtin admin role + all 26 perms, seed operator/cashier/readonly composable roles matching old behaviour, rebuild users to swap the column copying all rows). auth.ts: JWT payload role→roleId; permissionsFor(roleId) with in-memory cache + bumpPermsCache(); requirePermission(...perms) preHandler (jwtVerify+CSRF+perm check); requireAuth for /me & /language; initAuth(db) wires the resolver once in buildServer. Every route guard mapped to a permission (tariff:read/update, payment:create/read, session:read, shift:read/create/cash, site:read/update, device:read, subscription:*, event:read; ws→report:read); device ingress (devices.ts/qr-reader.ts) stays auth-free by design. New routes/users.ts (user:* CRUD, bcrypt 12, last-admin guard) + routes/roles.ts (role:* CRUD, builtin-protected, perms validated against the grid, cache bump on write). auth/me + /login now return {roleId, roleName, permissions, language}. seed-admin.mjs → roleId:'admin'. Frontend: api.ts SessionUser carries permissions + can() helper + users/roles CRUD fns; router.tsx nav/route guards gate by permission (requirePerm factory replaces adminOnly), SiteSettings edit now gated by site:update; new UsersManager.tsx + RolesManager.tsx (permission checkbox grid; admin role read-only/locked); i18n nav.users/roles + users/roles blocks (sq+en, parity green). DECISIONS (with user): one role per user; protected built-in admin (no-lockout); JWT carries roleId, perms resolved per-request (role edits apply immediately). VERIFIED: full monorepo build green; a 20-assertion inject test (cashier 403s on tariff publish + user list, admin passes, granting tariff:update to the cashier role applies on the NEXT request = cache invalidation works, last-admin delete/downgrade → 409, builtin admin role edit/delete → 409) all pass; migration 0007 applied to a COPY of the live DB (incl WAL/shm) → existing admin maps to role_id='admin', 4 roles seeded, 26 admin perms, all user rows preserved. Updated [[local-jwt-auth]]. Append-only event chain untouched (event:void gates appending a void, not a delete).