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.
- {permissiondiff-0.1.1 → permissiondiff-0.2.0}/PKG-INFO +31 -2
- {permissiondiff-0.1.1 → permissiondiff-0.2.0}/README.md +30 -1
- {permissiondiff-0.1.1 → permissiondiff-0.2.0}/pyproject.toml +1 -1
- {permissiondiff-0.1.1 → permissiondiff-0.2.0}/pyproject.toml.orig +1 -1
- {permissiondiff-0.1.1 → permissiondiff-0.2.0}/src/permissiondiff/__init__.py +1 -1
- permissiondiff-0.2.0/src/permissiondiff/adapters/__init__.py +68 -0
- permissiondiff-0.2.0/src/permissiondiff/adapters/http.py +57 -0
- {permissiondiff-0.1.1 → permissiondiff-0.2.0}/src/permissiondiff/application.py +73 -3
- {permissiondiff-0.1.1 → permissiondiff-0.2.0}/src/permissiondiff/cli.py +71 -5
- {permissiondiff-0.1.1 → permissiondiff-0.2.0}/src/permissiondiff/config.py +12 -1
- {permissiondiff-0.1.1 → permissiondiff-0.2.0}/src/permissiondiff/engine.py +110 -2
- permissiondiff-0.2.0/src/permissiondiff/evaluator.py +238 -0
- {permissiondiff-0.1.1 → permissiondiff-0.2.0}/src/permissiondiff/generator.py +25 -1
- permissiondiff-0.2.0/src/permissiondiff/gitref.py +70 -0
- {permissiondiff-0.1.1 → permissiondiff-0.2.0}/src/permissiondiff/models.py +63 -2
- {permissiondiff-0.1.1 → permissiondiff-0.2.0}/src/permissiondiff/report.py +55 -1
- {permissiondiff-0.1.1 → permissiondiff-0.2.0}/src/permissiondiff/worker.py +34 -18
- permissiondiff-0.1.1/src/permissiondiff/evaluator.py +0 -86
- {permissiondiff-0.1.1 → permissiondiff-0.2.0}/LICENSE +0 -0
- {permissiondiff-0.1.1 → permissiondiff-0.2.0}/src/permissiondiff/__main__.py +0 -0
- {permissiondiff-0.1.1 → permissiondiff-0.2.0}/src/permissiondiff/errors.py +0 -0
- {permissiondiff-0.1.1 → permissiondiff-0.2.0}/src/permissiondiff/invariants.py +0 -0
- {permissiondiff-0.1.1 → permissiondiff-0.2.0}/src/permissiondiff/loader.py +0 -0
- {permissiondiff-0.1.1 → permissiondiff-0.2.0}/src/permissiondiff/py.typed +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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
|
|
|
@@ -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
|
|
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 =
|
|
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
|
|
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[
|
|
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
|
-
|
|
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
|
-
|
|
151
|
-
return (len(serialized), serialized)
|
|
259
|
+
return _case_complexity(finding.case)
|