Tenancy Posture draft-7: three more reviews, eleven changes
All checks were successful
CI Smoke / host-smoke (push) Successful in 0s
CI Smoke / container-smoke (push) Successful in 1s

audit-core, railiance-platform and flex-auth all reviewed. Every posture I
guessed was too generous, on every repo that has now self-reported.

Two of my statements about other repos were wrong as fact in canon. flex-auth
does not call tenant-engine synchronously on the authorization path - the
adapter is built and has no non-test caller, which is also why they cannot
reach I3. And I justified A4 partly as ending the copying of action strings
between repos; AuthZEN standardises the envelope and deliberately not the
action vocabulary, so that argument is withdrawn and the interoperability one
kept.

Section 13.1 was found broken independently by audit-core and flex-auth: it
required an evidence artifact for every claim while defining none below I2, A2,
E1, P1, R2 - so section 5's own worked example of a conformant absorbed repo
could not satisfy it on any axis. At or below the no-control rung a declaration
now needs a stated reason, not an artifact.

The weakest-surface rule from draft-6 was insufficient alone. A bare minimum
destroys signal, since E3-write with E1-read declares identically to E1/E1.
Declare per path, quote the minimum. Two services found this shape in
themselves within a day, so it is the common case rather than a corner.

n/a is now an admissible level. P0 presupposes a database and R0 presupposes
retained data; a stateless service is neither, and without n/a a missing rung
forces the fabrication section 6 prohibits - which is what draft-1 was rejected
for.

The A ladder had no seat for a decision point. flex-auth cannot occupy A3,
since delegating to flex-auth is not something flex-auth can do. A PDP now
declares two numbers: its own inbound level and the maximum it enables. They
read A0 enables A3, which is more alarming than A3, which is the point.

The ladders described consumers and not providers. railiance-platform showed
apps-pg at I0 A0 E0 where the zeros are structural, and OpenBao at E0 where the
mechanism in place is E4 machinery aimed at a consumer boundary - literally
correct and inverting the real security position. A provider now declares what
it makes reachable.

Crypto-shredding needed a condition it did not have. audit-core showed that a
hash over a low-entropy canonical record is a confirmation oracle, so
destroying the key does not make content unrecoverable while the commitment
survives - and that shreddability is not retrofittable onto a chain committing
to cleartext. R4 by key destruction now requires that no retained commitment
reveal the erased content.

Section 9 named database credentials only; audit-core pointed out the argument
applies with more force to the credential carrying the tenant claim. Extended.

Section 17 led with the connection ceiling when memory binds first and fails
worse. Corrected against rapp-postgres ADR-0004.

Also: a low level may be permanent by design and the guard must not nag it, and
the A4 evidence artifact now requires recording decision differences, since
substitution proves interface portability rather than equivalence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
tegwick 2026-08-17 22:08:19 +02:00
parent 2744ce7d36
commit 357109b2ab

View file

@ -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 |