BetterIAM

Schema

The tables, indexes, indexed lookup fields, record rules, and every collection Better IAM stores in your database, generated from the source.

Better IAM stores every record in one generic table, iam_records, as a JSON document tagged with its collection (the record kind, such as identities or sessions). There are no per-feature tables, so a new feature never needs a new table, and the schema is identical on PostgreSQL, SQLite, and libSQL apart from column types and index definitions. This page lists the tables, their indexes, the rules every record follows, and every collection the server writes.

Read it to plan capacity, write reporting queries, or review what Better IAM keeps. Never write to these tables directly: services enforce references between records inside serialized transactions, and a raw SQL write can leave a revoked session alive or break the audit chain.

Tables

iam_records

PostgreSQL
CREATE TABLE IF NOT EXISTS iam_records (
  collection TEXT COLLATE "C" NOT NULL,
  id         TEXT COLLATE "C" NOT NULL,
  tenant_id  TEXT COLLATE "C" NOT NULL,
  unique_key TEXT COLLATE "C",
  data       TEXT NOT NULL,
  PRIMARY KEY (collection, id),
  UNIQUE (collection, tenant_id, unique_key)
);

On SQLite and libSQL the same table uses plain TEXT columns, whose default BINARY collation also orders by code point.

ColumnHolds
collectionThe record kind. Together with id, the primary key.
idThe record ID, unique within the collection.
tenant_idThe owning tenant. It never changes after the record is created; put with another tenant fails with CONFLICT.
unique_keyAn optional natural key, unique within (collection, tenant_id), such as one email per tenant.
dataThe whole record as JSON text, including id, tenantId, and uniqueKey. PostgreSQL casts it to jsonb in queries.

Uniqueness that must hold across all tenants is expressed through the record ID instead: tenantAliases uses the alias itself as its ID, and domainOwners is keyed by the verified domain.

iam_schema_version and iam_migrations

iam_schema_version holds one row with version = 1; a database with another version is refused with SCHEMA_VERSION. iam_migrations holds one row per applied schema step (name, applied_at in epoch milliseconds). See Migrations.

Indexes

All index names start with iam_records_. Every B-tree index added after 0001_records ends in id, so one index both finds rows and returns them in ORDER BY id order.

IndexStepColumns or expression
iam_records_tenant0001_records(collection, tenant_id)
iam_records_tenant_id0002_query_indexes(collection, tenant_id, id)
iam_records_unique_key0002_query_indexes(collection, unique_key, id) where unique_key IS NOT NULL
iam_records_document0002_query_indexesPostgreSQL only: GIN over (data::jsonb) jsonb_path_ops
iam_records_field_{field}0002, 0005, 0006SQLite and libSQL only: (collection, json_extract(data, '$."{field}"'), id) where the field is not null, one per indexed field
iam_records_order_timestamp, iam_records_order_sequence0003_ordered_indexes(collection, tenant_id, {field}, id) where the field is a number (PostgreSQL) or not null (SQLite and libSQL)
iam_records_expiry_expiresAt, iam_records_expiry_deliveredAt, iam_records_expiry_failedAt0004_expiry_indexes(collection, {field}, id) where the field is a number (PostgreSQL) or not null (SQLite and libSQL)

Indexed fields

INDEXED_FIELDS from better-iam/core lists the high-cardinality fields that lookups filter on across tenants or inside large collections. On SQLite and libSQL each one has its own partial expression index; on PostgreSQL the single GIN document index serves them all. The list affects only speed, never results. LOOKUP_INDEX_STEPS maps each field to the step that indexes it.

