django-universal-rbac 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. django_universal_rbac-0.1.0/.gitignore +11 -0
  2. django_universal_rbac-0.1.0/CHANGELOG.md +33 -0
  3. django_universal_rbac-0.1.0/LICENSE +21 -0
  4. django_universal_rbac-0.1.0/PKG-INFO +428 -0
  5. django_universal_rbac-0.1.0/README.md +399 -0
  6. django_universal_rbac-0.1.0/pyproject.toml +80 -0
  7. django_universal_rbac-0.1.0/tests/__init__.py +0 -0
  8. django_universal_rbac-0.1.0/tests/conftest.py +78 -0
  9. django_universal_rbac-0.1.0/tests/settings.py +72 -0
  10. django_universal_rbac-0.1.0/tests/test_admin.py +172 -0
  11. django_universal_rbac-0.1.0/tests/test_api.py +200 -0
  12. django_universal_rbac-0.1.0/tests/test_backend.py +129 -0
  13. django_universal_rbac-0.1.0/tests/test_bootstrap.py +108 -0
  14. django_universal_rbac-0.1.0/tests/test_checks.py +118 -0
  15. django_universal_rbac-0.1.0/tests/test_package.py +72 -0
  16. django_universal_rbac-0.1.0/tests/test_permissions.py +75 -0
  17. django_universal_rbac-0.1.0/tests/test_services.py +274 -0
  18. django_universal_rbac-0.1.0/tests/test_sync.py +170 -0
  19. django_universal_rbac-0.1.0/tests/testapp/__init__.py +0 -0
  20. django_universal_rbac-0.1.0/tests/testapp/rbac.py +7 -0
  21. django_universal_rbac-0.1.0/tests/testapp/views.py +78 -0
  22. django_universal_rbac-0.1.0/tests/urls.py +19 -0
  23. django_universal_rbac-0.1.0/tests/urls_bad.py +33 -0
  24. django_universal_rbac-0.1.0/tests_custom_user/__init__.py +0 -0
  25. django_universal_rbac-0.1.0/tests_custom_user/accounts/__init__.py +0 -0
  26. django_universal_rbac-0.1.0/tests_custom_user/accounts/migrations/0001_initial.py +31 -0
  27. django_universal_rbac-0.1.0/tests_custom_user/accounts/migrations/__init__.py +0 -0
  28. django_universal_rbac-0.1.0/tests_custom_user/accounts/models.py +25 -0
  29. django_universal_rbac-0.1.0/tests_custom_user/settings.py +37 -0
  30. django_universal_rbac-0.1.0/tests_custom_user/test_custom_user.py +76 -0
  31. django_universal_rbac-0.1.0/tests_custom_user/urls.py +3 -0
  32. django_universal_rbac-0.1.0/universal_rbac/__init__.py +6 -0
  33. django_universal_rbac-0.1.0/universal_rbac/admin.py +204 -0
  34. django_universal_rbac-0.1.0/universal_rbac/apps.py +57 -0
  35. django_universal_rbac-0.1.0/universal_rbac/backends.py +34 -0
  36. django_universal_rbac-0.1.0/universal_rbac/checks.py +247 -0
  37. django_universal_rbac-0.1.0/universal_rbac/exceptions.py +43 -0
  38. django_universal_rbac-0.1.0/universal_rbac/management/__init__.py +0 -0
  39. django_universal_rbac-0.1.0/universal_rbac/management/commands/__init__.py +0 -0
  40. django_universal_rbac-0.1.0/universal_rbac/management/commands/rbac_assign.py +33 -0
  41. django_universal_rbac-0.1.0/universal_rbac/management/commands/rbac_sync.py +39 -0
  42. django_universal_rbac-0.1.0/universal_rbac/migrations/0001_initial.py +84 -0
  43. django_universal_rbac-0.1.0/universal_rbac/migrations/__init__.py +0 -0
  44. django_universal_rbac-0.1.0/universal_rbac/models.py +104 -0
  45. django_universal_rbac-0.1.0/universal_rbac/permissions.py +63 -0
  46. django_universal_rbac-0.1.0/universal_rbac/rbac.py +4 -0
  47. django_universal_rbac-0.1.0/universal_rbac/registry.py +72 -0
  48. django_universal_rbac-0.1.0/universal_rbac/serializers.py +58 -0
  49. django_universal_rbac-0.1.0/universal_rbac/services.py +493 -0
  50. django_universal_rbac-0.1.0/universal_rbac/signals.py +13 -0
  51. django_universal_rbac-0.1.0/universal_rbac/urls.py +24 -0
  52. django_universal_rbac-0.1.0/universal_rbac/views.py +213 -0
