Tenancy and limits
Recipes for SaaS plan limits and usage metering, bulk onboarding with SCIM attributes, migrating from another login system, and libSQL or Turso storage.
These recipes cover running Better IAM as the identity layer of a multi-tenant SaaS product: enforcing what each
plan includes, bringing whole teams in at once, and choosing where the data lives. credential is the caller's
credential ({ token } or { headers }).
Plan limits and usage
The problem: your pricing plans include a number of seats, webhooks, or roles, and the limit has to hold on every path that creates a record, not only in your own signup form.
The solution: set plan limits on the with tenants.setLimits. They are checked
inside every creation transaction, and tenants.usage reports the current counts for dashboards and billing.
await iam.api.tenants.setLimits(rootCredential, {
tenantId,
limits: { identities: 25, webhooks: 5 },
});
const usage = await iam.api.tenants.usage(credential, { tenantId });
// usage: identities, mfaEnrolled, serviceAccounts, groups, roles, policies, resources,
// relationships, webhooks, activeSessions, and the limitsCreation past a limit fails with LIMIT_EXCEEDED on every path, including invitation acceptance, SCIM, and bulk
creation.
- Only root administrators set limits, and each change is audited as
tenant:limits.limits: nullremoves them. - Limits cover members (
identities),serviceAccounts,groups,roles,policies, registeredresources, andwebhooks. Deleted tombstones do not count. - The check also covers self-registration and federation, and invitations accepted after the limit was reached.
tenants.usagereports the current counts, how many members enrolled MFA, the active sessions, and the limits, for dashboards and metering.- To give every new organization a default plan, set
tenantDefaults.limitsin the configuration. It is stamped on every tenant thattenants.createcreates.
Bulk onboarding and directory attributes
The problem: a new customer arrives with a spreadsheet of fifty people, or with an identity provider that should keep their directory in sync. Your policies need facts about those people, such as each person's department.
The solution: create people in bulk, with attributes and roles, using identities.createMany. For a directory
that changes, let provision people and map the enterprise directory into the same
declared attributes with mapAttributes.
await iam.api.identities.createMany(credential, {
tenantId,
identities: rows.map((row) => ({
email: row.email,
name: row.name,
attributes: { department: row.department },
roleIds: [editor.id],
})),
});import { createScimService } from 'better-iam/scim';
// SCIM: map the enterprise extension to declared identity attributes.
const scim = createScimService({
...iam.protocolHost,
mapAttributes: ({ title, enterprise }) => ({
...(title ? { title } : {}),
...(typeof enterprise?.department === 'string' ? { department: enterprise.department } : {}),
}),
});
iam.useProtocol(scim);identities.createManycreates up to 100 identities in one transaction, applying attributes, roles, and groups under the caller's authority. Plan limits and password rules apply as for single creation.- Attributes must be declared in
permissions.identityAttributes. They reach policies asprincipal.departmentand so on. - SCIM stores the enterprise user extension (
department,costCenter,manager, and the rest) as provisioned.mapAttributesturns it into declared attributes, which the host validates, so the directory drives policy conditions. The extension's manager also becomes the person's manager for approvals.
See SCIM and Tenants and identities.
Move people over from another system
The problem: you are replacing an existing login system, either your own users table or a hosted provider, and the people in it must keep their organization and their access without signing up again.
The solution: recreate the organizations first, import people in batches with the old user ID kept as an attribute, then let each group of people in the way that matches how they sign in today. Credentials themselves do not move: no API accepts a password hash, and passkeys and authenticator apps are registered with the system that issued them. What moves is who people are, where they belong, and what they may do.
Recreate organizations
Create one per organization with tenants.create, which emails the organization's
owner an invitation. A new tenant stays pending until that owner accepts, and members cannot be created in a
pending tenant, so send these invitations first and import each organization once it is active. Create the roles
its people need with roles.create, or apply them from
configuration as code.
Import people in batches
Declare an attribute for the old ID, then create people 100 at a time. Results come back in the order you sent them, so you can record which new identity replaced which old user.
export const iam = betterIam({
// ...
permissions: { identityAttributes: { legacyId: 'string' } },
});for (let start = 0; start < users.length; start += 100) {
const batch = users.slice(start, start + 100);
const { identities } = await iam.api.identities.createMany(credential, {
tenantId,
identities: batch.map((user) => ({
email: user.email,
name: user.name,
attributes: { legacyId: user.id },
roleIds: [user.isAdmin ? adminRoleId : memberRoleId],
})),
});
// identities[i] replaces batch[i]: keep the mapping for your own tables.
await saveMapping(batch.map((user, i) => [user.id, identities[i]!.id]));
}The caller needs iam:identities:create in the tenant and, to hand out roles, the right to grant each one and an
active grant authority there; see createMany. Each batch is one
transaction: if any entry fails, nobody in that batch is created, so a script that stops can resume at the batch
that failed. A batch that contains an address already imported fails with IDENTITY_EXISTS,
so skip people you have already mapped when you re-run.
Let people in
Imported accounts are active, have no password, and have an unverified email. Choose how each group signs in:
| People who today | Do this |
|---|---|
| Sign in with a password | Turn on magic links (passwordlessEmail), so they sign in with their address and the address becomes verified; or send each a reset link with identities.requestPasswordReset. |
| Sign in with Google, GitHub, Microsoft, or their company's identity provider | Do not import them. Connect the provider and let their first sign-in create the account with a verified email. |
| Are managed by their company's directory | Let the directory create and update them through SCIM. |
Two traps
The public "forgot password" flow sends nothing to an unverified address, so imported people cannot reset a
password on their own until they have signed in once with a magic link or an administrator's reset link.
And an imported account blocks federated sign-in for the same address: accounts are never merged by email, so
the first sign-in through a provider fails with ACCOUNT_LINK_REQUIRED until the person signs in another way
and links the provider.
Switch over
Sessions from the old system do not carry over, so everyone signs in once after the switch. Before you announce
it, compare tenants.usage for each tenant with the old system's counts, and spot-check a few people with
policies.effectiveActions to confirm their roles came
across.
See Tenants and identities and sign-in methods.
Store data in libSQL or Turso
The problem: you want SQLite's simplicity, but the database must be reachable from several regions or serverless instances, or you want a local replica close to the application.
The solution: use the libSQL . One set of options covers local files, encrypted files, embedded replicas, and remote Turso or sqld databases.
import { betterIam } from 'better-iam';
import { libsqlAdapter } from 'better-iam/adapter-libsql';
const iam = betterIam({
// ...
database: libsqlAdapter({
url: 'libsql://name-org.turso.io',
authToken: process.env.TURSO_AUTH_TOKEN,
}),
});urlalso acceptsfile:./iam.db, a plain path,:memory:, andhttps:orwss:URLs.syncUrlwith a local file makes an embedded replica, andencryptionKeyencrypts a local file.- Remote servers queue write transactions themselves. Locally, transactions run one at a time and take the writer lock before any read.
- Local libSQL writes cost more than the SQLite adapter's, because
@libsql/clientcompiles each statement on every call.
See Storage adapters for every option, and for moving an existing deployment with
store-copy.
Next steps
Better IAM is created by Sean Filimon
Last updated
Support and privacy
Recipes for letting support staff view the product as a member, exporting everything stored about a person, and rendering the outbox's emails.
Access lifecycle
Recipes for just-in-time elevation with approvals, scheduled deactivation for contractors, API key hygiene, configuration as code, and offboarding.