Skip to content
Talk to our solutions team

Extending the user entity

A product extends iam.svc’s schema by re-declaring an entity by name and listing only the new fields. The composed schema a tenant runs on is folded from three layers on every engine build:

service base embedded in iam.svc, parsed once per process
⊕ product layer the product's iam/ folder, delivered by the product configuration source
⊕ tenant layer rows in the tenant's own tenant_extensions table

Layers are additive. A collision never merges and never replaces — the lower layer’s definition stands and the contribution is dropped. Access rules are the one exception: the product layer may replace a base entity’s access, per tier.

ContributionProduct layerTenant layer
New tenant-plane entityyesyes
New scope: shared entityyesdropped
New scope: superadmin entitydroppeddropped
New field on an existing entityyes, unless the entity is finalyes, unless the entity is final or scoped
Field with a name the base already declaresdroppeddropped
New index, new relationyesyes
access: block on an existing base entityper tier, unless lockeddropped outright
Change an entity’s scope:droppeddropped
Custom type or enum shadowing a base onedroppeddropped
endpoints: blockn/astripped before parsing

Two entity-level keys in the service base decide what a layer may touch.

KeyEffect
final: trueThe entity is frozen against every layer. No added fields, no access override, nothing.
access-lock: [tier…]Names access tiers a product layer may not replace. Valid tiers: services, actions, rls, fields.

22 of the 29 base entities are final: true. These seven are not:

Entityaccess-lockA product may add fieldsA product may override access
usersactions, rlsyesservices and fields only
userattributeactionsyesservices, rls, fields
user_lockactionsyesservices, rls, fields
user_eventlogactionsyesservices, rls, fields
federation_requestactionsyesservices, rls, fields
usergroupnoneyesevery tier
groupmembershipnoneyesevery tier

users is the extension point that matters. Its actions and rls tiers are locked, so a product can add columns to the identity row but cannot loosen who may read, create, update or delete it, and cannot widen the row-level filter that narrows a non-admin to their own row. See Identity entities for the full inventory and each entity’s rules.

The product’s iam/ folder. Schema files sit under extend/; the behavior files sit beside it.

iam/
providers.yaml # auth-method declarations
realms.yaml # which entity authenticates, with which providers
token.yaml # expiry, static claims, per-identity claims
access.yaml # named authorization predicates
config.yaml # free-form service properties
session.yaml # session posture
extend/ # entity definitions — this page
users.yaml
patient.yaml

The behavior files are fetched by exact path (iam/token.yaml, iam/realms.yaml, iam/providers.yaml, iam/access.yaml, iam/config.yaml, iam/session.yaml). The extend/ files are fetched as the product’s datastore file set, so filenames there are organizational only — the entity’s name: is what binds it to a base entity.

Rows in the tenant’s tenant_extensions table: path, content_type, content_text.

RowContent
/extend/<name>.yamlEntity definitions — same YAML as the product’s extend/ files
/token.yaml, /realms.yaml, /providers.yaml, /access.yaml, /config.yaml, /session.yamlBehavior overrides over the product layer

content_type must be one of application/x-yaml, application/yaml, text/yaml, text/x-yaml; the YAML body goes in content_text. Schema rows are selected by content_type, not by path, so the /extend/ prefix is a convention. Behavior rows are selected by exact path.

  • A base field wins over a layer field of the same name. The layer’s copy is dropped; the column keeps the base’s type, validations, compliances and attributes.
  • A lower layer wins over a higher one. Product beats tenant on a name collision, exactly as base beats product.
  • Access is per-tier, whole-tier replacement, product-layer only. A tier the product supplies replaces the base’s for that tier outright; a tier it omits is inherited untouched. A tenant access: block is dropped whatever it contains.
  • Scope is fixed by the layer that declares the entity. A layer that names a different scope: on an existing entity has the change dropped and the base’s scope kept.

The fold never returns an error. Every dropped contribution is recorded as a diagnostic and logged at error level; the engine keeps serving on the authoritative definition. The kinds:

DiagnosticCause
override-appliedA product access-tier override was applied. Informational, not logged.
ignored-duplicateA field, index or relation name a lower layer already declares.
ignored-tenant-accessA tenant layer supplied an access: block.
ignored-locked-tierA product override of a tier named in access-lock:.
ignored-final-entityAny contribution to a final: true entity.
ignored-scope-changeA scope change, a control-plane entity from a layer, or a tenant layer touching a scoped entity.
ignored-shadowA custom type or enum whose name the base already declares.

Three failures are hard: they fail the tenant’s engine build, and every request for that CPET then answers 500 engine_unavailable until the cause is fixed.

FailureLayer
YAML that does not parse, or fails loader validationproduct or tenant
A behavior file that does not parseproduct or tenant
The overlay store cannot be read (transport error, not a missing table)tenant

A fresh tenant whose datastore has not been bootstrapped yet has no tenant_extensions table at all. That is tolerated: the engine builds on layers 1 and 2, and the overlay activates on the next build after bootstrap.

A product wants employees to log in with a badge number as well as an email, and wants the department on the token.

iam/extend/users.yaml — re-declare users, list only what is new:

entities:
- name: users
fields:
- name: employee_id
type: string
nullable: true
validations:
- type: unique
attributes:
useforauth: true
- name: department
type: string
nullable: true

useforauth: true makes employee_id a login identifier. It composes through the layers because the declaration lives on the field, not in a separate list.

iam/token.yaml — put the department on every access token:

token:
claims_from:
department: department

Log in with the new identifier by naming it:

{ "identity": "E-4417", "identity_type": "employee_id", "password": "" }

