Skip to content
Talk to our solutions team

Identity entities

iam.svc ships 29 entities as an embedded schema, materialized per tenant at engine build. This page is the field-level reference: what each entity holds, who may touch it, whether a product or tenant may extend it, and which ones nothing in the service actually reads.

Each entity lands in one of three isolation planes, selected by its scope: key.

Planescope:Where it livesEntities
TenantunsetThe tenant’s own PostgreSQL schemaEverything not listed below
ControlsuperadminA separate schema named superadmin, split out at boottenant, tenant_eventlog, superadmin, superadmin_grant
SharedsharedA shared scope engineNone of the base entities use it

jwt_signing_key and user_refresh_token are tenant-plane entities that are additionally cloned into the control plane with scope forced to superadmin, so operator credentials never share a table with a tenant’s.

Extends says what a product or tenant layer may contribute: no means final: true (sealed against every layer), fields means new fields only, fields + access means new fields plus access tiers not named in access-lock:.

EntityPlaneHoldsExtendsAudit tableRead by the service
userstenantThe base identity rowfields + access (services, fields)users_audityes
userattributetenantCustom attribute definitionsfields + access (not actions)no
usergrouptenantGroups, optionally nestedfields + access (all tiers)no
groupmembershiptenantUser ↔ group membership, time-boundedfields + access (all tiers)no
roletenantRole definitionsnorole_auditno
sessiontenantManaged server-side sessionsnoyes
user_refresh_tokentenant + controlRotating refresh-token familiesnoyes
api_keytenantIssued service API keysnoyes
user_mfa_requesttenantTOTP enrolmentsnoyes
user_magic_linktenantMagic-link codesnoyes
password_reset_requesttenantPassword-reset codesnoyes
oauthstatetenantOAuth state + PKCE verifiernoyes
webauthn_credentialtenantRegistered passkeysnoyes
webauthn_sessiontenantIn-flight WebAuthn challengenoyes
jwt_signing_keytenant + controlThe per-tenant signing keyringnoyes
iamagentstenantAgent principals with claimsnoiamagents_auditno
iambotstenantBot principals with claimsnoiambots_auditno
iamdelegationstenantUser-to-user delegation grantsnoiamdelegations_auditno
user_otptenantOTP codesnono
user_tokentenantPer-provider refresh tokensnono
user_locktenantLockout recordsfields + access (not actions)no
user_eventlogtenantPer-user auth eventsfields + access (not actions)no
federation_requesttenantFederation handshake payloadsfields + access (not actions)no
trackedanonymoususertenantPre-login anonymous identitiesnono
tenant_extensionstenantThe tenant’s own schema overlay rowsnoyes
tenantcontrolThe tenant registrynotenant_audityes
tenant_eventlogcontrolTenant-level eventsnono
superadmincontrolThe operator rosternosuperadmin_audityes
superadmin_grantcontrolOperator → tenant grantsnosuperadmin_grant_audityes

There is no realm entity: realms are configuration, not rows. There is no product entity either — the product is a segment of the tenant key, not a stored object.

Two traits are applied to every entity in the schema, whether or not the entity declares inherits: kisai.common.

ColumnTypeNullableBehaviour
createdbystringnoImmutable after create; defaults to the request’s user id
createdondatetimenoImmutable after create; defaults to the current timestamp
updatedbystringyesMaterialized-computed from the request’s user id on every write
updatedondatetimeyesMaterialized-computed timestamp on every write
deletedbystringyesWrite-once
deletedondatetimeyesWrite-once

Because deletedon exists everywhere, DELETE is a soft delete on every IAM entity — an UPDATE that stamps deletedon and deletedby. Hard removal requires the purge permission.

TypeColumn
klidchar(27) — one uppercase prefix letter plus a 26-character ULID
ulid26-character ULID
stringvarchar(255)
textUnbounded text
boolean, int, timestamp, datetime, bytesThe obvious mappings
objectJSONB
array(string)Text array
named enumA declared enum type (groupvisibility, groupmembershiptype, sessionstatus)

ID prefixes on klid columns: U user and operator · A agent · B bot · D delegation · G grant.

The base identity entity. access-lock: [actions, rls], cdc: typeaudit, not final — this is the entity a product extends.

