Tenancy Posture draft-7: three more reviews, eleven changes
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:
parent
2744ce7d36
commit
357109b2ab
1 changed files with 170 additions and 31 deletions
|
|
@ -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 |
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue