Skip to main content

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:

  1. What does Spring Security authorize on — role names (hasRole('HR_ADMIN')) or fine-grained permission strings?
  2. 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. UserDetailsServiceImpl walks all the user's roles (including the hierarchy), collects the union of permission names, and maps each to a SimpleGrantedAuthority. Controllers authorize with @PreAuthorize against constants in PermissionConstants.java (e.g. hr.employees.create). The backend never checks a role name.
  • "Active role" exists only in the frontend. useAuth.ts intersects the user's real permissions with the static role-permissions.ts map for the selected role — purely to declutter navigation. No API call changes; the backend always authorizes against the full union. The e2e helper serverGrid.ts documents 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_permission insert), 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, so hasPermission() 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, and permissions.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_system marks 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 Platform category 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