> ## Documentation Index
> Fetch the complete documentation index at: https://docs.hr-easy.nlead.ch/llms.txt
> Use this file to discover all available pages before exploring further.

# Roles and permissions

> The seven roles, the hierarchy, and the permission grants that decide who sees what.

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

| Role           | Tier | Who it is                                                           |
| -------------- | ---: | ------------------------------------------------------------------- |
| `SUPERADMIN`   |  200 | The platform operator (n:lead). Seeds catalogs, imports tariffs.    |
| `ADMIN`        |  100 | The customer's HR/system owner. Everything except platform seeding. |
| `HR_STAFF`     |   50 | Day-to-day HR. Reads and edits people data; cannot lock payroll.    |
| `MANAGER`      |   25 | Line manager. Scoped to their own reports and positions.            |
| `SYSTEM_ADMIN` |   20 | Technical operator (customer IT). Administers *accounts*, not data. |
| `EMPLOYEE`     |   15 | Self-service.                                                       |
| `APPLICANT`    |   10 | Self-service for their own applications.                            |

### 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.

<Note>
  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.
</Note>

### 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:

| Suffix                                        | Meaning                                                |
| --------------------------------------------- | ------------------------------------------------------ |
| `:view` / `:read`                             | Read a surface                                         |
| `:view-all`                                   | Read beyond your own scope (all positions, all staff)  |
| `:create` / `:edit`                           | Ordinary writes                                        |
| `:manage`                                     | Broad write over the module                            |
| `:delete`                                     | Destructive, almost always `ADMIN`+                    |
| `:submit` / `:lock` / `:export` / `:transfer` | An action with an outside-world consequence — `ADMIN`+ |

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:

<AccordionGroup>
  <Accordion title="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'.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>
</AccordionGroup>

## 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](/development/security) for how routes apply these checks,
including the non-negotiable ownership rule on every HTTP verb.
