authzlock 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.
- authzlock-0.1.0/.gitignore +25 -0
- authzlock-0.1.0/.pre-commit-config.yaml +8 -0
- authzlock-0.1.0/.pre-commit-hooks.yaml +23 -0
- authzlock-0.1.0/CHANGELOG.md +42 -0
- authzlock-0.1.0/CONTRIBUTING.md +144 -0
- authzlock-0.1.0/LICENSE +21 -0
- authzlock-0.1.0/PKG-INFO +357 -0
- authzlock-0.1.0/README.md +310 -0
- authzlock-0.1.0/action.yml +131 -0
- authzlock-0.1.0/noxfile.py +66 -0
- authzlock-0.1.0/pyproject.toml +102 -0
- authzlock-0.1.0/src/authzlock/__init__.py +5 -0
- authzlock-0.1.0/src/authzlock/__main__.py +7 -0
- authzlock-0.1.0/src/authzlock/_dump.py +29 -0
- authzlock-0.1.0/src/authzlock/_github.py +201 -0
- authzlock-0.1.0/src/authzlock/classify.py +370 -0
- authzlock-0.1.0/src/authzlock/cli.py +245 -0
- authzlock-0.1.0/src/authzlock/diff.py +218 -0
- authzlock-0.1.0/src/authzlock/django_loader.py +66 -0
- authzlock-0.1.0/src/authzlock/errors.py +23 -0
- authzlock-0.1.0/src/authzlock/extract/__init__.py +44 -0
- authzlock-0.1.0/src/authzlock/extract/custom.py +80 -0
- authzlock-0.1.0/src/authzlock/extract/decorators.py +229 -0
- authzlock-0.1.0/src/authzlock/extract/drf.py +95 -0
- authzlock-0.1.0/src/authzlock/extract/methods.py +51 -0
- authzlock-0.1.0/src/authzlock/extract/scoping.py +69 -0
- authzlock-0.1.0/src/authzlock/extract/source.py +43 -0
- authzlock-0.1.0/src/authzlock/extract/urls.py +63 -0
- authzlock-0.1.0/src/authzlock/gitutil.py +85 -0
- authzlock-0.1.0/src/authzlock/lockfile.py +214 -0
- authzlock-0.1.0/src/authzlock/model.py +97 -0
- authzlock-0.1.0/src/authzlock/py.typed +0 -0
- authzlock-0.1.0/src/authzlock/render.py +297 -0
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*.egg-info/
|
|
5
|
+
build/
|
|
6
|
+
dist/
|
|
7
|
+
.eggs/
|
|
8
|
+
|
|
9
|
+
# Environments
|
|
10
|
+
.venv/
|
|
11
|
+
venv/
|
|
12
|
+
.nox/
|
|
13
|
+
.tox/
|
|
14
|
+
|
|
15
|
+
# Tooling caches
|
|
16
|
+
.pytest_cache/
|
|
17
|
+
.mypy_cache/
|
|
18
|
+
.ruff_cache/
|
|
19
|
+
.coverage
|
|
20
|
+
htmlcov/
|
|
21
|
+
|
|
22
|
+
# Editors and OS
|
|
23
|
+
.idea/
|
|
24
|
+
.vscode/
|
|
25
|
+
.DS_Store
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# Hooks that other repositories can use to keep their authz.lock current.
|
|
2
|
+
# Usage and configuration: docs/pre-commit.md.
|
|
3
|
+
#
|
|
4
|
+
# pre-commit runs hooks from the repository root. The entry `python -m authzlock` gets the
|
|
5
|
+
# current directory on sys.path from the interpreter; the `authzlock` script would work as
|
|
6
|
+
# well, since authzlock puts the current directory first on sys.path itself. Either way the
|
|
7
|
+
# project's settings module can be imported without setting PYTHONPATH.
|
|
8
|
+
- id: authzlock-check
|
|
9
|
+
name: authzlock check
|
|
10
|
+
description: Fail when authz.lock does not match the Django project's access rules.
|
|
11
|
+
entry: python -m authzlock check
|
|
12
|
+
language: python
|
|
13
|
+
pass_filenames: false
|
|
14
|
+
always_run: true
|
|
15
|
+
require_serial: true
|
|
16
|
+
- id: authzlock-update
|
|
17
|
+
name: authzlock update
|
|
18
|
+
description: Rewrite authz.lock from the Django project; fails when the file changed.
|
|
19
|
+
entry: python -m authzlock update
|
|
20
|
+
language: python
|
|
21
|
+
pass_filenames: false
|
|
22
|
+
always_run: true
|
|
23
|
+
require_serial: true
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to authzlock are recorded here. The format follows
|
|
4
|
+
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/) and the project uses
|
|
5
|
+
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
6
|
+
|
|
7
|
+
## [Unreleased]
|
|
8
|
+
|
|
9
|
+
## [0.1.0] - 2026-10-02
|
|
10
|
+
|
|
11
|
+
First release.
|
|
12
|
+
|
|
13
|
+
### Added
|
|
14
|
+
|
|
15
|
+
- Package skeleton with the `authzlock` command and `authzlock --version`.
|
|
16
|
+
- Loading a Django project from `DJANGO_SETTINGS_MODULE`, with plain error messages when the
|
|
17
|
+
settings module is missing or fails to import.
|
|
18
|
+
- Support for Python 3.10 to 3.13, Django 4.2, 5.1, 5.2, 6.0 and 6.1, and Django REST
|
|
19
|
+
Framework 3.14 and newer, tested in CI on every pull request.
|
|
20
|
+
- Release workflow that publishes to PyPI from a version tag using trusted publishing.
|
|
21
|
+
- `authzlock update` writes the project's access rules to `authz.lock`, a sorted YAML file
|
|
22
|
+
with schema version 1, and `authzlock check` exits 1 with a readable report when the
|
|
23
|
+
project and the lockfile differ. See `docs/lockfile.md` and `docs/cli.md`.
|
|
24
|
+
- pre-commit hooks `authzlock-check` and `authzlock-update`. See `docs/pre-commit.md`.
|
|
25
|
+
- `authzlock diff --base <ref>`: compares the lockfile committed at a git ref with the
|
|
26
|
+
current project and labels each route `loosened`, `tightened`, `added`, `removed` or
|
|
27
|
+
`changed-unknown`, as text or as markdown for a pull request comment, with
|
|
28
|
+
`--fail-on loosened` to fail only on loosened routes.
|
|
29
|
+
- GitHub Action (`smhasan94/authzlock@v1`): runs `authzlock diff` on a pull request, keeps one
|
|
30
|
+
comment with the changes up to date across pushes, and fails the job when a route is
|
|
31
|
+
loosened (`fail-on-loosened`, default true). See `docs/github-action.md`.
|
|
32
|
+
- Reference documentation indexed in `docs/index.md`, including
|
|
33
|
+
`docs/heuristics-and-limits.md` on what the heuristics cannot tell you, and a contributor
|
|
34
|
+
guide, `CONTRIBUTING.md`.
|
|
35
|
+
|
|
36
|
+
### Fixed
|
|
37
|
+
|
|
38
|
+
- `authzlock` now declares Django 4.2 or newer as a dependency, so `pip install authzlock`
|
|
39
|
+
followed by `authzlock --version` works in a fresh environment. DRF stays optional.
|
|
40
|
+
- The `authzlock` command now finds the project's settings module when run from the project
|
|
41
|
+
root without `PYTHONPATH`: the current directory is put first on the import path, as
|
|
42
|
+
`manage.py` and `python -m authzlock` do.
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# Contributing to authzlock
|
|
2
|
+
|
|
3
|
+
This page covers the development setup, the checks a change must pass, the branch and commit
|
|
4
|
+
conventions, and how to add a fixture project. [DEVELOPMENT.md](DEVELOPMENT.md) holds the
|
|
5
|
+
project brief and the same commands; `tests/test_docs.py` fails when the shell commands on
|
|
6
|
+
the two pages differ, so change both together. [docs/index.md](docs/index.md) lists the
|
|
7
|
+
reference pages.
|
|
8
|
+
|
|
9
|
+
## Setup
|
|
10
|
+
|
|
11
|
+
Python 3.10 or newer and git are needed. Set up a dev environment once:
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
python -m venv .venv && source .venv/bin/activate
|
|
15
|
+
pip install -e ".[dev]"
|
|
16
|
+
pip install nox
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Checks
|
|
20
|
+
|
|
21
|
+
Lint and format check (what CI runs):
|
|
22
|
+
|
|
23
|
+
```sh
|
|
24
|
+
nox -s lint
|
|
25
|
+
# equivalent to:
|
|
26
|
+
ruff check . && ruff format --check .
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
Auto-fix formatting and lint:
|
|
30
|
+
|
|
31
|
+
```sh
|
|
32
|
+
ruff format . && ruff check --fix .
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Type check (mypy strict on the package):
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
nox -s typecheck
|
|
39
|
+
# equivalent to:
|
|
40
|
+
mypy src/authzlock
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Tests, full matrix (Python 3.10-3.13 x Django 4.2/5.x):
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
nox -s tests
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Tests, one interpreter and Django version:
|
|
50
|
+
|
|
51
|
+
```sh
|
|
52
|
+
nox -s "tests(python='3.12', django='5.2')"
|
|
53
|
+
# or directly in the active venv:
|
|
54
|
+
pytest
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
Tests against the oldest supported DRF (3.14, on Python 3.12 and Django 4.2):
|
|
58
|
+
|
|
59
|
+
```sh
|
|
60
|
+
nox -s tests_min_drf
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Run everything CI runs:
|
|
64
|
+
|
|
65
|
+
```sh
|
|
66
|
+
nox
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Session names are defined in `noxfile.py`. CI runs the same sessions on every pull request:
|
|
70
|
+
`lint`, `typecheck`, eleven `tests (python=X, django=Y)` cells and the DRF 3.14 cell, plus
|
|
71
|
+
the GitHub Action end-to-end jobs described in
|
|
72
|
+
[docs/github-action.md](docs/github-action.md#testing-the-action).
|
|
73
|
+
|
|
74
|
+
## Branches, commits and pull requests
|
|
75
|
+
|
|
76
|
+
- Work happens on short-lived branches off `main`. A branch is named after its Linear
|
|
77
|
+
ticket: the lowercase identifier, a hyphen and a short slug of the title, for example
|
|
78
|
+
`sha-12-url-resolver-walk`.
|
|
79
|
+
- Commits use conventional prefixes: `feat:`, `fix:`, `docs:`, `chore:`, `test:`, `ci:`.
|
|
80
|
+
- Tests come first. Each test carries the ticket's T-case id in its name, for example
|
|
81
|
+
`test_t3_missing_module_raises_project_load_error`, and is committed before the change
|
|
82
|
+
that makes it pass.
|
|
83
|
+
- Every code change goes through a pull request against `main`. It is merged once CI passes
|
|
84
|
+
and it has been reviewed. `docs/plans` and `docs/backlog.md` may be committed to `main`
|
|
85
|
+
directly.
|
|
86
|
+
- A change is done when every T-case of its ticket passes in CI, ruff and mypy pass and the
|
|
87
|
+
docs are updated.
|
|
88
|
+
- Add a line to the `## [Unreleased]` section of [CHANGELOG.md](CHANGELOG.md) for any change
|
|
89
|
+
a user would notice.
|
|
90
|
+
|
|
91
|
+
Keep these rules from [DEVELOPMENT.md](DEVELOPMENT.md#working-rules) in mind: classification
|
|
92
|
+
stays conservative (when unsure, `changed-unknown`), the package makes no network calls,
|
|
93
|
+
lockfile output stays deterministic, and docs and messages use plain language.
|
|
94
|
+
|
|
95
|
+
## Adding a fixture project
|
|
96
|
+
|
|
97
|
+
Fixture projects under `tests/fixtures/` are small Django projects, one per view style.
|
|
98
|
+
[tests/fixtures/README.md](tests/fixtures/README.md) describes their layout and how tests
|
|
99
|
+
use them. To add one:
|
|
100
|
+
|
|
101
|
+
1. Create `tests/fixtures/<style>/` with `settings.py` (`ROOT_URLCONF`, `INSTALLED_APPS`,
|
|
102
|
+
and `REST_FRAMEWORK` defaults where DRF is used), `urls.py` and one app package holding
|
|
103
|
+
only the views the tests assert on. The settings module is always `settings`.
|
|
104
|
+
2. Write the tests first, using `run_extract("<style>")` from `tests/harness.py`. It runs
|
|
105
|
+
the extraction in a separate process, because Django can only be set up once per
|
|
106
|
+
process.
|
|
107
|
+
3. Check the CLI against it from the repository root:
|
|
108
|
+
|
|
109
|
+
```sh
|
|
110
|
+
DJANGO_SETTINGS_MODULE=settings PYTHONPATH=tests/fixtures/drf_viewsets \
|
|
111
|
+
authzlock check --lockfile tests/fixtures/drf_viewsets/authz.lock.expected
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
(with your fixture's directory in place of `drf_viewsets`; for a new fixture, use
|
|
115
|
+
`authzlock update` with the same options to write its first lockfile).
|
|
116
|
+
4. To have its lockfile compared byte for byte in every CI cell, add the fixture's name to
|
|
117
|
+
`DETERMINISM_FIXTURES` in `tests/test_determinism.py` and write its golden lockfile,
|
|
118
|
+
`authz.lock.expected`:
|
|
119
|
+
|
|
120
|
+
```sh
|
|
121
|
+
pytest tests/test_determinism.py -k t5 --update-golden
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
This rewrites the most specific golden file that exists for the installed Django and
|
|
125
|
+
DRF. If DRF 3.14 builds different routes for the fixture (the `tests_min_drf` session
|
|
126
|
+
fails), create `authz.lock.drf-3.14.expected` as a copy of `authz.lock.expected` and
|
|
127
|
+
rewrite it with `nox -s tests_min_drf -- tests/test_determinism.py -k t5 --update-golden`.
|
|
128
|
+
Read the generated files before committing them; they are the expected output from then
|
|
129
|
+
on.
|
|
130
|
+
5. Describe the fixture under "Current fixtures" in `tests/fixtures/README.md`.
|
|
131
|
+
|
|
132
|
+
## Generated docs
|
|
133
|
+
|
|
134
|
+
The output blocks in [docs/scenario.md](docs/scenario.md) and the `diff` output in
|
|
135
|
+
[README.md](README.md) are produced by the tests, which fail when they are stale. After a
|
|
136
|
+
change to the output, rewrite them and review the diff:
|
|
137
|
+
|
|
138
|
+
```sh
|
|
139
|
+
pytest tests/test_scenario.py tests/test_readme.py --update-docs
|
|
140
|
+
```
|
|
141
|
+
|
|
142
|
+
`tests/test_docs.py` also checks that [docs/cli.md](docs/cli.md) lists exactly the options
|
|
143
|
+
in `--help`, that relative links resolve and that no `authzlock` command in a code block uses
|
|
144
|
+
an option that does not exist. Update the docs in the same pull request as the change.
|
authzlock-0.1.0/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Sharukh Hasan
|
|
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.
|
authzlock-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: authzlock
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: An authorization lockfile for Django and Django REST Framework.
|
|
5
|
+
Project-URL: Homepage, https://github.com/smhasan94/authzlock
|
|
6
|
+
Project-URL: Issues, https://github.com/smhasan94/authzlock/issues
|
|
7
|
+
Author: Sharukh Hasan
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: authorization,django,drf,lockfile,permissions,security
|
|
11
|
+
Classifier: Development Status :: 2 - Pre-Alpha
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Framework :: Django
|
|
14
|
+
Classifier: Framework :: Django :: 4.2
|
|
15
|
+
Classifier: Framework :: Django :: 5.1
|
|
16
|
+
Classifier: Framework :: Django :: 5.2
|
|
17
|
+
Classifier: Framework :: Django :: 6.0
|
|
18
|
+
Classifier: Framework :: Django :: 6.1
|
|
19
|
+
Classifier: Intended Audience :: Developers
|
|
20
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
21
|
+
Classifier: Operating System :: OS Independent
|
|
22
|
+
Classifier: Programming Language :: Python :: 3
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
24
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
26
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
27
|
+
Classifier: Topic :: Security
|
|
28
|
+
Classifier: Topic :: Software Development :: Quality Assurance
|
|
29
|
+
Classifier: Typing :: Typed
|
|
30
|
+
Requires-Python: >=3.10
|
|
31
|
+
Requires-Dist: django>=4.2
|
|
32
|
+
Requires-Dist: pyyaml>=6
|
|
33
|
+
Requires-Dist: typer>=0.12
|
|
34
|
+
Provides-Extra: dev
|
|
35
|
+
Requires-Dist: build>=1.2; extra == 'dev'
|
|
36
|
+
Requires-Dist: django>=4.2; extra == 'dev'
|
|
37
|
+
Requires-Dist: djangorestframework>=3.14; extra == 'dev'
|
|
38
|
+
Requires-Dist: drf-nested-routers>=0.93; extra == 'dev'
|
|
39
|
+
Requires-Dist: hatchling>=1.25; extra == 'dev'
|
|
40
|
+
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
41
|
+
Requires-Dist: nox>=2024.4; extra == 'dev'
|
|
42
|
+
Requires-Dist: pre-commit>=3.8; extra == 'dev'
|
|
43
|
+
Requires-Dist: pytest>=8; extra == 'dev'
|
|
44
|
+
Requires-Dist: ruff==0.16.9; extra == 'dev'
|
|
45
|
+
Requires-Dist: types-pyyaml; extra == 'dev'
|
|
46
|
+
Description-Content-Type: text/markdown
|
|
47
|
+
|
|
48
|
+
# authzlock
|
|
49
|
+
|
|
50
|
+
[](https://github.com/smhasan94/authzlock/actions/workflows/ci.yml)
|
|
51
|
+
[](https://pypi.org/project/authzlock/)
|
|
52
|
+
|
|
53
|
+
An authorization lockfile for Django and Django REST Framework.
|
|
54
|
+
|
|
55
|
+
In a Django project, the rules for who may call which endpoint are spread across
|
|
56
|
+
`permission_classes` on views, `DEFAULT_PERMISSION_CLASSES` in settings, `get_permissions()`
|
|
57
|
+
and `get_queryset()` overrides, and decorators such as `login_required`. A pull request can
|
|
58
|
+
make an endpoint reachable by more users with a one-line change, and the reviewer sees the
|
|
59
|
+
line, not the effect. authzlock reads those rules from the code, writes them for every route
|
|
60
|
+
to a committed file, `authz.lock`, and on each pull request reports which routes changed and
|
|
61
|
+
whether a change made access looser.
|
|
62
|
+
|
|
63
|
+
**Status: pre-release.** The first release, 0.1.0, is not published yet. Until it is,
|
|
64
|
+
`pip install authzlock`, the `smhasan94/authzlock@v1` action tag and the `v0.1.0` pre-commit
|
|
65
|
+
rev below do not resolve; install from GitHub instead, as shown under Install.
|
|
66
|
+
|
|
67
|
+
## Install
|
|
68
|
+
|
|
69
|
+
```sh
|
|
70
|
+
pip install authzlock
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Install it into the same environment as your project, because authzlock imports your
|
|
74
|
+
settings and URL configuration. Django 4.2 or newer is installed with it if missing; Django REST
|
|
75
|
+
Framework is optional and only needed if your project uses it. Before the first release, install from the repository:
|
|
76
|
+
|
|
77
|
+
```sh
|
|
78
|
+
pip install git+https://github.com/smhasan94/authzlock
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## Quickstart
|
|
82
|
+
|
|
83
|
+
Run these from your project root, the directory that holds `manage.py`, inside a git
|
|
84
|
+
repository. Replace `mysite.settings` with your settings module.
|
|
85
|
+
|
|
86
|
+
```sh quickstart
|
|
87
|
+
export DJANGO_SETTINGS_MODULE=mysite.settings
|
|
88
|
+
authzlock update
|
|
89
|
+
git add authz.lock
|
|
90
|
+
git commit -m "Add authz.lock"
|
|
91
|
+
authzlock check
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
`update` writes `authz.lock` and prints how many routes it recorded. `check` prints
|
|
95
|
+
`authz.lock: up to date` and exits 0.
|
|
96
|
+
|
|
97
|
+
Now change an access rule. In the example project used to test this README, an `@action`
|
|
98
|
+
that only admins may call is opened to every signed-in user:
|
|
99
|
+
|
|
100
|
+
```diff
|
|
101
|
+
- @action(detail=True, methods=["post"], permission_classes=[IsAdminUser])
|
|
102
|
+
+ @action(detail=True, methods=["post"], permission_classes=[IsAuthenticated])
|
|
103
|
+
def archive(self, request: Request, pk: Any = None) -> Response:
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
```sh quickstart
|
|
107
|
+
authzlock check # exits 1
|
|
108
|
+
authzlock diff --base HEAD # exits 1
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
`check` exits 1 and lists the routes whose rules no longer match `authz.lock`. `diff` compares
|
|
112
|
+
the lockfile committed at `HEAD` with the code as it is now and labels each change. The
|
|
113
|
+
action is served by three routes, and all three are `loosened`, because `IsAuthenticated`
|
|
114
|
+
lets in more users than `IsAdminUser`:
|
|
115
|
+
|
|
116
|
+
<!-- readme-diff:start -->
|
|
117
|
+
```text
|
|
118
|
+
loosened POST ^invoices/(?P<pk>[^/.]+)/archive/$ -> billing.views.InvoiceViewSet permission_classes: [rest_framework.permissions.IsAdminUser] -> [rest_framework.permissions.IsAuthenticated] (R1: strongest built-in rest_framework.permissions.IsAdminUser -> rest_framework.permissions.IsAuthenticated)
|
|
119
|
+
loosened POST ^invoices/(?P<pk>[^/.]+)/archive\.(?P<format>[a-z0-9]+)/?$ -> billing.views.InvoiceViewSet permission_classes: [rest_framework.permissions.IsAdminUser] -> [rest_framework.permissions.IsAuthenticated] (R1: strongest built-in rest_framework.permissions.IsAdminUser -> rest_framework.permissions.IsAuthenticated)
|
|
120
|
+
loosened POST v2/^invoices/(?P<pk>[^/.]+)/archive/$ -> billing.views.InvoiceViewSet permission_classes: [rest_framework.permissions.IsAdminUser] -> [rest_framework.permissions.IsAuthenticated] (R1: strongest built-in rest_framework.permissions.IsAdminUser -> rest_framework.permissions.IsAuthenticated)
|
|
121
|
+
|
|
122
|
+
3 loosened, 0 tightened, 0 added, 0 removed, 0 changed-unknown
|
|
123
|
+
```
|
|
124
|
+
<!-- readme-diff:end -->
|
|
125
|
+
|
|
126
|
+
`diff` exits 1 whenever something changed; with `--fail-on loosened` it exits 1 only when a
|
|
127
|
+
route is loosened. To accept the change, record it and commit it with the code:
|
|
128
|
+
|
|
129
|
+
```sh quickstart
|
|
130
|
+
authzlock update
|
|
131
|
+
git commit -am "Let signed-in users archive invoices"
|
|
132
|
+
authzlock check
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
`check` exits 0 again. In CI, run `check` to make sure `authz.lock` is current, and `diff`
|
|
136
|
+
against the target branch to see what a pull request changes; the GitHub Action below does
|
|
137
|
+
the second. [docs/scenario.md](docs/scenario.md) walks through a longer example.
|
|
138
|
+
|
|
139
|
+
Exit codes for every command: 0 success, 1 the lockfile and the code differ, 2 error (the
|
|
140
|
+
project could not be loaded, the lockfile could not be read, or git failed).
|
|
141
|
+
|
|
142
|
+
## The lockfile
|
|
143
|
+
|
|
144
|
+
`authz.lock` is YAML, sorted and stable, so the same code always gives the same bytes and a
|
|
145
|
+
review diff shows only real changes. Do not edit it by hand; run `authzlock update`. Two of
|
|
146
|
+
the routes from the example project:
|
|
147
|
+
|
|
148
|
+
```yaml
|
|
149
|
+
schema_version: 1
|
|
150
|
+
routes:
|
|
151
|
+
- path: ^customers/$
|
|
152
|
+
name: customer-list
|
|
153
|
+
view: billing.views.CustomerViewSet
|
|
154
|
+
methods:
|
|
155
|
+
- GET
|
|
156
|
+
actions:
|
|
157
|
+
GET: list
|
|
158
|
+
permission_classes:
|
|
159
|
+
- rest_framework.permissions.IsAuthenticated
|
|
160
|
+
permission_source: settings-default
|
|
161
|
+
authentication_classes:
|
|
162
|
+
- rest_framework.authentication.SessionAuthentication
|
|
163
|
+
authentication_source: settings-default
|
|
164
|
+
django_auth: null
|
|
165
|
+
object_scoping:
|
|
166
|
+
get_object:
|
|
167
|
+
overridden: false
|
|
168
|
+
references_request_user: null
|
|
169
|
+
get_queryset:
|
|
170
|
+
overridden: false
|
|
171
|
+
references_request_user: null
|
|
172
|
+
perform_create:
|
|
173
|
+
overridden: false
|
|
174
|
+
references_request_user: null
|
|
175
|
+
- path: ^invoices/(?P<pk>[^/.]+)/archive/$
|
|
176
|
+
name: invoice-archive
|
|
177
|
+
view: billing.views.InvoiceViewSet
|
|
178
|
+
methods:
|
|
179
|
+
- POST
|
|
180
|
+
actions:
|
|
181
|
+
POST: archive
|
|
182
|
+
permission_classes:
|
|
183
|
+
- rest_framework.permissions.IsAdminUser
|
|
184
|
+
permission_source: action
|
|
185
|
+
authentication_classes:
|
|
186
|
+
- rest_framework.authentication.SessionAuthentication
|
|
187
|
+
authentication_source: settings-default
|
|
188
|
+
django_auth: null
|
|
189
|
+
object_scoping:
|
|
190
|
+
get_object:
|
|
191
|
+
overridden: false
|
|
192
|
+
references_request_user: null
|
|
193
|
+
get_queryset:
|
|
194
|
+
overridden: false
|
|
195
|
+
references_request_user: null
|
|
196
|
+
perform_create:
|
|
197
|
+
overridden: false
|
|
198
|
+
references_request_user: null
|
|
199
|
+
custom_permissions: {}
|
|
200
|
+
```
|
|
201
|
+
|
|
202
|
+
Each route records its path, URL name, view, HTTP methods, the effective DRF permission and
|
|
203
|
+
authentication classes and where they come from (`view`, `action` or `settings-default`),
|
|
204
|
+
Django decorators for plain views, and whether the view overrides the hooks that scope
|
|
205
|
+
objects to the caller. A `get_permissions()` override is recorded as `dynamic`. Custom
|
|
206
|
+
permission classes are listed under `custom_permissions` with their docstring and the
|
|
207
|
+
routes that use them. [docs/lockfile.md](docs/lockfile.md) describes every key.
|
|
208
|
+
|
|
209
|
+
## How changes are classified
|
|
210
|
+
|
|
211
|
+
`authzlock diff` gives every route that differs one label: `added`, `removed`, `loosened`,
|
|
212
|
+
`tightened` or `changed-unknown`. For a route on both sides, the rules below run in order and
|
|
213
|
+
the first that applies decides. Only DRF's built-in classes are ranked (`AllowAny` <
|
|
214
|
+
`IsAuthenticatedOrReadOnly` < `IsAuthenticated` < `IsAdminUser`), and Django decorators
|
|
215
|
+
(none < `login_required` < `permission_required`). A false `loosened` alarm is worse than
|
|
216
|
+
`changed-unknown`, so authzlock never guesses what a custom, composed or `dynamic`
|
|
217
|
+
permission does.
|
|
218
|
+
|
|
219
|
+
| Rule | Applies when | Label | Example |
|
|
220
|
+
|------|--------------|-------|---------|
|
|
221
|
+
| R1 | Both lists hold only ranked built-ins and the strongest class differs | `loosened` if the strongest class is weaker, `tightened` if stronger | `[IsAuthenticated]` to `[AllowAny]` is `loosened` |
|
|
222
|
+
| R2 | An unranked class is removed, the new list holds no unranked class, and the new strongest built-in is at most `IsAuthenticated` or at most the old strongest built-in | `loosened` | `[shop.permissions.IsOwner]` to `[IsAuthenticated]` is `loosened` |
|
|
223
|
+
| R3 | An unranked class is replaced by another unranked class, or by `IsAdminUser` | `changed-unknown` | `[shop.permissions.IsOwner]` to `[IsAdminUser]` is `changed-unknown` |
|
|
224
|
+
| R4 | An unranked class is added and every old entry is kept | `tightened` | `[IsAuthenticated]` to `[IsAuthenticated, shop.permissions.IsOwner]` is `tightened` |
|
|
225
|
+
| R5 | Either side of a changed field is `dynamic` | `changed-unknown` | `dynamic` to `[IsAuthenticated]` is `changed-unknown` |
|
|
226
|
+
| R6 | Only `django_auth.login_required` or `django_auth.permission_required` changed | `tightened` when the Django auth rank rises or `permission_required` gains entries; `loosened` for the reverse | `login_required` true to false is `loosened` |
|
|
227
|
+
| R7 | Only `methods` (and `actions`) changed and `methods` gained entries | `changed-unknown` | `[DELETE, GET]` to `[DELETE, GET, PUT]` is `changed-unknown` |
|
|
228
|
+
| R8 | Anything else | `changed-unknown` | `[(IsAuthenticated \| shop.permissions.IsOwner)]` to `[IsAuthenticated]` is `changed-unknown` |
|
|
229
|
+
|
|
230
|
+
R1 to R4 apply only when `permission_classes` is the one access field that changed.
|
|
231
|
+
[docs/classification.md](docs/classification.md) has the full rules, examples and the
|
|
232
|
+
cases that are `changed-unknown` on purpose.
|
|
233
|
+
|
|
234
|
+
## GitHub Action
|
|
235
|
+
|
|
236
|
+
The action runs `authzlock diff` on a pull request against its base branch, posts the result
|
|
237
|
+
as one comment that it updates on every push, and fails the job when a route is loosened.
|
|
238
|
+
Save this as `.github/workflows/authzlock.yml`:
|
|
239
|
+
|
|
240
|
+
```yaml
|
|
241
|
+
name: authzlock
|
|
242
|
+
|
|
243
|
+
on:
|
|
244
|
+
pull_request:
|
|
245
|
+
|
|
246
|
+
permissions:
|
|
247
|
+
contents: read
|
|
248
|
+
pull-requests: write
|
|
249
|
+
|
|
250
|
+
jobs:
|
|
251
|
+
authzlock:
|
|
252
|
+
runs-on: ubuntu-latest
|
|
253
|
+
steps:
|
|
254
|
+
- uses: actions/checkout@v4
|
|
255
|
+
- uses: actions/setup-python@v5
|
|
256
|
+
with:
|
|
257
|
+
python-version: "3.12"
|
|
258
|
+
- run: pip install -r requirements.txt
|
|
259
|
+
- uses: smhasan94/authzlock@v1
|
|
260
|
+
with:
|
|
261
|
+
settings-module: mysite.settings
|
|
262
|
+
python-version: "3.12"
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
Install your project's dependencies before the action, in the Python version you give it.
|
|
266
|
+
Inputs, outputs, fork pull requests and how to also run `check` are in
|
|
267
|
+
[docs/github-action.md](docs/github-action.md).
|
|
268
|
+
|
|
269
|
+
## pre-commit
|
|
270
|
+
|
|
271
|
+
The `authzlock-check` hook fails a commit when `authz.lock` does not match the code. Add this
|
|
272
|
+
to `.pre-commit-config.yaml` and run `pre-commit install`:
|
|
273
|
+
|
|
274
|
+
```yaml
|
|
275
|
+
repos:
|
|
276
|
+
- repo: https://github.com/smhasan94/authzlock
|
|
277
|
+
rev: v0.1.0
|
|
278
|
+
hooks:
|
|
279
|
+
- id: authzlock-check
|
|
280
|
+
args: ["--settings", "mysite.settings"]
|
|
281
|
+
additional_dependencies:
|
|
282
|
+
- "django==5.2.*"
|
|
283
|
+
- "djangorestframework==3.16.*"
|
|
284
|
+
```
|
|
285
|
+
|
|
286
|
+
The hook runs in its own virtualenv, so list every package your settings and URL
|
|
287
|
+
configuration import under `additional_dependencies`, pinned like your project. The
|
|
288
|
+
`authzlock-update` hook and the details are in [docs/pre-commit.md](docs/pre-commit.md).
|
|
289
|
+
|
|
290
|
+
## What authzlock does not do
|
|
291
|
+
|
|
292
|
+
- It does not support FastAPI or any framework other than Django and Django REST Framework.
|
|
293
|
+
- It does not prove that custom permission logic is correct. A custom class such as
|
|
294
|
+
`IsOwner` is recorded by name with its docstring; its code is never run or judged.
|
|
295
|
+
- It does not do black-box testing of a running app. It reads code and settings, sends no
|
|
296
|
+
HTTP requests and needs no users, roles file or seeded data.
|
|
297
|
+
- It does not generate tests from the lockfile.
|
|
298
|
+
|
|
299
|
+
Some of what it records comes from a heuristic, and the lockfile names it as such.
|
|
300
|
+
`dynamic` means the view overrides `get_permissions()` and the classes are only known at
|
|
301
|
+
request time. `object_scoping` records whether `get_object`, `get_queryset` and
|
|
302
|
+
`perform_create` are overridden and whether the override reads `request.user` directly; it
|
|
303
|
+
does not follow helper calls and does not show that the query is correct. Decorators are
|
|
304
|
+
detected by reading the view's source, and ones authzlock cannot classify are listed under
|
|
305
|
+
`unknown_decorators`. A green `check` means the lockfile matches the code, not that the
|
|
306
|
+
rules are right. authzlock runs offline and sends nothing anywhere.
|
|
307
|
+
[docs/heuristics-and-limits.md](docs/heuristics-and-limits.md) covers each heuristic and its
|
|
308
|
+
limits.
|
|
309
|
+
|
|
310
|
+
## Compatibility
|
|
311
|
+
|
|
312
|
+
| Python | Django 4.2 | Django 5.1 | Django 5.2 | Django 6.0 | Django 6.1 |
|
|
313
|
+
|--------|------------|------------|------------|------------|------------|
|
|
314
|
+
| 3.10 | yes | yes | yes | no | no |
|
|
315
|
+
| 3.11 | yes | yes | yes | no | no |
|
|
316
|
+
| 3.12 | yes | yes | yes | yes | yes |
|
|
317
|
+
| 3.13 | no | yes | yes | yes | yes |
|
|
318
|
+
|
|
319
|
+
Django REST Framework 3.14 and newer. CI runs every cell above with DRF 3.16 or newer, and
|
|
320
|
+
DRF 3.14 on Django 4.2 with Python 3.12. Routers from `drf-nested-routers` are supported.
|
|
321
|
+
Django 4.2 does not support Python 3.13, and Django 6.0 and newer need Python 3.12.
|
|
322
|
+
|
|
323
|
+
## Documentation
|
|
324
|
+
|
|
325
|
+
[docs/index.md](docs/index.md) lists every page. The main ones:
|
|
326
|
+
|
|
327
|
+
- [docs/cli.md](docs/cli.md): commands, options, output formats and exit codes.
|
|
328
|
+
- [docs/lockfile.md](docs/lockfile.md): the lockfile format and its stability guarantees.
|
|
329
|
+
- [docs/classification.md](docs/classification.md): the rules R1 to R8.
|
|
330
|
+
- [docs/heuristics-and-limits.md](docs/heuristics-and-limits.md): what the heuristics can
|
|
331
|
+
and cannot tell you, and what a green `check` does not prove.
|
|
332
|
+
- [docs/scenario.md](docs/scenario.md): a pull request that drops `IsOwner`, end to end.
|
|
333
|
+
- [docs/github-action.md](docs/github-action.md): the GitHub Action.
|
|
334
|
+
- [docs/pre-commit.md](docs/pre-commit.md): the pre-commit hooks.
|
|
335
|
+
- [CHANGELOG.md](CHANGELOG.md): changes by release.
|
|
336
|
+
|
|
337
|
+
## Development
|
|
338
|
+
|
|
339
|
+
See [CONTRIBUTING.md](CONTRIBUTING.md) for the setup, conventions and how to add a test
|
|
340
|
+
project, [DEVELOPMENT.md](DEVELOPMENT.md) for the project brief and development commands,
|
|
341
|
+
[docs/requirements.md](docs/requirements.md) for the requirements and
|
|
342
|
+
[docs/releasing.md](docs/releasing.md) for the release steps. The backlog lives in Linear and
|
|
343
|
+
is mirrored in [docs/backlog.md](docs/backlog.md). `tests/test_readme.py` runs the quickstart
|
|
344
|
+
above against the `drf_viewsets` test project and checks the lockfile excerpt, the rule table
|
|
345
|
+
and the snippets against their sources; `pytest tests/test_readme.py --update-docs` rewrites
|
|
346
|
+
the `diff` output shown above.
|
|
347
|
+
|
|
348
|
+
Every pull request and every push to `main` runs `.github/workflows/ci.yml`: a `lint` job
|
|
349
|
+
(ruff), a `typecheck` job (mypy strict) and one `tests (python=X, django=Y)` job per supported
|
|
350
|
+
combination, eleven in all, plus `tests (python=3.12, django=4.2, drf=3.14)` against the
|
|
351
|
+
oldest supported DRF. The jobs are the same nox sessions that `nox` runs locally with no
|
|
352
|
+
arguments. Mark `lint`, `typecheck` and every `tests (...)` job as required status checks on
|
|
353
|
+
`main` in the repository settings so a red check blocks merging.
|
|
354
|
+
|
|
355
|
+
## License
|
|
356
|
+
|
|
357
|
+
MIT. See [LICENSE](LICENSE).
|