verdik 0.0.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.
- verdik/__init__.py +39 -0
- verdik/core.py +224 -0
- verdik-0.0.0.dist-info/METADATA +44 -0
- verdik-0.0.0.dist-info/RECORD +5 -0
- verdik-0.0.0.dist-info/WHEEL +4 -0
verdik/__init__.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
from .core import (
|
|
2
|
+
Condition,
|
|
3
|
+
DecisionLog,
|
|
4
|
+
EvaluationOptions,
|
|
5
|
+
LocalPolicyEngine,
|
|
6
|
+
MatchedRule,
|
|
7
|
+
Obligation,
|
|
8
|
+
PolicyDecision,
|
|
9
|
+
PolicyInput,
|
|
10
|
+
Rule,
|
|
11
|
+
allow_all_rule,
|
|
12
|
+
allow_within_token_policy,
|
|
13
|
+
canonicalize,
|
|
14
|
+
create_local_policy_engine,
|
|
15
|
+
evaluate,
|
|
16
|
+
layer_rules,
|
|
17
|
+
matches_condition,
|
|
18
|
+
read_path,
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"Condition",
|
|
23
|
+
"DecisionLog",
|
|
24
|
+
"EvaluationOptions",
|
|
25
|
+
"LocalPolicyEngine",
|
|
26
|
+
"MatchedRule",
|
|
27
|
+
"Obligation",
|
|
28
|
+
"PolicyDecision",
|
|
29
|
+
"PolicyInput",
|
|
30
|
+
"Rule",
|
|
31
|
+
"allow_all_rule",
|
|
32
|
+
"allow_within_token_policy",
|
|
33
|
+
"canonicalize",
|
|
34
|
+
"create_local_policy_engine",
|
|
35
|
+
"evaluate",
|
|
36
|
+
"layer_rules",
|
|
37
|
+
"matches_condition",
|
|
38
|
+
"read_path",
|
|
39
|
+
]
|
verdik/core.py
ADDED
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from dataclasses import dataclass, field
|
|
5
|
+
from typing import Any, Literal, NotRequired, TypedDict
|
|
6
|
+
|
|
7
|
+
DefaultEffect = Literal["allow", "deny"]
|
|
8
|
+
ConditionOp = Literal[
|
|
9
|
+
"exists",
|
|
10
|
+
"eq",
|
|
11
|
+
"neq",
|
|
12
|
+
"in",
|
|
13
|
+
"contains",
|
|
14
|
+
"starts_with",
|
|
15
|
+
"lte",
|
|
16
|
+
"gte",
|
|
17
|
+
"lt",
|
|
18
|
+
"gt",
|
|
19
|
+
]
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Condition(TypedDict, total=False):
|
|
23
|
+
all: list[Condition]
|
|
24
|
+
any: list[Condition]
|
|
25
|
+
not_: Condition
|
|
26
|
+
field: str
|
|
27
|
+
op: ConditionOp
|
|
28
|
+
value: Any
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class Obligation(TypedDict):
|
|
32
|
+
kind: str
|
|
33
|
+
detail: NotRequired[dict[str, Any]]
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass(frozen=True)
|
|
37
|
+
class Rule:
|
|
38
|
+
id: str
|
|
39
|
+
effect: DefaultEffect
|
|
40
|
+
when: Condition
|
|
41
|
+
reason: str
|
|
42
|
+
obligations: list[Obligation] = field(default_factory=list)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
PolicyInput = dict[str, Any]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class PolicyDecision:
|
|
50
|
+
allow: bool
|
|
51
|
+
reason: str
|
|
52
|
+
obligations: list[Obligation] = field(default_factory=list)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class MatchedRule:
|
|
57
|
+
id: str
|
|
58
|
+
effect: DefaultEffect
|
|
59
|
+
reason: str
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
@dataclass(frozen=True)
|
|
63
|
+
class DecisionLog:
|
|
64
|
+
matched: list[MatchedRule]
|
|
65
|
+
obligations: list[Obligation]
|
|
66
|
+
binding_rule_id: str | None = None
|
|
67
|
+
defaulted: bool = False
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
@dataclass(frozen=True)
|
|
71
|
+
class EvaluationOptions:
|
|
72
|
+
default_effect: DefaultEffect = "deny"
|
|
73
|
+
default_reason: str | None = None
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@dataclass(frozen=True)
|
|
77
|
+
class EvaluationResult:
|
|
78
|
+
decision: PolicyDecision
|
|
79
|
+
log: DecisionLog
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
class LocalPolicyEngine:
|
|
83
|
+
def __init__(self, rules: list[Rule], options: EvaluationOptions | None = None) -> None:
|
|
84
|
+
self._rules = rules
|
|
85
|
+
self._options = options or EvaluationOptions()
|
|
86
|
+
|
|
87
|
+
async def decide(self, value: PolicyInput) -> PolicyDecision:
|
|
88
|
+
return evaluate(self._rules, value, self._options).decision
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def evaluate(
|
|
92
|
+
rules: list[Rule],
|
|
93
|
+
value: PolicyInput,
|
|
94
|
+
options: EvaluationOptions | None = None,
|
|
95
|
+
) -> EvaluationResult:
|
|
96
|
+
options = options or EvaluationOptions()
|
|
97
|
+
matched = [rule for rule in rules if matches_condition(rule.when, value)]
|
|
98
|
+
denied = next((rule for rule in matched if rule.effect == "deny"), None)
|
|
99
|
+
allow_rules = [rule for rule in matched if rule.effect == "allow"]
|
|
100
|
+
obligations = _unique_obligations(
|
|
101
|
+
obligation for rule in allow_rules for obligation in rule.obligations
|
|
102
|
+
)
|
|
103
|
+
|
|
104
|
+
if denied is not None:
|
|
105
|
+
return EvaluationResult(
|
|
106
|
+
PolicyDecision(False, denied.reason),
|
|
107
|
+
_build_log(matched, denied.id, False, obligations),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
first_allow = allow_rules[0] if allow_rules else None
|
|
111
|
+
if first_allow is not None:
|
|
112
|
+
return EvaluationResult(
|
|
113
|
+
PolicyDecision(True, first_allow.reason, obligations),
|
|
114
|
+
_build_log(matched, first_allow.id, False, obligations),
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
effect = options.default_effect
|
|
118
|
+
return EvaluationResult(
|
|
119
|
+
PolicyDecision(effect == "allow", options.default_reason or f"default_{effect}"),
|
|
120
|
+
_build_log([], None, True, []),
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def matches_condition(condition: Condition, value: PolicyInput) -> bool:
|
|
125
|
+
if "all" in condition:
|
|
126
|
+
return all(matches_condition(child, value) for child in condition["all"])
|
|
127
|
+
if "any" in condition:
|
|
128
|
+
return any(matches_condition(child, value) for child in condition["any"])
|
|
129
|
+
if "not_" in condition:
|
|
130
|
+
return not matches_condition(condition["not_"], value)
|
|
131
|
+
|
|
132
|
+
actual = read_path(value, condition["field"])
|
|
133
|
+
expected = condition.get("value")
|
|
134
|
+
op = condition["op"]
|
|
135
|
+
if op == "exists":
|
|
136
|
+
return actual is not None
|
|
137
|
+
if op == "eq":
|
|
138
|
+
return canonicalize(actual) == canonicalize(expected)
|
|
139
|
+
if op == "neq":
|
|
140
|
+
return canonicalize(actual) != canonicalize(expected)
|
|
141
|
+
if op == "in":
|
|
142
|
+
return isinstance(expected, list) and any(
|
|
143
|
+
canonicalize(item) == canonicalize(actual) for item in expected
|
|
144
|
+
)
|
|
145
|
+
if op == "contains":
|
|
146
|
+
if isinstance(actual, list):
|
|
147
|
+
return any(canonicalize(item) == canonicalize(expected) for item in actual)
|
|
148
|
+
return isinstance(actual, str) and isinstance(expected, str) and expected in actual
|
|
149
|
+
if op == "starts_with":
|
|
150
|
+
return isinstance(actual, str) and isinstance(expected, str) and actual.startswith(expected)
|
|
151
|
+
if op == "lte":
|
|
152
|
+
return _compare_number(actual, expected, lambda left, right: left <= right)
|
|
153
|
+
if op == "gte":
|
|
154
|
+
return _compare_number(actual, expected, lambda left, right: left >= right)
|
|
155
|
+
if op == "lt":
|
|
156
|
+
return _compare_number(actual, expected, lambda left, right: left < right)
|
|
157
|
+
if op == "gt":
|
|
158
|
+
return _compare_number(actual, expected, lambda left, right: left > right)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def read_path(value: PolicyInput, path: str) -> Any:
|
|
162
|
+
current: Any = value
|
|
163
|
+
for part in path.split("."):
|
|
164
|
+
if not isinstance(current, dict):
|
|
165
|
+
return None
|
|
166
|
+
current = current.get(part)
|
|
167
|
+
return current
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def canonicalize(value: Any) -> str:
|
|
171
|
+
return json.dumps(value, sort_keys=True, separators=(",", ":"))
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def create_local_policy_engine(
|
|
175
|
+
rules: list[Rule], options: EvaluationOptions | None = None
|
|
176
|
+
) -> LocalPolicyEngine:
|
|
177
|
+
return LocalPolicyEngine(rules, options)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def allow_all_rule(reason: str = "within_token") -> Rule:
|
|
181
|
+
return Rule(
|
|
182
|
+
id="allow_within_token",
|
|
183
|
+
effect="allow",
|
|
184
|
+
when={"field": "scope.action", "op": "exists"},
|
|
185
|
+
reason=reason,
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def allow_within_token_policy() -> LocalPolicyEngine:
|
|
190
|
+
return create_local_policy_engine([allow_all_rule()], EvaluationOptions(default_effect="deny"))
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def layer_rules(*layers: list[Rule]) -> list[Rule]:
|
|
194
|
+
return [rule for layer in layers for rule in layer]
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _build_log(
|
|
198
|
+
rules: list[Rule],
|
|
199
|
+
binding_rule_id: str | None,
|
|
200
|
+
defaulted: bool,
|
|
201
|
+
obligations: list[Obligation],
|
|
202
|
+
) -> DecisionLog:
|
|
203
|
+
return DecisionLog(
|
|
204
|
+
matched=[MatchedRule(rule.id, rule.effect, rule.reason) for rule in rules],
|
|
205
|
+
binding_rule_id=binding_rule_id,
|
|
206
|
+
defaulted=defaulted,
|
|
207
|
+
obligations=obligations,
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _unique_obligations(obligations: Any) -> list[Obligation]:
|
|
212
|
+
seen: set[str] = set()
|
|
213
|
+
out: list[Obligation] = []
|
|
214
|
+
for obligation in obligations:
|
|
215
|
+
key = canonicalize(obligation)
|
|
216
|
+
if key in seen:
|
|
217
|
+
continue
|
|
218
|
+
seen.add(key)
|
|
219
|
+
out.append(obligation)
|
|
220
|
+
return out
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _compare_number(left: Any, right: Any, compare: Any) -> bool:
|
|
224
|
+
return isinstance(left, int | float) and isinstance(right, int | float) and compare(left, right)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: verdik
|
|
3
|
+
Version: 0.0.0
|
|
4
|
+
Summary: Pure, decision-logged policy evaluation for MissionCtrl and agent-fabric authorization seams.
|
|
5
|
+
Project-URL: Homepage, https://github.com/cachetronaut/verdik
|
|
6
|
+
Project-URL: Repository, https://github.com/cachetronaut/verdik
|
|
7
|
+
Project-URL: Issues, https://github.com/cachetronaut/verdik/issues
|
|
8
|
+
License: MIT
|
|
9
|
+
Requires-Python: >=3.11
|
|
10
|
+
Description-Content-Type: text/markdown
|
|
11
|
+
|
|
12
|
+
# verdik
|
|
13
|
+
|
|
14
|
+
Python implementation of Verdik.
|
|
15
|
+
|
|
16
|
+
For product-level context, shared contracts, and cross-language repository information, see the public repository: https://github.com/cachetronaut/verdik.
|
|
17
|
+
|
|
18
|
+
## Install
|
|
19
|
+
|
|
20
|
+
```sh
|
|
21
|
+
pip install verdik
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## Import
|
|
25
|
+
|
|
26
|
+
```python
|
|
27
|
+
import verdik
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
## Development
|
|
31
|
+
|
|
32
|
+
Run from `py/`:
|
|
33
|
+
|
|
34
|
+
```sh
|
|
35
|
+
uv sync --dev
|
|
36
|
+
uv run --with ruff ruff check .
|
|
37
|
+
uv run --with ruff ruff format --check .
|
|
38
|
+
uv run --with ty ty check
|
|
39
|
+
uv run --with pytest --with pytest-asyncio python -m pytest
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## License
|
|
43
|
+
|
|
44
|
+
MIT
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
verdik/__init__.py,sha256=Owwt4-9gkflS92eUk5JMZucs2KbHTmUSOM51S1PsaCA,727
|
|
2
|
+
verdik/core.py,sha256=xY2-gdRFljsH7Vw9mEQVu0kHwwtxX2PrxrXMgOu8VIo,6456
|
|
3
|
+
verdik-0.0.0.dist-info/METADATA,sha256=u_ywtt1fPAgz-QKOcKPpi4Pqa8L3j-jWvV1KthRuv94,939
|
|
4
|
+
verdik-0.0.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
5
|
+
verdik-0.0.0.dist-info/RECORD,,
|