FieldStepExample lookups
tokenHash0002_query_indexesSessions and trusted devices by hashed token.
identityId0002_query_indexesSessions, group memberships, passkeys, devices, and challenges of one identity.
email0002_query_indexesIdentities by email address.
parentId0002_query_indexesChild tenants of a tenant.
subjectId0002_query_indexesBindings and onboarding progress of a subject.
groupId0002_query_indexesMembers and bindings of a group.
roleId0002_query_indexesBindings and role sessions of a role.
credentialId0002_query_indexesPasskeys by WebAuthn credential ID.
originalIdentityId0002_query_indexesSessions by their original identity.
impersonatorSessionId0002_query_indexesImpersonation sessions, which store the administrator's session ID.
accountId0002_query_indexesOAuth artifacts, which store the account they were issued for.
clientId0002_query_indexesOAuth clients and artifacts by client.
uidHash0002_query_indexesOAuth interaction artifacts.
userCodeHash0002_query_indexesOAuth device-flow artifacts.
grantIdHash0002_query_indexesOAuth artifacts of one grant.
connectionId0002_query_indexesSAML records of one connection.
targetId0002_query_indexesOutbound provisioning links of one target.
streamId0002_query_indexesShared Signals deliveries of one stream.
jti0002_query_indexesShared Signals deliveries by token ID.
sourceSessionId0005_lookup_indexesRole and temporary sessions created from a session.
trustId0005_lookup_indexesSessions created through a trust.
fingerprint0006_protection_indexesData protection tokens by keyed value fingerprint.

Two more field lists drive ordered reads (findOrdered):

  • ORDERED_FIELDS: timestamp and sequence, indexed per tenant for audit paging by time and by chain sequence.
  • EXPIRY_FIELDS: expiresAt, deliveredAt, and failedAt, indexed per collection so the finds due records without scanning.

Record rules

Every adapter enforces the same rules through the shared RecordStore base. Breaking one fails with INVALID_RECORD before anything is written.

RuleLimit
Identifierscollection, id, tenantId, and uniqueKey are non-empty strings of at most 512 UTF-8 bytes, without control characters (U+0000 to U+001F) or unpaired surrogates.
ValuesJSON only: strings, finite numbers, booleans, null, plain objects, and arrays without undefined items. No class instances, symbols, or circular references.
Keys__proto__, constructor, and prototype are refused as property names.
DepthAt most 64 levels of nesting.
SizeThe serialized record is at most 1,048,576 characters.

Field values may hold any string, including U+0000; the PostgreSQL adapter stores such values in a reversible encoding (value encoding).

Collections

The lists below are taken from the server source. Every tenant-scoped collection is swept when a deleted tenant is purged (iam.purgeDeleted(), the purge CLI command), except where noted. better-iam doctor lists the collections that actually hold records in your database, with counts, under storage.collections.

Tenancy and hosts

CollectionHolds
tenantsThe tenant tree: the platform root and every tenant below it (organizations and their children).
tenantAliasesSign-in aliases (slugs); the alias is the record ID, so aliases are globally unique.
tenantDomainsDomains a tenant claims, with their verification state.
domainOwnersVerified domains, keyed by domain, so one domain belongs to one tenant.
tenantHostnamesCustom sign-in hostnames of a tenant.
hostnameOwnersCustom hostnames, keyed so one hostname belongs to one tenant.
ownerInvitationsPending invitations for a new organization's owner.
memberInvitationsPending invitations for members.

Identities and sessions

CollectionHolds
identitiesPeople, service accounts, and agents. Deleted identities stay as tombstones without email or secrets.
sessionsUser sessions, API keys, role sessions, session tokens, and delegated agent sessions, by token hash.
externalIdentitiesLinks from a federated subject (provider, issuer, subject) to an identity.
identityLinksLinked accounts: pairs of ordinary user identities, usually in different tenants. Not tenant-swept; links of purged identities are deleted by the purge.
trustsCross-tenant trust relationships for role assumption.
principalBoundariesPermission boundaries attached to identities.

Authentication

CollectionHolds
authMfaEnrolled authenticator (TOTP) factors, sealed with the deployment secret.
authPasskeysRegistered passkeys.
authDevicesTrusted devices.
authChallengesPending sign-in, MFA, and verification challenges.
authSignInsPer-person sign-in bookkeeping, keyed by identity ID: the last sign-in and failed attempts since, for "last sign-in" notices. Never used for authorization.
authBlocksNetwork blocks.
authRateLimitsRate-limit windows.
passwordHistoryPrevious password hashes for reuse checks.
oidcProvidersOIDC providers trusted for web identity federation.
webIdentityReplaysRedeemed web identity tokens, kept to refuse replays.

Access model and catalog

