Skip to main content
Access control has two layers that work together:
  1. A role hierarchy — a numeric tier per role, used by routes that ask “is this caller at least an HR staffer?”.
  2. Explicit permission grants — a named capability (employees:edit, tenant:payroll-run:lock) mapped to the list of roles that hold it.
Most gates use the explicit grants. The hierarchy exists for the ~50 routes that only need a coarse “at least this tier” check.

The hierarchy

Why SYSTEM_ADMIN sits below MANAGER

This looks wrong at first glance and is deliberate. SYSTEM_ADMIN is a customer-internal IT operator: they need to create accounts, configure the mailbox, wire integrations and see observability — but they must never read payroll, PII, or employee dossiers. Placing the role at tier 20 means every hasRole(…, HR_STAFF) or hasRole(…, MANAGER) hierarchy check denies it automatically. Its actual capabilities come only from the explicit grants listed for it. Notably it is not granted audit:view, because the audit masker redacts AHV numbers, IBANs and tokens but not salary amounts — payroll audit rows can carry compensation values.
The exact SYSTEM_ADMIN grant set is pinned by a lockstep test. Widening it is a deliberate decision, never a side effect of a hierarchy comparison.

Escalation guards on user management

SYSTEM_ADMIN can administer accounts, so the user-management routes add two guards on top of the permission check. A caller who is not privileged (SUPERADMIN/ADMIN) may not:
  • create, modify or delete a user who holds a role above the SYSTEM_ADMIN tier (HR_STAFF, MANAGER, ADMIN, SUPERADMIN), nor
  • assign such a role to anyone, nor change their own role.
“Privileged” alone would be too narrow: HR_STAFF and MANAGER are not privileged roles, but they still grant payroll and PII access far beyond what a system administrator should be able to hand out.

Permission naming

Grants read module:action, and the verb tiers are consistent across the platform: That last row is the important pattern in payroll. HR_STAFF can see a payroll run, a payment batch, a Behörden-Meldung or a Lohnausweis, but only ADMIN+ can lock the run, export the pain.001 file, transmit the declaration, or submit the certificate. Once those cross the boundary, corrections become formal procedures.

Scoped reads

Three permissions control breadth rather than access:
MANAGER holds applications:view and so reaches the recruitment surfaces. Without applications:view-all they see only the applications for positions they are responsible for — their own PositionRole assignments plus their direct reports’.
The same shape for absences: employees see their own, managers see their direct reports (resolved through the org graph), HR sees everyone.
Deliberately not granted to HR_STAFF. HR staff read salaries only through hand-picked per-team or per-Standort SalaryAccessGrant rows. Managers read strictly below themselves in the org graph — never at or above their own level. Administering the grants is ADMIN-only, because granting salary access is itself a compensation-sensitive decision.

Where the rules live

src/lib/auth/roles.ts is the single source of truth: ROLE_HIERARCHY, ROLE_PERMISSIONS, and the guard helpers (hasRole, hasAnyRole, isPrivilegedRole, isAboveSystemAdminTier). A new module must add its permissions there. Roles are always verified against the database, never trusted from a cached token. See Security conventions for how routes apply these checks, including the non-negotiable ownership rule on every HTTP verb.