feat: add verifiable audit evidence bundles

This commit is contained in:
2026-08-20 18:00:13 +02:00
parent f07d12fa52
commit 7b43816b22
19 changed files with 2164 additions and 10 deletions
+301
View File
@@ -24,6 +24,18 @@ from govoplan_core.db.session import get_session
from govoplan_core.tenancy.scope import Tenant
from govoplan_core.core.events import platform_event_outbox
from govoplan_audit.backend.db.models import AuditEvidenceBundle
from govoplan_audit.backend.evidence_bundles import (
EvidenceBundleError,
build_evidence_bundle,
canonical_sha256,
configured_signing_key,
normalize_evidence_reference,
)
from govoplan_audit.backend.permissions import (
AUDIT_EVIDENCE_EXPORT_SCOPE,
AUDIT_SYSTEM_EVIDENCE_EXPORT_SCOPE,
)
from .schemas import (
AuditAdminDeltaResponse,
@@ -34,6 +46,9 @@ from .schemas import (
EventDeliveryMetricsResponse,
EventDeliveryReplayRequest,
EventDeliveryReplayResponse,
EvidenceBundleDownloadResponse,
EvidenceBundleExportRequest,
EvidenceBundleResponse,
)
router = APIRouter(tags=["audit"])
@@ -724,3 +739,289 @@ def replay_event_delivery(
)
session.commit()
return EventDeliveryReplayResponse.model_validate(result)
@router.post(
"/admin/audit/evidence-bundles",
response_model=EvidenceBundleResponse,
status_code=status.HTTP_201_CREATED,
)
def export_evidence_bundle(
payload: EvidenceBundleExportRequest,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(
require_any_scope(
AUDIT_EVIDENCE_EXPORT_SCOPE,
AUDIT_SYSTEM_EVIDENCE_EXPORT_SCOPE,
)
),
):
scope, tenant_id = _resolve_evidence_bundle_scope(session, principal, payload)
records = _evidence_bundle_records(
session,
payload=payload,
scope=scope,
tenant_id=tenant_id,
)
try:
normalized_references = [
normalize_evidence_reference(item.model_dump(mode="json"))
for item in payload.references
]
except EvidenceBundleError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
request_payload = payload.model_dump(mode="json")
request_payload["resolved_tenant_id"] = tenant_id
request_payload["selection_complete"] = True
row = AuditEvidenceBundle(
scope=scope,
tenant_id=tenant_id,
requested_by=principal.account_id,
status="pending",
request_payload=request_payload,
)
session.add(row)
session.flush()
generated_at = datetime.now(timezone.utc)
try:
key_id, key_path = (
configured_signing_key(required=True)
if payload.sign
else (None, None)
)
bundle = build_evidence_bundle(
records,
bundle_id=row.id,
generated_at=generated_at,
scope={"kind": scope, "tenant_id": tenant_id},
request=_evidence_manifest_request(request_payload),
references=normalized_references,
signing_key_id=key_id if payload.sign else None,
signing_private_key_path=key_path if payload.sign else None,
)
except EvidenceBundleError as exc:
row.status = "failed"
row.error_code = "evidence_bundle_generation_failed"
session.add(row)
session.commit()
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
row.status = "ready"
row.bundle_payload = bundle
row.bundle_sha256 = canonical_sha256(bundle)
row.record_count = len(records)
row.reference_count = len(payload.references)
row.generated_at = generated_at
session.add(row)
audit_from_principal(
session,
principal,
action="audit.evidence_bundle.generated",
scope="system" if scope in {"system", "all"} else "tenant",
object_type="audit_evidence_bundle",
object_id=row.id,
details={
"bundle_sha256": row.bundle_sha256,
"record_count": row.record_count,
"reference_count": row.reference_count,
"scope": scope,
},
)
session.commit()
return _evidence_bundle_response(row)
@router.get(
"/admin/audit/evidence-bundles/{bundle_id}",
response_model=EvidenceBundleResponse,
)
def get_evidence_bundle(
bundle_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(
require_any_scope(
AUDIT_EVIDENCE_EXPORT_SCOPE,
AUDIT_SYSTEM_EVIDENCE_EXPORT_SCOPE,
)
),
):
row = _authorized_evidence_bundle(session, principal, bundle_id)
return _evidence_bundle_response(row)
@router.get(
"/admin/audit/evidence-bundles/{bundle_id}/download",
response_model=EvidenceBundleDownloadResponse,
)
def download_evidence_bundle(
bundle_id: str,
session: Session = Depends(get_session),
principal: ApiPrincipal = Depends(
require_any_scope(
AUDIT_EVIDENCE_EXPORT_SCOPE,
AUDIT_SYSTEM_EVIDENCE_EXPORT_SCOPE,
)
),
):
row = _authorized_evidence_bundle(session, principal, bundle_id)
if row.status != "ready" or not isinstance(row.bundle_payload, dict):
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Evidence bundle is not ready for download.",
)
if not row.bundle_sha256 or canonical_sha256(row.bundle_payload) != row.bundle_sha256:
row.status = "failed"
row.error_code = "evidence_bundle_storage_integrity_failed"
session.add(row)
audit_from_principal(
session,
principal,
action="audit.evidence_bundle.integrity_failed",
scope="system" if row.scope in {"system", "all"} else "tenant",
object_type="audit_evidence_bundle",
object_id=row.id,
details={"expected_bundle_sha256": row.bundle_sha256},
)
session.commit()
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Stored evidence bundle failed its canonical integrity check.",
)
row.downloaded_at = datetime.now(timezone.utc)
session.add(row)
audit_from_principal(
session,
principal,
action="audit.evidence_bundle.downloaded",
scope="system" if row.scope in {"system", "all"} else "tenant",
object_type="audit_evidence_bundle",
object_id=row.id,
details={"bundle_sha256": row.bundle_sha256},
)
session.commit()
return EvidenceBundleDownloadResponse(bundle=row.bundle_payload)
def _resolve_evidence_bundle_scope(
session: Session,
principal: ApiPrincipal,
payload: EvidenceBundleExportRequest,
) -> tuple[str, str | None]:
if payload.scope in {"system", "all"}:
if not has_scope(principal, AUDIT_SYSTEM_EVIDENCE_EXPORT_SCOPE):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Missing scope: {AUDIT_SYSTEM_EVIDENCE_EXPORT_SCOPE}",
)
if payload.tenant_id is not None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="System and all-scope evidence bundles do not accept a tenant id.",
)
return payload.scope, None
if not has_scope(principal, AUDIT_EVIDENCE_EXPORT_SCOPE):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Missing scope: {AUDIT_EVIDENCE_EXPORT_SCOPE}",
)
tenant = _resolve_tenant(session, principal, payload.tenant_id)
return "tenant", tenant.id
def _evidence_bundle_records(
session: Session,
*,
payload: EvidenceBundleExportRequest,
scope: str,
tenant_id: str | None,
) -> list[AuditLog]:
query = session.query(AuditLog)
if scope == "tenant":
query = query.filter(AuditLog.scope == "tenant", AuditLog.tenant_id == tenant_id)
elif scope == "system":
query = query.filter(AuditLog.scope == "system")
if payload.since is not None:
query = query.filter(AuditLog.created_at >= payload.since)
if payload.until is not None:
query = query.filter(AuditLog.created_at <= payload.until)
if payload.record_ids:
query = query.filter(AuditLog.id.in_(payload.record_ids))
if payload.action:
query = query.filter(AuditLog.action == payload.action)
if payload.object_type:
query = query.filter(AuditLog.object_type == payload.object_type)
if payload.object_id:
query = query.filter(AuditLog.object_id == payload.object_id)
records = (
query.order_by(AuditLog.created_at.asc(), AuditLog.id.asc())
.limit(payload.max_records + 1)
.all()
)
if len(records) > payload.max_records:
raise HTTPException(
status_code=status.HTTP_409_CONFLICT,
detail="Evidence selection exceeds the bounded record limit; narrow the requested scope.",
)
if payload.record_ids and {item.id for item in records} != set(payload.record_ids):
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="One or more requested audit records are unavailable in the authorized scope.",
)
return records
def _authorized_evidence_bundle(
session: Session,
principal: ApiPrincipal,
bundle_id: str,
) -> AuditEvidenceBundle:
row = session.get(AuditEvidenceBundle, bundle_id)
if row is None:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Evidence bundle not found.")
if row.scope in {"system", "all"}:
allowed = has_scope(principal, AUDIT_SYSTEM_EVIDENCE_EXPORT_SCOPE)
else:
allowed = (
has_scope(principal, AUDIT_EVIDENCE_EXPORT_SCOPE)
and row.tenant_id == principal.tenant_id
)
if not allowed:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Evidence bundle not found.")
return row
def _evidence_manifest_request(request_payload: dict[str, Any]) -> dict[str, Any]:
result = dict(request_payload)
references = result.pop("references", [])
result["reference_ids"] = [
item.get("reference_id")
for item in references
if isinstance(item, dict) and item.get("reference_id")
]
return result
def _evidence_bundle_response(row: AuditEvidenceBundle) -> EvidenceBundleResponse:
return EvidenceBundleResponse(
id=row.id,
scope=row.scope,
tenant_id=row.tenant_id,
status=row.status,
bundle_sha256=row.bundle_sha256,
record_count=row.record_count,
reference_count=row.reference_count,
generated_at=row.generated_at,
downloaded_at=row.downloaded_at,
error_code=row.error_code,
created_at=row.created_at,
download_url=(
f"/api/v1/admin/audit/evidence-bundles/{row.id}/download"
if row.status == "ready"
else None
),
)