permissiondiff 0.1.0__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.
@@ -0,0 +1,278 @@
1
+ """Typed domain vocabulary for authorization evaluation and reporting."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from dataclasses import dataclass, field, replace
8
+ from enum import StrEnum
9
+ from typing import Any, Protocol
10
+
11
+ JsonObject = dict[str, Any]
12
+
13
+
14
+ class Decision(StrEnum):
15
+ """The only two valid authorization verdicts."""
16
+
17
+ ALLOW = "ALLOW"
18
+ DENY = "DENY"
19
+
20
+
21
+ class ChangeType(StrEnum):
22
+ """Classification of one baseline/candidate decision pair."""
23
+
24
+ UNCHANGED_DENIED = "unchanged_denied"
25
+ UNCHANGED_ALLOWED = "unchanged_allowed"
26
+ NEWLY_ALLOWED = "newly_allowed"
27
+ NEWLY_DENIED = "newly_denied"
28
+
29
+
30
+ class FindingKind(StrEnum):
31
+ """Kinds of actionable results PermissionDiff persists."""
32
+
33
+ CHANGE = "change"
34
+ INVARIANT_VIOLATION = "invariant_violation"
35
+ EVALUATION_ERROR = "evaluation_error"
36
+
37
+
38
+ class Severity(StrEnum):
39
+ """Human-facing finding severity."""
40
+
41
+ CRITICAL = "critical"
42
+ WARNING = "warning"
43
+ ERROR = "error"
44
+
45
+
46
+ @dataclass(frozen=True, slots=True)
47
+ class Subject:
48
+ """A principal whose authority is being tested."""
49
+
50
+ id: str
51
+ tenant: str | None = None
52
+ role: str | None = None
53
+ attributes: JsonObject = field(default_factory=dict)
54
+
55
+ def to_dict(self) -> JsonObject:
56
+ """Return a JSON-compatible representation."""
57
+ return {
58
+ "id": self.id,
59
+ "tenant": self.tenant,
60
+ "role": self.role,
61
+ "attributes": self.attributes,
62
+ }
63
+
64
+ @classmethod
65
+ def from_dict(cls, data: JsonObject) -> Subject:
66
+ """Build a subject from serialized data."""
67
+ return cls(
68
+ id=str(data["id"]),
69
+ tenant=_optional_string(data.get("tenant")),
70
+ role=_optional_string(data.get("role")),
71
+ attributes=dict(data.get("attributes", {})),
72
+ )
73
+
74
+
75
+ @dataclass(frozen=True, slots=True)
76
+ class Resource:
77
+ """An application object an action may target."""
78
+
79
+ id: str
80
+ type: str
81
+ tenant: str | None = None
82
+ owner_id: str | None = None
83
+ attributes: JsonObject = field(default_factory=dict)
84
+
85
+ def to_dict(self) -> JsonObject:
86
+ """Return a JSON-compatible representation."""
87
+ return {
88
+ "id": self.id,
89
+ "type": self.type,
90
+ "tenant": self.tenant,
91
+ "owner_id": self.owner_id,
92
+ "attributes": self.attributes,
93
+ }
94
+
95
+ @classmethod
96
+ def from_dict(cls, data: JsonObject) -> Resource:
97
+ """Build a resource from serialized data."""
98
+ return cls(
99
+ id=str(data["id"]),
100
+ type=str(data["type"]),
101
+ tenant=_optional_string(data.get("tenant")),
102
+ owner_id=_optional_string(data.get("owner_id")),
103
+ attributes=dict(data.get("attributes", {})),
104
+ )
105
+
106
+
107
+ @dataclass(frozen=True, slots=True)
108
+ class Action:
109
+ """A named operation a subject may attempt."""
110
+
111
+ name: str
112
+
113
+ def __str__(self) -> str:
114
+ """Return the action name for concise authorizer code."""
115
+ return self.name
116
+
117
+
118
+ @dataclass(frozen=True, slots=True)
119
+ class Context:
120
+ """Additional deterministic inputs to an authorization decision."""
121
+
122
+ amount: int | float | None = None
123
+ attributes: JsonObject = field(default_factory=dict)
124
+
125
+ def to_dict(self) -> JsonObject:
126
+ """Return a JSON-compatible representation."""
127
+ return {"amount": self.amount, "attributes": self.attributes}
128
+
129
+ @classmethod
130
+ def from_dict(cls, data: JsonObject) -> Context:
131
+ """Build context from serialized data."""
132
+ amount = data.get("amount")
133
+ if amount is not None and not isinstance(amount, int | float):
134
+ raise ValueError("context.amount must be numeric or null")
135
+ return cls(amount=amount, attributes=dict(data.get("attributes", {})))
136
+
137
+
138
+ @dataclass(frozen=True, slots=True)
139
+ class AuthorizationCase:
140
+ """One complete, replayable authorization input."""
141
+
142
+ subject: Subject
143
+ action: Action
144
+ resource: Resource
145
+ context: Context = field(default_factory=Context)
146
+
147
+ def to_dict(self) -> JsonObject:
148
+ """Return canonical semantic case data."""
149
+ return {
150
+ "subject": self.subject.to_dict(),
151
+ "action": self.action.name,
152
+ "resource": self.resource.to_dict(),
153
+ "context": self.context.to_dict(),
154
+ }
155
+
156
+ @classmethod
157
+ def from_dict(cls, data: JsonObject) -> AuthorizationCase:
158
+ """Recreate an authorization case from serialized data."""
159
+ return cls(
160
+ subject=Subject.from_dict(_object(data["subject"])),
161
+ action=Action(str(data["action"])),
162
+ resource=Resource.from_dict(_object(data["resource"])),
163
+ context=Context.from_dict(_object(data.get("context", {}))),
164
+ )
165
+
166
+ @property
167
+ def fingerprint(self) -> str:
168
+ """Return a stable digest containing only semantic case fields."""
169
+ payload = json.dumps(self.to_dict(), sort_keys=True, separators=(",", ":"))
170
+ return hashlib.sha256(payload.encode()).hexdigest()
171
+
172
+
173
+ class AuthorizationFunction(Protocol):
174
+ """Interface implemented by user authorization functions."""
175
+
176
+ def __call__(
177
+ self,
178
+ subject: Subject,
179
+ action: Action,
180
+ resource: Resource,
181
+ context: Context,
182
+ ) -> Decision: ...
183
+
184
+
185
+ @dataclass(frozen=True, slots=True)
186
+ class CaseEvaluation:
187
+ """A decision or explicit evaluation error for one case."""
188
+
189
+ case: AuthorizationCase
190
+ decision: Decision | None = None
191
+ error: str | None = None
192
+
193
+ def __post_init__(self) -> None:
194
+ """Require exactly one of decision and error."""
195
+ if (self.decision is None) == (self.error is None):
196
+ raise ValueError("evaluation must contain exactly one of decision or error")
197
+
198
+
199
+ @dataclass(frozen=True, slots=True)
200
+ class ComparisonResult:
201
+ """One baseline/candidate comparison over the same exact case."""
202
+
203
+ case: AuthorizationCase
204
+ baseline: Decision
205
+ candidate: Decision
206
+ change: ChangeType
207
+
208
+
209
+ @dataclass(frozen=True, slots=True)
210
+ class InvariantResult:
211
+ """Result of evaluating a deterministic invariant."""
212
+
213
+ name: str
214
+ passed: bool
215
+ message: str
216
+
217
+
218
+ @dataclass(frozen=True, slots=True)
219
+ class Finding:
220
+ """A stable, replayable issue found by evaluation or comparison."""
221
+
222
+ kind: FindingKind
223
+ severity: Severity
224
+ case: AuthorizationCase
225
+ message: str
226
+ finding_id: str = ""
227
+ baseline: Decision | None = None
228
+ candidate: Decision | None = None
229
+ change: ChangeType | None = None
230
+ invariant: str | None = None
231
+ error: str | None = None
232
+ equivalent_cases: int = 1
233
+
234
+ @property
235
+ def semantic_key(self) -> str:
236
+ """Return stable finding identity independent of display ID."""
237
+ payload = {
238
+ "kind": self.kind.value,
239
+ "case_fingerprint": self.case.fingerprint,
240
+ "baseline": self.baseline.value if self.baseline else None,
241
+ "candidate": self.candidate.value if self.candidate else None,
242
+ "change": self.change.value if self.change else None,
243
+ "invariant": self.invariant,
244
+ "error": self.error,
245
+ }
246
+ return json.dumps(payload, sort_keys=True, separators=(",", ":"))
247
+
248
+ def with_id(self, finding_id: str) -> Finding:
249
+ """Return this immutable finding with its stable display ID."""
250
+ return replace(self, finding_id=finding_id)
251
+
252
+ def to_dict(self) -> JsonObject:
253
+ """Return a machine-readable finding and reproduction."""
254
+ return {
255
+ "schema_version": 1,
256
+ "finding_id": self.finding_id,
257
+ "kind": self.kind.value,
258
+ "severity": self.severity.value,
259
+ "message": self.message,
260
+ "invariant": self.invariant,
261
+ "change": self.change.value if self.change else None,
262
+ "baseline": self.baseline.value if self.baseline else None,
263
+ "candidate": self.candidate.value if self.candidate else None,
264
+ "error": self.error,
265
+ "equivalent_cases": self.equivalent_cases,
266
+ "case_fingerprint": self.case.fingerprint,
267
+ "case": self.case.to_dict(),
268
+ }
269
+
270
+
271
+ def _object(value: object) -> JsonObject:
272
+ if not isinstance(value, dict):
273
+ raise ValueError("expected an object")
274
+ return value
275
+
276
+
277
+ def _optional_string(value: object) -> str | None:
278
+ return None if value is None else str(value)
@@ -0,0 +1 @@
1
+
@@ -0,0 +1,121 @@
1
+ """Human presentation and canonical JSON persistence for findings."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from rich.console import Console
9
+ from rich.table import Table
10
+
11
+ from permissiondiff.config import FailOnConfig
12
+ from permissiondiff.models import ChangeType, Finding, FindingKind, JsonObject, Severity
13
+
14
+ REPORT_SCHEMA_VERSION = 1
15
+
16
+
17
+ def assign_finding_ids(findings: list[Finding] | tuple[Finding, ...]) -> list[Finding]:
18
+ """Assign stable sequential IDs after deterministic semantic sorting."""
19
+ ordered = sorted(findings, key=lambda finding: finding.semantic_key)
20
+ return [finding.with_id(f"PD-{index:04d}") for index, finding in enumerate(ordered, 1)]
21
+
22
+
23
+ def persist_findings(
24
+ findings: list[Finding],
25
+ *,
26
+ failures_dir: Path,
27
+ report_path: Path,
28
+ cases_evaluated: int,
29
+ ) -> None:
30
+ """Write canonical report JSON and one exact reproduction per finding."""
31
+ failures_dir.mkdir(parents=True, exist_ok=True)
32
+ for stale in failures_dir.glob("PD-*.json"):
33
+ stale.unlink()
34
+ for finding in findings:
35
+ destination = failures_dir / f"{finding.finding_id}.json"
36
+ _write_json(destination, finding.to_dict())
37
+ report: JsonObject = {
38
+ "schema_version": REPORT_SCHEMA_VERSION,
39
+ "cases_evaluated": cases_evaluated,
40
+ "finding_count": len(findings),
41
+ "findings": [finding.to_dict() for finding in findings],
42
+ }
43
+ _write_json(report_path, report)
44
+
45
+
46
+ def render_terminal(
47
+ findings: list[Finding],
48
+ *,
49
+ cases_evaluated: int,
50
+ failures_dir: Path,
51
+ console: Console | None = None,
52
+ ) -> None:
53
+ """Render concise actionable findings without affecting semantics."""
54
+ output = console or Console()
55
+ output.print(f"[bold]PermissionDiff[/bold] evaluated {cases_evaluated:,} exact cases")
56
+ if not findings:
57
+ output.print("[green]PASS[/green] No authorization changes or invariant violations found.")
58
+ return
59
+
60
+ table = Table(show_header=True, header_style="bold")
61
+ table.add_column("ID")
62
+ table.add_column("Severity")
63
+ table.add_column("Result")
64
+ table.add_column("Minimal reproduction")
65
+ for finding in findings:
66
+ case = finding.case
67
+ path = failures_dir / f"{finding.finding_id}.json"
68
+ summary = (
69
+ f"{case.subject.role or case.subject.id}({case.subject.tenant or '-'}) "
70
+ f"→ {case.action.name} → {case.resource.type}({case.resource.tenant or '-'})"
71
+ )
72
+ decisions = ""
73
+ if finding.baseline and finding.candidate:
74
+ decisions = f" [{finding.baseline.value} → {finding.candidate.value}]"
75
+ elif finding.candidate:
76
+ decisions = f" [actual: {finding.candidate.value}]"
77
+ equivalent = (
78
+ f" ({finding.equivalent_cases} equivalent cases)"
79
+ if finding.equivalent_cases > 1
80
+ else ""
81
+ )
82
+ table.add_row(
83
+ finding.finding_id,
84
+ _severity_label(finding.severity),
85
+ f"{finding.message}\n{summary}{decisions}{equivalent}",
86
+ str(path),
87
+ )
88
+ output.print(table)
89
+
90
+
91
+ def result_exit_code(findings: list[Finding], fail_on: FailOnConfig) -> int:
92
+ """Apply the documented CI exit-code contract."""
93
+ if any(finding.kind is FindingKind.EVALUATION_ERROR for finding in findings):
94
+ return 3
95
+ for finding in findings:
96
+ if finding.kind is FindingKind.INVARIANT_VIOLATION and fail_on.invariant_violation:
97
+ return 1
98
+ if finding.change is ChangeType.NEWLY_ALLOWED and fail_on.newly_allowed:
99
+ return 1
100
+ if finding.change is ChangeType.NEWLY_DENIED and fail_on.newly_denied:
101
+ return 1
102
+ return 0
103
+
104
+
105
+ def explain_finding(path: Path) -> str:
106
+ """Return a persisted reproduction as readable canonical JSON."""
107
+ return json.dumps(json.loads(path.read_text(encoding="utf-8")), indent=2, sort_keys=True)
108
+
109
+
110
+ def _write_json(path: Path, payload: JsonObject) -> None:
111
+ path.parent.mkdir(parents=True, exist_ok=True)
112
+ path.write_text(json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8")
113
+
114
+
115
+ def _severity_label(severity: Severity) -> str:
116
+ colors = {
117
+ Severity.CRITICAL: "[bold red]CRITICAL[/bold red]",
118
+ Severity.WARNING: "[yellow]WARNING[/yellow]",
119
+ Severity.ERROR: "[bold magenta]ERROR[/bold magenta]",
120
+ }
121
+ return colors[severity]
@@ -0,0 +1,138 @@
1
+ """Versioned, canonical snapshots containing exact cases and decisions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from permissiondiff import __version__
11
+ from permissiondiff.errors import EvaluationError, SnapshotVersionError
12
+ from permissiondiff.models import AuthorizationCase, CaseEvaluation, Decision, JsonObject
13
+
14
+ SNAPSHOT_SCHEMA_VERSION = 1
15
+
16
+
17
+ @dataclass(frozen=True, slots=True)
18
+ class SnapshotCase:
19
+ """An exact baseline input and its decision."""
20
+
21
+ case: AuthorizationCase
22
+ baseline_decision: Decision
23
+
24
+ def to_dict(self) -> JsonObject:
25
+ """Serialize the exact case, decision, and stable fingerprint."""
26
+ return {
27
+ "fingerprint": self.case.fingerprint,
28
+ "case": self.case.to_dict(),
29
+ "baseline_decision": self.baseline_decision.value,
30
+ }
31
+
32
+ @classmethod
33
+ def from_dict(cls, raw: JsonObject) -> SnapshotCase:
34
+ """Load and integrity-check one stored baseline case."""
35
+ try:
36
+ case = AuthorizationCase.from_dict(_object(raw["case"]))
37
+ decision = Decision(raw["baseline_decision"])
38
+ fingerprint = str(raw["fingerprint"])
39
+ except (KeyError, TypeError, ValueError) as exc:
40
+ raise SnapshotVersionError(f"invalid snapshot case: {exc}") from exc
41
+ if fingerprint != case.fingerprint:
42
+ raise SnapshotVersionError("snapshot case fingerprint does not match case content")
43
+ return cls(case=case, baseline_decision=decision)
44
+
45
+
46
+ @dataclass(frozen=True, slots=True)
47
+ class Snapshot:
48
+ """A complete, versioned baseline corpus."""
49
+
50
+ seed: int
51
+ cases: tuple[SnapshotCase, ...]
52
+ permissiondiff_version: str = __version__
53
+ schema_version: int = SNAPSHOT_SCHEMA_VERSION
54
+
55
+ def to_dict(self) -> JsonObject:
56
+ """Return canonical snapshot data with stable case ordering."""
57
+ return {
58
+ "schema_version": self.schema_version,
59
+ "permissiondiff_version": self.permissiondiff_version,
60
+ "seed": self.seed,
61
+ "cases": [case.to_dict() for case in sorted(self.cases, key=_fingerprint)],
62
+ }
63
+
64
+ @classmethod
65
+ def from_dict(cls, raw: JsonObject) -> Snapshot:
66
+ """Load a supported snapshot without reinterpreting its schema."""
67
+ schema_version = raw.get("schema_version")
68
+ if schema_version != SNAPSHOT_SCHEMA_VERSION:
69
+ raise SnapshotVersionError(
70
+ f"unsupported snapshot schema {schema_version!r}; "
71
+ f"expected {SNAPSHOT_SCHEMA_VERSION}"
72
+ )
73
+ raw_cases = raw.get("cases")
74
+ if not isinstance(raw_cases, list):
75
+ raise SnapshotVersionError("snapshot cases must be a list")
76
+ try:
77
+ return cls(
78
+ schema_version=SNAPSHOT_SCHEMA_VERSION,
79
+ permissiondiff_version=str(raw["permissiondiff_version"]),
80
+ seed=int(raw["seed"]),
81
+ cases=tuple(SnapshotCase.from_dict(_object(case)) for case in raw_cases),
82
+ )
83
+ except (KeyError, TypeError, ValueError) as exc:
84
+ raise SnapshotVersionError(f"invalid snapshot: {exc}") from exc
85
+
86
+
87
+ def snapshot_from_evaluations(evaluations: list[CaseEvaluation], *, seed: int) -> Snapshot:
88
+ """Create a baseline only when every exact case has a decision."""
89
+ failures = [evaluation for evaluation in evaluations if evaluation.error is not None]
90
+ if failures:
91
+ raise EvaluationError(
92
+ f"cannot create snapshot: {len(failures)} case(s) failed evaluation; "
93
+ f"first error: {failures[0].error}"
94
+ )
95
+ return Snapshot(
96
+ seed=seed,
97
+ cases=tuple(
98
+ SnapshotCase(evaluation.case, _decision(evaluation)) for evaluation in evaluations
99
+ ),
100
+ )
101
+
102
+
103
+ def write_snapshot(snapshot: Snapshot, path: Path) -> None:
104
+ """Write canonical, diff-friendly JSON with no volatile fields."""
105
+ path.parent.mkdir(parents=True, exist_ok=True)
106
+ path.write_text(
107
+ json.dumps(snapshot.to_dict(), indent=2, sort_keys=True) + "\n",
108
+ encoding="utf-8",
109
+ )
110
+
111
+
112
+ def load_snapshot(path: Path) -> Snapshot:
113
+ """Read a versioned snapshot and validate its exact-case fingerprints."""
114
+ try:
115
+ raw = json.loads(path.read_text(encoding="utf-8"))
116
+ except FileNotFoundError as exc:
117
+ raise SnapshotVersionError(f"snapshot not found: {path}") from exc
118
+ except (OSError, json.JSONDecodeError) as exc:
119
+ raise SnapshotVersionError(f"could not read snapshot {path}: {exc}") from exc
120
+ if not isinstance(raw, dict):
121
+ raise SnapshotVersionError("snapshot root must be an object")
122
+ return Snapshot.from_dict(raw)
123
+
124
+
125
+ def _decision(evaluation: CaseEvaluation) -> Decision:
126
+ if evaluation.decision is None:
127
+ raise EvaluationError(evaluation.error or "missing decision")
128
+ return evaluation.decision
129
+
130
+
131
+ def _fingerprint(snapshot_case: SnapshotCase) -> str:
132
+ return snapshot_case.case.fingerprint
133
+
134
+
135
+ def _object(value: object) -> dict[str, Any]:
136
+ if not isinstance(value, dict):
137
+ raise SnapshotVersionError("expected a JSON object")
138
+ return value
@@ -0,0 +1,48 @@
1
+ """One-case subprocess worker for fault-isolated authorizer evaluation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ from typing import Any
8
+
9
+ from permissiondiff.loader import load_callable
10
+ from permissiondiff.models import AuthorizationCase, Decision
11
+
12
+
13
+ def evaluate_payload(authorizer_spec: str, payload: dict[str, Any]) -> dict[str, str]:
14
+ """Evaluate one serialized case and return a serialized result."""
15
+ try:
16
+ authorizer = load_callable(authorizer_spec)
17
+ case = AuthorizationCase.from_dict(payload)
18
+ result = authorizer(case.subject, case.action, case.resource, case.context)
19
+ if not isinstance(result, Decision):
20
+ return {
21
+ "error": (
22
+ "authorizer returned an invalid value; expected "
23
+ f"Decision.ALLOW or Decision.DENY, got {result!r}"
24
+ )
25
+ }
26
+ return {"decision": result.value}
27
+ except BaseException as exc: # worker must report SystemExit and user exceptions
28
+ return {"error": f"{type(exc).__name__}: {exc}"}
29
+
30
+
31
+ def main() -> int:
32
+ """Read one case from stdin and write exactly one JSON result."""
33
+ if len(sys.argv) != 2:
34
+ print(json.dumps({"error": "worker requires one authorizer spec"}))
35
+ return 2
36
+ try:
37
+ payload = json.loads(sys.stdin.read())
38
+ if not isinstance(payload, dict):
39
+ raise ValueError("worker input must be an object")
40
+ result = evaluate_payload(sys.argv[1], payload)
41
+ except BaseException as exc:
42
+ result = {"error": f"worker input error: {type(exc).__name__}: {exc}"}
43
+ print(json.dumps(result, sort_keys=True, separators=(",", ":")))
44
+ return 0
45
+
46
+
47
+ if __name__ == "__main__":
48
+ raise SystemExit(main())