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.
Files changed (64) hide show
  1. django_action_permissions-0.4.0/LICENSE +29 -0
  2. django_action_permissions-0.4.0/PKG-INFO +530 -0
  3. django_action_permissions-0.4.0/README.md +484 -0
  4. django_action_permissions-0.4.0/django_action_permissions/__init__.py +22 -0
  5. django_action_permissions-0.4.0/django_action_permissions/abac.py +171 -0
  6. django_action_permissions-0.4.0/django_action_permissions/admin.py +692 -0
  7. django_action_permissions-0.4.0/django_action_permissions/apps.py +11 -0
  8. django_action_permissions-0.4.0/django_action_permissions/auth.py +30 -0
  9. django_action_permissions-0.4.0/django_action_permissions/backends.py +79 -0
  10. django_action_permissions-0.4.0/django_action_permissions/checks.py +204 -0
  11. django_action_permissions-0.4.0/django_action_permissions/conf.py +30 -0
  12. django_action_permissions-0.4.0/django_action_permissions/entries.py +115 -0
  13. django_action_permissions-0.4.0/django_action_permissions/explain.py +221 -0
  14. django_action_permissions-0.4.0/django_action_permissions/management/__init__.py +0 -0
  15. django_action_permissions-0.4.0/django_action_permissions/management/commands/__init__.py +0 -0
  16. django_action_permissions-0.4.0/django_action_permissions/management/commands/django_action_permissions_export.py +21 -0
  17. django_action_permissions-0.4.0/django_action_permissions/management/commands/django_action_permissions_load.py +32 -0
  18. django_action_permissions-0.4.0/django_action_permissions/migrations/0001_initial.py +76 -0
  19. django_action_permissions-0.4.0/django_action_permissions/migrations/__init__.py +0 -0
  20. django_action_permissions-0.4.0/django_action_permissions/mixins.py +18 -0
  21. django_action_permissions-0.4.0/django_action_permissions/models.py +124 -0
  22. django_action_permissions-0.4.0/django_action_permissions/permissions.py +156 -0
  23. django_action_permissions-0.4.0/django_action_permissions/predicates.py +391 -0
  24. django_action_permissions-0.4.0/django_action_permissions/rbac.py +183 -0
  25. django_action_permissions-0.4.0/django_action_permissions/rules.py +46 -0
  26. django_action_permissions-0.4.0/django_action_permissions/signals.py +60 -0
  27. django_action_permissions-0.4.0/django_action_permissions/static/django_action_permissions/changelist.js +25 -0
  28. django_action_permissions-0.4.0/django_action_permissions/static/django_action_permissions/ruleset.css +81 -0
  29. django_action_permissions-0.4.0/django_action_permissions/static/django_action_permissions/ruleset.js +369 -0
  30. django_action_permissions-0.4.0/django_action_permissions/templates/admin/django_action_permissions/actionpermission/_answer.html +1 -0
  31. django_action_permissions-0.4.0/django_action_permissions/templates/admin/django_action_permissions/actionpermission/_breadcrumbs.html +7 -0
  32. django_action_permissions-0.4.0/django_action_permissions/templates/admin/django_action_permissions/actionpermission/_trace_node.html +8 -0
  33. django_action_permissions-0.4.0/django_action_permissions/templates/admin/django_action_permissions/actionpermission/change_list.html +22 -0
  34. django_action_permissions-0.4.0/django_action_permissions/templates/admin/django_action_permissions/actionpermission/check.html +24 -0
  35. django_action_permissions-0.4.0/django_action_permissions/templates/admin/django_action_permissions/actionpermission/load.html +16 -0
  36. django_action_permissions-0.4.0/django_action_permissions/templates/admin/django_action_permissions/actionpermission/overview.html +85 -0
  37. django_action_permissions-0.4.0/django_action_permissions/templates/admin/django_action_permissions/actionpermission/verify.html +102 -0
  38. django_action_permissions-0.4.0/django_action_permissions/templates/admin/django_action_permissions/actionpermissionrule/change_list.html +9 -0
  39. django_action_permissions-0.4.0/django_action_permissions/templates/admin/django_action_permissions/actionpermissionrule/sync.html +31 -0
  40. django_action_permissions-0.4.0/django_action_permissions/templates/django_action_permissions/widgets/ruleset.html +1 -0
  41. django_action_permissions-0.4.0/django_action_permissions/utils.py +26 -0
  42. django_action_permissions-0.4.0/django_action_permissions/views.py +153 -0
  43. django_action_permissions-0.4.0/django_action_permissions/yaml_io.py +144 -0
  44. django_action_permissions-0.4.0/django_action_permissions.egg-info/PKG-INFO +530 -0
  45. django_action_permissions-0.4.0/django_action_permissions.egg-info/SOURCES.txt +62 -0
  46. django_action_permissions-0.4.0/django_action_permissions.egg-info/dependency_links.txt +1 -0
  47. django_action_permissions-0.4.0/django_action_permissions.egg-info/requires.txt +19 -0
  48. django_action_permissions-0.4.0/django_action_permissions.egg-info/top_level.txt +1 -0
  49. django_action_permissions-0.4.0/pyproject.toml +104 -0
  50. django_action_permissions-0.4.0/setup.cfg +4 -0
  51. django_action_permissions-0.4.0/tests/test_abac.py +260 -0
  52. django_action_permissions-0.4.0/tests/test_admin.py +628 -0
  53. django_action_permissions-0.4.0/tests/test_auth.py +111 -0
  54. django_action_permissions-0.4.0/tests/test_backends.py +90 -0
  55. django_action_permissions-0.4.0/tests/test_cache.py +174 -0
  56. django_action_permissions-0.4.0/tests/test_checks.py +211 -0
  57. django_action_permissions-0.4.0/tests/test_explain.py +227 -0
  58. django_action_permissions-0.4.0/tests/test_mixins.py +27 -0
  59. django_action_permissions-0.4.0/tests/test_models.py +84 -0
  60. django_action_permissions-0.4.0/tests/test_permissions.py +325 -0
  61. django_action_permissions-0.4.0/tests/test_predicates.py +325 -0
  62. django_action_permissions-0.4.0/tests/test_rbac.py +245 -0
  63. django_action_permissions-0.4.0/tests/test_views.py +309 -0
  64. 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
+ [![Python](https://img.shields.io/badge/python-3.10%20%7C%203.11%20%7C%203.12%20%7C%203.13%20%7C%203.14-3776AB?logo=python&logoColor=white)](#supported-versions)
50
+ [![Django](https://img.shields.io/badge/django-4.2%20LTS%20%7C%205.2%20LTS%20%7C%206.1-092E20?logo=django&logoColor=white)](#supported-versions)
51
+ [![Django REST framework](https://img.shields.io/badge/DRF-3.16%2B%20%28optional%29-A30000)](#usage)
52
+ [![Tested](https://img.shields.io/badge/tested-Django%204.2%20%2B%20Py%203.10%20%7C%20Django%205.2%20%2B%20Py%203.13%20%7C%20Django%206.1%20%2B%20Py%203.14%20%7C%20without%20DRF-success)](#development)
53
+ [![PyPI](https://img.shields.io/pypi/v/django-action-permissions)](https://pypi.org/p/django-action-permissions)
54
+ [![License: BSD 3-Clause](https://img.shields.io/badge/license-BSD%203--Clause-blue)](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.