test_governance_audit_postgres.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487
  1. from __future__ import annotations
  2. import os
  3. import uuid
  4. from datetime import UTC, datetime, timedelta
  5. import pytest
  6. from sqlalchemy import text
  7. pytestmark = pytest.mark.integration
  8. def test_governance_audit_covers_six_sources_and_detects_tampering(monkeypatch):
  9. database_url = os.environ.get("TEST_DATABASE_URL")
  10. if not database_url:
  11. pytest.skip("TEST_DATABASE_URL is required")
  12. monkeypatch.setenv("DATABASE_URL", database_url)
  13. from app import create_app, db
  14. from app.core.system.governance_audit import (
  15. AUDIT_CATEGORIES,
  16. GovernanceAuditService,
  17. )
  18. from app.core.system.governance_audit_repository import (
  19. SqlAlchemyGovernanceAuditRepository,
  20. )
  21. app = create_app()
  22. app.config.update(TESTING=True)
  23. suffix = uuid.uuid4().hex[:10]
  24. ids = {name: str(uuid.uuid4()) for name in (
  25. "user",
  26. "source",
  27. "job",
  28. "asset_left",
  29. "asset_right",
  30. "candidate",
  31. "review",
  32. "merge",
  33. "rollback",
  34. "ontology",
  35. "ontology_version",
  36. "ontology_run",
  37. "quality_profile",
  38. "quality_version",
  39. "quality_run",
  40. "issue",
  41. "timeline",
  42. "query",
  43. "correlation",
  44. )}
  45. now = datetime.now(UTC).replace(microsecond=0)
  46. start = now - timedelta(minutes=1)
  47. end = now + timedelta(minutes=1)
  48. try:
  49. with app.app_context():
  50. statements = [
  51. (
  52. """
  53. INSERT INTO public.users (
  54. id, username, display_name, password_hash, status
  55. ) VALUES (
  56. CAST(:user AS uuid), :username, :username,
  57. 'wp12-integration-hash', 'active'
  58. )
  59. """,
  60. {"user": ids["user"], "username": f"wp12-{suffix}"},
  61. ),
  62. (
  63. """
  64. INSERT INTO public.auth_audit_events (
  65. user_id, username, event_type, success, detail,
  66. created_at
  67. ) VALUES (
  68. CAST(:user AS uuid), :username, 'login', TRUE,
  69. 'safe integration fixture', :now
  70. )
  71. """,
  72. {
  73. "user": ids["user"],
  74. "username": f"wp12-{suffix}",
  75. "now": now,
  76. },
  77. ),
  78. (
  79. """
  80. INSERT INTO public.ingestion_sources (
  81. uid, source_type, name, config, permission_scope,
  82. status, created_by
  83. ) VALUES (
  84. CAST(:source AS uuid), 'database', :name,
  85. '{"password":"source-secret"}'::jsonb,
  86. '{}'::jsonb, 'active', :user
  87. )
  88. """,
  89. {
  90. "source": ids["source"],
  91. "name": f"WP12 source {suffix}",
  92. "user": ids["user"],
  93. },
  94. ),
  95. (
  96. """
  97. INSERT INTO public.ingestion_jobs (
  98. uid, source_uid, job_type, status, idempotency_key,
  99. parser_version, parameters, statistics, actor_uid,
  100. attempt_count, created_at
  101. ) VALUES (
  102. CAST(:job AS uuid), CAST(:source AS uuid),
  103. 'database_catalog', 'published', :key, 'wp12-v1',
  104. '{}'::jsonb, '{}'::jsonb, :user, 1, :now
  105. )
  106. """,
  107. {
  108. "job": ids["job"],
  109. "source": ids["source"],
  110. "key": suffix.ljust(64, "0"),
  111. "user": ids["user"],
  112. "now": now,
  113. },
  114. ),
  115. ]
  116. for asset_key, marker in (
  117. ("asset_left", "a"),
  118. ("asset_right", "b"),
  119. ):
  120. statements.append(
  121. (
  122. """
  123. INSERT INTO public.device_assets (
  124. uid, asset_type, name, status, current_version,
  125. content_hash, attributes, created_by, updated_by
  126. ) VALUES (
  127. CAST(:uid AS uuid), 'device', :name, 'active', 1,
  128. :hash, '{}'::jsonb, :user, :user
  129. )
  130. """,
  131. {
  132. "uid": ids[asset_key],
  133. "name": f"WP12 asset {marker} {suffix}",
  134. "hash": marker * 64,
  135. "user": ids["user"],
  136. },
  137. )
  138. )
  139. statements.extend(
  140. [
  141. (
  142. """
  143. INSERT INTO public.device_entity_match_candidates (
  144. uid, left_asset_uid, right_asset_uid,
  145. canonical_asset_uid, status, suggestion_source,
  146. confidence, explanation, evidence_uids,
  147. current_version, created_by
  148. ) VALUES (
  149. CAST(:candidate AS uuid),
  150. CAST(:left_asset AS uuid),
  151. CAST(:right_asset AS uuid),
  152. CAST(:left_asset AS uuid), 'rolled_back',
  153. 'manual', 1, '[]'::jsonb, '[]'::jsonb, 2, :user
  154. )
  155. """,
  156. {
  157. "candidate": ids["candidate"],
  158. "left_asset": ids["asset_left"],
  159. "right_asset": ids["asset_right"],
  160. "user": ids["user"],
  161. },
  162. ),
  163. (
  164. """
  165. INSERT INTO public.device_entity_match_reviews (
  166. uid, candidate_uid, version, decision, reason,
  167. actor_uid, created_at
  168. ) VALUES (
  169. CAST(:review AS uuid), CAST(:candidate AS uuid),
  170. 1, 'approve', 'safe integration fixture',
  171. :user, :now
  172. )
  173. """,
  174. {
  175. "review": ids["review"],
  176. "candidate": ids["candidate"],
  177. "user": ids["user"],
  178. "now": now,
  179. },
  180. ),
  181. (
  182. """
  183. INSERT INTO public.device_entity_merge_events (
  184. uid, candidate_uid, canonical_asset_uid,
  185. member_asset_uid, review_uid, snapshot,
  186. actor_uid, created_at
  187. ) VALUES (
  188. CAST(:merge AS uuid), CAST(:candidate AS uuid),
  189. CAST(:left_asset AS uuid),
  190. CAST(:right_asset AS uuid),
  191. CAST(:review AS uuid),
  192. '{"password":"merge-secret"}'::jsonb,
  193. :user, :now
  194. )
  195. """,
  196. {
  197. "merge": ids["merge"],
  198. "candidate": ids["candidate"],
  199. "left_asset": ids["asset_left"],
  200. "right_asset": ids["asset_right"],
  201. "review": ids["review"],
  202. "user": ids["user"],
  203. "now": now,
  204. },
  205. ),
  206. (
  207. """
  208. INSERT INTO public.device_entity_merge_rollbacks (
  209. uid, merge_uid, candidate_uid, reason, snapshot,
  210. actor_uid, created_at
  211. ) VALUES (
  212. CAST(:rollback AS uuid), CAST(:merge AS uuid),
  213. CAST(:candidate AS uuid), 'safe rollback',
  214. '{"password":"rollback-secret"}'::jsonb,
  215. :user, :now
  216. )
  217. """,
  218. {
  219. "rollback": ids["rollback"],
  220. "merge": ids["merge"],
  221. "candidate": ids["candidate"],
  222. "user": ids["user"],
  223. "now": now,
  224. },
  225. ),
  226. (
  227. """
  228. INSERT INTO public.ontologies (
  229. uid, code, name, owner_uid, status,
  230. draft_revision, created_by
  231. ) VALUES (
  232. CAST(:ontology AS uuid), :code, :name, :user,
  233. 'published', 1, :user
  234. )
  235. """,
  236. {
  237. "ontology": ids["ontology"],
  238. "code": f"WP12-{suffix}",
  239. "name": f"WP12 ontology {suffix}",
  240. "user": ids["user"],
  241. },
  242. ),
  243. (
  244. """
  245. INSERT INTO public.ontology_versions (
  246. uid, ontology_uid, version, status,
  247. graph_document, content_hash, created_by,
  248. created_at, published_at
  249. ) VALUES (
  250. CAST(:version AS uuid), CAST(:ontology AS uuid),
  251. 1, 'published', '{}'::jsonb, :hash, :user,
  252. :now, :now
  253. )
  254. """,
  255. {
  256. "version": ids["ontology_version"],
  257. "ontology": ids["ontology"],
  258. "hash": "c" * 64,
  259. "user": ids["user"],
  260. "now": now,
  261. },
  262. ),
  263. (
  264. """
  265. INSERT INTO public.ontology_publish_runs (
  266. uid, ontology_uid, version_uid, idempotency_key,
  267. status, actor_uid, created_at, finished_at
  268. ) VALUES (
  269. CAST(:run AS uuid), CAST(:ontology AS uuid),
  270. CAST(:version AS uuid), :key, 'published',
  271. :user, :now, :now
  272. )
  273. """,
  274. {
  275. "run": ids["ontology_run"],
  276. "ontology": ids["ontology"],
  277. "version": ids["ontology_version"],
  278. "key": f"wp12-{suffix}",
  279. "user": ids["user"],
  280. "now": now,
  281. },
  282. ),
  283. (
  284. """
  285. INSERT INTO public.device_quality_profiles (
  286. uid, code, name, created_by
  287. ) VALUES (
  288. CAST(:profile AS uuid), :code, :name, :user
  289. )
  290. """,
  291. {
  292. "profile": ids["quality_profile"],
  293. "code": f"wp12-quality-{suffix}",
  294. "name": f"WP12 quality {suffix}",
  295. "user": ids["user"],
  296. },
  297. ),
  298. (
  299. """
  300. INSERT INTO public.device_quality_profile_versions (
  301. uid, profile_uid, version, status, rules,
  302. content_hash, created_by, published_by,
  303. created_at, published_at
  304. ) VALUES (
  305. CAST(:version AS uuid), CAST(:profile AS uuid),
  306. 1, 'published', '[]'::jsonb, :hash,
  307. :user, :user, :now, :now
  308. )
  309. """,
  310. {
  311. "version": ids["quality_version"],
  312. "profile": ids["quality_profile"],
  313. "hash": "d" * 64,
  314. "user": ids["user"],
  315. "now": now,
  316. },
  317. ),
  318. (
  319. """
  320. INSERT INTO public.device_quality_runs (
  321. uid, policy_version_uid, policy_hash, source_uid,
  322. status, total_assets, total_violations, score,
  323. created_by, created_at
  324. ) VALUES (
  325. CAST(:run AS uuid), CAST(:version AS uuid), :hash,
  326. CAST(:source AS uuid), 'success', 1, 1, 0,
  327. :user, :now
  328. )
  329. """,
  330. {
  331. "run": ids["quality_run"],
  332. "version": ids["quality_version"],
  333. "hash": "d" * 64,
  334. "source": ids["source"],
  335. "user": ids["user"],
  336. "now": now,
  337. },
  338. ),
  339. (
  340. """
  341. INSERT INTO public.device_quality_issues (
  342. uid, issue_code, source_violation_uid,
  343. source_run_uid, rule_code, severity, priority,
  344. asset_uid, field_name, message, evidence,
  345. recurrence_key, occurrence_number, status,
  346. current_version, created_by, updated_by
  347. ) VALUES (
  348. CAST(:issue AS uuid), :code,
  349. CAST(:violation AS uuid), CAST(:run AS uuid),
  350. 'WP12-RULE', 'error', 'high',
  351. CAST(:asset AS uuid), 'name', 'safe message',
  352. '{"password":"issue-secret"}'::jsonb, :key,
  353. 1, 'closed', 1,
  354. CAST(:user AS uuid), CAST(:user AS uuid)
  355. )
  356. """,
  357. {
  358. "issue": ids["issue"],
  359. "code": f"WP12-{suffix}",
  360. "violation": str(uuid.uuid4()),
  361. "run": ids["quality_run"],
  362. "asset": ids["asset_left"],
  363. "key": "e" * 64,
  364. "user": ids["user"],
  365. },
  366. ),
  367. (
  368. """
  369. INSERT INTO public.device_quality_issue_timeline (
  370. uid, issue_uid, action, from_status, to_status,
  371. actor_uid, note, payload, created_at
  372. ) VALUES (
  373. CAST(:timeline AS uuid), CAST(:issue AS uuid),
  374. 'closed', 'pending_review', 'closed',
  375. CAST(:user AS uuid), 'must not be selected',
  376. '{"password":"timeline-secret"}'::jsonb, :now
  377. )
  378. """,
  379. {
  380. "timeline": ids["timeline"],
  381. "issue": ids["issue"],
  382. "user": ids["user"],
  383. "now": now,
  384. },
  385. ),
  386. (
  387. """
  388. INSERT INTO public.knowledge_query_audits (
  389. id, query_hash, user_id, roles,
  390. business_domain_uids, mode, retriever_counts,
  391. cited_points, degraded_components, correlation_id,
  392. latency_ms, created_at
  393. ) VALUES (
  394. CAST(:query AS uuid), :hash, CAST(:user AS uuid),
  395. '["admin"]'::jsonb, '[]'::jsonb, 'hybrid',
  396. CAST(:retriever_counts AS jsonb),
  397. '["point-1"]'::jsonb,
  398. '[]'::jsonb, CAST(:correlation AS uuid), 12, :now
  399. )
  400. """,
  401. {
  402. "query": ids["query"],
  403. "hash": "f" * 64,
  404. "user": ids["user"],
  405. "correlation": ids["correlation"],
  406. "retriever_counts": '{"canonical":1}',
  407. "now": now,
  408. },
  409. ),
  410. ]
  411. )
  412. for statement, params in statements:
  413. db.session.execute(text(statement), params)
  414. db.session.flush()
  415. service = GovernanceAuditService(
  416. SqlAlchemyGovernanceAuditRepository(db.session),
  417. evidence_secret="wp12-integration-secret-with-32-bytes",
  418. key_version="integration-v1",
  419. now_factory=lambda: end,
  420. )
  421. coverage = service.coverage(
  422. period_start=start,
  423. period_end=end,
  424. )
  425. assert [item["category"] for item in coverage["categories"]] == list(
  426. AUDIT_CATEGORIES
  427. )
  428. assert all(item["count"] >= 1 for item in coverage["categories"])
  429. events = service.list_events(
  430. period_start=start,
  431. period_end=end,
  432. page_size=100,
  433. )
  434. serialized = repr(events)
  435. for secret in (
  436. "source-secret",
  437. "merge-secret",
  438. "rollback-secret",
  439. "issue-secret",
  440. "timeline-secret",
  441. "must not be selected",
  442. ):
  443. assert secret not in serialized
  444. seal = service.create_seal(
  445. period_start=start,
  446. period_end=end,
  447. actor_uid=ids["user"],
  448. )
  449. assert service.verify_seal(seal["uid"])["integrity_status"] == (
  450. "intact"
  451. )
  452. nested = db.session.begin_nested()
  453. db.session.execute(
  454. text(
  455. """
  456. UPDATE public.auth_audit_events
  457. SET success = FALSE
  458. WHERE username = :username
  459. AND created_at = :now
  460. """
  461. ),
  462. {"username": f"wp12-{suffix}", "now": now},
  463. )
  464. db.session.flush()
  465. assert service.verify_seal(seal["uid"])["integrity_status"] == (
  466. "tampered"
  467. )
  468. nested.rollback()
  469. assert service.verify_seal(seal["uid"])["integrity_status"] == (
  470. "intact"
  471. )
  472. finally:
  473. with app.app_context():
  474. db.session.rollback()
  475. db.session.remove()