Skip to main content

authzlock

CI PyPI

An authorization lockfile for Django and Django REST Framework.

In a Django project, the rules for who may call which endpoint are spread across permission_classes on views, DEFAULT_PERMISSION_CLASSES in settings, get_permissions() and get_queryset() overrides, and decorators such as login_required. A pull request can make an endpoint reachable by more users with a one-line change, and the reviewer sees the line, not the effect. authzlock reads those rules from the code, writes them for every route to a committed file, authz.lock, and on each pull request reports which routes changed and whether a change made access looser.

Status: pre-release. The first release, 0.1.0, is not published yet. Until it is, pip install authzlock, the smhasan94/authzlock@v1 action tag and the v0.1.0 pre-commit rev below do not resolve; install from GitHub instead, as shown under Install.

Install

pip install authzlock

Install it into the same environment as your project, because authzlock imports your settings and URL configuration. Django 4.2 or newer is installed with it if missing; Django REST Framework is optional and only needed if your project uses it. Before the first release, install from the repository:

pip install git+https://github.com/smhasan94/authzlock

Quickstart

Run these from your project root, the directory that holds manage.py, inside a git repository. Replace mysite.settings with your settings module.

export DJANGO_SETTINGS_MODULE=mysite.settings
authzlock update
git add authz.lock
git commit -m "Add authz.lock"
authzlock check

update writes authz.lock and prints how many routes it recorded. check prints authz.lock: up to date and exits 0.

Now change an access rule. In the example project used to test this README, an @action that only admins may call is opened to every signed-in user:

-    @action(detail=True, methods=["post"], permission_classes=[IsAdminUser])
+    @action(detail=True, methods=["post"], permission_classes=[IsAuthenticated])
     def archive(self, request: Request, pk: Any = None) -> Response:
authzlock check  # exits 1
authzlock diff --base HEAD  # exits 1

check exits 1 and lists the routes whose rules no longer match authz.lock. diff compares the lockfile committed at HEAD with the code as it is now and labels each change. The action is served by three routes, and all three are loosened, because IsAuthenticated lets in more users than IsAdminUser:

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)
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)
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)

3 loosened, 0 tightened, 0 added, 0 removed, 0 changed-unknown

diff exits 1 whenever something changed; with --fail-on loosened it exits 1 only when a route is loosened. To accept the change, record it and commit it with the code:

authzlock update
git commit -am "Let signed-in users archive invoices"
authzlock check

check exits 0 again. In CI, run check to make sure authz.lock is current, and diff against the target branch to see what a pull request changes; the GitHub Action below does the second. docs/scenario.md walks through a longer example.

Exit codes for every command: 0 success, 1 the lockfile and the code differ, 2 error (the project could not be loaded, the lockfile could not be read, or git failed).

The lockfile

authz.lock is YAML, sorted and stable, so the same code always gives the same bytes and a review diff shows only real changes. Do not edit it by hand; run authzlock update. Two of the routes from the example project:

schema_version: 1
routes:
- path: ^customers/$
  name: customer-list
  view: billing.views.CustomerViewSet
  methods:
  - GET
  actions:
    GET: list
  permission_classes:
  - rest_framework.permissions.IsAuthenticated
  permission_source: settings-default
  authentication_classes:
  - rest_framework.authentication.SessionAuthentication
  authentication_source: settings-default
  django_auth: null
  object_scoping:
    get_object:
      overridden: false
      references_request_user: null
    get_queryset:
      overridden: false
      references_request_user: null
    perform_create:
      overridden: false
      references_request_user: null
- path: ^invoices/(?P<pk>[^/.]+)/archive/$
  name: invoice-archive
  view: billing.views.InvoiceViewSet
  methods:
  - POST
  actions:
    POST: archive
  permission_classes:
  - rest_framework.permissions.IsAdminUser
  permission_source: action
  authentication_classes:
  - rest_framework.authentication.SessionAuthentication
  authentication_source: settings-default
  django_auth: null
  object_scoping:
    get_object:
      overridden: false
      references_request_user: null
    get_queryset:
      overridden: false
      references_request_user: null
    perform_create:
      overridden: false
      references_request_user: null
custom_permissions: {}

Each route records its path, URL name, view, HTTP methods, the effective DRF permission and authentication classes and where they come from (view, action or settings-default), Django decorators for plain views, and whether the view overrides the hooks that scope objects to the caller. A get_permissions() override is recorded as dynamic. Custom permission classes are listed under custom_permissions with their docstring and the routes that use them. docs/lockfile.md describes every key.

How changes are classified

authzlock diff gives every route that differs one label: added, removed, loosened, tightened or changed-unknown. For a route on both sides, the rules below run in order and the first that applies decides. Only DRF's built-in classes are ranked (AllowAny < IsAuthenticatedOrReadOnly < IsAuthenticated < IsAdminUser), and Django decorators (none < login_required < permission_required). A false loosened alarm is worse than changed-unknown, so authzlock never guesses what a custom, composed or dynamic permission does.

Rule Applies when Label Example
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
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
R3 An unranked class is replaced by another unranked class, or by IsAdminUser changed-unknown [shop.permissions.IsOwner] to [IsAdminUser] is changed-unknown
R4 An unranked class is added and every old entry is kept tightened [IsAuthenticated] to [IsAuthenticated, shop.permissions.IsOwner] is tightened
R5 Either side of a changed field is dynamic changed-unknown dynamic to [IsAuthenticated] is changed-unknown
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
R7 Only methods (and actions) changed and methods gained entries changed-unknown [DELETE, GET] to [DELETE, GET, PUT] is changed-unknown
R8 Anything else changed-unknown [(IsAuthenticated | shop.permissions.IsOwner)] to [IsAuthenticated] is changed-unknown

