spanforge 1.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.
- spanforge/__init__.py +815 -0
- spanforge/_ansi.py +93 -0
- spanforge/_batch_exporter.py +409 -0
- spanforge/_cli.py +2094 -0
- spanforge/_cli_audit.py +639 -0
- spanforge/_cli_compliance.py +711 -0
- spanforge/_cli_cost.py +243 -0
- spanforge/_cli_ops.py +791 -0
- spanforge/_cli_phase11.py +356 -0
- spanforge/_hooks.py +337 -0
- spanforge/_server.py +1708 -0
- spanforge/_span.py +1036 -0
- spanforge/_store.py +288 -0
- spanforge/_stream.py +664 -0
- spanforge/_trace.py +335 -0
- spanforge/_tracer.py +254 -0
- spanforge/actor.py +141 -0
- spanforge/alerts.py +469 -0
- spanforge/auto.py +464 -0
- spanforge/baseline.py +335 -0
- spanforge/cache.py +635 -0
- spanforge/compliance.py +325 -0
- spanforge/config.py +532 -0
- spanforge/consent.py +228 -0
- spanforge/consumer.py +377 -0
- spanforge/core/__init__.py +5 -0
- spanforge/core/compliance_mapping.py +1254 -0
- spanforge/cost.py +600 -0
- spanforge/debug.py +548 -0
- spanforge/deprecations.py +205 -0
- spanforge/drift.py +482 -0
- spanforge/egress.py +58 -0
- spanforge/eval.py +648 -0
- spanforge/event.py +1064 -0
- spanforge/exceptions.py +240 -0
- spanforge/explain.py +178 -0
- spanforge/export/__init__.py +69 -0
- spanforge/export/append_only.py +337 -0
- spanforge/export/cloud.py +357 -0
- spanforge/export/datadog.py +497 -0
- spanforge/export/grafana.py +320 -0
- spanforge/export/jsonl.py +195 -0
- spanforge/export/openinference.py +158 -0
- spanforge/export/otel_bridge.py +294 -0
- spanforge/export/otlp.py +811 -0
- spanforge/export/otlp_bridge.py +233 -0
- spanforge/export/redis_backend.py +282 -0
- spanforge/export/siem_schema.py +98 -0
- spanforge/export/siem_splunk.py +264 -0
- spanforge/export/siem_syslog.py +212 -0
- spanforge/export/webhook.py +299 -0
- spanforge/exporters/__init__.py +30 -0
- spanforge/exporters/console.py +271 -0
- spanforge/exporters/jsonl.py +144 -0
- spanforge/exporters/sqlite.py +142 -0
- spanforge/gate.py +1150 -0
- spanforge/governance.py +181 -0
- spanforge/hitl.py +295 -0
- spanforge/http.py +187 -0
- spanforge/inspect.py +427 -0
- spanforge/integrations/__init__.py +45 -0
- spanforge/integrations/_pricing.py +280 -0
- spanforge/integrations/anthropic.py +388 -0
- spanforge/integrations/azure_openai.py +133 -0
- spanforge/integrations/bedrock.py +292 -0
- spanforge/integrations/crewai.py +251 -0
- spanforge/integrations/gemini.py +351 -0
- spanforge/integrations/groq.py +442 -0
- spanforge/integrations/langchain.py +349 -0
- spanforge/integrations/langgraph.py +306 -0
- spanforge/integrations/llamaindex.py +373 -0
- spanforge/integrations/ollama.py +287 -0
- spanforge/integrations/openai.py +368 -0
- spanforge/integrations/together.py +483 -0
- spanforge/io.py +214 -0
- spanforge/lint.py +322 -0
- spanforge/metrics.py +417 -0
- spanforge/metrics_export.py +343 -0
- spanforge/migrate.py +402 -0
- spanforge/model_registry.py +278 -0
- spanforge/models.py +389 -0
- spanforge/namespaces/__init__.py +254 -0
- spanforge/namespaces/audit.py +256 -0
- spanforge/namespaces/cache.py +237 -0
- spanforge/namespaces/chain.py +77 -0
- spanforge/namespaces/confidence.py +72 -0
- spanforge/namespaces/consent.py +92 -0
- spanforge/namespaces/cost.py +179 -0
- spanforge/namespaces/decision.py +143 -0
- spanforge/namespaces/diff.py +157 -0
- spanforge/namespaces/drift.py +80 -0
- spanforge/namespaces/eval_.py +251 -0
- spanforge/namespaces/feedback.py +241 -0
- spanforge/namespaces/fence.py +193 -0
- spanforge/namespaces/guard.py +105 -0
- spanforge/namespaces/hitl.py +91 -0
- spanforge/namespaces/latency.py +72 -0
- spanforge/namespaces/prompt.py +190 -0
- spanforge/namespaces/redact.py +173 -0
- spanforge/namespaces/retrieval.py +379 -0
- spanforge/namespaces/runtime_governance.py +494 -0
- spanforge/namespaces/template.py +208 -0
- spanforge/namespaces/tool_call.py +77 -0
- spanforge/namespaces/trace.py +1029 -0
- spanforge/normalizer.py +171 -0
- spanforge/plugins.py +82 -0
- spanforge/presidio_backend.py +349 -0
- spanforge/processor.py +258 -0
- spanforge/prompt_registry.py +418 -0
- spanforge/py.typed +0 -0
- spanforge/redact.py +914 -0
- spanforge/regression.py +192 -0
- spanforge/runtime_policy.py +159 -0
- spanforge/sampling.py +511 -0
- spanforge/schema.py +183 -0
- spanforge/schemas/v1.0/schema.json +170 -0
- spanforge/schemas/v2.0/schema.json +536 -0
- spanforge/sdk/__init__.py +625 -0
- spanforge/sdk/_base.py +584 -0
- spanforge/sdk/_base.pyi +71 -0
- spanforge/sdk/_exceptions.py +1096 -0
- spanforge/sdk/_types.py +2184 -0
- spanforge/sdk/alert.py +1514 -0
- spanforge/sdk/alert.pyi +56 -0
- spanforge/sdk/audit.py +1196 -0
- spanforge/sdk/audit.pyi +67 -0
- spanforge/sdk/cec.py +1215 -0
- spanforge/sdk/cec.pyi +37 -0
- spanforge/sdk/config.py +641 -0
- spanforge/sdk/config.pyi +55 -0
- spanforge/sdk/enterprise.py +714 -0
- spanforge/sdk/enterprise.pyi +79 -0
- spanforge/sdk/explain.py +170 -0
- spanforge/sdk/fallback.py +432 -0
- spanforge/sdk/feedback.py +351 -0
- spanforge/sdk/gate.py +874 -0
- spanforge/sdk/gate.pyi +51 -0
- spanforge/sdk/identity.py +2114 -0
- spanforge/sdk/identity.pyi +47 -0
- spanforge/sdk/lineage.py +175 -0
- spanforge/sdk/observe.py +1065 -0
- spanforge/sdk/observe.pyi +50 -0
- spanforge/sdk/operator.py +338 -0
- spanforge/sdk/pii.py +1473 -0
- spanforge/sdk/pii.pyi +119 -0
- spanforge/sdk/pipelines.py +458 -0
- spanforge/sdk/pipelines.pyi +39 -0
- spanforge/sdk/policy.py +930 -0
- spanforge/sdk/rag.py +594 -0
- spanforge/sdk/rbac.py +280 -0
- spanforge/sdk/registry.py +430 -0
- spanforge/sdk/registry.pyi +46 -0
- spanforge/sdk/scope.py +279 -0
- spanforge/sdk/secrets.py +293 -0
- spanforge/sdk/secrets.pyi +25 -0
- spanforge/sdk/security.py +560 -0
- spanforge/sdk/security.pyi +57 -0
- spanforge/sdk/trust.py +472 -0
- spanforge/sdk/trust.pyi +41 -0
- spanforge/secrets.py +799 -0
- spanforge/signing.py +1179 -0
- spanforge/stats.py +100 -0
- spanforge/stream.py +560 -0
- spanforge/testing.py +378 -0
- spanforge/testing_mocks.py +1052 -0
- spanforge/trace.py +199 -0
- spanforge/types.py +696 -0
- spanforge/ulid.py +300 -0
- spanforge/validate.py +379 -0
- spanforge-1.0.0.dist-info/METADATA +1509 -0
- spanforge-1.0.0.dist-info/RECORD +174 -0
- spanforge-1.0.0.dist-info/WHEEL +4 -0
- spanforge-1.0.0.dist-info/entry_points.txt +5 -0
- spanforge-1.0.0.dist-info/licenses/LICENSE +128 -0
spanforge/sdk/policy.py
ADDED
|
@@ -0,0 +1,930 @@
|
|
|
1
|
+
"""spanforge.sdk.policy - Runtime policy engine for GA governance controls."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
import threading
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from spanforge.runtime_policy import RuntimePolicyBundle, RuntimePolicyRule
|
|
11
|
+
from spanforge.sdk._base import SFClientConfig, SFServiceClient
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"RuntimePolicyComparisonResult",
|
|
15
|
+
"RuntimePolicyDecision",
|
|
16
|
+
"RuntimePolicyReplayResult",
|
|
17
|
+
"RuntimePolicyReviewRecord",
|
|
18
|
+
"RuntimePolicySimulationResult",
|
|
19
|
+
"RuntimePolicyStatusInfo",
|
|
20
|
+
"SFPolicyClient",
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
_ALLOWED_DECISION_ACTIONS = frozenset({"allow", "allow+log", "redact", "block", "human_review"})
|
|
24
|
+
_ALLOWED_REVIEW_CLASSIFICATIONS = frozenset({"false_positive", "true_positive", "needs_tuning"})
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass
|
|
28
|
+
class RuntimePolicyDecision:
|
|
29
|
+
"""Auditable result of one runtime policy evaluation."""
|
|
30
|
+
|
|
31
|
+
decision_id: str
|
|
32
|
+
policy_id: str
|
|
33
|
+
policy_version: str
|
|
34
|
+
environment: str
|
|
35
|
+
service: str
|
|
36
|
+
control: str
|
|
37
|
+
action: str
|
|
38
|
+
allowed: bool
|
|
39
|
+
evaluated_at: str
|
|
40
|
+
reason: str
|
|
41
|
+
rule_id: str | None = None
|
|
42
|
+
threshold: float | None = None
|
|
43
|
+
observed_value: float | None = None
|
|
44
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
45
|
+
|
|
46
|
+
def __post_init__(self) -> None:
|
|
47
|
+
if not self.decision_id:
|
|
48
|
+
raise ValueError("RuntimePolicyDecision.decision_id must be non-empty")
|
|
49
|
+
if not self.policy_id:
|
|
50
|
+
raise ValueError("RuntimePolicyDecision.policy_id must be non-empty")
|
|
51
|
+
if not self.policy_version:
|
|
52
|
+
raise ValueError("RuntimePolicyDecision.policy_version must be non-empty")
|
|
53
|
+
if not self.environment:
|
|
54
|
+
raise ValueError("RuntimePolicyDecision.environment must be non-empty")
|
|
55
|
+
if not self.service:
|
|
56
|
+
raise ValueError("RuntimePolicyDecision.service must be non-empty")
|
|
57
|
+
if not self.control:
|
|
58
|
+
raise ValueError("RuntimePolicyDecision.control must be non-empty")
|
|
59
|
+
if self.action not in _ALLOWED_DECISION_ACTIONS:
|
|
60
|
+
raise ValueError(
|
|
61
|
+
f"RuntimePolicyDecision.action must be one of {sorted(_ALLOWED_DECISION_ACTIONS)}"
|
|
62
|
+
)
|
|
63
|
+
if not self.evaluated_at:
|
|
64
|
+
raise ValueError("RuntimePolicyDecision.evaluated_at must be non-empty")
|
|
65
|
+
if not self.reason:
|
|
66
|
+
raise ValueError("RuntimePolicyDecision.reason must be non-empty")
|
|
67
|
+
if self.threshold is not None and not (0.0 <= self.threshold <= 1.0):
|
|
68
|
+
raise ValueError("RuntimePolicyDecision.threshold must be in [0.0, 1.0]")
|
|
69
|
+
if self.observed_value is not None and not (0.0 <= self.observed_value <= 1.0):
|
|
70
|
+
raise ValueError("RuntimePolicyDecision.observed_value must be in [0.0, 1.0]")
|
|
71
|
+
|
|
72
|
+
def to_dict(self) -> dict[str, Any]:
|
|
73
|
+
data: dict[str, Any] = {
|
|
74
|
+
"decision_id": self.decision_id,
|
|
75
|
+
"policy_id": self.policy_id,
|
|
76
|
+
"policy_version": self.policy_version,
|
|
77
|
+
"environment": self.environment,
|
|
78
|
+
"service": self.service,
|
|
79
|
+
"control": self.control,
|
|
80
|
+
"action": self.action,
|
|
81
|
+
"allowed": self.allowed,
|
|
82
|
+
"evaluated_at": self.evaluated_at,
|
|
83
|
+
"reason": self.reason,
|
|
84
|
+
}
|
|
85
|
+
if self.rule_id is not None:
|
|
86
|
+
data["rule_id"] = self.rule_id
|
|
87
|
+
if self.threshold is not None:
|
|
88
|
+
data["threshold"] = self.threshold
|
|
89
|
+
if self.observed_value is not None:
|
|
90
|
+
data["observed_value"] = self.observed_value
|
|
91
|
+
if self.metadata:
|
|
92
|
+
data["metadata"] = self.metadata
|
|
93
|
+
return data
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class RuntimePolicySimulationResult:
|
|
98
|
+
"""One non-production policy simulation result."""
|
|
99
|
+
|
|
100
|
+
simulation_id: str
|
|
101
|
+
trace_id: str
|
|
102
|
+
environment: str
|
|
103
|
+
service: str
|
|
104
|
+
control: str
|
|
105
|
+
candidate_policy_id: str
|
|
106
|
+
candidate_policy_version: str
|
|
107
|
+
simulated_at: str
|
|
108
|
+
candidate_decision: RuntimePolicyDecision
|
|
109
|
+
production_decision: RuntimePolicyDecision | None = None
|
|
110
|
+
changed: bool = False
|
|
111
|
+
|
|
112
|
+
def to_dict(self) -> dict[str, Any]:
|
|
113
|
+
data: dict[str, Any] = {
|
|
114
|
+
"simulation_id": self.simulation_id,
|
|
115
|
+
"trace_id": self.trace_id,
|
|
116
|
+
"environment": self.environment,
|
|
117
|
+
"service": self.service,
|
|
118
|
+
"control": self.control,
|
|
119
|
+
"candidate_policy_id": self.candidate_policy_id,
|
|
120
|
+
"candidate_policy_version": self.candidate_policy_version,
|
|
121
|
+
"simulated_at": self.simulated_at,
|
|
122
|
+
"changed": self.changed,
|
|
123
|
+
"candidate_decision": self.candidate_decision.to_dict(),
|
|
124
|
+
}
|
|
125
|
+
if self.production_decision is not None:
|
|
126
|
+
data["production_decision"] = self.production_decision.to_dict()
|
|
127
|
+
return data
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass
|
|
131
|
+
class RuntimePolicyReplayResult:
|
|
132
|
+
"""Summary of replaying historical events through a policy bundle."""
|
|
133
|
+
|
|
134
|
+
replay_id: str
|
|
135
|
+
environment: str
|
|
136
|
+
policy_id: str
|
|
137
|
+
policy_version: str
|
|
138
|
+
replayed_at: str
|
|
139
|
+
event_count: int
|
|
140
|
+
changed_count: int
|
|
141
|
+
blocked_count: int
|
|
142
|
+
review_count: int
|
|
143
|
+
simulations: list[RuntimePolicySimulationResult] = field(default_factory=list)
|
|
144
|
+
|
|
145
|
+
def to_dict(self) -> dict[str, Any]:
|
|
146
|
+
return {
|
|
147
|
+
"replay_id": self.replay_id,
|
|
148
|
+
"environment": self.environment,
|
|
149
|
+
"policy_id": self.policy_id,
|
|
150
|
+
"policy_version": self.policy_version,
|
|
151
|
+
"replayed_at": self.replayed_at,
|
|
152
|
+
"event_count": self.event_count,
|
|
153
|
+
"changed_count": self.changed_count,
|
|
154
|
+
"blocked_count": self.blocked_count,
|
|
155
|
+
"review_count": self.review_count,
|
|
156
|
+
"simulations": [item.to_dict() for item in self.simulations],
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
@dataclass
|
|
161
|
+
class RuntimePolicyComparisonResult:
|
|
162
|
+
"""Comparison summary between baseline and candidate policy outcomes."""
|
|
163
|
+
|
|
164
|
+
comparison_id: str
|
|
165
|
+
environment: str
|
|
166
|
+
baseline_policy_id: str
|
|
167
|
+
baseline_policy_version: str
|
|
168
|
+
candidate_policy_id: str
|
|
169
|
+
candidate_policy_version: str
|
|
170
|
+
compared_at: str
|
|
171
|
+
event_count: int
|
|
172
|
+
changed_count: int
|
|
173
|
+
action_changes: dict[str, int] = field(default_factory=dict)
|
|
174
|
+
|
|
175
|
+
def to_dict(self) -> dict[str, Any]:
|
|
176
|
+
return {
|
|
177
|
+
"comparison_id": self.comparison_id,
|
|
178
|
+
"environment": self.environment,
|
|
179
|
+
"baseline_policy_id": self.baseline_policy_id,
|
|
180
|
+
"baseline_policy_version": self.baseline_policy_version,
|
|
181
|
+
"candidate_policy_id": self.candidate_policy_id,
|
|
182
|
+
"candidate_policy_version": self.candidate_policy_version,
|
|
183
|
+
"compared_at": self.compared_at,
|
|
184
|
+
"event_count": self.event_count,
|
|
185
|
+
"changed_count": self.changed_count,
|
|
186
|
+
"action_changes": dict(self.action_changes),
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@dataclass
|
|
191
|
+
class RuntimePolicyReviewRecord:
|
|
192
|
+
"""Basic false-positive review loop record."""
|
|
193
|
+
|
|
194
|
+
review_id: str
|
|
195
|
+
decision_id: str
|
|
196
|
+
trace_id: str
|
|
197
|
+
environment: str
|
|
198
|
+
service: str
|
|
199
|
+
control: str
|
|
200
|
+
action: str
|
|
201
|
+
policy_id: str
|
|
202
|
+
policy_version: str
|
|
203
|
+
classification: str
|
|
204
|
+
recorded_at: str
|
|
205
|
+
notes: str = ""
|
|
206
|
+
|
|
207
|
+
def __post_init__(self) -> None:
|
|
208
|
+
if self.classification not in _ALLOWED_REVIEW_CLASSIFICATIONS:
|
|
209
|
+
raise ValueError(
|
|
210
|
+
"RuntimePolicyReviewRecord.classification must be one of "
|
|
211
|
+
f"{sorted(_ALLOWED_REVIEW_CLASSIFICATIONS)}"
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
def to_dict(self) -> dict[str, Any]:
|
|
215
|
+
data: dict[str, Any] = {
|
|
216
|
+
"review_id": self.review_id,
|
|
217
|
+
"decision_id": self.decision_id,
|
|
218
|
+
"trace_id": self.trace_id,
|
|
219
|
+
"environment": self.environment,
|
|
220
|
+
"service": self.service,
|
|
221
|
+
"control": self.control,
|
|
222
|
+
"action": self.action,
|
|
223
|
+
"policy_id": self.policy_id,
|
|
224
|
+
"policy_version": self.policy_version,
|
|
225
|
+
"classification": self.classification,
|
|
226
|
+
"recorded_at": self.recorded_at,
|
|
227
|
+
}
|
|
228
|
+
if self.notes:
|
|
229
|
+
data["notes"] = self.notes
|
|
230
|
+
return data
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
@dataclass
|
|
234
|
+
class RuntimePolicyStatusInfo:
|
|
235
|
+
"""Runtime policy engine status."""
|
|
236
|
+
|
|
237
|
+
status: str
|
|
238
|
+
loaded_bundles: int
|
|
239
|
+
active_environments: int
|
|
240
|
+
decisions_emitted: int
|
|
241
|
+
simulations_emitted: int = 0
|
|
242
|
+
replays_emitted: int = 0
|
|
243
|
+
reviews_recorded: int = 0
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
class SFPolicyClient(SFServiceClient):
|
|
247
|
+
"""Runtime policy loading, activation, evaluation, and promotion."""
|
|
248
|
+
|
|
249
|
+
def __init__(self, config: SFClientConfig) -> None:
|
|
250
|
+
super().__init__(config, service_name="policy")
|
|
251
|
+
self._lock = threading.Lock()
|
|
252
|
+
self._bundles: dict[tuple[str, str, str], RuntimePolicyBundle] = {}
|
|
253
|
+
self._active_by_environment: dict[str, tuple[str, str]] = {}
|
|
254
|
+
self._decision_records: dict[str, RuntimePolicyDecision] = {}
|
|
255
|
+
self._decisions_by_trace: dict[str, list[str]] = {}
|
|
256
|
+
self._simulation_records: dict[str, RuntimePolicySimulationResult] = {}
|
|
257
|
+
self._simulations_by_trace: dict[str, list[str]] = {}
|
|
258
|
+
self._replay_records: dict[str, RuntimePolicyReplayResult] = {}
|
|
259
|
+
self._review_records: dict[str, RuntimePolicyReviewRecord] = {}
|
|
260
|
+
self._reviews_by_trace: dict[str, list[str]] = {}
|
|
261
|
+
self._decisions_emitted = 0
|
|
262
|
+
self._simulations_emitted = 0
|
|
263
|
+
self._replays_emitted = 0
|
|
264
|
+
self._reviews_recorded = 0
|
|
265
|
+
|
|
266
|
+
def load_bundle(self, bundle: RuntimePolicyBundle | dict[str, Any]) -> RuntimePolicyBundle:
|
|
267
|
+
"""Load and validate a bundle into the local policy registry."""
|
|
268
|
+
parsed = bundle if isinstance(bundle, RuntimePolicyBundle) else RuntimePolicyBundle.from_dict(bundle)
|
|
269
|
+
key = (parsed.environment, parsed.policy_id, parsed.version)
|
|
270
|
+
with self._lock:
|
|
271
|
+
self._bundles[key] = parsed
|
|
272
|
+
return parsed
|
|
273
|
+
|
|
274
|
+
def validate_bundle(self, bundle: RuntimePolicyBundle | dict[str, Any]) -> RuntimePolicyBundle:
|
|
275
|
+
"""Validate and return a parsed runtime policy bundle."""
|
|
276
|
+
return bundle if isinstance(bundle, RuntimePolicyBundle) else RuntimePolicyBundle.from_dict(bundle)
|
|
277
|
+
|
|
278
|
+
def activate(
|
|
279
|
+
self,
|
|
280
|
+
*,
|
|
281
|
+
environment: str,
|
|
282
|
+
policy_id: str,
|
|
283
|
+
version: str,
|
|
284
|
+
activated_at: str,
|
|
285
|
+
) -> RuntimePolicyBundle:
|
|
286
|
+
"""Activate one loaded bundle for an environment."""
|
|
287
|
+
key = (environment, policy_id, version)
|
|
288
|
+
with self._lock:
|
|
289
|
+
bundle = self._bundles[key]
|
|
290
|
+
self._active_by_environment[environment] = (policy_id, version)
|
|
291
|
+
self._emit_policy_event(
|
|
292
|
+
{
|
|
293
|
+
"event_type": "policy_activated",
|
|
294
|
+
"environment": environment,
|
|
295
|
+
"policy_id": policy_id,
|
|
296
|
+
"version": version,
|
|
297
|
+
"activated_at": activated_at,
|
|
298
|
+
}
|
|
299
|
+
)
|
|
300
|
+
return bundle
|
|
301
|
+
|
|
302
|
+
def deactivate(self, *, environment: str, deactivated_at: str) -> None:
|
|
303
|
+
"""Deactivate the currently active policy for an environment."""
|
|
304
|
+
with self._lock:
|
|
305
|
+
policy_id, version = self._active_by_environment.pop(environment)
|
|
306
|
+
self._emit_policy_event(
|
|
307
|
+
{
|
|
308
|
+
"event_type": "policy_deactivated",
|
|
309
|
+
"environment": environment,
|
|
310
|
+
"policy_id": policy_id,
|
|
311
|
+
"version": version,
|
|
312
|
+
"deactivated_at": deactivated_at,
|
|
313
|
+
}
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
def get_active_bundle(self, environment: str) -> RuntimePolicyBundle | None:
|
|
317
|
+
"""Return the active bundle for an environment, if any."""
|
|
318
|
+
with self._lock:
|
|
319
|
+
active = self._active_by_environment.get(environment)
|
|
320
|
+
if active is None:
|
|
321
|
+
return None
|
|
322
|
+
return self._bundles.get((environment, active[0], active[1]))
|
|
323
|
+
|
|
324
|
+
def list_versions(self, *, environment: str, policy_id: str) -> list[RuntimePolicyBundle]:
|
|
325
|
+
"""List loaded versions for one policy in one environment."""
|
|
326
|
+
with self._lock:
|
|
327
|
+
bundles = [
|
|
328
|
+
bundle
|
|
329
|
+
for (env, pid, _version), bundle in self._bundles.items()
|
|
330
|
+
if env == environment and pid == policy_id
|
|
331
|
+
]
|
|
332
|
+
return sorted(bundles, key=lambda item: item.version)
|
|
333
|
+
|
|
334
|
+
def promote(
|
|
335
|
+
self,
|
|
336
|
+
*,
|
|
337
|
+
policy_id: str,
|
|
338
|
+
from_environment: str,
|
|
339
|
+
to_environment: str,
|
|
340
|
+
version: str,
|
|
341
|
+
new_version: str,
|
|
342
|
+
owner: str,
|
|
343
|
+
effective_at: str,
|
|
344
|
+
) -> RuntimePolicyBundle:
|
|
345
|
+
"""Clone one loaded policy version into another environment."""
|
|
346
|
+
with self._lock:
|
|
347
|
+
source = self._bundles[(from_environment, policy_id, version)]
|
|
348
|
+
promoted = RuntimePolicyBundle(
|
|
349
|
+
policy_id=source.policy_id,
|
|
350
|
+
version=new_version,
|
|
351
|
+
environment=to_environment,
|
|
352
|
+
owner=owner,
|
|
353
|
+
effective_at=effective_at,
|
|
354
|
+
rules=[
|
|
355
|
+
RuntimePolicyRule.from_dict(copy.deepcopy(rule.to_dict()))
|
|
356
|
+
for rule in source.rules
|
|
357
|
+
],
|
|
358
|
+
rationale=source.rationale,
|
|
359
|
+
metadata=dict(source.metadata),
|
|
360
|
+
)
|
|
361
|
+
self.load_bundle(promoted)
|
|
362
|
+
self._emit_policy_event(
|
|
363
|
+
{
|
|
364
|
+
"event_type": "policy_promoted",
|
|
365
|
+
"policy_id": policy_id,
|
|
366
|
+
"from_environment": from_environment,
|
|
367
|
+
"to_environment": to_environment,
|
|
368
|
+
"source_version": version,
|
|
369
|
+
"new_version": new_version,
|
|
370
|
+
"effective_at": effective_at,
|
|
371
|
+
}
|
|
372
|
+
)
|
|
373
|
+
return promoted
|
|
374
|
+
|
|
375
|
+
def evaluate(
|
|
376
|
+
self,
|
|
377
|
+
*,
|
|
378
|
+
environment: str,
|
|
379
|
+
trace_id: str,
|
|
380
|
+
service: str,
|
|
381
|
+
control: str,
|
|
382
|
+
evaluated_at: str,
|
|
383
|
+
observed_value: float | None = None,
|
|
384
|
+
metadata: dict[str, Any] | None = None,
|
|
385
|
+
) -> RuntimePolicyDecision:
|
|
386
|
+
"""Evaluate one service/control pair against the active environment bundle."""
|
|
387
|
+
bundle = self.get_active_bundle(environment)
|
|
388
|
+
decision = self._evaluate_bundle(
|
|
389
|
+
bundle=bundle,
|
|
390
|
+
environment=environment,
|
|
391
|
+
service=service,
|
|
392
|
+
control=control,
|
|
393
|
+
evaluated_at=evaluated_at,
|
|
394
|
+
observed_value=observed_value,
|
|
395
|
+
metadata=metadata or {},
|
|
396
|
+
)
|
|
397
|
+
|
|
398
|
+
with self._lock:
|
|
399
|
+
self._decision_records[decision.decision_id] = decision
|
|
400
|
+
self._decisions_by_trace.setdefault(trace_id, []).append(decision.decision_id)
|
|
401
|
+
self._decisions_emitted += 1
|
|
402
|
+
|
|
403
|
+
self._emit_policy_decision(trace_id=trace_id, decision=decision)
|
|
404
|
+
return decision
|
|
405
|
+
|
|
406
|
+
def simulate(
|
|
407
|
+
self,
|
|
408
|
+
*,
|
|
409
|
+
environment: str,
|
|
410
|
+
trace_id: str,
|
|
411
|
+
service: str,
|
|
412
|
+
control: str,
|
|
413
|
+
simulated_at: str,
|
|
414
|
+
candidate_bundle: RuntimePolicyBundle | dict[str, Any],
|
|
415
|
+
observed_value: float | None = None,
|
|
416
|
+
metadata: dict[str, Any] | None = None,
|
|
417
|
+
production_decision: RuntimePolicyDecision | None = None,
|
|
418
|
+
) -> RuntimePolicySimulationResult:
|
|
419
|
+
"""Simulate one candidate bundle without changing live policy state."""
|
|
420
|
+
from spanforge.ulid import generate as _ulid
|
|
421
|
+
|
|
422
|
+
parsed_bundle = self.validate_bundle(candidate_bundle)
|
|
423
|
+
if parsed_bundle.environment != environment:
|
|
424
|
+
raise ValueError("candidate_bundle.environment must match simulation environment")
|
|
425
|
+
|
|
426
|
+
simulated_decision = self._evaluate_bundle(
|
|
427
|
+
bundle=parsed_bundle,
|
|
428
|
+
environment=environment,
|
|
429
|
+
service=service,
|
|
430
|
+
control=control,
|
|
431
|
+
evaluated_at=simulated_at,
|
|
432
|
+
observed_value=observed_value,
|
|
433
|
+
metadata=metadata or {},
|
|
434
|
+
)
|
|
435
|
+
changed = self._decision_changed(production_decision, simulated_decision)
|
|
436
|
+
result = RuntimePolicySimulationResult(
|
|
437
|
+
simulation_id=_ulid(),
|
|
438
|
+
trace_id=trace_id,
|
|
439
|
+
environment=environment,
|
|
440
|
+
service=service,
|
|
441
|
+
control=control,
|
|
442
|
+
candidate_policy_id=parsed_bundle.policy_id,
|
|
443
|
+
candidate_policy_version=parsed_bundle.version,
|
|
444
|
+
simulated_at=simulated_at,
|
|
445
|
+
candidate_decision=simulated_decision,
|
|
446
|
+
production_decision=production_decision,
|
|
447
|
+
changed=changed,
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
with self._lock:
|
|
451
|
+
self._simulation_records[result.simulation_id] = result
|
|
452
|
+
self._simulations_by_trace.setdefault(trace_id, []).append(result.simulation_id)
|
|
453
|
+
self._simulations_emitted += 1
|
|
454
|
+
|
|
455
|
+
self._emit_policy_simulation(result)
|
|
456
|
+
return result
|
|
457
|
+
|
|
458
|
+
def replay(
|
|
459
|
+
self,
|
|
460
|
+
*,
|
|
461
|
+
environment: str,
|
|
462
|
+
replayed_at: str,
|
|
463
|
+
events: list[dict[str, Any]],
|
|
464
|
+
candidate_bundle: RuntimePolicyBundle | dict[str, Any],
|
|
465
|
+
) -> RuntimePolicyReplayResult:
|
|
466
|
+
"""Replay historical policy events through a candidate bundle."""
|
|
467
|
+
from spanforge.ulid import generate as _ulid
|
|
468
|
+
|
|
469
|
+
parsed_bundle = self.validate_bundle(candidate_bundle)
|
|
470
|
+
if parsed_bundle.environment != environment:
|
|
471
|
+
raise ValueError("candidate_bundle.environment must match replay environment")
|
|
472
|
+
|
|
473
|
+
simulations: list[RuntimePolicySimulationResult] = []
|
|
474
|
+
blocked_count = 0
|
|
475
|
+
review_count = 0
|
|
476
|
+
changed_count = 0
|
|
477
|
+
for event in events:
|
|
478
|
+
parsed_event = self._validated_historical_event(event, environment=environment)
|
|
479
|
+
trace_id = str(parsed_event["trace_id"])
|
|
480
|
+
production_decision = self._decision_from_event(event)
|
|
481
|
+
simulation = self.simulate(
|
|
482
|
+
environment=environment,
|
|
483
|
+
trace_id=trace_id,
|
|
484
|
+
service=str(parsed_event["service"]),
|
|
485
|
+
control=str(parsed_event["control"]),
|
|
486
|
+
simulated_at=replayed_at,
|
|
487
|
+
candidate_bundle=parsed_bundle,
|
|
488
|
+
observed_value=self._optional_float(parsed_event.get("observed_value")),
|
|
489
|
+
metadata=dict(parsed_event.get("metadata", {})),
|
|
490
|
+
production_decision=production_decision,
|
|
491
|
+
)
|
|
492
|
+
simulations.append(simulation)
|
|
493
|
+
if simulation.candidate_decision.action == "block":
|
|
494
|
+
blocked_count += 1
|
|
495
|
+
if simulation.candidate_decision.action == "human_review":
|
|
496
|
+
review_count += 1
|
|
497
|
+
if simulation.changed:
|
|
498
|
+
changed_count += 1
|
|
499
|
+
|
|
500
|
+
result = RuntimePolicyReplayResult(
|
|
501
|
+
replay_id=_ulid(),
|
|
502
|
+
environment=environment,
|
|
503
|
+
policy_id=parsed_bundle.policy_id,
|
|
504
|
+
policy_version=parsed_bundle.version,
|
|
505
|
+
replayed_at=replayed_at,
|
|
506
|
+
event_count=len(events),
|
|
507
|
+
changed_count=changed_count,
|
|
508
|
+
blocked_count=blocked_count,
|
|
509
|
+
review_count=review_count,
|
|
510
|
+
simulations=simulations,
|
|
511
|
+
)
|
|
512
|
+
with self._lock:
|
|
513
|
+
self._replay_records[result.replay_id] = result
|
|
514
|
+
self._replays_emitted += 1
|
|
515
|
+
self._emit_policy_replay(result)
|
|
516
|
+
return result
|
|
517
|
+
|
|
518
|
+
def compare_policies(
|
|
519
|
+
self,
|
|
520
|
+
*,
|
|
521
|
+
environment: str,
|
|
522
|
+
compared_at: str,
|
|
523
|
+
events: list[dict[str, Any]],
|
|
524
|
+
baseline_bundle: RuntimePolicyBundle | dict[str, Any],
|
|
525
|
+
candidate_bundle: RuntimePolicyBundle | dict[str, Any],
|
|
526
|
+
) -> RuntimePolicyComparisonResult:
|
|
527
|
+
"""Compare baseline and candidate outcomes across historical events."""
|
|
528
|
+
from spanforge.ulid import generate as _ulid
|
|
529
|
+
|
|
530
|
+
baseline = self.validate_bundle(baseline_bundle)
|
|
531
|
+
candidate = self.validate_bundle(candidate_bundle)
|
|
532
|
+
if baseline.environment != environment or candidate.environment != environment:
|
|
533
|
+
raise ValueError("baseline and candidate bundle environments must match comparison environment")
|
|
534
|
+
|
|
535
|
+
changed_count = 0
|
|
536
|
+
action_changes: dict[str, int] = {}
|
|
537
|
+
for event in events:
|
|
538
|
+
parsed_event = self._validated_historical_event(event, environment=environment)
|
|
539
|
+
baseline_decision = self._evaluate_bundle(
|
|
540
|
+
bundle=baseline,
|
|
541
|
+
environment=environment,
|
|
542
|
+
service=str(parsed_event["service"]),
|
|
543
|
+
control=str(parsed_event["control"]),
|
|
544
|
+
evaluated_at=compared_at,
|
|
545
|
+
observed_value=self._optional_float(parsed_event.get("observed_value")),
|
|
546
|
+
metadata=dict(parsed_event.get("metadata", {})),
|
|
547
|
+
)
|
|
548
|
+
candidate_decision = self._evaluate_bundle(
|
|
549
|
+
bundle=candidate,
|
|
550
|
+
environment=environment,
|
|
551
|
+
service=str(parsed_event["service"]),
|
|
552
|
+
control=str(parsed_event["control"]),
|
|
553
|
+
evaluated_at=compared_at,
|
|
554
|
+
observed_value=self._optional_float(parsed_event.get("observed_value")),
|
|
555
|
+
metadata=dict(parsed_event.get("metadata", {})),
|
|
556
|
+
)
|
|
557
|
+
if self._decision_changed(baseline_decision, candidate_decision):
|
|
558
|
+
changed_count += 1
|
|
559
|
+
key = f"{baseline_decision.action}->{candidate_decision.action}"
|
|
560
|
+
action_changes[key] = action_changes.get(key, 0) + 1
|
|
561
|
+
|
|
562
|
+
result = RuntimePolicyComparisonResult(
|
|
563
|
+
comparison_id=_ulid(),
|
|
564
|
+
environment=environment,
|
|
565
|
+
baseline_policy_id=baseline.policy_id,
|
|
566
|
+
baseline_policy_version=baseline.version,
|
|
567
|
+
candidate_policy_id=candidate.policy_id,
|
|
568
|
+
candidate_policy_version=candidate.version,
|
|
569
|
+
compared_at=compared_at,
|
|
570
|
+
event_count=len(events),
|
|
571
|
+
changed_count=changed_count,
|
|
572
|
+
action_changes=action_changes,
|
|
573
|
+
)
|
|
574
|
+
self._emit_policy_comparison(result)
|
|
575
|
+
return result
|
|
576
|
+
|
|
577
|
+
def record_review(
|
|
578
|
+
self,
|
|
579
|
+
*,
|
|
580
|
+
decision_id: str,
|
|
581
|
+
trace_id: str,
|
|
582
|
+
classification: str,
|
|
583
|
+
recorded_at: str,
|
|
584
|
+
notes: str = "",
|
|
585
|
+
) -> RuntimePolicyReviewRecord:
|
|
586
|
+
"""Record a basic false-positive review for a policy decision."""
|
|
587
|
+
from spanforge.ulid import generate as _ulid
|
|
588
|
+
|
|
589
|
+
decision = self.get_decision(decision_id)
|
|
590
|
+
if decision is None:
|
|
591
|
+
raise KeyError(decision_id)
|
|
592
|
+
review = RuntimePolicyReviewRecord(
|
|
593
|
+
review_id=_ulid(),
|
|
594
|
+
decision_id=decision_id,
|
|
595
|
+
trace_id=trace_id,
|
|
596
|
+
environment=decision.environment,
|
|
597
|
+
service=decision.service,
|
|
598
|
+
control=decision.control,
|
|
599
|
+
action=decision.action,
|
|
600
|
+
policy_id=decision.policy_id,
|
|
601
|
+
policy_version=decision.policy_version,
|
|
602
|
+
classification=classification,
|
|
603
|
+
recorded_at=recorded_at,
|
|
604
|
+
notes=notes,
|
|
605
|
+
)
|
|
606
|
+
with self._lock:
|
|
607
|
+
self._review_records[review.review_id] = review
|
|
608
|
+
self._reviews_by_trace.setdefault(trace_id, []).append(review.review_id)
|
|
609
|
+
self._reviews_recorded += 1
|
|
610
|
+
self._emit_policy_review(review)
|
|
611
|
+
return review
|
|
612
|
+
|
|
613
|
+
def list_reviews_for_trace(self, trace_id: str) -> list[RuntimePolicyReviewRecord]:
|
|
614
|
+
"""Return review records recorded for a trace."""
|
|
615
|
+
with self._lock:
|
|
616
|
+
ids = list(self._reviews_by_trace.get(trace_id, []))
|
|
617
|
+
return [self._review_records[item] for item in ids if item in self._review_records]
|
|
618
|
+
|
|
619
|
+
def suggest_threshold(
|
|
620
|
+
self,
|
|
621
|
+
*,
|
|
622
|
+
service: str,
|
|
623
|
+
control: str,
|
|
624
|
+
classification: str = "false_positive",
|
|
625
|
+
comparator: str = "lt",
|
|
626
|
+
) -> float | None:
|
|
627
|
+
"""Suggest a threshold from reviewed decisions for one service/control."""
|
|
628
|
+
if comparator not in {"lt", "lte", "gt", "gte"}:
|
|
629
|
+
raise ValueError("comparator must be one of 'lt', 'lte', 'gt', 'gte'")
|
|
630
|
+
|
|
631
|
+
observed_values: list[float] = []
|
|
632
|
+
with self._lock:
|
|
633
|
+
reviews = list(self._review_records.values())
|
|
634
|
+
for review in reviews:
|
|
635
|
+
if review.classification != classification:
|
|
636
|
+
continue
|
|
637
|
+
if review.service != service or review.control != control:
|
|
638
|
+
continue
|
|
639
|
+
decision = self.get_decision(review.decision_id)
|
|
640
|
+
if decision is None or decision.observed_value is None:
|
|
641
|
+
continue
|
|
642
|
+
observed_values.append(decision.observed_value)
|
|
643
|
+
|
|
644
|
+
if not observed_values:
|
|
645
|
+
return None
|
|
646
|
+
if comparator in {"lt", "lte"}:
|
|
647
|
+
return max(observed_values)
|
|
648
|
+
return min(observed_values)
|
|
649
|
+
|
|
650
|
+
def get_decision(self, decision_id: str) -> RuntimePolicyDecision | None:
|
|
651
|
+
"""Return a previously emitted policy decision."""
|
|
652
|
+
with self._lock:
|
|
653
|
+
return self._decision_records.get(decision_id)
|
|
654
|
+
|
|
655
|
+
def get_simulation(self, simulation_id: str) -> RuntimePolicySimulationResult | None:
|
|
656
|
+
"""Return a recorded simulation result."""
|
|
657
|
+
with self._lock:
|
|
658
|
+
return self._simulation_records.get(simulation_id)
|
|
659
|
+
|
|
660
|
+
def get_replay(self, replay_id: str) -> RuntimePolicyReplayResult | None:
|
|
661
|
+
"""Return a recorded replay result."""
|
|
662
|
+
with self._lock:
|
|
663
|
+
return self._replay_records.get(replay_id)
|
|
664
|
+
|
|
665
|
+
def list_decisions_for_trace(self, trace_id: str) -> list[RuntimePolicyDecision]:
|
|
666
|
+
"""Return all policy decisions recorded for a trace."""
|
|
667
|
+
with self._lock:
|
|
668
|
+
ids = list(self._decisions_by_trace.get(trace_id, []))
|
|
669
|
+
return [self._decision_records[item] for item in ids if item in self._decision_records]
|
|
670
|
+
|
|
671
|
+
def list_simulations_for_trace(self, trace_id: str) -> list[RuntimePolicySimulationResult]:
|
|
672
|
+
"""Return all simulations recorded for a trace."""
|
|
673
|
+
with self._lock:
|
|
674
|
+
ids = list(self._simulations_by_trace.get(trace_id, []))
|
|
675
|
+
return [self._simulation_records[item] for item in ids if item in self._simulation_records]
|
|
676
|
+
|
|
677
|
+
def get_status(self) -> RuntimePolicyStatusInfo:
|
|
678
|
+
"""Return policy engine status."""
|
|
679
|
+
with self._lock:
|
|
680
|
+
return RuntimePolicyStatusInfo(
|
|
681
|
+
status="ok",
|
|
682
|
+
loaded_bundles=len(self._bundles),
|
|
683
|
+
active_environments=len(self._active_by_environment),
|
|
684
|
+
decisions_emitted=self._decisions_emitted,
|
|
685
|
+
simulations_emitted=self._simulations_emitted,
|
|
686
|
+
replays_emitted=self._replays_emitted,
|
|
687
|
+
reviews_recorded=self._reviews_recorded,
|
|
688
|
+
)
|
|
689
|
+
|
|
690
|
+
@staticmethod
|
|
691
|
+
def _matching_rule(
|
|
692
|
+
bundle: RuntimePolicyBundle,
|
|
693
|
+
*,
|
|
694
|
+
service: str,
|
|
695
|
+
control: str,
|
|
696
|
+
) -> RuntimePolicyRule | None:
|
|
697
|
+
for rule in bundle.rules:
|
|
698
|
+
if rule.enabled and rule.service == service and rule.control == control:
|
|
699
|
+
return rule
|
|
700
|
+
return None
|
|
701
|
+
|
|
702
|
+
def _evaluate_bundle(
|
|
703
|
+
self,
|
|
704
|
+
*,
|
|
705
|
+
bundle: RuntimePolicyBundle | None,
|
|
706
|
+
environment: str,
|
|
707
|
+
service: str,
|
|
708
|
+
control: str,
|
|
709
|
+
evaluated_at: str,
|
|
710
|
+
observed_value: float | None,
|
|
711
|
+
metadata: dict[str, Any],
|
|
712
|
+
) -> RuntimePolicyDecision:
|
|
713
|
+
if bundle is None:
|
|
714
|
+
return self._implicit_allow_decision(
|
|
715
|
+
environment=environment,
|
|
716
|
+
service=service,
|
|
717
|
+
control=control,
|
|
718
|
+
evaluated_at=evaluated_at,
|
|
719
|
+
observed_value=observed_value,
|
|
720
|
+
metadata=metadata,
|
|
721
|
+
)
|
|
722
|
+
rule = self._matching_rule(bundle, service=service, control=control)
|
|
723
|
+
return self._decision_from_rule(
|
|
724
|
+
bundle=bundle,
|
|
725
|
+
rule=rule,
|
|
726
|
+
environment=environment,
|
|
727
|
+
service=service,
|
|
728
|
+
control=control,
|
|
729
|
+
evaluated_at=evaluated_at,
|
|
730
|
+
observed_value=observed_value,
|
|
731
|
+
metadata=metadata,
|
|
732
|
+
)
|
|
733
|
+
|
|
734
|
+
def _implicit_allow_decision(
|
|
735
|
+
self,
|
|
736
|
+
*,
|
|
737
|
+
environment: str,
|
|
738
|
+
service: str,
|
|
739
|
+
control: str,
|
|
740
|
+
evaluated_at: str,
|
|
741
|
+
observed_value: float | None,
|
|
742
|
+
metadata: dict[str, Any],
|
|
743
|
+
) -> RuntimePolicyDecision:
|
|
744
|
+
from spanforge.ulid import generate as _ulid
|
|
745
|
+
|
|
746
|
+
return RuntimePolicyDecision(
|
|
747
|
+
decision_id=_ulid(),
|
|
748
|
+
policy_id="implicit-default",
|
|
749
|
+
policy_version="none",
|
|
750
|
+
environment=environment,
|
|
751
|
+
service=service,
|
|
752
|
+
control=control,
|
|
753
|
+
action="allow",
|
|
754
|
+
allowed=True,
|
|
755
|
+
evaluated_at=evaluated_at,
|
|
756
|
+
reason=f"no active runtime policy for environment '{environment}'",
|
|
757
|
+
observed_value=observed_value,
|
|
758
|
+
metadata=metadata,
|
|
759
|
+
)
|
|
760
|
+
|
|
761
|
+
def _decision_from_rule(
|
|
762
|
+
self,
|
|
763
|
+
*,
|
|
764
|
+
bundle: RuntimePolicyBundle,
|
|
765
|
+
rule: RuntimePolicyRule | None,
|
|
766
|
+
environment: str,
|
|
767
|
+
service: str,
|
|
768
|
+
control: str,
|
|
769
|
+
evaluated_at: str,
|
|
770
|
+
observed_value: float | None,
|
|
771
|
+
metadata: dict[str, Any],
|
|
772
|
+
) -> RuntimePolicyDecision:
|
|
773
|
+
from spanforge.ulid import generate as _ulid
|
|
774
|
+
|
|
775
|
+
if rule is None:
|
|
776
|
+
return RuntimePolicyDecision(
|
|
777
|
+
decision_id=_ulid(),
|
|
778
|
+
policy_id=bundle.policy_id,
|
|
779
|
+
policy_version=bundle.version,
|
|
780
|
+
environment=environment,
|
|
781
|
+
service=service,
|
|
782
|
+
control=control,
|
|
783
|
+
action="allow",
|
|
784
|
+
allowed=True,
|
|
785
|
+
evaluated_at=evaluated_at,
|
|
786
|
+
reason=f"no enabled rule matched {service}.{control}",
|
|
787
|
+
observed_value=observed_value,
|
|
788
|
+
metadata=metadata,
|
|
789
|
+
)
|
|
790
|
+
|
|
791
|
+
triggered = self._rule_triggers(rule, observed_value)
|
|
792
|
+
action = rule.action if triggered else "allow"
|
|
793
|
+
reason = (
|
|
794
|
+
rule.rationale
|
|
795
|
+
or f"rule '{rule.rule_id}' triggered {service}.{control}"
|
|
796
|
+
if triggered
|
|
797
|
+
else f"rule '{rule.rule_id}' did not trigger"
|
|
798
|
+
)
|
|
799
|
+
return RuntimePolicyDecision(
|
|
800
|
+
decision_id=_ulid(),
|
|
801
|
+
policy_id=bundle.policy_id,
|
|
802
|
+
policy_version=bundle.version,
|
|
803
|
+
environment=environment,
|
|
804
|
+
service=service,
|
|
805
|
+
control=control,
|
|
806
|
+
action=action,
|
|
807
|
+
allowed=action in {"allow", "allow+log"},
|
|
808
|
+
evaluated_at=evaluated_at,
|
|
809
|
+
reason=reason,
|
|
810
|
+
rule_id=rule.rule_id,
|
|
811
|
+
threshold=rule.threshold,
|
|
812
|
+
observed_value=observed_value,
|
|
813
|
+
metadata=metadata,
|
|
814
|
+
)
|
|
815
|
+
|
|
816
|
+
@staticmethod
|
|
817
|
+
def _decision_from_event(event: dict[str, Any]) -> RuntimePolicyDecision | None:
|
|
818
|
+
action = event.get("production_action")
|
|
819
|
+
if action is None:
|
|
820
|
+
return None
|
|
821
|
+
return RuntimePolicyDecision(
|
|
822
|
+
decision_id=str(event.get("production_decision_id", "production-decision")),
|
|
823
|
+
policy_id=str(event.get("production_policy_id", "production")),
|
|
824
|
+
policy_version=str(event.get("production_policy_version", "current")),
|
|
825
|
+
environment=str(event["environment"]),
|
|
826
|
+
service=str(event["service"]),
|
|
827
|
+
control=str(event["control"]),
|
|
828
|
+
action=str(action),
|
|
829
|
+
allowed=str(action) in {"allow", "allow+log"},
|
|
830
|
+
evaluated_at=str(event.get("evaluated_at", event.get("replayed_at", ""))),
|
|
831
|
+
reason=str(event.get("production_reason", "historical production decision")),
|
|
832
|
+
rule_id=(
|
|
833
|
+
str(event["production_rule_id"])
|
|
834
|
+
if event.get("production_rule_id") is not None
|
|
835
|
+
else None
|
|
836
|
+
),
|
|
837
|
+
threshold=SFPolicyClient._optional_float(event.get("production_threshold")),
|
|
838
|
+
observed_value=SFPolicyClient._optional_float(event.get("observed_value")),
|
|
839
|
+
metadata=dict(event.get("metadata", {})),
|
|
840
|
+
)
|
|
841
|
+
|
|
842
|
+
@staticmethod
|
|
843
|
+
def _decision_changed(
|
|
844
|
+
baseline: RuntimePolicyDecision | None,
|
|
845
|
+
candidate: RuntimePolicyDecision,
|
|
846
|
+
) -> bool:
|
|
847
|
+
if baseline is None:
|
|
848
|
+
return True
|
|
849
|
+
return baseline.action != candidate.action or baseline.allowed != candidate.allowed
|
|
850
|
+
|
|
851
|
+
@staticmethod
|
|
852
|
+
def _optional_float(value: Any) -> float | None:
|
|
853
|
+
if value is None:
|
|
854
|
+
return None
|
|
855
|
+
return float(value)
|
|
856
|
+
|
|
857
|
+
@staticmethod
|
|
858
|
+
def _validated_historical_event(event: dict[str, Any], *, environment: str) -> dict[str, Any]:
|
|
859
|
+
if not isinstance(event, dict):
|
|
860
|
+
raise ValueError("historical policy event must be a dict")
|
|
861
|
+
required_fields = ("trace_id", "environment", "service", "control")
|
|
862
|
+
missing = [field for field in required_fields if field not in event]
|
|
863
|
+
if missing:
|
|
864
|
+
raise ValueError(
|
|
865
|
+
"historical policy event is missing required fields: "
|
|
866
|
+
+ ", ".join(missing)
|
|
867
|
+
)
|
|
868
|
+
if str(event["environment"]) != environment:
|
|
869
|
+
raise ValueError("historical policy event environment must match requested environment")
|
|
870
|
+
metadata = event.get("metadata", {})
|
|
871
|
+
if metadata is not None and not isinstance(metadata, dict):
|
|
872
|
+
raise ValueError("historical policy event metadata must be a dict when provided")
|
|
873
|
+
observed_value = event.get("observed_value")
|
|
874
|
+
if observed_value is not None:
|
|
875
|
+
numeric = float(observed_value)
|
|
876
|
+
if not (0.0 <= numeric <= 1.0):
|
|
877
|
+
raise ValueError("historical policy event observed_value must be in [0.0, 1.0]")
|
|
878
|
+
return event
|
|
879
|
+
|
|
880
|
+
@staticmethod
|
|
881
|
+
def _rule_triggers(rule: RuntimePolicyRule, observed_value: float | None) -> bool:
|
|
882
|
+
if rule.threshold is None:
|
|
883
|
+
return True
|
|
884
|
+
if observed_value is None:
|
|
885
|
+
return False
|
|
886
|
+
comparator = str(rule.metadata.get("comparator", "lt"))
|
|
887
|
+
if comparator == "lt":
|
|
888
|
+
return observed_value < rule.threshold
|
|
889
|
+
if comparator == "lte":
|
|
890
|
+
return observed_value <= rule.threshold
|
|
891
|
+
if comparator == "gt":
|
|
892
|
+
return observed_value > rule.threshold
|
|
893
|
+
if comparator == "gte":
|
|
894
|
+
return observed_value >= rule.threshold
|
|
895
|
+
if comparator == "eq":
|
|
896
|
+
return observed_value == rule.threshold
|
|
897
|
+
if comparator == "neq":
|
|
898
|
+
return observed_value != rule.threshold
|
|
899
|
+
raise ValueError(f"Unsupported runtime policy comparator: {comparator!r}")
|
|
900
|
+
|
|
901
|
+
def _emit_policy_event(self, payload: dict[str, Any]) -> None:
|
|
902
|
+
from spanforge.sdk import sf_audit
|
|
903
|
+
|
|
904
|
+
sf_audit.append(payload, "spanforge.policy.lifecycle.v1")
|
|
905
|
+
|
|
906
|
+
def _emit_policy_decision(self, *, trace_id: str, decision: RuntimePolicyDecision) -> None:
|
|
907
|
+
from spanforge.sdk import sf_audit
|
|
908
|
+
|
|
909
|
+
payload = {"trace_id": trace_id, **decision.to_dict()}
|
|
910
|
+
sf_audit.append(payload, "spanforge.policy.decision.v1")
|
|
911
|
+
|
|
912
|
+
def _emit_policy_simulation(self, result: RuntimePolicySimulationResult) -> None:
|
|
913
|
+
from spanforge.sdk import sf_audit
|
|
914
|
+
|
|
915
|
+
sf_audit.append(result.to_dict(), "spanforge.policy.simulation.v1")
|
|
916
|
+
|
|
917
|
+
def _emit_policy_replay(self, result: RuntimePolicyReplayResult) -> None:
|
|
918
|
+
from spanforge.sdk import sf_audit
|
|
919
|
+
|
|
920
|
+
sf_audit.append(result.to_dict(), "spanforge.policy.replay.v1")
|
|
921
|
+
|
|
922
|
+
def _emit_policy_comparison(self, result: RuntimePolicyComparisonResult) -> None:
|
|
923
|
+
from spanforge.sdk import sf_audit
|
|
924
|
+
|
|
925
|
+
sf_audit.append(result.to_dict(), "spanforge.policy.comparison.v1")
|
|
926
|
+
|
|
927
|
+
def _emit_policy_review(self, result: RuntimePolicyReviewRecord) -> None:
|
|
928
|
+
from spanforge.sdk import sf_audit
|
|
929
|
+
|
|
930
|
+
sf_audit.append(result.to_dict(), "spanforge.policy.review.v1")
|