Tutorial
RBAC vs ABAC: Why Roles Alone Break Down in Multi-Tenant Django and FastAPI
RBAC vs ABAC for multi-tenant apps: why roles alone fail with tenant isolation, and how to design attribute-based policies in Django and FastAPI.
You added tenants. Now every permission check has a second question hiding inside it: not just “can this user do X?” but “can this user do X on this tenant’s data?” If your authorization logic was built around Django Groups or a hand-rolled roles table, this is usually the moment it starts to crack.
The short answer: RBAC (Role-Based Access Control) grants permissions based on a user’s assigned role — admin, editor, viewer. ABAC (Attribute-Based Access Control) grants permissions by evaluating a policy against attributes — who the user is, which tenant they belong to, which resource they’re touching, what action they’re attempting, and sometimes environmental context like time or IP range. RBAC answers “what role does this user have?” ABAC answers “given everything we know about this request, does a policy allow it?” In a single-tenant app, RBAC is usually sufficient. In a multi-tenant app, RBAC alone tends to degrade, and the fix is almost always to layer attribute checks — tenant ID chief among them — on top of roles rather than inventing more roles.
This article walks through both models concretely, shows where Django’s and FastAPI’s native tooling stops being enough, and gives you a working mental model (plus enforcement patterns) for tenant-aware authorization in this exact stack.
RBAC, Defined Concretely
RBAC is the model most engineers learn first because it maps cleanly onto how organizations already think about access: a person has a job title, and the job title implies a set of things they’re allowed to do. In implementation terms, RBAC usually consists of three building blocks:
- Users — the identity being authorized.
- Roles (often implemented as groups) — a named bundle of permissions, like
editorortenant-admin. - Permissions — atomic rights, like
can_edit_invoiceorcan_delete_user.
Django gives you this out of the box. It ships with a built-in User model, a Group model that functions as your role layer, a Permission model for fine-grained access control, and decorators for enforcing those permissions at the view level. You assign permissions to groups, assign users to groups, and Django’s auth backend resolves effective permissions by combining group membership with any permissions assigned directly to the user. Developers commonly extend this scaffolding to build custom role systems rather than inventing authorization from scratch.
For a single-tenant SaaS app, this is genuinely fine. The gap appears when the same role needs to mean different things in different contexts. A concrete, commonly reported case: an application has a “team member management” capability, but the actual permission level that role should carry differs by department within the same application — one department’s managers need broader rights than another’s, even though both carry the same role label. Django’s native Group/Permission model has no built-in concept of “this role, but scoped to this department” — you either create more granular roles or you start writing conditional logic in your views. Neither scales cleanly, and that’s before you’ve introduced tenants at all.
ABAC, Defined Concretely
ABAC is best understood as what you reach for once RBAC’s flatness stops matching your authorization rules. It’s generally described as an evolution of RBAC: instead of hard-coding role and group logic directly into application code, ABAC represents access rules as key-value attributes evaluated against centrally managed policies, which externalizes the authorization logic from the application itself.
A policy in ABAC terms looks less like “editors can edit” and more like a rule engine evaluating a tuple: subject attributes (user ID, roles, department), resource attributes (tenant ID, resource type, owner), action (read, write, delete), and sometimes environment (time of day, request origin, data classification). The decision comes from evaluating policy against all of these together, not from a single role lookup.
This isn’t a new or exotic idea — it has standardized policy languages behind it. XACML (eXtensible Access Control Markup Language) and its simplified authoring dialect ALFA exist specifically as implementations of the ABAC model, and policy engines such as AuthZForce, Axiomatics Policy Server, and AT&T’s XACML implementation support evaluating these externalized policies at runtime. You don’t need to adopt XACML itself to benefit from ABAC thinking — the pattern (externalize the rule, evaluate attributes, return allow/deny) is what matters for a Django/FastAPI stack, even if your implementation is simpler than a full XACML engine.
RBAC vs ABAC: Head-to-Head
| Dimension | RBAC | ABAC |
|---|---|---|
| Core decision input | Role assigned to the user | Attributes of subject, resource, action, and environment evaluated against policy |
| Where logic lives | Often embedded in application code (view checks, decorators) | Externalized into a policy layer, decoupled from application code |
| Handles tenant boundaries natively | No — roles are typically global unless manually scoped | Yes — tenant ID is just another attribute in the policy |
| Granularity | Coarse; same role = same permissions everywhere it’s assigned | Fine; same role can resolve differently per context |
| Operational overhead to maintain | Lower initially; grows as role count grows | Higher initially; stays flatter as context grows |
| Auditability | Easy to answer “what can this role do” | Easy to answer “why was this request allowed,” since the decision trail includes attributes |
| Failure mode at scale | Role explosion, duplicated near-identical roles | Policy sprawl if attributes/policies aren’t governed |
Neither model is strictly superior — they trade off different kinds of complexity. RBAC’s complexity grows in the number of roles you maintain; ABAC’s complexity grows in the number of policies and attributes you evaluate. The practical question for a multi-tenant product isn’t “which model is better” — it’s “where does my current model stop matching reality.”
Why RBAC Strains the Moment You Add Tenants
This is the core failure pattern worth naming explicitly. Traditional RBAC becomes risky in multi-tenant applications because global roles don’t express tenant boundaries or resource context well — a role like admin doesn’t inherently say admin of which tenant. As the number of tenants and resource types grows, teams commonly respond by duplicating roles per tenant or per resource type to compensate, which creates role explosion and increasingly inconsistent permission logic across the codebase. You end up with tenant_a_admin, tenant_b_admin, tenant_c_admin — hundreds of near-identical roles that differ only in which tenant they’re bound to, each one a maintenance liability and a drift risk.
The underlying problem is that a role answers “what can this identity do” but says nothing about “on whose data.” Without tenant context baked into the authorization decision itself, you’re relying on every engineer to remember to add a tenant_id filter in every query and every view — and one missed filter is a cross-tenant data leak.
The practical fix is not more roles. It’s treating role membership as one signal among several and combining it with tenant ID, resource ID, environment, and action type before granting access — implemented via ABAC, policy-as-code, or application-enforced tenant guards. This is the RBAC-to-ABAC evolution in concrete form: you keep roles (they’re still a useful, human-readable signal), but you stop letting role membership alone be the final word.
Academic work on multi-tenant cloud authorization has approached this from the architecture side: one proposed scheme, Role-Based Multi-Tenancy Access Control (RB-MTAC), combines identity management with RBAC so tenant users are assigned functional roles carrying distinct privileges, explicitly aiming for application independence and data isolation across tenants in cloud services. The lesson generalizes even if you don’t adopt that exact scheme: role and tenant identity need to be evaluated together at the point of decision, not treated as separate concerns resolved in different layers.
Enforcement Patterns: Django
In Django, the practical move is to keep Groups/Permissions as your role layer and add a tenant guard as a second, mandatory check — not an optional extra. Concretely, this means: every queryset that touches tenant-scoped data should be filtered by the authenticated user’s tenant before any permission check runs, and your permission-checking decorators or has_perm calls should be understood as answering “is this role allowed to do X in general,” not “is this specific cross-tenant action allowed.” A permission_required decorator alone can’t express “editor, but only within their own tenant” — that scoping has to be an explicit second gate, implemented either as a custom permission class, a middleware-level tenant guard, or a mixin applied uniformly across tenant-scoped views.
Enforcement Patterns: FastAPI
FastAPI starts from a different baseline than Django: it doesn’t ship with integrated security or authorization tooling at all. Authentication is typically handled separately (commonly via JWT, though that mechanism is out of scope here), and authorization is left entirely to you to wire in — typically through FastAPI’s dependency injection system using Depends().
This is actually a reasonable fit for policy-based (ABAC-style) enforcement, because Depends() gives you a clean seam to call out to a policy decision point before the route handler runs. One documented pattern delegates authorization entirely to an external policy engine — Permguard is cited as an open-source example — wired into route handlers through that same dependency injection mechanism, so the route itself stays ignorant of the authorization logic and just receives an allow/deny result. Permguard specifically is described as a Zero Trust Auth* (ZTAuth*) provider implementing a centralized, policy-as-code authorization layer decoupled from application code.
The enforcement flow in this pattern generally looks like: the dependency extracts the authenticated identity and request context, builds an authorization request (subject, tenant, resource, action), sends it to the policy engine, and either lets the request proceed or raises an HTTP 403 before the handler body executes. This keeps policy logic out of your route handlers entirely — which matters in a Django+FastAPI stack where you want one consistent authorization story rather than duplicating role logic in two frameworks with different native tooling.
Non-Human Identities Deserve the Same Scrutiny
Service accounts, internal API keys, and background workers are frequently the weakest link in tenant isolation, precisely because they’re often granted broad, standing access for convenience rather than scoped, task-specific permissions. The same operating model applies: a service account’s “role” should be combined with the tenant it’s acting on behalf of, scoped narrowly, and ideally short-lived rather than a permanent credential with blanket cross-tenant reach. Recommended practice is keeping automation and service identities narrowly scoped to a single tenant or tenant class, and preferring short-lived, task-specific credentials over long-lived broad ones for anything crossing tenant boundaries.
Governance, Break-Glass, and the Isolation Tradeoff
As a design baseline, grant each identity only the access it needs and keep exceptions controlled; that baseline shapes how tight your tenant-aware authorization should be.
But tighter isolation isn’t free. Tightening tenant-aware authorization increases operational overhead, and that tradeoff becomes concrete the moment your platform needs emergency support access into a tenant’s data, data migration privileges that span tenants, or cross-tenant observability for SRE tooling. This is where break-glass access patterns matter: a narrowly scoped, time-limited, approved, and heavily audit-logged exception path for the rare cases where strict tenant isolation would otherwise block legitimate operational work. Build that path deliberately and log every use of it — don’t let “we needed emergency access once” quietly become a standing overprivileged role that undoes the isolation you designed in the first place.
Choosing: RBAC, ABAC, or Hybrid
- Pick RBAC alone if you’re single-tenant, your permission structure is genuinely flat, and roles map 1:1 to what users can do regardless of context.
- Pick ABAC if you’re multi-tenant, you need the same role to behave differently by department or resource, or you’re already seeing role duplication across tenants.
- Pick a hybrid — the realistic default for most Django/FastAPI multi-tenant products — by keeping roles as a readable, coarse-grained signal for “what kind of user is this,” and layering tenant ID, resource ID, and action as mandatory attribute checks evaluated before any permission is granted.
The hybrid isn’t a compromise — it’s the honest description of what ABAC already is: RBAC’s role signal, evaluated as one attribute among several, inside a centrally managed policy rather than scattered permission checks.
FAQ
Is ABAC replacing RBAC? No — ABAC is best understood as an evolution that incorporates role as one attribute rather than a wholesale replacement; most production systems in multi-tenant contexts end up running roles plus attribute checks together.
Does Django support ABAC natively? Not out of the box. Django’s built-in tooling — User, Group, Permission, and decorators — is RBAC-shaped, and attribute-aware logic (like tenant scoping) has to be added as custom guards, middleware, or an external policy layer.
Does FastAPI have anything like Django Groups? No. FastAPI has no built-in security or authorization layer at all; everything, including role storage and policy evaluation, has to be wired in yourself, typically through dependency injection.
Is role explosion a sign I should migrate to ABAC immediately? It’s a strong signal, but the real trigger is whether you’re duplicating roles specifically to express tenant or resource context rather than genuinely different responsibilities — that’s the pattern that indicates roles alone have stopped working.
What’s out of scope here? Authentication mechanics — how users prove identity via JWTs, sessions, or third-party identity providers — are a separate concern from authorization and aren’t covered in depth here.