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.
See Also
- Python Api Key Management Why apps use special passwords called API keys, and how to keep them safe — explained with a library card analogy
- Python Attribute Based Access Control How apps make fine-grained permission decisions based on who you are, what you're accessing, and the circumstances — explained with an airport analogy
- Python Audit Logging Learn Audit Logging with a clear mental model so your Python code is easier to trust and maintain.
- Python Bandit Security Scanning Why Bandit Security Scanning helps Python teams catch painful mistakes early without slowing daily development.
- Python Clickjacking Prevention How invisible website layers trick you into clicking the wrong thing, and how Python apps stop it