feat: add verifiable audit evidence bundles
This commit is contained in:
@@ -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
|
||||
),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user