rules.py 7.4 KB

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