django-policy-tree 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_policy_tree-0.1.0/LICENSE +21 -0
- django_policy_tree-0.1.0/PKG-INFO +124 -0
- django_policy_tree-0.1.0/README.md +87 -0
- django_policy_tree-0.1.0/pyproject.toml +43 -0
- django_policy_tree-0.1.0/setup.cfg +4 -0
- django_policy_tree-0.1.0/src/django_policy_tree.egg-info/PKG-INFO +124 -0
- django_policy_tree-0.1.0/src/django_policy_tree.egg-info/SOURCES.txt +48 -0
- django_policy_tree-0.1.0/src/django_policy_tree.egg-info/dependency_links.txt +1 -0
- django_policy_tree-0.1.0/src/django_policy_tree.egg-info/requires.txt +21 -0
- django_policy_tree-0.1.0/src/django_policy_tree.egg-info/top_level.txt +1 -0
- django_policy_tree-0.1.0/src/policytree/__init__.py +3 -0
- django_policy_tree-0.1.0/src/policytree/admin.py +159 -0
- django_policy_tree-0.1.0/src/policytree/apps.py +18 -0
- django_policy_tree-0.1.0/src/policytree/audit.py +114 -0
- django_policy_tree-0.1.0/src/policytree/cache.py +95 -0
- django_policy_tree-0.1.0/src/policytree/checks.py +159 -0
- django_policy_tree-0.1.0/src/policytree/conf.py +83 -0
- django_policy_tree-0.1.0/src/policytree/drf.py +842 -0
- django_policy_tree-0.1.0/src/policytree/engine.py +508 -0
- django_policy_tree-0.1.0/src/policytree/management/__init__.py +0 -0
- django_policy_tree-0.1.0/src/policytree/management/commands/__init__.py +0 -0
- django_policy_tree-0.1.0/src/policytree/management/commands/policytree_export.py +35 -0
- django_policy_tree-0.1.0/src/policytree/management/commands/policytree_import_roles.py +61 -0
- django_policy_tree-0.1.0/src/policytree/management/commands/policytree_rebuild_paths.py +11 -0
- django_policy_tree-0.1.0/src/policytree/management/commands/policytree_seed.py +175 -0
- django_policy_tree-0.1.0/src/policytree/migrations/0001_initial.py +92 -0
- django_policy_tree-0.1.0/src/policytree/migrations/0002_effect_condition_audit.py +96 -0
- django_policy_tree-0.1.0/src/policytree/migrations/0003_roles.py +69 -0
- django_policy_tree-0.1.0/src/policytree/migrations/__init__.py +0 -0
- django_policy_tree-0.1.0/src/policytree/models.py +457 -0
- django_policy_tree-0.1.0/src/policytree/registry.py +342 -0
- django_policy_tree-0.1.0/src/policytree/roles.py +164 -0
- django_policy_tree-0.1.0/src/policytree/targets.py +53 -0
- django_policy_tree-0.1.0/src/policytree/templatetags/__init__.py +0 -0
- django_policy_tree-0.1.0/src/policytree/templatetags/policytree.py +43 -0
- django_policy_tree-0.1.0/src/policytree/views.py +150 -0
- django_policy_tree-0.1.0/tests/test_base_model.py +90 -0
- django_policy_tree-0.1.0/tests/test_edges.py +436 -0
- django_policy_tree-0.1.0/tests/test_features.py +481 -0
- django_policy_tree-0.1.0/tests/test_hardening.py +223 -0
- django_policy_tree-0.1.0/tests/test_policies.py +230 -0
- django_policy_tree-0.1.0/tests/test_properties.py +144 -0
- django_policy_tree-0.1.0/tests/test_review_regressions.py +335 -0
- django_policy_tree-0.1.0/tests/test_review_round2.py +355 -0
- django_policy_tree-0.1.0/tests/test_review_round3.py +329 -0
- django_policy_tree-0.1.0/tests/test_review_round4.py +204 -0
- django_policy_tree-0.1.0/tests/test_review_round5.py +226 -0
- django_policy_tree-0.1.0/tests/test_roles_admin.py +310 -0
- django_policy_tree-0.1.0/tests/test_spaces.py +80 -0
- django_policy_tree-0.1.0/tests/test_two_models.py +116 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Gerardo Ballester Jr.
|
|
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,124 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-policy-tree
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Domain-agnostic, tree-based permissions for Django and DRF, with policies superusers edit in the admin.
|
|
5
|
+
Author: Gerardo Ballester Jr.
|
|
6
|
+
License: MIT
|
|
7
|
+
Classifier: Development Status :: 4 - Beta
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Topic :: Security
|
|
11
|
+
Classifier: Framework :: Django
|
|
12
|
+
Classifier: Framework :: Django :: 4.2
|
|
13
|
+
Classifier: Framework :: Django :: 5.2
|
|
14
|
+
Classifier: Framework :: Django :: 6.0
|
|
15
|
+
Classifier: Framework :: Django :: 6.1
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: Django>=4.2
|
|
21
|
+
Provides-Extra: drf
|
|
22
|
+
Requires-Dist: djangorestframework>=3.14; extra == "drf"
|
|
23
|
+
Provides-Extra: test
|
|
24
|
+
Requires-Dist: djangorestframework>=3.14; extra == "test"
|
|
25
|
+
Requires-Dist: pytest>=8; extra == "test"
|
|
26
|
+
Requires-Dist: pytest-django>=4.8; extra == "test"
|
|
27
|
+
Requires-Dist: hypothesis>=6.100; extra == "test"
|
|
28
|
+
Requires-Dist: pytest-cov>=5; extra == "test"
|
|
29
|
+
Provides-Extra: docs
|
|
30
|
+
Requires-Dist: mkdocs<2,>=1.6; extra == "docs"
|
|
31
|
+
Requires-Dist: mkdocs-material<10,>=9.5; extra == "docs"
|
|
32
|
+
Provides-Extra: postgres
|
|
33
|
+
Requires-Dist: psycopg[binary]>=3.1; extra == "postgres"
|
|
34
|
+
Provides-Extra: mysql
|
|
35
|
+
Requires-Dist: mysqlclient>=2.2; extra == "mysql"
|
|
36
|
+
Dynamic: license-file
|
|
37
|
+
|
|
38
|
+
# django-policy-tree
|
|
39
|
+
|
|
40
|
+
Permissions for Django projects whose users belong to a **tree of organizational units**, with rules that **superusers edit in the admin**.
|
|
41
|
+
|
|
42
|
+
Any hierarchy works. You name the levels, you name the roles, you point your models at the tree:
|
|
43
|
+
|
|
44
|
+
| Domain | Tree | Roles |
|
|
45
|
+
|---|---|---|
|
|
46
|
+
| Company | Head office → Region → Branch → Team | admin, manager, member, viewer |
|
|
47
|
+
| Retail | Chain → Region → Store | owner, area manager, store manager, cashier |
|
|
48
|
+
| Healthcare | Hospital group → Hospital → Department → Ward | administrator, head nurse, doctor, nurse |
|
|
49
|
+
| SaaS | Tenant → Workspace → Project | owner, admin, editor, guest |
|
|
50
|
+
| Government | Ministry → Agency → Office | director, officer, clerk |
|
|
51
|
+
| Education | School → Campus → Department | admin, registrar, professor, student |
|
|
52
|
+
|
|
53
|
+
Wherever a user's role *in a unit* decides what they can do, django-policy-tree answers *"can this user do this, to this record, and with which fields?"* from one place, for lists, objects, creates, updates and fields alike.
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
class Project(models.Model):
|
|
57
|
+
organization = models.ForeignKey(Organization, on_delete=models.CASCADE) # the record's place in the tree
|
|
58
|
+
name = models.CharField(max_length=100)
|
|
59
|
+
budget = models.DecimalField(max_digits=12, decimal_places=2)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ProjectViewSet(PolicyQuerysetMixin, viewsets.ModelViewSet):
|
|
63
|
+
queryset = Project.objects.all()
|
|
64
|
+
serializer_class = ProjectSerializer
|
|
65
|
+
permission_classes = [PolicyPermission]
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
With the policy *"managers may update projects, scope tree"*, a manager of the North region sees only projects in North and the branches and teams below it, can't create or edit projects anywhere else, and can't move a project out of North. Add *"members may read projects, but not the `budget` field"* and the field disappears from their responses.
|
|
69
|
+
|
|
70
|
+
## Features
|
|
71
|
+
|
|
72
|
+
- **Default deny**, with allow and deny policies; deny wins
|
|
73
|
+
- **Scopes:** `org`, `tree`, `below`, `ancestors`, `siblings`, `own`, and your own
|
|
74
|
+
- **Conditions** written in code, picked per policy, checked before and/or after a change
|
|
75
|
+
- **Roles** in code or managed by superusers in the admin, with inheritance
|
|
76
|
+
- **Field rules** per record, on the way in and out
|
|
77
|
+
- **DRF** (permission class, viewset and serializer mixins), **plain Django views**, a **template tag**, and an engine for everything else
|
|
78
|
+
- **Audit log**, policies and roles as **JSON in git**, **system checks**
|
|
79
|
+
- Works with **UUID** primary keys and **soft delete**
|
|
80
|
+
|
|
81
|
+
## Install
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
pip install "django-policy-tree[drf]"
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
INSTALLED_APPS = [..., "rest_framework", "policytree", "core"]
|
|
89
|
+
POLICYTREE_ORGANIZATION_MODEL = "core.Organization"
|
|
90
|
+
POLICYTREE_MEMBER_MODEL = "core.Member"
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Then follow the **[Quickstart](docs/quickstart.md)**.
|
|
94
|
+
|
|
95
|
+
## Documentation
|
|
96
|
+
|
|
97
|
+
- [Quickstart](docs/quickstart.md)
|
|
98
|
+
- Concepts: [organizations](docs/concepts/organizations.md), [members and roles](docs/concepts/roles.md), [policies](docs/concepts/policies.md), [scopes](docs/concepts/scopes.md), [conditions](docs/concepts/conditions.md), [fields](docs/concepts/fields.md)
|
|
99
|
+
- Guides: [DRF](docs/guides/drf.md), [plain views](docs/guides/views.md), [engine](docs/guides/engine.md), [UUIDs and soft delete](docs/guides/base-model.md), [operations](docs/guides/operations.md)
|
|
100
|
+
- Recipes, one per domain: [company](docs/recipes/company.md), [retail](docs/recipes/retail.md), [healthcare](docs/recipes/healthcare.md), [SaaS](docs/recipes/saas.md), [education](docs/recipes/school.md)
|
|
101
|
+
- [Security](docs/security.md) · [Performance](docs/performance.md) · [Changelog](CHANGELOG.md)
|
|
102
|
+
|
|
103
|
+
Build the docs site with `pip install mkdocs-material && mkdocs serve`.
|
|
104
|
+
|
|
105
|
+
## Status
|
|
106
|
+
|
|
107
|
+
**0.1**: the API is intended to be the 1.0 API. Tested on Django 4.2, 5.2 and 6.1 with Python 3.10+, on SQLite, PostgreSQL and MariaDB, with property-based tests and an independent security review. It has not yet run in production; read [Security](docs/security.md) before relying on it.
|
|
108
|
+
|
|
109
|
+
## Development
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
pip install -e ".[test]"
|
|
113
|
+
pytest # SQLite
|
|
114
|
+
DB=postgres pytest # see tests/settings.py
|
|
115
|
+
DB=mysql pytest
|
|
116
|
+
pytest benchmarks -q -s # timings
|
|
117
|
+
mkdocs build --strict # docs
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`tests/testapp` holds the test fixtures. They model one sample domain so the tests read naturally; nothing in the package depends on it. See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
121
|
+
|
|
122
|
+
## License
|
|
123
|
+
|
|
124
|
+
MIT
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# django-policy-tree
|
|
2
|
+
|
|
3
|
+
Permissions for Django projects whose users belong to a **tree of organizational units**, with rules that **superusers edit in the admin**.
|
|
4
|
+
|
|
5
|
+
Any hierarchy works. You name the levels, you name the roles, you point your models at the tree:
|
|
6
|
+
|
|
7
|
+
| Domain | Tree | Roles |
|
|
8
|
+
|---|---|---|
|
|
9
|
+
| Company | Head office → Region → Branch → Team | admin, manager, member, viewer |
|
|
10
|
+
| Retail | Chain → Region → Store | owner, area manager, store manager, cashier |
|
|
11
|
+
| Healthcare | Hospital group → Hospital → Department → Ward | administrator, head nurse, doctor, nurse |
|
|
12
|
+
| SaaS | Tenant → Workspace → Project | owner, admin, editor, guest |
|
|
13
|
+
| Government | Ministry → Agency → Office | director, officer, clerk |
|
|
14
|
+
| Education | School → Campus → Department | admin, registrar, professor, student |
|
|
15
|
+
|
|
16
|
+
Wherever a user's role *in a unit* decides what they can do, django-policy-tree answers *"can this user do this, to this record, and with which fields?"* from one place, for lists, objects, creates, updates and fields alike.
|
|
17
|
+
|
|
18
|
+
```python
|
|
19
|
+
class Project(models.Model):
|
|
20
|
+
organization = models.ForeignKey(Organization, on_delete=models.CASCADE) # the record's place in the tree
|
|
21
|
+
name = models.CharField(max_length=100)
|
|
22
|
+
budget = models.DecimalField(max_digits=12, decimal_places=2)
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ProjectViewSet(PolicyQuerysetMixin, viewsets.ModelViewSet):
|
|
26
|
+
queryset = Project.objects.all()
|
|
27
|
+
serializer_class = ProjectSerializer
|
|
28
|
+
permission_classes = [PolicyPermission]
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
With the policy *"managers may update projects, scope tree"*, a manager of the North region sees only projects in North and the branches and teams below it, can't create or edit projects anywhere else, and can't move a project out of North. Add *"members may read projects, but not the `budget` field"* and the field disappears from their responses.
|
|
32
|
+
|
|
33
|
+
## Features
|
|
34
|
+
|
|
35
|
+
- **Default deny**, with allow and deny policies; deny wins
|
|
36
|
+
- **Scopes:** `org`, `tree`, `below`, `ancestors`, `siblings`, `own`, and your own
|
|
37
|
+
- **Conditions** written in code, picked per policy, checked before and/or after a change
|
|
38
|
+
- **Roles** in code or managed by superusers in the admin, with inheritance
|
|
39
|
+
- **Field rules** per record, on the way in and out
|
|
40
|
+
- **DRF** (permission class, viewset and serializer mixins), **plain Django views**, a **template tag**, and an engine for everything else
|
|
41
|
+
- **Audit log**, policies and roles as **JSON in git**, **system checks**
|
|
42
|
+
- Works with **UUID** primary keys and **soft delete**
|
|
43
|
+
|
|
44
|
+
## Install
|
|
45
|
+
|
|
46
|
+
```bash
|
|
47
|
+
pip install "django-policy-tree[drf]"
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
```python
|
|
51
|
+
INSTALLED_APPS = [..., "rest_framework", "policytree", "core"]
|
|
52
|
+
POLICYTREE_ORGANIZATION_MODEL = "core.Organization"
|
|
53
|
+
POLICYTREE_MEMBER_MODEL = "core.Member"
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Then follow the **[Quickstart](docs/quickstart.md)**.
|
|
57
|
+
|
|
58
|
+
## Documentation
|
|
59
|
+
|
|
60
|
+
- [Quickstart](docs/quickstart.md)
|
|
61
|
+
- Concepts: [organizations](docs/concepts/organizations.md), [members and roles](docs/concepts/roles.md), [policies](docs/concepts/policies.md), [scopes](docs/concepts/scopes.md), [conditions](docs/concepts/conditions.md), [fields](docs/concepts/fields.md)
|
|
62
|
+
- Guides: [DRF](docs/guides/drf.md), [plain views](docs/guides/views.md), [engine](docs/guides/engine.md), [UUIDs and soft delete](docs/guides/base-model.md), [operations](docs/guides/operations.md)
|
|
63
|
+
- Recipes, one per domain: [company](docs/recipes/company.md), [retail](docs/recipes/retail.md), [healthcare](docs/recipes/healthcare.md), [SaaS](docs/recipes/saas.md), [education](docs/recipes/school.md)
|
|
64
|
+
- [Security](docs/security.md) · [Performance](docs/performance.md) · [Changelog](CHANGELOG.md)
|
|
65
|
+
|
|
66
|
+
Build the docs site with `pip install mkdocs-material && mkdocs serve`.
|
|
67
|
+
|
|
68
|
+
## Status
|
|
69
|
+
|
|
70
|
+
**0.1**: the API is intended to be the 1.0 API. Tested on Django 4.2, 5.2 and 6.1 with Python 3.10+, on SQLite, PostgreSQL and MariaDB, with property-based tests and an independent security review. It has not yet run in production; read [Security](docs/security.md) before relying on it.
|
|
71
|
+
|
|
72
|
+
## Development
|
|
73
|
+
|
|
74
|
+
```bash
|
|
75
|
+
pip install -e ".[test]"
|
|
76
|
+
pytest # SQLite
|
|
77
|
+
DB=postgres pytest # see tests/settings.py
|
|
78
|
+
DB=mysql pytest
|
|
79
|
+
pytest benchmarks -q -s # timings
|
|
80
|
+
mkdocs build --strict # docs
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
`tests/testapp` holds the test fixtures. They model one sample domain so the tests read naturally; nothing in the package depends on it. See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
84
|
+
|
|
85
|
+
## License
|
|
86
|
+
|
|
87
|
+
MIT
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "django-policy-tree"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Domain-agnostic, tree-based permissions for Django and DRF, with policies superusers edit in the admin."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Gerardo Ballester Jr." }]
|
|
13
|
+
dependencies = ["Django>=4.2"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 4 - Beta",
|
|
16
|
+
"Intended Audience :: Developers",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Topic :: Security",
|
|
19
|
+
"Framework :: Django",
|
|
20
|
+
"Framework :: Django :: 4.2",
|
|
21
|
+
"Framework :: Django :: 5.2",
|
|
22
|
+
"Framework :: Django :: 6.0",
|
|
23
|
+
"Framework :: Django :: 6.1",
|
|
24
|
+
"Programming Language :: Python :: 3",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[project.optional-dependencies]
|
|
28
|
+
drf = ["djangorestframework>=3.14"]
|
|
29
|
+
test = ["djangorestframework>=3.14", "pytest>=8", "pytest-django>=4.8", "hypothesis>=6.100", "pytest-cov>=5"]
|
|
30
|
+
docs = ["mkdocs>=1.6,<2", "mkdocs-material>=9.5,<10"]
|
|
31
|
+
postgres = ["psycopg[binary]>=3.1"]
|
|
32
|
+
mysql = ["mysqlclient>=2.2"]
|
|
33
|
+
|
|
34
|
+
[tool.setuptools.packages.find]
|
|
35
|
+
where = ["src"]
|
|
36
|
+
|
|
37
|
+
[tool.setuptools.package-data]
|
|
38
|
+
policytree = ["migrations/*.py"]
|
|
39
|
+
|
|
40
|
+
[tool.pytest.ini_options]
|
|
41
|
+
DJANGO_SETTINGS_MODULE = "tests.settings"
|
|
42
|
+
pythonpath = ["src", "."]
|
|
43
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: django-policy-tree
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Domain-agnostic, tree-based permissions for Django and DRF, with policies superusers edit in the admin.
|
|
5
|
+
Author: Gerardo Ballester Jr.
|
|
6
|
+
License: MIT
|
|
7
|
+
Classifier: Development Status :: 4 - Beta
|
|
8
|
+
Classifier: Intended Audience :: Developers
|
|
9
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
10
|
+
Classifier: Topic :: Security
|
|
11
|
+
Classifier: Framework :: Django
|
|
12
|
+
Classifier: Framework :: Django :: 4.2
|
|
13
|
+
Classifier: Framework :: Django :: 5.2
|
|
14
|
+
Classifier: Framework :: Django :: 6.0
|
|
15
|
+
Classifier: Framework :: Django :: 6.1
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: Django>=4.2
|
|
21
|
+
Provides-Extra: drf
|
|
22
|
+
Requires-Dist: djangorestframework>=3.14; extra == "drf"
|
|
23
|
+
Provides-Extra: test
|
|
24
|
+
Requires-Dist: djangorestframework>=3.14; extra == "test"
|
|
25
|
+
Requires-Dist: pytest>=8; extra == "test"
|
|
26
|
+
Requires-Dist: pytest-django>=4.8; extra == "test"
|
|
27
|
+
Requires-Dist: hypothesis>=6.100; extra == "test"
|
|
28
|
+
Requires-Dist: pytest-cov>=5; extra == "test"
|
|
29
|
+
Provides-Extra: docs
|
|
30
|
+
Requires-Dist: mkdocs<2,>=1.6; extra == "docs"
|
|
31
|
+
Requires-Dist: mkdocs-material<10,>=9.5; extra == "docs"
|
|
32
|
+
Provides-Extra: postgres
|
|
33
|
+
Requires-Dist: psycopg[binary]>=3.1; extra == "postgres"
|
|
34
|
+
Provides-Extra: mysql
|
|
35
|
+
Requires-Dist: mysqlclient>=2.2; extra == "mysql"
|
|
36
|
+
Dynamic: license-file
|
|
37
|
+
|
|
38
|
+
# django-policy-tree
|
|
39
|
+
|
|
40
|
+
Permissions for Django projects whose users belong to a **tree of organizational units**, with rules that **superusers edit in the admin**.
|
|
41
|
+
|
|
42
|
+
Any hierarchy works. You name the levels, you name the roles, you point your models at the tree:
|
|
43
|
+
|
|
44
|
+
| Domain | Tree | Roles |
|
|
45
|
+
|---|---|---|
|
|
46
|
+
| Company | Head office → Region → Branch → Team | admin, manager, member, viewer |
|
|
47
|
+
| Retail | Chain → Region → Store | owner, area manager, store manager, cashier |
|
|
48
|
+
| Healthcare | Hospital group → Hospital → Department → Ward | administrator, head nurse, doctor, nurse |
|
|
49
|
+
| SaaS | Tenant → Workspace → Project | owner, admin, editor, guest |
|
|
50
|
+
| Government | Ministry → Agency → Office | director, officer, clerk |
|
|
51
|
+
| Education | School → Campus → Department | admin, registrar, professor, student |
|
|
52
|
+
|
|
53
|
+
Wherever a user's role *in a unit* decides what they can do, django-policy-tree answers *"can this user do this, to this record, and with which fields?"* from one place, for lists, objects, creates, updates and fields alike.
|
|
54
|
+
|
|
55
|
+
```python
|
|
56
|
+
class Project(models.Model):
|
|
57
|
+
organization = models.ForeignKey(Organization, on_delete=models.CASCADE) # the record's place in the tree
|
|
58
|
+
name = models.CharField(max_length=100)
|
|
59
|
+
budget = models.DecimalField(max_digits=12, decimal_places=2)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ProjectViewSet(PolicyQuerysetMixin, viewsets.ModelViewSet):
|
|
63
|
+
queryset = Project.objects.all()
|
|
64
|
+
serializer_class = ProjectSerializer
|
|
65
|
+
permission_classes = [PolicyPermission]
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
With the policy *"managers may update projects, scope tree"*, a manager of the North region sees only projects in North and the branches and teams below it, can't create or edit projects anywhere else, and can't move a project out of North. Add *"members may read projects, but not the `budget` field"* and the field disappears from their responses.
|
|
69
|
+
|
|
70
|
+
## Features
|
|
71
|
+
|
|
72
|
+
- **Default deny**, with allow and deny policies; deny wins
|
|
73
|
+
- **Scopes:** `org`, `tree`, `below`, `ancestors`, `siblings`, `own`, and your own
|
|
74
|
+
- **Conditions** written in code, picked per policy, checked before and/or after a change
|
|
75
|
+
- **Roles** in code or managed by superusers in the admin, with inheritance
|
|
76
|
+
- **Field rules** per record, on the way in and out
|
|
77
|
+
- **DRF** (permission class, viewset and serializer mixins), **plain Django views**, a **template tag**, and an engine for everything else
|
|
78
|
+
- **Audit log**, policies and roles as **JSON in git**, **system checks**
|
|
79
|
+
- Works with **UUID** primary keys and **soft delete**
|
|
80
|
+
|
|
81
|
+
## Install
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
pip install "django-policy-tree[drf]"
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
```python
|
|
88
|
+
INSTALLED_APPS = [..., "rest_framework", "policytree", "core"]
|
|
89
|
+
POLICYTREE_ORGANIZATION_MODEL = "core.Organization"
|
|
90
|
+
POLICYTREE_MEMBER_MODEL = "core.Member"
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
Then follow the **[Quickstart](docs/quickstart.md)**.
|
|
94
|
+
|
|
95
|
+
## Documentation
|
|
96
|
+
|
|
97
|
+
- [Quickstart](docs/quickstart.md)
|
|
98
|
+
- Concepts: [organizations](docs/concepts/organizations.md), [members and roles](docs/concepts/roles.md), [policies](docs/concepts/policies.md), [scopes](docs/concepts/scopes.md), [conditions](docs/concepts/conditions.md), [fields](docs/concepts/fields.md)
|
|
99
|
+
- Guides: [DRF](docs/guides/drf.md), [plain views](docs/guides/views.md), [engine](docs/guides/engine.md), [UUIDs and soft delete](docs/guides/base-model.md), [operations](docs/guides/operations.md)
|
|
100
|
+
- Recipes, one per domain: [company](docs/recipes/company.md), [retail](docs/recipes/retail.md), [healthcare](docs/recipes/healthcare.md), [SaaS](docs/recipes/saas.md), [education](docs/recipes/school.md)
|
|
101
|
+
- [Security](docs/security.md) · [Performance](docs/performance.md) · [Changelog](CHANGELOG.md)
|
|
102
|
+
|
|
103
|
+
Build the docs site with `pip install mkdocs-material && mkdocs serve`.
|
|
104
|
+
|
|
105
|
+
## Status
|
|
106
|
+
|
|
107
|
+
**0.1**: the API is intended to be the 1.0 API. Tested on Django 4.2, 5.2 and 6.1 with Python 3.10+, on SQLite, PostgreSQL and MariaDB, with property-based tests and an independent security review. It has not yet run in production; read [Security](docs/security.md) before relying on it.
|
|
108
|
+
|
|
109
|
+
## Development
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
pip install -e ".[test]"
|
|
113
|
+
pytest # SQLite
|
|
114
|
+
DB=postgres pytest # see tests/settings.py
|
|
115
|
+
DB=mysql pytest
|
|
116
|
+
pytest benchmarks -q -s # timings
|
|
117
|
+
mkdocs build --strict # docs
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
`tests/testapp` holds the test fixtures. They model one sample domain so the tests read naturally; nothing in the package depends on it. See [CONTRIBUTING.md](CONTRIBUTING.md).
|
|
121
|
+
|
|
122
|
+
## License
|
|
123
|
+
|
|
124
|
+
MIT
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/django_policy_tree.egg-info/PKG-INFO
|
|
5
|
+
src/django_policy_tree.egg-info/SOURCES.txt
|
|
6
|
+
src/django_policy_tree.egg-info/dependency_links.txt
|
|
7
|
+
src/django_policy_tree.egg-info/requires.txt
|
|
8
|
+
src/django_policy_tree.egg-info/top_level.txt
|
|
9
|
+
src/policytree/__init__.py
|
|
10
|
+
src/policytree/admin.py
|
|
11
|
+
src/policytree/apps.py
|
|
12
|
+
src/policytree/audit.py
|
|
13
|
+
src/policytree/cache.py
|
|
14
|
+
src/policytree/checks.py
|
|
15
|
+
src/policytree/conf.py
|
|
16
|
+
src/policytree/drf.py
|
|
17
|
+
src/policytree/engine.py
|
|
18
|
+
src/policytree/models.py
|
|
19
|
+
src/policytree/registry.py
|
|
20
|
+
src/policytree/roles.py
|
|
21
|
+
src/policytree/targets.py
|
|
22
|
+
src/policytree/views.py
|
|
23
|
+
src/policytree/management/__init__.py
|
|
24
|
+
src/policytree/management/commands/__init__.py
|
|
25
|
+
src/policytree/management/commands/policytree_export.py
|
|
26
|
+
src/policytree/management/commands/policytree_import_roles.py
|
|
27
|
+
src/policytree/management/commands/policytree_rebuild_paths.py
|
|
28
|
+
src/policytree/management/commands/policytree_seed.py
|
|
29
|
+
src/policytree/migrations/0001_initial.py
|
|
30
|
+
src/policytree/migrations/0002_effect_condition_audit.py
|
|
31
|
+
src/policytree/migrations/0003_roles.py
|
|
32
|
+
src/policytree/migrations/__init__.py
|
|
33
|
+
src/policytree/templatetags/__init__.py
|
|
34
|
+
src/policytree/templatetags/policytree.py
|
|
35
|
+
tests/test_base_model.py
|
|
36
|
+
tests/test_edges.py
|
|
37
|
+
tests/test_features.py
|
|
38
|
+
tests/test_hardening.py
|
|
39
|
+
tests/test_policies.py
|
|
40
|
+
tests/test_properties.py
|
|
41
|
+
tests/test_review_regressions.py
|
|
42
|
+
tests/test_review_round2.py
|
|
43
|
+
tests/test_review_round3.py
|
|
44
|
+
tests/test_review_round4.py
|
|
45
|
+
tests/test_review_round5.py
|
|
46
|
+
tests/test_roles_admin.py
|
|
47
|
+
tests/test_spaces.py
|
|
48
|
+
tests/test_two_models.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
Django>=4.2
|
|
2
|
+
|
|
3
|
+
[docs]
|
|
4
|
+
mkdocs<2,>=1.6
|
|
5
|
+
mkdocs-material<10,>=9.5
|
|
6
|
+
|
|
7
|
+
[drf]
|
|
8
|
+
djangorestframework>=3.14
|
|
9
|
+
|
|
10
|
+
[mysql]
|
|
11
|
+
mysqlclient>=2.2
|
|
12
|
+
|
|
13
|
+
[postgres]
|
|
14
|
+
psycopg[binary]>=3.1
|
|
15
|
+
|
|
16
|
+
[test]
|
|
17
|
+
djangorestframework>=3.14
|
|
18
|
+
pytest>=8
|
|
19
|
+
pytest-django>=4.8
|
|
20
|
+
hypothesis>=6.100
|
|
21
|
+
pytest-cov>=5
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
policytree
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
from django import forms
|
|
2
|
+
from django.contrib import admin
|
|
3
|
+
|
|
4
|
+
from .audit import PolicyAudit
|
|
5
|
+
from .models import Policy, PolicyChange, Role
|
|
6
|
+
from .registry import ConditionRegistry, ScopeRegistry
|
|
7
|
+
from .roles import Roles, RoleTree
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class PolicyForm(forms.ModelForm):
|
|
11
|
+
class Meta:
|
|
12
|
+
model = Policy
|
|
13
|
+
fields = "__all__"
|
|
14
|
+
|
|
15
|
+
def __init__(self, *args, **kwargs):
|
|
16
|
+
super().__init__(*args, **kwargs)
|
|
17
|
+
roles = Roles.choices()
|
|
18
|
+
if roles or Roles.uses_model():
|
|
19
|
+
self.fields["role"] = forms.ChoiceField(choices=[("", "---------")] + roles)
|
|
20
|
+
self.fields["scope"] = forms.ChoiceField(choices=ScopeRegistry.choices(), initial="org")
|
|
21
|
+
self.fields["condition"] = forms.ChoiceField(
|
|
22
|
+
choices=[("", "— none —")] + ConditionRegistry.choices(), required=False
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class RoleForm(forms.ModelForm):
|
|
27
|
+
class Meta:
|
|
28
|
+
model = Role
|
|
29
|
+
fields = ["code", "name", "description", "is_active", "includes"]
|
|
30
|
+
|
|
31
|
+
def __init__(self, *args, **kwargs):
|
|
32
|
+
super().__init__(*args, **kwargs)
|
|
33
|
+
if "includes" in self.fields and self.instance.pk:
|
|
34
|
+
self.fields["includes"].queryset = Role.objects.exclude(pk=self.instance.pk)
|
|
35
|
+
|
|
36
|
+
def clean(self):
|
|
37
|
+
cleaned = super().clean()
|
|
38
|
+
code = cleaned.get("code") or self.instance.code
|
|
39
|
+
includes = cleaned.get("includes")
|
|
40
|
+
if code and includes is not None:
|
|
41
|
+
graph = {
|
|
42
|
+
role.code: list(role.includes.values_list("code", flat=True))
|
|
43
|
+
for role in Role.objects.exclude(code=code)
|
|
44
|
+
}
|
|
45
|
+
graph[code] = [role.code for role in includes]
|
|
46
|
+
cycle = RoleTree.find_cycle(graph)
|
|
47
|
+
if cycle:
|
|
48
|
+
self.add_error("includes", f"That would make roles include each other: {' -> '.join(cycle)}.")
|
|
49
|
+
return cleaned
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class SuperuserOnlyAdmin(admin.ModelAdmin):
|
|
53
|
+
def has_module_permission(self, request):
|
|
54
|
+
return request.user.is_active and request.user.is_superuser
|
|
55
|
+
|
|
56
|
+
def has_view_permission(self, request, obj=None):
|
|
57
|
+
return self.has_module_permission(request)
|
|
58
|
+
|
|
59
|
+
def has_add_permission(self, request):
|
|
60
|
+
return self.has_module_permission(request)
|
|
61
|
+
|
|
62
|
+
def has_change_permission(self, request, obj=None):
|
|
63
|
+
return self.has_module_permission(request)
|
|
64
|
+
|
|
65
|
+
def has_delete_permission(self, request, obj=None):
|
|
66
|
+
return self.has_module_permission(request)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
@admin.register(Role)
|
|
70
|
+
class RoleAdmin(SuperuserOnlyAdmin):
|
|
71
|
+
"""Shown only when the member model's `role` is a RoleField."""
|
|
72
|
+
|
|
73
|
+
form = RoleForm
|
|
74
|
+
list_display = ["name", "code", "is_active", "included_roles", "policies", "members", "updated_at"]
|
|
75
|
+
list_filter = ["is_active"]
|
|
76
|
+
search_fields = ["code", "name"]
|
|
77
|
+
filter_horizontal = ["includes"]
|
|
78
|
+
readonly_fields = ["updated_at"]
|
|
79
|
+
|
|
80
|
+
def has_module_permission(self, request):
|
|
81
|
+
return super().has_module_permission(request) and Roles.uses_model()
|
|
82
|
+
|
|
83
|
+
def has_delete_permission(self, request, obj=None):
|
|
84
|
+
if not super().has_delete_permission(request, obj):
|
|
85
|
+
return False
|
|
86
|
+
return obj is None or not obj.in_use()
|
|
87
|
+
|
|
88
|
+
def get_readonly_fields(self, request, obj=None):
|
|
89
|
+
fields = list(super().get_readonly_fields(request, obj))
|
|
90
|
+
return fields + ["code"] if obj is not None else fields
|
|
91
|
+
|
|
92
|
+
@admin.display(description="Includes")
|
|
93
|
+
def included_roles(self, obj):
|
|
94
|
+
return ", ".join(obj.includes.values_list("code", flat=True)) or "—"
|
|
95
|
+
|
|
96
|
+
@admin.display(description="Policies")
|
|
97
|
+
def policies(self, obj):
|
|
98
|
+
return obj.policy_count()
|
|
99
|
+
|
|
100
|
+
@admin.display(description="Members")
|
|
101
|
+
def members(self, obj):
|
|
102
|
+
return obj.member_count()
|
|
103
|
+
|
|
104
|
+
def save_model(self, request, obj, form, change):
|
|
105
|
+
PolicyAudit.set_actor(obj, request.user, source="admin", defer=True)
|
|
106
|
+
super().save_model(request, obj, form, change)
|
|
107
|
+
|
|
108
|
+
def save_related(self, request, form, formsets, change):
|
|
109
|
+
super().save_related(request, form, formsets, change)
|
|
110
|
+
kind = PolicyChange.Kind.UPDATED if change else PolicyChange.Kind.CREATED
|
|
111
|
+
PolicyAudit.record_role(form.instance, kind)
|
|
112
|
+
|
|
113
|
+
def delete_model(self, request, obj):
|
|
114
|
+
PolicyAudit.set_actor(obj, request.user, source="admin")
|
|
115
|
+
super().delete_model(request, obj)
|
|
116
|
+
|
|
117
|
+
def delete_queryset(self, request, queryset):
|
|
118
|
+
# The admin refuses bulk deletes that include roles in use (see
|
|
119
|
+
# has_delete_permission), and Role.delete() refuses them too.
|
|
120
|
+
for obj in queryset:
|
|
121
|
+
self.delete_model(request, obj)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@admin.register(Policy)
|
|
125
|
+
class PolicyAdmin(SuperuserOnlyAdmin):
|
|
126
|
+
form = PolicyForm
|
|
127
|
+
list_display = ["code", "role", "content_type", "action", "effect", "scope", "condition", "updated_at"]
|
|
128
|
+
list_filter = ["effect", "role", "content_type", "action", "scope"]
|
|
129
|
+
search_fields = ["code", "name", "role"]
|
|
130
|
+
readonly_fields = ["updated_at"]
|
|
131
|
+
|
|
132
|
+
def save_model(self, request, obj, form, change):
|
|
133
|
+
PolicyAudit.set_actor(obj, request.user, source="admin")
|
|
134
|
+
super().save_model(request, obj, form, change)
|
|
135
|
+
|
|
136
|
+
def delete_model(self, request, obj):
|
|
137
|
+
PolicyAudit.set_actor(obj, request.user, source="admin")
|
|
138
|
+
super().delete_model(request, obj)
|
|
139
|
+
|
|
140
|
+
def delete_queryset(self, request, queryset):
|
|
141
|
+
for obj in queryset:
|
|
142
|
+
self.delete_model(request, obj)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
@admin.register(PolicyChange)
|
|
146
|
+
class PolicyChangeAdmin(SuperuserOnlyAdmin):
|
|
147
|
+
list_display = ["changed_at", "target", "policy_code", "kind", "source", "changed_by"]
|
|
148
|
+
list_filter = ["target", "kind", "source"]
|
|
149
|
+
search_fields = ["policy_code"]
|
|
150
|
+
readonly_fields = ["target", "policy_code", "kind", "data", "source", "changed_by", "changed_at"]
|
|
151
|
+
|
|
152
|
+
def has_add_permission(self, request):
|
|
153
|
+
return False
|
|
154
|
+
|
|
155
|
+
def has_change_permission(self, request, obj=None):
|
|
156
|
+
return False
|
|
157
|
+
|
|
158
|
+
def has_delete_permission(self, request, obj=None):
|
|
159
|
+
return False
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
from django.apps import AppConfig
|
|
2
|
+
from django.utils.module_loading import autodiscover_modules
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
class PolicyTreeConfig(AppConfig):
|
|
6
|
+
name = "policytree"
|
|
7
|
+
verbose_name = "Policy tree"
|
|
8
|
+
default_auto_field = "django.db.models.BigAutoField"
|
|
9
|
+
|
|
10
|
+
def ready(self):
|
|
11
|
+
from .audit import PolicyAudit
|
|
12
|
+
from .checks import PolicyTreeChecks
|
|
13
|
+
|
|
14
|
+
PolicyTreeChecks.register()
|
|
15
|
+
PolicyAudit.connect()
|
|
16
|
+
|
|
17
|
+
# Each app may register scopes and conditions in a `policies.py` module.
|
|
18
|
+
autodiscover_modules("policies")
|