rule_evidence.py 27 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692
  1. """Transactional, server-attested evidence for governed rule execution."""
  2. from __future__ import annotations
  3. import hashlib
  4. import json
  5. import os
  6. import re
  7. import tempfile
  8. from contextlib import suppress
  9. from typing import Any
  10. import polars as pl
  11. from sqlalchemy import text
  12. from app.core.common.identifiers import (
  13. ensure_governance_uid,
  14. new_governance_uid,
  15. )
  16. _DIGEST = re.compile(r"^[0-9a-f]{64}$")
  17. _FINAL_STATUSES = {"success", "failed", "unknown", "cancelled"}
  18. _COMMIT_OUTCOMES = {
  19. "not_applicable",
  20. "not_committed",
  21. "committed",
  22. "unknown",
  23. }
  24. _FINISH_KEYS = {
  25. "status",
  26. "rows_in",
  27. "rows_out",
  28. "rows_rejected",
  29. "rows_quarantined",
  30. "commit_outcome",
  31. "timings",
  32. "public_result",
  33. "violation_sample",
  34. "sample_count",
  35. "redaction_policy",
  36. }
  37. def _uid(value: Any, label: str) -> str:
  38. try:
  39. return ensure_governance_uid({"uid": str(value)})
  40. except ValueError as exc:
  41. raise ValueError(f"{label} is invalid") from exc
  42. def _canonical_digest(value: Any) -> str:
  43. encoded = json.dumps(
  44. value,
  45. sort_keys=True,
  46. separators=(",", ":"),
  47. ensure_ascii=False,
  48. ).encode("utf-8")
  49. return hashlib.sha256(encoded).hexdigest()
  50. def _bounded_count(value: Any, label: str) -> int:
  51. if isinstance(value, bool):
  52. raise ValueError(f"{label} is invalid")
  53. try:
  54. normalized = int(value or 0)
  55. except (TypeError, ValueError) as exc:
  56. raise ValueError(f"{label} is invalid") from exc
  57. if normalized < 0 or normalized > 10_000_000_000:
  58. raise ValueError(f"{label} is outside the evidence limit")
  59. return normalized
  60. def _sample_fields(sample: list[dict[str, Any]]) -> list[dict[str, Any]]:
  61. names = sorted({str(key) for row in sample for key in row})
  62. if not names:
  63. raise ValueError("violation sample has no fields")
  64. return [
  65. {"name": name, "type": "string", "nullable": True}
  66. for name in names
  67. ]
  68. class PostgresRuleEvidenceWriter:
  69. """Persist one immutable run and at most one expiring violation sample."""
  70. def __init__(
  71. self,
  72. engine,
  73. artifact_store,
  74. *,
  75. sample_ttl_seconds: int = 3600,
  76. ):
  77. self.engine = engine
  78. self.artifact_store = artifact_store
  79. self.sample_ttl_seconds = int(sample_ttl_seconds)
  80. if (
  81. self.sample_ttl_seconds < 1
  82. or self.sample_ttl_seconds
  83. > self.artifact_store.max_ttl_seconds
  84. ):
  85. raise ValueError("violation sample TTL is invalid")
  86. def start(
  87. self,
  88. *,
  89. component_binding_id: str,
  90. rule_version_id: str,
  91. plan_hash: str,
  92. correlation_id: str,
  93. dataflow_uid: str,
  94. workflow_version: int,
  95. node_id: str,
  96. ) -> str:
  97. component = _uid(component_binding_id, "component binding id")
  98. rule = _uid(rule_version_id, "rule version id")
  99. correlation = _uid(correlation_id, "correlation id")
  100. dataflow = _uid(dataflow_uid, "dataflow id")
  101. if _DIGEST.fullmatch(str(plan_hash or "")) is None:
  102. raise ValueError("plan hash is invalid")
  103. if (
  104. isinstance(workflow_version, bool)
  105. or not isinstance(workflow_version, int)
  106. or workflow_version < 1
  107. ):
  108. raise ValueError("workflow version is invalid")
  109. if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_-]{0,99}", str(node_id)):
  110. raise ValueError("node id is invalid")
  111. evidence_key = _canonical_digest(
  112. {
  113. "component_binding_id": component,
  114. "correlation_id": correlation,
  115. "dataflow_uid": dataflow,
  116. "node_id": node_id,
  117. "plan_hash": plan_hash,
  118. "rule_version_id": rule,
  119. "workflow_version": workflow_version,
  120. }
  121. )
  122. with self.engine.begin() as connection:
  123. deployments = connection.execute(
  124. text(
  125. """
  126. SELECT d.id::text AS deployment_id
  127. FROM public.dataflow_component_bindings b
  128. JOIN public.dataflow_versions v
  129. ON v.id = b.dataflow_version_id
  130. JOIN public.rule_execution_plans p
  131. ON p.component_binding_id = b.id
  132. JOIN public.dataflow_deployments d
  133. ON d.dataflow_version_id = v.id
  134. WHERE b.id = CAST(:component_binding_id AS uuid)
  135. AND b.rule_version_id =
  136. CAST(:rule_version_id AS uuid)
  137. AND p.plan_hash = :plan_hash
  138. AND p.status = 'published'
  139. AND v.dataflow_uid = CAST(:dataflow_uid AS uuid)
  140. AND v.version_no = :workflow_version
  141. AND d.status IN ('canary','active')
  142. ORDER BY
  143. CASE d.status WHEN 'active' THEN 0 ELSE 1 END,
  144. d.created_at DESC
  145. LIMIT 2
  146. """
  147. ),
  148. {
  149. "component_binding_id": component,
  150. "rule_version_id": rule,
  151. "plan_hash": plan_hash,
  152. "dataflow_uid": dataflow,
  153. "workflow_version": workflow_version,
  154. },
  155. ).mappings().all()
  156. if len(deployments) != 1:
  157. raise ValueError(
  158. "canonical rule deployment is missing or ambiguous"
  159. )
  160. rule_run_id = new_governance_uid()
  161. selected = connection.execute(
  162. text(
  163. """
  164. INSERT INTO public.rule_runs (
  165. id, deployment_id, component_binding_id,
  166. rule_version_id, plan_hash, status,
  167. correlation_id, evidence_key, started_at
  168. ) VALUES (
  169. CAST(:id AS uuid), CAST(:deployment_id AS uuid),
  170. CAST(:component_binding_id AS uuid),
  171. CAST(:rule_version_id AS uuid), :plan_hash,
  172. 'running', CAST(:correlation_id AS uuid),
  173. :evidence_key, CURRENT_TIMESTAMP
  174. )
  175. ON CONFLICT (evidence_key) DO NOTHING
  176. RETURNING id::text
  177. """
  178. ),
  179. {
  180. "id": rule_run_id,
  181. "deployment_id": deployments[0]["deployment_id"],
  182. "component_binding_id": component,
  183. "rule_version_id": rule,
  184. "plan_hash": plan_hash,
  185. "correlation_id": correlation,
  186. "evidence_key": evidence_key,
  187. },
  188. ).scalar_one_or_none()
  189. if selected is None:
  190. selected = connection.execute(
  191. text(
  192. """
  193. SELECT id::text
  194. FROM public.rule_runs
  195. WHERE evidence_key = :evidence_key
  196. """
  197. ),
  198. {"evidence_key": evidence_key},
  199. ).scalar_one()
  200. return str(selected)
  201. def replay(self, rule_run_id: str) -> dict[str, Any] | None:
  202. run_id = _uid(rule_run_id, "rule run id")
  203. with self.engine.connect() as connection:
  204. row = connection.execute(
  205. text(
  206. """
  207. SELECT status, commit_outcome, public_result
  208. FROM public.rule_runs
  209. WHERE id = CAST(:id AS uuid)
  210. """
  211. ),
  212. {"id": run_id},
  213. ).mappings().one_or_none()
  214. if row is None:
  215. raise ValueError("rule run was not found")
  216. if row["status"] in {"queued", "running"}:
  217. return None
  218. result = row["public_result"]
  219. if isinstance(result, str):
  220. result = json.loads(result)
  221. return {
  222. **(dict(result) if isinstance(result, dict) else {}),
  223. "status": str(row["status"]),
  224. "commit_outcome": str(row["commit_outcome"]),
  225. }
  226. @staticmethod
  227. def _validate_finish(result: Any) -> dict[str, Any]:
  228. if not isinstance(result, dict) or set(result) - _FINISH_KEYS:
  229. raise ValueError("rule evidence result has unsupported fields")
  230. status = result.get("status")
  231. commit_outcome = result.get("commit_outcome", "not_applicable")
  232. if status not in _FINAL_STATUSES:
  233. raise ValueError("rule evidence status is invalid")
  234. if commit_outcome not in _COMMIT_OUTCOMES:
  235. raise ValueError("rule evidence commit outcome is invalid")
  236. timings = result.get("timings", {})
  237. if (
  238. not isinstance(timings, dict)
  239. or set(timings) != {"duration_ms"}
  240. or isinstance(timings.get("duration_ms"), bool)
  241. or not isinstance(timings.get("duration_ms"), int)
  242. or timings["duration_ms"] < 0
  243. or timings["duration_ms"] > 86_400_000
  244. ):
  245. raise ValueError("rule evidence timings are invalid")
  246. public_result = result.get("public_result")
  247. if public_result is not None and (
  248. not isinstance(public_result, dict)
  249. or any(str(key).startswith("_") for key in public_result)
  250. or "rows" in public_result
  251. or len(
  252. json.dumps(
  253. public_result,
  254. sort_keys=True,
  255. separators=(",", ":"),
  256. ensure_ascii=False,
  257. ).encode("utf-8")
  258. )
  259. > 32_768
  260. ):
  261. raise ValueError("public rule result is not evidence safe")
  262. sample = result.get("violation_sample")
  263. if sample is not None:
  264. if (
  265. status != "success"
  266. or not isinstance(sample, list)
  267. or not 1 <= len(sample) <= 100
  268. or result.get("sample_count") != len(sample)
  269. or result.get("redaction_policy")
  270. != "rule-violation-default-v1"
  271. ):
  272. raise ValueError("violation sample is invalid")
  273. for row in sample:
  274. if not isinstance(row, dict):
  275. raise ValueError("violation sample row is invalid")
  276. for value in row.values():
  277. if value not in {None, "[REDACTED]"}:
  278. raise ValueError(
  279. "violation sample contains unredacted values"
  280. )
  281. return {
  282. **result,
  283. "rows_in": _bounded_count(result.get("rows_in"), "rows_in"),
  284. "rows_out": _bounded_count(result.get("rows_out"), "rows_out"),
  285. "rows_rejected": _bounded_count(
  286. result.get("rows_rejected"), "rows_rejected"
  287. ),
  288. "rows_quarantined": _bounded_count(
  289. result.get("rows_quarantined"), "rows_quarantined"
  290. ),
  291. "commit_outcome": commit_outcome,
  292. }
  293. def _prepare_sample(
  294. self,
  295. run_id: str,
  296. correlation_id: str,
  297. sample: list[dict[str, Any]],
  298. redaction_policy: str,
  299. ) -> tuple[str, dict[str, Any], str]:
  300. fields = _sample_fields(sample)
  301. frame = pl.DataFrame(
  302. {
  303. field["name"]: [
  304. row.get(field["name"]) for row in sample
  305. ]
  306. for field in fields
  307. },
  308. schema={field["name"]: pl.String for field in fields},
  309. )
  310. with tempfile.NamedTemporaryFile(
  311. prefix="dataops-rule-violation-",
  312. suffix=".parquet",
  313. delete=False,
  314. ) as handle:
  315. path = handle.name
  316. sample_id = None
  317. prepared = None
  318. try:
  319. frame.write_parquet(path)
  320. prepared = self.artifact_store.prepare_path(
  321. path,
  322. correlation_id,
  323. self.sample_ttl_seconds,
  324. schema_fields=fields,
  325. limits={
  326. "max_rows": min(100, self.artifact_store.max_rows),
  327. "max_artifact_bytes": min(
  328. 4 * 1024 * 1024,
  329. self.artifact_store.max_artifact_bytes,
  330. ),
  331. "memory_limit_bytes": min(
  332. 16 * 1024 * 1024,
  333. self.artifact_store.memory_limit_bytes,
  334. ),
  335. },
  336. )
  337. sample_id = new_governance_uid()
  338. with self.engine.begin() as connection:
  339. row = connection.execute(
  340. text(
  341. """
  342. SELECT id::text, artifact_ref, artifact_digest,
  343. schema_hash, sample_count,
  344. redaction_policy, expires_at, handoff_status
  345. FROM public.rule_violation_samples
  346. WHERE rule_run_id = CAST(:rule_run_id AS uuid)
  347. FOR UPDATE
  348. """
  349. ),
  350. {"rule_run_id": run_id},
  351. ).mappings().one_or_none()
  352. if row is None:
  353. connection.execute(
  354. text(
  355. """
  356. INSERT INTO public.rule_violation_samples (
  357. id, rule_run_id, artifact_ref,
  358. artifact_digest, schema_hash, sample_count,
  359. redaction_policy, expires_at,
  360. handoff_status
  361. ) VALUES (
  362. CAST(:id AS uuid),
  363. CAST(:rule_run_id AS uuid), :artifact_ref,
  364. :artifact_digest, :schema_hash,
  365. :sample_count, :redaction_policy,
  366. CAST(:expires_at AS timestamptz), 'pending'
  367. )
  368. """
  369. ),
  370. {
  371. "id": sample_id,
  372. "rule_run_id": run_id,
  373. "artifact_ref": prepared["artifact_ref"],
  374. "artifact_digest": prepared["digest"],
  375. "schema_hash": prepared["schema_hash"],
  376. "sample_count": len(sample),
  377. "redaction_policy": redaction_policy,
  378. "expires_at": prepared["expires_at"],
  379. },
  380. )
  381. else:
  382. if (
  383. str(row["artifact_digest"]) != prepared["digest"]
  384. or int(row["sample_count"]) != len(sample)
  385. or str(row["redaction_policy"])
  386. != redaction_policy
  387. ):
  388. raise ValueError(
  389. "violation sample evidence is immutable"
  390. )
  391. sample_id = str(row["id"])
  392. prepared.update(
  393. {
  394. "artifact_ref": str(row["artifact_ref"]),
  395. "digest": str(row["artifact_digest"]),
  396. "schema_hash": str(row["schema_hash"]),
  397. "expires_at": str(row["expires_at"]),
  398. }
  399. )
  400. if row["handoff_status"] == "ready":
  401. stored = self.artifact_store.describe(
  402. prepared["artifact_ref"]
  403. )
  404. if (
  405. stored["digest"] != prepared["digest"]
  406. or stored["schema_hash"]
  407. != prepared["schema_hash"]
  408. or stored["row_count"] != len(sample)
  409. ):
  410. raise ValueError(
  411. "ready violation sample does not match storage"
  412. )
  413. return sample_id, prepared, path
  414. if row["handoff_status"] == "failed":
  415. raise ValueError(
  416. "violation sample handoff already failed"
  417. )
  418. self.artifact_store.upload_path(
  419. path,
  420. prepared,
  421. limits={
  422. "max_rows": min(100, self.artifact_store.max_rows),
  423. "max_artifact_bytes": min(
  424. 4 * 1024 * 1024,
  425. self.artifact_store.max_artifact_bytes,
  426. ),
  427. "memory_limit_bytes": min(
  428. 16 * 1024 * 1024,
  429. self.artifact_store.memory_limit_bytes,
  430. ),
  431. },
  432. )
  433. with self.engine.begin() as connection:
  434. updated = connection.execute(
  435. text(
  436. """
  437. UPDATE public.rule_violation_samples
  438. SET handoff_status = 'ready',
  439. updated_at = CURRENT_TIMESTAMP
  440. WHERE id = CAST(:id AS uuid)
  441. AND handoff_status = 'pending'
  442. AND artifact_digest = :artifact_digest
  443. """
  444. ),
  445. {
  446. "id": sample_id,
  447. "artifact_digest": prepared["digest"],
  448. },
  449. )
  450. if updated.rowcount != 1:
  451. state = connection.execute(
  452. text(
  453. """
  454. SELECT handoff_status
  455. FROM public.rule_violation_samples
  456. WHERE id = CAST(:id AS uuid)
  457. """
  458. ),
  459. {"id": sample_id},
  460. ).scalar_one_or_none()
  461. if state != "ready":
  462. raise RuntimeError(
  463. "violation sample finalize outcome is unknown"
  464. )
  465. return sample_id, prepared, path
  466. except Exception:
  467. if sample_id is not None and prepared is not None:
  468. try:
  469. with self.engine.connect() as connection:
  470. committed = connection.execute(
  471. text(
  472. """
  473. SELECT handoff_status, artifact_digest
  474. FROM public.rule_violation_samples
  475. WHERE id = CAST(:id AS uuid)
  476. """
  477. ),
  478. {"id": sample_id},
  479. ).mappings().one_or_none()
  480. if (
  481. committed is not None
  482. and committed["handoff_status"] == "ready"
  483. and str(committed["artifact_digest"])
  484. == prepared["digest"]
  485. ):
  486. return sample_id, prepared, path
  487. except Exception:
  488. pass
  489. with self.engine.begin() as connection:
  490. connection.execute(
  491. text(
  492. """
  493. UPDATE public.rule_violation_samples
  494. SET handoff_status = 'failed',
  495. failure_code = 'sample_handoff_failed',
  496. updated_at = CURRENT_TIMESTAMP
  497. WHERE rule_run_id = CAST(:rule_run_id AS uuid)
  498. AND handoff_status = 'pending'
  499. """
  500. ),
  501. {"rule_run_id": run_id},
  502. )
  503. with suppress(FileNotFoundError):
  504. os.unlink(path)
  505. raise
  506. def finish(self, rule_run_id: str, result: Any) -> None:
  507. run_id = _uid(rule_run_id, "rule run id")
  508. normalized = self._validate_finish(result)
  509. sample_path = None
  510. try:
  511. with self.engine.connect() as connection:
  512. current = connection.execute(
  513. text(
  514. """
  515. SELECT status, correlation_id::text
  516. FROM public.rule_runs
  517. WHERE id = CAST(:id AS uuid)
  518. """
  519. ),
  520. {"id": run_id},
  521. ).mappings().one_or_none()
  522. if current is None:
  523. raise ValueError("rule run was not found")
  524. if current["status"] not in {"queued", "running"}:
  525. replay = self.replay(run_id)
  526. if replay and replay["status"] == normalized["status"]:
  527. return
  528. raise ValueError("rule run evidence is immutable")
  529. if normalized.get("violation_sample"):
  530. _sample_id, _prepared, sample_path = self._prepare_sample(
  531. run_id,
  532. str(current["correlation_id"]),
  533. normalized["violation_sample"],
  534. normalized["redaction_policy"],
  535. )
  536. try:
  537. with self.engine.begin() as connection:
  538. updated = connection.execute(
  539. text(
  540. """
  541. UPDATE public.rule_runs
  542. SET rows_in = :rows_in,
  543. rows_out = :rows_out,
  544. rows_rejected = :rows_rejected,
  545. rows_quarantined = :rows_quarantined,
  546. status = :status,
  547. timings = CAST(:timings AS jsonb),
  548. commit_outcome = :commit_outcome,
  549. public_result = CAST(:public_result AS jsonb),
  550. failure_code = :failure_code,
  551. finished_at = CURRENT_TIMESTAMP,
  552. updated_at = CURRENT_TIMESTAMP
  553. WHERE id = CAST(:id AS uuid)
  554. AND status IN ('queued','running')
  555. """
  556. ),
  557. {
  558. "id": run_id,
  559. "rows_in": normalized["rows_in"],
  560. "rows_out": normalized["rows_out"],
  561. "rows_rejected": normalized[
  562. "rows_rejected"
  563. ],
  564. "rows_quarantined": normalized[
  565. "rows_quarantined"
  566. ],
  567. "status": normalized["status"],
  568. "timings": json.dumps(
  569. normalized["timings"]
  570. ),
  571. "commit_outcome": normalized[
  572. "commit_outcome"
  573. ],
  574. "public_result": (
  575. json.dumps(
  576. normalized.get("public_result")
  577. )
  578. if normalized.get("public_result")
  579. is not None
  580. else None
  581. ),
  582. "failure_code": (
  583. None
  584. if normalized["status"] == "success"
  585. else (
  586. f"execution_{normalized['status']}"
  587. )
  588. ),
  589. },
  590. )
  591. if updated.rowcount != 1:
  592. state = connection.execute(
  593. text(
  594. """
  595. SELECT status, commit_outcome
  596. FROM public.rule_runs
  597. WHERE id = CAST(:id AS uuid)
  598. """
  599. ),
  600. {"id": run_id},
  601. ).mappings().one_or_none()
  602. if (
  603. state is None
  604. or state["status"] != normalized["status"]
  605. or state["commit_outcome"]
  606. != normalized["commit_outcome"]
  607. ):
  608. raise RuntimeError(
  609. "rule run finalize outcome is unknown"
  610. )
  611. except Exception as exc:
  612. try:
  613. replay = self.replay(run_id)
  614. except Exception as recheck_exc:
  615. raise RuntimeError(
  616. "rule run finalize outcome is unknown"
  617. ) from recheck_exc
  618. if (
  619. replay is None
  620. or replay["status"] != normalized["status"]
  621. or replay["commit_outcome"]
  622. != normalized["commit_outcome"]
  623. ):
  624. raise RuntimeError(
  625. "rule run finalize outcome is unknown"
  626. ) from exc
  627. finally:
  628. if sample_path is not None:
  629. with suppress(FileNotFoundError):
  630. os.unlink(sample_path)
  631. def cleanup_expired(self, *, limit: int = 100) -> int:
  632. if isinstance(limit, bool) or not isinstance(limit, int):
  633. raise ValueError("cleanup limit is invalid")
  634. if limit < 1 or limit > 1000:
  635. raise ValueError("cleanup limit is invalid")
  636. removed = 0
  637. with self.engine.begin() as connection:
  638. rows = connection.execute(
  639. text(
  640. """
  641. SELECT id::text, artifact_ref
  642. FROM public.rule_violation_samples
  643. WHERE expires_at <= CURRENT_TIMESTAMP
  644. AND handoff_status IN ('legacy','ready','failed')
  645. ORDER BY expires_at, id
  646. FOR UPDATE SKIP LOCKED
  647. LIMIT :limit
  648. """
  649. ),
  650. {"limit": limit},
  651. ).mappings().all()
  652. for row in rows:
  653. try:
  654. self.artifact_store.delete(str(row["artifact_ref"]))
  655. except Exception:
  656. continue
  657. connection.execute(
  658. text(
  659. """
  660. DELETE FROM public.rule_violation_samples
  661. WHERE id = CAST(:id AS uuid)
  662. """
  663. ),
  664. {"id": str(row["id"])},
  665. )
  666. removed += 1
  667. return removed
  668. __all__ = ["PostgresRuleEvidenceWriter"]