System design

Designing a multi-tenant RBAC service

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.

Topic
System design
Demo
demo/rbac-demo in this repo, runnable locally

Covered


The model

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.

Tenant acme, slug "acme" Membership user + role assignments Service client client_id + secret hash Role system or tenant custom Scopes same permission strings Permissions resource:action one vocabulary demo also has projects and documents, so "resource-level" below is a real check, not a stub
Same shape as the Flexday model, plus projects and documents so the resource-level check in the next section is something you can actually click.

Local checks vs a central authorize call

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.

UI wants to show a button resource specific? no decode token client-side, tag "local", no request yes POST /v1/authorize action, resource -- tagged "central" permission check, then resource check e.g. is subject a member of the document's project decision written to audit_log either way
The left branch never leaves the browser. The right branch is the only one that produces a record.

Revocation without waiting for a token to expire

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.

Service to service

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.

Tenant isolation

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.

Production deployment

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.

UIs, integrations, service-to-service callers Callers public internet ALB (public subnets) via k8s Ingress, ALB controller EKS: rbac-demo-backend 3 pods, 250m-1 vCPU / 512Mi-1Gi each private subnets RDS Postgres single instance, gp3, encrypted ElastiCache Redis policy-version + JWKS cache Node group: t3.small, desired 3 / min 2 / max 6, private subnets Single NAT gateway, 2 AZs -- same footprint the real service ran at
demo/rbac-demo/infra models this exactly; nothing here has been deployed.

Running it yourself

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.

Back to system design