Permissions
The SPA does not invent authorization. The Worker is the source of truth; the frontend mirrors that model for routing and UI so users do not see actions they cannot perform.
Related: Routing · Features · Lifecycle permissions matrix.
Three-layer model
| Layer | Where | Job |
|---|---|---|
| 1. Route | PermissionGuard + routeConfig | Block whole pages |
| 2. Action UI | usePermissions() / hasPermission | Hide or disable buttons |
| 3. Backend | Worker requirePermission* | Final deny — always |
If the UI is wrong, the API must still reject. If the API allows it, the UI should not invent a stricter rule without updating the shared permission catalog.
How the SPA gets permissions
- Auth / me load establishes the current user
- Permission IDs come from the user payload (and/or system catalog for labels)
usePermissions()(apps/web/src/context/auth/usePermissions.ts) exposes helpers such ashasPermission(id)- Super admin bypass is preserved in the client helper (aligned with Worker behavior)
- Role fallback is used only when a permission list is not present — prefer explicit IDs
System permission catalog (for admin UIs): systemService → GET /system/permissions.
Route gating
- Canonical map:
apps/web/src/router/AppRoutes.tsx - Access table:
apps/web/src/router/config/routeConfig.ts(getRouteAccess,ROUTE_ACCESS) - Wrapper:
PermissionGuardappliesaccessaround the page element
Paths are not role-prefixed (/users, not /admin/users). Role folders under domains/* are for unique experiences, not for access control by path segment.
Missing page access → unauthorized / blocked route flow (not a blank screen).
Action gating
On shared pages (users, maintenance, departments, system):
const { hasPermission } = usePermissions();
// e.g. show Create only if hasPermission('user.create')| Situation | UX |
|---|---|
| No page access | Unauthorized route |
| Page OK, action denied | Hide or disable the control |
| Backend still denies | Show error from ApiError gracefully |
Maintainer rules
- New routes must declare access in
routeConfigand wrap withPermissionGuard. - New destructive / privileged buttons must check a real permission ID from the Worker catalog.
- Do not hard-code role names for access when a permission ID exists.
- Keep lifecycle matrices in
docs/lifecycle/in sync when maintenance permissions change.