FieldTypeConstraintsDefault / notes
idklidrequired, unique, immutable'U'+ulid()
emailstringrequired, unique, length 3–255Login identifier and the default one; kept out of logs
mobilestringuniqueLogin identifier; not required; kept out of logs
metaobject{}
propertiesobject{}properties.roles is where authorization roles live
firstnamestringrequired, length 3–255Kept out of logs
lastnamestringrequired, length 3–255Kept out of logs
middlenamestringlength ≤ 255Kept out of logs
displaynamestringcomputedfirstname + " " + lastname
passwordstringargon2id-hashed on write; redacted on every read
activebooleanrequiredtrue
lockedbooleanfalse
avatarstring
tagsarray(string)
allow_impersonationbooleanfalse — the tenant’s per-user impersonation consent

allow_impersonation lives in tenant data on purpose: creating or updating a users row requires the admin or iam-service role, and an operator token carries neither, so an operator structurally cannot grant themselves consent.

The operator roster. final: true, scope: superadmin, cdc: typeaudit. Same shape as users minus allow_impersonation, with one difference in the computed name.

FieldTypeConstraintsDefault / notes
idklidrequired, unique, immutable'U'+ulid()
emailstringrequired, unique, length 3–255Login identifier and the default one
mobilestringuniqueLogin identifier
meta, propertiesobject{}properties.roles holds the operator’s roles
firstname, lastnamestringrequired, length 3–255
middlenamestringlength ≤ 255
displaynamestringcomputedfirstname + " " + middlename + " " + lastname
passwordstringargon2id-hashed, redacted
activebooleanrequiredtrue
lockedbooleanfalse
avatarstring
tagsarray(string)

Only iam-service may unmask an operator’s password hash — an operator-admin can manage the roster but never read another operator’s credential.

Attribute definitions. access-lock: [actions], extensible. Nothing reads it.

FieldTypeConstraints
idulidrequired, unique, immutable
namestringrequired, length ≤ 255
label, datatype, valuesstringlength ≤ 255
defaultvaluestring
activebooleanrequired, default true

Non-human principals. All three are final: true with cdc: typeaudit, and all three carry claims — which is why every action requires admin or iam-service. No authentication path reads them.

Fieldiamagentsiambotsiamdelegations
idklid 'A'+ulid()klid 'B'+ulid()klid 'D'+ulid()
namestring, required, 3–255, unique index with deletedonstring, required, 3–255, unique index with deletedonno unique validation
displaynamestring ≤ 512string ≤ 1024
useridklid, requiredklid, requiredklid, required
touseridklid, required
notbefore / notaftertimestamp, requiredabsenttimestamp, required
activeboolean, requiredboolean, requiredboolean, required
attributesobject {}object {}array(string)
claimsobject {}object {}object {}
allowedservicesarray(string)array(string)

Setting realmconfig.enable.agents, .bots or .delegations to false removes the entity from that tenant’s composed schema entirely — the table ceases to exist for the tenant, rather than the routes being guarded.

The live impersonation path is POST /superadmin/impersonate; it does not consult iamdelegations. See Impersonation.

final: true. id (ulid, required, immutable — the only entity whose id carries no unique validation) and userid (string). Anonymous tracking is not wired.

Every entity in this section is final: true and restricted to the iam-service role on all four actions. No external token can carry that role, so none of these tables are reachable over the REST or admin surface.

FieldTypeNullableNotes
idulidnoulid(); the row id used to revoke
session_idstringnoUnique. The opaque bearer credential, ≥128-bit CSPRNG
principal_idstringno
cpetstringnoServer-set; never client-asserted
statussessionstatusnoactive | revoked | expired, default active
aalintnoDefault 1
auth_methodstringno
idp_issstringyesIdP issuer, for logout correlation
idp_sidstringyesIdP session id
user_agent_labelstringno
created_attimestampno
last_seen_attimestampno
idle_timeout_sintnoDefault 0
absolute_expirytimestampno
revoked_reasonstringyesuser_logout | admin_revoke | concurrent_evict | backchannel_logout | idle_expired | absolute_expired | rotated
realmstringyes
impersonator_idstringyesSet only on an impersonated session
impersonator_reasonstringyesLength ≤ 500
impersonator_modestringyesreadonly | readwrite

Indexes: session_session_id_uq unique on (session_id), session_principal_idx on (principal_id), session_idp_idx on (idp_iss, idp_sid).

Rows are written only on a tenant whose session preset resolves to the managed model; see Sessions.

FieldTypeNullableNotes
idulidno
useridklidno
authtypestringnoLength ≤ 255
tokenstringno
tenantstringnoThe full tenant key; cross-checked at redeem
realmstringno
familystringnoRotation family id; empty on rows written before rotation existed
rotatedbooleannoDefault false — the tombstone that drives reuse detection
expiresontimestampyes

