← ~/logs LOG-008

>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:

  1. Strip Django’s built-in authz — subclass ModelBackend so all its has_perm methods return False. Authentication still works; authorization always falls through to your backend.
  2. Add a single custom backend that answers has_perm against your RBAC tables.
  3. 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

Slides | Experiment code