# Realms

> How iam.svc realms bind a schema entity to a set of providers, how a realm is resolved on a login request, and how login-identifier columns are declared.

<!-- source: https://docs.kis.ai/blocks/baas/iam/authn/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`

`realms:` is a **map keyed by realm name**, not a list.

```yaml
# iam/realms.yaml
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

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

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` |

:::caution
Merging replaces a realm **whole**, not field by field. Redeclaring `users` in your product file
with only `entity:` set discards the built-in `providers: [password]` list — which happens to leave
password login working, because an empty list means password-only, but the same rewrite of a realm
that listed several providers silently drops all of them. Restate every key you want to keep.
:::

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.

:::caution
Declare `default: true` on exactly one realm per file. Realms within one file are folded in map
order, so if two realms in the same file both claim it, which one wins is not deterministic across
boots.
:::

## Resolution order

`POST /auth/login` resolves the realm before it looks at any credential:

1. The request's `realm` field, if present. A value equal to `superadmin` — compared
   case-insensitively after trimming — short-circuits the entire realm machinery and authenticates
   against the control plane instead.
2. Otherwise the realm marked `default: true`.
3. 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`.

```http
POST /auth/login
```

```json
{ "identity": "user@acme.io",
  "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](/blocks/baas/iam/tokens/) preserves the realm the session started
in, and the [MFA-pending token](/blocks/baas/iam/authn/mfa/) carries it so the second step reads the
same entity. The full password flow is on [Password login](/blocks/baas/iam/authn/password/).

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

:::caution
The provider gate is consulted by **password login only**, and only ever with the literal string
`password`. [Magic link and WebAuthn](/blocks/baas/iam/authn/passwordless/) and
[OAuth](/blocks/baas/iam/authn/oauth/) never consult a realm at all: they hard-code the `users`
realm and query the base `users` entity by email address. Adding or removing `magiclink`, `o365` or
any other name from a realm's `providers` list changes nothing for those flows. A realm whose list
is `[password]` still permits magic-link login for any row in the base `users` table.
:::

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

Which columns can identify a user is declared **on the field**, in the entity definition, not in
`realms.yaml`:

```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](/blocks/baas/iam/identity/entities/) for what a layer may add and what the
`final` and `access-lock` seals refuse.

### Resolution

1. The `useforidentity` column, if one is declared.
2. The remaining `useforauth` columns, 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:

```json
{ "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

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

| 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

`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](/blocks/baas/iam/authz/superadmin/).

Every request and response shape for these routes is in the [IAM API reference](/blocks/baas/iam/api/); the full
error table is on the [error page](/blocks/baas/iam/error/).