django-topdownrbac
A Django library that implements hierarchical, top-down role-based access control (RBAC) with automatic permission propagation through object trees.
Installation
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:
Model overview
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"
)
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()
To move an object to a different parent:
To detach an object from its parent, making it a root node:
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)
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,
)
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
)
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?".
The method returns a UserSubject queryset, so it can be filtered further:
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)
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
On each sync:
- New roles in the YAML are created with
externally_provisioned=Trueandeditable=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.