django-action-permissions 0.4.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_action_permissions-0.4.0/LICENSE +29 -0
- django_action_permissions-0.4.0/PKG-INFO +530 -0
- django_action_permissions-0.4.0/README.md +484 -0
- django_action_permissions-0.4.0/django_action_permissions/__init__.py +22 -0
- django_action_permissions-0.4.0/django_action_permissions/abac.py +171 -0
- django_action_permissions-0.4.0/django_action_permissions/admin.py +692 -0
- django_action_permissions-0.4.0/django_action_permissions/apps.py +11 -0
- django_action_permissions-0.4.0/django_action_permissions/auth.py +30 -0
- django_action_permissions-0.4.0/django_action_permissions/backends.py +79 -0
- django_action_permissions-0.4.0/django_action_permissions/checks.py +204 -0
- django_action_permissions-0.4.0/django_action_permissions/conf.py +30 -0
- django_action_permissions-0.4.0/django_action_permissions/entries.py +115 -0
- django_action_permissions-0.4.0/django_action_permissions/explain.py +221 -0
- django_action_permissions-0.4.0/django_action_permissions/management/__init__.py +0 -0
- django_action_permissions-0.4.0/django_action_permissions/management/commands/__init__.py +0 -0
- django_action_permissions-0.4.0/django_action_permissions/management/commands/django_action_permissions_export.py +21 -0
- django_action_permissions-0.4.0/django_action_permissions/management/commands/django_action_permissions_load.py +32 -0
- django_action_permissions-0.4.0/django_action_permissions/migrations/0001_initial.py +76 -0
- django_action_permissions-0.4.0/django_action_permissions/migrations/__init__.py +0 -0
- django_action_permissions-0.4.0/django_action_permissions/mixins.py +18 -0
- django_action_permissions-0.4.0/django_action_permissions/models.py +124 -0
- django_action_permissions-0.4.0/django_action_permissions/permissions.py +156 -0
- django_action_permissions-0.4.0/django_action_permissions/predicates.py +391 -0
- django_action_permissions-0.4.0/django_action_permissions/rbac.py +183 -0
- django_action_permissions-0.4.0/django_action_permissions/rules.py +46 -0
- django_action_permissions-0.4.0/django_action_permissions/signals.py +60 -0
- django_action_permissions-0.4.0/django_action_permissions/static/django_action_permissions/changelist.js +25 -0
- django_action_permissions-0.4.0/django_action_permissions/static/django_action_permissions/ruleset.css +81 -0
- django_action_permissions-0.4.0/django_action_permissions/static/django_action_permissions/ruleset.js +369 -0
- django_action_permissions-0.4.0/django_action_permissions/templates/admin/django_action_permissions/actionpermission/_answer.html +1 -0
- django_action_permissions-0.4.0/django_action_permissions/templates/admin/django_action_permissions/actionpermission/_breadcrumbs.html +7 -0
- django_action_permissions-0.4.0/django_action_permissions/templates/admin/django_action_permissions/actionpermission/_trace_node.html +8 -0
- django_action_permissions-0.4.0/django_action_permissions/templates/admin/django_action_permissions/actionpermission/change_list.html +22 -0
- django_action_permissions-0.4.0/django_action_permissions/templates/admin/django_action_permissions/actionpermission/check.html +24 -0
- django_action_permissions-0.4.0/django_action_permissions/templates/admin/django_action_permissions/actionpermission/load.html +16 -0
- django_action_permissions-0.4.0/django_action_permissions/templates/admin/django_action_permissions/actionpermission/overview.html +85 -0
- django_action_permissions-0.4.0/django_action_permissions/templates/admin/django_action_permissions/actionpermission/verify.html +102 -0
- django_action_permissions-0.4.0/django_action_permissions/templates/admin/django_action_permissions/actionpermissionrule/change_list.html +9 -0
- django_action_permissions-0.4.0/django_action_permissions/templates/admin/django_action_permissions/actionpermissionrule/sync.html +31 -0
- django_action_permissions-0.4.0/django_action_permissions/templates/django_action_permissions/widgets/ruleset.html +1 -0
- django_action_permissions-0.4.0/django_action_permissions/utils.py +26 -0
- django_action_permissions-0.4.0/django_action_permissions/views.py +153 -0
- django_action_permissions-0.4.0/django_action_permissions/yaml_io.py +144 -0
- django_action_permissions-0.4.0/django_action_permissions.egg-info/PKG-INFO +530 -0
- django_action_permissions-0.4.0/django_action_permissions.egg-info/SOURCES.txt +62 -0
- django_action_permissions-0.4.0/django_action_permissions.egg-info/dependency_links.txt +1 -0
- django_action_permissions-0.4.0/django_action_permissions.egg-info/requires.txt +19 -0
- django_action_permissions-0.4.0/django_action_permissions.egg-info/top_level.txt +1 -0
- django_action_permissions-0.4.0/pyproject.toml +104 -0
- django_action_permissions-0.4.0/setup.cfg +4 -0
- django_action_permissions-0.4.0/tests/test_abac.py +260 -0
- django_action_permissions-0.4.0/tests/test_admin.py +628 -0
- django_action_permissions-0.4.0/tests/test_auth.py +111 -0
- django_action_permissions-0.4.0/tests/test_backends.py +90 -0
- django_action_permissions-0.4.0/tests/test_cache.py +174 -0
- django_action_permissions-0.4.0/tests/test_checks.py +211 -0
- django_action_permissions-0.4.0/tests/test_explain.py +227 -0
- django_action_permissions-0.4.0/tests/test_mixins.py +27 -0
- django_action_permissions-0.4.0/tests/test_models.py +84 -0
- django_action_permissions-0.4.0/tests/test_permissions.py +325 -0
- django_action_permissions-0.4.0/tests/test_predicates.py +325 -0
- django_action_permissions-0.4.0/tests/test_rbac.py +245 -0
- django_action_permissions-0.4.0/tests/test_views.py +309 -0
- django_action_permissions-0.4.0/tests/test_yaml_io.py +234 -0
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
BSD 3-Clause License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026, Giuseppe Galli
|
|
4
|
+
All rights reserved.
|
|
5
|
+
|
|
6
|
+
Redistribution and use in source and binary forms, with or without
|
|
7
|
+
modification, are permitted provided that the following conditions are met:
|
|
8
|
+
|
|
9
|
+
1. Redistributions of source code must retain the above copyright notice, this
|
|
10
|
+
list of conditions and the following disclaimer.
|
|
11
|
+
|
|
12
|
+
2. Redistributions in binary form must reproduce the above copyright notice,
|
|
13
|
+
this list of conditions and the following disclaimer in the documentation
|
|
14
|
+
and/or other materials provided with the distribution.
|
|
15
|
+
|
|
16
|
+
3. Neither the name of the copyright holder nor the names of its
|
|
17
|
+
contributors may be used to endorse or promote products derived from
|
|
18
|
+
this software without specific prior written permission.
|
|
19
|
+
|
|
20
|
+
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
|
21
|
+
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
|
22
|
+
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
23
|
+
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
|
24
|
+
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
|
25
|
+
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
|
26
|
+
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
|
27
|
+
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
|
28
|
+
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
|
29
|
+
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
@@ -0,0 +1,530 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-action-permissions
|
|
3
|
+
Version: 0.4.0
|
|
4
|
+
Summary: Action-based permissions for Django REST framework viewsets, driven by an RBAC/ABAC table stored in the database
|
|
5
|
+
Author-email: Giuseppe Galli <giuseppe.galli@gmail.com>
|
|
6
|
+
License-Expression: BSD-3-Clause
|
|
7
|
+
Project-URL: Homepage, https://github.com/pinoatrome/django-action-permissions-lib-project
|
|
8
|
+
Project-URL: Source, https://github.com/pinoatrome/django-action-permissions-lib-project
|
|
9
|
+
Project-URL: Issues, https://github.com/pinoatrome/django-action-permissions-lib-project/issues
|
|
10
|
+
Project-URL: Changelog, https://github.com/pinoatrome/django-action-permissions-lib-project/releases
|
|
11
|
+
Keywords: django,django-rest-framework,permissions,rbac,abac,authorization
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Environment :: Web Environment
|
|
14
|
+
Classifier: Framework :: Django
|
|
15
|
+
Classifier: Framework :: Django :: 4.2
|
|
16
|
+
Classifier: Framework :: Django :: 5.2
|
|
17
|
+
Classifier: Framework :: Django :: 6.1
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
24
|
+
Classifier: Intended Audience :: Developers
|
|
25
|
+
Classifier: Operating System :: OS Independent
|
|
26
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
27
|
+
Requires-Python: >=3.10
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
License-File: LICENSE
|
|
30
|
+
Requires-Dist: Django>=4.2
|
|
31
|
+
Provides-Extra: test
|
|
32
|
+
Requires-Dist: pytest>=7.0; extra == "test"
|
|
33
|
+
Requires-Dist: pytest-django>=4.8; extra == "test"
|
|
34
|
+
Requires-Dist: pytest-cov>=5.0; extra == "test"
|
|
35
|
+
Requires-Dist: coverage[toml]>=7.5; extra == "test"
|
|
36
|
+
Requires-Dist: PyYAML>=6; extra == "test"
|
|
37
|
+
Provides-Extra: drf
|
|
38
|
+
Requires-Dist: djangorestframework>=3.16; extra == "drf"
|
|
39
|
+
Provides-Extra: yaml
|
|
40
|
+
Requires-Dist: PyYAML>=6; extra == "yaml"
|
|
41
|
+
Provides-Extra: dev
|
|
42
|
+
Requires-Dist: tox>=4; extra == "dev"
|
|
43
|
+
Requires-Dist: ruff>=0.17; extra == "dev"
|
|
44
|
+
Requires-Dist: pre-commit>=4; extra == "dev"
|
|
45
|
+
Dynamic: license-file
|
|
46
|
+
|
|
47
|
+
# django-action-permissions-lib-project
|
|
48
|
+
|
|
49
|
+
[](#supported-versions)
|
|
50
|
+
[](#supported-versions)
|
|
51
|
+
[](#usage)
|
|
52
|
+
[](#development)
|
|
53
|
+
[](https://pypi.org/p/django-action-permissions)
|
|
54
|
+
[](https://github.com/pinoatrome/django-action-permissions-lib-project/blob/develop/LICENSE)
|
|
55
|
+
|
|
56
|
+
Action-based permissions for Django REST framework viewsets and plain Django views. A viewset action is checked
|
|
57
|
+
against the Django model permission it stands for, an action permission: which groups hold it (RBAC) and which
|
|
58
|
+
objects they may use it on (ABAC rulesets). Setup writes the permission definitions to the database; at runtime each
|
|
59
|
+
check reads its own permission's entry from there, through a Django cache. Nothing here knows about a particular
|
|
60
|
+
project: the definitions, their predicates and the group names are passed in by the app that uses it.
|
|
61
|
+
|
|
62
|
+
- `permissions`: `BaseActionModelPermission`, which maps a viewset action to the model permission it needs (`list`
|
|
63
|
+
and `retrieve` -> `view_<model>`, `create` -> `add_<model>`, `update` and `partial_update` -> `change_<model>`,
|
|
64
|
+
`destroy` -> `delete_<model>`, a custom action -> `<action>_<model>`, e.g. `upload_document`) and checks objects
|
|
65
|
+
against their rulesets; `DenyAll`. The one module that needs Django REST framework (the `drf` extra).
|
|
66
|
+
- `mixins`: `OperationInfoPermissionMixin` and `CodeInfoPermissionMixin` fix a permission class's operation or
|
|
67
|
+
whole permission code, for one action.
|
|
68
|
+
- `predicates`: the `Predicate` protocol, `@predicate`, and rulesets (predicates composed with `&`, `|`, `~`);
|
|
69
|
+
`rules`: the built-in predicates.
|
|
70
|
+
- `rbac`: `setup_rbac_permissions`, which writes the permission definitions and their predicates to the database.
|
|
71
|
+
- `entries`: each action permission's entry as the checks read it, cached under its own key.
|
|
72
|
+
- `views`: `permission_required` and `PermissionRequiredMixin`, for plain Django views (no DRF needed).
|
|
73
|
+
- `abac`: `unrestricted(user, perm)` and `allowed(user, perm, obj)`, the same checks in any code.
|
|
74
|
+
- `explain`: `explain(user, perm, obj)`, how `allowed` answers, step by step, each ruleset traced.
|
|
75
|
+
- `backends`: the rules backends, which run the rules and read their answers.
|
|
76
|
+
- `yaml_io` and the commands `django_action_permissions_load` / `django_action_permissions_export`: the permission
|
|
77
|
+
definitions as a YAML document.
|
|
78
|
+
- `auth`: `ActionPermissionBackend`, an optional authentication backend: `user.has_perm(perm, obj)` by the rulesets.
|
|
79
|
+
- `admin`: every part of the action permissions editable, the rulesets checked as they're typed, and the
|
|
80
|
+
Overview, Verify, Load, Export and Check tools.
|
|
81
|
+
- `checks`: system checks of the settings and of the stored action permissions.
|
|
82
|
+
|
|
83
|
+
## Install
|
|
84
|
+
|
|
85
|
+
```bash
|
|
86
|
+
pip install "django-action-permissions[drf,yaml]"
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
An unreleased commit or branch installs from git:
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
pip install "django-action-permissions[drf,yaml] @ git+https://github.com/pinoatrome/django-action-permissions-lib-project@develop"
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
The extras: `drf` (Django REST framework) for the viewset permission classes, `yaml` (PyYAML) for the YAML loader
|
|
96
|
+
and exporter. Without them the rest works in plain Django (see Usage).
|
|
97
|
+
|
|
98
|
+
```python
|
|
99
|
+
INSTALLED_APPS = [
|
|
100
|
+
...,
|
|
101
|
+
"django.contrib.auth",
|
|
102
|
+
"django.contrib.contenttypes",
|
|
103
|
+
"rest_framework", # with the drf extra
|
|
104
|
+
"django_action_permissions",
|
|
105
|
+
]
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Then `python manage.py migrate`, and set up the permission definitions (below).
|
|
109
|
+
|
|
110
|
+
## Predicates
|
|
111
|
+
|
|
112
|
+
A predicate is a function `(user, obj) -> bool | None` (the `predicates.Predicate` protocol): `True`, `False`, or
|
|
113
|
+
`None` when it doesn't apply; or it raises `PermissionDenied` (Django's, or DRF's when DRF is installed), which
|
|
114
|
+
blocks the whole check. `@predicate` makes a function one, under a code (its name, or `code=`) and a description
|
|
115
|
+
(its docstring's first paragraph, or `description=`). Define them in your apps' `rules.py`, at a module's top level:
|
|
116
|
+
|
|
117
|
+
```python
|
|
118
|
+
# shop/rules.py
|
|
119
|
+
from django_action_permissions.predicates import predicate
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@predicate
|
|
123
|
+
def own_order(user, obj):
|
|
124
|
+
"""The order is the user's."""
|
|
125
|
+
return obj.customer_id == user.pk
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
@predicate(code="archived")
|
|
129
|
+
def is_archived(user, obj):
|
|
130
|
+
"""The order is archived."""
|
|
131
|
+
return obj.archived_at is not None
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
Setup imports every installed app's `rules` module (as the admin does its `admin` modules) and stores each
|
|
135
|
+
registered predicate's definition (code, dotted path, description) in the database; a code taken by two functions
|
|
136
|
+
is `ImproperlyConfigured`. The library's own `rules` module ships these, whose codes are reserved:
|
|
137
|
+
|
|
138
|
+
| code | allows when |
|
|
139
|
+
|---|---|
|
|
140
|
+
| `always` / `never` | always / never |
|
|
141
|
+
| `is_authenticated`, `is_active`, `is_staff`, `is_superuser` | the user is (Django's flags) |
|
|
142
|
+
| `is_self` | the object is the user themselves |
|
|
143
|
+
|
|
144
|
+
## Rulesets
|
|
145
|
+
|
|
146
|
+
Predicates compose into a ruleset with `&` (and), `|` (or), `~` (not) and parentheses; `~` binds tighter than
|
|
147
|
+
`&`, and `&` tighter than `|`. `None` ("doesn't apply") is skipped:
|
|
148
|
+
|
|
149
|
+
| | evaluates | answers |
|
|
150
|
+
|---|---|---|
|
|
151
|
+
| `a & b & ...` | left to right, up to the first `False` | `False` if one is; else `True` if one is; else `None` |
|
|
152
|
+
| `a \| b \| ...` | left to right, up to the first `True` | `True` if one is; else `False` if one is; else `None` |
|
|
153
|
+
| `~a` | `a` | `True` and `False` swapped; `None` stays `None` |
|
|
154
|
+
|
|
155
|
+
The order is the execution order: `~is_blocked & own_order` checks the block first. A `PermissionDenied` goes
|
|
156
|
+
through every operator and blocks.
|
|
157
|
+
|
|
158
|
+
## Permission definitions
|
|
159
|
+
|
|
160
|
+
The definitions setup takes map a Django permission string (`app_label.codename`, as `user.has_perm()` takes it) to
|
|
161
|
+
its entry; each permission listed becomes an action permission:
|
|
162
|
+
|
|
163
|
+
```python
|
|
164
|
+
from django_action_permissions.rules import is_staff
|
|
165
|
+
from shop.rules import is_archived, own_order
|
|
166
|
+
|
|
167
|
+
DEFINITIONS = {
|
|
168
|
+
"shop.view_order": {
|
|
169
|
+
"rbac": {"CLERKS", "CUSTOMERS"}, # the groups granted the permission
|
|
170
|
+
"abac": {"CUSTOMERS": "own_order | is_staff"}, # a group's ruleset, as an expression ...
|
|
171
|
+
"no_detail": [], # groups that may list but not retrieve a single object
|
|
172
|
+
"staff": [], # groups that act with the rights of their manager (`manager_user`)
|
|
173
|
+
},
|
|
174
|
+
"shop.change_order": {
|
|
175
|
+
"rbac": {"CLERKS", "CUSTOMERS"},
|
|
176
|
+
"abac": {"CUSTOMERS": own_order & ~is_archived}, # ... or composed in Python
|
|
177
|
+
},
|
|
178
|
+
"shop.approve_order": {"rbac": {"CLERKS"}}, # a custom action `approve`
|
|
179
|
+
}
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
`abac` gives a group one ruleset: the group holds the permission on the objects it allows. A group holding the
|
|
183
|
+
permission without a ruleset holds it on any object. Set it up (after `migrate`, so the permissions exist):
|
|
184
|
+
|
|
185
|
+
```python
|
|
186
|
+
from django_action_permissions.rbac import setup_rbac_permissions
|
|
187
|
+
|
|
188
|
+
setup_rbac_permissions(DEFINITIONS, ["CLERKS", "CUSTOMERS"]) # groups created if missing
|
|
189
|
+
setup_rbac_permissions(DEFINITIONS, ["CLERKS", "CUSTOMERS"], keep=True)
|
|
190
|
+
```
|
|
191
|
+
|
|
192
|
+
Setup runs in one transaction. It stores the registered predicates (and the definitions `rules=` gives,
|
|
193
|
+
`{code: {"fqn", "description"}}`, e.g. a YAML document's), replaces each entry it lists, the rulesets in their
|
|
194
|
+
canonical form (`(own_order | is_staff) & ~archived`), and gives the groups the permissions it grants. Without
|
|
195
|
+
`keep`, the groups lose any other permission, and the entries the definitions no longer list, and the predicates no
|
|
196
|
+
longer defined and unused, are removed. Definitions that name a group not set up, gives a ruleset to a group its
|
|
197
|
+
`rbac` doesn't grant, has a ruleset that doesn't parse or names an unknown predicate, or a predicate that doesn't
|
|
198
|
+
import, is a `ValueError`, raised before anything is written; so is a list of rules (write `a | b`, or `a & b`).
|
|
199
|
+
|
|
200
|
+
## The answer on an object
|
|
201
|
+
|
|
202
|
+
On an object, every group of the user holding the permission evaluates its ruleset, in name order. A
|
|
203
|
+
`PermissionDenied` in any of them blocks; otherwise one group allowing is enough, so the order of `user.groups`
|
|
204
|
+
doesn't matter. When no group decides (`None`), the rules backend's policy does, which goes with the project's
|
|
205
|
+
default permission:
|
|
206
|
+
|
|
207
|
+
| | `DenyUnlessAllowedRulesBackend` (default, with `DenyAll`) | `AllowUnlessBlockedRulesBackend` (with DRF's `AllowAny`) |
|
|
208
|
+
|---|---|---|
|
|
209
|
+
| a ruleset answers `None` | deny | allow |
|
|
210
|
+
|
|
211
|
+
A permission that isn't an action permission is left to `user.has_perm(perm, obj)` (Django's object permission
|
|
212
|
+
backends),
|
|
213
|
+
and no group holds it on any object (`unrestricted` is false until setup has run).
|
|
214
|
+
|
|
215
|
+
## Settings
|
|
216
|
+
|
|
217
|
+
```python
|
|
218
|
+
ACTION_PERMISSIONS = {
|
|
219
|
+
"DEFAULT_PERMISSION": "django_action_permissions.permissions.DenyAll", # or rest_framework.permissions.AllowAny
|
|
220
|
+
# or django_action_permissions.backends.AllowUnlessBlockedRulesBackend
|
|
221
|
+
"RULES_BACKEND": "django_action_permissions.backends.DenyUnlessAllowedRulesBackend",
|
|
222
|
+
"CACHE": "default", # an alias of CACHES; None: read the database on each check
|
|
223
|
+
"CACHE_TIMEOUT": 300,
|
|
224
|
+
"MANAGER_USER": None, # "myapp.staff.manager_of": (user) -> manager, for staff groups in the auth backend
|
|
225
|
+
}
|
|
226
|
+
REST_FRAMEWORK = {"DEFAULT_PERMISSION_CLASSES": ["django_action_permissions.permissions.DenyAll"]}
|
|
227
|
+
```
|
|
228
|
+
|
|
229
|
+
These are the defaults. `DenyAll` goes with `DenyUnlessAllowedRulesBackend`, DRF's `AllowAny` with
|
|
230
|
+
`AllowUnlessBlockedRulesBackend` (DRF has no class that denies everything, hence `DenyAll`), and the same class is
|
|
231
|
+
the project's `DEFAULT_PERMISSION_CLASSES` (the check `W004` says when it isn't): with `DenyAll`, a view that
|
|
232
|
+
doesn't set its own `permission_classes` is closed. A custom backend subclasses `BaseRulesBackend` (e.g. to override
|
|
233
|
+
`order`).
|
|
234
|
+
|
|
235
|
+
A check reads only the entry of the permission it checks (`entries.perm_info`: its rulesets, the definitions of
|
|
236
|
+
their predicates, `no_detail`, `staff`), cached under its own key (`django_action_permissions:perm:<perm>`) of the
|
|
237
|
+
`CACHE` alias; a permission that isn't an action permission is cached as such. A change in the database deletes the
|
|
238
|
+
keys of the entries it affects once it commits: its own rows, a predicate it uses, a group it names; setup, the
|
|
239
|
+
admin's edits and a shell's alike. No whole set of permissions is ever loaded. The cache must be shared by every
|
|
240
|
+
process of the project (Redis, Memcached, the database cache): a `LocMemCache` is kept by each process on its own
|
|
241
|
+
(the check `W001` says so). A cache that fails is logged, and the database answers.
|
|
242
|
+
|
|
243
|
+
## Usage
|
|
244
|
+
|
|
245
|
+
What to use depends on whether Django REST framework is installed (the `drf` extra):
|
|
246
|
+
|
|
247
|
+
| | with DRF | without DRF |
|
|
248
|
+
|---|---|---|
|
|
249
|
+
| DRF viewsets and `APIView`s | `permissions.BaseActionModelPermission` | — |
|
|
250
|
+
| views closed by default | `REST_FRAMEWORK["DEFAULT_PERMISSION_CLASSES"]`: `DenyAll` (or `AllowAny`) | `LoginRequiredMiddleware` (Django 5.1+) |
|
|
251
|
+
| function views | `views.permission_required` | `views.permission_required` |
|
|
252
|
+
| class-based views | `views.PermissionRequiredMixin` | `views.PermissionRequiredMixin` |
|
|
253
|
+
| any other code (filters, templates, tasks) | `abac.unrestricted`, `abac.allowed` | `abac.unrestricted`, `abac.allowed` |
|
|
254
|
+
| Django's `user.has_perm(perm, obj)` (optional) | `auth.ActionPermissionBackend` | `auth.ActionPermissionBackend` |
|
|
255
|
+
| a rule blocks by raising | Django's or DRF's `PermissionDenied` | Django's `PermissionDenied` |
|
|
256
|
+
| `DEFAULT_PERMISSION` | a DRF permission class, in `DEFAULT_PERMISSION_CLASSES` (check `W004`) | `DenyAll` or `AllowAny`, by name only (check `E001`) |
|
|
257
|
+
|
|
258
|
+
Setup, the stored action permissions and their cache, the rules backends, the YAML tools, the commands and the admin are the same
|
|
259
|
+
in both.
|
|
260
|
+
|
|
261
|
+
### With DRF: viewsets
|
|
262
|
+
|
|
263
|
+
```python
|
|
264
|
+
from django_action_permissions.permissions import BaseActionModelPermission
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
class OrderViewSet(viewsets.ModelViewSet):
|
|
268
|
+
permission_classes = [BaseActionModelPermission]
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
`has_permission` checks the action's permission (and `no_detail`), `has_object_permission` the object against the
|
|
272
|
+
group's ruleset: call `self.check_object_permissions(request, obj)` on an object to be created, too. A project with `staff`
|
|
273
|
+
groups subclasses it to implement `manager_user(user)`. An action the viewset doesn't route is a 405 for an
|
|
274
|
+
authenticated user and a refusal (so a request for credentials) for an anonymous one. OPTIONS takes the `view`
|
|
275
|
+
permission.
|
|
276
|
+
|
|
277
|
+
An object the user may not use is a 403, or a 404 when they can't read it either (`view_<model>` on it), as DRF's
|
|
278
|
+
`DjangoObjectPermissions` does: another user's objects aren't revealed. A creation refused (the unsaved object
|
|
279
|
+
passed to `check_object_permissions`) is always a 403: there's nothing to hide. `not_found_when_unreadable = False`
|
|
280
|
+
on a subclass gives 403 for every refusal.
|
|
281
|
+
|
|
282
|
+
```python
|
|
283
|
+
REST_FRAMEWORK = {"DEFAULT_PERMISSION_CLASSES": ["django_action_permissions.permissions.DenyAll"]}
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
closes the views that don't set their own `permission_classes`; it is the class `ACTION_PERMISSIONS["DEFAULT_PERMISSION"]`
|
|
287
|
+
names (the check `W004` says when they differ).
|
|
288
|
+
|
|
289
|
+
### Plain Django views, with or without DRF
|
|
290
|
+
|
|
291
|
+
`django_action_permissions.views` holds the counterparts of Django's `permission_required` and
|
|
292
|
+
`PermissionRequiredMixin`, built on the action permissions. As Django's, an anonymous visitor is sent to the login page
|
|
293
|
+
(`LOGIN_URL`), and a signed-in user who may not gets a 403 rather than a login page to come back from; an object
|
|
294
|
+
they may not use is a 404 when they can't read it either, as in the DRF class (`not_found_when_unreadable=False`
|
|
295
|
+
on the decorator, or as the mixin's attribute, for 403 everywhere). Several permissions must all be held.
|
|
296
|
+
|
|
297
|
+
```python
|
|
298
|
+
from django.shortcuts import get_object_or_404
|
|
299
|
+
from django_action_permissions.views import permission_required
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
@permission_required("shop.view_order") # on any object: e.g. the back office's list of every order
|
|
303
|
+
def all_orders(request): ...
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
@permission_required("shop.change_order", get_object=lambda request, pk: get_object_or_404(Order, pk=pk))
|
|
307
|
+
def edit_order(request, pk): ... # on this order: the group's ruleset decides
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
Without `get_object`, the decorator asks for the permission on any object (`unrestricted`): a user who holds it on
|
|
311
|
+
their own objects only is refused. With `get_object(request, *args, **kwargs)`, it asks for it on the object
|
|
312
|
+
returned (`allowed`). Async views are guarded too; `manager_user=` gives the manager of a `staff` group.
|
|
313
|
+
|
|
314
|
+
```python
|
|
315
|
+
from django.views.generic import DetailView, ListView
|
|
316
|
+
from django_action_permissions.abac import unrestricted
|
|
317
|
+
from django_action_permissions.views import PermissionRequiredMixin
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
class OrderDetail(PermissionRequiredMixin, DetailView):
|
|
321
|
+
model = Order
|
|
322
|
+
permission_required = "shop.view_order" # get_object() refuses an order its ruleset doesn't allow
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
class OrderList(PermissionRequiredMixin, ListView):
|
|
326
|
+
model = Order
|
|
327
|
+
permission_required = "shop.view_order" # a customer gets in: the list shows their orders
|
|
328
|
+
|
|
329
|
+
def get_queryset(self):
|
|
330
|
+
orders, user = super().get_queryset(), self.request.user
|
|
331
|
+
return orders if unrestricted(user, "shop.view_order") else orders.filter(customer=user)
|
|
332
|
+
|
|
333
|
+
|
|
334
|
+
class BackOffice(PermissionRequiredMixin, ListView):
|
|
335
|
+
model = Order
|
|
336
|
+
permission_required = "shop.view_order"
|
|
337
|
+
permission_unrestricted = True # on any object only
|
|
338
|
+
```
|
|
339
|
+
|
|
340
|
+
The mixin lets in a user holding the permission, maybe on their own objects only (`has_perm`), or on any object
|
|
341
|
+
with `permission_unrestricted = True`, and `get_object()` checks the object (`allowed`): put the mixin first, before
|
|
342
|
+
`DetailView`, `UpdateView`, `DeleteView`. Define `manager_user(self, user)` for `staff` groups.
|
|
343
|
+
|
|
344
|
+
### Without DRF
|
|
345
|
+
|
|
346
|
+
Django has no permission classes, so nothing closes a view by default but
|
|
347
|
+
`django.contrib.auth.middleware.LoginRequiredMiddleware` (Django 5.1+; the check `W005` says when it's missing with
|
|
348
|
+
`DenyAll`). It requires a login only: the views still ask the action permissions, with `views` or `abac`. Before
|
|
349
|
+
Django 5.1 there's no such middleware, and no `W005`: each view is closed with `login_required` / `LoginRequiredMixin`.
|
|
350
|
+
|
|
351
|
+
`DEFAULT_PERMISSION` then only names the policy of the rules backend: `django_action_permissions.permissions.DenyAll`
|
|
352
|
+
or `rest_framework.permissions.AllowAny`, by name, as neither can be imported (the check `E001` refuses anything
|
|
353
|
+
else, which it couldn't verify). `BaseActionModelPermission` and `DenyAll` themselves need DRF.
|
|
354
|
+
|
|
355
|
+
### Compared with DRF's `DjangoModelPermissions` / `DjangoObjectPermissions`
|
|
356
|
+
|
|
357
|
+
Both map a request to a Django model permission of the view's queryset model and check it with `has_perm(s)`.
|
|
358
|
+
DRF's map the HTTP method: reads ask for nothing, and a POST to a custom action asks for `add_<model>`. This
|
|
359
|
+
library maps the viewset action: reads ask for `view_<model>`, a custom action for `<action>_<model>`. On an object,
|
|
360
|
+
`DjangoObjectPermissions` asks `user.has_perms(perms, obj)`, per-object grants stored by an authentication backend
|
|
361
|
+
such as django-guardian; this library runs a group's ruleset on the object's attributes, and stores nothing per
|
|
362
|
+
object. The two meet where this library is silent: for a permission that isn't an action permission, the object
|
|
363
|
+
check is
|
|
364
|
+
`user.has_perm(perm, obj)`, so guardian's grants apply. Both answer 404 rather than 403 for an object the user can't
|
|
365
|
+
read. With the auth backend below, DRF's own `DjangoObjectPermissions` asks the rulesets too.
|
|
366
|
+
|
|
367
|
+
### Django's `user.has_perm(perm, obj)`: the optional auth backend
|
|
368
|
+
|
|
369
|
+
```python
|
|
370
|
+
AUTHENTICATION_BACKENDS = [
|
|
371
|
+
"django.contrib.auth.backends.ModelBackend",
|
|
372
|
+
"django_action_permissions.auth.ActionPermissionBackend",
|
|
373
|
+
]
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
Without an object, `ModelBackend` answers (`Group.permissions`, which setup writes). With one, for an action
|
|
377
|
+
permission, `ActionPermissionBackend` answers as `abac.allowed`: Django's own API then asks its rulesets, so templates,
|
|
378
|
+
services, guardian-style code and DRF's `DjangoObjectPermissions` follow it, unchanged. As any Django backend, it
|
|
379
|
+
answers among the others: one allowing is enough, so another object-permission backend (django-guardian) may allow
|
|
380
|
+
what a ruleset refuses, but a rule raising `PermissionDenied` stops the backends after it (Django's
|
|
381
|
+
`_user_has_perm`). A permission that isn't an action permission is left to the other backends. The backend has no view to ask
|
|
382
|
+
for a `staff` group's manager: `ACTION_PERMISSIONS["MANAGER_USER"]` names the function (`user -> manager`; the check
|
|
383
|
+
`E007` says when it doesn't import), and without it a staff group allows nothing there.
|
|
384
|
+
|
|
385
|
+
`BaseActionModelPermission`, `views` and `abac` don't need the backend: they ask the action permissions directly.
|
|
386
|
+
|
|
387
|
+
### In any code
|
|
388
|
+
|
|
389
|
+
```python
|
|
390
|
+
from django_action_permissions.abac import allowed, held_permissions, unrestricted
|
|
391
|
+
|
|
392
|
+
unrestricted(user, "shop.view_order") # on any object: e.g. a list shows every order, or the user's own
|
|
393
|
+
allowed(user, "shop.view_order", order)
|
|
394
|
+
held_permissions(user) # {"shop.view_order": False, ...}: every action permission held, and whether on any object
|
|
395
|
+
```
|
|
396
|
+
|
|
397
|
+
```python
|
|
398
|
+
from django_action_permissions.explain import explain
|
|
399
|
+
|
|
400
|
+
explain(user, "shop.view_order", order).reason # "Allowed by CUSTOMERS's ruleset."
|
|
401
|
+
explain(user, "shop.view_order", order, draft=("CUSTOMERS", "own_order & ~archived")) # what if
|
|
402
|
+
```
|
|
403
|
+
|
|
404
|
+
`explain` goes through the steps of `allowed` and returns them: the checks before the rulesets, each group's ruleset
|
|
405
|
+
as a tree of the predicates' answers (those skipped by `&` / `|` marked), the backend's default, the answer, and, for an
|
|
406
|
+
object refused, the 403 or 404 a view gives. It doesn't let a block or an exception through: it reports them. With a
|
|
407
|
+
`draft`, one group's ruleset is replaced for that check only (`draft_info`). The admin's Verify page shows it.
|
|
408
|
+
|
|
409
|
+
`held_permissions(user)` is what a profile or a menu asks: the action permissions the user holds (through their
|
|
410
|
+
groups or directly), each with whether on any object (as `unrestricted` would answer), filtered in the database in
|
|
411
|
+
two queries, however many action permissions there are.
|
|
412
|
+
|
|
413
|
+
## YAML
|
|
414
|
+
|
|
415
|
+
The permission definitions as one document, the rulesets as expressions:
|
|
416
|
+
|
|
417
|
+
```yaml
|
|
418
|
+
groups: [CLERKS, CUSTOMERS]
|
|
419
|
+
rules: # optional: the registered predicates are stored anyway; a definition here may add or describe one
|
|
420
|
+
own_order: {fqn: shop.rules.own_order, description: The order is the user's}
|
|
421
|
+
permissions:
|
|
422
|
+
shop.view_order:
|
|
423
|
+
rbac: [CLERKS, CUSTOMERS]
|
|
424
|
+
abac: {CUSTOMERS: own_order & ~archived | is_staff}
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
```bash
|
|
428
|
+
python manage.py django_action_permissions_load permissions.yaml [--keep]
|
|
429
|
+
python manage.py django_action_permissions_export [-o permissions.yaml]
|
|
430
|
+
```
|
|
431
|
+
|
|
432
|
+
Loading is setup (`load_yaml`); a document of the wrong shape is refused, naming where. The export
|
|
433
|
+
(`dump_yaml`) is what the database holds: sorted, the rulesets canonical, the definitions of the predicates they
|
|
434
|
+
use, the groups they name (others are left out, so loading the export can't strip them of their
|
|
435
|
+
permissions). Load, export, load and export again
|
|
436
|
+
gives the same document.
|
|
437
|
+
|
|
438
|
+
## Admin
|
|
439
|
+
|
|
440
|
+
Every model is editable: an entry on one page (its permission, its `rbac` groups, `no_detail`, `staff`, and its
|
|
441
|
+
groups' rulesets), the predicates' definitions (code, function, description, their status in the code and their uses;
|
|
442
|
+
one in use can't be deleted), and the groups' rulesets on their own, with the predicates each uses. A ruleset is
|
|
443
|
+
checked as it's typed (where it doesn't parse, its canonical form, the predicates not stored) and completes the
|
|
444
|
+
predicates' codes (Ctrl+Space lists them all), with buttons for the operators: `&` and `|` after the caret (the
|
|
445
|
+
predicates' list opens for the next one), `~` before the predicate or the selection at the caret (again to remove
|
|
446
|
+
it), `( )` around the selection; it is made canonical as it's saved, and refused for a group that isn't
|
|
447
|
+
granted the permission. A setup or a Load without `keep` replaces what is edited here: export the YAML to keep the
|
|
448
|
+
definitions in the code in step. The entries' list has the tools:
|
|
449
|
+
|
|
450
|
+
- **Overview**: the entries against the groups, as a matrix: who holds each permission, on any object or through a
|
|
451
|
+
ruleset, `no_detail` and `staff`; filtered by text, app, group or predicate. An app's permissions that aren't
|
|
452
|
+
action permissions yet are listed with a link to add each one.
|
|
453
|
+
- **Verify**: superusers only, as it runs the project's predicates on any object, for any user
|
|
454
|
+
(`ActionPermissionAdmin.has_verify_permission` to open it to others). Given a user, the action permissions they
|
|
455
|
+
hold; with a permission, and maybe an object's primary key, `explain`'s answer, each group's ruleset traced; with a
|
|
456
|
+
draft for a group, the answer as if its ruleset were the draft, nothing saved. A ruleset's **Test…** link opens it
|
|
457
|
+
with the ruleset as the draft.
|
|
458
|
+
|
|
459
|
+
Rows checked in the list carry into the tools: **Verify** opens with the permission of the first one, **Overview**
|
|
460
|
+
shows only those.
|
|
461
|
+
- **Load YAML**: superusers only, as it rewrites the groups' permissions; nothing is written if the document has
|
|
462
|
+
an error.
|
|
463
|
+
- **Export YAML**, and the action **Export selected as YAML**: users who may view the three models.
|
|
464
|
+
- **Check configuration**: the system checks below, the database ones included.
|
|
465
|
+
|
|
466
|
+
Load and Export need the `yaml` extra; without it they aren't offered. The predicates' list has **Sync
|
|
467
|
+
predicates** (users who may add and change them): it shows what the code registers against what is stored, and stores
|
|
468
|
+
it (`rbac.store_predicates()`, what setup does first), without touching the entries.
|
|
469
|
+
|
|
470
|
+
## Checks
|
|
471
|
+
|
|
472
|
+
`python manage.py check` checks the settings; `python manage.py check --database default` the stored action permissions too.
|
|
473
|
+
|
|
474
|
+
- `E001` `DEFAULT_PERMISSION` isn't a DRF permission class (without DRF: isn't `DenyAll` or `AllowAny`);
|
|
475
|
+
`E002` `RULES_BACKEND` isn't a `BaseRulesBackend`; `E003` they don't go together; `E004` `CACHE` isn't an alias
|
|
476
|
+
of `CACHES`; `W001` `CACHE` is a `LocMemCache`.
|
|
477
|
+
- `E007` `MANAGER_USER` doesn't import or isn't callable.
|
|
478
|
+
- `W004` (with DRF) `DEFAULT_PERMISSION` isn't in `REST_FRAMEWORK["DEFAULT_PERMISSION_CLASSES"]`; `W005` (without
|
|
479
|
+
DRF, Django 5.1+) `DenyAll` without `LoginRequiredMiddleware`.
|
|
480
|
+
- `E005` a predicate's function doesn't import; `E006` a group has a ruleset for a permission it doesn't hold;
|
|
481
|
+
`E008` a ruleset doesn't parse, or names a predicate not stored; `W002` there's no action permission (setup not run);
|
|
482
|
+
`W003` a stored predicate isn't used and no `@predicate` defines it any more; `W006` a registered predicate isn't
|
|
483
|
+
stored (setup hasn't run since it was added).
|
|
484
|
+
|
|
485
|
+
## Inspiration
|
|
486
|
+
|
|
487
|
+
Parts of the implementation has been inspired by [django-rules](https://github.com/dfunckt/django-rules), whose ideas it
|
|
488
|
+
borrows:
|
|
489
|
+
|
|
490
|
+
- **predicates**: plain functions of the user and the object, made predicates by a decorator (`@predicate`);
|
|
491
|
+
- **composition**: predicates combined with `&`, `|` and `~` into larger rules (here, rulesets);
|
|
492
|
+
- **Django's own API**: an authentication backend answering `user.has_perm(perm, obj)`
|
|
493
|
+
(`auth.ActionPermissionBackend`), and a view decorator and a class-based view mixin in place of Django's
|
|
494
|
+
(`views.permission_required`, `views.PermissionRequiredMixin`).
|
|
495
|
+
|
|
496
|
+
What this library adds is the RBAC side and where the rules live:
|
|
497
|
+
- the groups granted each permission (`Group.permissions`);
|
|
498
|
+
- one ruleset per group and permission;
|
|
499
|
+
- a DRF permission mapping each viewset action to its model permission;
|
|
500
|
+
- the action permissions stored in the database rather than declared in code at import time. Setup writes them
|
|
501
|
+
from definitions in Python or YAML, the admin edits them, and each check reads one entry at a time through a
|
|
502
|
+
cache.
|
|
503
|
+
|
|
504
|
+
A predicate may also answer `None` ("doesn't apply"): the operators skip it, and the rules backend's policy decides
|
|
505
|
+
when nothing else does.
|
|
506
|
+
|
|
507
|
+
## Supported versions
|
|
508
|
+
|
|
509
|
+
Tested on Django 4.2 LTS (Python 3.10, Django REST framework 3.16–3.17), Django 5.2 LTS (Python 3.13) and Django 6.1
|
|
510
|
+
(Python 3.14), with Django REST framework 3.16+, and on Django 6.1 without DRF.
|
|
511
|
+
|
|
512
|
+
Django 4.2 is supported by the 0.4.x releases only; from 0.5 the library requires Django 5.2+.
|
|
513
|
+
|
|
514
|
+
## Development
|
|
515
|
+
|
|
516
|
+
```bash
|
|
517
|
+
pip install -e ".[dev,test,drf,yaml]"
|
|
518
|
+
pre-commit install # ruff on commit, the tests (tox, Django 6.1) on push
|
|
519
|
+
tox # lint, every Django version, combined coverage
|
|
520
|
+
tox -e lint # ruff check and ruff format --check only
|
|
521
|
+
tox -e py314-django61 # one Django version
|
|
522
|
+
tox -e py310-django42 # Django 4.2 LTS, the oldest Python
|
|
523
|
+
tox -e py314-django61-nodrf # without Django REST framework
|
|
524
|
+
```
|
|
525
|
+
|
|
526
|
+
`tox` combines the coverage of every env (`tox -e coverage`, at least 90%): the report is printed, with
|
|
527
|
+
the details in `htmlcov/index.html` and `coverage.xml` for CI.
|
|
528
|
+
|
|
529
|
+
The tests use an in-memory SQLite database (`tests/settings.py`): that is the library's own test setup, not a
|
|
530
|
+
requirement on the app.
|