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,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,6 @@
1
+ """Run PermissionDiff as ``python -m permissiondiff``."""
2
+
3
+ from permissiondiff.cli import app
4
+
5
+ if __name__ == "__main__":
6
+ app()
@@ -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
+ '''
@@ -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")