Skip to content

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...)

uv init
uv venv
source .venv/bin/activate
uv add django-topdownrbac
uv add djangorestframework

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:

uv run manage.py makemigrations example_app
uv run manage.py migrate

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:

uv run manage.py seed_data

Testing it through the API

Start the dev server and use curl (or any HTTP client):

uv run manage.py runserver
# 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:

uv run manage.py makemigrations example_app
uv run manage.py migrate

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.