governance_audit.py 8.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299
  1. """Admin-only governance audit and tamper-evident runtime evidence APIs."""
  2. from __future__ import annotations
  3. import hashlib
  4. import logging
  5. from datetime import UTC, datetime, timedelta
  6. from flask import current_app, g, jsonify, request
  7. from app import db
  8. from app.api.system import bp
  9. from app.config.config import is_placeholder_env_value
  10. from app.core.data_source.redaction import sanitize_exception
  11. from app.core.system.governance_audit import (
  12. GovernanceAuditInvalid,
  13. GovernanceAuditNotFound,
  14. GovernanceAuditService,
  15. )
  16. from app.core.system.governance_audit_repository import (
  17. SqlAlchemyGovernanceAuditRepository,
  18. )
  19. from app.models.result import failed, success
  20. logger = logging.getLogger(__name__)
  21. class GovernanceAuditUnavailable(RuntimeError):
  22. """Raised when the server cannot safely perform a sealing operation."""
  23. def _effective_evidence_secret() -> tuple[str, bool]:
  24. dedicated = str(
  25. current_app.config.get("AUDIT_EVIDENCE_SECRET") or ""
  26. ).strip()
  27. dedicated_ready = (
  28. len(dedicated.encode("utf-8")) >= 32
  29. and not is_placeholder_env_value(dedicated)
  30. )
  31. if dedicated_ready:
  32. return dedicated, True
  33. fallback = hashlib.sha256(
  34. (
  35. "dataops-wp12-local-audit:"
  36. + str(current_app.config.get("SECRET_KEY") or "")
  37. ).encode("utf-8")
  38. ).hexdigest()
  39. return fallback, False
  40. def _sealing_allowed(dedicated_ready: bool) -> bool:
  41. if dedicated_ready:
  42. return True
  43. if current_app.config.get("TESTING"):
  44. return True
  45. return (
  46. str(current_app.config.get("FLASK_ENV") or "").lower()
  47. != "production"
  48. )
  49. def _require_sealing_key() -> None:
  50. _secret, dedicated_ready = _effective_evidence_secret()
  51. if not _sealing_allowed(dedicated_ready):
  52. raise GovernanceAuditUnavailable(
  53. "dedicated audit evidence key is required"
  54. )
  55. def get_governance_audit_service():
  56. secret, _dedicated = _effective_evidence_secret()
  57. return GovernanceAuditService(
  58. SqlAlchemyGovernanceAuditRepository(db.session),
  59. evidence_secret=secret,
  60. key_version=str(
  61. current_app.config.get("AUDIT_EVIDENCE_KEY_VERSION")
  62. or "local-fallback-v1"
  63. ),
  64. )
  65. def get_security_checks():
  66. repository = SqlAlchemyGovernanceAuditRepository(db.session)
  67. snapshot = repository.security_snapshot()
  68. _secret, dedicated_ready = _effective_evidence_secret()
  69. credential_count = snapshot["credential_count"]
  70. encrypted_count = snapshot["encrypted_credential_count"]
  71. plaintext_count = snapshot["plaintext_source_config_count"]
  72. return {
  73. "seal_ready": _sealing_allowed(dedicated_ready),
  74. "production_key_ready": dedicated_ready,
  75. "assurance": (
  76. "签名封存用于检测篡改,不等于阻止数据库管理员修改;"
  77. "生产环境必须使用独立密钥并纳入企业密钥保管与轮换。"
  78. ),
  79. "checks": [
  80. {
  81. "code": "datasource_credential_encryption",
  82. "name": "数据源凭据密文存储",
  83. "status": (
  84. "passed"
  85. if credential_count == encrypted_count
  86. else "failed"
  87. ),
  88. "observed_count": encrypted_count,
  89. "expected_count": credential_count,
  90. },
  91. {
  92. "code": "legacy_plaintext_cleanup",
  93. "name": "采集源配置无明文秘密字段",
  94. "status": "passed" if plaintext_count == 0 else "failed",
  95. "observed_count": plaintext_count,
  96. "expected_count": 0,
  97. },
  98. {
  99. "code": "dedicated_evidence_key",
  100. "name": "独立审计封存密钥",
  101. "status": "passed" if dedicated_ready else "warning",
  102. "reason_code": (
  103. "dedicated_key_configured"
  104. if dedicated_ready
  105. else "local_fallback_key_in_use"
  106. ),
  107. },
  108. {
  109. "code": "safe_error_boundary",
  110. "name": "异常与日志脱敏边界",
  111. "status": "passed",
  112. },
  113. {
  114. "code": "security_response_headers",
  115. "name": "API 安全响应头",
  116. "status": "passed",
  117. },
  118. {
  119. "code": "audit_source_coverage",
  120. "name": "十一类关键操作审计源",
  121. "status": "passed",
  122. "expected_count": 11,
  123. },
  124. ],
  125. }
  126. def _timestamp(value, *, default=None):
  127. if value in (None, ""):
  128. if default is None:
  129. raise GovernanceAuditInvalid("timestamp is required")
  130. return default
  131. try:
  132. parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
  133. except (TypeError, ValueError) as exc:
  134. raise GovernanceAuditInvalid("timestamp must be ISO-8601") from exc
  135. if parsed.tzinfo is None:
  136. raise GovernanceAuditInvalid("timestamp must include a timezone")
  137. return parsed.astimezone(UTC)
  138. def _window(values):
  139. now = datetime.now(UTC)
  140. return (
  141. _timestamp(
  142. values.get("period_start"),
  143. default=now - timedelta(days=30),
  144. ),
  145. _timestamp(values.get("period_end"), default=now),
  146. )
  147. def _categories(values):
  148. if hasattr(values, "getlist"):
  149. result = values.getlist("category")
  150. if result:
  151. return result
  152. return values.get("categories") or values.get("category")
  153. def _safe_error(error, status=503):
  154. logger.error(
  155. "governance audit request failed: %s",
  156. sanitize_exception(error, limit=300),
  157. )
  158. return (
  159. jsonify(
  160. failed(
  161. "审计与运行证据暂不可用",
  162. code=status,
  163. error={"code": "GOVERNANCE_AUDIT_UNAVAILABLE"},
  164. )
  165. ),
  166. status,
  167. )
  168. def _execute(operation):
  169. try:
  170. return jsonify(success(operation()))
  171. except GovernanceAuditInvalid as exc:
  172. return (
  173. jsonify(
  174. failed(
  175. str(exc),
  176. code=400,
  177. error={"code": "GOVERNANCE_AUDIT_INVALID"},
  178. )
  179. ),
  180. 400,
  181. )
  182. except GovernanceAuditNotFound:
  183. return (
  184. jsonify(
  185. failed(
  186. "审计证据封存不存在",
  187. code=404,
  188. error={"code": "GOVERNANCE_AUDIT_NOT_FOUND"},
  189. )
  190. ),
  191. 404,
  192. )
  193. except GovernanceAuditUnavailable:
  194. return _safe_error(
  195. GovernanceAuditUnavailable("audit sealing is not configured")
  196. )
  197. except Exception as exc:
  198. db.session.rollback()
  199. return _safe_error(exc)
  200. @bp.route("/governance-audit/security-checks", methods=["GET"])
  201. def governance_audit_security_checks():
  202. return _execute(get_security_checks)
  203. @bp.route("/governance-audit/coverage", methods=["GET"])
  204. def governance_audit_coverage():
  205. def operation():
  206. start, end = _window(request.args)
  207. return get_governance_audit_service().coverage(
  208. period_start=start,
  209. period_end=end,
  210. )
  211. return _execute(operation)
  212. @bp.route("/governance-audit/events", methods=["GET"])
  213. def governance_audit_events():
  214. def operation():
  215. start, end = _window(request.args)
  216. return get_governance_audit_service().list_events(
  217. period_start=start,
  218. period_end=end,
  219. categories=_categories(request.args),
  220. action=request.args.get("action"),
  221. status=request.args.get("status"),
  222. page=request.args.get("page", 1),
  223. page_size=request.args.get("page_size", 20),
  224. )
  225. return _execute(operation)
  226. @bp.route("/governance-audit/seals", methods=["GET"])
  227. def governance_audit_seals():
  228. return _execute(
  229. lambda: get_governance_audit_service().list_seals(
  230. limit=request.args.get("limit", 50)
  231. )
  232. )
  233. @bp.route("/governance-audit/seals", methods=["POST"])
  234. def create_governance_audit_seal():
  235. def operation():
  236. _require_sealing_key()
  237. payload = request.get_json(silent=True) or {}
  238. start, end = _window(payload)
  239. seal = get_governance_audit_service().create_seal(
  240. period_start=start,
  241. period_end=end,
  242. categories=_categories(payload),
  243. actor_uid=g.current_user["id"],
  244. )
  245. db.session.commit()
  246. return seal
  247. return _execute(operation)
  248. @bp.route(
  249. "/governance-audit/seals/<seal_uid>/verify",
  250. methods=["POST"],
  251. )
  252. def verify_governance_audit_seal(seal_uid):
  253. def operation():
  254. _require_sealing_key()
  255. return get_governance_audit_service().verify_seal(seal_uid)
  256. return _execute(operation)