CollectionHolds
grantAuthoritiesDelegated grant authorities and their ceilings.
policies, policyVersionsStored policies and their version history.
rolesRoles.
bindingsRole bindings to identities and groups.
bindingActivationsActivations of eligible (just-in-time) bindings.
accessRequestsAccess requests and their decisions.
groups, groupMembersGroups and their memberships.
relationshipsRelationship tuples for relationship-based access.
actions, resourceTypes, resourcesThe tenant-defined catalog and managed resources.
classificationSchemes, clearances, resourceLabelsSecurity clearance schemes, people's clearances, and resource labels.

Governance

CollectionHolds
accessPackages, packageAssignments, packageRequests, packageRuleIssuesAccess packages, their assignments and requests, and automatic-assignment rule problems.
expiryReminderMarksWhich expiry reminders were already sent.
analysisSuppressionsSuppressed access-analysis findings.
certificationCampaigns, certificationItemsAccess certification campaigns and their review items.
sodRulesSeparation-of-duties rules.
accessInvariantsAccess invariants.
accessUsage, accessUsageTrackingRecorded permission usage and its tracking state.
agreements, agreementAcceptancesTerms people must accept, and their acceptances.
workflows, workflowRuns, workflowSubjectsLifecycle workflow definitions, runs, and mover/leaver baselines.
complianceControls, complianceExceptions, complianceResults, complianceRuns, complianceCheckpointsThe compliance center.
privacyPurposes, privacyConsents, privacyConsentEvents, privacyRequests, privacyExports, privacyHolds, privacyRestrictions, privacySettingsConsent, data-subject requests and exports, legal holds, and restrictions.
applications, appAssignments, appLaunchesThe application catalog, assignments, and launches.

Organizations, product features, and billing

CollectionHolds
teams, teamMembers, teamJoinRequests, teamReviews, teamReviewItemsTeams, memberships, join requests, and team access reviews.
departments, departmentMembersThe department chart and its members.
featureFlags, featureTargetsFeature flags and their targets and overrides.
onboardingFlows, onboardingSettings, onboardingProgressOnboarding checklists and progress.
guestInvitations, guestAccounts, crossTenantAccessB2B guest invitations, guest accounts, and cross-tenant access settings.
licenseProducts, licensePools, licenseAssignments, licenseSeats, licenseSettingsLicense management.
quotaPlans, quotaAssignments, quotaCountersAPI usage plans, assignments, and window counters.
billingMeters, billingPrices, billingUsage, billingRollups, billingBudgets, billingBudgetAlerts, billingCredits, billingProfiles, billingStatements, billingTerms, billingInvoiceItems, billingCreditNotes, billingPlans, billingSubscriptions, billingCoupons, billingDiscountsMeters, prices, usage and roll-ups, budgets, credits, invoices, plans, subscriptions, and coupons.

AI agents and inference

CollectionHolds
delegations, delegationConfirmations, delegationTokensDelegations people give agents, per-action confirmations, and issued delegation tokens.
agentCardsThe latest attested A2A card of each agent.
inferenceProviders, inferenceModels, inferenceBudgets, inferenceCounters, inferenceUsage, inferenceTickets, inferenceResponsesInference gateway providers, models, budgets and counters, usage, tickets, and response ownership.

Keys, secrets, and certificates

CollectionHolds
kmsKeys, kmsKeyVersions, kmsAliases, kmsGrantsManaged keys, their sealed versions, aliases, and grants.
pkiAuthorities, pkiCertificatesPrivate certificate authorities and issued certificates.
protectionProfiles, protectionTokensData protection (tokenization) profiles and tokens.
vaultSecrets, vaultVersions, vaultLeases, vaultAccessVault secrets, sealed versions, check-outs and leases, and access records.
sshSettings, sshAuthorities, sshHosts, sshCertificatesThe SSH certificate authority.

Security

CollectionHolds
threatDetections, threatIncidents, threatNotes, identityRisk, threatBaselines, threatCursors, threatSettings, threatPlaybooks, threatResponsesThreat detection and response.
registeredDevices, deviceKeys, deviceEnrollments, deviceIntegrations, deviceSettingsDevice posture.

