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>
244 lines
11 KiB
Markdown
244 lines
11 KiB
Markdown
---
|
|
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.
|