Propose AUDIT-WP-0008 for tenancy posture alignment.
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

Assesses audit-core against NetKingdom Tenancy Posture v0.1 following the
rapp-postgres review request. The guessed vector I2 A3 E2 P1 R1 is too high on
three axes; the honest reading is I1 A2 E1 P1 R1.

The E correction is a real defect rather than a bookkeeping one: the read path
in ingestion._read gates on may_read alone and never calls permits_tenant, so a
reader credential can read any tenant. Bounded today by may_read: false on the
production sender and by default-deny networking, but not enforced in code.

Also records the retention position (ceiling stays 365, multi-year belongs to
data.archive not the WAL window) and the question-11 answer: crypto-shredding
does not reach R4 here, because the chain commits to a SHA-256 of the cleartext
record, which survives key destruction as a confirmation oracle.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-17 18:09:15 +02:00
parent b463df8031
commit 94051bd1b6

View file

@ -0,0 +1,244 @@
---
id: AUDIT-WP-0008
type: workplan
title: "Tenancy posture declaration and read-path enforcement"
domain: infotech
repo: audit-core
status: proposed
owner: claude
topic_slug: railiance
created: "2026-08-17"
updated: "2026-08-17"
depends_on:
- AUDIT-WP-0007
---
# AUDIT-WP-0008 — Tenancy posture declaration and read-path enforcement
## Goal
Answer the `rapp-postgres` review request against NetKingdom Tenancy Posture
v0.1, declare an accurate posture vector, and close the one gap the assessment
exposed in running code.
Canonical framework: `net-kingdom/canon/standards/tenancy-posture_v0.1.md`
(rendered copy in `policy-nexus/build/tenancy-posture.html`).
## Assessment — what the ladders say about us
`rapp-postgres` guessed `audit-core I2 A3 E2 P1 R1`, and §5 carries that guess
as a worked example. Three of the five are too high.
| Axis | Guess | Actual | Why |
|---|---|---|---|
| I | 2 | **1** | Senders hold static bearer tokens (`audit_core/senders.py`); the tenant arrives in the request body (`audit_core/ingestion.py:379`) and is checked against an allowlist bound to the credential. No IdP, no verified claim. §4.1 names this case explicitly: I1 absorbs "the tenant is taken from the request rather than from a verified token". |
| A | 3 | **2** | No `flex-auth` call exists anywhere in the repo. Authorization is a single local boundary — `permits_source`, `permits_tenant`, `may_write`, `may_read`. That is the definition of A2. |
| E | 2 | **1** | The write path is E2-shaped. The read path applies **no tenant filter at all**. See below. |
| P | 1 | 1 | `platform-pg`, database per consumer. Correct. |
| R | 1 | 1 | No `backupRetentionDays` in `rapp-postgres/consumers/audit-core.yaml`; platform default 30 days applies. Correct. |
The I-axis correction is not a defect. The credential→tenant allowlist is a real
control; it is simply an **A**-axis control sitting where a reader would expect
an **I**-axis one. Decision 3.1 doing its job on its first live use.
### The E-axis finding
`_read` (`audit_core/ingestion.py:226-272`) gates on `identity.may_read` and
nothing else. `permits_tenant` is never called on the read path. A credential
with `may_read: true` can read **any** tenant's events through
`/v1/events?correlation_id=`, `/v1/events/<id>`, `/v1/dead-letters` and
`/v1/secret-findings`.
Exposure today is bounded: the production sender `user-engine` carries
`may_read: false`, and the read surface is ClusterIP behind a default-deny
NetworkPolicy. But bounded-by-deployment is not enforced-by-code, and under §6
declaring E2 on that basis would be the overclaim the framework exists to
prohibit. E1 is the honest reading until T04 lands.
This also corrects the premise of the review request. `rapp-postgres` wrote
"you are at E2, and E3 is more urgent for you than for anyone else". E3 is
indeed the right target for a service holding audit evidence, but §4.3 requires
the E2 evidence artifact alongside any E3 claim — so the first move is reaching
E2, not adopting RLS.
## Positions to take
### On the retention ceiling (their question 1)
**The 365-day ceiling is not wrong for us. Keep it.**
The ADR-0002 context line — "audit-core holds audit evidence and may need
years" — is right about the requirement and wrong about the mechanism. Physical
backup retention is not where multi-year audit custody belongs. It is already
routed: `data/capability/audit-core-operational.json` records `data.archive`
as an unmet requirement with a separate owner and an explicitly different
bucket and lifecycle from Barman.
Consequences we accept and want stated back:
- audit-core declares nothing above the 30-day platform default. We therefore
do **not** extend any co-resident's erasure horizon, now or under this plan.
- Multi-year retention is an archive procurement question, not a WAL-window
question. Raising the ceiling would buy us nothing and cost every co-resident
volume against 20Gi.
- Service class `batch` is correct. No correction wanted. Co-residency with
latency-critical `tenant-engine` is right to be reported under §8.3.3.
Ask of `rapp-postgres`: amend the ADR-0002 opening context to point at archive
rather than at backup retention, so the two statements agree.
### On erasure versus audit (their question 11)
Yes to the fact/payload split — retain the fact of an event, encrypt its
personal payload per subject, shred the key. But that resolution does not
survive contact with our integrity design as built, in two ways the framework
does not currently capture.
**One: it is not retrofittable.** `_record_hash`
(`audit_core/postgres_backend.py:583`) is SHA-256 over the canonical JSON of the
*cleartext* record, and that hash is what the chain commits to. Encrypting a
payload after the fact severs the binding between `payload_hash` and the stored
record. Shreddability has to be built as encrypt-then-hash at accept time, with
the chain committing to ciphertext. Retrofitting it onto existing events is not
possible without rewriting the chain, which is the one thing AUDIT-WP-0007 was
built to make detectable.
**Two, and sharper: the retained hash is a confirmation oracle.** A SHA-256 over
a canonical record whose fields are low-entropy (event type, actor, tenant,
subject, timestamp) lets anyone holding the hash guess the payload, hash the
guess, and confirm a match. Destroying the key does not make the content
unrecoverable while that hash survives. **Crypto-shredding does not reach R4
under this design.** It needs the integrity commitment to be an HMAC under a
per-subject key that dies with the key, or a per-record salt destroyed with it.
This is a genuine gap in §4.5, not just a fact about us: the key-destroyed route
treats key destruction as sufficient without requiring that no retained
commitment reveal the erased content. Recommend §4.5 gain that condition.
Our position: audit-core targets **R2**, not R4. Verified erasure of audit
evidence is a design tension we record rather than resolve, and a "R4
everywhere" fleet target must exempt this service explicitly.
## Framework review findings
Sent under §20.2 — the framework is validated by whether it can describe us.
1. **The E ladder cannot express asymmetric enforcement.** Our write path is E2
and our read path is E1. §4.3 assumes one level per service. This will recur
estate-wide, since most services enforce harder on write than on read, and
with no rule everyone will declare their best path. Recommend the vector take
the **minimum across paths**, stated explicitly — the reading consistent with
§6. Per-path declaration (`E: {write: 2, read: 1}`) is the richer option but
invites the same selective quoting downstream.
2. **§13.1 collides with the ladder floor.** "A level is claimed only with its
evidence artifact present", but §13's table has no artifact for I0, I1, A0,
A1, E0, E1, R0 or R1. §5's own worked example of a conformant absorbed repo —
`I1 A1 E1 P0 R0` — cannot satisfy 13.1 on any axis. Either declaration alone
is the evidence at the floor rungs, or 13.1 binds only above them. Small, but
it makes the framework's flagship example non-conformant against its own rule.
3. **§9.1 omits consumer-facing credentials.** Decision 9.1 bans static
long-lived credentials for database access above E0, and we comply — leases
re-read at connection time, `audit_core/credentials.py`. But our *ingest*
credentials are static long-lived bearer tokens rotated by publishing a
second token alongside the first. §9 promotes leased credentials to "a
tenancy control"; the same argument applies with more force to the credential
that actually carries the tenant claim. The omission reads as accidental.
Name consumer-facing credentials in scope, or say deliberately that they are
not.
4. **§4.5 key-destroyed route** — add the condition in "On erasure versus audit"
above: no retained integrity commitment may reveal the erased content.
5. **§5 worked example** — correct `audit-core` to `I1 A2 E1 P1 R1`, and revise
its note. "Both E3 and R2 are urgent targets" is half right: E3 is, R2 is
nearly vacuous for us while the real requirement sits in `data.archive`.
Nothing here asks the framework to change shape. Four of five axes described us
without strain, and the one that did not (E) failed in a way that found a real
defect in our code. That is the framework working.
## Non-goals
- **E3 / row-level security is not in this plan.** ADR-0003 publishes the
contract and it is the right target, but it is blocked behind reaching E2
(§4.3) and needs the `EXPLAIN` comparison first. Declared as target with a
recorded gap in T01, not built here.
- **No per-subject encryption is built.** T03 records a position; it does not
commit to an implementation.
- **No adversarial evidence facility is built here.** §19.3 gave that a home in
`whitehat-security`. T05 coordinates, it does not volunteer.
## Tasks
```task
id: AUDIT-WP-0008-T01
status: todo
priority: high
```
Publish the posture vector in the repo per Decision 5.1 — `docs/tenancy-posture.yaml`,
declaring `current: I1 A2 E1 P1 R1`, `target: I1 A2 E3 P1 R2`, reviewed date, and a
gap note per axis. The E gap names the read path and cites T04; the R gap names
`data.archive` as the real requirement. Cross-link from `SCOPE.md` and
`docs/interface-card.yaml`.
```task
id: AUDIT-WP-0008-T02
status: todo
priority: high
```
Reply to `rapp-postgres` message `56b77e20-0152-4e09-8f76-99640269063b` with the
retention position: ceiling stays at 365, audit-core declares nothing above the
30-day default, multi-year routes to `data.archive`, service class `batch`
confirmed. Ask for the ADR-0002 context amendment. Mark the message read on send.
```task
id: AUDIT-WP-0008-T03
status: todo
priority: high
```
Answer question 11 in `docs/erasure-and-audit.md`: the fact/payload split, the
encrypt-then-hash constraint, the confirmation-oracle finding, and the R2-not-R4
target. Record as an audit-core position; send the §4.5 condition to NetKingdom
as a framework amendment.
```task
id: AUDIT-WP-0008-T04
status: todo
priority: high
```
Bind the read path to permitted tenants. `permits_tenant` governs read as it
governs write, at the same choke point: tenant-scope `/v1/events`,
`/v1/events/<id>`, `/v1/dead-letters` and `/v1/secret-findings`. Decide and
record how an operator identity holding `tenants: ["*"]` is distinguished from a
scoped reader. Tests for cross-tenant read refusal. Update `docs/interface-card.yaml`
and the operator runbook. Raises E to 2 on both paths; update T01's declaration.
```task
id: AUDIT-WP-0008-T05
status: todo
priority: medium
```
Produce the §13 E2 evidence artifact — adversarial, with a review date: an
identity bound to tenant A demonstrably cannot read tenant B. Coordinate with
`whitehat-security` (WHITEHAT-WP-0001) on whether this is theirs to run or ours
to write and theirs to review. Record the review cadence as the exposure window
per §19.3.
```task
id: AUDIT-WP-0008-T06
status: todo
priority: medium
```
Send the five framework review findings to `net-kingdom` (owner of the canon
path) and `policy-nexus` (owner of the rendered copy), as a §20.2 review
response rather than as objections.
## Done when
- `docs/tenancy-posture.yaml` declares an accurate vector with targets and gaps.
- Read path enforces tenant scope, with a test that fails if it regresses.
- `rapp-postgres` has the retention answer and the question 11 answer.
- NetKingdom has the five framework findings.
- The §5 worked example for `audit-core` is corrected upstream.