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.
- django_universal_rbac-0.1.0/.gitignore +11 -0
- django_universal_rbac-0.1.0/CHANGELOG.md +33 -0
- django_universal_rbac-0.1.0/LICENSE +21 -0
- django_universal_rbac-0.1.0/PKG-INFO +428 -0
- django_universal_rbac-0.1.0/README.md +399 -0
- django_universal_rbac-0.1.0/pyproject.toml +80 -0
- django_universal_rbac-0.1.0/tests/__init__.py +0 -0
- django_universal_rbac-0.1.0/tests/conftest.py +78 -0
- django_universal_rbac-0.1.0/tests/settings.py +72 -0
- django_universal_rbac-0.1.0/tests/test_admin.py +172 -0
- django_universal_rbac-0.1.0/tests/test_api.py +200 -0
- django_universal_rbac-0.1.0/tests/test_backend.py +129 -0
- django_universal_rbac-0.1.0/tests/test_bootstrap.py +108 -0
- django_universal_rbac-0.1.0/tests/test_checks.py +118 -0
- django_universal_rbac-0.1.0/tests/test_package.py +72 -0
- django_universal_rbac-0.1.0/tests/test_permissions.py +75 -0
- django_universal_rbac-0.1.0/tests/test_services.py +274 -0
- django_universal_rbac-0.1.0/tests/test_sync.py +170 -0
- django_universal_rbac-0.1.0/tests/testapp/__init__.py +0 -0
- django_universal_rbac-0.1.0/tests/testapp/rbac.py +7 -0
- django_universal_rbac-0.1.0/tests/testapp/views.py +78 -0
- django_universal_rbac-0.1.0/tests/urls.py +19 -0
- django_universal_rbac-0.1.0/tests/urls_bad.py +33 -0
- django_universal_rbac-0.1.0/tests_custom_user/__init__.py +0 -0
- django_universal_rbac-0.1.0/tests_custom_user/accounts/__init__.py +0 -0
- django_universal_rbac-0.1.0/tests_custom_user/accounts/migrations/0001_initial.py +31 -0
- django_universal_rbac-0.1.0/tests_custom_user/accounts/migrations/__init__.py +0 -0
- django_universal_rbac-0.1.0/tests_custom_user/accounts/models.py +25 -0
- django_universal_rbac-0.1.0/tests_custom_user/settings.py +37 -0
- django_universal_rbac-0.1.0/tests_custom_user/test_custom_user.py +76 -0
- django_universal_rbac-0.1.0/tests_custom_user/urls.py +3 -0
- django_universal_rbac-0.1.0/universal_rbac/__init__.py +6 -0
- django_universal_rbac-0.1.0/universal_rbac/admin.py +204 -0
- django_universal_rbac-0.1.0/universal_rbac/apps.py +57 -0
- django_universal_rbac-0.1.0/universal_rbac/backends.py +34 -0
- django_universal_rbac-0.1.0/universal_rbac/checks.py +247 -0
- django_universal_rbac-0.1.0/universal_rbac/exceptions.py +43 -0
- django_universal_rbac-0.1.0/universal_rbac/management/__init__.py +0 -0
- django_universal_rbac-0.1.0/universal_rbac/management/commands/__init__.py +0 -0
- django_universal_rbac-0.1.0/universal_rbac/management/commands/rbac_assign.py +33 -0
- django_universal_rbac-0.1.0/universal_rbac/management/commands/rbac_sync.py +39 -0
- django_universal_rbac-0.1.0/universal_rbac/migrations/0001_initial.py +84 -0
- django_universal_rbac-0.1.0/universal_rbac/migrations/__init__.py +0 -0
- django_universal_rbac-0.1.0/universal_rbac/models.py +104 -0
- django_universal_rbac-0.1.0/universal_rbac/permissions.py +63 -0
- django_universal_rbac-0.1.0/universal_rbac/rbac.py +4 -0
- django_universal_rbac-0.1.0/universal_rbac/registry.py +72 -0
- django_universal_rbac-0.1.0/universal_rbac/serializers.py +58 -0
- django_universal_rbac-0.1.0/universal_rbac/services.py +493 -0
- django_universal_rbac-0.1.0/universal_rbac/signals.py +13 -0
- django_universal_rbac-0.1.0/universal_rbac/urls.py +24 -0
- django_universal_rbac-0.1.0/universal_rbac/views.py +213 -0
|
@@ -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
|
+
```
|