Omit identity_type and the service tries each declared identifier in order — the one marked useforidentity: true first (email on the base entity), then the rest by declaration order. Name a column that is not a declared identifier and the request is refused 400 bad_identity_type, with the declared list in the message.

A tenant of that product can add one more column of its own, as a tenant_extensions row at path /extend/users.yaml, content_type: application/x-yaml:

entities:
- name: users
fields:
- name: cost_centre
type: string
nullable: true

Worked example: a custom entity as a realm’s identity store

Section titled “Worked example: a custom entity as a realm’s identity store”

A realm binds a name to an identity entity and the providers allowed against it. Any entity in the composed schema can be that entity.

iam/extend/patient.yaml:

entities:
- name: patient
status: active
default-order: id
cdc: typeaudit
inherits: kisai.common
access:
actions:
read: { language: expr, expression: '"admin" in user.roles || "iam-service" in user.roles || "clinician" in user.roles' }
create: { language: expr, expression: '"admin" in user.roles || "iam-service" in user.roles' }
update: { language: expr, expression: '"admin" in user.roles || "iam-service" in user.roles' }
delete: { language: expr, expression: '"admin" in user.roles || "iam-service" in user.roles' }
unmask: { language: expr, expression: '"iam-service" in user.roles' }
purge: { language: expr, expression: '"iam-service" in user.roles' }
rls:
read: { language: expr, expression: '("admin" in user.roles || "iam-service" in user.roles) ? "" : user.id' }
fields:
- name: id
type: klid
defaultvalue:
language: expr
expression: |
'P'+ulid()
validations:
- type: required
- type: unique
- type: final
- name: email
type: string
validations:
- type: required
- type: unique
- type: length
min: 3
max: 255
attributes:
useforauth: true
useforidentity: true
compliances:
- type: nolog
- name: password
type: string
nullable: true
compliances:
- type: redact
- name: properties
type: object
defaultvalue: '{}'
- name: fullname
type: string
nullable: true

iam/realms.yamlrealms is a map keyed by realm name:

realms:
users:
default: true
active: true
providers: [password]
patient:
active: true
entity: patient
providers: [password]

Clients then log in against it:

{ "realm": "patient", "identity": "[email protected]", "password": "" }

The minted sub is the row id in patient, and the realm claim carries patient. Full realm behavior, including the provider gate, is on Realms.

RequirementWhy
At least one field with attributes.useforauth: trueOtherwise the entity falls back to a single identifier named email, and a login naming anything else is refused 400 bad_identity_type.
One of them with useforidentity: trueMarks the identifier tried first when the request names none.
A field named exactly passwordPassword verification reads that column. The name is what triggers argon2id hashing — the hook scans every entity in the composed schema for a field called password and hashes it on create and update.
compliances: [{type: redact}] on passwordHashing is name-driven; redaction is not. Without it the at-rest argon2 hash is returned on every read of the entity, including /rest.
An unmask action ruleRedaction is bypassed by ?unmask=true. An entity with an access block but no unmask rule permits that flag to anyone who passes the read rule.
A properties object fieldRoles come from properties.roles on the authenticating row. Without it the identity authenticates with no roles.
A purge action ruleDelete is a soft delete by default. An entity with no purge rule lets anyone who passes delete pass ?purge=true and hard-delete the row.

Every entity is deny-by-default for external callers: an entity with no access: block, or with a block that declares no rule for the operation being attempted, is refused on /rest and on the anonymous /anon/rest mirror alike. Internal service paths — the CLI, seeding, migrations — carry no auth level and are never gated.

Rule expressions receive the caller as user. Both actions and rls rules see user.id, user.tenant, user.roles, user.service and user.act — the last is non-nil only when the request is an impersonation, so user.act == nil is how an entity refuses to be reached through one. An rls rule returns a value, not SQL: an empty string means unrestricted, a scalar becomes an equality filter on the key column, a list becomes an IN predicate.

A schema change alters the composed schema at the next engine build; it does not alter the database. The table or column does not exist until a migration plan is generated and applied.

Terminal window
iam.svc data plan generate -f .iam.yaml --tenant <cpet>
iam.svc data plan show <plan-id>
iam.svc data plan apply <plan-id> --confirm-risk medium

Plan generation and review are available to a tenant admin over /admin/data/*; apply is operator-gated, because the DDL touches credential and identity tables.

The engine rebuild happens on its own when the tenant’s configuration is refreshed or the product configuration source reports a change — both drain the tenant’s cached scope so the next request rebuilds it. In development, restart the service or drain the tenant explicitly.

CheckWhere
The field is in the composed schemaGET /schema/<entity>
The field is queryableGET /rest/<entity> — read by id is GET /rest/<entity>/id/<id>
Nothing was droppedThe service log, at error level, on the build that followed the change
The identifier is acceptedPOST /auth/login with identity_type set to the new field name

Route shapes and payloads: HTTP API reference. Failure codes: Errors.

FormBehaviour
augments: <entity> on an entityNot a recognized entity key. The YAML parses as a brand-new entity under whatever name: it carries, which gets its own table; the target entity gains nothing. Re-declare the entity by name instead.
A top-level augments: list with target:Parses, then is discarded at fold time. Nothing resolves it.
Adding a field to a final: true entityDropped with ignored-final-entity.
A tenant overriding access on any entityDropped with ignored-tenant-access.
A layer declaring a scope: superadmin entityDropped with ignored-scope-change. The control plane takes no product layer and no tenant overlay.
Redefining a base field to change its type or validationsDropped with ignored-duplicate. There is no override for a field.