# 0008 — Escalation rules live in the service layer, not in policies

**Status:** Accepted · 2026-08-02

## Context

A policy answers "may this person manage users?". Necessary, and not sufficient.

The dangerous case is a user who legitimately *may* manage users granting more
access than they hold themselves. Create a role carrying `users.delete`, assign
it to your own account, and a receptionist login has become an administrator —
no exploit, no bug, just the permission model being used against itself. Every
policy check passes, because the actor really is allowed to create roles and
edit users.

The rule that catches it is about the *contents* of the grant, not the right to
grant, and it applies equally to the web UI, the REST API and any future CSV
importer.

## Decision

`Modules\Auth\Services\PrivilegeGuard` holds the rules, and the services call
it before they write anything:

- **May not grant what you do not hold.** A non-Super-Admin assigning a role has
  every permission that role carries checked against their own set. Any excess
  is refused.
- **May not grant Super Administrator.** Only a Super Administrator can.
- **May not manage an account that outranks yours.** Otherwise `users.update`
  is enough to reset a Super Administrator's password and take the installation.
- **May not remove what you could not grant.** Role editing checks removals as
  well as additions — an administrator who cannot grant `billing.refund` must
  not be able to strip it from colleagues either.
- **May not act destructively on your own account**, and **the last Super
  Administrator may not be removed, suspended or demoted.**

Every service method takes the acting user as an explicit parameter rather than
reading it from the session, so a queued job or an Artisan command is bound by
the same rules and cannot become a side door.

The UI additionally hides roles a user cannot grant. That is courtesy, not
security — the guard is the boundary.

## Consequences

**Good.** The permission system cannot be used to defeat itself, and the
protection holds for clients that do not exist yet. Ten feature tests pin the
behaviour, including the ordering: `assertMayManage` fires before the
last-admin check, because an account that outranks yours is not yours to touch
whatever the counts say.

**Bad.** Services carry an `$actor` parameter that reads as noise until you know
why. Documented here and in the interface, which is the trade we chose over an
implicit `auth()->user()` that silently does nothing in a queued job.

**Cost of getting it wrong.** A clinic locking itself out of its own system, or
a junior account quietly promoting itself. Neither is recoverable without
database access, and on a customer's cPanel that means a support engineer and a
very bad phone call.
