Role-Based Access Control in Python — Deep Dive

RBAC data model with SQLAlchemy

A proper RBAC system needs a relational model that supports many-to-many relationships between users, roles, and permissions:

from sqlalchemy import Column, Integer, String, Table, ForeignKey, DateTime
from sqlalchemy.orm import relationship, DeclarativeBase
from datetime import datetime, timezone

class Base(DeclarativeBase):
    pass

user_roles = Table(
    "user_roles", Base.metadata,
    Column("user_id", Integer, ForeignKey("users.id"), primary_key=True),
    Column("role_id", Integer, ForeignKey("roles.id"), primary_key=True),
    Column("assigned_at", DateTime, default=lambda: datetime.now(timezone.utc)),
    Column("assigned_by", Integer, ForeignKey("users.id")),
)

role_permissions = Table(
    "role_permissions", Base.metadata,
    Column("role_id", Integer, ForeignKey("roles.id"), primary_key=True),
    Column("permission_id", Integer, ForeignKey("permissions.id"), primary_key=True),
)

class User(Base):
    __tablename__ = "users"
    id = Column(Integer, primary_key=True)
    email = Column(String, unique=True, nullable=False)
    roles = relationship("Role", secondary=user_roles, back_populates="users")

    def has_permission(self, permission_name: str) -> bool:
        return any(
            permission_name in {p.name for p in role.effective_permissions()}
            for role in self.roles
        )

class Role(Base):
    __tablename__ = "roles"
    id = Column(Integer, primary_key=True)
    name = Column(String, unique=True, nullable=False)
    parent_id = Column(Integer, ForeignKey("roles.id"), nullable=True)
    parent = relationship("Role", remote_side=[id])
    users = relationship("User", secondary=user_roles, back_populates="roles")
    permissions = relationship("Permission", secondary=role_permissions,
                               back_populates="roles")

    def effective_permissions(self) -> set:
        """Get all permissions including inherited from parent roles."""
        perms = set(self.permissions)
        if self.parent:
            perms |= self.parent.effective_permissions()
        return perms

class Permission(Base):
    __tablename__ = "permissions"
    id = Column(Integer, primary_key=True)
    name = Column(String, unique=True, nullable=False)  # e.g., "posts:write"
    description = Column(String)
    roles = relationship("Role", secondary=role_permissions, back_populates="permissions")

FastAPI RBAC with dependency injection

FastAPI’s dependency system is ideal for authorization checks:

from fastapi import FastAPI, Depends, HTTPException, status
from functools import wraps

app = FastAPI()

async def get_current_user(token: str = Depends(oauth2_scheme)) -> User:
    """Extract and validate user from JWT token."""
    payload = decode_jwt(token)
    user = await get_user_by_id(payload["sub"])
    if not user:
        raise HTTPException(status_code=401, detail="User not found")
    return user

def require_permission(permission: str):
    """Dependency factory that checks for a specific permission."""
    async def checker(user: User = Depends(get_current_user)):
        if not user.has_permission(permission):
            raise HTTPException(
                status_code=status.HTTP_403_FORBIDDEN,
                detail=f"Permission '{permission}' required",
            )
        return user
    return checker

def require_role(role_name: str):
    """Dependency factory that checks for a specific role."""
    async def checker(user: User = Depends(get_current_user)):
        user_role_names = {r.name for r in user.roles}
        if role_name not in user_role_names:
            raise HTTPException(
                status_code=status.HTTP_403_FORBIDDEN,
                detail=f"Role '{role_name}' required",
            )
        return user
    return checker

# Usage in routes
@app.get("/posts")
async def list_posts(user: User = Depends(require_permission("posts:read"))):
    return await fetch_posts()

@app.delete("/posts/{post_id}")
async def delete_post(
    post_id: int,
    user: User = Depends(require_permission("posts:delete")),
):
    await remove_post(post_id)
    return {"status": "deleted"}

@app.get("/admin/users")
async def admin_users(user: User = Depends(require_role("admin"))):
    return await fetch_all_users()

Django’s built-in permission system

Django generates permissions automatically from models and provides a mature RBAC system via Groups:

# models.py
from django.db import models

class Article(models.Model):
    title = models.CharField(max_length=200)
    body = models.TextField()
    author = models.ForeignKey("auth.User", on_delete=models.CASCADE)

    class Meta:
        permissions = [
            ("publish_article", "Can publish articles"),
            ("feature_article", "Can feature articles on homepage"),
        ]

# views.py
from django.contrib.auth.decorators import permission_required

@permission_required("myapp.publish_article", raise_exception=True)
def publish_article(request, article_id):
    article = Article.objects.get(id=article_id)
    article.published = True
    article.save()
    return redirect("article_detail", article_id=article_id)

# Setup roles (Groups) via management command or admin
from django.contrib.auth.models import Group, Permission

editor_group, _ = Group.objects.get_or_create(name="Editor")
publish_perm = Permission.objects.get(codename="publish_article")
editor_group.permissions.add(publish_perm)

Permission caching strategy

Loading roles and permissions from the database on every request is expensive. Cache the permission set:

