Authorization breaks when the answer to “what may this user do?” comes from somewhere the user can influence, is decided once at login, or isn't scoped to the tenant. This spec defines a role-based model that closes all three: server-owned role data, tenant-scoped assignments, and checks at the boundary.
Entity model
Entity-relationship model
Users receive roles through tenant-scoped assignments; roles grant permissions through a role-permission join. A permission is an action on a resource. Nothing about a user's authority is stored on a record the user can write.
Isolation policy
Authorization source rule
Roles and permissions come from backend-owned tables, or from signed claims minted server-side — never from a profile document or field the subject can modify. If a user can write the value that determines their role, it is a preference, not a permission. The write path to authority data is as tightly controlled as the data it protects.
Definition
Tenant-scoped assignment
A role assignment binds a user to a role within a specific tenant. The same person may be an admin in one tenant and a read-only member in another; authority never leaks across tenants. Cross-tenant administrative access is a distinct, explicitly-modelled grant, not an accident of a global role.
Contract
Checking rule
Every protected action checks its required permission at the boundary, against the acting user's assignments in the current tenant, and is default-deny: absent a matching grant, the action is refused.
The tenant is not passed by hand at each call site — a forgotten argument would be a silent authority leak. It is bound once per request by the same middleware that scopes data access (see the row-level isolation spec), and both the ORM global scope and the permission check read it from there. Manual tenant-threading is prohibited for the same reason a forgotten `where` clause is: it fails open under human error.
// Tenant is request-bound by middleware — the same context that scopes queries —
// so it is never passed through call sites manually. can() reads it implicitly.
if (! $user->can('invoice.void')) {
abort(403);
}Definition
Defining a sensitive action
“Sensitive” is not left to per-developer judgement — that is precisely how a security posture drifts into inconsistency. It is classified objectively by the effect of the action and enforced declaratively (an attribute or policy the router reads), not remembered case-by-case. Anything that destroys state, moves value, changes the security context, or alters authority is sensitive; everything else is routine.
| Class | Examples | Check mode |
|---|---|---|
| State-destroying | delete, purge, irreversible bulk update | Live · write-primary |
| Value-moving | payment, refund, transfer, payout | Live · write-primary |
| Security-context | change credentials, MFA, API keys, sessions | Live · write-primary |
| Authority-changing | grant / revoke a role, impersonate | Live · write-primary |
| Routine | read, list, create draft, non-destructive edit | Cached · read-through |
Contract
Caching, invalidation, and consistency
A live check does not mean a database round-trip per request. Routine checks read the acting user's permission set from a read-through cache keyed by tenant and user, with a TTL backstop; the cache is invalidated the instant that user's roles, assignments, or a role's permissions change, so no stale grant survives a change (see the cache-invalidation runbook). Throughput comes from the cache; correctness comes from precise invalidation, not from hoping the TTL is short enough.
Sensitive actions cannot trust a cache or a read-replica: replication lag or a not-yet-invalidated entry could authorise a principal whose grant was revoked a moment ago. They bypass the cache and read authority from the write-primary — or an equivalently strongly-consistent path — so the decision reflects true current state at the instant of the dangerous operation. This is the deliberate trade: routine actions are fast and eventually-consistent, dangerous ones are strongly-consistent and pay for it.
// Routine: read-through cache, scoped to (tenant, user), TTL backstop.
$perms = Cache::tags(["t:{$tenantId}", "perms:{$user->id}"])
->remember("perms:{$user->id}", 300, fn () => $this->load($user, $tenantId));
// Invalidate precisely when authority changes — no stale grant survives.
Cache::tags(["perms:{$user->id}"])->flush(); // on role / assignment / permission change
// Sensitive: bypass cache, read the write-primary — lag cannot authorise a
// role that was just revoked.
$authoritative = $this->load($user, $tenantId, connection: 'primary');Invariants this spec guarantees
- Authorization is derived only from server-writable state — never from data the subject can modify.
- Role assignments are scoped to a tenant; authority never leaks across tenants.
- Tenant context is injected once per request by middleware and the ORM — never threaded through call sites by hand.
- Routine checks are served from a per-user, per-tenant cache invalidated on any authority change (with a TTL backstop), so live-correctness costs no per-request query.
- Sensitive actions read authority from the write-primary, so replication lag or a stale cache cannot authorise a revoked principal.
- Sensitivity is classified objectively by effect — destroy state, move value, change security context, change authority — not by per-developer judgement.