Multi-Tenant Auth

RBAC vs ABAC: Choosing a Permission Model You Won't Outgrow

Roles, permissions, attributes, and relationships — how each model breaks, the role explosion problem, where the policy decision point belongs, and a migration path that doesn't require rewriting every authorization check.

Emilian GheoneaJuly 20, 20268 min read

Every B2B product starts with the same four roles: owner, admin, member, viewer. It works beautifully for about eighteen months. Then a customer asks whether a member can be given access to just one project, and someone adds a project_member role. Then a customer wants contractors who can see everything but edit nothing except their own submissions, and someone adds contractor. Two years later you have forty-one roles, six of which differ by a single permission, and nobody can tell you what admin_legacy_v2 does.

This is role explosion, and it is the predictable end state of a model that was correct when it was chosen. This article covers the models available, how each one fails, and — most usefully — how to structure things now so that changing your mind later is affordable.

The models

RBAC — role-based. Permissions attach to roles; roles attach to users. "Alice is an admin, and admins can delete projects."

ABAC — attribute-based. Decisions are computed from attributes of the subject, the resource, the action, and the environment. "Alice can delete this project if she is in the same department, the project is not archived, and it is a working day."

ReBAC — relationship-based. Decisions follow relationships in a graph. "Alice can edit this document because she is an editor of the folder that contains it." This is the model behind Google Zanzibar and the systems inspired by it.

Most real systems end up as a hybrid, and that is not a compromise — it is the correct destination. The question is which one is the foundation.

Where RBAC actually breaks

RBAC's strength is legibility. An administrator can look at a list of roles and understand who can do what. That is worth more than it sounds; a permission model nobody can explain is a permission model that gets misconfigured.

It breaks on three specific requirements, and it is worth recognizing them early because each one is a signal.

Per-resource grants. "Alice is an admin, but only for the Marketing workspace." RBAC's answer is a role scoped to a resource — a membership row that names both the role and the thing it applies to. This is a genuine extension and it is manageable, but note that you have now left pure RBAC.

Conditions on attributes. "Managers can approve expenses under €5,000." A role cannot express a threshold. You can create manager_approve_5k, and you have taken the first step toward forty-one roles.

Ownership. "Users can edit their own comments." This is a relationship between the subject and the resource, not a property of the subject. Every system needs it, and RBAC cannot express it — which is why almost every RBAC system has an if (resource.ownerId === user.id) special case somewhere, scattered through the codebase.

The tell that you have outgrown RBAC is roles whose names contain conditions: admin_readonly, manager_own_team, editor_except_billing. Each one is an attribute or a relationship wearing a role's costume.

Where ABAC breaks

ABAC handles all of the above naturally, and introduces its own problems that are, frankly, worse to debug.

"Why can't I do this?" becomes hard to answer. With roles, you look at a list. With policies, you trace an evaluation across several attributes, possibly from several sources, and reconstruct which condition was false. Without excellent tooling — a decision log that records the inputs and the deciding rule — this is the single biggest operational cost of ABAC, and it lands on your support team.

"Who can access this?" becomes hard to answer. This is worse, and it is the question auditors ask. With roles, it is a query. With policies, answering it exactly requires evaluating every policy against every user, and many policy languages make it undecidable in practice. If you have compliance requirements around access review, ask how you will answer this question before choosing ABAC.

Attributes must be available at decision time. A policy that references the user's department, the resource's classification, and the current project status needs all three when the check runs. If those live in three services, every authorization check is now a distributed query — with the latency and failure modes that implies.

Policies drift from intent. A policy written to solve one case gets extended, and eighteen months later grants something nobody intended. Policies need tests exactly as much as code does, and they rarely get them.

Where ReBAC fits

ReBAC deserves more attention than it usually gets, because it fits a shape that is extremely common: nested resources with inherited access.

Organizations contain workspaces contain projects contain documents. A user granted editor on a workspace should be an editor on everything inside it. Expressing this in RBAC means denormalizing grants down the tree, which turns every permission change into a bulk write. Expressing it in ABAC means a policy that walks the hierarchy, which is a recursive query at decision time.

ReBAC makes it native: permissions are edges in a graph, and a check is a reachability question.

document:readme#viewer@user:alice          -- direct
folder:docs#editor@user:bob                -- inherited by everything inside
document:readme#parent@folder:docs

The cost is a specialized system — a separate service with its own consistency model and its own operational burden. That is real, and it is why ReBAC is usually the third system a company adopts rather than the first. But if your domain is fundamentally a hierarchy with inherited sharing, adopting it early is cheaper than the two migrations you would otherwise do.

The decision procedure

Rather than picking a philosophy, answer these:

Can a permission depend on a value that is not a property of the user? A threshold, a resource state, a time. If no, RBAC is sufficient and you should stop here.

Do users get access to individual resources rather than whole categories? If yes, you need at least resource-scoped roles, and possibly ReBAC.

