Designing RBAC for Multi-Tenant Django Applications
A practical guide to role-based access control in Django — from simple per-role permissions to a five-tier RBAC model powering a production medical platform, without duplicating authorization logic across microservices.
The Problem with Naive RBAC
Most Django tutorials show role-based access control as a simple field on the user model:
user.role == "admin". This works for small applications. It breaks down quickly when you have multiple
roles, complex permissions, and the logic scattered across views, serializers, and business functions.
At Medical Toxicology, we operate a five-role system: Superuser, Admin, SEO Manager, Staff, and Normal User. Each role has a distinct dashboard and a different set of allowed operations. Across a microservices architecture, the authorization logic cannot live in every service independently — that path leads to drift and security holes.
This post describes how we designed an RBAC model that is consistent across services and maintainable over time.
Roles as First-Class Objects
The first decision: roles must be explicit, not implicit. Instead of checking if user.is_staff, we enumerate
roles as a Python Enum and store them in the database.
from enum import IntEnum
class UserRole(IntEnum):
NORMAL = 1
STAFF = 2
SEO_MANAGER = 3
ADMIN = 4
SUPERUSER = 5
Using IntEnum means roles have a natural ordering (higher value = more privilege). This simplifies "at least
admin" checks: user.role >= UserRole.ADMIN.
The role is stored on the user record in the Auth Service database — the single source of truth.
The Auth Service as Authorization Gateway
In our microservices system, every API call passes through JWT authentication. The JWT payload includes the user's role:
def create_token(user):
return jwt.encode({
"sub": str(user.id),
"role": user.role,
"exp": datetime.utcnow() + TOKEN_TTL,
}, settings.JWT_SECRET, algorithm="HS256")
Downstream services decode the JWT and extract the role without calling the Auth Service on every request. This keeps authorization stateless and fast.
Permission Decorators
Rather than scattering if user.role >= ... checks through views, we define reusable decorators:
from functools import wraps
from rest_framework.exceptions import PermissionDenied
def require_role(minimum_role: UserRole):
def decorator(view_func):
@wraps(view_func)
def wrapper(request, *args, **kwargs):
user_role = UserRole(request.user.role)
if user_role < minimum_role:
raise PermissionDenied(
f"This action requires {minimum_role.name} or higher."
)
return view_func(request, *args, **kwargs)
return wrapper
return decorator
Applied to a view:
@api_view(["POST"])
@require_role(UserRole.ADMIN)
def create_service(request):
# Only Admin and Superuser reach here
...
The decorator handles the authorization. The view handles the business logic. These concerns stay separated.
Dashboard Routing by Role
Each user type has a different dashboard. The routing logic lives in one place — a central dispatch function:
DASHBOARD_URLS = {
UserRole.SUPERUSER: "/dashboard/superuser/",
UserRole.ADMIN: "/dashboard/admin/",
UserRole.SEO_MANAGER: "/dashboard/seo/",
UserRole.STAFF: "/dashboard/staff/",
UserRole.NORMAL: "/dashboard/",
}
def get_dashboard_redirect(user_role: UserRole) -> str:
return DASHBOARD_URLS.get(user_role, DASHBOARD_URLS[UserRole.NORMAL])
When a new role is added, only this mapping needs updating. The routing logic is not duplicated across views or frontend components.
Cross-Service Authorization
The trickiest part of RBAC in microservices is preventing cross-contamination — one service's authorization logic influencing another incorrectly.
Our rule: each service enforces its own permissions using the role from the JWT. The role is a trusted claim because the JWT is signed by the Auth Service. Services do not call each other to verify permissions.
This means:
- The CMS Service enforces SEO Manager permissions independently
- The Management Service enforces Admin+ permissions independently
- No service trusts another service's authorization decisions
The downside: if the permission model changes, every service that enforces that permission must be updated. We accept this cost because the alternative — centralized permission checking — creates a hard dependency on the Auth Service for every request.
Testing RBAC
RBAC is high-value territory for testing because authorization bugs are often silent failures — a user has access they should not, and nothing breaks until someone notices.
We write explicit tests for each permission boundary:
class TestServiceCreation(TestCase):
def test_normal_user_cannot_create_service(self):
user = UserFactory(role=UserRole.NORMAL)
response = self.client.post("/services/", data={...}, user=user)
self.assertEqual(response.status_code, 403)
def test_admin_can_create_service(self):
user = UserFactory(role=UserRole.ADMIN)
response = self.client.post("/services/", data={...}, user=user)
self.assertEqual(response.status_code, 201)
def test_staff_cannot_create_service(self):
user = UserFactory(role=UserRole.STAFF)
response = self.client.post("/services/", data={...}, user=user)
self.assertEqual(response.status_code, 403)
Testing the boundary conditions (the roles just below and just above the required level) catches more bugs than testing only the permitted role.
Key Takeaways
1. Make roles explicit. An IntEnum with ordered values is simple, readable, and enables range
comparisons without complex permission graph traversal.
2. Put the role in the JWT. Downstream services can enforce permissions without calling the Auth Service on every request. This keeps the system fast and reduces hard coupling.
3. Centralize permission logic in decorators. Views should not contain authorization logic. Decorators and permission classes are the right home.
4. Test the boundaries explicitly. Authorization tests should verify that the role just below the required level is denied, not just that the permitted role is allowed.
5. Accept the duplication cost. In microservices, each service enforcing its own permissions creates some duplication. That cost is worth paying to avoid centralized authorization bottlenecks.