Skip to content

django-topdownrbac

A Django library that implements hierarchical, top-down role-based access control (RBAC) with automatic permission propagation through object trees.

Installation

uv add django-topdownrbac
# or
pip install django-topdownrbac

Django configuration

Add the app to your settings, set the custom user model, and register the authentication backend:

# settings.py

INSTALLED_APPS = [
    "topdownrbac",
    # ... your apps ...
    "treebeard",
]

AUTH_USER_MODEL = "topdownrbac.UserSubject"

AUTHENTICATION_BACKENDS = [
    "django.contrib.auth.backends.ModelBackend",
    "topdownrbac.backends.restricted_object_backend.RestrictedObjectBackend",
]

Then run migrations:

python manage.py migrate

Model overview

treebeardLibdjangoYourAppMP_NodeRestrictedObjectAutoField restricted_object_idFK parentchange_parent()remove_parent()who_has_perm()get_queryset_for_user()SubjectAutoField subject_idUserSubjectCharField nameUserGroupSubjectM2M user_subjectsadd_user()remove_user()RoleCharField nameM2M permissionsBooleanField editableBooleanField externally_provisionedadd_permission()remove_permission()RoleBindingCharField nameBooleanField propagateUserPermissionFK userFK permFK restricted_objectFK sourceAbstractUserPermissionString codenameOrganizationProjectUserProfileparent0..1******1*1*1*1*1*1source*111
treebeardLibdjangoYourAppMP_NodeRestrictedObjectAutoField restricted_object_idFK parentchange_parent()remove_parent()who_has_perm()get_queryset_for_user()SubjectAutoField subject_idUserSubjectCharField nameUserGroupSubjectM2M user_subjectsadd_user()remove_user()RoleCharField nameM2M permissionsBooleanField editableBooleanField externally_provisionedadd_permission()remove_permission()RoleBindingCharField nameBooleanField propagateUserPermissionFK userFK permFK restricted_objectFK sourceAbstractUserPermissionString codenameOrganizationProjectUserProfileparent0..1******1*1*1*1*1*1source*111

Usage

Creating users

UserSubject extends Django's AbstractUser, so it works like the standard user model:

from topdownrbac.models import UserSubject

alice = UserSubject.objects.create_user(
    username="alice", password="secret", name="Alice"
)
bob = UserSubject.objects.create_user(
    username="bob", password="secret", name="Bob"
)
«user»alice : UserSubject«user»bob : UserSubject
«user»alice : UserSubject«user»bob : UserSubject

Setting up a hierarchy

RestrictedObject is organized as a tree. Set parent to place an object under another:

from topdownrbac.models import RestrictedObject

# Root node
org = RestrictedObject(parent=None)
org.save()

# Children
team = RestrictedObject(parent=org)
team.save()

project = RestrictedObject(parent=team)
project.save()
«user»alice : UserSubject«user»bob : UserSubject«ro»org : RestrictedObject«ro»team : RestrictedObject«ro»project : RestrictedObjectparentparent
«user»alice : UserSubject«user»bob : UserSubject«ro»org : RestrictedObject«ro»team : RestrictedObject«ro»project : RestrictedObjectparentparent

To move an object to a different parent:

project.change_parent(other_team)

To detach an object from its parent, making it a root node:

# These two are equivalent
project.remove_parent()
project.change_parent(None)

When an object is moved or detached, all propagated permissions are automatically recalculated: permissions inherited from the old parent are removed, and permissions from the new parent (if any) are applied.

Creating your own restricted objects

Your domain models can inherit directly from RestrictedObject:

from topdownrbac.models import RestrictedObject

class Organization(RestrictedObject):
    name = models.CharField(max_length=200)

class Project(RestrictedObject):
    name = models.CharField(max_length=200)
    description = models.TextField(blank=True)

This gives you the full hierarchy and permission system on your own models.

Roles and permissions

A Role groups one or more Django Permission objects. When you add or remove a permission from a role, the change automatically cascades to all users bound to that role.

from django.contrib.auth.models import Permission
from topdownrbac.models import Role

# Create a role
viewer = Role.objects.create(name="Viewer")

