policy-as-code-engine 0.1.1__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.
- policy_as_code_engine/__init__.py +50 -0
- policy_as_code_engine/__main__.py +53 -0
- policy_as_code_engine/app.py +226 -0
- policy_as_code_engine/audit_stream.py +82 -0
- policy_as_code_engine/evaluator.py +197 -0
- policy_as_code_engine/from_decision_card.py +162 -0
- policy_as_code_engine/loader.py +46 -0
- policy_as_code_engine/models.py +218 -0
- policy_as_code_engine-0.1.1.dist-info/METADATA +278 -0
- policy_as_code_engine-0.1.1.dist-info/RECORD +13 -0
- policy_as_code_engine-0.1.1.dist-info/WHEEL +4 -0
- policy_as_code_engine-0.1.1.dist-info/entry_points.txt +2 -0
- policy_as_code_engine-0.1.1.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""
|
|
2
|
+
policy-as-code-engine — declarative policy evaluation for Python services.
|
|
3
|
+
|
|
4
|
+
This package answers one question: *given this context object and these rules,
|
|
5
|
+
should the request be allowed?* It is deliberately small. Rules are JSON/YAML
|
|
6
|
+
documents; the engine walks them and produces a structured decision (allow /
|
|
7
|
+
deny / not-applicable) with the matching rule and the reason.
|
|
8
|
+
|
|
9
|
+
Designed to compose with the rest of the Kinetic Gain ecosystem:
|
|
10
|
+
|
|
11
|
+
procurement-decision-api -> drafts a Decision Card with conditions[]
|
|
12
|
+
policy-as-code-engine -> converts those conditions into a PolicyBundle,
|
|
13
|
+
then evaluates them against live requests
|
|
14
|
+
|
|
15
|
+
Two surfaces:
|
|
16
|
+
|
|
17
|
+
Library: from policy_as_code_engine import PolicyEvaluator
|
|
18
|
+
HTTP: uvicorn policy_as_code_engine.app:app (optional `[api]` extra)
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
from .evaluator import PolicyEvaluator
|
|
24
|
+
from .from_decision_card import policy_bundle_from_decision_card
|
|
25
|
+
from .models import (
|
|
26
|
+
Decision,
|
|
27
|
+
DecisionKind,
|
|
28
|
+
EvaluationContext,
|
|
29
|
+
EvaluationResult,
|
|
30
|
+
Matcher,
|
|
31
|
+
Policy,
|
|
32
|
+
PolicyBundle,
|
|
33
|
+
Rule,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
__version__ = "0.1.1"
|
|
37
|
+
|
|
38
|
+
__all__ = [
|
|
39
|
+
"Decision",
|
|
40
|
+
"DecisionKind",
|
|
41
|
+
"EvaluationContext",
|
|
42
|
+
"EvaluationResult",
|
|
43
|
+
"Matcher",
|
|
44
|
+
"Policy",
|
|
45
|
+
"PolicyBundle",
|
|
46
|
+
"PolicyEvaluator",
|
|
47
|
+
"Rule",
|
|
48
|
+
"__version__",
|
|
49
|
+
"policy_bundle_from_decision_card",
|
|
50
|
+
]
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Entry point. Either run the HTTP API or evaluate a bundle from the CLI.
|
|
3
|
+
|
|
4
|
+
# API
|
|
5
|
+
python -m policy_as_code_engine # binds 0.0.0.0:8089
|
|
6
|
+
|
|
7
|
+
# CLI eval
|
|
8
|
+
python -m policy_as_code_engine eval bundle.yaml ctx.json
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from .evaluator import PolicyEvaluator
|
|
19
|
+
from .loader import load_bundle_from_file
|
|
20
|
+
from .models import EvaluationContext
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _serve() -> None:
|
|
24
|
+
import uvicorn
|
|
25
|
+
|
|
26
|
+
port = int(os.environ.get("PORT", "8089"))
|
|
27
|
+
host = os.environ.get("HOST", "0.0.0.0")
|
|
28
|
+
uvicorn.run("policy_as_code_engine.app:app", host=host, port=port, log_level="info")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _eval(bundle_path: str, context_path: str) -> int:
|
|
32
|
+
bundle = load_bundle_from_file(bundle_path)
|
|
33
|
+
raw_ctx = Path(context_path).read_text(encoding="utf-8")
|
|
34
|
+
context = EvaluationContext.model_validate_json(raw_ctx)
|
|
35
|
+
result = PolicyEvaluator().evaluate(bundle, context)
|
|
36
|
+
print(json.dumps(result.model_dump(mode="json"), indent=2))
|
|
37
|
+
return 0 if result.decision.kind != "deny" else 1
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def main() -> None:
|
|
41
|
+
args = sys.argv[1:]
|
|
42
|
+
if not args:
|
|
43
|
+
_serve()
|
|
44
|
+
return
|
|
45
|
+
if args[0] == "eval" and len(args) == 3:
|
|
46
|
+
sys.exit(_eval(args[1], args[2]))
|
|
47
|
+
print("usage: python -m policy_as_code_engine # run HTTP API")
|
|
48
|
+
print(" python -m policy_as_code_engine eval BUNDLE CTX # CLI eval")
|
|
49
|
+
sys.exit(2)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
if __name__ == "__main__":
|
|
53
|
+
main()
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""
|
|
2
|
+
FastAPI app — five endpoints.
|
|
3
|
+
|
|
4
|
+
GET / service info
|
|
5
|
+
GET /healthz liveness probe
|
|
6
|
+
POST /bundles register a PolicyBundle (in-memory)
|
|
7
|
+
GET /bundles/{bundle_id} inspect a registered bundle
|
|
8
|
+
POST /bundles/{bundle_id}/evaluate evaluate it against a context
|
|
9
|
+
POST /evaluate one-shot: bundle + context in, decision out
|
|
10
|
+
POST /bundles/from-decision-card build a bundle from a Decision Card
|
|
11
|
+
|
|
12
|
+
Bundles are held in process memory by default; restart-safe storage is a
|
|
13
|
+
caller responsibility. Wire a Redis / Postgres backend by replacing the
|
|
14
|
+
`_BundleStore` instance in `lifespan`.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from collections.abc import AsyncIterator
|
|
20
|
+
from contextlib import asynccontextmanager
|
|
21
|
+
from threading import Lock
|
|
22
|
+
from typing import Any
|
|
23
|
+
|
|
24
|
+
import httpx
|
|
25
|
+
from fastapi import FastAPI, HTTPException, status
|
|
26
|
+
from pydantic import BaseModel, ValidationError
|
|
27
|
+
|
|
28
|
+
from . import __version__, audit_stream
|
|
29
|
+
from .evaluator import PolicyEvaluator
|
|
30
|
+
from .from_decision_card import policy_bundle_from_decision_card
|
|
31
|
+
from .models import EvaluationContext, EvaluationResult, PolicyBundle
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class _BundleStore:
|
|
35
|
+
"""Thread-safe in-memory bundle store."""
|
|
36
|
+
|
|
37
|
+
__slots__ = ("_bundles", "_lock")
|
|
38
|
+
|
|
39
|
+
def __init__(self) -> None:
|
|
40
|
+
self._bundles: dict[str, PolicyBundle] = {}
|
|
41
|
+
self._lock = Lock()
|
|
42
|
+
|
|
43
|
+
def put(self, bundle: PolicyBundle) -> None:
|
|
44
|
+
with self._lock:
|
|
45
|
+
self._bundles[bundle.bundle_id] = bundle
|
|
46
|
+
|
|
47
|
+
def get(self, bundle_id: str) -> PolicyBundle:
|
|
48
|
+
with self._lock:
|
|
49
|
+
try:
|
|
50
|
+
return self._bundles[bundle_id]
|
|
51
|
+
except KeyError as err:
|
|
52
|
+
raise KeyError(bundle_id) from err
|
|
53
|
+
|
|
54
|
+
def list_ids(self) -> list[str]:
|
|
55
|
+
with self._lock:
|
|
56
|
+
return list(self._bundles.keys())
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class _OneShotRequest(BaseModel):
|
|
60
|
+
bundle: PolicyBundle
|
|
61
|
+
context: EvaluationContext
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@asynccontextmanager
|
|
65
|
+
async def _lifespan(app: FastAPI) -> AsyncIterator[None]:
|
|
66
|
+
app.state.store = _BundleStore()
|
|
67
|
+
app.state.evaluator = PolicyEvaluator()
|
|
68
|
+
# Shared httpx client for best-effort audit-stream emission. Always
|
|
69
|
+
# created; the audit_stream module no-ops when AUDIT_STREAM_URL is unset.
|
|
70
|
+
app.state.http_client = httpx.AsyncClient(
|
|
71
|
+
headers={"User-Agent": f"policy-as-code-engine/{__version__} (+https://kineticgain.com)"},
|
|
72
|
+
)
|
|
73
|
+
try:
|
|
74
|
+
yield
|
|
75
|
+
finally:
|
|
76
|
+
await app.state.http_client.aclose()
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
app = FastAPI(
|
|
80
|
+
title="policy-as-code-engine",
|
|
81
|
+
version=__version__,
|
|
82
|
+
description=(
|
|
83
|
+
"Declarative policy-as-code evaluator. Pairs with procurement-decision-api: "
|
|
84
|
+
"drafted Decision Cards become enforceable PolicyBundles via "
|
|
85
|
+
"POST /bundles/from-decision-card."
|
|
86
|
+
),
|
|
87
|
+
lifespan=_lifespan,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _store() -> _BundleStore:
|
|
92
|
+
"""Typed accessor for app.state.store — keeps mypy strict happy."""
|
|
93
|
+
store = app.state.store
|
|
94
|
+
assert isinstance(store, _BundleStore)
|
|
95
|
+
return store
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _evaluator() -> PolicyEvaluator:
|
|
99
|
+
evaluator = app.state.evaluator
|
|
100
|
+
assert isinstance(evaluator, PolicyEvaluator)
|
|
101
|
+
return evaluator
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _http_client() -> httpx.AsyncClient:
|
|
105
|
+
"""Shared httpx client used by audit_stream.emit (best-effort)."""
|
|
106
|
+
client = app.state.http_client
|
|
107
|
+
assert isinstance(client, httpx.AsyncClient)
|
|
108
|
+
return client
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
@app.get("/", tags=["meta"])
|
|
112
|
+
async def root() -> dict[str, Any]:
|
|
113
|
+
return {
|
|
114
|
+
"name": "policy-as-code-engine",
|
|
115
|
+
"version": __version__,
|
|
116
|
+
"description": (
|
|
117
|
+
"Evaluates declarative policy bundles against arbitrary request contexts. "
|
|
118
|
+
"Bridges to the Kinetic Gain Protocol Suite via /bundles/from-decision-card."
|
|
119
|
+
),
|
|
120
|
+
"endpoints": {
|
|
121
|
+
"GET /": "this page",
|
|
122
|
+
"GET /healthz": "liveness probe",
|
|
123
|
+
"GET /bundles": "list registered bundle IDs",
|
|
124
|
+
"POST /bundles": "register a PolicyBundle",
|
|
125
|
+
"GET /bundles/{bundle_id}": "fetch a registered bundle",
|
|
126
|
+
"POST /bundles/{bundle_id}/evaluate": "evaluate a stored bundle against a context",
|
|
127
|
+
"POST /evaluate": "one-shot: bundle + context in, decision out",
|
|
128
|
+
"POST /bundles/from-decision-card": "build a bundle from a Decision Card",
|
|
129
|
+
},
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@app.get("/healthz", tags=["meta"])
|
|
134
|
+
async def healthz() -> dict[str, str]:
|
|
135
|
+
return {"status": "ok"}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
@app.get("/bundles", tags=["bundles"])
|
|
139
|
+
async def list_bundles() -> dict[str, list[str]]:
|
|
140
|
+
return {"bundle_ids": _store().list_ids()}
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@app.post("/bundles", tags=["bundles"], status_code=201)
|
|
144
|
+
async def register_bundle(bundle: PolicyBundle) -> dict[str, str]:
|
|
145
|
+
_store().put(bundle)
|
|
146
|
+
await audit_stream.emit(
|
|
147
|
+
_http_client(),
|
|
148
|
+
kind="policy_bundle_registered",
|
|
149
|
+
payload={
|
|
150
|
+
"bundle_id": bundle.bundle_id,
|
|
151
|
+
"policy_count": len(bundle.policies),
|
|
152
|
+
"source": bundle.source,
|
|
153
|
+
},
|
|
154
|
+
)
|
|
155
|
+
return {"bundle_id": bundle.bundle_id, "status": "registered"}
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
@app.get("/bundles/{bundle_id}", tags=["bundles"])
|
|
159
|
+
async def get_bundle(bundle_id: str) -> PolicyBundle:
|
|
160
|
+
try:
|
|
161
|
+
return _store().get(bundle_id)
|
|
162
|
+
except KeyError as err:
|
|
163
|
+
raise HTTPException(status_code=404, detail=f"unknown bundle: {bundle_id!r}") from err
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
async def _emit_decision(bundle_id: str, result: EvaluationResult) -> None:
|
|
167
|
+
"""Emit request_allowed / request_denied; skip on not_applicable."""
|
|
168
|
+
kind_map = {"allow": "request_allowed", "deny": "request_denied"}
|
|
169
|
+
event_kind = kind_map.get(result.decision.kind)
|
|
170
|
+
if event_kind is None:
|
|
171
|
+
return
|
|
172
|
+
await audit_stream.emit(
|
|
173
|
+
_http_client(),
|
|
174
|
+
kind=event_kind,
|
|
175
|
+
payload={
|
|
176
|
+
"bundle_id": bundle_id,
|
|
177
|
+
"decision": result.decision.kind,
|
|
178
|
+
"matched_policy_id": result.decision.matched_policy_id,
|
|
179
|
+
"matched_rule_id": result.decision.matched_rule_id,
|
|
180
|
+
"reason": result.decision.reason,
|
|
181
|
+
},
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
@app.post("/bundles/{bundle_id}/evaluate", tags=["evaluate"])
|
|
186
|
+
async def evaluate_registered(bundle_id: str, context: EvaluationContext) -> EvaluationResult:
|
|
187
|
+
try:
|
|
188
|
+
bundle = _store().get(bundle_id)
|
|
189
|
+
except KeyError as err:
|
|
190
|
+
raise HTTPException(status_code=404, detail=f"unknown bundle: {bundle_id!r}") from err
|
|
191
|
+
result = _evaluator().evaluate(bundle, context)
|
|
192
|
+
await _emit_decision(bundle.bundle_id, result)
|
|
193
|
+
return result
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
@app.post("/evaluate", tags=["evaluate"])
|
|
197
|
+
async def evaluate_oneshot(request: _OneShotRequest) -> EvaluationResult:
|
|
198
|
+
result = _evaluator().evaluate(request.bundle, request.context)
|
|
199
|
+
await _emit_decision(request.bundle.bundle_id, result)
|
|
200
|
+
return result
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@app.post("/bundles/from-decision-card", tags=["bridge"], status_code=201)
|
|
204
|
+
async def bundle_from_decision_card(card: dict[str, Any]) -> PolicyBundle:
|
|
205
|
+
"""
|
|
206
|
+
Translate a Kinetic Gain Procurement Decision Card into a PolicyBundle
|
|
207
|
+
and register it. This is the cross-ecosystem hook.
|
|
208
|
+
"""
|
|
209
|
+
try:
|
|
210
|
+
bundle = policy_bundle_from_decision_card(card)
|
|
211
|
+
except (ValueError, ValidationError) as err:
|
|
212
|
+
raise HTTPException(
|
|
213
|
+
status_code=status.HTTP_400_BAD_REQUEST,
|
|
214
|
+
detail=str(err),
|
|
215
|
+
) from err
|
|
216
|
+
_store().put(bundle)
|
|
217
|
+
await audit_stream.emit(
|
|
218
|
+
_http_client(),
|
|
219
|
+
kind="policy_bundle_registered",
|
|
220
|
+
payload={
|
|
221
|
+
"bundle_id": bundle.bundle_id,
|
|
222
|
+
"policy_count": len(bundle.policies),
|
|
223
|
+
"source": bundle.source,
|
|
224
|
+
},
|
|
225
|
+
)
|
|
226
|
+
return bundle
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Optional audit-stream-py integration.
|
|
3
|
+
|
|
4
|
+
When the `AUDIT_STREAM_URL` env var is set, this module fires governance
|
|
5
|
+
events at `{AUDIT_STREAM_URL}/events` for the moments the service produces.
|
|
6
|
+
Best-effort: a failed POST is logged, not raised — audit-stream outages
|
|
7
|
+
must never block policy registration or evaluation.
|
|
8
|
+
|
|
9
|
+
Event kinds this service emits:
|
|
10
|
+
policy_bundle_registered on POST /bundles (any successful registration,
|
|
11
|
+
including the from-decision-card bridge)
|
|
12
|
+
request_allowed on POST /bundles/{id}/evaluate when the bundle
|
|
13
|
+
decision is "allow"
|
|
14
|
+
request_denied on the same endpoint when the bundle decision
|
|
15
|
+
is "deny"
|
|
16
|
+
|
|
17
|
+
Same opt-in pattern as procurement-decision-api.audit_stream and
|
|
18
|
+
aeo-validator-service.audit_stream. Identical config envvars.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import os
|
|
24
|
+
from typing import Any
|
|
25
|
+
|
|
26
|
+
import httpx
|
|
27
|
+
|
|
28
|
+
DEFAULT_TIMEOUT_S = 2.5
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def is_enabled() -> bool:
|
|
32
|
+
"""True when AUDIT_STREAM_URL is set to a non-empty value."""
|
|
33
|
+
return bool(os.environ.get("AUDIT_STREAM_URL", "").strip())
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def base_url() -> str | None:
|
|
37
|
+
"""Stripped audit-stream base URL, or None when disabled."""
|
|
38
|
+
raw = os.environ.get("AUDIT_STREAM_URL", "").strip()
|
|
39
|
+
if not raw:
|
|
40
|
+
return None
|
|
41
|
+
return raw.rstrip("/")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def timeout_s() -> float:
|
|
45
|
+
"""Configured per-call timeout. Defaults to 2.5s."""
|
|
46
|
+
raw = os.environ.get("AUDIT_STREAM_TIMEOUT_S", "").strip()
|
|
47
|
+
if not raw:
|
|
48
|
+
return DEFAULT_TIMEOUT_S
|
|
49
|
+
try:
|
|
50
|
+
return max(0.1, float(raw))
|
|
51
|
+
except ValueError:
|
|
52
|
+
return DEFAULT_TIMEOUT_S
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
async def emit(
|
|
56
|
+
client: httpx.AsyncClient,
|
|
57
|
+
*,
|
|
58
|
+
kind: str,
|
|
59
|
+
payload: dict[str, Any],
|
|
60
|
+
) -> None:
|
|
61
|
+
"""Fire one event. Silent no-op when AUDIT_STREAM_URL is unset."""
|
|
62
|
+
url = base_url()
|
|
63
|
+
if url is None:
|
|
64
|
+
return
|
|
65
|
+
|
|
66
|
+
body = {
|
|
67
|
+
"kind": kind,
|
|
68
|
+
"source": "policy-as-code-engine",
|
|
69
|
+
"payload": payload,
|
|
70
|
+
}
|
|
71
|
+
try:
|
|
72
|
+
response = await client.post(
|
|
73
|
+
f"{url}/events",
|
|
74
|
+
json=body,
|
|
75
|
+
timeout=timeout_s(),
|
|
76
|
+
)
|
|
77
|
+
response.raise_for_status()
|
|
78
|
+
except (httpx.HTTPError, OSError) as err:
|
|
79
|
+
print(
|
|
80
|
+
f"audit-stream emit failed (kind={kind}): {type(err).__name__}: {err}",
|
|
81
|
+
flush=True,
|
|
82
|
+
)
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Policy evaluator. The hot path.
|
|
3
|
+
|
|
4
|
+
`PolicyEvaluator.evaluate(bundle, context)` walks every policy in the bundle,
|
|
5
|
+
finds the first rule whose `when` matches, and emits a `Decision` for that
|
|
6
|
+
policy. The bundle-level result then combines those per-policy decisions
|
|
7
|
+
(deny > allow > not_applicable).
|
|
8
|
+
|
|
9
|
+
The matcher logic lives in `_match()` and uses `EvaluationContext.lookup()`
|
|
10
|
+
for dotted-path traversal. Special-case: the `_MISSING` sentinel from
|
|
11
|
+
`models.lookup` is recognised so `exists` / `missing` matchers work correctly
|
|
12
|
+
without requiring the rule author to know about the sentinel.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import re
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from .models import (
|
|
21
|
+
_MISSING,
|
|
22
|
+
AllOfMatcher,
|
|
23
|
+
AlwaysMatcher,
|
|
24
|
+
AnyOfMatcher,
|
|
25
|
+
Decision,
|
|
26
|
+
EvaluationContext,
|
|
27
|
+
EvaluationResult,
|
|
28
|
+
FieldMatcher,
|
|
29
|
+
Matcher,
|
|
30
|
+
NotMatcher,
|
|
31
|
+
Policy,
|
|
32
|
+
PolicyBundle,
|
|
33
|
+
Rule,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class PolicyEvaluator:
|
|
38
|
+
"""Stateless evaluator. Cheap to construct; reuse one per process."""
|
|
39
|
+
|
|
40
|
+
__slots__ = ("_regex_cache",)
|
|
41
|
+
|
|
42
|
+
def __init__(self) -> None:
|
|
43
|
+
# Compiled-regex cache so repeated evaluations don't pay the parse cost.
|
|
44
|
+
self._regex_cache: dict[str, re.Pattern[str]] = {}
|
|
45
|
+
|
|
46
|
+
def evaluate(self, bundle: PolicyBundle, context: EvaluationContext) -> EvaluationResult:
|
|
47
|
+
policy_decisions: list[Decision] = []
|
|
48
|
+
for policy in bundle.policies:
|
|
49
|
+
policy_decisions.append(self._evaluate_policy(policy, context))
|
|
50
|
+
return EvaluationResult.combine(bundle.bundle_id, policy_decisions)
|
|
51
|
+
|
|
52
|
+
def evaluate_policy(self, policy: Policy, context: EvaluationContext) -> Decision:
|
|
53
|
+
"""Evaluate a single policy. Useful when callers manage bundles externally."""
|
|
54
|
+
return self._evaluate_policy(policy, context)
|
|
55
|
+
|
|
56
|
+
# ---- internals -----------------------------------------------------
|
|
57
|
+
|
|
58
|
+
def _evaluate_policy(self, policy: Policy, context: EvaluationContext) -> Decision:
|
|
59
|
+
for rule in policy.rules:
|
|
60
|
+
if self._match(rule.when, context):
|
|
61
|
+
return Decision(
|
|
62
|
+
kind=rule.effect,
|
|
63
|
+
matched_policy_id=policy.id,
|
|
64
|
+
matched_rule_id=rule.id,
|
|
65
|
+
reason=rule.description or f"matched rule {rule.id!r}",
|
|
66
|
+
)
|
|
67
|
+
# No rule matched — fall back to the policy default.
|
|
68
|
+
return Decision(
|
|
69
|
+
kind=policy.default_effect,
|
|
70
|
+
matched_policy_id=policy.id,
|
|
71
|
+
matched_rule_id=None,
|
|
72
|
+
reason=f"no rule matched; policy default_effect={policy.default_effect!r}",
|
|
73
|
+
)
|
|
74
|
+
|
|
75
|
+
def _match(self, matcher: Matcher, context: EvaluationContext) -> bool:
|
|
76
|
+
if isinstance(matcher, AlwaysMatcher):
|
|
77
|
+
return True
|
|
78
|
+
if isinstance(matcher, AllOfMatcher):
|
|
79
|
+
return all(self._match(m, context) for m in matcher.matchers)
|
|
80
|
+
if isinstance(matcher, AnyOfMatcher):
|
|
81
|
+
return any(self._match(m, context) for m in matcher.matchers)
|
|
82
|
+
if isinstance(matcher, NotMatcher):
|
|
83
|
+
return not self._match(matcher.matcher, context)
|
|
84
|
+
if isinstance(matcher, FieldMatcher):
|
|
85
|
+
return self._match_field(matcher, context)
|
|
86
|
+
# Exhaustive over the Matcher union — this is unreachable.
|
|
87
|
+
raise TypeError(f"unknown matcher type: {type(matcher).__name__}") # pragma: no cover
|
|
88
|
+
|
|
89
|
+
def _match_field(self, matcher: FieldMatcher, context: EvaluationContext) -> bool:
|
|
90
|
+
actual = context.lookup(matcher.field)
|
|
91
|
+
present = actual is not _MISSING
|
|
92
|
+
kind = matcher.kind
|
|
93
|
+
|
|
94
|
+
if kind == "exists":
|
|
95
|
+
return present
|
|
96
|
+
if kind == "missing":
|
|
97
|
+
return not present
|
|
98
|
+
if not present:
|
|
99
|
+
return False # any other matcher against a missing field is False
|
|
100
|
+
|
|
101
|
+
try:
|
|
102
|
+
return bool(_COMPARE[kind](self, actual, matcher.value))
|
|
103
|
+
except KeyError: # pragma: no cover - safety
|
|
104
|
+
raise TypeError(f"unsupported field matcher kind: {kind}") from None
|
|
105
|
+
|
|
106
|
+
# ---- comparison helpers --------------------------------------------
|
|
107
|
+
|
|
108
|
+
@staticmethod
|
|
109
|
+
def _eq(actual: Any, expected: Any) -> bool:
|
|
110
|
+
return bool(actual == expected)
|
|
111
|
+
|
|
112
|
+
@staticmethod
|
|
113
|
+
def _ne(actual: Any, expected: Any) -> bool:
|
|
114
|
+
return bool(actual != expected)
|
|
115
|
+
|
|
116
|
+
@staticmethod
|
|
117
|
+
def _gt(actual: Any, expected: Any) -> bool:
|
|
118
|
+
try:
|
|
119
|
+
return bool(actual > expected)
|
|
120
|
+
except TypeError:
|
|
121
|
+
return False
|
|
122
|
+
|
|
123
|
+
@staticmethod
|
|
124
|
+
def _gte(actual: Any, expected: Any) -> bool:
|
|
125
|
+
try:
|
|
126
|
+
return bool(actual >= expected)
|
|
127
|
+
except TypeError:
|
|
128
|
+
return False
|
|
129
|
+
|
|
130
|
+
@staticmethod
|
|
131
|
+
def _lt(actual: Any, expected: Any) -> bool:
|
|
132
|
+
try:
|
|
133
|
+
return bool(actual < expected)
|
|
134
|
+
except TypeError:
|
|
135
|
+
return False
|
|
136
|
+
|
|
137
|
+
@staticmethod
|
|
138
|
+
def _lte(actual: Any, expected: Any) -> bool:
|
|
139
|
+
try:
|
|
140
|
+
return bool(actual <= expected)
|
|
141
|
+
except TypeError:
|
|
142
|
+
return False
|
|
143
|
+
|
|
144
|
+
@staticmethod
|
|
145
|
+
def _in(actual: Any, expected: Any) -> bool:
|
|
146
|
+
return actual in expected
|
|
147
|
+
|
|
148
|
+
@staticmethod
|
|
149
|
+
def _not_in(actual: Any, expected: Any) -> bool:
|
|
150
|
+
return actual not in expected
|
|
151
|
+
|
|
152
|
+
@staticmethod
|
|
153
|
+
def _contains(actual: Any, expected: Any) -> bool:
|
|
154
|
+
if isinstance(actual, (list, tuple, set, str)):
|
|
155
|
+
return expected in actual
|
|
156
|
+
if isinstance(actual, dict):
|
|
157
|
+
return expected in actual
|
|
158
|
+
return False
|
|
159
|
+
|
|
160
|
+
def _regex(self, actual: Any, expected: Any) -> bool:
|
|
161
|
+
if not isinstance(actual, str) or not isinstance(expected, str):
|
|
162
|
+
return False
|
|
163
|
+
pattern = self._regex_cache.get(expected)
|
|
164
|
+
if pattern is None:
|
|
165
|
+
pattern = re.compile(expected)
|
|
166
|
+
self._regex_cache[expected] = pattern
|
|
167
|
+
return pattern.search(actual) is not None
|
|
168
|
+
|
|
169
|
+
@staticmethod
|
|
170
|
+
def _starts_with(actual: Any, expected: Any) -> bool:
|
|
171
|
+
return isinstance(actual, str) and isinstance(expected, str) and actual.startswith(expected)
|
|
172
|
+
|
|
173
|
+
@staticmethod
|
|
174
|
+
def _ends_with(actual: Any, expected: Any) -> bool:
|
|
175
|
+
return isinstance(actual, str) and isinstance(expected, str) and actual.endswith(expected)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
# Dispatch table for FieldMatcher.kind. Kept module-level so `_match_field`
|
|
179
|
+
# stays a tight if/else-free hot path.
|
|
180
|
+
_COMPARE: dict[str, Any] = {
|
|
181
|
+
"eq": lambda self, a, b: PolicyEvaluator._eq(a, b),
|
|
182
|
+
"ne": lambda self, a, b: PolicyEvaluator._ne(a, b),
|
|
183
|
+
"gt": lambda self, a, b: PolicyEvaluator._gt(a, b),
|
|
184
|
+
"gte": lambda self, a, b: PolicyEvaluator._gte(a, b),
|
|
185
|
+
"lt": lambda self, a, b: PolicyEvaluator._lt(a, b),
|
|
186
|
+
"lte": lambda self, a, b: PolicyEvaluator._lte(a, b),
|
|
187
|
+
"in": lambda self, a, b: PolicyEvaluator._in(a, b),
|
|
188
|
+
"not_in": lambda self, a, b: PolicyEvaluator._not_in(a, b),
|
|
189
|
+
"contains": lambda self, a, b: PolicyEvaluator._contains(a, b),
|
|
190
|
+
"regex": lambda self, a, b: self._regex(a, b),
|
|
191
|
+
"starts_with": lambda self, a, b: PolicyEvaluator._starts_with(a, b),
|
|
192
|
+
"ends_with": lambda self, a, b: PolicyEvaluator._ends_with(a, b),
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# Unused at runtime — re-export so tests can build `Rule(...)` directly.
|
|
197
|
+
__all__ = ["PolicyEvaluator", "Rule"]
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Bridge: AI Procurement Decision Card -> PolicyBundle.
|
|
3
|
+
|
|
4
|
+
The decision card spec encodes a buyer's posture toward a vendor as a `decision`
|
|
5
|
+
plus zero or more `conditions[]`. This module converts that human-authored
|
|
6
|
+
artifact into something a service can enforce automatically:
|
|
7
|
+
|
|
8
|
+
decision.status = "rejected*" -> single deny-all policy
|
|
9
|
+
decision.status = "approved" -> single allow-all policy
|
|
10
|
+
decision.status = "approved-with-conditions" -> per-condition policy +
|
|
11
|
+
fail-safe deny-all default
|
|
12
|
+
decision.status = "withdrawn" / "expired" / etc. -> single deny-all policy
|
|
13
|
+
|
|
14
|
+
For each condition we emit one Policy whose default_effect is `deny` and whose
|
|
15
|
+
single rule allows when the condition is *known to be satisfied* (signal field
|
|
16
|
+
`conditions_satisfied.{condition_id} == true` on the EvaluationContext).
|
|
17
|
+
Callers wire their own satisfaction signal into the context — e.g. result of a
|
|
18
|
+
verification job, a recent attestation timestamp, an external compliance check.
|
|
19
|
+
|
|
20
|
+
This is the most important hook in the package: it's what closes the loop
|
|
21
|
+
between the Kinetic Gain Protocol Suite (spec #11) and runtime enforcement.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
from .models import (
|
|
29
|
+
AlwaysMatcher,
|
|
30
|
+
FieldMatcher,
|
|
31
|
+
Policy,
|
|
32
|
+
PolicyBundle,
|
|
33
|
+
Rule,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
_REJECT_STATUSES = {"rejected", "rejected-with-remediation", "withdrawn", "expired"}
|
|
37
|
+
_APPROVE_STATUSES = {"approved"}
|
|
38
|
+
_CONDITIONAL_STATUSES = {"approved-with-conditions"}
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def policy_bundle_from_decision_card(card: dict[str, Any]) -> PolicyBundle:
|
|
42
|
+
"""
|
|
43
|
+
Build a PolicyBundle from a v0.1 Decision Card dict (matching the upstream
|
|
44
|
+
`decision-card.schema.json`).
|
|
45
|
+
|
|
46
|
+
The returned bundle is **runtime-evaluatable** — every policy's first
|
|
47
|
+
matching rule fires `allow` or `deny`. Conditions become policies whose
|
|
48
|
+
satisfaction is signalled by `conditions_satisfied.{id}` on the context.
|
|
49
|
+
"""
|
|
50
|
+
_validate_minimal_shape(card)
|
|
51
|
+
decision_id = card["decision_id"]
|
|
52
|
+
status = card["decision"]["status"]
|
|
53
|
+
vendor = card["subject"]["vendor_name"]
|
|
54
|
+
conditions = card.get("conditions") or []
|
|
55
|
+
source = f"decision-card:{decision_id}"
|
|
56
|
+
|
|
57
|
+
if status in _REJECT_STATUSES:
|
|
58
|
+
return _bundle(decision_id, source, [_deny_all_policy(decision_id, status, vendor)])
|
|
59
|
+
|
|
60
|
+
if status in _APPROVE_STATUSES:
|
|
61
|
+
return _bundle(decision_id, source, [_allow_all_policy(decision_id, vendor)])
|
|
62
|
+
|
|
63
|
+
if status in _CONDITIONAL_STATUSES:
|
|
64
|
+
if not conditions:
|
|
65
|
+
# The card itself should have failed validation upstream, but be
|
|
66
|
+
# defensive: an approved-with-conditions card with no conditions
|
|
67
|
+
# is treated as deny-all to fail safe.
|
|
68
|
+
return _bundle(decision_id, source, [_deny_all_policy(decision_id, status, vendor)])
|
|
69
|
+
policies = [_condition_policy(decision_id, c) for c in conditions]
|
|
70
|
+
return _bundle(decision_id, source, policies)
|
|
71
|
+
|
|
72
|
+
# Any unknown / pending status: fail safe.
|
|
73
|
+
return _bundle(decision_id, source, [_deny_all_policy(decision_id, status, vendor)])
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ---------------------------------------------------------------------------
|
|
77
|
+
# Builders
|
|
78
|
+
# ---------------------------------------------------------------------------
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _bundle(decision_id: str, source: str, policies: list[Policy]) -> PolicyBundle:
|
|
82
|
+
return PolicyBundle(
|
|
83
|
+
bundle_id=f"decision-card-{decision_id}",
|
|
84
|
+
version="0.1.0",
|
|
85
|
+
description=f"Generated from Decision Card {decision_id!r}.",
|
|
86
|
+
source=source,
|
|
87
|
+
policies=policies,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _allow_all_policy(decision_id: str, vendor: str) -> Policy:
|
|
92
|
+
return Policy(
|
|
93
|
+
id=f"{decision_id}__approved",
|
|
94
|
+
description=f"Vendor {vendor!r} is approved; all requests permitted.",
|
|
95
|
+
default_effect="allow",
|
|
96
|
+
rules=[
|
|
97
|
+
Rule(
|
|
98
|
+
id="approved-allow",
|
|
99
|
+
effect="allow",
|
|
100
|
+
when=AlwaysMatcher(),
|
|
101
|
+
description=f"Decision Card {decision_id!r} approved vendor {vendor!r}.",
|
|
102
|
+
tags=["approved"],
|
|
103
|
+
)
|
|
104
|
+
],
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _deny_all_policy(decision_id: str, status: str, vendor: str) -> Policy:
|
|
109
|
+
return Policy(
|
|
110
|
+
id=f"{decision_id}__{status}",
|
|
111
|
+
description=f"Vendor {vendor!r} is {status}; all requests denied.",
|
|
112
|
+
default_effect="deny",
|
|
113
|
+
rules=[
|
|
114
|
+
Rule(
|
|
115
|
+
id=f"{status}-deny",
|
|
116
|
+
effect="deny",
|
|
117
|
+
when=AlwaysMatcher(),
|
|
118
|
+
description=f"Decision Card {decision_id!r} status={status!r} for vendor {vendor!r}.",
|
|
119
|
+
tags=[status],
|
|
120
|
+
)
|
|
121
|
+
],
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _condition_policy(decision_id: str, condition: dict[str, Any]) -> Policy:
|
|
126
|
+
"""
|
|
127
|
+
Translate a single condition into a single policy:
|
|
128
|
+
- rule 1: ALLOW when conditions_satisfied.{id} is true
|
|
129
|
+
- default: DENY (fail-safe)
|
|
130
|
+
"""
|
|
131
|
+
cid = condition.get("id")
|
|
132
|
+
if not cid or not isinstance(cid, str):
|
|
133
|
+
raise ValueError("each condition must carry a non-empty `id`")
|
|
134
|
+
description = condition.get("description") or f"Condition {cid!r}"
|
|
135
|
+
return Policy(
|
|
136
|
+
id=f"{decision_id}__condition__{cid}",
|
|
137
|
+
description=description,
|
|
138
|
+
default_effect="deny",
|
|
139
|
+
rules=[
|
|
140
|
+
Rule(
|
|
141
|
+
id=f"{cid}-satisfied",
|
|
142
|
+
effect="allow",
|
|
143
|
+
when=FieldMatcher(
|
|
144
|
+
kind="eq",
|
|
145
|
+
field=f"conditions_satisfied.{cid}",
|
|
146
|
+
value=True,
|
|
147
|
+
),
|
|
148
|
+
description=description,
|
|
149
|
+
tags=["condition", cid],
|
|
150
|
+
)
|
|
151
|
+
],
|
|
152
|
+
)
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _validate_minimal_shape(card: dict[str, Any]) -> None:
|
|
156
|
+
for k in ("decision_id", "decision", "subject"):
|
|
157
|
+
if k not in card:
|
|
158
|
+
raise ValueError(f"Decision Card is missing required key {k!r}")
|
|
159
|
+
if "status" not in card["decision"]:
|
|
160
|
+
raise ValueError("Decision Card.decision is missing required key 'status'")
|
|
161
|
+
if "vendor_name" not in card["subject"]:
|
|
162
|
+
raise ValueError("Decision Card.subject is missing required key 'vendor_name'")
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""
|
|
2
|
+
File loaders for `PolicyBundle`.
|
|
3
|
+
|
|
4
|
+
Supports JSON and YAML so operators can author bundles in whichever format
|
|
5
|
+
their tooling already speaks. The loader is intentionally thin — the heavy
|
|
6
|
+
lifting is in `models.PolicyBundle.model_validate`.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
import yaml
|
|
16
|
+
|
|
17
|
+
from .models import PolicyBundle
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def load_bundle_from_string(raw: str, *, fmt: str | None = None) -> PolicyBundle:
|
|
21
|
+
"""
|
|
22
|
+
Parse a serialized bundle. `fmt` may be `"json"` or `"yaml"`; if omitted
|
|
23
|
+
the loader sniffs the leading character (`{` / `[` -> JSON, else YAML).
|
|
24
|
+
"""
|
|
25
|
+
fmt = (fmt or _sniff(raw)).lower()
|
|
26
|
+
if fmt == "json":
|
|
27
|
+
parsed: Any = json.loads(raw)
|
|
28
|
+
elif fmt in ("yaml", "yml"):
|
|
29
|
+
parsed = yaml.safe_load(raw)
|
|
30
|
+
else:
|
|
31
|
+
raise ValueError(f"unsupported bundle format: {fmt!r}")
|
|
32
|
+
return PolicyBundle.model_validate(parsed)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def load_bundle_from_file(path: str | Path) -> PolicyBundle:
|
|
36
|
+
p = Path(path)
|
|
37
|
+
raw = p.read_text(encoding="utf-8")
|
|
38
|
+
fmt = "yaml" if p.suffix.lower() in (".yaml", ".yml") else "json"
|
|
39
|
+
return load_bundle_from_string(raw, fmt=fmt)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _sniff(raw: str) -> str:
|
|
43
|
+
stripped = raw.lstrip()
|
|
44
|
+
if stripped.startswith(("{", "[")):
|
|
45
|
+
return "json"
|
|
46
|
+
return "yaml"
|
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Pydantic v2 models for policies, rules, matchers, and evaluation results.
|
|
3
|
+
|
|
4
|
+
A `PolicyBundle` is the top-level object — a named, versioned collection of
|
|
5
|
+
`Policy` objects. Each `Policy` is a list of `Rule` objects, evaluated in
|
|
6
|
+
declared order; the first match wins (deny rules trump allow rules at the
|
|
7
|
+
PolicyBundle level — see `EvaluationResult.combine`).
|
|
8
|
+
|
|
9
|
+
A `Rule` has:
|
|
10
|
+
- id stable identifier for telemetry
|
|
11
|
+
- effect "allow" | "deny"
|
|
12
|
+
- description optional human-readable note
|
|
13
|
+
- when a `Matcher` — the predicate over the EvaluationContext
|
|
14
|
+
|
|
15
|
+
A `Matcher` is one of the supported operators. Matchers are recursive: `all_of`,
|
|
16
|
+
`any_of`, and `not_` wrap child matchers, so the result is a small DSL that
|
|
17
|
+
covers ~95% of real-world request gates without having to ship a parser.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from typing import Any, Literal
|
|
23
|
+
|
|
24
|
+
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class StrictModel(BaseModel):
|
|
28
|
+
model_config = ConfigDict(extra="forbid")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
DecisionKind = Literal["allow", "deny", "not_applicable"]
|
|
32
|
+
Effect = Literal["allow", "deny"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
# Matchers — the DSL
|
|
37
|
+
# ---------------------------------------------------------------------------
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class FieldMatcher(StrictModel):
|
|
41
|
+
"""Compare a JSON-pointer-ish dotted path against a literal value."""
|
|
42
|
+
|
|
43
|
+
kind: Literal[
|
|
44
|
+
"eq",
|
|
45
|
+
"ne",
|
|
46
|
+
"gt",
|
|
47
|
+
"gte",
|
|
48
|
+
"lt",
|
|
49
|
+
"lte",
|
|
50
|
+
"in",
|
|
51
|
+
"not_in",
|
|
52
|
+
"contains",
|
|
53
|
+
"missing",
|
|
54
|
+
"exists",
|
|
55
|
+
"regex",
|
|
56
|
+
"starts_with",
|
|
57
|
+
"ends_with",
|
|
58
|
+
]
|
|
59
|
+
field: str = Field(..., min_length=1)
|
|
60
|
+
value: Any = None
|
|
61
|
+
|
|
62
|
+
@model_validator(mode="after")
|
|
63
|
+
def _check_value_required(self) -> FieldMatcher:
|
|
64
|
+
if self.kind in ("exists", "missing"):
|
|
65
|
+
return self
|
|
66
|
+
if self.value is None:
|
|
67
|
+
raise ValueError(f"matcher {self.kind!r} requires a `value`")
|
|
68
|
+
if self.kind in ("in", "not_in") and not isinstance(self.value, list):
|
|
69
|
+
raise ValueError(f"matcher {self.kind!r} requires `value` to be a list")
|
|
70
|
+
return self
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class AllOfMatcher(StrictModel):
|
|
74
|
+
kind: Literal["all_of"] = "all_of"
|
|
75
|
+
matchers: list[Matcher] = Field(..., min_length=1)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class AnyOfMatcher(StrictModel):
|
|
79
|
+
kind: Literal["any_of"] = "any_of"
|
|
80
|
+
matchers: list[Matcher] = Field(..., min_length=1)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class NotMatcher(StrictModel):
|
|
84
|
+
kind: Literal["not"] = "not"
|
|
85
|
+
matcher: Matcher
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class AlwaysMatcher(StrictModel):
|
|
89
|
+
"""Useful as a catch-all final rule (effectively the default)."""
|
|
90
|
+
|
|
91
|
+
kind: Literal["always"] = "always"
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
Matcher = FieldMatcher | AllOfMatcher | AnyOfMatcher | NotMatcher | AlwaysMatcher
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# Pydantic v2 forward-ref resolution for the recursive aliases.
|
|
98
|
+
AllOfMatcher.model_rebuild()
|
|
99
|
+
AnyOfMatcher.model_rebuild()
|
|
100
|
+
NotMatcher.model_rebuild()
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# ---------------------------------------------------------------------------
|
|
104
|
+
# Rules / policies / bundles
|
|
105
|
+
# ---------------------------------------------------------------------------
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
class Rule(StrictModel):
|
|
109
|
+
id: str = Field(..., min_length=1)
|
|
110
|
+
effect: Effect
|
|
111
|
+
when: Matcher
|
|
112
|
+
description: str | None = None
|
|
113
|
+
tags: list[str] | None = None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class Policy(StrictModel):
|
|
117
|
+
"""A named ordered list of rules. First match wins."""
|
|
118
|
+
|
|
119
|
+
id: str = Field(..., min_length=1)
|
|
120
|
+
description: str | None = None
|
|
121
|
+
default_effect: Effect = "deny"
|
|
122
|
+
rules: list[Rule] = Field(..., min_length=1)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class PolicyBundle(StrictModel):
|
|
126
|
+
"""The unit a service loads at startup. Versioned."""
|
|
127
|
+
|
|
128
|
+
bundle_id: str = Field(..., min_length=1)
|
|
129
|
+
version: str = "0.1.0"
|
|
130
|
+
description: str | None = None
|
|
131
|
+
source: str | None = Field(
|
|
132
|
+
default=None,
|
|
133
|
+
description="Where the bundle came from (a Decision Card id, URL, file path).",
|
|
134
|
+
)
|
|
135
|
+
policies: list[Policy] = Field(..., min_length=1)
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
# ---------------------------------------------------------------------------
|
|
139
|
+
# Evaluation
|
|
140
|
+
# ---------------------------------------------------------------------------
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class EvaluationContext(StrictModel):
|
|
144
|
+
"""
|
|
145
|
+
The thing rules are evaluated against. Free-form `data` so callers can pour
|
|
146
|
+
in whatever shape the rules expect (subject, action, resource, claims, ...).
|
|
147
|
+
"""
|
|
148
|
+
|
|
149
|
+
data: dict[str, Any] = Field(default_factory=dict)
|
|
150
|
+
subject: dict[str, Any] | None = None
|
|
151
|
+
action: str | None = None
|
|
152
|
+
resource: dict[str, Any] | None = None
|
|
153
|
+
|
|
154
|
+
def lookup(self, path: str) -> Any:
|
|
155
|
+
"""
|
|
156
|
+
Dotted-path lookup over the merged context. Returns the sentinel
|
|
157
|
+
`_MISSING` when any segment doesn't exist, so `exists` / `missing`
|
|
158
|
+
matchers behave correctly.
|
|
159
|
+
"""
|
|
160
|
+
merged: dict[str, Any] = {**self.data}
|
|
161
|
+
if self.subject is not None:
|
|
162
|
+
merged["subject"] = self.subject
|
|
163
|
+
if self.action is not None:
|
|
164
|
+
merged["action"] = self.action
|
|
165
|
+
if self.resource is not None:
|
|
166
|
+
merged["resource"] = self.resource
|
|
167
|
+
|
|
168
|
+
cur: Any = merged
|
|
169
|
+
for segment in path.split("."):
|
|
170
|
+
if isinstance(cur, dict) and segment in cur:
|
|
171
|
+
cur = cur[segment]
|
|
172
|
+
elif isinstance(cur, list):
|
|
173
|
+
try:
|
|
174
|
+
cur = cur[int(segment)]
|
|
175
|
+
except (ValueError, IndexError):
|
|
176
|
+
return _MISSING
|
|
177
|
+
else:
|
|
178
|
+
return _MISSING
|
|
179
|
+
return cur
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
_MISSING: Any = object()
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
class Decision(StrictModel):
|
|
186
|
+
kind: DecisionKind
|
|
187
|
+
matched_policy_id: str | None = None
|
|
188
|
+
matched_rule_id: str | None = None
|
|
189
|
+
reason: str | None = None
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
class EvaluationResult(StrictModel):
|
|
193
|
+
"""
|
|
194
|
+
Bundle-wide result. Per-policy decisions are kept in `policy_decisions` so
|
|
195
|
+
operators can see *why* the final outcome happened. Combining rule:
|
|
196
|
+
|
|
197
|
+
- If ANY policy returns `deny`, the bundle returns `deny`.
|
|
198
|
+
- Else if ANY policy returns `allow`, the bundle returns `allow`.
|
|
199
|
+
- Else `not_applicable`.
|
|
200
|
+
"""
|
|
201
|
+
|
|
202
|
+
bundle_id: str
|
|
203
|
+
decision: Decision
|
|
204
|
+
policy_decisions: list[Decision]
|
|
205
|
+
|
|
206
|
+
@classmethod
|
|
207
|
+
def combine(cls, bundle_id: str, policy_decisions: list[Decision]) -> EvaluationResult:
|
|
208
|
+
deny = next((d for d in policy_decisions if d.kind == "deny"), None)
|
|
209
|
+
if deny is not None:
|
|
210
|
+
return cls(bundle_id=bundle_id, decision=deny, policy_decisions=policy_decisions)
|
|
211
|
+
allow = next((d for d in policy_decisions if d.kind == "allow"), None)
|
|
212
|
+
if allow is not None:
|
|
213
|
+
return cls(bundle_id=bundle_id, decision=allow, policy_decisions=policy_decisions)
|
|
214
|
+
return cls(
|
|
215
|
+
bundle_id=bundle_id,
|
|
216
|
+
decision=Decision(kind="not_applicable", reason="no policy applied"),
|
|
217
|
+
policy_decisions=policy_decisions,
|
|
218
|
+
)
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: policy-as-code-engine
|
|
3
|
+
Version: 0.1.1
|
|
4
|
+
Summary: Declarative policy-as-code evaluator. JSON/YAML rules + Python predicates over arbitrary context objects. Enforces AI Procurement Decision Card conditions at request time. Optional audit-stream-py integration via AUDIT_STREAM_URL.
|
|
5
|
+
Project-URL: Homepage, https://github.com/mizcausevic-dev/policy-as-code-engine
|
|
6
|
+
Project-URL: Repository, https://github.com/mizcausevic-dev/policy-as-code-engine
|
|
7
|
+
Project-URL: Issues, https://github.com/mizcausevic-dev/policy-as-code-engine/issues
|
|
8
|
+
Project-URL: Decision Card spec, https://github.com/mizcausevic-dev/ai-procurement-decision-spec
|
|
9
|
+
Project-URL: Author Site, https://kineticgain.com/
|
|
10
|
+
Author-email: Miz Causevic <miz@kineticgain.com>
|
|
11
|
+
License: MIT
|
|
12
|
+
License-File: LICENSE
|
|
13
|
+
Keywords: ai-governance,decision-intelligence,fastapi,kinetic-gain-protocol-suite,policy,policy-as-code,rules-engine
|
|
14
|
+
Classifier: Development Status :: 4 - Beta
|
|
15
|
+
Classifier: Framework :: FastAPI
|
|
16
|
+
Classifier: Intended Audience :: Developers
|
|
17
|
+
Classifier: Intended Audience :: System Administrators
|
|
18
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
19
|
+
Classifier: Operating System :: OS Independent
|
|
20
|
+
Classifier: Programming Language :: Python :: 3
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
23
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
24
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
25
|
+
Classifier: Typing :: Typed
|
|
26
|
+
Requires-Python: >=3.11
|
|
27
|
+
Requires-Dist: httpx>=0.27
|
|
28
|
+
Requires-Dist: pydantic>=2.7
|
|
29
|
+
Requires-Dist: pyyaml>=6.0
|
|
30
|
+
Provides-Extra: api
|
|
31
|
+
Requires-Dist: fastapi>=0.115; extra == 'api'
|
|
32
|
+
Requires-Dist: uvicorn[standard]>=0.30; extra == 'api'
|
|
33
|
+
Provides-Extra: dev
|
|
34
|
+
Requires-Dist: fastapi>=0.115; extra == 'dev'
|
|
35
|
+
Requires-Dist: httpx>=0.27; extra == 'dev'
|
|
36
|
+
Requires-Dist: mypy>=1.11; extra == 'dev'
|
|
37
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
38
|
+
Requires-Dist: pytest>=8.2; extra == 'dev'
|
|
39
|
+
Requires-Dist: ruff>=0.6; extra == 'dev'
|
|
40
|
+
Requires-Dist: types-pyyaml>=6.0; extra == 'dev'
|
|
41
|
+
Requires-Dist: uvicorn[standard]>=0.30; extra == 'dev'
|
|
42
|
+
Description-Content-Type: text/markdown
|
|
43
|
+
|
|
44
|
+
# policy-as-code-engine
|
|
45
|
+
|
|
46
|
+
[](https://github.com/mizcausevic-dev/policy-as-code-engine/actions/workflows/ci.yml)
|
|
47
|
+
[](https://www.python.org/)
|
|
48
|
+
[](LICENSE)
|
|
49
|
+
|
|
50
|
+
**Declarative policy-as-code evaluator for Python services.** JSON/YAML rules → first-match-wins evaluation → structured allow/deny decision with the matching rule and the reason. Cheap to embed; ships with a FastAPI surface; **pairs directly with [`procurement-decision-api`](https://github.com/mizcausevic-dev/procurement-decision-api)** so the same Decision Card that records a buyer's posture also becomes the runtime gate that enforces it.
|
|
51
|
+
|
|
52
|
+
---
|
|
53
|
+
|
|
54
|
+
## Why
|
|
55
|
+
|
|
56
|
+
Most policy engines either ask you to learn a DSL (Rego, Cedar) or hand you a dictionary-of-lambdas and call it a library. Neither is the right shape when the *source of truth* is a JSON document a human signed off on. This engine:
|
|
57
|
+
|
|
58
|
+
1. **Reads JSON/YAML bundles.** No DSL. The matcher tree is the policy.
|
|
59
|
+
2. **Returns *why*, not just *what*.** Every decision carries the matched policy + rule + reason. Operators get a real audit log on each evaluation.
|
|
60
|
+
3. **Bridges to the Kinetic Gain Protocol Suite.** A single endpoint turns an AI Procurement Decision Card into a runtime-enforceable `PolicyBundle` — approve, reject, or approve-with-conditions all map to concrete allow/deny logic.
|
|
61
|
+
|
|
62
|
+
---
|
|
63
|
+
|
|
64
|
+
## Install
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
pip install policy-as-code-engine
|
|
68
|
+
# with the FastAPI surface:
|
|
69
|
+
pip install "policy-as-code-engine[api]"
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
Python 3.11+. Runtime deps: `pydantic` + `PyYAML`.
|
|
73
|
+
|
|
74
|
+
---
|
|
75
|
+
|
|
76
|
+
## Library quickstart
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from policy_as_code_engine import (
|
|
80
|
+
EvaluationContext,
|
|
81
|
+
PolicyBundle,
|
|
82
|
+
PolicyEvaluator,
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
bundle = PolicyBundle.model_validate({
|
|
86
|
+
"bundle_id": "edu-gate",
|
|
87
|
+
"policies": [{
|
|
88
|
+
"id": "writes-require-admin",
|
|
89
|
+
"default_effect": "deny",
|
|
90
|
+
"rules": [
|
|
91
|
+
{
|
|
92
|
+
"id": "admin-writes",
|
|
93
|
+
"effect": "allow",
|
|
94
|
+
"when": {
|
|
95
|
+
"kind": "all_of",
|
|
96
|
+
"matchers": [
|
|
97
|
+
{"kind": "in", "field": "action", "value": ["create", "update", "delete"]},
|
|
98
|
+
{"kind": "eq", "field": "subject.role", "value": "admin"},
|
|
99
|
+
],
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
],
|
|
103
|
+
}],
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
ctx = EvaluationContext(
|
|
107
|
+
subject={"id": "u-42", "role": "admin"},
|
|
108
|
+
action="update",
|
|
109
|
+
resource={"id": "doc-7"},
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
result = PolicyEvaluator().evaluate(bundle, ctx)
|
|
113
|
+
print(result.decision.kind) # "allow"
|
|
114
|
+
print(result.decision.matched_rule_id) # "admin-writes"
|
|
115
|
+
print(result.decision.reason) # "matched rule 'admin-writes'"
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`result.policy_decisions` carries every per-policy outcome — drop it straight into your audit log.
|
|
119
|
+
|
|
120
|
+
---
|
|
121
|
+
|
|
122
|
+
## Bundle DSL
|
|
123
|
+
|
|
124
|
+
A bundle is a small recursive structure. Matchers compose; rules ordered.
|
|
125
|
+
|
|
126
|
+
### Field matchers
|
|
127
|
+
|
|
128
|
+
| Kind | Notes |
|
|
129
|
+
| --------------- | --- |
|
|
130
|
+
| `eq` / `ne` | Strict equality. |
|
|
131
|
+
| `gt` / `gte` / `lt` / `lte` | Comparison; returns `false` on incompatible types (won't raise). |
|
|
132
|
+
| `in` / `not_in` | `value` must be a list. |
|
|
133
|
+
| `contains` | Works against strings, lists, sets, dicts. |
|
|
134
|
+
| `exists` / `missing` | No `value`. Operates against the dotted-path resolver. |
|
|
135
|
+
| `regex` | Compiled patterns are cached per-evaluator. |
|
|
136
|
+
| `starts_with` / `ends_with` | String-only. |
|
|
137
|
+
|
|
138
|
+
### Composite matchers
|
|
139
|
+
|
|
140
|
+
| Kind | Children | Truth |
|
|
141
|
+
| -------- | -------- | --- |
|
|
142
|
+
| `all_of` | `matchers: [...]` | All children true. |
|
|
143
|
+
| `any_of` | `matchers: [...]` | At least one child true. |
|
|
144
|
+
| `not` | `matcher: {...}` | Inverts the child. |
|
|
145
|
+
| `always` | — | Always true. Useful as a final catch-all. |
|
|
146
|
+
|
|
147
|
+
### Dotted paths
|
|
148
|
+
|
|
149
|
+
The resolver looks at the merged context (`data` + `subject` + `action` + `resource`):
|
|
150
|
+
|
|
151
|
+
```
|
|
152
|
+
subject.role
|
|
153
|
+
resource.tags.0 # list index
|
|
154
|
+
data.conditions_satisfied.dpa-signed
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
Missing segments produce a `_MISSING` sentinel — `exists` / `missing` matchers see it; every other matcher returns `false`.
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## FastAPI surface
|
|
162
|
+
|
|
163
|
+
```bash
|
|
164
|
+
pip install "policy-as-code-engine[api]"
|
|
165
|
+
python -m policy_as_code_engine # binds 0.0.0.0:8089 by default
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
| Method | Path | What it does |
|
|
169
|
+
| --- | --- | --- |
|
|
170
|
+
| GET | `/healthz` | Liveness probe. |
|
|
171
|
+
| GET | `/` | Service info. |
|
|
172
|
+
| POST | `/bundles` | Register a `PolicyBundle` in memory. |
|
|
173
|
+
| GET | `/bundles` | List registered bundle IDs. |
|
|
174
|
+
| GET | `/bundles/{bundle_id}` | Inspect a registered bundle. |
|
|
175
|
+
| POST | `/bundles/{bundle_id}/evaluate` | Evaluate a stored bundle against an `EvaluationContext`. |
|
|
176
|
+
| POST | `/evaluate` | One-shot. Bundle + context in, decision out. |
|
|
177
|
+
| POST | `/bundles/from-decision-card` | **The cross-ecosystem hook.** Turn a Kinetic Gain Procurement Decision Card into a `PolicyBundle` and register it. |
|
|
178
|
+
|
|
179
|
+
---
|
|
180
|
+
|
|
181
|
+
## The cross-ecosystem hook
|
|
182
|
+
|
|
183
|
+
The headline feature. An AI Procurement Decision Card is the buyer-side record that says "we evaluated this vendor and our position is X." This engine turns that human-authored artifact into a runtime gate, mechanically.
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
curl -X POST http://localhost:8089/bundles/from-decision-card \
|
|
187
|
+
-H 'Content-Type: application/json' \
|
|
188
|
+
-d @decision-card.json
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Mapping:
|
|
192
|
+
|
|
193
|
+
| Decision Card status | Resulting bundle |
|
|
194
|
+
| --- | --- |
|
|
195
|
+
| `approved` | Single `allow-all` policy. |
|
|
196
|
+
| `rejected` · `rejected-with-remediation` · `withdrawn` · `expired` · `pending` | Single `deny-all` policy (fail safe). |
|
|
197
|
+
| `approved-with-conditions` | One policy *per* condition. Each policy `allow`s only when `conditions_satisfied.{condition_id}` is `true` in the evaluation context; `deny` otherwise. The bundle combiner does deny-trumps-allow, so **every** condition must be satisfied to allow. |
|
|
198
|
+
|
|
199
|
+
Wire your own satisfaction signal — DPA verifier, bias-audit freshness check, attestation timestamp — into the context, and the bundle does the rest.
|
|
200
|
+
|
|
201
|
+
```python
|
|
202
|
+
from policy_as_code_engine import (
|
|
203
|
+
EvaluationContext,
|
|
204
|
+
PolicyEvaluator,
|
|
205
|
+
policy_bundle_from_decision_card,
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
card = {...} # POST /decisions/draft output from procurement-decision-api
|
|
209
|
+
bundle = policy_bundle_from_decision_card(card)
|
|
210
|
+
|
|
211
|
+
ctx = EvaluationContext(
|
|
212
|
+
subject={"id": "u-1"},
|
|
213
|
+
action="enroll",
|
|
214
|
+
data={
|
|
215
|
+
"conditions_satisfied": {
|
|
216
|
+
"dpa-signed": True,
|
|
217
|
+
"bias-audit-fresh": True,
|
|
218
|
+
}
|
|
219
|
+
},
|
|
220
|
+
)
|
|
221
|
+
|
|
222
|
+
decision = PolicyEvaluator().evaluate(bundle, ctx).decision
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
## CLI
|
|
228
|
+
|
|
229
|
+
```bash
|
|
230
|
+
python -m policy_as_code_engine eval examples/example-bundle.yaml examples/example-context.json
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
Prints the full `EvaluationResult` as JSON. Exits non-zero on `deny`.
|
|
234
|
+
|
|
235
|
+
---
|
|
236
|
+
|
|
237
|
+
## How decisions combine
|
|
238
|
+
|
|
239
|
+
Inside a single policy: **first matching rule wins**, otherwise `default_effect`.
|
|
240
|
+
|
|
241
|
+
Across the bundle:
|
|
242
|
+
|
|
243
|
+
```
|
|
244
|
+
deny -> deny (any policy denies => bundle denies)
|
|
245
|
+
allow -> allow (otherwise, any allow => bundle allows)
|
|
246
|
+
neither -> not_applicable
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
Per-policy decisions are always returned — useful for "we denied because of policy B, but A would have allowed" audit narratives.
|
|
250
|
+
|
|
251
|
+
---
|
|
252
|
+
|
|
253
|
+
## Tests
|
|
254
|
+
|
|
255
|
+
```bash
|
|
256
|
+
pip install -e ".[dev]"
|
|
257
|
+
ruff check src tests && ruff format --check src tests
|
|
258
|
+
mypy src
|
|
259
|
+
pytest -v
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
CI matrix runs Python 3.11 / 3.12 / 3.13.
|
|
263
|
+
|
|
264
|
+
---
|
|
265
|
+
|
|
266
|
+
## Related in this ecosystem
|
|
267
|
+
|
|
268
|
+
- **[procurement-decision-api](https://github.com/mizcausevic-dev/procurement-decision-api)** — drafts the Decision Cards that this engine enforces.
|
|
269
|
+
- **[ai-procurement-decision-spec](https://github.com/mizcausevic-dev/ai-procurement-decision-spec)** — the v0.1 schema.
|
|
270
|
+
- **[slo-budget-tracker](https://github.com/mizcausevic-dev/slo-budget-tracker)** — error-budget tracker that you can wire into the same FastAPI app.
|
|
271
|
+
- **[reliability-toolkit-rs](https://github.com/mizcausevic-dev/reliability-toolkit-rs)** — Rust async reliability primitives.
|
|
272
|
+
- More at [kineticgain.com](https://kineticgain.com/).
|
|
273
|
+
|
|
274
|
+
---
|
|
275
|
+
|
|
276
|
+
## License
|
|
277
|
+
|
|
278
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
policy_as_code_engine/__init__.py,sha256=YdLkvc2VuV3HU8BmrOSrHvABbNjUtaZxUZq4sJBllrg,1399
|
|
2
|
+
policy_as_code_engine/__main__.py,sha256=HArqi8lokgiY41lvNeOmZkbTidToHqscch75L9c_iF8,1493
|
|
3
|
+
policy_as_code_engine/app.py,sha256=rJJZHZnw47nFIUaYSbdseYOwp1ag9sRoO62m8LrI4Yo,7582
|
|
4
|
+
policy_as_code_engine/audit_stream.py,sha256=3Y0YvgvmQzpUVlR9WpWb0bH7KidMHY_ap9OXjdMUqiY,2376
|
|
5
|
+
policy_as_code_engine/evaluator.py,sha256=4PufA3teQXr_6J5rl8pNk_bNzlJ14uyDHO_3HlxyVxs,7161
|
|
6
|
+
policy_as_code_engine/from_decision_card.py,sha256=uYVLCOHHrgIsCbx6yse5p5t5QTMV5JAfCO2No_u8IlM,6155
|
|
7
|
+
policy_as_code_engine/loader.py,sha256=e0ZyWe8qO5j2X3vGaTTBJoFMfuiEuBMZNK2Ep1iPFi8,1313
|
|
8
|
+
policy_as_code_engine/models.py,sha256=ff418E8NjpWu5IuhdHa3awaO7Q-1iSfg8B4pAsOVZu0,6805
|
|
9
|
+
policy_as_code_engine-0.1.1.dist-info/METADATA,sha256=PiIeJwq_ziwTkJdo3wTpTVpjDX09h4f_X9z5Jdcub1Q,10222
|
|
10
|
+
policy_as_code_engine-0.1.1.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
|
|
11
|
+
policy_as_code_engine-0.1.1.dist-info/entry_points.txt,sha256=M4F2M0hYy4uU4DgGDelJczeh4homL8wEV5QXxCQ8VZA,68
|
|
12
|
+
policy_as_code_engine-0.1.1.dist-info/licenses/LICENSE,sha256=9REhXrEWXVgwUgFKw9H__Zaboj9yGUNNVJeYTlmqvG8,1084
|
|
13
|
+
policy_as_code_engine-0.1.1.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Miz Causevic / Kinetic Gain
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|