diff --git a/workplans/AUDIT-WP-0008-tenancy-posture-alignment.md b/workplans/AUDIT-WP-0008-tenancy-posture-alignment.md new file mode 100644 index 0000000..12df94d --- /dev/null +++ b/workplans/AUDIT-WP-0008-tenancy-posture-alignment.md @@ -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/`, `/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/`, `/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.