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
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""Public PermissionDiff API."""
|
|
2
|
+
|
|
3
|
+
from permissiondiff.models import (
|
|
4
|
+
Action,
|
|
5
|
+
AuthorizationCase,
|
|
6
|
+
ChangeType,
|
|
7
|
+
Context,
|
|
8
|
+
Decision,
|
|
9
|
+
Resource,
|
|
10
|
+
Subject,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"Action",
|
|
15
|
+
"AuthorizationCase",
|
|
16
|
+
"ChangeType",
|
|
17
|
+
"Context",
|
|
18
|
+
"Decision",
|
|
19
|
+
"Resource",
|
|
20
|
+
"Subject",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"""Application orchestration composed from generation, evaluation, and pure logic."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Sequence
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from permissiondiff.config import PermissionDiffConfig
|
|
10
|
+
from permissiondiff.engine import (
|
|
11
|
+
compare_decisions,
|
|
12
|
+
comparison_findings,
|
|
13
|
+
invariant_findings,
|
|
14
|
+
)
|
|
15
|
+
from permissiondiff.evaluator import evaluate_cases
|
|
16
|
+
from permissiondiff.generator import generate_cases, shrink_findings
|
|
17
|
+
from permissiondiff.invariants import build_invariants
|
|
18
|
+
from permissiondiff.models import AuthorizationCase, CaseEvaluation, ComparisonResult, Finding
|
|
19
|
+
from permissiondiff.snapshot import Snapshot, load_snapshot, snapshot_from_evaluations
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass(frozen=True, slots=True)
|
|
23
|
+
class RunResult:
|
|
24
|
+
"""Semantic output shared by human and machine report renderers."""
|
|
25
|
+
|
|
26
|
+
cases_evaluated: int
|
|
27
|
+
findings: tuple[Finding, ...]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def run_test(
|
|
31
|
+
config: PermissionDiffConfig,
|
|
32
|
+
*,
|
|
33
|
+
workdir: Path,
|
|
34
|
+
seed: int,
|
|
35
|
+
max_examples: int | None = None,
|
|
36
|
+
) -> RunResult:
|
|
37
|
+
"""Generate cases, evaluate the authorizer, and check all invariants."""
|
|
38
|
+
cases = generate_cases(config, seed=seed, max_examples=max_examples)
|
|
39
|
+
evaluations = _evaluate(config, cases, workdir)
|
|
40
|
+
invariants = build_invariants(config.invariants, workdir=workdir)
|
|
41
|
+
findings = shrink_findings(
|
|
42
|
+
invariant_findings(evaluations, invariants, minimize=False),
|
|
43
|
+
seed=seed,
|
|
44
|
+
)
|
|
45
|
+
return RunResult(len(cases), tuple(findings))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def create_snapshot(
|
|
49
|
+
config: PermissionDiffConfig,
|
|
50
|
+
*,
|
|
51
|
+
workdir: Path,
|
|
52
|
+
seed: int,
|
|
53
|
+
max_examples: int | None = None,
|
|
54
|
+
) -> Snapshot:
|
|
55
|
+
"""Generate and evaluate the exact baseline case corpus."""
|
|
56
|
+
cases = generate_cases(config, seed=seed, max_examples=max_examples)
|
|
57
|
+
evaluations = _evaluate(config, cases, workdir)
|
|
58
|
+
return snapshot_from_evaluations(evaluations, seed=seed)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def run_diff(
|
|
62
|
+
config: PermissionDiffConfig,
|
|
63
|
+
*,
|
|
64
|
+
workdir: Path,
|
|
65
|
+
baseline_path: Path,
|
|
66
|
+
) -> RunResult:
|
|
67
|
+
"""Replay every exact baseline case against the candidate authorizer."""
|
|
68
|
+
snapshot = load_snapshot(baseline_path)
|
|
69
|
+
cases = [snapshot_case.case for snapshot_case in snapshot.cases]
|
|
70
|
+
evaluations = _evaluate(config, cases, workdir)
|
|
71
|
+
comparisons: list[ComparisonResult] = []
|
|
72
|
+
for snapshot_case, evaluation in zip(snapshot.cases, evaluations, strict=True):
|
|
73
|
+
if evaluation.decision is not None:
|
|
74
|
+
comparisons.append(
|
|
75
|
+
compare_decisions(
|
|
76
|
+
snapshot_case.case,
|
|
77
|
+
snapshot_case.baseline_decision,
|
|
78
|
+
evaluation.decision,
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
invariants = build_invariants(config.invariants, workdir=workdir)
|
|
82
|
+
findings = shrink_findings(
|
|
83
|
+
[
|
|
84
|
+
*comparison_findings(comparisons, minimize=False),
|
|
85
|
+
*invariant_findings(evaluations, invariants, minimize=False),
|
|
86
|
+
],
|
|
87
|
+
seed=snapshot.seed,
|
|
88
|
+
)
|
|
89
|
+
return RunResult(len(cases), tuple(findings))
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _evaluate(
|
|
93
|
+
config: PermissionDiffConfig,
|
|
94
|
+
cases: Sequence[AuthorizationCase],
|
|
95
|
+
workdir: Path,
|
|
96
|
+
) -> list[CaseEvaluation]:
|
|
97
|
+
return evaluate_cases(
|
|
98
|
+
list(cases),
|
|
99
|
+
config.authorizer,
|
|
100
|
+
workdir=workdir,
|
|
101
|
+
timeout_seconds=config.execution.timeout_seconds,
|
|
102
|
+
)
|
permissiondiff/cli.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""Thin Typer command layer for PermissionDiff workflows."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Annotated
|
|
7
|
+
|
|
8
|
+
import typer
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
from permissiondiff import __version__
|
|
12
|
+
from permissiondiff.application import create_snapshot, run_diff, run_test
|
|
13
|
+
from permissiondiff.config import PermissionDiffConfig, load_config
|
|
14
|
+
from permissiondiff.errors import PermissionDiffError
|
|
15
|
+
from permissiondiff.models import Finding
|
|
16
|
+
from permissiondiff.report import (
|
|
17
|
+
assign_finding_ids,
|
|
18
|
+
explain_finding,
|
|
19
|
+
persist_findings,
|
|
20
|
+
render_terminal,
|
|
21
|
+
result_exit_code,
|
|
22
|
+
)
|
|
23
|
+
from permissiondiff.snapshot import write_snapshot
|
|
24
|
+
|
|
25
|
+
app = typer.Typer(
|
|
26
|
+
help="Deterministic differential authorization regression testing.",
|
|
27
|
+
no_args_is_help=True,
|
|
28
|
+
)
|
|
29
|
+
console = Console()
|
|
30
|
+
|
|
31
|
+
CONFIG_OPTION = Annotated[
|
|
32
|
+
Path,
|
|
33
|
+
typer.Option("--config", "-c", help="PermissionDiff YAML configuration."),
|
|
34
|
+
]
|
|
35
|
+
SEED_OPTION = Annotated[int, typer.Option("--seed", help="Case-corpus seed.")]
|
|
36
|
+
DEBUG_OPTION = Annotated[bool, typer.Option("--debug", help="Show unexpected tracebacks.")]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _version_callback(value: bool) -> None:
|
|
40
|
+
if value:
|
|
41
|
+
typer.echo(f"permissiondiff {__version__}")
|
|
42
|
+
raise typer.Exit()
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@app.callback()
|
|
46
|
+
def main(
|
|
47
|
+
version: Annotated[
|
|
48
|
+
bool | None,
|
|
49
|
+
typer.Option("--version", callback=_version_callback, is_eager=True),
|
|
50
|
+
] = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
"""Expose global CLI options."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@app.command()
|
|
56
|
+
def init(
|
|
57
|
+
directory: Annotated[Path, typer.Argument(help="Directory to initialize.")] = Path("."),
|
|
58
|
+
force: Annotated[
|
|
59
|
+
bool,
|
|
60
|
+
typer.Option("--force", help="Overwrite generated sample files."),
|
|
61
|
+
] = False,
|
|
62
|
+
) -> None:
|
|
63
|
+
"""Create a runnable sample config and deliberately vulnerable authorizer."""
|
|
64
|
+
config_path = directory / "permissiondiff.yaml"
|
|
65
|
+
authorizer_path = directory / "permissiondiff_authorizer.py"
|
|
66
|
+
existing = [path for path in (config_path, authorizer_path) if path.exists()]
|
|
67
|
+
if existing and not force:
|
|
68
|
+
console.print(f"[red]Configuration error:[/red] {existing[0]} already exists")
|
|
69
|
+
raise typer.Exit(2)
|
|
70
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
config_path.write_text(_INIT_CONFIG, encoding="utf-8")
|
|
72
|
+
authorizer_path.write_text(_INIT_AUTHORIZER, encoding="utf-8")
|
|
73
|
+
console.print(f"Created {config_path} and {authorizer_path}")
|
|
74
|
+
console.print("Run [bold]permissiondiff test[/bold] to find the sample cross-tenant bug.")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@app.command("test")
|
|
78
|
+
def test_command(
|
|
79
|
+
config: CONFIG_OPTION = Path("permissiondiff.yaml"),
|
|
80
|
+
seed: SEED_OPTION = 42,
|
|
81
|
+
max_examples: Annotated[
|
|
82
|
+
int | None, typer.Option("--max-examples", min=1, help="Override corpus size.")
|
|
83
|
+
] = None,
|
|
84
|
+
json_output: Annotated[
|
|
85
|
+
Path, typer.Option("--json-output", help="Machine-readable report path.")
|
|
86
|
+
] = Path(".permissiondiff/report.json"),
|
|
87
|
+
failures_dir: Annotated[
|
|
88
|
+
Path, typer.Option("--failures-dir", help="Minimal reproduction directory.")
|
|
89
|
+
] = Path(".permissiondiff/failures"),
|
|
90
|
+
debug: DEBUG_OPTION = False,
|
|
91
|
+
) -> None:
|
|
92
|
+
"""Generate cases and enforce configured invariants."""
|
|
93
|
+
try:
|
|
94
|
+
loaded = load_config(config)
|
|
95
|
+
result = run_test(
|
|
96
|
+
loaded,
|
|
97
|
+
workdir=config.resolve().parent,
|
|
98
|
+
seed=seed,
|
|
99
|
+
max_examples=max_examples,
|
|
100
|
+
)
|
|
101
|
+
_finish_run(loaded, result.cases_evaluated, result.findings, failures_dir, json_output)
|
|
102
|
+
except typer.Exit:
|
|
103
|
+
raise
|
|
104
|
+
except PermissionDiffError as exc:
|
|
105
|
+
_fail(exc, debug)
|
|
106
|
+
except Exception as exc:
|
|
107
|
+
_unexpected(exc, debug)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
@app.command()
|
|
111
|
+
def snapshot(
|
|
112
|
+
output: Annotated[Path, typer.Option("--output", "-o", help="Baseline JSON path.")],
|
|
113
|
+
config: CONFIG_OPTION = Path("permissiondiff.yaml"),
|
|
114
|
+
seed: SEED_OPTION = 42,
|
|
115
|
+
max_examples: Annotated[
|
|
116
|
+
int | None, typer.Option("--max-examples", min=1, help="Override corpus size.")
|
|
117
|
+
] = None,
|
|
118
|
+
debug: DEBUG_OPTION = False,
|
|
119
|
+
) -> None:
|
|
120
|
+
"""Record exact generated cases and baseline decisions."""
|
|
121
|
+
try:
|
|
122
|
+
loaded = load_config(config)
|
|
123
|
+
baseline = create_snapshot(
|
|
124
|
+
loaded,
|
|
125
|
+
workdir=config.resolve().parent,
|
|
126
|
+
seed=seed,
|
|
127
|
+
max_examples=max_examples,
|
|
128
|
+
)
|
|
129
|
+
write_snapshot(baseline, output)
|
|
130
|
+
console.print(f"Wrote {len(baseline.cases):,} exact baseline cases to {output}")
|
|
131
|
+
except typer.Exit:
|
|
132
|
+
raise
|
|
133
|
+
except PermissionDiffError as exc:
|
|
134
|
+
_fail(exc, debug)
|
|
135
|
+
except Exception as exc:
|
|
136
|
+
_unexpected(exc, debug)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
@app.command()
|
|
140
|
+
def diff(
|
|
141
|
+
baseline: Annotated[Path, typer.Option("--baseline", help="Baseline snapshot path.")],
|
|
142
|
+
config: CONFIG_OPTION = Path("permissiondiff.yaml"),
|
|
143
|
+
json_output: Annotated[
|
|
144
|
+
Path, typer.Option("--json-output", help="Machine-readable report path.")
|
|
145
|
+
] = Path(".permissiondiff/report.json"),
|
|
146
|
+
failures_dir: Annotated[
|
|
147
|
+
Path, typer.Option("--failures-dir", help="Minimal reproduction directory.")
|
|
148
|
+
] = Path(".permissiondiff/failures"),
|
|
149
|
+
debug: DEBUG_OPTION = False,
|
|
150
|
+
) -> None:
|
|
151
|
+
"""Replay the exact baseline corpus against the candidate authorizer."""
|
|
152
|
+
try:
|
|
153
|
+
loaded = load_config(config)
|
|
154
|
+
result = run_diff(loaded, workdir=config.resolve().parent, baseline_path=baseline)
|
|
155
|
+
_finish_run(loaded, result.cases_evaluated, result.findings, failures_dir, json_output)
|
|
156
|
+
except typer.Exit:
|
|
157
|
+
raise
|
|
158
|
+
except PermissionDiffError as exc:
|
|
159
|
+
_fail(exc, debug)
|
|
160
|
+
except Exception as exc:
|
|
161
|
+
_unexpected(exc, debug)
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
@app.command()
|
|
165
|
+
def explain(
|
|
166
|
+
finding_id: Annotated[str, typer.Argument(help="Stable finding ID, e.g. PD-0001")],
|
|
167
|
+
failures_dir: Annotated[
|
|
168
|
+
Path, typer.Option("--failures-dir", help="Minimal reproduction directory.")
|
|
169
|
+
] = Path(".permissiondiff/failures"),
|
|
170
|
+
) -> None:
|
|
171
|
+
"""Print a persisted minimal reproduction."""
|
|
172
|
+
path = failures_dir / f"{finding_id}.json"
|
|
173
|
+
try:
|
|
174
|
+
console.print_json(explain_finding(path))
|
|
175
|
+
except (OSError, ValueError) as exc:
|
|
176
|
+
console.print(f"[red]Configuration error:[/red] could not read {path}: {exc}")
|
|
177
|
+
raise typer.Exit(2) from exc
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def _finish_run(
|
|
181
|
+
config: PermissionDiffConfig,
|
|
182
|
+
cases_evaluated: int,
|
|
183
|
+
raw_findings: tuple[Finding, ...],
|
|
184
|
+
failures_dir: Path,
|
|
185
|
+
report_path: Path,
|
|
186
|
+
) -> None:
|
|
187
|
+
findings = assign_finding_ids(raw_findings)
|
|
188
|
+
persist_findings(
|
|
189
|
+
findings,
|
|
190
|
+
failures_dir=failures_dir,
|
|
191
|
+
report_path=report_path,
|
|
192
|
+
cases_evaluated=cases_evaluated,
|
|
193
|
+
)
|
|
194
|
+
render_terminal(findings, cases_evaluated=cases_evaluated, failures_dir=failures_dir)
|
|
195
|
+
raise typer.Exit(result_exit_code(findings, config.fail_on))
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _fail(error: PermissionDiffError, debug: bool) -> None:
|
|
199
|
+
if debug:
|
|
200
|
+
raise error
|
|
201
|
+
name = type(error).__name__
|
|
202
|
+
configuration_errors = {
|
|
203
|
+
"ConfigurationError",
|
|
204
|
+
"InvariantDefinitionError",
|
|
205
|
+
"SnapshotVersionError",
|
|
206
|
+
}
|
|
207
|
+
exit_code = 2 if name in configuration_errors else 3
|
|
208
|
+
console.print(f"[red]{name}:[/red] {error}")
|
|
209
|
+
raise typer.Exit(exit_code) from error
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _unexpected(error: Exception, debug: bool) -> None:
|
|
213
|
+
if debug:
|
|
214
|
+
raise error
|
|
215
|
+
console.print(f"[red]Runtime error:[/red] {type(error).__name__}: {error}")
|
|
216
|
+
raise typer.Exit(3) from error
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
_INIT_CONFIG = """authorizer: permissiondiff_authorizer:authorize
|
|
220
|
+
|
|
221
|
+
subjects:
|
|
222
|
+
- {id: alice, tenant: acme, role: support}
|
|
223
|
+
- {id: bob, tenant: globex, role: support}
|
|
224
|
+
- {id: root, tenant: system, role: admin}
|
|
225
|
+
|
|
226
|
+
resources:
|
|
227
|
+
- {id: invoice-acme, type: invoice, tenant: acme, owner_id: alice}
|
|
228
|
+
- {id: invoice-globex, type: invoice, tenant: globex, owner_id: bob}
|
|
229
|
+
|
|
230
|
+
actions: [read_invoice, delete_account]
|
|
231
|
+
|
|
232
|
+
contexts:
|
|
233
|
+
amounts: {min: 0, max: 1000, boundaries: [499, 500, 501]}
|
|
234
|
+
|
|
235
|
+
invariants:
|
|
236
|
+
- tenant_isolation
|
|
237
|
+
- role_boundary: {action: delete_account, allowed_roles: [admin]}
|
|
238
|
+
|
|
239
|
+
fail_on:
|
|
240
|
+
invariant_violation: true
|
|
241
|
+
newly_allowed: true
|
|
242
|
+
newly_denied: false
|
|
243
|
+
|
|
244
|
+
generation: {max_examples: 32}
|
|
245
|
+
execution: {timeout_seconds: 2}
|
|
246
|
+
"""
|
|
247
|
+
|
|
248
|
+
_INIT_AUTHORIZER = '''"""Deliberately vulnerable sample authorizer generated by PermissionDiff."""
|
|
249
|
+
|
|
250
|
+
from permissiondiff import Action, Context, Decision, Resource, Subject
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def authorize(
|
|
254
|
+
subject: Subject,
|
|
255
|
+
action: Action,
|
|
256
|
+
resource: Resource,
|
|
257
|
+
context: Context,
|
|
258
|
+
) -> Decision:
|
|
259
|
+
"""Return a decision; this sample intentionally forgets tenant isolation."""
|
|
260
|
+
if subject.role == "admin":
|
|
261
|
+
return Decision.ALLOW
|
|
262
|
+
if subject.role == "support" and action.name == "read_invoice":
|
|
263
|
+
return Decision.ALLOW # BUG: resource.tenant is never checked
|
|
264
|
+
return Decision.DENY
|
|
265
|
+
'''
|
permissiondiff/config.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""YAML configuration models and validation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
import yaml
|
|
9
|
+
from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator
|
|
10
|
+
|
|
11
|
+
from permissiondiff.errors import ConfigurationError
|
|
12
|
+
from permissiondiff.models import Resource, Subject
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class SubjectConfig(BaseModel):
|
|
16
|
+
"""A meaningful subject declared by the application."""
|
|
17
|
+
|
|
18
|
+
model_config = ConfigDict(extra="forbid")
|
|
19
|
+
|
|
20
|
+
id: str = Field(min_length=1)
|
|
21
|
+
tenant: str | None = None
|
|
22
|
+
role: str | None = None
|
|
23
|
+
attributes: dict[str, Any] = Field(default_factory=dict)
|
|
24
|
+
|
|
25
|
+
def to_domain(self) -> Subject:
|
|
26
|
+
"""Convert boundary validation data into the core model."""
|
|
27
|
+
return Subject(
|
|
28
|
+
id=self.id,
|
|
29
|
+
tenant=self.tenant,
|
|
30
|
+
role=self.role,
|
|
31
|
+
attributes=self.attributes,
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class ResourceConfig(BaseModel):
|
|
36
|
+
"""A meaningful resource declared by the application."""
|
|
37
|
+
|
|
38
|
+
model_config = ConfigDict(extra="forbid")
|
|
39
|
+
|
|
40
|
+
id: str = Field(min_length=1)
|
|
41
|
+
type: str = Field(min_length=1)
|
|
42
|
+
tenant: str | None = None
|
|
43
|
+
owner_id: str | None = None
|
|
44
|
+
attributes: dict[str, Any] = Field(default_factory=dict)
|
|
45
|
+
|
|
46
|
+
def to_domain(self) -> Resource:
|
|
47
|
+
"""Convert boundary validation data into the core model."""
|
|
48
|
+
return Resource(
|
|
49
|
+
id=self.id,
|
|
50
|
+
type=self.type,
|
|
51
|
+
tenant=self.tenant,
|
|
52
|
+
owner_id=self.owner_id,
|
|
53
|
+
attributes=self.attributes,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class AmountDomain(BaseModel):
|
|
58
|
+
"""Numeric context range plus security-relevant boundary values."""
|
|
59
|
+
|
|
60
|
+
model_config = ConfigDict(extra="forbid")
|
|
61
|
+
|
|
62
|
+
min: int = 0
|
|
63
|
+
max: int = 1000
|
|
64
|
+
boundaries: list[int] = Field(default_factory=list)
|
|
65
|
+
|
|
66
|
+
@model_validator(mode="after")
|
|
67
|
+
def validate_range(self) -> AmountDomain:
|
|
68
|
+
"""Reject inverted amount ranges."""
|
|
69
|
+
if self.max < self.min:
|
|
70
|
+
raise ValueError("amounts.max must be greater than or equal to amounts.min")
|
|
71
|
+
return self
|
|
72
|
+
|
|
73
|
+
def interesting_values(self) -> tuple[int, ...]:
|
|
74
|
+
"""Return sorted, unique values that emphasize range edges."""
|
|
75
|
+
candidates = {
|
|
76
|
+
self.min,
|
|
77
|
+
self.max,
|
|
78
|
+
self.min + 1 if self.min < self.max else self.min,
|
|
79
|
+
self.max - 1 if self.min < self.max else self.max,
|
|
80
|
+
*self.boundaries,
|
|
81
|
+
}
|
|
82
|
+
return tuple(sorted(candidates))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
class ContextDomains(BaseModel):
|
|
86
|
+
"""Explicit context dimensions available to case generation."""
|
|
87
|
+
|
|
88
|
+
model_config = ConfigDict(extra="forbid")
|
|
89
|
+
|
|
90
|
+
amounts: AmountDomain | None = None
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
class GenerationConfig(BaseModel):
|
|
94
|
+
"""Bounds for deterministic, lazy case-corpus generation."""
|
|
95
|
+
|
|
96
|
+
model_config = ConfigDict(extra="forbid")
|
|
97
|
+
|
|
98
|
+
max_examples: int = Field(default=64, ge=1, le=10_000)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
class ExecutionConfig(BaseModel):
|
|
102
|
+
"""Fault-isolation settings for the authorizer worker."""
|
|
103
|
+
|
|
104
|
+
model_config = ConfigDict(extra="forbid")
|
|
105
|
+
|
|
106
|
+
timeout_seconds: float = Field(default=2.0, gt=0, le=300)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
class FailOnConfig(BaseModel):
|
|
110
|
+
"""Controls which semantic results fail CI."""
|
|
111
|
+
|
|
112
|
+
model_config = ConfigDict(extra="forbid")
|
|
113
|
+
|
|
114
|
+
invariant_violation: bool = True
|
|
115
|
+
newly_allowed: bool = True
|
|
116
|
+
newly_denied: bool = False
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class PermissionDiffConfig(BaseModel):
|
|
120
|
+
"""Validated user-facing PermissionDiff configuration."""
|
|
121
|
+
|
|
122
|
+
model_config = ConfigDict(extra="forbid")
|
|
123
|
+
|
|
124
|
+
authorizer: str = Field(min_length=3)
|
|
125
|
+
subjects: list[SubjectConfig] = Field(min_length=1)
|
|
126
|
+
resources: list[ResourceConfig] = Field(min_length=1)
|
|
127
|
+
actions: list[str] = Field(min_length=1)
|
|
128
|
+
contexts: ContextDomains = Field(default_factory=ContextDomains)
|
|
129
|
+
invariants: list[str | dict[str, Any]] = Field(default_factory=list)
|
|
130
|
+
fail_on: FailOnConfig = Field(default_factory=FailOnConfig)
|
|
131
|
+
generation: GenerationConfig = Field(default_factory=GenerationConfig)
|
|
132
|
+
execution: ExecutionConfig = Field(default_factory=ExecutionConfig)
|
|
133
|
+
|
|
134
|
+
@model_validator(mode="after")
|
|
135
|
+
def validate_domain_identity(self) -> PermissionDiffConfig:
|
|
136
|
+
"""Require unique named entities and actions for stable fingerprints."""
|
|
137
|
+
_ensure_unique([subject.id for subject in self.subjects], "subject ids")
|
|
138
|
+
_ensure_unique([resource.id for resource in self.resources], "resource ids")
|
|
139
|
+
_ensure_unique(self.actions, "actions")
|
|
140
|
+
if any(not action.strip() for action in self.actions):
|
|
141
|
+
raise ValueError("actions must not be blank")
|
|
142
|
+
return self
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def load_config(path: Path) -> PermissionDiffConfig:
|
|
146
|
+
"""Load and validate a PermissionDiff YAML file."""
|
|
147
|
+
try:
|
|
148
|
+
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
|
|
149
|
+
except FileNotFoundError as exc:
|
|
150
|
+
raise ConfigurationError(f"configuration file not found: {path}") from exc
|
|
151
|
+
except (OSError, yaml.YAMLError) as exc:
|
|
152
|
+
raise ConfigurationError(f"could not read configuration {path}: {exc}") from exc
|
|
153
|
+
if not isinstance(raw, dict):
|
|
154
|
+
raise ConfigurationError("configuration root must be a YAML mapping")
|
|
155
|
+
try:
|
|
156
|
+
return PermissionDiffConfig.model_validate(raw)
|
|
157
|
+
except ValidationError as exc:
|
|
158
|
+
raise ConfigurationError(str(exc)) from exc
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _ensure_unique(values: list[str], label: str) -> None:
|
|
162
|
+
if len(values) != len(set(values)):
|
|
163
|
+
raise ValueError(f"{label} must be unique")
|