One-read permission checks for multi-tenant SaaS

· David · #rbac #aws #authorization #dynamodb

Authorization checks sit on the hottest path in a SaaS product. Every API request asks the same question — may this user do this thing in this scope? — and however you answer it, you pay for the answer on every request.

Most systems answer it one of two ways:

  1. Compute at read time. Walk the user's groups, roles, and policies on every check. Flexible, and increasingly expensive as the model grows — the graph traversal happens millions of times a day.
  2. Call a policy service. Hosted authorization vendors put a policy engine behind an API. Now the hot path includes a network hop, a per-check bill, and someone else's latency distribution.

SaaSLayer's RBAC service takes a third route: materialize at write time, read one document at check time.

The boring hot path

When anything authorization-relevant changes — a role assignment, a policy edit, a group membership — the write path recomputes the affected users' effective permissions and stores them as documents in DynamoDB. A permission check then:

const result = await checker.hasPermission(
  'user-alice',
  'read',
  'device',
  'tenant-prod'
)

…is one DynamoDB read plus local evaluation: explicit denies first (a deny always wins), then allows capped by permission boundaries, then an optional grant fallback. No graph traversal, no policy engine, no cross-service hop. The cost and latency profile is a GetItem — flat and predictable whether the customer has ten users or fifty thousand.

Paying the cost where it's rare

The trade is deliberate: writes get more expensive, reads get trivial. Role changes are thousands of times rarer than permission checks, so that's the right side of the ledger to pay on.

The interesting engineering lives on the write path. Deleting a role that thousands of users hold can't rewrite every document synchronously, so bulk changes propagate through async cascades with repair jobs, and every document carries freshness metadata — staleness is observable, not mysterious. Revocations are protected by default-deny behavior while cascades settle.

Explanations for free

Materialized documents keep their provenance, so any check can answer why:

const result = await checker.hasPermission(
  'user-alice', 'read', 'device', 'tenant-prod',
  { explain: true }
)
// result.explanation → the assignment, policy, boundary, or deny that decided it

That powers admin tooling and "why can't I do this?" UI without a separate audit reconstruction.

The full evaluation model is in the permission checks docs, and the quickstart gets you to a working check in a few minutes.

← All posts