Every application ends up asking itself the same question: can this user do this to this thing? At first you answer it with an if. Then with two. Then a role column shows up in the users table. Later an is_admin one, then an is_owner, and one day someone asks for “managers should see the reports, but only for their region, except in December” — and the if is already fifteen levels deep.
There are three classic models for organizing that answer without losing your mind. They aren’t religions: they’re different ways of modeling the data that feeds the same function.
the question, in the abstract
Any authorization system boils down to a function:
function can(user: User, action: Action, resource: Resource): boolean;
The only thing that changes between models is where the data lives that this function consults and how it’s combined. Roles, attributes, relationships. Three ways of organizing the same information.
RBAC — roles between the user and the permission
In Role-Based Access Control users don’t have permissions: they have roles. Permissions hang off the role. Change a user’s role and their capabilities change all at once.
const roles = {
viewer: ["article:read"],
editor: ["article:read", "article:write"],
admin: ["article:read", "article:write", "article:delete", "user:manage"],
};
function can(user: User, action: string) {
return roles[user.role].includes(action);
}
It works surprisingly well when the reality of the business can be described with three or four labels. Most small SaaS products live here for years: viewer, editor, admin, and little else.
Where it breaks. The moment a permission depends on the specific resource, not the type of resource, RBAC starts straining in every direction. “Editors can only edit articles from their own team” doesn’t fit in a role — it fits in a relationship. The easy way out is to invent more roles: team_a_editor, team_b_editor, team_a_editor_read_only_in_december. That combinatorial explosion is the sign that the model has run out of road.
ABAC — the permission is a function of attributes
Attribute-Based Access Control throws roles in the trash (or demotes them to just another attribute) and decides based on the attributes of the user, the resource and the environment. The policy is an expression evaluated against those attributes.
function can(user: User, action: "read", article: Article, ctx: Context) {
if (article.visibility === "public") return true;
if (article.authorId === user.id) return true;
if (user.department === article.department && ctx.now < article.embargoUntil)
return false;
if (user.department === article.department) return true;
return false;
}
That’s ABAC in miniature. With a real policy engine (OPA, Cedar, Casbin) the logic is written in a declarative language separate from the code, and evaluated by passing it the attributes of the user, the resource and the context.
What you gain. Expressiveness. Almost any business rule can be written if you have the right attributes.
What it costs. Three things, worth being clear about before adopting it:
- Debugging is hard. “Why can’t I get in?” stops having a short answer. A good engine gives you a trace; the rest give you a
falseand a shrug. - The UI has to reflect the policy. It’s not enough for the backend to say no — the button shouldn’t be there. That means exposing the decision to the frontend too, ideally with the same policy.
- Attributes have to be maintained. If the user’s
departmentis stale, the policy decides correctly over wrong data.
ReBAC — permissions are graphs
Relationship-Based Access Control is what’s underneath Google Drive, GitHub and nearly any product where the main verb is share. The question stops being “what role do you have?” and becomes “what relationship do you have with this resource?”.
The data are tuples:
document:q1-report owner user:maria
document:q1-report editor group:finance
group:finance member user:luis
And the rules say how permissions are inherited through those relationships:
relation owner: User
relation editor: User | Group#member
relation viewer: User | Group#member
permission read = viewer + editor + owner
permission write = editor + owner
permission delete = owner
To ask “can Luis read the report?” the engine walks the graph: Luis is a member of finance, which is an editor of q1-report, and editor implies read. Yes.
That idea — authorization as a graph + an engine that traverses it — is the one Google described in the Zanzibar paper and which products like SpiceDB, OpenFGA and Permify implement today.
Where it shines. When the product is sharing: documents, repos, nested folders, teams with sub-teams, inheritance by hierarchy. Trying to model GitHub with pure RBAC is torture; with ReBAC it’s almost natural.
What it costs. One more piece of infrastructure. You need to keep the tuples in sync with your primary database — every time someone joins a team, every time a document is shared, you have to write in two places. And the consistency problems between them are real.
which one to choose
The trap is choosing by aesthetics. ABAC sounds general, ReBAC sounds modern, and RBAC sounds boring. The useful criterion is a different one: what is the simplest model that describes your domain without forcing you to invent impossible roles?
- Start with RBAC. Three or four roles. It solves 90% of B2B SaaS before the product needs anything more.
- Move up to ABAC when rules appear that depend on attributes of the resource or the context — schedules, regions, states — and you find yourself inventing roles whose only reason to exist is to encode those attributes.
- Go to ReBAC when the central verb of the product is share or inherit: documents, folders, nested teams, repos. If users are going to invite other users to specific things, ReBAC is the right abstraction.
And nothing stops you from combining them. Most real systems are RBAC for the general case + ABAC rules for the fine-grained ones, or ReBAC for shared resources + global roles to distinguish customers from platform administrators.
details that matter
A single decision point. Whatever the model, the decision should always go through the same function. If in some places you check user.role === "admin" directly, you’ve lost — the day the policy changes, you’ve got a hundred places to update and no idea which ones.
The UI has to match the policy. It’s not security — the backend already blocks — but a button that, when clicked, shows “you don’t have permission” is a bad experience. Expose the results of can(...) to the frontend and use them to hide or disable actions.
Auditing. Every interesting decision should leave a trail: who, what, on what, when, with which policy. The day someone asks “who deleted this?” you’ll want to be able to answer in under five minutes.
Tests with negative cases. Authorization tests are far more useful testing what you can’t do than what you can. “A viewer can’t delete”, “an editor from another team can’t edit”, “an expired user can’t read”. If you only test the happy path, you aren’t testing authorization.
the summary, in one sentence
- RBAC: the user has a role, the role has permissions.
- ABAC: the permission is a function of attributes of the user, the resource and the environment.
- ReBAC: the permission is derived by traversing a graph of relationships.
Three ways of organizing the same data. The good news is you almost never have to pick just one — the bad news is you have to know which is which, so you don’t end up implementing ReBAC by hand on top of a role column.