Scope the read path by tenant (AUDIT-WP-0008-T04).
All checks were successful
CI Smoke / host-smoke (push) Successful in 1s
CI Smoke / container-smoke (push) Successful in 2s

_read gated on may_read alone and never called permits_tenant, so any reader
credential could read every tenant through /v1/events, /v1/events/<id>,
/v1/dead-letters and /v1/secret-findings. Deployment bounded the exposure --
the only production sender holds may_read: false -- but the boundary was not in
the code, which is the difference between E2 and E1 on the tenancy posture
enforcement ladder.

Two rules, because the surfaces divide cleanly. Event reads are filtered to the
tenants the credential may act for. Surfaces with no tenant key to filter on --
stats, integrity, dead letters, secret findings -- require full tenant scope and
are refused rather than served instance-wide facts to a scoped reader.

A cross-tenant fetch returns 404 rather than 403. A distinguishable forbidden
would confirm that an event id exists and which tenant holds it, turning the
read surface into an existence oracle. Correlation lookup is filtered rather
than refused, since a correlation id legitimately spans tenants.

_readable_by fails closed: a record with no tenant is readable only at full
scope. Three existing tests read instance-wide surfaces with a scoped
credential, which this makes a 403; bound_app now carries an unrestricted
operator identity and those reads use it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-17 22:05:52 +02:00
parent 26defd9bec
commit aaa2b4c50b
6 changed files with 196 additions and 13 deletions

View file

@ -30,8 +30,8 @@ warden route show database-dynamic-credentials --json
| --- | --- |
| `GET /healthz` | Process is up. Liveness uses this. A database outage must **not** restart the pod. |
| `GET /readyz` | Custody is reachable and `custody_class=operational`. Also reports `recoverable_days` and `tamper_evidence`. Readiness uses this; the pod leaves the Service rather than accept events it cannot store. |
| `GET /v1/stats` | In-process counters since start (`accepted`, `duplicate`, `conflict`, `rejected`, `unauthorized`, `forbidden`, `unavailable`, `error`). Resets on restart. Requires `may_read`. |
| `GET /v1/integrity` | Hash-chain walk: `{intact, events, head, first_break}`. No payloads. Requires `may_read`. A break is a custody defect, not a sender retry. |
| `GET /v1/stats` | In-process counters since start (`accepted`, `duplicate`, `conflict`, `rejected`, `unauthorized`, `forbidden`, `unavailable`, `error`). Resets on restart. Requires `may_read` and full tenant scope. |
| `GET /v1/integrity` | Hash-chain walk: `{intact, events, head, first_break}`. No payloads. Requires `may_read` and full tenant scope. A break is a custody defect, not a sender retry. |
A missing `AUDIT_CORE_DATABASE_URL` / credential directory is a startup
failure (`AUDIT_CORE_REQUIRE_CUSTODY_CLASS=operational`), not a silent
@ -61,6 +61,20 @@ again. Reboot unreadiness is unrelated.
All read routes require a sender identity with `may_read: true`. A write
credential must not be able to read the trail back.
**Read is also tenant-scoped** (AUDIT-WP-0008-T04). A reader sees only the
tenants its identity may act for, and the two rules differ by surface:
| Surface | Behaviour for a scoped reader |
|---|---|
| `GET /v1/events/<id>` | Another tenant's event returns **404**, identical to an id that does not exist. A distinguishable 403 would confirm the event exists and whose it is. |
| `GET /v1/events?correlation_id=` | Filtered to the readable slice. A correlation id legitimately spans services and tenants, so this is filtered rather than refused. |
| `/v1/stats`, `/v1/integrity`, `/v1/dead-letters`, `/v1/secret-findings` | **403 `full_tenant_scope_required`.** These carry no tenant key, so there is nothing to filter on; they are reserved for an identity with `tenants: ["*"]`. |
An operator identity holding `tenants: ["*"]` is unaffected and sees everything,
which is what `$OPERATOR_TOKEN` below assumes. A reader scoped to one tenant is
the case this protects: before T04, `may_read` was the only gate and any reader
could read every tenant.
```bash
# One event
curl -sS -H "Authorization: Bearer $OPERATOR_TOKEN" \