R1 to R4 apply only when permission_classes is the one access field that changed. docs/classification.md has the full rules, examples and the cases that are changed-unknown on purpose.

GitHub Action

The action runs authzlock diff on a pull request against its base branch, posts the result as one comment that it updates on every push, and fails the job when a route is loosened. Save this as .github/workflows/authzlock.yml:

name: authzlock

on:
  pull_request:

permissions:
  contents: read
  pull-requests: write

jobs:
  authzlock:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-python@v5
        with:
          python-version: "3.12"
      - run: pip install -r requirements.txt
      - uses: smhasan94/authzlock@v1
        with:
          settings-module: mysite.settings
          python-version: "3.12"

Install your project's dependencies before the action, in the Python version you give it. Inputs, outputs, fork pull requests and how to also run check are in docs/github-action.md.

pre-commit

The authzlock-check hook fails a commit when authz.lock does not match the code. Add this to .pre-commit-config.yaml and run pre-commit install:

repos:
  - repo: https://github.com/smhasan94/authzlock
    rev: v0.1.0
    hooks:
      - id: authzlock-check
        args: ["--settings", "mysite.settings"]
        additional_dependencies:
          - "django==5.2.*"
          - "djangorestframework==3.16.*"

The hook runs in its own virtualenv, so list every package your settings and URL configuration import under additional_dependencies, pinned like your project. The authzlock-update hook and the details are in docs/pre-commit.md.

What authzlock does not do

  • It does not support FastAPI or any framework other than Django and Django REST Framework.
  • It does not prove that custom permission logic is correct. A custom class such as IsOwner is recorded by name with its docstring; its code is never run or judged.
  • It does not do black-box testing of a running app. It reads code and settings, sends no HTTP requests and needs no users, roles file or seeded data.
  • It does not generate tests from the lockfile.

Some of what it records comes from a heuristic, and the lockfile names it as such. dynamic means the view overrides get_permissions() and the classes are only known at request time. object_scoping records whether get_object, get_queryset and perform_create are overridden and whether the override reads request.user directly; it does not follow helper calls and does not show that the query is correct. Decorators are detected by reading the view's source, and ones authzlock cannot classify are listed under unknown_decorators. A green check means the lockfile matches the code, not that the rules are right. authzlock runs offline and sends nothing anywhere. docs/heuristics-and-limits.md covers each heuristic and its limits.

Compatibility

Python Django 4.2 Django 5.1 Django 5.2 Django 6.0 Django 6.1
3.10 yes yes yes no no
3.11 yes yes yes no no
3.12 yes yes yes yes yes
3.13 no yes yes yes yes

Django REST Framework 3.14 and newer. CI runs every cell above with DRF 3.16 or newer, and DRF 3.14 on Django 4.2 with Python 3.12. Routers from drf-nested-routers are supported. Django 4.2 does not support Python 3.13, and Django 6.0 and newer need Python 3.12.

Documentation

docs/index.md lists every page. The main ones:

Development

See CONTRIBUTING.md for the setup, conventions and how to add a test project, DEVELOPMENT.md for the project brief and development commands, docs/requirements.md for the requirements and docs/releasing.md for the release steps. The backlog lives in Linear and is mirrored in docs/backlog.md. tests/test_readme.py runs the quickstart above against the drf_viewsets test project and checks the lockfile excerpt, the rule table and the snippets against their sources; pytest tests/test_readme.py --update-docs rewrites the diff output shown above.

Every pull request and every push to main runs .github/workflows/ci.yml: a lint job (ruff), a typecheck job (mypy strict) and one tests (python=X, django=Y) job per supported combination, eleven in all, plus tests (python=3.12, django=4.2, drf=3.14) against the oldest supported DRF. The jobs are the same nox sessions that nox runs locally with no arguments. Mark lint, typecheck and every tests (...) job as required status checks on main in the repository settings so a red check blocks merging.

License

MIT. See LICENSE.

Metadata

Release files for authzlock 0.1.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for authzlock 0.1.0
File Size Uploaded Provenance
authzlock-0.1.0.tar.gz 38.6 kB With provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for authzlock 0.1.0
File Interpreter ABI Platform
authzlock-0.1.0-py3-none-any.whl Python 3 none any Details

Total release size: 81.0 kB

Release files / authzlock-0.1.0.tar.gz

Download URL authzlock-0.1.0.tar.gz
Size 38.6 kB
Tags Source
SHA-256 checksum
How to use checksums
2910b4f5e990703d81a1e9e83cdaf68c4bab31c15b8e3be2642073d2bccef3c9
BLAKE2b-256 checksum
How to use checksums
db45fc975659d935bf057c888c074ba0f95eb1011e7df80c5ba0a8da7b8a1773
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 3, 2026.

Transparency log

Release files / authzlock-0.1.0-py3-none-any.whl

Download URL authzlock-0.1.0-py3-none-any.whl
Size 42.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
66da8252f505d55992f5b0fd2da20bf12dd170f05465467576d63886f8544f65
BLAKE2b-256 checksum
How to use checksums
c8639b9182aa4dc49e6c263c4aad520fcce3fcf3df22d4e8697e7f8abc6e66f0
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 3, 2026.

Transparency log

Security for authzlock 0.1.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for authzlock-0.1.0.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 3, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page