@@ -0,0 +1,11 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ .venv/
4
+ dist/
5
+ build/
6
+ *.egg-info/
7
+ .coverage
8
+ htmlcov/
9
+ .pytest_cache/
10
+ .ruff_cache/
11
+ docs/
@@ -0,0 +1,33 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. The format follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
5
+ [Semantic Versioning](https://semver.org/). Until 1.0.0, minor releases may contain breaking changes.
6
+ Each such change is listed under **Changed** or **Removed**.
7
+
8
+ ## [Unreleased]
9
+
10
+ ## [0.1.0]
11
+
12
+ ### Added
13
+ - Action keys declared per app in `rbac.py` (`PERMISSIONS = {"invoice.approve": "..."}`) and
14
+ synced to the database after `migrate` or with `rbac_sync` (`--prune` removes keys no longer
15
+ declared and lists the roles that lose them). Keys removed from code grant nothing.
16
+ - Models `Permission`, `Role`, `RolePermission` and `UserRole`: roles are created in the UI, a
17
+ user may have several roles, and role names are unique ignoring case.
18
+ - `RBACBackend` for `user.has_perm()` / `ahas_perm()` and `{{ perms }}`: one query per request,
19
+ global permissions only.
20
+ - The Super Admin role (`SUPER_ADMIN_ROLE`), the only role defined in code, allowed every declared
21
+ key. The first login from `BOOTSTRAP_SUPER_ADMIN_DOMAINS` gets it, once: bootstrap stops for
22
+ good when the role is first given to anyone (`Role.first_assigned_at`). Sync warns when a
23
+ changed `SUPER_ADMIN_ROLE` demotes the old role.
24
+ - DRF permission classes `HasPermission`, `HasAllPermissions`, `HasAnyPermission` and
25
+ `ActionPermissions` (unmapped actions are denied).
26
+ - Management API for the project's UI: `me/permissions`, permissions, roles, role members and
27
+ user roles, with stable error codes. `ENABLE_MANAGEMENT_API` turns it off.
28
+ - Services for every write, with the privilege-escalation rule and row locking, and the signals
29
+ `role_created`, `role_updated`, `role_deleted`, `role_assigned` and `role_revoked`, sent after
30
+ commit.
31
+ - Django admin following the same rules (`rbac.view`, `rbac.manage`).
32
+ - `rbac_assign` command to restore access from the shell.
33
+ - System checks `universal_rbac.E001`–`E008` and `W001`–`W005`.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 OpsTree
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,428 @@
1
+ Metadata-Version: 2.5
2
+ Name: django-universal-rbac
3
+ Version: 0.1.0
4
+ Summary: Generic, reusable role-based access control with action-based permissions for Django and DRF.
5
+ Author: OpsTree
6
+ License-Expression: MIT
7
+ License-File: LICENSE
8
+ Keywords: authorization,django,djangorestframework,permissions,rbac,roles
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Environment :: Web Environment
11
+ Classifier: Framework :: Django
12
+ Classifier: Framework :: Django :: 5.2
13
+ Classifier: Framework :: Django :: 6.0
14
+ Classifier: Framework :: Django :: 6.1
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: Operating System :: OS Independent
17
+ Classifier: Programming Language :: Python :: 3
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Programming Language :: Python :: 3.14
23
+ Classifier: Topic :: Security
24
+ Requires-Python: >=3.10
25
+ Requires-Dist: django<7,>=5.2
26
+ Provides-Extra: drf
27
+ Requires-Dist: djangorestframework<4,>=3.16; extra == 'drf'
28
+ Description-Content-Type: text/markdown
29
+
30
+ # django-universal-rbac
31
+
32
+ Generic, reusable role-based access control for any Django project. Each app declares the actions
33
+ it protects (`invoice.approve`, `report.export`, ...) in code. Admins create roles from those
34
+ actions in the project's UI and give users one or more roles. Views check actions, never role
35
+ names.
36
+
37
+ - **Action-based:** code checks `user.has_perm("invoice.approve")` or a DRF permission class.
38
+ - **Roles are data:** created, edited and assigned in the UI through the management API. No
39
+ deploy is needed to change who may do what.
40
+ - **Several roles per user:** a user may do what any of their roles allows.
41
+ - **One Super Admin role**, the only role defined in code, may do every declared action.
42
+ - **No privilege escalation:** nobody can hand out a permission they don't hold.
43
+
44
+ The package has no dependency beyond Django (DRF is optional) and works next to
45
+ [django-universal-auth](https://pypi.org/project/django-universal-auth/) and
46
+ django-universal-audit without importing them. It is single-tenant:
47
+ a role applies everywhere in the project.
48
+
49
+ - Python 3.10+, Django 5.2 / 6.x, optionally Django REST framework 3.16+
50
+ - Tested on MySQL 8.4; uses only portable ORM features.
51
+
52
+ ## Installation
53
+
54
+ The `drf` extra adds the DRF permission classes and the management API:
55
+
56
+ ```bash
57
+ pip install "django-universal-rbac[drf]"
58
+ ```
59
+
60
+ ```python
61
+ INSTALLED_APPS = [
62
+ ...
63
+ "django.contrib.auth",
64
+ "django.contrib.contenttypes",
65
+ "rest_framework",
66
+ "universal_rbac",
67
+ ]
68
+
69
+ AUTHENTICATION_BACKENDS = [
70
+ "django.contrib.auth.backends.ModelBackend",
71
+ "universal_rbac.backends.RBACBackend", # answers permissions only, never logs anyone in
72
+ ]
73
+
74
+ REST_FRAMEWORK = {
75
+ "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"],
76
+ }
77
+
78
+ UNIVERSAL_RBAC = {
79
+ "BOOTSTRAP_SUPER_ADMIN_DOMAINS": ["company.com"],
80
+ }
81
+ ```
82
+
83
+ ```python
84
+ # urls.py
85
+ urlpatterns = [
86
+ ...,
87
+ path("rbac/", include("universal_rbac.urls")),
88
+ ]
89
+ ```
90
+
91
+ ```bash
92
+ python manage.py migrate universal_rbac
93
+ ```
94
+
95
+ ## Settings
96
+
97
+ All settings live in the `UNIVERSAL_RBAC` dict. Roles are not configured here.
98
+
99
+ | Setting | Default | Meaning |
100
+ | --- | --- | --- |
101
+ | `SUPER_ADMIN_ROLE` | `"Super Admin"` | Name of the role that may do every declared action; `None` disables it |
102
+ | `BOOTSTRAP_SUPER_ADMIN_DOMAINS` | `[]` | Email domains whose first login becomes the first super admin (see [First super admin](#first-super-admin)) |
103
+ | `ENABLE_MANAGEMENT_API` | `True` | `False` leaves only `me/permissions` in `universal_rbac.urls` |
104
+
105
+ Changing `SUPER_ADMIN_ROLE` later turns the old role into a normal role with no permissions, so its
106
+ users lose super admin access. Sync prints a warning naming the role and how many users it had;
107
+ give them the new role with `rbac_assign`.
108
+
109
+ Without DRF, set `ENABLE_MANAGEMENT_API` to `False` and don't include `universal_rbac.urls` (every
110
+ view in it, `me/permissions` too, is a DRF view). The backend, services, admin and commands work
111
+ without DRF.
112
+
113
+ ## Declaring actions
114
+
115
+ Each app lists its actions in an `rbac.py` module, found automatically like `admin.py`:
116
+
117
+ ```python
118
+ # billing/rbac.py
119
+ PERMISSIONS = {
120
+ "invoice.view": "View invoices",
121
+ "invoice.create": "Create invoices",
122
+ "invoice.approve": "Approve invoices",
123
+ "invoice.export": "Export invoices",
124
+ }
125
+ ```
126
+
127
+ - Keys look like `resource.action`: lowercase letters, digits and `_`, at most 100 characters.
128
+ The part before the dot is the `resource`, used by the UI to group checkboxes.
129
+ - The package declares its own keys: `rbac.view` (see roles and permissions) and `rbac.manage`
130
+ (create, edit, delete and assign roles).
131
+ - Actions can't be created in the UI, because only code checks them.
132
+
133
+ **Sync** copies the declared keys and the Super Admin role into the database. It runs after every
134
+ `migrate`, or with `python manage.py rbac_sync`. It is safe to run repeatedly and from several
135
+ servers at once, and respects database routers.
136
+
137
+ Keys removed from code grant nothing from then on, but stay on their roles until
138
+ `rbac_sync --prune` deletes them and lists the roles that lose each one (declaring a key again
139
+ restores it before a prune). To rename a key, add the new key, move the roles to it, then prune
140
+ the old one.
141
+
142
+ ## Protecting views
143
+
144
+ ```python
145
+ from universal_rbac.permissions import ActionPermissions, HasPermission
146
+
147
+
148
+ class InvoiceApproveView(APIView):
149
+ permission_classes = [HasPermission.of("invoice.approve")]
150
+
151
+
152
+ class InvoiceViewSet(viewsets.ModelViewSet):
153
+ permission_classes = [ActionPermissions]
154
+ required_permissions = {
155
+ "list": ["invoice.view"],
156
+ "retrieve": ["invoice.view"],
157
+ "create": ["invoice.create"],
158
+ "approve": ["invoice.approve"], # an @action
159
+ }
160
+ # update, partial_update and destroy are not listed, so they are denied for everyone.
161
+ ```
162
+
163
+ - `HasAnyPermission.of(...)` needs one of the keys, `HasAllPermissions.of(...)` all of them
164
+ (`HasPermission` is the same as `HasAllPermissions`). Combine classes with `&` and `|`.
165
+ - `ActionPermissions` reads the ViewSet action, or for a plain `APIView` the lowercase HTTP
166
+ method (`"get"`, `"post"`, ...). A missing entry means denied; an empty list (`"list": []`)
167
+ means any logged-in user.
168
+ - Anonymous users get `401` (or `403` when the authentication class sends no challenge);
169
+ logged-in users without the permission get `403`.
170
+ - Object-level rules (only your branch, only your own invoices) belong in the project's
171
+ `get_queryset()`. Let super admins through with `services.is_super_admin(user)`.
172
+
173
+ Outside DRF, use Django's own API, in sync and async code:
174
+
175
+ ```python
176
+ user.has_perm("invoice.approve")
177
+ await user.ahas_perm("invoice.approve")
178
+ ```
179
+
180
+ ```django
181
+ {% if perms.invoice.approve %}<button>Approve</button>{% endif %}
182
+ ```
183
+
184
+ Check full keys: `{% if perms.invoice %}` (any key of a resource) asks Django's module check,
185
+ which this backend doesn't answer.
186
+
187
+ ### How a check works
188
+
189
+ ```text
190
+ user.has_perm("invoice.approve")
191
+ inactive user → no
192
+ Django superuser → yes (Django checks this before any backend)
193
+ load the user's roles → one query, cached on the user object for this request
194
+ Super Admin role → yes, if the key is declared
195
+ otherwise → yes if any of the user's roles holds the key
196
+ ```
197
+
198
+ - **One query per request** for the roles, however many checks; the backend and the services
199
+ share it. Django's `ModelBackend`, listed before it, adds its own two queries (direct and
200
+ group permissions) the first time it is asked. Keep `ModelBackend` first: Django's test
201
+ client `force_login()` and `login()` without a backend use the first entry to load the user,
202
+ and `RBACBackend` never loads users.
203
+ - Changes apply on the next request; nothing is cached across requests or put in tokens. With a
204
+ database router that sends reads to replicas, a change applies once the replica has it.
205
+ - **Global permissions only.** Calls for an object (`has_perm(key, obj)`) get "no" from this
206
+ backend, so a role never overrides another backend's per-object answer.
207
+ - **Long-lived user objects** (shell, Celery tasks) keep their cached permissions. The services
208
+ clear the cache on the user object they change; elsewhere reload the user or call
209
+ `services.clear_cache(user)`.
210
+
211
+ ## Super Admin
212
+
213
+ One role, named by `SUPER_ADMIN_ROLE`, may do every declared action, including actions added
214
+ later. It holds no permission list.
215
+
216
+ - Sync creates it; it is the only role defined in code. Nobody can edit or delete it through the
217
+ API or the admin, and no other role can get its powers.
218
+ - Only super admins (and Django superusers) can assign or remove it, because it counts as holding
219
+ every permission (see [Security rules](#security-rules)).
220
+ - It covers declared keys only. Django's own model permissions (`auth.change_user`, admin
221
+ powers) stay with Django's `is_superuser`, which the package never sets.
222
+ - Inactive holders get nothing.
223
+
224
+ ### First super admin
225
+
226
+ Until the Super Admin role has been given to anyone, the first user who logs in with an email in
227
+ `BOOTSTRAP_SUPER_ADMIN_DOMAINS` gets it:
228
+
229
+ | First login | Result |
230
+ | --- | --- |
231
+ | `asha@company.com`, no super admin yet | Becomes super admin |
232
+ | `stranger@gmail.com` | Normal user (domain not allowed) |
233
+ | `ravi@company.com`, after the role was given to someone | Normal user (bootstrap is done) |
234
+
235
+ - It listens to Django's `user_logged_in` signal, so it works for password and SSO logins
236
+ (django-universal-auth sends it for both).
237
+ - The domain must match exactly, case-insensitively: `company.com` doesn't match
238
+ `sub.company.com` or `company.com.evil.io`. List subdomains separately.
239
+ - **It runs once.** As soon as the role has been given to anyone (by bootstrap, `rbac_assign`,
240
+ the API or the admin), it stops for good. Removing or deactivating every super admin later, or
241
+ renaming the role, doesn't start it again; use `rbac_assign` to recover.
242
+ - Two simultaneous first logins give one super admin. After bootstrap, logins cost one indexed
243
+ query and take no lock.
244
+ - Django superusers (`createsuperuser`) don't stop it: they are the platform owners, while the
245
+ Super Admin role is for day-to-day admins.
246
+ - Only verified emails should count. If the project lets users register themselves, it must verify
247
+ the email before the first login. Pair it with django-universal-auth's `ALLOWED_EMAIL_DOMAINS`
248
+ for SSO.
249
+ - Log in yourself right after the first deploy. The empty default turns bootstrap off.
250
+
251
+ Recovery, or setup without bootstrap, from the shell:
252
+
253
+ ```bash
254
+ python manage.py rbac_assign meera "Super Admin"
255
+ ```
256
+
257
+ `rbac_assign <username> <role>` is trusted (no escalation check), so access can always be restored
258
+ without SQL.
259
+
260
+ ## Management API
261
+
262
+ The package ships no screens. The project's frontend builds them on this API (paths relative to
263
+ where `universal_rbac.urls` is included):
264
+
265
+ | Method and path | Requires | Does |
266
+ | --- | --- | --- |
267
+ | `GET me/permissions` | logged in | `{"is_super_admin": bool, "permissions": [...]}`, for showing and hiding UI |
268
+ | `GET permissions` | `rbac.view` | Every declared key with `description` and `resource` |
269
+ | `GET roles` | `rbac.view` | Roles with `permissions`, `user_count` and `is_super_admin` |
270
+ | `POST roles` | `rbac.manage` | Create `{"name", "description", "permissions": [keys]}` |
271
+ | `GET roles/<id>` | `rbac.view` | One role |
272
+ | `PATCH roles/<id>` | `rbac.manage` | Rename, change the description, or replace the permission list |
273
+ | `DELETE roles/<id>` | `rbac.manage` | Delete a role nobody has |
274
+ | `GET roles/<id>/users` | `rbac.view` | Who has the role: `[{"id", "username"}]`, paginated (`?page=`, `?page_size=` up to 200) |
275
+ | `GET users/<user_id>/roles` | `rbac.view` | A user's roles, with `assigned_by` and `assigned_at` |
276
+ | `POST users/<user_id>/roles` | `rbac.manage` | Assign `{"role": <id>}`: `201`, or `200` if the user already had it |
277
+ | `DELETE users/<user_id>/roles/<role_id>` | `rbac.manage` | Remove the role: `204` |
278
+
279
+ `user_id` accepts any primary-key type. There is no user search: the project's own user API feeds
280
+ the pickers. `me/permissions` is for display only; the server checks every request.
281
+
282
+ Example: a super admin sets up two roles.
283
+
284
+ ```http
285
+ POST /rbac/roles {"name": "Sales", "permissions": ["invoice.view", "invoice.create"]}
286
+ POST /rbac/roles {"name": "Accountant", "permissions": ["invoice.view", "invoice.approve", "invoice.export"]}
287
+ POST /rbac/users/12/roles {"role": 2}
288
+ ```
289
+
290
+ Errors use DRF's format plus a stable `code`:
291
+
292
+ | Status | `code` | When |
293
+ | --- | --- | --- |
294
+ | 400 | field errors | Unknown permission key, duplicate role name (ignoring case, and including the Super Admin role's name) |
295
+ | 401 | `not_authenticated` | Not logged in |
296
+ | 403 | `permission_denied` | Missing `rbac.view` or `rbac.manage` |
297
+ | 403 | `privilege_escalation` | The role has permissions you don't hold |
298
+ | 403 | `super_admin_role` | Editing or deleting the Super Admin role |
299
+ | 404 | `not_found` | Unknown role or user, or the user doesn't have the role |
300
+ | 409 | `role_in_use` | Deleting a role users still have |
301
+
302
+ ## Security rules
303
+
304
+ 1. **No privilege escalation.** You may create, edit, delete, assign or remove a role only if you
305
+ hold every permission it has, before and after the change. The Super Admin role counts as
306
+ holding every permission. Super admins and Django superusers are exempt. This stops both
307
+ granting yourself power and stripping roles from more powerful users.
308
+ 2. **Locked checks.** Each write is one transaction, and the role row is locked
309
+ (`SELECT ... FOR UPDATE`) during the check, so a concurrent edit can't slip a permission in.
310
+ 3. **Keys come only from code.** Unknown keys are rejected.
311
+ 4. **Default deny.** No role, an inactive user, or an unmapped action means no access.
312
+ 5. **No overlap with Django's permissions.** A key equal to a Django model permission is a startup
313
+ error (`E006`), so a role can never grant Django admin powers by accident.
314
+ 6. **The admin follows the same rules** (see [Admin](#admin)).
315
+
316
+ Direct ORM or SQL writes to the RBAC tables skip these rules and the signals. Always use the
317
+ services.
318
+
319
+ ## Services and signals
320
+
321
+ Plain Python, without DRF, for scripts, tasks and your own views:
322
+
323
+ ```python
324
+ from universal_rbac import services
325
+
326
+ services.get_user_permissions(user) # frozenset of keys (every declared key for super admins)
327
+ services.is_super_admin(user) # Super Admin role or Django superuser
328
+ services.can_manage_role(actor, role)
329
+ services.check_role_name("Sales") # raises DuplicateRoleName if taken (ignoring case)
330
+
331
+ role = services.create_role("Sales", permissions=["invoice.view"], actor=request.user)
332
+ services.update_role(
333
+ role, name="Sales team", permissions=["invoice.view", "invoice.create"], actor=request.user
334
+ )
335
+ services.assign_role(user, role, actor=request.user) # True if newly assigned
336
+ services.revoke_role(user, role, actor=request.user) # True if the user had it
337
+ services.delete_role(role, actor=request.user)
338
+
339
+ services.sync_permissions(prune=False)
340
+ ```
341
+
342
+ `actor` is who makes the change: it is checked against the escalation rule, stored as
343
+ `assigned_by`, and sent with the signals. `actor=None` means trusted system code. Refused changes
344
+ raise subclasses of `universal_rbac.exceptions.RBACError` (each with a `code`).
345
+
346
+ The functions above (`services.__all__`), the models, the DRF classes in
347
+ `universal_rbac.permissions`, the signals, the exceptions and the settings are the public API.
348
+ Other names in the package are internal and may change.
349
+
350
+ The signals in `universal_rbac.signals` are sent after the transaction commits; a failing receiver
351
+ is logged and never breaks the change:
352
+
353
+ | Signal | Arguments |
354
+ | --- | --- |
355
+ | `role_created` | `role`, `actor` |
356
+ | `role_updated` | `role`, `actor`, `added`, `removed` (sets of keys) |
357
+ | `role_deleted` | `role_id`, `name`, `actor` |
358
+ | `role_assigned`, `role_revoked` | `user`, `role`, `actor` |
359
+
360
+ ### Using with django-universal-audit
361
+
362
+ Track the RBAC models to record who changed which role and who gave whom a role:
363
+
364
+ ```python
365
+ UNIVERSAL_AUDIT = {
366
+ "TRACKED_MODELS": {
367
+ "universal_rbac.Role": {},
368
+ "universal_rbac.RolePermission": {},
369
+ "universal_rbac.UserRole": {},
370
+ },
371
+ }
372
+ ```
373
+
374
+ Or connect the signals to `record_event()` for your own event types.
375
+
376
+ ## Admin
377
+
378
+ - Permissions are read-only.
379
+ - Roles have a permission picker, and users' roles are added and removed in their own list.
380
+ - Viewing needs `rbac.view` or `rbac.manage`, changing needs `rbac.manage` (not Django's model
381
+ permissions). Every save and delete, including the bulk-delete action, goes through the
382
+ services, so the escalation rule applies. A bulk delete is all or nothing.
383
+ - Roles you can't manage, and the Super Admin role, are read-only.
384
+
385
+ ## System checks
386
+
387
+ | Id | Problem |
388
+ | --- | --- |
389
+ | `universal_rbac.E001` | An `rbac.py` is not a dict, or has an invalid key or description |
390
+ | `universal_rbac.E002` | Two apps declare the same key |
391
+ | `universal_rbac.E003` | A UI-created role already uses the `SUPER_ADMIN_ROLE` name (sync won't turn it into the Super Admin role) |
392
+ | `universal_rbac.E004` | `RBACBackend` is missing from `AUTHENTICATION_BACKENDS` |
393
+ | `universal_rbac.E005` | `ENABLE_MANAGEMENT_API` is on, but DRF is not installed |
394
+ | `universal_rbac.E006` | A key equals a Django model permission (`auth.change_user`) |
395
+ | `universal_rbac.E007` | A routed DRF view requires a key that no app declares (typo) |
396
+ | `universal_rbac.E008` | A `UNIVERSAL_RBAC` setting has an invalid value |
397
+ | `universal_rbac.W001` | Unknown `UNIVERSAL_RBAC` setting |
398
+ | `universal_rbac.W002` | A routed view using `ActionPermissions` has an action with no entry, so it is denied |
399
+ | `universal_rbac.W003` | DRF's default permission is `AllowAny`, so new views are public |
400
+ | `universal_rbac.W004` | A key removed from code is still held by roles (run `rbac_sync --prune`) |
401
+ | `universal_rbac.W005` | `BOOTSTRAP_SUPER_ADMIN_DOMAINS` lists a public email domain (`gmail.com`, ...) |
402
+
403
+ `E003` and `W004` read the database, so they run with `migrate` and `check --database default`.
404
+ `E007` and `W002` read the URLconf and cover views routed through DRF.
405
+
406
+ ## Rolling out in an existing project
407
+
408
+ Deny by default means a view protected before users have roles locks them out. Roll out in this
409
+ order:
410
+
411
+ 1. Install the package and add every app's `rbac.py`. Nothing is enforced yet.
412
+ 2. Create roles that match today's access and assign them to all users (for many users, a one-off
413
+ script using the services). Check with `me/permissions`.
414
+ 3. Protect one app at a time. `W002` and `E007` catch gaps and typos.
415
+ 4. Let the frontend read `me/permissions` after login to show and hide buttons.
416
+
417
+ ## Development
418
+
419
+ Tests need a MySQL server (the database name is `universal_rbac`):
420
+
421
+ ```bash
422
+ uv venv && uv pip install -e ".[drf]" --group dev
423
+ UA_DB_HOST=127.0.0.1 UA_DB_PORT=3306 UA_DB_USER=root UA_DB_PASSWORD=... \
424
+ .venv/bin/pytest -W error::DeprecationWarning
425
+ # A custom user model (UUID key, email login, no is_active column) on SQLite:
426
+ .venv/bin/pytest --ds=tests_custom_user.settings tests_custom_user
427
+ .venv/bin/ruff check . && .venv/bin/ruff format --check .
428
+ ```