ADR-0011: Permission-name authorities; "active role" is a frontend-only lens
Status: Accepted (retroactive) Date: 2026-07-24
Context
Users hold multiple roles (an HR Administrator is also an Employee), and roles
form a hierarchy in the RBAC tables
(V1__core_rbac.sql:
role, permission, role_permission, user_role). Two questions had to be
settled:
- What does Spring Security authorize on — role names (
hasRole('HR_ADMIN')) or fine-grained permission strings? - When a multi-role user "switches role" in the UI, does the backend need to know?
Role-name checks scatter role→capability knowledge across controllers and make adding a role a code change. And making role-switching a backend concern would mean per-session server state (which token carries which role?) contradicting the stateless JWT design (ADR-0003).
Decision
- GrantedAuthorities are permission names, not roles.
UserDetailsServiceImplwalks all the user's roles (including the hierarchy), collects the union of permission names, and maps each to aSimpleGrantedAuthority. Controllers authorize with@PreAuthorizeagainst constants inPermissionConstants.java(e.g.hr.employees.create). The backend never checks a role name. - "Active role" exists only in the frontend.
useAuth.tsintersects the user's real permissions with the staticrole-permissions.tsmap for the selected role — purely to declutter navigation. No API call changes; the backend always authorizes against the full union. The e2e helperserverGrid.tsdocuments this exact behavior (auto-selection of a non-Employee role on first render).
Consequences
Positive
- Authorization is stateless and fine-grained; granting a role a new
capability is a data change (a Flyway
role_permissioninsert), not a code change. - Endpoint requirements are self-documenting at the controller
(
@PreAuthorize("hasAuthority('payroll.run.process')")). - Role switching costs nothing on the backend — no session, no re-issued token.
Negative
- The frontend map must be manually synced with RBAC migrations — and this
has already failed in production terms:
troubleshooting.md documents the incident where
four inventory permissions added by migration never made it into
role-permissions.ts, sohasPermission()returned false and nav items silently vanished for Warehouse Managers. There is no runtime sync and no build-time check. - Permission strings are duplicated in three places — the DB seed
migrations,
PermissionConstants.java, andpermissions.ts— with only convention keeping them identical; a typo in one place produces a permanently-false permission check, not an error. - The active-role lens can hide capability but never grant it, which is safe but regularly confuses e2e authors and new developers ("the user has the permission, why is the menu missing?").
- Multi-tenancy adds a fourth place. Roles are now per-tenant copies cloned
from blueprint rows (
role.tenant_id IS NULL AND is_blueprint), so a migration granting a new permission to a standard role has to reach the blueprint and every tenant's copy —role.is_systemmarks the clones, which is how such a migration finds them. Granting only the blueprint leaves every existing tenant without the permission; granting only the clones leaves every future tenant without it. See ADR-0013. - Permissions in the
Platformcategory are the exception: they belong to the platform operator alone and must never be granted to a tenant role. Since all the existing grants select by category, this holds by construction.
References
../../backend/src/main/java/com/motorph/payroll/security/service/UserDetailsServiceImpl.java../../backend/src/main/java/com/motorph/payroll/constants/PermissionConstants.java../../frontend/src/hooks/useAuth.ts,../../frontend/src/constants/role-permissions.ts,../../frontend/src/constants/permissions.ts../../e2e/helpers/serverGrid.ts— activeRole auto-selection notes../troubleshooting.md— the documented sync failure../security/authentication.md— RBAC model and role switching