import json
from functools import lru_cache
import redis

redis_client = redis.Redis()
CACHE_TTL = 300  # 5 minutes

def get_user_permissions(user_id: int) -> set[str]:
    """Get user permissions with Redis caching."""
    cache_key = f"user_perms:{user_id}"

    # Try cache
    cached = redis_client.get(cache_key)
    if cached:
        return set(json.loads(cached))

    # Load from database
    user = get_user_with_roles(user_id)
    perms = set()
    for role in user.roles:
        for perm in role.effective_permissions():
            perms.add(perm.name)

    # Cache the result
    redis_client.setex(cache_key, CACHE_TTL, json.dumps(list(perms)))
    return perms

def invalidate_user_permissions(user_id: int):
    """Call when roles or permissions change."""
    redis_client.delete(f"user_perms:{user_id}")

def invalidate_role_permissions(role_id: int):
    """Invalidate all users with this role."""
    user_ids = get_users_with_role(role_id)
    pipeline = redis_client.pipeline()
    for uid in user_ids:
        pipeline.delete(f"user_perms:{uid}")
    pipeline.execute()

Embedding roles in JWTs

For stateless architectures, encode roles (or permissions) directly in the JWT:

import jwt
from datetime import datetime, timedelta, timezone

def create_access_token(user: User) -> str:
    permissions = set()
    for role in user.roles:
        for perm in role.effective_permissions():
            permissions.add(perm.name)

    payload = {
        "sub": str(user.id),
        "email": user.email,
        "roles": [r.name for r in user.roles],
        "permissions": list(permissions),
        "exp": datetime.now(timezone.utc) + timedelta(minutes=15),
    }
    return jwt.encode(payload, SECRET_KEY, algorithm="RS256")

def check_jwt_permission(token: str, required: str) -> bool:
    payload = jwt.decode(token, PUBLIC_KEY, algorithms=["RS256"])
    return required in payload.get("permissions", [])

The tradeoff: JWT permissions can become stale. If you revoke a role, existing tokens still carry the old permissions until they expire. Use short-lived access tokens (15 minutes) with refresh tokens to limit the staleness window.

Audit logging

Every role and permission change should be logged for compliance:

from datetime import datetime, timezone

class AuditLog(Base):
    __tablename__ = "audit_log"
    id = Column(Integer, primary_key=True)
    timestamp = Column(DateTime, default=lambda: datetime.now(timezone.utc))
    actor_id = Column(Integer, ForeignKey("users.id"))
    action = Column(String)  # "role_assigned", "role_revoked", "permission_added"
    target_user_id = Column(Integer, ForeignKey("users.id"), nullable=True)
    target_role_id = Column(Integer, ForeignKey("roles.id"), nullable=True)
    details = Column(String)  # JSON with before/after state

def assign_role(actor: User, target: User, role: Role, db):
    """Assign a role with audit trail."""
    target.roles.append(role)
    db.add(AuditLog(
        actor_id=actor.id,
        action="role_assigned",
        target_user_id=target.id,
        target_role_id=role.id,
        details=f'{{"role": "{role.name}"}}',
    ))
    db.commit()
    invalidate_user_permissions(target.id)

Handling super-admin and system roles

Avoid hardcoding admin checks. Instead, give the admin role explicit permissions:

# Bad: hardcoded role check
if "admin" in user_roles:
    allow()

# Good: permission-based check
if user.has_permission("users:manage"):
    allow()

This lets you create granular admin tiers (billing admin, content admin, super admin) without code changes.

For a “super admin” bypass (use sparingly):

def has_permission(self, permission_name: str) -> bool:
    if any(r.name == "super_admin" for r in self.roles):
        return True  # super admin bypasses all checks
    return any(
        permission_name in {p.name for p in role.effective_permissions()}
        for role in self.roles
    )

Testing RBAC

import pytest

@pytest.fixture
def user_with_roles(db):
    """Create users with various roles for testing."""
    viewer = create_user("viewer@test.com", roles=["viewer"])
    editor = create_user("editor@test.com", roles=["editor"])
    admin = create_user("admin@test.com", roles=["admin"])
    return viewer, editor, admin

def test_viewer_cannot_delete(client, user_with_roles):
    viewer, _, _ = user_with_roles
    response = client.delete("/posts/1", headers=auth_header(viewer))
    assert response.status_code == 403

def test_admin_can_delete(client, user_with_roles):
    _, _, admin = user_with_roles
    response = client.delete("/posts/1", headers=auth_header(admin))
    assert response.status_code == 200

def test_role_hierarchy(db):
    admin_role = get_role("admin")
    perms = {p.name for p in admin_role.effective_permissions()}
    # Admin inherits all editor permissions
    assert "posts:write" in perms
    # And has its own
    assert "users:manage" in perms

The one thing to remember: Production RBAC in Python means a relational data model with hierarchical roles, framework-specific middleware for enforcement, cached permission lookups, JWT integration for stateless checks, and audit logging for every role change — permission checks should be declarative and centralized, never scattered as ad-hoc conditions.

pythonsecurityauthorizationweb

See Also