rules.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292
  1. """Load immutable rule plans and dispatch them through allowlisted adapters."""
  2. from __future__ import annotations
  3. import hashlib
  4. import json
  5. import re
  6. from collections.abc import Mapping
  7. from typing import Any
  8. from sqlalchemy import text
  9. from app.core.common.identifiers import ensure_governance_uid
  10. from app.core.data_rules.compilers.polars import (
  11. COMPILER_VERSION as POLARS_COMPILER_VERSION,
  12. )
  13. from app.core.data_rules.compilers.polars import (
  14. validate_bound_polars_plan,
  15. )
  16. from app.core.data_rules.compilers.sql import (
  17. COMPILER_VERSION as SQL_COMPILER_VERSION,
  18. )
  19. from app.core.data_rules.compilers.sql import (
  20. validate_bound_sql_plan,
  21. )
  22. from app.runner.nodes import NodeExecutionError
  23. CONFIG_KEYS = {
  24. "component_binding_id",
  25. "rule_version_id",
  26. "execution_plan_hash",
  27. "provenance",
  28. }
  29. IDEMPOTENCY_STRATEGIES = {
  30. "partition_replace",
  31. "upsert",
  32. "deduplication_key",
  33. }
  34. def _canonical_hash(value: Any) -> str:
  35. try:
  36. canonical = json.dumps(
  37. value,
  38. sort_keys=True,
  39. separators=(",", ":"),
  40. ensure_ascii=False,
  41. )
  42. except (TypeError, ValueError) as exc:
  43. raise NodeExecutionError(
  44. "published rule plan is not JSON serializable"
  45. ) from exc
  46. return hashlib.sha256(canonical.encode("utf-8")).hexdigest()
  47. def _uid(value: Any, label: str) -> str:
  48. try:
  49. return ensure_governance_uid({"uid": str(value)})
  50. except ValueError as exc:
  51. raise NodeExecutionError(f"{label} is invalid") from exc
  52. class PostgresRulePlanRepository:
  53. """Read one plan through all immutable binding and version constraints."""
  54. def __init__(self, engine):
  55. self.engine = engine
  56. def load(
  57. self,
  58. *,
  59. component_binding_id: str,
  60. rule_version_id: str,
  61. plan_hash: str,
  62. ) -> dict[str, Any] | None:
  63. statement = text(
  64. """
  65. SELECT
  66. p.component_binding_id::text AS component_binding_id,
  67. b.rule_version_id::text AS rule_version_id,
  68. p.backend,
  69. p.compiler_version,
  70. p.plan,
  71. p.plan_hash,
  72. p.schema_hashes,
  73. p.status AS plan_status,
  74. r.status AS rule_status,
  75. r.spec_hash AS canonical_rule_spec_hash,
  76. ins.id::text AS canonical_input_schema_snapshot_id,
  77. ins.schema_hash AS canonical_input_schema_hash,
  78. outs.id::text AS canonical_output_schema_snapshot_id,
  79. outs.schema_hash AS canonical_output_schema_hash,
  80. b.component_kind,
  81. b.idempotency AS binding_idempotency
  82. FROM public.rule_execution_plans p
  83. JOIN public.dataflow_component_bindings b
  84. ON b.id = p.component_binding_id
  85. JOIN public.data_rule_versions r
  86. ON r.id = b.rule_version_id
  87. LEFT JOIN public.dataflow_dataset_bindings ib
  88. ON ib.id = CAST(p.plan->>'input_binding_id' AS uuid)
  89. LEFT JOIN public.data_schema_snapshots ins
  90. ON ins.id = ib.schema_snapshot_id
  91. LEFT JOIN public.dataflow_dataset_bindings ob
  92. ON ob.id = CAST(p.plan->>'output_binding_id' AS uuid)
  93. LEFT JOIN public.data_schema_snapshots outs
  94. ON outs.id = ob.schema_snapshot_id
  95. WHERE p.component_binding_id = CAST(:component_binding_id AS uuid)
  96. AND b.rule_version_id = CAST(:rule_version_id AS uuid)
  97. AND p.plan_hash = :plan_hash
  98. """
  99. )
  100. with self.engine.connect() as connection:
  101. row = connection.execute(
  102. statement,
  103. {
  104. "component_binding_id": component_binding_id,
  105. "rule_version_id": rule_version_id,
  106. "plan_hash": plan_hash,
  107. },
  108. ).mappings().one_or_none()
  109. return dict(row) if row is not None else None
  110. class RulePlanExecutor:
  111. """Fail closed unless the exact published plan is still executable."""
  112. def __init__(self, repository, *, adapters: Mapping[str, Any]):
  113. self.repository = repository
  114. self.adapters = dict(adapters)
  115. def execute(
  116. self,
  117. node,
  118. parameters,
  119. *,
  120. write_authorized=False,
  121. **execution_context,
  122. ):
  123. if node.get("type") not in {"rule.apply", "quality.check"}:
  124. raise NodeExecutionError("unsupported governed rule node")
  125. config = node.get("config")
  126. if not isinstance(config, dict) or set(config) - CONFIG_KEYS:
  127. raise NodeExecutionError(
  128. "governed rule node contains inline or unsupported fields"
  129. )
  130. component_binding_id = _uid(
  131. config.get("component_binding_id"),
  132. "component_binding_id",
  133. )
  134. rule_version_id = _uid(
  135. config.get("rule_version_id"),
  136. "rule_version_id",
  137. )
  138. plan_hash = str(config.get("execution_plan_hash") or "")
  139. if not re.fullmatch(r"[0-9a-f]{64}", plan_hash):
  140. raise NodeExecutionError("execution plan hash is invalid")
  141. if node.get("type") == "rule.apply":
  142. idempotency = node.get("idempotency")
  143. if (
  144. node.get("purpose") != "write"
  145. or not write_authorized
  146. or not isinstance(idempotency, dict)
  147. or idempotency.get("strategy")
  148. not in IDEMPOTENCY_STRATEGIES
  149. or not str(idempotency.get("key") or "").strip()
  150. ):
  151. raise NodeExecutionError(
  152. "governed write authorization and idempotency are required"
  153. )
  154. elif node.get("purpose") != "read":
  155. raise NodeExecutionError("quality check must be read only")
  156. record = self.repository.load(
  157. component_binding_id=component_binding_id,
  158. rule_version_id=rule_version_id,
  159. plan_hash=plan_hash,
  160. )
  161. if not isinstance(record, dict):
  162. raise NodeExecutionError("published rule plan was not found")
  163. if (
  164. record.get("component_binding_id") != component_binding_id
  165. or record.get("rule_version_id") != rule_version_id
  166. or record.get("plan_hash") != plan_hash
  167. or record.get("plan_status") != "published"
  168. or record.get("rule_status") != "published"
  169. or _canonical_hash(record.get("plan")) != plan_hash
  170. ):
  171. raise NodeExecutionError("published rule plan is not executable")
  172. if node.get("type") == "rule.apply" and (
  173. record.get("component_kind") != "rule.apply"
  174. or record.get("binding_idempotency") != node.get("idempotency")
  175. ):
  176. raise NodeExecutionError(
  177. "governed rule idempotency does not match its binding"
  178. )
  179. backend = record.get("backend")
  180. if backend == "sql_pushdown":
  181. try:
  182. plan = validate_bound_sql_plan(record.get("plan"))
  183. except ValueError as exc:
  184. raise NodeExecutionError(
  185. "published rule plan is not executable"
  186. ) from exc
  187. expected_schema_hashes = {
  188. "rule_spec_hash": plan["rule_spec_hash"],
  189. "input_schema_snapshot_id": plan[
  190. "input_schema_snapshot_id"
  191. ],
  192. "input_schema_hash": plan["input_schema_hash"],
  193. "output_schema_snapshot_id": plan[
  194. "output_schema_snapshot_id"
  195. ],
  196. "output_schema_hash": plan["output_schema_hash"],
  197. }
  198. if (
  199. record.get("compiler_version") != SQL_COMPILER_VERSION
  200. or plan["compiler_version"] != SQL_COMPILER_VERSION
  201. or record.get("schema_hashes") != expected_schema_hashes
  202. or record.get("canonical_rule_spec_hash")
  203. != plan["rule_spec_hash"]
  204. or record.get("canonical_input_schema_snapshot_id")
  205. != plan["input_schema_snapshot_id"]
  206. or record.get("canonical_input_schema_hash")
  207. != plan["input_schema_hash"]
  208. or record.get("canonical_output_schema_snapshot_id")
  209. != plan["output_schema_snapshot_id"]
  210. or record.get("canonical_output_schema_hash")
  211. != plan["output_schema_hash"]
  212. ):
  213. raise NodeExecutionError(
  214. "published rule plan canonical attestation does not match"
  215. )
  216. elif backend == "polars_batch":
  217. try:
  218. plan = validate_bound_polars_plan(record.get("plan"))
  219. except ValueError as exc:
  220. raise NodeExecutionError(
  221. "published rule plan is not executable"
  222. ) from exc
  223. expected_schema_hashes = {
  224. "rule_spec_hash": plan["rule_spec_hash"],
  225. "input_schema_snapshot_id": plan[
  226. "input_schema_snapshot_id"
  227. ],
  228. "input_schema_hash": plan["input_schema_hash"],
  229. "output_schema_snapshot_id": plan[
  230. "output_schema_snapshot_id"
  231. ],
  232. "output_schema_hash": plan["output_schema_hash"],
  233. }
  234. if (
  235. record.get("compiler_version") != POLARS_COMPILER_VERSION
  236. or plan["compiler_version"] != POLARS_COMPILER_VERSION
  237. or record.get("schema_hashes") != expected_schema_hashes
  238. or record.get("canonical_rule_spec_hash")
  239. != plan["rule_spec_hash"]
  240. or record.get("canonical_input_schema_snapshot_id")
  241. != plan["input_schema_snapshot_id"]
  242. or record.get("canonical_input_schema_hash")
  243. != plan["input_schema_hash"]
  244. or record.get("canonical_output_schema_snapshot_id")
  245. != plan["output_schema_snapshot_id"]
  246. or record.get("canonical_output_schema_hash")
  247. != plan["output_schema_hash"]
  248. ):
  249. raise NodeExecutionError(
  250. "published rule plan canonical attestation does not match"
  251. )
  252. adapter = self.adapters.get(backend)
  253. if adapter is None or not callable(getattr(adapter, "execute", None)):
  254. raise NodeExecutionError("rule plan backend is not registered")
  255. adapter_context = {}
  256. if backend == "polars_batch":
  257. adapter_context["correlation_id"] = execution_context.get(
  258. "correlation_id"
  259. )
  260. result = adapter.execute(
  261. plan=record["plan"],
  262. node=node,
  263. parameters=parameters,
  264. write_authorized=write_authorized,
  265. **adapter_context,
  266. )
  267. if not isinstance(result, dict):
  268. raise NodeExecutionError("rule plan result must be an object")
  269. return {
  270. **result,
  271. "component_binding_id": component_binding_id,
  272. "rule_version_id": rule_version_id,
  273. "execution_plan_hash": plan_hash,
  274. }