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.
- permissiondiff/__init__.py +23 -0
- permissiondiff/__main__.py +6 -0
- permissiondiff/application.py +102 -0
- permissiondiff/cli.py +265 -0
- permissiondiff/config.py +163 -0
- permissiondiff/engine.py +155 -0
- permissiondiff/errors.py +25 -0
- permissiondiff/evaluator.py +86 -0
- permissiondiff/generator.py +148 -0
- permissiondiff/invariants.py +193 -0
- permissiondiff/loader.py +27 -0
- permissiondiff/models.py +278 -0
- permissiondiff/py.typed +1 -0
- permissiondiff/report.py +121 -0
- permissiondiff/snapshot.py +138 -0
- permissiondiff/worker.py +48 -0
- permissiondiff-0.1.0.dist-info/METADATA +244 -0
- permissiondiff-0.1.0.dist-info/RECORD +21 -0
- permissiondiff-0.1.0.dist-info/WHEEL +4 -0
- permissiondiff-0.1.0.dist-info/entry_points.txt +3 -0
- permissiondiff-0.1.0.dist-info/licenses/LICENSE +13 -0
permissiondiff/models.py
ADDED
|
@@ -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)
|
permissiondiff/py.typed
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
permissiondiff/report.py
ADDED
|
@@ -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
|
permissiondiff/worker.py
ADDED
|
@@ -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())
|