Access control seems simple until you need to implement it: users have roles, roles have permissions, done. Then the requirements evolve—“managers can approve expenses, but only for their own department” or “users can edit documents they created, but only if the document is in draft status and created less than 30 days ago.”
Suddenly, roles aren’t enough. You start adding more roles (manager_sales, manager_engineering, manager_finance), special-case permissions, and hardcoded logic. Three months later, you have 147 roles and nobody remembers what editor_limited_regional_temporary is supposed to do.
The question isn’t RBAC vs ABAC—it’s when RBAC stops being enough and how to evolve to ABAC without rewriting your entire authorization system. Here’s what actually works.
RBAC: Simple Until It Isn’t
Role-Based Access Control (RBAC) assigns permissions to roles, then assigns roles to users:
# Roles
admin:
- create_user
- delete_user
- view_all_data
editor:
- create_post
- edit_post
- publish_post
viewer:
- view_post
Users get one or more roles, and permissions are checked based on those roles:
def can_publish_post(user, post):
return 'editor' in user.roles or 'admin' in user.roles
This works perfectly for:
- Static permissions (admins can delete, editors can publish, viewers can view)
- Organization-wide access (all editors can edit any post)
- Simple hierarchies (admins have all permissions that editors have)
RBAC breaks down when permissions depend on:
- Context (managers can approve expenses for their own department)
- Relationships (users can edit documents they created)
- Resource attributes (users can view draft documents, but published documents require subscription)
- Time (temporary access that expires)
- Combinations (users in the “finance” department with “manager” role can approve expenses over $10,000)
Most teams start with RBAC and end up bolting on special cases:
def can_approve_expense(user, expense):
# RBAC check
if 'admin' in user.roles:
return True
# Special case #1: managers in same department
if 'manager' in user.roles and user.department == expense.department:
return True
# Special case #2: finance team for large expenses
if 'finance' in user.roles and expense.amount > 10000:
return True
# Special case #3: expense creator can approve if delegated
if user.id == expense.creator_id and expense.delegated_approval:
return True
return False
This code works, but it’s not scalable. Every new permission requirement adds another if statement. Auditing what users can actually do becomes impossible because the logic is scattered across your codebase.
ABAC: Flexible But Complex
Attribute-Based Access Control (ABAC) makes decisions based on attributes of:
- User (role, department, clearance level, location)
- Resource (owner, status, classification, created_date)
- Action (read, write, delete, approve)
- Environment (time, IP address, device type)
Instead of hardcoded roles, you define policies:
# Policy: Managers can approve expenses in their department
- effect: allow
subject:
role: manager
department: $resource.department
resource:
type: expense
action: approve
# Policy: Users can edit their own draft documents
- effect: allow
subject:
id: $resource.owner_id
resource:
type: document
status: draft
action: edit
# Policy: Finance team can view all expenses over $10,000
- effect: allow
subject:
department: finance
resource:
type: expense
amount: { $gt: 10000 }
action: view
The authorization engine evaluates these policies at runtime:
def can_user_perform_action(user, action, resource, context):
policies = load_policies()
for policy in policies:
if matches_policy(user, action, resource, context, policy):
return policy.effect == 'allow'
return False # Default deny
ABAC handles complex scenarios naturally:
- Data-dependent permissions: “Users can view documents marked as public OR documents they created”
- Contextual access: “Employees can access the VPN only from registered devices”
- Dynamic hierarchies: “Managers can approve expenses up to 2× their direct reports’ highest salary”
- Temporal access: “Contractors have read-only access for 90 days after project completion”
The trade-off: ABAC is harder to implement, harder to debug, and harder to audit than RBAC. When a user can’t access something, figuring out why requires evaluating all policies and understanding which one denied access.
Hybrid Approach: RBAC with Contextual Checks
Most teams don’t need full ABAC. You can extend RBAC with contextual checks:
# Base RBAC check
def has_permission(user, permission):
for role in user.roles:
if permission in role.permissions:
return True
return False
# Contextual wrapper
def can_approve_expense(user, expense):
# RBAC: user must have 'approve_expense' permission
if not has_permission(user, 'approve_expense'):
return False
# Contextual: check department match
if user.department != expense.department:
return False
# Contextual: check expense limit based on role
max_amount = get_approval_limit(user)
if expense.amount > max_amount:
return False
return True
This keeps RBAC simplicity for most permissions while handling special cases explicitly. It’s easier to understand than full ABAC but more maintainable than hardcoded conditionals everywhere.
The downside: authorization logic lives in application code, not in a centralized policy engine. Auditing requires reading code.
When to Use Full ABAC
You need ABAC when:
Permissions depend on data. “Users can view invoices for customers they manage” can’t be expressed in RBAC without creating a role for every user-customer combination.
Compliance requires detailed access policies. Healthcare (HIPAA), finance (SOX), and government systems often require fine-grained access based on data classification, user clearance, time of day, and location.
You have many roles that differ only slightly. If you have regional_manager_northeast, regional_manager_southwest, regional_manager_international, you’re working around RBAC’s limitations. ABAC can express this as a single policy with a region attribute.
Delegation and temporary access are common. “Grant Alice access to Bob’s files while Bob is on vacation” is hard in RBAC (you’d need to change Alice’s role or Bob’s files’ permissions). In ABAC, it’s a policy with a time constraint.
You’re building a multi-tenant platform. SaaS platforms need to enforce tenant isolation (“users can only access data in their own tenant”) plus custom permissions per tenant (“tenant A allows editors to publish; tenant B requires admin approval”). ABAC handles this naturally.
Implementation Patterns
RBAC implementation:
# Database schema
users:
id, name, email
roles:
id, name
user_roles:
user_id, role_id
permissions:
id, name
role_permissions:
role_id, permission_id
# Authorization check
def has_permission(user_id, permission_name):
return db.execute("""
SELECT 1 FROM users u
JOIN user_roles ur ON u.id = ur.user_id
JOIN role_permissions rp ON ur.role_id = rp.role_id
JOIN permissions p ON rp.permission_id = p.id
WHERE u.id = ? AND p.name = ?
""", user_id, permission_name).exists()
Simple, fast, works in any database.
ABAC implementation (using Open Policy Agent):
# Policy in Rego language
package app.authz
default allow = false
# Managers can approve expenses in their department
allow {
input.user.role == "manager"
input.user.department == input.resource.department
input.action == "approve"
input.resource.type == "expense"
}
# Users can edit their own draft documents
allow {
input.user.id == input.resource.owner_id
input.resource.status == "draft"
input.action == "edit"
}
# Application code
from opa_client import OPA
opa = OPA("http://opa:8181")
def can_approve_expense(user, expense):
decision = opa.evaluate("app/authz/allow", {
"user": {
"id": user.id,
"role": user.role,
"department": user.department
},
"resource": {
"type": "expense",
"id": expense.id,
"department": expense.department,
"amount": expense.amount,
"owner_id": expense.creator_id
},
"action": "approve"
})
return decision.get("result", False)
Policies are centralized, versioned, and testable. Changes don’t require code deploys.
Migrating from RBAC to ABAC
Don’t do a big-bang rewrite. Migrate incrementally:
Phase 1: Externalize RBAC logic
Move role-permission mappings from database to a policy file:
# policies.yaml
roles:
admin:
- users:create
- users:delete
- posts:publish
editor:
- posts:create
- posts:edit
- posts:publish
viewer:
- posts:view
Load this at startup. Your application code doesn’t change, but policies are now in one place.
Phase 2: Add attribute-based rules
Keep RBAC for simple permissions, add ABAC for complex cases:
# policies.yaml
rbac_rules:
admin: [users:create, users:delete, posts:publish]
editor: [posts:create, posts:edit, posts:publish]
abac_rules:
- description: "Users can edit their own draft posts"
effect: allow
subject: { id: $resource.author_id }
action: edit
resource: { type: post, status: draft }
- description: "Managers approve expenses in their department"
effect: allow
subject: { role: manager, department: $resource.department }
action: approve
resource: { type: expense }
Check RBAC first (fast), fall back to ABAC for unmatched permissions.
Phase 3: Full ABAC migration
Convert all permissions to attribute-based policies. This is optional—many teams stop at Phase 2.
Debugging and Auditing
The hardest part of ABAC is answering: “Why can’t this user do this thing?”
For RBAC, it’s straightforward:
-- Check user's roles
SELECT r.name FROM roles r
JOIN user_roles ur ON r.id = ur.role_id
WHERE ur.user_id = 12345;
-- Check role's permissions
SELECT p.name FROM permissions p
JOIN role_permissions rp ON p.id = rp.permission_id
WHERE rp.role_id = 5;
For ABAC, you need policy evaluation logs:
{
"user": {"id": 12345, "role": "editor", "department": "engineering"},
"resource": {"id": 67890, "type": "expense", "department": "sales", "amount": 5000},
"action": "approve",
"decision": "deny",
"evaluated_policies": [
{
"policy": "managers_approve_department_expenses",
"result": "not_applicable",
"reason": "user.department != resource.department"
},
{
"policy": "finance_approves_large_expenses",
"result": "not_applicable",
"reason": "user.department != 'finance'"
}
]
}
Build this into your authorization engine from day one. Without evaluation logs, debugging ABAC policies is nearly impossible.
What We Actually Use
For most projects, we use RBAC with contextual checks:
- Standard permissions (view, create, edit, delete) are role-based
- Ownership checks (edit own posts, view own invoices) are explicit conditionals
- Department/team isolation is enforced at the query level (filter by user’s department)
- Approval workflows are custom logic (expense approval limits, document review chains)
We implement full ABAC when:
- Clients need audit trails showing policy compliance (healthcare, finance)
- Multi-tenant platforms require customizable permissions per tenant
- Complex delegation and temporary access are core requirements
The key insight: RBAC is enough for 80% of applications. The other 20% need ABAC, but they really need it—and trying to solve those problems with RBAC leads to unmaintainable role explosion.
Start simple. Add complexity only when the pain of working around RBAC’s limitations exceeds the cost of implementing ABAC.
Need help designing an authorization system that scales with your business requirements? We can help.