governance_audit.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333
  1. """Governance audit normalization and tamper-evident evidence sealing."""
  2. from __future__ import annotations
  3. import hashlib
  4. import hmac
  5. import json
  6. from datetime import UTC, datetime
  7. from typing import Any
  8. from app.core.common.identifiers import new_governance_uid
  9. from app.core.data_source.redaction import redact_mapping
  10. AUDIT_CATEGORIES = (
  11. "authentication",
  12. "ingestion",
  13. "entity_resolution",
  14. "publication",
  15. "remediation",
  16. "knowledge_query",
  17. "authorization",
  18. "workflow_task",
  19. "data_product",
  20. "agent",
  21. "security_governance",
  22. )
  23. MAX_SEAL_EVENTS = 50_000
  24. class GovernanceAuditInvalid(ValueError):
  25. """Raised when an audit query or seal request is invalid."""
  26. class GovernanceAuditNotFound(LookupError):
  27. """Raised when a requested evidence seal does not exist."""
  28. def _utc(value: datetime, label: str) -> datetime:
  29. if not isinstance(value, datetime):
  30. raise GovernanceAuditInvalid(f"{label} must be a datetime")
  31. if value.tzinfo is None:
  32. raise GovernanceAuditInvalid(f"{label} must include a timezone")
  33. return value.astimezone(UTC)
  34. def _iso(value: datetime, label: str) -> str:
  35. return _utc(value, label).isoformat().replace("+00:00", "Z")
  36. def _canonical(value: Any) -> bytes:
  37. return json.dumps(
  38. value,
  39. ensure_ascii=False,
  40. sort_keys=True,
  41. separators=(",", ":"),
  42. ).encode("utf-8")
  43. def _digest(value: Any) -> str:
  44. return hashlib.sha256(_canonical(value)).hexdigest()
  45. def _required_text(value: Any, label: str, maximum: int) -> str:
  46. normalized = str(value or "").strip()
  47. if not normalized:
  48. raise GovernanceAuditInvalid(f"{label} is required")
  49. if len(normalized) > maximum:
  50. raise GovernanceAuditInvalid(f"{label} exceeds {maximum} characters")
  51. return normalized
  52. def _normalize_categories(categories=None) -> tuple[str, ...]:
  53. if categories is None:
  54. return AUDIT_CATEGORIES
  55. if isinstance(categories, str):
  56. categories = [categories]
  57. normalized = tuple(
  58. dict.fromkeys(str(item).strip() for item in categories if str(item).strip())
  59. )
  60. if not normalized:
  61. raise GovernanceAuditInvalid("at least one category is required")
  62. unsupported = sorted(set(normalized) - set(AUDIT_CATEGORIES))
  63. if unsupported:
  64. raise GovernanceAuditInvalid(
  65. f"unsupported category: {', '.join(unsupported)}"
  66. )
  67. return tuple(item for item in AUDIT_CATEGORIES if item in normalized)
  68. def _normalize_window(period_start, period_end) -> tuple[datetime, datetime]:
  69. start = _utc(period_start, "period_start")
  70. end = _utc(period_end, "period_end")
  71. if end <= start:
  72. raise GovernanceAuditInvalid("period_end must be after period_start")
  73. return start, end
  74. def _normalize_event(event: dict[str, Any]) -> dict[str, Any]:
  75. category = _required_text(event.get("category"), "category", 40)
  76. if category not in AUDIT_CATEGORIES:
  77. raise GovernanceAuditInvalid(f"unsupported category: {category}")
  78. occurred_at = event.get("occurred_at")
  79. return {
  80. "event_uid": _required_text(event.get("event_uid"), "event_uid", 160),
  81. "category": category,
  82. "action": _required_text(event.get("action"), "action", 80),
  83. "status": _required_text(event.get("status"), "status", 40),
  84. "actor_uid": str(event.get("actor_uid") or "")[:160] or None,
  85. "resource_type": _required_text(
  86. event.get("resource_type"), "resource_type", 80
  87. ),
  88. "resource_uid": str(event.get("resource_uid") or "")[:200] or None,
  89. "occurred_at": _iso(occurred_at, "occurred_at"),
  90. "safe_detail": redact_mapping(dict(event.get("safe_detail") or {})),
  91. }
  92. def _event_sort_key(event: dict[str, Any]) -> tuple[str, str]:
  93. return event["occurred_at"], event["event_uid"]
  94. def _root(events: list[dict[str, Any]]) -> str:
  95. ordered = sorted(events, key=_event_sort_key)
  96. return _digest([_digest(event) for event in ordered])
  97. def _signature_payload(seal: dict[str, Any]) -> dict[str, Any]:
  98. return {
  99. "uid": seal["uid"],
  100. "period_start": seal["period_start"],
  101. "period_end": seal["period_end"],
  102. "categories": list(seal["categories"]),
  103. "event_count": int(seal["event_count"]),
  104. "root_hash": seal["root_hash"],
  105. "key_version": seal["key_version"],
  106. "sealed_by": seal["sealed_by"],
  107. }
  108. class GovernanceAuditService:
  109. """Normalize distributed audit evidence and create signed evidence seals."""
  110. def __init__(
  111. self,
  112. repository,
  113. *,
  114. evidence_secret: str,
  115. key_version: str,
  116. uid_factory=new_governance_uid,
  117. now_factory=None,
  118. ):
  119. secret = str(evidence_secret or "").encode("utf-8")
  120. if len(secret) < 32:
  121. raise GovernanceAuditInvalid(
  122. "audit evidence secret must contain at least 32 bytes"
  123. )
  124. self.repository = repository
  125. self._secret = secret
  126. self.key_version = _required_text(key_version, "key_version", 80)
  127. self.uid_factory = uid_factory
  128. self.now_factory = now_factory or (lambda: datetime.now(UTC))
  129. def _events(self, *, categories, period_start, period_end):
  130. normalized_categories = _normalize_categories(categories)
  131. start, end = _normalize_window(period_start, period_end)
  132. events = [
  133. _normalize_event(event)
  134. for event in self.repository.fetch_events(
  135. categories=normalized_categories,
  136. period_start=start,
  137. period_end=end,
  138. )
  139. ]
  140. return normalized_categories, start, end, events
  141. def list_events(
  142. self,
  143. *,
  144. period_start,
  145. period_end,
  146. categories=None,
  147. action=None,
  148. status=None,
  149. page=1,
  150. page_size=20,
  151. ) -> dict[str, Any]:
  152. try:
  153. page = int(page)
  154. page_size = int(page_size)
  155. except (TypeError, ValueError) as exc:
  156. raise GovernanceAuditInvalid(
  157. "page and page_size must be integers"
  158. ) from exc
  159. if page < 1:
  160. raise GovernanceAuditInvalid("page must be at least 1")
  161. if page_size < 1 or page_size > 100:
  162. raise GovernanceAuditInvalid("page_size must be between 1 and 100")
  163. selected, start, end, events = self._events(
  164. categories=categories,
  165. period_start=period_start,
  166. period_end=period_end,
  167. )
  168. if action:
  169. events = [event for event in events if event["action"] == action]
  170. if status:
  171. events = [event for event in events if event["status"] == status]
  172. events.sort(key=_event_sort_key, reverse=True)
  173. offset = (page - 1) * page_size
  174. return {
  175. "period_start": _iso(start, "period_start"),
  176. "period_end": _iso(end, "period_end"),
  177. "categories": list(selected),
  178. "records": events[offset : offset + page_size],
  179. "page": page,
  180. "page_size": page_size,
  181. "total": len(events),
  182. }
  183. def coverage(self, *, period_start, period_end) -> dict[str, Any]:
  184. _, start, end, events = self._events(
  185. categories=AUDIT_CATEGORIES,
  186. period_start=period_start,
  187. period_end=period_end,
  188. )
  189. counts = dict.fromkeys(AUDIT_CATEGORIES, 0)
  190. latest = dict.fromkeys(AUDIT_CATEGORIES)
  191. for event in sorted(events, key=_event_sort_key):
  192. category = event["category"]
  193. counts[category] += 1
  194. latest[category] = event["occurred_at"]
  195. return {
  196. "period_start": _iso(start, "period_start"),
  197. "period_end": _iso(end, "period_end"),
  198. "categories": [
  199. {
  200. "category": category,
  201. "count": counts[category],
  202. "latest_at": latest[category],
  203. "available": counts[category] > 0,
  204. }
  205. for category in AUDIT_CATEGORIES
  206. ],
  207. }
  208. def _sign(self, seal: dict[str, Any]) -> str:
  209. return hmac.new(
  210. self._secret,
  211. _canonical(_signature_payload(seal)),
  212. hashlib.sha256,
  213. ).hexdigest()
  214. def create_seal(
  215. self,
  216. *,
  217. period_start,
  218. period_end,
  219. actor_uid,
  220. categories=None,
  221. ) -> dict[str, Any]:
  222. selected, start, end, events = self._events(
  223. categories=categories,
  224. period_start=period_start,
  225. period_end=period_end,
  226. )
  227. if end > _utc(self.now_factory(), "current_time"):
  228. raise GovernanceAuditInvalid(
  229. "evidence seal period_end cannot be in the future"
  230. )
  231. if len(events) > MAX_SEAL_EVENTS:
  232. raise GovernanceAuditInvalid(
  233. "evidence seal cannot contain more than 50,000 events"
  234. )
  235. seal = {
  236. "uid": self.uid_factory(),
  237. "period_start": _iso(start, "period_start"),
  238. "period_end": _iso(end, "period_end"),
  239. "categories": list(selected),
  240. "event_count": len(events),
  241. "root_hash": _root(events),
  242. "key_version": self.key_version,
  243. "sealed_by": _required_text(actor_uid, "actor_uid", 160),
  244. }
  245. seal["signature"] = self._sign(seal)
  246. return self.repository.save_seal(seal)
  247. def verify_seal(self, seal_uid) -> dict[str, Any]:
  248. seal = self.repository.get_seal(
  249. _required_text(seal_uid, "seal_uid", 160)
  250. )
  251. if seal is None:
  252. raise GovernanceAuditNotFound("evidence seal not found")
  253. expected_signature = self._sign(seal)
  254. if not hmac.compare_digest(
  255. expected_signature, str(seal.get("signature") or "")
  256. ):
  257. return {
  258. **seal,
  259. "integrity_status": "invalid_signature",
  260. "actual_event_count": None,
  261. "actual_root_hash": None,
  262. }
  263. categories, _, _, events = self._events(
  264. categories=seal["categories"],
  265. period_start=datetime.fromisoformat(
  266. seal["period_start"].replace("Z", "+00:00")
  267. ),
  268. period_end=datetime.fromisoformat(
  269. seal["period_end"].replace("Z", "+00:00")
  270. ),
  271. )
  272. actual_root = _root(events)
  273. intact = (
  274. list(categories) == list(seal["categories"])
  275. and len(events) == int(seal["event_count"])
  276. and hmac.compare_digest(actual_root, seal["root_hash"])
  277. )
  278. return {
  279. **seal,
  280. "integrity_status": "intact" if intact else "tampered",
  281. "actual_event_count": len(events),
  282. "actual_root_hash": actual_root,
  283. }
  284. def list_seals(self, *, limit=50):
  285. try:
  286. limit = int(limit)
  287. except (TypeError, ValueError) as exc:
  288. raise GovernanceAuditInvalid("limit must be an integer") from exc
  289. if limit < 1 or limit > 100:
  290. raise GovernanceAuditInvalid("limit must be between 1 and 100")
  291. return list(self.repository.list_seals(limit=limit))