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.
Files changed (50) hide show
  1. django_policy_tree-0.1.0/LICENSE +21 -0
  2. django_policy_tree-0.1.0/PKG-INFO +124 -0
  3. django_policy_tree-0.1.0/README.md +87 -0
  4. django_policy_tree-0.1.0/pyproject.toml +43 -0
  5. django_policy_tree-0.1.0/setup.cfg +4 -0
  6. django_policy_tree-0.1.0/src/django_policy_tree.egg-info/PKG-INFO +124 -0
  7. django_policy_tree-0.1.0/src/django_policy_tree.egg-info/SOURCES.txt +48 -0
  8. django_policy_tree-0.1.0/src/django_policy_tree.egg-info/dependency_links.txt +1 -0
  9. django_policy_tree-0.1.0/src/django_policy_tree.egg-info/requires.txt +21 -0
  10. django_policy_tree-0.1.0/src/django_policy_tree.egg-info/top_level.txt +1 -0
  11. django_policy_tree-0.1.0/src/policytree/__init__.py +3 -0
  12. django_policy_tree-0.1.0/src/policytree/admin.py +159 -0
  13. django_policy_tree-0.1.0/src/policytree/apps.py +18 -0
  14. django_policy_tree-0.1.0/src/policytree/audit.py +114 -0
  15. django_policy_tree-0.1.0/src/policytree/cache.py +95 -0
  16. django_policy_tree-0.1.0/src/policytree/checks.py +159 -0
  17. django_policy_tree-0.1.0/src/policytree/conf.py +83 -0
  18. django_policy_tree-0.1.0/src/policytree/drf.py +842 -0
  19. django_policy_tree-0.1.0/src/policytree/engine.py +508 -0
  20. django_policy_tree-0.1.0/src/policytree/management/__init__.py +0 -0
  21. django_policy_tree-0.1.0/src/policytree/management/commands/__init__.py +0 -0
  22. django_policy_tree-0.1.0/src/policytree/management/commands/policytree_export.py +35 -0
  23. django_policy_tree-0.1.0/src/policytree/management/commands/policytree_import_roles.py +61 -0
  24. django_policy_tree-0.1.0/src/policytree/management/commands/policytree_rebuild_paths.py +11 -0
  25. django_policy_tree-0.1.0/src/policytree/management/commands/policytree_seed.py +175 -0
  26. django_policy_tree-0.1.0/src/policytree/migrations/0001_initial.py +92 -0
  27. django_policy_tree-0.1.0/src/policytree/migrations/0002_effect_condition_audit.py +96 -0
  28. django_policy_tree-0.1.0/src/policytree/migrations/0003_roles.py +69 -0
  29. django_policy_tree-0.1.0/src/policytree/migrations/__init__.py +0 -0
  30. django_policy_tree-0.1.0/src/policytree/models.py +457 -0
  31. django_policy_tree-0.1.0/src/policytree/registry.py +342 -0
  32. django_policy_tree-0.1.0/src/policytree/roles.py +164 -0
  33. django_policy_tree-0.1.0/src/policytree/targets.py +53 -0
  34. django_policy_tree-0.1.0/src/policytree/templatetags/__init__.py +0 -0
  35. django_policy_tree-0.1.0/src/policytree/templatetags/policytree.py +43 -0
  36. django_policy_tree-0.1.0/src/policytree/views.py +150 -0
  37. django_policy_tree-0.1.0/tests/test_base_model.py +90 -0
  38. django_policy_tree-0.1.0/tests/test_edges.py +436 -0
  39. django_policy_tree-0.1.0/tests/test_features.py +481 -0
  40. django_policy_tree-0.1.0/tests/test_hardening.py +223 -0
  41. django_policy_tree-0.1.0/tests/test_policies.py +230 -0
  42. django_policy_tree-0.1.0/tests/test_properties.py +144 -0
  43. django_policy_tree-0.1.0/tests/test_review_regressions.py +335 -0
  44. django_policy_tree-0.1.0/tests/test_review_round2.py +355 -0
  45. django_policy_tree-0.1.0/tests/test_review_round3.py +329 -0
  46. django_policy_tree-0.1.0/tests/test_review_round4.py +204 -0
  47. django_policy_tree-0.1.0/tests/test_review_round5.py +226 -0
  48. django_policy_tree-0.1.0/tests/test_roles_admin.py +310 -0
  49. django_policy_tree-0.1.0/tests/test_spaces.py +80 -0
  50. 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,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,3 @@
1
+ """Organization-tree permissions with database-driven policies for Django and DRF."""
2
+
3
+ __version__ = "0.1.0"
@@ -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")