Skip to main content

django-universal-rbac

Generic, reusable role-based access control for any Django project. Each app declares the actions it protects (invoice.approve, report.export, ...) in code. Admins create roles from those actions in the project's UI and give users one or more roles. Views check actions, never role names.

  • Action-based: code checks user.has_perm("invoice.approve") or a DRF permission class.
  • Roles are data: created, edited and assigned in the UI through the management API. No deploy is needed to change who may do what.
  • Several roles per user: a user may do what any of their roles allows.
  • One Super Admin role, the only role defined in code, may do every declared action.
  • No privilege escalation: nobody can hand out a permission they don't hold.

The package has no dependency beyond Django (DRF is optional) and works next to django-universal-auth and django-universal-audit without importing them. It is single-tenant: a role applies everywhere in the project.

  • Python 3.10+, Django 5.2 / 6.x, optionally Django REST framework 3.16+
  • Tested on MySQL 8.4; uses only portable ORM features.

Installation

The drf extra adds the DRF permission classes and the management API:

pip install "django-universal-rbac[drf]"
INSTALLED_APPS = [
    ...
    "django.contrib.auth",
    "django.contrib.contenttypes",
    "rest_framework",
    "universal_rbac",
]

AUTHENTICATION_BACKENDS = [
    "django.contrib.auth.backends.ModelBackend",
    "universal_rbac.backends.RBACBackend",  # answers permissions only, never logs anyone in
]

REST_FRAMEWORK = {
    "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"],
}

UNIVERSAL_RBAC = {
    "BOOTSTRAP_SUPER_ADMIN_DOMAINS": ["company.com"],
}
# urls.py
urlpatterns = [
    ...,
    path("rbac/", include("universal_rbac.urls")),
]
python manage.py migrate universal_rbac

Settings

All settings live in the UNIVERSAL_RBAC dict. Roles are not configured here.

Setting Default Meaning
SUPER_ADMIN_ROLE "Super Admin" Name of the role that may do every declared action; None disables it
BOOTSTRAP_SUPER_ADMIN_DOMAINS [] Email domains whose first login becomes the first super admin (see First super admin)
ENABLE_MANAGEMENT_API True False leaves only me/permissions in universal_rbac.urls

Changing SUPER_ADMIN_ROLE later turns the old role into a normal role with no permissions, so its users lose super admin access. Sync prints a warning naming the role and how many users it had; give them the new role with rbac_assign.

Without DRF, set ENABLE_MANAGEMENT_API to False and don't include universal_rbac.urls (every view in it, me/permissions too, is a DRF view). The backend, services, admin and commands work without DRF.

Declaring actions

Each app lists its actions in an rbac.py module, found automatically like admin.py:

# billing/rbac.py
PERMISSIONS = {
    "invoice.view": "View invoices",
    "invoice.create": "Create invoices",
    "invoice.approve": "Approve invoices",
    "invoice.export": "Export invoices",
}
  • Keys look like resource.action: lowercase letters, digits and _, at most 100 characters. The part before the dot is the resource, used by the UI to group checkboxes.
  • The package declares its own keys: rbac.view (see roles and permissions) and rbac.manage (create, edit, delete and assign roles).
  • Actions can't be created in the UI, because only code checks them.

Sync copies the declared keys and the Super Admin role into the database. It runs after every migrate, or with python manage.py rbac_sync. It is safe to run repeatedly and from several servers at once, and respects database routers.

Keys removed from code grant nothing from then on, but stay on their roles until rbac_sync --prune deletes them and lists the roles that lose each one (declaring a key again restores it before a prune). To rename a key, add the new key, move the roles to it, then prune the old one.

Protecting views

from universal_rbac.permissions import ActionPermissions, HasPermission


class InvoiceApproveView(APIView):
    permission_classes = [HasPermission.of("invoice.approve")]


