# Plane I — Identity: prior knowledge and best practices > Research artefact — 2026-08-17. Supports `canon/architecture/adr-008-multi-tenancy-model.md` > (draft-2), plane I of four. Surveys external practice for how multi-tenant > systems name, carry, and validate tenant identity, and holds our `I0–I3` > ladder against it. > Companions: plane A (authorization), plane E (enforcement), plane P (placement). > Index: `2026-08-17-adr008-multi-tenancy-research-index.md`. ## 1. What the plane covers How a tenant is named, where that name is bound to a principal, how it travels with a request, and how fresh the binding must be. Our ladder: | Level | State | |---|---| | I0 | No tenant concept | | I1 | Local tenant notion, non-canonical | | I2 | Canonical `tenant::`, validated; `tenant-engine` owns existence | | I3 | I2 + capability roles honoured, live re-query for `aal2`-class decisions | ## 2. Consensus practice: bind at registration, carry in the token The dominant pattern across vendor guidance is that tenant identity is bound to the principal **at the identity provider**, not derived per-application. - AWS calls the unpacked token claims the **tenant context**, and frames it as environment-like: it "influences how the SaaS application processes the request" rather than being one parameter among many ([SaaS Lens — preventing cross-tenant access](https://docs.aws.amazon.com/wellarchitected/latest/saas-lens/preventing-cross-tenant-access.html)). - Binding is explicitly a **registration-time** responsibility: storing the tenant identifier as a claim or attribute on the user in the IdP "shifts the responsibility of mapping identities to tenants from each application to the user registration process" ([AWS Prescriptive Guidance — tenant onboarding and registration](https://docs.aws.amazon.com/prescriptive-guidance/latest/saas-multitenant-api-access-authorization/avp-design-onboarding-registration.html)). - The recommendation is to define tenant context **at the IAM layer** rather than relying on the application layer ([AWS — 5 multi-tenant SaaS architecture best practices](https://aws.amazon.com/isv/resources/5-multi-tenant-saas-architecture/)). **Against our model:** this is what `I2` describes and what the IAM Profile already mandates (`tenant` claim required on every token accepted by profile consumers). We are aligned with consensus here, and `key-cape`/Keycloak stamping the claim at issuance is the sanctioned shape. **Gap:** the ladder's I2 says "canonical identifiers used, validated". It does not say *where the binding happens*. A service could satisfy I2 by parsing a tenant id out of a request body. Consider tightening I2 to require the binding be IdP-sourced, with request-supplied tenant ids explicitly at I1. ## 3. Propagation is a separate discipline from naming Practice treats "the tenant id must reach every layer" as its own requirement, not a consequence of having one: - A stable identifier should follow every request and stay consistent across databases, services, logs, billing records, and background processes; without a reliable tenant registry, other isolation controls are hard to enforce consistently ([WorkOS — developer's guide to SaaS multi-tenant architecture](https://workos.com/blog/developers-guide-saas-multi-tenant-architecture)). - Requests should execute in a tenant context established by middleware or context variables, tagging each request with its owner ([SSOJet — multi-tenant identity management](https://ssojet.com/blog/multi-tenant-identity-management)). - Two leak surfaces get called out repeatedly and are **not** database concerns: search indices (Elasticsearch queries that omit the tenant filter) and cache keys that do not include the tenant id ([WorkOS](https://workos.com/blog/developers-guide-saas-multi-tenant-architecture)). **Against our model:** ADR-008 has nothing to say about background jobs, caches, or search indices. Our E ladder is described almost entirely in database terms (tenant-keyed tables, RLS, per-tenant credentials). A service could sit honestly at `E3` and still leak through a Redis key or an unfiltered index. **Proposed amendment:** either widen the E-ladder evidence artifacts to cover non-database stores, or add an explicit scope note that the E ladder describes the *primary datastore* only and other stores are assessed separately. The second is cheaper and more honest. ## 4. Identity-plane placement is its own decision — and it has a known ceiling Identity providers face the same silo/pool question the P plane describes, and the industry has converged on an answer with a number attached. - Keycloak offers **realm-per-tenant** (strong isolation, heavyweight) versus **Organizations** (multiple tenants inside one realm, stable since Keycloak 26, designed for the SaaS case) ([Phase Two — multi-tenancy options in Keycloak](https://phasetwo.io/blog/multi-tenancy-options-keycloak/), [Skycloak — multitenancy using Organizations](https://skycloak.io/blog/multitenancy-in-keycloak-using-the-organizations-feature/)). - The operational verdict is blunt: "realm-per-tenant works for 5–20 tenants; beyond that, you are managing an IAM platform, not building your product" ([Izylife — Keycloak multi-tenancy: realm per tenant or Organizations?](https://izylifesolutions.com/articles/keycloak-multi-tenancy-b2b-iam/)). - Auth0's equivalent is Organizations within a single Auth0 tenant, with per-organization connections ([KeycloakPro — Keycloak multi-tenancy with Organizations](https://keycloakpro.com/blog/keycloak-multi-tenancy-organizations-guide)). **Against our model:** ADR-008 treats placement as a property of *data substrate* only. Identity has a placement axis too, it is live in our estate (`key-cape`/Keycloak), and nobody has decided realm-per-tenant versus Organizations. Given the tenant taxonomy includes `consumer` (private individuals) and `family`, realm-per-tenant is as economically impossible here as cluster-per-tenant — the 5–20 ceiling is well below our target tenant count. **Proposed amendment:** note in §3 of the ADR that the P plane, as written, scopes to data substrate; identity-provider placement is a parallel decision with the same silo/pool shape and should be recorded, probably by whoever owns `key-cape`. ## 5. Freshness: the tradeoff we already made, confirmed Our I3 requires live `tenant-engine` re-query for privileged, destructive, credential-vending or `aal2`-class decisions, with the cached `tenant_roles` claim acceptable for ordinary ones. External practice supports the split and names the cost precisely. - The tradeoff is explicit: "tighter revocation controls often increase latency and operational overhead, requiring organisations to balance immediate cutoff against service reliability" ([NHI Mgmt Group — JWT vs OAuth revocation](https://nhimg.org/faq/why-do-jwt-revocation-and-oauth-revocation-need-different-handling/)). - For high-stakes decisions the sanctioned answer is opaque tokens plus introspection (RFC 7662) — "a real kill switch at the cost of a lookup on the hot path" ([oneuptime — how to handle JWT revocation](https://oneuptime.com/blog/post/2026-02-02-jwt-revocation/view)). - Short lifetimes are the mainstream mitigation: "a 5-minute access token caps how stale any resource server's view can be, which is the quiet reason the default is 5 minutes and not an hour" (ibid.). Our IAM Profile's 5–30 minute service/agent token lifetime sits inside this norm. - Known failure mode directly relevant to us: controls "break down when services cache JWT validation results" (ibid.). Our hybrid explicitly permits caching for ordinary decisions, so the boundary between ordinary and high-stakes is load-bearing and must be enforced, not documented. - **Token versioning** is an alternative we do not use: a `token_version` per subject, bumped on change, rejecting stale tokens service-side ([TechNet Experts — JWT token versioning](https://www.technetexperts.com/jwt-token-versioning-stale-data/)). It gives cheap global invalidation without a per-request lookup, at the cost of a version read. Worth considering as a middle rung between our cached claim and full live re-query. **Against our model:** we match best practice and, unusually, we wrote the boundary down. The gap is evidential rather than architectural — our I3 evidence artifact ("live re-query demonstrated on an `aal2`-class path; cached path shown not to be used there") is exactly right and does not exist yet for any service. ## 6. Finding: our tenant identifier encodes a mutable attribute This is the significant new finding, and it is a critique of ratified canon rather than of ADR-008. Our identifier is `tenant::`, where `` is an onboarding-risk / entity-shape classification from ADR-0013: `trial`, `friendly`, `single`, `small` (≤10 employees), `medium` (≤100), `large` (≤1000), `enterprise` (1001+), `consumer`, `family`, `community`, `association`, `agentic`. Several of those are **explicitly headcount bands**, and headcount changes. A tenant onboarded as `tenant:small:acme` that grows past ten employees is either misclassified forever or must be renamed — and renaming a tenant identifier is a estate-wide data migration, since the identifier is the key shared with `user-engine`, appears in OpenBao paths (`tenants//...`), and is carried in every token. External practice is unambiguous: - "Avoid encoding mutable data or business logic in the identifier" — the **ID Entanglement Effect**, where composite ids encode business relationships or mutable state that is later parsed ([Cogna — the unreasonable power of identifiers](https://cogna.co/blog/the-unreasonable-power-of-identifiers/)). - Opaque identifiers "decouple identification from descriptive attributes, thereby enhancing stability against entity changes" ([Grokipedia — unique identifier](https://grokipedia.com/page/Unique_identifier)). - The FAIR/persistent-identifier literature reaches the same conclusion: make identifiers opaque from the start, so that when a name changes "just the `rdfs:label` and some other properties may change — but not the URI" ([Cool URIs for FAIR Knowledge Graphs](https://arxiv.org/pdf/2407.09237)). - A structured id is acceptable *only if treated as opaque* — "the key is that we treat it as an unchanging reference and don't try to parse meaning from its structure" ([Cogna](https://cogna.co/blog/the-unreasonable-power-of-identifiers/)). The IAM Profile half-anticipates this. It states that capability roles were deliberately separated from the identifier so "a tenant's roles may change without renaming its identifier", and that grouping "does not gate which capability roles a tenant may hold". The same reasoning applies to grouping itself and was not carried through. **Two honest readings**, and this needs an owner decision rather than an assertion from this document: 1. **Grouping is onboarding-time and frozen by design.** It records the shape of the entity *when it was onboarded*, not its current headcount, and is never expected to change. If so, this should be stated explicitly in ADR-0013 — because every reader will otherwise assume `small` means currently-small, and some system will eventually make a decision on it. 2. **Grouping is current classification.** Then the identifier encodes a mutable attribute, the estate has a rename problem it has not hit yet because tenant counts are tiny, and the fix (opaque id + grouping as a mutable property on the tenant record) gets more expensive every month. Either way, `rapp-postgres` is already correct to treat these as opaque strings — ADR-0001 §4 prohibits the storage platform from parsing them. The risk sits with any consumer that *does* parse the grouping segment. **Proposed action:** raise as a question to `tenant-engine` and NetKingdom canon rather than folding into ADR-008. It is out of ADR-008's scope but materially affects plane I's top rung. ## 7. Summary against our ladder | Finding | Verdict | |---|---| | Bind tenant at IdP, carry in token, treat as context not parameter | **Aligned.** Matches I2 + IAM Profile. | | I2 does not require the binding be IdP-sourced | **Gap.** Tighten wording; request-supplied ids belong at I1. | | Propagation must cover caches, search indices, background jobs | **Gap.** ADR-008's ladders are datastore-shaped. Add a scope note. | | Identity-provider placement is its own silo/pool decision, ceiling ~5–20 tenants for realm-per-tenant | **Missing.** P plane scopes to data only; identity placement is undecided and unowned. | | Hybrid cached-claim / live-re-query for high-stakes | **Aligned and unusually well specified.** Evidence artifact does not exist yet. | | Token versioning as a middle option | **Unconsidered.** Cheap global invalidation without hot-path lookup. | | Identifier encodes mutable headcount bands | **Finding.** Contradicts identifier-design consensus; needs an ADR-0013 clarification. | ## Sources - [AWS SaaS Lens — Preventing cross-tenant access](https://docs.aws.amazon.com/wellarchitected/latest/saas-lens/preventing-cross-tenant-access.html) - [AWS Well-Architected SaaS Lens (PDF)](https://docs.aws.amazon.com/pdfs/wellarchitected/latest/saas-lens/wellarchitected-saas-lens.pdf) - [AWS Prescriptive Guidance — Tenant onboarding and user tenant registration](https://docs.aws.amazon.com/prescriptive-guidance/latest/saas-multitenant-api-access-authorization/avp-design-onboarding-registration.html) - [AWS — 5 multi-tenant SaaS architecture best practices](https://aws.amazon.com/isv/resources/5-multi-tenant-saas-architecture/) - [AWS Security Blog — SaaS tenant isolation with ABAC using STS support for tags in JWT](https://aws.amazon.com/blogs/security/saas-tenant-isolation-with-abac-using-aws-sts-support-for-tags-in-jwt/) - [WorkOS — The developer's guide to SaaS multi-tenant architecture](https://workos.com/blog/developers-guide-saas-multi-tenant-architecture) - [SSOJet — Multi-tenant identity management for SaaS](https://ssojet.com/blog/multi-tenant-identity-management) - [Phase Two — Understanding multi-tenancy options in Keycloak](https://phasetwo.io/blog/multi-tenancy-options-keycloak/) - [Skycloak — Multitenancy in Keycloak using the Organizations feature](https://skycloak.io/blog/multitenancy-in-keycloak-using-the-organizations-feature/) - [Izylife — Keycloak multi-tenancy: realm per tenant or Organizations?](https://izylifesolutions.com/articles/keycloak-multi-tenancy-b2b-iam/) - [KeycloakPro — Keycloak multi-tenancy with Organizations](https://keycloakpro.com/blog/keycloak-multi-tenancy-organizations-guide) - [oneuptime — How to handle JWT revocation](https://oneuptime.com/blog/post/2026-02-02-jwt-revocation/view) - [NHI Management Group — Why JWT revocation and OAuth revocation need different handling](https://nhimg.org/faq/why-do-jwt-revocation-and-oauth-revocation-need-different-handling/) - [TechNet Experts — How to fix stale JWTs via token versioning](https://www.technetexperts.com/jwt-token-versioning-stale-data/) - [Cogna — The unreasonable power of identifiers](https://cogna.co/blog/the-unreasonable-power-of-identifiers/) - [Grokipedia — Unique identifier](https://grokipedia.com/page/Unique_identifier) - [Cool URIs for FAIR Knowledge Graphs (arXiv 2407.09237)](https://arxiv.org/pdf/2407.09237)