Almost every authorization system starts with a single question: what is the user's role? And it works, as long as the domain is simple too. The trouble starts when the same decision begins to depend on things of a different nature and someone tries to solve it all by stretching the same role hierarchy.
This text is a cut from an architecture I designed for human authorization in multi-tenant systems. It isn't all of Zanzibar, it isn't a generic policy engine and it doesn't push every rule into the database. It's a deliberate choice: take from each access-control tradition the abstraction that solves one specific problem well, and only that.
When a role stops being enough
The beginning usually looks like this:
viewer < editor < admin < superadmin
if user.role.rank >= required_rank:
allow()It looks economical, but it mixes concepts. An administrator may need to register people without reading sensitive content. An analyst may run an operation, but only over their own unit's data. Someone from another department may receive one specific report without joining that department. And a platform operator may need to cross tenants in an emergency without their role name becoming a master password in every handler.
The hierarchy isn't missing levels. Hierarchy just isn't the right abstraction for all of these questions. Insist on it and the usual symptoms show up: magic ranks, ad hoc exceptions, per-department permissions, if admin scattered through the code, sharing that grants more power than it should and tenant isolation that depends on the discipline of each endpoint.
The four questions
A mature authorization decision breaks down into four independent dimensions:
| Question | Mechanism | Role |
|---|---|---|
| What can the subject do? | RBAC with permissions | Capability |
| Which units do they belong to? | Organizational membership | Partition |
| Was this object shared with them? | Typed relationship, ReBAC-style | Point exception |
| Does this row belong to the tenant? | Row Level Security | Isolation |
All four can produce the same final answer, allow or deny, but they don't share semantics. Squeezing them into a role column or a generic grants table is the shortest path to a policy nobody can explain or test.
One authorization question, one main mechanism.
There's a simple way to see that the axes need to stay apart: look at how each one changes. Capability changes when a person's job changes. Partition changes when the org chart changes. Sharing changes through a one-off act of collaboration. Tenant almost never changes during the life of an identity. These are events with different frequencies, owners, trails and revocation paths, and putting them in the same record forces the system to treat different things as the same.
Capability: RBAC without magic ranks
The classic RBAC core stays: users get roles, roles bundle permissions and authorization checks the effective set. What I reject is the role as a number. A role is a declared set. If one contains another, it's because it was declared that way, not because one got a 3 and the other a 2.
VIEWER = { profile.manage, content.view }
ANALYST = VIEWER ∪ {
analysis.run, data.query,
content.manage, content.share
}
TENANT_ADMIN = ANALYST ∪ {
users.read, users.write,
authz.roles.read, authz.roles.assign_tenant
}That keeps power inspectable. You can change what a role contains without rebuilding the meaning of the whole hierarchy, and the effective set becomes a plain union:
effective(u) =
permissions(base_role)
∪ permissions(roles of u)
∪ valid direct grants(u)The policy is monotonic: there are only grants, no explicit deny. That's a choice, not an oversight. Deny changes precedence and the analysis of everything, and deserves to be treated as an architectural change, not one more column.
A permission is an atomic capability, shaped domain.resource.action. Before one joins the catalog, it goes through three questions:
- Is there a real enforcement point for it today?
- Is there a role that has it and another that doesn't?
- Will the backend and the frontend use exactly the same code?
If the first answer is no, the permission is documentation with no effect. If the second is no, the granularity is theatrical. If the third is no, the interface and the API will drift apart. The most common symptom of a dishonest catalog is one permission per screen step, workflow.upload, workflow.preview, workflow.map, that no role tells apart. That doesn't express power; it just copies the UI's structure.
And not every permission can be granted inside a tenant. Things like platform.tenant.bypass and authz.roles.assign_platform live on the other side of a boundary, and CI checks that no tenant role contains one. That property matters more than any role's name.
Partition: the org chart is not a catalog
If a company has dozens of units, encoding them in permissions gives you content.view.finance, content.view.hr, content.view.branch_001. The org chart becomes code and every reorganization becomes a deploy. The permission stops representing a capability and starts representing a data instance.
A partition is membership, and membership is data:
org_unit(id, tenant_id, name)
user_org_unit(user_id, org_unit_id)
resource.org_unit_idOne decision that looks small and isn't: the resource belongs to the unit, not to the person who created it. If the author moves departments, they may lose access to what they produced. It sounds counterintuitive, but it keeps the principle that the asset belongs to the organization.
And mind the legacy. A system mid-migration always has old data with no unit. Treating org_unit_id IS NULL as “legacy” forever is dangerous, because every new object born without a unit inherits the same historical exception. Legacy has to be marked as legacy, in its own column, and new objects can't be born with the mark.
Relationship: sharing is an exception, not membership
Receiving a report from another department doesn't make anyone a member of it. That's the perfect case for a typed relationship, and it's the part of Zanzibar I actually use. I don't need a global namespace, zookies or a distributed graph. The useful idea is smaller:
An access exception on a concrete object should be an explicit, revocable and auditable relationship.
resource_share(
resource_id,
grantee_user_id,
granted_by_user_id,
granted_at
)Three rules come with the relationship. Sharing is intra-tenant by default: if sharing across customers is a requirement, it deserves its own model, and it can't appear as a side effect of a table without a constraint. The recipient doesn't get to reshare, because receiving viewer isn't granting authority. And sharing takes both capability and authority over the object, expressed as a permission and a predicate, never as if user.role == "admin".
can_share(u, r, grantee) :=
has(u, content.share)
AND same_tenant(u, r, grantee)
AND can_view_resource(u, r)
AND ( created_by(r) = u
OR has(u, content.view_all_partitions) )An aggregate is not microdata
This is the easiest distinction to lose. A report and the dataset it came from are different authorization objects, and each has its own predicate:
can_view_resource(u, r) :=
same_tenant(u, r)
AND ( in_same_partition(u, r)
OR shared_with(u, r)
OR has_all_partitions_view(u)
OR is_legacy_unpartitioned(r) )
can_access_data_context(u, c) :=
same_tenant(u, c)
AND ( in_same_partition(u, c)
OR is_legacy_unpartitioned(c) )The missing shared_with in the second predicate is deliberate. Sharing the presentation can't turn into access to the raw data. For the same reason, content.view_all_partitions means seeing aggregates across every unit, not querying every unit's microdata. If the business needs both, they're two capabilities. A permission's name has to reflect its real blast radius.
And partitioning only the report is a cosmetic barrier. If the source has no authorization of its own, all it takes is someone discovering the context's identifier and calling the endpoint directly.
Isolation: the database guarantees the floor
Row Level Security has a narrow job here: preventing cross-tenant access. The policy is simple enough to fit in two lines and be audited:
tenant_id = current_tenant()
OR current_platform_bypass() = trueThe database doesn't know who's a viewer, an analyst or the owner of a share. Capability, partition and relationship stay in the application. Duplicating everything in RLS and in code means double maintenance, and a fix on one side never reaches the other. If partitions ever move into the database, the policy should be generated from the same specification.
The database guarantees the floor. The application guarantees the policy.
But tenant RLS stops tenant A from seeing tenant B; it doesn't stop department A from seeing department B. Once partitions exist, a repository that does get_by_id(id) and trusts RLS is no longer safe. The answer here is to require an AccessContext on every content read, keep sensitive repositories private to the authorization module and make CI fail when a router imports one directly. PR review helps, but it can't be the only barrier.
Who can give power to whom
The “you can only grant what you have” rule looks safe and isn't. If an admin has every admin permission, the subset check lets them create another admin. At the same time it forces anyone who only needs to grant a capability to hold it. Granting authority needs to be modelled directly, in an explicit matrix:
role_grantable(grantor_role, target_role)
tenant_admin -> viewer, analyst
platform_admin -> tenant_admin, viewer, analystUsing a capability and assigning it to someone else are different operations. So are registering an identity and granting authority, which is why they're different endpoints. I wrote more about it in Who can give power to whom.
Sessions with an expiry date
Almost every system keeps the effective set in the session so it doesn't recompute everything on each request. That's fine, as long as the cache knows when it has gone stale. And it goes stale in three ways: when the user changes, when the catalog changes and when the clock passes a temporary grant's expiry. Each one needs its own mechanism, and the last one can't depend on a cron job. The details are in Cached permissions go stale in three ways.
The frontend mirrors, the backend decides
The browser is outside the trust boundary. Hiding a button isn't authorization, and a role claim sent by the client isn't a source of truth. A permission has the same code on both ends, require_permission("analysis.run") on the server and hasPermission("analysis.run") in the interface, and only the server decides.
| Frontend | Backend | Result |
|---|---|---|
| hides | blocks | correct |
| hides | only observes | rollout, not enforcement |
| shows | blocks | bad UX |
| shows | allows with no gate | vulnerability |
In a migration it's worth running authorization in shadow mode: the predicate is evaluated and the decision logged, but a denial doesn't block yet. That measures the real impact before the cutover. But shadow mode isn't security, and staying in it indefinitely is measurable debt. Every authenticated route declares a permission or is explicitly classified as ungated, and CI compares the route inventory with the catalog.
What was left out on purpose
- Everything in ReBAC. You can model membership and inheritance as relationships, but for stable partitions the cost in graph, consistency and tooling doesn't pay off. The rejection is pragmatic, not theoretical.
- A generic ABAC engine. I use attributes like
tenant_id,org_unit_idandexpires_at, but in closed, testable predicates. An arbitrary policy language is hard to review and to explain. - Everything in RLS. Enforcement close to the data is great for the floor; for product rules it turns into hard versioning and duplication.
- Sharing as a new role. It turns an exception about one object into global power.
Wrapping up
Mature authorization isn't a string of if role == ..., but it doesn't have to be a global distributed graph either. In most application systems the problem fits in four questions: what the person can do, which unit the data belongs to, whether there's an exception on that object and whether the row belongs to the right tenant. RBAC, membership, a typed relationship and RLS, each in its place.
The best side effect is a discipline for change. A new unit doesn't need a new permission. A new share doesn't change anyone's role. A new capability doesn't touch the tenant. Each axis evolves at its own pace.
Capability is not partition, partition is not sharing, sharing is not tenancy, an aggregate is not microdata and the interface is not enforcement.