Replaying a token whose rotated flag is set revokes the whole family.

FieldTypeNotes
idulid
servicestringrequired, ≤ 255 — an API key belongs to a service, not a user; there is no owner column
keybytesrequired — a placeholder written to satisfy NOT NULL, not used
secretstringrequired, encrypted at rest under the secret key; stores the SHA-256 of the plaintext
capabilitiesobject{}
expiresattimestamprequired

The wire format handed to the caller once at issue time is <id>.<secret>.

FieldTypeNotes
idulid
namestringrequired, ≤ 255
useridklidrequired
providerstringrequired, ≤ 255
secretstringencrypted at rest under the secret key
counterint
lastusedtimestampDoubles as the enabled flag: unset means pending setup, set means active
realmstringrequired
FieldTypeNotes
idulid
useridklidrequired
providerstringrequired, ≤ 255
tokenstringrequired
expiresattimestamprequired
approvedbooleanrequired, default false
numbersobject{}
realmstringrequired
FieldTypeNotes
idulid
tenantslugstringrequired, ≤ 50
emailstringrequired, ≤ 255
codestringrequired, ≤ 255 — stores a hash of the emailed code
expiresontimestamprequired
usedontimestampStamped on confirm

There is no userid column; the row is keyed by email + tenantslug. A row whose expireson is missing or not a time is treated as expired.

FieldTypeNotes
idulid
statekeystringrequired
statevaluestringrequired
codeverifierstringThe PKCE verifier; kept out of logs
expiresontimestamp

Server-side only, single-use, short-lived. No OAuth provider is wired in the deployable binary, so nothing writes these rows in a shipped deployment.

EntityFieldTypeNotes
webauthn_credentialidulid
useridklidrequired
namestringrequired, ≤ 255 — the user’s label for the authenticator
credential_idbytesrequired — the opaque WebAuthn credential id
credentialobjectrequired — the full marshaled credential as JSON
realmstringrequired — carried for operational queries only
webauthn_sessionidulid
namestringrequired — the register flow encodes the label as register:<name>
dataobjectrequired — the in-flight challenge, purged after finish

A user may hold many credential rows. The JSON envelope absorbs library changes without a schema migration. WebAuthn is not enabled in the deployable binary; every route answers 503 webauthn_disabled, so these tables stay empty in a shipped deployment. See Passwordless.

FieldTypeNotes
idulid
kidstringrequired, unique, ≤ 64
algstringrequired, enum EdDSA | ES256
publickeytextrequired
privatekeytextrequired; kept out of logs, excluded from export, encrypted at rest
activebooleanDefault true

At most one active row per tenant. Rotation flips the previous row to active: false in the same transaction that inserts the new one; inactive rows are retained so previously issued tokens keep verifying. Cloned into the control plane so operator tokens are signed by a keyring no tenant shares.

Both are declared, migrated and unreferenced by any code.

EntityFields
user_otpid (ulid), userid (klid, required), provider (string, required ≤ 255), token (string, required), expiresat (timestamp, required), realm (string, required)
user_tokenid (ulid), userid (klid, required), ulid (ulid, required), flowtype (string, required ≤ 255), provider (string, required ≤ 255), refreshtoken (string, required), realm (string, required)

final: true, cdc: typeaudit. Mounted for admin CRUD.

FieldTypeConstraints
idulidrequired, unique, immutable
slugstringrequired, unique
displaynamestringrequired
activebooleanrequired, default true
propertiesobject{}

Declared in one file, and the only entities in the schema that declare explicit references. Neither is final and neither declares access-lock, so a product layer owns their access outright. No code reads them.

EntityFieldTypeConstraints
usergroupidulidrequired, unique, immutable
namestringrequired
visibilitygroupvisibilityrequired — Private | Public | Hidden
parentidulid
groupmembershipidulidrequired, unique, immutable
useridulidrequired
membershiptypegroupmembershiptypeOwner | Moderator | Member
groupidulidrequired
starttimetimestamprequired
endtimetimestamprequired

References: usergroup.idusergroup.parentid (one-to-many) and usergroup.idgroupmembership.groupid (one-to-many). Index usergroup_name_deletedon_uq is unique on (name, deletedon), so a name freed by a soft delete can be reused.

The registry. final: true, scope: superadmin, cdc: typeaudit.

