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 tableLayers 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.
What each layer may contribute
Section titled “What each layer may contribute”| Contribution | Product layer | Tenant layer |
|---|---|---|
| New tenant-plane entity | yes | yes |
New scope: shared entity | yes | dropped |
New scope: superadmin entity | dropped | dropped |
| New field on an existing entity | yes, unless the entity is final | yes, unless the entity is final or scoped |
| Field with a name the base already declares | dropped | dropped |
| New index, new relation | yes | yes |
access: block on an existing base entity | per tier, unless locked | dropped outright |
Change an entity’s scope: | dropped | dropped |
| Custom type or enum shadowing a base one | dropped | dropped |
endpoints: block | n/a | stripped before parsing |
The seals: final and access-lock
Section titled “The seals: final and access-lock”Two entity-level keys in the service base decide what a layer may touch.
| Key | Effect |
|---|---|
final: true | The 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:
| Entity | access-lock | A product may add fields | A product may override access |
|---|---|---|---|
users | actions, rls | yes | services and fields only |
userattribute | actions | yes | services, rls, fields |
user_lock | actions | yes | services, rls, fields |
user_eventlog | actions | yes | services, rls, fields |
federation_request | actions | yes | services, rls, fields |
usergroup | none | yes | every tier |
groupmembership | none | yes | every 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.
Where the definitions live
Section titled “Where the definitions live”Product layer
Section titled “Product layer”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.yamlThe 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.
Tenant layer
Section titled “Tenant layer”Rows in the tenant’s tenant_extensions table: path, content_type, content_text.
| Row | Content |
|---|---|
/extend/<name>.yaml | Entity definitions — same YAML as the product’s extend/ files |
/token.yaml, /realms.yaml, /providers.yaml, /access.yaml, /config.yaml, /session.yaml | Behavior 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.
Merge semantics
Section titled “Merge semantics”What wins
Section titled “What wins”- 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.
What is dropped, and how you find out
Section titled “What is dropped, and how you find out”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:
| Diagnostic | Cause |
|---|---|
override-applied | A product access-tier override was applied. Informational, not logged. |
ignored-duplicate | A field, index or relation name a lower layer already declares. |
ignored-tenant-access | A tenant layer supplied an access: block. |
ignored-locked-tier | A product override of a tier named in access-lock:. |
ignored-final-entity | Any contribution to a final: true entity. |
ignored-scope-change | A scope change, a control-plane entity from a layer, or a tenant layer touching a scoped entity. |
ignored-shadow | A custom type or enum whose name the base already declares. |
What does abort the build
Section titled “What does abort the build”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.
| Failure | Layer |
|---|---|
| YAML that does not parse, or fails loader validation | product or tenant |
| A behavior file that does not parse | product 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.
Worked example: adding fields to users
Section titled “Worked example: adding fields to users”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: trueuseforauth: 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: departmentLog 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: trueWorked 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: trueiam/realms.yaml — realms 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:
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.
What a realm identity entity must declare
Section titled “What a realm identity entity must declare”| Requirement | Why |
|---|---|
At least one field with attributes.useforauth: true | Otherwise 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: true | Marks the identifier tried first when the request names none. |
A field named exactly password | Password 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 password | Hashing 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 rule | Redaction 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 field | Roles come from properties.roles on the authenticating row. Without it the identity authenticates with no roles. |
A purge action rule | Delete 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. |
Access rules on a new entity
Section titled “Access rules on a new entity”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.
Applying a change
Section titled “Applying a change”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.
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 mediumPlan 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.
Verifying the result
Section titled “Verifying the result”| Check | Where |
|---|---|
| The field is in the composed schema | GET /schema/<entity> |
| The field is queryable | GET /rest/<entity> — read by id is GET /rest/<entity>/id/<id> |
| Nothing was dropped | The service log, at error level, on the build that followed the change |
| The identifier is accepted | POST /auth/login with identity_type set to the new field name |
Route shapes and payloads: HTTP API reference. Failure codes: Errors.
What does not work
Section titled “What does not work”| Form | Behaviour |
|---|---|
augments: <entity> on an entity | Not 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 entity | Dropped with ignored-final-entity. |
| A tenant overriding access on any entity | Dropped with ignored-tenant-access. |
A layer declaring a scope: superadmin entity | Dropped with ignored-scope-change. The control plane takes no product layer and no tenant overlay. |
| Redefining a base field to change its type or validations | Dropped with ignored-duplicate. There is no override for a field. |