Tenancy
Every request to iam.svc is isolated by CPET — customer, product, environment, tenant. The four
values arrive as four headers, become one typed key, and that key selects the connection pool, the
composed schema, the script runtime, the behavior config and the signing keyring the request is
served from. Nothing downstream re-reads a header.
One process serves any product, not one. The service registers its own iam product at boot and
registers every other product the first time it sees the name in X-Product.
The four levels
Section titled “The four levels”| Level | Header | Bounds |
|---|---|---|
| Customer | X-Customer | The billing and legal boundary |
| Product | X-Product | The application, whose product files supply the iam/ layer |
| Environment | X-Env | dev, stage, prod |
| Tenant | X-Tenant | The isolated occupant inside one environment of one product |
The canonical key is the four values joined by colons:
acme:shop:prod:mainAll four segments are required and none may be empty. A key with a part count other than four, or with any empty part, is rejected wherever a key is parsed.
How a request resolves to a tenant
Section titled “How a request resolves to a tenant”Every route except GET /ready and GET /health runs the same chain:
security headers + 1 MiB body cap → request timeout → tenant middleware (X-Customer, X-Product, X-Env, X-Tenant → CPET key) → auth middleware (Bearer PASETO/JWT, or ApiKey <id>.<secret>) → routerThe tenant middleware applies two gates:
- Presence. All four values must be non-empty. Otherwise the request ends at
400with the plain-text bodyinvalid tenant. - Existence. The middleware probes the config client for the tenant, first by the full CPET
join, then by the bare
X-Tenantvalue. The request is rejected only if both lookups error.
/ready and /health return before either gate, so they answer with no CPET headers at all.
The auth middleware must sit inside the tenant middleware: the bearer path reads the tenant key from context to pick the keyring. It is constructed on first request, after route registration.
What the tenant key selects
Section titled “What the tenant key selects”The service holds one scope per CPET. Each scope carries, per named datastore, a single composite entry built on first use and cached for the life of the tenant:
| Per-CPET resource | Built from |
|---|---|
| Connection pool | the tenant’s named datastore → its dbpool config |
| Entity engine | the composed schema over that pool |
| Composed schema | service base ⊕ the product’s iam/ extend files ⊕ the tenant’s tenant_extensions rows |
| Composed behavior config | built-in defaults ⊕ the product’s iam/ files ⊕ tenant overlay rows (realms, providers, token, session, access, config) |
| Script runtime | the CPET’s own engine for access rules, row-level-security rules and pointcuts |
| Signing keyring | the tenant’s own jwt_signing_key table, reached through the tenant’s engine |
Builds are single-flighted per (tenant, datastore). Schema layers are additive: neither the product layer nor the tenant overlay may shadow a base field or entity, and a malformed layer aborts the build rather than being skipped. A tenant with no product source and no extensions runs the base layer alone.
Because each CPET gets its own script runtime, one tenant’s compiled scripts can never be served to another. The base schema is a process singleton shared by pointer; per-tenant renaming of the physical namespace is copy-on-write, so no tenant’s namespace can leak into another’s engine.
The per-request datastore override is ?datastore=<name>; an empty name resolves to the schema’s
default datastore.
One process, any product
Section titled “One process, any product”| Product | When it is registered |
|---|---|
iam (the service’s own) | statically, at boot |
| every other product | lazily, on first sighting of the name in X-Product |
Registration puts the product in multi mode: the key comes from the wire on every request. The single-tenant and no-tenant product postures exist in the platform’s tenancy library but this service never uses them, and there is no product allow-list to edit before a new product can be served.
Registration is a cheap empty bucket. No pool, schema, engine or script runtime is built until a real tenant scope resolves, and that requires resolvable tenant config. Two consequences:
- An unknown product is not an error at the middleware. It becomes an error at engine build.
- A spoofed
X-Productis harmless but not free. It permanently adds an idle entry to the process’s product map. Products are never removed, including when their last tenant is drained.
Physical isolation
Section titled “Physical isolation”Isolation is chosen per tenant by its dbpool, in the tenant hive (inline in development, on
config.svc in production). Both postures ship and both are proven against a
real database.
| Posture | How it is expressed |
|---|---|
| Database per tenant | distinct database: per tenant |
| Schema per tenant, shared database | one database:, distinct schema: per tenant |
hives: tenant: - path: acme:shop:prod:main name: main active: true datastores: main: dbpool: acme_shop_prod_main dbpools: acme_shop_prod_main: type: postgres dbserver: localhost:5432 database: shop_prod dbuser: appuser password: vault:acme-shop-prod-main schema: acme maxconnections: 4 connect_timeout: 5| Key | Meaning |
|---|---|
path | The CPET prefix this node configures. "" is the root node and serves every CPET the process sees. A prefix such as acme:shop:prod is inherited by its children. |
name, active, domain | Tenant short name, active flag, associated domains |
datastores.<name>.dbpool | Binds a named datastore (default main) to a named pool. The name is what ?datastore= selects. |
dbpools.<name>.type | Backend type. postgres is the only type with a pool factory in this service. |
dbpools.<name>.dbserver | host:port |
dbpools.<name>.database | Physical database name |
dbpools.<name>.dbuser, .password | Credentials. password resolves through Vault when a vault client is registered, and is treated as a literal when none is. |
dbpools.<name>.schema | The physical PostgreSQL schema every emitted statement is qualified with |
dbpools.<name>.maxconnections, .connect_timeout | Pool size, connect timeout in seconds |
dbpools.<name>.dsn | Explicit DSN escape hatch; bypasses per-field validation |
meta.url / meta.localdir | The product-file source that supplies the tenant’s iam/ layer |
session.preset, .max_concurrent_sessions, .on_limit | Session posture for this tenant — see Sessions |
PostgreSQL pools are wrapped so the tenant id session variable is set on every connection checkout,
which is what row-level-security rules read. That wrapper reads the typed key: a code path that
carries only the legacy string values skips the SET silently.
A proven topology, one process, two products:
product "shop" → database shop_prod acme:shop:prod:main → PG schema "acme" globex:shop:prod:main → PG schema "globex"product "clinic" → database clinic_prod acme:clinic:prod:main → PG schema "acme" mercy:clinic:prod:main → PG schema "mercy"A user in acme:shop is invisible in globex:shop (same database, different schema) and invisible in
acme:clinic (same customer and same schema name, different database). Credentials do not
authenticate across a product or tenant boundary.
Keys and tokens are per CPET
Section titled “Keys and tokens are per CPET”Signing keys are per tenant key. Each tenant’s keyring lives in that tenant’s own database and is
minted on first use when none exists. GET /.well-known/jwks.json returns only the keys of the CPET
named by the request headers — tenant A’s key set never contains tenant B’s keys.
Verification pins a token to the tenant it targets, twice:
- the token is verified against the keyring of the CPET the request names;
- the token’s
tenantclaim must equal the request’s CPET key.
There is no control-plane exception. An operator reaches tenant data only through impersonation, which mints the target user’s own tenant-signed token and therefore satisfies the pin by construction. Claim details and key custody are in Tokens.
Managed sessions store their CPET on the session row. On exchange the CPET is taken from the request context — never from the body — and cross-checked against the stored row, so a session bound to one tenant cannot mint a token for another. Concurrency is enforced per (CPET, principal).
The reserved tenant slot
Section titled “The reserved tenant slot”superadmin and shared are reserved names. Neither may be used as a customer, product, environment
or tenant segment, nor as a tenant short name, in any deployment. Key parsing deliberately admits
them so the reserved scopes flow through the same plumbing; rejection happens where names are minted.
The control plane is the reserved tenant <customer>:<product>:<env>:superadmin — one per product
and environment, derived from the request’s own CPET with only the tenant slot replaced. It resolves
through exactly the same path as any tenant, with its own scope, engine, pool and keyring, and it
takes no product layer and no tenant overlay. Full detail:
Superadmin.
Entities declaring scope: shared are served by a separate scope engine keyed by (scope, datastore),
a peer of the per-CPET engines. Scoped entities are absent from a tenant’s served schema, migration
plan and row-level security, so no query joins across planes. A scope declared by an entity but with
no reserved hive binding fails loudly rather than being served from the wrong plane. Scope engines are
not owned by tenancy: they close at process shutdown, not on tenant drain.
Failure modes
Section titled “Failure modes”The asymmetry to internalise: a missing header fails at the door; an unknown product, environment or tenant fails at engine build.
| Condition | What the caller gets |
|---|---|
| Any of the four headers missing or empty | 400, text/plain body invalid tenant |
| Config client unresolvable inside the middleware | 400, text/plain body config client not initialised |
| Product never seen before | nothing — it is registered on first sighting |
| CPET with no resolvable tenant config, unreachable database, or failed schema composition | 500 engine_unavailable — could not reach datastore |
| Bearer token presented with no tenant identity | 401 no_tenant |
| Token does not verify against the target tenant’s keyring | 401 invalid_token |
Token’s tenant claim differs from the request’s CPET | 401 tenant_mismatch |
| API key that does not resolve in the request’s tenant | 401 invalid_api_key |
| Auth handler reached with no tenant key in context | 400 no_tenant |
| Control-plane route where the plane has no hive binding | 503 superadmin_not_deployed |
| Registering a tenant whose slug is empty, unparseable, or uses a reserved name | 400 reserved_or_invalid_name |
| Provisioning a tenant that was never registered | 400 bad_request |
500 engine_unavailable is the canonical unknown-product, unknown-environment and unknown-tenant
response, because lazy product registration always succeeds and a config lookup for an absent tenant
returns an empty section rather than an error. The HTTP body hides the cause deliberately; the server
log carries the real pool or config error. Check, in order: the tenant node exists at the CPET’s path,
its datastore names a declared dbpool, the database is reachable with those credentials, and the
pool’s type is postgres.
Configuration errors that surface only in logs or on the CLI, never in an HTTP body:
| Cause | Effect |
|---|---|
| Malformed CPET string passed to a one-shot command | key parse failure naming the bad key and the expected four-part form |
| Tenant config declares no datastore under the requested name | build failure suggesting ?datastore=<name> |
| Composed schema declares no datastore matching the requested name | build failure listing the default and available datastores |
dbpool type other than postgres | build failure — no pool factory for that type |
| Entity declares a scope with no reserved hive binding | build failure — scope not deployed |
Live tenants, drain and invalidation
Section titled “Live tenants, drain and invalidation”The tenant listing surface returns only tenants with a live scope in this process — those that have had at least one request build per-tenant resources. Configured-but-untouched tenants are not listed; the config tenant hive is the authoritative configured inventory.
Every invalidation path converges on one drain: a config refresh (credential rotation, pool host change, product-file change), tenant removal, and the admin cache-invalidation call. Drain closes the engine, closes the script runtime in lockstep, releases the pool, and sweeps pool entries orphaned by builds that never produced an engine. Draining a tenant with no live scope is a no-op. The process singleton base schema is never invalidated.
Addressing a tenant off the request path
Section titled “Addressing a tenant off the request path”One-shot commands take --tenant <cpet> and inject the identity exactly as the HTTP middleware does.
The default is default:iam:dev:default, matching the local-development profile. There is no
all-tenants pass: bootstrap and migration run one CPET at a time, and the control plane is addressed
as an ordinary CPET with superadmin in the tenant slot.
iam.svc data bootstrap -f .iam.yaml --tenant acme:shop:prod:mainiam.svc data bootstrap -f .iam.yaml --tenant acme:shop:prod:superadminRegistration and provisioning are separate steps, and provisioning refuses an unregistered CPET — it is never an implicit create. The block provisions the schema, tables, ledger and seed admin; it never creates the database. A missing database surfaces as a loud bootstrap error. See Operating for the onboarding sequence.
When the block calls another platform service it re-emits the four headers from the parsed key, so the CPET propagates unchanged:
X-Customer: acmeX-Product: shopX-Env: prodX-Tenant: mainA minimal request carries the same four headers:
curl -s localhost:5030/auth/login -H 'Content-Type: application/json' -H 'X-Customer: default' -H 'X-Product: iam' -H 'X-Env: dev' -H 'X-Tenant: default' -d '{"identity":"[email protected]","password":"…"}'Every route and its request and response shapes are in the HTTP API reference.