permissiondiff 0.1.1__tar.gz → 0.2.0__tar.gz

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.
Files changed (25) hide show
  1. {permissiondiff-0.1.1 → permissiondiff-0.2.0}/PKG-INFO +31 -2
  2. {permissiondiff-0.1.1 → permissiondiff-0.2.0}/README.md +30 -1
  3. {permissiondiff-0.1.1 → permissiondiff-0.2.0}/pyproject.toml +1 -1
  4. {permissiondiff-0.1.1 → permissiondiff-0.2.0}/pyproject.toml.orig +1 -1
  5. {permissiondiff-0.1.1 → permissiondiff-0.2.0}/src/permissiondiff/__init__.py +1 -1
  6. permissiondiff-0.2.0/src/permissiondiff/adapters/__init__.py +68 -0
  7. permissiondiff-0.2.0/src/permissiondiff/adapters/http.py +57 -0
  8. {permissiondiff-0.1.1 → permissiondiff-0.2.0}/src/permissiondiff/application.py +73 -3
  9. {permissiondiff-0.1.1 → permissiondiff-0.2.0}/src/permissiondiff/cli.py +71 -5
  10. {permissiondiff-0.1.1 → permissiondiff-0.2.0}/src/permissiondiff/config.py +12 -1
  11. {permissiondiff-0.1.1 → permissiondiff-0.2.0}/src/permissiondiff/engine.py +110 -2
  12. permissiondiff-0.2.0/src/permissiondiff/evaluator.py +238 -0
  13. {permissiondiff-0.1.1 → permissiondiff-0.2.0}/src/permissiondiff/generator.py +25 -1
  14. permissiondiff-0.2.0/src/permissiondiff/gitref.py +70 -0
  15. {permissiondiff-0.1.1 → permissiondiff-0.2.0}/src/permissiondiff/models.py +63 -2
  16. {permissiondiff-0.1.1 → permissiondiff-0.2.0}/src/permissiondiff/report.py +55 -1
  17. {permissiondiff-0.1.1 → permissiondiff-0.2.0}/src/permissiondiff/worker.py +34 -18
  18. permissiondiff-0.1.1/src/permissiondiff/evaluator.py +0 -86
  19. {permissiondiff-0.1.1 → permissiondiff-0.2.0}/LICENSE +0 -0
  20. {permissiondiff-0.1.1 → permissiondiff-0.2.0}/src/permissiondiff/__main__.py +0 -0
  21. {permissiondiff-0.1.1 → permissiondiff-0.2.0}/src/permissiondiff/errors.py +0 -0
  22. {permissiondiff-0.1.1 → permissiondiff-0.2.0}/src/permissiondiff/invariants.py +0 -0
  23. {permissiondiff-0.1.1 → permissiondiff-0.2.0}/src/permissiondiff/loader.py +0 -0
  24. {permissiondiff-0.1.1 → permissiondiff-0.2.0}/src/permissiondiff/py.typed +0 -0
  25. {permissiondiff-0.1.1 → permissiondiff-0.2.0}/src/permissiondiff/snapshot.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: permissiondiff
3
- Version: 0.1.1
3
+ Version: 0.2.0
4
4
  Summary: Deterministic differential authorization regression testing
5
5
  Keywords: authorization,ci,differential-testing,property-based-testing,security-testing
6
6
  Author: Abishek Giri
@@ -163,6 +163,28 @@ invariants:
163
163
 
164
164
  The callable receives `(AuthorizationCase, Decision)` and returns `bool` or `InvariantResult`.
165
165
 
