Implement AUDIT-WP-0006 honest operational custody.
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 2s

Postgres now reports custody_class=operational with a cited 30-day
recoverable window. Join ITC-CAP operations.audit at D4, publish the
interface card, and overlay user-engine tenants [*] from Git so an
ExternalSecret refresh cannot shrink it.
This commit is contained in:
tegwick 2026-08-16 00:24:33 +02:00
parent 0a3d05ff1c
commit ded432a63f
25 changed files with 832 additions and 94 deletions

View file

@ -13,6 +13,7 @@ from audit_core.interface import (
IdempotentAuditBackend,
RetentionPolicy,
SCHEMA_VERSION_V1ALPHA1,
custody_class_satisfies,
validate_event,
)
from audit_core.mock_file_backend import MockFileAuditBackend
@ -30,5 +31,6 @@ __all__ = [
"RetentionPolicy",
"SCHEMA_VERSION_V1ALPHA1",
"SQLiteAuditBackend",
"custody_class_satisfies",
"validate_event",
]

View file

@ -38,6 +38,7 @@ from audit_core.interface import (
EventConflictError,
EventValidationError,
IdempotentAuditBackend,
custody_class_satisfies,
)
from audit_core.redaction import (
POLICY_REDACT,
@ -102,10 +103,13 @@ class IngestionApplication:
require_custody_class: str | None = None,
) -> None:
policy = backend.retention_policy
if require_custody_class and policy.custody_class != require_custody_class:
if require_custody_class and not custody_class_satisfies(
policy.custody_class, require_custody_class
):
# Production sets this. Without it, losing AUDIT_CORE_DATABASE_URL
# silently downgrades custody to the development store instead of
# failing to start.
# failing to start. ``operational`` and ``archive`` alias each
# other for one mixed-rollout deploy (AUDIT-WP-0006-T01).
raise ValueError(
f"backend custody_class={policy.custody_class!r} does not meet the "
f"required {require_custody_class!r}; refusing to start"
@ -330,11 +334,10 @@ class IngestionApplication:
return self._json(
start_response, HTTPStatus.SERVICE_UNAVAILABLE, {"status": "unavailable"}
)
policy = self.backend.retention_policy
return self._json(
start_response,
HTTPStatus.OK,
{"status": "ok", "custody_class": policy.custody_class, "durable": policy.durable},
self.backend.retention_policy.as_readiness(),
)
@staticmethod
@ -490,11 +493,15 @@ def build_backend() -> IdempotentAuditBackend:
else "AUDIT_CORE_DATABASE_URL" if url
else "brokered libpq environment")
log.info("custody backend: postgresql (%s)", source)
recoverable = os.environ.get("AUDIT_CORE_RECOVERABLE_DAYS")
return PostgresAuditBackend(
url or "",
credential_dir=credential_dir,
schema=os.environ.get("AUDIT_CORE_DATABASE_SCHEMA", "audit_core"),
retention_days=int(retention) if retention else None,
recoverable_days=(
int(recoverable) if recoverable else 30
),
max_size=int(os.environ.get("AUDIT_CORE_DB_POOL_MAX", "8")),
statement_timeout_ms=int(
os.environ.get("AUDIT_CORE_DB_STATEMENT_TIMEOUT_MS", "30000")

View file

@ -13,7 +13,23 @@ from uuid import uuid4
SCHEMA_VERSION_V1ALPHA1 = "audit-core.event.v1alpha1"
CustodyClass = Literal["development", "archive", "hot_search"]
CustodyClass = Literal["development", "operational", "archive", "hot_search"]
# Production Postgres reports ``operational``. Manifests written before
# AUDIT-WP-0006 required ``archive``. The two are aliases for one deploy so
# a mixed rollout cannot refuse to start. ``development`` is never an alias.
_PRODUCTION_CUSTODY_CLASSES = frozenset({"operational", "archive"})
def custody_class_satisfies(actual: str, required: str) -> bool:
"""Whether a backend's class meets a startup requirement.
``operational`` and ``archive`` satisfy each other. A development
backend satisfies only ``development``.
"""
if actual == required:
return True
return {actual, required} <= _PRODUCTION_CUSTODY_CLASSES
_REQUIRED_STRING_FIELDS = (
"schema_version",
@ -41,6 +57,24 @@ class RetentionPolicy:
immutable: bool
tamper_evidence: bool
durable: bool
# Recoverable history is the platform backup window, not a deletion
# policy. ``None`` means not declared (development backends).
recoverable_days: int | None = None
recoverable_source: str | None = None
recoverable_basis: str | None = None
def as_readiness(self) -> dict[str, Any]:
"""Sender-visible /readyz body. Keeps ``custody_class`` and adds recovery."""
payload: dict[str, Any] = {
"status": "ok",
"custody_class": self.custody_class,
"durable": self.durable,
}
if self.recoverable_days is not None or self.recoverable_source:
payload["recoverable_days"] = self.recoverable_days
payload["recoverable_source"] = self.recoverable_source
payload["recoverable_basis"] = self.recoverable_basis
return payload
@dataclass(frozen=True)
@ -142,7 +176,7 @@ def validate_event(event: AuditEvent) -> None:
class AuditBackend(Protocol):
"""Protocol implemented by replaceable audit sinks.
Production backends provide durable archive or hot-search custody.
Production backends provide durable operational or archive custody.
Development backends (such as :class:`~audit_core.mock_file_backend.MockFileAuditBackend`)
are for wiring only and must not be treated as audit custody.
"""

View file

@ -151,6 +151,12 @@ class PostgresAuditBackend:
*,
schema: str = DEFAULT_SCHEMA,
retention_days: int | None = None,
recoverable_days: int | None = 30,
recoverable_source: str | None = (
"resource-control/data/capability/platform-audit-storage.json"
"#provisions[capability=data.backup]"
),
recoverable_basis: str | None = "measured",
min_size: int = 1,
max_size: int = 8,
statement_timeout_ms: int = 30_000,
@ -178,6 +184,9 @@ class PostgresAuditBackend:
raise ValueError(f"unsafe schema name: {schema!r}")
self.schema = schema
self.retention_days = retention_days
self.recoverable_days = recoverable_days
self.recoverable_source = recoverable_source
self.recoverable_basis = recoverable_basis
base_kwargs = {
"autocommit": True,
# A stalled write must surface as unavailable rather than hold a
@ -266,13 +275,22 @@ class PostgresAuditBackend:
who can drop the trigger; ``tamper_evidence`` is correspondingly False,
because nothing here would *prove* they had. Hash-chaining or external
anchoring would be needed for that, and is not implemented.
``custody_class`` is ``operational``, not ``archive``. This store is
durable append-only Postgres recovered through the platform
``data.backup`` provision. It is not ITC-CAP ``data.archive`` (WORM
object storage, manifests, retrieval tests). Recoverable history is
the cited platform window, not ``retention_days``.
"""
return RetentionPolicy(
custody_class="archive",
custody_class="operational",
retention_days=self.retention_days,
immutable=True,
tamper_evidence=False,
durable=True,
recoverable_days=self.recoverable_days,
recoverable_source=self.recoverable_source,
recoverable_basis=self.recoverable_basis,
)
def emit(self, event: AuditEvent) -> str:

View file

@ -17,6 +17,7 @@ import hmac
import json
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Iterable
from audit_core.redaction import POLICIES, POLICY_REDACT
@ -113,7 +114,7 @@ class SenderRegistry:
env = env if env is not None else dict(os.environ)
raw = env.get("AUDIT_CORE_SENDERS")
if raw:
return cls(_parse_identities(raw))
return cls(_apply_scope_overlay(_parse_identities(raw), env))
legacy = (env.get("AUDIT_CORE_INGEST_TOKEN") or "").strip()
if not legacy:
@ -134,6 +135,66 @@ class SenderRegistry:
])
def _load_scope_overlay(env: dict[str, str]) -> list[dict[str, Any]]:
"""Non-secret sender policy. Tokens never come from here.
``AUDIT_CORE_SENDERS_SCOPE`` is inline JSON (tests).
``AUDIT_CORE_SENDERS_SCOPE_PATH`` is a file (production ConfigMap).
"""
inline = env.get("AUDIT_CORE_SENDERS_SCOPE")
if inline:
payload = json.loads(inline)
else:
path = env.get("AUDIT_CORE_SENDERS_SCOPE_PATH")
if not path:
return []
payload = json.loads(Path(path).read_text())
if not isinstance(payload, list):
raise ValueError("sender scope overlay must be a JSON list")
return [entry for entry in payload if isinstance(entry, dict) and entry.get("name")]
def _apply_scope_overlay(
identities: list[SenderIdentity], env: dict[str, str]
) -> list[SenderIdentity]:
"""Overlay non-secret fields from Git/ConfigMap onto Secret-backed tokens.
ExternalSecret refresh cannot shrink ``user-engine`` tenants below the
declared scope (AUDIT-WP-0006-T05). Tokens are never taken from the overlay.
"""
overlay = {entry["name"]: entry for entry in _load_scope_overlay(env)}
if not overlay:
return identities
merged: list[SenderIdentity] = []
for identity in identities:
extra = overlay.get(identity.name)
if extra is None:
merged.append(identity)
continue
tenants = extra.get("tenants")
sources = extra.get("sources")
merged.append(
SenderIdentity(
name=identity.name,
tokens=identity.tokens,
sources=(
frozenset(str(s) for s in sources) if sources else identity.sources
),
tenants=(
frozenset(str(t) for t in tenants) if tenants else identity.tenants
),
may_write=(
bool(extra["may_write"]) if "may_write" in extra else identity.may_write
),
may_read=(
bool(extra["may_read"]) if "may_read" in extra else identity.may_read
),
secret_policy=identity.secret_policy,
)
)
return merged
def _parse_identities(raw: str) -> list[SenderIdentity]:
try:
entries = json.loads(raw)