FieldTypeNullableConstraints
idulidnorequired, unique, immutable — ulid()
parentidulidyes
slugstringnorequired, unique, ≤ 50 — the tenant key
displaynamestringnorequired, ≤ 255
namespacestringyes
domainstringyes
activebooleannorequired, default true
markedforseedbooleannoSet on register, cleared when provisioning completes
metaobjectno{}

Row-level read: ("iam-service" in user.roles || "root" in user.roles) ? "" : user.grants. An operator sees only the tenants they hold a grant for; an operator with no grants gets a filter matching no row, so unauthorized tenants are invisible rather than merely unactionable. user.grants is bound per request from the plane’s superadmin_grant rows, so a revoke takes effect immediately.

FieldTypeNullableConstraints
idklidnorequired, unique, immutable — 'G'+ulid()
superadmin_idstringnorequired, immutable
tenant_idstringnorequired, immutable — the registry row id, not the tenant key
reasonstringyes≤ 500
activebooleannoDefault true

Indexes: superadmin_grant_uq unique on (superadmin_id, tenant_id), superadmin_grant_operator_idx on (superadmin_id).

The row is deliberately flat — no role column. superadmin_id and tenant_id are immutable: a grant cannot be moved, only revoked and re-granted. An operator holding root needs no rows; the role is an implicit grant over every tenant in its own plane. See The operator plane.

The tenant’s own schema-overlay store. final: true.

FieldTypeNullableConstraints
idulidnorequired, unique, immutable
pathstringnorequired, unique — one overlay row per path
content_typestringyesMIME type; application/x-yaml, application/yaml, text/yaml and text/x-yaml are read as schema overlays
content_texttextyes
content_blobbytesyes

Eight entities declare cdc: typeaudit: users, role, tenant, superadmin, superadmin_grant, iamagents, iambots, iamdelegations. Each gets a parallel table with one row per field change.

ColumnTypeNotes
idTEXT PRIMARY KEYAudit row ULID
entity_idTEXT NOT NULLLogical id of the changed row
field_nameTEXTEmpty for a delete
old_valueTEXTJSON-encoded pre-change value
new_valueTEXTJSON-encoded post-change value
operationTEXT NOT NULLcreate | update | delete
changed_atTIMESTAMPTZ NOT NULLDefaults to now()
changed_byTEXTUser id; empty for system writes

During an impersonation, changed_by is the operator, and the rows land in the tenant’s own schema so the customer can query them. session, api_key, user_refresh_token and jwt_signing_key have no audit table.

These look like the audit stores and are never written by the service. The real record is the _audit side tables above.

EntityFields
user_eventlogid (ulid), userid (string), eventtype / attributetype / outcometype (int), message (string), context (object {}), meta (object {}), realm (string, required)
tenant_eventlogid (ulid), tenantid (ulid, required), eventtype / attributetype / outcometype (int), message (string), context (object {})

Both restrict create, update and delete to iam-service: an administrator can read the log but cannot forge or erase entries. tenant_eventlog read additionally admits tenant-read and tenant-provision.

Every entity is deny-by-default — an action with no rule is refused. Rules are expr predicates over user.roles, user.id and user.grants.

Entityreadcreateupdatedeleteunmask
usersuser.id != ""admin, iam-serviceadmin, iam-serviceadmin, iam-serviceiam-service
userattribute, user_lock, usergroup, groupmembership, role, federation_request, trackedanonymoususer, iamagents, iambots, iamdelegationsadmin, iam-serviceadmin, iam-serviceadmin, iam-serviceadmin, iam-service
user_eventlogadmin, iam-serviceiam-serviceiam-serviceiam-service
session, user_refresh_token, user_token, user_otp, user_mfa_request, user_magic_link, password_reset_request, oauthstate, webauthn_credential, webauthn_session, api_key, jwt_signing_key, tenant_extensionsiam-serviceiam-serviceiam-serviceiam-service
tenantiam-service, root, tenant-read, tenant-provisioniam-service, root, tenant-provisioniam-service, root, tenant-provisioniam-service, root
tenant_eventlogiam-service, root, tenant-read, tenant-provisioniam-serviceiam-serviceiam-service
superadminiam-service, root, superadmin-adminiam-service, root, superadmin-adminiam-service, root, superadmin-adminiam-service, rootiam-service
superadmin_grantiam-service, root, superadmin-admin, tenant-read, tenant-provisioniam-service, root, superadmin-adminiam-service, root, superadmin-adminiam-service, root, superadmin-admin

