rules.py 6.4 KB

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