class InvoiceViewSet(viewsets.ModelViewSet):
    permission_classes = [ActionPermissions]
    required_permissions = {
        "list": ["invoice.view"],
        "retrieve": ["invoice.view"],
        "create": ["invoice.create"],
        "approve": ["invoice.approve"],  # an @action
    }
    # update, partial_update and destroy are not listed, so they are denied for everyone.
  • HasAnyPermission.of(...) needs one of the keys, HasAllPermissions.of(...) all of them (HasPermission is the same as HasAllPermissions). Combine classes with & and |.
  • ActionPermissions reads the ViewSet action, or for a plain APIView the lowercase HTTP method ("get", "post", ...). A missing entry means denied; an empty list ("list": []) means any logged-in user.
  • Anonymous users get 401 (or 403 when the authentication class sends no challenge); logged-in users without the permission get 403.
  • Object-level rules (only your branch, only your own invoices) belong in the project's get_queryset(). Let super admins through with services.is_super_admin(user).

Outside DRF, use Django's own API, in sync and async code:

user.has_perm("invoice.approve")
await user.ahas_perm("invoice.approve")
{% if perms.invoice.approve %}<button>Approve</button>{% endif %}

Check full keys: {% if perms.invoice %} (any key of a resource) asks Django's module check, which this backend doesn't answer.

How a check works

user.has_perm("invoice.approve")
  inactive user          → no
  Django superuser       → yes (Django checks this before any backend)
  load the user's roles  → one query, cached on the user object for this request
  Super Admin role       → yes, if the key is declared
  otherwise              → yes if any of the user's roles holds the key
  • One query per request for the roles, however many checks; the backend and the services share it. Django's ModelBackend, listed before it, adds its own two queries (direct and group permissions) the first time it is asked. Keep ModelBackend first: Django's test client force_login() and login() without a backend use the first entry to load the user, and RBACBackend never loads users.
  • Changes apply on the next request; nothing is cached across requests or put in tokens. With a database router that sends reads to replicas, a change applies once the replica has it.
  • Global permissions only. Calls for an object (has_perm(key, obj)) get "no" from this backend, so a role never overrides another backend's per-object answer.
  • Long-lived user objects (shell, Celery tasks) keep their cached permissions. The services clear the cache on the user object they change; elsewhere reload the user or call services.clear_cache(user).

Super Admin

One role, named by SUPER_ADMIN_ROLE, may do every declared action, including actions added later. It holds no permission list.

  • Sync creates it; it is the only role defined in code. Nobody can edit or delete it through the API or the admin, and no other role can get its powers.
  • Only super admins (and Django superusers) can assign or remove it, because it counts as holding every permission (see Security rules).
  • It covers declared keys only. Django's own model permissions (auth.change_user, admin powers) stay with Django's is_superuser, which the package never sets.
  • Inactive holders get nothing.

First super admin

Until the Super Admin role has been given to anyone, the first user who logs in with an email in BOOTSTRAP_SUPER_ADMIN_DOMAINS gets it:

First login Result
asha@company.com, no super admin yet Becomes super admin
stranger@gmail.com Normal user (domain not allowed)
ravi@company.com, after the role was given to someone Normal user (bootstrap is done)
  • It listens to Django's user_logged_in signal, so it works for password and SSO logins (django-universal-auth sends it for both).
  • The domain must match exactly, case-insensitively: company.com doesn't match sub.company.com or company.com.evil.io. List subdomains separately.
  • It runs once. As soon as the role has been given to anyone (by bootstrap, rbac_assign, the API or the admin), it stops for good. Removing or deactivating every super admin later, or renaming the role, doesn't start it again; use rbac_assign to recover.
  • Two simultaneous first logins give one super admin. After bootstrap, logins cost one indexed query and take no lock.
  • Django superusers (createsuperuser) don't stop it: they are the platform owners, while the Super Admin role is for day-to-day admins.
  • Only verified emails should count. If the project lets users register themselves, it must verify the email before the first login. Pair it with django-universal-auth's ALLOWED_EMAIL_DOMAINS for SSO.
  • Log in yourself right after the first deploy. The empty default turns bootstrap off.

Recovery, or setup without bootstrap, from the shell:

python manage.py rbac_assign meera "Super Admin"

rbac_assign <username> <role> is trusted (no escalation check), so access can always be restored without SQL.

Management API

The package ships no screens. The project's frontend builds them on this API (paths relative to where universal_rbac.urls is included):

Method and path Requires Does
GET me/permissions logged in {"is_super_admin": bool, "permissions": [...]}, for showing and hiding UI
GET permissions rbac.view Every declared key with description and resource
GET roles rbac.view Roles with permissions, user_count and is_super_admin
POST roles rbac.manage Create {"name", "description", "permissions": [keys]}
GET roles/<id> rbac.view One role
PATCH roles/<id> rbac.manage Rename, change the description, or replace the permission list
DELETE roles/<id> rbac.manage Delete a role nobody has
GET roles/<id>/users rbac.view Who has the role: [{"id", "username"}], paginated (?page=, ?page_size= up to 200)
GET users/<user_id>/roles rbac.view A user's roles, with assigned_by and assigned_at
POST users/<user_id>/roles rbac.manage Assign {"role": <id>}: 201, or 200 if the user already had it
DELETE users/<user_id>/roles/<role_id> rbac.manage Remove the role: 204

user_id accepts any primary-key type. There is no user search: the project's own user API feeds the pickers. me/permissions is for display only; the server checks every request.

Example: a super admin sets up two roles.

POST /rbac/roles  {"name": "Sales", "permissions": ["invoice.view", "invoice.create"]}
POST /rbac/roles  {"name": "Accountant", "permissions": ["invoice.view", "invoice.approve", "invoice.export"]}
POST /rbac/users/12/roles  {"role": 2}

Errors use DRF's format plus a stable code:

Status code When
400 field errors Unknown permission key, duplicate role name (ignoring case, and including the Super Admin role's name)
401 not_authenticated Not logged in
403 permission_denied Missing rbac.view or rbac.manage
403 privilege_escalation The role has permissions you don't hold
403 super_admin_role Editing or deleting the Super Admin role
404 not_found Unknown role or user, or the user doesn't have the role
409 role_in_use Deleting a role users still have

Security rules

  1. No privilege escalation. You may create, edit, delete, assign or remove a role only if you hold every permission it has, before and after the change. The Super Admin role counts as holding every permission. Super admins and Django superusers are exempt. This stops both granting yourself power and stripping roles from more powerful users.
  2. Locked checks. Each write is one transaction, and the role row is locked (SELECT ... FOR UPDATE) during the check, so a concurrent edit can't slip a permission in.
  3. Keys come only from code. Unknown keys are rejected.
  4. Default deny. No role, an inactive user, or an unmapped action means no access.
  5. No overlap with Django's permissions. A key equal to a Django model permission is a startup error (E006), so a role can never grant Django admin powers by accident.
  6. The admin follows the same rules (see Admin).

Direct ORM or SQL writes to the RBAC tables skip these rules and the signals. Always use the services.

Services and signals

Plain Python, without DRF, for scripts, tasks and your own views:

from universal_rbac import services

services.get_user_permissions(user)  # frozenset of keys (every declared key for super admins)
services.is_super_admin(user)  # Super Admin role or Django superuser
services.can_manage_role(actor, role)
services.check_role_name("Sales")  # raises DuplicateRoleName if taken (ignoring case)

role = services.create_role("Sales", permissions=["invoice.view"], actor=request.user)
services.update_role(
    role, name="Sales team", permissions=["invoice.view", "invoice.create"], actor=request.user
)
services.assign_role(user, role, actor=request.user)  # True if newly assigned
services.revoke_role(user, role, actor=request.user)  # True if the user had it
services.delete_role(role, actor=request.user)

services.sync_permissions(prune=False)

actor is who makes the change: it is checked against the escalation rule, stored as assigned_by, and sent with the signals. actor=None means trusted system code. Refused changes raise subclasses of universal_rbac.exceptions.RBACError (each with a code).

The functions above (services.__all__), the models, the DRF classes in universal_rbac.permissions, the signals, the exceptions and the settings are the public API. Other names in the package are internal and may change.

The signals in universal_rbac.signals are sent after the transaction commits; a failing receiver is logged and never breaks the change:

Signal Arguments
role_created role, actor
role_updated role, actor, added, removed (sets of keys)
role_deleted role_id, name, actor
role_assigned, role_revoked user, role, actor

Using with django-universal-audit

Track the RBAC models to record who changed which role and who gave whom a role:

UNIVERSAL_AUDIT = {
    "TRACKED_MODELS": {
        "universal_rbac.Role": {},
        "universal_rbac.RolePermission": {},
        "universal_rbac.UserRole": {},
    },
}

Or connect the signals to record_event() for your own event types.

Admin

  • Permissions are read-only.
  • Roles have a permission picker, and users' roles are added and removed in their own list.
  • Viewing needs rbac.view or rbac.manage, changing needs rbac.manage (not Django's model permissions). Every save and delete, including the bulk-delete action, goes through the services, so the escalation rule applies. A bulk delete is all or nothing.
  • Roles you can't manage, and the Super Admin role, are read-only.

System checks

Id Problem
universal_rbac.E001 An rbac.py is not a dict, or has an invalid key or description
universal_rbac.E002 Two apps declare the same key
universal_rbac.E003 A UI-created role already uses the SUPER_ADMIN_ROLE name (sync won't turn it into the Super Admin role)
universal_rbac.E004 RBACBackend is missing from AUTHENTICATION_BACKENDS
universal_rbac.E005 ENABLE_MANAGEMENT_API is on, but DRF is not installed
universal_rbac.E006 A key equals a Django model permission (auth.change_user)
universal_rbac.E007 A routed DRF view requires a key that no app declares (typo)
universal_rbac.E008 A UNIVERSAL_RBAC setting has an invalid value
universal_rbac.W001 Unknown UNIVERSAL_RBAC setting
universal_rbac.W002 A routed view using ActionPermissions has an action with no entry, so it is denied
universal_rbac.W003 DRF's default permission is AllowAny, so new views are public
universal_rbac.W004 A key removed from code is still held by roles (run rbac_sync --prune)
universal_rbac.W005 BOOTSTRAP_SUPER_ADMIN_DOMAINS lists a public email domain (gmail.com, ...)

E003 and W004 read the database, so they run with migrate and check --database default. E007 and W002 read the URLconf and cover views routed through DRF.

Rolling out in an existing project

Deny by default means a view protected before users have roles locks them out. Roll out in this order:

  1. Install the package and add every app's rbac.py. Nothing is enforced yet.
  2. Create roles that match today's access and assign them to all users (for many users, a one-off script using the services). Check with me/permissions.
  3. Protect one app at a time. W002 and E007 catch gaps and typos.
  4. Let the frontend read me/permissions after login to show and hide buttons.

Development

Tests need a MySQL server (the database name is universal_rbac):

uv venv && uv pip install -e ".[drf]" --group dev
UA_DB_HOST=127.0.0.1 UA_DB_PORT=3306 UA_DB_USER=root UA_DB_PASSWORD=... \
    .venv/bin/pytest -W error::DeprecationWarning
# A custom user model (UUID key, email login, no is_active column) on SQLite:
.venv/bin/pytest --ds=tests_custom_user.settings tests_custom_user
.venv/bin/ruff check . && .venv/bin/ruff format --check .

Metadata

Release files for django-universal-rbac 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for django-universal-rbac 0.1.0
File Size Uploaded
django_universal_rbac-0.1.0.tar.gz 41.5 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for django-universal-rbac 0.1.0
File Interpreter ABI Platform
django_universal_rbac-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 75.1 kB

Release files / django_universal_rbac-0.1.0.tar.gz

Download URL django_universal_rbac-0.1.0.tar.gz
Size 41.5 kB
Tags Source
SHA-256 checksum
How to use checksums
861a31356415ce54e805eec62c639542752caeb17904bea76ff5efe544828de5
BLAKE2b-256 checksum
How to use checksums
191efebbaa7970ae00cf11886b05946082054fe3aa5bc511bac81f6ab6fed8e6
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.3

Release files / django_universal_rbac-0.1.0-py3-none-any.whl

Download URL django_universal_rbac-0.1.0-py3-none-any.whl
Size 33.6 kB
Tags Python 3
SHA-256 checksum
How to use checksums
ebe9a014b3aa59b7865896c46148aa63e9273d44b8c45b4df535f6d30845cbaa
BLAKE2b-256 checksum
How to use checksums
85edf23b199d645e6ce243042128acd81fcd8dfc39216136c9d1abbd1f3911b1
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
No
Uploaded via twine/7.0.0 CPython/3.14.3

Release history Release notifications | RSS feed

This release

0.1.0 This release

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page