166
+ ## Least-privilege review
167
+
168
+ `mine` reports the authorizer's effective grant surface — each distinct role × action ×
169
+ resource-type × tenant-relation × ownership pattern it allows — and flags **broad** grants (those
170
+ crossing a tenant boundary or reaching a non-owned resource) for tightening:
171
+
172
+ ```bash
173
+ uv run permissiondiff mine --config permissiondiff.yaml
174
+ ```
175
+
176
+ ## Policy engines and agents
177
+
178
+ Wrap an external policy engine as the authorizer with the adapter toolkit in
179
+ `permissiondiff.adapters` (`from_boolean`, `from_decision`, or `http_authorizer` for OPA-style
180
+ endpoints); runnable example adapters for OPA, OpenFGA, Auth0 FGA, Cedar, and SpiceDB live under
181
+ [`examples/adapters/`](https://github.com/abishekgiri/permissiondiff/tree/main/examples/adapters).
182
+ Adapters make read-only decision calls — point them at a non-production policy instance.
183
+
184
+ For agent / on-behalf-of principals, declare `delegated_by: [id, ...]` on a subject. PermissionDiff
185
+ enforces the least-privilege intersection rule: a delegated principal allowed where any delegator
186
+ is denied (on the identical case) is a critical privilege-escalation finding.
187
+
166
188
  ## Baseline and diff workflow
167
189
 
168
190
  Create a baseline on trusted code:
@@ -179,6 +201,13 @@ uv run permissiondiff diff --config permissiondiff.yaml \
179
201
  --baseline .permissiondiff/main.json
180
202
  ```
181
203
 
204
+ Or skip the snapshot file entirely and diff against a git ref — PermissionDiff evaluates the
205
+ baseline authorizer as it existed at that ref in a temporary, auto-removed worktree:
206
+
207
+ ```bash
208
+ uv run permissiondiff diff --config permissiondiff.yaml --git-ref main
209
+ ```
210
+
182
211
  Classification is exhaustive:
183
212
 
184
213
  | Baseline | Candidate | Result |
@@ -219,7 +248,7 @@ The repository's thin composite action invokes the same CLI without duplicating
219
248
  ```
220
249
 
221
250
  Check out the calling repository before this step and make sure the baseline is present in CI.
222
- Use `@v0.1.1` to pin the latest release; `@v0` follows compatible v0 releases. The
251
+ Use `@v0.2.0` to pin the latest release; `@v0` follows compatible v0 releases. The
223
252
  [published-consumer smoke test](https://github.com/abishekgiri/permissiondiff/blob/main/.github/workflows/published-smoke.yml)
224
253
  exercises the PyPI package and the remote action in a fresh workspace without a source checkout.
225
254
 
@@ -131,6 +131,28 @@ invariants:
131
131
 
132
132
  The callable receives `(AuthorizationCase, Decision)` and returns `bool` or `InvariantResult`.
133
133
 
134
+ ## Least-privilege review
135
+
136
+ `mine` reports the authorizer's effective grant surface — each distinct role × action ×
137
+ resource-type × tenant-relation × ownership pattern it allows — and flags **broad** grants (those
138
+ crossing a tenant boundary or reaching a non-owned resource) for tightening:
139
+
140
+ ```bash
141
+ uv run permissiondiff mine --config permissiondiff.yaml
142
+ ```
143
+
144
+ ## Policy engines and agents
145
+
146
+ Wrap an external policy engine as the authorizer with the adapter toolkit in
147
+ `permissiondiff.adapters` (`from_boolean`, `from_decision`, or `http_authorizer` for OPA-style
148
+ endpoints); runnable example adapters for OPA, OpenFGA, Auth0 FGA, Cedar, and SpiceDB live under
149
+ [`examples/adapters/`](https://github.com/abishekgiri/permissiondiff/tree/main/examples/adapters).
150
+ Adapters make read-only decision calls — point them at a non-production policy instance.
151
+
152
+ For agent / on-behalf-of principals, declare `delegated_by: [id, ...]` on a subject. PermissionDiff
153
+ enforces the least-privilege intersection rule: a delegated principal allowed where any delegator
154
+ is denied (on the identical case) is a critical privilege-escalation finding.
155
+
134
156
  ## Baseline and diff workflow
135
157
 
136
158
  Create a baseline on trusted code:
@@ -147,6 +169,13 @@ uv run permissiondiff diff --config permissiondiff.yaml \
147
169
  --baseline .permissiondiff/main.json
148
170
  ```
149
171
 
172
+ Or skip the snapshot file entirely and diff against a git ref — PermissionDiff evaluates the
173
+ baseline authorizer as it existed at that ref in a temporary, auto-removed worktree:
174
+
175
+ ```bash
176
+ uv run permissiondiff diff --config permissiondiff.yaml --git-ref main
177
+ ```
178
+
150
179
  Classification is exhaustive:
151
180
 
152
181
  | Baseline | Candidate | Result |
@@ -187,7 +216,7 @@ The repository's thin composite action invokes the same CLI without duplicating
187
216
  ```
188
217
 
189
218
  Check out the calling repository before this step and make sure the baseline is present in CI.
190
- Use `@v0.1.1` to pin the latest release; `@v0` follows compatible v0 releases. The
219
+ Use `@v0.2.0` to pin the latest release; `@v0` follows compatible v0 releases. The
191
220
  [published-consumer smoke test](https://github.com/abishekgiri/permissiondiff/blob/main/.github/workflows/published-smoke.yml)
192
221
  exercises the PyPI package and the remote action in a fresh workspace without a source checkout.
193
222
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "permissiondiff"
3
- version = "0.1.1"
3
+ version = "0.2.0"
4
4
  description = "Deterministic differential authorization regression testing"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "permissiondiff"
3
- version = "0.1.1"
3
+ version = "0.2.0"
4
4
  description = "Deterministic differential authorization regression testing"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.12"
@@ -20,4 +20,4 @@ __all__ = [
20
20
  "Subject",
21
21
  ]
22
22
 
23
- __version__ = "0.1.1"
23
+ __version__ = "0.2.0"
@@ -0,0 +1,68 @@
1
+ """User-side toolkit for building a PermissionDiff authorizer from an external policy system.
2
+
3
+ Adapters live **outside** the core engine (the core never imports this package). An adapter turns
4
+ an external authorization decision -- a boolean check from a policy-engine SDK (OpenFGA, SpiceDB,
5
+ Cedar, Auth0 FGA, ...) or a JSON response from an HTTP policy endpoint (OPA, ...) -- into the
6
+ ``authorize(subject, action, resource, context) -> Decision`` callable that PermissionDiff
7
+ evaluates.
8
+
9
+ Adapters make **read-only decision calls only**. As with any authorizer, point them at a
10
+ non-production / test policy instance -- never at production data or anything with side effects.
11
+
12
+ Most SDK-based engines reduce to a boolean check, so :func:`from_boolean` is usually all you need;
13
+ :func:`from_decision` covers engines that already return an allow/deny verdict object. See
14
+ ``examples/adapters/`` for concrete per-engine wrappers.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from collections.abc import Callable
20
+
21
+ from permissiondiff.adapters.http import http_authorizer
22
+ from permissiondiff.models import (
23
+ Action,
24
+ AuthorizationFunction,
25
+ Context,
26
+ Decision,
27
+ Resource,
28
+ Subject,
29
+ )
30
+
31
+ BooleanCheck = Callable[[Subject, Action, Resource, Context], bool]
32
+
33
+ __all__ = ["BooleanCheck", "from_boolean", "from_decision", "http_authorizer"]
34
+
35
+
36
+ def from_boolean(check: BooleanCheck) -> AuthorizationFunction:
37
+ """Wrap a boolean allow/deny check (the shape of most SDK calls) as an authorizer."""
38
+
39
+ def _authorize(
40
+ subject: Subject, action: Action, resource: Resource, context: Context
41
+ ) -> Decision:
42
+ return Decision.ALLOW if check(subject, action, resource, context) else Decision.DENY
43
+
44
+ return _authorize
45
+
46
+
47
+ def from_decision(
48
+ check: Callable[[Subject, Action, Resource, Context], bool | Decision],
49
+ ) -> AuthorizationFunction:
50
+ """Wrap a check that returns a bool or an actual ``Decision``.
51
+
52
+ Anything that is not a ``Decision`` or a ``bool`` raises ``TypeError`` so a misconfigured
53
+ adapter surfaces as an explicit evaluation error rather than a silent wrong verdict.
54
+ """
55
+
56
+ def _authorize(
57
+ subject: Subject, action: Action, resource: Resource, context: Context
58
+ ) -> Decision:
59
+ result = check(subject, action, resource, context)
60
+ if isinstance(result, Decision):
61
+ return result
62
+ if isinstance(result, bool):
63
+ return Decision.ALLOW if result else Decision.DENY
64
+ raise TypeError(
65
+ f"adapter check returned {type(result).__name__}, expected bool or Decision"
66
+ )
67
+
68
+ return _authorize
@@ -0,0 +1,57 @@
1
+ """Generic HTTP-JSON policy adapter (e.g. Open Policy Agent's data API), standard library only.
2
+
3
+ This is user-side glue that the core engine never imports. It POSTs a JSON request built from the
4
+ case to a policy endpoint and turns the JSON response into a ``Decision``. Point it at a
5
+ non-production policy server; it performs a read-only decision query.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import json
11
+ import urllib.request
12
+ from collections.abc import Callable
13
+ from typing import Any
14
+
15
+ from permissiondiff.models import (
16
+ Action,
17
+ AuthorizationFunction,
18
+ Context,
19
+ Decision,
20
+ Resource,
21
+ Subject,
22
+ )
23
+
24
+ RequestBuilder = Callable[[Subject, Action, Resource, Context], dict[str, Any]]
25
+ AllowedParser = Callable[[Any], bool]
26
+
27
+
28
+ def http_authorizer(
29
+ endpoint: str,
30
+ *,
31
+ build_request: RequestBuilder,
32
+ parse_allowed: AllowedParser,
33
+ timeout: float = 5.0,
34
+ ) -> AuthorizationFunction:
35
+ """Build an authorizer that queries an HTTP-JSON policy endpoint.
36
+
37
+ ``build_request`` maps a case to the JSON body to POST; ``parse_allowed`` maps the decoded
38
+ JSON response to a boolean allow/deny. Network or parse failures propagate, so PermissionDiff
39
+ records them as explicit evaluation errors rather than guessing a verdict.
40
+ """
41
+
42
+ def _authorize(
43
+ subject: Subject, action: Action, resource: Resource, context: Context
44
+ ) -> Decision:
45
+ body = json.dumps(build_request(subject, action, resource, context)).encode("utf-8")
46
+ # The endpoint is operator-supplied configuration, not attacker-controlled case data.
47
+ request = urllib.request.Request(
48
+ endpoint,
49
+ data=body,
50
+ headers={"Content-Type": "application/json"},
51
+ method="POST",
52
+ )
53
+ with urllib.request.urlopen(request, timeout=timeout) as response:
54
+ payload = json.loads(response.read().decode("utf-8"))
55
+ return Decision.ALLOW if parse_allowed(payload) else Decision.DENY
56
+
57
+ return _authorize
@@ -10,12 +10,20 @@ from permissiondiff.config import PermissionDiffConfig
10
10
  from permissiondiff.engine import (
11
11
  compare_decisions,
12
12
  comparison_findings,
13
+ delegation_findings,
13
14
  invariant_findings,
15
+ mine_grants,
14
16
  )
15
17
  from permissiondiff.evaluator import evaluate_cases
16
18
  from permissiondiff.generator import generate_cases
17
19
  from permissiondiff.invariants import build_invariants
18
- from permissiondiff.models import AuthorizationCase, CaseEvaluation, ComparisonResult, Finding
20
+ from permissiondiff.models import (
21
+ AuthorizationCase,
22
+ CaseEvaluation,
23
+ ComparisonResult,
24
+ Finding,
25
+ Grant,
26
+ )
19
27
  from permissiondiff.snapshot import Snapshot, load_snapshot, snapshot_from_evaluations
20
28
 
21
29
 
@@ -27,6 +35,15 @@ class RunResult:
27
35
  findings: tuple[Finding, ...]
28
36
 
29
37
 
38
+ @dataclass(frozen=True, slots=True)
39
+ class MineResult:
40
+ """Effective grant surface plus any cases that could not be evaluated."""
41
+
42
+ cases_evaluated: int
43
+ grants: tuple[Grant, ...]
44
+ errors: tuple[CaseEvaluation, ...]
45
+
46
+
30
47
  def run_test(
31
48
  config: PermissionDiffConfig,
32
49
  *,
@@ -38,10 +55,28 @@ def run_test(
38
55
  cases = generate_cases(config, seed=seed, max_examples=max_examples)
39
56
  evaluations = _evaluate(config, cases, workdir)
40
57
  invariants = build_invariants(config.invariants, workdir=workdir)
41
- findings = invariant_findings(evaluations, invariants)
58
+ findings = [
59
+ *invariant_findings(evaluations, invariants),
60
+ *delegation_findings(evaluations),
61
+ ]
42
62
  return RunResult(len(cases), tuple(findings))
43
63
 
44
64
 
65
+ def run_mine(
66
+ config: PermissionDiffConfig,
67
+ *,
68
+ workdir: Path,
69
+ seed: int,
70
+ max_examples: int | None = None,
71
+ ) -> MineResult:
72
+ """Generate cases, evaluate the authorizer, and mine its effective grant surface."""
73
+ cases = generate_cases(config, seed=seed, max_examples=max_examples)
74
+ evaluations = _evaluate(config, cases, workdir)
75
+ grants = mine_grants(evaluations)
76
+ errors = tuple(evaluation for evaluation in evaluations if evaluation.error is not None)
77
+ return MineResult(len(cases), tuple(grants), errors)
78
+
79
+
45
80
  def create_snapshot(
46
81
  config: PermissionDiffConfig,
47
82
  *,
@@ -60,9 +95,42 @@ def run_diff(
60
95
  *,
61
96
  workdir: Path,
62
97
  baseline_path: Path,
98
+ ) -> RunResult:
99
+ """Replay every exact baseline case (from a snapshot file) against the candidate."""
100
+ return _diff_against_snapshot(config, workdir=workdir, snapshot=load_snapshot(baseline_path))
101
+
102
+
103
+ def run_git_diff(
104
+ config: PermissionDiffConfig,
105
+ *,
106
+ workdir: Path,
107
+ ref: str,
108
+ seed: int,
109
+ max_examples: int | None = None,
110
+ ) -> RunResult:
111
+ """Diff the candidate against the baseline authorizer as it existed at a git ref.
112
+
113
+ The ref is checked out into a temporary detached worktree; the candidate config's domain
114
+ generates the corpus, which is evaluated against the baseline authorizer code there. The
115
+ candidate then replays that exact corpus in the working tree.
116
+ """
117
+ from permissiondiff.gitref import baseline_worktree, repo_root
118
+
119
+ relative = workdir.resolve().relative_to(repo_root(workdir))
120
+ with baseline_worktree(workdir, ref) as tree:
121
+ baseline = create_snapshot(
122
+ config, workdir=tree / relative, seed=seed, max_examples=max_examples
123
+ )
124
+ return _diff_against_snapshot(config, workdir=workdir, snapshot=baseline)
125
+
126
+
127
+ def _diff_against_snapshot(
128
+ config: PermissionDiffConfig,
129
+ *,
130
+ workdir: Path,
131
+ snapshot: Snapshot,
63
132
  ) -> RunResult:
64
133
  """Replay every exact baseline case against the candidate authorizer."""
65
- snapshot = load_snapshot(baseline_path)
66
134
  cases = [snapshot_case.case for snapshot_case in snapshot.cases]
67
135
  evaluations = _evaluate(config, cases, workdir)
68
136
  comparisons: list[ComparisonResult] = []
@@ -82,6 +150,7 @@ def run_diff(
82
150
  findings = [
83
151
  *comparison_findings(comparisons),
84
152
  *invariant_findings(evaluations, invariants),
153
+ *delegation_findings(evaluations),
85
154
  ]
86
155
  return RunResult(len(cases), tuple(findings))
87
156
 
@@ -96,4 +165,5 @@ def _evaluate(
96
165
  config.authorizer,
97
166
  workdir=workdir,
98
167
  timeout_seconds=config.execution.timeout_seconds,
168
+ mode=config.execution.worker,
99
169
  )
@@ -9,14 +9,22 @@ import typer
9
9
  from rich.console import Console
10
10
 
11
11
  from permissiondiff import __version__
12
- from permissiondiff.application import create_snapshot, run_diff, run_test
12
+ from permissiondiff.application import (
13
+ create_snapshot,
14
+ run_diff,
15
+ run_git_diff,
16
+ run_mine,
17
+ run_test,
18
+ )
13
19
  from permissiondiff.config import PermissionDiffConfig, load_config
14
- from permissiondiff.errors import PermissionDiffError
20
+ from permissiondiff.errors import ConfigurationError, EvaluationError, PermissionDiffError
15
21
  from permissiondiff.models import Finding
16
22
  from permissiondiff.report import (
17
23
  assign_finding_ids,
18
24
  explain_finding,
19
25
  persist_findings,
26
+ persist_grants,
27
+ render_grants,
20
28
  render_terminal,
21
29
  result_exit_code,
22
30
  )
@@ -107,6 +115,41 @@ def test_command(
107
115
  _unexpected(exc, debug)
108
116
 
109
117
 
118
+ @app.command()
119
+ def mine(
120
+ config: CONFIG_OPTION = Path("permissiondiff.yaml"),
121
+ seed: SEED_OPTION = 42,
122
+ max_examples: Annotated[
123
+ int | None, typer.Option("--max-examples", min=1, help="Override corpus size.")
124
+ ] = None,
125
+ json_output: Annotated[
126
+ Path, typer.Option("--json-output", help="Machine-readable grant report path.")
127
+ ] = Path(".permissiondiff/grants.json"),
128
+ debug: DEBUG_OPTION = False,
129
+ ) -> None:
130
+ """Report the authorizer's effective grant surface for least-privilege review."""
131
+ try:
132
+ loaded = load_config(config)
133
+ result = run_mine(
134
+ loaded, workdir=config.resolve().parent, seed=seed, max_examples=max_examples
135
+ )
136
+ if result.errors:
137
+ # Never present a partial grant surface as complete.
138
+ raise EvaluationError(
139
+ f"{len(result.errors)} case(s) failed evaluation; "
140
+ f"first error: {result.errors[0].error}"
141
+ )
142
+ grants = list(result.grants)
143
+ persist_grants(grants, report_path=json_output, cases_evaluated=result.cases_evaluated)
144
+ render_grants(grants, cases_evaluated=result.cases_evaluated)
145
+ except typer.Exit:
146
+ raise
147
+ except PermissionDiffError as exc:
148
+ _fail(exc, debug)
149
+ except Exception as exc:
150
+ _unexpected(exc, debug)
151
+
152
+
110
153
  @app.command()
111
154
  def snapshot(
112
155
  output: Annotated[Path, typer.Option("--output", "-o", help="Baseline JSON path.")],
@@ -138,8 +181,18 @@ def snapshot(
138
181
 
139
182
  @app.command()
140
183
  def diff(
141
- baseline: Annotated[Path, typer.Option("--baseline", help="Baseline snapshot path.")],
184
+ baseline: Annotated[
185
+ Path | None, typer.Option("--baseline", help="Baseline snapshot path.")
186
+ ] = None,
187
+ git_ref: Annotated[
188
+ str | None,
189
+ typer.Option("--git-ref", help="Baseline git ref to evaluate in a temporary worktree."),
190
+ ] = None,
142
191
  config: CONFIG_OPTION = Path("permissiondiff.yaml"),
192
+ seed: SEED_OPTION = 42,
193
+ max_examples: Annotated[
194
+ int | None, typer.Option("--max-examples", min=1, help="Override corpus size (--git-ref).")
195
+ ] = None,
143
196
  json_output: Annotated[
144
197
  Path, typer.Option("--json-output", help="Machine-readable report path.")
145
198
  ] = Path(".permissiondiff/report.json"),
@@ -148,10 +201,23 @@ def diff(
148
201
  ] = Path(".permissiondiff/failures"),
149
202
  debug: DEBUG_OPTION = False,
150
203
  ) -> None:
151
- """Replay the exact baseline corpus against the candidate authorizer."""
204
+ """Replay the exact baseline corpus against the candidate authorizer.
205
+
206
+ Provide exactly one baseline source: --baseline (a snapshot file) or --git-ref (a git ref
207
+ whose authorizer is evaluated in a temporary worktree).
208
+ """
152
209
  try:
210
+ if (baseline is None) == (git_ref is None):
211
+ raise ConfigurationError("provide exactly one of --baseline or --git-ref")
153
212
  loaded = load_config(config)
154
- result = run_diff(loaded, workdir=config.resolve().parent, baseline_path=baseline)
213
+ workdir = config.resolve().parent
214
+ if git_ref is not None:
215
+ result = run_git_diff(
216
+ loaded, workdir=workdir, ref=git_ref, seed=seed, max_examples=max_examples
217
+ )
218
+ else:
219
+ assert baseline is not None
220
+ result = run_diff(loaded, workdir=workdir, baseline_path=baseline)
155
221
  _finish_run(loaded, result.cases_evaluated, result.findings, failures_dir, json_output)
156
222
  except typer.Exit:
157
223
  raise
@@ -3,7 +3,7 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from pathlib import Path
6
- from typing import Any
6
+ from typing import Any, Literal
7
7
 
8
8
  import yaml
9
9
  from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator
@@ -21,6 +21,7 @@ class SubjectConfig(BaseModel):
21
21
  tenant: str | None = None
22
22
  role: str | None = None
23
23
  attributes: dict[str, Any] = Field(default_factory=dict)
24
+ delegated_by: list[str] = Field(default_factory=list)
24
25
 
25
26
  def to_domain(self) -> Subject:
26
27
  """Convert boundary validation data into the core model."""
@@ -29,6 +30,7 @@ class SubjectConfig(BaseModel):
29
30
  tenant=self.tenant,
30
31
  role=self.role,
31
32
  attributes=self.attributes,
33
+ delegated_by=tuple(self.delegated_by),
32
34
  )
33
35
 
34
36
 
@@ -104,6 +106,15 @@ class ExecutionConfig(BaseModel):
104
106
  model_config = ConfigDict(extra="forbid")
105
107
 
106
108
  timeout_seconds: float = Field(default=2.0, gt=0, le=300)
109
+ worker: Literal["persistent", "process_per_case"] = "persistent"
110
+ """How cases reach the authorizer.
111
+
112
+ ``persistent`` (default) imports the authorizer once in a long-lived worker and streams cases
113
+ over a line-delimited protocol, re-spawning on any crash or timeout so a bad case cannot
114
+ corrupt later cases -- far faster on large corpora. ``process_per_case`` spawns a fresh
115
+ interpreter per case (maximum isolation, no shared import state); use it if an authorizer
116
+ relies on process-global state and cannot tolerate a shared import.
117
+ """
107
118
 
108
119
 
109
120
  class FailOnConfig(BaseModel):
@@ -15,10 +15,67 @@ from permissiondiff.models import (
15
15
  Decision,
16
16
  Finding,
17
17
  FindingKind,
18
+ Grant,
18
19
  Severity,
19
20
  )
20
21
 
21
22
 
23
+ def _tenant_relation(case: AuthorizationCase) -> str:
24
+ if case.subject.tenant is None or case.resource.tenant is None:
25
+ return "unknown"
26
+ return "same" if case.subject.tenant == case.resource.tenant else "different"
27
+
28
+
29
+ def _ownership(case: AuthorizationCase) -> str:
30
+ if case.resource.owner_id is None:
31
+ return "unknown"
32
+ return "owner" if case.subject.id == case.resource.owner_id else "non_owner"
33
+
34
+
35
+ def mine_grants(evaluations: Iterable[CaseEvaluation]) -> list[Grant]:
36
+ """Aggregate ALLOW decisions into the authorizer's effective, deduplicated grant surface.
37
+
38
+ Pure and deterministic. Each distinct (role, action, resource type, tenant relation, ownership)
39
+ pattern that the authorizer permits becomes one :class:`Grant`, keeping the smallest observed
40
+ case as its example and counting how many cases matched. Evaluation errors are ignored here;
41
+ callers surface those separately (mining describes what is allowed, it is not a gate).
42
+ """
43
+ groups: dict[tuple[str, str, str, str, str], list[CaseEvaluation]] = {}
44
+ for evaluation in evaluations:
45
+ if evaluation.decision is not Decision.ALLOW:
46
+ continue
47
+ case = evaluation.case
48
+ key = (
49
+ case.subject.role or "",
50
+ case.action.name,
51
+ case.resource.type,
52
+ _tenant_relation(case),
53
+ _ownership(case),
54
+ )
55
+ groups.setdefault(key, []).append(evaluation)
56
+
57
+ grants: list[Grant] = []
58
+ for members in groups.values():
59
+ example = min(members, key=lambda e: _case_complexity(e.case)).case
60
+ grants.append(
61
+ Grant(
62
+ role=example.subject.role,
63
+ action=example.action.name,
64
+ resource_type=example.resource.type,
65
+ tenant_relation=_tenant_relation(example),
66
+ ownership=_ownership(example),
67
+ count=len(members),
68
+ example=example,
69
+ )
70
+ )
71
+ return sorted(grants, key=lambda grant: grant.key)
72
+
73
+
74
+ def _case_complexity(case: AuthorizationCase) -> tuple[int, str]:
75
+ serialized = json.dumps(case.to_dict(), sort_keys=True, separators=(",", ":"))
76
+ return (len(serialized), serialized)
77
+
78
+
22
79
  def classify_change(baseline: Decision, candidate: Decision) -> ChangeType:
23
80
  """Classify one exhaustive pair of authorization decisions."""
24
81
  mapping = {
@@ -79,6 +136,58 @@ def invariant_findings(
79
136
  return minimize_findings(findings)
80
137
 
81
138
 
139
+ def _delegation_correlation(case: AuthorizationCase) -> str:
140
+ """Identity of a case ignoring the subject, so delegated and delegator cases correlate."""
141
+ return json.dumps(
142
+ {
143
+ "action": case.action.name,
144
+ "resource": case.resource.to_dict(),
145
+ "context": case.context.to_dict(),
146
+ },
147
+ sort_keys=True,
148
+ separators=(",", ":"),
149
+ )
150
+
151
+
152
+ def delegation_findings(evaluations: Iterable[CaseEvaluation]) -> list[Finding]:
153
+ """Flag delegated principals that obtain more authority than a principal they act for.
154
+
155
+ Enforces the least-privilege intersection rule: if a subject that acts on behalf of others is
156
+ ALLOWED for a case, every delegator evaluated on the identical (action, resource, context) must
157
+ also be ALLOWED. A delegator DENY under a delegated ALLOW is a privilege-escalation finding.
158
+ Pure and deterministic.
159
+ """
160
+ decisions: dict[tuple[str, str], Decision] = {}
161
+ delegated: list[CaseEvaluation] = []
162
+ for evaluation in evaluations:
163
+ if evaluation.decision is None:
164
+ continue
165
+ key = (_delegation_correlation(evaluation.case), evaluation.case.subject.id)
166
+ decisions[key] = evaluation.decision
167
+ if evaluation.case.subject.delegated_by and evaluation.decision is Decision.ALLOW:
168
+ delegated.append(evaluation)
169
+
170
+ findings: list[Finding] = []
171
+ for evaluation in delegated:
172
+ correlation = _delegation_correlation(evaluation.case)
173
+ for delegator_id in evaluation.case.subject.delegated_by:
174
+ if decisions.get((correlation, delegator_id)) is Decision.DENY:
175
+ findings.append(
176
+ Finding(
177
+ kind=FindingKind.INVARIANT_VIOLATION,
178
+ severity=Severity.CRITICAL,
179
+ case=evaluation.case,
180
+ message=(
181
+ f"Delegated principal {evaluation.case.subject.id!r} is allowed where "
182
+ f"delegator {delegator_id!r} is denied (privilege escalation)"
183
+ ),
184
+ candidate=evaluation.decision,
185
+ invariant="delegation",
186
+ )
187
+ )
188
+ return minimize_findings(findings)
189
+
190
+
82
191
  def comparison_findings(comparisons: Iterable[ComparisonResult]) -> list[Finding]:
83
192
  """Convert changed decisions into minimized security findings."""
84
193
  findings: list[Finding] = []
@@ -147,5 +256,4 @@ def finding_equivalence_key(finding: Finding) -> tuple[object, ...]:
147
256
 
148
257
  def finding_case_complexity(finding: Finding) -> tuple[int, str]:
149
258
  """Order reproductions by compact canonical case representation."""
150
- serialized = json.dumps(finding.case.to_dict(), sort_keys=True, separators=(",", ":"))
151
- return (len(serialized), serialized)
259
+ return _case_complexity(finding.case)