Federation protocols

CollectionHolds
oauthClients, oauthArtifacts, oauthGrantSessions, oauthLoginStates, oauthRegistrationTokensThe OAuth/OIDC provider: clients, codes and tokens (hashed), grant sessions, login states, and registration tokens.
oauthRevokedGrantsRevoked OAuth grants, by grant hash. Stored under the internal tenant __protocol__, so not tenant-swept.
samlConnections, samlRequests, samlRelays, samlAssertionsSAML connections, pending requests, relay states, and assertion replay records.
scimConnections, scimUsers, scimGroupsInbound SCIM connections and their user and group mappings.
provisioningTargets, provisioningLinks, provisioningGroupLinksOutbound SCIM targets and their links.
ssfStreams, ssfDeliveriesShared Signals transmitter streams and deliveries.
signalSources, signalEventsShared Signals receiver transmitters and received events.
ldapSettings, ldapBasesThe LDAP gateway's settings and base DN index.
vcIssuerKeys, vcCredentialTypes, vcIssued, vcStatusLists, vcOffers, vcNoncesVerifiable credential issuance.

Delivery and audit

CollectionHolds
outboxQueued emails, SMS messages, and webhook deliveries, sealed with the deployment secret.
webhooksWebhook subscriptions.
auditAudit events, hash-chained per tenant. Kept when a tenant is purged; removed only by audit-prune.
auditChainsThe head of each tenant's audit chain, keyed by tenant ID. Not tenant-swept.
auditHooksCommitted events waiting for plugin hooks, subscribers, and events.onEvent. Not tenant-swept; removed by the sweep after dispatch.
auditArchiveCursorsHow far each tenant's chain has been copied to the audit archive. Not tenant-swept.

Plugins

Plugins keep their records in collections of their own, through the same IamStore, and remove them in their purge callback. The reference @better-iam/projects plugin stores tenant-scoped records in projects.

What the retention sweep deletes

iam.sweepExpired() (the sweep CLI command) deletes records the system already treats as dead, oldest first, in short batches. Pending deliveries are never swept.

CollectionDeleted when
sessionsPast expiresAt, for user, role, session-token, and delegated sessions. API keys stay.
authDevices, relationships, webIdentityReplays, oauthLoginStates, samlRequests, samlRelays, samlAssertionsPast expiresAt.
oauthArtifactsPast expiresAt; grants only 31 days after expiry.
inferenceUsage, inferenceCounters, inferenceTickets, inferenceResponses, delegationConfirmations, delegationTokens, agentCards, teamMembers, vaultAccess, quotaCounters, privacyExports, workflowRuns, complianceResults, complianceRuns, appAssignments, sshCertificates, vcIssued, vcOffers, vcNonces, deviceEnrollments, signalEvents, licensePoolsPast the expiresAt each feature stamps on its records.
complianceExceptions400 days after expiresAt.
guestInvitations90 days after expiresAt, once no longer pending.
outboxDelivered or abandoned longer ago than the delivery retention (30 days by default).
ssfDeliveriesFailed longer ago than the delivery retention.
auditHooksDispatched, after a five-minute grace period.

Everything else (invitations, access requests, usage records, SCIM connections) is history or can be renewed, so the sweep never deletes it by age. See Scheduled jobs.

Read the schema at runtime

IamStore.describe() (and doctor) report the schema version, applied migrations, record counts per collection, and adapter settings:

scripts/describe.ts
import { iam } from '../iam';

const description = await iam.store.describe?.();
for (const { name, records } of description?.collections ?? []) console.log(name, records);
await iam.store.close();
{
  "adapter": "postgres",
  "schemaVersion": 1,
  "migrations": [{ "name": "0001_records", "appliedAt": 1767225600000 }],
  "collections": [{ "name": "identities", "records": 1204 }],
  "settings": { "serverVersion": "17.2", "synchronousCommit": "on", "sizeBytes": 18874368, "lockTimeoutMs": 5000 }
}

describe() is optional in the IamStore contract; a custom adapter without it makes doctor report storage-undescribed.

Next steps

Was this page helpful?

Better IAM is created by Sean Filimon

Last updated

On this page