Browse Source

feat: establish cross-domain security governance

马小龙 2 weeks ago
parent
commit
bcc0982d9b
52 changed files with 5903 additions and 64 deletions
  1. 1 0
      app/api/system/__init__.py
  2. 2 2
      app/api/system/governance_audit.py
  3. 280 0
      app/api/system/security_governance.py
  4. 61 7
      app/api/system/users.py
  5. 3 0
      app/config/config.py
  6. 8 1
      app/core/governance/work_center.py
  7. 5 0
      app/core/system/governance_audit.py
  8. 68 0
      app/core/system/governance_audit_repository.py
  9. 32 0
      app/core/system/permissions.py
  10. 61 0
      app/core/system/security_delivery.py
  11. 882 0
      app/core/system/security_governance.py
  12. 362 0
      app/core/system/security_governance_repository.py
  13. 1 0
      deploy/docker/.env.example
  14. 1 0
      deploy/docker/docker-compose.yml
  15. 1 0
      deployment/app/api/system/__init__.py
  16. 2 2
      deployment/app/api/system/governance_audit.py
  17. 280 0
      deployment/app/api/system/security_governance.py
  18. 61 7
      deployment/app/api/system/users.py
  19. 3 0
      deployment/app/config/config.py
  20. 8 1
      deployment/app/core/governance/work_center.py
  21. 5 0
      deployment/app/core/system/governance_audit.py
  22. 68 0
      deployment/app/core/system/governance_audit_repository.py
  23. 32 0
      deployment/app/core/system/permissions.py
  24. 61 0
      deployment/app/core/system/security_delivery.py
  25. 882 0
      deployment/app/core/system/security_governance.py
  26. 362 0
      deployment/app/core/system/security_governance_repository.py
  27. 2 0
      deployment/dataops.env
  28. 193 0
      deployment/migrations/versions/20260802_460_security_governance.py
  29. 14 5
      docs/DATAOPS_PHASE2_3_MONTH_DEVELOPMENT_PLAN_20260730.md
  30. 12 12
      docs/FUNCTION_MODULE_CENSUS_20260726.md
  31. 24 1
      docs/architecture/DATA_MODEL.md
  32. 673 1
      docs/architecture/OPENAPI.yaml
  33. 49 0
      docs/phase2/P2_WP10_SECURITY_FOUNDATION.md
  34. 15 0
      docs/phase2/p2-wp10-hardening/context.md
  35. 12 0
      docs/phase2/p2-wp10-hardening/diagrams/security-control-plane-after.mmd
  36. 6 0
      docs/phase2/p2-wp10-hardening/diagrams/security-control-plane-before.mmd
  37. 20 0
      docs/phase2/p2-wp10-hardening/hardening.json
  38. 17 0
      docs/phase2/p2-wp10-hardening/hardening.md
  39. 8 0
      docs/phase2/p2-wp10-hardening/implementation/central-control-plane.md
  40. 17 0
      docs/phase2/p2-wp10-hardening/proposals/security-control-plane.md
  41. 33 0
      frontend/src/api/securityGovernance.js
  42. 1 0
      frontend/src/router/routes.js
  43. 320 21
      frontend/src/views/dataGovernance/dataSecurity/index.vue
  44. 29 1
      frontend/src/views/systemManage/governanceAudit/governanceAuditModel.js
  45. 1 1
      frontend/src/views/systemManage/governanceAudit/index.vue
  46. 193 0
      migrations/versions/20260802_460_security_governance.py
  47. 10 2
      tests/integration/test_governance_audit_postgres.py
  48. 151 0
      tests/integration/test_security_governance_postgres.py
  49. 386 0
      tests/security/test_security_governance.py
  50. 10 0
      tests/system/test_governance_audit_frontend_contract.py
  51. 95 0
      tests/test_security_governance_api.py
  52. 80 0
      tests/test_security_governance_contract.py

+ 1 - 0
app/api/system/__init__.py

@@ -7,6 +7,7 @@ bp = Blueprint("system", __name__)
 from app.api.system import governance_audit  # noqa: E402, F401
 from app.api.system import responsibilities  # noqa: E402, F401
 from app.api.system import routes  # noqa: E402, F401
+from app.api.system import security_governance  # noqa: E402, F401
 from app.api.system import users  # noqa: E402, F401
 from app.api.system import workbench  # noqa: E402, F401
 from app.api.system import work_center  # noqa: E402, F401

+ 2 - 2
app/api/system/governance_audit.py

@@ -134,9 +134,9 @@ def get_security_checks():
             },
             {
                 "code": "audit_source_coverage",
-                "name": "类关键操作审计源",
+                "name": "十一类关键操作审计源",
                 "status": "passed",
-                "expected_count": 6,
+                "expected_count": 11,
             },
         ],
     }

+ 280 - 0
app/api/system/security_governance.py

@@ -0,0 +1,280 @@
+"""Cross-domain data security and security engineering APIs."""
+
+from __future__ import annotations
+
+from datetime import UTC, datetime
+
+from flask import current_app, g, jsonify, request
+
+from app import db
+from app.api.system import bp
+from app.core.system.permissions import (
+    SECURITY_GOVERNANCE_MANAGE,
+    SECURITY_GOVERNANCE_OPERATE,
+    SECURITY_GOVERNANCE_READ,
+    require_permissions,
+)
+from app.core.system.security_delivery import HttpsWebhookSyslogTransport
+from app.core.system.security_governance import SecurityGovernanceService
+from app.core.system.security_governance_repository import (
+    SqlAlchemySecurityGovernanceRepository,
+    WorkCenterSecurityApprovalGateway,
+)
+from app.models.result import failed, success
+
+
+def _service():
+    allowlist = {
+        item.strip().lower()
+        for item in str(current_app.config.get("SECURITY_SIEM_HOST_ALLOWLIST") or "").split(",")
+        if item.strip()
+    }
+    return SecurityGovernanceService(
+        SqlAlchemySecurityGovernanceRepository(db.session),
+        approval_gateway=WorkCenterSecurityApprovalGateway(db.session),
+        siem_transport=HttpsWebhookSyslogTransport(),
+        siem_host_allowlist=allowlist,
+        commit=db.session.commit,
+        rollback=db.session.rollback,
+    )
+
+
+def _version():
+    value = str(request.headers.get("If-Match") or "").strip().removeprefix("W/").strip('"')
+    if not value.isdigit():
+        raise ValueError("missing valid If-Match version")
+    return int(value)
+
+
+def _etag(response, record):
+    if record.get("current_version"):
+        response.headers["ETag"] = f'"{record["current_version"]}"'
+    return response
+
+
+def _execute(operation, *, created=False, versioned=False):
+    try:
+        result = operation()
+        response = jsonify(success(result, code=201 if created else 200))
+        if versioned and isinstance(result, dict):
+            response = _etag(response, result)
+        return (response, 201) if created else response
+    except Exception as exc:
+        db.session.rollback()
+        if isinstance(exc, LookupError):
+            status = 404
+        elif isinstance(exc, PermissionError):
+            status = 403
+        elif isinstance(exc, RuntimeError):
+            status = 409
+        else:
+            status = 400
+        return jsonify(failed(str(exc), code=status)), status
+
+
+@bp.route("/security-governance/dashboard", methods=["GET"])
+@require_permissions(SECURITY_GOVERNANCE_READ)
+def security_dashboard():
+    return _execute(_service().dashboard)
+
+
+@bp.route("/security-governance/profiles", methods=["GET"])
+@require_permissions(SECURITY_GOVERNANCE_READ)
+def security_list_profiles():
+    return _execute(lambda: _service().list_profiles(business_domain_uid=request.args.get("business_domain_uid")))
+
+
+@bp.route("/security-governance/profiles", methods=["POST"])
+@require_permissions(SECURITY_GOVERNANCE_OPERATE)
+def security_create_profile():
+    return _execute(
+        lambda: _service().create_classification_profile(request.get_json(silent=True) or {}, actor_uid=g.current_user["id"]),
+        created=True,
+    )
+
+
+@bp.route("/security-governance/scans", methods=["GET"])
+@require_permissions(SECURITY_GOVERNANCE_READ)
+def security_list_scans():
+    return _execute(lambda: _service().list_classification_scans(business_domain_uid=request.args.get("business_domain_uid")))
+
+
+@bp.route("/security-governance/scans", methods=["POST"])
+@require_permissions(SECURITY_GOVERNANCE_OPERATE)
+def security_create_scan():
+    return _execute(
+        lambda: _service().scan_sensitive_sample(request.get_json(silent=True) or {}, actor_uid=g.current_user["id"]),
+        created=True,
+    )
+
+
+@bp.route("/security-governance/findings", methods=["GET"])
+@require_permissions(SECURITY_GOVERNANCE_READ)
+def security_findings():
+    return _execute(lambda: _service().list_classification_findings(status=request.args.get("status")))
+
+
+@bp.route("/security-governance/findings/<finding_uid>/review", methods=["POST"])
+@require_permissions(SECURITY_GOVERNANCE_MANAGE)
+def security_review_finding(finding_uid):
+    return _execute(
+        lambda: _service().review_classification_finding(
+            finding_uid, request.get_json(silent=True) or {}, expected_version=_version(), actor_uid=g.current_user["id"]
+        ),
+        versioned=True,
+    )
+
+
+@bp.route("/security-governance/access-policies", methods=["GET"])
+@require_permissions(SECURITY_GOVERNANCE_READ)
+def security_list_access_policies():
+    return _execute(lambda: _service().list_access_policies(business_domain_uid=request.args.get("business_domain_uid"), status=request.args.get("status")))
+
+
+@bp.route("/security-governance/access-policies", methods=["POST"])
+@require_permissions(SECURITY_GOVERNANCE_OPERATE)
+def security_create_access_policy():
+    return _execute(
+        lambda: _service().create_access_policy(request.get_json(silent=True) or {}, actor_uid=g.current_user["id"]),
+        created=True,
+    )
+
+
+@bp.route("/security-governance/access-decisions", methods=["GET"])
+@require_permissions(SECURITY_GOVERNANCE_READ)
+def security_access_decisions():
+    return _execute(lambda: _service().list_access_decisions(decision=request.args.get("decision")))
+
+
+@bp.route("/security-governance/access/evaluate", methods=["POST"])
+@require_permissions(SECURITY_GOVERNANCE_OPERATE)
+def security_evaluate_access():
+    body = request.get_json(silent=True) or {}
+    body["user_uid"] = g.current_user["id"]
+    body["roles"] = list(g.current_user["roles"])
+    return _execute(lambda: _service().evaluate_access(body), created=True)
+
+
+@bp.route("/security-governance/egress", methods=["GET"])
+@require_permissions(SECURITY_GOVERNANCE_READ)
+def security_list_egress():
+    return _execute(lambda: _service().list_egress_requests(status=request.args.get("status")))
+
+
+@bp.route("/security-governance/egress", methods=["POST"])
+@require_permissions(SECURITY_GOVERNANCE_OPERATE)
+def security_create_egress():
+    return _execute(
+        lambda: _service().submit_egress_request(request.get_json(silent=True) or {}, actor_uid=g.current_user["id"]),
+        created=True,
+        versioned=True,
+    )
+
+
+@bp.route("/security-governance/egress/<request_uid>/reconcile", methods=["POST"])
+@require_permissions(SECURITY_GOVERNANCE_MANAGE)
+def security_reconcile_egress(request_uid):
+    return _execute(
+        lambda: _service().reconcile_egress_request(request_uid, expected_version=_version(), actor_uid=g.current_user["id"]),
+        versioned=True,
+    )
+
+
+@bp.route("/security-governance/retention", methods=["GET"])
+@require_permissions(SECURITY_GOVERNANCE_READ)
+def security_list_retention():
+    return _execute(_service().list_retention_policies)
+
+
+@bp.route("/security-governance/retention", methods=["POST"])
+@require_permissions(SECURITY_GOVERNANCE_MANAGE)
+def security_create_retention():
+    return _execute(
+        lambda: _service().create_retention_policy(request.get_json(silent=True) or {}, actor_uid=g.current_user["id"]),
+        created=True,
+    )
+
+
+@bp.route("/security-governance/retention/candidates", methods=["GET"])
+@require_permissions(SECURITY_GOVERNANCE_MANAGE)
+def security_retention_candidates():
+    value = request.args.get("as_of") or datetime.now(UTC).isoformat()
+    return _execute(lambda: _service().retention_candidates(as_of=value, limit=int(request.args.get("limit", 100))))
+
+
+@bp.route("/security-governance/siem/sinks", methods=["GET"])
+@require_permissions(SECURITY_GOVERNANCE_MANAGE)
+def security_list_siem_sinks():
+    return _execute(_service().list_siem_sinks)
+
+
+@bp.route("/security-governance/siem/sinks", methods=["POST"])
+@require_permissions(SECURITY_GOVERNANCE_MANAGE)
+def security_create_siem_sink():
+    return _execute(
+        lambda: _service().create_siem_sink(request.get_json(silent=True) or {}, actor_uid=g.current_user["id"]),
+        created=True,
+    )
+
+
+@bp.route("/security-governance/siem/deliveries", methods=["GET"])
+@require_permissions(SECURITY_GOVERNANCE_MANAGE)
+def security_siem_deliveries():
+    return _execute(lambda: _service().list_siem_deliveries(sink_uid=request.args.get("sink_uid")))
+
+
+@bp.route("/security-governance/siem/sinks/<sink_uid>/dispatch", methods=["POST"])
+@require_permissions(SECURITY_GOVERNANCE_MANAGE)
+def security_siem_dispatch(sink_uid):
+    return _execute(lambda: _service().dispatch_siem_events(sink_uid, request.get_json(silent=True) or {}, actor_uid=g.current_user["id"]), created=True)
+
+
+@bp.route("/security-governance/sboms", methods=["GET"])
+@require_permissions(SECURITY_GOVERNANCE_READ)
+def security_list_sboms():
+    return _execute(_service().list_sboms)
+
+
+@bp.route("/security-governance/sboms", methods=["POST"])
+@require_permissions(SECURITY_GOVERNANCE_MANAGE)
+def security_register_sbom():
+    return _execute(lambda: _service().register_sbom(request.get_json(silent=True) or {}, actor_uid=g.current_user["id"]), created=True)
+
+
+@bp.route("/security-governance/sboms/<sbom_uid>/vulnerabilities", methods=["POST"])
+@require_permissions(SECURITY_GOVERNANCE_MANAGE)
+def security_import_vulnerabilities(sbom_uid):
+    return _execute(lambda: _service().ingest_vulnerabilities(sbom_uid, request.get_json(silent=True) or {}, actor_uid=g.current_user["id"]), created=True)
+
+
+@bp.route("/security-governance/vulnerabilities", methods=["GET"])
+@require_permissions(SECURITY_GOVERNANCE_READ)
+def security_vulnerabilities():
+    return _execute(lambda: _service().list_vulnerabilities(status=request.args.get("status"), severity=request.args.get("severity")))
+
+
+def _vulnerability_change(finding_uid, action):
+    service = _service()
+    method = getattr(service, f"{action}_vulnerability")
+    return _execute(
+        lambda: method(finding_uid, request.get_json(silent=True) or {}, expected_version=_version(), actor_uid=g.current_user["id"]),
+        versioned=True,
+    )
+
+
+@bp.route("/security-governance/vulnerabilities/<finding_uid>/assign", methods=["POST"])
+@require_permissions(SECURITY_GOVERNANCE_MANAGE)
+def security_assign_vulnerability(finding_uid):
+    return _vulnerability_change(finding_uid, "assign")
+
+
+@bp.route("/security-governance/vulnerabilities/<finding_uid>/resolve", methods=["POST"])
+@require_permissions(SECURITY_GOVERNANCE_MANAGE)
+def security_resolve_vulnerability(finding_uid):
+    return _vulnerability_change(finding_uid, "resolve")
+
+
+@bp.route("/security-governance/vulnerabilities/<finding_uid>/close", methods=["POST"])
+@require_permissions(SECURITY_GOVERNANCE_MANAGE)
+def security_close_vulnerability(finding_uid):
+    return _vulnerability_change(finding_uid, "close")

+ 61 - 7
app/api/system/users.py

@@ -1,5 +1,7 @@
 from __future__ import annotations
 
+import json
+
 from flask import g, jsonify, request
 from sqlalchemy import text
 from sqlalchemy.exc import IntegrityError
@@ -12,10 +14,27 @@ from app.core.system.auth import hash_password
 from app.core.system.permissions import MANAGE_USERS, require_permissions
 from app.models.result import failed, success
 
-
 VALID_ROLES = {"admin", "editor", "viewer"}
 
 
+def _audit_access_control(action, resource_uid, status, safe_detail):
+    db.session.execute(
+        text(
+            "INSERT INTO public.access_control_audit_events "
+            "(uid,action,actor_uid,resource_type,resource_uid,status,safe_detail) VALUES "
+            "(CAST(:uid AS uuid),:action,CAST(:actor AS uuid),'user',:resource_uid,:status,CAST(:detail AS jsonb))"
+        ),
+        {
+            "uid": new_governance_uid(),
+            "action": action,
+            "actor": g.current_user["id"],
+            "resource_uid": resource_uid,
+            "status": status,
+            "detail": json.dumps(safe_detail, ensure_ascii=False),
+        },
+    )
+
+
 def _active_admin_count(session) -> int:
     return int(
         session.execute(
@@ -109,6 +128,9 @@ def create_user():
             ),
             {"user_id": user_id, "assigned_by": g.current_user["id"], "roles": sorted(roles)},
         )
+        _audit_access_control(
+            "user_created", user_id, "success", {"roles": sorted(roles)}
+        )
         db.session.commit()
         return jsonify(success({"id": user_id}, "用户创建成功", code=201)), 201
     except (ValueError, IntegrityError) as exc:
@@ -123,9 +145,12 @@ def update_user(user_id: str):
     status = body.get("status")
     if status not in (None, "active", "disabled"):
         return jsonify(failed("用户状态无效", code=400)), 400
-    if status == "disabled" and _is_active_admin(db.session, user_id):
-        if _active_admin_count(db.session) <= 1:
-            return jsonify(failed("不能停用最后一个有效管理员", code=409)), 409
+    if (
+        status == "disabled"
+        and _is_active_admin(db.session, user_id)
+        and _active_admin_count(db.session) <= 1
+    ):
+        return jsonify(failed("不能停用最后一个有效管理员", code=409)), 409
     values = {"id": user_id}
     assignments = []
     if status is not None:
@@ -150,6 +175,17 @@ def update_user(user_id: str):
     if not result.rowcount:
         db.session.rollback()
         return jsonify(failed("用户不存在", code=404)), 404
+    _audit_access_control(
+        "user_updated",
+        user_id,
+        "success",
+        {
+            "changed_fields": sorted(
+                key for key in ("status", "display_name", "password") if key in body
+            ),
+            "password_value_retained": False,
+        },
+    )
     db.session.commit()
     return jsonify(success(message="用户更新成功"))
 
@@ -161,9 +197,21 @@ def update_user_roles(user_id: str):
     roles = set(body.get("roles") or [])
     if not roles or not roles <= VALID_ROLES:
         return jsonify(failed("角色无效", code=400)), 400
-    if _is_active_admin(db.session, user_id) and "admin" not in roles:
-        if _active_admin_count(db.session) <= 1:
-            return jsonify(failed("不能移除最后一个有效管理员角色", code=409)), 409
+    if (
+        _is_active_admin(db.session, user_id)
+        and "admin" not in roles
+        and _active_admin_count(db.session) <= 1
+    ):
+        return jsonify(failed("不能移除最后一个有效管理员角色", code=409)), 409
+    previous_roles = list(
+        db.session.execute(
+            text(
+                "SELECT r.name FROM public.user_roles ur JOIN public.roles r ON r.id=ur.role_id "
+                "WHERE ur.user_id=CAST(:id AS uuid) ORDER BY r.name"
+            ),
+            {"id": user_id},
+        ).scalars()
+    )
     db.session.execute(
         text("DELETE FROM public.user_roles WHERE user_id = CAST(:id AS uuid)"),
         {"id": user_id},
@@ -179,5 +227,11 @@ def update_user_roles(user_id: str):
     if result.rowcount != len(roles):
         db.session.rollback()
         return jsonify(failed("用户或角色不存在", code=404)), 404
+    _audit_access_control(
+        "user_roles_updated",
+        user_id,
+        "success",
+        {"before_roles": previous_roles, "after_roles": sorted(roles)},
+    )
     db.session.commit()
     return jsonify(success(message="角色更新成功"))

+ 3 - 0
app/config/config.py

@@ -285,6 +285,9 @@ def apply_runtime_env_config(app) -> None:
             "AUDIT_EVIDENCE_KEY_VERSION": _clean_env(
                 "AUDIT_EVIDENCE_KEY_VERSION", "local-fallback-v1"
             ),
+            "SECURITY_SIEM_HOST_ALLOWLIST": _clean_env(
+                "SECURITY_SIEM_HOST_ALLOWLIST"
+            ),
             "AGENT_CREDENTIAL_SECRET": _clean_env("AGENT_CREDENTIAL_SECRET"),
         }
     )

+ 8 - 1
app/core/governance/work_center.py

@@ -14,7 +14,13 @@ from app.core.common.identifiers import new_governance_uid
 from app.core.common.timezone_utils import now_china
 
 SUBJECT_TYPES = frozenset(
-    {"quality_issue", "semantic_governance", "data_product", "agent"}
+    {
+        "quality_issue",
+        "semantic_governance",
+        "data_product",
+        "agent",
+        "security_request",
+    }
 )
 TASK_TYPES = frozenset(
     {
@@ -23,6 +29,7 @@ TASK_TYPES = frozenset(
         "semantic_governance",
         "data_product_approval",
         "agent_approval",
+        "data_egress",
         "governance_work_order",
         "release",
         "high_risk",

+ 5 - 0
app/core/system/governance_audit.py

@@ -18,6 +18,11 @@ AUDIT_CATEGORIES = (
     "publication",
     "remediation",
     "knowledge_query",
+    "authorization",
+    "workflow_task",
+    "data_product",
+    "agent",
+    "security_governance",
 )
 MAX_SEAL_EVENTS = 50_000
 

+ 68 - 0
app/core/system/governance_audit_repository.py

@@ -174,6 +174,74 @@ _EVENT_QUERIES = {
         WHERE audit.created_at >= :period_start
           AND audit.created_at <= :period_end
     """,
+    "authorization": """
+        SELECT * FROM (
+            SELECT 'access-control:' || uid::text AS event_uid,
+                   'authorization' AS category, action, status,
+                   actor_uid::text AS actor_uid, resource_type, resource_uid,
+                   created_at AS occurred_at, safe_detail
+            FROM public.access_control_audit_events
+            UNION ALL
+            SELECT 'access-decision:' || uid::text AS event_uid,
+                   'authorization' AS category, 'access_evaluated' AS action,
+                   decision AS status, user_uid::text AS actor_uid,
+                   'data_resource' AS resource_type, resource_uid,
+                   decided_at AS occurred_at,
+                   jsonb_build_object('reason_code', reason_code) AS safe_detail
+            FROM public.security_access_decisions
+        ) evidence
+        WHERE evidence.occurred_at >= :period_start
+          AND evidence.occurred_at <= :period_end
+    """,
+    "workflow_task": """
+        SELECT 'workflow-task:' || event.uid::text AS event_uid,
+               'workflow_task' AS category, event.action,
+               task.status, event.actor_uid::text AS actor_uid,
+               'governance_task' AS resource_type,
+               event.task_uid::text AS resource_uid,
+               event.created_at AS occurred_at,
+               jsonb_build_object('task_type', task.task_type,
+                                  'subject_type', task.subject_type,
+                                  'source_state_unchanged', task.source_state_unchanged)
+                   AS safe_detail
+        FROM public.governance_task_events event
+        JOIN public.governance_tasks task ON task.uid = event.task_uid
+        WHERE event.created_at >= :period_start
+          AND event.created_at <= :period_end
+    """,
+    "data_product": """
+        SELECT 'data-product:' || uid::text AS event_uid,
+               'data_product' AS category, action,
+               COALESCE(payload->>'status', 'recorded') AS status,
+               actor_uid::text AS actor_uid,
+               'data_product' AS resource_type,
+               product_uid::text AS resource_uid,
+               created_at AS occurred_at,
+               jsonb_build_object('version', product_version) AS safe_detail
+        FROM public.data_product_governance_events
+        WHERE created_at >= :period_start AND created_at <= :period_end
+    """,
+    "agent": """
+        SELECT 'agent:' || uid::text AS event_uid,
+               'agent' AS category, action,
+               COALESCE(payload->>'decision', 'recorded') AS status,
+               actor_subject AS actor_uid,
+               'governed_agent' AS resource_type,
+               agent_uid::text AS resource_uid,
+               created_at AS occurred_at,
+               jsonb_build_object('version', agent_version) AS safe_detail
+        FROM public.agent_governance_events
+        WHERE created_at >= :period_start AND created_at <= :period_end
+    """,
+    "security_governance": """
+        SELECT 'security-governance:' || uid::text AS event_uid,
+               'security_governance' AS category, action,
+               'recorded' AS status, actor_uid::text AS actor_uid,
+               resource_type, resource_uid, created_at AS occurred_at,
+               safe_detail
+        FROM public.security_governance_events
+        WHERE created_at >= :period_start AND created_at <= :period_end
+    """,
 }
 
 _SEAL_FIELDS = """

+ 32 - 0
app/core/system/permissions.py

@@ -67,6 +67,9 @@ DATA_PRODUCTS_MANAGE = "data-products:manage"
 AGENTS_READ = "agents:read"
 AGENTS_OPERATE = "agents:operate"
 AGENTS_MANAGE = "agents:manage"
+SECURITY_GOVERNANCE_READ = "security-governance:read"
+SECURITY_GOVERNANCE_OPERATE = "security-governance:operate"
+SECURITY_GOVERNANCE_MANAGE = "security-governance:manage"
 
 ROLE_PERMISSIONS = {
     "viewer": frozenset(
@@ -80,6 +83,7 @@ ROLE_PERMISSIONS = {
             WORK_CENTER_READ,
             DATA_PRODUCTS_READ,
             AGENTS_READ,
+            SECURITY_GOVERNANCE_READ,
         }
     ),
     "editor": frozenset(
@@ -115,6 +119,8 @@ ROLE_PERMISSIONS = {
             DATA_PRODUCTS_OPERATE,
             AGENTS_READ,
             AGENTS_OPERATE,
+            SECURITY_GOVERNANCE_READ,
+            SECURITY_GOVERNANCE_OPERATE,
         }
     ),
     "admin": frozenset(
@@ -179,6 +185,9 @@ ROLE_PERMISSIONS = {
             AGENTS_READ,
             AGENTS_OPERATE,
             AGENTS_MANAGE,
+            SECURITY_GOVERNANCE_READ,
+            SECURITY_GOVERNANCE_OPERATE,
+            SECURITY_GOVERNANCE_MANAGE,
         }
     ),
 }
@@ -208,6 +217,29 @@ def permission_for_request(path: str, method: str) -> tuple[str, ...]:
         if method == "GET":
             return (GOVERNANCE_AUDIT_READ,)
         return (GOVERNANCE_AUDIT_SEAL,)
+    if path.startswith("/api/system/security-governance"):
+        if method == "GET":
+            return (SECURITY_GOVERNANCE_READ,)
+        if any(
+            marker in path
+            for marker in (
+                "/findings/",
+                "/reconcile",
+                "/vulnerabilities/",
+                "/dispatch",
+            )
+        ):
+            return (SECURITY_GOVERNANCE_MANAGE,)
+        if any(
+            path.startswith(prefix)
+            for prefix in (
+                "/api/system/security-governance/retention",
+                "/api/system/security-governance/siem",
+                "/api/system/security-governance/sboms",
+            )
+        ):
+            return (SECURITY_GOVERNANCE_MANAGE,)
+        return (SECURITY_GOVERNANCE_OPERATE,)
     if path.startswith("/api/dataservice/governance"):
         if method == "GET":
             return (DATA_PRODUCTS_READ,)

+ 61 - 0
app/core/system/security_delivery.py

@@ -0,0 +1,61 @@
+"""Restricted outbound transports for security audit evidence."""
+
+from __future__ import annotations
+
+import json
+import socket
+import ssl
+from urllib.parse import urlsplit
+from urllib.request import HTTPRedirectHandler, HTTPSHandler, Request, build_opener
+
+
+class _NoRedirect(HTTPRedirectHandler):
+    def redirect_request(self, req, fp, code, msg, headers, newurl):
+        return None
+
+
+class HttpsWebhookSyslogTransport:
+    """Deliver bounded safe envelopes after service-level endpoint validation."""
+
+    def __init__(self, *, timeout: float = 5.0, maximum_bytes: int = 1_048_576):
+        self.timeout = float(timeout)
+        self.maximum_bytes = int(maximum_bytes)
+
+    def deliver(self, sink, envelope):
+        payload = json.dumps(
+            envelope, ensure_ascii=False, sort_keys=True, separators=(",", ":")
+        ).encode("utf-8")
+        if len(payload) > self.maximum_bytes:
+            return {"status": "failed", "error_code": "payload_too_large"}
+        try:
+            if sink["sink_type"] == "webhook":
+                return self._webhook(sink["endpoint"], payload)
+            return self._syslog_tls(sink["endpoint"], payload)
+        except (OSError, ssl.SSLError, TimeoutError):
+            return {"status": "failed", "error_code": "transport_unavailable"}
+
+    def _webhook(self, endpoint, payload):
+        request = Request(
+            endpoint,
+            data=payload,
+            headers={"Content-Type": "application/json", "User-Agent": "dataops-security/1"},
+            method="POST",
+        )
+        opener = build_opener(HTTPSHandler(), _NoRedirect())
+        with opener.open(request, timeout=self.timeout) as response:
+            status = int(response.status)
+            if status < 200 or status >= 300:
+                return {"status": "failed", "error_code": f"http_{status}"}
+            remote_ref = str(response.headers.get("X-Request-ID") or "")[:300] or None
+            return {"status": "delivered", "remote_ref": remote_ref}
+
+    def _syslog_tls(self, endpoint, payload):
+        parsed = urlsplit(endpoint)
+        port = int(parsed.port or 6514)
+        context = ssl.create_default_context()
+        with (
+            socket.create_connection((parsed.hostname, port), timeout=self.timeout) as raw,
+            context.wrap_socket(raw, server_hostname=parsed.hostname) as secured,
+        ):
+            secured.sendall(payload + b"\n")
+        return {"status": "delivered"}

+ 882 - 0
app/core/system/security_governance.py

@@ -0,0 +1,882 @@
+"""Cross-domain data security governance and security engineering controls."""
+
+from __future__ import annotations
+
+import copy
+import hashlib
+import json
+import re
+import uuid
+from collections.abc import Callable
+from datetime import UTC, datetime, timedelta
+from typing import Any
+from urllib.parse import urlsplit
+
+from app.core.common.identifiers import new_governance_uid
+from app.core.common.timezone_utils import now_china
+
+CLASSIFICATIONS = ("public", "internal", "sensitive", "highly_sensitive")
+CLASSIFICATION_RANK = {value: index for index, value in enumerate(CLASSIFICATIONS)}
+FINDING_STATUSES = {"pending_review", "confirmed", "dismissed"}
+ACCESS_ACTIONS = {"read", "use"}
+ENVIRONMENTS = {"development", "test", "production"}
+RETENTION_EVIDENCE_TYPES = {
+    "classification_evidence", "access_decision", "egress_request",
+    "audit_event", "siem_delivery", "sbom", "vulnerability",
+}
+ARCHIVE_MODES = {"hot", "immutable_external"}
+DISPOSITION_ACTIONS = {"review", "archive"}
+SIEM_CATEGORIES = {
+    "authentication", "ingestion", "entity_resolution", "publication",
+    "remediation", "knowledge_query", "authorization", "workflow_task",
+    "data_product", "agent", "security_governance",
+}
+SEVERITIES = {"unknown", "low", "medium", "high", "critical"}
+RESOLUTION_TYPES = {"patched", "not_affected", "accepted_risk"}
+ROLE_PATTERN = re.compile(r"^[a-z][a-z0-9:_-]{1,79}$")
+CODE_PATTERN = re.compile(r"^[A-Z][A-Z0-9_]{2,119}$")
+FIELD_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_.-]{0,199}$")
+HASH_PATTERN = re.compile(r"^[0-9a-f]{64}$")
+PHONE_PATTERN = re.compile(r"(?<!\d)1[3-9]\d{9}(?!\d)")
+EMAIL_PATTERN = re.compile(r"(?i)\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b")
+BANK_CARD_PATTERN = re.compile(r"(?<!\d)\d{15,19}(?!\d)")
+PRC_ID_PATTERN = re.compile(r"(?<!\d)\d{17}[0-9Xx](?!\d)")
+
+
+def _closed(value: Any, allowed: set[str], label: str) -> dict[str, Any]:
+    if not isinstance(value, dict):
+        raise ValueError(f"{label} must be an object")
+    unknown = sorted(set(value) - allowed)
+    if unknown:
+        raise ValueError(f"{label} contains unsupported fields: {', '.join(unknown)}")
+    return copy.deepcopy(value)
+
+
+def _text(value: Any, label: str, maximum: int = 1000) -> str:
+    if not isinstance(value, str) or not value.strip():
+        raise ValueError(f"{label} is required")
+    result = value.strip()
+    if len(result) > maximum:
+        raise ValueError(f"{label} exceeds {maximum} characters")
+    return result
+
+
+def _optional_text(value: Any, label: str, maximum: int = 1000) -> str | None:
+    if value in (None, ""):
+        return None
+    return _text(value, label, maximum)
+
+
+def _uid(value: Any, label: str) -> str:
+    try:
+        return str(uuid.UUID(str(value)))
+    except (TypeError, ValueError, AttributeError) as error:
+        raise ValueError(f"{label} must be a UUID") from error
+
+
+def _list(value: Any, label: str, minimum: int = 0, maximum: int = 1000) -> list[Any]:
+    if not isinstance(value, list) or len(value) < minimum or len(value) > maximum:
+        raise ValueError(f"{label} must contain between {minimum} and {maximum} items")
+    return copy.deepcopy(value)
+
+
+def _time(value: Any, label: str) -> datetime:
+    if isinstance(value, datetime):
+        result = value
+    else:
+        try:
+            result = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
+        except (TypeError, ValueError) as error:
+            raise ValueError(f"{label} must be ISO-8601") from error
+    if result.tzinfo is None:
+        raise ValueError(f"{label} must include a timezone")
+    return result.astimezone(UTC)
+
+
+def _classification(value: Any, label: str = "classification") -> str:
+    result = _text(value, label, 40)
+    if result not in CLASSIFICATION_RANK:
+        raise ValueError(f"unsupported {label}")
+    return result
+
+
+def _canonical(value: Any) -> bytes:
+    return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()
+
+
+def _digest(value: Any) -> str:
+    return hashlib.sha256(_canonical(value)).hexdigest()
+
+
+def _mask(value: str, detector: str) -> str:
+    if detector == "email" and "@" in value:
+        local, domain = value.split("@", 1)
+        return f"{local[:1]}***@{domain}"
+    if detector == "phone" and len(value) >= 7:
+        return f"{value[:3]}****{value[-4:]}"
+    if len(value) >= 6:
+        return f"{value[:2]}****{value[-2:]}"
+    return "***"
+
+
+def _normalize_evidence(value: Any) -> list[dict[str, str]]:
+    result = []
+    for item in _list(value, "evidence_refs", 1, 50):
+        body = _closed(item, {"type", "ref", "digest"}, "evidence reference")
+        digest = _text(body.get("digest"), "evidence digest", 64).lower()
+        if not HASH_PATTERN.fullmatch(digest):
+            raise ValueError("evidence digest must be SHA-256")
+        result.append({
+            "type": _text(body.get("type"), "evidence type", 60),
+            "ref": _text(body.get("ref"), "evidence ref", 300),
+            "digest": digest,
+        })
+    return result
+
+
+class SecurityGovernanceService:
+    """Own security decisions while leaving data and external security tools authoritative."""
+
+    def __init__(
+        self,
+        repository,
+        *,
+        approval_gateway,
+        siem_transport,
+        siem_host_allowlist: set[str] | frozenset[str],
+        uid_factory: Callable[[], str] = new_governance_uid,
+        now_factory: Callable[[], datetime] = now_china,
+        commit: Callable[[], None] = lambda: None,
+        rollback: Callable[[], None] = lambda: None,
+    ):
+        self.repository = repository
+        self.approval_gateway = approval_gateway
+        self.siem_transport = siem_transport
+        self.siem_host_allowlist = {
+            str(value).strip().lower() for value in siem_host_allowlist if str(value).strip()
+        }
+        self.uid_factory = uid_factory
+        self.now_factory = now_factory
+        self.commit = commit
+        self.rollback = rollback
+
+    def _actor(self, actor_uid: Any) -> str:
+        actor = _uid(actor_uid, "actor_uid")
+        if self.repository.users_available({actor}) != {actor}:
+            raise ValueError("security actor is unavailable")
+        return actor
+
+    def _save(self, operation):
+        try:
+            result = operation()
+            self.commit()
+            return result
+        except Exception:
+            self.rollback()
+            raise
+
+    def _event(self, resource_type, resource_uid, action, actor_uid, detail=None):
+        self.repository.add_event(
+            resource_type, resource_uid, action, actor_uid, copy.deepcopy(detail or {})
+        )
+
+    def create_classification_profile(self, payload: Any, *, actor_uid: str):
+        body = _closed(
+            payload,
+            {"code", "name", "business_domain_uid", "default_classification", "rules"},
+            "classification profile",
+        )
+        actor = self._actor(actor_uid)
+        code = _text(body.get("code"), "profile code", 120).upper()
+        if not CODE_PATTERN.fullmatch(code):
+            raise ValueError("classification profile code is invalid")
+        rules = []
+        for raw in _list(body.get("rules"), "classification rules", 1, 100):
+            rule = _closed(raw, {"field_tokens", "category", "classification"}, "classification rule")
+            tokens = sorted({
+                _text(item, "field token", 60).casefold()
+                for item in _list(rule.get("field_tokens"), "field_tokens", 1, 20)
+            })
+            if any(not re.fullmatch(r"[a-z0-9_-]+", item) for item in tokens):
+                raise ValueError("field tokens must be simple identifiers")
+            rules.append({
+                "field_tokens": tokens,
+                "category": _text(rule.get("category"), "category", 80),
+                "classification": _classification(rule.get("classification")),
+            })
+        now = self.now_factory().isoformat()
+        record = {
+            "uid": self.uid_factory(), "code": code,
+            "name": _text(body.get("name"), "profile name", 300),
+            "business_domain_uid": _uid(body.get("business_domain_uid"), "business_domain_uid"),
+            "default_classification": _classification(body.get("default_classification")),
+            "rules": rules, "status": "active", "current_version": 1,
+            "created_by": actor, "created_at": now, "updated_at": now,
+        }
+
+        def operation():
+            result = self.repository.create_profile(record)
+            self._event("classification_profile", record["uid"], "profile_created", actor, {"code": code})
+            return result
+
+        return self._save(operation)
+
+    @staticmethod
+    def _detect_field(profile: dict[str, Any], field: dict[str, Any]):
+        field_name = _text(field.get("name"), "field name", 200)
+        if not FIELD_PATTERN.fullmatch(field_name):
+            raise ValueError("field name is invalid")
+        values = [str(value)[:500] for value in _list(field.get("sample_values", []), "sample_values", 0, 20)]
+        normalized = field_name.casefold().replace("-", "_").replace(".", "_")
+        matches = []
+        for rule in profile["rules"]:
+            if any(token in normalized.split("_") or token in normalized for token in rule["field_tokens"]):
+                matches.append((rule["classification"], rule["category"], "field_rule", None))
+        detectors = (
+            ("prc_id", PRC_ID_PATTERN, "personal_identifier", "highly_sensitive"),
+            ("bank_card", BANK_CARD_PATTERN, "financial_account", "highly_sensitive"),
+            ("phone", PHONE_PATTERN, "personal_contact", "sensitive"),
+            ("email", EMAIL_PATTERN, "personal_contact", "sensitive"),
+        )
+        for value in values:
+            for detector, pattern, category, level in detectors:
+                found = pattern.search(value)
+                if found:
+                    sample = found.group(0)
+                    matches.append((level, category, detector, sample))
+        if not matches:
+            return None
+        level = max(matches, key=lambda item: CLASSIFICATION_RANK[item[0]])[0]
+        categories = sorted({item[1] for item in matches})
+        detector_codes = sorted({item[2] for item in matches})
+        raw_matches = [item for item in matches if item[3] is not None]
+        return {
+            "field_name": field_name,
+            "categories": categories,
+            "proposed_classification": level,
+            "detector_codes": detector_codes,
+            "sample_fingerprints": sorted({_digest(item[3]) for item in raw_matches}),
+            "masked_examples": sorted({_mask(item[3], item[2]) for item in raw_matches})[:3],
+        }
+
+    def scan_sensitive_sample(self, payload: Any, *, actor_uid: str):
+        body = _closed(
+            payload,
+            {"profile_uid", "resource_type", "resource_uid", "business_domain_uid", "fields"},
+            "sensitive sample scan",
+        )
+        actor = self._actor(actor_uid)
+        profile = self.repository.get_profile(_uid(body.get("profile_uid"), "profile_uid"))
+        if not profile or profile["status"] != "active":
+            raise LookupError("active classification profile was not found")
+        domain_uid = _uid(body.get("business_domain_uid"), "business_domain_uid")
+        if profile["business_domain_uid"] != domain_uid:
+            raise PermissionError("classification profile domain does not match")
+        fields = _list(body.get("fields"), "fields", 1, 100)
+        now = self.now_factory().isoformat()
+        scan_uid = self.uid_factory()
+        findings = []
+        for field in fields:
+            normalized = self._detect_field(profile, field)
+            if not normalized:
+                continue
+            findings.append({
+                "uid": self.uid_factory(), "scan_uid": scan_uid,
+                **normalized, "status": "pending_review", "final_classification": None,
+                "review_reason": None, "reviewed_by": None, "reviewed_at": None,
+                "current_version": 1, "created_at": now,
+            })
+        scan = {
+            "uid": scan_uid, "profile_uid": profile["uid"],
+            "resource_type": _text(body.get("resource_type"), "resource_type", 80),
+            "resource_uid": _text(body.get("resource_uid"), "resource_uid", 200),
+            "business_domain_uid": domain_uid, "field_count": len(fields),
+            "finding_count": len(findings), "sample_retained": False,
+            "created_by": actor, "created_at": now,
+        }
+
+        def operation():
+            result = self.repository.create_scan(scan, findings)
+            self._event(
+                "classification_scan", scan_uid, "sample_scanned", actor,
+                {"finding_count": len(findings), "sample_retained": False},
+            )
+            return result
+
+        return self._save(operation)
+
+    def review_classification_finding(
+        self, finding_uid: str, payload: Any, *, expected_version: int, actor_uid: str
+    ):
+        body = _closed(payload, {"decision", "final_classification", "reason"}, "classification review")
+        actor = self._actor(actor_uid)
+        finding = self.repository.get_finding(_uid(finding_uid, "finding_uid"))
+        if not finding:
+            raise LookupError("classification finding was not found")
+        if finding["status"] != "pending_review":
+            raise RuntimeError("classification finding is not pending review")
+        scan = self.repository.get_scan(finding["scan_uid"]) if hasattr(self.repository, "get_scan") else None
+        creator = scan.get("created_by") if scan else None
+        if actor == creator or (creator is None and actor == finding.get("created_by")):
+            raise PermissionError("classification requires an independent reviewer")
+        # Memory repositories keep the creator on the scan rather than the finding.
+        if (
+            creator is None
+            and hasattr(self.repository, "scans")
+            and actor == self.repository.scans[finding["scan_uid"]]["created_by"]
+        ):
+            raise PermissionError("classification requires an independent reviewer")
+        decision = _text(body.get("decision"), "decision", 20)
+        if decision not in {"confirm", "dismiss"}:
+            raise ValueError("unsupported classification review decision")
+        updated = {
+            **finding,
+            "status": "confirmed" if decision == "confirm" else "dismissed",
+            "final_classification": (
+                _classification(body.get("final_classification")) if decision == "confirm" else None
+            ),
+            "review_reason": _text(body.get("reason"), "review reason", 1000),
+            "reviewed_by": actor, "reviewed_at": self.now_factory().isoformat(),
+        }
+
+        def operation():
+            result = self.repository.update_finding(updated, int(expected_version))
+            self._event("classification_finding", finding["uid"], f"finding_{updated['status']}", actor, {"classification": updated["final_classification"]})
+            return result
+
+        return self._save(operation)
+
+    def create_access_policy(self, payload: Any, *, actor_uid: str):
+        body = _closed(
+            payload,
+            {
+                "code", "name", "business_domain_uid", "subject_user_uids",
+                "subject_roles", "purposes", "environments", "actions",
+                "max_classification", "allowed_fields", "expires_at", "review_due_at",
+            },
+            "access policy",
+        )
+        actor = self._actor(actor_uid)
+        code = _text(body.get("code"), "policy code", 120).upper()
+        if not CODE_PATTERN.fullmatch(code):
+            raise ValueError("access policy code is invalid")
+        users = sorted({_uid(value, "subject_user_uid") for value in _list(body.get("subject_user_uids", []), "subject_user_uids", 0, 100)})
+        roles = sorted({_text(value, "subject role", 80) for value in _list(body.get("subject_roles", []), "subject_roles", 0, 50)})
+        if not users and not roles:
+            raise ValueError("access policy requires a user or role subject")
+        if users and self.repository.users_available(users) != set(users):
+            raise ValueError("access policy contains unavailable users")
+        if any(not ROLE_PATTERN.fullmatch(role) for role in roles):
+            raise ValueError("access policy role is invalid")
+        purposes = sorted({_text(value, "purpose", 100) for value in _list(body.get("purposes"), "purposes", 1, 50)})
+        environments = sorted({_text(value, "environment", 30) for value in _list(body.get("environments"), "environments", 1, 10)})
+        actions = sorted({_text(value, "action", 20) for value in _list(body.get("actions"), "actions", 1, 10)})
+        if not set(environments) <= ENVIRONMENTS or not set(actions) <= ACCESS_ACTIONS:
+            raise ValueError("unsupported access environment or action")
+        fields = sorted({_text(value, "allowed field", 200) for value in _list(body.get("allowed_fields"), "allowed_fields", 1, 500)})
+        if any(value != "*" and not FIELD_PATTERN.fullmatch(value) for value in fields):
+            raise ValueError("allowed field is invalid")
+        now = self.now_factory().astimezone(UTC)
+        expires = _time(body.get("expires_at"), "expires_at")
+        review = _time(body.get("review_due_at"), "review_due_at")
+        if review <= now or expires <= review:
+            raise ValueError("access policy review and expiry dates are invalid")
+        record = {
+            "uid": self.uid_factory(), "code": code,
+            "name": _text(body.get("name"), "policy name", 300),
+            "business_domain_uid": _uid(body.get("business_domain_uid"), "business_domain_uid"),
+            "subject_user_uids": users, "subject_roles": roles, "purposes": purposes,
+            "environments": environments, "actions": actions,
+            "max_classification": _classification(body.get("max_classification"), "max_classification"),
+            "allowed_fields": fields, "expires_at": expires.isoformat(),
+            "review_due_at": review.isoformat(), "status": "active", "current_version": 1,
+            "created_by": actor, "created_at": now.isoformat(), "updated_at": now.isoformat(),
+        }
+
+        def operation():
+            result = self.repository.create_access_policy(record)
+            self._event("access_policy", record["uid"], "access_policy_created", actor, {"code": code})
+            return result
+
+        return self._save(operation)
+
+    @staticmethod
+    def _policy_matches(policy: dict[str, Any], context: dict[str, Any], now: datetime, *, check_fields=True):
+        subject = (
+            context["user_uid"] in policy["subject_user_uids"]
+            or bool(set(context["roles"]) & set(policy["subject_roles"]))
+        )
+        if not subject:
+            return False
+        if policy["business_domain_uid"] != context["business_domain_uid"]:
+            return False
+        if context["purpose"] not in policy["purposes"]:
+            return False
+        if context["environment"] not in policy["environments"]:
+            return False
+        if context["action"] not in policy["actions"]:
+            return False
+        if CLASSIFICATION_RANK[context["classification"]] > CLASSIFICATION_RANK[policy["max_classification"]]:
+            return False
+        if _time(policy["expires_at"], "expires_at") <= now or _time(policy["review_due_at"], "review_due_at") <= now:
+            return False
+        if check_fields and "*" not in policy["allowed_fields"]:
+            return set(context["requested_fields"]) <= set(policy["allowed_fields"])
+        return True
+
+    def evaluate_access(self, payload: Any):
+        body = _closed(
+            payload,
+            {
+                "user_uid", "roles", "business_domain_uid", "purpose", "environment",
+                "action", "resource_uid", "classification", "requested_fields",
+            },
+            "access decision",
+        )
+        user_uid = _uid(body.get("user_uid"), "user_uid")
+        if self.repository.users_available({user_uid}) != {user_uid}:
+            raise ValueError("access user is unavailable")
+        roles = sorted({_text(value, "role", 80) for value in _list(body.get("roles"), "roles", 1, 50)})
+        fields = sorted({_text(value, "requested field", 200) for value in _list(body.get("requested_fields"), "requested_fields", 1, 500)})
+        context = {
+            "user_uid": user_uid, "roles": roles,
+            "business_domain_uid": _uid(body.get("business_domain_uid"), "business_domain_uid"),
+            "purpose": _text(body.get("purpose"), "purpose", 100),
+            "environment": _text(body.get("environment"), "environment", 30),
+            "action": _text(body.get("action"), "action", 20),
+            "resource_uid": _text(body.get("resource_uid"), "resource_uid", 200),
+            "classification": _classification(body.get("classification")),
+            "requested_fields": fields,
+        }
+        if context["environment"] not in ENVIRONMENTS or context["action"] not in ACCESS_ACTIONS:
+            raise ValueError("unsupported access environment or action")
+        now = self.now_factory().astimezone(UTC)
+        policies = self.repository.matching_access_policies(**context)
+        policy = next((item for item in policies if self._policy_matches(item, context, now)), None)
+        scope_policy = next((item for item in policies if self._policy_matches(item, context, now, check_fields=False)), None)
+        if policy:
+            decision, reason = "authorized", "policy_allowed"
+        elif scope_policy:
+            decision, reason = "denied", "field_minimization_denied"
+        else:
+            decision, reason = "denied", "default_deny"
+        record = {
+            "uid": self.uid_factory(), **context,
+            "policy_uid": policy["uid"] if policy else None,
+            "decision": decision, "reason_code": reason,
+            "decided_at": self.now_factory().isoformat(),
+        }
+
+        def operation():
+            result = self.repository.create_access_decision(record)
+            self._event("access_decision", record["uid"], f"access_{decision}", user_uid, {"reason_code": reason, "policy_uid": record["policy_uid"]})
+            return result
+
+        return self._save(operation)
+
+    def submit_egress_request(self, payload: Any, *, actor_uid: str):
+        body = _closed(
+            payload,
+            {
+                "business_domain_uid", "resource_uid", "classification", "purpose",
+                "environment", "requested_fields", "minimized_fields", "masking_applied",
+                "destination_zone", "expires_at", "workflow_uid",
+            },
+            "egress request",
+        )
+        actor = self._actor(actor_uid)
+        requested = sorted({_text(value, "requested field", 200) for value in _list(body.get("requested_fields"), "requested_fields", 1, 500)})
+        minimized = sorted({_text(value, "minimized field", 200) for value in _list(body.get("minimized_fields"), "minimized_fields", 1, 500)})
+        if not set(minimized) <= set(requested):
+            raise ValueError("minimized fields must be a subset of requested fields")
+        masking = body.get("masking_applied")
+        if not isinstance(masking, bool):
+            raise ValueError("masking_applied must be boolean")
+        classification = _classification(body.get("classification"))
+        if classification in {"sensitive", "highly_sensitive"} and not masking:
+            raise ValueError("sensitive egress requires masking")
+        environment = _text(body.get("environment"), "environment", 30)
+        if environment not in ENVIRONMENTS:
+            raise ValueError("unsupported egress environment")
+        now = self.now_factory().astimezone(UTC)
+        expires = _time(body.get("expires_at"), "expires_at")
+        if expires <= now or expires > now + timedelta(days=30):
+            raise ValueError("egress expiry must be within 30 days")
+        high = classification == "highly_sensitive"
+        record = {
+            "uid": self.uid_factory(),
+            "business_domain_uid": _uid(body.get("business_domain_uid"), "business_domain_uid"),
+            "resource_uid": _text(body.get("resource_uid"), "resource_uid", 200),
+            "classification": classification,
+            "purpose": _text(body.get("purpose"), "purpose", 300),
+            "environment": environment, "requested_fields": requested,
+            "minimized_fields": minimized, "approved_fields": [],
+            "masking_applied": masking,
+            "destination_zone": _text(body.get("destination_zone"), "destination_zone", 100),
+            "expires_at": expires.isoformat(),
+            "status": "denied" if high else "pending_approval",
+            "reason_code": "highly_sensitive_egress_disabled" if high else "approval_required",
+            "approval_task_uid": None, "current_version": 1,
+            "created_by": actor, "created_at": now.isoformat(), "updated_at": now.isoformat(),
+        }
+        if not high:
+            workflow_uid = _uid(body.get("workflow_uid"), "workflow_uid")
+            task = self.approval_gateway.create_egress_task(record, workflow_uid, actor)
+            record["approval_task_uid"] = task["uid"]
+
+        def operation():
+            result = self.repository.create_egress_request(record)
+            self._event("egress_request", record["uid"], f"egress_{record['status']}", actor, {"classification": classification, "reason_code": record["reason_code"]})
+            return result
+
+        return self._save(operation)
+
+    def reconcile_egress_request(self, request_uid: str, *, expected_version: int, actor_uid: str):
+        actor = self._actor(actor_uid)
+        record = self.repository.get_egress_request(_uid(request_uid, "request_uid"))
+        if not record:
+            raise LookupError("egress request was not found")
+        if record["status"] != "pending_approval":
+            raise RuntimeError("egress request is not pending approval")
+        task = self.approval_gateway.get_task(record["approval_task_uid"])
+        if not task or task["status"] not in {"approved", "rejected"}:
+            raise RuntimeError("egress approval has no final decision")
+        approved = task["status"] == "approved"
+        updated = {
+            **record,
+            "status": "authorized_until_expiry" if approved else "denied",
+            "reason_code": "approval_granted" if approved else "approval_rejected",
+            "approved_fields": record["minimized_fields"] if approved else [],
+            "updated_at": self.now_factory().isoformat(),
+        }
+
+        def operation():
+            result = self.repository.update_egress_request(updated, int(expected_version))
+            self._event("egress_request", record["uid"], f"egress_{updated['status']}", actor, {"approved_field_count": len(updated["approved_fields"])})
+            return result
+
+        return self._save(operation)
+
+    def create_retention_policy(self, payload: Any, *, actor_uid: str):
+        body = _closed(
+            payload,
+            {"code", "name", "evidence_type", "retention_days", "archive_mode", "disposition_action"},
+            "retention policy",
+        )
+        actor = self._actor(actor_uid)
+        code = _text(body.get("code"), "retention code", 120).upper()
+        if not CODE_PATTERN.fullmatch(code):
+            raise ValueError("retention code is invalid")
+        evidence_type = _text(body.get("evidence_type"), "evidence_type", 60)
+        archive_mode = _text(body.get("archive_mode"), "archive_mode", 40)
+        disposition = _text(body.get("disposition_action"), "disposition_action", 40)
+        if evidence_type not in RETENTION_EVIDENCE_TYPES or archive_mode not in ARCHIVE_MODES or disposition not in DISPOSITION_ACTIONS:
+            raise ValueError("unsupported retention policy option")
+        try:
+            days = int(body.get("retention_days"))
+        except (TypeError, ValueError) as error:
+            raise ValueError("retention_days must be an integer") from error
+        if days < 30 or days > 36500:
+            raise ValueError("retention_days must be between 30 and 36500")
+        now = self.now_factory().isoformat()
+        record = {
+            "uid": self.uid_factory(), "code": code,
+            "name": _text(body.get("name"), "retention name", 300),
+            "evidence_type": evidence_type, "retention_days": days,
+            "archive_mode": archive_mode, "disposition_action": disposition,
+            "automatic_deletion": False, "status": "active", "current_version": 1,
+            "created_by": actor, "created_at": now, "updated_at": now,
+        }
+
+        def operation():
+            result = self.repository.create_retention_policy(record)
+            self._event("retention_policy", record["uid"], "retention_policy_created", actor, {"evidence_type": evidence_type, "automatic_deletion": False})
+            return result
+
+        return self._save(operation)
+
+    def retention_candidates(self, *, as_of: datetime, limit: int):
+        instant = _time(as_of, "as_of")
+        size = int(limit)
+        if size < 1 or size > 1000:
+            raise ValueError("retention candidate limit must be between 1 and 1000")
+        return self.repository.retention_candidates(instant, size)
+
+    def create_siem_sink(self, payload: Any, *, actor_uid: str):
+        body = _closed(payload, {"name", "sink_type", "endpoint", "categories"}, "SIEM sink")
+        actor = self._actor(actor_uid)
+        sink_type = _text(body.get("sink_type"), "sink_type", 30)
+        endpoint = _text(body.get("endpoint"), "endpoint", 500)
+        parsed = urlsplit(endpoint)
+        required_scheme = "https" if sink_type == "webhook" else "tls"
+        if sink_type not in {"webhook", "syslog_tls"} or parsed.scheme != required_scheme:
+            raise ValueError("SIEM sink requires HTTPS webhook or TLS syslog")
+        if parsed.username or parsed.password or parsed.query or parsed.fragment:
+            raise ValueError("SIEM endpoint must not contain credentials, query or fragment")
+        host = str(parsed.hostname or "").lower()
+        if not host or host not in self.siem_host_allowlist:
+            raise ValueError("SIEM endpoint host is outside the allowlist")
+        categories = sorted({_text(value, "SIEM category", 40) for value in _list(body.get("categories"), "categories", 1, 20)})
+        if not set(categories) <= SIEM_CATEGORIES:
+            raise ValueError("unsupported SIEM audit category")
+        now = self.now_factory().isoformat()
+        record = {
+            "uid": self.uid_factory(), "name": _text(body.get("name"), "sink name", 300),
+            "sink_type": sink_type, "endpoint": endpoint, "endpoint_host": host,
+            "categories": categories, "status": "active", "current_version": 1,
+            "created_by": actor, "created_at": now, "updated_at": now,
+        }
+
+        def operation():
+            result = self.repository.create_siem_sink(record)
+            self._event("siem_sink", record["uid"], "siem_sink_created", actor, {"sink_type": sink_type, "endpoint_host": host})
+            return result
+
+        return self._save(operation)
+
+    def dispatch_siem_events(self, sink_uid: str, payload: Any, *, actor_uid: str):
+        body = _closed(payload, {"period_start", "period_end", "limit"}, "SIEM delivery")
+        actor = self._actor(actor_uid)
+        sink = self.repository.get_siem_sink(_uid(sink_uid, "sink_uid"))
+        if not sink or sink["status"] != "active":
+            raise LookupError("active SIEM sink was not found")
+        start = _time(body.get("period_start"), "period_start")
+        end = _time(body.get("period_end"), "period_end")
+        limit = int(body.get("limit", 100))
+        if end <= start or limit < 1 or limit > 1000:
+            raise ValueError("SIEM delivery window or limit is invalid")
+        events = self.repository.fetch_siem_events(
+            categories=sink["categories"], period_start=start, period_end=end, limit=limit
+        )
+        envelope = {
+            "schema": "dataops.security.audit.v1",
+            "period_start": start.isoformat(), "period_end": end.isoformat(),
+            "events": events,
+        }
+        result = self.siem_transport.deliver(sink, envelope)
+        status = result.get("status")
+        if status not in {"delivered", "failed"}:
+            raise RuntimeError("SIEM transport returned an invalid status")
+        now = self.now_factory().isoformat()
+        record = {
+            "uid": self.uid_factory(), "sink_uid": sink["uid"],
+            "period_start": start.isoformat(), "period_end": end.isoformat(),
+            "event_count": len(events), "payload_digest": _digest(envelope),
+            "status": status, "remote_ref": _optional_text(result.get("remote_ref"), "remote_ref", 300),
+            "error_code": _optional_text(result.get("error_code"), "error_code", 80),
+            "created_by": actor, "created_at": now,
+        }
+
+        def operation():
+            saved = self.repository.create_siem_delivery(record)
+            self._event("siem_delivery", saved["uid"], f"siem_{status}", actor, {"event_count": len(events), "payload_digest": record["payload_digest"]})
+            return saved
+
+        return self._save(operation)
+
+    def register_sbom(self, payload: Any, *, actor_uid: str):
+        body = _closed(
+            payload,
+            {"artifact_name", "artifact_version", "artifact_type", "source_ref", "document"},
+            "SBOM registration",
+        )
+        actor = self._actor(actor_uid)
+        document = body.get("document")
+        if not isinstance(document, dict):
+            raise ValueError("SBOM document must be an object")
+        if document.get("bomFormat") != "CycloneDX" or document.get("specVersion") != "1.5":
+            raise ValueError("SBOM must use CycloneDX 1.5")
+        components = []
+        for raw in _list(document.get("components", []), "SBOM components", 0, 10000):
+            component = _closed(
+                raw,
+                {
+                    "type", "name", "version", "purl", "bom-ref", "licenses",
+                    "externalReferences", "properties", "group", "supplier", "publisher",
+                    "author", "description", "hashes", "scope", "copyright",
+                },
+                "SBOM component",
+            )
+            components.append({
+                "type": _text(component.get("type"), "component type", 50),
+                "name": _text(component.get("name"), "component name", 300),
+                "version": _optional_text(component.get("version"), "component version", 200),
+                "purl": _optional_text(component.get("purl"), "component purl", 500),
+            })
+        now = self.now_factory().isoformat()
+        record = {
+            "uid": self.uid_factory(),
+            "artifact_name": _text(body.get("artifact_name"), "artifact name", 300),
+            "artifact_version": _text(body.get("artifact_version"), "artifact version", 120),
+            "artifact_type": _text(body.get("artifact_type"), "artifact type", 50),
+            "source_ref": _text(body.get("source_ref"), "source_ref", 500),
+            "format": "CycloneDX", "spec_version": "1.5",
+            "document_digest": _digest(document), "component_count": len(components),
+            "components": sorted(components, key=lambda item: (item["name"], item.get("version") or "")),
+            "created_by": actor, "created_at": now,
+        }
+
+        def operation():
+            result = self.repository.create_sbom(record)
+            self._event("sbom", record["uid"], "sbom_registered", actor, {"artifact_name": record["artifact_name"], "component_count": len(components), "document_digest": record["document_digest"]})
+            return result
+
+        return self._save(operation)
+
+    def ingest_vulnerabilities(self, sbom_uid: str, payload: Any, *, actor_uid: str):
+        body = _closed(payload, {"scanner", "scan_ref", "findings"}, "vulnerability import")
+        actor = self._actor(actor_uid)
+        sbom = self.repository.get_sbom(_uid(sbom_uid, "sbom_uid"))
+        if not sbom:
+            raise LookupError("SBOM was not found")
+        scanner = _text(body.get("scanner"), "scanner", 100)
+        scan_ref = _text(body.get("scan_ref"), "scan_ref", 500)
+        now = self.now_factory().isoformat()
+        records = []
+        for raw in _list(body.get("findings"), "vulnerability findings", 1, 5000):
+            finding = _closed(
+                raw,
+                {"external_id", "severity", "component_name", "installed_version", "fixed_version", "title"},
+                "vulnerability finding",
+            )
+            severity = _text(finding.get("severity"), "severity", 20).lower()
+            if severity not in SEVERITIES:
+                raise ValueError("unsupported vulnerability severity")
+            records.append({
+                "uid": self.uid_factory(), "sbom_uid": sbom["uid"],
+                "scanner": scanner, "scan_ref": scan_ref,
+                "external_id": _text(finding.get("external_id"), "external_id", 120),
+                "severity": severity,
+                "component_name": _text(finding.get("component_name"), "component_name", 300),
+                "installed_version": _text(finding.get("installed_version"), "installed_version", 200),
+                "fixed_version": _optional_text(finding.get("fixed_version"), "fixed_version", 200),
+                "title": _text(finding.get("title"), "title", 500),
+                "status": "open", "assignee_uid": None, "due_at": None,
+                "resolution_type": None, "resolved_version": None, "resolution": None,
+                "evidence_refs": [], "resolved_by": None, "resolved_at": None,
+                "closed_by": None, "closed_at": None, "close_reason": None,
+                "current_version": 1, "created_by": actor, "created_at": now, "updated_at": now,
+            })
+
+        def operation():
+            result = self.repository.upsert_vulnerabilities(sbom["uid"], records)
+            self._event("sbom", sbom["uid"], "vulnerabilities_imported", actor, {"scanner": scanner, "finding_count": len(result)})
+            return result
+
+        return self._save(operation)
+
+    def assign_vulnerability(
+        self, finding_uid: str, payload: Any, *, expected_version: int, actor_uid: str
+    ):
+        body = _closed(payload, {"assignee_uid", "due_at"}, "vulnerability assignment")
+        actor = self._actor(actor_uid)
+        finding = self.repository.get_vulnerability(_uid(finding_uid, "finding_uid"))
+        if not finding:
+            raise LookupError("vulnerability was not found")
+        if finding["status"] not in {"open", "triaged", "in_progress"}:
+            raise RuntimeError("vulnerability cannot be assigned")
+        assignee = _uid(body.get("assignee_uid"), "assignee_uid")
+        if self.repository.users_available({assignee}) != {assignee}:
+            raise ValueError("vulnerability assignee is unavailable")
+        due_at = _time(body.get("due_at"), "due_at")
+        if due_at <= self.now_factory().astimezone(UTC):
+            raise ValueError("vulnerability due date must be in the future")
+        updated = {
+            **finding, "status": "in_progress", "assignee_uid": assignee,
+            "due_at": due_at.isoformat(), "updated_at": self.now_factory().isoformat(),
+        }
+        return self._update_vulnerability(updated, expected_version, "vulnerability_assigned", actor, {"assignee_uid": assignee})
+
+    def resolve_vulnerability(
+        self, finding_uid: str, payload: Any, *, expected_version: int, actor_uid: str
+    ):
+        body = _closed(
+            payload,
+            {"resolution_type", "resolved_version", "resolution", "evidence_refs"},
+            "vulnerability resolution",
+        )
+        actor = self._actor(actor_uid)
+        finding = self.repository.get_vulnerability(_uid(finding_uid, "finding_uid"))
+        if not finding:
+            raise LookupError("vulnerability was not found")
+        if finding["status"] != "in_progress" or actor != finding["assignee_uid"]:
+            raise PermissionError("only the assigned owner can resolve an in-progress vulnerability")
+        resolution_type = _text(body.get("resolution_type"), "resolution_type", 30)
+        if resolution_type not in RESOLUTION_TYPES:
+            raise ValueError("unsupported vulnerability resolution type")
+        resolved_version = _optional_text(body.get("resolved_version"), "resolved_version", 200)
+        if resolution_type == "patched" and not resolved_version:
+            raise ValueError("patched vulnerabilities require a resolved version")
+        updated = {
+            **finding, "status": "resolved", "resolution_type": resolution_type,
+            "resolved_version": resolved_version,
+            "resolution": _text(body.get("resolution"), "resolution", 2000),
+            "evidence_refs": _normalize_evidence(body.get("evidence_refs")),
+            "resolved_by": actor, "resolved_at": self.now_factory().isoformat(),
+            "updated_at": self.now_factory().isoformat(),
+        }
+        return self._update_vulnerability(updated, expected_version, "vulnerability_resolved", actor, {"resolution_type": resolution_type})
+
+    def close_vulnerability(
+        self, finding_uid: str, payload: Any, *, expected_version: int, actor_uid: str
+    ):
+        body = _closed(payload, {"reason"}, "vulnerability closure")
+        actor = self._actor(actor_uid)
+        finding = self.repository.get_vulnerability(_uid(finding_uid, "finding_uid"))
+        if not finding:
+            raise LookupError("vulnerability was not found")
+        if finding["status"] != "resolved":
+            raise RuntimeError("only resolved vulnerabilities can be closed")
+        if actor in {finding.get("assignee_uid"), finding.get("resolved_by")}:
+            raise PermissionError("vulnerability closure requires an independent reviewer")
+        updated = {
+            **finding, "status": "closed", "closed_by": actor,
+            "closed_at": self.now_factory().isoformat(),
+            "close_reason": _text(body.get("reason"), "close reason", 1000),
+            "updated_at": self.now_factory().isoformat(),
+        }
+        return self._update_vulnerability(updated, expected_version, "vulnerability_closed", actor, {"resolution_type": finding["resolution_type"]})
+
+    def _update_vulnerability(self, record, expected_version, action, actor, detail):
+        def operation():
+            result = self.repository.update_vulnerability(record, int(expected_version), action, actor)
+            self._event("vulnerability", record["uid"], action, actor, detail)
+            return result
+
+        return self._save(operation)
+
+    def list_profiles(self, **filters):
+        return self.repository.list_profiles(**filters)
+
+    def list_classification_scans(self, **filters):
+        return self.repository.list_scans(**filters)
+
+    def list_classification_findings(self, **filters):
+        return self.repository.list_findings(**filters)
+
+    def list_access_policies(self, **filters):
+        return self.repository.list_access_policies(**filters)
+
+    def list_access_decisions(self, **filters):
+        return self.repository.list_access_decisions(**filters)
+
+    def list_egress_requests(self, **filters):
+        return self.repository.list_egress_requests(**filters)
+
+    def list_retention_policies(self):
+        return self.repository.list_retention_policies()
+
+    def list_siem_sinks(self):
+        return self.repository.list_siem_sinks()
+
+    def list_siem_deliveries(self, **filters):
+        return self.repository.list_siem_deliveries(**filters)
+
+    def list_sboms(self):
+        return self.repository.list_sboms()
+
+    def list_vulnerabilities(self, **filters):
+        return self.repository.list_vulnerabilities(**filters)
+
+    def dashboard(self):
+        return self.repository.dashboard()

+ 362 - 0
app/core/system/security_governance_repository.py

@@ -0,0 +1,362 @@
+"""PostgreSQL persistence and unified-work adapter for security governance."""
+
+from __future__ import annotations
+
+import json
+from datetime import datetime
+
+from sqlalchemy import text
+
+from app.core.governance.work_center import UnifiedWorkCenterService
+from app.core.governance.work_center_repository import SqlAlchemyWorkCenterRepository
+from app.core.system.governance_audit_repository import (
+    SqlAlchemyGovernanceAuditRepository,
+)
+
+
+def _json(value) -> str:
+    return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
+
+
+def _iso(value):
+    if isinstance(value, datetime):
+        return value.isoformat().replace("+00:00", "Z")
+    return value
+
+
+def _plain_record(row):
+    if row is None:
+        return None
+    value = dict(row)["record"]
+    return dict(value)
+
+
+class WorkCenterSecurityApprovalGateway:
+    """Create egress review tasks without introducing another approval engine."""
+
+    def __init__(self, session):
+        self.repository = SqlAlchemyWorkCenterRepository(session)
+        self.service = UnifiedWorkCenterService(self.repository)
+
+    def create_egress_task(self, request_record, workflow_uid, actor_uid):
+        return self.service.create_task(
+            {
+                "workflow_uid": workflow_uid,
+                "task_type": "data_egress",
+                "subject_type": "security_request",
+                "subject_uid": request_record["uid"],
+                "source_type": "security_egress_request",
+                "source_uid": request_record["uid"],
+                "title": "敏感数据出域审批",
+                "description": "核验用途绑定、字段最小化、脱敏和有效期。",
+                "priority": "high" if request_record["classification"] == "sensitive" else "critical",
+                "business_domain_uid": request_record["business_domain_uid"],
+                "context": {
+                    "business_domain_uid": request_record["business_domain_uid"],
+                    "risk_level": "high",
+                    "sensitivity_level": request_record["classification"],
+                    "environment": request_record["environment"],
+                },
+            },
+            actor_uid=actor_uid,
+        )
+
+    def get_task(self, uid):
+        return self.repository.get_task(uid)
+
+
+class SqlAlchemySecurityGovernanceRepository:
+    """Persist normalized security records; raw scanned values are never accepted."""
+
+    def __init__(self, session):
+        self.session = session
+
+    def users_available(self, values):
+        values = sorted(set(values))
+        if not values:
+            return set()
+        rows = self.session.execute(
+            text("SELECT id::text FROM public.users WHERE status = 'active' AND id = ANY(CAST(:ids AS uuid[]))"),
+            {"ids": values},
+        ).scalars().all()
+        return set(rows)
+
+    def _insert_record(self, table, columns, record):
+        names = [*columns, "record"]
+        values = [f"CAST(:{name} AS uuid)" if name.endswith("_uid") or name == "uid" else f":{name}" for name in columns]
+        values.append("CAST(:record AS jsonb)")
+        params = {name: record.get(name) for name in columns}
+        params["record"] = _json(record)
+        row = self.session.execute(
+            text(
+                f"INSERT INTO public.{table} ({', '.join(names)}) VALUES ({', '.join(values)}) "
+                "RETURNING record"
+            ),
+            params,
+        ).mappings().one()
+        self.session.flush()
+        return _plain_record(row)
+
+    def _get(self, table, uid):
+        row = self.session.execute(
+            text(f"SELECT record FROM public.{table} WHERE uid = CAST(:uid AS uuid)"),
+            {"uid": uid},
+        ).mappings().one_or_none()
+        return _plain_record(row)
+
+    def _list(self, table, *, filters=None, order="created_at DESC", limit=500):
+        filters = filters or {}
+        clauses, params = [], {"limit": int(limit)}
+        allowed = {"status", "business_domain_uid", "decision", "severity", "sink_uid", "sbom_uid"}
+        for key, value in filters.items():
+            if value in (None, "") or key not in allowed:
+                continue
+            clauses.append(f"{key} = " + (f"CAST(:{key} AS uuid)" if key.endswith("_uid") else f":{key}"))
+            params[key] = value
+        where = " WHERE " + " AND ".join(clauses) if clauses else ""
+        rows = self.session.execute(
+            text(f"SELECT record FROM public.{table}{where} ORDER BY {order} LIMIT :limit"),
+            params,
+        ).mappings().all()
+        return [_plain_record(row) for row in rows]
+
+    def create_profile(self, record):
+        return self._insert_record(
+            "security_classification_profiles",
+            ["uid", "code", "business_domain_uid", "status"], record,
+        )
+
+    def get_profile(self, uid):
+        return self._get("security_classification_profiles", uid)
+
+    def list_profiles(self, **filters):
+        return self._list("security_classification_profiles", filters=filters)
+
+    def create_scan(self, scan, findings):
+        saved = self._insert_record(
+            "security_classification_scans",
+            ["uid", "profile_uid", "business_domain_uid", "resource_type", "resource_uid"], scan,
+        )
+        for finding in findings:
+            self._insert_record(
+                "security_classification_findings",
+                ["uid", "scan_uid", "status", "current_version"], finding,
+            )
+        return {**saved, "findings": findings}
+
+    def get_scan(self, uid):
+        return self._get("security_classification_scans", uid)
+
+    def list_scans(self, **filters):
+        return self._list("security_classification_scans", filters=filters)
+
+    def get_finding(self, uid):
+        return self._get("security_classification_findings", uid)
+
+    def list_findings(self, **filters):
+        return self._list("security_classification_findings", filters=filters)
+
+    def _versioned_update(self, table, record, expected_version):
+        saved = {**record, "current_version": int(expected_version) + 1}
+        row = self.session.execute(
+            text(
+                f"UPDATE public.{table} SET status = :status, current_version = current_version + 1, "
+                "record = CAST(:record AS jsonb), updated_at = CURRENT_TIMESTAMP "
+                "WHERE uid = CAST(:uid AS uuid) AND current_version = :expected RETURNING record"
+            ),
+            {"uid": record["uid"], "status": record["status"], "expected": int(expected_version), "record": _json(saved)},
+        ).mappings().one_or_none()
+        if row is None:
+            raise RuntimeError("security governance version conflict")
+        return _plain_record(row)
+
+    def update_finding(self, record, expected_version):
+        return self._versioned_update("security_classification_findings", record, expected_version)
+
+    def create_access_policy(self, record):
+        return self._insert_record(
+            "security_access_policies",
+            ["uid", "code", "business_domain_uid", "status", "expires_at", "review_due_at"], record,
+        )
+
+    def list_access_policies(self, **filters):
+        return self._list("security_access_policies", filters=filters)
+
+    def matching_access_policies(self, **context):
+        rows = self.session.execute(
+            text(
+                "SELECT record FROM public.security_access_policies WHERE status = 'active' "
+                "AND business_domain_uid = CAST(:domain_uid AS uuid) AND expires_at > CURRENT_TIMESTAMP "
+                "AND review_due_at > CURRENT_TIMESTAMP ORDER BY created_at"
+            ),
+            {"domain_uid": context["business_domain_uid"]},
+        ).mappings().all()
+        return [_plain_record(row) for row in rows]
+
+    def create_access_decision(self, record):
+        return self._insert_record(
+            "security_access_decisions",
+            ["uid", "user_uid", "business_domain_uid", "resource_uid", "decision", "reason_code", "decided_at"], record,
+        )
+
+    def list_access_decisions(self, **filters):
+        return self._list("security_access_decisions", filters=filters, order="decided_at DESC")
+
+    def create_egress_request(self, record):
+        return self._insert_record(
+            "security_egress_requests",
+            ["uid", "business_domain_uid", "classification", "status", "approval_task_uid", "expires_at", "current_version"], record,
+        )
+
+    def get_egress_request(self, uid):
+        return self._get("security_egress_requests", uid)
+
+    def list_egress_requests(self, **filters):
+        return self._list("security_egress_requests", filters=filters, order="updated_at DESC")
+
+    def update_egress_request(self, record, expected_version):
+        return self._versioned_update("security_egress_requests", record, expected_version)
+
+    def create_retention_policy(self, record):
+        return self._insert_record(
+            "security_retention_policies", ["uid", "code", "evidence_type", "status"], record,
+        )
+
+    def list_retention_policies(self):
+        return self._list("security_retention_policies")
+
+    def retention_candidates(self, as_of, limit):
+        rows = self.session.execute(
+            text(
+                "SELECT evidence_type, (record->>'retention_days')::integer AS retention_days "
+                "FROM public.security_retention_policies WHERE status = 'active' ORDER BY created_at LIMIT :limit"
+            ),
+            {"limit": int(limit)},
+        ).mappings().all()
+        candidates = []
+        for row in rows:
+            count = 0
+            if row["evidence_type"] == "access_decision":
+                count = int(self.session.execute(
+                    text("SELECT COUNT(*) FROM public.security_access_decisions WHERE decided_at <= CAST(:cutoff AS timestamptz) - (:days * INTERVAL '1 day')"),
+                    {"cutoff": as_of, "days": row["retention_days"]},
+                ).scalar_one())
+            candidates.append({"evidence_type": row["evidence_type"], "candidate_count": count, "automatic_deletion": False})
+        return candidates
+
+    def create_siem_sink(self, record):
+        return self._insert_record("security_siem_sinks", ["uid", "name", "status"], record)
+
+    def get_siem_sink(self, uid):
+        return self._get("security_siem_sinks", uid)
+
+    def list_siem_sinks(self):
+        return self._list("security_siem_sinks")
+
+    def create_siem_delivery(self, record):
+        return self._insert_record(
+            "security_siem_deliveries",
+            ["uid", "sink_uid", "status", "payload_digest", "event_count"], record,
+        )
+
+    def list_siem_deliveries(self, **filters):
+        return self._list("security_siem_deliveries", filters=filters)
+
+    def fetch_siem_events(self, *, categories, period_start, period_end, limit):
+        records = SqlAlchemyGovernanceAuditRepository(self.session).fetch_events(
+            categories=categories, period_start=period_start, period_end=period_end
+        )
+        records.sort(key=lambda item: (item["occurred_at"], item["event_uid"]))
+        return [
+            {
+                **dict(item),
+                "occurred_at": _iso(item.get("occurred_at")),
+                "safe_detail": dict(item.get("safe_detail") or {}),
+            }
+            for item in records[: int(limit)]
+        ]
+
+    def create_sbom(self, record):
+        return self._insert_record(
+            "security_sboms",
+            ["uid", "artifact_name", "artifact_version", "document_digest"], record,
+        )
+
+    def get_sbom(self, uid):
+        return self._get("security_sboms", uid)
+
+    def list_sboms(self):
+        return self._list("security_sboms")
+
+    def upsert_vulnerabilities(self, sbom_uid, records):
+        saved = []
+        for record in records:
+            existing = self.session.execute(
+                text(
+                    "SELECT record FROM public.security_vulnerabilities WHERE sbom_uid = CAST(:sbom AS uuid) "
+                    "AND scanner = :scanner AND external_id = :external_id"
+                ),
+                {"sbom": sbom_uid, "scanner": record["scanner"], "external_id": record["external_id"]},
+            ).mappings().one_or_none()
+            if existing:
+                saved.append(_plain_record(existing))
+                continue
+            saved.append(self._insert_record(
+                "security_vulnerabilities",
+                ["uid", "sbom_uid", "scanner", "external_id", "severity", "status", "assignee_uid", "current_version"], record,
+            ))
+        return saved
+
+    def get_vulnerability(self, uid):
+        return self._get("security_vulnerabilities", uid)
+
+    def list_vulnerabilities(self, **filters):
+        return self._list("security_vulnerabilities", filters=filters, order="updated_at DESC")
+
+    def update_vulnerability(self, record, expected_version, action, actor_uid):
+        saved = {**record, "current_version": int(expected_version) + 1}
+        row = self.session.execute(
+            text(
+                "UPDATE public.security_vulnerabilities SET status=:status, severity=:severity, "
+                "assignee_uid=CAST(:assignee_uid AS uuid), current_version=current_version+1, "
+                "record=CAST(:record AS jsonb), updated_at=CURRENT_TIMESTAMP "
+                "WHERE uid=CAST(:uid AS uuid) AND current_version=:expected RETURNING record"
+            ),
+            {
+                "uid": record["uid"], "status": record["status"], "severity": record["severity"],
+                "assignee_uid": record.get("assignee_uid"), "expected": int(expected_version), "record": _json(saved),
+            },
+        ).mappings().one_or_none()
+        if row is None:
+            raise RuntimeError("vulnerability version conflict")
+        return _plain_record(row)
+
+    def add_event(self, resource_type, resource_uid, action, actor_uid, safe_detail):
+        from app.core.common.identifiers import new_governance_uid
+
+        self.session.execute(
+            text(
+                "INSERT INTO public.security_governance_events "
+                "(uid,resource_type,resource_uid,action,actor_uid,safe_detail) VALUES "
+                "(CAST(:uid AS uuid),:resource_type,:resource_uid,:action,CAST(:actor_uid AS uuid),CAST(:detail AS jsonb))"
+            ),
+            {
+                "uid": new_governance_uid(), "resource_type": resource_type, "resource_uid": resource_uid,
+                "action": action, "actor_uid": actor_uid, "detail": _json(safe_detail),
+            },
+        )
+
+    def dashboard(self):
+        row = self.session.execute(text(
+            "SELECT "
+            "(SELECT COUNT(*) FROM public.security_classification_findings WHERE status='pending_review') AS pending_reviews, "
+            "(SELECT COUNT(*) FROM public.security_access_decisions WHERE decision='denied') AS denied_access, "
+            "(SELECT COUNT(*) FROM public.security_egress_requests WHERE status='pending_approval') AS pending_egress, "
+            "(SELECT COUNT(*) FROM public.security_vulnerabilities WHERE status<>'closed') AS open_vulnerabilities"
+        )).mappings().one()
+        return {
+            "pending_classification_reviews": int(row["pending_reviews"]),
+            "denied_access_count": int(row["denied_access"]),
+            "pending_egress_count": int(row["pending_egress"]),
+            "open_vulnerability_count": int(row["open_vulnerabilities"]),
+        }

+ 1 - 0
deploy/docker/.env.example

@@ -9,6 +9,7 @@ RULE_GENERATION_RECEIPT_SECRET=replace-with-dedicated-64-hex-random-value
 # Generate independently with: openssl rand -hex 32
 AUDIT_EVIDENCE_SECRET=replace-with-dedicated-64-hex-random-value
 AUDIT_EVIDENCE_KEY_VERSION=v1
+SECURITY_SIEM_HOST_ALLOWLIST=
 
 # Create this key in the local n8n UI after owner setup, then restart backend.
 N8N_API_KEY=

+ 1 - 0
deploy/docker/docker-compose.yml

@@ -415,6 +415,7 @@ services:
       SECRET_KEY: dataops-local-test-secret-key
       AUDIT_EVIDENCE_SECRET: ${AUDIT_EVIDENCE_SECRET:-dataops-local-audit-evidence-secret-change-me}
       AUDIT_EVIDENCE_KEY_VERSION: ${AUDIT_EVIDENCE_KEY_VERSION:-local-v1}
+      SECURITY_SIEM_HOST_ALLOWLIST: ${SECURITY_SIEM_HOST_ALLOWLIST:-}
       RULE_GENERATION_RECEIPT_SECRET: ${RULE_GENERATION_RECEIPT_SECRET:-}
       DATASOURCE_CREDENTIAL_MASTER_KEY: ${DATASOURCE_CREDENTIAL_MASTER_KEY:-MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY=}
       DATASOURCE_CREDENTIAL_KEY_VERSION: ${DATASOURCE_CREDENTIAL_KEY_VERSION:-v1}

+ 1 - 0
deployment/app/api/system/__init__.py

@@ -7,6 +7,7 @@ bp = Blueprint("system", __name__)
 from app.api.system import governance_audit  # noqa: E402, F401
 from app.api.system import responsibilities  # noqa: E402, F401
 from app.api.system import routes  # noqa: E402, F401
+from app.api.system import security_governance  # noqa: E402, F401
 from app.api.system import users  # noqa: E402, F401
 from app.api.system import workbench  # noqa: E402, F401
 from app.api.system import work_center  # noqa: E402, F401

+ 2 - 2
deployment/app/api/system/governance_audit.py

@@ -134,9 +134,9 @@ def get_security_checks():
             },
             {
                 "code": "audit_source_coverage",
-                "name": "类关键操作审计源",
+                "name": "十一类关键操作审计源",
                 "status": "passed",
-                "expected_count": 6,
+                "expected_count": 11,
             },
         ],
     }

+ 280 - 0
deployment/app/api/system/security_governance.py

@@ -0,0 +1,280 @@
+"""Cross-domain data security and security engineering APIs."""
+
+from __future__ import annotations
+
+from datetime import UTC, datetime
+
+from flask import current_app, g, jsonify, request
+
+from app import db
+from app.api.system import bp
+from app.core.system.permissions import (
+    SECURITY_GOVERNANCE_MANAGE,
+    SECURITY_GOVERNANCE_OPERATE,
+    SECURITY_GOVERNANCE_READ,
+    require_permissions,
+)
+from app.core.system.security_delivery import HttpsWebhookSyslogTransport
+from app.core.system.security_governance import SecurityGovernanceService
+from app.core.system.security_governance_repository import (
+    SqlAlchemySecurityGovernanceRepository,
+    WorkCenterSecurityApprovalGateway,
+)
+from app.models.result import failed, success
+
+
+def _service():
+    allowlist = {
+        item.strip().lower()
+        for item in str(current_app.config.get("SECURITY_SIEM_HOST_ALLOWLIST") or "").split(",")
+        if item.strip()
+    }
+    return SecurityGovernanceService(
+        SqlAlchemySecurityGovernanceRepository(db.session),
+        approval_gateway=WorkCenterSecurityApprovalGateway(db.session),
+        siem_transport=HttpsWebhookSyslogTransport(),
+        siem_host_allowlist=allowlist,
+        commit=db.session.commit,
+        rollback=db.session.rollback,
+    )
+
+
+def _version():
+    value = str(request.headers.get("If-Match") or "").strip().removeprefix("W/").strip('"')
+    if not value.isdigit():
+        raise ValueError("missing valid If-Match version")
+    return int(value)
+
+
+def _etag(response, record):
+    if record.get("current_version"):
+        response.headers["ETag"] = f'"{record["current_version"]}"'
+    return response
+
+
+def _execute(operation, *, created=False, versioned=False):
+    try:
+        result = operation()
+        response = jsonify(success(result, code=201 if created else 200))
+        if versioned and isinstance(result, dict):
+            response = _etag(response, result)
+        return (response, 201) if created else response
+    except Exception as exc:
+        db.session.rollback()
+        if isinstance(exc, LookupError):
+            status = 404
+        elif isinstance(exc, PermissionError):
+            status = 403
+        elif isinstance(exc, RuntimeError):
+            status = 409
+        else:
+            status = 400
+        return jsonify(failed(str(exc), code=status)), status
+
+
+@bp.route("/security-governance/dashboard", methods=["GET"])
+@require_permissions(SECURITY_GOVERNANCE_READ)
+def security_dashboard():
+    return _execute(_service().dashboard)
+
+
+@bp.route("/security-governance/profiles", methods=["GET"])
+@require_permissions(SECURITY_GOVERNANCE_READ)
+def security_list_profiles():
+    return _execute(lambda: _service().list_profiles(business_domain_uid=request.args.get("business_domain_uid")))
+
+
+@bp.route("/security-governance/profiles", methods=["POST"])
+@require_permissions(SECURITY_GOVERNANCE_OPERATE)
+def security_create_profile():
+    return _execute(
+        lambda: _service().create_classification_profile(request.get_json(silent=True) or {}, actor_uid=g.current_user["id"]),
+        created=True,
+    )
+
+
+@bp.route("/security-governance/scans", methods=["GET"])
+@require_permissions(SECURITY_GOVERNANCE_READ)
+def security_list_scans():
+    return _execute(lambda: _service().list_classification_scans(business_domain_uid=request.args.get("business_domain_uid")))
+
+
+@bp.route("/security-governance/scans", methods=["POST"])
+@require_permissions(SECURITY_GOVERNANCE_OPERATE)
+def security_create_scan():
+    return _execute(
+        lambda: _service().scan_sensitive_sample(request.get_json(silent=True) or {}, actor_uid=g.current_user["id"]),
+        created=True,
+    )
+
+
+@bp.route("/security-governance/findings", methods=["GET"])
+@require_permissions(SECURITY_GOVERNANCE_READ)
+def security_findings():
+    return _execute(lambda: _service().list_classification_findings(status=request.args.get("status")))
+
+
+@bp.route("/security-governance/findings/<finding_uid>/review", methods=["POST"])
+@require_permissions(SECURITY_GOVERNANCE_MANAGE)
+def security_review_finding(finding_uid):
+    return _execute(
+        lambda: _service().review_classification_finding(
+            finding_uid, request.get_json(silent=True) or {}, expected_version=_version(), actor_uid=g.current_user["id"]
+        ),
+        versioned=True,
+    )
+
+
+@bp.route("/security-governance/access-policies", methods=["GET"])
+@require_permissions(SECURITY_GOVERNANCE_READ)
+def security_list_access_policies():
+    return _execute(lambda: _service().list_access_policies(business_domain_uid=request.args.get("business_domain_uid"), status=request.args.get("status")))
+
+
+@bp.route("/security-governance/access-policies", methods=["POST"])
+@require_permissions(SECURITY_GOVERNANCE_OPERATE)
+def security_create_access_policy():
+    return _execute(
+        lambda: _service().create_access_policy(request.get_json(silent=True) or {}, actor_uid=g.current_user["id"]),
+        created=True,
+    )
+
+
+@bp.route("/security-governance/access-decisions", methods=["GET"])
+@require_permissions(SECURITY_GOVERNANCE_READ)
+def security_access_decisions():
+    return _execute(lambda: _service().list_access_decisions(decision=request.args.get("decision")))
+
+
+@bp.route("/security-governance/access/evaluate", methods=["POST"])
+@require_permissions(SECURITY_GOVERNANCE_OPERATE)
+def security_evaluate_access():
+    body = request.get_json(silent=True) or {}
+    body["user_uid"] = g.current_user["id"]
+    body["roles"] = list(g.current_user["roles"])
+    return _execute(lambda: _service().evaluate_access(body), created=True)
+
+
+@bp.route("/security-governance/egress", methods=["GET"])
+@require_permissions(SECURITY_GOVERNANCE_READ)
+def security_list_egress():
+    return _execute(lambda: _service().list_egress_requests(status=request.args.get("status")))
+
+
+@bp.route("/security-governance/egress", methods=["POST"])
+@require_permissions(SECURITY_GOVERNANCE_OPERATE)
+def security_create_egress():
+    return _execute(
+        lambda: _service().submit_egress_request(request.get_json(silent=True) or {}, actor_uid=g.current_user["id"]),
+        created=True,
+        versioned=True,
+    )
+
+
+@bp.route("/security-governance/egress/<request_uid>/reconcile", methods=["POST"])
+@require_permissions(SECURITY_GOVERNANCE_MANAGE)
+def security_reconcile_egress(request_uid):
+    return _execute(
+        lambda: _service().reconcile_egress_request(request_uid, expected_version=_version(), actor_uid=g.current_user["id"]),
+        versioned=True,
+    )
+
+
+@bp.route("/security-governance/retention", methods=["GET"])
+@require_permissions(SECURITY_GOVERNANCE_READ)
+def security_list_retention():
+    return _execute(_service().list_retention_policies)
+
+
+@bp.route("/security-governance/retention", methods=["POST"])
+@require_permissions(SECURITY_GOVERNANCE_MANAGE)
+def security_create_retention():
+    return _execute(
+        lambda: _service().create_retention_policy(request.get_json(silent=True) or {}, actor_uid=g.current_user["id"]),
+        created=True,
+    )
+
+
+@bp.route("/security-governance/retention/candidates", methods=["GET"])
+@require_permissions(SECURITY_GOVERNANCE_MANAGE)
+def security_retention_candidates():
+    value = request.args.get("as_of") or datetime.now(UTC).isoformat()
+    return _execute(lambda: _service().retention_candidates(as_of=value, limit=int(request.args.get("limit", 100))))
+
+
+@bp.route("/security-governance/siem/sinks", methods=["GET"])
+@require_permissions(SECURITY_GOVERNANCE_MANAGE)
+def security_list_siem_sinks():
+    return _execute(_service().list_siem_sinks)
+
+
+@bp.route("/security-governance/siem/sinks", methods=["POST"])
+@require_permissions(SECURITY_GOVERNANCE_MANAGE)
+def security_create_siem_sink():
+    return _execute(
+        lambda: _service().create_siem_sink(request.get_json(silent=True) or {}, actor_uid=g.current_user["id"]),
+        created=True,
+    )
+
+
+@bp.route("/security-governance/siem/deliveries", methods=["GET"])
+@require_permissions(SECURITY_GOVERNANCE_MANAGE)
+def security_siem_deliveries():
+    return _execute(lambda: _service().list_siem_deliveries(sink_uid=request.args.get("sink_uid")))
+
+
+@bp.route("/security-governance/siem/sinks/<sink_uid>/dispatch", methods=["POST"])
+@require_permissions(SECURITY_GOVERNANCE_MANAGE)
+def security_siem_dispatch(sink_uid):
+    return _execute(lambda: _service().dispatch_siem_events(sink_uid, request.get_json(silent=True) or {}, actor_uid=g.current_user["id"]), created=True)
+
+
+@bp.route("/security-governance/sboms", methods=["GET"])
+@require_permissions(SECURITY_GOVERNANCE_READ)
+def security_list_sboms():
+    return _execute(_service().list_sboms)
+
+
+@bp.route("/security-governance/sboms", methods=["POST"])
+@require_permissions(SECURITY_GOVERNANCE_MANAGE)
+def security_register_sbom():
+    return _execute(lambda: _service().register_sbom(request.get_json(silent=True) or {}, actor_uid=g.current_user["id"]), created=True)
+
+
+@bp.route("/security-governance/sboms/<sbom_uid>/vulnerabilities", methods=["POST"])
+@require_permissions(SECURITY_GOVERNANCE_MANAGE)
+def security_import_vulnerabilities(sbom_uid):
+    return _execute(lambda: _service().ingest_vulnerabilities(sbom_uid, request.get_json(silent=True) or {}, actor_uid=g.current_user["id"]), created=True)
+
+
+@bp.route("/security-governance/vulnerabilities", methods=["GET"])
+@require_permissions(SECURITY_GOVERNANCE_READ)
+def security_vulnerabilities():
+    return _execute(lambda: _service().list_vulnerabilities(status=request.args.get("status"), severity=request.args.get("severity")))
+
+
+def _vulnerability_change(finding_uid, action):
+    service = _service()
+    method = getattr(service, f"{action}_vulnerability")
+    return _execute(
+        lambda: method(finding_uid, request.get_json(silent=True) or {}, expected_version=_version(), actor_uid=g.current_user["id"]),
+        versioned=True,
+    )
+
+
+@bp.route("/security-governance/vulnerabilities/<finding_uid>/assign", methods=["POST"])
+@require_permissions(SECURITY_GOVERNANCE_MANAGE)
+def security_assign_vulnerability(finding_uid):
+    return _vulnerability_change(finding_uid, "assign")
+
+
+@bp.route("/security-governance/vulnerabilities/<finding_uid>/resolve", methods=["POST"])
+@require_permissions(SECURITY_GOVERNANCE_MANAGE)
+def security_resolve_vulnerability(finding_uid):
+    return _vulnerability_change(finding_uid, "resolve")
+
+
+@bp.route("/security-governance/vulnerabilities/<finding_uid>/close", methods=["POST"])
+@require_permissions(SECURITY_GOVERNANCE_MANAGE)
+def security_close_vulnerability(finding_uid):
+    return _vulnerability_change(finding_uid, "close")

+ 61 - 7
deployment/app/api/system/users.py

@@ -1,5 +1,7 @@
 from __future__ import annotations
 
+import json
+
 from flask import g, jsonify, request
 from sqlalchemy import text
 from sqlalchemy.exc import IntegrityError
@@ -12,10 +14,27 @@ from app.core.system.auth import hash_password
 from app.core.system.permissions import MANAGE_USERS, require_permissions
 from app.models.result import failed, success
 
-
 VALID_ROLES = {"admin", "editor", "viewer"}
 
 
+def _audit_access_control(action, resource_uid, status, safe_detail):
+    db.session.execute(
+        text(
+            "INSERT INTO public.access_control_audit_events "
+            "(uid,action,actor_uid,resource_type,resource_uid,status,safe_detail) VALUES "
+            "(CAST(:uid AS uuid),:action,CAST(:actor AS uuid),'user',:resource_uid,:status,CAST(:detail AS jsonb))"
+        ),
+        {
+            "uid": new_governance_uid(),
+            "action": action,
+            "actor": g.current_user["id"],
+            "resource_uid": resource_uid,
+            "status": status,
+            "detail": json.dumps(safe_detail, ensure_ascii=False),
+        },
+    )
+
+
 def _active_admin_count(session) -> int:
     return int(
         session.execute(
@@ -109,6 +128,9 @@ def create_user():
             ),
             {"user_id": user_id, "assigned_by": g.current_user["id"], "roles": sorted(roles)},
         )
+        _audit_access_control(
+            "user_created", user_id, "success", {"roles": sorted(roles)}
+        )
         db.session.commit()
         return jsonify(success({"id": user_id}, "用户创建成功", code=201)), 201
     except (ValueError, IntegrityError) as exc:
@@ -123,9 +145,12 @@ def update_user(user_id: str):
     status = body.get("status")
     if status not in (None, "active", "disabled"):
         return jsonify(failed("用户状态无效", code=400)), 400
-    if status == "disabled" and _is_active_admin(db.session, user_id):
-        if _active_admin_count(db.session) <= 1:
-            return jsonify(failed("不能停用最后一个有效管理员", code=409)), 409
+    if (
+        status == "disabled"
+        and _is_active_admin(db.session, user_id)
+        and _active_admin_count(db.session) <= 1
+    ):
+        return jsonify(failed("不能停用最后一个有效管理员", code=409)), 409
     values = {"id": user_id}
     assignments = []
     if status is not None:
@@ -150,6 +175,17 @@ def update_user(user_id: str):
     if not result.rowcount:
         db.session.rollback()
         return jsonify(failed("用户不存在", code=404)), 404
+    _audit_access_control(
+        "user_updated",
+        user_id,
+        "success",
+        {
+            "changed_fields": sorted(
+                key for key in ("status", "display_name", "password") if key in body
+            ),
+            "password_value_retained": False,
+        },
+    )
     db.session.commit()
     return jsonify(success(message="用户更新成功"))
 
@@ -161,9 +197,21 @@ def update_user_roles(user_id: str):
     roles = set(body.get("roles") or [])
     if not roles or not roles <= VALID_ROLES:
         return jsonify(failed("角色无效", code=400)), 400
-    if _is_active_admin(db.session, user_id) and "admin" not in roles:
-        if _active_admin_count(db.session) <= 1:
-            return jsonify(failed("不能移除最后一个有效管理员角色", code=409)), 409
+    if (
+        _is_active_admin(db.session, user_id)
+        and "admin" not in roles
+        and _active_admin_count(db.session) <= 1
+    ):
+        return jsonify(failed("不能移除最后一个有效管理员角色", code=409)), 409
+    previous_roles = list(
+        db.session.execute(
+            text(
+                "SELECT r.name FROM public.user_roles ur JOIN public.roles r ON r.id=ur.role_id "
+                "WHERE ur.user_id=CAST(:id AS uuid) ORDER BY r.name"
+            ),
+            {"id": user_id},
+        ).scalars()
+    )
     db.session.execute(
         text("DELETE FROM public.user_roles WHERE user_id = CAST(:id AS uuid)"),
         {"id": user_id},
@@ -179,5 +227,11 @@ def update_user_roles(user_id: str):
     if result.rowcount != len(roles):
         db.session.rollback()
         return jsonify(failed("用户或角色不存在", code=404)), 404
+    _audit_access_control(
+        "user_roles_updated",
+        user_id,
+        "success",
+        {"before_roles": previous_roles, "after_roles": sorted(roles)},
+    )
     db.session.commit()
     return jsonify(success(message="角色更新成功"))

+ 3 - 0
deployment/app/config/config.py

@@ -285,6 +285,9 @@ def apply_runtime_env_config(app) -> None:
             "AUDIT_EVIDENCE_KEY_VERSION": _clean_env(
                 "AUDIT_EVIDENCE_KEY_VERSION", "local-fallback-v1"
             ),
+            "SECURITY_SIEM_HOST_ALLOWLIST": _clean_env(
+                "SECURITY_SIEM_HOST_ALLOWLIST"
+            ),
             "AGENT_CREDENTIAL_SECRET": _clean_env("AGENT_CREDENTIAL_SECRET"),
         }
     )

