domain_templates.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432
  1. """Generic governance object contracts and versioned domain templates."""
  2. from __future__ import annotations
  3. import hashlib
  4. import json
  5. import re
  6. import uuid
  7. from collections.abc import Callable
  8. from copy import deepcopy
  9. from dataclasses import dataclass
  10. from typing import Any
  11. from app.core.common.identifiers import new_governance_uid
  12. IDENTIFIER_RE = re.compile(r"^[a-z][a-z0-9_]{1,63}$")
  13. CODE_RE = re.compile(r"^[A-Za-z][A-Za-z0-9_.-]{1,79}$")
  14. LIFECYCLE_STATUSES = frozenset({"draft", "active", "retired"})
  15. FIELD_TYPES = frozenset(
  16. {"string", "integer", "number", "boolean", "date", "datetime", "object", "array"}
  17. )
  18. SECRET_MARKERS = ("password", "secret", "token", "credential", "api_key", "private_key")
  19. MAX_OBJECT_TYPES = 100
  20. MAX_COLLECTION_ITEMS = 500
  21. class DomainTemplateValidationError(ValueError):
  22. pass
  23. class DomainTemplateNotFound(LookupError):
  24. pass
  25. @dataclass(frozen=True)
  26. class GovernanceObjectType:
  27. """Runtime contract for one template-defined governance object type."""
  28. template_code: str
  29. type_code: str
  30. name: str
  31. stable_uid_prefix: str
  32. source_identity_fields: tuple[str, ...]
  33. fields: tuple[dict[str, Any], ...]
  34. lifecycle_status: str = "active"
  35. current_version: int = 1
  36. @classmethod
  37. def from_definition(
  38. cls,
  39. template_code: str,
  40. definition: dict[str, Any],
  41. *,
  42. current_version: int = 1,
  43. ):
  44. normalized = _normalize_object_types([definition])[0]
  45. return cls(
  46. template_code=_require_identifier(template_code, "template_code"),
  47. type_code=normalized["type_code"],
  48. name=normalized["name"],
  49. stable_uid_prefix=normalized["stable_uid_prefix"],
  50. source_identity_fields=tuple(normalized["source_identity_fields"]),
  51. fields=tuple(normalized["fields"]),
  52. lifecycle_status=normalized["lifecycle_status"],
  53. current_version=int(current_version),
  54. )
  55. def stable_uid(self, source_identity: dict[str, Any]) -> str:
  56. missing = [
  57. field
  58. for field in self.source_identity_fields
  59. if source_identity.get(field) is None
  60. or str(source_identity.get(field)).strip() == ""
  61. ]
  62. if missing:
  63. raise DomainTemplateValidationError(
  64. "source_identity is missing required fields: " + ", ".join(missing)
  65. )
  66. canonical_identity = {
  67. field: source_identity[field] for field in self.source_identity_fields
  68. }
  69. return stable_governance_object_uid(
  70. self.template_code,
  71. self.type_code,
  72. canonical_identity,
  73. )
  74. def _canonical_json(value: Any) -> str:
  75. return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"))
  76. def _content_hash(value: Any) -> str:
  77. return hashlib.sha256(_canonical_json(value).encode("utf-8")).hexdigest()
  78. def _require_identifier(value: Any, field: str) -> str:
  79. normalized = str(value or "").strip()
  80. if not IDENTIFIER_RE.fullmatch(normalized):
  81. raise DomainTemplateValidationError(
  82. f"{field} must be a lowercase snake_case identifier"
  83. )
  84. return normalized
  85. def _require_code(value: Any, field: str) -> str:
  86. normalized = str(value or "").strip()
  87. if not CODE_RE.fullmatch(normalized):
  88. raise DomainTemplateValidationError(f"{field} must be a stable code")
  89. return normalized
  90. def _require_text(value: Any, field: str, max_length: int = 200) -> str:
  91. normalized = str(value or "").strip()
  92. if not normalized or len(normalized) > max_length:
  93. raise DomainTemplateValidationError(
  94. f"{field} must be between 1 and {max_length} characters"
  95. )
  96. return normalized
  97. def _reject_secrets(value: Any, path: str = "template") -> None:
  98. if isinstance(value, dict):
  99. for key, nested in value.items():
  100. normalized_key = str(key).lower().replace("-", "_")
  101. if any(marker in normalized_key for marker in SECRET_MARKERS):
  102. raise DomainTemplateValidationError(
  103. f"secret-bearing field is not allowed at {path}.{key}"
  104. )
  105. _reject_secrets(nested, f"{path}.{key}")
  106. elif isinstance(value, list):
  107. for index, nested in enumerate(value):
  108. _reject_secrets(nested, f"{path}[{index}]")
  109. def _normalize_fields(fields: Any, object_code: str) -> list[dict[str, Any]]:
  110. if fields is None:
  111. fields = []
  112. if not isinstance(fields, list) or len(fields) > MAX_COLLECTION_ITEMS:
  113. raise DomainTemplateValidationError(f"{object_code}.fields must be a bounded list")
  114. normalized = []
  115. seen = set()
  116. for item in fields:
  117. if not isinstance(item, dict):
  118. raise DomainTemplateValidationError(f"{object_code}.fields items must be objects")
  119. code = _require_identifier(item.get("code"), f"{object_code}.field.code")
  120. if any(marker in code for marker in SECRET_MARKERS):
  121. raise DomainTemplateValidationError(
  122. f"secret-bearing field is not allowed at {object_code}.{code}"
  123. )
  124. if code in seen:
  125. raise DomainTemplateValidationError(f"duplicate field {object_code}.{code}")
  126. seen.add(code)
  127. field_type = str(item.get("type") or "string").strip().lower()
  128. if field_type not in FIELD_TYPES:
  129. raise DomainTemplateValidationError(
  130. f"{object_code}.{code}.type is unsupported"
  131. )
  132. normalized.append(
  133. {
  134. "code": code,
  135. "name": _require_text(
  136. item.get("name") or code, f"{object_code}.{code}.name"
  137. ),
  138. "type": field_type,
  139. "required": bool(item.get("required", False)),
  140. "description": str(item.get("description") or "").strip(),
  141. }
  142. )
  143. return sorted(normalized, key=lambda item: item["code"])
  144. def _normalize_object_types(items: Any) -> list[dict[str, Any]]:
  145. if not isinstance(items, list) or not items or len(items) > MAX_OBJECT_TYPES:
  146. raise DomainTemplateValidationError("object_types must be a non-empty bounded list")
  147. normalized = []
  148. seen = set()
  149. for item in items:
  150. if not isinstance(item, dict):
  151. raise DomainTemplateValidationError("object type must be an object")
  152. code = _require_identifier(item.get("type_code"), "object_types.type_code")
  153. if code in seen:
  154. raise DomainTemplateValidationError(f"duplicate object type: {code}")
  155. seen.add(code)
  156. source_identity_fields = item.get("source_identity_fields")
  157. if not isinstance(source_identity_fields, list) or not source_identity_fields:
  158. raise DomainTemplateValidationError(
  159. f"{code}.source_identity_fields must be a non-empty list"
  160. )
  161. source_identity_fields = [
  162. _require_identifier(value, f"{code}.source_identity_fields")
  163. for value in source_identity_fields
  164. ]
  165. if any(
  166. marker in field
  167. for field in source_identity_fields
  168. for marker in SECRET_MARKERS
  169. ):
  170. raise DomainTemplateValidationError(
  171. f"{code}.source_identity_fields cannot contain secret-bearing fields"
  172. )
  173. if len(set(source_identity_fields)) != len(source_identity_fields):
  174. raise DomainTemplateValidationError(
  175. f"{code}.source_identity_fields contains duplicates"
  176. )
  177. normalized.append(
  178. {
  179. "type_code": code,
  180. "name": _require_text(item.get("name"), f"{code}.name"),
  181. "description": str(item.get("description") or "").strip(),
  182. "stable_uid_prefix": _require_text(
  183. item.get("stable_uid_prefix"), f"{code}.stable_uid_prefix", 16
  184. ).upper(),
  185. "source_identity_fields": source_identity_fields,
  186. "fields": _normalize_fields(item.get("fields"), code),
  187. "lifecycle_status": str(
  188. item.get("lifecycle_status") or "active"
  189. ).strip().lower(),
  190. "extension": deepcopy(item.get("extension") or {}),
  191. }
  192. )
  193. if normalized[-1]["lifecycle_status"] not in LIFECYCLE_STATUSES:
  194. raise DomainTemplateValidationError(
  195. f"{code}.lifecycle_status is unsupported"
  196. )
  197. return sorted(normalized, key=lambda item: item["type_code"])
  198. def _normalize_named_collection(value: Any, name: str) -> list[dict[str, Any]]:
  199. if value is None:
  200. return []
  201. if not isinstance(value, list) or len(value) > MAX_COLLECTION_ITEMS:
  202. raise DomainTemplateValidationError(f"{name} must be a bounded list")
  203. normalized = []
  204. seen = set()
  205. for item in value:
  206. if not isinstance(item, dict):
  207. raise DomainTemplateValidationError(f"{name} items must be objects")
  208. code = _require_code(item.get("code"), f"{name}.code")
  209. if code in seen:
  210. raise DomainTemplateValidationError(f"duplicate {name} code: {code}")
  211. seen.add(code)
  212. normalized.append({**deepcopy(item), "code": code})
  213. return sorted(normalized, key=lambda item: item["code"])
  214. def normalize_domain_template(definition: Any) -> dict[str, Any]:
  215. if not isinstance(definition, dict):
  216. raise DomainTemplateValidationError("template definition must be an object")
  217. _reject_secrets(definition)
  218. lifecycle_status = str(definition.get("lifecycle_status") or "draft").strip().lower()
  219. if lifecycle_status not in LIFECYCLE_STATUSES:
  220. raise DomainTemplateValidationError("lifecycle_status is unsupported")
  221. seed_data = deepcopy(definition.get("seed_data") or [])
  222. if not isinstance(seed_data, list) or len(seed_data) > MAX_COLLECTION_ITEMS:
  223. raise DomainTemplateValidationError("seed_data must be a bounded list")
  224. normalized = {
  225. "template_code": _require_identifier(
  226. definition.get("template_code"), "template_code"
  227. ),
  228. "name": _require_text(definition.get("name"), "name"),
  229. "description": str(definition.get("description") or "").strip(),
  230. "lifecycle_status": lifecycle_status,
  231. "object_types": _normalize_object_types(definition.get("object_types")),
  232. "responsibility_roles": _normalize_named_collection(
  233. definition.get("responsibility_roles"), "responsibility_roles"
  234. ),
  235. "rules": _normalize_named_collection(definition.get("rules"), "rules"),
  236. "metrics": _normalize_named_collection(definition.get("metrics"), "metrics"),
  237. "seed_data": seed_data,
  238. "source_identity_contract": {
  239. "strategy": "uuid5",
  240. "canonicalization": "sorted-json",
  241. "required": True,
  242. },
  243. "version_contract": {
  244. "strategy": "append-only",
  245. "current_version_field": "current_version",
  246. "content_hash": "sha256",
  247. },
  248. "lifecycle_contract": {
  249. "statuses": sorted(LIFECYCLE_STATUSES),
  250. "transitions": {
  251. "draft": ["active", "retired"],
  252. "active": ["retired"],
  253. "retired": [],
  254. },
  255. },
  256. }
  257. normalized["content_hash"] = _content_hash(
  258. {key: value for key, value in normalized.items() if key != "content_hash"}
  259. )
  260. return normalized
  261. def stable_governance_object_uid(
  262. template_code: str,
  263. object_type: str,
  264. source_identity: dict[str, Any],
  265. ) -> str:
  266. template_code = _require_identifier(template_code, "template_code")
  267. object_type = _require_identifier(object_type, "object_type")
  268. if not isinstance(source_identity, dict) or not source_identity:
  269. raise DomainTemplateValidationError("source_identity must be a non-empty object")
  270. if any(value is None or str(value).strip() == "" for value in source_identity.values()):
  271. raise DomainTemplateValidationError("source_identity values must be non-empty")
  272. identity = f"{template_code}/{object_type}/{_canonical_json(source_identity)}"
  273. return str(uuid.uuid5(uuid.NAMESPACE_URL, f"dataops-governance:{identity}"))
  274. def diff_domain_templates(
  275. before: dict[str, Any] | None,
  276. after: dict[str, Any],
  277. ) -> dict[str, Any]:
  278. before = before or {}
  279. before_types = {
  280. item["type_code"]: item for item in before.get("object_types", [])
  281. }
  282. after_types = {item["type_code"]: item for item in after.get("object_types", [])}
  283. shared = set(before_types) & set(after_types)
  284. sections = ("responsibility_roles", "rules", "metrics", "seed_data")
  285. return {
  286. "added_object_types": sorted(set(after_types) - set(before_types)),
  287. "removed_object_types": sorted(set(before_types) - set(after_types)),
  288. "changed_object_types": sorted(
  289. code for code in shared if before_types[code] != after_types[code]
  290. ),
  291. "changed_sections": sorted(
  292. section
  293. for section in sections
  294. if before.get(section, []) != after.get(section, [])
  295. ),
  296. "lifecycle_changed": (
  297. bool(before)
  298. and before.get("lifecycle_status") != after.get("lifecycle_status")
  299. ),
  300. }
  301. class DomainTemplateService:
  302. def __init__(
  303. self,
  304. repository,
  305. *,
  306. uid_factory: Callable[[], str] = new_governance_uid,
  307. ):
  308. self.repository = repository
  309. self.uid_factory = uid_factory
  310. def list_templates(self):
  311. return self.repository.list_templates()
  312. def get_template(self, template_code: str):
  313. template_code = _require_identifier(template_code, "template_code")
  314. result = self.repository.get_template(template_code)
  315. if result is None:
  316. raise DomainTemplateNotFound(f"domain template {template_code} was not found")
  317. return result
  318. def list_imports(self, template_code: str):
  319. template_code = _require_identifier(template_code, "template_code")
  320. return self.repository.list_imports(template_code)
  321. def dry_run(self, definition: Any):
  322. normalized = normalize_domain_template(definition)
  323. before = self.repository.get_template(normalized["template_code"])
  324. return {
  325. "valid": True,
  326. "template": normalized,
  327. "diff": diff_domain_templates(before, normalized),
  328. }
  329. def import_template(
  330. self,
  331. definition: Any,
  332. *,
  333. actor_uid: str,
  334. operation: str = "import",
  335. target_version: int | None = None,
  336. ):
  337. normalized = normalize_domain_template(definition)
  338. before = self.repository.get_template(normalized["template_code"])
  339. version = int(before.get("current_version", 0)) + 1 if before else 1
  340. persisted = {**normalized, "current_version": version}
  341. audit = {
  342. "uid": self.uid_factory(),
  343. "template_code": normalized["template_code"],
  344. "operation": operation,
  345. "status": "applied",
  346. "version": version,
  347. "target_version": target_version,
  348. "before_state": deepcopy(before),
  349. "after_state": deepcopy(persisted),
  350. "diff": diff_domain_templates(before, normalized),
  351. "actor_uid": str(actor_uid),
  352. }
  353. return self.repository.apply_import(
  354. {
  355. "uid": self.uid_factory(),
  356. "template_code": normalized["template_code"],
  357. "version": version,
  358. "template": persisted,
  359. "audit": audit,
  360. }
  361. )
  362. def rollback(
  363. self,
  364. template_code: str,
  365. *,
  366. target_version: int,
  367. actor_uid: str,
  368. ):
  369. template_code = _require_identifier(template_code, "template_code")
  370. if not isinstance(target_version, int) or target_version < 1:
  371. raise DomainTemplateValidationError("target_version must be a positive integer")
  372. target = self.repository.get_version(template_code, target_version)
  373. if target is None:
  374. raise DomainTemplateNotFound(
  375. f"domain template {template_code} version {target_version} was not found"
  376. )
  377. definition = {
  378. key: deepcopy(value)
  379. for key, value in target.items()
  380. if key not in {"current_version", "content_hash"}
  381. }
  382. return self.import_template(
  383. definition,
  384. actor_uid=actor_uid,
  385. operation="rollback",
  386. target_version=target_version,
  387. )