| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299 |
- """Admin-only governance audit and tamper-evident runtime evidence APIs."""
- from __future__ import annotations
- import hashlib
- import logging
- from datetime import UTC, datetime, timedelta
- from flask import current_app, g, jsonify, request
- from app import db
- from app.api.system import bp
- from app.config.config import is_placeholder_env_value
- from app.core.data_source.redaction import sanitize_exception
- from app.core.system.governance_audit import (
- GovernanceAuditInvalid,
- GovernanceAuditNotFound,
- GovernanceAuditService,
- )
- from app.core.system.governance_audit_repository import (
- SqlAlchemyGovernanceAuditRepository,
- )
- from app.models.result import failed, success
- logger = logging.getLogger(__name__)
- class GovernanceAuditUnavailable(RuntimeError):
- """Raised when the server cannot safely perform a sealing operation."""
- def _effective_evidence_secret() -> tuple[str, bool]:
- dedicated = str(
- current_app.config.get("AUDIT_EVIDENCE_SECRET") or ""
- ).strip()
- dedicated_ready = (
- len(dedicated.encode("utf-8")) >= 32
- and not is_placeholder_env_value(dedicated)
- )
- if dedicated_ready:
- return dedicated, True
- fallback = hashlib.sha256(
- (
- "dataops-wp12-local-audit:"
- + str(current_app.config.get("SECRET_KEY") or "")
- ).encode("utf-8")
- ).hexdigest()
- return fallback, False
- def _sealing_allowed(dedicated_ready: bool) -> bool:
- if dedicated_ready:
- return True
- if current_app.config.get("TESTING"):
- return True
- return (
- str(current_app.config.get("FLASK_ENV") or "").lower()
- != "production"
- )
- def _require_sealing_key() -> None:
- _secret, dedicated_ready = _effective_evidence_secret()
- if not _sealing_allowed(dedicated_ready):
- raise GovernanceAuditUnavailable(
- "dedicated audit evidence key is required"
- )
- def get_governance_audit_service():
- secret, _dedicated = _effective_evidence_secret()
- return GovernanceAuditService(
- SqlAlchemyGovernanceAuditRepository(db.session),
- evidence_secret=secret,
- key_version=str(
- current_app.config.get("AUDIT_EVIDENCE_KEY_VERSION")
- or "local-fallback-v1"
- ),
- )
- def get_security_checks():
- repository = SqlAlchemyGovernanceAuditRepository(db.session)
- snapshot = repository.security_snapshot()
- _secret, dedicated_ready = _effective_evidence_secret()
- credential_count = snapshot["credential_count"]
- encrypted_count = snapshot["encrypted_credential_count"]
- plaintext_count = snapshot["plaintext_source_config_count"]
- return {
- "seal_ready": _sealing_allowed(dedicated_ready),
- "production_key_ready": dedicated_ready,
- "assurance": (
- "签名封存用于检测篡改,不等于阻止数据库管理员修改;"
- "生产环境必须使用独立密钥并纳入企业密钥保管与轮换。"
- ),
- "checks": [
- {
- "code": "datasource_credential_encryption",
- "name": "数据源凭据密文存储",
- "status": (
- "passed"
- if credential_count == encrypted_count
- else "failed"
- ),
- "observed_count": encrypted_count,
- "expected_count": credential_count,
- },
- {
- "code": "legacy_plaintext_cleanup",
- "name": "采集源配置无明文秘密字段",
- "status": "passed" if plaintext_count == 0 else "failed",
- "observed_count": plaintext_count,
- "expected_count": 0,
- },
- {
- "code": "dedicated_evidence_key",
- "name": "独立审计封存密钥",
- "status": "passed" if dedicated_ready else "warning",
- "reason_code": (
- "dedicated_key_configured"
- if dedicated_ready
- else "local_fallback_key_in_use"
- ),
- },
- {
- "code": "safe_error_boundary",
- "name": "异常与日志脱敏边界",
- "status": "passed",
- },
- {
- "code": "security_response_headers",
- "name": "API 安全响应头",
- "status": "passed",
- },
- {
- "code": "audit_source_coverage",
- "name": "十一类关键操作审计源",
- "status": "passed",
- "expected_count": 11,
- },
- ],
- }
- def _timestamp(value, *, default=None):
- if value in (None, ""):
- if default is None:
- raise GovernanceAuditInvalid("timestamp is required")
- return default
- try:
- parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
- except (TypeError, ValueError) as exc:
- raise GovernanceAuditInvalid("timestamp must be ISO-8601") from exc
- if parsed.tzinfo is None:
- raise GovernanceAuditInvalid("timestamp must include a timezone")
- return parsed.astimezone(UTC)
- def _window(values):
- now = datetime.now(UTC)
- return (
- _timestamp(
- values.get("period_start"),
- default=now - timedelta(days=30),
- ),
- _timestamp(values.get("period_end"), default=now),
- )
- def _categories(values):
- if hasattr(values, "getlist"):
- result = values.getlist("category")
- if result:
- return result
- return values.get("categories") or values.get("category")
- def _safe_error(error, status=503):
- logger.error(
- "governance audit request failed: %s",
- sanitize_exception(error, limit=300),
- )
- return (
- jsonify(
- failed(
- "审计与运行证据暂不可用",
- code=status,
- error={"code": "GOVERNANCE_AUDIT_UNAVAILABLE"},
- )
- ),
- status,
- )
- def _execute(operation):
- try:
- return jsonify(success(operation()))
- except GovernanceAuditInvalid as exc:
- return (
- jsonify(
- failed(
- str(exc),
- code=400,
- error={"code": "GOVERNANCE_AUDIT_INVALID"},
- )
- ),
- 400,
- )
- except GovernanceAuditNotFound:
- return (
- jsonify(
- failed(
- "审计证据封存不存在",
- code=404,
- error={"code": "GOVERNANCE_AUDIT_NOT_FOUND"},
- )
- ),
- 404,
- )
- except GovernanceAuditUnavailable:
- return _safe_error(
- GovernanceAuditUnavailable("audit sealing is not configured")
- )
- except Exception as exc:
- db.session.rollback()
- return _safe_error(exc)
- @bp.route("/governance-audit/security-checks", methods=["GET"])
- def governance_audit_security_checks():
- return _execute(get_security_checks)
- @bp.route("/governance-audit/coverage", methods=["GET"])
- def governance_audit_coverage():
- def operation():
- start, end = _window(request.args)
- return get_governance_audit_service().coverage(
- period_start=start,
- period_end=end,
- )
- return _execute(operation)
- @bp.route("/governance-audit/events", methods=["GET"])
- def governance_audit_events():
- def operation():
- start, end = _window(request.args)
- return get_governance_audit_service().list_events(
- period_start=start,
- period_end=end,
- categories=_categories(request.args),
- action=request.args.get("action"),
- status=request.args.get("status"),
- page=request.args.get("page", 1),
- page_size=request.args.get("page_size", 20),
- )
- return _execute(operation)
- @bp.route("/governance-audit/seals", methods=["GET"])
- def governance_audit_seals():
- return _execute(
- lambda: get_governance_audit_service().list_seals(
- limit=request.args.get("limit", 50)
- )
- )
- @bp.route("/governance-audit/seals", methods=["POST"])
- def create_governance_audit_seal():
- def operation():
- _require_sealing_key()
- payload = request.get_json(silent=True) or {}
- start, end = _window(payload)
- seal = get_governance_audit_service().create_seal(
- period_start=start,
- period_end=end,
- categories=_categories(payload),
- actor_uid=g.current_user["id"],
- )
- db.session.commit()
- return seal
- return _execute(operation)
- @bp.route(
- "/governance-audit/seals/<seal_uid>/verify",
- methods=["POST"],
- )
- def verify_governance_audit_seal(seal_uid):
- def operation():
- _require_sealing_key()
- return get_governance_audit_service().verify_seal(seal_uid)
- return _execute(operation)
|