feat: add verifiable audit evidence bundles

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