audit-core/audit_core/ingestion.py

624 lines
26 KiB
Python
Raw Normal View History

Route ingestion through the backend contract; fix error semantics AUDIT-WP-0004 T01, T02, T07. T01 - ingestion wrote to SQLite directly and never called the AuditBackend contract, so a 202 meant a row existed rather than that a backend with a declared retention policy had accepted the event. Adds IdempotentAuditBackend to the contract: duplicate detection lives inside the backend so custody and idempotency state share a transaction and cannot diverge. SQLiteAuditBackend implements it with WAL, synchronous=FULL and a busy timeout. Ingestion now refuses any backend declaring durable=False, so the development file backend cannot silently become the production sink. The atomicity claim was tested rather than asserted, and the first attempt failed: with a single shared connection, 16 racing submissions of one event told two callers they were first. Storage was correct but the response was not. Fixed with per-thread connections and BEGIN IMMEDIATE around the insert/read pair, and locked in by a test. T02 - storage errors previously escaped the handler with start_response never called, and the auth check sat outside the try block so a non-ASCII Authorization header crashed the request. Adds a catch-all, maps conflict to 409, backend unavailability to 503 and unexpected faults to 500, and documents the full response contract with the retry semantics each status implies, since senders key their behaviour off it. T07 - ingestion tests 2 -> 23, suite 15 -> 36. accepted_at is now UTC rather than local time, and naive timestamps are rejected instead of silently assumed. Remaining in WP-0004: T03 tenant/source binding, T04 redaction policy, T05 operator read surface, T06 production serving layer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:30:20 +02:00
"""Authenticated, idempotent HTTP ingestion for user-engine outbox events.
Response contract (AUDIT-WP-0004-T02). Senders key their retry behaviour off
these, so they are part of the interface, not an implementation detail:
=== ========== =========================================================
202 accepted Event is durably in custody. Do not retry.
200 duplicate Exact resubmission of an event already in custody. Do not
retry; delivery already succeeded.
400 rejected Malformed or disallowed. Retrying will not help dead
letter it.
401 unauthorized Credential missing or invalid. Do not retry without a
new credential.
409 conflict The event id is held with a different payload. Retrying
will not help; this indicates a sender bug or id reuse.
503 unavailable Not accepted, but retryable. Retry with backoff.
500 error Unexpected fault. Not accepted. Retryable with backoff.
=== ========== =========================================================
"""
from __future__ import annotations
import hashlib
import json
Route ingestion through the backend contract; fix error semantics AUDIT-WP-0004 T01, T02, T07. T01 - ingestion wrote to SQLite directly and never called the AuditBackend contract, so a 202 meant a row existed rather than that a backend with a declared retention policy had accepted the event. Adds IdempotentAuditBackend to the contract: duplicate detection lives inside the backend so custody and idempotency state share a transaction and cannot diverge. SQLiteAuditBackend implements it with WAL, synchronous=FULL and a busy timeout. Ingestion now refuses any backend declaring durable=False, so the development file backend cannot silently become the production sink. The atomicity claim was tested rather than asserted, and the first attempt failed: with a single shared connection, 16 racing submissions of one event told two callers they were first. Storage was correct but the response was not. Fixed with per-thread connections and BEGIN IMMEDIATE around the insert/read pair, and locked in by a test. T02 - storage errors previously escaped the handler with start_response never called, and the auth check sat outside the try block so a non-ASCII Authorization header crashed the request. Adds a catch-all, maps conflict to 409, backend unavailability to 503 and unexpected faults to 500, and documents the full response contract with the retry semantics each status implies, since senders key their behaviour off it. T07 - ingestion tests 2 -> 23, suite 15 -> 36. accepted_at is now UTC rather than local time, and naive timestamps are rejected instead of silently assumed. Remaining in WP-0004: T03 tenant/source binding, T04 redaction policy, T05 operator read surface, T06 production serving layer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:30:20 +02:00
import logging
import os
Bind sender identities, add operator read surface, real serving layer AUDIT-WP-0004 T03, T05, T06. T03 - the receiver accepted whatever tenant and source a caller sent as long as it held the one shared token, despite WP-0003 recording tenant isolation as delivered. audit_core.senders binds each credential to the sources and tenants it may assert, driven by AUDIT_CORE_SENDERS rather than literals. Identities hold a list of tokens so rotation publishes the replacement alongside the incumbent and needs no delivery gap. Read is a separate privilege from write, so a sender credential cannot read the audit trail back. T05 - lookup by event id, lookup by correlation id, and a dead-letter view. Rejections are recorded rather than silently dropped. An event rejected for carrying secret-shaped material has its payload withheld: storing it would write that material into the audit store, which is what the rejection exists to prevent. Reason and payload hash are kept so it stays traceable. Replay is deliberately not built here. Idempotent replay is a property of the durable store and building it against SQLite would produce a second implementation to throw away; it lands with the Postgres backend in AUDIT-WP-0005-T01. T06 - serving moves to waitress with configurable threads and channel timeout, installed in the image via the serve extra. Without it the entrypoint falls back to a threaded wsgiref server with a socket timeout and graceful shutdown on SIGTERM, and logs a warning so a deployment cannot quietly land on the fallback. Metric counters deferred to WP-0005-T03 to be designed against the real scrape path. Tests 36 -> 46, covering cross-tenant and cross-source refusal, token rotation, read/write privilege separation, correlation lookup, and payload withholding on secret rejection. Remaining in WP-0004: T04 redaction policy, which needs a decision on whether a secret-shaped field is a rejection or a redaction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:50:02 +02:00
import signal
Route ingestion through the backend contract; fix error semantics AUDIT-WP-0004 T01, T02, T07. T01 - ingestion wrote to SQLite directly and never called the AuditBackend contract, so a 202 meant a row existed rather than that a backend with a declared retention policy had accepted the event. Adds IdempotentAuditBackend to the contract: duplicate detection lives inside the backend so custody and idempotency state share a transaction and cannot diverge. SQLiteAuditBackend implements it with WAL, synchronous=FULL and a busy timeout. Ingestion now refuses any backend declaring durable=False, so the development file backend cannot silently become the production sink. The atomicity claim was tested rather than asserted, and the first attempt failed: with a single shared connection, 16 racing submissions of one event told two callers they were first. Storage was correct but the response was not. Fixed with per-thread connections and BEGIN IMMEDIATE around the insert/read pair, and locked in by a test. T02 - storage errors previously escaped the handler with start_response never called, and the auth check sat outside the try block so a non-ASCII Authorization header crashed the request. Adds a catch-all, maps conflict to 409, backend unavailability to 503 and unexpected faults to 500, and documents the full response contract with the retry semantics each status implies, since senders key their behaviour off it. T07 - ingestion tests 2 -> 23, suite 15 -> 36. accepted_at is now UTC rather than local time, and naive timestamps are rejected instead of silently assumed. Remaining in WP-0004: T03 tenant/source binding, T04 redaction policy, T05 operator read surface, T06 production serving layer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:30:20 +02:00
import sys
Add deployment manifests, custody-class guard and request counters AUDIT-WP-0005-T03 (progress). Manifests validated --dry-run=server --validate=strict against railiance01; not applied, since deployment is gated on RAPP-POSTGRES-WP-0002 and T02 credentials. Nothing here mutates the cluster. Conventions read off the deployed user-engine workload rather than invented: digest-pinned image from forgejo.coulomb.social, runAsNonRoot with RuntimeDefault seccomp, no privilege escalation, all capabilities dropped, readOnlyRootFilesystem, probes on a named http port, same resource envelope. The namespace carries railiance.io/postgres-client: platform-pg, which is what platform-pg-consumer-ingress in rapp-postgres admits; without that label the pod cannot reach the database at all. NetworkPolicies default-deny both directions, then permit ingress from the user-engine namespace only, a separately labelled operator read path, and egress to PostgreSQL in databases plus DNS. Three decisions worth naming. Liveness is /healthz while readiness is /readyz, so a database outage drops the pod from the Service rather than restarting it in a loop. readOnlyRootFilesystem enforces the empty-filesystem property rather than trusting it, so the SQLite fallback physically cannot accumulate audit records on ephemeral storage. AUDIT_CORE_REQUIRE_CUSTODY_CLASS=archive makes a missing database URL a startup failure instead of a silent downgrade to the development store. Counters deferred from WP-0004-T06 are exposed as JSON at /v1/stats behind the read privilege, not as Prometheus exposition format: the cluster runs no Prometheus, no ServiceMonitor CRD and no other scrape target, so an exposition endpoint would target a scrape path that does not exist. Usable with curl now and a small step from /metrics later. Tests 77 -> 80. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:42:43 +02:00
import threading
Route ingestion through the backend contract; fix error semantics AUDIT-WP-0004 T01, T02, T07. T01 - ingestion wrote to SQLite directly and never called the AuditBackend contract, so a 202 meant a row existed rather than that a backend with a declared retention policy had accepted the event. Adds IdempotentAuditBackend to the contract: duplicate detection lives inside the backend so custody and idempotency state share a transaction and cannot diverge. SQLiteAuditBackend implements it with WAL, synchronous=FULL and a busy timeout. Ingestion now refuses any backend declaring durable=False, so the development file backend cannot silently become the production sink. The atomicity claim was tested rather than asserted, and the first attempt failed: with a single shared connection, 16 racing submissions of one event told two callers they were first. Storage was correct but the response was not. Fixed with per-thread connections and BEGIN IMMEDIATE around the insert/read pair, and locked in by a test. T02 - storage errors previously escaped the handler with start_response never called, and the auth check sat outside the try block so a non-ASCII Authorization header crashed the request. Adds a catch-all, maps conflict to 409, backend unavailability to 503 and unexpected faults to 500, and documents the full response contract with the retry semantics each status implies, since senders key their behaviour off it. T07 - ingestion tests 2 -> 23, suite 15 -> 36. accepted_at is now UTC rather than local time, and naive timestamps are rejected instead of silently assumed. Remaining in WP-0004: T03 tenant/source binding, T04 redaction policy, T05 operator read surface, T06 production serving layer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:30:20 +02:00
from datetime import datetime, timezone
from http import HTTPStatus
from typing import Any
Bind sender identities, add operator read surface, real serving layer AUDIT-WP-0004 T03, T05, T06. T03 - the receiver accepted whatever tenant and source a caller sent as long as it held the one shared token, despite WP-0003 recording tenant isolation as delivered. audit_core.senders binds each credential to the sources and tenants it may assert, driven by AUDIT_CORE_SENDERS rather than literals. Identities hold a list of tokens so rotation publishes the replacement alongside the incumbent and needs no delivery gap. Read is a separate privilege from write, so a sender credential cannot read the audit trail back. T05 - lookup by event id, lookup by correlation id, and a dead-letter view. Rejections are recorded rather than silently dropped. An event rejected for carrying secret-shaped material has its payload withheld: storing it would write that material into the audit store, which is what the rejection exists to prevent. Reason and payload hash are kept so it stays traceable. Replay is deliberately not built here. Idempotent replay is a property of the durable store and building it against SQLite would produce a second implementation to throw away; it lands with the Postgres backend in AUDIT-WP-0005-T01. T06 - serving moves to waitress with configurable threads and channel timeout, installed in the image via the serve extra. Without it the entrypoint falls back to a threaded wsgiref server with a socket timeout and graceful shutdown on SIGTERM, and logs a warning so a deployment cannot quietly land on the fallback. Metric counters deferred to WP-0005-T03 to be designed against the real scrape path. Tests 36 -> 46, covering cross-tenant and cross-source refusal, token rotation, read/write privilege separation, correlation lookup, and payload withholding on secret rejection. Remaining in WP-0004: T04 redaction policy, which needs a decision on whether a secret-shaped field is a rejection or a redaction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:50:02 +02:00
from urllib.parse import parse_qs
Route ingestion through the backend contract; fix error semantics AUDIT-WP-0004 T01, T02, T07. T01 - ingestion wrote to SQLite directly and never called the AuditBackend contract, so a 202 meant a row existed rather than that a backend with a declared retention policy had accepted the event. Adds IdempotentAuditBackend to the contract: duplicate detection lives inside the backend so custody and idempotency state share a transaction and cannot diverge. SQLiteAuditBackend implements it with WAL, synchronous=FULL and a busy timeout. Ingestion now refuses any backend declaring durable=False, so the development file backend cannot silently become the production sink. The atomicity claim was tested rather than asserted, and the first attempt failed: with a single shared connection, 16 racing submissions of one event told two callers they were first. Storage was correct but the response was not. Fixed with per-thread connections and BEGIN IMMEDIATE around the insert/read pair, and locked in by a test. T02 - storage errors previously escaped the handler with start_response never called, and the auth check sat outside the try block so a non-ASCII Authorization header crashed the request. Adds a catch-all, maps conflict to 409, backend unavailability to 503 and unexpected faults to 500, and documents the full response contract with the retry semantics each status implies, since senders key their behaviour off it. T07 - ingestion tests 2 -> 23, suite 15 -> 36. accepted_at is now UTC rather than local time, and naive timestamps are rejected instead of silently assumed. Remaining in WP-0004: T03 tenant/source binding, T04 redaction policy, T05 operator read surface, T06 production serving layer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:30:20 +02:00
from audit_core.interface import (
AuditEvent,
BackendUnavailableError,
EventConflictError,
EventValidationError,
IdempotentAuditBackend,
custody_class_satisfies,
Route ingestion through the backend contract; fix error semantics AUDIT-WP-0004 T01, T02, T07. T01 - ingestion wrote to SQLite directly and never called the AuditBackend contract, so a 202 meant a row existed rather than that a backend with a declared retention policy had accepted the event. Adds IdempotentAuditBackend to the contract: duplicate detection lives inside the backend so custody and idempotency state share a transaction and cannot diverge. SQLiteAuditBackend implements it with WAL, synchronous=FULL and a busy timeout. Ingestion now refuses any backend declaring durable=False, so the development file backend cannot silently become the production sink. The atomicity claim was tested rather than asserted, and the first attempt failed: with a single shared connection, 16 racing submissions of one event told two callers they were first. Storage was correct but the response was not. Fixed with per-thread connections and BEGIN IMMEDIATE around the insert/read pair, and locked in by a test. T02 - storage errors previously escaped the handler with start_response never called, and the auth check sat outside the try block so a non-ASCII Authorization header crashed the request. Adds a catch-all, maps conflict to 409, backend unavailability to 503 and unexpected faults to 500, and documents the full response contract with the retry semantics each status implies, since senders key their behaviour off it. T07 - ingestion tests 2 -> 23, suite 15 -> 36. accepted_at is now UTC rather than local time, and naive timestamps are rejected instead of silently assumed. Remaining in WP-0004: T03 tenant/source binding, T04 redaction policy, T05 operator read surface, T06 production serving layer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:30:20 +02:00
)
2026-08-10 16:02:22 +02:00
from audit_core.redaction import (
POLICY_REDACT,
SecretFieldRejection,
apply_policy,
finding_from_path,
)
Bind sender identities, add operator read surface, real serving layer AUDIT-WP-0004 T03, T05, T06. T03 - the receiver accepted whatever tenant and source a caller sent as long as it held the one shared token, despite WP-0003 recording tenant isolation as delivered. audit_core.senders binds each credential to the sources and tenants it may assert, driven by AUDIT_CORE_SENDERS rather than literals. Identities hold a list of tokens so rotation publishes the replacement alongside the incumbent and needs no delivery gap. Read is a separate privilege from write, so a sender credential cannot read the audit trail back. T05 - lookup by event id, lookup by correlation id, and a dead-letter view. Rejections are recorded rather than silently dropped. An event rejected for carrying secret-shaped material has its payload withheld: storing it would write that material into the audit store, which is what the rejection exists to prevent. Reason and payload hash are kept so it stays traceable. Replay is deliberately not built here. Idempotent replay is a property of the durable store and building it against SQLite would produce a second implementation to throw away; it lands with the Postgres backend in AUDIT-WP-0005-T01. T06 - serving moves to waitress with configurable threads and channel timeout, installed in the image via the serve extra. Without it the entrypoint falls back to a threaded wsgiref server with a socket timeout and graceful shutdown on SIGTERM, and logs a warning so a deployment cannot quietly land on the fallback. Metric counters deferred to WP-0005-T03 to be designed against the real scrape path. Tests 36 -> 46, covering cross-tenant and cross-source refusal, token rotation, read/write privilege separation, correlation lookup, and payload withholding on secret rejection. Remaining in WP-0004: T04 redaction policy, which needs a decision on whether a secret-shaped field is a rejection or a redaction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:50:02 +02:00
from audit_core.senders import SenderIdentity, SenderRegistry, development_registry
Route ingestion through the backend contract; fix error semantics AUDIT-WP-0004 T01, T02, T07. T01 - ingestion wrote to SQLite directly and never called the AuditBackend contract, so a 202 meant a row existed rather than that a backend with a declared retention policy had accepted the event. Adds IdempotentAuditBackend to the contract: duplicate detection lives inside the backend so custody and idempotency state share a transaction and cannot diverge. SQLiteAuditBackend implements it with WAL, synchronous=FULL and a busy timeout. Ingestion now refuses any backend declaring durable=False, so the development file backend cannot silently become the production sink. The atomicity claim was tested rather than asserted, and the first attempt failed: with a single shared connection, 16 racing submissions of one event told two callers they were first. Storage was correct but the response was not. Fixed with per-thread connections and BEGIN IMMEDIATE around the insert/read pair, and locked in by a test. T02 - storage errors previously escaped the handler with start_response never called, and the auth check sat outside the try block so a non-ASCII Authorization header crashed the request. Adds a catch-all, maps conflict to 409, backend unavailability to 503 and unexpected faults to 500, and documents the full response contract with the retry semantics each status implies, since senders key their behaviour off it. T07 - ingestion tests 2 -> 23, suite 15 -> 36. accepted_at is now UTC rather than local time, and naive timestamps are rejected instead of silently assumed. Remaining in WP-0004: T03 tenant/source binding, T04 redaction policy, T05 operator read surface, T06 production serving layer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:30:20 +02:00
from audit_core.sqlite_backend import SQLiteAuditBackend
MAX_BODY_BYTES = 256 * 1024
Route ingestion through the backend contract; fix error semantics AUDIT-WP-0004 T01, T02, T07. T01 - ingestion wrote to SQLite directly and never called the AuditBackend contract, so a 202 meant a row existed rather than that a backend with a declared retention policy had accepted the event. Adds IdempotentAuditBackend to the contract: duplicate detection lives inside the backend so custody and idempotency state share a transaction and cannot diverge. SQLiteAuditBackend implements it with WAL, synchronous=FULL and a busy timeout. Ingestion now refuses any backend declaring durable=False, so the development file backend cannot silently become the production sink. The atomicity claim was tested rather than asserted, and the first attempt failed: with a single shared connection, 16 racing submissions of one event told two callers they were first. Storage was correct but the response was not. Fixed with per-thread connections and BEGIN IMMEDIATE around the insert/read pair, and locked in by a test. T02 - storage errors previously escaped the handler with start_response never called, and the auth check sat outside the try block so a non-ASCII Authorization header crashed the request. Adds a catch-all, maps conflict to 409, backend unavailability to 503 and unexpected faults to 500, and documents the full response contract with the retry semantics each status implies, since senders key their behaviour off it. T07 - ingestion tests 2 -> 23, suite 15 -> 36. accepted_at is now UTC rather than local time, and naive timestamps are rejected instead of silently assumed. Remaining in WP-0004: T03 tenant/source binding, T04 redaction policy, T05 operator read surface, T06 production serving layer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:30:20 +02:00
log = logging.getLogger("audit_core.ingestion")
Add deployment manifests, custody-class guard and request counters AUDIT-WP-0005-T03 (progress). Manifests validated --dry-run=server --validate=strict against railiance01; not applied, since deployment is gated on RAPP-POSTGRES-WP-0002 and T02 credentials. Nothing here mutates the cluster. Conventions read off the deployed user-engine workload rather than invented: digest-pinned image from forgejo.coulomb.social, runAsNonRoot with RuntimeDefault seccomp, no privilege escalation, all capabilities dropped, readOnlyRootFilesystem, probes on a named http port, same resource envelope. The namespace carries railiance.io/postgres-client: platform-pg, which is what platform-pg-consumer-ingress in rapp-postgres admits; without that label the pod cannot reach the database at all. NetworkPolicies default-deny both directions, then permit ingress from the user-engine namespace only, a separately labelled operator read path, and egress to PostgreSQL in databases plus DNS. Three decisions worth naming. Liveness is /healthz while readiness is /readyz, so a database outage drops the pod from the Service rather than restarting it in a loop. readOnlyRootFilesystem enforces the empty-filesystem property rather than trusting it, so the SQLite fallback physically cannot accumulate audit records on ephemeral storage. AUDIT_CORE_REQUIRE_CUSTODY_CLASS=archive makes a missing database URL a startup failure instead of a silent downgrade to the development store. Counters deferred from WP-0004-T06 are exposed as JSON at /v1/stats behind the read privilege, not as Prometheus exposition format: the cluster runs no Prometheus, no ServiceMonitor CRD and no other scrape target, so an exposition endpoint would target a scrape path that does not exist. Usable with curl now and a small step from /metrics later. Tests 77 -> 80. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:42:43 +02:00
class Counters:
"""In-process request counters (AUDIT-WP-0005-T03).
Exposed as JSON at ``/v1/stats`` rather than in Prometheus exposition
format, because railiance01 currently runs no Prometheus, ServiceMonitor
CRD, or any other scrape target. Building an exposition endpoint for a
scrape path that does not exist would be guessing; this is usable by an
operator with curl today and is a small step from a /metrics endpoint when
a metrics stack lands.
These reset on restart, which is correct for rate signals. The counters
that must survive a restart secret-shaped field findings are persisted
by the backend instead.
"""
FIELDS = ("accepted", "duplicate", "conflict", "rejected", "unauthorized",
"forbidden", "unavailable", "error")
def __init__(self) -> None:
self._lock = threading.Lock()
self._counts = {name: 0 for name in self.FIELDS}
self._started = datetime.now(timezone.utc).replace(microsecond=0)
def hit(self, name: str) -> None:
with self._lock:
if name in self._counts:
self._counts[name] += 1
def snapshot(self) -> dict[str, Any]:
with self._lock:
counts = dict(self._counts)
return {"since": self._started.isoformat(), "counts": counts}
class IngestionApplication:
Route ingestion through the backend contract; fix error semantics AUDIT-WP-0004 T01, T02, T07. T01 - ingestion wrote to SQLite directly and never called the AuditBackend contract, so a 202 meant a row existed rather than that a backend with a declared retention policy had accepted the event. Adds IdempotentAuditBackend to the contract: duplicate detection lives inside the backend so custody and idempotency state share a transaction and cannot diverge. SQLiteAuditBackend implements it with WAL, synchronous=FULL and a busy timeout. Ingestion now refuses any backend declaring durable=False, so the development file backend cannot silently become the production sink. The atomicity claim was tested rather than asserted, and the first attempt failed: with a single shared connection, 16 racing submissions of one event told two callers they were first. Storage was correct but the response was not. Fixed with per-thread connections and BEGIN IMMEDIATE around the insert/read pair, and locked in by a test. T02 - storage errors previously escaped the handler with start_response never called, and the auth check sat outside the try block so a non-ASCII Authorization header crashed the request. Adds a catch-all, maps conflict to 409, backend unavailability to 503 and unexpected faults to 500, and documents the full response contract with the retry semantics each status implies, since senders key their behaviour off it. T07 - ingestion tests 2 -> 23, suite 15 -> 36. accepted_at is now UTC rather than local time, and naive timestamps are rejected instead of silently assumed. Remaining in WP-0004: T03 tenant/source binding, T04 redaction policy, T05 operator read surface, T06 production serving layer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:30:20 +02:00
"""WSGI application accepting user-engine outbox events.
Writes through the audit backend contract rather than to storage directly,
so a 202 means a backend with a declared retention policy acknowledged the
event.
"""
Bind sender identities, add operator read surface, real serving layer AUDIT-WP-0004 T03, T05, T06. T03 - the receiver accepted whatever tenant and source a caller sent as long as it held the one shared token, despite WP-0003 recording tenant isolation as delivered. audit_core.senders binds each credential to the sources and tenants it may assert, driven by AUDIT_CORE_SENDERS rather than literals. Identities hold a list of tokens so rotation publishes the replacement alongside the incumbent and needs no delivery gap. Read is a separate privilege from write, so a sender credential cannot read the audit trail back. T05 - lookup by event id, lookup by correlation id, and a dead-letter view. Rejections are recorded rather than silently dropped. An event rejected for carrying secret-shaped material has its payload withheld: storing it would write that material into the audit store, which is what the rejection exists to prevent. Reason and payload hash are kept so it stays traceable. Replay is deliberately not built here. Idempotent replay is a property of the durable store and building it against SQLite would produce a second implementation to throw away; it lands with the Postgres backend in AUDIT-WP-0005-T01. T06 - serving moves to waitress with configurable threads and channel timeout, installed in the image via the serve extra. Without it the entrypoint falls back to a threaded wsgiref server with a socket timeout and graceful shutdown on SIGTERM, and logs a warning so a deployment cannot quietly land on the fallback. Metric counters deferred to WP-0005-T03 to be designed against the real scrape path. Tests 36 -> 46, covering cross-tenant and cross-source refusal, token rotation, read/write privilege separation, correlation lookup, and payload withholding on secret rejection. Remaining in WP-0004: T04 redaction policy, which needs a decision on whether a secret-shaped field is a rejection or a redaction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:50:02 +02:00
def __init__(
Add deployment manifests, custody-class guard and request counters AUDIT-WP-0005-T03 (progress). Manifests validated --dry-run=server --validate=strict against railiance01; not applied, since deployment is gated on RAPP-POSTGRES-WP-0002 and T02 credentials. Nothing here mutates the cluster. Conventions read off the deployed user-engine workload rather than invented: digest-pinned image from forgejo.coulomb.social, runAsNonRoot with RuntimeDefault seccomp, no privilege escalation, all capabilities dropped, readOnlyRootFilesystem, probes on a named http port, same resource envelope. The namespace carries railiance.io/postgres-client: platform-pg, which is what platform-pg-consumer-ingress in rapp-postgres admits; without that label the pod cannot reach the database at all. NetworkPolicies default-deny both directions, then permit ingress from the user-engine namespace only, a separately labelled operator read path, and egress to PostgreSQL in databases plus DNS. Three decisions worth naming. Liveness is /healthz while readiness is /readyz, so a database outage drops the pod from the Service rather than restarting it in a loop. readOnlyRootFilesystem enforces the empty-filesystem property rather than trusting it, so the SQLite fallback physically cannot accumulate audit records on ephemeral storage. AUDIT_CORE_REQUIRE_CUSTODY_CLASS=archive makes a missing database URL a startup failure instead of a silent downgrade to the development store. Counters deferred from WP-0004-T06 are exposed as JSON at /v1/stats behind the read privilege, not as Prometheus exposition format: the cluster runs no Prometheus, no ServiceMonitor CRD and no other scrape target, so an exposition endpoint would target a scrape path that does not exist. Usable with curl now and a small step from /metrics later. Tests 77 -> 80. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:42:43 +02:00
self,
backend: IdempotentAuditBackend,
senders: SenderRegistry | str,
require_custody_class: str | None = None,
Bind sender identities, add operator read surface, real serving layer AUDIT-WP-0004 T03, T05, T06. T03 - the receiver accepted whatever tenant and source a caller sent as long as it held the one shared token, despite WP-0003 recording tenant isolation as delivered. audit_core.senders binds each credential to the sources and tenants it may assert, driven by AUDIT_CORE_SENDERS rather than literals. Identities hold a list of tokens so rotation publishes the replacement alongside the incumbent and needs no delivery gap. Read is a separate privilege from write, so a sender credential cannot read the audit trail back. T05 - lookup by event id, lookup by correlation id, and a dead-letter view. Rejections are recorded rather than silently dropped. An event rejected for carrying secret-shaped material has its payload withheld: storing it would write that material into the audit store, which is what the rejection exists to prevent. Reason and payload hash are kept so it stays traceable. Replay is deliberately not built here. Idempotent replay is a property of the durable store and building it against SQLite would produce a second implementation to throw away; it lands with the Postgres backend in AUDIT-WP-0005-T01. T06 - serving moves to waitress with configurable threads and channel timeout, installed in the image via the serve extra. Without it the entrypoint falls back to a threaded wsgiref server with a socket timeout and graceful shutdown on SIGTERM, and logs a warning so a deployment cannot quietly land on the fallback. Metric counters deferred to WP-0005-T03 to be designed against the real scrape path. Tests 36 -> 46, covering cross-tenant and cross-source refusal, token rotation, read/write privilege separation, correlation lookup, and payload withholding on secret rejection. Remaining in WP-0004: T04 redaction policy, which needs a decision on whether a secret-shaped field is a rejection or a redaction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:50:02 +02:00
) -> None:
Route ingestion through the backend contract; fix error semantics AUDIT-WP-0004 T01, T02, T07. T01 - ingestion wrote to SQLite directly and never called the AuditBackend contract, so a 202 meant a row existed rather than that a backend with a declared retention policy had accepted the event. Adds IdempotentAuditBackend to the contract: duplicate detection lives inside the backend so custody and idempotency state share a transaction and cannot diverge. SQLiteAuditBackend implements it with WAL, synchronous=FULL and a busy timeout. Ingestion now refuses any backend declaring durable=False, so the development file backend cannot silently become the production sink. The atomicity claim was tested rather than asserted, and the first attempt failed: with a single shared connection, 16 racing submissions of one event told two callers they were first. Storage was correct but the response was not. Fixed with per-thread connections and BEGIN IMMEDIATE around the insert/read pair, and locked in by a test. T02 - storage errors previously escaped the handler with start_response never called, and the auth check sat outside the try block so a non-ASCII Authorization header crashed the request. Adds a catch-all, maps conflict to 409, backend unavailability to 503 and unexpected faults to 500, and documents the full response contract with the retry semantics each status implies, since senders key their behaviour off it. T07 - ingestion tests 2 -> 23, suite 15 -> 36. accepted_at is now UTC rather than local time, and naive timestamps are rejected instead of silently assumed. Remaining in WP-0004: T03 tenant/source binding, T04 redaction policy, T05 operator read surface, T06 production serving layer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:30:20 +02:00
policy = backend.retention_policy
if require_custody_class and not custody_class_satisfies(
policy.custody_class, require_custody_class
):
Add deployment manifests, custody-class guard and request counters AUDIT-WP-0005-T03 (progress). Manifests validated --dry-run=server --validate=strict against railiance01; not applied, since deployment is gated on RAPP-POSTGRES-WP-0002 and T02 credentials. Nothing here mutates the cluster. Conventions read off the deployed user-engine workload rather than invented: digest-pinned image from forgejo.coulomb.social, runAsNonRoot with RuntimeDefault seccomp, no privilege escalation, all capabilities dropped, readOnlyRootFilesystem, probes on a named http port, same resource envelope. The namespace carries railiance.io/postgres-client: platform-pg, which is what platform-pg-consumer-ingress in rapp-postgres admits; without that label the pod cannot reach the database at all. NetworkPolicies default-deny both directions, then permit ingress from the user-engine namespace only, a separately labelled operator read path, and egress to PostgreSQL in databases plus DNS. Three decisions worth naming. Liveness is /healthz while readiness is /readyz, so a database outage drops the pod from the Service rather than restarting it in a loop. readOnlyRootFilesystem enforces the empty-filesystem property rather than trusting it, so the SQLite fallback physically cannot accumulate audit records on ephemeral storage. AUDIT_CORE_REQUIRE_CUSTODY_CLASS=archive makes a missing database URL a startup failure instead of a silent downgrade to the development store. Counters deferred from WP-0004-T06 are exposed as JSON at /v1/stats behind the read privilege, not as Prometheus exposition format: the cluster runs no Prometheus, no ServiceMonitor CRD and no other scrape target, so an exposition endpoint would target a scrape path that does not exist. Usable with curl now and a small step from /metrics later. Tests 77 -> 80. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:42:43 +02:00
# Production sets this. Without it, losing AUDIT_CORE_DATABASE_URL
# silently downgrades custody to the development store instead of
# failing to start. ``operational`` and ``archive`` alias each
# other for one mixed-rollout deploy (AUDIT-WP-0006-T01).
Add deployment manifests, custody-class guard and request counters AUDIT-WP-0005-T03 (progress). Manifests validated --dry-run=server --validate=strict against railiance01; not applied, since deployment is gated on RAPP-POSTGRES-WP-0002 and T02 credentials. Nothing here mutates the cluster. Conventions read off the deployed user-engine workload rather than invented: digest-pinned image from forgejo.coulomb.social, runAsNonRoot with RuntimeDefault seccomp, no privilege escalation, all capabilities dropped, readOnlyRootFilesystem, probes on a named http port, same resource envelope. The namespace carries railiance.io/postgres-client: platform-pg, which is what platform-pg-consumer-ingress in rapp-postgres admits; without that label the pod cannot reach the database at all. NetworkPolicies default-deny both directions, then permit ingress from the user-engine namespace only, a separately labelled operator read path, and egress to PostgreSQL in databases plus DNS. Three decisions worth naming. Liveness is /healthz while readiness is /readyz, so a database outage drops the pod from the Service rather than restarting it in a loop. readOnlyRootFilesystem enforces the empty-filesystem property rather than trusting it, so the SQLite fallback physically cannot accumulate audit records on ephemeral storage. AUDIT_CORE_REQUIRE_CUSTODY_CLASS=archive makes a missing database URL a startup failure instead of a silent downgrade to the development store. Counters deferred from WP-0004-T06 are exposed as JSON at /v1/stats behind the read privilege, not as Prometheus exposition format: the cluster runs no Prometheus, no ServiceMonitor CRD and no other scrape target, so an exposition endpoint would target a scrape path that does not exist. Usable with curl now and a small step from /metrics later. Tests 77 -> 80. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:42:43 +02:00
raise ValueError(
f"backend custody_class={policy.custody_class!r} does not meet the "
f"required {require_custody_class!r}; refusing to start"
)
Route ingestion through the backend contract; fix error semantics AUDIT-WP-0004 T01, T02, T07. T01 - ingestion wrote to SQLite directly and never called the AuditBackend contract, so a 202 meant a row existed rather than that a backend with a declared retention policy had accepted the event. Adds IdempotentAuditBackend to the contract: duplicate detection lives inside the backend so custody and idempotency state share a transaction and cannot diverge. SQLiteAuditBackend implements it with WAL, synchronous=FULL and a busy timeout. Ingestion now refuses any backend declaring durable=False, so the development file backend cannot silently become the production sink. The atomicity claim was tested rather than asserted, and the first attempt failed: with a single shared connection, 16 racing submissions of one event told two callers they were first. Storage was correct but the response was not. Fixed with per-thread connections and BEGIN IMMEDIATE around the insert/read pair, and locked in by a test. T02 - storage errors previously escaped the handler with start_response never called, and the auth check sat outside the try block so a non-ASCII Authorization header crashed the request. Adds a catch-all, maps conflict to 409, backend unavailability to 503 and unexpected faults to 500, and documents the full response contract with the retry semantics each status implies, since senders key their behaviour off it. T07 - ingestion tests 2 -> 23, suite 15 -> 36. accepted_at is now UTC rather than local time, and naive timestamps are rejected instead of silently assumed. Remaining in WP-0004: T03 tenant/source binding, T04 redaction policy, T05 operator read surface, T06 production serving layer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:30:20 +02:00
if not policy.durable:
# The mock file backend declares durable=False. Refusing it here is
# what stops a development sink from silently becoming the
# production one (AUDIT-WP-0004-T01).
raise ValueError(
f"backend custody_class={policy.custody_class!r} is not durable; "
"refusing to accept audit events against it"
)
Bind sender identities, add operator read surface, real serving layer AUDIT-WP-0004 T03, T05, T06. T03 - the receiver accepted whatever tenant and source a caller sent as long as it held the one shared token, despite WP-0003 recording tenant isolation as delivered. audit_core.senders binds each credential to the sources and tenants it may assert, driven by AUDIT_CORE_SENDERS rather than literals. Identities hold a list of tokens so rotation publishes the replacement alongside the incumbent and needs no delivery gap. Read is a separate privilege from write, so a sender credential cannot read the audit trail back. T05 - lookup by event id, lookup by correlation id, and a dead-letter view. Rejections are recorded rather than silently dropped. An event rejected for carrying secret-shaped material has its payload withheld: storing it would write that material into the audit store, which is what the rejection exists to prevent. Reason and payload hash are kept so it stays traceable. Replay is deliberately not built here. Idempotent replay is a property of the durable store and building it against SQLite would produce a second implementation to throw away; it lands with the Postgres backend in AUDIT-WP-0005-T01. T06 - serving moves to waitress with configurable threads and channel timeout, installed in the image via the serve extra. Without it the entrypoint falls back to a threaded wsgiref server with a socket timeout and graceful shutdown on SIGTERM, and logs a warning so a deployment cannot quietly land on the fallback. Metric counters deferred to WP-0005-T03 to be designed against the real scrape path. Tests 36 -> 46, covering cross-tenant and cross-source refusal, token rotation, read/write privilege separation, correlation lookup, and payload withholding on secret rejection. Remaining in WP-0004: T04 redaction policy, which needs a decision on whether a secret-shaped field is a rejection or a redaction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:50:02 +02:00
if isinstance(senders, str):
if not senders:
raise ValueError("bearer token is required")
senders = development_registry(senders)
Route ingestion through the backend contract; fix error semantics AUDIT-WP-0004 T01, T02, T07. T01 - ingestion wrote to SQLite directly and never called the AuditBackend contract, so a 202 meant a row existed rather than that a backend with a declared retention policy had accepted the event. Adds IdempotentAuditBackend to the contract: duplicate detection lives inside the backend so custody and idempotency state share a transaction and cannot diverge. SQLiteAuditBackend implements it with WAL, synchronous=FULL and a busy timeout. Ingestion now refuses any backend declaring durable=False, so the development file backend cannot silently become the production sink. The atomicity claim was tested rather than asserted, and the first attempt failed: with a single shared connection, 16 racing submissions of one event told two callers they were first. Storage was correct but the response was not. Fixed with per-thread connections and BEGIN IMMEDIATE around the insert/read pair, and locked in by a test. T02 - storage errors previously escaped the handler with start_response never called, and the auth check sat outside the try block so a non-ASCII Authorization header crashed the request. Adds a catch-all, maps conflict to 409, backend unavailability to 503 and unexpected faults to 500, and documents the full response contract with the retry semantics each status implies, since senders key their behaviour off it. T07 - ingestion tests 2 -> 23, suite 15 -> 36. accepted_at is now UTC rather than local time, and naive timestamps are rejected instead of silently assumed. Remaining in WP-0004: T03 tenant/source binding, T04 redaction policy, T05 operator read surface, T06 production serving layer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:30:20 +02:00
self.backend = backend
Bind sender identities, add operator read surface, real serving layer AUDIT-WP-0004 T03, T05, T06. T03 - the receiver accepted whatever tenant and source a caller sent as long as it held the one shared token, despite WP-0003 recording tenant isolation as delivered. audit_core.senders binds each credential to the sources and tenants it may assert, driven by AUDIT_CORE_SENDERS rather than literals. Identities hold a list of tokens so rotation publishes the replacement alongside the incumbent and needs no delivery gap. Read is a separate privilege from write, so a sender credential cannot read the audit trail back. T05 - lookup by event id, lookup by correlation id, and a dead-letter view. Rejections are recorded rather than silently dropped. An event rejected for carrying secret-shaped material has its payload withheld: storing it would write that material into the audit store, which is what the rejection exists to prevent. Reason and payload hash are kept so it stays traceable. Replay is deliberately not built here. Idempotent replay is a property of the durable store and building it against SQLite would produce a second implementation to throw away; it lands with the Postgres backend in AUDIT-WP-0005-T01. T06 - serving moves to waitress with configurable threads and channel timeout, installed in the image via the serve extra. Without it the entrypoint falls back to a threaded wsgiref server with a socket timeout and graceful shutdown on SIGTERM, and logs a warning so a deployment cannot quietly land on the fallback. Metric counters deferred to WP-0005-T03 to be designed against the real scrape path. Tests 36 -> 46, covering cross-tenant and cross-source refusal, token rotation, read/write privilege separation, correlation lookup, and payload withholding on secret rejection. Remaining in WP-0004: T04 redaction policy, which needs a decision on whether a secret-shaped field is a rejection or a redaction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:50:02 +02:00
self.senders = senders
Add deployment manifests, custody-class guard and request counters AUDIT-WP-0005-T03 (progress). Manifests validated --dry-run=server --validate=strict against railiance01; not applied, since deployment is gated on RAPP-POSTGRES-WP-0002 and T02 credentials. Nothing here mutates the cluster. Conventions read off the deployed user-engine workload rather than invented: digest-pinned image from forgejo.coulomb.social, runAsNonRoot with RuntimeDefault seccomp, no privilege escalation, all capabilities dropped, readOnlyRootFilesystem, probes on a named http port, same resource envelope. The namespace carries railiance.io/postgres-client: platform-pg, which is what platform-pg-consumer-ingress in rapp-postgres admits; without that label the pod cannot reach the database at all. NetworkPolicies default-deny both directions, then permit ingress from the user-engine namespace only, a separately labelled operator read path, and egress to PostgreSQL in databases plus DNS. Three decisions worth naming. Liveness is /healthz while readiness is /readyz, so a database outage drops the pod from the Service rather than restarting it in a loop. readOnlyRootFilesystem enforces the empty-filesystem property rather than trusting it, so the SQLite fallback physically cannot accumulate audit records on ephemeral storage. AUDIT_CORE_REQUIRE_CUSTODY_CLASS=archive makes a missing database URL a startup failure instead of a silent downgrade to the development store. Counters deferred from WP-0004-T06 are exposed as JSON at /v1/stats behind the read privilege, not as Prometheus exposition format: the cluster runs no Prometheus, no ServiceMonitor CRD and no other scrape target, so an exposition endpoint would target a scrape path that does not exist. Usable with curl now and a small step from /metrics later. Tests 77 -> 80. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:42:43 +02:00
self.counters = Counters()
def __call__(self, environ, start_response):
Route ingestion through the backend contract; fix error semantics AUDIT-WP-0004 T01, T02, T07. T01 - ingestion wrote to SQLite directly and never called the AuditBackend contract, so a 202 meant a row existed rather than that a backend with a declared retention policy had accepted the event. Adds IdempotentAuditBackend to the contract: duplicate detection lives inside the backend so custody and idempotency state share a transaction and cannot diverge. SQLiteAuditBackend implements it with WAL, synchronous=FULL and a busy timeout. Ingestion now refuses any backend declaring durable=False, so the development file backend cannot silently become the production sink. The atomicity claim was tested rather than asserted, and the first attempt failed: with a single shared connection, 16 racing submissions of one event told two callers they were first. Storage was correct but the response was not. Fixed with per-thread connections and BEGIN IMMEDIATE around the insert/read pair, and locked in by a test. T02 - storage errors previously escaped the handler with start_response never called, and the auth check sat outside the try block so a non-ASCII Authorization header crashed the request. Adds a catch-all, maps conflict to 409, backend unavailability to 503 and unexpected faults to 500, and documents the full response contract with the retry semantics each status implies, since senders key their behaviour off it. T07 - ingestion tests 2 -> 23, suite 15 -> 36. accepted_at is now UTC rather than local time, and naive timestamps are rejected instead of silently assumed. Remaining in WP-0004: T03 tenant/source binding, T04 redaction policy, T05 operator read surface, T06 production serving layer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:30:20 +02:00
try:
return self._handle(environ, start_response)
except Exception:
# Nothing may escape: an unhandled exception here means
# start_response is never called and the sender sees a dropped
# connection it cannot classify.
log.exception("unhandled error in ingestion request")
Add deployment manifests, custody-class guard and request counters AUDIT-WP-0005-T03 (progress). Manifests validated --dry-run=server --validate=strict against railiance01; not applied, since deployment is gated on RAPP-POSTGRES-WP-0002 and T02 credentials. Nothing here mutates the cluster. Conventions read off the deployed user-engine workload rather than invented: digest-pinned image from forgejo.coulomb.social, runAsNonRoot with RuntimeDefault seccomp, no privilege escalation, all capabilities dropped, readOnlyRootFilesystem, probes on a named http port, same resource envelope. The namespace carries railiance.io/postgres-client: platform-pg, which is what platform-pg-consumer-ingress in rapp-postgres admits; without that label the pod cannot reach the database at all. NetworkPolicies default-deny both directions, then permit ingress from the user-engine namespace only, a separately labelled operator read path, and egress to PostgreSQL in databases plus DNS. Three decisions worth naming. Liveness is /healthz while readiness is /readyz, so a database outage drops the pod from the Service rather than restarting it in a loop. readOnlyRootFilesystem enforces the empty-filesystem property rather than trusting it, so the SQLite fallback physically cannot accumulate audit records on ephemeral storage. AUDIT_CORE_REQUIRE_CUSTODY_CLASS=archive makes a missing database URL a startup failure instead of a silent downgrade to the development store. Counters deferred from WP-0004-T06 are exposed as JSON at /v1/stats behind the read privilege, not as Prometheus exposition format: the cluster runs no Prometheus, no ServiceMonitor CRD and no other scrape target, so an exposition endpoint would target a scrape path that does not exist. Usable with curl now and a small step from /metrics later. Tests 77 -> 80. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:42:43 +02:00
self.counters.hit("error")
Route ingestion through the backend contract; fix error semantics AUDIT-WP-0004 T01, T02, T07. T01 - ingestion wrote to SQLite directly and never called the AuditBackend contract, so a 202 meant a row existed rather than that a backend with a declared retention policy had accepted the event. Adds IdempotentAuditBackend to the contract: duplicate detection lives inside the backend so custody and idempotency state share a transaction and cannot diverge. SQLiteAuditBackend implements it with WAL, synchronous=FULL and a busy timeout. Ingestion now refuses any backend declaring durable=False, so the development file backend cannot silently become the production sink. The atomicity claim was tested rather than asserted, and the first attempt failed: with a single shared connection, 16 racing submissions of one event told two callers they were first. Storage was correct but the response was not. Fixed with per-thread connections and BEGIN IMMEDIATE around the insert/read pair, and locked in by a test. T02 - storage errors previously escaped the handler with start_response never called, and the auth check sat outside the try block so a non-ASCII Authorization header crashed the request. Adds a catch-all, maps conflict to 409, backend unavailability to 503 and unexpected faults to 500, and documents the full response contract with the retry semantics each status implies, since senders key their behaviour off it. T07 - ingestion tests 2 -> 23, suite 15 -> 36. accepted_at is now UTC rather than local time, and naive timestamps are rejected instead of silently assumed. Remaining in WP-0004: T03 tenant/source binding, T04 redaction policy, T05 operator read surface, T06 production serving layer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:30:20 +02:00
return self._json(
start_response, HTTPStatus.INTERNAL_SERVER_ERROR, {"error": "internal_error"}
)
def _handle(self, environ, start_response):
path = environ.get("PATH_INFO", "")
Route ingestion through the backend contract; fix error semantics AUDIT-WP-0004 T01, T02, T07. T01 - ingestion wrote to SQLite directly and never called the AuditBackend contract, so a 202 meant a row existed rather than that a backend with a declared retention policy had accepted the event. Adds IdempotentAuditBackend to the contract: duplicate detection lives inside the backend so custody and idempotency state share a transaction and cannot diverge. SQLiteAuditBackend implements it with WAL, synchronous=FULL and a busy timeout. Ingestion now refuses any backend declaring durable=False, so the development file backend cannot silently become the production sink. The atomicity claim was tested rather than asserted, and the first attempt failed: with a single shared connection, 16 racing submissions of one event told two callers they were first. Storage was correct but the response was not. Fixed with per-thread connections and BEGIN IMMEDIATE around the insert/read pair, and locked in by a test. T02 - storage errors previously escaped the handler with start_response never called, and the auth check sat outside the try block so a non-ASCII Authorization header crashed the request. Adds a catch-all, maps conflict to 409, backend unavailability to 503 and unexpected faults to 500, and documents the full response contract with the retry semantics each status implies, since senders key their behaviour off it. T07 - ingestion tests 2 -> 23, suite 15 -> 36. accepted_at is now UTC rather than local time, and naive timestamps are rejected instead of silently assumed. Remaining in WP-0004: T03 tenant/source binding, T04 redaction policy, T05 operator read surface, T06 production serving layer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:30:20 +02:00
if path == "/healthz":
return self._json(start_response, HTTPStatus.OK, {"status": "ok"})
Route ingestion through the backend contract; fix error semantics AUDIT-WP-0004 T01, T02, T07. T01 - ingestion wrote to SQLite directly and never called the AuditBackend contract, so a 202 meant a row existed rather than that a backend with a declared retention policy had accepted the event. Adds IdempotentAuditBackend to the contract: duplicate detection lives inside the backend so custody and idempotency state share a transaction and cannot diverge. SQLiteAuditBackend implements it with WAL, synchronous=FULL and a busy timeout. Ingestion now refuses any backend declaring durable=False, so the development file backend cannot silently become the production sink. The atomicity claim was tested rather than asserted, and the first attempt failed: with a single shared connection, 16 racing submissions of one event told two callers they were first. Storage was correct but the response was not. Fixed with per-thread connections and BEGIN IMMEDIATE around the insert/read pair, and locked in by a test. T02 - storage errors previously escaped the handler with start_response never called, and the auth check sat outside the try block so a non-ASCII Authorization header crashed the request. Adds a catch-all, maps conflict to 409, backend unavailability to 503 and unexpected faults to 500, and documents the full response contract with the retry semantics each status implies, since senders key their behaviour off it. T07 - ingestion tests 2 -> 23, suite 15 -> 36. accepted_at is now UTC rather than local time, and naive timestamps are rejected instead of silently assumed. Remaining in WP-0004: T03 tenant/source binding, T04 redaction policy, T05 operator read surface, T06 production serving layer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:30:20 +02:00
if path == "/readyz":
return self._readiness(start_response)
Bind sender identities, add operator read surface, real serving layer AUDIT-WP-0004 T03, T05, T06. T03 - the receiver accepted whatever tenant and source a caller sent as long as it held the one shared token, despite WP-0003 recording tenant isolation as delivered. audit_core.senders binds each credential to the sources and tenants it may assert, driven by AUDIT_CORE_SENDERS rather than literals. Identities hold a list of tokens so rotation publishes the replacement alongside the incumbent and needs no delivery gap. Read is a separate privilege from write, so a sender credential cannot read the audit trail back. T05 - lookup by event id, lookup by correlation id, and a dead-letter view. Rejections are recorded rather than silently dropped. An event rejected for carrying secret-shaped material has its payload withheld: storing it would write that material into the audit store, which is what the rejection exists to prevent. Reason and payload hash are kept so it stays traceable. Replay is deliberately not built here. Idempotent replay is a property of the durable store and building it against SQLite would produce a second implementation to throw away; it lands with the Postgres backend in AUDIT-WP-0005-T01. T06 - serving moves to waitress with configurable threads and channel timeout, installed in the image via the serve extra. Without it the entrypoint falls back to a threaded wsgiref server with a socket timeout and graceful shutdown on SIGTERM, and logs a warning so a deployment cannot quietly land on the fallback. Metric counters deferred to WP-0005-T03 to be designed against the real scrape path. Tests 36 -> 46, covering cross-tenant and cross-source refusal, token rotation, read/write privilege separation, correlation lookup, and payload withholding on secret rejection. Remaining in WP-0004: T04 redaction policy, which needs a decision on whether a secret-shaped field is a rejection or a redaction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:50:02 +02:00
method = environ.get("REQUEST_METHOD")
identity = self.senders.authenticate(environ.get("HTTP_AUTHORIZATION"))
if identity is None:
Add deployment manifests, custody-class guard and request counters AUDIT-WP-0005-T03 (progress). Manifests validated --dry-run=server --validate=strict against railiance01; not applied, since deployment is gated on RAPP-POSTGRES-WP-0002 and T02 credentials. Nothing here mutates the cluster. Conventions read off the deployed user-engine workload rather than invented: digest-pinned image from forgejo.coulomb.social, runAsNonRoot with RuntimeDefault seccomp, no privilege escalation, all capabilities dropped, readOnlyRootFilesystem, probes on a named http port, same resource envelope. The namespace carries railiance.io/postgres-client: platform-pg, which is what platform-pg-consumer-ingress in rapp-postgres admits; without that label the pod cannot reach the database at all. NetworkPolicies default-deny both directions, then permit ingress from the user-engine namespace only, a separately labelled operator read path, and egress to PostgreSQL in databases plus DNS. Three decisions worth naming. Liveness is /healthz while readiness is /readyz, so a database outage drops the pod from the Service rather than restarting it in a loop. readOnlyRootFilesystem enforces the empty-filesystem property rather than trusting it, so the SQLite fallback physically cannot accumulate audit records on ephemeral storage. AUDIT_CORE_REQUIRE_CUSTODY_CLASS=archive makes a missing database URL a startup failure instead of a silent downgrade to the development store. Counters deferred from WP-0004-T06 are exposed as JSON at /v1/stats behind the read privilege, not as Prometheus exposition format: the cluster runs no Prometheus, no ServiceMonitor CRD and no other scrape target, so an exposition endpoint would target a scrape path that does not exist. Usable with curl now and a small step from /metrics later. Tests 77 -> 80. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:42:43 +02:00
self.counters.hit("unauthorized")
Route ingestion through the backend contract; fix error semantics AUDIT-WP-0004 T01, T02, T07. T01 - ingestion wrote to SQLite directly and never called the AuditBackend contract, so a 202 meant a row existed rather than that a backend with a declared retention policy had accepted the event. Adds IdempotentAuditBackend to the contract: duplicate detection lives inside the backend so custody and idempotency state share a transaction and cannot diverge. SQLiteAuditBackend implements it with WAL, synchronous=FULL and a busy timeout. Ingestion now refuses any backend declaring durable=False, so the development file backend cannot silently become the production sink. The atomicity claim was tested rather than asserted, and the first attempt failed: with a single shared connection, 16 racing submissions of one event told two callers they were first. Storage was correct but the response was not. Fixed with per-thread connections and BEGIN IMMEDIATE around the insert/read pair, and locked in by a test. T02 - storage errors previously escaped the handler with start_response never called, and the auth check sat outside the try block so a non-ASCII Authorization header crashed the request. Adds a catch-all, maps conflict to 409, backend unavailability to 503 and unexpected faults to 500, and documents the full response contract with the retry semantics each status implies, since senders key their behaviour off it. T07 - ingestion tests 2 -> 23, suite 15 -> 36. accepted_at is now UTC rather than local time, and naive timestamps are rejected instead of silently assumed. Remaining in WP-0004: T03 tenant/source binding, T04 redaction policy, T05 operator read surface, T06 production serving layer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:30:20 +02:00
return self._json(
start_response, HTTPStatus.UNAUTHORIZED, {"error": "unauthorized"}
)
2026-08-10 16:02:22 +02:00
if method == "GET" and (
path.startswith("/v1/events")
or path in (
"/v1/dead-letters",
"/v1/secret-findings",
"/v1/stats",
"/v1/integrity",
)
2026-08-10 16:02:22 +02:00
):
Bind sender identities, add operator read surface, real serving layer AUDIT-WP-0004 T03, T05, T06. T03 - the receiver accepted whatever tenant and source a caller sent as long as it held the one shared token, despite WP-0003 recording tenant isolation as delivered. audit_core.senders binds each credential to the sources and tenants it may assert, driven by AUDIT_CORE_SENDERS rather than literals. Identities hold a list of tokens so rotation publishes the replacement alongside the incumbent and needs no delivery gap. Read is a separate privilege from write, so a sender credential cannot read the audit trail back. T05 - lookup by event id, lookup by correlation id, and a dead-letter view. Rejections are recorded rather than silently dropped. An event rejected for carrying secret-shaped material has its payload withheld: storing it would write that material into the audit store, which is what the rejection exists to prevent. Reason and payload hash are kept so it stays traceable. Replay is deliberately not built here. Idempotent replay is a property of the durable store and building it against SQLite would produce a second implementation to throw away; it lands with the Postgres backend in AUDIT-WP-0005-T01. T06 - serving moves to waitress with configurable threads and channel timeout, installed in the image via the serve extra. Without it the entrypoint falls back to a threaded wsgiref server with a socket timeout and graceful shutdown on SIGTERM, and logs a warning so a deployment cannot quietly land on the fallback. Metric counters deferred to WP-0005-T03 to be designed against the real scrape path. Tests 36 -> 46, covering cross-tenant and cross-source refusal, token rotation, read/write privilege separation, correlation lookup, and payload withholding on secret rejection. Remaining in WP-0004: T04 redaction policy, which needs a decision on whether a secret-shaped field is a rejection or a redaction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:50:02 +02:00
return self._read(start_response, environ, path, identity)
if path != "/v1/events" or method != "POST":
return self._json(start_response, HTTPStatus.NOT_FOUND, {"error": "not_found"})
if not identity.may_write:
Add deployment manifests, custody-class guard and request counters AUDIT-WP-0005-T03 (progress). Manifests validated --dry-run=server --validate=strict against railiance01; not applied, since deployment is gated on RAPP-POSTGRES-WP-0002 and T02 credentials. Nothing here mutates the cluster. Conventions read off the deployed user-engine workload rather than invented: digest-pinned image from forgejo.coulomb.social, runAsNonRoot with RuntimeDefault seccomp, no privilege escalation, all capabilities dropped, readOnlyRootFilesystem, probes on a named http port, same resource envelope. The namespace carries railiance.io/postgres-client: platform-pg, which is what platform-pg-consumer-ingress in rapp-postgres admits; without that label the pod cannot reach the database at all. NetworkPolicies default-deny both directions, then permit ingress from the user-engine namespace only, a separately labelled operator read path, and egress to PostgreSQL in databases plus DNS. Three decisions worth naming. Liveness is /healthz while readiness is /readyz, so a database outage drops the pod from the Service rather than restarting it in a loop. readOnlyRootFilesystem enforces the empty-filesystem property rather than trusting it, so the SQLite fallback physically cannot accumulate audit records on ephemeral storage. AUDIT_CORE_REQUIRE_CUSTODY_CLASS=archive makes a missing database URL a startup failure instead of a silent downgrade to the development store. Counters deferred from WP-0004-T06 are exposed as JSON at /v1/stats behind the read privilege, not as Prometheus exposition format: the cluster runs no Prometheus, no ServiceMonitor CRD and no other scrape target, so an exposition endpoint would target a scrape path that does not exist. Usable with curl now and a small step from /metrics later. Tests 77 -> 80. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:42:43 +02:00
self.counters.hit("forbidden")
Bind sender identities, add operator read surface, real serving layer AUDIT-WP-0004 T03, T05, T06. T03 - the receiver accepted whatever tenant and source a caller sent as long as it held the one shared token, despite WP-0003 recording tenant isolation as delivered. audit_core.senders binds each credential to the sources and tenants it may assert, driven by AUDIT_CORE_SENDERS rather than literals. Identities hold a list of tokens so rotation publishes the replacement alongside the incumbent and needs no delivery gap. Read is a separate privilege from write, so a sender credential cannot read the audit trail back. T05 - lookup by event id, lookup by correlation id, and a dead-letter view. Rejections are recorded rather than silently dropped. An event rejected for carrying secret-shaped material has its payload withheld: storing it would write that material into the audit store, which is what the rejection exists to prevent. Reason and payload hash are kept so it stays traceable. Replay is deliberately not built here. Idempotent replay is a property of the durable store and building it against SQLite would produce a second implementation to throw away; it lands with the Postgres backend in AUDIT-WP-0005-T01. T06 - serving moves to waitress with configurable threads and channel timeout, installed in the image via the serve extra. Without it the entrypoint falls back to a threaded wsgiref server with a socket timeout and graceful shutdown on SIGTERM, and logs a warning so a deployment cannot quietly land on the fallback. Metric counters deferred to WP-0005-T03 to be designed against the real scrape path. Tests 36 -> 46, covering cross-tenant and cross-source refusal, token rotation, read/write privilege separation, correlation lookup, and payload withholding on secret rejection. Remaining in WP-0004: T04 redaction policy, which needs a decision on whether a secret-shaped field is a rejection or a redaction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:50:02 +02:00
return self._json(start_response, HTTPStatus.FORBIDDEN, {"error": "write_forbidden"})
raw = b""
2026-08-10 16:02:22 +02:00
payload: Any = {}
Route ingestion through the backend contract; fix error semantics AUDIT-WP-0004 T01, T02, T07. T01 - ingestion wrote to SQLite directly and never called the AuditBackend contract, so a 202 meant a row existed rather than that a backend with a declared retention policy had accepted the event. Adds IdempotentAuditBackend to the contract: duplicate detection lives inside the backend so custody and idempotency state share a transaction and cannot diverge. SQLiteAuditBackend implements it with WAL, synchronous=FULL and a busy timeout. Ingestion now refuses any backend declaring durable=False, so the development file backend cannot silently become the production sink. The atomicity claim was tested rather than asserted, and the first attempt failed: with a single shared connection, 16 racing submissions of one event told two callers they were first. Storage was correct but the response was not. Fixed with per-thread connections and BEGIN IMMEDIATE around the insert/read pair, and locked in by a test. T02 - storage errors previously escaped the handler with start_response never called, and the auth check sat outside the try block so a non-ASCII Authorization header crashed the request. Adds a catch-all, maps conflict to 409, backend unavailability to 503 and unexpected faults to 500, and documents the full response contract with the retry semantics each status implies, since senders key their behaviour off it. T07 - ingestion tests 2 -> 23, suite 15 -> 36. accepted_at is now UTC rather than local time, and naive timestamps are rejected instead of silently assumed. Remaining in WP-0004: T03 tenant/source binding, T04 redaction policy, T05 operator read surface, T06 production serving layer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:30:20 +02:00
try:
raw = self._read_body(environ)
2026-08-10 16:02:22 +02:00
payload = json.loads(raw)
event = normalize(payload, environ.get("HTTP_IDEMPOTENCY_KEY"), identity)
except SecretFieldRejection as exc:
self._count_secrets(payload, identity, "rejected", exc.findings)
self._dead_letter(raw, str(exc), identity)
Add deployment manifests, custody-class guard and request counters AUDIT-WP-0005-T03 (progress). Manifests validated --dry-run=server --validate=strict against railiance01; not applied, since deployment is gated on RAPP-POSTGRES-WP-0002 and T02 credentials. Nothing here mutates the cluster. Conventions read off the deployed user-engine workload rather than invented: digest-pinned image from forgejo.coulomb.social, runAsNonRoot with RuntimeDefault seccomp, no privilege escalation, all capabilities dropped, readOnlyRootFilesystem, probes on a named http port, same resource envelope. The namespace carries railiance.io/postgres-client: platform-pg, which is what platform-pg-consumer-ingress in rapp-postgres admits; without that label the pod cannot reach the database at all. NetworkPolicies default-deny both directions, then permit ingress from the user-engine namespace only, a separately labelled operator read path, and egress to PostgreSQL in databases plus DNS. Three decisions worth naming. Liveness is /healthz while readiness is /readyz, so a database outage drops the pod from the Service rather than restarting it in a loop. readOnlyRootFilesystem enforces the empty-filesystem property rather than trusting it, so the SQLite fallback physically cannot accumulate audit records on ephemeral storage. AUDIT_CORE_REQUIRE_CUSTODY_CLASS=archive makes a missing database URL a startup failure instead of a silent downgrade to the development store. Counters deferred from WP-0004-T06 are exposed as JSON at /v1/stats behind the read privilege, not as Prometheus exposition format: the cluster runs no Prometheus, no ServiceMonitor CRD and no other scrape target, so an exposition endpoint would target a scrape path that does not exist. Usable with curl now and a small step from /metrics later. Tests 77 -> 80. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:42:43 +02:00
self.counters.hit("rejected")
2026-08-10 16:02:22 +02:00
return self._json(start_response, HTTPStatus.BAD_REQUEST, {"error": str(exc)})
Route ingestion through the backend contract; fix error semantics AUDIT-WP-0004 T01, T02, T07. T01 - ingestion wrote to SQLite directly and never called the AuditBackend contract, so a 202 meant a row existed rather than that a backend with a declared retention policy had accepted the event. Adds IdempotentAuditBackend to the contract: duplicate detection lives inside the backend so custody and idempotency state share a transaction and cannot diverge. SQLiteAuditBackend implements it with WAL, synchronous=FULL and a busy timeout. Ingestion now refuses any backend declaring durable=False, so the development file backend cannot silently become the production sink. The atomicity claim was tested rather than asserted, and the first attempt failed: with a single shared connection, 16 racing submissions of one event told two callers they were first. Storage was correct but the response was not. Fixed with per-thread connections and BEGIN IMMEDIATE around the insert/read pair, and locked in by a test. T02 - storage errors previously escaped the handler with start_response never called, and the auth check sat outside the try block so a non-ASCII Authorization header crashed the request. Adds a catch-all, maps conflict to 409, backend unavailability to 503 and unexpected faults to 500, and documents the full response contract with the retry semantics each status implies, since senders key their behaviour off it. T07 - ingestion tests 2 -> 23, suite 15 -> 36. accepted_at is now UTC rather than local time, and naive timestamps are rejected instead of silently assumed. Remaining in WP-0004: T03 tenant/source binding, T04 redaction policy, T05 operator read surface, T06 production serving layer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:30:20 +02:00
except (ValueError, TypeError, KeyError, json.JSONDecodeError) as exc:
Bind sender identities, add operator read surface, real serving layer AUDIT-WP-0004 T03, T05, T06. T03 - the receiver accepted whatever tenant and source a caller sent as long as it held the one shared token, despite WP-0003 recording tenant isolation as delivered. audit_core.senders binds each credential to the sources and tenants it may assert, driven by AUDIT_CORE_SENDERS rather than literals. Identities hold a list of tokens so rotation publishes the replacement alongside the incumbent and needs no delivery gap. Read is a separate privilege from write, so a sender credential cannot read the audit trail back. T05 - lookup by event id, lookup by correlation id, and a dead-letter view. Rejections are recorded rather than silently dropped. An event rejected for carrying secret-shaped material has its payload withheld: storing it would write that material into the audit store, which is what the rejection exists to prevent. Reason and payload hash are kept so it stays traceable. Replay is deliberately not built here. Idempotent replay is a property of the durable store and building it against SQLite would produce a second implementation to throw away; it lands with the Postgres backend in AUDIT-WP-0005-T01. T06 - serving moves to waitress with configurable threads and channel timeout, installed in the image via the serve extra. Without it the entrypoint falls back to a threaded wsgiref server with a socket timeout and graceful shutdown on SIGTERM, and logs a warning so a deployment cannot quietly land on the fallback. Metric counters deferred to WP-0005-T03 to be designed against the real scrape path. Tests 36 -> 46, covering cross-tenant and cross-source refusal, token rotation, read/write privilege separation, correlation lookup, and payload withholding on secret rejection. Remaining in WP-0004: T04 redaction policy, which needs a decision on whether a secret-shaped field is a rejection or a redaction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:50:02 +02:00
self._dead_letter(raw, str(exc), identity)
Add deployment manifests, custody-class guard and request counters AUDIT-WP-0005-T03 (progress). Manifests validated --dry-run=server --validate=strict against railiance01; not applied, since deployment is gated on RAPP-POSTGRES-WP-0002 and T02 credentials. Nothing here mutates the cluster. Conventions read off the deployed user-engine workload rather than invented: digest-pinned image from forgejo.coulomb.social, runAsNonRoot with RuntimeDefault seccomp, no privilege escalation, all capabilities dropped, readOnlyRootFilesystem, probes on a named http port, same resource envelope. The namespace carries railiance.io/postgres-client: platform-pg, which is what platform-pg-consumer-ingress in rapp-postgres admits; without that label the pod cannot reach the database at all. NetworkPolicies default-deny both directions, then permit ingress from the user-engine namespace only, a separately labelled operator read path, and egress to PostgreSQL in databases plus DNS. Three decisions worth naming. Liveness is /healthz while readiness is /readyz, so a database outage drops the pod from the Service rather than restarting it in a loop. readOnlyRootFilesystem enforces the empty-filesystem property rather than trusting it, so the SQLite fallback physically cannot accumulate audit records on ephemeral storage. AUDIT_CORE_REQUIRE_CUSTODY_CLASS=archive makes a missing database URL a startup failure instead of a silent downgrade to the development store. Counters deferred from WP-0004-T06 are exposed as JSON at /v1/stats behind the read privilege, not as Prometheus exposition format: the cluster runs no Prometheus, no ServiceMonitor CRD and no other scrape target, so an exposition endpoint would target a scrape path that does not exist. Usable with curl now and a small step from /metrics later. Tests 77 -> 80. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:42:43 +02:00
self.counters.hit("rejected")
Route ingestion through the backend contract; fix error semantics AUDIT-WP-0004 T01, T02, T07. T01 - ingestion wrote to SQLite directly and never called the AuditBackend contract, so a 202 meant a row existed rather than that a backend with a declared retention policy had accepted the event. Adds IdempotentAuditBackend to the contract: duplicate detection lives inside the backend so custody and idempotency state share a transaction and cannot diverge. SQLiteAuditBackend implements it with WAL, synchronous=FULL and a busy timeout. Ingestion now refuses any backend declaring durable=False, so the development file backend cannot silently become the production sink. The atomicity claim was tested rather than asserted, and the first attempt failed: with a single shared connection, 16 racing submissions of one event told two callers they were first. Storage was correct but the response was not. Fixed with per-thread connections and BEGIN IMMEDIATE around the insert/read pair, and locked in by a test. T02 - storage errors previously escaped the handler with start_response never called, and the auth check sat outside the try block so a non-ASCII Authorization header crashed the request. Adds a catch-all, maps conflict to 409, backend unavailability to 503 and unexpected faults to 500, and documents the full response contract with the retry semantics each status implies, since senders key their behaviour off it. T07 - ingestion tests 2 -> 23, suite 15 -> 36. accepted_at is now UTC rather than local time, and naive timestamps are rejected instead of silently assumed. Remaining in WP-0004: T03 tenant/source binding, T04 redaction policy, T05 operator read surface, T06 production serving layer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:30:20 +02:00
return self._json(start_response, HTTPStatus.BAD_REQUEST, {"error": str(exc)})
2026-08-10 16:02:22 +02:00
redaction = event.details.get("redaction")
if redaction:
self._count_secrets(
payload, identity, "redacted",
[finding_from_path(p) for p in redaction["paths"]],
)
Route ingestion through the backend contract; fix error semantics AUDIT-WP-0004 T01, T02, T07. T01 - ingestion wrote to SQLite directly and never called the AuditBackend contract, so a 202 meant a row existed rather than that a backend with a declared retention policy had accepted the event. Adds IdempotentAuditBackend to the contract: duplicate detection lives inside the backend so custody and idempotency state share a transaction and cannot diverge. SQLiteAuditBackend implements it with WAL, synchronous=FULL and a busy timeout. Ingestion now refuses any backend declaring durable=False, so the development file backend cannot silently become the production sink. The atomicity claim was tested rather than asserted, and the first attempt failed: with a single shared connection, 16 racing submissions of one event told two callers they were first. Storage was correct but the response was not. Fixed with per-thread connections and BEGIN IMMEDIATE around the insert/read pair, and locked in by a test. T02 - storage errors previously escaped the handler with start_response never called, and the auth check sat outside the try block so a non-ASCII Authorization header crashed the request. Adds a catch-all, maps conflict to 409, backend unavailability to 503 and unexpected faults to 500, and documents the full response contract with the retry semantics each status implies, since senders key their behaviour off it. T07 - ingestion tests 2 -> 23, suite 15 -> 36. accepted_at is now UTC rather than local time, and naive timestamps are rejected instead of silently assumed. Remaining in WP-0004: T03 tenant/source binding, T04 redaction policy, T05 operator read surface, T06 production serving layer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:30:20 +02:00
try:
result = self.backend.accept(event, hashlib.sha256(raw).hexdigest())
except EventConflictError as exc:
log.warning("event conflict: %s", exc)
Add deployment manifests, custody-class guard and request counters AUDIT-WP-0005-T03 (progress). Manifests validated --dry-run=server --validate=strict against railiance01; not applied, since deployment is gated on RAPP-POSTGRES-WP-0002 and T02 credentials. Nothing here mutates the cluster. Conventions read off the deployed user-engine workload rather than invented: digest-pinned image from forgejo.coulomb.social, runAsNonRoot with RuntimeDefault seccomp, no privilege escalation, all capabilities dropped, readOnlyRootFilesystem, probes on a named http port, same resource envelope. The namespace carries railiance.io/postgres-client: platform-pg, which is what platform-pg-consumer-ingress in rapp-postgres admits; without that label the pod cannot reach the database at all. NetworkPolicies default-deny both directions, then permit ingress from the user-engine namespace only, a separately labelled operator read path, and egress to PostgreSQL in databases plus DNS. Three decisions worth naming. Liveness is /healthz while readiness is /readyz, so a database outage drops the pod from the Service rather than restarting it in a loop. readOnlyRootFilesystem enforces the empty-filesystem property rather than trusting it, so the SQLite fallback physically cannot accumulate audit records on ephemeral storage. AUDIT_CORE_REQUIRE_CUSTODY_CLASS=archive makes a missing database URL a startup failure instead of a silent downgrade to the development store. Counters deferred from WP-0004-T06 are exposed as JSON at /v1/stats behind the read privilege, not as Prometheus exposition format: the cluster runs no Prometheus, no ServiceMonitor CRD and no other scrape target, so an exposition endpoint would target a scrape path that does not exist. Usable with curl now and a small step from /metrics later. Tests 77 -> 80. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:42:43 +02:00
self.counters.hit("conflict")
Route ingestion through the backend contract; fix error semantics AUDIT-WP-0004 T01, T02, T07. T01 - ingestion wrote to SQLite directly and never called the AuditBackend contract, so a 202 meant a row existed rather than that a backend with a declared retention policy had accepted the event. Adds IdempotentAuditBackend to the contract: duplicate detection lives inside the backend so custody and idempotency state share a transaction and cannot diverge. SQLiteAuditBackend implements it with WAL, synchronous=FULL and a busy timeout. Ingestion now refuses any backend declaring durable=False, so the development file backend cannot silently become the production sink. The atomicity claim was tested rather than asserted, and the first attempt failed: with a single shared connection, 16 racing submissions of one event told two callers they were first. Storage was correct but the response was not. Fixed with per-thread connections and BEGIN IMMEDIATE around the insert/read pair, and locked in by a test. T02 - storage errors previously escaped the handler with start_response never called, and the auth check sat outside the try block so a non-ASCII Authorization header crashed the request. Adds a catch-all, maps conflict to 409, backend unavailability to 503 and unexpected faults to 500, and documents the full response contract with the retry semantics each status implies, since senders key their behaviour off it. T07 - ingestion tests 2 -> 23, suite 15 -> 36. accepted_at is now UTC rather than local time, and naive timestamps are rejected instead of silently assumed. Remaining in WP-0004: T03 tenant/source binding, T04 redaction policy, T05 operator read surface, T06 production serving layer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:30:20 +02:00
return self._json(start_response, HTTPStatus.CONFLICT, {"error": "event_id_conflict"})
except EventValidationError as exc:
return self._json(start_response, HTTPStatus.BAD_REQUEST, {"error": str(exc)})
except BackendUnavailableError as exc:
log.error("backend unavailable: %s", exc)
Add deployment manifests, custody-class guard and request counters AUDIT-WP-0005-T03 (progress). Manifests validated --dry-run=server --validate=strict against railiance01; not applied, since deployment is gated on RAPP-POSTGRES-WP-0002 and T02 credentials. Nothing here mutates the cluster. Conventions read off the deployed user-engine workload rather than invented: digest-pinned image from forgejo.coulomb.social, runAsNonRoot with RuntimeDefault seccomp, no privilege escalation, all capabilities dropped, readOnlyRootFilesystem, probes on a named http port, same resource envelope. The namespace carries railiance.io/postgres-client: platform-pg, which is what platform-pg-consumer-ingress in rapp-postgres admits; without that label the pod cannot reach the database at all. NetworkPolicies default-deny both directions, then permit ingress from the user-engine namespace only, a separately labelled operator read path, and egress to PostgreSQL in databases plus DNS. Three decisions worth naming. Liveness is /healthz while readiness is /readyz, so a database outage drops the pod from the Service rather than restarting it in a loop. readOnlyRootFilesystem enforces the empty-filesystem property rather than trusting it, so the SQLite fallback physically cannot accumulate audit records on ephemeral storage. AUDIT_CORE_REQUIRE_CUSTODY_CLASS=archive makes a missing database URL a startup failure instead of a silent downgrade to the development store. Counters deferred from WP-0004-T06 are exposed as JSON at /v1/stats behind the read privilege, not as Prometheus exposition format: the cluster runs no Prometheus, no ServiceMonitor CRD and no other scrape target, so an exposition endpoint would target a scrape path that does not exist. Usable with curl now and a small step from /metrics later. Tests 77 -> 80. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:42:43 +02:00
self.counters.hit("unavailable")
Route ingestion through the backend contract; fix error semantics AUDIT-WP-0004 T01, T02, T07. T01 - ingestion wrote to SQLite directly and never called the AuditBackend contract, so a 202 meant a row existed rather than that a backend with a declared retention policy had accepted the event. Adds IdempotentAuditBackend to the contract: duplicate detection lives inside the backend so custody and idempotency state share a transaction and cannot diverge. SQLiteAuditBackend implements it with WAL, synchronous=FULL and a busy timeout. Ingestion now refuses any backend declaring durable=False, so the development file backend cannot silently become the production sink. The atomicity claim was tested rather than asserted, and the first attempt failed: with a single shared connection, 16 racing submissions of one event told two callers they were first. Storage was correct but the response was not. Fixed with per-thread connections and BEGIN IMMEDIATE around the insert/read pair, and locked in by a test. T02 - storage errors previously escaped the handler with start_response never called, and the auth check sat outside the try block so a non-ASCII Authorization header crashed the request. Adds a catch-all, maps conflict to 409, backend unavailability to 503 and unexpected faults to 500, and documents the full response contract with the retry semantics each status implies, since senders key their behaviour off it. T07 - ingestion tests 2 -> 23, suite 15 -> 36. accepted_at is now UTC rather than local time, and naive timestamps are rejected instead of silently assumed. Remaining in WP-0004: T03 tenant/source binding, T04 redaction policy, T05 operator read surface, T06 production serving layer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:30:20 +02:00
return self._json(
start_response, HTTPStatus.SERVICE_UNAVAILABLE, {"error": "backend_unavailable"}
)
Add deployment manifests, custody-class guard and request counters AUDIT-WP-0005-T03 (progress). Manifests validated --dry-run=server --validate=strict against railiance01; not applied, since deployment is gated on RAPP-POSTGRES-WP-0002 and T02 credentials. Nothing here mutates the cluster. Conventions read off the deployed user-engine workload rather than invented: digest-pinned image from forgejo.coulomb.social, runAsNonRoot with RuntimeDefault seccomp, no privilege escalation, all capabilities dropped, readOnlyRootFilesystem, probes on a named http port, same resource envelope. The namespace carries railiance.io/postgres-client: platform-pg, which is what platform-pg-consumer-ingress in rapp-postgres admits; without that label the pod cannot reach the database at all. NetworkPolicies default-deny both directions, then permit ingress from the user-engine namespace only, a separately labelled operator read path, and egress to PostgreSQL in databases plus DNS. Three decisions worth naming. Liveness is /healthz while readiness is /readyz, so a database outage drops the pod from the Service rather than restarting it in a loop. readOnlyRootFilesystem enforces the empty-filesystem property rather than trusting it, so the SQLite fallback physically cannot accumulate audit records on ephemeral storage. AUDIT_CORE_REQUIRE_CUSTODY_CLASS=archive makes a missing database URL a startup failure instead of a silent downgrade to the development store. Counters deferred from WP-0004-T06 are exposed as JSON at /v1/stats behind the read privilege, not as Prometheus exposition format: the cluster runs no Prometheus, no ServiceMonitor CRD and no other scrape target, so an exposition endpoint would target a scrape path that does not exist. Usable with curl now and a small step from /metrics later. Tests 77 -> 80. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:42:43 +02:00
self.counters.hit("duplicate" if result.duplicate else "accepted")
Route ingestion through the backend contract; fix error semantics AUDIT-WP-0004 T01, T02, T07. T01 - ingestion wrote to SQLite directly and never called the AuditBackend contract, so a 202 meant a row existed rather than that a backend with a declared retention policy had accepted the event. Adds IdempotentAuditBackend to the contract: duplicate detection lives inside the backend so custody and idempotency state share a transaction and cannot diverge. SQLiteAuditBackend implements it with WAL, synchronous=FULL and a busy timeout. Ingestion now refuses any backend declaring durable=False, so the development file backend cannot silently become the production sink. The atomicity claim was tested rather than asserted, and the first attempt failed: with a single shared connection, 16 racing submissions of one event told two callers they were first. Storage was correct but the response was not. Fixed with per-thread connections and BEGIN IMMEDIATE around the insert/read pair, and locked in by a test. T02 - storage errors previously escaped the handler with start_response never called, and the auth check sat outside the try block so a non-ASCII Authorization header crashed the request. Adds a catch-all, maps conflict to 409, backend unavailability to 503 and unexpected faults to 500, and documents the full response contract with the retry semantics each status implies, since senders key their behaviour off it. T07 - ingestion tests 2 -> 23, suite 15 -> 36. accepted_at is now UTC rather than local time, and naive timestamps are rejected instead of silently assumed. Remaining in WP-0004: T03 tenant/source binding, T04 redaction policy, T05 operator read surface, T06 production serving layer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:30:20 +02:00
return self._json(
start_response,
HTTPStatus.OK if result.duplicate else HTTPStatus.ACCEPTED,
{
"status": "duplicate" if result.duplicate else "accepted",
"reference": result.reference,
},
)
Bind sender identities, add operator read surface, real serving layer AUDIT-WP-0004 T03, T05, T06. T03 - the receiver accepted whatever tenant and source a caller sent as long as it held the one shared token, despite WP-0003 recording tenant isolation as delivered. audit_core.senders binds each credential to the sources and tenants it may assert, driven by AUDIT_CORE_SENDERS rather than literals. Identities hold a list of tokens so rotation publishes the replacement alongside the incumbent and needs no delivery gap. Read is a separate privilege from write, so a sender credential cannot read the audit trail back. T05 - lookup by event id, lookup by correlation id, and a dead-letter view. Rejections are recorded rather than silently dropped. An event rejected for carrying secret-shaped material has its payload withheld: storing it would write that material into the audit store, which is what the rejection exists to prevent. Reason and payload hash are kept so it stays traceable. Replay is deliberately not built here. Idempotent replay is a property of the durable store and building it against SQLite would produce a second implementation to throw away; it lands with the Postgres backend in AUDIT-WP-0005-T01. T06 - serving moves to waitress with configurable threads and channel timeout, installed in the image via the serve extra. Without it the entrypoint falls back to a threaded wsgiref server with a socket timeout and graceful shutdown on SIGTERM, and logs a warning so a deployment cannot quietly land on the fallback. Metric counters deferred to WP-0005-T03 to be designed against the real scrape path. Tests 36 -> 46, covering cross-tenant and cross-source refusal, token rotation, read/write privilege separation, correlation lookup, and payload withholding on secret rejection. Remaining in WP-0004: T04 redaction policy, which needs a decision on whether a secret-shaped field is a rejection or a redaction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:50:02 +02:00
def _read(self, start_response, environ, path: str, identity):
"""Operator read surface (AUDIT-WP-0004-T05, AUDIT-WP-0008-T04).
Bind sender identities, add operator read surface, real serving layer AUDIT-WP-0004 T03, T05, T06. T03 - the receiver accepted whatever tenant and source a caller sent as long as it held the one shared token, despite WP-0003 recording tenant isolation as delivered. audit_core.senders binds each credential to the sources and tenants it may assert, driven by AUDIT_CORE_SENDERS rather than literals. Identities hold a list of tokens so rotation publishes the replacement alongside the incumbent and needs no delivery gap. Read is a separate privilege from write, so a sender credential cannot read the audit trail back. T05 - lookup by event id, lookup by correlation id, and a dead-letter view. Rejections are recorded rather than silently dropped. An event rejected for carrying secret-shaped material has its payload withheld: storing it would write that material into the audit store, which is what the rejection exists to prevent. Reason and payload hash are kept so it stays traceable. Replay is deliberately not built here. Idempotent replay is a property of the durable store and building it against SQLite would produce a second implementation to throw away; it lands with the Postgres backend in AUDIT-WP-0005-T01. T06 - serving moves to waitress with configurable threads and channel timeout, installed in the image via the serve extra. Without it the entrypoint falls back to a threaded wsgiref server with a socket timeout and graceful shutdown on SIGTERM, and logs a warning so a deployment cannot quietly land on the fallback. Metric counters deferred to WP-0005-T03 to be designed against the real scrape path. Tests 36 -> 46, covering cross-tenant and cross-source refusal, token rotation, read/write privilege separation, correlation lookup, and payload withholding on secret rejection. Remaining in WP-0004: T04 redaction policy, which needs a decision on whether a secret-shaped field is a rejection or a redaction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:50:02 +02:00
Read is a distinct privilege from write: a sender credential must not
be able to read the audit trail back.
Read is also tenant-scoped. Until AUDIT-WP-0008-T04 this method gated
on ``may_read`` alone and never consulted ``permits_tenant``, so any
reader could read every tenant. Deployment bounded the exposure the
production sender holds ``may_read: false`` but the boundary was not
in the code, which is the difference between E2 and E1 in the tenancy
posture (framework §4.3).
Two rules, because the surfaces divide cleanly:
* Event reads are filtered to the tenants the credential may act for.
* Surfaces that are not tenant-keyed chain verification, counters,
dead letters, secret findings require full tenant scope. They
cannot be filtered, so a scoped reader is refused rather than served
instance-wide facts.
Bind sender identities, add operator read surface, real serving layer AUDIT-WP-0004 T03, T05, T06. T03 - the receiver accepted whatever tenant and source a caller sent as long as it held the one shared token, despite WP-0003 recording tenant isolation as delivered. audit_core.senders binds each credential to the sources and tenants it may assert, driven by AUDIT_CORE_SENDERS rather than literals. Identities hold a list of tokens so rotation publishes the replacement alongside the incumbent and needs no delivery gap. Read is a separate privilege from write, so a sender credential cannot read the audit trail back. T05 - lookup by event id, lookup by correlation id, and a dead-letter view. Rejections are recorded rather than silently dropped. An event rejected for carrying secret-shaped material has its payload withheld: storing it would write that material into the audit store, which is what the rejection exists to prevent. Reason and payload hash are kept so it stays traceable. Replay is deliberately not built here. Idempotent replay is a property of the durable store and building it against SQLite would produce a second implementation to throw away; it lands with the Postgres backend in AUDIT-WP-0005-T01. T06 - serving moves to waitress with configurable threads and channel timeout, installed in the image via the serve extra. Without it the entrypoint falls back to a threaded wsgiref server with a socket timeout and graceful shutdown on SIGTERM, and logs a warning so a deployment cannot quietly land on the fallback. Metric counters deferred to WP-0005-T03 to be designed against the real scrape path. Tests 36 -> 46, covering cross-tenant and cross-source refusal, token rotation, read/write privilege separation, correlation lookup, and payload withholding on secret rejection. Remaining in WP-0004: T04 redaction policy, which needs a decision on whether a secret-shaped field is a rejection or a redaction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:50:02 +02:00
"""
if not identity.may_read:
return self._json(start_response, HTTPStatus.FORBIDDEN, {"error": "read_forbidden"})
query = parse_qs(environ.get("QUERY_STRING", ""))
try:
if path in _UNSCOPED_READ_PATHS and not identity.has_full_tenant_scope():
# Refused rather than filtered: these carry no tenant key, so
# there is nothing to filter on and serving them to a scoped
# reader would leak across the boundary this method enforces.
return self._json(
start_response, HTTPStatus.FORBIDDEN,
{"error": "full_tenant_scope_required"},
)
Bind sender identities, add operator read surface, real serving layer AUDIT-WP-0004 T03, T05, T06. T03 - the receiver accepted whatever tenant and source a caller sent as long as it held the one shared token, despite WP-0003 recording tenant isolation as delivered. audit_core.senders binds each credential to the sources and tenants it may assert, driven by AUDIT_CORE_SENDERS rather than literals. Identities hold a list of tokens so rotation publishes the replacement alongside the incumbent and needs no delivery gap. Read is a separate privilege from write, so a sender credential cannot read the audit trail back. T05 - lookup by event id, lookup by correlation id, and a dead-letter view. Rejections are recorded rather than silently dropped. An event rejected for carrying secret-shaped material has its payload withheld: storing it would write that material into the audit store, which is what the rejection exists to prevent. Reason and payload hash are kept so it stays traceable. Replay is deliberately not built here. Idempotent replay is a property of the durable store and building it against SQLite would produce a second implementation to throw away; it lands with the Postgres backend in AUDIT-WP-0005-T01. T06 - serving moves to waitress with configurable threads and channel timeout, installed in the image via the serve extra. Without it the entrypoint falls back to a threaded wsgiref server with a socket timeout and graceful shutdown on SIGTERM, and logs a warning so a deployment cannot quietly land on the fallback. Metric counters deferred to WP-0005-T03 to be designed against the real scrape path. Tests 36 -> 46, covering cross-tenant and cross-source refusal, token rotation, read/write privilege separation, correlation lookup, and payload withholding on secret rejection. Remaining in WP-0004: T04 redaction policy, which needs a decision on whether a secret-shaped field is a rejection or a redaction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:50:02 +02:00
if path == "/v1/dead-letters":
return self._json(
start_response, HTTPStatus.OK,
{"dead_letters": self.backend.dead_letters(_limit(query))},
)
Add deployment manifests, custody-class guard and request counters AUDIT-WP-0005-T03 (progress). Manifests validated --dry-run=server --validate=strict against railiance01; not applied, since deployment is gated on RAPP-POSTGRES-WP-0002 and T02 credentials. Nothing here mutates the cluster. Conventions read off the deployed user-engine workload rather than invented: digest-pinned image from forgejo.coulomb.social, runAsNonRoot with RuntimeDefault seccomp, no privilege escalation, all capabilities dropped, readOnlyRootFilesystem, probes on a named http port, same resource envelope. The namespace carries railiance.io/postgres-client: platform-pg, which is what platform-pg-consumer-ingress in rapp-postgres admits; without that label the pod cannot reach the database at all. NetworkPolicies default-deny both directions, then permit ingress from the user-engine namespace only, a separately labelled operator read path, and egress to PostgreSQL in databases plus DNS. Three decisions worth naming. Liveness is /healthz while readiness is /readyz, so a database outage drops the pod from the Service rather than restarting it in a loop. readOnlyRootFilesystem enforces the empty-filesystem property rather than trusting it, so the SQLite fallback physically cannot accumulate audit records on ephemeral storage. AUDIT_CORE_REQUIRE_CUSTODY_CLASS=archive makes a missing database URL a startup failure instead of a silent downgrade to the development store. Counters deferred from WP-0004-T06 are exposed as JSON at /v1/stats behind the read privilege, not as Prometheus exposition format: the cluster runs no Prometheus, no ServiceMonitor CRD and no other scrape target, so an exposition endpoint would target a scrape path that does not exist. Usable with curl now and a small step from /metrics later. Tests 77 -> 80. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:42:43 +02:00
if path == "/v1/stats":
return self._json(start_response, HTTPStatus.OK, self.counters.snapshot())
2026-08-10 16:02:22 +02:00
if path == "/v1/secret-findings":
return self._json(
start_response, HTTPStatus.OK,
{"secret_findings": self.backend.secret_findings(_limit(query))},
)
if path == "/v1/integrity":
verify = getattr(self.backend, "verify_chain", None)
if not callable(verify):
return self._json(
start_response, HTTPStatus.NOT_FOUND,
{"error": "integrity_not_supported"},
)
return self._json(start_response, HTTPStatus.OK, verify().as_dict())
Bind sender identities, add operator read surface, real serving layer AUDIT-WP-0004 T03, T05, T06. T03 - the receiver accepted whatever tenant and source a caller sent as long as it held the one shared token, despite WP-0003 recording tenant isolation as delivered. audit_core.senders binds each credential to the sources and tenants it may assert, driven by AUDIT_CORE_SENDERS rather than literals. Identities hold a list of tokens so rotation publishes the replacement alongside the incumbent and needs no delivery gap. Read is a separate privilege from write, so a sender credential cannot read the audit trail back. T05 - lookup by event id, lookup by correlation id, and a dead-letter view. Rejections are recorded rather than silently dropped. An event rejected for carrying secret-shaped material has its payload withheld: storing it would write that material into the audit store, which is what the rejection exists to prevent. Reason and payload hash are kept so it stays traceable. Replay is deliberately not built here. Idempotent replay is a property of the durable store and building it against SQLite would produce a second implementation to throw away; it lands with the Postgres backend in AUDIT-WP-0005-T01. T06 - serving moves to waitress with configurable threads and channel timeout, installed in the image via the serve extra. Without it the entrypoint falls back to a threaded wsgiref server with a socket timeout and graceful shutdown on SIGTERM, and logs a warning so a deployment cannot quietly land on the fallback. Metric counters deferred to WP-0005-T03 to be designed against the real scrape path. Tests 36 -> 46, covering cross-tenant and cross-source refusal, token rotation, read/write privilege separation, correlation lookup, and payload withholding on secret rejection. Remaining in WP-0004: T04 redaction policy, which needs a decision on whether a secret-shaped field is a rejection or a redaction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:50:02 +02:00
if path == "/v1/events":
correlation = (query.get("correlation_id") or [""])[0]
if not correlation:
return self._json(
start_response, HTTPStatus.BAD_REQUEST,
{"error": "correlation_id_required"},
)
# A correlation id deliberately spans services, so a single
# correlation can carry events for more than one tenant.
# Filtering here rather than refusing keeps the surface useful
# to a scoped reader without widening what it sees.
events = [
event for event in self.backend.by_correlation(correlation, _limit(query))
if _readable_by(identity, event)
]
return self._json(start_response, HTTPStatus.OK, {"events": events})
Bind sender identities, add operator read surface, real serving layer AUDIT-WP-0004 T03, T05, T06. T03 - the receiver accepted whatever tenant and source a caller sent as long as it held the one shared token, despite WP-0003 recording tenant isolation as delivered. audit_core.senders binds each credential to the sources and tenants it may assert, driven by AUDIT_CORE_SENDERS rather than literals. Identities hold a list of tokens so rotation publishes the replacement alongside the incumbent and needs no delivery gap. Read is a separate privilege from write, so a sender credential cannot read the audit trail back. T05 - lookup by event id, lookup by correlation id, and a dead-letter view. Rejections are recorded rather than silently dropped. An event rejected for carrying secret-shaped material has its payload withheld: storing it would write that material into the audit store, which is what the rejection exists to prevent. Reason and payload hash are kept so it stays traceable. Replay is deliberately not built here. Idempotent replay is a property of the durable store and building it against SQLite would produce a second implementation to throw away; it lands with the Postgres backend in AUDIT-WP-0005-T01. T06 - serving moves to waitress with configurable threads and channel timeout, installed in the image via the serve extra. Without it the entrypoint falls back to a threaded wsgiref server with a socket timeout and graceful shutdown on SIGTERM, and logs a warning so a deployment cannot quietly land on the fallback. Metric counters deferred to WP-0005-T03 to be designed against the real scrape path. Tests 36 -> 46, covering cross-tenant and cross-source refusal, token rotation, read/write privilege separation, correlation lookup, and payload withholding on secret rejection. Remaining in WP-0004: T04 redaction policy, which needs a decision on whether a secret-shaped field is a rejection or a redaction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:50:02 +02:00
event_id = path[len("/v1/events/"):]
record = self.backend.get(event_id) if event_id else None
if record is not None and not _readable_by(identity, record):
# 404, not 403. A distinguishable "forbidden" would confirm
# that an event id exists and which tenant holds it, turning
# the read surface into an existence oracle.
record = None
Bind sender identities, add operator read surface, real serving layer AUDIT-WP-0004 T03, T05, T06. T03 - the receiver accepted whatever tenant and source a caller sent as long as it held the one shared token, despite WP-0003 recording tenant isolation as delivered. audit_core.senders binds each credential to the sources and tenants it may assert, driven by AUDIT_CORE_SENDERS rather than literals. Identities hold a list of tokens so rotation publishes the replacement alongside the incumbent and needs no delivery gap. Read is a separate privilege from write, so a sender credential cannot read the audit trail back. T05 - lookup by event id, lookup by correlation id, and a dead-letter view. Rejections are recorded rather than silently dropped. An event rejected for carrying secret-shaped material has its payload withheld: storing it would write that material into the audit store, which is what the rejection exists to prevent. Reason and payload hash are kept so it stays traceable. Replay is deliberately not built here. Idempotent replay is a property of the durable store and building it against SQLite would produce a second implementation to throw away; it lands with the Postgres backend in AUDIT-WP-0005-T01. T06 - serving moves to waitress with configurable threads and channel timeout, installed in the image via the serve extra. Without it the entrypoint falls back to a threaded wsgiref server with a socket timeout and graceful shutdown on SIGTERM, and logs a warning so a deployment cannot quietly land on the fallback. Metric counters deferred to WP-0005-T03 to be designed against the real scrape path. Tests 36 -> 46, covering cross-tenant and cross-source refusal, token rotation, read/write privilege separation, correlation lookup, and payload withholding on secret rejection. Remaining in WP-0004: T04 redaction policy, which needs a decision on whether a secret-shaped field is a rejection or a redaction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:50:02 +02:00
if record is None:
return self._json(start_response, HTTPStatus.NOT_FOUND, {"error": "not_found"})
return self._json(start_response, HTTPStatus.OK, record)
except BackendUnavailableError as exc:
log.error("read failed: %s", exc)
return self._json(
start_response, HTTPStatus.SERVICE_UNAVAILABLE, {"error": "backend_unavailable"}
)
2026-08-10 16:02:22 +02:00
def _count_secrets(self, payload: Any, identity, outcome: str, findings) -> None:
"""Count secret-shaped fields by path, sender, source and action.
Counted so the sending service can be fixed. Aggregation is by field
path rather than by event, because the actionable unit is "stop
emitting ``data.auth.token`` on ``membership.added``", not "there were
47 redactions".
"""
counter = getattr(self.backend, "count_secret_findings", None)
if not callable(counter) or not findings:
return
source = str(payload.get("source") or "") if isinstance(payload, dict) else ""
action = str(payload.get("type") or "") if isinstance(payload, dict) else ""
try:
counter(
sender=identity.name, source=source, action=action,
outcome=outcome, findings=findings,
)
except BackendUnavailableError as exc:
# A missed counter must never change the event's outcome.
log.error("could not count secret findings: %s", exc)
Bind sender identities, add operator read surface, real serving layer AUDIT-WP-0004 T03, T05, T06. T03 - the receiver accepted whatever tenant and source a caller sent as long as it held the one shared token, despite WP-0003 recording tenant isolation as delivered. audit_core.senders binds each credential to the sources and tenants it may assert, driven by AUDIT_CORE_SENDERS rather than literals. Identities hold a list of tokens so rotation publishes the replacement alongside the incumbent and needs no delivery gap. Read is a separate privilege from write, so a sender credential cannot read the audit trail back. T05 - lookup by event id, lookup by correlation id, and a dead-letter view. Rejections are recorded rather than silently dropped. An event rejected for carrying secret-shaped material has its payload withheld: storing it would write that material into the audit store, which is what the rejection exists to prevent. Reason and payload hash are kept so it stays traceable. Replay is deliberately not built here. Idempotent replay is a property of the durable store and building it against SQLite would produce a second implementation to throw away; it lands with the Postgres backend in AUDIT-WP-0005-T01. T06 - serving moves to waitress with configurable threads and channel timeout, installed in the image via the serve extra. Without it the entrypoint falls back to a threaded wsgiref server with a socket timeout and graceful shutdown on SIGTERM, and logs a warning so a deployment cannot quietly land on the fallback. Metric counters deferred to WP-0005-T03 to be designed against the real scrape path. Tests 36 -> 46, covering cross-tenant and cross-source refusal, token rotation, read/write privilege separation, correlation lookup, and payload withholding on secret rejection. Remaining in WP-0004: T04 redaction policy, which needs a decision on whether a secret-shaped field is a rejection or a redaction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:50:02 +02:00
def _dead_letter(self, raw: bytes, reason: str, identity) -> None:
"""Record a rejection so an operator can see what the sender dropped."""
recorder = getattr(self.backend, "record_rejection", None)
if not callable(recorder):
return
event_id = None
Route ingestion through the backend contract; fix error semantics AUDIT-WP-0004 T01, T02, T07. T01 - ingestion wrote to SQLite directly and never called the AuditBackend contract, so a 202 meant a row existed rather than that a backend with a declared retention policy had accepted the event. Adds IdempotentAuditBackend to the contract: duplicate detection lives inside the backend so custody and idempotency state share a transaction and cannot diverge. SQLiteAuditBackend implements it with WAL, synchronous=FULL and a busy timeout. Ingestion now refuses any backend declaring durable=False, so the development file backend cannot silently become the production sink. The atomicity claim was tested rather than asserted, and the first attempt failed: with a single shared connection, 16 racing submissions of one event told two callers they were first. Storage was correct but the response was not. Fixed with per-thread connections and BEGIN IMMEDIATE around the insert/read pair, and locked in by a test. T02 - storage errors previously escaped the handler with start_response never called, and the auth check sat outside the try block so a non-ASCII Authorization header crashed the request. Adds a catch-all, maps conflict to 409, backend unavailability to 503 and unexpected faults to 500, and documents the full response contract with the retry semantics each status implies, since senders key their behaviour off it. T07 - ingestion tests 2 -> 23, suite 15 -> 36. accepted_at is now UTC rather than local time, and naive timestamps are rejected instead of silently assumed. Remaining in WP-0004: T03 tenant/source binding, T04 redaction policy, T05 operator read surface, T06 production serving layer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:30:20 +02:00
try:
Bind sender identities, add operator read surface, real serving layer AUDIT-WP-0004 T03, T05, T06. T03 - the receiver accepted whatever tenant and source a caller sent as long as it held the one shared token, despite WP-0003 recording tenant isolation as delivered. audit_core.senders binds each credential to the sources and tenants it may assert, driven by AUDIT_CORE_SENDERS rather than literals. Identities hold a list of tokens so rotation publishes the replacement alongside the incumbent and needs no delivery gap. Read is a separate privilege from write, so a sender credential cannot read the audit trail back. T05 - lookup by event id, lookup by correlation id, and a dead-letter view. Rejections are recorded rather than silently dropped. An event rejected for carrying secret-shaped material has its payload withheld: storing it would write that material into the audit store, which is what the rejection exists to prevent. Reason and payload hash are kept so it stays traceable. Replay is deliberately not built here. Idempotent replay is a property of the durable store and building it against SQLite would produce a second implementation to throw away; it lands with the Postgres backend in AUDIT-WP-0005-T01. T06 - serving moves to waitress with configurable threads and channel timeout, installed in the image via the serve extra. Without it the entrypoint falls back to a threaded wsgiref server with a socket timeout and graceful shutdown on SIGTERM, and logs a warning so a deployment cannot quietly land on the fallback. Metric counters deferred to WP-0005-T03 to be designed against the real scrape path. Tests 36 -> 46, covering cross-tenant and cross-source refusal, token rotation, read/write privilege separation, correlation lookup, and payload withholding on secret rejection. Remaining in WP-0004: T04 redaction policy, which needs a decision on whether a secret-shaped field is a rejection or a redaction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:50:02 +02:00
parsed = json.loads(raw)
if isinstance(parsed, dict):
event_id = str(parsed.get("id") or "") or None
except (ValueError, TypeError):
pass
try:
recorder(
event_id=event_id,
reason=reason,
payload_hash=hashlib.sha256(raw).hexdigest(),
sender=identity.name,
payload=raw.decode("utf-8", "replace"),
)
except BackendUnavailableError as exc:
# A rejection we could not record is worth a log line, but it must
# not turn a 400 into a 503 — the event is still rejected.
log.error("could not record dead letter: %s", exc)
Route ingestion through the backend contract; fix error semantics AUDIT-WP-0004 T01, T02, T07. T01 - ingestion wrote to SQLite directly and never called the AuditBackend contract, so a 202 meant a row existed rather than that a backend with a declared retention policy had accepted the event. Adds IdempotentAuditBackend to the contract: duplicate detection lives inside the backend so custody and idempotency state share a transaction and cannot diverge. SQLiteAuditBackend implements it with WAL, synchronous=FULL and a busy timeout. Ingestion now refuses any backend declaring durable=False, so the development file backend cannot silently become the production sink. The atomicity claim was tested rather than asserted, and the first attempt failed: with a single shared connection, 16 racing submissions of one event told two callers they were first. Storage was correct but the response was not. Fixed with per-thread connections and BEGIN IMMEDIATE around the insert/read pair, and locked in by a test. T02 - storage errors previously escaped the handler with start_response never called, and the auth check sat outside the try block so a non-ASCII Authorization header crashed the request. Adds a catch-all, maps conflict to 409, backend unavailability to 503 and unexpected faults to 500, and documents the full response contract with the retry semantics each status implies, since senders key their behaviour off it. T07 - ingestion tests 2 -> 23, suite 15 -> 36. accepted_at is now UTC rather than local time, and naive timestamps are rejected instead of silently assumed. Remaining in WP-0004: T03 tenant/source binding, T04 redaction policy, T05 operator read surface, T06 production serving layer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:30:20 +02:00
def _read_body(self, environ) -> bytes:
try:
length = int(environ.get("CONTENT_LENGTH") or 0)
Route ingestion through the backend contract; fix error semantics AUDIT-WP-0004 T01, T02, T07. T01 - ingestion wrote to SQLite directly and never called the AuditBackend contract, so a 202 meant a row existed rather than that a backend with a declared retention policy had accepted the event. Adds IdempotentAuditBackend to the contract: duplicate detection lives inside the backend so custody and idempotency state share a transaction and cannot diverge. SQLiteAuditBackend implements it with WAL, synchronous=FULL and a busy timeout. Ingestion now refuses any backend declaring durable=False, so the development file backend cannot silently become the production sink. The atomicity claim was tested rather than asserted, and the first attempt failed: with a single shared connection, 16 racing submissions of one event told two callers they were first. Storage was correct but the response was not. Fixed with per-thread connections and BEGIN IMMEDIATE around the insert/read pair, and locked in by a test. T02 - storage errors previously escaped the handler with start_response never called, and the auth check sat outside the try block so a non-ASCII Authorization header crashed the request. Adds a catch-all, maps conflict to 409, backend unavailability to 503 and unexpected faults to 500, and documents the full response contract with the retry semantics each status implies, since senders key their behaviour off it. T07 - ingestion tests 2 -> 23, suite 15 -> 36. accepted_at is now UTC rather than local time, and naive timestamps are rejected instead of silently assumed. Remaining in WP-0004: T03 tenant/source binding, T04 redaction policy, T05 operator read surface, T06 production serving layer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:30:20 +02:00
except (TypeError, ValueError):
raise ValueError("invalid_content_length") from None
if length <= 0:
raise ValueError("empty_body")
if length > MAX_BODY_BYTES:
raise ValueError("payload_too_large")
raw = environ["wsgi.input"].read(length)
if len(raw) != length:
raise ValueError("truncated_body")
return raw
def _readiness(self, start_response):
try:
health = getattr(self.backend, "health", None)
if callable(health):
health()
except BackendUnavailableError as exc:
log.error("readiness failed: %s", exc)
return self._json(
start_response, HTTPStatus.SERVICE_UNAVAILABLE, {"status": "unavailable"}
)
Route ingestion through the backend contract; fix error semantics AUDIT-WP-0004 T01, T02, T07. T01 - ingestion wrote to SQLite directly and never called the AuditBackend contract, so a 202 meant a row existed rather than that a backend with a declared retention policy had accepted the event. Adds IdempotentAuditBackend to the contract: duplicate detection lives inside the backend so custody and idempotency state share a transaction and cannot diverge. SQLiteAuditBackend implements it with WAL, synchronous=FULL and a busy timeout. Ingestion now refuses any backend declaring durable=False, so the development file backend cannot silently become the production sink. The atomicity claim was tested rather than asserted, and the first attempt failed: with a single shared connection, 16 racing submissions of one event told two callers they were first. Storage was correct but the response was not. Fixed with per-thread connections and BEGIN IMMEDIATE around the insert/read pair, and locked in by a test. T02 - storage errors previously escaped the handler with start_response never called, and the auth check sat outside the try block so a non-ASCII Authorization header crashed the request. Adds a catch-all, maps conflict to 409, backend unavailability to 503 and unexpected faults to 500, and documents the full response contract with the retry semantics each status implies, since senders key their behaviour off it. T07 - ingestion tests 2 -> 23, suite 15 -> 36. accepted_at is now UTC rather than local time, and naive timestamps are rejected instead of silently assumed. Remaining in WP-0004: T03 tenant/source binding, T04 redaction policy, T05 operator read surface, T06 production serving layer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:30:20 +02:00
return self._json(
start_response,
HTTPStatus.OK,
self.backend.retention_policy.as_readiness(),
Route ingestion through the backend contract; fix error semantics AUDIT-WP-0004 T01, T02, T07. T01 - ingestion wrote to SQLite directly and never called the AuditBackend contract, so a 202 meant a row existed rather than that a backend with a declared retention policy had accepted the event. Adds IdempotentAuditBackend to the contract: duplicate detection lives inside the backend so custody and idempotency state share a transaction and cannot diverge. SQLiteAuditBackend implements it with WAL, synchronous=FULL and a busy timeout. Ingestion now refuses any backend declaring durable=False, so the development file backend cannot silently become the production sink. The atomicity claim was tested rather than asserted, and the first attempt failed: with a single shared connection, 16 racing submissions of one event told two callers they were first. Storage was correct but the response was not. Fixed with per-thread connections and BEGIN IMMEDIATE around the insert/read pair, and locked in by a test. T02 - storage errors previously escaped the handler with start_response never called, and the auth check sat outside the try block so a non-ASCII Authorization header crashed the request. Adds a catch-all, maps conflict to 409, backend unavailability to 503 and unexpected faults to 500, and documents the full response contract with the retry semantics each status implies, since senders key their behaviour off it. T07 - ingestion tests 2 -> 23, suite 15 -> 36. accepted_at is now UTC rather than local time, and naive timestamps are rejected instead of silently assumed. Remaining in WP-0004: T03 tenant/source binding, T04 redaction policy, T05 operator read surface, T06 production serving layer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:30:20 +02:00
)
@staticmethod
def _json(start_response, status: HTTPStatus, payload: dict[str, Any]):
body = json.dumps(payload).encode()
Route ingestion through the backend contract; fix error semantics AUDIT-WP-0004 T01, T02, T07. T01 - ingestion wrote to SQLite directly and never called the AuditBackend contract, so a 202 meant a row existed rather than that a backend with a declared retention policy had accepted the event. Adds IdempotentAuditBackend to the contract: duplicate detection lives inside the backend so custody and idempotency state share a transaction and cannot diverge. SQLiteAuditBackend implements it with WAL, synchronous=FULL and a busy timeout. Ingestion now refuses any backend declaring durable=False, so the development file backend cannot silently become the production sink. The atomicity claim was tested rather than asserted, and the first attempt failed: with a single shared connection, 16 racing submissions of one event told two callers they were first. Storage was correct but the response was not. Fixed with per-thread connections and BEGIN IMMEDIATE around the insert/read pair, and locked in by a test. T02 - storage errors previously escaped the handler with start_response never called, and the auth check sat outside the try block so a non-ASCII Authorization header crashed the request. Adds a catch-all, maps conflict to 409, backend unavailability to 503 and unexpected faults to 500, and documents the full response contract with the retry semantics each status implies, since senders key their behaviour off it. T07 - ingestion tests 2 -> 23, suite 15 -> 36. accepted_at is now UTC rather than local time, and naive timestamps are rejected instead of silently assumed. Remaining in WP-0004: T03 tenant/source binding, T04 redaction policy, T05 operator read surface, T06 production serving layer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:30:20 +02:00
start_response(
f"{status.value} {status.phrase}",
[("Content-Type", "application/json"), ("Content-Length", str(len(body)))],
)
return [body]
Bind sender identities, add operator read surface, real serving layer AUDIT-WP-0004 T03, T05, T06. T03 - the receiver accepted whatever tenant and source a caller sent as long as it held the one shared token, despite WP-0003 recording tenant isolation as delivered. audit_core.senders binds each credential to the sources and tenants it may assert, driven by AUDIT_CORE_SENDERS rather than literals. Identities hold a list of tokens so rotation publishes the replacement alongside the incumbent and needs no delivery gap. Read is a separate privilege from write, so a sender credential cannot read the audit trail back. T05 - lookup by event id, lookup by correlation id, and a dead-letter view. Rejections are recorded rather than silently dropped. An event rejected for carrying secret-shaped material has its payload withheld: storing it would write that material into the audit store, which is what the rejection exists to prevent. Reason and payload hash are kept so it stays traceable. Replay is deliberately not built here. Idempotent replay is a property of the durable store and building it against SQLite would produce a second implementation to throw away; it lands with the Postgres backend in AUDIT-WP-0005-T01. T06 - serving moves to waitress with configurable threads and channel timeout, installed in the image via the serve extra. Without it the entrypoint falls back to a threaded wsgiref server with a socket timeout and graceful shutdown on SIGTERM, and logs a warning so a deployment cannot quietly land on the fallback. Metric counters deferred to WP-0005-T03 to be designed against the real scrape path. Tests 36 -> 46, covering cross-tenant and cross-source refusal, token rotation, read/write privilege separation, correlation lookup, and payload withholding on secret rejection. Remaining in WP-0004: T04 redaction policy, which needs a decision on whether a secret-shaped field is a rejection or a redaction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:50:02 +02:00
def normalize(
payload: dict[str, Any],
idempotency_key: str | None,
identity: SenderIdentity | None = None,
) -> AuditEvent:
Route ingestion through the backend contract; fix error semantics AUDIT-WP-0004 T01, T02, T07. T01 - ingestion wrote to SQLite directly and never called the AuditBackend contract, so a 202 meant a row existed rather than that a backend with a declared retention policy had accepted the event. Adds IdempotentAuditBackend to the contract: duplicate detection lives inside the backend so custody and idempotency state share a transaction and cannot diverge. SQLiteAuditBackend implements it with WAL, synchronous=FULL and a busy timeout. Ingestion now refuses any backend declaring durable=False, so the development file backend cannot silently become the production sink. The atomicity claim was tested rather than asserted, and the first attempt failed: with a single shared connection, 16 racing submissions of one event told two callers they were first. Storage was correct but the response was not. Fixed with per-thread connections and BEGIN IMMEDIATE around the insert/read pair, and locked in by a test. T02 - storage errors previously escaped the handler with start_response never called, and the auth check sat outside the try block so a non-ASCII Authorization header crashed the request. Adds a catch-all, maps conflict to 409, backend unavailability to 503 and unexpected faults to 500, and documents the full response contract with the retry semantics each status implies, since senders key their behaviour off it. T07 - ingestion tests 2 -> 23, suite 15 -> 36. accepted_at is now UTC rather than local time, and naive timestamps are rejected instead of silently assumed. Remaining in WP-0004: T03 tenant/source binding, T04 redaction policy, T05 operator read surface, T06 production serving layer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:30:20 +02:00
required = (
"id", "type", "source", "subject", "tenant", "correlation_id", "occurred_at", "data",
)
if not isinstance(payload, dict) or any(not payload.get(key) for key in required):
raise ValueError("invalid_event")
if idempotency_key != payload["id"]:
raise ValueError("idempotency_key_mismatch")
Bind sender identities, add operator read surface, real serving layer AUDIT-WP-0004 T03, T05, T06. T03 - the receiver accepted whatever tenant and source a caller sent as long as it held the one shared token, despite WP-0003 recording tenant isolation as delivered. audit_core.senders binds each credential to the sources and tenants it may assert, driven by AUDIT_CORE_SENDERS rather than literals. Identities hold a list of tokens so rotation publishes the replacement alongside the incumbent and needs no delivery gap. Read is a separate privilege from write, so a sender credential cannot read the audit trail back. T05 - lookup by event id, lookup by correlation id, and a dead-letter view. Rejections are recorded rather than silently dropped. An event rejected for carrying secret-shaped material has its payload withheld: storing it would write that material into the audit store, which is what the rejection exists to prevent. Reason and payload hash are kept so it stays traceable. Replay is deliberately not built here. Idempotent replay is a property of the durable store and building it against SQLite would produce a second implementation to throw away; it lands with the Postgres backend in AUDIT-WP-0005-T01. T06 - serving moves to waitress with configurable threads and channel timeout, installed in the image via the serve extra. Without it the entrypoint falls back to a threaded wsgiref server with a socket timeout and graceful shutdown on SIGTERM, and logs a warning so a deployment cannot quietly land on the fallback. Metric counters deferred to WP-0005-T03 to be designed against the real scrape path. Tests 36 -> 46, covering cross-tenant and cross-source refusal, token rotation, read/write privilege separation, correlation lookup, and payload withholding on secret rejection. Remaining in WP-0004: T04 redaction policy, which needs a decision on whether a secret-shaped field is a rejection or a redaction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:50:02 +02:00
source = str(payload["source"])
tenant = str(payload["tenant"])
# The claimed source and tenant are checked against what this credential is
# permitted to assert, not against a literal (AUDIT-WP-0004-T03).
if identity is not None:
if not identity.permits_source(source):
raise ValueError("source_not_allowed")
if not identity.permits_tenant(tenant):
raise ValueError("tenant_not_allowed")
elif source != "user-engine":
raise ValueError("source_not_allowed")
Route ingestion through the backend contract; fix error semantics AUDIT-WP-0004 T01, T02, T07. T01 - ingestion wrote to SQLite directly and never called the AuditBackend contract, so a 202 meant a row existed rather than that a backend with a declared retention policy had accepted the event. Adds IdempotentAuditBackend to the contract: duplicate detection lives inside the backend so custody and idempotency state share a transaction and cannot diverge. SQLiteAuditBackend implements it with WAL, synchronous=FULL and a busy timeout. Ingestion now refuses any backend declaring durable=False, so the development file backend cannot silently become the production sink. The atomicity claim was tested rather than asserted, and the first attempt failed: with a single shared connection, 16 racing submissions of one event told two callers they were first. Storage was correct but the response was not. Fixed with per-thread connections and BEGIN IMMEDIATE around the insert/read pair, and locked in by a test. T02 - storage errors previously escaped the handler with start_response never called, and the auth check sat outside the try block so a non-ASCII Authorization header crashed the request. Adds a catch-all, maps conflict to 409, backend unavailability to 503 and unexpected faults to 500, and documents the full response contract with the retry semantics each status implies, since senders key their behaviour off it. T07 - ingestion tests 2 -> 23, suite 15 -> 36. accepted_at is now UTC rather than local time, and naive timestamps are rejected instead of silently assumed. Remaining in WP-0004: T03 tenant/source binding, T04 redaction policy, T05 operator read surface, T06 production serving layer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:30:20 +02:00
observed_at = _normalize_timestamp(payload["occurred_at"])
2026-08-10 16:02:22 +02:00
policy = identity.secret_policy if identity is not None else POLICY_REDACT
# Raises SecretFieldRejection under the reject policy; that exception
# carries the findings so the caller can count them.
data, findings = apply_policy(payload, policy)
details: dict[str, Any] = {
"correlation_id": str(payload["correlation_id"]),
"data": data,
}
if findings:
# The stored record differs from what the sender transmitted. Say so in
# the record itself rather than leaving it to be inferred.
details["redaction"] = {
"policy": policy,
"paths": [f.path for f in findings],
}
return AuditEvent(
Route ingestion through the backend contract; fix error semantics AUDIT-WP-0004 T01, T02, T07. T01 - ingestion wrote to SQLite directly and never called the AuditBackend contract, so a 202 meant a row existed rather than that a backend with a declared retention policy had accepted the event. Adds IdempotentAuditBackend to the contract: duplicate detection lives inside the backend so custody and idempotency state share a transaction and cannot diverge. SQLiteAuditBackend implements it with WAL, synchronous=FULL and a busy timeout. Ingestion now refuses any backend declaring durable=False, so the development file backend cannot silently become the production sink. The atomicity claim was tested rather than asserted, and the first attempt failed: with a single shared connection, 16 racing submissions of one event told two callers they were first. Storage was correct but the response was not. Fixed with per-thread connections and BEGIN IMMEDIATE around the insert/read pair, and locked in by a test. T02 - storage errors previously escaped the handler with start_response never called, and the auth check sat outside the try block so a non-ASCII Authorization header crashed the request. Adds a catch-all, maps conflict to 409, backend unavailability to 503 and unexpected faults to 500, and documents the full response contract with the retry semantics each status implies, since senders key their behaviour off it. T07 - ingestion tests 2 -> 23, suite 15 -> 36. accepted_at is now UTC rather than local time, and naive timestamps are rejected instead of silently assumed. Remaining in WP-0004: T03 tenant/source binding, T04 redaction policy, T05 operator read surface, T06 production serving layer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:30:20 +02:00
event_id=str(payload["id"]),
observed_at=observed_at,
Bind sender identities, add operator read surface, real serving layer AUDIT-WP-0004 T03, T05, T06. T03 - the receiver accepted whatever tenant and source a caller sent as long as it held the one shared token, despite WP-0003 recording tenant isolation as delivered. audit_core.senders binds each credential to the sources and tenants it may assert, driven by AUDIT_CORE_SENDERS rather than literals. Identities hold a list of tokens so rotation publishes the replacement alongside the incumbent and needs no delivery gap. Read is a separate privilege from write, so a sender credential cannot read the audit trail back. T05 - lookup by event id, lookup by correlation id, and a dead-letter view. Rejections are recorded rather than silently dropped. An event rejected for carrying secret-shaped material has its payload withheld: storing it would write that material into the audit store, which is what the rejection exists to prevent. Reason and payload hash are kept so it stays traceable. Replay is deliberately not built here. Idempotent replay is a property of the durable store and building it against SQLite would produce a second implementation to throw away; it lands with the Postgres backend in AUDIT-WP-0005-T01. T06 - serving moves to waitress with configurable threads and channel timeout, installed in the image via the serve extra. Without it the entrypoint falls back to a threaded wsgiref server with a socket timeout and graceful shutdown on SIGTERM, and logs a warning so a deployment cannot quietly land on the fallback. Metric counters deferred to WP-0005-T03 to be designed against the real scrape path. Tests 36 -> 46, covering cross-tenant and cross-source refusal, token rotation, read/write privilege separation, correlation lookup, and payload withholding on secret rejection. Remaining in WP-0004: T04 redaction policy, which needs a decision on whether a secret-shaped field is a rejection or a redaction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:50:02 +02:00
tenant=tenant,
Route ingestion through the backend contract; fix error semantics AUDIT-WP-0004 T01, T02, T07. T01 - ingestion wrote to SQLite directly and never called the AuditBackend contract, so a 202 meant a row existed rather than that a backend with a declared retention policy had accepted the event. Adds IdempotentAuditBackend to the contract: duplicate detection lives inside the backend so custody and idempotency state share a transaction and cannot diverge. SQLiteAuditBackend implements it with WAL, synchronous=FULL and a busy timeout. Ingestion now refuses any backend declaring durable=False, so the development file backend cannot silently become the production sink. The atomicity claim was tested rather than asserted, and the first attempt failed: with a single shared connection, 16 racing submissions of one event told two callers they were first. Storage was correct but the response was not. Fixed with per-thread connections and BEGIN IMMEDIATE around the insert/read pair, and locked in by a test. T02 - storage errors previously escaped the handler with start_response never called, and the auth check sat outside the try block so a non-ASCII Authorization header crashed the request. Adds a catch-all, maps conflict to 409, backend unavailability to 503 and unexpected faults to 500, and documents the full response contract with the retry semantics each status implies, since senders key their behaviour off it. T07 - ingestion tests 2 -> 23, suite 15 -> 36. accepted_at is now UTC rather than local time, and naive timestamps are rejected instead of silently assumed. Remaining in WP-0004: T03 tenant/source binding, T04 redaction policy, T05 operator read surface, T06 production serving layer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:30:20 +02:00
scope="tenant",
Bind sender identities, add operator read surface, real serving layer AUDIT-WP-0004 T03, T05, T06. T03 - the receiver accepted whatever tenant and source a caller sent as long as it held the one shared token, despite WP-0003 recording tenant isolation as delivered. audit_core.senders binds each credential to the sources and tenants it may assert, driven by AUDIT_CORE_SENDERS rather than literals. Identities hold a list of tokens so rotation publishes the replacement alongside the incumbent and needs no delivery gap. Read is a separate privilege from write, so a sender credential cannot read the audit trail back. T05 - lookup by event id, lookup by correlation id, and a dead-letter view. Rejections are recorded rather than silently dropped. An event rejected for carrying secret-shaped material has its payload withheld: storing it would write that material into the audit store, which is what the rejection exists to prevent. Reason and payload hash are kept so it stays traceable. Replay is deliberately not built here. Idempotent replay is a property of the durable store and building it against SQLite would produce a second implementation to throw away; it lands with the Postgres backend in AUDIT-WP-0005-T01. T06 - serving moves to waitress with configurable threads and channel timeout, installed in the image via the serve extra. Without it the entrypoint falls back to a threaded wsgiref server with a socket timeout and graceful shutdown on SIGTERM, and logs a warning so a deployment cannot quietly land on the fallback. Metric counters deferred to WP-0005-T03 to be designed against the real scrape path. Tests 36 -> 46, covering cross-tenant and cross-source refusal, token rotation, read/write privilege separation, correlation lookup, and payload withholding on secret rejection. Remaining in WP-0004: T04 redaction policy, which needs a decision on whether a secret-shaped field is a rejection or a redaction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:50:02 +02:00
source=source,
Route ingestion through the backend contract; fix error semantics AUDIT-WP-0004 T01, T02, T07. T01 - ingestion wrote to SQLite directly and never called the AuditBackend contract, so a 202 meant a row existed rather than that a backend with a declared retention policy had accepted the event. Adds IdempotentAuditBackend to the contract: duplicate detection lives inside the backend so custody and idempotency state share a transaction and cannot diverge. SQLiteAuditBackend implements it with WAL, synchronous=FULL and a busy timeout. Ingestion now refuses any backend declaring durable=False, so the development file backend cannot silently become the production sink. The atomicity claim was tested rather than asserted, and the first attempt failed: with a single shared connection, 16 racing submissions of one event told two callers they were first. Storage was correct but the response was not. Fixed with per-thread connections and BEGIN IMMEDIATE around the insert/read pair, and locked in by a test. T02 - storage errors previously escaped the handler with start_response never called, and the auth check sat outside the try block so a non-ASCII Authorization header crashed the request. Adds a catch-all, maps conflict to 409, backend unavailability to 503 and unexpected faults to 500, and documents the full response contract with the retry semantics each status implies, since senders key their behaviour off it. T07 - ingestion tests 2 -> 23, suite 15 -> 36. accepted_at is now UTC rather than local time, and naive timestamps are rejected instead of silently assumed. Remaining in WP-0004: T03 tenant/source binding, T04 redaction policy, T05 operator read surface, T06 production serving layer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:30:20 +02:00
action=str(payload["type"]),
resource=str(payload["subject"]),
outcome="recorded",
actor=None,
2026-08-10 16:02:22 +02:00
details=details,
)
Route ingestion through the backend contract; fix error semantics AUDIT-WP-0004 T01, T02, T07. T01 - ingestion wrote to SQLite directly and never called the AuditBackend contract, so a 202 meant a row existed rather than that a backend with a declared retention policy had accepted the event. Adds IdempotentAuditBackend to the contract: duplicate detection lives inside the backend so custody and idempotency state share a transaction and cannot diverge. SQLiteAuditBackend implements it with WAL, synchronous=FULL and a busy timeout. Ingestion now refuses any backend declaring durable=False, so the development file backend cannot silently become the production sink. The atomicity claim was tested rather than asserted, and the first attempt failed: with a single shared connection, 16 racing submissions of one event told two callers they were first. Storage was correct but the response was not. Fixed with per-thread connections and BEGIN IMMEDIATE around the insert/read pair, and locked in by a test. T02 - storage errors previously escaped the handler with start_response never called, and the auth check sat outside the try block so a non-ASCII Authorization header crashed the request. Adds a catch-all, maps conflict to 409, backend unavailability to 503 and unexpected faults to 500, and documents the full response contract with the retry semantics each status implies, since senders key their behaviour off it. T07 - ingestion tests 2 -> 23, suite 15 -> 36. accepted_at is now UTC rather than local time, and naive timestamps are rejected instead of silently assumed. Remaining in WP-0004: T03 tenant/source binding, T04 redaction policy, T05 operator read surface, T06 production serving layer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:30:20 +02:00
def _normalize_timestamp(value: Any) -> str:
"""Parse an event timestamp, requiring an explicit offset.
A naive timestamp is ambiguous by up to a day, which is not good enough for
an audit trail the sender must say which offset it meant.
"""
try:
parsed = datetime.fromisoformat(str(value).replace("Z", "+00:00"))
except (TypeError, ValueError):
raise ValueError("invalid_timestamp") from None
if parsed.tzinfo is None:
raise ValueError("timestamp_missing_timezone")
return parsed.astimezone(timezone.utc).isoformat()
# Read surfaces with no tenant key to filter on. Serving these to a scoped
# reader would cross the boundary _read enforces, so they require full scope
# (AUDIT-WP-0008-T04).
_UNSCOPED_READ_PATHS = frozenset({
"/v1/dead-letters",
"/v1/stats",
"/v1/secret-findings",
"/v1/integrity",
})
def _readable_by(identity, record: dict) -> bool:
"""Whether ``identity`` may read ``record``.
Fails closed: a record carrying no tenant is readable only by an identity
with full scope. A stored event always has one ``_validate`` requires it
so reaching that branch means the row predates the requirement or was
written by something other than the ingest path, and neither is a case to
resolve in favour of the reader.
"""
if identity.has_full_tenant_scope():
return True
tenant = record.get("tenant") if isinstance(record, dict) else None
return bool(tenant) and identity.permits_tenant(str(tenant))
Bind sender identities, add operator read surface, real serving layer AUDIT-WP-0004 T03, T05, T06. T03 - the receiver accepted whatever tenant and source a caller sent as long as it held the one shared token, despite WP-0003 recording tenant isolation as delivered. audit_core.senders binds each credential to the sources and tenants it may assert, driven by AUDIT_CORE_SENDERS rather than literals. Identities hold a list of tokens so rotation publishes the replacement alongside the incumbent and needs no delivery gap. Read is a separate privilege from write, so a sender credential cannot read the audit trail back. T05 - lookup by event id, lookup by correlation id, and a dead-letter view. Rejections are recorded rather than silently dropped. An event rejected for carrying secret-shaped material has its payload withheld: storing it would write that material into the audit store, which is what the rejection exists to prevent. Reason and payload hash are kept so it stays traceable. Replay is deliberately not built here. Idempotent replay is a property of the durable store and building it against SQLite would produce a second implementation to throw away; it lands with the Postgres backend in AUDIT-WP-0005-T01. T06 - serving moves to waitress with configurable threads and channel timeout, installed in the image via the serve extra. Without it the entrypoint falls back to a threaded wsgiref server with a socket timeout and graceful shutdown on SIGTERM, and logs a warning so a deployment cannot quietly land on the fallback. Metric counters deferred to WP-0005-T03 to be designed against the real scrape path. Tests 36 -> 46, covering cross-tenant and cross-source refusal, token rotation, read/write privilege separation, correlation lookup, and payload withholding on secret rejection. Remaining in WP-0004: T04 redaction policy, which needs a decision on whether a secret-shaped field is a rejection or a redaction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:50:02 +02:00
def _limit(query: dict[str, list[str]], default: int = 100, ceiling: int = 1000) -> int:
try:
return max(1, min(int((query.get("limit") or [default])[0]), ceiling))
except (TypeError, ValueError):
return default
def serve(app, host: str, port: int, threads: int, timeout: int) -> None:
"""Serve ``app``, preferring a production WSGI server (T06).
waitress is the intended production server and is installed in the image
via the ``serve`` extra. The fallback is a threaded wsgiref server with a
socket timeout bounded rather than good, and loud about which one is in
use so a deployment cannot quietly end up on the fallback.
"""
try:
from waitress import serve as waitress_serve
except ImportError:
log.warning(
"waitress not installed — falling back to a threaded wsgiref server. "
"Install the 'serve' extra for production (AUDIT-WP-0004-T06)."
)
_serve_fallback(app, host, port, timeout)
return
log.info("serving on waitress host=%s port=%s threads=%s", host, port, threads)
waitress_serve(
app, host=host, port=port, threads=threads,
channel_timeout=timeout, ident="audit-core",
)
def _serve_fallback(app, host: str, port: int, timeout: int) -> None:
from socketserver import ThreadingMixIn
from wsgiref.simple_server import WSGIServer, make_server
class ThreadedWSGIServer(ThreadingMixIn, WSGIServer):
daemon_threads = True
# Without this a slow or idle client holds a worker indefinitely; the
# original single-threaded server let one such client block every
# sender.
timeout = timeout
with make_server(host, port, app, server_class=ThreadedWSGIServer) as server:
server.socket.settimeout(timeout)
def shutdown(signum, _frame):
log.info("received signal %s, shutting down", signum)
server.shutdown()
for sig in (signal.SIGTERM, signal.SIGINT):
signal.signal(sig, shutdown)
log.info("serving on threaded wsgiref host=%s port=%s", host, port)
server.serve_forever()
Add the PostgreSQL audit backend and a shared conformance suite AUDIT-WP-0005-T01, built and verified against PostgreSQL 16 locally in Docker; the Railiance cluster was not needed. tests/test_backend_conformance.py is one suite run against every backend, so "the Postgres backend is done" means it satisfies the same contract SQLite already does rather than having its own green tests. It skips cleanly with no server reachable; make pg-test-up and make test-pg run it. Suite 50 -> 71. RetentionPolicy declares immutable=True and earns it: migration 0002 installs a trigger rejecting UPDATE and DELETE on the events table, so a leaked runtime credential can append but cannot rewrite or erase the trail. That materially narrows the residual risk ADR-0001 section 5 called out. tamper_evidence stays False because nothing here would prove a database owner had dropped the trigger - hash-chaining or external anchoring would be needed and is not implemented. Idempotency is one statement (INSERT ... ON CONFLICT DO NOTHING RETURNING), verified to behave identically to the SQLite backend under 12 concurrent submissions of the same event. Migrations are ordered, recorded and idempotent. Replay reconciles rather than duplicating - the piece deferred out of WP-0004-T05 - and is tested to leave exactly one custody record. Backend selection is by AUDIT_CORE_DATABASE_URL; the SQLite fallback logs a warning so a deployment that lost its URL is visible rather than quietly running on the wrong store. Also fixed: ingestion had no __main__ guard, so python -m audit_core.ingestion silently did nothing. Found during end-to-end smoke. Counting semantics documented: occurrences counts transmissions, not stored events, so a retry of a secret-shaped field increments it again. That is the sender behaviour being optimized away. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:09:46 +02:00
def build_backend() -> IdempotentAuditBackend:
"""Select the custody backend from the environment.
``AUDIT_CORE_DATABASE_URL`` selects PostgreSQL (production custody).
Falling back to SQLite is explicit and logged, so a deployment that meant
to use Postgres and lost its URL is visible rather than quietly running on
the wrong store.
"""
url = os.environ.get("AUDIT_CORE_DATABASE_URL")
2026-08-12 01:36:28 +02:00
credential_dir = os.environ.get("AUDIT_CORE_CREDENTIAL_DIR")
brokered = bool(os.environ.get("PGHOST") and os.environ.get("PGUSER"))
2026-08-12 01:36:28 +02:00
if url or brokered or credential_dir:
Add the PostgreSQL audit backend and a shared conformance suite AUDIT-WP-0005-T01, built and verified against PostgreSQL 16 locally in Docker; the Railiance cluster was not needed. tests/test_backend_conformance.py is one suite run against every backend, so "the Postgres backend is done" means it satisfies the same contract SQLite already does rather than having its own green tests. It skips cleanly with no server reachable; make pg-test-up and make test-pg run it. Suite 50 -> 71. RetentionPolicy declares immutable=True and earns it: migration 0002 installs a trigger rejecting UPDATE and DELETE on the events table, so a leaked runtime credential can append but cannot rewrite or erase the trail. That materially narrows the residual risk ADR-0001 section 5 called out. tamper_evidence stays False because nothing here would prove a database owner had dropped the trigger - hash-chaining or external anchoring would be needed and is not implemented. Idempotency is one statement (INSERT ... ON CONFLICT DO NOTHING RETURNING), verified to behave identically to the SQLite backend under 12 concurrent submissions of the same event. Migrations are ordered, recorded and idempotent. Replay reconciles rather than duplicating - the piece deferred out of WP-0004-T05 - and is tested to leave exactly one custody record. Backend selection is by AUDIT_CORE_DATABASE_URL; the SQLite fallback logs a warning so a deployment that lost its URL is visible rather than quietly running on the wrong store. Also fixed: ingestion had no __main__ guard, so python -m audit_core.ingestion silently did nothing. Found during end-to-end smoke. Counting semantics documented: occurrences counts transmissions, not stored events, so a retry of a secret-shaped field increments it again. That is the sender behaviour being optimized away. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:09:46 +02:00
from audit_core.postgres_backend import PostgresAuditBackend
retention = os.environ.get("AUDIT_CORE_RETENTION_DAYS")
2026-08-12 01:36:28 +02:00
source = ("mounted credential directory" if credential_dir
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")
Add the PostgreSQL audit backend and a shared conformance suite AUDIT-WP-0005-T01, built and verified against PostgreSQL 16 locally in Docker; the Railiance cluster was not needed. tests/test_backend_conformance.py is one suite run against every backend, so "the Postgres backend is done" means it satisfies the same contract SQLite already does rather than having its own green tests. It skips cleanly with no server reachable; make pg-test-up and make test-pg run it. Suite 50 -> 71. RetentionPolicy declares immutable=True and earns it: migration 0002 installs a trigger rejecting UPDATE and DELETE on the events table, so a leaked runtime credential can append but cannot rewrite or erase the trail. That materially narrows the residual risk ADR-0001 section 5 called out. tamper_evidence stays False because nothing here would prove a database owner had dropped the trigger - hash-chaining or external anchoring would be needed and is not implemented. Idempotency is one statement (INSERT ... ON CONFLICT DO NOTHING RETURNING), verified to behave identically to the SQLite backend under 12 concurrent submissions of the same event. Migrations are ordered, recorded and idempotent. Replay reconciles rather than duplicating - the piece deferred out of WP-0004-T05 - and is tested to leave exactly one custody record. Backend selection is by AUDIT_CORE_DATABASE_URL; the SQLite fallback logs a warning so a deployment that lost its URL is visible rather than quietly running on the wrong store. Also fixed: ingestion had no __main__ guard, so python -m audit_core.ingestion silently did nothing. Found during end-to-end smoke. Counting semantics documented: occurrences counts transmissions, not stored events, so a retry of a secret-shaped field increments it again. That is the sender behaviour being optimized away. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:09:46 +02:00
return PostgresAuditBackend(
url or "",
2026-08-12 01:36:28 +02:00
credential_dir=credential_dir,
Add the PostgreSQL audit backend and a shared conformance suite AUDIT-WP-0005-T01, built and verified against PostgreSQL 16 locally in Docker; the Railiance cluster was not needed. tests/test_backend_conformance.py is one suite run against every backend, so "the Postgres backend is done" means it satisfies the same contract SQLite already does rather than having its own green tests. It skips cleanly with no server reachable; make pg-test-up and make test-pg run it. Suite 50 -> 71. RetentionPolicy declares immutable=True and earns it: migration 0002 installs a trigger rejecting UPDATE and DELETE on the events table, so a leaked runtime credential can append but cannot rewrite or erase the trail. That materially narrows the residual risk ADR-0001 section 5 called out. tamper_evidence stays False because nothing here would prove a database owner had dropped the trigger - hash-chaining or external anchoring would be needed and is not implemented. Idempotency is one statement (INSERT ... ON CONFLICT DO NOTHING RETURNING), verified to behave identically to the SQLite backend under 12 concurrent submissions of the same event. Migrations are ordered, recorded and idempotent. Replay reconciles rather than duplicating - the piece deferred out of WP-0004-T05 - and is tested to leave exactly one custody record. Backend selection is by AUDIT_CORE_DATABASE_URL; the SQLite fallback logs a warning so a deployment that lost its URL is visible rather than quietly running on the wrong store. Also fixed: ingestion had no __main__ guard, so python -m audit_core.ingestion silently did nothing. Found during end-to-end smoke. Counting semantics documented: occurrences counts transmissions, not stored events, so a retry of a secret-shaped field increments it again. That is the sender behaviour being optimized away. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:09:46 +02:00
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
),
Add the PostgreSQL audit backend and a shared conformance suite AUDIT-WP-0005-T01, built and verified against PostgreSQL 16 locally in Docker; the Railiance cluster was not needed. tests/test_backend_conformance.py is one suite run against every backend, so "the Postgres backend is done" means it satisfies the same contract SQLite already does rather than having its own green tests. It skips cleanly with no server reachable; make pg-test-up and make test-pg run it. Suite 50 -> 71. RetentionPolicy declares immutable=True and earns it: migration 0002 installs a trigger rejecting UPDATE and DELETE on the events table, so a leaked runtime credential can append but cannot rewrite or erase the trail. That materially narrows the residual risk ADR-0001 section 5 called out. tamper_evidence stays False because nothing here would prove a database owner had dropped the trigger - hash-chaining or external anchoring would be needed and is not implemented. Idempotency is one statement (INSERT ... ON CONFLICT DO NOTHING RETURNING), verified to behave identically to the SQLite backend under 12 concurrent submissions of the same event. Migrations are ordered, recorded and idempotent. Replay reconciles rather than duplicating - the piece deferred out of WP-0004-T05 - and is tested to leave exactly one custody record. Backend selection is by AUDIT_CORE_DATABASE_URL; the SQLite fallback logs a warning so a deployment that lost its URL is visible rather than quietly running on the wrong store. Also fixed: ingestion had no __main__ guard, so python -m audit_core.ingestion silently did nothing. Found during end-to-end smoke. Counting semantics documented: occurrences counts transmissions, not stored events, so a retry of a secret-shaped field increments it again. That is the sender behaviour being optimized away. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:09:46 +02:00
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")
),
# Production mounts the runtime role, which cannot CREATE TABLE.
# Schema changes run as a Job with the migration lease
# (AUDIT-WP-0005-T02). Local and brokered use keep the default.
migrate=_env_flag("AUDIT_CORE_AUTO_MIGRATE", default=True),
Add the PostgreSQL audit backend and a shared conformance suite AUDIT-WP-0005-T01, built and verified against PostgreSQL 16 locally in Docker; the Railiance cluster was not needed. tests/test_backend_conformance.py is one suite run against every backend, so "the Postgres backend is done" means it satisfies the same contract SQLite already does rather than having its own green tests. It skips cleanly with no server reachable; make pg-test-up and make test-pg run it. Suite 50 -> 71. RetentionPolicy declares immutable=True and earns it: migration 0002 installs a trigger rejecting UPDATE and DELETE on the events table, so a leaked runtime credential can append but cannot rewrite or erase the trail. That materially narrows the residual risk ADR-0001 section 5 called out. tamper_evidence stays False because nothing here would prove a database owner had dropped the trigger - hash-chaining or external anchoring would be needed and is not implemented. Idempotency is one statement (INSERT ... ON CONFLICT DO NOTHING RETURNING), verified to behave identically to the SQLite backend under 12 concurrent submissions of the same event. Migrations are ordered, recorded and idempotent. Replay reconciles rather than duplicating - the piece deferred out of WP-0004-T05 - and is tested to leave exactly one custody record. Backend selection is by AUDIT_CORE_DATABASE_URL; the SQLite fallback logs a warning so a deployment that lost its URL is visible rather than quietly running on the wrong store. Also fixed: ingestion had no __main__ guard, so python -m audit_core.ingestion silently did nothing. Found during end-to-end smoke. Counting semantics documented: occurrences counts transmissions, not stored events, so a retry of a secret-shaped field increments it again. That is the sender behaviour being optimized away. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:09:46 +02:00
)
path = os.environ.get("AUDIT_CORE_DATABASE_PATH", "/data/audit-core.db")
log.warning(
"custody backend: sqlite at %s — neither AUDIT_CORE_DATABASE_URL nor a "
"brokered PG* environment is set, so this is not the production store", path,
Add the PostgreSQL audit backend and a shared conformance suite AUDIT-WP-0005-T01, built and verified against PostgreSQL 16 locally in Docker; the Railiance cluster was not needed. tests/test_backend_conformance.py is one suite run against every backend, so "the Postgres backend is done" means it satisfies the same contract SQLite already does rather than having its own green tests. It skips cleanly with no server reachable; make pg-test-up and make test-pg run it. Suite 50 -> 71. RetentionPolicy declares immutable=True and earns it: migration 0002 installs a trigger rejecting UPDATE and DELETE on the events table, so a leaked runtime credential can append but cannot rewrite or erase the trail. That materially narrows the residual risk ADR-0001 section 5 called out. tamper_evidence stays False because nothing here would prove a database owner had dropped the trigger - hash-chaining or external anchoring would be needed and is not implemented. Idempotency is one statement (INSERT ... ON CONFLICT DO NOTHING RETURNING), verified to behave identically to the SQLite backend under 12 concurrent submissions of the same event. Migrations are ordered, recorded and idempotent. Replay reconciles rather than duplicating - the piece deferred out of WP-0004-T05 - and is tested to leave exactly one custody record. Backend selection is by AUDIT_CORE_DATABASE_URL; the SQLite fallback logs a warning so a deployment that lost its URL is visible rather than quietly running on the wrong store. Also fixed: ingestion had no __main__ guard, so python -m audit_core.ingestion silently did nothing. Found during end-to-end smoke. Counting semantics documented: occurrences counts transmissions, not stored events, so a retry of a secret-shaped field increments it again. That is the sender behaviour being optimized away. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:09:46 +02:00
)
return SQLiteAuditBackend(path)
def _env_flag(name: str, *, default: bool) -> bool:
"""Parse a boolean environment flag. Unset or empty keeps ``default``."""
raw = os.environ.get(name)
if raw is None or not raw.strip():
return default
return raw.strip().lower() not in {"0", "false", "no", "off"}
def main() -> None:
Route ingestion through the backend contract; fix error semantics AUDIT-WP-0004 T01, T02, T07. T01 - ingestion wrote to SQLite directly and never called the AuditBackend contract, so a 202 meant a row existed rather than that a backend with a declared retention policy had accepted the event. Adds IdempotentAuditBackend to the contract: duplicate detection lives inside the backend so custody and idempotency state share a transaction and cannot diverge. SQLiteAuditBackend implements it with WAL, synchronous=FULL and a busy timeout. Ingestion now refuses any backend declaring durable=False, so the development file backend cannot silently become the production sink. The atomicity claim was tested rather than asserted, and the first attempt failed: with a single shared connection, 16 racing submissions of one event told two callers they were first. Storage was correct but the response was not. Fixed with per-thread connections and BEGIN IMMEDIATE around the insert/read pair, and locked in by a test. T02 - storage errors previously escaped the handler with start_response never called, and the auth check sat outside the try block so a non-ASCII Authorization header crashed the request. Adds a catch-all, maps conflict to 409, backend unavailability to 503 and unexpected faults to 500, and documents the full response contract with the retry semantics each status implies, since senders key their behaviour off it. T07 - ingestion tests 2 -> 23, suite 15 -> 36. accepted_at is now UTC rather than local time, and naive timestamps are rejected instead of silently assumed. Remaining in WP-0004: T03 tenant/source binding, T04 redaction policy, T05 operator read surface, T06 production serving layer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:30:20 +02:00
logging.basicConfig(
level=os.environ.get("AUDIT_CORE_LOG_LEVEL", "INFO"),
format='{"ts":"%(asctime)s","level":"%(levelname)s","logger":"%(name)s","msg":"%(message)s"}',
stream=sys.stdout,
)
Add deployment manifests, custody-class guard and request counters AUDIT-WP-0005-T03 (progress). Manifests validated --dry-run=server --validate=strict against railiance01; not applied, since deployment is gated on RAPP-POSTGRES-WP-0002 and T02 credentials. Nothing here mutates the cluster. Conventions read off the deployed user-engine workload rather than invented: digest-pinned image from forgejo.coulomb.social, runAsNonRoot with RuntimeDefault seccomp, no privilege escalation, all capabilities dropped, readOnlyRootFilesystem, probes on a named http port, same resource envelope. The namespace carries railiance.io/postgres-client: platform-pg, which is what platform-pg-consumer-ingress in rapp-postgres admits; without that label the pod cannot reach the database at all. NetworkPolicies default-deny both directions, then permit ingress from the user-engine namespace only, a separately labelled operator read path, and egress to PostgreSQL in databases plus DNS. Three decisions worth naming. Liveness is /healthz while readiness is /readyz, so a database outage drops the pod from the Service rather than restarting it in a loop. readOnlyRootFilesystem enforces the empty-filesystem property rather than trusting it, so the SQLite fallback physically cannot accumulate audit records on ephemeral storage. AUDIT_CORE_REQUIRE_CUSTODY_CLASS=archive makes a missing database URL a startup failure instead of a silent downgrade to the development store. Counters deferred from WP-0004-T06 are exposed as JSON at /v1/stats behind the read privilege, not as Prometheus exposition format: the cluster runs no Prometheus, no ServiceMonitor CRD and no other scrape target, so an exposition endpoint would target a scrape path that does not exist. Usable with curl now and a small step from /metrics later. Tests 77 -> 80. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:42:43 +02:00
app = IngestionApplication(
build_backend(),
SenderRegistry.from_env(),
require_custody_class=os.environ.get("AUDIT_CORE_REQUIRE_CUSTODY_CLASS") or None,
)
Bind sender identities, add operator read surface, real serving layer AUDIT-WP-0004 T03, T05, T06. T03 - the receiver accepted whatever tenant and source a caller sent as long as it held the one shared token, despite WP-0003 recording tenant isolation as delivered. audit_core.senders binds each credential to the sources and tenants it may assert, driven by AUDIT_CORE_SENDERS rather than literals. Identities hold a list of tokens so rotation publishes the replacement alongside the incumbent and needs no delivery gap. Read is a separate privilege from write, so a sender credential cannot read the audit trail back. T05 - lookup by event id, lookup by correlation id, and a dead-letter view. Rejections are recorded rather than silently dropped. An event rejected for carrying secret-shaped material has its payload withheld: storing it would write that material into the audit store, which is what the rejection exists to prevent. Reason and payload hash are kept so it stays traceable. Replay is deliberately not built here. Idempotent replay is a property of the durable store and building it against SQLite would produce a second implementation to throw away; it lands with the Postgres backend in AUDIT-WP-0005-T01. T06 - serving moves to waitress with configurable threads and channel timeout, installed in the image via the serve extra. Without it the entrypoint falls back to a threaded wsgiref server with a socket timeout and graceful shutdown on SIGTERM, and logs a warning so a deployment cannot quietly land on the fallback. Metric counters deferred to WP-0005-T03 to be designed against the real scrape path. Tests 36 -> 46, covering cross-tenant and cross-source refusal, token rotation, read/write privilege separation, correlation lookup, and payload withholding on secret rejection. Remaining in WP-0004: T04 redaction policy, which needs a decision on whether a secret-shaped field is a rejection or a redaction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 14:50:02 +02:00
serve(
app,
host=os.environ.get("AUDIT_CORE_HOST", "0.0.0.0"),
port=int(os.environ.get("AUDIT_CORE_HTTP_PORT", "8080")),
threads=int(os.environ.get("AUDIT_CORE_THREADS", "8")),
timeout=int(os.environ.get("AUDIT_CORE_REQUEST_TIMEOUT", "30")),
)
Add the PostgreSQL audit backend and a shared conformance suite AUDIT-WP-0005-T01, built and verified against PostgreSQL 16 locally in Docker; the Railiance cluster was not needed. tests/test_backend_conformance.py is one suite run against every backend, so "the Postgres backend is done" means it satisfies the same contract SQLite already does rather than having its own green tests. It skips cleanly with no server reachable; make pg-test-up and make test-pg run it. Suite 50 -> 71. RetentionPolicy declares immutable=True and earns it: migration 0002 installs a trigger rejecting UPDATE and DELETE on the events table, so a leaked runtime credential can append but cannot rewrite or erase the trail. That materially narrows the residual risk ADR-0001 section 5 called out. tamper_evidence stays False because nothing here would prove a database owner had dropped the trigger - hash-chaining or external anchoring would be needed and is not implemented. Idempotency is one statement (INSERT ... ON CONFLICT DO NOTHING RETURNING), verified to behave identically to the SQLite backend under 12 concurrent submissions of the same event. Migrations are ordered, recorded and idempotent. Replay reconciles rather than duplicating - the piece deferred out of WP-0004-T05 - and is tested to leave exactly one custody record. Backend selection is by AUDIT_CORE_DATABASE_URL; the SQLite fallback logs a warning so a deployment that lost its URL is visible rather than quietly running on the wrong store. Also fixed: ingestion had no __main__ guard, so python -m audit_core.ingestion silently did nothing. Found during end-to-end smoke. Counting semantics documented: occurrences counts transmissions, not stored events, so a retry of a secret-shaped field increments it again. That is the sender behaviour being optimized away. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 17:09:46 +02:00
if __name__ == "__main__": # pragma: no cover
main()