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 theresource, used by the UI to group checkboxes. - The package declares its own keys:
rbac.view(see roles and permissions) andrbac.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 (HasPermissionis the same asHasAllPermissions). Combine classes with&and|.ActionPermissionsreads the ViewSet action, or for a plainAPIViewthe lowercase HTTP method ("get","post", ...). A missing entry means denied; an empty list ("list": []) means any logged-in user.- Anonymous users get
401(or403when the authentication class sends no challenge); logged-in users without the permission get403. - Object-level rules (only your branch, only your own invoices) belong in the project's
get_queryset(). Let super admins through withservices.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. KeepModelBackendfirst: Django's test clientforce_login()andlogin()without a backend use the first entry to load the user, andRBACBackendnever 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'sis_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_insignal, so it works for password and SSO logins (django-universal-auth sends it for both). - The domain must match exactly, case-insensitively:
company.comdoesn't matchsub.company.comorcompany.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; userbac_assignto 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_DOMAINSfor 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
- 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.
- 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. - Keys come only from code. Unknown keys are rejected.
- Default deny. No role, an inactive user, or an unmapped action means no access.
- 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. - 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.vieworrbac.manage, changing needsrbac.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:
- Install the package and add every app's
rbac.py. Nothing is enforced yet. - 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. - Protect one app at a time.
W002andE007catch gaps and typos. - Let the frontend read
me/permissionsafter 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)
| File | Size | Uploaded | |
|---|---|---|---|
| django_universal_rbac-0.1.0.tar.gz | 41.5 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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
|