From cb23dc82fd5dbadfdfb84e99581068bd0aa667c0 Mon Sep 17 00:00:00 2001 From: tegwick Date: Fri, 11 Sep 2026 06:46:15 +0200 Subject: [PATCH] Prepare compatible audit receiver with verified container lifecycle Assistant: codex Assistant-Model: gpt-6-astra Assistant-Session: 01a07ff8-19d0-7820-b4d0-1353833cb7fc --- Containerfile | 30 +++-- audit_core/ingestion.py | 19 ++- .../2026-09-11-factory-receiver-release.json | 109 +++++++++++++++ docs/operator-runbook.md | 33 ++++- intakes/intakes.md | 28 ++++ requirements.lock | 77 +++++++++++ scripts/release_container_check.py | 127 ++++++++++++++++++ ...AUDIT-WP-0009-evidence-role-conformance.md | 20 ++- 8 files changed, 426 insertions(+), 17 deletions(-) create mode 100644 docs/evidence/2026-09-11-factory-receiver-release.json create mode 100644 requirements.lock create mode 100644 scripts/release_container_check.py diff --git a/Containerfile b/Containerfile index a4eeef5..24073eb 100644 --- a/Containerfile +++ b/Containerfile @@ -1,18 +1,30 @@ -# Base pinned by digest, not a mutable tag (AUDIT-WP-0005-T03). -# Same python:3.12-slim digest as the deployed user-engine image. -FROM python:3.12-slim@sha256:d764629ce0ddd8c71fd371e9901efb324a95789d2315a47db7e4d27e78f1b0e9 +# Shared Python/Alpine baseline used by the factory approval services. +# Digest and dependency hashes are release inputs; scan the resulting image. +FROM python:3.12-alpine@sha256:b64631e04e4920160c50fbe8d8df828f7f35f06f425cb44aa09bca53e708a35a AS build +ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PATH=/opt/venv/bin:$PATH +RUN python -m venv /opt/venv +COPY requirements.lock /build/requirements.lock +RUN pip install --no-cache-dir --require-hashes --only-binary=:all: -r /build/requirements.lock +WORKDIR /build +COPY pyproject.toml README.md LICENSE ./ +COPY audit_core ./audit_core +RUN pip install --no-cache-dir --no-deps . +FROM python:3.12-alpine@sha256:b64631e04e4920160c50fbe8d8df828f7f35f06f425cb44aa09bca53e708a35a ARG GIT_COMMIT=unknown LABEL org.opencontainers.image.title="audit-core" \ org.opencontainers.image.source="https://forgejo.coulomb.social/coulomb/audit-core" \ org.opencontainers.image.revision="${GIT_COMMIT}" - -RUN useradd --system --uid 10001 --create-home audit-core +ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 PATH=/opt/venv/bin:$PATH +RUN apk add --no-cache 'libuuid>=2.42.3-r1' \ + && addgroup -S -g 10001 audit-core \ + && adduser -S -u 10001 -G audit-core -H audit-core +COPY --from=build /opt/venv /opt/venv +RUN rm -rf /usr/local/bin/pip* /usr/local/lib/python3.12/site-packages/pip* \ + /usr/local/lib/python3.12/site-packages/setuptools* /usr/local/lib/python3.12/site-packages/pkg_resources \ + /opt/venv/bin/pip* /opt/venv/lib/python3.12/site-packages/pip* \ + /opt/venv/lib/python3.12/site-packages/setuptools* /opt/venv/lib/python3.12/site-packages/pkg_resources WORKDIR /app -COPY pyproject.toml README.md LICENSE ./ -COPY audit_core ./audit_core -RUN pip install --no-cache-dir ".[serve,postgres]" USER 10001:10001 -ENV PYTHONUNBUFFERED=1 EXPOSE 8080 CMD ["audit-core-ingest"] diff --git a/audit_core/ingestion.py b/audit_core/ingestion.py index 578adc8..84b1fd5 100644 --- a/audit_core/ingestion.py +++ b/audit_core/ingestion.py @@ -643,10 +643,21 @@ def serve(app, host: str, port: int, threads: int, timeout: int) -> None: 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", - ) + # PID 1 ignores the default SIGTERM disposition. Raising SystemExit lets + # Waitress stop its dispatcher and wait for active workers (bounded by its + # shutdown timeout). A sender without an acknowledgement must still retry + # the same event id; shutdown never substitutes for durable acceptance. + def terminate(signum, frame): + raise SystemExit(0) + + previous = signal.signal(signal.SIGTERM, terminate) + try: + waitress_serve( + app, host=host, port=port, threads=threads, + channel_timeout=timeout, ident="audit-core", + ) + finally: + signal.signal(signal.SIGTERM, previous) def _serve_fallback(app, host: str, port: int, timeout: int) -> None: diff --git a/docs/evidence/2026-09-11-factory-receiver-release.json b/docs/evidence/2026-09-11-factory-receiver-release.json new file mode 100644 index 0000000..6c830fb --- /dev/null +++ b/docs/evidence/2026-09-11-factory-receiver-release.json @@ -0,0 +1,109 @@ +{ + "status": "release_prepared", + "source_base": "f1b05770956a251da1b9de274f6bd1de5e325b5f", + "receiver_tests": { + "passed": 229, + "command": "PYTHONPATH=../infospace-bench/src AUDIT_CORE_TEST_DATABASE_URL= python -m pytest -q -ra --ignore=tests/test_migrate_store.py", + "excluded": "tests/test_migrate_store.py: five pre-existing failures, one passing empty case. AUDIT-IN-0004 owns repair. No native import." + }, + "container": { + "image": "audit-core:hfact-20260911-hardened", + "native_credentials_used": false, + "checks": { + "operational_ready": true, + "nonroot_readonly": true, + "approval-engine": { + "accepted": 202, + "duplicate": 200, + "wrong_source": 400, + "wrong_tenant": 400, + "read_routes_denied": 7, + "independent_read": 200, + "redacted": true, + "stored_once": true + }, + "informed-decision": { + "accepted": 202, + "duplicate": 200, + "wrong_source": 400, + "wrong_tenant": 400, + "read_routes_denied": 7, + "independent_read": 200, + "redacted": true, + "stored_once": true + }, + "sigterm": { + "exit_code": 0, + "inflight_committed_once": true, + "restart_retry_duplicate": true, + "chain_intact": true + } + }, + "started_at": "2026-09-11T04:43:46.088591+00:00", + "status": "passed", + "own_fixture_cleanup": true + }, + "schema_comparison": { + "live": { + "0001-events": "e613b4bfb54914fbd98a4abd1fd6017e37108df43ffc306ab825f2d5a8afdd3c", + "0002-append-only": "29251f9300f33cfb90fd40677f5fedbe40057e1c0c866cd29a1d2cb160c4a762", + "0003-dead-letters": "a82024b5b4137aecc7885ac8e1212b1711dd06bc5806a01e79882c4a1092d341", + "0004-secret-findings": "f269be3e19d2f69673b96a65fa33a2b1f863a39dfff6f9937062ab6aa58f63e5", + "0005-runtime-grants": "008247e2daeae5a286cd6e46328fbf0d15fa70c6a99b69cadb296a0c713791fe", + "0006-chain": "a984f8226a5d407f990447110f0dd61e400ef4d9da952a268872e5e75f81db2d" + }, + "candidate": { + "0001-events": "e613b4bfb54914fbd98a4abd1fd6017e37108df43ffc306ab825f2d5a8afdd3c", + "0002-append-only": "29251f9300f33cfb90fd40677f5fedbe40057e1c0c866cd29a1d2cb160c4a762", + "0003-dead-letters": "a82024b5b4137aecc7885ac8e1212b1711dd06bc5806a01e79882c4a1092d341", + "0004-secret-findings": "f269be3e19d2f69673b96a65fa33a2b1f863a39dfff6f9937062ab6aa58f63e5", + "0005-runtime-grants": "008247e2daeae5a286cd6e46328fbf0d15fa70c6a99b69cadb296a0c713791fe", + "0006-chain": "a984f8226a5d407f990447110f0dd61e400ef4d9da952a268872e5e75f81db2d" + } + }, + "native_before": { + "ready": { + "status": "ok", + "custody_class": "operational", + "durable": true, + "tamper_evidence": true, + "recoverable_days": 30, + "recoverable_source": "resource-control/data/capability/platform-audit-storage.json#provisions[capability=data.backup]", + "recoverable_basis": "measured" + }, + "chain": { + "intact": true, + "events": 32, + "head": "9fc0b4f25c562cec037a5077f00ee63380db8b5c4e85b6016fc40b301431e4fd", + "head_event_id": "whitehat-e2-event-b-20260822-03", + "head_accepted_at": "2026-08-22T22:09:37+00:00", + "first_break": null, + "attestation_match": null + }, + "image": "forgejo.coulomb.social/coulomb/audit-core@sha256:c2fe39a0185b99be3fc0cb14d2de69772b8e66e20490097c9d11d90cc39719a6", + "generation": 15, + "deployment_uid": "b85fe3d0-75c9-4e0d-8c34-c6f1df0881bb" + }, + "scan": { + "candidate": { + "sha256": "33be06088970b025af916a81ad3833313749c2ff25f0fd99641b6fc4823c1ff3", + "vulnerabilities": { + "LOW": 85, + "HIGH": 81, + "MEDIUM": 99, + "UNKNOWN": 6, + "CRITICAL": 3 + } + }, + "hardened": { + "sha256": "c7598f203067f36be1d0c0f2a7b33c3e02b13583d605d7091baea1bb380f604f", + "vulnerabilities": {} + }, + "scanner": "aquasec/trivy@sha256:62b1e65e8869bc4b4c6aa4fa2b21595256c7c2f6018a9d9ad61caf87187c1969", + "warning": "OS lifecycle list does not yet include Alpine 3.24; vulnerability scanner recognized Alpine 3.24 repository." + }, + "old_candidate_sigterm_exit": 137, + "native_rollout": "pending", + "factory_attempts": 0, + "paid_model_calls": 0 +} diff --git a/docs/operator-runbook.md b/docs/operator-runbook.md index 26ae71f..ae0c59e 100644 --- a/docs/operator-runbook.md +++ b/docs/operator-runbook.md @@ -10,8 +10,8 @@ In-cluster URL: http://audit-core.audit-core.svc.cluster.local:8080 ``` -Reachability is part of the threat model. Ingress is allowed only from the -`user-engine` namespace (write) and from namespaces labelled +Reachability is part of the threat model. Ingress is allowed from the declared producer peers in +`deploy/networkpolicies.yaml` (write) and from namespaces labelled `railiance.io/audit-core-reader=true` (operator read). From a workstation, `kubectl -n audit-core port-forward svc/audit-core 8080:8080` is the usual path and does not require that label. @@ -261,3 +261,32 @@ narrows a column must replace that note before release. 5. Deployment. `/readyz` must report `custody_class=operational` and `recoverable_days=30`. 6. In-pod `MODE=remote DISRUPT=0` failure matrix. Evidence goes to NK-WP-0024. + +## Factory sender receiver release (2026-09-11) + +Build from a committed source with `make image-build`. `requirements.lock` pins +the five runtime packages with hashes; refresh intentionally with +`uv pip compile pyproject.toml --extra serve --extra postgres --generate-hashes --output-file requirements.lock`. +The image uses the factory Python 3.12 Alpine baseline, non-root UID 10001 and +no runtime package installer. Scan every candidate; a clean vulnerability result +is time-bound and not a claim that all future defects are absent. + +The release check script uses only a labelled disposable local PostgreSQL +container, synthetic sender identities, and a fresh schema. It proves exact +source/tenant refusal, all seven evidence read routes denied to writers, +independent readback/redaction, duplicate handling, and a database write in +flight during SIGTERM. Waitress has a bounded five-second worker drain. Requests +without an acknowledgement still require idempotent retry; do not promise that +every queued or long-running request finishes inside a shutdown window. + +The original c2fe39a image and this release have identical migrations 0001-0006. +This receiver upgrade requires no schema migration job. Rollback to c2fe39a is +structurally compatible, but loses evidence classification support: stop the two +new factory producers first and retain the custody records, then re-admit their +sender contract before resuming. Do not claim load-bearing admission while the +legacy receiver is running. + +The historical SQLite importer has five PostgreSQL test failures tracked by +AUDIT-IN-0004. It is outside the live receiver path; do not use it as a recovery +procedure until that record closes. The release test result must state this +exclusion explicitly, never describe the entire suite as green. diff --git a/intakes/intakes.md b/intakes/intakes.md index b1364a0..0325f91 100644 --- a/intakes/intakes.md +++ b/intakes/intakes.md @@ -177,3 +177,31 @@ resolution: 'Assent. The split is right: approval-engine owns the operative stat recorded_in: history/2026-08-28-approval-evidence-assent.md state_hub_intake_id: "01a04d8f-be67-75ed-a221-d50f99dbb78e" ``` + +## AUDIT-IN-0004 — Repair SQLite import into chained PostgreSQL custody + +```yaml +id: AUDIT-IN-0004 +kind: intake +title: Repair SQLite import into chained PostgreSQL custody +status: open +origin: residual +origin_ref: AUDIT-WP-0005 +priority: medium +owner: audit-core +requested_by: codex +created: '2026-09-11' +updated: '2026-09-11' +description: > + The 2026-09-11 receiver release rehearsal enables real PostgreSQL tests. + Five tests in tests/test_migrate_store.py fail because _import_event omits + chain_hash/chain_prev, now NOT NULL. This predates the receiver release and + is not used by the native PostgreSQL receiver or its unchanged schema. + Design the historic accepted_at versus chain ordering rule before fixing + the importer: do not disable append-only protection or rewrite existing + event timestamps to make tests pass. Cover empty and populated destinations, + concurrency with live append, idempotent replay, conflict preservation, and + an intact chain. Also evaluate repeated dead-letter/finding import counting. + Run the full PostgreSQL suite with no migration exclusions for closure. + No native import is authorized or attempted by this release. +``` diff --git a/requirements.lock b/requirements.lock new file mode 100644 index 0000000..3ab78e6 --- /dev/null +++ b/requirements.lock @@ -0,0 +1,77 @@ +# This file was autogenerated by uv via the following command: +# uv pip compile pyproject.toml --extra serve --extra postgres --generate-hashes --output-file requirements.lock +psycopg==3.3.5 \ + --hash=sha256:ce5aa5cdb4f9379f00f487590e5890bfa7df9a164648c969ffa628505e21af4e \ + --hash=sha256:d0a3d9ccf5788af054cbd745278cb02401b5c312aeaafbf2c6144460aec47da4 + # via audit-core (pyproject.toml) +psycopg-binary==3.3.5 \ + --hash=sha256:0249c3e960cdee686000eb77169fb6590105c05bacc37e057ccdffdcd8e6ebde \ + --hash=sha256:04f64b39830887c2c737b522cbfd6ad215d65e67ebfff674aa4cf21c02af487b \ + --hash=sha256:06de14ac978a2d53e864069fb5487075c6e3cfb0740f1bfd7017bc8b9942067f \ + --hash=sha256:0d8a4b7ae47f3381e2ded89891d2455b809f4afb7e5b58086844abb8cfa420ea \ + --hash=sha256:1344fd57a19737554670e67aecabd4fb37cd7937f2925840009645d641117e5a \ + --hash=sha256:14f432430fd9e1a9e7d9ab2fe14956c77f5d074ebdc556a1ad04e9a1bd3fca04 \ + --hash=sha256:14fdfd65a96ecbd8b586d14546105641f4a6ac7cbe335c786830ea4de94bbe60 \ + --hash=sha256:19e5bf9872dbd164c220567fd385ba2309c7d9df1541f78343510c6b0f36a1b7 \ + --hash=sha256:1ef2e498be47800f6202b9a2304c22646325ca6d54001b7c785bcfdb24a1e8ab \ + --hash=sha256:2111f880add40fb03c60556069ad68e884a0908a74d2debafc603caf93b73552 \ + --hash=sha256:25105f9b46bdf2a30fcb67f56976ed66f6855941ae16bc024192609b917d493c \ + --hash=sha256:2719fe19a4da752c4110cc767716d0a5bdb760d1153d89018bb7c9c61717bde5 \ + --hash=sha256:358748fc4c8ccdc0e2bdf55420494930e19c3ade586ea9c3a6de3dad1f897311 \ + --hash=sha256:35885e333020fc152d27bea1a494bef13b2e68f6fd92b6229015e93539152008 \ + --hash=sha256:39e70c8e3b5fad70e2970ea4cc502bf3b612018f128aa1c670f1fa78b9774543 \ + --hash=sha256:40505676b1526b9ea387dace034040a8c8b0bcf984cd6bd4720a2ab15e813586 \ + --hash=sha256:40f8b132c7243ef5f503f0b6f986bf16d38a51b0df1c6ba2577743f128be03e3 \ + --hash=sha256:479b96fd78149cfa10369dc53fbfb89ee729be13146b584a23dbc7e164c0cf1e \ + --hash=sha256:4901e5b9a31c230211a1871263b6594373bacd770b14ad9b14d349716cb69cbe \ + --hash=sha256:553b5443cbc94fdb9b0e31b62acdf615e0780982d6b13752a15eb3d6c0dfd0d2 \ + --hash=sha256:5698ab5941a4d138c30fef858588e651fe7d583280cd6e41832825ad9e747750 \ + --hash=sha256:5816472e3bb05615f33a741e0835043d1f4bf9709ff30d2f4aed71815cfc6b5e \ + --hash=sha256:5b981d25fc2dd13fa7328e40703ea8a03f3e9d855ec946431e96a23206d3b9fd \ + --hash=sha256:682a17a57415c3ca1731eec018ed031f012ffcb81ba74806eb219cb396065672 \ + --hash=sha256:6e85d50b87257fb117675a19ee59daa7bf9a57f6431500adf7059df799232ef4 \ + --hash=sha256:7b443f943abfe35aa5a776630cea27c9348aa66659286cee0b99084332252080 \ + --hash=sha256:88e01aa2e938a45655a8a5213fc3a44ba78cb4cab8a569b3e0bcb3d1d0eaba16 \ + --hash=sha256:893ce86a4b997f6ca1261a7826db2506727332a6ff66646fa7f024b39b5e630e \ + --hash=sha256:8dbd694f3741dd4ac5bc60b70e17f7841aefb3f0f38cef4d2756de270e03af43 \ + --hash=sha256:972cc28e943746e71ede254a4dfd1fdfcdd6dcadd6f375703849859f09377f24 \ + --hash=sha256:98a388509306e5e08a4203253ac52846bc1b034e5cbd0ae6da1211593cc28594 \ + --hash=sha256:9c071bf78e5c2e6efa40bc9089a954d7b41221347a72f35c6bf2d8c96e632f75 \ + --hash=sha256:a5e45e4bb68656253ce5c7a344c0a425c293581eba954d7fd7c2e4b2dc9f3038 \ + --hash=sha256:ab39e2794b95af61a2ff69e33e5ab6ac5df36e9ffea9a3b18e38b2aaca8c5ad5 \ + --hash=sha256:ae67072db949d0c094b747a8ec52ad0fa3c42b27842a5f746f3613d54dde3fba \ + --hash=sha256:af5084124fb2fd16557073822519dfe8c389636a16adef661a4c0c3918733171 \ + --hash=sha256:ba466011569297114449df9d523438e1adeedf3e4f31ffb78e897ec3fef3076b \ + --hash=sha256:c065531e8c1815276f50dbfa283e3a7f022671414cdda6fa9a16794dd53b28f9 \ + --hash=sha256:c09775c549b40b274206e1b043c5e5b5af39666e85c98382a30bd05d23ab677b \ + --hash=sha256:c0cac998b9b1e82dec853d2e53b3d34d56a525cf231f9441a636cfd5992929a9 \ + --hash=sha256:c6bd84e4cf67930f26f015dec33f615472b9c5871d46408efe112dbc1bc021de \ + --hash=sha256:ca8af7c0454cdce235d4aedcb5528857468f1490202d25e24fc7af40e176d563 \ + --hash=sha256:cb3b3bffebfe07110730626e76238161124f35ac87b748d663316a28d22f58b0 \ + --hash=sha256:cd0faa2475ab254ad1b507430131cf7f7f0be927ffdc03c32ad3b33d2ef63f42 \ + --hash=sha256:cf0e5e63ee86098299c673992053d556c489ba9ae6aca6cb6e24d16a8e0b09e6 \ + --hash=sha256:d06da67e9c687c6a6fdac9da4b17cbeb296ddd59bd01f6416ed4294bc57c5faf \ + --hash=sha256:d2a61e8147902771df7efe14062a3c8736347850d0d8befcf048235752504f2e \ + --hash=sha256:d8b66353b20e79bf7ac0a80f03ae97f522ccbbf909d687eec62f112e56c0276d \ + --hash=sha256:df209e64674a34b41662c67fdc8b4e0ffd77d2136393790691d086a09f9a6cab \ + --hash=sha256:df9853b832b7b916e02ef68e0d5403a7dab2d5c1ddfe94f22b1b155eb862622f \ + --hash=sha256:e5becd311f9af8d180bad372f51fb2252fd02cb2073056e2b170c9274f95fe7f \ + --hash=sha256:f45d77e398542ce0937d9fa3cd9d84e9c5fc6b34c50a66404ae840bada312750 \ + --hash=sha256:f7e1e45aad410e20de45df2b159df68ff6c8dbf47a3501f806c4489b27f4ad2b \ + --hash=sha256:fd5b047c9fd887b767d063845413e405f5de8ce1dc7a9d0da0637b77b836b469 \ + --hash=sha256:fdbeb38c9b7ca8fa57a7bda3802bedb62f4494ad3dd46c7dd36dc3f77fd5093f + # via psycopg +psycopg-pool==3.3.1 \ + --hash=sha256:2af5b432941c4c9ad5c87b3fa410aec910ec8f7c122855897983a06c45f2e4b5 \ + --hash=sha256:b10b10b7a175d5cc1592147dc5b7eec8a9e0834eb3ed2c4a92c858e2f51eb63c + # via psycopg +typing-extensions==4.16.0 \ + --hash=sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8 \ + --hash=sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5 + # via + # psycopg + # psycopg-pool +waitress==3.0.2 \ + --hash=sha256:682aaaf2af0c44ada4abfb70ded36393f0e307f4ab9456a215ce0020baefc31f \ + --hash=sha256:c56d67fd6e87c2ee598b76abdd4e96cfad1f24cacdea5078d382b1f9d7b5ed2e + # via audit-core (pyproject.toml) diff --git a/scripts/release_container_check.py b/scripts/release_container_check.py new file mode 100644 index 0000000..b6189c6 --- /dev/null +++ b/scripts/release_container_check.py @@ -0,0 +1,127 @@ +#!/usr/bin/env python3 +"""Exercise a candidate against a labelled disposable local PostgreSQL container. + +No native credentials or cluster access. Requires psycopg on the host. Keeps +only a JSON receipt; deletes only the receiver and database schema it creates. +""" +import argparse +from concurrent.futures import ThreadPoolExecutor +from datetime import datetime, timezone +import json +from pathlib import Path +import subprocess +import time +import uuid + +import psycopg + + +def run(argv, **kwargs): + return subprocess.run(argv, check=True, capture_output=True, text=True, **kwargs).stdout + + +def main(): + p = argparse.ArgumentParser(description=__doc__) + p.add_argument('--image', required=True) + p.add_argument('--postgres-container', required=True) + p.add_argument('--postgres-port', type=int, required=True) + p.add_argument('--receipt', type=Path, required=True) + a = p.parse_args() + pg = json.loads(run(['docker', 'inspect', a.postgres_container]))[0] + assert pg['Config']['Labels'].get('hfact.task') == 'audit-release-20260911', 'local test fixture required' + suffix = uuid.uuid4().hex[:12] + schema = 'release_' + suffix + name = 'audit-release-check-' + suffix + dsn = f'postgresql://postgres:local-test-only@127.0.0.1:{a.postgres_port}/audit_core' + registry = [{'name': n, 'tokens': [n+'-fixture'], 'sources': [n], + 'tenants': ['tenant:platform'], 'may_write': True, 'may_read': False, + 'evidence_kind': 'load-bearing', 'secret_policy': 'redact'} + for n in ['approval-engine', 'informed-decision']] + registry.append({'name':'independent-reader','tokens':['reader-fixture'],'sources':['*'], + 'tenants':['*'],'may_write':False,'may_read':True}) + proof = {'image': a.image, 'native_credentials_used': False, 'checks': {}, + 'started_at': datetime.now(timezone.utc).isoformat()} + created = False + def request(method, path, token='', body=None): + # Values here are local synthetic fixtures, never native credentials. + payload=json.dumps({'method':method,'path':path,'token':token,'body':body}) + code='''import json,sys,urllib.request,urllib.error +x=json.load(sys.stdin); b=x['body'] +r=urllib.request.Request('http://127.0.0.1:8080'+x['path'],method=x['method'],data=json.dumps(b).encode() if b is not None else None,headers={'Content-Type':'application/json','Authorization':'Bearer '+x['token'],'Idempotency-Key':b['id'] if b else ''}) +try: + with urllib.request.urlopen(r,timeout=15) as f: print(json.dumps([f.status,json.load(f)])) +except urllib.error.HTTPError as e: print(json.dumps([e.code,json.load(e)])) +''' + return json.loads(run(['docker','exec','-i',name,'python','-c',code],input=payload)) + def event(sender, suffix): + return {'id':suffix,'source':sender,'type':'factory.custody-check', + 'subject':'synthetic-release-check','tenant':'tenant:platform','correlation_id':'release-'+suffix, + 'occurred_at':datetime.now(timezone.utc).isoformat(), + 'data':{'synthetic':True,'auth_token':'fixture-must-be-redacted'}} + try: + run(['docker','run','-d','--name',name,'--network','container:'+a.postgres_container, + '--read-only','--tmpfs','/tmp','--cap-drop','ALL','--security-opt','no-new-privileges', + '-e','AUDIT_CORE_DATABASE_URL=postgresql://postgres:local-test-only@127.0.0.1:5432/audit_core', + '-e','AUDIT_CORE_DATABASE_SCHEMA='+schema,'-e','AUDIT_CORE_REQUIRE_CUSTODY_CLASS=operational', + '-e','AUDIT_CORE_SENDERS='+json.dumps(registry),a.image]) + created = True + for _ in range(50): + try: + status,body=request('GET','/readyz') + if status==200: break + except (subprocess.CalledProcessError, ValueError): time.sleep(.2) + else: raise AssertionError('receiver readiness timed out') + proof['checks']['operational_ready']=body['custody_class']=='operational' + assert proof['checks']['operational_ready'] + uid=run(['docker','exec',name,'id','-u']).strip(); assert uid=='10001' + proof['checks']['nonroot_readonly']=True + for sender in ['approval-engine','informed-decision']: + e=event(sender,sender+'-'+suffix); token=sender+'-fixture' + assert request('POST','/v1/events',token,e)[0]==202 + assert request('POST','/v1/events',token,e)[0]==200 + assert request('POST','/v1/events',token,dict(e,source='sibling'))[0]==400 + assert request('POST','/v1/events',token,dict(e,tenant='tenant:sibling'))[0]==400 + for path in ['/v1/events/'+e['id'],'/v1/events?correlation_id=fixture','/v1/stats','/v1/integrity','/v1/dead-letters','/v1/secret-findings','/v1/stream-findings']: + assert request('GET',path,token)[0]==403, path + status,stored=request('GET','/v1/events/'+e['id'],'reader-fixture') + assert status==200 and 'fixture-must-be-redacted' not in json.dumps(stored) + with psycopg.connect(dsn) as c: + assert c.execute(f'SELECT count(*) FROM {schema}.events WHERE event_id=%s',(e['id'],)).fetchone()[0]==1 + proof['checks'][sender]={'accepted':202,'duplicate':200,'wrong_source':400,'wrong_tenant':400,'read_routes_denied':7,'independent_read':200,'redacted':True,'stored_once':True} + e=event('approval-engine','shutdown-'+suffix) + with psycopg.connect(dsn,autocommit=True) as lock, ThreadPoolExecutor() as pool: + lock.execute('SELECT pg_advisory_lock(%s)',(0xA0D17007,)) + pending=pool.submit(request,'POST','/v1/events','approval-engine-fixture',e) + for _ in range(100): + if lock.execute("SELECT count(*) FROM pg_locks WHERE locktype='advisory' AND NOT granted").fetchone()[0]: break + time.sleep(.05) + else: raise AssertionError('in-flight database write not observed') + stop=pool.submit(run,['docker','stop','--timeout','10',name]) + time.sleep(.5) + lock.execute('SELECT pg_advisory_unlock(%s)',(0xA0D17007,)) + try: pending.result() + except subprocess.CalledProcessError: pass # Lost acknowledgement requires retry. + stop.result() + code=int(run(['docker','inspect',name,'--format','{{.State.ExitCode}}']).strip()); assert code==0, code + with psycopg.connect(dsn) as c: + assert c.execute(f'SELECT count(*) FROM {schema}.events WHERE event_id=%s',(e['id'],)).fetchone()[0]==1 + run(['docker','start',name]) + for _ in range(50): + try: + if request('GET','/readyz')[0]==200: break + except subprocess.CalledProcessError: pass + time.sleep(.2) + assert request('POST','/v1/events','approval-engine-fixture',e)[0]==200 + status,integrity=request('GET','/v1/integrity','reader-fixture') + assert status==200 and integrity['intact'] and integrity['events']==3 + proof['checks']['sigterm']={'exit_code':code,'inflight_committed_once':True,'restart_retry_duplicate':True,'chain_intact':True} + proof['status']='passed' + finally: + if created: run(['docker','rm','-f',name]) + with psycopg.connect(dsn) as c: c.execute(f'DROP SCHEMA IF EXISTS {schema} CASCADE') + proof['own_fixture_cleanup']=True + with a.receipt.open('x') as f: json.dump(proof,f,indent=2); f.write('\n') + print(json.dumps(proof,indent=2)) + + +if __name__=='__main__': main() diff --git a/workplans/AUDIT-WP-0009-evidence-role-conformance.md b/workplans/AUDIT-WP-0009-evidence-role-conformance.md index 6fde6a0..c3df875 100644 --- a/workplans/AUDIT-WP-0009-evidence-role-conformance.md +++ b/workplans/AUDIT-WP-0009-evidence-role-conformance.md @@ -456,8 +456,8 @@ CCR-2026-0021 (approval-engine) and CCR-2026-0022 (informed-decision), with exact-path ESO projections and CAS-preserving first provision/resume. Its receiver check refuses the current image before credential access. Seventeen new tests, including eleven local OpenBao cases, and 53 existing credential-change -tests pass. Named owner reviews, compatible receiver release and target namespace -readiness remain prerequisites. No native sender credential or deployment changed. +tests pass. The user approved both named owner review sets on 2026-09-11. +Compatible receiver release and target namespace readiness remain prerequisites. See railiance-platform/docs/credential-lane-designs/factory-audit-senders-review.md and its dated preparation evidence. Existing sender/source scopes are preserved. @@ -483,3 +483,19 @@ to cover. T05's dependency is real and should not be worked around. audit-core argued the cadence obligation up to a MUST; inventing a local cadence shape ahead of the Taxonomy schema would fragment the very interface it asked for. + +### Compatible receiver release rehearsal — 2026-09-11 + +229 receiver/contract tests pass with real disposable PostgreSQL. The full suite +exposed five historical SQLite-import failures, now handed off as AUDIT-IN-0004; +that importer is not used by the native receiver. No migration SQL changed. +The new image uses the factory Alpine baseline and hashed runtime dependencies. +Candidate scan went from 81 High/3 Critical to zero reported vulnerabilities, +with the Alpine lifecycle-list warning retained. SIGTERM previously killed +PID 1 with exit 137; explicit handling now exits 0, preserves one in-flight +write and returns duplicate on restart/retry. Both synthetic senders pass exact +scope, redaction, independent readback and seven read-route denials. + +[Rehearsal evidence](../docs/evidence/2026-09-11-factory-receiver-release.json). +Publish and native rollout are the next steps; T09/T11 remain progress until +real sender custody, delivery and owner acceptance are evidenced.