authzlock 0.1.0rc1__py3-none-any.whl
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/__init__.py +5 -0
- authzlock/__main__.py +7 -0
- authzlock/_dump.py +29 -0
- authzlock/_github.py +201 -0
- authzlock/classify.py +370 -0
- authzlock/cli.py +245 -0
- authzlock/diff.py +218 -0
- authzlock/django_loader.py +66 -0
- authzlock/errors.py +23 -0
- authzlock/extract/__init__.py +44 -0
- authzlock/extract/custom.py +80 -0
- authzlock/extract/decorators.py +229 -0
- authzlock/extract/drf.py +95 -0
- authzlock/extract/methods.py +51 -0
- authzlock/extract/scoping.py +69 -0
- authzlock/extract/source.py +43 -0
- authzlock/extract/urls.py +63 -0
- authzlock/gitutil.py +85 -0
- authzlock/lockfile.py +214 -0
- authzlock/model.py +97 -0
- authzlock/py.typed +0 -0
- authzlock/render.py +297 -0
- authzlock-0.1.0rc1.dist-info/METADATA +357 -0
- authzlock-0.1.0rc1.dist-info/RECORD +27 -0
- authzlock-0.1.0rc1.dist-info/WHEEL +4 -0
- authzlock-0.1.0rc1.dist-info/entry_points.txt +2 -0
- authzlock-0.1.0rc1.dist-info/licenses/LICENSE +21 -0
authzlock/__init__.py
ADDED
authzlock/__main__.py
ADDED
authzlock/_dump.py
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""Print the inventory of the configured Django project as JSON.
|
|
2
|
+
|
|
3
|
+
Used by the test harness: `python -m authzlock._dump` with `DJANGO_SETTINGS_MODULE` set.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import sys
|
|
10
|
+
|
|
11
|
+
from authzlock.django_loader import load_project
|
|
12
|
+
from authzlock.errors import EXIT_ERROR, EXIT_OK, AuthzlockError
|
|
13
|
+
from authzlock.extract import extract
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main() -> int:
|
|
17
|
+
try:
|
|
18
|
+
load_project(None)
|
|
19
|
+
inventory = extract()
|
|
20
|
+
except AuthzlockError as exc:
|
|
21
|
+
print(f"authzlock: {exc}", file=sys.stderr)
|
|
22
|
+
return EXIT_ERROR
|
|
23
|
+
json.dump(inventory.to_dict(), sys.stdout, sort_keys=True, indent=2)
|
|
24
|
+
sys.stdout.write("\n")
|
|
25
|
+
return EXIT_OK
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
if __name__ == "__main__":
|
|
29
|
+
sys.exit(main())
|
authzlock/_github.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Helpers the GitHub Action (`action.yml`) runs as `python -m authzlock._github`.
|
|
2
|
+
|
|
3
|
+
Three subcommands, each reading the markdown report `authzlock diff --format markdown`
|
|
4
|
+
wrote to a file:
|
|
5
|
+
|
|
6
|
+
- `upsert-comment --repo OWNER/NAME --pr N --body-file PATH` posts the report as a pull
|
|
7
|
+
request comment, or updates the newest comment that starts with `MARKDOWN_MARKER`, so a
|
|
8
|
+
pull request keeps one authzlock comment however often it is pushed to. Older marker
|
|
9
|
+
comments are left as they are. It talks to GitHub only through `gh api`, which reads
|
|
10
|
+
the token from `GH_TOKEN`.
|
|
11
|
+
- `outputs --report PATH` writes the `summary` and `loosened` step outputs.
|
|
12
|
+
- `gate --report PATH --fail-on-loosened true|false` exits 1 when the flag is true and the
|
|
13
|
+
summary line counts at least one loosened route.
|
|
14
|
+
|
|
15
|
+
Exit codes follow the CLI: 0 ok, 1 the gate failed, 2 error. This module is private: its
|
|
16
|
+
interface is the action, not a public API.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import argparse
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import re
|
|
25
|
+
import subprocess
|
|
26
|
+
import sys
|
|
27
|
+
from collections.abc import Mapping, Sequence
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from authzlock.errors import EXIT_ERROR, EXIT_MISMATCH, EXIT_OK
|
|
32
|
+
from authzlock.render import MARKDOWN_MARKER, SUMMARY_LABELS
|
|
33
|
+
|
|
34
|
+
# GitHub rejects issue comments longer than 65536 characters.
|
|
35
|
+
MAX_BODY = 65536
|
|
36
|
+
HEADING = "### authzlock: access-control changes"
|
|
37
|
+
NO_CHANGES_BODY = f"{MARKDOWN_MARKER}\n{HEADING}\n\nauthzlock found no access-control changes.\n"
|
|
38
|
+
TRUNCATED = "\n\n_The report was truncated to fit in a comment; the full diff is in the job log._\n"
|
|
39
|
+
SUMMARY_RE = re.compile(
|
|
40
|
+
r"^" + r", ".join(rf"(\d+) {re.escape(label)}" for label in SUMMARY_LABELS) + r"$",
|
|
41
|
+
re.MULTILINE,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
class GithubError(Exception):
|
|
46
|
+
"""A `gh` call failed or the report could not be used."""
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def comment_body(markdown: str) -> str:
|
|
50
|
+
"""The comment text for a report: the report itself, marked and cut to GitHub's limit."""
|
|
51
|
+
if not markdown.strip():
|
|
52
|
+
return NO_CHANGES_BODY
|
|
53
|
+
body = markdown if markdown.startswith(MARKDOWN_MARKER) else f"{MARKDOWN_MARKER}\n{markdown}"
|
|
54
|
+
if len(body) > MAX_BODY:
|
|
55
|
+
body = body[: MAX_BODY - len(TRUNCATED)].rstrip() + TRUNCATED
|
|
56
|
+
return body
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def parse_summary(markdown: str) -> dict[str, int]:
|
|
60
|
+
"""The five counts of the report's summary line, keyed by label."""
|
|
61
|
+
matches = SUMMARY_RE.findall(markdown)
|
|
62
|
+
if len(matches) != 1:
|
|
63
|
+
raise GithubError(
|
|
64
|
+
f"expected one summary line in the authzlock report, found {len(matches)}.\n"
|
|
65
|
+
"Check the output of `authzlock diff` above."
|
|
66
|
+
)
|
|
67
|
+
return {label: int(count) for label, count in zip(SUMMARY_LABELS, matches[0], strict=True)}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def should_fail(*, fail_on_loosened: bool, loosened: int) -> bool:
|
|
71
|
+
"""True when the job must fail: the flag is set and a route was loosened."""
|
|
72
|
+
return fail_on_loosened and loosened > 0
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def find_comment(comments: Sequence[Mapping[str, Any]]) -> int | None:
|
|
76
|
+
"""Id of the newest comment whose body starts with the marker, or None.
|
|
77
|
+
|
|
78
|
+
"Starts with" rather than "contains" so that a reply quoting the comment is never
|
|
79
|
+
taken for it. Newest is by `created_at`, then by id.
|
|
80
|
+
"""
|
|
81
|
+
marked = [c for c in comments if str(c.get("body", "")).lstrip().startswith(MARKDOWN_MARKER)]
|
|
82
|
+
if not marked:
|
|
83
|
+
return None
|
|
84
|
+
newest = max(marked, key=lambda c: (str(c.get("created_at", "")), int(c["id"])))
|
|
85
|
+
return int(newest["id"])
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _gh_api(args: Sequence[str], payload: Mapping[str, Any] | None = None) -> Any:
|
|
89
|
+
command = ["gh", "api", *args]
|
|
90
|
+
try:
|
|
91
|
+
result = subprocess.run(
|
|
92
|
+
command,
|
|
93
|
+
input=json.dumps(payload) if payload is not None else None,
|
|
94
|
+
capture_output=True,
|
|
95
|
+
text=True,
|
|
96
|
+
check=False,
|
|
97
|
+
)
|
|
98
|
+
except FileNotFoundError as exc:
|
|
99
|
+
raise GithubError(
|
|
100
|
+
"gh is not installed.\nThe action needs the GitHub CLI, which GitHub-hosted "
|
|
101
|
+
"runners provide."
|
|
102
|
+
) from exc
|
|
103
|
+
if result.returncode != 0:
|
|
104
|
+
detail = result.stderr.strip() or result.stdout.strip()
|
|
105
|
+
raise GithubError(f"`gh api {' '.join(args)}` failed: {detail}")
|
|
106
|
+
return json.loads(result.stdout) if result.stdout.strip() else None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def list_comments(repo: str, pr: int) -> list[dict[str, Any]]:
|
|
110
|
+
"""Every comment on the pull request, across all pages."""
|
|
111
|
+
pages = _gh_api(["--paginate", "--slurp", f"repos/{repo}/issues/{pr}/comments"])
|
|
112
|
+
return [comment for page in pages or [] for comment in page]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def upsert_comment(repo: str, pr: int, body: str) -> tuple[str, int]:
|
|
116
|
+
"""Update the newest marker comment or create one; returns the action taken and the id."""
|
|
117
|
+
existing = find_comment(list_comments(repo, pr))
|
|
118
|
+
if existing is None:
|
|
119
|
+
created = _gh_api(
|
|
120
|
+
["-X", "POST", f"repos/{repo}/issues/{pr}/comments", "--input", "-"], {"body": body}
|
|
121
|
+
)
|
|
122
|
+
return "created", int(created["id"])
|
|
123
|
+
_gh_api(
|
|
124
|
+
["-X", "PATCH", f"repos/{repo}/issues/comments/{existing}", "--input", "-"],
|
|
125
|
+
{"body": body},
|
|
126
|
+
)
|
|
127
|
+
return "updated", existing
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _read(path: Path) -> str:
|
|
131
|
+
try:
|
|
132
|
+
return path.read_text(encoding="utf-8")
|
|
133
|
+
except OSError as exc:
|
|
134
|
+
raise GithubError(f"cannot read {path}: {exc.strerror or exc}.") from exc
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _flag(value: str) -> bool:
|
|
138
|
+
lowered = value.strip().lower()
|
|
139
|
+
if lowered not in {"true", "false"}:
|
|
140
|
+
raise GithubError(f"fail-on-loosened must be true or false, not {value!r}.")
|
|
141
|
+
return lowered == "true"
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _write_outputs(values: Mapping[str, str]) -> None:
|
|
145
|
+
lines = "".join(f"{key}={value}\n" for key, value in values.items())
|
|
146
|
+
target = os.environ.get("GITHUB_OUTPUT")
|
|
147
|
+
if target:
|
|
148
|
+
with open(target, "a", encoding="utf-8") as fh:
|
|
149
|
+
fh.write(lines)
|
|
150
|
+
else:
|
|
151
|
+
sys.stdout.write(lines)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _parser() -> argparse.ArgumentParser:
|
|
155
|
+
parser = argparse.ArgumentParser(prog="python -m authzlock._github")
|
|
156
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
157
|
+
upsert = commands.add_parser("upsert-comment", help="Create or update the PR comment.")
|
|
158
|
+
upsert.add_argument("--repo", required=True)
|
|
159
|
+
upsert.add_argument("--pr", required=True, type=int)
|
|
160
|
+
upsert.add_argument("--body-file", required=True, type=Path)
|
|
161
|
+
outputs = commands.add_parser("outputs", help="Write the summary step outputs.")
|
|
162
|
+
outputs.add_argument("--report", required=True, type=Path)
|
|
163
|
+
gate = commands.add_parser("gate", help="Fail when a route is loosened.")
|
|
164
|
+
gate.add_argument("--report", required=True, type=Path)
|
|
165
|
+
gate.add_argument("--fail-on-loosened", required=True)
|
|
166
|
+
return parser
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _run(args: argparse.Namespace) -> int:
|
|
170
|
+
if args.command == "upsert-comment":
|
|
171
|
+
action, comment_id = upsert_comment(args.repo, args.pr, comment_body(_read(args.body_file)))
|
|
172
|
+
print(f"authzlock: {action} comment {comment_id} on {args.repo}#{args.pr}")
|
|
173
|
+
return EXIT_OK
|
|
174
|
+
counts = parse_summary(_read(args.report))
|
|
175
|
+
if args.command == "outputs":
|
|
176
|
+
summary = ", ".join(f"{counts[label]} {label}" for label in SUMMARY_LABELS)
|
|
177
|
+
_write_outputs({"summary": summary, "loosened": str(counts["loosened"])})
|
|
178
|
+
return EXIT_OK
|
|
179
|
+
loosened = counts["loosened"]
|
|
180
|
+
if should_fail(fail_on_loosened=_flag(args.fail_on_loosened), loosened=loosened):
|
|
181
|
+
noun = "route" if loosened == 1 else "routes"
|
|
182
|
+
print(
|
|
183
|
+
f"::error::authzlock: {loosened} {noun} loosened. Review the access-control "
|
|
184
|
+
"changes, or set fail-on-loosened: false to report without failing."
|
|
185
|
+
)
|
|
186
|
+
return EXIT_MISMATCH
|
|
187
|
+
print(f"authzlock: {loosened} loosened; not failing")
|
|
188
|
+
return EXIT_OK
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
192
|
+
args = _parser().parse_args(argv)
|
|
193
|
+
try:
|
|
194
|
+
return _run(args)
|
|
195
|
+
except GithubError as exc:
|
|
196
|
+
print(f"authzlock: {exc}", file=sys.stderr)
|
|
197
|
+
return EXIT_ERROR
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
if __name__ == "__main__":
|
|
201
|
+
sys.exit(main())
|
authzlock/classify.py
ADDED
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
"""Classification of route changes: loosened, tightened or changed-unknown.
|
|
2
|
+
|
|
3
|
+
Rules R1 to R8 (agreed 2026-09-29, recorded on epic SHA-177) are evaluated in `RULES`
|
|
4
|
+
order and the first rule that returns a `Classification` wins; R8 always matches. Every
|
|
5
|
+
result names its rule and gives a one-line reason that starts with the rule id.
|
|
6
|
+
|
|
7
|
+
The rules are conservative: a false `loosened` alarm is worse than `changed-unknown`. Only
|
|
8
|
+
the four built-in DRF classes in `BUILTIN_RANK` are ranked. Every other permission entry is
|
|
9
|
+
opaque: custom and third-party classes and the unranked built-ins (`DjangoModelPermissions`
|
|
10
|
+
and the like) are only handled by the explicit removal and addition rules R2 to R4, and
|
|
11
|
+
composed expressions such as `(a.X | b.Y)` and the `dynamic` sentinel are never labelled
|
|
12
|
+
loosened or tightened. `docs/classification.md` lists the rules with one example each.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from collections.abc import Callable, Mapping
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import Any, Literal
|
|
20
|
+
|
|
21
|
+
from authzlock.diff import Diff, FieldChange, RouteChange
|
|
22
|
+
from authzlock.extract.custom import BUILTIN_MODULE
|
|
23
|
+
from authzlock.extract.decorators import NON_LITERAL
|
|
24
|
+
from authzlock.model import Route
|
|
25
|
+
|
|
26
|
+
Label = Literal["added", "removed", "loosened", "tightened", "changed-unknown"]
|
|
27
|
+
LABELS: tuple[Label, ...] = ("added", "removed", "loosened", "tightened", "changed-unknown")
|
|
28
|
+
|
|
29
|
+
DYNAMIC = "dynamic"
|
|
30
|
+
|
|
31
|
+
# DRF built-ins from weakest to strongest. A permission list is an AND, and each class
|
|
32
|
+
# implies every class ranked below it, so a list of ranked classes is as strong as its
|
|
33
|
+
# strongest member. An empty list allows anyone, like AllowAny.
|
|
34
|
+
BUILTIN_RANK: Mapping[str, int] = {
|
|
35
|
+
f"{BUILTIN_MODULE}.AllowAny": 0,
|
|
36
|
+
f"{BUILTIN_MODULE}.IsAuthenticatedOrReadOnly": 1,
|
|
37
|
+
f"{BUILTIN_MODULE}.IsAuthenticated": 2,
|
|
38
|
+
f"{BUILTIN_MODULE}.IsAdminUser": 3,
|
|
39
|
+
}
|
|
40
|
+
IS_AUTHENTICATED = f"{BUILTIN_MODULE}.IsAuthenticated"
|
|
41
|
+
IS_ADMIN_USER = f"{BUILTIN_MODULE}.IsAdminUser"
|
|
42
|
+
|
|
43
|
+
# Django auth levels from weakest to strongest; see `_django_auth_level`.
|
|
44
|
+
DJANGO_AUTH_RANK: Mapping[str, int] = {
|
|
45
|
+
"none": 0,
|
|
46
|
+
"login_required": 1,
|
|
47
|
+
"permission_required": 2,
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
# Fields that say where a rule came from or what a route is called, not who may call it.
|
|
51
|
+
# They may change alongside the fields a rule covers without stopping that rule.
|
|
52
|
+
NEUTRAL_FIELDS = frozenset({"name", "permission_source", "authentication_source"})
|
|
53
|
+
# The django_auth leaves R6 understands; any other django_auth leaf change goes to R8.
|
|
54
|
+
_DJANGO_AUTH_LEAVES = frozenset({"django_auth.login_required", "django_auth.permission_required"})
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class Classification:
|
|
59
|
+
"""A label, the rule that produced it (None for added and removed routes) and why."""
|
|
60
|
+
|
|
61
|
+
label: Label
|
|
62
|
+
rule: str | None
|
|
63
|
+
reason: str
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
ADDED = Classification("added", None, "route added")
|
|
67
|
+
REMOVED = Classification("removed", None, "route removed")
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class Rule:
|
|
72
|
+
"""One classification rule: an id, a one-line summary for docs and a check that
|
|
73
|
+
returns a `Classification` when the rule applies and None otherwise."""
|
|
74
|
+
|
|
75
|
+
id: str
|
|
76
|
+
summary: str
|
|
77
|
+
check: Callable[[RouteChange], Classification | None]
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass(frozen=True, kw_only=True)
|
|
81
|
+
class ClassifiedRoute:
|
|
82
|
+
"""One entry of a classified diff. `route` is the current route, or the base route
|
|
83
|
+
for a removed one; `change` is set for routes present on both sides."""
|
|
84
|
+
|
|
85
|
+
key: str
|
|
86
|
+
route: Route
|
|
87
|
+
classification: Classification
|
|
88
|
+
change: RouteChange | None = None
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def classify(change: RouteChange, rules: tuple[Rule, ...] | None = None) -> Classification:
|
|
92
|
+
"""Classify one changed route with the first rule that applies."""
|
|
93
|
+
for rule in RULES if rules is None else rules:
|
|
94
|
+
result = rule.check(change)
|
|
95
|
+
if result is not None:
|
|
96
|
+
return result
|
|
97
|
+
return _r8_anything_else(change)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def classify_diff(diff: Diff, rules: tuple[Rule, ...] | None = None) -> tuple[ClassifiedRoute, ...]:
|
|
101
|
+
"""Every added, removed and changed route of `diff` with its label, sorted by route key."""
|
|
102
|
+
entries = [
|
|
103
|
+
*(
|
|
104
|
+
ClassifiedRoute(key=route.key(), route=route, classification=ADDED)
|
|
105
|
+
for route in diff.added
|
|
106
|
+
),
|
|
107
|
+
*(
|
|
108
|
+
ClassifiedRoute(key=route.key(), route=route, classification=REMOVED)
|
|
109
|
+
for route in diff.removed
|
|
110
|
+
),
|
|
111
|
+
*(
|
|
112
|
+
ClassifiedRoute(
|
|
113
|
+
key=change.key(),
|
|
114
|
+
route=change.current,
|
|
115
|
+
classification=classify(change, rules),
|
|
116
|
+
change=change,
|
|
117
|
+
)
|
|
118
|
+
for change in diff.changed
|
|
119
|
+
),
|
|
120
|
+
]
|
|
121
|
+
return tuple(
|
|
122
|
+
sorted(entries, key=lambda entry: (entry.key, LABELS.index(entry.classification.label)))
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# Helpers ---------------------------------------------------------------------------------
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _changed(change: RouteChange) -> dict[str, FieldChange]:
|
|
130
|
+
"""Changed fields by name, without the neutral ones."""
|
|
131
|
+
return {fc.field: fc for fc in change.fields if fc.field not in NEUTRAL_FIELDS}
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _only(change: RouteChange, *names: str) -> FieldChange | None:
|
|
135
|
+
"""The change to `names[0]` when it changed and nothing outside `names` did."""
|
|
136
|
+
changed = _changed(change)
|
|
137
|
+
if names[0] not in changed or not set(changed) <= set(names):
|
|
138
|
+
return None
|
|
139
|
+
return changed[names[0]]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _is_composed(entry: str) -> bool:
|
|
143
|
+
return entry.startswith("(")
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _is_custom(entry: str) -> bool:
|
|
147
|
+
return entry.rsplit(".", 1)[0] != BUILTIN_MODULE
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass(frozen=True)
|
|
151
|
+
class _Split:
|
|
152
|
+
"""A permission list split into ranked built-ins and opaque single classes."""
|
|
153
|
+
|
|
154
|
+
ranked: frozenset[str]
|
|
155
|
+
opaque: frozenset[str]
|
|
156
|
+
|
|
157
|
+
@property
|
|
158
|
+
def strongest(self) -> int:
|
|
159
|
+
return max((BUILTIN_RANK[entry] for entry in self.ranked), default=0)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _split(value: Any) -> _Split | None:
|
|
163
|
+
"""None when the list is not a plain list of classes (null, dynamic or composed)."""
|
|
164
|
+
if not isinstance(value, tuple) or any(_is_composed(entry) for entry in value):
|
|
165
|
+
return None
|
|
166
|
+
ranked = frozenset(entry for entry in value if entry in BUILTIN_RANK)
|
|
167
|
+
return _Split(ranked=ranked, opaque=frozenset(value) - ranked)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _permission_sets(change: RouteChange) -> tuple[_Split, _Split] | None:
|
|
171
|
+
"""Old and new permission lists when only they (and neutral fields) changed and both
|
|
172
|
+
are plain lists of classes."""
|
|
173
|
+
fc = _only(change, "permission_classes")
|
|
174
|
+
if fc is None:
|
|
175
|
+
return None
|
|
176
|
+
old, new = _split(fc.old), _split(fc.new)
|
|
177
|
+
if old is None or new is None:
|
|
178
|
+
return None
|
|
179
|
+
return old, new
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _rank_name(rank: int) -> str:
|
|
183
|
+
return next(name for name, value in BUILTIN_RANK.items() if value == rank)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _describe(entries: frozenset[str]) -> str:
|
|
187
|
+
return ", ".join(
|
|
188
|
+
f"{'custom' if _is_custom(entry) else 'unranked'} class {entry}"
|
|
189
|
+
for entry in sorted(entries)
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
# Rules -----------------------------------------------------------------------------------
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _r1_builtin_ranking(change: RouteChange) -> Classification | None:
|
|
197
|
+
sets = _permission_sets(change)
|
|
198
|
+
if sets is None:
|
|
199
|
+
return None
|
|
200
|
+
old, new = sets
|
|
201
|
+
if old.opaque or new.opaque or old.strongest == new.strongest:
|
|
202
|
+
return None
|
|
203
|
+
label: Label = "loosened" if new.strongest < old.strongest else "tightened"
|
|
204
|
+
return Classification(
|
|
205
|
+
label,
|
|
206
|
+
"R1",
|
|
207
|
+
f"R1: strongest built-in {_rank_name(old.strongest)} -> {_rank_name(new.strongest)}",
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _r2_custom_removed(change: RouteChange) -> Classification | None:
|
|
212
|
+
sets = _permission_sets(change)
|
|
213
|
+
if sets is None:
|
|
214
|
+
return None
|
|
215
|
+
old, new = sets
|
|
216
|
+
# Any opaque class left in the new list could make up for the removal, so R2 never
|
|
217
|
+
# fires then; that is what guarantees a new custom class never yields `loosened`.
|
|
218
|
+
if not old.opaque or new.opaque:
|
|
219
|
+
return None
|
|
220
|
+
ceiling = max(old.strongest, BUILTIN_RANK[IS_AUTHENTICATED])
|
|
221
|
+
if new.strongest > ceiling:
|
|
222
|
+
return None
|
|
223
|
+
if new.ranked <= old.ranked:
|
|
224
|
+
detail = f"{_describe(old.opaque)} removed"
|
|
225
|
+
else:
|
|
226
|
+
detail = f"{_describe(old.opaque)} replaced by built-in {_rank_name(new.strongest)}"
|
|
227
|
+
return Classification("loosened", "R2", f"R2: {detail}")
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _r3_custom_replaced(change: RouteChange) -> Classification | None:
|
|
231
|
+
sets = _permission_sets(change)
|
|
232
|
+
if sets is None:
|
|
233
|
+
return None
|
|
234
|
+
old, new = sets
|
|
235
|
+
removed, added = old.opaque - new.opaque, new.opaque - old.opaque
|
|
236
|
+
if not removed:
|
|
237
|
+
return None
|
|
238
|
+
if added:
|
|
239
|
+
detail = f"{_describe(removed)} replaced by {_describe(added)}"
|
|
240
|
+
elif not new.opaque and new.strongest == BUILTIN_RANK[IS_ADMIN_USER]:
|
|
241
|
+
detail = f"{_describe(removed)} replaced by built-in {IS_ADMIN_USER}"
|
|
242
|
+
else:
|
|
243
|
+
return None
|
|
244
|
+
return Classification("changed-unknown", "R3", f"R3: {detail}; cannot rank a custom class")
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _r4_custom_added(change: RouteChange) -> Classification | None:
|
|
248
|
+
sets = _permission_sets(change)
|
|
249
|
+
if sets is None:
|
|
250
|
+
return None
|
|
251
|
+
old, new = sets
|
|
252
|
+
added = new.opaque - old.opaque
|
|
253
|
+
kept = old.ranked <= new.ranked and old.opaque <= new.opaque
|
|
254
|
+
if not added or not kept:
|
|
255
|
+
return None
|
|
256
|
+
return Classification(
|
|
257
|
+
"tightened", "R4", f"R4: {_describe(added)} added to the existing classes"
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
def _r5_dynamic(change: RouteChange) -> Classification | None:
|
|
262
|
+
for fc in change.fields:
|
|
263
|
+
if DYNAMIC in (fc.old, fc.new):
|
|
264
|
+
detail = f"{fc.field} {_show(fc.old)} -> {_show(fc.new)}"
|
|
265
|
+
return Classification("changed-unknown", "R5", f"R5: {detail}; dynamic is not ranked")
|
|
266
|
+
return None
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def _show(value: Any) -> str:
|
|
270
|
+
if isinstance(value, tuple):
|
|
271
|
+
return f"[{', '.join(str(item) for item in value)}]"
|
|
272
|
+
return "null" if value is None else str(value)
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def _django_auth_level(auth: Mapping[str, Any]) -> int:
|
|
276
|
+
if auth.get("permission_required"):
|
|
277
|
+
return DJANGO_AUTH_RANK["permission_required"]
|
|
278
|
+
if auth.get("login_required"):
|
|
279
|
+
return DJANGO_AUTH_RANK["login_required"]
|
|
280
|
+
return DJANGO_AUTH_RANK["none"]
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def _r6_django_auth(change: RouteChange) -> Classification | None:
|
|
284
|
+
changed = _changed(change)
|
|
285
|
+
if not changed or not set(changed) <= _DJANGO_AUTH_LEAVES:
|
|
286
|
+
return None
|
|
287
|
+
old = dict(change.base.django_auth or {})
|
|
288
|
+
new = dict(change.current.django_auth or {})
|
|
289
|
+
# A custom test or an unreadable permission_required argument is opaque: leave it to R8.
|
|
290
|
+
for side in (old, new):
|
|
291
|
+
if side.get("user_passes_test") or NON_LITERAL in (side.get("unknown_decorators") or ()):
|
|
292
|
+
return None
|
|
293
|
+
old_level, new_level = _django_auth_level(old), _django_auth_level(new)
|
|
294
|
+
old_perms = frozenset(old.get("permission_required") or ())
|
|
295
|
+
new_perms = frozenset(new.get("permission_required") or ())
|
|
296
|
+
names = {value: name for name, value in DJANGO_AUTH_RANK.items()}
|
|
297
|
+
if old_level != new_level:
|
|
298
|
+
label: Label = "loosened" if new_level < old_level else "tightened"
|
|
299
|
+
detail = f"{names[old_level]} -> {names[new_level]}"
|
|
300
|
+
elif new_level == DJANGO_AUTH_RANK["permission_required"] and old_perms < new_perms:
|
|
301
|
+
label, detail = (
|
|
302
|
+
"tightened",
|
|
303
|
+
f"permission_required gained {', '.join(sorted(new_perms - old_perms))}",
|
|
304
|
+
)
|
|
305
|
+
elif new_level == DJANGO_AUTH_RANK["permission_required"] and new_perms < old_perms:
|
|
306
|
+
label, detail = (
|
|
307
|
+
"loosened",
|
|
308
|
+
f"permission_required lost {', '.join(sorted(old_perms - new_perms))}",
|
|
309
|
+
)
|
|
310
|
+
else:
|
|
311
|
+
return None
|
|
312
|
+
return Classification(label, "R6", f"R6: django_auth {detail}")
|
|
313
|
+
|
|
314
|
+
|
|
315
|
+
def _r7_methods_gained(change: RouteChange) -> Classification | None:
|
|
316
|
+
fc = _only(change, "methods", "actions")
|
|
317
|
+
if fc is None:
|
|
318
|
+
return None
|
|
319
|
+
old, new = set(fc.old or ()), set(fc.new or ())
|
|
320
|
+
if not old < new:
|
|
321
|
+
return None
|
|
322
|
+
gained = ", ".join(sorted(new - old))
|
|
323
|
+
return Classification(
|
|
324
|
+
"changed-unknown", "R7", f"R7: methods gained {gained} with permissions unchanged"
|
|
325
|
+
)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _r8_anything_else(change: RouteChange) -> Classification:
|
|
329
|
+
fields = [fc.field for fc in change.fields]
|
|
330
|
+
perm = next((fc for fc in change.fields if fc.field == "permission_classes"), None)
|
|
331
|
+
if perm is not None and any(
|
|
332
|
+
isinstance(side, tuple) and any(_is_composed(entry) for entry in side)
|
|
333
|
+
for side in (perm.old, perm.new)
|
|
334
|
+
):
|
|
335
|
+
detail = "composed permission expression is not ranked"
|
|
336
|
+
else:
|
|
337
|
+
detail = "no rule covers this change"
|
|
338
|
+
return Classification("changed-unknown", "R8", f"R8: {detail} ({', '.join(fields)})")
|
|
339
|
+
|
|
340
|
+
|
|
341
|
+
RULES: tuple[Rule, ...] = (
|
|
342
|
+
Rule(
|
|
343
|
+
"R1",
|
|
344
|
+
"Both permission lists hold only ranked built-ins: compare the strongest class.",
|
|
345
|
+
_r1_builtin_ranking,
|
|
346
|
+
),
|
|
347
|
+
Rule(
|
|
348
|
+
"R2",
|
|
349
|
+
"Custom class removed, or replaced by a built-in at or below IsAuthenticated: loosened.",
|
|
350
|
+
_r2_custom_removed,
|
|
351
|
+
),
|
|
352
|
+
Rule(
|
|
353
|
+
"R3",
|
|
354
|
+
"Custom class replaced by IsAdminUser or another custom class: changed-unknown.",
|
|
355
|
+
_r3_custom_replaced,
|
|
356
|
+
),
|
|
357
|
+
Rule("R4", "Custom class added to an otherwise unchanged list: tightened.", _r4_custom_added),
|
|
358
|
+
Rule("R5", "Either side of a changed field is dynamic: changed-unknown.", _r5_dynamic),
|
|
359
|
+
Rule(
|
|
360
|
+
"R6",
|
|
361
|
+
"login_required or permission_required gained: tightened; the reverse: loosened.",
|
|
362
|
+
_r6_django_auth,
|
|
363
|
+
),
|
|
364
|
+
Rule(
|
|
365
|
+
"R7",
|
|
366
|
+
"Methods gained entries with permissions unchanged: changed-unknown.",
|
|
367
|
+
_r7_methods_gained,
|
|
368
|
+
),
|
|
369
|
+
Rule("R8", "Anything else: changed-unknown.", _r8_anything_else),
|
|
370
|
+
)
|