This spec defines a role-based access model for multi-tenant systems. The security principles it rests on — server-owned authority, tenant scoping, default-deny, and re-verification of dangerous actions — are, in our assessment, correct for that threat model. The implementation choices around them (how tenant context propagates, what is cached, when a check reads the primary) are decisions with tradeoffs, presented with their rationale rather than as the only valid path.
Conformance language follows RFC 2119: MUST / MUST NOT are enforceable requirements; SHOULD / SHOULD NOT are strong recommendations with legitimate exceptions; MAY marks a genuine choice. Statements that are design preferences rather than requirements are labelled as such, and the guarantees the system actually provides are kept separate from the ones it aims for but cannot ensure in every failure mode.
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.
Definition
Design principles
Four principles anchor the model. They hold for the threat model this spec targets — a multi-tenant system in which a compromised or careless client MUST NOT be able to escalate privilege or read across tenant boundaries. They are principles, not laws for every system: a single-tenant internal tool, for example, can reasonably relax the tenant-scoping principle.
Server-owned authority — role and permission data MUST originate from backend-owned storage or server-minted signed claims, and MUST NOT be derived from client-supplied state the subject can modify. Tenant scoping — every role assignment MUST carry a tenant scope, and cross-tenant authority MUST be an explicit grant rather than a side effect of a global role. Default-deny — absent a matching grant, a protected action MUST be refused. Re-verification — dangerous actions SHOULD be evaluated against current authoritative state rather than a login-time snapshot, within the bounds the consistency model below can actually provide.
Isolation policy
Authorization source
Roles and permissions MUST come from backend-owned tables or signed claims minted server-side, and MUST NOT be read from a profile document or field the subject can write. The rationale is direct: if the subject can write the value that determines their authority, it is a preference, not a permission. Enforcement is concrete — no code path resolves a role or permission from client-controlled input, verified in code review and guarded by a test that asserts authority cannot be set from a request payload.
Definition
Tenant context propagation
Requirement: the acting tenant MUST be derivable from authenticated request context, and the same tenant MUST be applied to both data access and authorization checks. A check that resolves the tenant from unauthenticated input, or omits it, is non-conformant.
Decision (a preference, not a mandate): we bind the tenant once per request in middleware and read it from an ORM global scope and the permission check, mirroring how row-level data isolation is enforced (see the row-level isolation spec). Rationale: a single bind point removes per-call-site repetition and the failure mode where one call forgets the tenant — the same reasoning that favours a global query scope over a hand-written `where` clause on every query.
Tradeoff, stated plainly: implicit context is not free. The tenant is absent from individual function signatures, which some teams reasonably dislike for auditability and local reasoning. Explicit passing — `can(permission, tenant)` — is an equally conformant alternative that trades boilerplate and the risk of omission for locality and grep-ability. Either satisfies the requirement. A team SHOULD choose one and apply it consistently; what MUST NOT happen is a mixture where some paths inject and others thread by hand, because that inconsistency is exactly where a forgotten scope hides.
Definition
Sensitivity classification
Actions are classified according to a defined taxonomy, agreed during design review and enforced via an attribute or policy the router reads — not decided ad hoc in each handler. The taxonomy makes classification consistent and auditable; it does not remove human judgement. Assigning a new endpoint its class is a review decision, and genuine edge cases — a “read” that returns sensitive PII, a “draft” that fires an external webhook — are resolved by that review, not by the table alone.
The check mode below is the default for each class. Design review MAY escalate a nominally-routine action to a stronger check when its side effects warrant it; the taxonomy sets the floor, not a ceiling.
| Class | Examples | Default check mode |
|---|---|---|
| State-destroying | delete, purge, irreversible bulk update | Strongly consistent |
| Value-moving | payment, refund, transfer, payout | Strongly consistent |
| Security-context | change credentials, MFA, API keys, sessions | Strongly consistent |
| Authority-changing | grant / revoke a role, impersonate | Strongly consistent |
| Routine | read, list, create draft, non-destructive edit | Cached · read-through |
Note on “routine” reads: classifying a read or view as “routine” is the default floor, not a ceiling. A read that exposes sensitive PII, financial data, or security-relevant state MUST be escalated to a strongly-consistent check during design review if the cost of eventual consistency — stale data exposure — is unacceptable. Design review SHOULD weigh the sensitivity of the data being accessed, not only the action type, when classifying an endpoint.
Contract
Consistency and caching model
A routine authorization check does not require a database round-trip per request. It reads the acting user's permission set from a read-through cache keyed by tenant and user, with a TTL backstop. On an authority change, the system invalidates the affected entries; propagation is near-instant within a single cache node and bounded by invalidation-message delivery across nodes. Between the change committing and its invalidation propagating, a routine check MAY observe a stale grant for a bounded window — typically sub-second in a healthy single-region deployment, longer under invalidation backlog or partition. The TTL bounds that staleness even if an invalidation message is lost entirely.
A strongly-consistent check (the default for sensitive actions) MUST read from the write-primary, or from a read that is strongly consistent with it, rather than a lagging replica or the cache. It reflects state committed before the query runs, subject to the primary's transaction isolation level: under READ COMMITTED it sees all grants committed before it executes, while a revocation committing concurrently may or may not be visible depending on ordering; SERIALIZABLE removes that ambiguity at a throughput cost. The isolation level is a deployment decision — the requirement is only that the read be strongly consistent with the primary, not that any particular level be used.
This is the deliberate trade: routine checks are fast and eventually-consistent within a bounded window; sensitive checks are strongly-consistent and pay for it in latency and primary load. When the strongly-consistent source is unavailable, sensitive checks MUST fail closed — see Known limitations.
// Pseudocode example — adapt to your framework.
// Key includes tenant: "t:{$tenantId}" prevents cross-tenant cache collisions.
// Routine: read-through cache, scoped to (tenant, user), TTL backstop.
// MAY observe a stale grant within the invalidation-propagation window.
$perms = Cache::tags(["t:{$tenantId}", "perms:{$user->id}"])
->remember("perms:{$user->id}", 300, fn () => $this->load($user, $tenantId));
// On any authority change: invalidate before the change request returns, so the
// stale window starts closing immediately (bounded by propagation, not instant).
Cache::tags(["perms:{$user->id}"])->flush();
// Sensitive: read strongly-consistent with the primary — not cache, not replica.
// Fail closed if the primary is unreachable rather than trust stale state.
$authoritative = $this->load($user, $tenantId, connection: 'primary');The cache key structure MUST include both tenant and user identifiers to prevent cross-tenant leakage.
Contract
Guarantees, goals, and preferences
Not every statement in a security spec carries the same weight. The table separates what the system actually enforces from what it aims for but cannot guarantee under all failure modes, and from choices that could reasonably be implemented differently. Reading a goal as a guarantee is how teams get surprised in an incident.
| Statement | Category | Enforcement / caveat |
|---|---|---|
| Authority originates server-side, never from client input | Guaranteed | No code path reads authority from client-controlled fields; review + tests |
| Sensitive checks use a primary-consistent read | Guaranteed | The sensitive path pins the primary connection; unit-testable |
| Default-deny on a missing grant | Guaranteed | Structural — the check denies unless a grant matches |
| A revoked grant stops authorizing sensitive actions immediately | Goal | True at the primary once the revocation commits; a gap exists only if the primary is unavailable, where the system fails closed |
| A revoked grant stops authorizing routine actions quickly | Goal (bounded) | Holds within the invalidation + TTL window; degrades if invalidation is lost or a node is partitioned |
| A stale cache never authorizes a sensitive action | Goal (approximated) | True by design (sensitive skips the cache) — but not absolute under primary failover to a lagging replica; see limitations |
| Tenant context injected via middleware/ORM | Preference | One valid pattern; explicit passing is equally conformant |
| Routine cache TTL of 300s | Preference | Tunable per deployment against the acceptable stale window |
Definition
Known limitations
Cache invalidation across nodes. In a multi-node deployment, invalidation relies on message delivery (pub/sub or a bus). If a message is lost, or a node is partitioned from the invalidation channel, that node serves stale routine authorizations until the TTL expires. Mitigations: keep the TTL short enough that the worst-case stale window is acceptable, monitor invalidation lag, and consider version-stamped cache keys so a version bump misses en masse rather than relying on per-key deletes.
Primary unavailable. A strongly-consistent check requires a reachable primary (or consensus store). When the primary is unavailable for a strongly-consistent check, the system MUST respond with HTTP 503 Service Unavailable for synchronous API requests, or a comparable application-level error for asynchronous operations. This means it SHOULD NOT fall back to a cached permission set or a read-replica, and SHOULD NOT return HTTP 403 Forbidden — the subject's authority is unknown, not denied. Routine actions MAY continue from cache/replica during the outage; only the strongly-consistent path is affected. This choice prioritizes safety over availability for dangerous operations; teams requiring higher availability for these actions should consider a consensus-backed store (e.g. etcd, ZooKeeper) rather than a single-primary database.
Propagating new permissions to live sessions. Authorization is evaluated per request against fresh authority, not embedded in the session or access token. A newly-granted permission therefore takes effect on the next request that reads fresh state — on the next sensitive/primary read, and within the cache window for routine ones. This is deliberate: embedding permissions in a long-lived token would make revocation lag until the token expired, which is the failure this model exists to avoid.
Cost model under load. A routine check costs a cache read — cheap and horizontally scalable. A sensitive check costs a primary round-trip and cannot be served from read replicas, so a workload dominated by sensitive actions concentrates load on the primary and does not scale by adding replicas. Systems with high sensitive-action volume SHOULD budget primary capacity accordingly, coalesce authorization decisions within a single request, or move authority into a consensus-backed store designed for high-consistency reads.
Invariants this spec guarantees
- Role and permission data MUST originate from server-owned storage or server-minted signed claims; no code path derives authority from client-modifiable input.
- Every role assignment MUST carry a tenant scope, and the acting tenant MUST be derivable from authenticated request context and applied to both queries and authorization checks.
- Authorization MUST be default-deny: absent a matching grant, the action is refused.
- Sensitive actions MUST be evaluated against a read strongly consistent with the write-primary — never a lagging replica or the cache.
- When the strongly-consistent source is unavailable, sensitive actions MUST fail closed (deny) rather than fall back to stale state.
Revision history: this specification was revised on 24 August 2026 to clarify tradeoffs, distinguish guarantees from goals, add known limitations, and align with RFC 2119 conformance language. Implementation teams should refer to the “Guarantees, goals, and preferences” table for a clear mapping of what is enforced versus what is aspirational.