# Add permissions to it
view_perm = Permission.objects.get(codename="view_organization")
viewer.add_permission(view_perm)

# Add multiple permissions at once
perms = Permission.objects.filter(codename__in=["view_project", "change_project"])
editor = Role.objects.create(name="Editor")
editor.add_many_permissions(list(perms))

# Remove a permission (cascades to all bound users)
editor.remove_permission(view_perm)
«user»alice : UserSubject«user»bob : UserSubject«ro»org : RestrictedObject«ro»team : RestrictedObject«ro»project : RestrictedObject«role»viewer : Role«role»editor : Role«perm»view_organization : Permission«perm»view_project : Permission«perm»change_project : Permissionparentparent
«user»alice : UserSubject«user»bob : UserSubject«ro»org : RestrictedObject«ro»team : RestrictedObject«ro»project : RestrictedObject«role»viewer : Role«role»editor : Role«perm»view_organization : Permission«perm»view_project : Permission«perm»change_project : Permissionparentparent

Roles can be locked to prevent accidental changes:

viewer.set_immutable()       # raises PermissionDenied on add/remove/delete
viewer.set_editable()        # unlocks the role

Assigning roles with RoleBindings

A RoleBinding links a subject (user or group) to a role on a specific restricted object. The propagate flag controls whether the permissions apply only to that object or to all its descendants as well.

Without propagation

When propagate=False, the user only receives permissions on the exact object specified:

from topdownrbac.models import RoleBinding

# Give alice the Viewer role on org only
RoleBinding.objects.create(
    role=viewer,
    subject=alice,
    restricted_object=org,
    propagate=False,
)
«user»alice : UserSubject«ro»org : RestrictedObject«ro»team : RestrictedObject«ro»project : RestrictedObject«role»viewer : Role«perm»view_organization : Permission«rb»rb1 : RoleBindingpropagate = false«up»alice / view_organization / orgparentparentsubjectrolerestricted_objectgenerates
«user»alice : UserSubject«ro»org : RestrictedObject«ro»team : RestrictedObject«ro»project : RestrictedObject«role»viewer : Role«perm»view_organization : Permission«rb»rb1 : RoleBindingpropagate = false«up»alice / view_organization / orgparentparentsubjectrolerestricted_objectgenerates

Alice gets a single UserPermission row: she can view_organization on org, but not on team or project.

With propagation

When propagate=True, the user receives permissions on the object and all its descendants:

# Give bob the Editor role on org and all its descendants
RoleBinding.objects.create(
    role=editor,
    subject=bob,
    restricted_object=org,
    propagate=True,  # bob gets permissions on org, team, and project
)
«user»bob : UserSubject«ro»org : RestrictedObject«ro»team : RestrictedObject«ro»project : RestrictedObject«role»editor : Role«perm»view_project : Permission«perm»change_project : Permission«rb»rb2 : RoleBindingpropagate = true«up»bob / view_project / org«up»bob / change_project / org«up»bob / view_project / team«up»bob / change_project / team«up»bob / view_project / project«up»bob / change_project / projectparentparentsubjectrolerestricted_objectgenerates
«user»bob : UserSubject«ro»org : RestrictedObject«ro»team : RestrictedObject«ro»project : RestrictedObject«role»editor : Role«perm»view_project : Permission«perm»change_project : Permission«rb»rb2 : RoleBindingpropagate = true«up»bob / view_project / org«up»bob / change_project / org«up»bob / view_project / team«up»bob / change_project / team«up»bob / view_project / project«up»bob / change_project / projectparentparentsubjectrolerestricted_objectgenerates

Bob gets 6 UserPermission rows: view_project and change_project on each of org, team, and project. When new children are added under org, Bob automatically receives permissions on them too.

UserPermission rows are created automatically when a RoleBinding is saved, and removed automatically when it is deleted. The role, subject, restricted_object, and propagate fields are immutable after creation — attempting to change them will raise PermissionDenied. To modify a binding, delete it and create a new one; the library will handle cleaning up the old UserPermission rows and generating the new ones.

Checking permissions

The library ships with a custom authentication backend, so Django's standard has_perm works out of the box. Pass the object as the second argument:

alice.has_perm("view_organization", org)       # True
alice.has_perm("view_organization", project)   # False (propagate=False)