Do resources nest, with access inherited downward? If yes, ReBAC will save you a great deal of pain.

Must you answer "who can access X?" exactly, on demand? If yes — and compliance usually means yes — favour models with enumerable grants. This constraint alone rules out unconstrained ABAC for many regulated products.

Do customers configure their own permissions? If yes, you need a model they can understand. Roles are the only one most administrators can reason about, which is an argument for RBAC as the interface regardless of the engine underneath.

The structural advice that outlasts the choice

This is the part worth acting on today, whichever model you pick.

Check permissions, never roles. The single most valuable discipline in this entire article.

// Brittle — every new role requires touching every call site
if (user.role === 'admin' || user.role === 'owner') { … }

// Durable — roles become a mapping, changeable in one place
if (can(user, 'project:delete', project)) { … }

With the second form, changing from RBAC to ABAC means rewriting can(). With the first, it means finding every conditional in your codebase — and missing one, which is a security bug rather than a compile error.

Route every decision through one function. A single can(subject, action, resource) entry point is what makes the model swappable, what makes decision logging possible, and what makes it testable. Enforce it: a lint rule banning direct role comparisons outside the authorization module costs an afternoon and pays for years.

Separate the decision from the enforcement. The policy decision point answers "may this happen." The enforcement point acts on the answer. Keeping them distinct is what lets you move the decision into a policy engine, or a separate service, without touching the call sites.

Store grants, not computed results. Record that Alice has the editor role on this workspace. Do not denormalize "Alice can edit document 5,912" into a table, because the day the rules change you have to recompute everything and you will miss rows. Cache computed results with an explicit invalidation path, and treat the cache as a cache.

Log decisions, including denials. The denials are the valuable ones: they tell you where your model does not match what people are trying to do, and they are the first signal of both a misconfiguration and an attack. Log the subject, action, resource, outcome, and the deciding rule. See audit logging.

Enforce tenancy separately and beneath everything. Whether Alice can delete this project and whether this project belongs to Alice's organization are different questions, and the second must be enforced at the data layer with row-level security as a backstop — not as a permission rule that a policy change could accidentally relax. This is the point of the isolation model in multi-tenant auth design.

The pragmatic hybrid

For most B2B SaaS, this shape carries you a long way:

Roles as the customer-facing interface. Owner, admin, member, viewer — a small, fixed, comprehensible set. This is what appears in the UI and in your documentation.

Permissions as the internal primitive. Each role expands to a permission set. Code checks permissions. Adding a role is a data change; adding a permission is a small code change.

Resource-scoped memberships for per-workspace and per-project access, expressed as the same roles applied at a different scope.

A small number of attribute conditions for the cases roles genuinely cannot express — ownership, thresholds, resource state — implemented as explicit named conditions rather than a general policy language.

const ROLE_PERMISSIONS = {
  owner:  ['*'],
  admin:  ['project:*', 'member:invite', 'member:remove', 'settings:write'],
  member: ['project:read', 'project:write', 'comment:*'],
  viewer: ['project:read', 'comment:read'],
} as const;

const CONDITIONS = {
  'comment:delete': (subject, resource) =>
    resource.authorId === subject.id || hasPermission(subject, 'comment:moderate'),
};

This is not architecturally pure and it is easy to explain, easy to audit, and easy to extend. When you eventually need a real policy engine, can() is the only thing that changes.

Migrating without a rewrite

If you are already deep in role checks scattered through the codebase, the path out is incremental:

  1. Introduce can() and have it implement your current role logic exactly. No behaviour change.
  2. Replace call sites mechanically, one at a time, with tests. Lint against new direct role comparisons so the problem stops growing.
  3. Extract the role-to-permission mapping into data. Now roles are configuration.
  4. Add conditions for the cases that forced you to create hyphenated roles, and collapse those roles.
  5. Only then, if the requirements genuinely demand it, swap the engine behind can().

Step 1 and step 2 deliver most of the value. Many teams never need step 5 — and the ones that do find it is a week's work instead of a quarter's.

Key takeaways

  • Role explosion is the symptom, and roles with conditions in their names are the tell.
  • RBAC breaks on per-resource grants, attribute conditions, and ownership. ABAC breaks on explainability and "who can access X?". ReBAC fits nested, inherited resources and costs a specialized system.
  • Check permissions, never roles. This single discipline is what makes every later change affordable.
  • One can(subject, action, resource) function, lint-enforced, with decision logging including denials.
  • Store grants, not computed results, and treat any derived table as a cache.
  • Tenancy isolation is not a permission rule. Enforce it at the data layer, underneath everything else.
  • The roles-as-interface, permissions-as-primitive hybrid carries most B2B products far longer than architectural purity would.

Written by

Emilian Gheonea

Senior Blockchain & Full-Stack Software Engineer. I build EmbedAuth — an embeddable authentication platform for SaaS — and write about the auth problems most teams hit too late.