A close-up portrait of a man with a salt-and-pepper beard wearing a white collared shirt against a textured beige background.
A close-up portrait of a man with a salt-and-pepper beard wearing a white collared shirt against a textured beige background.

Permissions borrowed from Amazon and Google

Jesse James Richard
|
Feb 5, 2026
|
8 min read
#Access Control
#Testing

Giant Context is multitenant. Organizations that must never see each other share one database, one API and one codebase, so the access model is the thing standing between them. A permission model that is almost correct fails at the only job it has.

I did not design one. The model here is Amazon's grammar and Google's hierarchy, adopted with the vocabulary changed and almost nothing else.

The identity half, briefly

A user system answers two questions, who are you and what may you do. Permissions bind to the first, so it comes first. Authentication runs on Google Identity Platform, which stores credentials, runs sign-in and multi-factor, and issues a signed token. Giant Context 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 id. One column joins the two.

The token carries who, never what. Providers offer custom claims that stamp roles into a token at sign-in, which means removing a contractor's access on Friday leaves their Monday token still describing the access they had. Resolved from the tables instead, revocation lands on the next request.

What RBAC is for

Role-based access control answers the second question through one move. Nobody grants a permission to a person. Permissions are named capabilities, roles are bundles of permissions, and people hold roles.

That indirection is what makes the model worth using. When capabilities attach to people directly, an audit means reconstructing who was given what, and a departure means finding every grant one at a time. When they attach to roles, who can do this, what can she do, and what happens when he leaves are all single lookups.

The model is decades old and the largest multitenant systems in the world run on descendants of it, which is why I did not write my own.

How the three clouds model it

For two years I ran production on Azure, and this platform has run on Google Cloud since its first commit. Both, and Amazon's, use the same four pieces under different names.

AWS
Google Cloud
Azure
Here

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 large security teams arrived at the same shape independently.

Which vendor spells it which way does not matter much. What matters is that the same four pieces keep appearing, and that a system built without them ends up reinventing them badly under deadline.

So the four pieces were adopted as-is. Permissions are service:action strings, roles bundle them, grants bind a role to a person at a scope, and the scopes run organization, project, app, inheriting downward.

Where the grant lives

The decision underneath the vocabulary is where a grant is stored, and it determines which questions are cheap to answer.

Attach grants to the person and you can answer what can this person do in one read, because it is all on their record. Answering who can reach this project means examining every person. Attach grants to the resource and it inverts. Who can reach this project is one read, and what can this person do means walking every resource looking for their name.

Both questions get asked constantly. The first runs on every request, because the API has to decide whether this caller may do this thing. The second is what an administrator opens a settings page to see, and what somebody has to answer during an audit or the week after a person leaves.

A grant here is a row that names both sides. A person, a scope, and a role. It belongs to neither the user nor the organization, so it can be indexed from both directions and both questions stay a single indexed lookup. That is why the model is four tables rather than a column on a user.

It is also what makes people cross-tenant. A person is one record in the platform, and they can hold an owner role in one organization, an editor role on a single project in another, and nothing at all in a third. Their capabilities are not a property of who they are. They are the set of grant rows naming them, and each row is scoped to the place it applies. A role column on a user could not express that, because the same person is different things in different organizations.

The cost is that a grant is a real row somebody has to create and somebody has to remove. There is no implicit access anywhere, no rule that the person who made a thing can edit it, no ownership shortcut. Every capability someone has traces to a row written on purpose, and revoking access means deleting rows rather than hoping nothing else confers it.

Four tables

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 ( ... );

Permissions, bundles, grants, and the people they belong to. Nothing in there is original and none of it should be.

The comment on the first table is the part specific to this codebase. Nobody maintains the permissions table by hand. It is synced from the API specification, which means the catalogue of what can be permitted is derived from the routes that enforce it.

Declared once, derived everywhere

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 specification declares. The frontend reads 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 one string from one source.

There is no hand-written check for whether someone may do something anywhere in the codebase. Elements, pages and routes all derive from the generated files. A surface declares what it is, the declaration carries the requirement, and the machinery gates it.

The agreement is checked by machine. Among the generated test families is an RBAC suite that asserts, for every role and every permission, what the model says access should be, so a route whose declaration and enforcement disagree fails in CI rather than in production.

Multitenancy is the reason for all of it

A single-tenant product needs none of this. One customer, one set of data, and a role column on a user would carry you a long way. The hierarchy exists because there are many organizations in one database and none of them may see each other.

Organizations are hard walls. Nothing grants across them, and no role, however elevated inside its own organization, reaches into another. There is no role above the wall.

Below it the rungs make delegation granular. An organization role for the people who run the company, a project role for the people who work on one site, an app-level grant for someone who should touch the email tool and nothing else. Without those rungs the only way to let someone do their job is to make them an admin, and everyone ends up holding more than they need.

Machine callers get no separate treatment. API access runs on the same permission model as a person holding a role, so the answer to who can do this carries no exception for automation.

None of the model is original, and that was the point of the exercise. The interesting work was not choosing a shape. It was making the shape impossible to contradict, with one declaration on each route, generated to both sides, synced into the database, and checked on every push. If you are building multitenant software, take the model from the people already running it at scale and spend your effort on the wiring instead.

Tokens as a currency

#Data
#Architecture
#AI & Agents

Every tracked event gained a trace id and a token count, which answers why a request was slow and what it cost from one row. The larger consequence is...

Jesse James Richard

|

Feb 3, 2026
Read previous

Building something like this

I'm Jesse. I build platforms end to end, and I'm open to work. If this is the kind of engineering you need, get in touch.

Contact Jesse
Home
About
Contact
Sitemap
Privacy Policy
Terms of Service
Cookie Policy