bob.has_perm("change_project", project)        # True (propagated from org)

Filtering querysets

Use get_queryset_for_user() to get all objects of a given type that a user has a specific permission on:

# All organizations alice can view
qs = Organization.get_queryset_for_user(alice, "view_organization")

In a DRF viewset:

class OrganizationViewSet(viewsets.ModelViewSet):
    def get_queryset(self):
        return Organization.get_queryset_for_user(
            self.request.user, "view_organization"
        )

Superusers automatically get all objects. Unauthenticated users raise PermissionDenied.

Listing users with a permission on an object

Use who_has_perm() to get all users that have a specific permission on a given object. This is the inverse of get_queryset_for_user() -- instead of "which objects can this user access?", it answers "which users can access this object?".

# All users who can view this organization
users = org.who_has_perm("view_organization")

The method returns a UserSubject queryset, so it can be filtered further:

active_viewers = org.who_has_perm("view_organization").filter(is_active=True)

Users who have the permission via any path (direct binding, group membership, or propagation from an ancestor) are included. Each user appears only once regardless of how many bindings grant them the permission.

Groups

UserGroupSubject lets you assign roles to a group of users. Permissions are granted to all members, and membership changes automatically cascade.

from topdownrbac.models import UserGroupSubject

team_group = UserGroupSubject.objects.create()
team_group.add_user(alice)
team_group.add_user(bob)

# Bind a role to the group
RoleBinding.objects.create(
    role=editor,
    subject=team_group,
    restricted_object=team,
    propagate=True,
)

# Both alice and bob now have Editor permissions on team and its descendants.

# Adding a user to the group grants them the existing permissions
team_group.add_user(charlie)

# Removing a user revokes them
team_group.remove_user(alice)
«user»alice : UserSubject«user»bob : UserSubject«user»charlie : UserSubject«group»team_group : UserGroupSubject«ro»org : RestrictedObject«ro»team : RestrictedObject«ro»project : RestrictedObject«role»viewer : Role«role»editor : Role«rb»rb1 : RoleBindingalice / viewer / orgpropagate=false«rb»rb2 : RoleBindingbob / editor / orgpropagate=true«rb»rb3 : RoleBindingteam_group / editor / teampropagate=truemembermemberparentparentsubjectrolerestricted_objectsubjectrolerestricted_objectsubjectrolerestricted_object
«user»alice : UserSubject«user»bob : UserSubject«user»charlie : UserSubject«group»team_group : UserGroupSubject«ro»org : RestrictedObject«ro»team : RestrictedObject«ro»project : RestrictedObject«role»viewer : Role«role»editor : Role«rb»rb1 : RoleBindingalice / viewer / orgpropagate=false«rb»rb2 : RoleBindingbob / editor / orgpropagate=true«rb»rb3 : RoleBindingteam_group / editor / teampropagate=truemembermemberparentparentsubjectrolerestricted_objectsubjectrolerestricted_objectsubjectrolerestricted_object

Management commands

Provisioning roles from YAML

Roles can be defined in a YAML file and synced with the database using the import_roles command. Imported roles are automatically marked as externally_provisioned=True and editable=False — they become immutable and can only be modified by updating the YAML file and re-running the command.

# roles.yaml
roles:
  viewer:
    permissions:
      - view_organization
      - view_project
  editor:
    permissions:
      - view_organization
      - change_organization
      - view_project
      - change_project
python manage.py import_roles roles.yaml

On each sync:

  • New roles in the YAML are created with externally_provisioned=True and editable=False.
  • Modified roles (permissions added or removed) are updated, and the changes cascade to all users bound to that role.
  • Removed roles (present in the database but no longer in the YAML) are deleted, along with their RoleBindings and UserPermissions.

Manually created roles (externally_provisioned=False) are never affected by the sync.

Checking permission consistency

The check_perm_consistency command audits the UserPermission table against existing RoleBindings. It detects orphaned rows (stale references), missing rows, and can optionally fix them.

# Report inconsistencies
python manage.py check_perm_consistency

# Report and fix automatically
python manage.py check_perm_consistency -u

This can be run as a periodic task or manually if inconsistencies are suspected.