permissiondiff 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- permissiondiff/__init__.py +23 -0
- permissiondiff/__main__.py +6 -0
- permissiondiff/application.py +102 -0
- permissiondiff/cli.py +265 -0
- permissiondiff/config.py +163 -0
- permissiondiff/engine.py +155 -0
- permissiondiff/errors.py +25 -0
- permissiondiff/evaluator.py +86 -0
- permissiondiff/generator.py +148 -0
- permissiondiff/invariants.py +193 -0
- permissiondiff/loader.py +27 -0
- permissiondiff/models.py +278 -0
- permissiondiff/py.typed +1 -0
- permissiondiff/report.py +121 -0
- permissiondiff/snapshot.py +138 -0
- permissiondiff/worker.py +48 -0
- permissiondiff-0.1.0.dist-info/METADATA +244 -0
- permissiondiff-0.1.0.dist-info/RECORD +21 -0
- permissiondiff-0.1.0.dist-info/WHEEL +4 -0
- permissiondiff-0.1.0.dist-info/entry_points.txt +3 -0
- permissiondiff-0.1.0.dist-info/licenses/LICENSE +13 -0
permissiondiff/engine.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
"""Pure comparison, invariant evaluation, and finding minimization logic."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
from dataclasses import replace
|
|
8
|
+
|
|
9
|
+
from permissiondiff.invariants import Invariant
|
|
10
|
+
from permissiondiff.models import (
|
|
11
|
+
AuthorizationCase,
|
|
12
|
+
CaseEvaluation,
|
|
13
|
+
ChangeType,
|
|
14
|
+
ComparisonResult,
|
|
15
|
+
Decision,
|
|
16
|
+
Finding,
|
|
17
|
+
FindingKind,
|
|
18
|
+
Severity,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def classify_change(baseline: Decision, candidate: Decision) -> ChangeType:
|
|
23
|
+
"""Classify one exhaustive pair of authorization decisions."""
|
|
24
|
+
mapping = {
|
|
25
|
+
(Decision.DENY, Decision.DENY): ChangeType.UNCHANGED_DENIED,
|
|
26
|
+
(Decision.ALLOW, Decision.ALLOW): ChangeType.UNCHANGED_ALLOWED,
|
|
27
|
+
(Decision.DENY, Decision.ALLOW): ChangeType.NEWLY_ALLOWED,
|
|
28
|
+
(Decision.ALLOW, Decision.DENY): ChangeType.NEWLY_DENIED,
|
|
29
|
+
}
|
|
30
|
+
return mapping[(baseline, candidate)]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def compare_decisions(
|
|
34
|
+
case: AuthorizationCase,
|
|
35
|
+
baseline: Decision,
|
|
36
|
+
candidate: Decision,
|
|
37
|
+
) -> ComparisonResult:
|
|
38
|
+
"""Compare two decisions for one exact authorization case."""
|
|
39
|
+
return ComparisonResult(
|
|
40
|
+
case=case,
|
|
41
|
+
baseline=baseline,
|
|
42
|
+
candidate=candidate,
|
|
43
|
+
change=classify_change(baseline, candidate),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def invariant_findings(
|
|
48
|
+
evaluations: Iterable[CaseEvaluation],
|
|
49
|
+
invariants: Iterable[Invariant],
|
|
50
|
+
*,
|
|
51
|
+
minimize: bool = True,
|
|
52
|
+
) -> list[Finding]:
|
|
53
|
+
"""Evaluate invariants and return minimized, deduplicated violations."""
|
|
54
|
+
findings: list[Finding] = []
|
|
55
|
+
invariant_list = list(invariants)
|
|
56
|
+
for evaluation in evaluations:
|
|
57
|
+
if evaluation.decision is None:
|
|
58
|
+
findings.append(
|
|
59
|
+
Finding(
|
|
60
|
+
kind=FindingKind.EVALUATION_ERROR,
|
|
61
|
+
severity=Severity.ERROR,
|
|
62
|
+
case=evaluation.case,
|
|
63
|
+
message="Authorizer evaluation failed",
|
|
64
|
+
error=evaluation.error,
|
|
65
|
+
)
|
|
66
|
+
)
|
|
67
|
+
continue
|
|
68
|
+
for invariant in invariant_list:
|
|
69
|
+
result = invariant.evaluate(evaluation.case, evaluation.decision)
|
|
70
|
+
if not result.passed:
|
|
71
|
+
findings.append(
|
|
72
|
+
Finding(
|
|
73
|
+
kind=FindingKind.INVARIANT_VIOLATION,
|
|
74
|
+
severity=Severity.CRITICAL,
|
|
75
|
+
case=evaluation.case,
|
|
76
|
+
message=result.message,
|
|
77
|
+
candidate=evaluation.decision,
|
|
78
|
+
invariant=result.name,
|
|
79
|
+
)
|
|
80
|
+
)
|
|
81
|
+
return minimize_findings(findings) if minimize else findings
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def comparison_findings(
|
|
85
|
+
comparisons: Iterable[ComparisonResult], *, minimize: bool = True
|
|
86
|
+
) -> list[Finding]:
|
|
87
|
+
"""Convert changed decisions into minimized security findings."""
|
|
88
|
+
findings: list[Finding] = []
|
|
89
|
+
for comparison in comparisons:
|
|
90
|
+
if comparison.change is ChangeType.NEWLY_ALLOWED:
|
|
91
|
+
findings.append(
|
|
92
|
+
Finding(
|
|
93
|
+
kind=FindingKind.CHANGE,
|
|
94
|
+
severity=Severity.CRITICAL,
|
|
95
|
+
case=comparison.case,
|
|
96
|
+
message="Authorization path is newly allowed",
|
|
97
|
+
baseline=comparison.baseline,
|
|
98
|
+
candidate=comparison.candidate,
|
|
99
|
+
change=comparison.change,
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
elif comparison.change is ChangeType.NEWLY_DENIED:
|
|
103
|
+
findings.append(
|
|
104
|
+
Finding(
|
|
105
|
+
kind=FindingKind.CHANGE,
|
|
106
|
+
severity=Severity.WARNING,
|
|
107
|
+
case=comparison.case,
|
|
108
|
+
message="Authorization path is newly denied",
|
|
109
|
+
baseline=comparison.baseline,
|
|
110
|
+
candidate=comparison.candidate,
|
|
111
|
+
change=comparison.change,
|
|
112
|
+
)
|
|
113
|
+
)
|
|
114
|
+
return minimize_findings(findings) if minimize else findings
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def minimize_findings(findings: Iterable[Finding]) -> list[Finding]:
|
|
118
|
+
"""Deduplicate equivalent failures and retain the smallest reproduction."""
|
|
119
|
+
groups: dict[tuple[object, ...], list[Finding]] = {}
|
|
120
|
+
for finding in findings:
|
|
121
|
+
groups.setdefault(finding_equivalence_key(finding), []).append(finding)
|
|
122
|
+
|
|
123
|
+
minimized: list[Finding] = []
|
|
124
|
+
for equivalents in groups.values():
|
|
125
|
+
smallest = min(equivalents, key=finding_case_complexity)
|
|
126
|
+
minimized.append(replace(smallest, equivalent_cases=len(equivalents)))
|
|
127
|
+
return sorted(minimized, key=lambda finding: finding.semantic_key)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def finding_equivalence_key(finding: Finding) -> tuple[object, ...]:
|
|
131
|
+
"""Group failures with the same security meaning before shrinking."""
|
|
132
|
+
case = finding.case
|
|
133
|
+
tenant_relation = (
|
|
134
|
+
"unknown"
|
|
135
|
+
if case.subject.tenant is None or case.resource.tenant is None
|
|
136
|
+
else "same"
|
|
137
|
+
if case.subject.tenant == case.resource.tenant
|
|
138
|
+
else "different"
|
|
139
|
+
)
|
|
140
|
+
return (
|
|
141
|
+
finding.kind,
|
|
142
|
+
finding.change,
|
|
143
|
+
finding.invariant,
|
|
144
|
+
finding.error,
|
|
145
|
+
case.action.name,
|
|
146
|
+
case.subject.role,
|
|
147
|
+
case.resource.type,
|
|
148
|
+
tenant_relation,
|
|
149
|
+
)
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def finding_case_complexity(finding: Finding) -> tuple[int, str]:
|
|
153
|
+
"""Order reproductions by compact canonical case representation."""
|
|
154
|
+
serialized = json.dumps(finding.case.to_dict(), sort_keys=True, separators=(",", ":"))
|
|
155
|
+
return (len(serialized), serialized)
|
permissiondiff/errors.py
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""Domain-specific exceptions exposed by PermissionDiff."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class PermissionDiffError(Exception):
|
|
5
|
+
"""Base class for expected PermissionDiff failures."""
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class ConfigurationError(PermissionDiffError):
|
|
9
|
+
"""Raised when configuration is missing or invalid."""
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class AdapterError(PermissionDiffError):
|
|
13
|
+
"""Raised when an authorizer or custom invariant cannot be loaded."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class EvaluationError(PermissionDiffError):
|
|
17
|
+
"""Raised when authorizer evaluation crashes, hangs, or returns invalid data."""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class SnapshotVersionError(PermissionDiffError):
|
|
21
|
+
"""Raised when a snapshot schema is unsupported."""
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class InvariantDefinitionError(PermissionDiffError):
|
|
25
|
+
"""Raised when an invariant declaration is invalid."""
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""Parent-side orchestration for subprocess authorizer evaluation."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from permissiondiff.models import AuthorizationCase, CaseEvaluation, Decision
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def evaluate_case(
|
|
15
|
+
case: AuthorizationCase,
|
|
16
|
+
authorizer_spec: str,
|
|
17
|
+
*,
|
|
18
|
+
workdir: Path,
|
|
19
|
+
timeout_seconds: float,
|
|
20
|
+
) -> CaseEvaluation:
|
|
21
|
+
"""Evaluate a case in a fresh worker process with a hard timeout."""
|
|
22
|
+
command = [sys.executable, "-m", "permissiondiff.worker", authorizer_spec]
|
|
23
|
+
payload = json.dumps(case.to_dict(), sort_keys=True, separators=(",", ":"))
|
|
24
|
+
environment = os.environ.copy()
|
|
25
|
+
package_root = str(Path(__file__).resolve().parents[1])
|
|
26
|
+
inherited_path = environment.get("PYTHONPATH")
|
|
27
|
+
environment["PYTHONPATH"] = os.pathsep.join(
|
|
28
|
+
[package_root, inherited_path] if inherited_path else [package_root]
|
|
29
|
+
)
|
|
30
|
+
try:
|
|
31
|
+
completed = subprocess.run(
|
|
32
|
+
command,
|
|
33
|
+
input=payload,
|
|
34
|
+
text=True,
|
|
35
|
+
capture_output=True,
|
|
36
|
+
cwd=workdir,
|
|
37
|
+
env=environment,
|
|
38
|
+
timeout=timeout_seconds,
|
|
39
|
+
check=False,
|
|
40
|
+
)
|
|
41
|
+
except subprocess.TimeoutExpired:
|
|
42
|
+
return CaseEvaluation(
|
|
43
|
+
case=case,
|
|
44
|
+
error=f"authorizer timed out after {timeout_seconds:g} seconds",
|
|
45
|
+
)
|
|
46
|
+
except OSError as exc:
|
|
47
|
+
return CaseEvaluation(case=case, error=f"could not start authorizer worker: {exc}")
|
|
48
|
+
|
|
49
|
+
if completed.returncode != 0:
|
|
50
|
+
detail = completed.stderr.strip() or completed.stdout.strip() or "no worker output"
|
|
51
|
+
return CaseEvaluation(
|
|
52
|
+
case=case,
|
|
53
|
+
error=f"authorizer worker exited with code {completed.returncode}: {detail}",
|
|
54
|
+
)
|
|
55
|
+
try:
|
|
56
|
+
result = json.loads(completed.stdout)
|
|
57
|
+
except json.JSONDecodeError:
|
|
58
|
+
return CaseEvaluation(case=case, error="authorizer worker returned invalid JSON")
|
|
59
|
+
if not isinstance(result, dict):
|
|
60
|
+
return CaseEvaluation(case=case, error="authorizer worker returned a non-object result")
|
|
61
|
+
if isinstance(result.get("error"), str):
|
|
62
|
+
return CaseEvaluation(case=case, error=result["error"])
|
|
63
|
+
try:
|
|
64
|
+
decision = Decision(result["decision"])
|
|
65
|
+
except (KeyError, TypeError, ValueError):
|
|
66
|
+
return CaseEvaluation(case=case, error="authorizer worker returned an invalid decision")
|
|
67
|
+
return CaseEvaluation(case=case, decision=decision)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def evaluate_cases(
|
|
71
|
+
cases: list[AuthorizationCase] | tuple[AuthorizationCase, ...],
|
|
72
|
+
authorizer_spec: str,
|
|
73
|
+
*,
|
|
74
|
+
workdir: Path,
|
|
75
|
+
timeout_seconds: float,
|
|
76
|
+
) -> list[CaseEvaluation]:
|
|
77
|
+
"""Evaluate cases independently so a crash cannot corrupt parent state."""
|
|
78
|
+
return [
|
|
79
|
+
evaluate_case(
|
|
80
|
+
case,
|
|
81
|
+
authorizer_spec,
|
|
82
|
+
workdir=workdir,
|
|
83
|
+
timeout_seconds=timeout_seconds,
|
|
84
|
+
)
|
|
85
|
+
for case in cases
|
|
86
|
+
]
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
"""Bounded authorization-case generation over explicit application domains."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import random
|
|
6
|
+
from collections.abc import Callable, Iterable
|
|
7
|
+
from dataclasses import replace
|
|
8
|
+
|
|
9
|
+
from hypothesis import HealthCheck, Phase, find, given, settings
|
|
10
|
+
from hypothesis import seed as hypothesis_seed
|
|
11
|
+
from hypothesis import strategies as st
|
|
12
|
+
from hypothesis.strategies import SearchStrategy
|
|
13
|
+
|
|
14
|
+
from permissiondiff.config import PermissionDiffConfig
|
|
15
|
+
from permissiondiff.engine import finding_case_complexity, finding_equivalence_key
|
|
16
|
+
from permissiondiff.models import Action, AuthorizationCase, Context, Finding, Resource, Subject
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def case_strategy(config: PermissionDiffConfig) -> SearchStrategy[AuthorizationCase]:
|
|
20
|
+
"""Build a Hypothesis strategy that combines only declared domain entities."""
|
|
21
|
+
subjects = [subject.to_domain() for subject in config.subjects]
|
|
22
|
+
resources = [resource.to_domain() for resource in config.resources]
|
|
23
|
+
actions = [Action(name) for name in config.actions]
|
|
24
|
+
amount_strategy: SearchStrategy[int | None]
|
|
25
|
+
if config.contexts.amounts is None:
|
|
26
|
+
amount_strategy = st.none()
|
|
27
|
+
else:
|
|
28
|
+
amount_domain = config.contexts.amounts
|
|
29
|
+
boundary_values = amount_domain.interesting_values()
|
|
30
|
+
amount_strategy = st.one_of(
|
|
31
|
+
st.sampled_from(boundary_values),
|
|
32
|
+
st.integers(min_value=amount_domain.min, max_value=amount_domain.max),
|
|
33
|
+
)
|
|
34
|
+
return st.builds(
|
|
35
|
+
AuthorizationCase,
|
|
36
|
+
subject=st.sampled_from(subjects),
|
|
37
|
+
action=st.sampled_from(actions),
|
|
38
|
+
resource=st.sampled_from(resources),
|
|
39
|
+
context=st.builds(Context, amount=amount_strategy),
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def generate_cases(
|
|
44
|
+
config: PermissionDiffConfig,
|
|
45
|
+
*,
|
|
46
|
+
seed: int,
|
|
47
|
+
max_examples: int | None = None,
|
|
48
|
+
) -> list[AuthorizationCase]:
|
|
49
|
+
"""Generate a stable, bounded corpus with relationship and numeric boundaries."""
|
|
50
|
+
limit = max_examples or config.generation.max_examples
|
|
51
|
+
if limit < 1:
|
|
52
|
+
raise ValueError("max_examples must be positive")
|
|
53
|
+
|
|
54
|
+
cases_by_fingerprint: dict[str, AuthorizationCase] = {}
|
|
55
|
+
coverage_budget = max(1, limit // 2)
|
|
56
|
+
for case in _coverage_cases(config):
|
|
57
|
+
cases_by_fingerprint.setdefault(case.fingerprint, case)
|
|
58
|
+
if len(cases_by_fingerprint) >= coverage_budget:
|
|
59
|
+
break
|
|
60
|
+
|
|
61
|
+
remaining = limit - len(cases_by_fingerprint)
|
|
62
|
+
if remaining:
|
|
63
|
+
strategy = case_strategy(config)
|
|
64
|
+
|
|
65
|
+
@hypothesis_seed(seed)
|
|
66
|
+
@settings(
|
|
67
|
+
max_examples=max(remaining * 3, remaining),
|
|
68
|
+
database=None,
|
|
69
|
+
deadline=None,
|
|
70
|
+
phases=(Phase.generate,),
|
|
71
|
+
suppress_health_check=(HealthCheck.filter_too_much,),
|
|
72
|
+
)
|
|
73
|
+
@given(strategy)
|
|
74
|
+
def collect(case: AuthorizationCase) -> None:
|
|
75
|
+
if len(cases_by_fingerprint) < limit:
|
|
76
|
+
cases_by_fingerprint.setdefault(case.fingerprint, case)
|
|
77
|
+
|
|
78
|
+
collect()
|
|
79
|
+
|
|
80
|
+
return sorted(cases_by_fingerprint.values(), key=lambda case: case.fingerprint)[:limit]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def shrink_findings(findings: Iterable[Finding], *, seed: int) -> list[Finding]:
|
|
84
|
+
"""Use Hypothesis shrinking to select one minimal exact-corpus reproduction."""
|
|
85
|
+
groups: dict[tuple[object, ...], list[Finding]] = {}
|
|
86
|
+
for finding in findings:
|
|
87
|
+
groups.setdefault(finding_equivalence_key(finding), []).append(finding)
|
|
88
|
+
|
|
89
|
+
minimized: list[Finding] = []
|
|
90
|
+
for index, equivalents in enumerate(groups.values()):
|
|
91
|
+
ordered = sorted(equivalents, key=finding_case_complexity)
|
|
92
|
+
smallest = find(
|
|
93
|
+
st.sampled_from(ordered),
|
|
94
|
+
equivalents.__contains__,
|
|
95
|
+
settings=settings(
|
|
96
|
+
max_examples=max(10, len(ordered) * 2),
|
|
97
|
+
database=None,
|
|
98
|
+
deadline=None,
|
|
99
|
+
phases=(Phase.generate, Phase.shrink),
|
|
100
|
+
),
|
|
101
|
+
random=random.Random(seed + index),
|
|
102
|
+
)
|
|
103
|
+
minimized.append(replace(smallest, equivalent_cases=len(equivalents)))
|
|
104
|
+
return sorted(minimized, key=lambda finding: finding.semantic_key)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _coverage_cases(config: PermissionDiffConfig) -> Iterable[AuthorizationCase]:
|
|
108
|
+
"""Yield a lazy spine covering same/cross-tenant and owner relationships."""
|
|
109
|
+
subjects = [subject.to_domain() for subject in config.subjects]
|
|
110
|
+
resources = [resource.to_domain() for resource in config.resources]
|
|
111
|
+
amounts: tuple[int | None, ...]
|
|
112
|
+
if config.contexts.amounts is None:
|
|
113
|
+
amounts = (None,)
|
|
114
|
+
else:
|
|
115
|
+
amounts = config.contexts.amounts.interesting_values()
|
|
116
|
+
|
|
117
|
+
index = 0
|
|
118
|
+
for action_name in config.actions:
|
|
119
|
+
for subject in subjects:
|
|
120
|
+
for resource in _relationship_resources(subject, resources):
|
|
121
|
+
amount = amounts[index % len(amounts)]
|
|
122
|
+
index += 1
|
|
123
|
+
yield AuthorizationCase(
|
|
124
|
+
subject=subject,
|
|
125
|
+
action=Action(action_name),
|
|
126
|
+
resource=resource,
|
|
127
|
+
context=Context(amount=amount),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _relationship_resources(subject: Subject, resources: list[Resource]) -> list[Resource]:
|
|
132
|
+
selected: list[Resource] = []
|
|
133
|
+
predicates: tuple[Callable[[Resource], bool], ...] = (
|
|
134
|
+
lambda resource: resource.owner_id == subject.id,
|
|
135
|
+
lambda resource: resource.tenant == subject.tenant,
|
|
136
|
+
lambda resource: (
|
|
137
|
+
subject.tenant is not None
|
|
138
|
+
and resource.tenant is not None
|
|
139
|
+
and resource.tenant != subject.tenant
|
|
140
|
+
),
|
|
141
|
+
)
|
|
142
|
+
for predicate in predicates:
|
|
143
|
+
match = next((resource for resource in resources if predicate(resource)), None)
|
|
144
|
+
if match is not None and match not in selected:
|
|
145
|
+
selected.append(match)
|
|
146
|
+
if not selected:
|
|
147
|
+
selected.append(resources[0])
|
|
148
|
+
return selected
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
"""Deterministic built-in and custom authorization invariants."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from dataclasses import dataclass
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Protocol
|
|
10
|
+
|
|
11
|
+
from permissiondiff.errors import InvariantDefinitionError
|
|
12
|
+
from permissiondiff.loader import load_callable
|
|
13
|
+
from permissiondiff.models import AuthorizationCase, Decision, InvariantResult
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Invariant(Protocol):
|
|
17
|
+
"""Interface shared by every deterministic invariant."""
|
|
18
|
+
|
|
19
|
+
@property
|
|
20
|
+
def name(self) -> str: ...
|
|
21
|
+
|
|
22
|
+
def evaluate(self, case: AuthorizationCase, decision: Decision) -> InvariantResult: ...
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True, slots=True)
|
|
26
|
+
class TenantIsolationInvariant:
|
|
27
|
+
"""Require cross-tenant authorization cases to be denied."""
|
|
28
|
+
|
|
29
|
+
name: str = "tenant_isolation"
|
|
30
|
+
|
|
31
|
+
def evaluate(self, case: AuthorizationCase, decision: Decision) -> InvariantResult:
|
|
32
|
+
"""Check tenant isolation when both tenants are known and differ."""
|
|
33
|
+
applies = (
|
|
34
|
+
case.subject.tenant is not None
|
|
35
|
+
and case.resource.tenant is not None
|
|
36
|
+
and case.subject.tenant != case.resource.tenant
|
|
37
|
+
)
|
|
38
|
+
passed = not applies or decision is Decision.DENY
|
|
39
|
+
return InvariantResult(
|
|
40
|
+
name=self.name,
|
|
41
|
+
passed=passed,
|
|
42
|
+
message=(
|
|
43
|
+
"Cross-tenant access must be denied" if not passed else "Tenant isolation holds"
|
|
44
|
+
),
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True, slots=True)
|
|
49
|
+
class RoleBoundaryInvariant:
|
|
50
|
+
"""Restrict selected actions to an explicit set of roles."""
|
|
51
|
+
|
|
52
|
+
actions: frozenset[str]
|
|
53
|
+
allowed_roles: frozenset[str]
|
|
54
|
+
name: str = "role_boundary"
|
|
55
|
+
|
|
56
|
+
def evaluate(self, case: AuthorizationCase, decision: Decision) -> InvariantResult:
|
|
57
|
+
"""Require denial when a selected action is attempted by another role."""
|
|
58
|
+
applies = case.action.name in self.actions and case.subject.role not in self.allowed_roles
|
|
59
|
+
passed = not applies or decision is Decision.DENY
|
|
60
|
+
return InvariantResult(
|
|
61
|
+
name=self.name,
|
|
62
|
+
passed=passed,
|
|
63
|
+
message=(
|
|
64
|
+
f"Role {case.subject.role!r} must not perform {case.action.name!r}"
|
|
65
|
+
if not passed
|
|
66
|
+
else "Role boundary holds"
|
|
67
|
+
),
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
@dataclass(frozen=True, slots=True)
|
|
72
|
+
class OwnershipInvariant:
|
|
73
|
+
"""Require non-owners to be denied for selected actions."""
|
|
74
|
+
|
|
75
|
+
actions: frozenset[str]
|
|
76
|
+
name: str = "ownership"
|
|
77
|
+
|
|
78
|
+
def evaluate(self, case: AuthorizationCase, decision: Decision) -> InvariantResult:
|
|
79
|
+
"""Check ownership only when the resource declares an owner."""
|
|
80
|
+
applies = (
|
|
81
|
+
case.action.name in self.actions
|
|
82
|
+
and case.resource.owner_id is not None
|
|
83
|
+
and case.subject.id != case.resource.owner_id
|
|
84
|
+
)
|
|
85
|
+
passed = not applies or decision is Decision.DENY
|
|
86
|
+
return InvariantResult(
|
|
87
|
+
name=self.name,
|
|
88
|
+
passed=passed,
|
|
89
|
+
message=(
|
|
90
|
+
"Non-owner access must be denied" if not passed else "Ownership boundary holds"
|
|
91
|
+
),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@dataclass(frozen=True, slots=True)
|
|
96
|
+
class CustomInvariant:
|
|
97
|
+
"""Adapter for a user-defined deterministic invariant callable."""
|
|
98
|
+
|
|
99
|
+
name: str
|
|
100
|
+
function: Callable[[AuthorizationCase, Decision], bool | InvariantResult]
|
|
101
|
+
|
|
102
|
+
def evaluate(self, case: AuthorizationCase, decision: Decision) -> InvariantResult:
|
|
103
|
+
"""Validate and normalize the custom invariant result."""
|
|
104
|
+
try:
|
|
105
|
+
result = self.function(case, decision)
|
|
106
|
+
except Exception as exc:
|
|
107
|
+
raise InvariantDefinitionError(
|
|
108
|
+
f"custom invariant {self.name!r} crashed: {exc}"
|
|
109
|
+
) from exc
|
|
110
|
+
if isinstance(result, InvariantResult):
|
|
111
|
+
return result
|
|
112
|
+
if isinstance(result, bool):
|
|
113
|
+
return InvariantResult(
|
|
114
|
+
name=self.name,
|
|
115
|
+
passed=result,
|
|
116
|
+
message=(
|
|
117
|
+
f"Custom invariant {self.name!r} failed"
|
|
118
|
+
if not result
|
|
119
|
+
else f"Custom invariant {self.name!r} holds"
|
|
120
|
+
),
|
|
121
|
+
)
|
|
122
|
+
raise InvariantDefinitionError(
|
|
123
|
+
f"custom invariant {self.name!r} returned {type(result).__name__}, expected bool"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def build_invariants(declarations: list[str | dict[str, Any]], *, workdir: Path) -> list[Invariant]:
|
|
128
|
+
"""Turn validated config declarations into core invariant objects."""
|
|
129
|
+
invariants: list[Invariant] = []
|
|
130
|
+
for declaration in declarations:
|
|
131
|
+
if isinstance(declaration, str):
|
|
132
|
+
if declaration != "tenant_isolation":
|
|
133
|
+
raise InvariantDefinitionError(f"unknown invariant {declaration!r}")
|
|
134
|
+
invariants.append(TenantIsolationInvariant())
|
|
135
|
+
continue
|
|
136
|
+
if len(declaration) != 1:
|
|
137
|
+
raise InvariantDefinitionError("invariant mappings must contain exactly one key")
|
|
138
|
+
name, raw_options = next(iter(declaration.items()))
|
|
139
|
+
options = _options(raw_options, name)
|
|
140
|
+
if name == "tenant_isolation":
|
|
141
|
+
invariants.append(TenantIsolationInvariant())
|
|
142
|
+
elif name == "role_boundary":
|
|
143
|
+
actions = _actions(options, name)
|
|
144
|
+
roles = options.get("allowed_roles")
|
|
145
|
+
if (
|
|
146
|
+
not isinstance(roles, list)
|
|
147
|
+
or not roles
|
|
148
|
+
or not all(isinstance(role, str) for role in roles)
|
|
149
|
+
):
|
|
150
|
+
raise InvariantDefinitionError("role_boundary.allowed_roles must be a string list")
|
|
151
|
+
invariants.append(RoleBoundaryInvariant(actions, frozenset(roles)))
|
|
152
|
+
elif name == "ownership":
|
|
153
|
+
invariants.append(OwnershipInvariant(_actions(options, name)))
|
|
154
|
+
elif name == "custom":
|
|
155
|
+
spec = options.get("callable")
|
|
156
|
+
custom_name = options.get("name", spec)
|
|
157
|
+
if not isinstance(spec, str) or not isinstance(custom_name, str):
|
|
158
|
+
raise InvariantDefinitionError(
|
|
159
|
+
"custom invariant requires callable and optional name"
|
|
160
|
+
)
|
|
161
|
+
invariants.append(CustomInvariant(custom_name, _load_from_workdir(spec, workdir)))
|
|
162
|
+
else:
|
|
163
|
+
raise InvariantDefinitionError(f"unknown invariant {name!r}")
|
|
164
|
+
return invariants
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def _actions(options: dict[str, Any], name: str) -> frozenset[str]:
|
|
168
|
+
raw = options.get("actions", options.get("action"))
|
|
169
|
+
if isinstance(raw, str):
|
|
170
|
+
return frozenset({raw})
|
|
171
|
+
if isinstance(raw, list) and raw and all(isinstance(action, str) for action in raw):
|
|
172
|
+
return frozenset(raw)
|
|
173
|
+
raise InvariantDefinitionError(f"{name}.actions must be a string or non-empty string list")
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _options(value: object, name: str) -> dict[str, Any]:
|
|
177
|
+
if value is None:
|
|
178
|
+
return {}
|
|
179
|
+
if not isinstance(value, dict):
|
|
180
|
+
raise InvariantDefinitionError(f"{name} options must be a mapping")
|
|
181
|
+
return value
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _load_from_workdir(spec: str, workdir: Path) -> Callable[..., Any]:
|
|
185
|
+
path = str(workdir.resolve())
|
|
186
|
+
added = path not in sys.path
|
|
187
|
+
if added:
|
|
188
|
+
sys.path.insert(0, path)
|
|
189
|
+
try:
|
|
190
|
+
return load_callable(spec)
|
|
191
|
+
finally:
|
|
192
|
+
if added:
|
|
193
|
+
sys.path.remove(path)
|
permissiondiff/loader.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Import helpers for user-provided authorizers and custom invariants."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import importlib
|
|
6
|
+
from collections.abc import Callable
|
|
7
|
+
from typing import Any, cast
|
|
8
|
+
|
|
9
|
+
from permissiondiff.errors import AdapterError
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def load_callable(spec: str) -> Callable[..., Any]:
|
|
13
|
+
"""Load a callable from a strict ``module:attribute`` reference."""
|
|
14
|
+
module_name, separator, attribute_name = spec.partition(":")
|
|
15
|
+
if not separator or not module_name or not attribute_name:
|
|
16
|
+
raise AdapterError(f"invalid callable {spec!r}; expected 'module:function'")
|
|
17
|
+
try:
|
|
18
|
+
module = importlib.import_module(module_name)
|
|
19
|
+
except Exception as exc:
|
|
20
|
+
raise AdapterError(f"could not import module {module_name!r}: {exc}") from exc
|
|
21
|
+
try:
|
|
22
|
+
value = getattr(module, attribute_name)
|
|
23
|
+
except AttributeError as exc:
|
|
24
|
+
raise AdapterError(f"module {module_name!r} has no attribute {attribute_name!r}") from exc
|
|
25
|
+
if not callable(value):
|
|
26
|
+
raise AdapterError(f"{spec!r} does not reference a callable")
|
|
27
|
+
return cast("Callable[..., Any]", value)
|