Skip to content
Talk to our solutions team

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.

LevelHeaderBounds
CustomerX-CustomerThe billing and legal boundary
ProductX-ProductThe application, whose product files supply the iam/ layer
EnvironmentX-Envdev, stage, prod
TenantX-TenantThe isolated occupant inside one environment of one product

The canonical key is the four values joined by colons:

acme:shop:prod:main

All 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.

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>)
→ router

The tenant middleware applies two gates:

  1. Presence. All four values must be non-empty. Otherwise the request ends at 400 with the plain-text body invalid tenant.
  2. Existence. The middleware probes the config client for the tenant, first by the full CPET join, then by the bare X-Tenant value. 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.

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 resourceBuilt from
Connection poolthe tenant’s named datastore → its dbpool config
Entity enginethe composed schema over that pool
Composed schemaservice base ⊕ the product’s iam/ extend files ⊕ the tenant’s tenant_extensions rows
Composed behavior configbuilt-in defaults ⊕ the product’s iam/ files ⊕ tenant overlay rows (realms, providers, token, session, access, config)
Script runtimethe CPET’s own engine for access rules, row-level-security rules and pointcuts
Signing keyringthe 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.

ProductWhen it is registered
iam (the service’s own)statically, at boot
every other productlazily, 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-Product is 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.

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.

PostureHow it is expressed
Database per tenantdistinct database: per tenant
Schema per tenant, shared databaseone 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
KeyMeaning
pathThe 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, domainTenant short name, active flag, associated domains
datastores.<name>.dbpoolBinds a named datastore (default main) to a named pool. The name is what ?datastore= selects.
dbpools.<name>.typeBackend type. postgres is the only type with a pool factory in this service.
dbpools.<name>.dbserverhost:port
dbpools.<name>.databasePhysical database name
dbpools.<name>.dbuser, .passwordCredentials. password resolves through Vault when a vault client is registered, and is treated as a literal when none is.
dbpools.<name>.schemaThe physical PostgreSQL schema every emitted statement is qualified with
dbpools.<name>.maxconnections, .connect_timeoutPool size, connect timeout in seconds
dbpools.<name>.dsnExplicit DSN escape hatch; bypasses per-field validation
meta.url / meta.localdirThe product-file source that supplies the tenant’s iam/ layer
session.preset, .max_concurrent_sessions, .on_limitSession 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.

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 tenant claim 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).

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.

The asymmetry to internalise: a missing header fails at the door; an unknown product, environment or tenant fails at engine build.

ConditionWhat the caller gets
Any of the four headers missing or empty400, text/plain body invalid tenant
Config client unresolvable inside the middleware400, text/plain body config client not initialised
Product never seen beforenothing — it is registered on first sighting
CPET with no resolvable tenant config, unreachable database, or failed schema composition500 engine_unavailablecould not reach datastore
Bearer token presented with no tenant identity401 no_tenant
Token does not verify against the target tenant’s keyring401 invalid_token
Token’s tenant claim differs from the request’s CPET401 tenant_mismatch
API key that does not resolve in the request’s tenant401 invalid_api_key
Auth handler reached with no tenant key in context400 no_tenant
Control-plane route where the plane has no hive binding503 superadmin_not_deployed
Registering a tenant whose slug is empty, unparseable, or uses a reserved name400 reserved_or_invalid_name
Provisioning a tenant that was never registered400 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:

CauseEffect
Malformed CPET string passed to a one-shot commandkey parse failure naming the bad key and the expected four-part form
Tenant config declares no datastore under the requested namebuild failure suggesting ?datastore=<name>
Composed schema declares no datastore matching the requested namebuild failure listing the default and available datastores
dbpool type other than postgresbuild failure — no pool factory for that type
Entity declares a scope with no reserved hive bindingbuild failure — scope not deployed

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.

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.

Terminal window
iam.svc data bootstrap -f .iam.yaml --tenant acme:shop:prod:main
iam.svc data bootstrap -f .iam.yaml --tenant acme:shop:prod:superadmin

Registration 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: acme
X-Product: shop
X-Env: prod
X-Tenant: main

A minimal request carries the same four headers:

Terminal window
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.