AuthZ: RBAC, ReBAC, ABAC
Authorization bugs are responsible for more data breaches than most teams realize. OWASP's Broken Access Control has topped the API Security Top 10 for three consecutive years. The reason is not that engineers are careless — it is that authorization logic tends to grow organically, embedding itself in business logic, controllers, ORM queries, and middleware in inconsistent ways until no single engineer can reason about what a given user can actually do.
This post is about picking a model that matches your domain, implementing it in a way that is actually auditable, and recognizing the patterns that cause authorization to silently fail.
Three Models, Three Domains
RBAC (Role-Based Access Control) assigns permissions to roles and users to roles. Simple, well-understood, works for most SaaS products with a handful of distinct user types.
ReBAC (Relationship-Based Access Control) derives permissions from the graph of relationships between subjects and objects. Google Zanzibar is the canonical implementation. Works when users and resources have nested ownership — think Google Docs where a document inherits permissions from its parent folder.
ABAC (Attribute-Based Access Control) evaluates policies against attributes of the subject, resource, and environment at request time. Expressive and flexible; complexity grows with the policy surface.
Choosing the wrong model for your domain is a bigger risk than choosing the wrong library.
| Model | Good fit | Poor fit |
|---|---|---|
| RBAC | Fixed user types, uniform permissions per role | Fine-grained per-resource sharing |
| ReBAC | Nested resource ownership, social graphs, collaborative tools | Simple admin/user split |
| ABAC | Regulatory compliance, time-/context-sensitive access | Small teams, simple permission sets |
Decision Flow
Most B2B SaaS products start at H and grow toward D or G as they add team features and compliance requirements.
RBAC: The Basics Done Right
The failure mode in RBAC is not the role table — it is the enforcement. Roles are assigned correctly but enforcement is scattered across the codebase and easy to miss.
-- Canonical RBAC schema
CREATE TABLE roles (
id UUID PRIMARY KEY,
name TEXT NOT NULL UNIQUE -- 'admin', 'editor', 'viewer'
);
CREATE TABLE permissions (
id UUID PRIMARY KEY,
resource TEXT NOT NULL, -- 'document', 'invoice'
action TEXT NOT NULL, -- 'read', 'write', 'delete'
UNIQUE (resource, action)
);
CREATE TABLE role_permissions (
role_id UUID REFERENCES roles(id),
permission_id UUID REFERENCES permissions(id),
PRIMARY KEY (role_id, permission_id)
);
CREATE TABLE user_roles (
user_id UUID NOT NULL,
role_id UUID REFERENCES roles(id),
PRIMARY KEY (user_id, role_id)
);Enforcement at the query layer — not in if-statements scattered through controllers:
def get_user_permissions(user_id: str, db) -> set[tuple[str, str]]:
rows = db.execute("""
SELECT p.resource, p.action
FROM user_roles ur
JOIN role_permissions rp ON ur.role_id = rp.role_id
JOIN permissions p ON rp.permission_id = p.id
WHERE ur.user_id = %s
""", (user_id,)).fetchall()
return {(r.resource, r.action) for r in rows}
def require_permission(resource: str, action: str):
"""FastAPI dependency."""
def dependency(current_user=Depends(get_current_user), db=Depends(get_db)):
perms = get_user_permissions(current_user.id, db)
if (resource, action) not in perms:
raise HTTPException(status_code=403, detail="Forbidden")
return Depends(dependency)
# Usage:
@router.delete("/documents/{doc_id}",
dependencies=[require_permission("document", "delete")])
async def delete_document(doc_id: str): ...OPA: Policy-as-Code for ABAC
Open Policy Agent externalizes authorization logic into Rego policies that are version-controlled, tested, and evaluated at runtime via a sidecar or API call.
# policy/document.rego
package document
import future.keywords.if
default allow := false
allow if {
input.action == "read"
document_readable_by_user
}
allow if {
input.action in {"write", "delete"}
input.user.role == "admin"
}
document_readable_by_user if {
input.user.id == data.documents[input.resource.id].owner_id
}
document_readable_by_user if {
input.user.id in data.documents[input.resource.id].shared_with
}
document_readable_by_user if {
input.user.role == "admin"
}Query OPA from your service:
import httpx
OPA_URL = "http://localhost:8181/v1/data/document/allow"
async def is_allowed(user: dict, action: str, resource: dict) -> bool:
payload = {
"input": {
"user": user,
"action": action,
"resource": resource,
}
}
async with httpx.AsyncClient() as client:
resp = await client.post(OPA_URL, json=payload, timeout=0.1)
resp.raise_for_status()
return resp.json().get("result", False)OPA policies are unit-testable with opa test — write tests the same way you write application tests.
Cedar: AWS's Newer Alternative
Cedar (used by AWS Verified Permissions) uses a structured policy language that is formally verified for performance guarantees. It is a good choice if you are on AWS or want stronger policy analysis tooling.
// Cedar policy: editors can edit documents in their own workspace
permit (
principal in Role::"editor",
action == Action::"EditDocument",
resource
) when {
resource.workspace == principal.workspace
};
// Deny all access outside business hours
forbid (
principal,
action,
resource
) when {
context.hour < 8 || context.hour >= 18
};ReBAC: When Hierarchy Matters
For collaborative products, relationship tuples model ownership naturally. OpenFGA is the open-source Zanzibar implementation most teams reach for.
// OpenFGA authorization model (DSL)
model
schema 1.1
type user
type folder
relations
define owner: [user]
define editor: [user] or owner
define viewer: [user] or editor
type document
relations
define parent_folder: [folder]
define owner: [user]
define editor: [user] or owner or editor from parent_folder
define viewer: [user] or editor or viewer from parent_folderA document inherits viewer access from its parent folder. Adding a user as folder viewer transitively grants document access — without explicitly listing every document.
The Enforcement Anti-Patterns
1. Checking roles in the controller but not in the query.
A user's role says they cannot read other users' records. The controller checks the role. But the ORM query Document.objects.filter(id=doc_id) retrieves the document regardless of owner — the controller passed the check because the user's role is valid, not because they own the document.
2. Positive-only policies with no default-deny.
Every if user.can_read(resource) call assumes the function returns false correctly. A missing policy entry, a typo in the role name, or an uncaught exception that returns None (truthy in some languages) opens access silently.
3. Authorization that lives in the UI only. Hiding a button is not authorization. The API endpoint must enforce independently.
4. Wildcard roles for convenience.
An admin role that bypasses all authorization checks is a blast radius problem. Scope admin roles to specific resources or actions.
Key Takeaways
- Match your model to your domain: RBAC for simple user types, ReBAC for hierarchical resource ownership, ABAC for attribute-driven or compliance-heavy policies.
- Enforce authorization at the data layer and the API layer — checking roles in controllers while your ORM queries are unscoped is not authorization, it is theater.
- Externalizing policy into OPA or Cedar makes authorization auditable, version-controlled, and independently testable.
- Default-deny is non-negotiable: every access check must fail closed when the policy is missing or ambiguous.
- UI-only enforcement (hiding buttons, greying out links) is not access control — every API endpoint must enforce independently of what the frontend shows.
- Wildcard admin bypass is a blast-radius risk; scope elevated roles to specific resource types or actions rather than global overrides.