Let's create a django app using topdownrbac and DRF
Base setup
Assuming all requirements are met, let's create the base django template:
mkdir exampleapp
django-admin startproject config exampleapp/
cd exampleapp
django-admin startapp example_app
We will be using uv as our package manager here, but you can use your favourite one (pip...)
Let's configure the settings in the config/settings.py file (add topdownrbac, rest_framework, example_app and treebeard (which is a required dependency) to INSTALLED_APPS:
INSTALLED_APPS = [
"topdownrbac",
"rest_framework",
"example_app",
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"treebeard",
]
AUTH_USER_MODEL = "topdownrbac.UserSubject"
AUTHENTICATION_BACKENDS = [
"django.contrib.auth.backends.ModelBackend",
"topdownrbac.backends.restricted_object_backend.RestrictedObjectBackend",
]
REST_FRAMEWORK = {
"DEFAULT_AUTHENTICATION_CLASSES": [
"rest_framework.authentication.SessionAuthentication",
"rest_framework.authentication.BasicAuthentication",
],
}
Making a basic app
Now let's create some basic classes which we want to protect in example_app/models.py.
from django.conf import settings
from django.db import models
from topdownrbac.models import RestrictedObject, UserGroupSubject
class Organization(RestrictedObject, UserGroupSubject):
"""A top-level restricted object. Root of the hierarchy. Is also a UserGroupSubject,
so we can assign permissions to users on the org itself."""
name = models.CharField(max_length=200)
def __str__(self):
return self.name
class Project(RestrictedObject):
"""A restricted object that lives under an Organization."""
name = models.CharField(max_length=200)
description = models.TextField(blank=True)
def __str__(self):
return self.name
class UserProfile(models.Model):
"""
App-specific profile attached to the library-provided UserSubject.
This is the recommended way to add custom fields to the user model
without modifying or subclassing UserSubject.
"""
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="profile",
)
bio = models.TextField(blank=True)
avatar_url = models.URLField(blank=True)
def __str__(self):
return f"Profile of {self.user.username}"
Then run migrations:
We'll also create the views and serializers, as well as other drf setup (we will stop at create / read for simplicity) :
from rest_framework import serializers
from .models import Organization, Project
class OrganizationSerializer(serializers.ModelSerializer):
class Meta:
model = Organization
fields = ["restricted_object_id", "name"]
read_only_fields = ["restricted_object_id"]
class ProjectSerializer(serializers.ModelSerializer):
class Meta:
model = Project
fields = ["restricted_object_id", "name", "description"]
read_only_fields = ["restricted_object_id"]
# example_app/views.py
from rest_framework import mixins, viewsets
from rest_framework.permissions import IsAuthenticated
from .models import Organization, Project
from .serializers import OrganizationSerializer, ProjectSerializer
class OrganizationViewSet(
mixins.CreateModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
viewsets.GenericViewSet,
):
serializer_class = OrganizationSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
# Only return organizations the user has permission to view
return Organization.get_queryset_for_user(
self.request.user, "view_organization"
)
def perform_create(self, serializer):
# Organizations are root nodes (no parent)
serializer.save(parent=None)
class ProjectViewSet(
mixins.CreateModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
viewsets.GenericViewSet,
):
serializer_class = ProjectSerializer
permission_classes = [IsAuthenticated]
def get_queryset(self):
# Only return projects the user has permission to view
return Project.get_queryset_for_user(self.request.user, "view_project")
def perform_create(self, serializer):
# Projects live under an organization
org_id = self.request.data.get("organization_id")
org = Organization.objects.get(restricted_object_id=org_id)
serializer.save(parent=org)
from django.urls import include, path
from rest_framework.routers import DefaultRouter
from .views import OrganizationViewSet, ProjectViewSet
router = DefaultRouter()
router.register(r"organizations", OrganizationViewSet, basename="organization")
router.register(r"projects", ProjectViewSet, basename="project")
urlpatterns = [
path("api/", include(router.urls)),
]
from django.contrib import admin
from django.urls import include, path
urlpatterns = [
path("admin/", admin.site.urls),
path("", include("example_app.urls")),
]
Setting up roles and permissions
With the app in place, let's create a management command to seed some example data. Create example_app/management/commands/seed_data.py (don't forget the __init__.py files):
mkdir -p example_app/management/commands
touch example_app/management/__init__.py
touch example_app/management/commands/__init__.py
# example_app/management/commands/seed_data.py
from django.contrib.auth.models import Permission
from django.core.management.base import BaseCommand
from example_app.models import Organization, Project
from topdownrbac.models import Role, RoleBinding, UserSubject
class Command(BaseCommand):
help = "Seed the database with example users, roles, and permissions"
def handle(self, *args, **options):
# Create users
alice, _ = UserSubject.objects.get_or_create(
username="alice",
)
alice.set_password("secret")
alice.save()
bob, _ = UserSubject.objects.get_or_create(
username="bob",
)
bob.set_password("secret")
bob.save()
charlie, _ = UserSubject.objects.get_or_create(
username="charlie",
)
charlie.set_password("secret")
charlie.save()
self.stdout.write(f"Users: {alice.username}, {bob.username}, {charlie.username}")
# Populate user profiles (auto-created by the post_save signal)
alice.profile.bio = "Alice is an org-wide viewer."
alice.profile.save()
bob.profile.bio = "Bob is an org-wide editor."
bob.profile.save()
charlie.profile.bio = "Charlie can only view one project."
charlie.profile.save()
self.stdout.write("User profiles populated.")
# Create the hierarchy
# RestrictedObject subclasses must use instantiation + save(),
# not get_or_create(), because save() delegates to treebeard.
if not Organization.objects.filter(name="Acme Corp").exists():
acme = Organization(name="Acme Corp", parent=None)
acme.save()
else:
acme = Organization.objects.get(name="Acme Corp")
if not Project.objects.filter(name="Website Redesign").exists():
website = Project(name="Website Redesign", parent=acme)
website.save()
else:
website = Project.objects.get(name="Website Redesign")
if not Project.objects.filter(name="API Backend").exists():
api_proj = Project(name="API Backend", parent=acme)
api_proj.save()
else:
api_proj = Project.objects.get(name="API Backend")
self.stdout.write(f"Hierarchy: {acme.name} -> [{website.name}, {api_proj.name}]")
# Create roles
viewer, _ = Role.objects.get_or_create(name="Viewer")
viewer.add_permission(Permission.objects.get(codename="view_organization"))
viewer.add_permission(Permission.objects.get(codename="view_project"))
editor, _ = Role.objects.get_or_create(name="Editor")
editor.add_many_permissions(
list(
Permission.objects.filter(
codename__in=[
"view_organization",
"change_organization",
"view_project",
"change_project",
]
)
)
)
self.stdout.write(f"Roles: {viewer.name}, {editor.name}")
# Give alice Viewer on the whole org tree (propagate=True)
if not RoleBinding.objects.filter(role=viewer, subject=alice, restricted_object=acme).exists():
RoleBinding.objects.create(
role=viewer,
subject=alice,
restricted_object=acme,
propagate=True,
)
# Give bob Editor on the whole org tree
if not RoleBinding.objects.filter(role=editor, subject=bob, restricted_object=acme).exists():
RoleBinding.objects.create(
role=editor,
subject=bob,
restricted_object=acme,
propagate=True,
)
self.stdout.write("RoleBindings created (propagate=True)")
# Give charlie Viewer on only the Website Redesign project (no propagation)
if not RoleBinding.objects.filter(role=viewer, subject=charlie, restricted_object=website).exists():
RoleBinding.objects.create(
role=viewer,
subject=charlie,
restricted_object=website,
propagate=False,
)
self.stdout.write("RoleBinding for charlie created (propagate=False)")
# Verify permissions
assert alice.has_perm("view_organization", acme)
assert alice.has_perm("view_project", website)
assert not alice.has_perm("change_project", website)
assert bob.has_perm("change_project", api_proj)
# charlie can only view Website Redesign, nothing else
assert charlie.has_perm("view_project", website)
assert not charlie.has_perm("view_project", api_proj)
assert not charlie.has_perm("view_organization", acme)
self.stdout.write(self.style.SUCCESS("Seed data created and permissions verified."))
Run it:
Testing it through the API
Start the dev server and use curl (or any HTTP client):
# List organizations alice can see
curl -u alice:secret http://localhost:8000/api/organizations/
# List projects bob can see (both projects -- Editor on the whole org tree)
curl -u bob:secret http://localhost:8000/api/projects/
# List projects charlie can see (only Website Redesign -- Viewer on that single project)
curl -u charlie:secret http://localhost:8000/api/projects/
Alice will see Acme Corp and both projects (view permission propagated from the org), while bob will also be able to modify them thanks to the Editor role. Charlie, however, can only see Website Redesign -- demonstrating that without propagate=True or a broader binding, access is limited to exactly the object the role was bound to.
Any new project created under Acme Corp will automatically inherit the propagated permissions -- no additional role bindings needed.
Extending the user model with a profile
topdownrbac provides UserSubject as a ready-to-use concrete user model (set via AUTH_USER_MODEL). Rather than subclassing it, the recommended way to attach app-specific fields is the profile pattern: a separate model linked to UserSubject with a OneToOneField.
Add a UserProfile model in your app's models.py:
from django.conf import settings
from django.db import models
from topdownrbac.models import RestrictedObject, UserGroupSubject
class Organization(RestrictedObject, UserGroupSubject):
"""A top-level restricted object. Root of the hierarchy. Is also a UserGroupSubject,
so we can assign permissions to users on the org itself."""
name = models.CharField(max_length=200)
def __str__(self):
return self.name
class Project(RestrictedObject):
"""A restricted object that lives under an Organization."""
name = models.CharField(max_length=200)
description = models.TextField(blank=True)
def __str__(self):
return self.name
class UserProfile(models.Model):
"""
App-specific profile attached to the library-provided UserSubject.
This is the recommended way to add custom fields to the user model
without modifying or subclassing UserSubject.
"""
user = models.OneToOneField(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="profile",
)
bio = models.TextField(blank=True)
avatar_url = models.URLField(blank=True)
def __str__(self):
return f"Profile of {self.user.username}"
Then wire up a post_save signal so that a profile is automatically created whenever a new user is saved. Create example_app/signals.py:
from django.conf import settings
from django.db.models.signals import post_save
from django.dispatch import receiver
@receiver(post_save, sender=settings.AUTH_USER_MODEL)
def create_user_profile(sender, instance, created, **kwargs):
"""Automatically create a UserProfile when a new user is created."""
if created:
from example_app.models import UserProfile
UserProfile.objects.create(user=instance)
And register the signal in your app config (example_app/apps.py):
from django.apps import AppConfig
class ExampleAppConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "example_app"
def ready(self):
import example_app.signals # noqa: F401
Run migrations to create the new table:
That's it. You can now read and write profile fields on any user:
from topdownrbac.models import UserSubject
user = UserSubject.objects.get(username="alice")
user.profile.bio = "Alice is an org-wide viewer."
user.profile.save()
print(user.profile.bio) # "Alice is an org-wide viewer."
print(user.profile.avatar_url) # ""
This approach keeps the library's UserSubject untouched while letting each consuming project add whatever fields it needs. The profile is accessible via user.profile (the related_name) and can be efficiently fetched with select_related("profile") in querysets.