+ 8 - 1
deployment/app/core/governance/work_center.py

@@ -14,7 +14,13 @@ from app.core.common.identifiers import new_governance_uid
 from app.core.common.timezone_utils import now_china
 
 SUBJECT_TYPES = frozenset(
-    {"quality_issue", "semantic_governance", "data_product", "agent"}
+    {
+        "quality_issue",
+        "semantic_governance",
+        "data_product",
+        "agent",
+        "security_request",
+    }
 )
 TASK_TYPES = frozenset(
     {
@@ -23,6 +29,7 @@ TASK_TYPES = frozenset(
         "semantic_governance",
         "data_product_approval",
         "agent_approval",
+        "data_egress",
         "governance_work_order",
         "release",
         "high_risk",

+ 5 - 0
deployment/app/core/system/governance_audit.py

@@ -18,6 +18,11 @@ AUDIT_CATEGORIES = (
     "publication",
     "remediation",
     "knowledge_query",
+    "authorization",
+    "workflow_task",
+    "data_product",
+    "agent",
+    "security_governance",
 )
 MAX_SEAL_EVENTS = 50_000
 

+ 68 - 0
deployment/app/core/system/governance_audit_repository.py

@@ -174,6 +174,74 @@ _EVENT_QUERIES = {
         WHERE audit.created_at >= :period_start
           AND audit.created_at <= :period_end
     """,
+    "authorization": """
+        SELECT * FROM (
+            SELECT 'access-control:' || uid::text AS event_uid,
+                   'authorization' AS category, action, status,
+                   actor_uid::text AS actor_uid, resource_type, resource_uid,
+                   created_at AS occurred_at, safe_detail
+            FROM public.access_control_audit_events
+            UNION ALL
+            SELECT 'access-decision:' || uid::text AS event_uid,
+                   'authorization' AS category, 'access_evaluated' AS action,
+                   decision AS status, user_uid::text AS actor_uid,
+                   'data_resource' AS resource_type, resource_uid,
+                   decided_at AS occurred_at,
+                   jsonb_build_object('reason_code', reason_code) AS safe_detail
+            FROM public.security_access_decisions
+        ) evidence
+        WHERE evidence.occurred_at >= :period_start
+          AND evidence.occurred_at <= :period_end
+    """,
+    "workflow_task": """
+        SELECT 'workflow-task:' || event.uid::text AS event_uid,
+               'workflow_task' AS category, event.action,
+               task.status, event.actor_uid::text AS actor_uid,
+               'governance_task' AS resource_type,
+               event.task_uid::text AS resource_uid,
+               event.created_at AS occurred_at,
+               jsonb_build_object('task_type', task.task_type,
+                                  'subject_type', task.subject_type,
+                                  'source_state_unchanged', task.source_state_unchanged)
+                   AS safe_detail
+        FROM public.governance_task_events event
+        JOIN public.governance_tasks task ON task.uid = event.task_uid
+        WHERE event.created_at >= :period_start
+          AND event.created_at <= :period_end
+    """,
+    "data_product": """
+        SELECT 'data-product:' || uid::text AS event_uid,
+               'data_product' AS category, action,
+               COALESCE(payload->>'status', 'recorded') AS status,
+               actor_uid::text AS actor_uid,
+               'data_product' AS resource_type,
+               product_uid::text AS resource_uid,
+               created_at AS occurred_at,
+               jsonb_build_object('version', product_version) AS safe_detail
+        FROM public.data_product_governance_events
+        WHERE created_at >= :period_start AND created_at <= :period_end
+    """,
+    "agent": """
+        SELECT 'agent:' || uid::text AS event_uid,
+               'agent' AS category, action,
+               COALESCE(payload->>'decision', 'recorded') AS status,
+               actor_subject AS actor_uid,
+               'governed_agent' AS resource_type,
+               agent_uid::text AS resource_uid,
+               created_at AS occurred_at,
+               jsonb_build_object('version', agent_version) AS safe_detail
+        FROM public.agent_governance_events
+        WHERE created_at >= :period_start AND created_at <= :period_end
+    """,
+    "security_governance": """
+        SELECT 'security-governance:' || uid::text AS event_uid,
+               'security_governance' AS category, action,
+               'recorded' AS status, actor_uid::text AS actor_uid,
+               resource_type, resource_uid, created_at AS occurred_at,
+               safe_detail
+        FROM public.security_governance_events
+        WHERE created_at >= :period_start AND created_at <= :period_end
+    """,
 }
 
 _SEAL_FIELDS = """

+ 32 - 0
deployment/app/core/system/permissions.py

@@ -67,6 +67,9 @@ DATA_PRODUCTS_MANAGE = "data-products:manage"
 AGENTS_READ = "agents:read"
 AGENTS_OPERATE = "agents:operate"
 AGENTS_MANAGE = "agents:manage"
+SECURITY_GOVERNANCE_READ = "security-governance:read"
+SECURITY_GOVERNANCE_OPERATE = "security-governance:operate"
+SECURITY_GOVERNANCE_MANAGE = "security-governance:manage"
 
 ROLE_PERMISSIONS = {
     "viewer": frozenset(
@@ -80,6 +83,7 @@ ROLE_PERMISSIONS = {
             WORK_CENTER_READ,
             DATA_PRODUCTS_READ,
             AGENTS_READ,
+            SECURITY_GOVERNANCE_READ,
         }
     ),
     "editor": frozenset(
@@ -115,6 +119,8 @@ ROLE_PERMISSIONS = {
             DATA_PRODUCTS_OPERATE,
             AGENTS_READ,
             AGENTS_OPERATE,
+            SECURITY_GOVERNANCE_READ,
+            SECURITY_GOVERNANCE_OPERATE,
         }
     ),
     "admin": frozenset(
@@ -179,6 +185,9 @@ ROLE_PERMISSIONS = {
             AGENTS_READ,
             AGENTS_OPERATE,
             AGENTS_MANAGE,
+            SECURITY_GOVERNANCE_READ,
+            SECURITY_GOVERNANCE_OPERATE,
+            SECURITY_GOVERNANCE_MANAGE,
         }
     ),
 }
@@ -208,6 +217,29 @@ def permission_for_request(path: str, method: str) -> tuple[str, ...]:
         if method == "GET":
             return (GOVERNANCE_AUDIT_READ,)
         return (GOVERNANCE_AUDIT_SEAL,)
+    if path.startswith("/api/system/security-governance"):
+        if method == "GET":
+            return (SECURITY_GOVERNANCE_READ,)
+        if any(
+            marker in path
+            for marker in (
+                "/findings/",
+                "/reconcile",
+                "/vulnerabilities/",
+                "/dispatch",
+            )
+        ):
+            return (SECURITY_GOVERNANCE_MANAGE,)
+        if any(
+            path.startswith(prefix)
+            for prefix in (
+                "/api/system/security-governance/retention",
+                "/api/system/security-governance/siem",
+                "/api/system/security-governance/sboms",
+            )
+        ):
+            return (SECURITY_GOVERNANCE_MANAGE,)
+        return (SECURITY_GOVERNANCE_OPERATE,)
     if path.startswith("/api/dataservice/governance"):
         if method == "GET":
             return (DATA_PRODUCTS_READ,)

+ 61 - 0
deployment/app/core/system/security_delivery.py

@@ -0,0 +1,61 @@
+"""Restricted outbound transports for security audit evidence."""
+
+from __future__ import annotations
+
+import json
+import socket
+import ssl
+from urllib.parse import urlsplit
+from urllib.request import HTTPRedirectHandler, HTTPSHandler, Request, build_opener
+
+
+class _NoRedirect(HTTPRedirectHandler):
+    def redirect_request(self, req, fp, code, msg, headers, newurl):
+        return None
+
+
+class HttpsWebhookSyslogTransport:
+    """Deliver bounded safe envelopes after service-level endpoint validation."""
+
+    def __init__(self, *, timeout: float = 5.0, maximum_bytes: int = 1_048_576):
+        self.timeout = float(timeout)
+        self.maximum_bytes = int(maximum_bytes)
+
+    def deliver(self, sink, envelope):
+        payload = json.dumps(
+            envelope, ensure_ascii=False, sort_keys=True, separators=(",", ":")
+        ).encode("utf-8")
+        if len(payload) > self.maximum_bytes:
+            return {"status": "failed", "error_code": "payload_too_large"}
+        try:
+            if sink["sink_type"] == "webhook":
+                return self._webhook(sink["endpoint"], payload)
+            return self._syslog_tls(sink["endpoint"], payload)
+        except (OSError, ssl.SSLError, TimeoutError):
+            return {"status": "failed", "error_code": "transport_unavailable"}
+
+    def _webhook(self, endpoint, payload):
+        request = Request(
+            endpoint,
+            data=payload,
+            headers={"Content-Type": "application/json", "User-Agent": "dataops-security/1"},
+            method="POST",
+        )
+        opener = build_opener(HTTPSHandler(), _NoRedirect())
+        with opener.open(request, timeout=self.timeout) as response:
+            status = int(response.status)
+            if status < 200 or status >= 300:
+                return {"status": "failed", "error_code": f"http_{status}"}
+            remote_ref = str(response.headers.get("X-Request-ID") or "")[:300] or None
+            return {"status": "delivered", "remote_ref": remote_ref}
+
+    def _syslog_tls(self, endpoint, payload):
+        parsed = urlsplit(endpoint)
+        port = int(parsed.port or 6514)
+        context = ssl.create_default_context()
+        with (
+            socket.create_connection((parsed.hostname, port), timeout=self.timeout) as raw,
+            context.wrap_socket(raw, server_hostname=parsed.hostname) as secured,
+        ):
+            secured.sendall(payload + b"\n")
+        return {"status": "delivered"}

+ 882 - 0
deployment/app/core/system/security_governance.py

@@ -0,0 +1,882 @@
+"""Cross-domain data security governance and security engineering controls."""
+
+from __future__ import annotations
+
+import copy
+import hashlib
+import json
+import re
+import uuid
+from collections.abc import Callable
+from datetime import UTC, datetime, timedelta
+from typing import Any
+from urllib.parse import urlsplit
+
+from app.core.common.identifiers import new_governance_uid
+from app.core.common.timezone_utils import now_china
+
+CLASSIFICATIONS = ("public", "internal", "sensitive", "highly_sensitive")
+CLASSIFICATION_RANK = {value: index for index, value in enumerate(CLASSIFICATIONS)}
+FINDING_STATUSES = {"pending_review", "confirmed", "dismissed"}
+ACCESS_ACTIONS = {"read", "use"}
+ENVIRONMENTS = {"development", "test", "production"}
+RETENTION_EVIDENCE_TYPES = {
+    "classification_evidence", "access_decision", "egress_request",
+    "audit_event", "siem_delivery", "sbom", "vulnerability",
+}
+ARCHIVE_MODES = {"hot", "immutable_external"}
+DISPOSITION_ACTIONS = {"review", "archive"}
+SIEM_CATEGORIES = {
+    "authentication", "ingestion", "entity_resolution", "publication",
+    "remediation", "knowledge_query", "authorization", "workflow_task",
+    "data_product", "agent", "security_governance",
+}
+SEVERITIES = {"unknown", "low", "medium", "high", "critical"}
+RESOLUTION_TYPES = {"patched", "not_affected", "accepted_risk"}
+ROLE_PATTERN = re.compile(r"^[a-z][a-z0-9:_-]{1,79}$")
+CODE_PATTERN = re.compile(r"^[A-Z][A-Z0-9_]{2,119}$")
+FIELD_PATTERN = re.compile(r"^[A-Za-z_][A-Za-z0-9_.-]{0,199}$")
+HASH_PATTERN = re.compile(r"^[0-9a-f]{64}$")
+PHONE_PATTERN = re.compile(r"(?<!\d)1[3-9]\d{9}(?!\d)")
+EMAIL_PATTERN = re.compile(r"(?i)\b[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}\b")
+BANK_CARD_PATTERN = re.compile(r"(?<!\d)\d{15,19}(?!\d)")
+PRC_ID_PATTERN = re.compile(r"(?<!\d)\d{17}[0-9Xx](?!\d)")
+
+
+def _closed(value: Any, allowed: set[str], label: str) -> dict[str, Any]:
+    if not isinstance(value, dict):
+        raise ValueError(f"{label} must be an object")
+    unknown = sorted(set(value) - allowed)
+    if unknown:
+        raise ValueError(f"{label} contains unsupported fields: {', '.join(unknown)}")
+    return copy.deepcopy(value)
+
+
+def _text(value: Any, label: str, maximum: int = 1000) -> str:
+    if not isinstance(value, str) or not value.strip():
+        raise ValueError(f"{label} is required")
+    result = value.strip()
+    if len(result) > maximum:
+        raise ValueError(f"{label} exceeds {maximum} characters")
+    return result
+
+
+def _optional_text(value: Any, label: str, maximum: int = 1000) -> str | None:
+    if value in (None, ""):
+        return None
+    return _text(value, label, maximum)
+
+
+def _uid(value: Any, label: str) -> str:
+    try:
+        return str(uuid.UUID(str(value)))
+    except (TypeError, ValueError, AttributeError) as error:
+        raise ValueError(f"{label} must be a UUID") from error
+
+
+def _list(value: Any, label: str, minimum: int = 0, maximum: int = 1000) -> list[Any]:
+    if not isinstance(value, list) or len(value) < minimum or len(value) > maximum:
+        raise ValueError(f"{label} must contain between {minimum} and {maximum} items")
+    return copy.deepcopy(value)
+
+
+def _time(value: Any, label: str) -> datetime:
+    if isinstance(value, datetime):
+        result = value
+    else:
+        try:
+            result = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
+        except (TypeError, ValueError) as error:
+            raise ValueError(f"{label} must be ISO-8601") from error
+    if result.tzinfo is None:
+        raise ValueError(f"{label} must include a timezone")
+    return result.astimezone(UTC)
+
+
+def _classification(value: Any, label: str = "classification") -> str:
+    result = _text(value, label, 40)
+    if result not in CLASSIFICATION_RANK:
+        raise ValueError(f"unsupported {label}")
+    return result
+
+
+def _canonical(value: Any) -> bytes:
+    return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode()
+
+
+def _digest(value: Any) -> str:
+    return hashlib.sha256(_canonical(value)).hexdigest()
+
+
+def _mask(value: str, detector: str) -> str:
+    if detector == "email" and "@" in value:
+        local, domain = value.split("@", 1)
+        return f"{local[:1]}***@{domain}"
+    if detector == "phone" and len(value) >= 7:
+        return f"{value[:3]}****{value[-4:]}"
+    if len(value) >= 6:
+        return f"{value[:2]}****{value[-2:]}"
+    return "***"
+
+
+def _normalize_evidence(value: Any) -> list[dict[str, str]]:
+    result = []
+    for item in _list(value, "evidence_refs", 1, 50):
+        body = _closed(item, {"type", "ref", "digest"}, "evidence reference")
+        digest = _text(body.get("digest"), "evidence digest", 64).lower()
+        if not HASH_PATTERN.fullmatch(digest):
+            raise ValueError("evidence digest must be SHA-256")
+        result.append({
+            "type": _text(body.get("type"), "evidence type", 60),
+            "ref": _text(body.get("ref"), "evidence ref", 300),
+            "digest": digest,
+        })
+    return result
+
+
+class SecurityGovernanceService:
+    """Own security decisions while leaving data and external security tools authoritative."""
+
+    def __init__(
+        self,
+        repository,
+        *,
+        approval_gateway,
+        siem_transport,
+        siem_host_allowlist: set[str] | frozenset[str],
+        uid_factory: Callable[[], str] = new_governance_uid,
+        now_factory: Callable[[], datetime] = now_china,
+        commit: Callable[[], None] = lambda: None,
+        rollback: Callable[[], None] = lambda: None,
+    ):
+        self.repository = repository
+        self.approval_gateway = approval_gateway
+        self.siem_transport = siem_transport
+        self.siem_host_allowlist = {
+            str(value).strip().lower() for value in siem_host_allowlist if str(value).strip()
+        }
+        self.uid_factory = uid_factory
+        self.now_factory = now_factory
+        self.commit = commit
+        self.rollback = rollback
+
+    def _actor(self, actor_uid: Any) -> str:
+        actor = _uid(actor_uid, "actor_uid")
+        if self.repository.users_available({actor}) != {actor}:
+            raise ValueError("security actor is unavailable")
+        return actor
+
+    def _save(self, operation):
+        try:
+            result = operation()
+            self.commit()
+            return result
+        except Exception:
+            self.rollback()
+            raise
+
+    def _event(self, resource_type, resource_uid, action, actor_uid, detail=None):
+        self.repository.add_event(
+            resource_type, resource_uid, action, actor_uid, copy.deepcopy(detail or {})
+        )
+
+    def create_classification_profile(self, payload: Any, *, actor_uid: str):
+        body = _closed(
+            payload,
+            {"code", "name", "business_domain_uid", "default_classification", "rules"},
+            "classification profile",
+        )
+        actor = self._actor(actor_uid)
+        code = _text(body.get("code"), "profile code", 120).upper()
+        if not CODE_PATTERN.fullmatch(code):
+            raise ValueError("classification profile code is invalid")
+        rules = []
+        for raw in _list(body.get("rules"), "classification rules", 1, 100):
+            rule = _closed(raw, {"field_tokens", "category", "classification"}, "classification rule")
+            tokens = sorted({
+                _text(item, "field token", 60).casefold()
+                for item in _list(rule.get("field_tokens"), "field_tokens", 1, 20)
+            })
+            if any(not re.fullmatch(r"[a-z0-9_-]+", item) for item in tokens):
+                raise ValueError("field tokens must be simple identifiers")
+            rules.append({
+                "field_tokens": tokens,
+                "category": _text(rule.get("category"), "category", 80),
+                "classification": _classification(rule.get("classification")),
+            })
+        now = self.now_factory().isoformat()
+        record = {
+            "uid": self.uid_factory(), "code": code,
+            "name": _text(body.get("name"), "profile name", 300),
+            "business_domain_uid": _uid(body.get("business_domain_uid"), "business_domain_uid"),
+            "default_classification": _classification(body.get("default_classification")),
+            "rules": rules, "status": "active", "current_version": 1,
+            "created_by": actor, "created_at": now, "updated_at": now,
+        }
+
+        def operation():
+            result = self.repository.create_profile(record)
+            self._event("classification_profile", record["uid"], "profile_created", actor, {"code": code})
+            return result
+
+        return self._save(operation)
+
+    @staticmethod
+    def _detect_field(profile: dict[str, Any], field: dict[str, Any]):
+        field_name = _text(field.get("name"), "field name", 200)
+        if not FIELD_PATTERN.fullmatch(field_name):
+            raise ValueError("field name is invalid")
+        values = [str(value)[:500] for value in _list(field.get("sample_values", []), "sample_values", 0, 20)]
+        normalized = field_name.casefold().replace("-", "_").replace(".", "_")
+        matches = []
+        for rule in profile["rules"]:
+            if any(token in normalized.split("_") or token in normalized for token in rule["field_tokens"]):
+                matches.append((rule["classification"], rule["category"], "field_rule", None))
+        detectors = (
+            ("prc_id", PRC_ID_PATTERN, "personal_identifier", "highly_sensitive"),
+            ("bank_card", BANK_CARD_PATTERN, "financial_account", "highly_sensitive"),
+            ("phone", PHONE_PATTERN, "personal_contact", "sensitive"),
+            ("email", EMAIL_PATTERN, "personal_contact", "sensitive"),
+        )
+        for value in values:
+            for detector, pattern, category, level in detectors:
+                found = pattern.search(value)
+                if found:
+                    sample = found.group(0)
+                    matches.append((level, category, detector, sample))
+        if not matches:
+            return None
+        level = max(matches, key=lambda item: CLASSIFICATION_RANK[item[0]])[0]
+        categories = sorted({item[1] for item in matches})
+        detector_codes = sorted({item[2] for item in matches})
+        raw_matches = [item for item in matches if item[3] is not None]
+        return {
+            "field_name": field_name,
+            "categories": categories,
+            "proposed_classification": level,
+            "detector_codes": detector_codes,
+            "sample_fingerprints": sorted({_digest(item[3]) for item in raw_matches}),
+            "masked_examples": sorted({_mask(item[3], item[2]) for item in raw_matches})[:3],
+        }
+
+    def scan_sensitive_sample(self, payload: Any, *, actor_uid: str):
+        body = _closed(
+            payload,
+            {"profile_uid", "resource_type", "resource_uid", "business_domain_uid", "fields"},
+            "sensitive sample scan",
+        )
+        actor = self._actor(actor_uid)
+        profile = self.repository.get_profile(_uid(body.get("profile_uid"), "profile_uid"))
+        if not profile or profile["status"] != "active":
+            raise LookupError("active classification profile was not found")
+        domain_uid = _uid(body.get("business_domain_uid"), "business_domain_uid")
+        if profile["business_domain_uid"] != domain_uid:
+            raise PermissionError("classification profile domain does not match")
+        fields = _list(body.get("fields"), "fields", 1, 100)
+        now = self.now_factory().isoformat()
+        scan_uid = self.uid_factory()
+        findings = []
+        for field in fields:
+            normalized = self._detect_field(profile, field)
+            if not normalized:
+                continue
+            findings.append({
+                "uid": self.uid_factory(), "scan_uid": scan_uid,
+                **normalized, "status": "pending_review", "final_classification": None,
+                "review_reason": None, "reviewed_by": None, "reviewed_at": None,
+                "current_version": 1, "created_at": now,
+            })
+        scan = {
+            "uid": scan_uid, "profile_uid": profile["uid"],
+            "resource_type": _text(body.get("resource_type"), "resource_type", 80),
+            "resource_uid": _text(body.get("resource_uid"), "resource_uid", 200),
+            "business_domain_uid": domain_uid, "field_count": len(fields),
+            "finding_count": len(findings), "sample_retained": False,
+            "created_by": actor, "created_at": now,
+        }
+
+        def operation():
+            result = self.repository.create_scan(scan, findings)
+            self._event(
+                "classification_scan", scan_uid, "sample_scanned", actor,
+                {"finding_count": len(findings), "sample_retained": False},
+            )
+            return result
+
+        return self._save(operation)
+
+    def review_classification_finding(
+        self, finding_uid: str, payload: Any, *, expected_version: int, actor_uid: str
+    ):
+        body = _closed(payload, {"decision", "final_classification", "reason"}, "classification review")
+        actor = self._actor(actor_uid)
+        finding = self.repository.get_finding(_uid(finding_uid, "finding_uid"))
+        if not finding:
+            raise LookupError("classification finding was not found")
+        if finding["status"] != "pending_review":
+            raise RuntimeError("classification finding is not pending review")
+        scan = self.repository.get_scan(finding["scan_uid"]) if hasattr(self.repository, "get_scan") else None
+        creator = scan.get("created_by") if scan else None
+        if actor == creator or (creator is None and actor == finding.get("created_by")):
+            raise PermissionError("classification requires an independent reviewer")
+        # Memory repositories keep the creator on the scan rather than the finding.
+        if (
+            creator is None
+            and hasattr(self.repository, "scans")
+            and actor == self.repository.scans[finding["scan_uid"]]["created_by"]
+        ):
+            raise PermissionError("classification requires an independent reviewer")
+        decision = _text(body.get("decision"), "decision", 20)
+        if decision not in {"confirm", "dismiss"}:
+            raise ValueError("unsupported classification review decision")
+        updated = {
+            **finding,
+            "status": "confirmed" if decision == "confirm" else "dismissed",
+            "final_classification": (
+                _classification(body.get("final_classification")) if decision == "confirm" else None
+            ),
+            "review_reason": _text(body.get("reason"), "review reason", 1000),
+            "reviewed_by": actor, "reviewed_at": self.now_factory().isoformat(),
+        }
+
+        def operation():
+            result = self.repository.update_finding(updated, int(expected_version))
+            self._event("classification_finding", finding["uid"], f"finding_{updated['status']}", actor, {"classification": updated["final_classification"]})
+            return result
+
+        return self._save(operation)
+
+    def create_access_policy(self, payload: Any, *, actor_uid: str):
+        body = _closed(
+            payload,
+            {
+                "code", "name", "business_domain_uid", "subject_user_uids",
+                "subject_roles", "purposes", "environments", "actions",
+                "max_classification", "allowed_fields", "expires_at", "review_due_at",
+            },
+            "access policy",
+        )
+        actor = self._actor(actor_uid)
+        code = _text(body.get("code"), "policy code", 120).upper()
+        if not CODE_PATTERN.fullmatch(code):
+            raise ValueError("access policy code is invalid")
+        users = sorted({_uid(value, "subject_user_uid") for value in _list(body.get("subject_user_uids", []), "subject_user_uids", 0, 100)})
+        roles = sorted({_text(value, "subject role", 80) for value in _list(body.get("subject_roles", []), "subject_roles", 0, 50)})
+        if not users and not roles:
+            raise ValueError("access policy requires a user or role subject")
+        if users and self.repository.users_available(users) != set(users):
+            raise ValueError("access policy contains unavailable users")
+        if any(not ROLE_PATTERN.fullmatch(role) for role in roles):
+            raise ValueError("access policy role is invalid")
+        purposes = sorted({_text(value, "purpose", 100) for value in _list(body.get("purposes"), "purposes", 1, 50)})
+        environments = sorted({_text(value, "environment", 30) for value in _list(body.get("environments"), "environments", 1, 10)})
+        actions = sorted({_text(value, "action", 20) for value in _list(body.get("actions"), "actions", 1, 10)})
+        if not set(environments) <= ENVIRONMENTS or not set(actions) <= ACCESS_ACTIONS:
+            raise ValueError("unsupported access environment or action")
+        fields = sorted({_text(value, "allowed field", 200) for value in _list(body.get("allowed_fields"), "allowed_fields", 1, 500)})
+        if any(value != "*" and not FIELD_PATTERN.fullmatch(value) for value in fields):
+            raise ValueError("allowed field is invalid")
+        now = self.now_factory().astimezone(UTC)
+        expires = _time(body.get("expires_at"), "expires_at")
+        review = _time(body.get("review_due_at"), "review_due_at")
+        if review <= now or expires <= review:
+            raise ValueError("access policy review and expiry dates are invalid")
+        record = {
+            "uid": self.uid_factory(), "code": code,
+            "name": _text(body.get("name"), "policy name", 300),
+            "business_domain_uid": _uid(body.get("business_domain_uid"), "business_domain_uid"),
+            "subject_user_uids": users, "subject_roles": roles, "purposes": purposes,
+            "environments": environments, "actions": actions,
+            "max_classification": _classification(body.get("max_classification"), "max_classification"),
+            "allowed_fields": fields, "expires_at": expires.isoformat(),
+            "review_due_at": review.isoformat(), "status": "active", "current_version": 1,
+            "created_by": actor, "created_at": now.isoformat(), "updated_at": now.isoformat(),
+        }
+
+        def operation():
+            result = self.repository.create_access_policy(record)
+            self._event("access_policy", record["uid"], "access_policy_created", actor, {"code": code})
+            return result
+
+        return self._save(operation)
+
+    @staticmethod
+    def _policy_matches(policy: dict[str, Any], context: dict[str, Any], now: datetime, *, check_fields=True):
+        subject = (
+            context["user_uid"] in policy["subject_user_uids"]
+            or bool(set(context["roles"]) & set(policy["subject_roles"]))
+        )
+        if not subject:
+            return False
+        if policy["business_domain_uid"] != context["business_domain_uid"]:
+            return False
+        if context["purpose"] not in policy["purposes"]:
+            return False
+        if context["environment"] not in policy["environments"]:
+            return False
+        if context["action"] not in policy["actions"]:
+            return False
+        if CLASSIFICATION_RANK[context["classification"]] > CLASSIFICATION_RANK[policy["max_classification"]]:
+            return False
+        if _time(policy["expires_at"], "expires_at") <= now or _time(policy["review_due_at"], "review_due_at") <= now:
+            return False
+        if check_fields and "*" not in policy["allowed_fields"]:
+            return set(context["requested_fields"]) <= set(policy["allowed_fields"])
+        return True
+
+    def evaluate_access(self, payload: Any):
+        body = _closed(
+            payload,
+            {
+                "user_uid", "roles", "business_domain_uid", "purpose", "environment",
+                "action", "resource_uid", "classification", "requested_fields",
+            },
+            "access decision",
+        )
+        user_uid = _uid(body.get("user_uid"), "user_uid")
+        if self.repository.users_available({user_uid}) != {user_uid}:
+            raise ValueError("access user is unavailable")
+        roles = sorted({_text(value, "role", 80) for value in _list(body.get("roles"), "roles", 1, 50)})
+        fields = sorted({_text(value, "requested field", 200) for value in _list(body.get("requested_fields"), "requested_fields", 1, 500)})
+        context = {
+            "user_uid": user_uid, "roles": roles,
+            "business_domain_uid": _uid(body.get("business_domain_uid"), "business_domain_uid"),
+            "purpose": _text(body.get("purpose"), "purpose", 100),
+            "environment": _text(body.get("environment"), "environment", 30),
+            "action": _text(body.get("action"), "action", 20),
+            "resource_uid": _text(body.get("resource_uid"), "resource_uid", 200),
+            "classification": _classification(body.get("classification")),
+            "requested_fields": fields,
+        }
+        if context["environment"] not in ENVIRONMENTS or context["action"] not in ACCESS_ACTIONS:
+            raise ValueError("unsupported access environment or action")
+        now = self.now_factory().astimezone(UTC)
+        policies = self.repository.matching_access_policies(**context)
+        policy = next((item for item in policies if self._policy_matches(item, context, now)), None)
+        scope_policy = next((item for item in policies if self._policy_matches(item, context, now, check_fields=False)), None)
+        if policy:
+            decision, reason = "authorized", "policy_allowed"
+        elif scope_policy:
+            decision, reason = "denied", "field_minimization_denied"
+        else:
+            decision, reason = "denied", "default_deny"
+        record = {
+            "uid": self.uid_factory(), **context,
+            "policy_uid": policy["uid"] if policy else None,
+            "decision": decision, "reason_code": reason,
+            "decided_at": self.now_factory().isoformat(),
+        }
+
+        def operation():
+            result = self.repository.create_access_decision(record)
+            self._event("access_decision", record["uid"], f"access_{decision}", user_uid, {"reason_code": reason, "policy_uid": record["policy_uid"]})
+            return result
+
+        return self._save(operation)
+
+    def submit_egress_request(self, payload: Any, *, actor_uid: str):
+        body = _closed(
+            payload,
+            {
+                "business_domain_uid", "resource_uid", "classification", "purpose",
+                "environment", "requested_fields", "minimized_fields", "masking_applied",
+                "destination_zone", "expires_at", "workflow_uid",
+            },
+            "egress request",
+        )
+        actor = self._actor(actor_uid)
+        requested = sorted({_text(value, "requested field", 200) for value in _list(body.get("requested_fields"), "requested_fields", 1, 500)})
+        minimized = sorted({_text(value, "minimized field", 200) for value in _list(body.get("minimized_fields"), "minimized_fields", 1, 500)})
+        if not set(minimized) <= set(requested):
+            raise ValueError("minimized fields must be a subset of requested fields")
+        masking = body.get("masking_applied")
+        if not isinstance(masking, bool):
+            raise ValueError("masking_applied must be boolean")
+        classification = _classification(body.get("classification"))
+        if classification in {"sensitive", "highly_sensitive"} and not masking:
+            raise ValueError("sensitive egress requires masking")
+        environment = _text(body.get("environment"), "environment", 30)
+        if environment not in ENVIRONMENTS:
+            raise ValueError("unsupported egress environment")
+        now = self.now_factory().astimezone(UTC)
+        expires = _time(body.get("expires_at"), "expires_at")
+        if expires <= now or expires > now + timedelta(days=30):
+            raise ValueError("egress expiry must be within 30 days")
+        high = classification == "highly_sensitive"
+        record = {
+            "uid": self.uid_factory(),
+            "business_domain_uid": _uid(body.get("business_domain_uid"), "business_domain_uid"),
+            "resource_uid": _text(body.get("resource_uid"), "resource_uid", 200),
+            "classification": classification,
+            "purpose": _text(body.get("purpose"), "purpose", 300),
+            "environment": environment, "requested_fields": requested,
+            "minimized_fields": minimized, "approved_fields": [],
+            "masking_applied": masking,
+            "destination_zone": _text(body.get("destination_zone"), "destination_zone", 100),
+            "expires_at": expires.isoformat(),
+            "status": "denied" if high else "pending_approval",
+            "reason_code": "highly_sensitive_egress_disabled" if high else "approval_required",
+            "approval_task_uid": None, "current_version": 1,
+            "created_by": actor, "created_at": now.isoformat(), "updated_at": now.isoformat(),
+        }
+        if not high:
+            workflow_uid = _uid(body.get("workflow_uid"), "workflow_uid")
+            task = self.approval_gateway.create_egress_task(record, workflow_uid, actor)
+            record["approval_task_uid"] = task["uid"]
+
+        def operation():
+            result = self.repository.create_egress_request(record)
+            self._event("egress_request", record["uid"], f"egress_{record['status']}", actor, {"classification": classification, "reason_code": record["reason_code"]})
+            return result
+
+        return self._save(operation)
+
+    def reconcile_egress_request(self, request_uid: str, *, expected_version: int, actor_uid: str):
+        actor = self._actor(actor_uid)
+        record = self.repository.get_egress_request(_uid(request_uid, "request_uid"))
+        if not record:
+            raise LookupError("egress request was not found")
+        if record["status"] != "pending_approval":
+            raise RuntimeError("egress request is not pending approval")
+        task = self.approval_gateway.get_task(record["approval_task_uid"])
+        if not task or task["status"] not in {"approved", "rejected"}:
+            raise RuntimeError("egress approval has no final decision")
+        approved = task["status"] == "approved"
+        updated = {
+            **record,
+            "status": "authorized_until_expiry" if approved else "denied",
+            "reason_code": "approval_granted" if approved else "approval_rejected",
+            "approved_fields": record["minimized_fields"] if approved else [],
+            "updated_at": self.now_factory().isoformat(),
+        }
+
+        def operation():
+            result = self.repository.update_egress_request(updated, int(expected_version))
+            self._event("egress_request", record["uid"], f"egress_{updated['status']}", actor, {"approved_field_count": len(updated["approved_fields"])})
+            return result
+
+        return self._save(operation)
+
+    def create_retention_policy(self, payload: Any, *, actor_uid: str):
+        body = _closed(
+            payload,
+            {"code", "name", "evidence_type", "retention_days", "archive_mode", "disposition_action"},
+            "retention policy",
+        )
+        actor = self._actor(actor_uid)
+        code = _text(body.get("code"), "retention code", 120).upper()
+        if not CODE_PATTERN.fullmatch(code):
+            raise ValueError("retention code is invalid")
+        evidence_type = _text(body.get("evidence_type"), "evidence_type", 60)
+        archive_mode = _text(body.get("archive_mode"), "archive_mode", 40)
+        disposition = _text(body.get("disposition_action"), "disposition_action", 40)
+        if evidence_type not in RETENTION_EVIDENCE_TYPES or archive_mode not in ARCHIVE_MODES or disposition not in DISPOSITION_ACTIONS:
+            raise ValueError("unsupported retention policy option")
+        try:
+            days = int(body.get("retention_days"))
+        except (TypeError, ValueError) as error:
+            raise ValueError("retention_days must be an integer") from error
+        if days < 30 or days > 36500:
+            raise ValueError("retention_days must be between 30 and 36500")
+        now = self.now_factory().isoformat()
+        record = {
+            "uid": self.uid_factory(), "code": code,
+            "name": _text(body.get("name"), "retention name", 300),
+            "evidence_type": evidence_type, "retention_days": days,
+            "archive_mode": archive_mode, "disposition_action": disposition,
+            "automatic_deletion": False, "status": "active", "current_version": 1,
+            "created_by": actor, "created_at": now, "updated_at": now,
+        }
+
+        def operation():
+            result = self.repository.create_retention_policy(record)
+            self._event("retention_policy", record["uid"], "retention_policy_created", actor, {"evidence_type": evidence_type, "automatic_deletion": False})
+            return result
+
+        return self._save(operation)
+
+    def retention_candidates(self, *, as_of: datetime, limit: int):
+        instant = _time(as_of, "as_of")
+        size = int(limit)
+        if size < 1 or size > 1000:
+            raise ValueError("retention candidate limit must be between 1 and 1000")
+        return self.repository.retention_candidates(instant, size)
+
+    def create_siem_sink(self, payload: Any, *, actor_uid: str):
+        body = _closed(payload, {"name", "sink_type", "endpoint", "categories"}, "SIEM sink")
+        actor = self._actor(actor_uid)
+        sink_type = _text(body.get("sink_type"), "sink_type", 30)
+        endpoint = _text(body.get("endpoint"), "endpoint", 500)
+        parsed = urlsplit(endpoint)
+        required_scheme = "https" if sink_type == "webhook" else "tls"
+        if sink_type not in {"webhook", "syslog_tls"} or parsed.scheme != required_scheme:
+            raise ValueError("SIEM sink requires HTTPS webhook or TLS syslog")
+        if parsed.username or parsed.password or parsed.query or parsed.fragment:
+            raise ValueError("SIEM endpoint must not contain credentials, query or fragment")
+        host = str(parsed.hostname or "").lower()
+        if not host or host not in self.siem_host_allowlist:
+            raise ValueError("SIEM endpoint host is outside the allowlist")
+        categories = sorted({_text(value, "SIEM category", 40) for value in _list(body.get("categories"), "categories", 1, 20)})
+        if not set(categories) <= SIEM_CATEGORIES:
+            raise ValueError("unsupported SIEM audit category")
+        now = self.now_factory().isoformat()
+        record = {
+            "uid": self.uid_factory(), "name": _text(body.get("name"), "sink name", 300),
+            "sink_type": sink_type, "endpoint": endpoint, "endpoint_host": host,
+            "categories": categories, "status": "active", "current_version": 1,
+            "created_by": actor, "created_at": now, "updated_at": now,
+        }
+
+        def operation():
+            result = self.repository.create_siem_sink(record)
+            self._event("siem_sink", record["uid"], "siem_sink_created", actor, {"sink_type": sink_type, "endpoint_host": host})
+            return result
+
+        return self._save(operation)
+
+    def dispatch_siem_events(self, sink_uid: str, payload: Any, *, actor_uid: str):
+        body = _closed(payload, {"period_start", "period_end", "limit"}, "SIEM delivery")
+        actor = self._actor(actor_uid)
+        sink = self.repository.get_siem_sink(_uid(sink_uid, "sink_uid"))
+        if not sink or sink["status"] != "active":
+            raise LookupError("active SIEM sink was not found")
+        start = _time(body.get("period_start"), "period_start")
+        end = _time(body.get("period_end"), "period_end")
+        limit = int(body.get("limit", 100))
+        if end <= start or limit < 1 or limit > 1000:
+            raise ValueError("SIEM delivery window or limit is invalid")
+        events = self.repository.fetch_siem_events(
+            categories=sink["categories"], period_start=start, period_end=end, limit=limit
+        )
+        envelope = {
+            "schema": "dataops.security.audit.v1",
+            "period_start": start.isoformat(), "period_end": end.isoformat(),
+            "events": events,
+        }
+        result = self.siem_transport.deliver(sink, envelope)
+        status = result.get("status")
+        if status not in {"delivered", "failed"}:
+            raise RuntimeError("SIEM transport returned an invalid status")
+        now = self.now_factory().isoformat()
+        record = {
+            "uid": self.uid_factory(), "sink_uid": sink["uid"],
+            "period_start": start.isoformat(), "period_end": end.isoformat(),
+            "event_count": len(events), "payload_digest": _digest(envelope),
+            "status": status, "remote_ref": _optional_text(result.get("remote_ref"), "remote_ref", 300),
+            "error_code": _optional_text(result.get("error_code"), "error_code", 80),
+            "created_by": actor, "created_at": now,
+        }
+
+        def operation():
+            saved = self.repository.create_siem_delivery(record)
+            self._event("siem_delivery", saved["uid"], f"siem_{status}", actor, {"event_count": len(events), "payload_digest": record["payload_digest"]})
+            return saved
+
+        return self._save(operation)
+
+    def register_sbom(self, payload: Any, *, actor_uid: str):
+        body = _closed(
+            payload,
+            {"artifact_name", "artifact_version", "artifact_type", "source_ref", "document"},
+            "SBOM registration",
+        )
+        actor = self._actor(actor_uid)
+        document = body.get("document")
+        if not isinstance(document, dict):
+            raise ValueError("SBOM document must be an object")
+        if document.get("bomFormat") != "CycloneDX" or document.get("specVersion") != "1.5":
+            raise ValueError("SBOM must use CycloneDX 1.5")
+        components = []
+        for raw in _list(document.get("components", []), "SBOM components", 0, 10000):
+            component = _closed(
+                raw,
+                {
+                    "type", "name", "version", "purl", "bom-ref", "licenses",
+                    "externalReferences", "properties", "group", "supplier", "publisher",
+                    "author", "description", "hashes", "scope", "copyright",
+                },
+                "SBOM component",
+            )
+            components.append({
+                "type": _text(component.get("type"), "component type", 50),
+                "name": _text(component.get("name"), "component name", 300),
+                "version": _optional_text(component.get("version"), "component version", 200),
+                "purl": _optional_text(component.get("purl"), "component purl", 500),
+            })
+        now = self.now_factory().isoformat()
+        record = {
+            "uid": self.uid_factory(),
+            "artifact_name": _text(body.get("artifact_name"), "artifact name", 300),
+            "artifact_version": _text(body.get("artifact_version"), "artifact version", 120),
+            "artifact_type": _text(body.get("artifact_type"), "artifact type", 50),
+            "source_ref": _text(body.get("source_ref"), "source_ref", 500),
+            "format": "CycloneDX", "spec_version": "1.5",
+            "document_digest": _digest(document), "component_count": len(components),
+            "components": sorted(components, key=lambda item: (item["name"], item.get("version") or "")),
+            "created_by": actor, "created_at": now,
+        }
+
+        def operation():
+            result = self.repository.create_sbom(record)
+            self._event("sbom", record["uid"], "sbom_registered", actor, {"artifact_name": record["artifact_name"], "component_count": len(components), "document_digest": record["document_digest"]})
+            return result
+
+        return self._save(operation)
+
+    def ingest_vulnerabilities(self, sbom_uid: str, payload: Any, *, actor_uid: str):
+        body = _closed(payload, {"scanner", "scan_ref", "findings"}, "vulnerability import")
+        actor = self._actor(actor_uid)
+        sbom = self.repository.get_sbom(_uid(sbom_uid, "sbom_uid"))
+        if not sbom:
+            raise LookupError("SBOM was not found")
+        scanner = _text(body.get("scanner"), "scanner", 100)
+        scan_ref = _text(body.get("scan_ref"), "scan_ref", 500)
+        now = self.now_factory().isoformat()
+        records = []
+        for raw in _list(body.get("findings"), "vulnerability findings", 1, 5000):
+            finding = _closed(
+                raw,
+                {"external_id", "severity", "component_name", "installed_version", "fixed_version", "title"},
+                "vulnerability finding",
+            )
+            severity = _text(finding.get("severity"), "severity", 20).lower()
+            if severity not in SEVERITIES:
+                raise ValueError("unsupported vulnerability severity")
+            records.append({
+                "uid": self.uid_factory(), "sbom_uid": sbom["uid"],
+                "scanner": scanner, "scan_ref": scan_ref,
+                "external_id": _text(finding.get("external_id"), "external_id", 120),
+                "severity": severity,
+                "component_name": _text(finding.get("component_name"), "component_name", 300),
+                "installed_version": _text(finding.get("installed_version"), "installed_version", 200),
+                "fixed_version": _optional_text(finding.get("fixed_version"), "fixed_version", 200),
+                "title": _text(finding.get("title"), "title", 500),
+                "status": "open", "assignee_uid": None, "due_at": None,
+                "resolution_type": None, "resolved_version": None, "resolution": None,
+                "evidence_refs": [], "resolved_by": None, "resolved_at": None,
+                "closed_by": None, "closed_at": None, "close_reason": None,
+                "current_version": 1, "created_by": actor, "created_at": now, "updated_at": now,
+            })
+
+        def operation():
+            result = self.repository.upsert_vulnerabilities(sbom["uid"], records)
+            self._event("sbom", sbom["uid"], "vulnerabilities_imported", actor, {"scanner": scanner, "finding_count": len(result)})
+            return result
+
+        return self._save(operation)
+
+    def assign_vulnerability(
+        self, finding_uid: str, payload: Any, *, expected_version: int, actor_uid: str
+    ):
+        body = _closed(payload, {"assignee_uid", "due_at"}, "vulnerability assignment")
+        actor = self._actor(actor_uid)
+        finding = self.repository.get_vulnerability(_uid(finding_uid, "finding_uid"))
+        if not finding:
+            raise LookupError("vulnerability was not found")
+        if finding["status"] not in {"open", "triaged", "in_progress"}:
+            raise RuntimeError("vulnerability cannot be assigned")
+        assignee = _uid(body.get("assignee_uid"), "assignee_uid")
+        if self.repository.users_available({assignee}) != {assignee}:
+            raise ValueError("vulnerability assignee is unavailable")
+        due_at = _time(body.get("due_at"), "due_at")
+        if due_at <= self.now_factory().astimezone(UTC):
+            raise ValueError("vulnerability due date must be in the future")
+        updated = {
+            **finding, "status": "in_progress", "assignee_uid": assignee,
+            "due_at": due_at.isoformat(), "updated_at": self.now_factory().isoformat(),
+        }
+        return self._update_vulnerability(updated, expected_version, "vulnerability_assigned", actor, {"assignee_uid": assignee})
+
+    def resolve_vulnerability(
+        self, finding_uid: str, payload: Any, *, expected_version: int, actor_uid: str
+    ):
+        body = _closed(
+            payload,
+            {"resolution_type", "resolved_version", "resolution", "evidence_refs"},
+            "vulnerability resolution",
+        )
+        actor = self._actor(actor_uid)
+        finding = self.repository.get_vulnerability(_uid(finding_uid, "finding_uid"))
+        if not finding:
+            raise LookupError("vulnerability was not found")
+        if finding["status"] != "in_progress" or actor != finding["assignee_uid"]:
+            raise PermissionError("only the assigned owner can resolve an in-progress vulnerability")
+        resolution_type = _text(body.get("resolution_type"), "resolution_type", 30)
+        if resolution_type not in RESOLUTION_TYPES:
+            raise ValueError("unsupported vulnerability resolution type")
+        resolved_version = _optional_text(body.get("resolved_version"), "resolved_version", 200)
+        if resolution_type == "patched" and not resolved_version:
+            raise ValueError("patched vulnerabilities require a resolved version")
+        updated = {
+            **finding, "status": "resolved", "resolution_type": resolution_type,
+            "resolved_version": resolved_version,
+            "resolution": _text(body.get("resolution"), "resolution", 2000),
+            "evidence_refs": _normalize_evidence(body.get("evidence_refs")),
+            "resolved_by": actor, "resolved_at": self.now_factory().isoformat(),
+            "updated_at": self.now_factory().isoformat(),
+        }
+        return self._update_vulnerability(updated, expected_version, "vulnerability_resolved", actor, {"resolution_type": resolution_type})
+
+    def close_vulnerability(
+        self, finding_uid: str, payload: Any, *, expected_version: int, actor_uid: str
+    ):
+        body = _closed(payload, {"reason"}, "vulnerability closure")
+        actor = self._actor(actor_uid)
+        finding = self.repository.get_vulnerability(_uid(finding_uid, "finding_uid"))
+        if not finding:
+            raise LookupError("vulnerability was not found")
+        if finding["status"] != "resolved":
+            raise RuntimeError("only resolved vulnerabilities can be closed")
+        if actor in {finding.get("assignee_uid"), finding.get("resolved_by")}:
+            raise PermissionError("vulnerability closure requires an independent reviewer")
+        updated = {
+            **finding, "status": "closed", "closed_by": actor,
+            "closed_at": self.now_factory().isoformat(),
+            "close_reason": _text(body.get("reason"), "close reason", 1000),
+            "updated_at": self.now_factory().isoformat(),
+        }
+        return self._update_vulnerability(updated, expected_version, "vulnerability_closed", actor, {"resolution_type": finding["resolution_type"]})
+
+    def _update_vulnerability(self, record, expected_version, action, actor, detail):
+        def operation():
+            result = self.repository.update_vulnerability(record, int(expected_version), action, actor)
+            self._event("vulnerability", record["uid"], action, actor, detail)
+            return result
+
+        return self._save(operation)
+
+    def list_profiles(self, **filters):
+        return self.repository.list_profiles(**filters)
+
+    def list_classification_scans(self, **filters):
+        return self.repository.list_scans(**filters)
+
+    def list_classification_findings(self, **filters):
+        return self.repository.list_findings(**filters)
+
+    def list_access_policies(self, **filters):
+        return self.repository.list_access_policies(**filters)
+
+    def list_access_decisions(self, **filters):
+        return self.repository.list_access_decisions(**filters)
+
+    def list_egress_requests(self, **filters):
+        return self.repository.list_egress_requests(**filters)
+
+    def list_retention_policies(self):
+        return self.repository.list_retention_policies()
+
+    def list_siem_sinks(self):
+        return self.repository.list_siem_sinks()
+
+    def list_siem_deliveries(self, **filters):
+        return self.repository.list_siem_deliveries(**filters)
+
+    def list_sboms(self):
+        return self.repository.list_sboms()
+
+    def list_vulnerabilities(self, **filters):
+        return self.repository.list_vulnerabilities(**filters)
+
+    def dashboard(self):
+        return self.repository.dashboard()

+ 362 - 0
deployment/app/core/system/security_governance_repository.py

@@ -0,0 +1,362 @@
+"""PostgreSQL persistence and unified-work adapter for security governance."""
+
+from __future__ import annotations
+
+import json
+from datetime import datetime
+
+from sqlalchemy import text
+
+from app.core.governance.work_center import UnifiedWorkCenterService
+from app.core.governance.work_center_repository import SqlAlchemyWorkCenterRepository
+from app.core.system.governance_audit_repository import (
+    SqlAlchemyGovernanceAuditRepository,
+)
+
+
+def _json(value) -> str:
+    return json.dumps(value, ensure_ascii=False, separators=(",", ":"))
+
+
+def _iso(value):
+    if isinstance(value, datetime):
+        return value.isoformat().replace("+00:00", "Z")
+    return value
+
+
+def _plain_record(row):
+    if row is None:
+        return None
+    value = dict(row)["record"]
+    return dict(value)
+
+
+class WorkCenterSecurityApprovalGateway:
+    """Create egress review tasks without introducing another approval engine."""
+
+    def __init__(self, session):
+        self.repository = SqlAlchemyWorkCenterRepository(session)
+        self.service = UnifiedWorkCenterService(self.repository)
+
+    def create_egress_task(self, request_record, workflow_uid, actor_uid):
+        return self.service.create_task(
+            {
+                "workflow_uid": workflow_uid,
+                "task_type": "data_egress",
+                "subject_type": "security_request",
+                "subject_uid": request_record["uid"],
+                "source_type": "security_egress_request",
+                "source_uid": request_record["uid"],
+                "title": "敏感数据出域审批",
+                "description": "核验用途绑定、字段最小化、脱敏和有效期。",
+                "priority": "high" if request_record["classification"] == "sensitive" else "critical",
+                "business_domain_uid": request_record["business_domain_uid"],
+                "context": {
+                    "business_domain_uid": request_record["business_domain_uid"],
+                    "risk_level": "high",
+                    "sensitivity_level": request_record["classification"],
+                    "environment": request_record["environment"],
+                },
+            },
+            actor_uid=actor_uid,
+        )
+
+    def get_task(self, uid):
+        return self.repository.get_task(uid)
+
+
+class SqlAlchemySecurityGovernanceRepository:
+    """Persist normalized security records; raw scanned values are never accepted."""
+
+    def __init__(self, session):
+        self.session = session
+
+    def users_available(self, values):
+        values = sorted(set(values))
+        if not values:
+            return set()
+        rows = self.session.execute(
+            text("SELECT id::text FROM public.users WHERE status = 'active' AND id = ANY(CAST(:ids AS uuid[]))"),
+            {"ids": values},
+        ).scalars().all()
+        return set(rows)
+
+    def _insert_record(self, table, columns, record):
+        names = [*columns, "record"]
+        values = [f"CAST(:{name} AS uuid)" if name.endswith("_uid") or name == "uid" else f":{name}" for name in columns]
+        values.append("CAST(:record AS jsonb)")
+        params = {name: record.get(name) for name in columns}
+        params["record"] = _json(record)
+        row = self.session.execute(
+            text(
+                f"INSERT INTO public.{table} ({', '.join(names)}) VALUES ({', '.join(values)}) "
+                "RETURNING record"
+            ),
+            params,
+        ).mappings().one()
+        self.session.flush()
+        return _plain_record(row)
+
+    def _get(self, table, uid):
+        row = self.session.execute(
+            text(f"SELECT record FROM public.{table} WHERE uid = CAST(:uid AS uuid)"),
+            {"uid": uid},
+        ).mappings().one_or_none()
+        return _plain_record(row)
+
+    def _list(self, table, *, filters=None, order="created_at DESC", limit=500):
+        filters = filters or {}
+        clauses, params = [], {"limit": int(limit)}
+        allowed = {"status", "business_domain_uid", "decision", "severity", "sink_uid", "sbom_uid"}
+        for key, value in filters.items():
+            if value in (None, "") or key not in allowed:
+                continue
+            clauses.append(f"{key} = " + (f"CAST(:{key} AS uuid)" if key.endswith("_uid") else f":{key}"))
+            params[key] = value
+        where = " WHERE " + " AND ".join(clauses) if clauses else ""
+        rows = self.session.execute(
+            text(f"SELECT record FROM public.{table}{where} ORDER BY {order} LIMIT :limit"),
+            params,
+        ).mappings().all()
+        return [_plain_record(row) for row in rows]
+
+    def create_profile(self, record):
+        return self._insert_record(
+            "security_classification_profiles",
+            ["uid", "code", "business_domain_uid", "status"], record,
+        )
+
+    def get_profile(self, uid):
+        return self._get("security_classification_profiles", uid)
+
+    def list_profiles(self, **filters):
+        return self._list("security_classification_profiles", filters=filters)
+
+    def create_scan(self, scan, findings):
+        saved = self._insert_record(
+            "security_classification_scans",
+            ["uid", "profile_uid", "business_domain_uid", "resource_type", "resource_uid"], scan,
+        )
+        for finding in findings:
+            self._insert_record(
+                "security_classification_findings",
+                ["uid", "scan_uid", "status", "current_version"], finding,
+            )
+        return {**saved, "findings": findings}
+
+    def get_scan(self, uid):
+        return self._get("security_classification_scans", uid)
+
+    def list_scans(self, **filters):
+        return self._list("security_classification_scans", filters=filters)
+
+    def get_finding(self, uid):
+        return self._get("security_classification_findings", uid)
+
+    def list_findings(self, **filters):
+        return self._list("security_classification_findings", filters=filters)
+
+    def _versioned_update(self, table, record, expected_version):
+        saved = {**record, "current_version": int(expected_version) + 1}
+        row = self.session.execute(
+            text(
+                f"UPDATE public.{table} SET status = :status, current_version = current_version + 1, "
+                "record = CAST(:record AS jsonb), updated_at = CURRENT_TIMESTAMP "
+                "WHERE uid = CAST(:uid AS uuid) AND current_version = :expected RETURNING record"
+            ),
+            {"uid": record["uid"], "status": record["status"], "expected": int(expected_version), "record": _json(saved)},
+        ).mappings().one_or_none()
+        if row is None:
+            raise RuntimeError("security governance version conflict")
+        return _plain_record(row)
+
+    def update_finding(self, record, expected_version):
+        return self._versioned_update("security_classification_findings", record, expected_version)
+
+    def create_access_policy(self, record):
+        return self._insert_record(
+            "security_access_policies",
+            ["uid", "code", "business_domain_uid", "status", "expires_at", "review_due_at"], record,
+        )
+
+    def list_access_policies(self, **filters):
+        return self._list("security_access_policies", filters=filters)
+
+    def matching_access_policies(self, **context):
+        rows = self.session.execute(
+            text(
+                "SELECT record FROM public.security_access_policies WHERE status = 'active' "
+                "AND business_domain_uid = CAST(:domain_uid AS uuid) AND expires_at > CURRENT_TIMESTAMP "
+                "AND review_due_at > CURRENT_TIMESTAMP ORDER BY created_at"
+            ),
+            {"domain_uid": context["business_domain_uid"]},
+        ).mappings().all()
+        return [_plain_record(row) for row in rows]
+
+    def create_access_decision(self, record):
+        return self._insert_record(
+            "security_access_decisions",
+            ["uid", "user_uid", "business_domain_uid", "resource_uid", "decision", "reason_code", "decided_at"], record,
+        )
+
+    def list_access_decisions(self, **filters):
+        return self._list("security_access_decisions", filters=filters, order="decided_at DESC")
+
+    def create_egress_request(self, record):
+        return self._insert_record(
+            "security_egress_requests",
+            ["uid", "business_domain_uid", "classification", "status", "approval_task_uid", "expires_at", "current_version"], record,
+        )
+
+    def get_egress_request(self, uid):
+        return self._get("security_egress_requests", uid)
+
+    def list_egress_requests(self, **filters):
+        return self._list("security_egress_requests", filters=filters, order="updated_at DESC")
+
+    def update_egress_request(self, record, expected_version):
+        return self._versioned_update("security_egress_requests", record, expected_version)
+
+    def create_retention_policy(self, record):
+        return self._insert_record(
+            "security_retention_policies", ["uid", "code", "evidence_type", "status"], record,
+        )
+
+    def list_retention_policies(self):
+        return self._list("security_retention_policies")
+
+    def retention_candidates(self, as_of, limit):
+        rows = self.session.execute(
+            text(
+                "SELECT evidence_type, (record->>'retention_days')::integer AS retention_days "
+                "FROM public.security_retention_policies WHERE status = 'active' ORDER BY created_at LIMIT :limit"
+            ),
+            {"limit": int(limit)},
+        ).mappings().all()
+        candidates = []
+        for row in rows:
+            count = 0
+            if row["evidence_type"] == "access_decision":
+                count = int(self.session.execute(
+                    text("SELECT COUNT(*) FROM public.security_access_decisions WHERE decided_at <= CAST(:cutoff AS timestamptz) - (:days * INTERVAL '1 day')"),
+                    {"cutoff": as_of, "days": row["retention_days"]},
+                ).scalar_one())
+            candidates.append({"evidence_type": row["evidence_type"], "candidate_count": count, "automatic_deletion": False})
+        return candidates
+
+    def create_siem_sink(self, record):
+        return self._insert_record("security_siem_sinks", ["uid", "name", "status"], record)
+
+    def get_siem_sink(self, uid):
+        return self._get("security_siem_sinks", uid)
+
+    def list_siem_sinks(self):
+        return self._list("security_siem_sinks")
+
+    def create_siem_delivery(self, record):
+        return self._insert_record(
+            "security_siem_deliveries",
+            ["uid", "sink_uid", "status", "payload_digest", "event_count"], record,
+        )
+
+    def list_siem_deliveries(self, **filters):
+        return self._list("security_siem_deliveries", filters=filters)
+
+    def fetch_siem_events(self, *, categories, period_start, period_end, limit):
+        records = SqlAlchemyGovernanceAuditRepository(self.session).fetch_events(
+            categories=categories, period_start=period_start, period_end=period_end
+        )
+        records.sort(key=lambda item: (item["occurred_at"], item["event_uid"]))
+        return [
+            {
+                **dict(item),
+                "occurred_at": _iso(item.get("occurred_at")),
+                "safe_detail": dict(item.get("safe_detail") or {}),
+            }
+            for item in records[: int(limit)]
+        ]
+
+    def create_sbom(self, record):
+        return self._insert_record(
+            "security_sboms",
+            ["uid", "artifact_name", "artifact_version", "document_digest"], record,
+        )
+
+    def get_sbom(self, uid):
+        return self._get("security_sboms", uid)
+
+    def list_sboms(self):
+        return self._list("security_sboms")
+
+    def upsert_vulnerabilities(self, sbom_uid, records):
+        saved = []
+        for record in records:
+            existing = self.session.execute(
+                text(
+                    "SELECT record FROM public.security_vulnerabilities WHERE sbom_uid = CAST(:sbom AS uuid) "
+                    "AND scanner = :scanner AND external_id = :external_id"
+                ),
+                {"sbom": sbom_uid, "scanner": record["scanner"], "external_id": record["external_id"]},
+            ).mappings().one_or_none()
+            if existing:
+                saved.append(_plain_record(existing))
+                continue
+            saved.append(self._insert_record(
+                "security_vulnerabilities",
+                ["uid", "sbom_uid", "scanner", "external_id", "severity", "status", "assignee_uid", "current_version"], record,
+            ))
+        return saved
+
+    def get_vulnerability(self, uid):
+        return self._get("security_vulnerabilities", uid)
+
+    def list_vulnerabilities(self, **filters):
+        return self._list("security_vulnerabilities", filters=filters, order="updated_at DESC")
+
+    def update_vulnerability(self, record, expected_version, action, actor_uid):
+        saved = {**record, "current_version": int(expected_version) + 1}
+        row = self.session.execute(
+            text(
+                "UPDATE public.security_vulnerabilities SET status=:status, severity=:severity, "
+                "assignee_uid=CAST(:assignee_uid AS uuid), current_version=current_version+1, "
+                "record=CAST(:record AS jsonb), updated_at=CURRENT_TIMESTAMP "
+                "WHERE uid=CAST(:uid AS uuid) AND current_version=:expected RETURNING record"
+            ),
+            {
+                "uid": record["uid"], "status": record["status"], "severity": record["severity"],
+                "assignee_uid": record.get("assignee_uid"), "expected": int(expected_version), "record": _json(saved),
+            },
+        ).mappings().one_or_none()
+        if row is None:
+            raise RuntimeError("vulnerability version conflict")
+        return _plain_record(row)
+
+    def add_event(self, resource_type, resource_uid, action, actor_uid, safe_detail):
+        from app.core.common.identifiers import new_governance_uid
+
+        self.session.execute(
+            text(
+                "INSERT INTO public.security_governance_events "
+                "(uid,resource_type,resource_uid,action,actor_uid,safe_detail) VALUES "
+                "(CAST(:uid AS uuid),:resource_type,:resource_uid,:action,CAST(:actor_uid AS uuid),CAST(:detail AS jsonb))"
+            ),
+            {
+                "uid": new_governance_uid(), "resource_type": resource_type, "resource_uid": resource_uid,
+                "action": action, "actor_uid": actor_uid, "detail": _json(safe_detail),
+            },
+        )
+
+    def dashboard(self):
+        row = self.session.execute(text(
+            "SELECT "
+            "(SELECT COUNT(*) FROM public.security_classification_findings WHERE status='pending_review') AS pending_reviews, "
+            "(SELECT COUNT(*) FROM public.security_access_decisions WHERE decision='denied') AS denied_access, "
+            "(SELECT COUNT(*) FROM public.security_egress_requests WHERE status='pending_approval') AS pending_egress, "
+            "(SELECT COUNT(*) FROM public.security_vulnerabilities WHERE status<>'closed') AS open_vulnerabilities"
+        )).mappings().one()
+        return {
+            "pending_classification_reviews": int(row["pending_reviews"]),
+            "denied_access_count": int(row["denied_access"]),
+            "pending_egress_count": int(row["pending_egress"]),
+            "open_vulnerability_count": int(row["open_vulnerabilities"]),
+        }

+ 2 - 0
deployment/dataops.env

@@ -11,6 +11,8 @@ RULE_GENERATION_RECEIPT_SECRET=replace-with-dedicated-64-hex-random-value
 # Generate independently with: openssl rand -hex 32
 AUDIT_EVIDENCE_SECRET=replace-with-dedicated-64-hex-random-value
 AUDIT_EVIDENCE_KEY_VERSION=v1
+# Comma-separated exact hostnames. Keep empty until enterprise SIEM is approved.
+SECURITY_SIEM_HOST_ALLOWLIST=
 
 # 平台 PostgreSQL(可与平台同机;部署前替换密码)
 DATABASE_URL=postgresql://dataops_user:replace-password@127.0.0.1:5432/dataops

+ 193 - 0
deployment/migrations/versions/20260802_460_security_governance.py

@@ -0,0 +1,193 @@
+"""Add cross-domain security governance control plane."""
+
+from alembic import op
+
+revision = "20260802_460"
+down_revision = "20260802_450"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    op.execute(
+        """
+        CREATE TABLE public.security_classification_profiles (
+            uid UUID PRIMARY KEY,
+            code VARCHAR(120) NOT NULL UNIQUE,
+            business_domain_uid UUID NOT NULL,
+            status VARCHAR(20) NOT NULL,
+            record JSONB NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (jsonb_typeof(record) = 'object')
+        );
+        CREATE INDEX idx_security_profile_domain_status
+            ON public.security_classification_profiles(business_domain_uid, status);
+
+        CREATE TABLE public.security_classification_scans (
+            uid UUID PRIMARY KEY,
+            profile_uid UUID NOT NULL REFERENCES public.security_classification_profiles(uid),
+            business_domain_uid UUID NOT NULL,
+            resource_type VARCHAR(80) NOT NULL,
+            resource_uid VARCHAR(200) NOT NULL,
+            record JSONB NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (jsonb_typeof(record) = 'object')
+        );
+        CREATE INDEX idx_security_scan_domain_created
+            ON public.security_classification_scans(business_domain_uid, created_at DESC);
+
+        CREATE TABLE public.security_classification_findings (
+            uid UUID PRIMARY KEY,
+            scan_uid UUID NOT NULL REFERENCES public.security_classification_scans(uid),
+            status VARCHAR(30) NOT NULL,
+            current_version INTEGER NOT NULL DEFAULT 1 CHECK (current_version > 0),
+            record JSONB NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (jsonb_typeof(record) = 'object')
+        );
+        CREATE INDEX idx_security_finding_status_created
+            ON public.security_classification_findings(status, created_at DESC);
+
+        CREATE TABLE public.security_access_policies (
+            uid UUID PRIMARY KEY,
+            code VARCHAR(120) NOT NULL UNIQUE,
+            business_domain_uid UUID NOT NULL,
+            status VARCHAR(20) NOT NULL,
+            expires_at TIMESTAMPTZ NOT NULL,
+            review_due_at TIMESTAMPTZ NOT NULL,
+            record JSONB NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (expires_at > review_due_at),
+            CHECK (jsonb_typeof(record) = 'object')
+        );
+        CREATE INDEX idx_security_policy_domain_status
+            ON public.security_access_policies(business_domain_uid, status, expires_at);
+
+        CREATE TABLE public.security_access_decisions (
+            uid UUID PRIMARY KEY,
+            user_uid UUID NOT NULL REFERENCES public.users(id),
+            business_domain_uid UUID NOT NULL,
+            resource_uid VARCHAR(200) NOT NULL,
+            decision VARCHAR(20) NOT NULL CHECK (decision IN ('authorized','denied')),
+            reason_code VARCHAR(80) NOT NULL,
+            record JSONB NOT NULL,
+            decided_at TIMESTAMPTZ NOT NULL,
+            CHECK (jsonb_typeof(record) = 'object')
+        );
+        CREATE INDEX idx_security_decision_user_time
+            ON public.security_access_decisions(user_uid, decided_at DESC);
+        CREATE INDEX idx_security_decision_denied
+            ON public.security_access_decisions(decided_at DESC) WHERE decision = 'denied';
+
+        CREATE TABLE public.security_egress_requests (
+            uid UUID PRIMARY KEY,
+            business_domain_uid UUID NOT NULL,
+            classification VARCHAR(30) NOT NULL,
+            status VARCHAR(40) NOT NULL,
+            approval_task_uid UUID REFERENCES public.governance_tasks(uid),
+            expires_at TIMESTAMPTZ NOT NULL,
+            current_version INTEGER NOT NULL DEFAULT 1 CHECK (current_version > 0),
+            record JSONB NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (jsonb_typeof(record) = 'object')
+        );
+        CREATE INDEX idx_security_egress_status_expiry
+            ON public.security_egress_requests(status, expires_at);
+
+        CREATE TABLE public.security_retention_policies (
+            uid UUID PRIMARY KEY,
+            code VARCHAR(120) NOT NULL UNIQUE,
+            evidence_type VARCHAR(60) NOT NULL,
+            status VARCHAR(20) NOT NULL,
+            record JSONB NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (jsonb_typeof(record) = 'object')
+        );
+
+        CREATE TABLE public.security_siem_sinks (
+            uid UUID PRIMARY KEY,
+            name VARCHAR(300) NOT NULL,
+            status VARCHAR(20) NOT NULL,
+            record JSONB NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (jsonb_typeof(record) = 'object')
+        );
+        CREATE TABLE public.security_siem_deliveries (
+            uid UUID PRIMARY KEY,
+            sink_uid UUID NOT NULL REFERENCES public.security_siem_sinks(uid),
+            status VARCHAR(20) NOT NULL,
+            payload_digest CHAR(64) NOT NULL,
+            event_count INTEGER NOT NULL CHECK (event_count >= 0),
+            record JSONB NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (jsonb_typeof(record) = 'object')
+        );
+        CREATE INDEX idx_security_siem_delivery_created
+            ON public.security_siem_deliveries(sink_uid, created_at DESC);
+
+        CREATE TABLE public.security_sboms (
+            uid UUID PRIMARY KEY,
+            artifact_name VARCHAR(300) NOT NULL,
+            artifact_version VARCHAR(120) NOT NULL,
+            document_digest CHAR(64) NOT NULL,
+            record JSONB NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (jsonb_typeof(record) = 'object')
+        );
+        CREATE INDEX idx_security_sbom_artifact
+            ON public.security_sboms(artifact_name, created_at DESC);
+
+        CREATE TABLE public.security_vulnerabilities (
+            uid UUID PRIMARY KEY,
+            sbom_uid UUID NOT NULL REFERENCES public.security_sboms(uid),
+            scanner VARCHAR(100) NOT NULL,
+            external_id VARCHAR(120) NOT NULL,
+            severity VARCHAR(20) NOT NULL CHECK (severity IN ('critical','high','medium','low','unknown')),
+            status VARCHAR(30) NOT NULL,
+            assignee_uid UUID REFERENCES public.users(id),
+            current_version INTEGER NOT NULL DEFAULT 1 CHECK (current_version > 0),
+            record JSONB NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (sbom_uid, scanner, external_id),
+            CHECK (jsonb_typeof(record) = 'object')
+        );
+        CREATE INDEX idx_security_vulnerability_queue
+            ON public.security_vulnerabilities(status, severity, updated_at DESC);
+
+        CREATE TABLE public.security_governance_events (
+            uid UUID PRIMARY KEY,
+            resource_type VARCHAR(80) NOT NULL,
+            resource_uid VARCHAR(200) NOT NULL,
+            action VARCHAR(80) NOT NULL,
+            actor_uid UUID NOT NULL REFERENCES public.users(id),
+            safe_detail JSONB NOT NULL DEFAULT '{}'::jsonb,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (jsonb_typeof(safe_detail) = 'object')
+        );
+        CREATE INDEX idx_security_governance_event_time
+            ON public.security_governance_events(created_at DESC, uid DESC);
+
+        CREATE TABLE public.access_control_audit_events (
+            uid UUID PRIMARY KEY,
+            action VARCHAR(80) NOT NULL,
+            actor_uid UUID NOT NULL REFERENCES public.users(id),
+            resource_type VARCHAR(80) NOT NULL,
+            resource_uid VARCHAR(200) NOT NULL,
+            status VARCHAR(30) NOT NULL,
+            safe_detail JSONB NOT NULL DEFAULT '{}'::jsonb,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (jsonb_typeof(safe_detail) = 'object')
+        );
+        CREATE INDEX idx_access_control_audit_time
+            ON public.access_control_audit_events(created_at DESC, uid DESC);
+        """
+    )
+
+
+def downgrade() -> None:
+    raise RuntimeError(
+        "Forward-only migration: security governance evidence cannot be destructively removed"
+    )

+ 14 - 5
docs/DATAOPS_PHASE2_3_MONTH_DEVELOPMENT_PLAN_20260730.md

@@ -452,11 +452,11 @@ UAT 仍待后续完成。详见 `docs/phase2/P2_WP09_AGENT_GOVERNANCE.md`。
 
 **主要工作:**
 
-- [ ] 建设分类分级、敏感识别和人工复核流程。
-- [ ] 将用户、角色、业务域、用途和环境纳入访问策略。
-- [ ] 建设数据最小化、出域审批、用途绑定和到期复核。
-- [ ] 扩展统一审计到权限、任务、数据产品和 Agent。
-- [ ] 建设证据保留策略、SIEM Webhook/Syslog 输出、SBOM 和漏洞闭环。
+- [x] 建设分类分级、敏感识别和人工复核流程。
+- [x] 将用户、角色、业务域、用途和环境纳入访问策略。
+- [x] 建设数据最小化、出域审批、用途绑定和到期复核。
+- [x] 扩展统一审计到权限、任务、数据产品和 Agent。
+- [x] 建设证据保留策略、SIEM Webhook/Syslog 输出、SBOM 和漏洞闭环。
 
 **主要文件区域:**
 
@@ -469,6 +469,15 @@ UAT 仍待后续完成。详见 `docs/phase2/P2_WP09_AGENT_GOVERNANCE.md`。
 **完成门禁:** 第二业务域敏感样本可识别、复核和审计;高敏数据出域默认拒绝;
 依赖和镜像扫描结果可登记、分级、分派和关闭。
 
+**工程状态:** 已完成本地工程门禁。备品备件/物料主数据作为第二业务域样本,完成
+字段规则与模式识别、原值不落库、独立人工复核和统一安全审计;访问策略绑定身份、角色、
+业务域、用途、环境、动作、分级、字段和有效期,未命中时默认拒绝。敏感出域复用统一工作
+中心,高敏感出域固定拒绝;CycloneDX 1.5 SBOM、漏洞导入、严重度、指派、修复证据和独立
+关闭已形成闭环。SIEM 仅允许白名单 HTTPS Webhook/TLS Syslog,平台只留投递摘要;留存
+到期只形成复核/外部不可变归档候选,不自动删除证据。真实 DLP、动态脱敏执行网关、KMS/HSM、
+企业 SIEM/漏洞平台、行列级数据库授权和法规正式测评仍待企业集成与 UAT。详见
+`docs/phase2/P2_WP10_SECURITY_FOUNDATION.md`。
+
 ### P2-WP11 产品工程与交付
 
 **目标:** 使第二阶段能力可构建、可迁移、可观察、可升级和可回滚。

+ 12 - 12
docs/FUNCTION_MODULE_CENSUS_20260726.md

@@ -717,27 +717,27 @@ WP-09 已形成设备关系与根因的最小工程链:告警、故障、维
 
 | 模块编号 | 模块分级 | 功能项 | 成熟度 |
 |---|---|---|---|
-| SEC-01 | 数据安全 / 安全入口 | 数据安全页面和规划能力说明 | 部分建设 |
-| SEC-02 | 数据安全 / 分类分级 | 数据级别、类别、责任和处理规则 | 规划中 |
-| SEC-03 | 数据安全 / 敏感识别 | 个人信息、重要数据、金融和医疗敏感数据识别 | 规划中 |
+| SEC-01 | 数据安全 / 安全入口 | 已形成分类复核、访问/出域、漏洞和安全工程统一工作台 | 工程完成,待企业安全运营验收 |
+| SEC-02 | 数据安全 / 分类分级 | 已形成公开、内部、敏感和高敏感四级策略及独立人工复核;行业处理规则待配置 | 部分建设 |
+| SEC-03 | 数据安全 / 敏感识别 | 已支持字段规则及身份证、银行卡、手机号、邮箱模式识别且原值不落库;重要数据、医疗和企业识别模型待配置 | 部分建设 |
 | SEC-04 | 数据安全 / 脱敏策略 | 静态、动态、显示和导出脱敏 | 规划中 |
-| SEC-05 | 数据安全 / 访问策略 | 用户、角色、业务域、行列、用途和环境策略 | 部分建设 |
-| SEC-06 | 数据安全 / 出域策略 | 原始数据不出内网、最小化、脱敏和审批 | 规划中 |
-| SEC-07 | 数据安全 / 数据使用 | 用途绑定、二次传播限制和到期回收 | 规划中 |
+| SEC-05 | 数据安全 / 访问策略 | 已绑定用户、角色、业务域、用途、环境、动作、分级、字段和期限并默认拒绝;数据库行列执行网关待建设 | 部分建设 |
+| SEC-06 | 数据安全 / 出域策略 | 已形成字段最小化、脱敏声明、统一审批和高敏感默认拒绝;真实导出/DLP 执行待集成 | 部分建设 |
+| SEC-07 | 数据安全 / 数据使用 | 已形成用途绑定、限期授权和到期复核;二次传播技术控制与自动回收执行待建设 | 部分建设 |
 | SEC-08 | 应用安全 / 秘密保护 | 凭据密文、全局异常与日志脱敏、短期令牌和轮换检查 | 已建设 |
 | SEC-09 | 应用安全 / 加密传输 | TLS、证书、内部服务认证和证书轮换 | 部分建设 |
 | SEC-10 | 应用安全 / 安全头 | 已形成 API 安全头和不回显异常的错误边界;生产 CORS 白名单、TLS 入口与集中策略仍待交付 | 部分建设 |
-| SEC-11 | 安全审计 / 统一审计 | 已汇总首期登录、采集、实体治理、发布、整改和问答六类安全记录;通用权限、插件和 Agent 全域审计仍待建设 | 部分建设 |
+| SEC-11 | 安全审计 / 统一审计 | 已汇总认证、采集、实体治理、发布、整改、问答、权限、统一任务、数据产品、Agent 和安全治理十一类安全记录;插件专项审计待建设 | 部分建设 |
 | SEC-12 | 安全审计 / 防篡改 | 已形成事件根摘要、HMAC-SHA256 签名封存与复核;外部可信时间戳、不可变归档和法务证明仍待建设 | 部分建设 |
-| SEC-13 | 安全审计 / 证据保留 | 默认五年、按行业配置、关键记录永久归档 | 规划中 |
+| SEC-13 | 安全审计 / 证据保留 | 已支持按证据类型配置期限、人工复核或外部不可变归档候选且禁止自动删除;企业五年/永久归档介质待验收 | 部分建设 |
 | SEC-14 | 安全审计 / 法务保全 | 保全、冻结、导出、销毁审批和链路证明 | 规划中 |
-| SEC-15 | 安全集成 / SIEM | 内部安全中心以及 Syslog/Webhook 输出 | 规划中 |
-| SEC-16 | 合规管理 / 中国通用 | 等保、网络安全法、数据安全法和个人信息保护法 | 规划中 |
+| SEC-15 | 安全集成 / SIEM | 已支持主机白名单 HTTPS Webhook/TLS Syslog 及投递摘要;企业鉴权、证书、重试和正式 SIEM 联调待建设 | 部分建设 |
+| SEC-16 | 合规管理 / 中国通用 | 已形成分类、最小化、用途、审计和留存技术控制底座;等保及法律正式测评、制度和证据映射待企业完成 | 部分建设 |
 | SEC-17 | 合规管理 / 金融 | 金融数据分类、审计、监管检查和报送证据 | 规划中 |
 | SEC-18 | 合规管理 / 医疗 | 患者隐私、医疗数据分类、访问和留痕 | 规划中 |
 | SEC-19 | 合规管理 / 政务国企 | 信创、内网、分级保护和严格交付要求 | 规划中 |
-| SEC-20 | 安全工程 / 供应链 | SBOM、依赖许可证、镜像和插件安全扫描 | 部分建设 |
-| SEC-21 | 安全工程 / 漏洞治理 | 漏洞、补丁、安全基线和修复跟踪 | 规划中 |
+| SEC-20 | 安全工程 / 供应链 | 已支持 CycloneDX 1.5 SBOM 登记与依赖/镜像扫描结果导入;许可证、插件扫描和 CI 强制门禁待建设 | 部分建设 |
+| SEC-21 | 安全工程 / 漏洞治理 | 已形成漏洞严重度、负责人、期限、修复证据和独立关闭闭环;补丁平台、安全基线扫描和 SLA 自动升级待集成 | 部分建设 |
 | SEC-22 | 安全集成 / 深度集成 | KMS/HSM、堡垒机、DLP 和漏洞平台 | 能力预留 |
 
 ### 12.12 平台工程与企业交付

+ 24 - 1
docs/architecture/DATA_MODEL.md

@@ -456,6 +456,28 @@ Agent 治理控制面管理平台内部 Agent 的机器身份、定义版本、
 表。需要审批的动作创建统一工作中心 Agent 任务,源服务显式同步最终结果;高风险和关键
 风险必须双人复核,且永远不标记自动执行。
 
+## 4.11 P2-WP10 通用安全治理
+
+安全治理层保存策略、判定和最小化证据,不复制受保护业务数据,也不替代数据库、DLP、IAM、
+SIEM 或漏洞扫描器。敏感样本扫描只保存字段名、检测器、脱敏示例和 SHA-256 指纹,原始样本
+不进入治理表。
+
+| 数据对象 | 作用 | 关键约束 |
+|---|---|---|
+| `security_classification_profiles` | 业务域分类规则 | 四级分级,规则版本和业务域显式绑定 |
+| `security_classification_scans` / `security_classification_findings` | 样本发现和独立复核 | `sample_retained=false`;扫描发起人不能自审 |
+| `security_access_policies` / `security_access_decisions` | 最小访问策略及逐次判定 | 身份、角色、域、用途、环境、字段、期限缺一则默认拒绝 |
+| `security_egress_requests` | 出域申请当前态 | 敏感数据复用统一任务;高敏感数据固定拒绝;只批准最小字段 |
+| `security_retention_policies` | 安全证据留存策略 | 只形成复核或外部不可变归档候选,禁止自动物理删除 |
+| `security_siem_sinks` / `security_siem_deliveries` | 白名单审计输出及投递账本 | 仅 HTTPS/TLS;端点无凭据/查询参数;账本只保留摘要 |
+| `security_sboms` / `security_vulnerabilities` | 制品清单和漏洞整改 | CycloneDX 1.5;指派、修复证据、独立关闭及乐观版本 |
+| `security_governance_events` | 安全治理不可变时间线 | 只保存显式安全字段,不保存样本、SBOM 原文或投递载荷 |
+| `access_control_audit_events` | 用户与角色变更审计 | 密码值永不写入,仅记录变更字段及角色前后值 |
+
+统一审计在原六类投影上增加权限、统一任务、数据产品、Agent 和安全治理五类安全投影。
+SIEM 输出读取同一安全投影并受类别和时间窗限制。出域审批只记录授权证据,不自动导出数据;
+访问判定也不直接向数据库或 IAM 下发权限。
+
 ## 5. 所有权与删除规则
 
 - PostgreSQL 是身份、权限、映射、任务状态、布局和一致性事件的源真相。
@@ -471,7 +493,7 @@ Agent 治理控制面管理平台内部 Agent 的机器身份、定义版本、
 - 设备运行事件和有向证据关系以 PostgreSQL 为源真相;关系图是最多三跳、100 个节点和 200 条边的可重建查询投影。根因分析只沿 `indicates`、`triggered` 和 `evidences` 上游关系返回候选及证据路径;没有持久化路径时必须返回“证据不足,无法确认根因”,结果不触发自动修复或维修计划。
 - 设备知识检索直接读取授权后的 PostgreSQL canonical 资产、来源映射和运行事件;不建立第二份设备主数据,不读取来源配置和事件原始证据。问答不能替代 WP-09 的证据路径或设备专家根因结论。
 - 治理运营指标是 PostgreSQL canonical 数据的实时只读查询投影;不保存人工覆盖值。指标汇总与明细必须使用同一业务域授权边界,跨域合并只有两端均可见时才能计入非管理员结果。
-- 审计中心只读取类现有权威记录的安全投影;`governance_audit_seals` 只追加封存摘要和签名,不接收原始问题、凭据、来源配置、自由文本备注或证据正文。
+- 审计中心只读取十一类现有权威记录的安全投影;`governance_audit_seals` 只追加封存摘要和签名,不接收原始问题、凭据、来源配置、自由文本备注或证据正文。
 - 领域模板、模板版本、通用对象类型和导入审计以 PostgreSQL 为源真相;模板只描述对象契约,不替代设备台账或复制第二套资产服务。模板回滚追加新版本,被移除对象类型只退役、不删除。
 - 主动元数据计划、批次、资产当前态、不可变版本、变化候选、字段血缘、健康信号和纠错审计以 PostgreSQL 为源真相;现有目录快照继续作为批次输入证据。删除只形成候选,解析失败保留原因,发现或纠错不能绕过既有发布门禁覆盖 Neo4j 已发布元数据。
 - 业务术语、通用代码集、指标口径、物理字段映射、不可变版本、独立审批和发布审计以 PostgreSQL 为源真相;标准版本继续复用既有不可变发布门禁。指标口径不是执行计划,发布后通过 outbox 同步 canonical 治理知识,知识同步配置未就绪时事件不得丢失。
@@ -480,6 +502,7 @@ Agent 治理控制面管理平台内部 Agent 的机器身份、定义版本、
 - 通用流程版本、统一任务、参与人、审批、评论、附件引用、处理时间线、通知模板、偏好和送达尝试以 PostgreSQL 为源真相;质量问题、术语标准、数据产品和 Agent 本身的状态仍归各自源模块。工作中心只写处理证据和 outbox 回执,不把审批结果直接冒充源模块状态。
 - 已有 `data_products` 继续作为产品生产结果,产品治理登记、申请、申请事件、合同及不可变版本、合格证、反馈和治理时间线以 PostgreSQL 为源真相。合格证保存权威证据快照而非复制原始资产;申请履约不自动授予数据访问权。
 - Agent 治理登记、定义版本、工具授权、凭证摘要、策略判定和时间线以 PostgreSQL 为源真相;机器凭证明文只在签发时返回,提示和输出正文不落治理表。统一工作中心只保存审批证据,高风险批准只允许人工执行。
+- 安全分类规则、扫描摘要、复核、访问判定、出域申请、留存策略、SIEM 投递摘要、SBOM 摘要和漏洞闭环以 PostgreSQL 为源真相;敏感样本、SBOM 原文和 SIEM 载荷不落治理表。真实授权、导出、脱敏、归档和安全工具执行仍归外部执行系统。
 - 设备本体、故障/原因/措施代码身份、不可变代码版本和审批记录以 PostgreSQL 为源真相;Neo4j 只接收通过发布门禁的本体投影。
 - `DEVICE_SEMANTIC` 本体发布必须同时通过通用图校验、设备语义覆盖度校验和设备资产负责人校验;代码审批复用同一责任矩阵门禁。
 - 本轮只清理代码和建库脚本。生产表必须在数据核查、备份和依赖确认后以独立变更单下线。

+ 673 - 1
docs/architecture/OPENAPI.yaml

@@ -3,7 +3,7 @@ info:
   title: "DataOps Platform API(当前代码基线)"
   version: "2026-07-16"
   description: "由 scripts/generate_openapi.py 从 app/api/*/routes.py 生成。请求体与响应细节仍以现有专项 API 文档和代码为准。"
-x-route-count: 367
+x-route-count: 395
 servers:
   - url: "http://localhost:15500"
     description: "全本地隔离测试后端"
@@ -8906,6 +8906,678 @@ paths:
             application/json:
               schema:
                 $ref: '#/components/schemas/ApiEnvelope'
+  "/api/system/security-governance/access-decisions":
+    get:
+      tags: [system]
+      operationId: system_security_access_decisions_get
+      summary: "security access decisions"
+      x-source: "app/api/system/security_governance.py"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/system/security-governance/access-policies":
+    get:
+      tags: [system]
+      operationId: system_security_list_access_policies_get
+      summary: "security list access policies"
+      x-source: "app/api/system/security_governance.py"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+    post:
+      tags: [system]
+      operationId: system_security_create_access_policy_post
+      summary: "security create access policy"
+      x-source: "app/api/system/security_governance.py"
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/system/security-governance/access/evaluate":
+    post:
+      tags: [system]
+      operationId: system_security_evaluate_access_post
+      summary: "security evaluate access"
+      x-source: "app/api/system/security_governance.py"
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/system/security-governance/dashboard":
+    get:
+      tags: [system]
+      operationId: system_security_dashboard_get
+      summary: "security dashboard"
+      x-source: "app/api/system/security_governance.py"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/system/security-governance/egress":
+    get:
+      tags: [system]
+      operationId: system_security_list_egress_get
+      summary: "security list egress"
+      x-source: "app/api/system/security_governance.py"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+    post:
+      tags: [system]
+      operationId: system_security_create_egress_post
+      summary: "security create egress"
+      x-source: "app/api/system/security_governance.py"
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/system/security-governance/egress/{request_uid}/reconcile":
+    post:
+      tags: [system]
+      operationId: system_security_reconcile_egress_post
+      summary: "security reconcile egress"
+      x-source: "app/api/system/security_governance.py"
+      parameters:
+        - name: request_uid
+          in: path
+          required: true
+          schema:
+            type: string
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/system/security-governance/findings":
+    get:
+      tags: [system]
+      operationId: system_security_findings_get
+      summary: "security findings"
+      x-source: "app/api/system/security_governance.py"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/system/security-governance/findings/{finding_uid}/review":
+    post:
+      tags: [system]
+      operationId: system_security_review_finding_post
+      summary: "security review finding"
+      x-source: "app/api/system/security_governance.py"
+      parameters:
+        - name: finding_uid
+          in: path
+          required: true
+          schema:
+            type: string
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/system/security-governance/profiles":
+    get:
+      tags: [system]
+      operationId: system_security_list_profiles_get
+      summary: "security list profiles"
+      x-source: "app/api/system/security_governance.py"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+    post:
+      tags: [system]
+      operationId: system_security_create_profile_post
+      summary: "security create profile"
+      x-source: "app/api/system/security_governance.py"
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/system/security-governance/retention":
+    get:
+      tags: [system]
+      operationId: system_security_list_retention_get
+      summary: "security list retention"
+      x-source: "app/api/system/security_governance.py"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+    post:
+      tags: [system]
+      operationId: system_security_create_retention_post
+      summary: "security create retention"
+      x-source: "app/api/system/security_governance.py"
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/system/security-governance/retention/candidates":
+    get:
+      tags: [system]
+      operationId: system_security_retention_candidates_get
+      summary: "security retention candidates"
+      x-source: "app/api/system/security_governance.py"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/system/security-governance/sboms":
+    get:
+      tags: [system]
+      operationId: system_security_list_sboms_get
+      summary: "security list sboms"
+      x-source: "app/api/system/security_governance.py"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+    post:
+      tags: [system]
+      operationId: system_security_register_sbom_post
+      summary: "security register sbom"
+      x-source: "app/api/system/security_governance.py"
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/system/security-governance/sboms/{sbom_uid}/vulnerabilities":
+    post:
+      tags: [system]
+      operationId: system_security_import_vulnerabilities_post
+      summary: "security import vulnerabilities"
+      x-source: "app/api/system/security_governance.py"
+      parameters:
+        - name: sbom_uid
+          in: path
+          required: true
+          schema:
+            type: string
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/system/security-governance/scans":
+    get:
+      tags: [system]
+      operationId: system_security_list_scans_get
+      summary: "security list scans"
+      x-source: "app/api/system/security_governance.py"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+    post:
+      tags: [system]
+      operationId: system_security_create_scan_post
+      summary: "security create scan"
+      x-source: "app/api/system/security_governance.py"
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/system/security-governance/siem/deliveries":
+    get:
+      tags: [system]
+      operationId: system_security_siem_deliveries_get
+      summary: "security siem deliveries"
+      x-source: "app/api/system/security_governance.py"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/system/security-governance/siem/sinks":
+    get:
+      tags: [system]
+      operationId: system_security_list_siem_sinks_get
+      summary: "security list siem sinks"
+      x-source: "app/api/system/security_governance.py"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+    post:
+      tags: [system]
+      operationId: system_security_create_siem_sink_post
+      summary: "security create siem sink"
+      x-source: "app/api/system/security_governance.py"
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/system/security-governance/siem/sinks/{sink_uid}/dispatch":
+    post:
+      tags: [system]
+      operationId: system_security_siem_dispatch_post
+      summary: "security siem dispatch"
+      x-source: "app/api/system/security_governance.py"
+      parameters:
+        - name: sink_uid
+          in: path
+          required: true
+          schema:
+            type: string
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/system/security-governance/vulnerabilities":
+    get:
+      tags: [system]
+      operationId: system_security_vulnerabilities_get
+      summary: "security vulnerabilities"
+      x-source: "app/api/system/security_governance.py"
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/system/security-governance/vulnerabilities/{finding_uid}/assign":
+    post:
+      tags: [system]
+      operationId: system_security_assign_vulnerability_post
+      summary: "security assign vulnerability"
+      x-source: "app/api/system/security_governance.py"
+      parameters:
+        - name: finding_uid
+          in: path
+          required: true
+          schema:
+            type: string
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/system/security-governance/vulnerabilities/{finding_uid}/close":
+    post:
+      tags: [system]
+      operationId: system_security_close_vulnerability_post
+      summary: "security close vulnerability"
+      x-source: "app/api/system/security_governance.py"
+      parameters:
+        - name: finding_uid
+          in: path
+          required: true
+          schema:
+            type: string
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+  "/api/system/security-governance/vulnerabilities/{finding_uid}/resolve":
+    post:
+      tags: [system]
+      operationId: system_security_resolve_vulnerability_post
+      summary: "security resolve vulnerability"
+      x-source: "app/api/system/security_governance.py"
+      parameters:
+        - name: finding_uid
+          in: path
+          required: true
+          schema:
+            type: string
+      requestBody:
+        required: false
+        content:
+          application/json:
+            schema:
+              type: object
+              additionalProperties: true
+      responses:
+        "200":
+          description: "请求已由当前实现处理"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
+        default:
+          description: "错误响应"
+          content:
+            application/json:
+              schema:
+                $ref: '#/components/schemas/ApiEnvelope'
   "/api/system/translate":
     post:
       tags: [system]

+ 49 - 0
docs/phase2/P2_WP10_SECURITY_FOUNDATION.md

@@ -0,0 +1,49 @@
+# P2-WP10 通用安全底座交付说明
+
+## 1. 交付结论
+
+P2-WP10 已完成本地工程门禁。平台新增跨业务域安全治理控制面,并以备品备件/物料主数据
+完成第二业务域样本验证。当前结论是“工程完成,待企业安全工具集成与 UAT”,不等同于
+生产合规测评通过。
+
+## 2. 已完成能力
+
+- 分类分级:公开、内部、敏感、高敏感四级规则,字段规则与身份证、银行卡、手机号、邮箱模式识别;只保留脱敏示例和摘要,原始样本不落库。
+- 人工复核:分类扫描发起人不能自审,复核采用乐观版本并写入追加式安全时间线。
+- 访问策略:同时绑定用户/角色、业务域、用途、环境、动作、分级、字段、复核期限和失效期限;未命中策略时默认拒绝。
+- 数据出域:敏感出域复用统一工作中心,只批准最小字段;高敏感数据出域固定拒绝,本工作包不提供绕过开关。
+- 安全审计:从六类扩展为十一类,新增权限、统一任务、数据产品、Agent 和安全治理投影;用户和角色变更单独追加审计,密码值不留痕。
+- 证据留存:按证据类型配置期限和人工复核/外部不可变归档方式,任何到期候选均不自动物理删除。
+- SIEM:只允许主机白名单内 HTTPS Webhook 或 TLS Syslog;端点禁止内嵌凭据、查询参数和片段,平台只保存事件数和载荷摘要。
+- 供应链安全:登记 CycloneDX 1.5 SBOM 摘要和最小组件清单,导入依赖/镜像扫描发现,完成严重度、指派、修复证据和独立关闭。
+- 工作台:原“规划中”占位页已替换为分类复核、访问/出域、漏洞和安全工程工作台,包含加载、空态、错误态和高敏阻断说明。
+
+## 3. 安全边界
+
+- 平台保存策略、判定和证据索引,不保存扫描样本原值、SBOM 原文、SIEM 载荷或密码值。
+- 访问判定不直接向数据库/IAM 下发权限,出域批准不自动执行导出。
+- 本工作包未建设 DLP、动态脱敏执行网关、KMS/HSM、堡垒机、真实行列级访问网关、自动销毁或法务保全。
+- SIEM/漏洞平台鉴权、双向证书、生产重试、代理策略和正式接收格式需与企业安全中心联调。
+- 分类规则是技术底座,不替代重要数据目录、个人信息影响评估、等级保护测评或法律意见。
+
+## 4. 验收证据
+
+- 核心安全治理单元契约覆盖分类、独立复核、访问默认拒绝、字段最小化、高敏出域拒绝、留存、SIEM 和漏洞闭环。
+- 真实 PostgreSQL 从 `20260802_450` 升级到 `20260802_460`,并在事务回滚隔离下完成第二业务域全链验证。
+- 发布同步校验确保 `app/`、`migrations/` 与 `deployment/` 副本一致。
+- OpenAPI 清单已重新生成,共 395 项操作,包含完整安全治理接口面。
+- 后端仅运行 P2-WP10 相关测试,前端仅运行变更文件检查和生产构建,不执行全量回归。
+
+## 5. 企业 UAT 与生产前置条件
+
+1. 企业安全负责人确认分类级别、敏感类别、业务域规则、用途目录和访问审计字段。
+2. 选择真实 SIEM 与漏洞扫描平台,配置白名单、证书/鉴权、接收格式、失败重试和责任人。
+3. 明确安全证据五年或行业期限、不可变归档介质、法务保全、销毁审批和恢复抽检。
+4. 在真实访问/导出网关完成同策略的行列权限、动态脱敏、出域阻断和权限回收联调。
+5. 将 SBOM/镜像/依赖扫描接入 CI,确定严重度 SLA、例外审批、补丁窗口和安全基线。
+
+## 6. 回滚与恢复
+
+迁移为前向保留策略,`downgrade()` 拒绝破坏性删除安全证据。若应用版本需回退,应先停止
+P2-WP10 新写入,再回退应用制品;安全表与审计记录保留,后续通过独立批准的归档/兼容迁移
+处理,不执行直接删表。

+ 15 - 0
docs/phase2/p2-wp10-hardening/context.md

@@ -0,0 +1,15 @@
+# P2-WP10 安全强化上下文
+
+## 已观察证据
+
+- 数据安全页面此前明确标注为后续建设占位页。
+- 平台已有固定角色 RBAC、六类治理审计、签名封存、统一工作中心和一份规则运行时 CycloneDX SBOM。
+- 平台已有数据产品和 Agent 治理事件,但未进入统一审计类别。
+- 当时没有通用分类复核、用途/字段级访问策略、出域申请、留存策略、SIEM 投递账本和漏洞整改状态机。
+
+## 约束
+
+- 复用现有 RBAC、统一工作中心和审计封存,禁止并行建设第二套审批或权限系统。
+- PostgreSQL 保存安全治理当前态和最小证据,不复制业务数据或安全工具原始载荷。
+- 高敏感数据出域在 P2-WP10 无条件拒绝。
+- 真实 DLP、IAM、SIEM、漏洞平台和合规测评属于企业集成边界。

+ 12 - 0
docs/phase2/p2-wp10-hardening/diagrams/security-control-plane-after.mmd

@@ -0,0 +1,12 @@
+flowchart LR
+    U[用户/角色/Agent] --> C[安全治理控制面]
+    C --> P[分类与访问策略]
+    C --> E[出域与统一工作中心]
+    C --> V[SBOM 与漏洞闭环]
+    P --> D[(安全判定与最小证据)]
+    E --> D
+    V --> D
+    D --> A[十一类统一审计]
+    A --> S[签名封存]
+    A --> I[白名单 SIEM 输出]
+    C -. 策略,不直接执行 .-> X[DLP/IAM/KMS/安全工具]

+ 6 - 0
docs/phase2/p2-wp10-hardening/diagrams/security-control-plane-before.mmd

@@ -0,0 +1,6 @@
+flowchart LR
+    U[用户与固定角色] --> A[业务接口]
+    A --> D[(业务数据与事件)]
+    D --> G[六类治理审计]
+    G --> S[签名封存]
+    X[分类/出域/留存/漏洞] -. 缺少统一控制面 .-> A

+ 20 - 0
docs/phase2/p2-wp10-hardening/hardening.json

@@ -0,0 +1,20 @@
+{
+  "work_package": "P2-WP10",
+  "status": "implemented_local_engineering_gate",
+  "selected_option": "central_security_governance_control_plane",
+  "controls": [
+    {"id": "SG-01", "priority": "P0", "name": "classification_and_independent_review", "status": "implemented"},
+    {"id": "SG-02", "priority": "P0", "name": "purpose_bound_default_deny_access", "status": "implemented"},
+    {"id": "SG-03", "priority": "P0", "name": "highly_sensitive_egress_deny", "status": "implemented"},
+    {"id": "SG-04", "priority": "P1", "name": "eleven_category_audit_and_siem_digest", "status": "implemented_local"},
+    {"id": "SG-05", "priority": "P1", "name": "sbom_vulnerability_closure", "status": "implemented_local"},
+    {"id": "SG-06", "priority": "P2", "name": "dlp_kms_hsm_and_access_gateway", "status": "enterprise_integration_required"}
+  ],
+  "production_gates": [
+    "enterprise_policy_approval",
+    "real_access_and_egress_gateway_poc",
+    "siem_and_vulnerability_platform_integration",
+    "immutable_archive_and_key_management",
+    "security_uat_and_formal_compliance_assessment"
+  ]
+}

+ 17 - 0
docs/phase2/p2-wp10-hardening/hardening.md

@@ -0,0 +1,17 @@
+# P2-WP10 安全强化建议与实施状态
+
+| 优先级 | 建议 | 当前状态 | 后续门禁 |
+|---|---|---|---|
+| P0 | 建立统一安全治理控制面并默认拒绝 | 已实施 | 企业策略与真实执行网关联调 |
+| P0 | 高敏感数据出域固定拒绝,敏感出域复用双人审批 | 已实施底座 | 企业流程需配置为双人复核并完成 UAT |
+| P0 | 样本、密码、SBOM 原文、SIEM 载荷不落治理库 | 已实施 | 安全抽样和日志/备份复核 |
+| P1 | 十一类统一审计、签名封存和 SIEM 白名单输出 | 已实施底座 | 独立密钥、mTLS/鉴权、重试和 WORM 归档 |
+| P1 | SBOM 与漏洞发现全生命周期 | 已实施底座 | CI 强制扫描、严重度 SLA 和例外审批 |
+| P1 | 用户与角色变更追加式审计 | 已实施 | 企业 IAM/SSO 事件接入 |
+| P2 | DLP、动态脱敏、行列策略和到期回收执行 | 能力预留 | 选择执行网关并做同策略 POC |
+| P2 | KMS/HSM、堡垒机、法务保全和自动归档 | 未建设 | 企业安全架构和制度确认 |
+
+选择了“集中安全控制面”方案。原因是平台已有统一工作中心、RBAC 和审计封存,集中方案可
+在不复制审批引擎的情况下,为分类、访问、出域、留存和漏洞提供一致证据。分散式快速补丁
+虽然改动小,但会造成策略不一致;完全委托外部 DLP/SIEM 则无法在企业工具选型前形成平台
+级最小闭环。

+ 8 - 0
docs/phase2/p2-wp10-hardening/implementation/central-control-plane.md

@@ -0,0 +1,8 @@
+# 集中安全治理控制面实施记录
+
+实施内容包括 `20260802_460` 前向迁移、核心安全服务与 PostgreSQL 仓储、统一工作中心出域
+适配器、受限 SIEM 传输、系统 API、权限矩阵、十一类审计投影、用户/角色变更审计、数据
+安全工作台、定向契约测试和真实 PostgreSQL 第二业务域验证。
+
+关键失败关闭行为:策略未命中即拒绝,字段超范围拒绝,扫描发起人自审拒绝,高敏感出域
+拒绝,非白名单 SIEM 拒绝,漏洞无修复证据不能解决,处理人不能独立关闭自己的漏洞。

+ 17 - 0
docs/phase2/p2-wp10-hardening/proposals/security-control-plane.md

@@ -0,0 +1,17 @@
+# 安全控制面方案比较
+
+## 方案 A:分散式安全补丁
+
+在各业务模块分别增加分类、出域和漏洞字段。改动较小,但策略、审批、审计和期限容易分叉,
+不适合作为第二业务域复制基线。
+
+## 方案 B:集中安全治理控制面(已选择)
+
+集中保存分类规则、访问判定、出域申请、留存策略、SIEM 投递摘要、SBOM 和漏洞状态;复用
+现有 RBAC、统一工作中心和审计封存。该方案保持源系统权威边界,能在真实企业安全工具接入
+前形成可验证闭环。
+
+## 方案 C:完全委托外部安全平台
+
+平台只调用 DLP、IAM、SIEM 和漏洞平台。生产能力上限更高,但当前企业工具、协议、证书、
+SLA 和采购状态未确定,无法独立满足本阶段工程门禁。

+ 33 - 0
frontend/src/api/securityGovernance.js

@@ -0,0 +1,33 @@
+import http from '@/utils/request'
+
+const root = '/system/security-governance'
+const versionHeaders = version => ({ headers: { 'If-Match': `"${version}"` } })
+
+export const getSecurityDashboard = () => http.get(`${root}/dashboard`)
+export const listSecurityProfiles = params => http.get(`${root}/profiles`, params)
+export const createSecurityProfile = payload => http.post(`${root}/profiles`, payload)
+export const listSecurityScans = params => http.get(`${root}/scans`, params)
+export const scanSensitiveSample = payload => http.post(`${root}/scans`, payload)
+export const listSecurityFindings = params => http.get(`${root}/findings`, params)
+export const reviewSecurityFinding = (uid, payload, version) => http.post(`${root}/findings/${uid}/review`, payload, versionHeaders(version))
+export const listSecurityAccessPolicies = params => http.get(`${root}/access-policies`, params)
+export const createSecurityAccessPolicy = payload => http.post(`${root}/access-policies`, payload)
+export const listSecurityAccessDecisions = params => http.get(`${root}/access-decisions`, params)
+export const evaluateSecurityAccess = payload => http.post(`${root}/access/evaluate`, payload)
+export const listSecurityEgress = params => http.get(`${root}/egress`, params)
+export const createSecurityEgress = payload => http.post(`${root}/egress`, payload)
+export const reconcileSecurityEgress = (uid, version) => http.post(`${root}/egress/${uid}/reconcile`, {}, versionHeaders(version))
+export const listSecurityRetention = () => http.get(`${root}/retention`)
+export const createSecurityRetention = payload => http.post(`${root}/retention`, payload)
+export const listSecurityRetentionCandidates = params => http.get(`${root}/retention/candidates`, params)
+export const listSecuritySiemSinks = () => http.get(`${root}/siem/sinks`)
+export const createSecuritySiemSink = payload => http.post(`${root}/siem/sinks`, payload)
+export const listSecuritySiemDeliveries = params => http.get(`${root}/siem/deliveries`, params)
+export const dispatchSecuritySiem = (uid, payload) => http.post(`${root}/siem/sinks/${uid}/dispatch`, payload)
+export const listSecuritySboms = () => http.get(`${root}/sboms`)
+export const registerSecuritySbom = payload => http.post(`${root}/sboms`, payload)
+export const importSecurityVulnerabilities = (uid, payload) => http.post(`${root}/sboms/${uid}/vulnerabilities`, payload)
+export const listSecurityVulnerabilities = params => http.get(`${root}/vulnerabilities`, params)
+export const assignSecurityVulnerability = (uid, payload, version) => http.post(`${root}/vulnerabilities/${uid}/assign`, payload, versionHeaders(version))
+export const resolveSecurityVulnerability = (uid, payload, version) => http.post(`${root}/vulnerabilities/${uid}/resolve`, payload, versionHeaders(version))
+export const closeSecurityVulnerability = (uid, payload, version) => http.post(`${root}/vulnerabilities/${uid}/close`, payload, versionHeaders(version))

+ 1 - 0
frontend/src/router/routes.js

@@ -609,6 +609,7 @@ export default {
             keepAlive: false,
             allowClick: false,
             roles: [],
+            permissions: ['security-governance:read'],
             enName: 'Data Security',
             icon: 'mdi-shield-lock-outline',
             editModules: false,

+ 320 - 21
frontend/src/views/dataGovernance/dataSecurity/index.vue

@@ -1,38 +1,337 @@
 <template>
-  <div class="pa-6 white fill-height">
-    <div class="d-flex align-center mb-6">
-      <v-avatar color="primary" size="48" class="mr-4">
-        <v-icon dark>mdi-shield-lock-outline</v-icon>
-      </v-avatar>
-      <div>
-        <h2 class="text-h5 mb-1">数据安全</h2>
-        <div class="text-body-2 grey--text text--darken-1">数据安全能力入口已保留,功能将在后续迭代中建设。</div>
+  <div class="pa-6 security-workbench">
+    <header class="d-flex flex-wrap align-start justify-space-between mb-5">
+      <div class="security-title">
+        <h1 class="text-h4 mb-2">数据安全治理</h1>
+        <p class="text--secondary mb-0">
+          统一管理数据分类分级、用途绑定、最小权限、出域审批、审计投递和漏洞整改。
+        </p>
       </div>
-    </div>
+      <v-btn color="primary" outlined :loading="loading" class="mt-2" @click="loadAll">
+        <v-icon left>mdi-refresh</v-icon>
+        刷新治理状态
+      </v-btn>
+    </header>
 
-    <v-alert type="info" outlined>
-      当前版本不提供虚假的列表或操作接口。后续将在此建设数据分级分类、脱敏策略、访问审计和权限策略。
+    <v-alert type="error" outlined prominent class="mb-5">
+      高敏感数据出域默认拒绝。敏感数据出域必须完成用途绑定、字段最小化、脱敏、审批和有效期约束
     </v-alert>
 
-    <v-row>
-      <v-col v-for="item in plannedCapabilities" :key="item" cols="12" sm="6" md="3">
-        <v-card outlined class="pa-4 text-center" height="100%">
-          <v-icon color="primary" size="32" class="mb-3">mdi-lock-clock</v-icon>
-          <div class="text-subtitle-1">{{ item }}</div>
-          <v-chip small class="mt-3">规划中</v-chip>
-        </v-card>
-      </v-col>
-    </v-row>
+    <v-alert v-if="error" type="error" outlined dismissible class="mb-5" @input="error = ''">
+      {{ error }}
+    </v-alert>
+
+    <v-skeleton-loader v-if="loading && !loaded" type="heading, list-item-three-line, table" />
+    <template v-else>
+      <section class="security-metrics mb-6" aria-label="安全治理待办概览">
+        <div v-for="metric in metrics" :key="metric.key" class="security-metric">
+          <v-icon :color="metric.color" size="28">{{ metric.icon }}</v-icon>
+          <div>
+            <div class="text-h5 font-weight-bold">{{ metric.value }}</div>
+            <div class="caption text--secondary">{{ metric.label }}</div>
+          </div>
+        </div>
+      </section>
+
+      <v-tabs v-model="tab" show-arrows class="security-tabs mb-4">
+        <v-tab>分类分级</v-tab>
+        <v-tab>访问与出域</v-tab>
+        <v-tab>漏洞闭环</v-tab>
+        <v-tab>安全工程</v-tab>
+      </v-tabs>
+
+      <v-tabs-items v-model="tab" class="transparent">
+        <v-tab-item>
+          <v-card outlined>
+            <v-card-title class="d-flex flex-wrap justify-space-between">
+              <span>分类发现与人工复核</span>
+              <v-chip small outlined color="primary">样本原值不落库</v-chip>
+            </v-card-title>
+            <v-card-subtitle>识别结果必须由不同于扫描发起人的治理人员复核。</v-card-subtitle>
+            <v-data-table :headers="findingHeaders" :items="findings" item-key="uid" :loading="loading" class="elevation-0">
+              <template v-slot:[`item.field_name`]="{ item }">
+                <strong>{{ item.field_name }}</strong>
+                <div class="caption text--secondary">{{ formatCategories(item.categories) }}</div>
+              </template>
+              <template v-slot:[`item.proposed_classification`]="{ item }">
+                <v-chip small :color="classificationColor(item.proposed_classification)" dark>
+                  {{ classificationLabel(item.proposed_classification) }}
+                </v-chip>
+              </template>
+              <template v-slot:[`item.status`]="{ item }">
+                {{ statusLabel(item.status) }}
+              </template>
+              <template v-slot:[`item.actions`]="{ item }">
+                <v-btn v-if="canManage && item.status === 'pending_review'" text small color="primary" @click="openReview(item)">
+                  人工复核
+                </v-btn>
+                <span v-else class="caption text--secondary">{{ item.reviewed_by ? '已留痕' : '等待复核' }}</span>
+              </template>
+              <template #no-data>
+                <div class="py-8 text--secondary">暂无分类发现。请通过已登记的分类规则发起样本扫描。</div>
+              </template>
+            </v-data-table>
+          </v-card>
+        </v-tab-item>
+
+        <v-tab-item>
+          <v-row>
+            <v-col cols="12" lg="6">
+              <v-card outlined height="100%">
+                <v-card-title>访问决策</v-card-title>
+                <v-card-subtitle>身份、角色、业务域、用途、环境、字段和有效期共同参与判定。</v-card-subtitle>
+                <v-data-table :headers="decisionHeaders" :items="decisions" item-key="uid" class="elevation-0">
+                  <template v-slot:[`item.decision`]="{ item }">
+                    <v-chip small :color="item.decision === 'authorized' ? 'success' : 'error'" dark>
+                      {{ item.decision === 'authorized' ? '已授权' : '已拒绝' }}
+                    </v-chip>
+                  </template>
+                  <template v-slot:[`item.decided_at`]="{ item }">{{ formatTime(item.decided_at) }}</template>
+                  <template #no-data><div class="py-8 text--secondary">暂无访问判定记录。</div></template>
+                </v-data-table>
+              </v-card>
+            </v-col>
+            <v-col cols="12" lg="6">
+              <v-card outlined height="100%">
+                <v-card-title>出域审批</v-card-title>
+                <v-card-subtitle>审批只授权最小化后的字段,不自动下发或导出数据。</v-card-subtitle>
+                <v-data-table :headers="egressHeaders" :items="egress" item-key="uid" class="elevation-0">
+                  <template v-slot:[`item.classification`]="{ item }">
+                    <v-chip small :color="classificationColor(item.classification)" dark>{{ classificationLabel(item.classification) }}</v-chip>
+                  </template>
+                  <template v-slot:[`item.status`]="{ item }">{{ statusLabel(item.status) }}</template>
+                  <template v-slot:[`item.actions`]="{ item }">
+                    <v-btn v-if="canManage && item.status === 'pending_approval'" text small color="primary" @click="reconcileEgress(item)">
+                      同步审批结果
+                    </v-btn>
+                    <span v-else class="caption text--secondary">{{ item.reason_code }}</span>
+                  </template>
+                  <template #no-data><div class="py-8 text--secondary">暂无数据出域申请。</div></template>
+                </v-data-table>
+              </v-card>
+            </v-col>
+          </v-row>
+        </v-tab-item>
+
+        <v-tab-item>
+          <v-card outlined>
+            <v-card-title class="d-flex flex-wrap justify-space-between">
+              <span>SBOM 与漏洞整改</span>
+              <span class="caption text--secondary">登记 - 分级 - 指派 - 修复证据 - 独立关闭</span>
+            </v-card-title>
+            <v-data-table :headers="vulnerabilityHeaders" :items="vulnerabilities" item-key="uid" :loading="loading" class="elevation-0">
+              <template v-slot:[`item.external_id`]="{ item }">
+                <strong>{{ item.external_id }}</strong>
+                <div class="caption text--secondary">{{ item.component_name }} {{ item.installed_version }}</div>
+              </template>
+              <template v-slot:[`item.severity`]="{ item }">
+                <v-chip small :color="severityColor(item.severity)" dark>{{ severityLabel(item.severity) }}</v-chip>
+              </template>
+              <template v-slot:[`item.status`]="{ item }">{{ statusLabel(item.status) }}</template>
+              <template v-slot:[`item.due_at`]="{ item }">{{ formatTime(item.due_at) }}</template>
+              <template #no-data><div class="py-8 text--secondary">暂无漏洞记录。管理员可登记 CycloneDX 1.5 SBOM 并导入扫描结果。</div></template>
+            </v-data-table>
+          </v-card>
+        </v-tab-item>
+
+        <v-tab-item>
+          <v-row>
+            <v-col cols="12" md="7">
+              <v-card outlined height="100%">
+                <v-card-title>审计投递</v-card-title>
+                <v-card-subtitle>仅支持白名单内的 HTTPS Webhook 或 TLS Syslog,平台只保留投递摘要。</v-card-subtitle>
+                <v-data-table :headers="deliveryHeaders" :items="deliveries" item-key="uid" class="elevation-0">
+                  <template v-slot:[`item.payload_digest`]="{ item }"><code>{{ shortDigest(item.payload_digest) }}</code></template>
+                  <template v-slot:[`item.created_at`]="{ item }">{{ formatTime(item.created_at) }}</template>
+                  <template #no-data><div class="py-8 text--secondary">尚未产生 SIEM 投递记录。</div></template>
+                </v-data-table>
+              </v-card>
+            </v-col>
+            <v-col cols="12" md="5">
+              <v-card outlined height="100%">
+                <v-card-title>留存边界</v-card-title>
+                <v-card-text>
+                  <v-alert type="info" outlined dense>安全证据不会自动删除。到期记录只进入人工复核或外部不可变归档候选。</v-alert>
+                  <div v-for="policy in retention" :key="policy.uid" class="retention-row">
+                    <strong>{{ policy.name }}</strong>
+                    <div class="caption text--secondary">{{ policy.evidence_type }},{{ policy.retention_days }} 天</div>
+                  </div>
+                  <div v-if="!retention.length" class="py-6 text--secondary">尚未登记留存策略。</div>
+                </v-card-text>
+              </v-card>
+            </v-col>
+          </v-row>
+        </v-tab-item>
+      </v-tabs-items>
+    </template>
+
+    <v-dialog v-model="reviewDialog" max-width="620">
+      <v-card>
+        <v-card-title>复核分类发现</v-card-title>
+        <v-card-subtitle v-if="selectedFinding">字段:{{ selectedFinding.field_name }}</v-card-subtitle>
+        <v-card-text>
+          <v-select v-model="reviewForm.finalClassification" :items="classificationOptions" label="最终分级" outlined dense />
+          <v-textarea v-model.trim="reviewForm.reason" label="复核依据" outlined rows="4" counter="1000" />
+        </v-card-text>
+        <v-card-actions>
+          <v-spacer />
+          <v-btn text @click="reviewDialog = false">取消</v-btn>
+          <v-btn color="primary" :loading="saving" :disabled="!reviewForm.reason" @click="confirmFinding">确认并留痕</v-btn>
+        </v-card-actions>
+      </v-card>
+    </v-dialog>
+
+    <v-snackbar v-model="snackbar" color="success">{{ message }}</v-snackbar>
   </div>
 </template>
 
 <script>
+import {
+  getSecurityDashboard,
+  listSecurityAccessDecisions,
+  listSecurityEgress,
+  listSecurityFindings,
+  listSecurityRetention,
+  listSecuritySiemDeliveries,
+  listSecurityVulnerabilities,
+  reconcileSecurityEgress,
+  reviewSecurityFinding
+} from '@/api/securityGovernance'
+
 export default {
   name: 'data-security',
   data () {
     return {
-      plannedCapabilities: ['数据分级分类', '数据脱敏', '访问审计', '权限策略']
+      tab: 0,
+      loading: false,
+      loaded: false,
+      saving: false,
+      error: '',
+      snackbar: false,
+      message: '',
+      dashboard: {},
+      findings: [],
+      decisions: [],
+      egress: [],
+      vulnerabilities: [],
+      deliveries: [],
+      retention: [],
+      reviewDialog: false,
+      selectedFinding: null,
+      reviewForm: { finalClassification: 'sensitive', reason: '' },
+      classificationOptions: [
+        { text: '公开', value: 'public' }, { text: '内部', value: 'internal' },
+        { text: '敏感', value: 'sensitive' }, { text: '高敏感', value: 'highly_sensitive' }
+      ],
+      findingHeaders: [
+        { text: '字段与类别', value: 'field_name' }, { text: '建议分级', value: 'proposed_classification' },
+        { text: '检测器', value: 'detector_codes' }, { text: '状态', value: 'status' }, { text: '操作', value: 'actions', sortable: false }
+      ],
+      decisionHeaders: [
+        { text: '资源', value: 'resource_uid' }, { text: '用途', value: 'purpose' },
+        { text: '结果', value: 'decision' }, { text: '原因', value: 'reason_code' }, { text: '时间', value: 'decided_at' }
+      ],
+      egressHeaders: [
+        { text: '资源', value: 'resource_uid' }, { text: '分级', value: 'classification' },
+        { text: '目的区', value: 'destination_zone' }, { text: '状态', value: 'status' }, { text: '操作', value: 'actions', sortable: false }
+      ],
+      vulnerabilityHeaders: [
+        { text: '漏洞与组件', value: 'external_id' }, { text: '严重度', value: 'severity' },
+        { text: '状态', value: 'status' }, { text: '负责人', value: 'assignee_uid' }, { text: '期限', value: 'due_at' }
+      ],
+      deliveryHeaders: [
+        { text: '状态', value: 'status' }, { text: '事件数', value: 'event_count' },
+        { text: '载荷摘要', value: 'payload_digest' }, { text: '时间', value: 'created_at' }
+      ]
+    }
+  },
+  computed: {
+    permissions () { return (this.$store.state.user.userInfo || {}).permissions || [] },
+    canManage () { return this.permissions.includes('security-governance:manage') },
+    metrics () {
+      return [
+        { key: 'review', label: '待分类复核', value: this.dashboard.pending_classification_reviews || 0, color: 'warning', icon: 'mdi-tag-alert-outline' },
+        { key: 'deny', label: '访问拒绝记录', value: this.dashboard.denied_access_count || 0, color: 'error', icon: 'mdi-shield-off-outline' },
+        { key: 'egress', label: '待出域审批', value: this.dashboard.pending_egress_count || 0, color: 'deep-orange', icon: 'mdi-export-variant' },
+        { key: 'vulnerability', label: '未关闭漏洞', value: this.dashboard.open_vulnerability_count || 0, color: 'primary', icon: 'mdi-bug-outline' }
+      ]
     }
+  },
+  created () { this.loadAll() },
+  methods: {
+    async loadAll () {
+      this.loading = true
+      this.error = ''
+      try {
+        const [dashboard, findings, decisions, egress, vulnerabilities, deliveries, retention] = await Promise.all([
+          getSecurityDashboard(), listSecurityFindings(), listSecurityAccessDecisions(), listSecurityEgress(),
+          listSecurityVulnerabilities(),
+          this.canManage ? listSecuritySiemDeliveries() : Promise.resolve({ data: [] }),
+          listSecurityRetention()
+        ])
+        this.dashboard = dashboard.data || {}
+        this.findings = findings.data || []
+        this.decisions = decisions.data || []
+        this.egress = egress.data || []
+        this.vulnerabilities = vulnerabilities.data || []
+        this.deliveries = deliveries.data || []
+        this.retention = retention.data || []
+        this.loaded = true
+      } catch (error) {
+        this.error = error.message || error.msg || '安全治理状态加载失败,请稍后重试。'
+      } finally {
+        this.loading = false
+      }
+    },
+    openReview (item) {
+      this.selectedFinding = item
+      this.reviewForm = { finalClassification: item.proposed_classification, reason: '' }
+      this.reviewDialog = true
+    },
+    async confirmFinding () {
+      this.saving = true
+      try {
+        await reviewSecurityFinding(this.selectedFinding.uid, {
+          decision: 'confirm', final_classification: this.reviewForm.finalClassification, reason: this.reviewForm.reason
+        }, this.selectedFinding.current_version)
+        this.reviewDialog = false
+        this.notify('分类复核已完成并留痕')
+        await this.loadAll()
+      } catch (error) {
+        this.error = error.message || error.msg || '分类复核失败。'
+      } finally { this.saving = false }
+    },
+    async reconcileEgress (item) {
+      try {
+        await reconcileSecurityEgress(item.uid, item.current_version)
+        this.notify('已同步统一工作中心的审批结果')
+        await this.loadAll()
+      } catch (error) { this.error = error.message || error.msg || '审批结果尚未就绪。' }
+    },
+    notify (message) { this.message = message; this.snackbar = true },
+    classificationLabel (value) { return ({ public: '公开', internal: '内部', sensitive: '敏感', highly_sensitive: '高敏感' })[value] || value },
+    classificationColor (value) { return ({ public: 'success', internal: 'blue-grey', sensitive: 'deep-orange', highly_sensitive: 'error' })[value] || 'grey' },
+    severityLabel (value) { return ({ critical: '严重', high: '高', medium: '中', low: '低', unknown: '未知' })[value] || value },
+    severityColor (value) { return ({ critical: 'error', high: 'deep-orange', medium: 'warning', low: 'blue-grey', unknown: 'grey' })[value] || 'grey' },
+    statusLabel (value) {
+      return ({ pending_review: '待复核', confirmed: '已确认', dismissed: '已排除', pending_approval: '待审批', authorized_until_expiry: '限期授权', denied: '已拒绝', open: '待处理', in_progress: '处理中', resolved: '待关闭', closed: '已关闭' })[value] || value
+    },
+    formatCategories (value) { return Array.isArray(value) ? value.join('、') : '-' },
+    formatTime (value) { return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '-' },
+    shortDigest (value) { return value ? `${value.slice(0, 10)}...${value.slice(-8)}` : '-' }
   }
 }
 </script>
+
+<style scoped>
+.security-workbench { min-height: 100%; background: #f7f9fb; }
+.security-title { max-width: 760px; }
+.security-metrics { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 1px; overflow: hidden; border: 1px solid #dfe5ec; border-radius: 8px; background: #dfe5ec; }
+.security-metric { display: flex; align-items: center; gap: 14px; min-height: 104px; padding: 20px; background: #fff; }
+.security-tabs { border-bottom: 1px solid #dfe5ec; }
+.retention-row { padding: 14px 0; border-bottom: 1px solid #e7ebf0; }
+.retention-row:last-child { border-bottom: 0; }
+code { color: #37474f; font-size: 12px; }
+@media (max-width: 959px) { .security-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
+@media (max-width: 599px) { .security-workbench { padding: 16px !important; } .security-metrics { grid-template-columns: 1fr; } }
+</style>

+ 29 - 1
frontend/src/views/systemManage/governanceAudit/governanceAuditModel.js

@@ -28,6 +28,31 @@ const CATEGORIES = {
     label: '知识问答',
     icon: 'mdi-message-text-lock-outline',
     color: 'cyan'
+  },
+  authorization: {
+    label: '权限与访问决策',
+    icon: 'mdi-account-key-outline',
+    color: 'red'
+  },
+  workflow_task: {
+    label: '统一任务',
+    icon: 'mdi-clipboard-flow-outline',
+    color: 'blue-grey'
+  },
+  data_product: {
+    label: '数据产品',
+    icon: 'mdi-package-variant-closed-check',
+    color: 'green'
+  },
+  agent: {
+    label: '智能体治理',
+    icon: 'mdi-robot-outline',
+    color: 'purple'
+  },
+  security_governance: {
+    label: '安全治理',
+    icon: 'mdi-shield-lock-outline',
+    color: 'deep-orange'
   }
 }
 
@@ -39,7 +64,10 @@ const STATUSES = {
   approve: { label: '已批准', color: 'success' },
   reject: { label: '已拒绝', color: 'error' },
   closed: { label: '已关闭', color: 'success' },
-  pending_review: { label: '待复核', color: 'warning' }
+  pending_review: { label: '待复核', color: 'warning' },
+  authorized: { label: '已授权', color: 'success' },
+  denied: { label: '已拒绝', color: 'error' },
+  recorded: { label: '已留痕', color: 'info' }
 }
 
 const INTEGRITY = {

+ 1 - 1
frontend/src/views/systemManage/governanceAudit/index.vue

@@ -5,7 +5,7 @@
         <div class="overline audit-kicker">SECURITY &amp; OPERATIONS EVIDENCE</div>
         <h1 class="text-h4 font-weight-bold mb-2">审计与运行证据</h1>
         <p class="audit-subtitle mb-0">
-          汇总登录、采集、实体治理、发布、整改和知识问答记录,形成可复核的安全运营证据。
+          汇总认证、采集、治理、权限、任务、产品、智能体和安全控制记录,形成可复核的安全运营证据。
         </p>
       </div>
       <v-btn color="primary" depressed :loading="loading" @click="loadAll">

+ 193 - 0
migrations/versions/20260802_460_security_governance.py

@@ -0,0 +1,193 @@
+"""Add cross-domain security governance control plane."""
+
+from alembic import op
+
+revision = "20260802_460"
+down_revision = "20260802_450"
+branch_labels = None
+depends_on = None
+
+
+def upgrade() -> None:
+    op.execute(
+        """
+        CREATE TABLE public.security_classification_profiles (
+            uid UUID PRIMARY KEY,
+            code VARCHAR(120) NOT NULL UNIQUE,
+            business_domain_uid UUID NOT NULL,
+            status VARCHAR(20) NOT NULL,
+            record JSONB NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (jsonb_typeof(record) = 'object')
+        );
+        CREATE INDEX idx_security_profile_domain_status
+            ON public.security_classification_profiles(business_domain_uid, status);
+
+        CREATE TABLE public.security_classification_scans (
+            uid UUID PRIMARY KEY,
+            profile_uid UUID NOT NULL REFERENCES public.security_classification_profiles(uid),
+            business_domain_uid UUID NOT NULL,
+            resource_type VARCHAR(80) NOT NULL,
+            resource_uid VARCHAR(200) NOT NULL,
+            record JSONB NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (jsonb_typeof(record) = 'object')
+        );
+        CREATE INDEX idx_security_scan_domain_created
+            ON public.security_classification_scans(business_domain_uid, created_at DESC);
+
+        CREATE TABLE public.security_classification_findings (
+            uid UUID PRIMARY KEY,
+            scan_uid UUID NOT NULL REFERENCES public.security_classification_scans(uid),
+            status VARCHAR(30) NOT NULL,
+            current_version INTEGER NOT NULL DEFAULT 1 CHECK (current_version > 0),
+            record JSONB NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (jsonb_typeof(record) = 'object')
+        );
+        CREATE INDEX idx_security_finding_status_created
+            ON public.security_classification_findings(status, created_at DESC);
+
+        CREATE TABLE public.security_access_policies (
+            uid UUID PRIMARY KEY,
+            code VARCHAR(120) NOT NULL UNIQUE,
+            business_domain_uid UUID NOT NULL,
+            status VARCHAR(20) NOT NULL,
+            expires_at TIMESTAMPTZ NOT NULL,
+            review_due_at TIMESTAMPTZ NOT NULL,
+            record JSONB NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (expires_at > review_due_at),
+            CHECK (jsonb_typeof(record) = 'object')
+        );
+        CREATE INDEX idx_security_policy_domain_status
+            ON public.security_access_policies(business_domain_uid, status, expires_at);
+
+        CREATE TABLE public.security_access_decisions (
+            uid UUID PRIMARY KEY,
+            user_uid UUID NOT NULL REFERENCES public.users(id),
+            business_domain_uid UUID NOT NULL,
+            resource_uid VARCHAR(200) NOT NULL,
+            decision VARCHAR(20) NOT NULL CHECK (decision IN ('authorized','denied')),
+            reason_code VARCHAR(80) NOT NULL,
+            record JSONB NOT NULL,
+            decided_at TIMESTAMPTZ NOT NULL,
+            CHECK (jsonb_typeof(record) = 'object')
+        );
+        CREATE INDEX idx_security_decision_user_time
+            ON public.security_access_decisions(user_uid, decided_at DESC);
+        CREATE INDEX idx_security_decision_denied
+            ON public.security_access_decisions(decided_at DESC) WHERE decision = 'denied';
+
+        CREATE TABLE public.security_egress_requests (
+            uid UUID PRIMARY KEY,
+            business_domain_uid UUID NOT NULL,
+            classification VARCHAR(30) NOT NULL,
+            status VARCHAR(40) NOT NULL,
+            approval_task_uid UUID REFERENCES public.governance_tasks(uid),
+            expires_at TIMESTAMPTZ NOT NULL,
+            current_version INTEGER NOT NULL DEFAULT 1 CHECK (current_version > 0),
+            record JSONB NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (jsonb_typeof(record) = 'object')
+        );
+        CREATE INDEX idx_security_egress_status_expiry
+            ON public.security_egress_requests(status, expires_at);
+
+        CREATE TABLE public.security_retention_policies (
+            uid UUID PRIMARY KEY,
+            code VARCHAR(120) NOT NULL UNIQUE,
+            evidence_type VARCHAR(60) NOT NULL,
+            status VARCHAR(20) NOT NULL,
+            record JSONB NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (jsonb_typeof(record) = 'object')
+        );
+
+        CREATE TABLE public.security_siem_sinks (
+            uid UUID PRIMARY KEY,
+            name VARCHAR(300) NOT NULL,
+            status VARCHAR(20) NOT NULL,
+            record JSONB NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (jsonb_typeof(record) = 'object')
+        );
+        CREATE TABLE public.security_siem_deliveries (
+            uid UUID PRIMARY KEY,
+            sink_uid UUID NOT NULL REFERENCES public.security_siem_sinks(uid),
+            status VARCHAR(20) NOT NULL,
+            payload_digest CHAR(64) NOT NULL,
+            event_count INTEGER NOT NULL CHECK (event_count >= 0),
+            record JSONB NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (jsonb_typeof(record) = 'object')
+        );
+        CREATE INDEX idx_security_siem_delivery_created
+            ON public.security_siem_deliveries(sink_uid, created_at DESC);
+
+        CREATE TABLE public.security_sboms (
+            uid UUID PRIMARY KEY,
+            artifact_name VARCHAR(300) NOT NULL,
+            artifact_version VARCHAR(120) NOT NULL,
+            document_digest CHAR(64) NOT NULL,
+            record JSONB NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (jsonb_typeof(record) = 'object')
+        );
+        CREATE INDEX idx_security_sbom_artifact
+            ON public.security_sboms(artifact_name, created_at DESC);
+
+        CREATE TABLE public.security_vulnerabilities (
+            uid UUID PRIMARY KEY,
+            sbom_uid UUID NOT NULL REFERENCES public.security_sboms(uid),
+            scanner VARCHAR(100) NOT NULL,
+            external_id VARCHAR(120) NOT NULL,
+            severity VARCHAR(20) NOT NULL CHECK (severity IN ('critical','high','medium','low','unknown')),
+            status VARCHAR(30) NOT NULL,
+            assignee_uid UUID REFERENCES public.users(id),
+            current_version INTEGER NOT NULL DEFAULT 1 CHECK (current_version > 0),
+            record JSONB NOT NULL,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            UNIQUE (sbom_uid, scanner, external_id),
+            CHECK (jsonb_typeof(record) = 'object')
+        );
+        CREATE INDEX idx_security_vulnerability_queue
+            ON public.security_vulnerabilities(status, severity, updated_at DESC);
+
+        CREATE TABLE public.security_governance_events (
+            uid UUID PRIMARY KEY,
+            resource_type VARCHAR(80) NOT NULL,
+            resource_uid VARCHAR(200) NOT NULL,
+            action VARCHAR(80) NOT NULL,
+            actor_uid UUID NOT NULL REFERENCES public.users(id),
+            safe_detail JSONB NOT NULL DEFAULT '{}'::jsonb,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (jsonb_typeof(safe_detail) = 'object')
+        );
+        CREATE INDEX idx_security_governance_event_time
+            ON public.security_governance_events(created_at DESC, uid DESC);
+
+        CREATE TABLE public.access_control_audit_events (
+            uid UUID PRIMARY KEY,
+            action VARCHAR(80) NOT NULL,
+            actor_uid UUID NOT NULL REFERENCES public.users(id),
+            resource_type VARCHAR(80) NOT NULL,
+            resource_uid VARCHAR(200) NOT NULL,
+            status VARCHAR(30) NOT NULL,
+            safe_detail JSONB NOT NULL DEFAULT '{}'::jsonb,
+            created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP,
+            CHECK (jsonb_typeof(safe_detail) = 'object')
+        );
+        CREATE INDEX idx_access_control_audit_time
+            ON public.access_control_audit_events(created_at DESC, uid DESC);
+        """
+    )
+
+
+def downgrade() -> None:
+    raise RuntimeError(
+        "Forward-only migration: security governance evidence cannot be destructively removed"
+    )

+ 10 - 2
tests/integration/test_governance_audit_postgres.py

@@ -10,7 +10,7 @@ from sqlalchemy import text
 pytestmark = pytest.mark.integration
 
 
-def test_governance_audit_covers_six_sources_and_detects_tampering(monkeypatch):
+def test_governance_audit_covers_seeded_sources_and_detects_tampering(monkeypatch):
     database_url = os.environ.get("TEST_DATABASE_URL")
     if not database_url:
         pytest.skip("TEST_DATABASE_URL is required")
@@ -434,7 +434,15 @@ def test_governance_audit_covers_six_sources_and_detects_tampering(monkeypatch):
             assert [item["category"] for item in coverage["categories"]] == list(
                 AUDIT_CATEGORIES
             )
-            assert all(item["count"] >= 1 for item in coverage["categories"])
+            counts = {
+                item["category"]: item["count"]
+                for item in coverage["categories"]
+            }
+            assert all(
+                counts[category] >= 1
+                for category in AUDIT_CATEGORIES[:6]
+            )
+            assert set(AUDIT_CATEGORIES[6:]) <= set(counts)
 
             events = service.list_events(
                 period_start=start,

+ 151 - 0
tests/integration/test_security_governance_postgres.py

@@ -0,0 +1,151 @@
+from __future__ import annotations
+
+import os
+import uuid
+from copy import deepcopy
+from datetime import UTC, datetime, timedelta
+
+import pytest
+from sqlalchemy import create_engine, text
+from sqlalchemy.orm import Session
+
+from app.core.system.governance_audit_repository import (
+    SqlAlchemyGovernanceAuditRepository,
+)
+from app.core.system.security_governance import SecurityGovernanceService
+from app.core.system.security_governance_repository import (
+    SqlAlchemySecurityGovernanceRepository,
+)
+
+pytestmark = pytest.mark.integration
+
+
+def _uid():
+    return str(uuid.uuid4())
+
+
+class ApprovalGateway:
+    def __init__(self):
+        self.tasks = {}
+
+    def create_egress_task(self, request_record, workflow_uid, actor_uid):
+        task = {"uid": _uid(), "status": "pending"}
+        self.tasks[task["uid"]] = task
+        return deepcopy(task)
+
+    def get_task(self, uid):
+        return deepcopy(self.tasks.get(uid))
+
+
+class NoopTransport:
+    def deliver(self, sink, envelope):
+        return {"status": "delivered", "remote_ref": "integration"}
+
+
+def test_second_domain_security_governance_and_vulnerability_closure_in_postgres():
+    database_url = os.environ.get("TEST_DATABASE_URL")
+    if not database_url:
+        pytest.skip("TEST_DATABASE_URL is required")
+    engine = create_engine(database_url)
+    connection = engine.connect()
+    transaction = connection.begin()
+    session = Session(bind=connection)
+    owner, reviewer, user, domain = _uid(), _uid(), _uid(), _uid()
+    now = datetime.now(UTC)
+    approvals = ApprovalGateway()
+    try:
+        for uid, name in ((owner, "owner"), (reviewer, "reviewer"), (user, "operator")):
+            session.execute(text(
+                "INSERT INTO public.users (id,username,display_name,password_hash,status) VALUES "
+                "(CAST(:uid AS uuid),:username,:username,'integration-only','active')"
+            ), {"uid": uid, "username": f"wp10-{name}-{uid[:8]}"})
+        repository = SqlAlchemySecurityGovernanceRepository(session)
+        service = SecurityGovernanceService(
+            repository,
+            approval_gateway=approvals,
+            siem_transport=NoopTransport(),
+            siem_host_allowlist={"siem.example.internal"},
+            now_factory=lambda: now,
+        )
+        profile = service.create_classification_profile({
+            "code": f"MATERIAL_{domain[:8].upper()}", "name": "备品备件分类规则",
+            "business_domain_uid": domain, "default_classification": "internal",
+            "rules": [
+                {"field_tokens": ["phone"], "category": "personal_contact", "classification": "sensitive"},
+                {"field_tokens": ["bank"], "category": "financial_account", "classification": "highly_sensitive"},
+            ],
+        }, actor_uid=owner)
+        scan = service.scan_sensitive_sample({
+            "profile_uid": profile["uid"], "resource_type": "material_master",
+            "resource_uid": _uid(), "business_domain_uid": domain,
+            "fields": [
+                {"name": "material_code", "sample_values": ["MAT-0001"]},
+                {"name": "supplier_phone", "sample_values": ["13800138000"]},
+                {"name": "supplier_bank_account", "sample_values": ["6222021234567890"]},
+            ],
+        }, actor_uid=owner)
+        assert scan["sample_retained"] is False
+        assert {item["proposed_classification"] for item in scan["findings"]} == {"sensitive", "highly_sensitive"}
+        reviewed = service.review_classification_finding(
+            scan["findings"][0]["uid"],
+            {"decision": "confirm", "final_classification": "sensitive", "reason": "供应商联系方式属于个人联系信息"},
+            expected_version=1, actor_uid=reviewer,
+        )
+        assert reviewed["current_version"] == 2
+
+        service.create_access_policy({
+            "code": f"MATERIAL_READ_{domain[:8].upper()}", "name": "物料质量用途受控读取",
+            "business_domain_uid": domain, "subject_user_uids": [user], "subject_roles": [],
+            "purposes": ["material_quality"], "environments": ["test"], "actions": ["read"],
+            "max_classification": "sensitive", "allowed_fields": ["material_code", "supplier_phone"],
+            "review_due_at": (now + timedelta(days=7)).isoformat(),
+            "expires_at": (now + timedelta(days=14)).isoformat(),
+        }, actor_uid=owner)
+        assert service.evaluate_access({
+            "user_uid": user, "roles": ["editor"], "business_domain_uid": domain,
+            "purpose": "material_quality", "environment": "test", "action": "read",
+            "resource_uid": _uid(), "classification": "sensitive", "requested_fields": ["material_code"],
+        })["decision"] == "authorized"
+        assert service.submit_egress_request({
+            "business_domain_uid": domain, "resource_uid": _uid(), "classification": "highly_sensitive",
+            "purpose": "supplier_settlement", "environment": "production",
+            "requested_fields": ["supplier_bank_account"], "minimized_fields": ["supplier_bank_account"],
+            "masking_applied": True, "destination_zone": "partner",
+            "expires_at": (now + timedelta(days=1)).isoformat(),
+        }, actor_uid=user)["status"] == "denied"
+
+        sbom = service.register_sbom({
+            "artifact_name": "dataops-backend", "artifact_version": "wp10", "artifact_type": "application",
+            "source_ref": "integration:wp10", "document": {
+                "bomFormat": "CycloneDX", "specVersion": "1.5", "version": 1,
+                "components": [{"type": "library", "name": "example-lib", "version": "1.0.0"}],
+            },
+        }, actor_uid=owner)
+        finding = service.ingest_vulnerabilities(sbom["uid"], {
+            "scanner": "trivy", "scan_ref": "integration:scan", "findings": [{
+                "external_id": "CVE-2026-1000", "severity": "high", "component_name": "example-lib",
+                "installed_version": "1.0.0", "fixed_version": "1.0.1", "title": "integration finding",
+            }],
+        }, actor_uid=owner)[0]
+        finding = service.assign_vulnerability(finding["uid"], {
+            "assignee_uid": user, "due_at": (now + timedelta(days=7)).isoformat()
+        }, expected_version=1, actor_uid=owner)
+        finding = service.resolve_vulnerability(finding["uid"], {
+            "resolution_type": "patched", "resolved_version": "1.0.1", "resolution": "已升级并复扫",
+            "evidence_refs": [{"type": "scan", "ref": "integration:fixed", "digest": "a" * 64}],
+        }, expected_version=2, actor_uid=user)
+        finding = service.close_vulnerability(finding["uid"], {"reason": "复扫无对应发现"}, expected_version=3, actor_uid=reviewer)
+        assert finding["status"] == "closed"
+
+        events = SqlAlchemyGovernanceAuditRepository(session).fetch_events(
+            categories=["security_governance"], period_start=now - timedelta(minutes=1), period_end=now + timedelta(minutes=1)
+        )
+        assert any(item["action"] == "egress_denied" for item in events)
+        serialized = repr(repository.list_findings())
+        assert "13800138000" not in serialized
+        assert "6222021234567890" not in serialized
+    finally:
+        session.close()
+        transaction.rollback()
+        connection.close()
+        engine.dispose()

+ 386 - 0
tests/security/test_security_governance.py

@@ -0,0 +1,386 @@
+from __future__ import annotations
+
+from copy import deepcopy
+from datetime import UTC, datetime, timedelta
+
+import pytest
+
+from app.core.system.security_governance import SecurityGovernanceService
+
+OWNER = "01900000-0000-7000-8000-000000010001"
+REVIEWER = "01900000-0000-7000-8000-000000010002"
+USER = "01900000-0000-7000-8000-000000010003"
+DOMAIN = "01900000-0000-7000-8000-000000010101"
+OTHER_DOMAIN = "01900000-0000-7000-8000-000000010102"
+WORKFLOW = "01900000-0000-7000-8000-000000010201"
+
+
+class MemorySecurityRepository:
+    def __init__(self):
+        self.users = {OWNER, REVIEWER, USER}
+        self.profiles = {}
+        self.scans = {}
+        self.findings = {}
+        self.policies = {}
+        self.decisions = {}
+        self.egress = {}
+        self.retention = {}
+        self.sinks = {}
+        self.deliveries = {}
+        self.sboms = {}
+        self.vulnerabilities = {}
+        self.events = []
+
+    def users_available(self, values):
+        return set(values) & self.users
+
+    def create_profile(self, record):
+        self.profiles[record["uid"]] = deepcopy(record)
+        return deepcopy(record)
+
+    def get_profile(self, uid):
+        return deepcopy(self.profiles.get(uid))
+
+    def create_scan(self, scan, findings):
+        self.scans[scan["uid"]] = deepcopy(scan)
+        for finding in findings:
+            self.findings[finding["uid"]] = deepcopy(finding)
+        return {**deepcopy(scan), "findings": deepcopy(findings)}
+
+    def get_finding(self, uid):
+        return deepcopy(self.findings.get(uid))
+
+    def update_finding(self, record, expected_version):
+        current = self.findings[record["uid"]]
+        if current["current_version"] != expected_version:
+            raise RuntimeError("classification finding version conflict")
+        saved = deepcopy(record)
+        saved["current_version"] += 1
+        self.findings[record["uid"]] = saved
+        return deepcopy(saved)
+
+    def create_access_policy(self, record):
+        self.policies[record["uid"]] = deepcopy(record)
+        return deepcopy(record)
+
+    def matching_access_policies(self, **context):
+        return [deepcopy(item) for item in self.policies.values() if item["status"] == "active"]
+
+    def create_access_decision(self, record):
+        self.decisions[record["uid"]] = deepcopy(record)
+        return deepcopy(record)
+
+    def create_egress_request(self, record):
+        self.egress[record["uid"]] = deepcopy(record)
+        return deepcopy(record)
+
+    def get_egress_request(self, uid):
+        return deepcopy(self.egress.get(uid))
+
+    def update_egress_request(self, record, expected_version):
+        if self.egress[record["uid"]]["current_version"] != expected_version:
+            raise RuntimeError("egress request version conflict")
+        saved = deepcopy(record)
+        saved["current_version"] += 1
+        self.egress[record["uid"]] = saved
+        return deepcopy(saved)
+
+    def create_retention_policy(self, record):
+        self.retention[record["uid"]] = deepcopy(record)
+        return deepcopy(record)
+
+    def retention_candidates(self, as_of, limit):
+        return [{"evidence_type": "access_decision", "candidate_count": 2, "automatic_deletion": False}]
+
+    def create_siem_sink(self, record):
+        self.sinks[record["uid"]] = deepcopy(record)
+        return deepcopy(record)
+
+    def get_siem_sink(self, uid):
+        return deepcopy(self.sinks.get(uid))
+
+    def fetch_siem_events(self, **filters):
+        return [{"event_uid": "event-1", "category": "authorization", "action": "denied", "safe_detail": {}}]
+
+    def create_siem_delivery(self, record):
+        self.deliveries[record["uid"]] = deepcopy(record)
+        return deepcopy(record)
+
+    def create_sbom(self, record):
+        self.sboms[record["uid"]] = deepcopy(record)
+        return deepcopy(record)
+
+    def get_sbom(self, uid):
+        return deepcopy(self.sboms.get(uid))
+
+    def upsert_vulnerabilities(self, sbom_uid, records):
+        for record in records:
+            self.vulnerabilities[record["uid"]] = deepcopy(record)
+        return deepcopy(records)
+
+    def get_vulnerability(self, uid):
+        return deepcopy(self.vulnerabilities.get(uid))
+
+    def update_vulnerability(self, record, expected_version, action, actor_uid):
+        current = self.vulnerabilities[record["uid"]]
+        if current["current_version"] != expected_version:
+            raise RuntimeError("vulnerability version conflict")
+        saved = deepcopy(record)
+        saved["current_version"] += 1
+        self.vulnerabilities[record["uid"]] = saved
+        return deepcopy(saved)
+
+    def add_event(self, resource_type, resource_uid, action, actor_uid, safe_detail):
+        self.events.append({
+            "resource_type": resource_type, "resource_uid": resource_uid,
+            "action": action, "actor_uid": actor_uid, "safe_detail": deepcopy(safe_detail),
+        })
+
+    def dashboard(self):
+        return {
+            "pending_classification_reviews": sum(item["status"] == "pending_review" for item in self.findings.values()),
+            "denied_access_count": sum(item["decision"] == "denied" for item in self.decisions.values()),
+            "open_vulnerability_count": sum(item["status"] != "closed" for item in self.vulnerabilities.values()),
+        }
+
+
+class FakeApprovalGateway:
+    def __init__(self):
+        self.tasks = {}
+
+    def create_egress_task(self, request_record, workflow_uid, actor_uid):
+        task = {"uid": f"01900000-0000-7000-8000-{len(self.tasks) + 1:012d}", "status": "pending"}
+        self.tasks[task["uid"]] = task
+        return deepcopy(task)
+
+    def get_task(self, uid):
+        return deepcopy(self.tasks.get(uid))
+
+
+class FakeSiemTransport:
+    def __init__(self):
+        self.sent = []
+
+    def deliver(self, sink, envelope):
+        self.sent.append((deepcopy(sink), deepcopy(envelope)))
+        return {"status": "delivered", "remote_ref": "siem-accepted-1"}
+
+
+@pytest.fixture()
+def security():
+    repository = MemorySecurityRepository()
+    approvals = FakeApprovalGateway()
+    transport = FakeSiemTransport()
+    ids = iter(f"01900000-0000-7000-8000-{value:012d}" for value in range(300, 900))
+    now = datetime(2026, 8, 2, 8, 0, tzinfo=UTC)
+    service = SecurityGovernanceService(
+        repository,
+        approval_gateway=approvals,
+        siem_transport=transport,
+        siem_host_allowlist={"siem.example.internal"},
+        uid_factory=lambda: next(ids),
+        now_factory=lambda: now,
+    )
+    return service, repository, approvals, transport, now
+
+
+def profile_payload():
+    return {
+        "code": "MATERIAL_SECURITY",
+        "name": "备品备件敏感识别规则",
+        "business_domain_uid": DOMAIN,
+        "default_classification": "internal",
+        "rules": [
+            {"field_tokens": ["phone", "mobile"], "category": "personal_contact", "classification": "sensitive"},
+            {"field_tokens": ["bank", "account"], "category": "financial_account", "classification": "highly_sensitive"},
+        ],
+    }
+
+
+def test_second_domain_sensitive_sample_is_identified_without_persisting_raw_values(security):
+    service, repository, _approvals, _transport, _now = security
+    profile = service.create_classification_profile(profile_payload(), actor_uid=OWNER)
+    scan = service.scan_sensitive_sample({
+        "profile_uid": profile["uid"],
+        "resource_type": "active_metadata_asset",
+        "resource_uid": "01900000-0000-7000-8000-000000010301",
+        "business_domain_uid": DOMAIN,
+        "fields": [
+            {"name": "material_code", "sample_values": ["MAT-0001"]},
+            {"name": "supplier_contact_phone", "sample_values": ["13800138000"]},
+            {"name": "supplier_bank_account", "sample_values": ["6222021234567890"]},
+        ],
+    }, actor_uid=OWNER)
+    assert {item["proposed_classification"] for item in scan["findings"]} == {"sensitive", "highly_sensitive"}
+    serialized = repr({"scan": repository.scans, "findings": repository.findings})
+    assert "13800138000" not in serialized
+    assert "6222021234567890" not in serialized
+    assert "MAT-0001" not in serialized
+    assert all(item["status"] == "pending_review" for item in scan["findings"])
+
+
+def test_classification_requires_independent_human_review(security):
+    service, _repository, _approvals, _transport, _now = security
+    profile = service.create_classification_profile(profile_payload(), actor_uid=OWNER)
+    scan = service.scan_sensitive_sample({
+        "profile_uid": profile["uid"], "resource_type": "file_field",
+        "resource_uid": "01900000-0000-7000-8000-000000010302",
+        "business_domain_uid": DOMAIN,
+        "fields": [{"name": "supplier_contact_phone", "sample_values": ["13800138000"]}],
+    }, actor_uid=OWNER)
+    finding = scan["findings"][0]
+    with pytest.raises(PermissionError, match="independent reviewer"):
+        service.review_classification_finding(
+            finding["uid"], {"decision": "confirm", "final_classification": "sensitive", "reason": "rule confirmed"},
+            expected_version=1, actor_uid=OWNER,
+        )
+    reviewed = service.review_classification_finding(
+        finding["uid"], {"decision": "confirm", "final_classification": "sensitive", "reason": "supplier contact is personal information"},
+        expected_version=1, actor_uid=REVIEWER,
+    )
+    assert reviewed["status"] == "confirmed"
+    assert reviewed["reviewed_by"] == REVIEWER
+
+
+def test_access_policy_binds_identity_role_domain_purpose_environment_fields_and_expiry(security):
+    service, _repository, _approvals, _transport, now = security
+    service.create_access_policy({
+        "code": "MATERIAL_STEWARD_READ",
+        "name": "物料管理员受控读取",
+        "business_domain_uid": DOMAIN,
+        "subject_user_uids": [USER],
+        "subject_roles": ["editor"],
+        "purposes": ["material_quality"],
+        "environments": ["test"],
+        "actions": ["read", "use"],
+        "max_classification": "sensitive",
+        "allowed_fields": ["material_code", "supplier_contact_phone"],
+        "expires_at": (now + timedelta(days=30)).isoformat(),
+        "review_due_at": (now + timedelta(days=15)).isoformat(),
+    }, actor_uid=OWNER)
+    allowed = service.evaluate_access({
+        "user_uid": USER, "roles": ["editor"], "business_domain_uid": DOMAIN,
+        "purpose": "material_quality", "environment": "test", "action": "read",
+        "resource_uid": "01900000-0000-7000-8000-000000010401",
+        "classification": "sensitive", "requested_fields": ["material_code"],
+    })
+    denied = service.evaluate_access({
+        "user_uid": USER, "roles": ["editor"], "business_domain_uid": OTHER_DOMAIN,
+        "purpose": "material_quality", "environment": "test", "action": "read",
+        "resource_uid": "01900000-0000-7000-8000-000000010401",
+        "classification": "sensitive", "requested_fields": ["material_code"],
+    })
+    excessive = service.evaluate_access({
+        "user_uid": USER, "roles": ["editor"], "business_domain_uid": DOMAIN,
+        "purpose": "material_quality", "environment": "test", "action": "read",
+        "resource_uid": "01900000-0000-7000-8000-000000010401",
+        "classification": "sensitive", "requested_fields": ["supplier_bank_account"],
+    })
+    assert allowed["decision"] == "authorized"
+    assert denied["reason_code"] == "default_deny"
+    assert excessive["reason_code"] == "field_minimization_denied"
+
+
+def test_highly_sensitive_egress_is_denied_and_sensitive_egress_requires_approval(security):
+    service, _repository, approvals, _transport, now = security
+    high = service.submit_egress_request({
+        "business_domain_uid": DOMAIN, "resource_uid": "01900000-0000-7000-8000-000000010501",
+        "classification": "highly_sensitive", "purpose": "supplier_settlement",
+        "environment": "production", "requested_fields": ["supplier_bank_account"],
+        "minimized_fields": ["supplier_bank_account"], "masking_applied": True,
+        "destination_zone": "partner", "expires_at": (now + timedelta(days=1)).isoformat(),
+    }, actor_uid=USER)
+    assert high["status"] == "denied"
+    assert high["reason_code"] == "highly_sensitive_egress_disabled"
+
+    pending = service.submit_egress_request({
+        "business_domain_uid": DOMAIN, "resource_uid": "01900000-0000-7000-8000-000000010502",
+        "classification": "sensitive", "purpose": "supplier_contact_validation",
+        "environment": "test", "requested_fields": ["supplier_contact_phone", "material_code"],
+        "minimized_fields": ["supplier_contact_phone"], "masking_applied": True,
+        "destination_zone": "controlled_partner", "expires_at": (now + timedelta(days=2)).isoformat(),
+        "workflow_uid": WORKFLOW,
+    }, actor_uid=USER)
+    assert pending["status"] == "pending_approval"
+    approvals.tasks[pending["approval_task_uid"]]["status"] = "approved"
+    approved = service.reconcile_egress_request(
+        pending["uid"], expected_version=1, actor_uid=REVIEWER
+    )
+    assert approved["status"] == "authorized_until_expiry"
+    assert approved["approved_fields"] == ["supplier_contact_phone"]
+
+
+def test_retention_is_policy_driven_and_never_automatically_deletes_evidence(security):
+    service, _repository, _approvals, _transport, now = security
+    policy = service.create_retention_policy({
+        "code": "ACCESS_DECISION_5Y", "name": "访问决策五年保留",
+        "evidence_type": "access_decision", "retention_days": 1825,
+        "archive_mode": "immutable_external", "disposition_action": "review",
+    }, actor_uid=OWNER)
+    candidates = service.retention_candidates(as_of=now, limit=100)
+    assert policy["automatic_deletion"] is False
+    assert candidates[0]["automatic_deletion"] is False
+
+
+def test_siem_sink_is_allowlisted_and_delivery_keeps_only_digest(security):
+    service, repository, _approvals, transport, now = security
+    with pytest.raises(ValueError, match="allowlist"):
+        service.create_siem_sink({
+            "name": "untrusted", "sink_type": "webhook",
+            "endpoint": "https://evil.example/collect", "categories": ["authorization"],
+        }, actor_uid=OWNER)
+    sink = service.create_siem_sink({
+        "name": "enterprise siem", "sink_type": "webhook",
+        "endpoint": "https://siem.example.internal/dataops/events",
+        "categories": ["authorization", "agent"],
+    }, actor_uid=OWNER)
+    delivery = service.dispatch_siem_events(sink["uid"], {
+        "period_start": (now - timedelta(hours=1)).isoformat(),
+        "period_end": now.isoformat(), "limit": 100,
+    }, actor_uid=OWNER)
+    assert delivery["status"] == "delivered"
+    assert len(delivery["payload_digest"]) == 64
+    assert "events" not in repository.deliveries[delivery["uid"]]
+    assert transport.sent[0][1]["events"][0]["category"] == "authorization"
+
+
+def test_sbom_and_vulnerability_lifecycle_requires_assignment_evidence_and_independent_close(security):
+    service, _repository, _approvals, _transport, now = security
+    sbom = service.register_sbom({
+        "artifact_name": "dataops-backend", "artifact_version": "0.3.0",
+        "artifact_type": "application", "source_ref": "build:wp10",
+        "document": {
+            "bomFormat": "CycloneDX", "specVersion": "1.5", "version": 1,
+            "components": [{"type": "library", "name": "example-lib", "version": "1.0.0", "purl": "pkg:pypi/example-lib@1.0.0"}],
+        },
+    }, actor_uid=OWNER)
+    finding = service.ingest_vulnerabilities(sbom["uid"], {
+        "scanner": "trivy", "scan_ref": "scan:wp10",
+        "findings": [{
+            "external_id": "CVE-2026-1000", "severity": "high", "component_name": "example-lib",
+            "installed_version": "1.0.0", "fixed_version": "1.0.1", "title": "example vulnerability",
+        }],
+    }, actor_uid=OWNER)[0]
+    assigned = service.assign_vulnerability(
+        finding["uid"], {"assignee_uid": USER, "due_at": (now + timedelta(days=7)).isoformat()},
+        expected_version=1, actor_uid=OWNER,
+    )
+    resolved = service.resolve_vulnerability(
+        finding["uid"], {
+            "resolution_type": "patched", "resolved_version": "1.0.1",
+            "resolution": "dependency upgraded and rescanned",
+            "evidence_refs": [{"type": "scan", "ref": "scan:wp10-fixed", "digest": "a" * 64}],
+        }, expected_version=2, actor_uid=USER,
+    )
+    with pytest.raises(PermissionError, match="independent reviewer"):
+        service.close_vulnerability(
+            finding["uid"], {"reason": "verified"}, expected_version=3, actor_uid=USER
+        )
+    closed = service.close_vulnerability(
+        finding["uid"], {"reason": "fixed version and rescan evidence verified"},
+        expected_version=3, actor_uid=REVIEWER,
+    )
+    assert assigned["status"] == "in_progress"
+    assert resolved["status"] == "resolved"
+    assert closed["status"] == "closed"
+    assert service.dashboard()["open_vulnerability_count"] == 0

+ 10 - 0
tests/system/test_governance_audit_frontend_contract.py

@@ -58,3 +58,13 @@ def test_audit_workbench_never_requests_or_renders_unsafe_columns():
         "encrypted_payload",
     ):
         assert forbidden not in combined
+
+
+def test_audit_workbench_presents_cross_domain_security_categories():
+    model = (
+        ROOT / "frontend/src/views/systemManage/governanceAudit/governanceAuditModel.js"
+    ).read_text(encoding="utf-8")
+    for category in (
+        "authorization", "workflow_task", "data_product", "agent", "security_governance"
+    ):
+        assert category in model

+ 95 - 0
tests/test_security_governance_api.py

@@ -0,0 +1,95 @@
+from __future__ import annotations
+
+USER_UID = "01900000-0000-7000-8000-000000018801"
+FINDING_UID = "01900000-0000-7000-8000-000000018802"
+
+
+class FakeSecurityService:
+    def __init__(self):
+        self.calls = []
+
+    def dashboard(self):
+        return {"pending_classification_reviews": 1, "open_vulnerability_count": 2}
+
+    def list_classification_findings(self, **filters):
+        self.calls.append(("findings", filters))
+        return [{"uid": FINDING_UID, "status": "pending_review"}]
+
+    def scan_sensitive_sample(self, payload, actor_uid):
+        self.calls.append(("scan", payload, actor_uid))
+        return {"uid": "scan-1", "sample_retained": False, "findings": []}
+
+    def evaluate_access(self, payload):
+        self.calls.append(("evaluate", payload))
+        return {"uid": "decision-1", "decision": "denied", "reason_code": "default_deny"}
+
+    def review_classification_finding(self, uid, payload, expected_version, actor_uid):
+        self.calls.append(("review", uid, payload, expected_version, actor_uid))
+        return {"uid": uid, "status": "confirmed", "current_version": 2}
+
+
+def _headers(role, **extra):
+    return {"Authorization": f"Bearer {role}", **extra}
+
+
+def _client(monkeypatch):
+    from app import create_app
+    from app.api.system import security_governance
+
+    service = FakeSecurityService()
+    monkeypatch.setattr(security_governance, "_service", lambda: service)
+    monkeypatch.setattr(
+        "app.core.system.auth.load_identity_from_token",
+        lambda token, secret: (
+            {"id": USER_UID, "username": token, "roles": [token]}
+            if token in {"viewer", "editor", "admin"}
+            else None
+        ),
+    )
+    app = create_app()
+    app.config.update(TESTING=True)
+    return app.test_client(), service
+
+
+def test_security_governance_role_boundaries_and_server_bound_identity(monkeypatch):
+    client, service = _client(monkeypatch)
+    assert client.get(
+        "/api/system/security-governance/dashboard", headers=_headers("viewer")
+    ).status_code == 200
+    assert client.get(
+        "/api/system/security-governance/findings?status=pending_review",
+        headers=_headers("viewer"),
+    ).status_code == 200
+
+    forbidden = client.post(
+        "/api/system/security-governance/scans", json={}, headers=_headers("viewer")
+    )
+    assert forbidden.status_code == 403
+    assert client.post(
+        "/api/system/security-governance/scans", json={}, headers=_headers("editor")
+    ).status_code == 201
+
+    decision = client.post(
+        "/api/system/security-governance/access/evaluate",
+        json={"user_uid": "forged", "roles": ["admin"]},
+        headers=_headers("editor"),
+    )
+    assert decision.status_code == 201
+    body = service.calls[-1][1]
+    assert body["user_uid"] == USER_UID
+    assert body["roles"] == ["editor"]
+
+
+def test_independent_review_is_admin_only_and_requires_etag(monkeypatch):
+    client, service = _client(monkeypatch)
+    path = f"/api/system/security-governance/findings/{FINDING_UID}/review"
+    assert client.post(path, json={}, headers=_headers("editor")).status_code == 403
+    assert client.post(path, json={}, headers=_headers("admin")).status_code == 400
+    response = client.post(
+        path,
+        json={"decision": "confirm"},
+        headers=_headers("admin", **{"If-Match": '"1"'}),
+    )
+    assert response.status_code == 200
+    assert response.headers["ETag"] == '"2"'
+    assert service.calls[-1][3] == 1

+ 80 - 0
tests/test_security_governance_contract.py

@@ -0,0 +1,80 @@
+from pathlib import Path
+
+from app.core.system.permissions import (
+    SECURITY_GOVERNANCE_MANAGE,
+    SECURITY_GOVERNANCE_OPERATE,
+    SECURITY_GOVERNANCE_READ,
+    permission_for_request,
+    permissions_for_roles,
+)
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def test_security_migration_is_additive_forward_only_and_evidence_bearing():
+    migration = (ROOT / "migrations/versions/20260802_460_security_governance.py").read_text()
+    assert 'revision = "20260802_460"' in migration
+    assert 'down_revision = "20260802_450"' in migration
+    for table in (
+        "security_classification_profiles", "security_classification_scans",
+        "security_classification_findings", "security_access_policies",
+        "security_access_decisions", "security_egress_requests",
+        "security_retention_policies", "security_siem_sinks",
+        "security_siem_deliveries", "security_sboms",
+        "security_vulnerabilities", "security_governance_events",
+        "access_control_audit_events",
+    ):
+        assert f"CREATE TABLE public.{table}" in migration
+    assert "raise RuntimeError" in migration
+    assert "DROP TABLE" not in migration.upper()
+
+
+def test_security_permission_matrix_is_monotonic_and_risk_aware():
+    assert SECURITY_GOVERNANCE_READ in permissions_for_roles(["viewer"])
+    assert SECURITY_GOVERNANCE_OPERATE not in permissions_for_roles(["viewer"])
+    assert SECURITY_GOVERNANCE_OPERATE in permissions_for_roles(["editor"])
+    assert SECURITY_GOVERNANCE_MANAGE not in permissions_for_roles(["editor"])
+    assert SECURITY_GOVERNANCE_MANAGE in permissions_for_roles(["admin"])
+    assert permission_for_request("/api/system/security-governance/dashboard", "GET") == (SECURITY_GOVERNANCE_READ,)
+    assert permission_for_request("/api/system/security-governance/scans", "POST") == (SECURITY_GOVERNANCE_OPERATE,)
+    assert permission_for_request("/api/system/security-governance/findings/x/review", "POST") == (SECURITY_GOVERNANCE_MANAGE,)
+    assert permission_for_request("/api/system/security-governance/egress/x/reconcile", "POST") == (SECURITY_GOVERNANCE_MANAGE,)
+    assert permission_for_request("/api/system/security-governance/sboms", "POST") == (SECURITY_GOVERNANCE_MANAGE,)
+
+
+def test_security_surface_reuses_work_center_and_expands_safe_audit_categories():
+    repository = (ROOT / "app/core/system/security_governance_repository.py").read_text()
+    service = (ROOT / "app/core/system/security_governance.py").read_text()
+    audit = (ROOT / "app/core/system/governance_audit_repository.py").read_text()
+    work_center = (ROOT / "app/core/governance/work_center.py").read_text()
+    assert "UnifiedWorkCenterService" in repository
+    assert '"data_egress"' in work_center
+    assert '"security_request"' in work_center
+    assert "highly_sensitive_egress_disabled" in service
+    assert '"authorization"' in audit
+    assert '"workflow_task"' in audit
+    assert '"data_product"' in audit
+    assert '"agent"' in audit
+    assert '"security_governance"' in audit
+    assert "sample_values" not in repository
+
+
+def test_data_security_workbench_replaces_placeholder_with_operational_surfaces():
+    client = (ROOT / "frontend/src/api/securityGovernance.js").read_text()
+    view = (ROOT / "frontend/src/views/dataGovernance/dataSecurity/index.vue").read_text()
+    routes = (ROOT / "frontend/src/router/routes.js").read_text()
+    for operation in (
+        "listSecurityFindings", "reviewSecurityFinding", "evaluateSecurityAccess",
+        "createSecurityEgress", "reconcileSecurityEgress", "listSecurityRetention",
+        "dispatchSecuritySiem", "registerSecuritySbom", "importSecurityVulnerabilities",
+        "assignSecurityVulnerability", "resolveSecurityVulnerability", "closeSecurityVulnerability",
+    ):
+        assert operation in client
+    for capability in (
+        "数据安全治理", "分类发现与人工复核", "访问决策", "出域审批",
+        "SBOM 与漏洞整改", "审计投递", "留存边界", "高敏感数据出域默认拒绝",
+        "样本原值不落库", "不会自动删除",
+    ):
+        assert capability in view
+    assert "功能将在后续迭代中建设" not in view
+    assert "security-governance:read" in routes