>Role-Based Access Control in Django
Replacing Django built-in authorization with a custom RBAC backend using role hierarchies and transitive closure caching.
Gergo Simonyi’s talk at DjangoCon Europe 2026 on building an RBAC system for authentik, an open-source identity provider. Django’s built-in model-level permissions and django-guardian’s object-level permissions weren’t enough for enterprise customers who needed group hierarchies, just-in-time access, and permission delegation.
The approach
Three moves:
- Strip Django’s built-in authz — subclass
ModelBackendso all itshas_permmethods returnFalse. Authentication still works; authorization always falls through to your backend. - Add a single custom backend that answers
has_permagainst your RBAC tables. - Cache the role hierarchy with a transitive closure: one row per
(ancestor, descendant, depth)pair. A permission check collapses to a single query regardless of tree depth.
The models
class Role(models.Model):
name = models.CharField(max_length=100, unique=True)
parent = models.ForeignKey("self", null=True, blank=True, on_delete=models.SET_NULL)
permissions = models.ManyToManyField(Permission, blank=True)
class RoleAncestry(models.Model):
"""(A, D, k) = A is an ancestor of D at distance k. Includes self-links."""
ancestor = models.ForeignKey(Role, related_name="descendant_links", on_delete=models.CASCADE)
descendant = models.ForeignKey(Role, related_name="ancestor_links", on_delete=models.CASCADE)
depth = models.PositiveIntegerField()
class UserRole(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE)
role = models.ForeignKey(Role, on_delete=models.CASCADE)
content_type = models.ForeignKey(ContentType, null=True, blank=True, on_delete=models.CASCADE)
object_id = models.PositiveIntegerField(null=True, blank=True)
scope = GenericForeignKey("content_type", "object_id")
expires_at = models.DateTimeField(null=True, blank=True) # just-in-time access
The single-query has_perm
The closure takes the depth out of the problem. One query, no recursion:
class RBACBackend:
def has_perm(self, user_obj, perm, obj=None):
if not user_obj.is_active: return False
if user_obj.is_superuser: return True
app_label, codename = perm.split(".", 1)
now = timezone.now()
direct = UserRole.objects.filter(user=user_obj).filter(
Q(expires_at__isnull=True) | Q(expires_at__gt=now)
)
# ... scope filtering ...
return Role.objects.filter(
descendant_links__descendant__in=direct.values("role"),
permissions__content_type__app_label=app_label,
permissions__codename=codename,
).exists()
Just-in-time access
Literally one column. expires_at on the assignment plus a filter in the query. No cron job, no revocation workflow. Grant “admin on this project” for the next hour, and it expires automatically.
Custom permissions
No new machinery — just Django’s Meta.permissions:
class Book(models.Model):
class Meta:
permissions = [
("copy_book", "Can copy a book"),
("archive_book", "Can archive a book"),
]
Bind to a role exactly like built-in permissions. Your RBAC tables reference auth.Permission — they don’t care whether the permission is change_book or approve_merger.
Key takeaways
- Django’s authz is pluggable — subclass
ModelBackend, silence the authz methods, add your own backend - Hierarchy + live permission checks = closure table. Without it you walk the tree on every request
- Object-level scoping is a generic FK on the assignment, not a whole new permission model
- Just-in-time is one column plus one filter condition