Two entities declare row-level security:

EntityRuleEffect
users("admin" in user.roles || "iam-service" in user.roles) ? "" : user.idA non-admin reads only their own row
tenant("iam-service" in user.roles || "root" in user.roles) ? "" : user.grantsAn operator reads only granted tenants

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.

iam-service is the service’s own internal principal — a context-attached identity whose user id and single role are both the literal iam-service. Every internal credential read and write runs under it, which is why the credential tables can be sealed to it. It is not a role any issued token can carry, and there is no engine-level system bypass behind it. See Authorization for the role vocabulary and how properties.roles reaches a request.

The schema a tenant runs is folded from three layers, in order:

LayerSourceMay contribute
Service baseThe embedded entity definitions, parsed once per process
ProductThe product’s iam/ folder, delivered through the product configuration sourceNew entities, new fields on non-final entities, access tiers not named in access-lock:
TenantRows in tenant_extensionsNew entities and new fields only — an access block from this layer is dropped

The control plane takes neither layer: a tenant never reshapes the plane that governs it.

SealMeaningEntities
final: trueNo layer may contribute anything — no fields, no access22 entities: role, tenant, tenant_extensions, tenant_eventlog, session, user_token, user_refresh_token, api_key, iamagents, iambots, iamdelegations, superadmin, superadmin_grant, user_otp, user_mfa_request, user_magic_link, password_reset_request, oauthstate, trackedanonymoususer, webauthn_credential, webauthn_session, jwt_signing_key
access-lock: [actions, rls]A product may add fields and override the services and fields access tiers, but not actions or rlsusers
access-lock: [actions]A product may add fields and override every tier except actionsuserattribute, user_lock, user_eventlog, federation_request
noneFully open to the product layerusergroup, groupmembership

The four valid access tiers are services, actions, rls and fields.

Re-declare the entity by name in a layer file, listing only the new fields:

entities:
- name: users
fields:
- name: department
type: string
nullable: true
- name: employee_number
type: string
nullable: true

Place it in the product’s iam/extend/ folder, or store it as a tenant_extensions row with content_type: application/x-yaml. The fold adds each new field name to the base entity and tags it with the contributing layer.

The fold never fails. A structural collision, a tenant-supplied access block, a scope change, a contribution to a final entity, or a duplicate field name is discarded, recorded as a diagnostic and logged at error level — the base definition stands and the engine keeps serving.

DiagnosticCause
override-appliedA layer legitimately replaced a value
ignored-duplicateThe field name already exists on the base
ignored-tenant-accessA tenant layer supplied an access: block
ignored-locked-tierA product tried to override an access-lock tier
ignored-final-entityA layer contributed to a final entity
ignored-scope-changeA layer tried to move an entity to another plane
ignored-shadowA layer redefined a base type or enum

The layer’s author sees a log line, not a failed deploy, and the field simply does not exist.

Login identifiers are declared on the field

Section titled “Login identifiers are declared on the field”

Any field carrying attributes: {useforauth: true} becomes a login-identifier column; useforidentity: true marks the default one, tried first. Base users declares email with both and mobile with useforauth. A product adds its own on users or on its realm’s identity entity.

entities:
- name: users
fields:
- name: employee_number
type: string
nullable: true
attributes:
useforauth: true

An entity declaring none falls back to email. A login request naming an identity type the entity did not mark is refused with 400 bad_identity_type and the declared list in the message — the service never filters on an unmarked column. Which entity a login authenticates against is a realm decision: see Realms.

ExpectedReality
An invitation entity or invite flowNot implemented — no entity, no route, no code
Email or phone verificationNot implemented
Self-service registrationNo signup route in the deployable service
A user-to-role join tableNone; roles live in users.properties.roles
A realm entityRemoved — realms are configuration, not rows
A product entityThe product is a segment of the tenant key, not a row
Account lock or disable enforcement on loginusers.locked and user_lock are inert on the password path
principal_grant / a principal-type claimSpecified, not adopted — neither identifier exists in the service

Accounts are created by exactly four paths: admin CRUD (POST /admin/user or POST /rest/users), the seed command, tenant provisioning, and just-in-time on an OAuth callback for an unknown email. Signup events fire only from that last path, which no shipped deployment reaches — no OAuth provider is wired in the deployable binary. Seeding and tenant provisioning are covered in Operating.

For the routes that read and write these entities, see the IAM API reference and the error table. For the entity engine itself — query syntax, hooks, migrations and the generic REST surface — see the Data API.