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.
Planes
Section titled “Planes”Each entity lands in one of three isolation planes, selected by its scope: key.
| Plane | scope: | Where it lives | Entities |
|---|---|---|---|
| Tenant | unset | The tenant’s own PostgreSQL schema | Everything not listed below |
| Control | superadmin | A separate schema named superadmin, split out at boot | tenant, tenant_eventlog, superadmin, superadmin_grant |
| Shared | shared | A shared scope engine | None 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.
Entity inventory
Section titled “Entity inventory”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:.
| Entity | Plane | Holds | Extends | Audit table | Read by the service |
|---|---|---|---|---|---|
users | tenant | The base identity row | fields + access (services, fields) | users_audit | yes |
userattribute | tenant | Custom attribute definitions | fields + access (not actions) | — | no |
usergroup | tenant | Groups, optionally nested | fields + access (all tiers) | — | no |
groupmembership | tenant | User ↔ group membership, time-bounded | fields + access (all tiers) | — | no |
role | tenant | Role definitions | no | role_audit | no |
session | tenant | Managed server-side sessions | no | — | yes |
user_refresh_token | tenant + control | Rotating refresh-token families | no | — | yes |
api_key | tenant | Issued service API keys | no | — | yes |
user_mfa_request | tenant | TOTP enrolments | no | — | yes |
user_magic_link | tenant | Magic-link codes | no | — | yes |
password_reset_request | tenant | Password-reset codes | no | — | yes |
oauthstate | tenant | OAuth state + PKCE verifier | no | — | yes |
webauthn_credential | tenant | Registered passkeys | no | — | yes |
webauthn_session | tenant | In-flight WebAuthn challenge | no | — | yes |
jwt_signing_key | tenant + control | The per-tenant signing keyring | no | — | yes |
iamagents | tenant | Agent principals with claims | no | iamagents_audit | no |
iambots | tenant | Bot principals with claims | no | iambots_audit | no |
iamdelegations | tenant | User-to-user delegation grants | no | iamdelegations_audit | no |
user_otp | tenant | OTP codes | no | — | no |
user_token | tenant | Per-provider refresh tokens | no | — | no |
user_lock | tenant | Lockout records | fields + access (not actions) | — | no |
user_eventlog | tenant | Per-user auth events | fields + access (not actions) | — | no |
federation_request | tenant | Federation handshake payloads | fields + access (not actions) | — | no |
trackedanonymoususer | tenant | Pre-login anonymous identities | no | — | no |
tenant_extensions | tenant | The tenant’s own schema overlay rows | no | — | yes |
tenant | control | The tenant registry | no | tenant_audit | yes |
tenant_eventlog | control | Tenant-level events | no | — | no |
superadmin | control | The operator roster | no | superadmin_audit | yes |
superadmin_grant | control | Operator → tenant grants | no | superadmin_grant_audit | yes |
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.
Columns every entity carries
Section titled “Columns every entity carries”Two traits are applied to every entity in the schema, whether or not the entity declares inherits: kisai.common.
| Column | Type | Nullable | Behaviour |
|---|---|---|---|
createdby | string | no | Immutable after create; defaults to the request’s user id |
createdon | datetime | no | Immutable after create; defaults to the current timestamp |
updatedby | string | yes | Materialized-computed from the request’s user id on every write |
updatedon | datetime | yes | Materialized-computed timestamp on every write |
deletedby | string | yes | Write-once |
deletedon | datetime | yes | Write-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.
Type vocabulary
Section titled “Type vocabulary”| Type | Column |
|---|---|
klid | char(27) — one uppercase prefix letter plus a 26-character ULID |
ulid | 26-character ULID |
string | varchar(255) |
text | Unbounded text |
boolean, int, timestamp, datetime, bytes | The obvious mappings |
object | JSONB |
array(string) | Text array |
| named enum | A declared enum type (groupvisibility, groupmembershiptype, sessionstatus) |
ID prefixes on klid columns: U user and operator · A agent · B bot · D delegation · G grant.
Identity
Section titled “Identity”The base identity entity. access-lock: [actions, rls], cdc: typeaudit, not final — this is the entity a product extends.
| Field | Type | Constraints | Default / notes |
|---|---|---|---|
id | klid | required, unique, immutable | 'U'+ulid() |
email | string | required, unique, length 3–255 | Login identifier and the default one; kept out of logs |
mobile | string | unique | Login identifier; not required; kept out of logs |
meta | object | — | {} |
properties | object | — | {} — properties.roles is where authorization roles live |
firstname | string | required, length 3–255 | Kept out of logs |
lastname | string | required, length 3–255 | Kept out of logs |
middlename | string | length ≤ 255 | Kept out of logs |
displayname | string | computed | firstname + " " + lastname |
password | string | — | argon2id-hashed on write; redacted on every read |
active | boolean | required | true |
locked | boolean | — | false |
avatar | string | — | — |
tags | array(string) | — | — |
allow_impersonation | boolean | — | false — 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.
superadmin
Section titled “superadmin”The operator roster. final: true, scope: superadmin, cdc: typeaudit. Same shape as users minus allow_impersonation, with one difference in the computed name.
| Field | Type | Constraints | Default / notes |
|---|---|---|---|
id | klid | required, unique, immutable | 'U'+ulid() |
email | string | required, unique, length 3–255 | Login identifier and the default one |
mobile | string | unique | Login identifier |
meta, properties | object | — | {} — properties.roles holds the operator’s roles |
firstname, lastname | string | required, length 3–255 | — |
middlename | string | length ≤ 255 | — |
displayname | string | computed | firstname + " " + middlename + " " + lastname |
password | string | — | argon2id-hashed, redacted |
active | boolean | required | true |
locked | boolean | — | false |
avatar | string | — | — |
tags | array(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.
userattribute
Section titled “userattribute”Attribute definitions. access-lock: [actions], extensible. Nothing reads it.
| Field | Type | Constraints |
|---|---|---|
id | ulid | required, unique, immutable |
name | string | required, length ≤ 255 |
label, datatype, values | string | length ≤ 255 |
defaultvalue | string | — |
active | boolean | required, default true |
iamagents, iambots, iamdelegations
Section titled “iamagents, iambots, iamdelegations”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.
| Field | iamagents | iambots | iamdelegations |
|---|---|---|---|
id | klid 'A'+ulid() | klid 'B'+ulid() | klid 'D'+ulid() |
name | string, required, 3–255, unique index with deletedon | string, required, 3–255, unique index with deletedon — no unique validation | — |
displayname | string ≤ 512 | string ≤ 1024 | — |
userid | klid, required | klid, required | klid, required |
touserid | — | — | klid, required |
notbefore / notafter | timestamp, required | absent | timestamp, required |
active | boolean, required | boolean, required | boolean, required |
attributes | object {} | object {} | array(string) |
claims | object {} | object {} | object {} |
allowedservices | array(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.
trackedanonymoususer
Section titled “trackedanonymoususer”final: true. id (ulid, required, immutable — the only entity whose id carries no unique validation) and userid (string). Anonymous tracking is not wired.
Credentials and flow state
Section titled “Credentials and flow state”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.
session
Section titled “session”| Field | Type | Nullable | Notes |
|---|---|---|---|
id | ulid | no | ulid(); the row id used to revoke |
session_id | string | no | Unique. The opaque bearer credential, ≥128-bit CSPRNG |
principal_id | string | no | — |
cpet | string | no | Server-set; never client-asserted |
status | sessionstatus | no | active | revoked | expired, default active |
aal | int | no | Default 1 |
auth_method | string | no | — |
idp_iss | string | yes | IdP issuer, for logout correlation |
idp_sid | string | yes | IdP session id |
user_agent_label | string | no | — |
created_at | timestamp | no | — |
last_seen_at | timestamp | no | — |
idle_timeout_s | int | no | Default 0 |
absolute_expiry | timestamp | no | — |
revoked_reason | string | yes | user_logout | admin_revoke | concurrent_evict | backchannel_logout | idle_expired | absolute_expired | rotated |
realm | string | yes | — |
impersonator_id | string | yes | Set only on an impersonated session |
impersonator_reason | string | yes | Length ≤ 500 |
impersonator_mode | string | yes | readonly | 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.
user_refresh_token
Section titled “user_refresh_token”| Field | Type | Nullable | Notes |
|---|---|---|---|
id | ulid | no | — |
userid | klid | no | — |
authtype | string | no | Length ≤ 255 |
token | string | no | — |
tenant | string | no | The full tenant key; cross-checked at redeem |
realm | string | no | — |
family | string | no | Rotation family id; empty on rows written before rotation existed |
rotated | boolean | no | Default false — the tombstone that drives reuse detection |
expireson | timestamp | yes | — |
Replaying a token whose rotated flag is set revokes the whole family.
api_key
Section titled “api_key”| Field | Type | Notes |
|---|---|---|
id | ulid | — |
service | string | required, ≤ 255 — an API key belongs to a service, not a user; there is no owner column |
key | bytes | required — a placeholder written to satisfy NOT NULL, not used |
secret | string | required, encrypted at rest under the secret key; stores the SHA-256 of the plaintext |
capabilities | object | {} |
expiresat | timestamp | required |
The wire format handed to the caller once at issue time is <id>.<secret>.
user_mfa_request
Section titled “user_mfa_request”| Field | Type | Notes |
|---|---|---|
id | ulid | — |
name | string | required, ≤ 255 |
userid | klid | required |
provider | string | required, ≤ 255 |
secret | string | encrypted at rest under the secret key |
counter | int | — |
lastused | timestamp | Doubles as the enabled flag: unset means pending setup, set means active |
realm | string | required |
user_magic_link
Section titled “user_magic_link”| Field | Type | Notes |
|---|---|---|
id | ulid | — |
userid | klid | required |
provider | string | required, ≤ 255 |
token | string | required |
expiresat | timestamp | required |
approved | boolean | required, default false |
numbers | object | {} |
realm | string | required |
password_reset_request
Section titled “password_reset_request”| Field | Type | Notes |
|---|---|---|
id | ulid | — |
tenantslug | string | required, ≤ 50 |
email | string | required, ≤ 255 |
code | string | required, ≤ 255 — stores a hash of the emailed code |
expireson | timestamp | required |
usedon | timestamp | Stamped 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.
oauthstate
Section titled “oauthstate”| Field | Type | Notes |
|---|---|---|
id | ulid | — |
statekey | string | required |
statevalue | string | required |
codeverifier | string | The PKCE verifier; kept out of logs |
expireson | timestamp | — |
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.
webauthn_credential and webauthn_session
Section titled “webauthn_credential and webauthn_session”| Entity | Field | Type | Notes |
|---|---|---|---|
webauthn_credential | id | ulid | — |
userid | klid | required | |
name | string | required, ≤ 255 — the user’s label for the authenticator | |
credential_id | bytes | required — the opaque WebAuthn credential id | |
credential | object | required — the full marshaled credential as JSON | |
realm | string | required — carried for operational queries only | |
webauthn_session | id | ulid | — |
name | string | required — the register flow encodes the label as register:<name> | |
data | object | required — 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.
jwt_signing_key
Section titled “jwt_signing_key”| Field | Type | Notes |
|---|---|---|
id | ulid | — |
kid | string | required, unique, ≤ 64 |
alg | string | required, enum EdDSA | ES256 |
publickey | text | required |
privatekey | text | required; kept out of logs, excluded from export, encrypted at rest |
active | boolean | Default 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.
user_otp and user_token
Section titled “user_otp and user_token”Both are declared, migrated and unreferenced by any code.
| Entity | Fields |
|---|---|
user_otp | id (ulid), userid (klid, required), provider (string, required ≤ 255), token (string, required), expiresat (timestamp, required), realm (string, required) |
user_token | id (ulid), userid (klid, required), ulid (ulid, required), flowtype (string, required ≤ 255), provider (string, required ≤ 255), refreshtoken (string, required), realm (string, required) |
Roles and groups
Section titled “Roles and groups”final: true, cdc: typeaudit. Mounted for admin CRUD.
| Field | Type | Constraints |
|---|---|---|
id | ulid | required, unique, immutable |
slug | string | required, unique |
displayname | string | required |
active | boolean | required, default true |
properties | object | {} |
usergroup and groupmembership
Section titled “usergroup and groupmembership”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.
| Entity | Field | Type | Constraints |
|---|---|---|---|
usergroup | id | ulid | required, unique, immutable |
name | string | required | |
visibility | groupvisibility | required — Private | Public | Hidden | |
parentid | ulid | — | |
groupmembership | id | ulid | required, unique, immutable |
userid | ulid | required | |
membershiptype | groupmembershiptype | Owner | Moderator | Member | |
groupid | ulid | required | |
starttime | timestamp | required | |
endtime | timestamp | required |
References: usergroup.id → usergroup.parentid (one-to-many) and usergroup.id → groupmembership.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.
Tenancy and the control plane
Section titled “Tenancy and the control plane”tenant
Section titled “tenant”The registry. final: true, scope: superadmin, cdc: typeaudit.
| Field | Type | Nullable | Constraints |
|---|---|---|---|
id | ulid | no | required, unique, immutable — ulid() |
parentid | ulid | yes | — |
slug | string | no | required, unique, ≤ 50 — the tenant key |
displayname | string | no | required, ≤ 255 |
namespace | string | yes | — |
domain | string | yes | — |
active | boolean | no | required, default true |
markedforseed | boolean | no | Set on register, cleared when provisioning completes |
meta | object | no | {} |
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.
superadmin_grant
Section titled “superadmin_grant”| Field | Type | Nullable | Constraints |
|---|---|---|---|
id | klid | no | required, unique, immutable — 'G'+ulid() |
superadmin_id | string | no | required, immutable |
tenant_id | string | no | required, immutable — the registry row id, not the tenant key |
reason | string | yes | ≤ 500 |
active | boolean | no | Default 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.
tenant_extensions
Section titled “tenant_extensions”The tenant’s own schema-overlay store. final: true.
| Field | Type | Nullable | Constraints |
|---|---|---|---|
id | ulid | no | required, unique, immutable |
path | string | no | required, unique — one overlay row per path |
content_type | string | yes | MIME type; application/x-yaml, application/yaml, text/yaml and text/x-yaml are read as schema overlays |
content_text | text | yes | — |
content_blob | bytes | yes | — |
<entity>_audit side tables
Section titled “<entity>_audit side tables”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.
| Column | Type | Notes |
|---|---|---|
id | TEXT PRIMARY KEY | Audit row ULID |
entity_id | TEXT NOT NULL | Logical id of the changed row |
field_name | TEXT | Empty for a delete |
old_value | TEXT | JSON-encoded pre-change value |
new_value | TEXT | JSON-encoded post-change value |
operation | TEXT NOT NULL | create | update | delete |
changed_at | TIMESTAMPTZ NOT NULL | Defaults to now() |
changed_by | TEXT | User 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.
user_eventlog and tenant_eventlog
Section titled “user_eventlog and tenant_eventlog”These look like the audit stores and are never written by the service. The real record is the _audit side tables above.
| Entity | Fields |
|---|---|
user_eventlog | id (ulid), userid (string), eventtype / attributetype / outcometype (int), message (string), context (object {}), meta (object {}), realm (string, required) |
tenant_eventlog | id (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.
Access rules
Section titled “Access rules”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.
| Entity | read | create | update | delete | unmask |
|---|---|---|---|---|---|
users | user.id != "" | admin, iam-service | admin, iam-service | admin, iam-service | iam-service |
userattribute, user_lock, usergroup, groupmembership, role, federation_request, trackedanonymoususer, iamagents, iambots, iamdelegations | admin, iam-service | admin, iam-service | admin, iam-service | admin, iam-service | — |
user_eventlog | admin, iam-service | iam-service | iam-service | iam-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_extensions | iam-service | iam-service | iam-service | iam-service | — |
tenant | iam-service, root, tenant-read, tenant-provision | iam-service, root, tenant-provision | iam-service, root, tenant-provision | iam-service, root | — |
tenant_eventlog | iam-service, root, tenant-read, tenant-provision | iam-service | iam-service | iam-service | — |
superadmin | iam-service, root, superadmin-admin | iam-service, root, superadmin-admin | iam-service, root, superadmin-admin | iam-service, root | iam-service |
superadmin_grant | iam-service, root, superadmin-admin, tenant-read, tenant-provision | iam-service, root, superadmin-admin | iam-service, root, superadmin-admin | iam-service, root, superadmin-admin | — |
Two entities declare row-level security:
| Entity | Rule | Effect |
|---|---|---|
users | ("admin" in user.roles || "iam-service" in user.roles) ? "" : user.id | A non-admin reads only their own row |
tenant | ("iam-service" in user.roles || "root" in user.roles) ? "" : user.grants | An 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.
Extending the schema
Section titled “Extending the schema”The schema a tenant runs is folded from three layers, in order:
| Layer | Source | May contribute |
|---|---|---|
| Service base | The embedded entity definitions, parsed once per process | — |
| Product | The product’s iam/ folder, delivered through the product configuration source | New entities, new fields on non-final entities, access tiers not named in access-lock: |
| Tenant | Rows in tenant_extensions | New 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.
What is sealed
Section titled “What is sealed”| Seal | Meaning | Entities |
|---|---|---|
final: true | No layer may contribute anything — no fields, no access | 22 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 rls | users |
access-lock: [actions] | A product may add fields and override every tier except actions | userattribute, user_lock, user_eventlog, federation_request |
| none | Fully open to the product layer | usergroup, groupmembership |
The four valid access tiers are services, actions, rls and fields.
The mechanism that works
Section titled “The mechanism that works”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: truePlace 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.
Contributions are dropped silently
Section titled “Contributions are dropped silently”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.
| Diagnostic | Cause |
|---|---|
override-applied | A layer legitimately replaced a value |
ignored-duplicate | The field name already exists on the base |
ignored-tenant-access | A tenant layer supplied an access: block |
ignored-locked-tier | A product tried to override an access-lock tier |
ignored-final-entity | A layer contributed to a final entity |
ignored-scope-change | A layer tried to move an entity to another plane |
ignored-shadow | A 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: trueAn 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.
What does not exist
Section titled “What does not exist”| Expected | Reality |
|---|---|
| An invitation entity or invite flow | Not implemented — no entity, no route, no code |
| Email or phone verification | Not implemented |
| Self-service registration | No signup route in the deployable service |
| A user-to-role join table | None; roles live in users.properties.roles |
A realm entity | Removed — realms are configuration, not rows |
A product entity | The product is a segment of the tenant key, not a row |
| Account lock or disable enforcement on login | users.locked and user_lock are inert on the password path |
principal_grant / a principal-type claim | Specified, 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.