This one is not hypothetical. It is the same model I built at Flexday (write-up here), rebuilt from scratch as a standalone demo: full auth flows, a resource-level authorize endpoint, policy-version revocation, and a production-shaped deployment behind it.
A tenant owns everything: users join it through a membership, and the membership is what carries role assignments, not the user directly. The same person can hold different roles in different tenants because the assignment lives on the membership, not the account.
A role is a named bundle of permissions, written as
resource:action strings — document:approve,
project:write. Roles are either system roles, shared across every
tenant, or custom roles a tenant defines for itself. Service clients
sit alongside memberships: a client id and secret authenticate as a machine rather
than a user, and their scopes are drawn from the exact same permission strings roles
are built from.
Most permission checks are coarse and answerable from the access token alone: can
this user see the documents list at all. The demo's UI decodes the JWT client-side
and reads the permission straight out of it — no request made, tagged
local in the decision log. The token's signature is still verified
server-side against the JWKS on every actual API call; the client-side read is purely
to decide what to render.
Some checks cannot be answered from a token. Approving a document depends on whether
the caller is a member of the project that document belongs to — a fact that would
make the token enormous and stale within minutes if it had to carry every resource a
user could touch. Those go to POST /v1/authorize, which checks the
permission and then walks the resource relationship, and writes a row to
audit_log either way.
Access tokens carry a policy version alongside the tenant id. Assigning or revoking a
role bumps tenants.policy_version and invalidates the cached copy in
Redis in the same request. Every open session polls /v1/me every few
seconds; when the tenant's current version moves past the version baked into the
token, the badge flips to stale and the client silently exchanges its refresh token
for a fresh one. The change is visible within one poll interval instead of at
whatever time the old token happened to expire.
Refresh tokens are opaque, rotate on every use, and are tracked by a family id. If a token that was already marked used shows up again, the whole family is revoked — the signal that a refresh token was copied and used by someone other than its holder.
A client id and secret exchange for a service token on a separate audience, using
POST /v1/auth/token. The audience separation means a stolen service
token can never be replayed against a user-facing endpoint even though both token
types are signed by the same key. Scopes come from the same permission table roles
are built from, so a denied service call and a denied user click get diagnosed the
same way.
Every tenant-scoped repository function in the backend takes the tenant id as a required first argument and throws if it is missing, rather than silently running an unfiltered query. The test suite goes further: every case seeds two tenants and asserts that a query scoped to one never returns anything belonging to the other. That second habit is the one that actually catches a missing filter — a test suite that only ever seeds one tenant will pass regardless.
The Terraform in demo/rbac-demo/infra is written to the shape the real
Flexday service ran at — a handful of small pods behind a load balancer, one managed
Postgres instance, one managed Redis — rather than to run for a portfolio demo. It is
not applied; there is no state backend configured, on purpose, so a stray
terraform apply can't provision real AWS resources for this.
The demo runs locally with Docker Compose: Postgres, Redis, the Fastify backend, and the React UI, plus a seed script that creates a demo tenant with four users holding different roles across two projects, so the resource-level authorize check has something real to deny.
cd demo/rbac-demo
docker compose up --build
The seed script prints the demo accounts on first run. Two of them share a role with
identical permissions — document:approve — but are on different
projects, specifically so you can watch the same coarse permission produce an allow
for one document and a deny for another, decided centrally rather than from the
token.
Read the original write-up for the version of this that ran in production at Flexday, across three different clouds.