permissiondiff 0.1.0__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.0 → permissiondiff-0.2.0}/PKG-INFO +45 -5
- {permissiondiff-0.1.0 → permissiondiff-0.2.0}/README.md +44 -4
- {permissiondiff-0.1.0 → permissiondiff-0.2.0}/pyproject.toml +2 -2
- {permissiondiff-0.1.0 → permissiondiff-0.2.0}/pyproject.toml.orig +2 -2
- {permissiondiff-0.1.0 → 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.0 → permissiondiff-0.2.0}/src/permissiondiff/application.py +81 -14
- {permissiondiff-0.1.0 → permissiondiff-0.2.0}/src/permissiondiff/cli.py +71 -5
- {permissiondiff-0.1.0 → permissiondiff-0.2.0}/src/permissiondiff/config.py +12 -1
- {permissiondiff-0.1.0 → permissiondiff-0.2.0}/src/permissiondiff/engine.py +113 -9
- {permissiondiff-0.1.0 → permissiondiff-0.2.0}/src/permissiondiff/errors.py +10 -0
- permissiondiff-0.2.0/src/permissiondiff/evaluator.py +238 -0
- {permissiondiff-0.1.0 → permissiondiff-0.2.0}/src/permissiondiff/generator.py +26 -29
- permissiondiff-0.2.0/src/permissiondiff/gitref.py +70 -0
- {permissiondiff-0.1.0 → permissiondiff-0.2.0}/src/permissiondiff/invariants.py +28 -6
- {permissiondiff-0.1.0 → permissiondiff-0.2.0}/src/permissiondiff/models.py +69 -8
- {permissiondiff-0.1.0 → permissiondiff-0.2.0}/src/permissiondiff/report.py +57 -3
- permissiondiff-0.2.0/src/permissiondiff/worker.py +85 -0
- permissiondiff-0.1.0/src/permissiondiff/evaluator.py +0 -86
- permissiondiff-0.1.0/src/permissiondiff/worker.py +0 -48
- {permissiondiff-0.1.0 → permissiondiff-0.2.0}/LICENSE +0 -0
- {permissiondiff-0.1.0 → permissiondiff-0.2.0}/src/permissiondiff/__main__.py +0 -0
- {permissiondiff-0.1.0 → permissiondiff-0.2.0}/src/permissiondiff/loader.py +0 -0
- {permissiondiff-0.1.0 → permissiondiff-0.2.0}/src/permissiondiff/py.typed +0 -0
- {permissiondiff-0.1.0 → 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
|
|
@@ -35,6 +35,7 @@ Description-Content-Type: text/markdown
|
|
|
35
35
|
> **Prove your code didn't just hand the wrong person the keys.**
|
|
36
36
|
|
|
37
37
|
[](https://github.com/abishekgiri/permissiondiff/actions/workflows/ci.yml)
|
|
38
|
+
[](https://pypi.org/project/permissiondiff/)
|
|
38
39
|

|
|
39
40
|

|
|
40
41
|
[](https://github.com/astral-sh/ruff)
|
|
@@ -57,7 +58,14 @@ interfaces may evolve before 1.0. It supports Python 3.12, 3.13, and 3.14 and is
|
|
|
57
58
|
|
|
58
59
|
## Install
|
|
59
60
|
|
|
60
|
-
|
|
61
|
+
Add the [published package](https://pypi.org/project/permissiondiff/) to an existing uv project:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
uv add permissiondiff
|
|
65
|
+
uv run permissiondiff --help
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
For a standalone CLI, install it with uv:
|
|
61
69
|
|
|
62
70
|
```bash
|
|
63
71
|
uv tool install permissiondiff
|
|
@@ -69,11 +77,9 @@ Or install it in an active virtual environment with pip:
|
|
|
69
77
|
python -m pip install permissiondiff
|
|
70
78
|
```
|
|
71
79
|
|
|
72
|
-
To add PermissionDiff to an existing uv project instead, run `uv add permissiondiff`.
|
|
73
|
-
|
|
74
80
|
## Five-minute quickstart
|
|
75
81
|
|
|
76
|
-
After installation:
|
|
82
|
+
After standalone CLI installation (or use `uv run permissiondiff` inside a uv project):
|
|
77
83
|
|
|
78
84
|
```bash
|
|
79
85
|
permissiondiff init demo
|
|
@@ -157,6 +163,28 @@ invariants:
|
|
|
157
163
|
|
|
158
164
|
The callable receives `(AuthorizationCase, Decision)` and returns `bool` or `InvariantResult`.
|
|
159
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
|
+
|
|
160
188
|
## Baseline and diff workflow
|
|
161
189
|
|
|
162
190
|
Create a baseline on trusted code:
|
|
@@ -173,6 +201,13 @@ uv run permissiondiff diff --config permissiondiff.yaml \
|
|
|
173
201
|
--baseline .permissiondiff/main.json
|
|
174
202
|
```
|
|
175
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
|
+
|
|
176
211
|
Classification is exhaustive:
|
|
177
212
|
|
|
178
213
|
| Baseline | Candidate | Result |
|
|
@@ -212,6 +247,11 @@ The repository's thin composite action invokes the same CLI without duplicating
|
|
|
212
247
|
baseline: .permissiondiff/main.json
|
|
213
248
|
```
|
|
214
249
|
|
|
250
|
+
Check out the calling repository before this step and make sure the baseline is present in CI.
|
|
251
|
+
Use `@v0.2.0` to pin the latest release; `@v0` follows compatible v0 releases. The
|
|
252
|
+
[published-consumer smoke test](https://github.com/abishekgiri/permissiondiff/blob/main/.github/workflows/published-smoke.yml)
|
|
253
|
+
exercises the PyPI package and the remote action in a fresh workspace without a source checkout.
|
|
254
|
+
|
|
215
255
|
## Security and limitations
|
|
216
256
|
|
|
217
257
|
**Only point PermissionDiff at decision logic with no live side effects. Never use an authorizer that issues refunds, deletes data, sends messages, or contacts production.**
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
> **Prove your code didn't just hand the wrong person the keys.**
|
|
4
4
|
|
|
5
5
|
[](https://github.com/abishekgiri/permissiondiff/actions/workflows/ci.yml)
|
|
6
|
+
[](https://pypi.org/project/permissiondiff/)
|
|
6
7
|

|
|
7
8
|

|
|
8
9
|
[](https://github.com/astral-sh/ruff)
|
|
@@ -25,7 +26,14 @@ interfaces may evolve before 1.0. It supports Python 3.12, 3.13, and 3.14 and is
|
|
|
25
26
|
|
|
26
27
|
## Install
|
|
27
28
|
|
|
28
|
-
|
|
29
|
+
Add the [published package](https://pypi.org/project/permissiondiff/) to an existing uv project:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
uv add permissiondiff
|
|
33
|
+
uv run permissiondiff --help
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
For a standalone CLI, install it with uv:
|
|
29
37
|
|
|
30
38
|
```bash
|
|
31
39
|
uv tool install permissiondiff
|
|
@@ -37,11 +45,9 @@ Or install it in an active virtual environment with pip:
|
|
|
37
45
|
python -m pip install permissiondiff
|
|
38
46
|
```
|
|
39
47
|
|
|
40
|
-
To add PermissionDiff to an existing uv project instead, run `uv add permissiondiff`.
|
|
41
|
-
|
|
42
48
|
## Five-minute quickstart
|
|
43
49
|
|
|
44
|
-
After installation:
|
|
50
|
+
After standalone CLI installation (or use `uv run permissiondiff` inside a uv project):
|
|
45
51
|
|
|
46
52
|
```bash
|
|
47
53
|
permissiondiff init demo
|
|
@@ -125,6 +131,28 @@ invariants:
|
|
|
125
131
|
|
|
126
132
|
The callable receives `(AuthorizationCase, Decision)` and returns `bool` or `InvariantResult`.
|
|
127
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
|
+
|
|
128
156
|
## Baseline and diff workflow
|
|
129
157
|
|
|
130
158
|
Create a baseline on trusted code:
|
|
@@ -141,6 +169,13 @@ uv run permissiondiff diff --config permissiondiff.yaml \
|
|
|
141
169
|
--baseline .permissiondiff/main.json
|
|
142
170
|
```
|
|
143
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
|
+
|
|
144
179
|
Classification is exhaustive:
|
|
145
180
|
|
|
146
181
|
| Baseline | Candidate | Result |
|
|
@@ -180,6 +215,11 @@ The repository's thin composite action invokes the same CLI without duplicating
|
|
|
180
215
|
baseline: .permissiondiff/main.json
|
|
181
216
|
```
|
|
182
217
|
|
|
218
|
+
Check out the calling repository before this step and make sure the baseline is present in CI.
|
|
219
|
+
Use `@v0.2.0` to pin the latest release; `@v0` follows compatible v0 releases. The
|
|
220
|
+
[published-consumer smoke test](https://github.com/abishekgiri/permissiondiff/blob/main/.github/workflows/published-smoke.yml)
|
|
221
|
+
exercises the PyPI package and the remote action in a fresh workspace without a source checkout.
|
|
222
|
+
|
|
183
223
|
## Security and limitations
|
|
184
224
|
|
|
185
225
|
**Only point PermissionDiff at decision logic with no live side effects. Never use an authorizer that issues refunds, deletes data, sends messages, or contacts production.**
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "permissiondiff"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.2.0"
|
|
4
4
|
description = "Deterministic differential authorization regression testing"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.12"
|
|
@@ -57,7 +57,7 @@ dev = [
|
|
|
57
57
|
]
|
|
58
58
|
|
|
59
59
|
[build-system]
|
|
60
|
-
requires = ["uv_build>=0.11.1,<0.
|
|
60
|
+
requires = ["uv_build>=0.11.1,<0.13.0"]
|
|
61
61
|
build-backend = "uv_build"
|
|
62
62
|
|
|
63
63
|
[tool.ruff]
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[project]
|
|
2
2
|
name = "permissiondiff"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.2.0"
|
|
4
4
|
description = "Deterministic differential authorization regression testing"
|
|
5
5
|
readme = "README.md"
|
|
6
6
|
requires-python = ">=3.12"
|
|
@@ -55,7 +55,7 @@ dev = [
|
|
|
55
55
|
]
|
|
56
56
|
|
|
57
57
|
[build-system]
|
|
58
|
-
requires = ["uv_build>=0.11.1,<0.
|
|
58
|
+
requires = ["uv_build>=0.11.1,<0.13.0"]
|
|
59
59
|
build-backend = "uv_build"
|
|
60
60
|
|
|
61
61
|
[tool.ruff]
|
|
@@ -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
|
-
from permissiondiff.generator import generate_cases
|
|
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,13 +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 =
|
|
42
|
-
invariant_findings(evaluations, invariants
|
|
43
|
-
|
|
44
|
-
|
|
58
|
+
findings = [
|
|
59
|
+
*invariant_findings(evaluations, invariants),
|
|
60
|
+
*delegation_findings(evaluations),
|
|
61
|
+
]
|
|
45
62
|
return RunResult(len(cases), tuple(findings))
|
|
46
63
|
|
|
47
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
|
+
|
|
48
80
|
def create_snapshot(
|
|
49
81
|
config: PermissionDiffConfig,
|
|
50
82
|
*,
|
|
@@ -63,9 +95,42 @@ def run_diff(
|
|
|
63
95
|
*,
|
|
64
96
|
workdir: Path,
|
|
65
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,
|
|
66
132
|
) -> RunResult:
|
|
67
133
|
"""Replay every exact baseline case against the candidate authorizer."""
|
|
68
|
-
snapshot = load_snapshot(baseline_path)
|
|
69
134
|
cases = [snapshot_case.case for snapshot_case in snapshot.cases]
|
|
70
135
|
evaluations = _evaluate(config, cases, workdir)
|
|
71
136
|
comparisons: list[ComparisonResult] = []
|
|
@@ -79,13 +144,14 @@ def run_diff(
|
|
|
79
144
|
)
|
|
80
145
|
)
|
|
81
146
|
invariants = build_invariants(config.invariants, workdir=workdir)
|
|
82
|
-
findings
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
147
|
+
# Comparison and invariant findings never share an equivalence key (their
|
|
148
|
+
# FindingKind differs), so minimizing each group independently is equivalent
|
|
149
|
+
# to minimizing the combined list, and it preserves equivalent-case counts.
|
|
150
|
+
findings = [
|
|
151
|
+
*comparison_findings(comparisons),
|
|
152
|
+
*invariant_findings(evaluations, invariants),
|
|
153
|
+
*delegation_findings(evaluations),
|
|
154
|
+
]
|
|
89
155
|
return RunResult(len(cases), tuple(findings))
|
|
90
156
|
|
|
91
157
|
|
|
@@ -99,4 +165,5 @@ def _evaluate(
|
|
|
99
165
|
config.authorizer,
|
|
100
166
|
workdir=workdir,
|
|
101
167
|
timeout_seconds=config.execution.timeout_seconds,
|
|
168
|
+
mode=config.execution.worker,
|
|
102
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):
|