Metadata-Version: 2.5
Name: django-universal-rbac
Version: 0.1.0
Summary: Generic, reusable role-based access control with action-based permissions for Django and DRF.
Author: OpsTree
License-Expression: MIT
License-File: LICENSE
Keywords: authorization,django,djangorestframework,permissions,rbac,roles
Classifier: Development Status :: 3 - Alpha
Classifier: Environment :: Web Environment
Classifier: Framework :: Django
Classifier: Framework :: Django :: 5.2
Classifier: Framework :: Django :: 6.0
Classifier: Framework :: Django :: 6.1
Classifier: Intended Audience :: Developers
Classifier: Operating System :: OS Independent
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.10
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Security
Requires-Python: >=3.10
Requires-Dist: django<7,>=5.2
Provides-Extra: drf
Requires-Dist: djangorestframework<4,>=3.16; extra == 'drf'
Description-Content-Type: text/markdown

# 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](https://pypi.org/project/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:

```bash
pip install "django-universal-rbac[drf]"
```

```python
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"],
}
```

```python
# urls.py
urlpatterns = [
    ...,
    path("rbac/", include("universal_rbac.urls")),
]
```

```bash
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](#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`:

```python
# 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

```python
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:

```python
user.has_perm("invoice.approve")
await user.ahas_perm("invoice.approve")
```

```django
{% 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

```text
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](#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:

```bash
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.

```http
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](#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:

```python
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:

```python
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`):

```bash
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 .
```
