Implement AUDIT-WP-0006 honest operational custody.
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:
parent
0a3d05ff1c
commit
ded432a63f
25 changed files with 832 additions and 94 deletions
|
|
@ -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",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue