diff --git a/canon/standards/tenancy-posture_v0.1.md b/canon/standards/tenancy-posture_v0.1.md index 92ff0cc..1e96d0f 100644 --- a/canon/standards/tenancy-posture_v0.1.md +++ b/canon/standards/tenancy-posture_v0.1.md @@ -8,7 +8,7 @@ version: "0.1" created: "2026-08-17" updated: "2026-08-17" scope: multi-tenancy-security-framework -revision: "draft-6" +revision: "draft-7" adr: - docs/adr/ADR-0006-recursive-multi-tenant-identity-authorization.md - docs/adr/ADR-0013-tenant-onboarding-grouping-taxonomy.md @@ -24,7 +24,7 @@ related: ## Status -**Proposed, draft-6.** Relocated from `the-custodian/canon/architecture` on +**Proposed, draft-7.** Relocated from `the-custodian/canon/architecture` on 2026-08-17: multi-tenancy is part of the IT-security framework NetKingdom provides, so this framework belongs in NetKingdom canon beside the IAM Profile and the tenant-engine boundary contract, not in the work-factory canon. @@ -41,12 +41,19 @@ and the tenant-engine boundary contract, not in the work-factory canon. shared infrastructure cannot provide. - **draft-5** relocated to NetKingdom and renamed the dimensions from *planes* to *axes*, because the word was already taken (§0). -- **draft-6** applies the first review. `tenant-engine` assessed itself, - corrected a guess downward on two axes, found an axis that did not fit its - data shape, and found a real cross-tenant defect in its own code while - reading the ladder. Five changes followed (§4.1, §4.4, §4.5, §5.2, and the E - registry exception). **The ratification test worked: the document changed, - not the repo.** +- **draft-6** applied `tenant-engine`'s review: five changes, including an + axis that did not fit its data shape. +- **draft-7** applies `audit-core`, `railiance-platform` and `flex-auth`. + Eleven further changes, two of them corrections to statements this document + made as fact about other repos. **Every posture I guessed was too generous, + on every repo that has now self-reported.** + +**Reviewed by four of six. The score so far:** four repos found three live +defects in their own code by reading the ladders — `tenant-engine`'s unfiltered +event accessor, `audit-core`'s unfiltered read path, `flex-auth`'s +unauthenticated `/v1/check` — and `railiance-platform` found `apps-pg` running +with no backup configured at all while writing its §10.2 disclosure. The +document changed eleven times and no repo was told it was wrong. Every correction so far was found by research or by relocation, not by review. @@ -219,17 +226,55 @@ up. That is the pattern this framework generalises. | **A3** | Decisions delegated to `flex-auth` as PDP, with live re-query where the IAM Profile requires it. | | **A4** | A3 over a **standard** PDP interface (OpenID AuthZEN Authorization API 1.0), so the decision point is swappable and the enforcement point is not coupled to one engine's request shape. | -A4 is new. `flex-auth` uses a bespoke `CheckRequest` and a bespoke action -vocabulary, with action strings copied verbatim between repos to avoid -re-derivation — exactly the coupling AuthZEN removes. The specification reached -Final in January 2026 and Keycloak shipped experimental support in May. We are -not wrong, we are pre-standard, and the ladder should have somewhere to go. +**This ladder describes enforcement points.** A decision point cannot occupy +A3 — "delegated to `flex-auth`" is not something `flex-auth` can do. A service +that *is* a PDP declares **two numbers**: its own inbound level, and the +maximum it enables for consumers. `flex-auth` reads `A0, enables A3` — accurate, +and considerably more alarming than `A3`, which is the point. Raised by +`flex-auth`, whose absence from the §5 worked examples was this surfacing +implicitly. + +A4 is new. The specification reached Final in January 2026 and Keycloak shipped +experimental support in May; the argument for it is **interoperability** — a +swappable decision point and an enforcement point not coupled to one engine's +request shape. + +*Correction from flex-auth's review:* earlier drafts also justified A4 as +ending the copying of action strings between repos. **It does not.** AuthZEN +standardises the envelope — subject, action, resource, context, endpoint — and +deliberately does not standardise the action vocabulary or the policy language. +At A4, `tenant.guardrail.set` still has to be agreed and still gets copied. +Those are two problems with different fixes, and the cheaper one is not A4: +`flex-auth`'s registry already carries action definitions per system and could +serve them read-only. The vocabulary argument is withdrawn. **Internal service-to-service calls are in scope for this axis.** "Skipping -tenant validation for internal services" is a named anti-pattern, and our -estate is mostly internal calls — `flex-auth` calls `tenant-engine` -synchronously on the authorization path. A service identity acting on behalf of -a tenant must carry and revalidate tenant context to claim A2 or above. +tenant validation for internal services" is a named anti-pattern and our estate +is mostly internal calls. + +*Correction from flex-auth's review:* earlier drafts asserted that `flex-auth` +calls `tenant-engine` synchronously on the authorization path. **That is not +true.** The adapter is built and complete and has no non-test caller, so the +IAM Profile's live re-query exists and is unwired — which is also why +`flex-auth` cannot reach I3. Built-and-unwired is the worst of the three states +because it reads as capability. + +The requirement, narrowed on their proposal because the original was too strong +to be met and would have made `tenant-engine` a hard availability dependency of +every decision in the estate: + +> Tenant context MUST be carried on every internal hop and MUST NOT be +> re-derived from a service identity. It MUST be revalidated against +> `tenant-engine` at least once per request chain — at the service that holds +> or mutates the tenant's data, or before a privileged, destructive, +> credential-vending or `aal2`-class decision, whichever comes first. A hop +> that neither holds tenant data nor makes such a decision may carry the +> context without revalidating it. + +**And carrying tenant context is worthless without an authenticated hop to +carry it over.** `flex-auth` found this in itself: it carries tenant context +faithfully and cannot distinguish "user-engine asking on behalf of tenant X" +from "any pod asking on behalf of tenant X". ### 4.3 Enforcement (E) @@ -360,6 +405,28 @@ layer. Reference implementation: `rapp-postgres` ADR-0002. | **Horizon-elapsed** | Wait out the published erasure horizon; the data ages out of every retained copy. | Available to everyone, proves little, and the wait is set by a co-resident's retention requirement rather than your own. | | **Key-destroyed** | Encrypt per entity, then destroy the key. Retained copies survive but are unreadable. | Requires per-entity keys, strong encryption, and an auditable destruction record. Immediate. | +**Decision 4.5.3 — key destruction is not sufficient on its own.** The +key-destroyed route requires that **no retained commitment reveals the erased +content**. Found by `audit-core`, and it is a general defect rather than a fact +about them: + +- A SHA-256 over a canonical record whose fields are low-entropy — event type, + actor, tenant, subject, timestamp — is a **confirmation oracle**. Anyone + holding the hash can guess the payload, hash the guess, and confirm a match. + Destroying the key does not make the content unrecoverable while that hash + survives. +- Shreddability is **not retrofittable** onto an integrity chain that commits + to cleartext. It has to be built as encrypt-then-hash at accept time, with the + chain committing to ciphertext. Retrofitting means rewriting the chain — the + exact thing a tamper-evident log exists to make detectable. + +So a service claiming R4 by key destruction must show that its retained +commitments — hashes, chains, indexes, search keys — do not reveal what was +erased. The remedies are an HMAC under a per-subject key that dies with the +key, or a per-record salt destroyed alongside it. **`audit-core` cannot reach +R4 under its current design and targets R2; a fleet R4 target must exempt it +explicitly.** + **Regulatory standing of the key-destroyed route, stated carefully because overclaiming here is worse than anywhere else in this framework.** Data protection authorities have accepted key destruction as erasure where physical @@ -428,29 +495,74 @@ under-claim. Placement is therefore declared as a default plus exceptions: A service with exceptions must be able to say which tenants are on which substrate. That mapping is a first-class artifact, not archaeology. -Worked examples, best-effort and subject to owner correction: +**Decision 5.5 — a provider declares what it makes reachable, not where it +sits.** The five ladders describe a *consumer* of infrastructure. They describe +a *provider* of it badly, and `railiance-platform`'s review demonstrated how +badly: `apps-pg` is `I0 A0 E0` because a database has no tenant concept, +carries no tenant claim and applies no tenant predicate. Those zeros are +**structural, not weak** — the cluster is exactly as strong as its consumers +make it. + +The sharp case is OpenBao at `E0`. Literally correct, and actively misleading: +the mechanism in place is credential-scoped structural separation — `E4` +machinery — pointed at a *consumer* boundary rather than a tenant one. A reader +scanning a column of E values would rank it below a service doing per-query +filtering in application code, inverting the real security position. + +So a platform service additionally declares, per axis, **the maximum level it +makes reachable and what a consumer must do to reach it**. For `apps-pg`: `E4` +unreachable (shared credential per consumer, no per-tenant credential), `E3` +reachable once the GUC contract exists, `R2` blocked on a backup target. That is +the sentence a consumer actually needs, and no arrangement of the consumer +ladders produces it. + +A provider's own `P` is `n/a`, not a number. `apps-pg` *provides* `P1`; it is +not *at* `P1`, and writing `P: 1` there would later read as an isolation claim. + +Worked examples, self-reported where marked, and every guess so far has been +too generous: | Service | Current | Notes | |---|---|---| | `tenant-engine` | `I1 A2 E2 P— R0/R1` | **Self-reported on review**, correcting a more generous guess. I1: acting identity comes from the request body, not a verified token. A2: three read routes unauthorized. P—: still on SQLite, P1 is TEN-WP-0009's target. R: see §4.5 on the erasure/retention split. | -| `audit-core` | `I2 A3 E2 P1 R1` | Guessed, not yet self-reported. Holds audit evidence, so E3 is urgent. | +| `audit-core` | `I1 A2 E1 P1 R1→R2` | **Self-reported**, three axes below my guess. E1 because the read path applies no tenant filter at all — a defect found by reading the ladder. Bounded by deployment, not by code. | +| `flex-auth` | `I1 A0 E2 P n/a R n/a` | **Self-reported**, `enables A3` for consumers. A0: `/v1/check` authenticates no caller, so any workload with network reach can assert any subject and receive an authoritative allow. | +| `apps-pg` (provider) | `I0 A0 E0 P n/a R0` | **Self-reported.** Zeros are structural. `R0` here means no backup configured at all — found while writing the §10.2 disclosure. | | A newly absorbed repo | `I1 A1 E1 P0 R0` | Conformant **if declared**, with a recorded path. | **Decision 5.1:** the posture vector is declared in the repo, not in the hub, consistent with local-files-are-source-of-truth. -**Decision 5.2 — a level reports the weakest surface, not the best one.** A -service whose mutations are authorized by a PDP and whose read routes are -unauthenticated is at the read routes' level, not the mutations'. Publishing -the stronger surface would be accurate about that surface and misleading about -the service, which §6 forbids. +**Decision 5.2 — declare per path, quote the minimum.** A service whose +mutations are authorized and whose reads are not is at the reads' level. The +quoted number is the minimum across paths; the per-path detail is declared +beside it. -Raised by `tenant-engine`, which found exactly this shape in itself during -review — A3 on writes, no authorization on three read routes including the one -`flex-auth` calls for `aal2`-class decisions — and reported A2. A per-surface -vector was considered and rejected as premature: it multiplies the declaration -before anyone has shown the single weakest number is insufficient. Services -with a materially split surface should record the split in the `gap` field. +Draft-6 required only the minimum, on `tenant-engine`'s review. `audit-core` +then showed why that is insufficient on its own: a bare minimum destroys +signal, because `E3`-write/`E1`-read declares identically to `E1`/`E1`. Bare +per-path invites "our write path is E3", which is the sentence §6 exists to +stop. Both, related explicitly, is the rule. + +Two services found this shape in themselves within a day of each other — +`tenant-engine` (writes authorized, three read routes not) and `audit-core` +(write path tenant-filtered, read path not filtered at all). Most services +enforce harder on write than read, so this is the common case, not the corner. + +**Decision 5.3 — `n/a` is a level, and it is conformant.** `P0` presupposes a +database and `R0` presupposes retained data. A service holding nothing at rest +— `flex-auth` runs with its registry and policy baked read-only into the image +and no decision log persisted — is neither. Without an admissible `n/a`, a +missing rung **forces** the fabrication §6 prohibits, which is precisely what +draft-1 was rejected for. `n/a` is declared with a stated reason. + +**Decision 5.4 — the vector lives at `tenancy.yaml` in the repo root.** +Draft-6 said "in the repo" and not where or in what shape, which left §12's +guard needing per-repo archaeology. `flex-auth` adopted `tenancy.yaml` +speculatively; adopted here as the convention. The schema carries `current`, +`target`, `reviewed`, `gap`, `placement_exceptions`, `service_class` (§8.3), +per-path detail (§5.2), and — for a service that is one — the provider +declaration (§5.5). ## 6. Conformance is accuracy, not altitude @@ -465,6 +577,12 @@ with a materially split surface should record the split in the `gap` field. class, carrying a plan tier — by requirements expressed as minimum levels. - Downgrading is permitted and must be declared. A regression found by guarding is a defect; a regression declared in advance is a decision. +- **A low level may be permanent by design, and the declaration must be able to + say so.** `flex-auth` is `I1` and always will be: a decision point evaluates + the claims it is handed, and verifying its own inputs would make it the + identity provider its scope refuses to be. A `target` equal to `current` with + a reason is a settled position, not a stalled trajectory, and §12's guard + must not nag it as though it were one. Without the axis separation, "not rigorous about tenant separation" is one verdict a repo passes or fails. With it, the same repo is `I1 A1 E1 P0 R0` with @@ -554,6 +672,15 @@ norm of a long-lived per-service secret. **Decision 9.1:** static long-lived database credentials are not a sanctioned path for any service above E0. +**Decision 9.2 — the rule extends to consumer-facing credentials.** Draft-6 +named database access only. `audit-core` pointed out that its *ingest* +credentials are static long-lived bearer tokens, rotated by publishing a second +alongside the first — and that the argument applies with **more** force to the +credential that actually carries the tenant claim than to the one that reaches +the database behind it. Read as an accidental omission; it was. Consumer-facing +credentials are named in. Where a service cannot yet meet this, it is a stated +gap rather than a silent exclusion. + ## 10. Blast radius must be published **Decision 10.1:** every platform holding consumer data MUST publish, in @@ -617,6 +744,18 @@ Guarding must be designed for invisible failure, not for crashes. **Decision 13.1:** a level is claimed only with its evidence artifact present. This turns §6's accuracy rule from an honour system into a check. +**Decision 13.1a — the floor needs no artifact, only a reason.** Found +independently by `audit-core` and `flex-auth`: the table below defines +artifacts from `I2`, `A2`, `E1`, `P1`, `R2` upward and none below, so a literal +13.1 made the lowest rungs unclaimable — including §5's own worked example of a +conformant absorbed repo, `I1 A1 E1 P0 R0`, which could not satisfy it on any +axis. A rule that forbids the declaration §6 exists to permit is a defect in +the rule. + +At or below the "no control" rung of an axis, a declaration requires a **stated +reason**, not an artifact. Evidence is what stops you overclaiming, and there is +nothing to overclaim at the bottom of a ladder. + **Decision 13.4 — an artifact must assert something achievable.** Draft-3's noisy-neighbour evidence required proof that a saturating consumer "does not breach" another's allowance. Shared infrastructure cannot provide that; the @@ -639,7 +778,7 @@ E2 evidence.** | **I3** | Live re-query demonstrated on an `aal2`-class path; cached-claim path shown unused there | Mechanical | | **A2** | Choke point identified; test that an unbound request is refused | Mechanical | | **A3** | Live decision with a denial observed at the endpoint, not only at the decision surface | Mechanical | -| **A4** | Decision served over the standard interface; a second PDP substituted without PEP change | Mechanical | +| **A4** | Decision served over the standard interface; a second PDP substituted without PEP change, **with the decision differences between the two recorded** — substitution proves interface portability, not decision equivalence | Mechanical | | **E1** | Every tenant-owned table carries the tenant key | Mechanical | | **E2** | Choke point identified; identity bound to tenant A demonstrably cannot read tenant B | **Adversarial**, with a review date | | **E3** | `FORCE ROW LEVEL SECURITY` on every tenant table; no `BYPASSRLS` on leased roles; probe that a session without the GUC reads nothing; probe that a wrong GUC reads nothing; `EXPLAIN` comparison | Mechanical |