- A role hierarchy — a numeric tier per role, used by routes that ask “is this caller at least an HR staffer?”.
- Explicit permission grants — a named capability (
employees:edit,tenant:payroll-run:lock) mapped to the list of roles that hold it.
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_ADMINtier (HR_STAFF,MANAGER,ADMIN,SUPERADMIN), nor - assign such a role to anyone, nor change their own role.
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 readmodule: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:applications:view-all
applications:view-all
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’.staff-absence:view-all
staff-absence:view-all
The same shape for absences: employees see their own, managers see their direct reports
(resolved through the org graph), HR sees everyone.
salaries:view-all
salaries:view-all
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.