test_security_governance_postgres.py 7.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151
  1. from __future__ import annotations
  2. import os
  3. import uuid
  4. from copy import deepcopy
  5. from datetime import UTC, datetime, timedelta
  6. import pytest
  7. from sqlalchemy import create_engine, text
  8. from sqlalchemy.orm import Session
  9. from app.core.system.governance_audit_repository import (
  10. SqlAlchemyGovernanceAuditRepository,
  11. )
  12. from app.core.system.security_governance import SecurityGovernanceService
  13. from app.core.system.security_governance_repository import (
  14. SqlAlchemySecurityGovernanceRepository,
  15. )
  16. pytestmark = pytest.mark.integration
  17. def _uid():
  18. return str(uuid.uuid4())
  19. class ApprovalGateway:
  20. def __init__(self):
  21. self.tasks = {}
  22. def create_egress_task(self, request_record, workflow_uid, actor_uid):
  23. task = {"uid": _uid(), "status": "pending"}
  24. self.tasks[task["uid"]] = task
  25. return deepcopy(task)
  26. def get_task(self, uid):
  27. return deepcopy(self.tasks.get(uid))
  28. class NoopTransport:
  29. def deliver(self, sink, envelope):
  30. return {"status": "delivered", "remote_ref": "integration"}
  31. def test_second_domain_security_governance_and_vulnerability_closure_in_postgres():
  32. database_url = os.environ.get("TEST_DATABASE_URL")
  33. if not database_url:
  34. pytest.skip("TEST_DATABASE_URL is required")
  35. engine = create_engine(database_url)
  36. connection = engine.connect()
  37. transaction = connection.begin()
  38. session = Session(bind=connection)
  39. owner, reviewer, user, domain = _uid(), _uid(), _uid(), _uid()
  40. now = datetime.now(UTC)
  41. approvals = ApprovalGateway()
  42. try:
  43. for uid, name in ((owner, "owner"), (reviewer, "reviewer"), (user, "operator")):
  44. session.execute(text(
  45. "INSERT INTO public.users (id,username,display_name,password_hash,status) VALUES "
  46. "(CAST(:uid AS uuid),:username,:username,'integration-only','active')"
  47. ), {"uid": uid, "username": f"wp10-{name}-{uid[:8]}"})
  48. repository = SqlAlchemySecurityGovernanceRepository(session)
  49. service = SecurityGovernanceService(
  50. repository,
  51. approval_gateway=approvals,
  52. siem_transport=NoopTransport(),
  53. siem_host_allowlist={"siem.example.internal"},
  54. now_factory=lambda: now,
  55. )
  56. profile = service.create_classification_profile({
  57. "code": f"MATERIAL_{domain[:8].upper()}", "name": "备品备件分类规则",
  58. "business_domain_uid": domain, "default_classification": "internal",
  59. "rules": [
  60. {"field_tokens": ["phone"], "category": "personal_contact", "classification": "sensitive"},
  61. {"field_tokens": ["bank"], "category": "financial_account", "classification": "highly_sensitive"},
  62. ],
  63. }, actor_uid=owner)
  64. scan = service.scan_sensitive_sample({
  65. "profile_uid": profile["uid"], "resource_type": "material_master",
  66. "resource_uid": _uid(), "business_domain_uid": domain,
  67. "fields": [
  68. {"name": "material_code", "sample_values": ["MAT-0001"]},
  69. {"name": "supplier_phone", "sample_values": ["13800138000"]},
  70. {"name": "supplier_bank_account", "sample_values": ["6222021234567890"]},
  71. ],
  72. }, actor_uid=owner)
  73. assert scan["sample_retained"] is False
  74. assert {item["proposed_classification"] for item in scan["findings"]} == {"sensitive", "highly_sensitive"}
  75. reviewed = service.review_classification_finding(
  76. scan["findings"][0]["uid"],
  77. {"decision": "confirm", "final_classification": "sensitive", "reason": "供应商联系方式属于个人联系信息"},
  78. expected_version=1, actor_uid=reviewer,
  79. )
  80. assert reviewed["current_version"] == 2
  81. service.create_access_policy({
  82. "code": f"MATERIAL_READ_{domain[:8].upper()}", "name": "物料质量用途受控读取",
  83. "business_domain_uid": domain, "subject_user_uids": [user], "subject_roles": [],
  84. "purposes": ["material_quality"], "environments": ["test"], "actions": ["read"],
  85. "max_classification": "sensitive", "allowed_fields": ["material_code", "supplier_phone"],
  86. "review_due_at": (now + timedelta(days=7)).isoformat(),
  87. "expires_at": (now + timedelta(days=14)).isoformat(),
  88. }, actor_uid=owner)
  89. assert service.evaluate_access({
  90. "user_uid": user, "roles": ["editor"], "business_domain_uid": domain,
  91. "purpose": "material_quality", "environment": "test", "action": "read",
  92. "resource_uid": _uid(), "classification": "sensitive", "requested_fields": ["material_code"],
  93. })["decision"] == "authorized"
  94. assert service.submit_egress_request({
  95. "business_domain_uid": domain, "resource_uid": _uid(), "classification": "highly_sensitive",
  96. "purpose": "supplier_settlement", "environment": "production",
  97. "requested_fields": ["supplier_bank_account"], "minimized_fields": ["supplier_bank_account"],
  98. "masking_applied": True, "destination_zone": "partner",
  99. "expires_at": (now + timedelta(days=1)).isoformat(),
  100. }, actor_uid=user)["status"] == "denied"
  101. sbom = service.register_sbom({
  102. "artifact_name": "dataops-backend", "artifact_version": "wp10", "artifact_type": "application",
  103. "source_ref": "integration:wp10", "document": {
  104. "bomFormat": "CycloneDX", "specVersion": "1.5", "version": 1,
  105. "components": [{"type": "library", "name": "example-lib", "version": "1.0.0"}],
  106. },
  107. }, actor_uid=owner)
  108. finding = service.ingest_vulnerabilities(sbom["uid"], {
  109. "scanner": "trivy", "scan_ref": "integration:scan", "findings": [{
  110. "external_id": "CVE-2026-1000", "severity": "high", "component_name": "example-lib",
  111. "installed_version": "1.0.0", "fixed_version": "1.0.1", "title": "integration finding",
  112. }],
  113. }, actor_uid=owner)[0]
  114. finding = service.assign_vulnerability(finding["uid"], {
  115. "assignee_uid": user, "due_at": (now + timedelta(days=7)).isoformat()
  116. }, expected_version=1, actor_uid=owner)
  117. finding = service.resolve_vulnerability(finding["uid"], {
  118. "resolution_type": "patched", "resolved_version": "1.0.1", "resolution": "已升级并复扫",
  119. "evidence_refs": [{"type": "scan", "ref": "integration:fixed", "digest": "a" * 64}],
  120. }, expected_version=2, actor_uid=user)
  121. finding = service.close_vulnerability(finding["uid"], {"reason": "复扫无对应发现"}, expected_version=3, actor_uid=reviewer)
  122. assert finding["status"] == "closed"
  123. events = SqlAlchemyGovernanceAuditRepository(session).fetch_events(
  124. categories=["security_governance"], period_start=now - timedelta(minutes=1), period_end=now + timedelta(minutes=1)
  125. )
  126. assert any(item["action"] == "egress_denied" for item in events)
  127. serialized = repr(repository.list_findings())
  128. assert "13800138000" not in serialized
  129. assert "6222021234567890" not in serialized
  130. finally:
  131. session.close()
  132. transaction.rollback()
  133. connection.close()
  134. engine.dispose()