Skip to content
Roles and permissions

Roles and permissions

The demo roles are not authentication. The permission matrix behind every guard is ROLE_PERMISSIONS in shared/auth/permissions.mjs — product code, and the file a buyer keeps. The demo toolbar's role switcher reads DEMO_ACCOUNTS from shared/auth/session.mjs — demo code, and the file a buyer deletes. Role and permission labels come from enum.role.* and permission.* keys, present in all four message catalogues. A starter ships no shared/auth/ directory at all: it has no demo data, no auth and no record search to guard.

Identity, and what a role may do

Every request but /__* and /auth/* other than /auth/session needs an identity. React, Vue and the HTML edition send it as Authorization: Bearer demo.<userId>; Next and Nuxt send it as the orion.session cookie instead, because their guard has to run on the server. No token at all resolves to usr_01 — the owner, and the default this demo always had — so every walk and screenshot still sees the same person. A token that names nobody is refused outright:

{ "error": { "status": 401, "code": "error.unauthorised", "detail": null } }

A resolved identity can still be refused the specific thing it asked to do. shared/auth/permissions.mjs's apiPermission() runs before any route does, so a write a role cannot make never reaches the handler that would have performed it — a viewer's PATCH on an order that does not exist answers 403 for the same reason a viewer's PATCH on one that does:

{ "error": { "status": 403, "code": "error.forbidden", "detail": { "permission": "orders.update" } } }

A write nobody wrote a rule for is refused too, by default, rather than let through: it needs workspace.manage, which only the owner and admin hold. GET /api/auth/session is the one route the demo switches never touch — the signed-in reader is not demo data, and a __error switch left on by the demo toolbar should never make an edition's sidebar disagree about who is signed in.

What to keep, what to delete

Keep permissions.mjs whole. Every route guard, every Can/useDenied call and the 403 page all ask it the same question — can(role, permission) — and a real backend answering with a real role name needs nothing else changed on the client.

Delete session.mjs, the demo accounts (DEMO_ACCOUNTS, DEMO_ROLES), the role switcher in the demo toolbar, login-by-email in the mock's POST /auth/login, and the usr_01-with-no-token default. All four exist to let a demo show three roles without a real sign-in behind them; none of them belong once there is one.

The server contract

GET /auth/session answers { "user": { "id", "name", "role", … } }. A missing or invalid credential is 401 — fail closed; never default to a user, the way this demo's no-token-means-the-owner rule deliberately does for demonstration purposes only. A role that lacks a permission is 403:

{ "error": { "code": "error.forbidden", "detail": { "permission": "orders.update" } } }

The backend enforces every one of these on its own, mirroring apiPermission() — including the default-deny rule for a write nobody wrote a rule for, which needs workspace.manage. A client that only hides a button is not a permission system; it is a UI that agrees with one that has to exist somewhere else.

Reads default the other way: a GET nobody wrote a rule for is open to every signed-in role, not refused the way an unmapped write is. apiPermission() gates only /invoices*; every other collection's GET returns null. A new sensitive GET — an audit log, a compensation figure, an API key — needs its own rule added before it ships, or every role reads it.

Adding a role, a permission, a guarded route, a gated control

  • A role: add it to ROLE_PERMISSIONS, add enum.role.<role> to all four catalogues, and decide whether it gets a switcher button (demoRoleOf) — most new roles, like support in this demo, do not need one to be reachable by signing in as someone who holds it.
  • A permission: add it to PERMISSIONS, add permission.<permission> to all four catalogues, and add it to ROLE_PERMISSIONS for the roles that hold it.
  • A guarded route: add ['/the/path', 'the.permission'] to ROUTE_PERMISSIONS. routePermission() and canVisit() pick it up everywhere at once — the route guard, the nav filter, the palette and the search results' fallback path.
  • A gated control: wrap it in <Can permission="the.permission"> to hide it, or spread useDenied('the.permission') onto it to disable it with a reason. Never disable a control that has to change to show its own state — hide it instead — and never leave a control enabled that the server would refuse: that is the one rule this whole layer exists to keep.

Known demo simplifications

  • GET /settings is not filtered by role: an editor still receives the billing tab's data even though the tab is hidden from them. Filtering it — data minimisation, not just UI hiding — is a real backend's job, not this demo's.
  • The notifications tab in Settings is stored once per workspace, not once per signed-in person, because the mock has one settings object, not one per staff row.
  • More generally: every rule in apiPermission() that returns null because a write is "personal" — the account tab, the notifications tab, marking a thread read — is safe only if the backend actually stores that state per signed-in user. This mock does not; a real one that copies the rule without copying per-user storage lets a viewer change a workspace-wide setting.
  • support exists only in the permission matrix. It is reachable by signing in as one of the staff04–staff06 fixtures; no demo toolbar button presses it, because the toolbar only shows the three roles a buyer is meant to try first.
  • Editor and viewer see the same nav and the same command palette page list: Pricing is the only nav destination either of them is refused, because it is the only nav destination that needs a permission neither role holds.

Per-edition sections

Each edition's own README has its own "Roles and permissions" section, covering how that edition wires the switcher and the guard to its own routing and rendering:

  • html/README.md
  • react/README.md
  • vue/README.md
  • nextjs/README.md
  • nuxt/README.md