diff --git a/canon/standards/tenancy-posture_v0.1.md b/canon/standards/tenancy-posture_v0.1.md index bfae362..92ff0cc 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-5" +revision: "draft-6" 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-5.** Relocated from `the-custodian/canon/architecture` on +**Proposed, draft-6.** 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. @@ -39,8 +39,14 @@ and the tenant-engine boundary contract, not in the work-factory canon. - **draft-4** closed the two gaps draft-3 left open: `R4` had no mechanism beyond waiting, and the noisy-neighbour evidence artifact asserted something shared infrastructure cannot provide. -- **draft-5** relocates to NetKingdom and renames the dimensions from *planes* +- **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.** Every correction so far was found by research or by relocation, not by review. @@ -183,13 +189,22 @@ level is the one a service can evidence and its risk warrants. |---|---| | **I0** | No tenant concept. Data not attributable to a tenant. | | **I1** | A local tenant notion exists but is not canonical, **or** the tenant is taken from the request rather than from a verified token. | -| **I2** | Canonical identifiers, bound at the identity provider and carried as a verified claim; `tenant-engine` is the source of existence. | +| **I2** | Canonical identifiers, bound at the identity provider and carried as a verified claim, **and verified by this service on its own inbound calls**. | | **I3** | I2 plus capability roles honoured, with live `tenant-engine` re-query for privileged, destructive, credential-vending or `aal2`-class decisions. | I1 now explicitly absorbs request-supplied tenant identifiers. "Never trust client-supplied tenant IDs without validation" is a named anti-pattern; a service reading the tenant from a header is at I1 however canonical the string. +**An axis is assessed on a service's own inbound surface, never on its +authority over the concept.** `tenant-engine` is the source of existence for +tenant records and is nonetheless at I1, because it takes the acting identity +from the request body rather than from a verified token. Draft-5 conflated +these by naming the authority inside the I2 definition, which made the level +describing canonical identity unclaimable by the service that provides it. +Corrected on tenant-engine's review — a reader would otherwise assume the +authority must be at I2 by definition. + `business-app-service-contract` §2.1 sets app-local accounts as the v1 baseline for business apps — a sanctioned low level with recorded triggers for moving up. That is the pattern this framework generalises. @@ -267,6 +282,20 @@ already established that our migration role owns the tables it creates), no `EXPLAIN` comparison because RLS disables functional indexes built on non-leakproof functions. +**Not all data is tenant-keyed, and the ladder must not pretend otherwise.** A +registry whose rows *are* the tenants has no per-tenant predicate to scope a +policy by; enforcing one would break the service's function rather than secure +it. `tenant-engine`'s `tenants` table is the worked example — `key-cape` +enumerates it at token issuance and `flex-auth` queries it live, both of which +are cross-tenant reads by design. + +A service with mixed data shapes declares **E-level plus a registry +exception**: the level its tenant-keyed tables hold, and a named list of tables +excluded because they are registries rather than tenant data. The exception is +part of the claim and is reviewable; an unnamed exception is an overclaim. +Without this, mixed-shape services either overclaim or stay at E2 permanently, +and `tenant-engine` declined to claim E3 on precisely that reasoning. + **Default expectation** for a new platform service: E2 at first serve, E3 recorded as target. Services whose cross-tenant exposure would be a reportable breach SHOULD target E3 or above. @@ -276,7 +305,7 @@ breach SHOULD target E3 or above. | Level | Shape | Live occupants | |---|---|---| | **P0** | Shares a database with another consumer. | None sanctioned; the state absorbed repos arrive in. | -| **P1** | Database per consumer, shared cluster. | `audit-core`, `tenant-engine` on `platform-pg` | +| **P1** | Database per consumer, shared cluster. | `audit-core` on `platform-pg`; `tenant-engine` (target, TEN-WP-0009 — still on SQLite) | | **P2** | Dedicated cluster per consumer. | `user-engine-pg`, `target-revenue-pg` | | **P3** | Dedicated cluster per tenant. | Business apps per `business-app-service-contract` §1.2 | | **P4** | P3 plus separate region or jurisdiction. | None | @@ -290,7 +319,7 @@ unreachable: |---|---|---|---|---|---| | **E4** | — | — | — | (business app) | | | **E3** | | (target) | | | | -| **E2** | | tenant-engine
audit-core | | | | +| **E2** | (tenant-engine) | audit-core | | | | | **E1** | (absorbed repo) | | | | | | **E0** | | | | | | @@ -403,13 +432,26 @@ Worked examples, best-effort and subject to owner correction: | Service | Current | Notes | |---|---|---| -| `tenant-engine` | `I2 A3 E2 P1 R1` | Moving to P1 under TEN-WP-0009; retention declared, horizon not yet published. | -| `audit-core` | `I2 A3 E2 P1 R1` | Holds audit evidence, so both E3 and R2 are urgent targets. | +| `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. | | 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. + +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. + ## 6. Conformance is accuracy, not altitude > **A service is conformant when its declared posture is accurate, its target