Authorization Patterns
RBAC, ABAC, ACL, OPA (Open Policy Agent)
Introduction
Authorization answers: "What are you allowed to do?" After authentication proves identity, authorization enforces what that identity can access. This lesson covers four models: RBAC (role-based), ABAC (attribute-based), ACL (access control lists), and OPA (policy engines), each appropriate for different scales of complexity and each with Python implementations.
RBAC, Role-Based Access Control
RBAC assigns permissions to roles, and roles to users. It's the most common pattern for web applications because it's simple to reason about and manage.
# rbac.py
from dataclasses import dataclass, field
from functools import wraps
from typing import Callable
@dataclass
class Permission:
resource: str # e.g., "post", "user", "report"
action: str # e.g., "read", "write", "delete", "admin"
def __str__(self) -> str:
return f"{self.resource}:{self.action}"
@dataclass
class Role:
name: str
permissions: set[str] = field(default_factory=set) # Set of "resource:action"
def has_permission(self, resource: str, action: str) -> bool:
return f"{resource}:{action}" in self.permissions or f"{resource}:admin" in self.permissions
@dataclass
class User:
user_id: int
username: str
roles: list[str] = field(default_factory=list)
# Define roles and permissions
ROLES: dict[str, Role] = {
"viewer": Role("viewer", {"post:read", "report:read"}),
"editor": Role("editor", {"post:read", "post:write", "report:read"}),
"admin": Role("admin", {"post:admin", "user:admin", "report:admin"}),
}
def user_has_permission(user: User, resource: str, action: str) -> bool:
"""Check if any of the user's roles grant the requested permission."""
return any(
ROLES[role_name].has_permission(resource, action)
for role_name in user.roles
if role_name in ROLES
)
# Decorator for enforcing RBAC in functions/routes
def require_permission(resource: str, action: str):
def decorator(func: Callable) -> Callable:
@wraps(func)
def wrapper(user: User, *args, **kwargs):
if not user_has_permission(user, resource, action):
raise PermissionError(
f"User '{user.username}' lacks {resource}:{action}"
)
return func(user, *args, **kwargs)
return wrapper
return decorator
# Example routes
@require_permission("post", "write")
def create_post(user: User, title: str) -> str:
return f"Post '{title}' created by {user.username}"
@require_permission("user", "admin")
def delete_user(user: User, target_id: int) -> str:
return f"User {target_id} deleted by {user.username}"
# Test
alice = User(1, "alice", roles=["editor"])
bob = User(2, "bob", roles=["viewer"])
carol = User(3, "carol", roles=["admin"])
print(create_post(alice, "My Article")) # ✅ editor has post:write
try:
create_post(bob, "Hacking in") # ❌ viewer has no post:write
except PermissionError as e:
print(f"Denied: {e}")
print(delete_user(carol, target_id=99)) # ✅ admin has user:admin
Expected Output:
Post 'My Article' created by alice Denied: User 'bob' lacks post:write User 99 deleted by carol
ABAC, Attribute-Based Access Control
ABAC makes decisions based on arbitrary attributes of the user, resource, and environment. It is more flexible than RBAC but harder to reason about at scale.
# abac.py
from dataclasses import dataclass
from datetime import datetime, timezone
@dataclass
class UserAttrs:
user_id: int
department: str
clearance_level: int # 1-5
is_contractor: bool
@dataclass
class ResourceAttrs:
resource_id: str
classification: str # public, internal, confidential, secret
owner_dept: str
min_clearance: int
@dataclass
class EnvironmentAttrs:
ip_address: str
current_hour: int # 0-23 (business hours check)
CLASSIFICATION_LEVEL = {"public": 1, "internal": 2, "confidential": 3, "secret": 4}
def can_access_document(
user: UserAttrs,
resource: ResourceAttrs,
env: EnvironmentAttrs,
) -> tuple[bool, str]:
"""ABAC policy for document access."""
# Rule 1: Minimum clearance level
if user.clearance_level < resource.min_clearance:
return False, f"Clearance {user.clearance_level} < required {resource.min_clearance}"
# Rule 2: Contractors cannot access secret documents
if user.is_contractor and resource.classification == "secret":
return False, "Contractors may not access secret documents"
# Rule 3: Business hours only for confidential/secret
if resource.classification in ("confidential", "secret") and not (9 <= env.current_hour <= 17):
return False, "Confidential documents only accessible during business hours"
# Rule 4: Same department or admin
if resource.owner_dept != "public" and resource.owner_dept != user.department:
if user.clearance_level < 4:
return False, f"Cross-department access requires clearance >= 4"
return True, "Access granted"
# Test
doc = ResourceAttrs("doc-001", "confidential", "engineering", min_clearance=3)
env = EnvironmentAttrs("10.0.0.1", current_hour=14) # 2pm = business hours
alice = UserAttrs(1, "engineering", clearance_level=4, is_contractor=False)
bob = UserAttrs(2, "marketing", clearance_level=2, is_contractor=True)
carol = UserAttrs(3, "finance", clearance_level=3, is_contractor=False)
for user in [alice, bob, carol]:
allowed, reason = can_access_document(user, doc, env)
status = "✅" if allowed else "❌"
print(f"{status} User {user.user_id} ({user.department}): {reason}")
Expected Output:
✅ User 1 (engineering): Access granted ❌ User 2 (marketing): Clearance 2 < required 3 ❌ User 3 (finance): Cross-department access requires clearance >= 4
OPA, Open Policy Agent
OPA is a general-purpose policy engine that decouples policy decisions from application code. Policies are written in Rego, a declarative language. Your application queries OPA over HTTP.
# rego_policy.rego, OPA policy example (Rego language)
package authz.api
import future.keywords.if
import future.keywords.in
default allow := false
# Allow access if the user has the required role for this path
allow if {
some role in input.user.roles
required_roles[input.request.path][input.request.method][role]
}
# Role matrix: path → method → allowed roles
required_roles := {
"/api/posts": {
"GET": {"viewer", "editor", "admin"},
"POST": {"editor", "admin"},
"DELETE": {"admin"},
},
"/api/users": {
"GET": {"admin"},
"DELETE": {"admin"},
},
}
# Additional rule: check clearance level for sensitive endpoints
allow if {
input.request.path == "/api/reports/financial"
input.user.clearance_level >= 3
}
# opa_client.py, Query OPA from Python
# Requires OPA running: opa run --server policy.rego
import requests
OPA_URL = "http://localhost:8181/v1/data/authz/api/allow"
def check_authorization(user: dict, request: dict) -> bool:
"""Query OPA for an authorization decision."""
payload = {"input": {"user": user, "request": request}}
response = requests.post(OPA_URL, json=payload, timeout=5)
response.raise_for_status()
return response.json().get("result", False)
# Simulate authorization checks
test_cases = [
({"roles": ["viewer"], "clearance_level": 1}, {"path": "/api/posts", "method": "GET"}),
({"roles": ["viewer"], "clearance_level": 1}, {"path": "/api/posts", "method": "POST"}),
({"roles": ["admin"], "clearance_level": 5}, {"path": "/api/users", "method": "DELETE"}),
({"roles": ["editor"], "clearance_level": 3}, {"path": "/api/reports/financial", "method": "GET"}),
]
for user, req in test_cases:
# allowed = check_authorization(user, req) # Real OPA call
# Simulated logic for demo:
print(f"User roles={user['roles']} → {req['method']} {req['path']}")
print("\n✅ Policy is external, change rules without redeploying application")
print("✅ OPA supports Kubernetes admission control, Envoy, Terraform")
| Model | Complexity | Flexibility | Best For |
|---|---|---|---|
| ACL | Low | Low | File systems, simple resource ownership |
| RBAC | Medium | Medium | Most web applications, SaaS platforms |
| ABAC | High | High | Healthcare, finance, government (fine-grained) |
| OPA | High | Highest | Microservices, Kubernetes, multi-tenant platforms |
Key Takeaways
- RBAC is the right default, simple, auditable, and sufficient for most applications; start here
- ABAC handles context-dependent rules, "can access if clearance ≥ 3 AND during business hours AND same department"
- Enforce server-side always, never rely on client-side UI to enforce authorization; always check in backend code
- Use decorators for DRY enforcement,
@require_permissionkeeps auth logic separate from business logic - OPA decouples policy from code, update authorization rules without code deployments
- Principle of least privilege, grant the minimum permissions required; expand as needed, not preemptively
What's Next?
Lesson 12 surveys the OWASP Top 10, the industry-standard list of the most critical web application security risks.
- A01 Broken Access Control, the #1 security risk; vulnerable vs fixed Python examples
- A02 Cryptographic Failures, bad (MD5) vs good (bcrypt/AES) examples
- A03–A10, concept cards, mitigations, and an automated checklist script