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