Realms
A realm names the schema entity that holds a set of identities and the provider names allowed to
authenticate against it. Realms are configuration composed per tenant — there is no realm table,
no /admin/realm route, and no discovery endpoint. A client must know its realm name out of band.
iam.svc ships one built-in realm:
| Realm | Entity | Active | Default | Providers |
|---|---|---|---|---|
users | users | yes | yes | password |
Every layer you add composes over that.
realms.yaml
Section titled “realms.yaml”realms: is a map keyed by realm name, not a list.
realms: users: default: true active: true providers: - password - magiclink patient: active: true entity: patient # your entity, from iam/extend/patient.yaml providers: - password| Key | Type | Default | Meaning |
|---|---|---|---|
realms.<name>.entity | string | users | Schema entity holding this realm’s identities. Empty resolves to the base users entity. |
realms.<name>.active | bool | true when absent | An inactive realm refuses login with 400 unknown_realm. |
realms.<name>.default | bool | false | Marks the realm used when a login request names none. |
realms.<name>.providers | list of string | [] | Provider names this realm allows. An empty or absent list means password-only. |
The same file may also carry a realmconfig.enable.agents / .bots / .delegations block. Those
toggles are not realm settings — they remove the corresponding entities from the tenant’s composed
schema entirely.
Binding an entity
Section titled “Binding an entity”The realm’s entity must exist in the tenant’s composed schema (service base ⊕ product ⊕ tenant
extensions). To authenticate against it the entity needs:
- at least one field carrying
attributes: {useforauth: true}, and - a field named
password, which the engine argon2id-hashes on create and update.
A login against realm: patient reads the patient entity, and the minted token carries
realm: "patient" with sub set to the row id in that entity — not in users. Two realms over
two entities are two disjoint identity populations that happen to share a tenant.
Composition and precedence
Section titled “Composition and precedence”Realms compose across three layers, last-write-wins per named realm:
| Order | Layer | Source |
|---|---|---|
| 1 | Service defaults | Built in — realm users over entity users, provider password |
| 2 | Product | iam/realms.yaml |
| 3 | Tenant | A tenant_extensions row at path /realms.yaml |
A realm cannot be deleted by a later layer, only redefined. Setting active: false is how you
retire one. Because the service defaults are always layer 1, the realm named users is always
present in the composed map — a product cannot remove it, only redefine or deactivate it.
The default realm is whichever realm was last seen carrying default: true. A layer that declares
realms but marks none of them default leaves the previous layer’s choice standing.
Resolution order
Section titled “Resolution order”POST /auth/login resolves the realm before it looks at any credential:
- The request’s
realmfield, if present. A value equal tosuperadmin— compared case-insensitively after trimming — short-circuits the entire realm machinery and authenticates against the control plane instead. - Otherwise the realm marked
default: true. - Otherwise the literal
users.
The resolved name is then looked up in the composed map. A name that is absent, or present but
inactive, fails. An empty entity on the resolved realm becomes users.
Steps 2 and 3 are name fallbacks, not escape hatches — the name they produce is looked up and
gated like any other. Redeclaring users as active: false without marking another realm
default: true makes every login that omits realm fail with 400 unknown_realm.
POST /auth/login "password": "…", "identity_type": "mobile", "realm": "patient" }Errors from realm resolution, in the standard {"code": …, "error": …} envelope:
| Code | HTTP | When |
|---|---|---|
unknown_realm | 400 | The named realm is not in the composed map. Message ends unknown realm "<name>". |
unknown_realm | 400 | The realm exists but is inactive. Message ends realm "<name>" is inactive. |
unknown_realm | 401 | On POST /auth/mfa/verify only — the realm carried by the mfa_token no longer resolves. Message: realm no longer exists. |
provider_not_allowed | 403 | The realm’s providers list does not contain password. |
bad_identity_type | 400 | identity_type names a column the entity did not declare as a login identifier. |
Realm survives the flow: refresh preserves the realm the session started in, and the MFA-pending token carries it so the second step reads the same entity. The full password flow is on Password login.
The provider gate
Section titled “The provider gate”providers: is a name gate. A provider must be declared in iam/providers.yaml for a realm to
reference it, but the declaration carries exactly three fields — name, type, template — and no
credentials, endpoints or callback URLs.
Nothing reads type or template at request time either, so type: oauth on a declared provider
wires no identity provider. The consequence for the realm author is narrow and worth stating
plainly: providers decides one thing — whether password login against this realm returns
403 provider_not_allowed.
Login identifiers
Section titled “Login identifiers”Which columns can identify a user is declared on the field, in the entity definition, not in
realms.yaml:
fields: - name: email type: string attributes: useforauth: true # this column is a login identifier useforidentity: true # …and the default one, tried first - name: mobile type: string attributes: useforauth: true - name: employeeid type: string attributes: useforauth: true| Attribute | Effect |
|---|---|
useforauth: true | The column may be matched against the request’s identity value. |
useforidentity: true | Among the useforauth columns, this is the one tried first. |
Accepted truthy spellings: boolean true, or the strings "true", "yes", "1". Anything else
reads as false.
Because the declaration lives on the field, it composes through the same layering as the rest of the
schema — a product or tenant that adds an employeeid field with useforauth: true has added a
login identifier without touching the auth configuration. See
Identity entities for what a layer may add and what the
final and access-lock seals refuse.
Resolution
Section titled “Resolution”- The
useforidentitycolumn, if one is declared. - The remaining
useforauthcolumns, in declared field position order.
With no identity_type in the request, each column is tried in that order until one matches. A
failure at any column returns the same 401 invalid_credentials — which column missed is never
disclosed.
Sending identity_type narrows the lookup to exactly one column. The value is lower-cased and
trimmed, then matched against the declared set. A name that is not in that set returns
400 bad_identity_type, and the message enumerates what is declared:
{ "code": "bad_identity_type", "error": "identity_type \"username\" is not a declared login identifier (declared: email, mobile)" }The service never filters on a column the entity did not mark.
Fallback
Section titled “Fallback”An entity that declares no useforauth column at all falls back to ["email"]. The same fallback
applies when the composed schema cannot be resolved for the tenant. A minimal product realm entity
therefore logs in by email whether or not it says so.
Base identifiers
Section titled “Base identifiers”| Entity | Plane | useforidentity | Other useforauth |
|---|---|---|---|
users | tenant | email | mobile |
superadmin | control | email | mobile |
Both columns carry a unique constraint; email is additionally required, mobile is not.
The reserved superadmin realm
Section titled “The reserved superadmin realm”realm: "superadmin" is not a realm you can declare. It is intercepted before tenant resolution and
authenticates against the control plane’s own superadmin entity, signed by that plane’s own
keyring, with roles: ["superadmin"] derived structurally from the path and never from row data.
It bypasses realm resolution, the composed behavior config and the provider gate entirely, and it
carries no MFA and no lockout policy. Declaring a realm named superadmin in realms.yaml has no
effect — the interception happens first. The operator plane is covered on
Superadmin.
Every request and response shape for these routes is in the IAM API reference; the full error table is on the error page.