_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>
350 lines
18 KiB
Markdown
350 lines
18 KiB
Markdown
---
|
|
id: AUDIT-WP-0008
|
|
type: workplan
|
|
title: "Tenancy posture declaration and read-path enforcement"
|
|
domain: infotech
|
|
repo: audit-core
|
|
status: ready
|
|
owner: claude
|
|
topic_slug: railiance
|
|
created: "2026-08-17"
|
|
updated: "2026-08-17"
|
|
depends_on:
|
|
- AUDIT-WP-0007
|
|
state_hub_workstream_id: "84c01552-4c65-45fd-bf6c-7daab0ecc9b6"
|
|
---
|
|
|
|
# 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.
|
|
|
|
**This position is conditional, and the condition is dated.** Routing multi-year
|
|
custody to `data.archive` is correct mechanically and unfunded practically:
|
|
the capability case records it as unmet, with no booked cost and no procurement
|
|
owner in motion. Taking this position without a date would read as "audit-core
|
|
is fine", and audit-core is not fine — it is correctly routed and unresourced.
|
|
|
|
Escalation: **reviewed 2026-12-31.** If no `data.archive` provision is procured
|
|
by then, audit-core reopens placement. §4.5 makes retention a placement trigger,
|
|
and P2 — a dedicated cluster with an independent window — becomes the fallback
|
|
worth arguing for. P2 still does not deliver WORM, so it is a worse answer than
|
|
archive; it is named here so the fallback exists rather than being improvised
|
|
under pressure. Recorded now so the review has something to fire against.
|
|
|
|
**Also taken now: R1 → R2.** R2 needs only that retention be declared per
|
|
dataset rather than inherited silently, and that the erasure horizon be
|
|
published. That is an explicit `backupRetentionDays: 30` in the consumer
|
|
declaration plus the horizon rendered in our operator surface — a change of
|
|
hours, not a future target. Deferring it as a "target" would have been the
|
|
framework's own §8.1 failure: a value in a file nobody re-reads.
|
|
|
|
### 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.
|
|
|
|
**The exemption rests on a legal basis nobody in this estate has confirmed, and
|
|
that must be said out loud rather than assumed.** Declining to build erasure
|
|
means that if an Article 17 request arrives naming a data subject in the audit
|
|
trail, audit-core has no mechanism and must rely on audit evidence being
|
|
exempt — legal obligation, or legitimate interest in fraud and security
|
|
investigation. Those grounds are ordinary and widely relied on. They are also a
|
|
legal conclusion, and this repo is not competent to reach one. Nothing in
|
|
INTENT, SCOPE or the capability case records anyone having reached it either.
|
|
|
|
This workplan therefore **routes the question rather than absorbing it**: the
|
|
position is "we do not build erasure, on a legal basis we have named and not
|
|
validated", and the validation is somebody else's to own. An unnamed dependency
|
|
here is the difference between a recorded tension and an unnoticed liability —
|
|
and an audit service discovering in the middle of a regulator's question that
|
|
its exemption was assumed is the specific failure worth spending a paragraph to
|
|
avoid.
|
|
|
|
## 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.
|
|
|
|
Neither obvious fix is right alone. A bare **minimum across paths** is safe
|
|
to publish but destroys information: a service at E3-write/E1-read declares
|
|
identically to one at E1/E1, though the first is materially further along.
|
|
Bare **per-path declaration** (`E: {write: 3, read: 1}`) keeps the signal and
|
|
invites exactly the selective quoting §6 exists to stop — "our write path is
|
|
E3" is a sentence someone will say.
|
|
|
|
Recommend both, with the relationship stated: **declare per path, quote the
|
|
minimum.** The posture vector's `E` is the minimum across paths; the per-path
|
|
breakdown sits beside it and is where improvement is tracked. One sentence in
|
|
§4.3, and it is the only version that keeps honesty and signal at once.
|
|
|
|
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.
|
|
|
|
**The target carries a date: 2027-03-31.** The framework says services whose
|
|
cross-tenant exposure would be a reportable breach SHOULD target E3, and no
|
|
service on `platform-pg` fits that description better than this one. Every
|
|
quarter spent at E2-with-a-target is a quarter of stated, unclosed risk, and
|
|
an undated target is what §8.1 calls documentation rather than control. If the
|
|
date slips it slips as a decision, which is the whole point of §12.
|
|
- **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.
|
|
|
|
## Sequencing
|
|
|
|
**T04 runs before T01.** Task ids are hub-registered and not renumbered; the
|
|
execution order is T02 → T04 → T01 → T03 → T05 → T06.
|
|
|
|
The reason is narrow. A posture vector published in the repo is a canon-shaped
|
|
artifact that outlives the fortnight it takes to fix the read path, and `E1` on
|
|
an audit service is a sentence that will be quoted back long after it stopped
|
|
being true. Fixing first means the declaration is `E2` on both paths from its
|
|
first commit, and the E1 interval never enters the permanent record.
|
|
|
|
This is emphatically **not** withholding the finding. T02 goes first precisely
|
|
so the correction reaches `rapp-postgres` immediately: their guess of `E2` is
|
|
live in canon *now*, and it is wrong now. A message correcting the record is the
|
|
fast instrument; a published vector is the durable one. Deferring the durable
|
|
artifact by two weeks while the fast one goes out today is sequencing. Delaying
|
|
both would be concealment, and §6 would be right to call it that.
|
|
|
|
## Tasks
|
|
|
|
```task
|
|
id: AUDIT-WP-0008-T01
|
|
status: todo
|
|
priority: high
|
|
state_hub_task_id: "65f3109a-fa98-459a-9e59-d7ac211bd2bc"
|
|
```
|
|
Publish the posture vector in the repo per Decision 5.1 — `docs/tenancy-posture.yaml`.
|
|
**Runs after T04.** Declares `current: I1 A2 E2 P1 R2`, `target: I1 A2 E3 P1 R2`,
|
|
reviewed date, and a gap note per axis. Include the per-path E breakdown
|
|
(`write: 2, read: 2`) alongside the vector, per finding 1. The E gap names RLS
|
|
with its 2027-03-31 date; the R gap names `data.archive` with its 2026-12-31
|
|
review. If T04 has not landed when this is published, declare `E1` and say why —
|
|
the vector describes what is true on the day it is written, never what is
|
|
expected. Cross-link from `SCOPE.md` and `docs/interface-card.yaml`.
|
|
|
|
Fold in the R1 → R2 move: explicit `backupRetentionDays: 30` requested of
|
|
`rapp-postgres` for `consumers/audit-core.yaml` (that file is theirs, so this is
|
|
a request, not an edit), and the erasure horizon rendered in the operator
|
|
surface alongside `recoverable_days`.
|
|
|
|
```task
|
|
id: AUDIT-WP-0008-T02
|
|
status: done
|
|
priority: high
|
|
state_hub_task_id: "224a12c0-d646-4c7f-9215-120e1ede4691"
|
|
```
|
|
Reply to `rapp-postgres` message `56b77e20-0152-4e09-8f76-99640269063b`.
|
|
**Runs first.** Carries four things: the retention position (ceiling stays 365,
|
|
audit-core declares nothing above the 30-day default, multi-year routes to
|
|
`data.archive`) with the **2026-12-31 escalation date and the P2 fallback named**;
|
|
the request for an explicit `backupRetentionDays: 30` in `consumers/audit-core.yaml`
|
|
to take us to R2; the ADR-0002 context amendment; and `batch` confirmed.
|
|
|
|
It also **corrects their posture guess immediately** — `I1 A2 E1 P1 R1`, not
|
|
`I2 A3 E2 P1 R1`, with the read-path defect stated plainly and T04 named as the
|
|
fix in flight. Their `E2` guess is live in canon and wrong today; the correction
|
|
does not wait on T01. Mark the message read on send.
|
|
|
|
```task
|
|
id: AUDIT-WP-0008-T03
|
|
status: todo
|
|
priority: high
|
|
state_hub_task_id: "0003954e-5033-457f-b9aa-97efdefe9beb"
|
|
```
|
|
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.
|
|
|
|
**Name the legal dependency explicitly and route it.** The document must state
|
|
that declining to build erasure rests on audit evidence being exempt under legal
|
|
obligation or legitimate interest, that this is a legal conclusion, and that no
|
|
one in the estate is recorded as having reached it. Route that question to
|
|
`the-custodian` for an owner rather than closing it here — audit-core is not
|
|
competent to answer it and should not appear to have. The position is publishable
|
|
with the dependency open; it is not publishable with the dependency hidden.
|
|
|
|
```task
|
|
id: AUDIT-WP-0008-T04
|
|
status: done
|
|
priority: high
|
|
state_hub_task_id: "e4440db3-6eb0-49f6-9aa9-930db8be9d18"
|
|
```
|
|
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
|
|
state_hub_task_id: "30b56b30-e7eb-4873-aa7c-f4a3bf2fcb24"
|
|
```
|
|
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
|
|
state_hub_task_id: "ed58ca37-755b-4284-b1ac-2a02baaceb21"
|
|
```
|
|
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, per-path
|
|
E breakdown, and dated gaps.
|
|
- Read path enforces tenant scope, with a test that fails if it regresses.
|
|
- `rapp-postgres` has the retention answer, the corrected posture guess, and the
|
|
question 11 answer.
|
|
- NetKingdom has the five framework findings.
|
|
- The §5 worked example for `audit-core` is corrected upstream.
|
|
- Two dates are recorded and monitored per §8.1 rather than merely written:
|
|
`data.archive` review 2026-12-31, E3 target 2027-03-31.
|
|
- The erasure legal question has an owner outside audit-core.
|