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")
ModelComplexityFlexibilityBest For
ACLLowLowFile systems, simple resource ownership
RBACMediumMediumMost web applications, SaaS platforms
ABACHighHighHealthcare, finance, government (fine-grained)
OPAHighHighestMicroservices, 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_permission keeps 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