Access control is the least forgiving system in a platform. Every other subsystem can earn partial credit. A slow query is still a query, a rough UI still renders, but a permission model that is almost right is wrong, and wrong here means someone saw something that was not theirs. Giant Context, the website platform I build, is multitenant, which raises the stakes again. Organizations that must never see each other share one database, one API, and one codebase. The structure is public. The plumbing stays home on purpose.
A user system answers two questions, who are you and what may you do. Permissions bind to the who, so that half comes first. Authentication here is rented. Google Cloud's Identity Platform stores the credentials, runs sign-in and MFA, and issues a signed token when a person proves who they are. Identity is owned. The platform keeps its own users table, resolves every verified token to an internal user id at the API, and binds memberships, grants, and audit history to that internal id. The coupling is one column. Swap the provider and nothing downstream notices.
One rule crosses from identity into everything below. The token carries who, never what. Providers offer custom claims that stamp roles into the token at sign-in. Remove a contractor's access on Friday and a claims-based system honors their Monday token until it expires. Resolved from the schema instead, revocation lands on the next request. Authorization lives in tables, where it can change between two requests.
Role-based access control answers the second question, what may you do, and its core move is indirection. Nobody grants a permission to a person. Permissions are named capabilities, roles are bundles of permissions, and people hold roles. The indirection is the entire value. When capabilities attach to people directly, every audit means reconstructing who was given what, and every departure means tracking down each grant one at a time. When they attach to roles, the questions an auditor actually asks (who can do this, what can she do, what happens when he leaves) become lookups.
The model is decades old and the biggest multitenant systems on earth run on descendants of it. That is precisely why I did not invent one.
For two years I ran production on Azure, and I have run this platform's stack on Google Cloud since its first commit. Both clouds, and Amazon's, converge on the same skeleton with different vocabulary:
Permission
s3:GetObject
storage.objects.get
provider action strings
email:read
Bundle
policy
role
role definition
role
Grant
policy attachment
binding
assignment
role grant
Scope
resource / account
org → folder → project
mgmt group → subscription → group
org → project → app
Inheritance
via resource paths
down the hierarchy
down the scopes
down the ladder
Across any row, the differences are spelling. Permissions are namespaced strings. Roles bundle them. Grants bind a role to a principal at a scope, and scopes form a hierarchy that inherits downward. Three companies with different histories and enormous security teams arrived at the same shape. Convergence like that is evidence.
Three companies with different histories and enormous security teams arrived at the same shape. Convergence like that is evidence.
So when it came time to build this platform's model, the decision was to adopt it outright rather than improvise. The action grammar here is Amazon's, exactly. A permission is service:action, so crm:delete and email:read read the way s3:GetObject reads. The scoping is Google's. Grants live on a resource ladder that runs organization, then project, then app, and a role granted at a rung covers the rungs below it. I had operated inside Azure's version for two years without ever seeing its internals, so I built what I could infer from the outside and measured it against the standard the outside implied. The standard was secure by default, multitenant at every layer, and granular down to the app.
Four tables carry the entire model, and the migration that created them describes itself:
The RBAC tables, from the migration
-- Creates permissions, roles, role_permissions, user_roles tables-- Permissions table (auto-synced from OpenAPI x-permissions)CREATE TABLE permissions ( ... );-- Roles table (admin-managed)CREATE TABLE roles ( ... );-- Role-Permission junction tableCREATE TABLE role_permissions ( ... );-- User-Role junction table (users can have multiple roles)CREATE TABLE user_roles ( ... );Textbook RBAC, unmodified. Permissions, bundles, grants, and the people they belong to. The first comment carries the design. The permissions table is not maintained by anyone. It is auto-synced from the API specification.
This codebase generates its client surface from the API's description of itself, and the permission model rides the same machinery. Each operation in the API carries its permission requirement as part of its OpenAPI description. From that single declaration, the pieces derive.
The permission catalog is extracted from the routes into a spec file, and a sync step keeps the two from drifting:
One permission, extracted from the routes
{ "name": "email:read", "routes": [ "GET .../apps/email/{appId}/emails", "GET .../apps/email/{appId}/emails/{emailId}", "GET .../apps/email/{appId}/templates" ]}The backend enforces what the spec declares. The frontend consumes the same declaration, because each console page exports its requirements in the same permission language, so the page that shows a thing and the endpoint that serves it are gated by the same string from the same source. There is no second vocabulary to fall out of agreement, on either side of the wire. One set of names everywhere, generated rather than remembered.
No conditional grant checks, anywhere. Elements, pages, and routes all derive from the generated files. I don't write permission conditionals. I have my grants, and things are tagged accordingly.
That is the developer experience the whole structure buys. There is no hand-written if-may-they in this codebase. A surface declares what it is, the tags carry the requirement, and the machinery gates it.
And the agreement is tested by machine. Among the generated test families is an RBAC suite that exercises the declared permissions against the enforced ones, so a route whose declaration and enforcement disagree fails in CI rather than in production. I do not audit the permission model by reading it. The suite audits it on every push.
I do not audit the permission model by reading it. The suite audits it on every push.
The ladder earns its keep the moment two customers exist. Organizations are hard walls. Nothing grants across them, and no role, however elevated inside its organization, reaches into another. Below the wall, the ladder makes delegation granular. An organization role for the people who run the company. A project role for the people who work a given site. An app-level grant for someone who should touch the email tool and nothing else. Granularity is what lets real teams map their actual trust onto the platform instead of rounding everyone up to admin.
Machine callers get no separate treatment. API access rides the same permission model as a person holding a role, which means the answer to who can do this never has an asterisk for automation.
I borrowed rather than innovated. I consider that the most defensible security decision in the codebase. The model is the one Amazon, Google, and Microsoft converged on. The grammar is Amazon's, the hierarchy is Google's, the discipline of running it came from two years inside Azure. What this codebase adds is not a new idea about access control. It is the wiring, spec-declared, generated to both sides, synced into the database, and tested for agreement on every push. If you are building multitenant software, that is the part worth copying. Take the shape from the giants. Then make your own system incapable of disagreeing with itself.
If you're interested in my work or Giant Context, contact me!