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 | Table | Plane | Holds |
|---|---|---|---|
principal | iam_principal | tenant | The registry: one row per principal, its kind, and where its detail lives |
credentials | iam_principal_credential | tenant | Every way a principal authenticates, local or federated |
principal_grant | iam_principal_grant | tenant | Grants between principals, including delegation |
users | iam_principal_user | tenant | The base identity row |
iamagents | iam_principal_agent | tenant | Agent principals: the agent of an app connection |
iambots | iam_principal_bot | tenant | Bot principals with claims |
bot_member | iam_bot_member | tenant | Who belongs to a bot |
iamservices | iam_principal_service | tenant | Service principals |
iamdelegations | iam_principal_delegate | tenant | User-to-user delegation grants |
peer_actors | iam_peer_actor | tenant | Actors seen from another plane |
role | iam_role | tenant | Role definitions |
session | iam_session | tenant | Managed server-side sessions |
challenges | iam_auth_challenge | tenant | In-flight authentication challenges, of every kind |
user_refresh_token | iam_refresh_token | tenant + control | Rotating refresh-token families |
api_key | iam_api_key | tenant | Issued service API keys |
user_mfa_request | iam_user_mfa_request | tenant | TOTP enrolments |
user_magic_link | iam_user_magic_link | tenant | Magic-link codes |
password_reset_request | iam_password_reset_request | tenant | Password-reset codes |
oauthstate | iam_oauth_state | tenant | OAuth state and PKCE verifier |
providerconfigs | iam_provider_config | tenant | Identity-provider endpoints and settings |
webauthn_credential | iam_webauthn_credential | tenant | Registered passkeys |
webauthn_session | iam_webauthn_session | tenant | In-flight WebAuthn challenge |
jwt_signing_key | iam_token_signing_key | tenant + control | The per-tenant signing keyring |
impersonation_requests | iam_impersonation_request | tenant | Impersonation grants and their audit |
user_eventlog | iam_user_eventlog | tenant | Per-user auth events |
tenant_extensions | iam_tenant_extension | tenant | The tenant’s own schema overlay rows |
tenant | iam_tenant | control | The tenant registry |
tenant_eventlog | iam_tenant_eventlog | control | Tenant-level events |
superadmin | iam_staff | control | The operator roster |
superadmin_grant | iam_staff_grant | control | Operator to tenant grants |
Every table carries the iam_ prefix. Entity names are unchanged: they are what the REST path
and the access rules reference, and the prefix is on the storage underneath them. It exists because
the database schema encodes tenancy rather than the service, so two services sharing a database
would otherwise contend for names as ordinary as tenant and role.
The audit side table of an entity follows its table, so users audits to iam_principal_user_audit.
The principal registry
Section titled “The principal registry”principal records that an id exists, what kind of principal it is, and which entity holds its
detail. It holds no detail itself, so a product still brings its own population and the layering is
untouched. It is a registry rather than a base table: resolving what an id is becomes one read
instead of probing every derived entity in turn.
status on it is where a principal is enabled or locked.
credentials is every way a principal authenticates, local and federated alike, because that is one
question with one answer shape. A principal with a password, TOTP and two identity providers is four
rows, and “what can this principal log in with” is one query.
challenges is in-flight authentication state of every kind in one entity, rather than one table
per flow.
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 (sessionstatus) |
ID prefixes on klid columns: U user and operator · A agent · B bot · D delegation · G grant.
Identity
Section titled “Identity”principal
Section titled “principal”The registry. One row per principal of any kind, recording that the id exists, what kind it is, and which entity holds the detail.
| Field | Type | |
|---|---|---|
id | klid | required |
ptyp | string | required. The principal kind: user, agent, bot, service |
realm | string | required |
entity | string | required. Which entity holds this principal’s detail |
display_name | string | nullable |
status | string | required, defaults to active |
created_by | klid | nullable |
It holds no detail of its own, so a product still brings its own population and the layering is untouched. Resolving what an id is becomes one read rather than probing each derived entity.
credentials
Section titled “credentials”Every way a principal authenticates, local and federated alike. A principal with a password, TOTP and two identity providers is four rows.
| Field | Type | |
|---|---|---|
id | klid | required |
principal_id | klid | required |
kind | string | required. password, totp, oidc, webauthn |
provider_id | string | required |
subject | string | nullable. The provider’s own identifier |
material_ref | string | nullable. Where the secret lives, never the secret |
counter | integer | nullable |
email_at_link | string | nullable. The address at the time the credential was linked |
linked_on, last_used_at | timestamp | nullable |
active | boolean |
Unique on the provider and subject pair, so one federated identity cannot be linked twice.
principal_grant
Section titled “principal_grant”A grant from one principal to another, including delegation. Each
app connection has one: the user as grantor_id and
acting_for, the agent as grantee_id, the roles its scopes confer in roles, the scope names in
conditions.app_scopes, and the connection’s expiry. Revoking the connection sets revokedon,
revoked_by and active: false; the row stays.
| Field | Type | |
|---|---|---|
id | klid | required |
grantor_id, grantee_id | klid | required |
acting_for | klid | required |
acting_for_ptyp | string | required |
roles | array(string) | required |
tenant_id | string | required |
expireson | timestamp | required |
conditions | object | nullable |
spawn_subordinate | boolean | Whether the grantee may grant onward |
reason | string | nullable |
revokedon, revoked_by | nullable | |
active | boolean |
challenges
Section titled “challenges”In-flight authentication state of every kind, in one entity rather than one table per flow.
| Field | Type | |
|---|---|---|
id | klid | required |
kind | string | required. Which flow this challenge belongs to |
provider, subject_ref, realm | string | nullable |
secret_hash | string | nullable |
payload | object | nullable |
expireson | timestamp | required |
consumedon | timestamp | nullable. Set when the challenge is spent |
attempts | integer |
Indexed for lookup and for expiry, so the sweep of spent and expired rows is a range scan.
peer_actors
Section titled “peer_actors”Actors seen from another plane, with the block list that governs them.
| Field | Type | |
|---|---|---|
id | klid | required |
plane | string | required |
actor_sub | string | required |
display_hint | string | nullable |
first_seen, last_seen | timestamp | nullable |
blocked | boolean | |
blocked_reason | string | nullable |
blocked_by | klid | nullable |
Unique on the plane and actor pair.
bot_member
Section titled “bot_member”Who belongs to a bot.
| Field | Type | |
|---|---|---|
id | klid | required |
bot_id | klid | required |
member_id | klid | required |
member_ptyp | string | required |
granted_by | klid | required |
expireson | timestamp | nullable |
conditions | object | nullable |
active | boolean |
providerconfigs
Section titled “providerconfigs”Identity-provider endpoints and settings.
| Field | Type | |
|---|---|---|
id | klid | required |
provider | string | required, unique |
issuer, auth_url, token_url, jwks_url, userinfo_url | string | nullable |
client_id | string | nullable |
client_secret_ref | string | nullable. A reference to the secret, never the secret |
scopes | array(string) | nullable |
email_claim, subject_claim | string | nullable |
last_used_at | timestamp | nullable |
active | boolean |
iamservices
Section titled “iamservices”Service principals.
| Field | Type | |
|---|---|---|
id | klid | required |
name | string | required |
spiffe_id | string | nullable |
displayname | string | nullable |
properties, attributes | object | nullable |
active | boolean |
The base identity entity. access-lock: [actions, rls], scd: { strategy: 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: customer, scd: { strategy: 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.
iamagents
Section titled “iamagents”Agent principals. Each is the agent of an app connection,
acting for one user under one principal_grant. final: true with scd: { strategy: typeaudit }. The user an
agent acts for may read it; every write requires admin or iam-service.
| Field | Type | |
|---|---|---|
id | klid | 'A'+ulid() |
acting_for | klid | required, immutable. The user the agent acts for |
acting_for_ptyp | string | required, immutable |
grant_id | klid | required, immutable. The grant that confers its authority |
expireson | timestamp | required |
displayname | string | nullable, up to 200. The connection’s name |
tool_allowlist | array(string) | nullable. The tools of the connection’s scopes |
model_policy | object | nullable |
attributes | object | nullable. App connections record app.kind and the user’s app.realm |
active | boolean | default true; false once the connection is revoked |
iambots, iamdelegations
Section titled “iambots, iamdelegations”Non-human principals and delegation records. Both are final: true with scd: { strategy: typeaudit }, and both carry claims, which is why every action requires admin or iam-service. They are records of the principals you manage; issue credentials through the documented auth flows.
| Field | iambots | iamdelegations |
|---|---|---|
id | klid 'B'+ulid() | klid 'D'+ulid() |
name | string, required, 3–255, unique index with deletedon. no unique validation | — |
displayname | string ≤ 1024 | — |
userid | klid, required | klid, required |
touserid | — | klid, required |
notbefore / notafter | absent | timestamp, required |
active | boolean, required | boolean, required |
attributes | object {} | array(string) |
claims | object {} | object {} |
allowedservices | 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.
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 | — |
cept | 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; an app key carries kisai-app |
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 |
principal_id | klid | nullable, immutable. Set on an app key: the agent of its app connection |
name | string | nullable, up to 200. An app key’s connection name |
lastusedon | timestamp | nullable. An app key’s latest successful exchange |
The wire format handed to the caller once at issue time is <id>.<secret>, and kisak_<id>.<secret> for an app key.
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.
Roles and groups
Section titled “Roles and groups”final: true, scd: { strategy: 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 | {} |
Tenancy and the control plane
Section titled “Tenancy and the control plane”tenant
Section titled “tenant”The registry. final: true, scope: customer, scd: { strategy: 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”Sixteen entities declare scd: { strategy: typeaudit }: users, role, tenant, superadmin, superadmin_grant, principal, principal_grant, credentials, iamagents, iambots, iamdelegations, iamservices, bot_member, peer_actors, impersonation_requests and providerconfigs. 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 | The identity the change was performed under. The target user during an impersonation; empty for system writes |
actor | TEXT | Who performed it. The operator during an impersonation, otherwise the same as changed_by |
During an impersonation changed_by is the target user and actor 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 |
role, iambots, iamdelegations, peer_actors | admin, iam-service | admin, iam-service | admin, iam-service | admin, iam-service | — |
iamagents | admin, iam-service, the user it acts for | admin, iam-service | admin, iam-service | admin, iam-service | — |
principal | admin, iam-service, the principal itself | admin, iam-service | admin, iam-service | admin, iam-service | — |
principal_grant | admin, iam-service, grant-read | admin, iam-service | admin, iam-service | admin, iam-service | — |
user_eventlog | admin, iam-service | iam-service | iam-service | iam-service | — |
session, user_refresh_token, challenges, user_mfa_request, user_magic_link, password_reset_request, oauthstate, webauthn_credential, webauthn_session, api_key, jwt_signing_key, tenant_extensions, credentials | 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 | Every entity in the inventory except users |
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 | user_eventlog |
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 |
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.