From 7b43816b22bfe2a6d9320b16adf16137669a0488 Mon Sep 17 00:00:00 2001 From: Albrecht Degering Date: Thu, 20 Aug 2026 18:00:13 +0200 Subject: [PATCH] feat: add verifiable audit evidence bundles --- README.md | 9 + docs/EVIDENCE_BUNDLES.md | 85 +++ pyproject.toml | 3 + src/govoplan_audit/backend/api/v1/routes.py | 301 ++++++++++ src/govoplan_audit/backend/api/v1/schemas.py | 65 ++- src/govoplan_audit/backend/db/models.py | 31 + .../backend/evidence_bundles.py | 388 +++++++++++++ .../backend/evidence_verifier.py | 547 ++++++++++++++++++ src/govoplan_audit/backend/manifest.py | 41 +- .../b9e2f5a8c3d6_evidence_bundles.py | 66 +++ .../b9e2f5a8c3d6_v0119_evidence_bundles.py | 66 +++ src/govoplan_audit/backend/permissions.py | 55 ++ .../backend/verify_evidence_bundle.py | 70 +++ tests/fixtures/evidence_bundle_v1.json | 83 +++ tests/test_audit_module_contract.py | 18 +- tests/test_evidence_bundles.py | 239 ++++++++ webui/src/api/audit.ts | 41 ++ webui/src/features/audit/AdminAuditPanel.tsx | 58 +- webui/src/i18n/generatedTranslations.ts | 8 +- 19 files changed, 2164 insertions(+), 10 deletions(-) create mode 100644 docs/EVIDENCE_BUNDLES.md create mode 100644 src/govoplan_audit/backend/evidence_bundles.py create mode 100644 src/govoplan_audit/backend/evidence_verifier.py create mode 100644 src/govoplan_audit/backend/migrations/dev_versions/b9e2f5a8c3d6_evidence_bundles.py create mode 100644 src/govoplan_audit/backend/migrations/versions/b9e2f5a8c3d6_v0119_evidence_bundles.py create mode 100644 src/govoplan_audit/backend/permissions.py create mode 100644 src/govoplan_audit/backend/verify_evidence_bundle.py create mode 100644 tests/fixtures/evidence_bundle_v1.json create mode 100644 tests/test_evidence_bundles.py diff --git a/README.md b/README.md index 0f79b2c..da9aaa2 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,15 @@ This repository owns the live `audit_log` table, audit API route contributions, the `@govoplan/audit-webui` package, and the target boundary for future audit sink/export capability work. +Audit now also owns versioned evidence-bundle export. Authorized auditors can +select a bounded tenant or system record set, attach module-owned external +evidence references, and download a redacted artifact with canonical hashes +and optional Ed25519 signatures. The `govoplan-audit-verify` command validates +the bundle offline and reports unsupported, incomplete, unverifiable, and +tampered evidence separately. See +[docs/EVIDENCE_BUNDLES.md](docs/EVIDENCE_BUNDLES.md) for API permissions, +signing configuration, module contribution rules, and verifier usage. + The WebUI package contributes the `system-audit` and `tenant-audit` admin sections through the shared `admin.sections` UI capability. The admin shell does not render audit panels unless this module is installed and enabled. diff --git a/docs/EVIDENCE_BUNDLES.md b/docs/EVIDENCE_BUNDLES.md new file mode 100644 index 0000000..4b4a8d4 --- /dev/null +++ b/docs/EVIDENCE_BUNDLES.md @@ -0,0 +1,85 @@ +# Audit evidence bundles + +Audit evidence bundles are bounded, portable JSON artifacts for independent +review. They are not database backups. A bundle contains selected audit facts, +trace context, policy and source provenance, external evidence references, and +redaction declarations. It never embeds referenced files, raw messages, full +recipient lists, credentials, tokens, or arbitrary feature payloads. + +## Export lifecycle and permissions + +Create an export with `POST /api/v1/admin/audit/evidence-bundles`. A tenant +export requires `audit:evidence:export` and is constrained to the principal's +active tenant. `system` and `all` scopes require +`audit:system_evidence:export`. Selection is limited to 500 audit records and +200 external references. An over-broad selection is rejected instead of being +silently truncated. + +The response records the `pending`, `ready`, or `failed` lifecycle state and a +canonical bundle SHA-256. Metadata and content are available at: + +- `GET /api/v1/admin/audit/evidence-bundles/{id}` +- `GET /api/v1/admin/audit/evidence-bundles/{id}/download` + +The Audit administration page offers **Export page evidence** to create an +unsigned bundle for the currently displayed records. Use the API when a review +needs a broader filtered selection, explicit module references, or signing. + +Generation and every download produce separate audit records. Download access +is re-authorized against the original scope so a tenant switch cannot expose a +bundle from another tenant. Audit also checks the stored bundle against its +persisted canonical hash before each download and fails the lifecycle record if +storage integrity no longer matches. + +## Module evidence references + +Feature modules keep their evidence and storage ownership. They may record a +serialized `govoplan_core.core.institutional.EvidenceReference` in bounded +audit details or supply an external reference in the export request. The Audit +module stores only its id, kind, owner module, locator, required flag, and +optional content SHA-256. This lets modules participate without importing Audit +internals or handing Audit file contents. + +External references should include a SHA-256 whenever the referenced artifact +can be canonicalized. A missing hash is reported as unverifiable. A required +artifact that is not supplied during offline review is reported as missing, +and supplied bytes that do not match their hash are reported as tampered. + +## Signatures + +Canonical record and reference hashes are always emitted. Trusted signatures +are optional and use Ed25519. To enable signed exports, configure both: + +- `GOVOPLAN_AUDIT_EVIDENCE_SIGNING_KEY_ID` +- `GOVOPLAN_AUDIT_EVIDENCE_SIGNING_PRIVATE_KEY` (path to a PEM Ed25519 key) + +The request must set `sign` to `true`; otherwise the bundle remains unsigned. +Keep private keys outside the application database and distribute raw, +base64-encoded Ed25519 public keys to independent reviewers through a separate +trusted channel. + +## Offline verification + +The installed `govoplan-audit-verify` command requires no source database: + +```text +govoplan-audit-verify bundle.json --pretty +govoplan-audit-verify bundle.json \ + --trusted-key institution-2026=BASE64_PUBLIC_KEY \ + --external decision-42=/review/decision-42.json +``` + +Output is deterministic and returns one of these states: + +- `valid`: schema, canonical hashes, completeness, redaction declarations, + supplied external evidence, and any trusted signatures are valid. +- `incomplete`: a manifest item or required external artifact is missing. +- `unverifiable`: a reference lacks a checksum or a signature key is untrusted. +- `tampered`: a canonical item, manifest, external artifact, or trusted + signature does not match. +- `invalid`: the supported schema or redaction contract is malformed. +- `unsupported`: the schema or version is not supported by this verifier. + +An unsigned bundle can still be `valid`: the verifier establishes internal +hash consistency, while provenance trust must then be established by the +review process. diff --git a/pyproject.toml b/pyproject.toml index 1ac8173..1043004 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -13,6 +13,9 @@ dependencies = [ "govoplan-core>=0.1.18", ] +[project.scripts] +govoplan-audit-verify = "govoplan_audit.backend.verify_evidence_bundle:main" + [tool.setuptools.packages.find] where = ["src"] diff --git a/src/govoplan_audit/backend/api/v1/routes.py b/src/govoplan_audit/backend/api/v1/routes.py index 43aaf29..db62a31 100644 --- a/src/govoplan_audit/backend/api/v1/routes.py +++ b/src/govoplan_audit/backend/api/v1/routes.py @@ -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 + ), + ) diff --git a/src/govoplan_audit/backend/api/v1/schemas.py b/src/govoplan_audit/backend/api/v1/schemas.py index 4487ced..14d68fa 100644 --- a/src/govoplan_audit/backend/api/v1/schemas.py +++ b/src/govoplan_audit/backend/api/v1/schemas.py @@ -3,7 +3,7 @@ from __future__ import annotations from datetime import datetime from typing import Any, Literal -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator from govoplan_core.api.v1.schemas import DeltaDeletedItem @@ -79,3 +79,66 @@ class EventDeliveryReplayResponse(BaseModel): last_replayed_by: str | None = None last_replay_reason: str | None = None last_error: str | None = None + + +class EvidenceBundleReferenceRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + reference_id: str = Field(min_length=1, max_length=200) + kind: str = Field(min_length=1, max_length=80) + owner_module: str = Field(min_length=1, max_length=100) + locator: str = Field(min_length=1, max_length=2048) + content_sha256: str | None = Field(default=None, pattern=r"^[0-9A-Fa-f]{64}$") + required: bool = True + + +class EvidenceBundleExportRequest(BaseModel): + model_config = ConfigDict(extra="forbid") + + scope: Literal["tenant", "system", "all"] = "tenant" + tenant_id: str | None = Field(default=None, max_length=36) + since: datetime | None = None + until: datetime | None = None + record_ids: list[str] = Field(default_factory=list, max_length=500) + action: str | None = Field(default=None, max_length=100) + object_type: str | None = Field(default=None, max_length=100) + object_id: str | None = Field(default=None, max_length=100) + max_records: int = Field(default=500, ge=1, le=500) + references: list[EvidenceBundleReferenceRequest] = Field( + default_factory=list, + max_length=200, + ) + sign: bool = False + + @model_validator(mode="after") + def validate_window_and_selection(self): + for label, value in (("since", self.since), ("until", self.until)): + if value is not None and (value.tzinfo is None or value.utcoffset() is None): + raise ValueError(f"Evidence bundle {label} must include a timezone.") + if self.since and self.until and self.until < self.since: + raise ValueError("Evidence bundle until must not precede since.") + if len(self.record_ids) != len(set(self.record_ids)): + raise ValueError("Evidence bundle record ids must be unique.") + reference_ids = [item.reference_id for item in self.references] + if len(reference_ids) != len(set(reference_ids)): + raise ValueError("Evidence bundle reference ids must be unique.") + return self + + +class EvidenceBundleResponse(BaseModel): + id: str + scope: Literal["tenant", "system", "all"] + tenant_id: str | None = None + status: Literal["pending", "ready", "failed"] + bundle_sha256: str | None = None + record_count: int + reference_count: int + generated_at: datetime | None = None + downloaded_at: datetime | None = None + error_code: str | None = None + created_at: datetime + download_url: str | None = None + + +class EvidenceBundleDownloadResponse(BaseModel): + bundle: dict[str, Any] diff --git a/src/govoplan_audit/backend/db/models.py b/src/govoplan_audit/backend/db/models.py index 86bbd77..4a2f73d 100644 --- a/src/govoplan_audit/backend/db/models.py +++ b/src/govoplan_audit/backend/db/models.py @@ -149,7 +149,38 @@ class AuditOutboxDelivery(Base, TimestampMixin): ) +class AuditEvidenceBundle(Base, TimestampMixin): + __tablename__ = "audit_evidence_bundles" + __table_args__ = ( + Index( + "ix_audit_evidence_bundle_tenant_created_at", + "tenant_id", + "created_at", + ), + Index( + "ix_audit_evidence_bundle_status_created_at", + "status", + "created_at", + ), + ) + + id: Mapped[str] = mapped_column(String(36), primary_key=True, default=new_uuid) + scope: Mapped[str] = mapped_column(String(20), nullable=False, default="tenant") + tenant_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True) + requested_by: Mapped[str] = mapped_column(String(128), nullable=False) + status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending", index=True) + request_payload: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False) + bundle_payload: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True) + bundle_sha256: Mapped[str | None] = mapped_column(String(64), nullable=True) + record_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + reference_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + generated_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + downloaded_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + error_code: Mapped[str | None] = mapped_column(String(100), nullable=True) + + __all__ = [ + "AuditEvidenceBundle", "AuditLog", "AuditOutboxDelivery", "AuditOutboxEvent", diff --git a/src/govoplan_audit/backend/evidence_bundles.py b/src/govoplan_audit/backend/evidence_bundles.py new file mode 100644 index 0000000..a24e3aa --- /dev/null +++ b/src/govoplan_audit/backend/evidence_bundles.py @@ -0,0 +1,388 @@ +from __future__ import annotations + +import base64 +import hashlib +import json +import os +import re +from collections.abc import Iterable, Mapping +from datetime import datetime, timezone +from pathlib import Path +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from govoplan_audit.backend.db.models import AuditLog + + +EVIDENCE_BUNDLE_SCHEMA = "govoplan.audit.evidence-bundle" +EVIDENCE_BUNDLE_VERSION = "1.0" +EVIDENCE_REDACTION_PROFILE = "bounded-v1" +MAX_EVIDENCE_RECORDS = 500 +MAX_EVIDENCE_REFERENCES = 200 +MAX_SANITIZED_DETAILS_BYTES = 64 * 1024 + +_TRACE_KEYS = frozenset( + { + "correlation_id", + "causation_id", + "request_id", + "run_id", + "trace_id", + } +) +_REFERENCE_KEYS = frozenset( + { + "evidence_ref", + "evidence_refs", + "legal_basis_ref", + "policy_decision_ref", + "policy_ref", + "source_ref", + } +) +_PROHIBITED_KEYS = frozenset( + { + "authorization", + "body", + "content_bytes", + "credential", + "credentials", + "file_content", + "file_contents", + "message", + "message_body", + "password", + "payload", + "raw_message", + "recipient_list", + "recipients", + "secret", + "token", + } +) +_SENSITIVE_KEY_PARTS = ("password", "secret", "credential", "authorization_token") +_SHA256_RE = re.compile(r"^[0-9a-f]{64}$") +_SENSITIVE_LOCATOR_RE = re.compile( + r"(?i)(?:^|[?&;])(?:access_?token|token|secret|password|credential|authorization|signature)=[^&;]+|://[^/@\s]+:[^/@\s]+@" +) + + +class EvidenceBundleError(ValueError): + """Stable error for bounded Audit evidence-bundle generation.""" + + +def canonical_json_bytes(value: object) -> bytes: + return json.dumps( + value, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + + +def canonical_sha256(value: object) -> str: + return hashlib.sha256(canonical_json_bytes(value)).hexdigest() + + +def sanitize_audit_details(value: object) -> tuple[object, list[str]]: + redactions: list[str] = [] + + def sanitize(candidate: object, path: str) -> object: + if isinstance(candidate, Mapping): + result: dict[str, object] = {} + for raw_key, nested in sorted(candidate.items(), key=lambda item: str(item[0])): + key = str(raw_key) + normalized = key.strip().lower().replace("-", "_") + nested_path = f"{path}.{key}" if path else key + if _is_prohibited_key(normalized): + result[key] = _redacted_summary(nested) + redactions.append(nested_path) + else: + result[key] = sanitize(nested, nested_path) + return result + if isinstance(candidate, (list, tuple)): + return [sanitize(item, f"{path}[{index}]") for index, item in enumerate(candidate)] + if candidate is None or isinstance(candidate, (bool, int, float, str)): + return candidate + redactions.append(path or "details") + return {"redacted": True, "type": type(candidate).__name__} + + sanitized = sanitize(value, "details") + if len(canonical_json_bytes(sanitized)) > MAX_SANITIZED_DETAILS_BYTES: + redactions.append("details") + sanitized = { + "redacted": True, + "reason": "sanitized_details_size_limit", + "original_type": type(value).__name__, + } + return sanitized, sorted(set(redactions)) + + +def _is_prohibited_key(normalized: str) -> bool: + return ( + normalized in _PROHIBITED_KEYS + or normalized.endswith("_token") + or any(part in normalized for part in _SENSITIVE_KEY_PARTS) + ) + + +def _redacted_summary(value: object) -> dict[str, object]: + summary: dict[str, object] = {"redacted": True} + if isinstance(value, (list, tuple, Mapping)): + summary["item_count"] = len(value) + return summary + + +def _record_payload(record: AuditLog) -> dict[str, object]: + sanitized, redactions = sanitize_audit_details(record.details or {}) + details = sanitized if isinstance(sanitized, dict) else {"value": sanitized} + return { + "record_id": record.id, + "scope": record.scope, + "tenant_id": record.tenant_id, + "actor": { + "user_id": record.user_id, + "api_key_id": record.api_key_id, + }, + "action": record.action, + "object": { + "type": record.object_type, + "id": record.object_id, + }, + "recorded_at": _datetime_text(record.created_at), + "trace_context": _bounded_context(details, _TRACE_KEYS), + "policy_source_provenance": _bounded_context(details, _REFERENCE_KEYS), + "details": details, + "redaction": { + "profile": EVIDENCE_REDACTION_PROFILE, + "redacted_paths": redactions, + }, + } + + +def _bounded_context(details: Mapping[str, object], keys: frozenset[str]) -> dict[str, object]: + result: dict[str, object] = {} + for key in sorted(keys): + if key not in details: + continue + value = details[key] + if isinstance(value, str): + result[key] = value[:2048] + elif isinstance(value, list): + result[key] = [_bounded_reference_value(item) for item in value[:50]] + elif isinstance(value, Mapping): + result[key] = _bounded_reference_value(value) + return result + + +def _bounded_reference_value(value: object) -> object: + if isinstance(value, str): + return value[:2048] + if isinstance(value, Mapping): + return { + str(key): _bounded_reference_value(nested) + for key, nested in sorted(value.items(), key=lambda item: str(item[0])) + if str(key).lower() not in _PROHIBITED_KEYS + } + if isinstance(value, list): + return [_bounded_reference_value(item) for item in value[:50]] + if value is None or isinstance(value, (bool, int, float)): + return value + return str(value)[:2048] + + +def normalize_evidence_reference(value: Mapping[str, object]) -> dict[str, object]: + reference_id = _required_text(value, "reference_id", maximum=200) + kind = _required_text(value, "kind", maximum=80) + owner_module = _required_text(value, "owner_module", maximum=100) + locator = _required_text(value, "locator", maximum=2048) + if _SENSITIVE_LOCATOR_RE.search(locator): + raise EvidenceBundleError( + f"Evidence reference {reference_id!r} locator appears to contain a secret." + ) + content_sha256 = _optional_text(value.get("content_sha256"), maximum=64) + if content_sha256 is not None: + content_sha256 = content_sha256.lower() + if not _SHA256_RE.fullmatch(content_sha256): + raise EvidenceBundleError( + f"Evidence reference {reference_id!r} has an invalid SHA-256 checksum." + ) + return { + "reference_id": reference_id, + "kind": kind, + "owner_module": owner_module, + "locator": locator, + "content_sha256": content_sha256, + "required": bool(value.get("required", True)), + "availability": "external", + } + + +def build_evidence_bundle( + records: Iterable[AuditLog], + *, + bundle_id: str, + generated_at: datetime, + scope: Mapping[str, object], + request: Mapping[str, object], + references: Iterable[Mapping[str, object]] = (), + signing_key_id: str | None = None, + signing_private_key_path: Path | None = None, +) -> dict[str, object]: + record_payloads = [_record_payload(record) for record in records] + if len(record_payloads) > MAX_EVIDENCE_RECORDS: + raise EvidenceBundleError( + f"Evidence bundles support at most {MAX_EVIDENCE_RECORDS} audit records." + ) + reference_payloads = [normalize_evidence_reference(item) for item in references] + if len(reference_payloads) > MAX_EVIDENCE_REFERENCES: + raise EvidenceBundleError( + f"Evidence bundles support at most {MAX_EVIDENCE_REFERENCES} external references." + ) + reference_ids = [str(item["reference_id"]) for item in reference_payloads] + if len(reference_ids) != len(set(reference_ids)): + raise EvidenceBundleError("Evidence reference ids must be unique within a bundle.") + + entries = [ + { + "path": f"records/{index}", + "kind": "audit_record", + "sha256": canonical_sha256(payload), + } + for index, payload in enumerate(record_payloads) + ] + entries.extend( + { + "path": f"references/{index}", + "kind": "external_reference", + "sha256": canonical_sha256(payload), + } + for index, payload in enumerate(reference_payloads) + ) + manifest_core: dict[str, object] = { + "schema": EVIDENCE_BUNDLE_SCHEMA, + "version": EVIDENCE_BUNDLE_VERSION, + "bundle_id": _required_scalar_text(bundle_id, "Bundle id", maximum=100), + "generated_at": _datetime_text(generated_at), + "scope": dict(scope), + "request": dict(request), + "record_count": len(record_payloads), + "reference_count": len(reference_payloads), + "entries": entries, + "redaction": { + "profile": EVIDENCE_REDACTION_PROFILE, + "prohibited_fields": sorted(_PROHIBITED_KEYS), + "raw_evidence_embedded": False, + }, + } + manifest_sha256 = canonical_sha256(manifest_core) + signed_manifest = {**manifest_core, "manifest_sha256": manifest_sha256} + signatures: list[dict[str, str]] = [] + if signing_key_id is not None or signing_private_key_path is not None: + if not signing_key_id or signing_private_key_path is None: + raise EvidenceBundleError( + "Evidence signing requires both a key id and an Ed25519 private-key path." + ) + signatures.append( + _sign_manifest( + signed_manifest, + key_id=signing_key_id, + private_key_path=signing_private_key_path, + ) + ) + manifest = {**signed_manifest, "signatures": signatures} + return { + "schema": EVIDENCE_BUNDLE_SCHEMA, + "version": EVIDENCE_BUNDLE_VERSION, + "manifest": manifest, + "records": record_payloads, + "references": reference_payloads, + } + + +def configured_signing_key(*, required: bool) -> tuple[str | None, Path | None]: + key_id = os.getenv("GOVOPLAN_AUDIT_EVIDENCE_SIGNING_KEY_ID", "").strip() + raw_path = os.getenv("GOVOPLAN_AUDIT_EVIDENCE_SIGNING_PRIVATE_KEY", "").strip() + if not key_id and not raw_path and not required: + return None, None + if not key_id or not raw_path: + raise EvidenceBundleError( + "Evidence signing is not fully configured; set both signing key environment variables." + ) + path = Path(raw_path) + if not path.is_file(): + raise EvidenceBundleError("The configured evidence signing private key is unavailable.") + return key_id, path + + +def _sign_manifest( + manifest: Mapping[str, object], + *, + key_id: str, + private_key_path: Path, +) -> dict[str, str]: + try: + private_key = serialization.load_pem_private_key( + private_key_path.read_bytes(), + password=None, + ) + except (OSError, TypeError, ValueError) as exc: + raise EvidenceBundleError( + "The configured evidence signing private key could not be loaded." + ) from exc + if not isinstance(private_key, Ed25519PrivateKey): + raise EvidenceBundleError("Evidence signing requires an Ed25519 private key.") + signature = private_key.sign(canonical_json_bytes(manifest)) + return { + "algorithm": "ed25519", + "key_id": _required_scalar_text(key_id, "Signing key id", maximum=200), + "value": base64.b64encode(signature).decode("ascii"), + } + + +def _required_text( + value: Mapping[str, object], + key: str, + *, + maximum: int, +) -> str: + return _required_scalar_text(value.get(key), key.replace("_", " ").title(), maximum=maximum) + + +def _required_scalar_text(value: object, label: str, *, maximum: int) -> str: + if not isinstance(value, str) or not value.strip(): + raise EvidenceBundleError(f"{label} is required.") + clean = value.strip() + if len(clean) > maximum: + raise EvidenceBundleError(f"{label} is too long.") + return clean + + +def _optional_text(value: object, *, maximum: int) -> str | None: + if value is None: + return None + if not isinstance(value, str) or not value.strip() or len(value.strip()) > maximum: + raise EvidenceBundleError("Evidence reference text is invalid.") + return value.strip() + + +def _datetime_text(value: datetime) -> str: + if value.tzinfo is None or value.utcoffset() is None: + value = value.replace(tzinfo=timezone.utc) + return value.astimezone(timezone.utc).isoformat().replace("+00:00", "Z") + + +__all__ = [ + "EVIDENCE_BUNDLE_SCHEMA", + "EVIDENCE_BUNDLE_VERSION", + "EVIDENCE_REDACTION_PROFILE", + "EvidenceBundleError", + "MAX_EVIDENCE_RECORDS", + "MAX_EVIDENCE_REFERENCES", + "build_evidence_bundle", + "canonical_json_bytes", + "canonical_sha256", + "configured_signing_key", + "normalize_evidence_reference", + "sanitize_audit_details", +] diff --git a/src/govoplan_audit/backend/evidence_verifier.py b/src/govoplan_audit/backend/evidence_verifier.py new file mode 100644 index 0000000..b5c797d --- /dev/null +++ b/src/govoplan_audit/backend/evidence_verifier.py @@ -0,0 +1,547 @@ +from __future__ import annotations + +import base64 +import hashlib +from collections.abc import Mapping +from dataclasses import dataclass + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + +from govoplan_audit.backend.evidence_bundles import ( + EVIDENCE_BUNDLE_SCHEMA, + EVIDENCE_BUNDLE_VERSION, + canonical_json_bytes, + canonical_sha256, +) + + +@dataclass(frozen=True, slots=True, order=True) +class VerificationFinding: + code: str + path: str + message: str + + def as_dict(self) -> dict[str, str]: + return {"code": self.code, "path": self.path, "message": self.message} + + +def verify_evidence_bundle( + payload: object, + *, + trusted_keys: Mapping[str, str] | None = None, + external_evidence: Mapping[str, bytes] | None = None, +) -> dict[str, object]: + errors: list[VerificationFinding] = [] + warnings: list[VerificationFinding] = [] + trusted_keys = trusted_keys or {} + external_evidence = external_evidence or {} + if isinstance(payload, Mapping) and isinstance(payload.get("bundle"), Mapping): + payload = payload["bundle"] + if not isinstance(payload, Mapping): + return _result( + status="invalid", + errors=[_finding("invalid_schema", "$", "Bundle must be a JSON object.")], + warnings=[], + ) + if payload.get("schema") != EVIDENCE_BUNDLE_SCHEMA: + return _result( + status="unsupported", + errors=[ + _finding( + "unsupported_schema", + "schema", + f"Unsupported evidence-bundle schema: {payload.get('schema')!r}.", + ) + ], + warnings=[], + ) + if payload.get("version") != EVIDENCE_BUNDLE_VERSION: + return _result( + status="unsupported", + errors=[ + _finding( + "unsupported_version", + "version", + f"Unsupported evidence-bundle version: {payload.get('version')!r}.", + ) + ], + warnings=[], + ) + + manifest = payload.get("manifest") + records = payload.get("records") + references = payload.get("references") + if not isinstance(manifest, Mapping): + errors.append(_finding("invalid_manifest", "manifest", "Manifest must be an object.")) + manifest = {} + if not isinstance(records, list): + errors.append(_finding("invalid_records", "records", "Records must be an array.")) + records = [] + if not isinstance(references, list): + errors.append(_finding("invalid_references", "references", "References must be an array.")) + references = [] + + _verify_manifest_contract(manifest, errors) + _verify_manifest_hash(manifest, errors) + _verify_entries(manifest, records, references, errors) + _verify_counts(manifest, records, references, errors) + _verify_selection(manifest, records, references, errors) + signature_state = _verify_signatures(manifest, trusted_keys, errors, warnings) + _verify_redaction(manifest, records, errors) + _verify_external_references( + references, + external_evidence=external_evidence, + errors=errors, + warnings=warnings, + ) + + status = _status(errors, warnings) + return _result( + status=status, + errors=errors, + warnings=warnings, + signature_state=signature_state, + record_count=len(records), + reference_count=len(references), + ) + + +def _verify_manifest_hash( + manifest: Mapping[str, object], + errors: list[VerificationFinding], +) -> None: + expected = manifest.get("manifest_sha256") + if not isinstance(expected, str): + errors.append( + _finding( + "manifest_hash_missing", + "manifest.manifest_sha256", + "Manifest SHA-256 is missing.", + ) + ) + return + core = dict(manifest) + core.pop("manifest_sha256", None) + core.pop("signatures", None) + if canonical_sha256(core) != expected: + errors.append( + _finding( + "manifest_hash_mismatch", + "manifest.manifest_sha256", + "Manifest canonical hash does not match its contents.", + ) + ) + + +def _verify_manifest_contract( + manifest: Mapping[str, object], + errors: list[VerificationFinding], +) -> None: + if manifest.get("schema") != EVIDENCE_BUNDLE_SCHEMA: + errors.append( + _finding( + "invalid_manifest_schema", + "manifest.schema", + "Manifest schema does not match the supported bundle schema.", + ) + ) + if manifest.get("version") != EVIDENCE_BUNDLE_VERSION: + errors.append( + _finding( + "invalid_manifest_version", + "manifest.version", + "Manifest version does not match the supported bundle version.", + ) + ) + for key in ("bundle_id", "generated_at"): + if not isinstance(manifest.get(key), str) or not manifest.get(key): + errors.append( + _finding( + "invalid_manifest_field", + f"manifest.{key}", + f"Manifest {key} is required.", + ) + ) + + +def _verify_entries( + manifest: Mapping[str, object], + records: list[object], + references: list[object], + errors: list[VerificationFinding], +) -> None: + raw_entries = manifest.get("entries") + if not isinstance(raw_entries, list): + errors.append( + _finding("manifest_entries_missing", "manifest.entries", "Manifest entries are missing.") + ) + return + entries: dict[str, Mapping[str, object]] = {} + for index, item in enumerate(raw_entries): + path = f"manifest.entries[{index}]" + if not isinstance(item, Mapping) or not isinstance(item.get("path"), str): + errors.append(_finding("invalid_manifest_entry", path, "Manifest entry is invalid.")) + continue + entry_path = str(item["path"]) + if entry_path in entries: + errors.append( + _finding("duplicate_manifest_entry", path, f"Duplicate manifest path: {entry_path}.") + ) + continue + entries[entry_path] = item + + expected_items = { + **{f"records/{index}": item for index, item in enumerate(records)}, + **{f"references/{index}": item for index, item in enumerate(references)}, + } + for path, item in sorted(expected_items.items()): + entry = entries.get(path) + if entry is None: + errors.append( + _finding("manifest_entry_missing", path, "Included item has no manifest entry.") + ) + continue + digest = entry.get("sha256") + if not isinstance(digest, str) or canonical_sha256(item) != digest: + errors.append( + _finding("item_hash_mismatch", path, "Included item hash does not match the manifest.") + ) + for path in sorted(set(entries) - set(expected_items)): + errors.append( + _finding("included_item_missing", path, "Manifest entry has no included item.") + ) + + +def _verify_counts( + manifest: Mapping[str, object], + records: list[object], + references: list[object], + errors: list[VerificationFinding], +) -> None: + if manifest.get("record_count") != len(records): + errors.append( + _finding("record_count_mismatch", "manifest.record_count", "Record count is incomplete.") + ) + if manifest.get("reference_count") != len(references): + errors.append( + _finding( + "reference_count_mismatch", + "manifest.reference_count", + "Reference count is incomplete.", + ) + ) + + +def _verify_selection( + manifest: Mapping[str, object], + records: list[object], + references: list[object], + errors: list[VerificationFinding], +) -> None: + request = manifest.get("request") + if not isinstance(request, Mapping): + errors.append( + _finding("selection_incomplete", "manifest.request", "Selection declaration is missing.") + ) + return + if request.get("selection_complete") is not True: + errors.append( + _finding( + "selection_incomplete", + "manifest.request.selection_complete", + "Exporter did not declare the selected audit-record set complete.", + ) + ) + requested_ids = request.get("record_ids") + if isinstance(requested_ids, list) and requested_ids: + included_ids = { + item.get("record_id") + for item in records + if isinstance(item, Mapping) and isinstance(item.get("record_id"), str) + } + if set(requested_ids) != included_ids: + errors.append( + _finding( + "selection_incomplete", + "manifest.request.record_ids", + "Requested audit record ids do not match the included records.", + ) + ) + requested_references = request.get("reference_ids") + if isinstance(requested_references, list): + included_references = { + item.get("reference_id") + for item in references + if isinstance(item, Mapping) and isinstance(item.get("reference_id"), str) + } + if set(requested_references) != included_references: + errors.append( + _finding( + "selection_incomplete", + "manifest.request.reference_ids", + "Requested evidence references do not match the included references.", + ) + ) + + +def _verify_signatures( + manifest: Mapping[str, object], + trusted_keys: Mapping[str, str], + errors: list[VerificationFinding], + warnings: list[VerificationFinding], +) -> str: + signatures = manifest.get("signatures") + if not isinstance(signatures, list) or not signatures: + warnings.append( + _finding( + "manifest_unsigned", + "manifest.signatures", + "Manifest is unsigned; canonical hashes were still verified.", + ) + ) + return "unsigned" + signed_manifest = dict(manifest) + signed_manifest.pop("signatures", None) + trusted = False + for index, signature in enumerate(signatures): + path = f"manifest.signatures[{index}]" + if not isinstance(signature, Mapping): + errors.append(_finding("invalid_signature", path, "Signature must be an object.")) + continue + algorithm = signature.get("algorithm") + key_id = signature.get("key_id") + value = signature.get("value") + if algorithm != "ed25519": + errors.append( + _finding( + "unsupported_signature_algorithm", + path, + f"Unsupported signature algorithm: {algorithm!r}.", + ) + ) + continue + if not isinstance(key_id, str) or not isinstance(value, str): + errors.append(_finding("invalid_signature", path, "Signature key and value are required.")) + continue + trusted_value = trusted_keys.get(key_id) + if trusted_value is None: + warnings.append( + _finding( + "signature_key_untrusted", + path, + f"No trusted public key was supplied for {key_id!r}.", + ) + ) + continue + try: + public_key = Ed25519PublicKey.from_public_bytes( + base64.b64decode(trusted_value, validate=True) + ) + public_key.verify( + base64.b64decode(value, validate=True), + canonical_json_bytes(signed_manifest), + ) + trusted = True + except (ValueError, InvalidSignature) as exc: + errors.append( + _finding( + "signature_verification_failed", + path, + f"Trusted signature verification failed: {type(exc).__name__}.", + ) + ) + if trusted: + return "trusted" + return "unverifiable" + + +def _verify_redaction( + manifest: Mapping[str, object], + records: list[object], + errors: list[VerificationFinding], +) -> None: + redaction = manifest.get("redaction") + if not isinstance(redaction, Mapping) or redaction.get("raw_evidence_embedded") is not False: + errors.append( + _finding( + "redaction_declaration_missing", + "manifest.redaction", + "Manifest must declare that raw evidence is not embedded.", + ) + ) + for index, record in enumerate(records): + if not isinstance(record, Mapping): + errors.append( + _finding("invalid_record", f"records/{index}", "Audit record must be an object.") + ) + continue + declaration = record.get("redaction") + if not isinstance(declaration, Mapping) or not isinstance( + declaration.get("redacted_paths"), list + ): + errors.append( + _finding( + "record_redaction_missing", + f"records/{index}.redaction", + "Record redaction declaration is missing.", + ) + ) + _find_unredacted_prohibited_fields(record.get("details"), f"records/{index}.details", errors) + + +def _find_unredacted_prohibited_fields( + value: object, + path: str, + errors: list[VerificationFinding], +) -> None: + prohibited = { + "authorization", + "body", + "content_bytes", + "credential", + "credentials", + "file_content", + "file_contents", + "message", + "message_body", + "password", + "payload", + "raw_message", + "recipient_list", + "recipients", + "secret", + "token", + } + if isinstance(value, Mapping): + for raw_key, nested in value.items(): + key = str(raw_key) + nested_path = f"{path}.{key}" + normalized = key.lower().replace("-", "_") + if ( + normalized in prohibited + or normalized.endswith("_token") + or any( + part in normalized + for part in ("password", "secret", "credential", "authorization_token") + ) + ): + if not ( + isinstance(nested, Mapping) + and nested.get("redacted") is True + and set(nested).issubset({"redacted", "item_count"}) + ): + errors.append( + _finding( + "redaction_violation", + nested_path, + "Prohibited evidence content is not redacted.", + ) + ) + else: + _find_unredacted_prohibited_fields(nested, nested_path, errors) + elif isinstance(value, list): + for index, nested in enumerate(value): + _find_unredacted_prohibited_fields(nested, f"{path}[{index}]", errors) + + +def _verify_external_references( + references: list[object], + *, + external_evidence: Mapping[str, bytes], + errors: list[VerificationFinding], + warnings: list[VerificationFinding], +) -> None: + seen: set[str] = set() + for index, reference in enumerate(references): + path = f"references/{index}" + if not isinstance(reference, Mapping): + errors.append(_finding("invalid_reference", path, "Reference must be an object.")) + continue + reference_id = reference.get("reference_id") + if not isinstance(reference_id, str) or not reference_id: + errors.append(_finding("invalid_reference", path, "Reference id is required.")) + continue + if reference_id in seen: + errors.append( + _finding("duplicate_reference", path, f"Reference id {reference_id!r} is duplicated.") + ) + seen.add(reference_id) + expected = reference.get("content_sha256") + evidence = external_evidence.get(reference_id) + if not isinstance(expected, str) or len(expected) != 64: + warnings.append( + _finding( + "reference_unverifiable", + path, + f"External evidence {reference_id!r} has no canonical content hash.", + ) + ) + continue + if evidence is None: + finding = _finding( + "external_evidence_missing", + path, + f"External evidence {reference_id!r} was not supplied to the verifier.", + ) + if reference.get("required") is False: + warnings.append(finding) + else: + errors.append(finding) + continue + if hashlib.sha256(evidence).hexdigest() != expected: + errors.append( + _finding( + "external_evidence_hash_mismatch", + path, + f"External evidence {reference_id!r} does not match its declared hash.", + ) + ) + + +def _status( + errors: list[VerificationFinding], + warnings: list[VerificationFinding], +) -> str: + codes = {item.code for item in errors} + if any("hash_mismatch" in code or "signature_verification_failed" == code for code in codes): + return "tampered" + if any(code in {"manifest_entry_missing", "included_item_missing", "record_count_mismatch", "reference_count_mismatch", "selection_incomplete", "external_evidence_missing"} for code in codes): + return "incomplete" + if errors: + return "invalid" + if any( + item.code in {"reference_unverifiable", "signature_key_untrusted"} + for item in warnings + ): + return "unverifiable" + return "valid" + + +def _result( + *, + status: str, + errors: list[VerificationFinding], + warnings: list[VerificationFinding], + signature_state: str = "not_checked", + record_count: int = 0, + reference_count: int = 0, +) -> dict[str, object]: + return { + "status": status, + "schema_supported": status != "unsupported", + "integrity_valid": status not in {"invalid", "tampered", "unsupported"}, + "complete": status in {"valid", "unverifiable"}, + "signature_state": signature_state, + "record_count": record_count, + "reference_count": reference_count, + "errors": [item.as_dict() for item in sorted(set(errors))], + "warnings": [item.as_dict() for item in sorted(set(warnings))], + } + + +def _finding(code: str, path: str, message: str) -> VerificationFinding: + return VerificationFinding(code=code, path=path, message=message) + + +__all__ = ["VerificationFinding", "verify_evidence_bundle"] diff --git a/src/govoplan_audit/backend/manifest.py b/src/govoplan_audit/backend/manifest.py index e4b06f4..16ee469 100644 --- a/src/govoplan_audit/backend/manifest.py +++ b/src/govoplan_audit/backend/manifest.py @@ -10,11 +10,12 @@ from govoplan_core.core.access import ( CAPABILITY_AUTH_PRINCIPAL_RESOLVER, ) from govoplan_core.core.module_guards import drop_table_retirement_provider, persistent_table_uninstall_guard -from govoplan_core.core.modules import DocumentationTopic, FrontendModule, MigrationSpec, ModuleContext, ModuleManifest +from govoplan_core.core.modules import DocumentationCondition, DocumentationTopic, FrontendModule, MigrationSpec, ModuleContext, ModuleManifest from govoplan_core.core.provider_governance import declared_module_architecture from govoplan_core.core.events import CAPABILITY_PLATFORM_EVENT_OUTBOX from govoplan_core.core.views import ViewSurface from govoplan_core.db.base import Base +from govoplan_audit.backend.permissions import AUDIT_PERMISSIONS, AUDIT_ROLE_TEMPLATES def _route_factory(context: ModuleContext): @@ -54,6 +55,8 @@ manifest = ModuleManifest( id="audit", name="Audit", version="0.1.18", + permissions=AUDIT_PERMISSIONS, + role_templates=AUDIT_ROLE_TEMPLATES, required_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR), route_factory=_route_factory, documentation=( @@ -91,6 +94,32 @@ manifest = ModuleManifest( ], }, ), + DocumentationTopic( + id="audit.evidence-bundles", + title="Export and independently verify audit evidence", + summary="Authorized auditors can export bounded, redacted evidence bundles with canonical hashes and optional trusted signatures.", + body="Tenant exports require audit:evidence:export and remain tenant-scoped; system or all-scope exports require audit:system_evidence:export. Each bundle contains versioned audit-record DTOs, trace and policy/source provenance, external module evidence references, redaction declarations, and canonical hashes, but never raw messages, recipient lists, secrets, credentials, or file contents. Generation and download are audited. Use govoplan-audit-verify with optional trusted Ed25519 public keys and external evidence files to distinguish valid, incomplete, unverifiable, unsupported, and tampered evidence without database access. Modules contribute external references through serialized Core EvidenceReference-compatible facts or the export request; they do not import Audit internals.", + documentation_types=("user", "admin"), + audience=("auditor", "security_officer", "operator"), + conditions=( + DocumentationCondition(required_scopes=("audit:evidence:export",)), + DocumentationCondition(required_scopes=("audit:system_evidence:export",)), + ), + related_modules=("policy", "files"), + metadata={ + "kind": "workflow", + "help_contexts": [ + "audit.evidence.export", + "audit.evidence.verify", + "audit.evidence.signing", + ], + "verification": [ + "Confirm the requested record scope is complete and bounded before export.", + "Verify canonical hashes offline and supply trusted keys or referenced evidence when required.", + "Treat missing external evidence, unverifiable references, and hash mismatches as distinct outcomes.", + ], + }, + ), ), frontend=FrontendModule( module_id="audit", @@ -108,6 +137,7 @@ manifest = ModuleManifest( ), retirement_supported=True, retirement_provider=drop_table_retirement_provider( + audit_models.AuditEvidenceBundle, audit_models.AuditLog, audit_models.AuditOutboxDelivery, audit_models.AuditOutboxEvent, @@ -117,6 +147,7 @@ manifest = ModuleManifest( ), uninstall_guard_providers=( persistent_table_uninstall_guard( + audit_models.AuditEvidenceBundle, audit_models.AuditLog, audit_models.AuditOutboxDelivery, audit_models.AuditOutboxEvent, @@ -134,12 +165,12 @@ manifest = ModuleManifest( maturity="vertical_slice", documentation_ref="docs/AUDIT_TRACE_CONTEXT.md", test_ref="tests/test_audit_module_contract.py", - known_limits=("Cross-deployment archival and evidentiary export profiles are not yet reference-ready.",), - owned_concepts=("audit record", "audit retention", "transactional event outbox"), + known_limits=("Cross-deployment long-term archive transfer remains deployment-specific.",), + owned_concepts=("audit record", "audit retention", "audit evidence bundle", "transactional event outbox"), non_owned_concepts=("domain record", "policy decision", "external effect"), recovery_docs=("README.md",), - security_docs=("docs/AUDIT_TRACE_CONTEXT.md",), - operations_docs=("README.md",), + security_docs=("docs/AUDIT_TRACE_CONTEXT.md", "docs/EVIDENCE_BUNDLES.md"), + operations_docs=("README.md", "docs/EVIDENCE_BUNDLES.md"), ), ) diff --git a/src/govoplan_audit/backend/migrations/dev_versions/b9e2f5a8c3d6_evidence_bundles.py b/src/govoplan_audit/backend/migrations/dev_versions/b9e2f5a8c3d6_evidence_bundles.py new file mode 100644 index 0000000..2873ebe --- /dev/null +++ b/src/govoplan_audit/backend/migrations/dev_versions/b9e2f5a8c3d6_evidence_bundles.py @@ -0,0 +1,66 @@ +"""audit evidence bundle lifecycle + +Revision ID: b9e2f5a8c3d6 +Revises: a8d1e4f7b2c5 +Create Date: 2026-08-20 00:00:00.000000 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "b9e2f5a8c3d6" +down_revision = "a8d1e4f7b2c5" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + inspector = sa.inspect(op.get_bind()) + if "audit_evidence_bundles" in inspector.get_table_names(): + return + op.create_table( + "audit_evidence_bundles", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("scope", sa.String(length=20), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=True), + sa.Column("requested_by", sa.String(length=128), nullable=False), + sa.Column("status", sa.String(length=20), nullable=False), + sa.Column("request_payload", sa.JSON(), nullable=False), + sa.Column("bundle_payload", sa.JSON(), nullable=True), + sa.Column("bundle_sha256", sa.String(length=64), nullable=True), + sa.Column("record_count", sa.Integer(), nullable=False), + sa.Column("reference_count", sa.Integer(), nullable=False), + sa.Column("generated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("downloaded_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("error_code", sa.String(length=100), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("pk_audit_evidence_bundles")), + ) + op.create_index( + "ix_audit_evidence_bundles_tenant_id", + "audit_evidence_bundles", + ["tenant_id"], + ) + op.create_index( + "ix_audit_evidence_bundles_status", + "audit_evidence_bundles", + ["status"], + ) + op.create_index( + "ix_audit_evidence_bundle_tenant_created_at", + "audit_evidence_bundles", + ["tenant_id", "created_at"], + ) + op.create_index( + "ix_audit_evidence_bundle_status_created_at", + "audit_evidence_bundles", + ["status", "created_at"], + ) + + +def downgrade() -> None: + if "audit_evidence_bundles" in sa.inspect(op.get_bind()).get_table_names(): + op.drop_table("audit_evidence_bundles") diff --git a/src/govoplan_audit/backend/migrations/versions/b9e2f5a8c3d6_v0119_evidence_bundles.py b/src/govoplan_audit/backend/migrations/versions/b9e2f5a8c3d6_v0119_evidence_bundles.py new file mode 100644 index 0000000..2873ebe --- /dev/null +++ b/src/govoplan_audit/backend/migrations/versions/b9e2f5a8c3d6_v0119_evidence_bundles.py @@ -0,0 +1,66 @@ +"""audit evidence bundle lifecycle + +Revision ID: b9e2f5a8c3d6 +Revises: a8d1e4f7b2c5 +Create Date: 2026-08-20 00:00:00.000000 +""" +from __future__ import annotations + +from alembic import op +import sqlalchemy as sa + + +revision = "b9e2f5a8c3d6" +down_revision = "a8d1e4f7b2c5" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + inspector = sa.inspect(op.get_bind()) + if "audit_evidence_bundles" in inspector.get_table_names(): + return + op.create_table( + "audit_evidence_bundles", + sa.Column("id", sa.String(length=36), nullable=False), + sa.Column("scope", sa.String(length=20), nullable=False), + sa.Column("tenant_id", sa.String(length=36), nullable=True), + sa.Column("requested_by", sa.String(length=128), nullable=False), + sa.Column("status", sa.String(length=20), nullable=False), + sa.Column("request_payload", sa.JSON(), nullable=False), + sa.Column("bundle_payload", sa.JSON(), nullable=True), + sa.Column("bundle_sha256", sa.String(length=64), nullable=True), + sa.Column("record_count", sa.Integer(), nullable=False), + sa.Column("reference_count", sa.Integer(), nullable=False), + sa.Column("generated_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("downloaded_at", sa.DateTime(timezone=True), nullable=True), + sa.Column("error_code", sa.String(length=100), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False), + sa.PrimaryKeyConstraint("id", name=op.f("pk_audit_evidence_bundles")), + ) + op.create_index( + "ix_audit_evidence_bundles_tenant_id", + "audit_evidence_bundles", + ["tenant_id"], + ) + op.create_index( + "ix_audit_evidence_bundles_status", + "audit_evidence_bundles", + ["status"], + ) + op.create_index( + "ix_audit_evidence_bundle_tenant_created_at", + "audit_evidence_bundles", + ["tenant_id", "created_at"], + ) + op.create_index( + "ix_audit_evidence_bundle_status_created_at", + "audit_evidence_bundles", + ["status", "created_at"], + ) + + +def downgrade() -> None: + if "audit_evidence_bundles" in sa.inspect(op.get_bind()).get_table_names(): + op.drop_table("audit_evidence_bundles") diff --git a/src/govoplan_audit/backend/permissions.py b/src/govoplan_audit/backend/permissions.py new file mode 100644 index 0000000..556bca7 --- /dev/null +++ b/src/govoplan_audit/backend/permissions.py @@ -0,0 +1,55 @@ +from __future__ import annotations + +from govoplan_core.core.modules import PermissionDefinition, RoleTemplate + + +AUDIT_EVIDENCE_EXPORT_SCOPE = "audit:evidence:export" +AUDIT_SYSTEM_EVIDENCE_EXPORT_SCOPE = "audit:system_evidence:export" + +AUDIT_PERMISSIONS = ( + PermissionDefinition( + scope=AUDIT_EVIDENCE_EXPORT_SCOPE, + module_id="audit", + resource="evidence", + action="export", + label="Export tenant audit evidence", + description="Generate and download bounded evidence bundles for the active tenant.", + category="Audit", + level="tenant", + ), + PermissionDefinition( + scope=AUDIT_SYSTEM_EVIDENCE_EXPORT_SCOPE, + module_id="audit", + resource="system_evidence", + action="export", + label="Export system audit evidence", + description="Generate and download system-wide or cross-tenant audit evidence bundles.", + category="Audit", + level="system", + ), +) + +AUDIT_ROLE_TEMPLATES = ( + RoleTemplate( + slug="audit_evidence_exporter", + name="Audit evidence exporter", + description="Export independently verifiable evidence bundles for the active tenant; audit read access remains separately assignable.", + permissions=(AUDIT_EVIDENCE_EXPORT_SCOPE,), + level="tenant", + ), + RoleTemplate( + slug="audit_system_evidence_exporter", + name="System audit evidence exporter", + description="Export cross-tenant evidence bundles; system audit read access remains separately assignable.", + permissions=(AUDIT_SYSTEM_EVIDENCE_EXPORT_SCOPE,), + level="system", + ), +) + + +__all__ = [ + "AUDIT_EVIDENCE_EXPORT_SCOPE", + "AUDIT_PERMISSIONS", + "AUDIT_ROLE_TEMPLATES", + "AUDIT_SYSTEM_EVIDENCE_EXPORT_SCOPE", +] diff --git a/src/govoplan_audit/backend/verify_evidence_bundle.py b/src/govoplan_audit/backend/verify_evidence_bundle.py new file mode 100644 index 0000000..c420337 --- /dev/null +++ b/src/govoplan_audit/backend/verify_evidence_bundle.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import argparse +import json +from pathlib import Path +from typing import Sequence + +from govoplan_audit.backend.evidence_verifier import verify_evidence_bundle + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser( + description="Verify a GovOPlaN Audit evidence bundle without a source database.", + ) + parser.add_argument("bundle", type=Path, help="Downloaded evidence-bundle JSON file") + parser.add_argument( + "--trusted-key", + action="append", + default=[], + metavar="KEY_ID=BASE64_PUBLIC_KEY", + help="Trusted Ed25519 public key; may be repeated.", + ) + parser.add_argument( + "--external", + action="append", + default=[], + metavar="REFERENCE_ID=PATH", + help="External evidence file corresponding to a manifest reference; may be repeated.", + ) + parser.add_argument("--pretty", action="store_true", help="Indent verification JSON output") + args = parser.parse_args(argv) + + try: + payload = json.loads(args.bundle.read_text(encoding="utf-8")) + trusted_keys = _key_values(args.trusted_key, label="trusted key") + external_paths = _key_values(args.external, label="external evidence") + external = {reference_id: Path(path).read_bytes() for reference_id, path in external_paths.items()} + except (OSError, ValueError, json.JSONDecodeError) as exc: + parser.error(str(exc)) + result = verify_evidence_bundle( + payload, + trusted_keys=trusted_keys, + external_evidence=external, + ) + print( + json.dumps( + result, + ensure_ascii=False, + indent=2 if args.pretty else None, + separators=None if args.pretty else (",", ":"), + sort_keys=True, + ) + ) + return 0 if result["status"] == "valid" else 1 + + +def _key_values(values: list[str], *, label: str) -> dict[str, str]: + result: dict[str, str] = {} + for value in values: + key, separator, item = value.partition("=") + if not separator or not key.strip() or not item.strip(): + raise ValueError(f"Invalid {label}; expected KEY=VALUE.") + if key in result: + raise ValueError(f"Duplicate {label} id: {key}.") + result[key] = item + return result + + +if __name__ == "__main__": # pragma: no cover - console entry point + raise SystemExit(main()) diff --git a/tests/fixtures/evidence_bundle_v1.json b/tests/fixtures/evidence_bundle_v1.json new file mode 100644 index 0000000..b646a9d --- /dev/null +++ b/tests/fixtures/evidence_bundle_v1.json @@ -0,0 +1,83 @@ +{ + "manifest": { + "bundle_id": "fixture-bundle-v1", + "entries": [ + { + "kind": "audit_record", + "path": "records/0", + "sha256": "4d1acd7f7bb9a4a74c88c43485a0fcfde7c25ae086745ae18fa94107ad5bd3d9" + } + ], + "generated_at": "2026-08-20T10:00:00Z", + "manifest_sha256": "6c6eac07c422e14936a5abddfe999e1d7346371284b5faa111ddaea0399e538b", + "record_count": 1, + "redaction": { + "profile": "bounded-v1", + "prohibited_fields": [ + "authorization", + "body", + "content_bytes", + "credential", + "credentials", + "file_content", + "file_contents", + "message", + "message_body", + "password", + "payload", + "raw_message", + "recipient_list", + "recipients", + "secret", + "token" + ], + "raw_evidence_embedded": false + }, + "reference_count": 0, + "request": { + "record_ids": [ + "fixture-audit-1" + ], + "selection_complete": true + }, + "schema": "govoplan.audit.evidence-bundle", + "scope": { + "kind": "tenant", + "tenant_id": "tenant-fixture" + }, + "signatures": [], + "version": "1.0" + }, + "records": [ + { + "action": "fixture.recorded", + "actor": { + "api_key_id": null, + "user_id": null + }, + "details": { + "correlation_id": "fixture-trace", + "result": "accepted" + }, + "object": { + "id": "fixture-1", + "type": "fixture" + }, + "policy_source_provenance": {}, + "record_id": "fixture-audit-1", + "recorded_at": "2026-08-20T10:00:00Z", + "redaction": { + "profile": "bounded-v1", + "redacted_paths": [] + }, + "scope": "tenant", + "tenant_id": "tenant-fixture", + "trace_context": { + "correlation_id": "fixture-trace" + } + } + ], + "references": [], + "schema": "govoplan.audit.evidence-bundle", + "version": "1.0" +} diff --git a/tests/test_audit_module_contract.py b/tests/test_audit_module_contract.py index bce3643..637527b 100644 --- a/tests/test_audit_module_contract.py +++ b/tests/test_audit_module_contract.py @@ -15,7 +15,7 @@ class AuditModuleContractTests(unittest.TestCase): project = tomllib.loads((ROOT / "pyproject.toml").read_text(encoding="utf-8"))["project"] dependencies = tuple(project["dependencies"]) - self.assertIn("govoplan-core>=0.1.8", dependencies) + self.assertTrue(any(item.startswith("govoplan-core>=") for item in dependencies)) self.assertFalse(any(item.startswith("govoplan-access") for item in dependencies)) def test_audit_source_does_not_import_access_implementation(self) -> None: @@ -40,6 +40,22 @@ class AuditModuleContractTests(unittest.TestCase): operations = topics["audit.recording-retention-and-outbox"] self.assertIn("audit.retention", operations.metadata["help_contexts"]) + def test_evidence_export_permissions_are_module_owned_and_scope_separated(self) -> None: + manifest = get_manifest() + permissions = {item.scope: item for item in manifest.permissions} + + self.assertEqual("tenant", permissions["audit:evidence:export"].level) + self.assertEqual("system", permissions["audit:system_evidence:export"].level) + roles = {item.slug: item for item in manifest.role_templates} + self.assertIn( + "audit:evidence:export", + roles["audit_evidence_exporter"].permissions, + ) + self.assertIn( + "audit:system_evidence:export", + roles["audit_system_evidence_exporter"].permissions, + ) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_evidence_bundles.py b/tests/test_evidence_bundles.py new file mode 100644 index 0000000..142bd45 --- /dev/null +++ b/tests/test_evidence_bundles.py @@ -0,0 +1,239 @@ +from __future__ import annotations + +import base64 +import copy +import hashlib +import json +from datetime import datetime, timezone +from pathlib import Path + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from govoplan_audit.backend.db.models import AuditEvidenceBundle, AuditLog +from govoplan_audit.backend.evidence_bundles import ( + EvidenceBundleError, + build_evidence_bundle, + canonical_sha256, + sanitize_audit_details, +) +from govoplan_audit.backend.evidence_verifier import verify_evidence_bundle +from govoplan_core.db.base import Base + + +FIXTURES = Path(__file__).with_name("fixtures") +NOW = datetime(2026, 8, 20, 10, 0, tzinfo=timezone.utc) + + +def _record() -> AuditLog: + item = AuditLog( + id="audit-1", + scope="tenant", + tenant_id="tenant-1", + user_id=None, + api_key_id=None, + action="case.decision.recorded", + object_type="case", + object_id="case-42", + details={ + "correlation_id": "trace-42", + "policy_decision_ref": "policy:42", + "source_ref": "cases:case-42:v7", + "recipient_count": 2, + "recipients": ["one@example.test", "two@example.test"], + "message_body": "restricted body", + "api_token": "secret-token", + }, + ) + item.created_at = NOW + item.updated_at = NOW + return item + + +def _bundle(*, references=(), signing_key_id=None, signing_private_key_path=None): + return build_evidence_bundle( + [_record()], + bundle_id="bundle-1", + generated_at=NOW, + scope={"kind": "tenant", "tenant_id": "tenant-1"}, + request={"record_ids": ["audit-1"], "selection_complete": True}, + references=references, + signing_key_id=signing_key_id, + signing_private_key_path=signing_private_key_path, + ) + + +def test_bundle_is_deterministic_and_redacts_prohibited_evidence() -> None: + first = _bundle() + second = _bundle() + + assert first == second + assert canonical_sha256(first) == canonical_sha256(second) + details = first["records"][0]["details"] + assert details["recipient_count"] == 2 + assert details["recipients"] == {"redacted": True, "item_count": 2} + assert details["message_body"] == {"redacted": True} + assert details["api_token"] == {"redacted": True} + encoded = json.dumps(first, sort_keys=True) + assert "one@example.test" not in encoded + assert "restricted body" not in encoded + assert "secret-token" not in encoded + assert verify_evidence_bundle(first)["status"] == "valid" + + +def test_sanitizer_bounds_non_json_values_and_oversized_details() -> None: + sanitized, paths = sanitize_audit_details({"custom": object(), "body": "secret"}) + + assert sanitized["custom"]["redacted"] is True + assert sanitized["body"] == {"redacted": True} + assert paths == ["details.body", "details.custom"] + + +def test_reference_locators_cannot_embed_credentials_or_tokens() -> None: + reference = { + "reference_id": "decision-42", + "kind": "module_evidence", + "owner_module": "decisions", + "locator": "https://user:password@example.test/evidence", + } + + try: + _bundle(references=[reference]) + except EvidenceBundleError as exc: + assert "appears to contain a secret" in str(exc) + else: # pragma: no cover - explicit fail-closed assertion + raise AssertionError("Secret-bearing evidence locators must be rejected") + + +def test_verifier_distinguishes_missing_unverifiable_and_tampered_evidence() -> None: + artifact = b"canonical external evidence" + reference = { + "reference_id": "decision-42", + "kind": "module_evidence", + "owner_module": "decisions", + "locator": "decisions:decision-42:v3", + "content_sha256": hashlib.sha256(artifact).hexdigest(), + "required": True, + } + bundle = _bundle(references=[reference]) + + missing = verify_evidence_bundle(bundle) + assert missing["status"] == "incomplete" + assert missing["errors"][0]["code"] == "external_evidence_missing" + + valid = verify_evidence_bundle( + bundle, + external_evidence={"decision-42": artifact}, + ) + assert valid["status"] == "valid" + + mismatched = verify_evidence_bundle( + bundle, + external_evidence={"decision-42": b"changed"}, + ) + assert mismatched["status"] == "tampered" + assert mismatched["errors"][0]["code"] == "external_evidence_hash_mismatch" + + unverifiable_reference = dict(reference) + unverifiable_reference["content_sha256"] = None + unverifiable = verify_evidence_bundle(_bundle(references=[unverifiable_reference])) + assert unverifiable["status"] == "unverifiable" + assert unverifiable["warnings"][1]["code"] == "reference_unverifiable" + + +def test_verifier_detects_record_and_manifest_tampering() -> None: + bundle = _bundle() + changed = copy.deepcopy(bundle) + changed["records"][0]["action"] = "case.decision.deleted" + + result = verify_evidence_bundle(changed) + + assert result["status"] == "tampered" + assert {item["code"] for item in result["errors"]} == {"item_hash_mismatch"} + + +def test_trusted_ed25519_signature_verifies_offline(tmp_path: Path) -> None: + private_key = Ed25519PrivateKey.generate() + private_path = tmp_path / "audit-evidence.pem" + private_path.write_bytes( + private_key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ) + ) + public_key = base64.b64encode( + private_key.public_key().public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + ).decode("ascii") + bundle = _bundle( + signing_key_id="institution-2026", + signing_private_key_path=private_path, + ) + + trusted = verify_evidence_bundle( + bundle, + trusted_keys={"institution-2026": public_key}, + ) + untrusted = verify_evidence_bundle(bundle) + + assert trusted["status"] == "valid" + assert trusted["signature_state"] == "trusted" + assert untrusted["status"] == "unverifiable" + assert untrusted["signature_state"] == "unverifiable" + + +def test_unsupported_version_is_reported_without_guessing() -> None: + bundle = _bundle() + bundle["version"] = "2.0" + + result = verify_evidence_bundle(bundle) + + assert result["status"] == "unsupported" + assert result["errors"][0]["code"] == "unsupported_version" + + +def test_supported_v1_compatibility_fixture_verifies_deterministically() -> None: + payload = json.loads((FIXTURES / "evidence_bundle_v1.json").read_text(encoding="utf-8")) + + first = verify_evidence_bundle(payload) + second = verify_evidence_bundle(payload) + + assert first == second + assert first["status"] == "valid" + + +def test_evidence_bundle_lifecycle_is_persisted() -> None: + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(bind=engine, tables=[AuditEvidenceBundle.__table__]) + Session = sessionmaker(bind=engine) + with Session() as session: + row = AuditEvidenceBundle( + id="bundle-1", + scope="tenant", + tenant_id="tenant-1", + requested_by="operator-1", + status="pending", + request_payload={"record_ids": ["audit-1"]}, + ) + session.add(row) + session.flush() + assert row.status == "pending" + + row.status = "ready" + row.bundle_payload = _bundle() + row.bundle_sha256 = canonical_sha256(row.bundle_payload) + row.record_count = 1 + row.generated_at = NOW + session.commit() + + restored = session.get(AuditEvidenceBundle, "bundle-1") + assert restored is not None + assert restored.status == "ready" + assert restored.record_count == 1 + assert restored.bundle_payload["version"] == "1.0" + engine.dispose() diff --git a/webui/src/api/audit.ts b/webui/src/api/audit.ts index 2e358d4..bfedee2 100644 --- a/webui/src/api/audit.ts +++ b/webui/src/api/audit.ts @@ -45,6 +45,30 @@ export type AuditAdminDeltaResponse = AuditAdminListResponse & { full: boolean; }; +export type EvidenceBundleExportRequest = { + scope: "tenant" | "system" | "all"; + tenant_id?: string | null; + record_ids?: string[]; + max_records?: number; + sign?: boolean; +}; + +export type EvidenceBundleResponse = { + id: string; + scope: "tenant" | "system" | "all"; + tenant_id?: string | null; + status: "pending" | "ready" | "failed"; + bundle_sha256?: string | null; + record_count: number; + reference_count: number; + generated_at?: string | null; + download_url?: string | null; +}; + +export type EvidenceBundleDownloadResponse = { + bundle: Record; +}; + function auditQuery(options: AuditQueryOptions & { since?: string | null } = {}): string { const params = new URLSearchParams(); if (options.tenantId) params.set("tenant_id", options.tenantId); @@ -72,3 +96,20 @@ export function fetchAdminAudit(settings: ApiSettings, options: AuditQueryOption export function fetchAdminAuditDelta(settings: ApiSettings, options: AuditQueryOptions & { since?: string | null } = {}): Promise { return apiFetch(settings, `/api/v1/admin/audit/delta${auditQuery(options)}`); } + +export function exportAuditEvidenceBundle( + settings: ApiSettings, + payload: EvidenceBundleExportRequest +): Promise { + return apiFetch(settings, "/api/v1/admin/audit/evidence-bundles", { + method: "POST", + body: JSON.stringify(payload) + }); +} + +export function downloadAuditEvidenceBundle( + settings: ApiSettings, + bundleId: string +): Promise { + return apiFetch(settings, `/api/v1/admin/audit/evidence-bundles/${bundleId}/download`); +} diff --git a/webui/src/features/audit/AdminAuditPanel.tsx b/webui/src/features/audit/AdminAuditPanel.tsx index de86310..84b9c41 100644 --- a/webui/src/features/audit/AdminAuditPanel.tsx +++ b/webui/src/features/audit/AdminAuditPanel.tsx @@ -9,6 +9,7 @@ import { Dialog, DocumentationHelpLink, formatAdminDateTime as formatDateTime, + hasScope, i18nMessage, mergeDeltaRows, TableActionGroup, @@ -18,7 +19,14 @@ import { type DataGridColumn, type DataGridQueryState } from "@govoplan/core-webui"; -import { fetchAdminAudit, fetchAdminAuditDelta, type AuditAdminItem, type AuditSortBy } from "../../api/audit"; +import { + downloadAuditEvidenceBundle, + exportAuditEvidenceBundle, + fetchAdminAudit, + fetchAdminAuditDelta, + type AuditAdminItem, + type AuditSortBy +} from "../../api/audit"; type Props = { settings: ApiSettings; @@ -38,6 +46,8 @@ const I18N = { close: "i18n:govoplan-audit.close.f1a20804", details: "i18n:govoplan-audit.details.f1a20805", eventDetails: "i18n:govoplan-audit.audit_event_details.f1a20806", + exportEvidence: "i18n:govoplan-audit.export_page_evidence.f1a20822", + exportingEvidence: "i18n:govoplan-audit.audit_evidence_export_is_in_progress.f1a20823", inspect: "i18n:govoplan-audit.inspect_audit_event.f1a20807", loading: "i18n:govoplan-audit.audit_evidence_is_loading.f1a20808", noDetails: "i18n:govoplan-audit.no_additional_details_were_recorded.f1a20809", @@ -74,7 +84,12 @@ export default function AdminAuditPanel({ settings, auth, systemMode = false }: const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const [reloadToken, setReloadToken] = useState(0); + const [exporting, setExporting] = useState(false); const tenantId = (auth.active_tenant ?? auth.tenant).id; + const canExport = hasScope( + auth, + systemMode ? "audit:system_evidence:export" : "audit:evidence:export" + ); const load = useCallback(async () => { setLoading(true); @@ -144,6 +159,29 @@ export default function AdminAuditPanel({ settings, auth, systemMode = false }: }); }, []); + const exportPageEvidence = useCallback(async () => { + setExporting(true); + setError(""); + try { + const created = await exportAuditEvidenceBundle(settings, { + scope: systemMode ? "system" : "tenant", + tenant_id: systemMode ? null : tenantId, + record_ids: items.map((item) => item.id), + max_records: Math.max(1, items.length), + sign: false + }); + const downloaded = await downloadAuditEvidenceBundle(settings, created.id); + downloadJson( + downloaded.bundle, + `govoplan-audit-evidence-${created.id}.json` + ); + } catch (err) { + setError(adminErrorMessage(err)); + } finally { + setExporting(false); + } + }, [items, settings, systemMode, tenantId]); + const columns = useMemo[]>(() => [ { id: "time", header: I18N.time, width: 190, minWidth: 150, maxWidth: 260, resizable: true, sticky: "start", sortable: true, filterable: true, filterType: "date", value: (row) => row.created_at, render: (row) => formatDateTime(row.created_at) }, { id: "actor", header: I18N.actor, width: 220, minWidth: 170, maxWidth: 360, resizable: true, sortable: true, filterable: true, value: (row) => row.actor_email || "System", render: (row) => row.actor_email || I18N.system }, @@ -175,6 +213,14 @@ export default function AdminAuditPanel({ settings, auth, systemMode = false }: error={error} actions={( <> + {canExport && ( + + )} ): AuditDetailRow[] { return Object.entries(details) .sort(([left], [right]) => left.localeCompare(right)) diff --git a/webui/src/i18n/generatedTranslations.ts b/webui/src/i18n/generatedTranslations.ts index c0825e8..c526d8b 100644 --- a/webui/src/i18n/generatedTranslations.ts +++ b/webui/src/i18n/generatedTranslations.ts @@ -22,7 +22,9 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-audit.tenant_context.f1a20818": "Tenant context", "i18n:govoplan-audit.tenant_level_administrative_history_showing_value0_value1_of_value2.f1a20819": "Tenant-level administrative history for the active tenant, showing {value0}-{value1} of {value2}.", "i18n:govoplan-audit.time.f1a20820": "Time", - "i18n:govoplan-audit.value.f1a20821": "Value" + "i18n:govoplan-audit.value.f1a20821": "Value", + "i18n:govoplan-audit.export_page_evidence.f1a20822": "Export page evidence", + "i18n:govoplan-audit.audit_evidence_export_is_in_progress.f1a20823": "Audit evidence export is in progress." }, de: { "i18n:govoplan-audit.action.f1a20801": "Aktion", @@ -45,6 +47,8 @@ export const generatedTranslations: PlatformTranslations = { "i18n:govoplan-audit.tenant_context.f1a20818": "Mandantenkontext", "i18n:govoplan-audit.tenant_level_administrative_history_showing_value0_value1_of_value2.f1a20819": "Administrative Historie des aktiven Mandanten, angezeigt werden {value0}-{value1} von {value2}.", "i18n:govoplan-audit.time.f1a20820": "Zeit", - "i18n:govoplan-audit.value.f1a20821": "Wert" + "i18n:govoplan-audit.value.f1a20821": "Wert", + "i18n:govoplan-audit.export_page_evidence.f1a20822": "Seitennachweise exportieren", + "i18n:govoplan-audit.audit_evidence_export_is_in_progress.f1a20823": "Der Export der Auditnachweise läuft." } };