rules.py 9.0 KB

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