Authorization: RBAC, ABAC and where to enforce them
Authentication says who you are. Authorization says what you may do, and it is
the half that gets designed by accident - a few if user.is_admin checks that
become unmaintainable once the second customer asks for a custom role.
RBAC: permissions through roles
Users get roles, roles carry permissions, code checks permissions.
user --> role --> permission --> resource typeThe rule that keeps RBAC healthy: check permissions, never roles.
# Brittle: adding a role means editing every site.
if user.role in ("admin", "billing_admin"):
...
# Stable: roles change, the check does not.
if "invoice:refund" in user.permissions:
...Permission names should read resource:action. When a customer asks for “a
role that can refund but not delete”, that is a data change rather than a
deploy - which is the entire payoff of RBAC.
Where it runs out
RBAC answers “may this user refund invoices?” It cannot answer “may this user refund this invoice?” - anything involving the specific resource, the relationship, or the context.
The symptom is roles multiplying to encode data: regional_manager_emea,
regional_manager_apac. Each new region is a new role, and the model is
carrying information that belongs in a condition.
ABAC: permissions through attributes
ABAC evaluates a rule over attributes of the subject, the resource, the action and the environment.
def can_refund(user, invoice) -> bool:
return (
"invoice:refund" in user.permissions
and invoice.tenant_id == user.tenant_id
and invoice.amount <= user.approval_limit
and invoice.status == "paid"
)That expresses in one place what RBAC needed a role explosion for. The cost is that authorization is no longer a set lookup: you must load the resource before deciding, and the rules become logic that needs its own tests.
Most real systems are both: RBAC for the coarse capability, an ABAC-style condition for the instance. Saying that is a better answer than picking one.
ReBAC, briefly
Relationship-based access control - Google Zanzibar, and OpenFGA or SpiceDB as implementations - answers “is this user an editor of this document, or of a folder that contains it?” It suits hierarchies and sharing, and it is worth naming when the interviewer describes a Google-Docs-shaped problem.
Where to enforce it
The mistake that causes leaks is enforcing at the wrong layer.
| Layer | Good for | Fails at |
|---|---|---|
| Gateway | Coarse route access | Anything per-resource |
| Route handler | Capability checks | Bypassed by other callers |
| Service layer | The real decision | Needs the loaded resource |
| Database (RLS) | Backstop that cannot be skipped | Poor error messages |
Enforce in the service layer, and back it with a database constraint. The gateway is too far from the data to know anything about it; a decorator on the route is invisible to a Celery task calling the same function.
async def refund(actor: User, invoice_id: UUID):
inv = await repo.get(invoice_id) # load first
if not can_refund(actor, inv):
raise Forbidden()
...Note the ordering: you must fetch the resource to authorize against it, which means the authorization check cannot live in a dependency that runs before the handler.
403 versus 404
Returning 403 for a resource the user may not even know exists confirms it exists. For tenant-scoped data, prefer 404 - the resource is genuinely not in that user’s world. Keep 403 for “you know about it, you cannot do this to it”.
Getting permissions into the request
Two options, and the trade is staleness against a lookup.
In the token. Fast, no round trip, but a revoked permission stays valid until the token expires, and a user with many permissions inflates every request’s headers.
Looked up per request. Always current, at the cost of a cache hit or query. Cache by user with a short TTL and invalidate on role change.
Put roles in the token and resolve permissions server-side when the permission set is large or changes often. Never trust a permission claim from a token you did not verify - see JWT and the JOSE Family.
The audit requirement
Authorization decisions are the ones compliance asks about, so record the denials as well as the grants, with the rule that decided. “Access denied” in a log with no reason is useless during an incident and during an audit - see SOC 2, ISO 27001 and GRC for engineers.
Related
Interview angle 6
- “RBAC or ABAC?” - usually both. RBAC gives the coarse capability, “may this user refund invoices”, and ABAC answers the instance question, “may they refund this one”, using attributes of the resource and context. Choosing only RBAC shows up as roles multiplying to encode data, like a role per region.
- “Why check permissions rather than roles?” - a role check hard-codes policy into every call site, so a new role means editing code. Checking a
resource:actionpermission makes a new role a data change instead of a deploy. - “Where do you enforce it?” - the service layer, because that is where the resource is loaded and where every caller passes, including background jobs that never touch a route. Back it with row-level security so a missed check fails closed, and keep gateway checks to coarse routing.
- “Why can’t authorization be a FastAPI dependency?” - a dependency runs before the handler, so it cannot see the resource. Capability checks work there; per-resource decisions need the object loaded first.
- “403 or 404?” - 404 for resources outside the user’s tenant, since a 403 confirms existence. 403 when they legitimately know about the resource but lack the action.
- “Permissions in the token or looked up?” - in the token is fast but stale until expiry, which matters for revocation. Looked up per request is current at the cost of a cached query. Roles in the token with permissions resolved server-side is the common compromise.