CLI
The better-iam command line: what each of its 47 commands does, when to run it, and its flags.
The better-iam command runs the work that does not belong in a web request: creating and upgrading the schema,
creating the first administrator, health checks, the scheduled jobs that deliver, expire, remind, reconcile, and clean
up, audit verification and archiving, configuration as code, and moving a deployment between databases. It is the
bin of @better-iam/cli, and runCli(argv) from the same package (also exported as better-iam/cli) runs a
command from your own scripts.
Every command that touches the deployment loads your application configuration: --config, else
BETTER_IAM_CONFIG, else the nearest better-iam.config.mjs (or .js, .ts, .mts, .cjs) in the working
directory or a parent, else BETTER_IAM_DATABASE_URL and BETTER_IAM_SECRET with no file at all. The file is a module
whose default export is the options you pass to betterIam(), a function (sync or async) that returns them and
receives { command, env, cwd }, or an instance you already created. It may also export cli = { defaults } (flag
defaults per command) and commands (project commands built with defineCommand). It runs
as JavaScript with access to your database and secrets, so point it only at trusted files, and give the CLI the same
environment (BETTER_IAM_SECRET, database location, BETTER_IAM_PREVIOUS_SECRETS) as your application.
Commands come in three kinds:
- Deployment operations (
migrate,bootstrap,recover-root,doctor,outbox,purge,sweep,digest,remind,reconcile,close-certifications,monitor-invariants,audit-verify,audit-export,audit-prune,audit-archive,rotate-secrets, and thestore-*commands) need no credential. They work on storage with the deployment's authority, and the ones that change access record audit events asdeployment-operator. Whoever can run them with your configuration holds the database and the secret, so protect that configuration like a root credential. - Member commands (
config-export,config-plan,config-apply,analyze,report,mine-roles,check-invariants,whoami,api,can,explain, andwho-can) act as the session token or API key inBETTER_IAM_TOKEN, or the session saved byloginwhen it is unset. They run in process through the configuration or against a running server with--url(BETTER_IAM_URL), and read--tenantfromBETTER_IAM_TENANTor the saved session. They are authorized and audited exactly like the same call from the console, so use the API key of a service account whose role holds only the permissions the command needs. - Offline commands (
init,secret,config-validate,audit-verify-archive,profiles, andcompletion) touch no database.
Results are printed as indented JSON on standard output (purge, outbox, audit-prune, and audit-export keep
their one-line result); --format compact prints one line, --format json indents, --format table aligned
columns, and --query PATH (summary.create, findings[].kind) only part of the result. Every flag also takes the
--flag=value form, and better-iam help <command> lists a command's flags with their defaults and environment
variables. A failure prints CODE: message on standard error, often followed by a Hint: line naming the next step
(a generic message for errors that are neither Better IAM nor system errors; BETTER_IAM_DEBUG=1 shows them), and
exits with status 2 for a command-line mistake and 1 otherwise.
Unknown commands fail with INVALID_COMMAND, flags a command does not take with INVALID_ARGUMENT, and member
commands without a token with MISSING_ENV. Several commands also exit 1 on purpose when they find something, for CI
and alerting: doctor --strict (DOCTOR_FINDINGS), config-plan --fail-on-drift (CONFIG_DRIFT),
analyze --fail-on (FINDINGS), check-invariants --fail-on-broken (INVARIANTS_BROKEN),
reconcile --fail-on-attention (RECONCILE_ATTENTION), audit-verify (AUDIT_CHAIN_BROKEN), audit-archive
(AUDIT_ARCHIVE_FAILED), audit-verify-archive (AUDIT_ARCHIVE_INVALID), rotate-secrets
(UNREADABLE_SECRETS), config-validate --strict (CONFIG_WARNINGS), and can (ACCESS_DENIED). Each of them
prints its JSON result first, so the job log keeps the details.
npx better-iam migrateSecrets never go on the command line
Bootstrap and recovery read BETTER_IAM_ROOT_EMAIL, BETTER_IAM_ROOT_NAME, BETTER_IAM_ROOT_PASSWORD from the environment, and login reads the password from BETTER_IAM_PASSWORD or a hidden prompt. Commands that act as a member (configuration, analysis, reports, api, can) use the session token or API key in BETTER_IAM_TOKEN, or the session saved by login, in process or against --url, and are authorized and audited like console operations.
Scheduling the job commands
Better IAM starts no background work of its own. Messages wait in the outbox, expired access waits for the purge,
and package rules wait for reconciliation until something runs the matching command, or the matching instance
function from a worker in your application (see Scheduled jobs). Every job below is safe to
rerun and to overlap with itself, and doctor reports the ones that have stopped running.
| Command | Cadence | Why |
|---|---|---|
outbox | Every minute | Delivers email, SMS, and webhooks. doctor warns when messages wait more than 15 minutes. |
audit-archive | Every few minutes, at least hourly | Keeps the independent audit copy current. doctor warns about unarchived events older than a day. |
reconcile | Every 15 minutes, after purge | Rule-based access packages only see SCIM, invitation, attribute, and group changes through it. |
purge | Hourly, at least daily | Ends expired access and removes deleted tenants. doctor warns when expired records are a day old. |
sweep | Hourly or daily, beside purge | Stops storage growing with traffic. doctor warns about records due for more than two days. |
monitor-invariants | Hourly, and after configuration changes | Records invariant:broken and invariant:restored for webhooks to alert on. |
close-certifications | Hourly or daily | Applies auto-closing certification campaigns once they are due. |
digest, remind, then outbox | Daily | Emails owners and people about access that ends soon. |
audit-prune | Per your retention policy, after archiving | Deletes each tenant's old audit events. |
report, analyze --fail-on | Nightly | Member jobs for a ticket, chat channel, or alert. |
mine-roles | Weekly | A role-mining snapshot for access reviews. |
config-plan --fail-on-drift, check-invariants --fail-on-broken | Every deploy, and nightly | CI gates against drift and broken guardrails. |
doctor --strict | After each deploy, and as a health check | Fails on any error or warning finding. |
CONFIG=/etc/better-iam/better-iam.config.mjs
# Every minute: deliver email, SMS, and webhooks.
* * * * * better-iam outbox --config $CONFIG
# Every 5 minutes: continuous audit archiving.
*/5 * * * * better-iam audit-archive --config $CONFIG
# Hourly: expire, sweep, then apply package rules; reconcile again every quarter hour.
0 * * * * better-iam purge --config $CONFIG && better-iam sweep --config $CONFIG && better-iam reconcile --config $CONFIG --fail-on-attention
15,30,45 * * * * better-iam reconcile --config $CONFIG --fail-on-attention
# Hourly: guardrails and due certification campaigns.
30 * * * * better-iam monitor-invariants --config $CONFIG && better-iam close-certifications --config $CONFIG
# Daily: owner digest and personal reminders, then deliver them.
0 7 * * * better-iam digest --config $CONFIG && better-iam remind --config $CONFIG && better-iam outbox --config $CONFIGIf your application registers subscribers with iam.events.subscribe, run the outbox step in that process instead
(iam.auth.dispatchOutbox() and iam.events.dispatch() every minute): the outbox command also dispatches queued
audit hooks, and the events it dispatches never reach subscribers that live in another process.
Common flags
Commands that load the configuration take --config; commands that act as a member also take --url and --profile; every command that prints JSON takes --format and --query. Every flag also accepts the --flag=value form, and better-iam help <command> prints the flags of the installed version.
| Flag | Value | Description |
|---|---|---|
--config | PATH | Where to write the configuration |
--format | FORMAT | Output as json (indented, default), compact (one line), or table |
--query | PATH | Print only part of the result, e.g. summary.create or findings[].kind (strings print raw) |
--url | URL | Call a running IAM server (https://host or https://host/api/iam) instead of loading the configuration (env BETTER_IAM_URL) |
--profile | NAME | Saved session to act as (see login); ignored while BETTER_IAM_TOKEN is set (env BETTER_IAM_PROFILE) |
Environment variables
| Variable | Meaning |
|---|---|
BETTER_IAM_CONFIG | Configuration module, like --config |
BETTER_IAM_TOKEN | Session or API key token commands act as |
BETTER_IAM_URL | IAM server token commands call, like --url |
BETTER_IAM_PROFILE | Saved session to use, like --profile |
BETTER_IAM_TENANT | Default --tenant for token commands |
BETTER_IAM_CREDENTIALS | File saved sessions live in (default ~/.config/better-iam/credentials.json, %APPDATA% on Windows) |
BETTER_IAM_ROOT_EMAIL | bootstrap / recover-root: the root administrator (with BETTER_IAM_ROOT_NAME, BETTER_IAM_ROOT_PASSWORD) |
BETTER_IAM_ROOT_NAME | bootstrap / recover-root: display name |
BETTER_IAM_ROOT_PASSWORD | bootstrap / recover-root: password (never a flag) |
BETTER_IAM_PASSWORD | login: the password, instead of the prompt |
BETTER_IAM_MFA_CODE | login: the authenticator code, instead of the prompt |
BETTER_IAM_DATABASE_URL | Without a configuration file: the database (postgres://, sqlite:PATH, libsql://), with BETTER_IAM_SECRET |
BETTER_IAM_SECRET | Without a configuration file: the deployment secret |
Commands
| Command | What it does |
|---|---|
init | Writes a starter better-iam.config.mjs for SQLite, PostgreSQL, or libSQL. |
migrate | Creates or upgrades the database schema and applies plugin migrations and one-time data upgrades. |
bootstrap | Creates the root tenant and the first root administrator of a new installation. |
recover-root | Creates an additional root administrator when no one can sign in as root any more. |
doctor | Prints the deployment's health: storage details, audit-chain totals, and configuration and job findings. |
secret | Prints a new random value for BETTER_IAM_SECRET. |
outbox | Delivers pending email, SMS, and webhook messages, then dispatches queued audit hooks. |
purge | Runs the retention worker: expires ended access and removes tenants deleted more than N days ago. |
sweep | Deletes expired sessions, devices, relationship tuples, OAuth and SAML artifacts, and old deliveries in short batches. |
digest | Emails each organization's owners its access report when there is something to report. |
remind | Emails each person whose access ends within N days one reminder listing it. |
reconcile | Applies access-package rules (birthright access): people who match a rule receive the package, and automatic holders who stopped matching lose it. |
close-certifications | Closes every auto-closing certification campaign whose due date has passed and applies its decisions. |
monitor-invariants | Evaluates the invariants of every organization and records an audit event whenever one breaks or recovers. |
rotate-secrets | Re-seals authenticator secrets, webhook secrets, and pending deliveries with the current deployment secret. |
billing-close | Issues billing statements for a month that has ended and deletes raw usage events past their retention. |
billing-reminders | Reminds billing contacts of unpaid invoices before and after their due date. |
billing-alerts | Checks every spend budget and sends alerts for thresholds reached and projections past the budget. |
billing-anomalies | Checks every billing account for spend spikes and alerts on each once. |
billing-seats | Records one seat for every active person of every tenant a seats meter reaches, once per day. |
spend | Prints the spend of a tenant and the tenants below it for a month. |
audit-verify | Recomputes one tenant's audit hash chain straight from storage and fails when it does not verify. |
audit-export | Writes one tenant's audit chain to a new JSON Lines file for archiving or outside analysis. |
audit-prune | Deletes one tenant's audit events older than N days and appends a checkpoint so the rest of the chain still verifies. |
audit-archive | Copies every tenant's new audit events, verified and in chain order, to the configured auditArchive. |
audit-verify-archive | Verifies one tenant's archived audit chain from the archive files alone, without the database. |
store-export | Writes every record of the database to a JSON Lines snapshot file. |
store-import | Loads a snapshot into an empty database in one transaction. |
store-copy | Copies the configured database into the empty database of another configuration in one step, for example from SQLite to PostgreSQL. |
config-export | Writes a tenant's access model as a JSON configuration document that config-plan and config-apply accept. |
config-plan | Shows the creates, updates, and deletes that applying a configuration file would make, without changing anything. |
config-apply | Applies a configuration file to a tenant in one transaction. |
config-validate | Checks a tenant configuration file offline, without a database or a token. |
analyze | Prints a tenant's access-analysis findings as JSON and can fail when serious ones exist. |
report | Prints a tenant's access report: what ends soon, unused API keys, live elevations, and pending requests. |
mine-roles | Prints role-mining suggestions and peer outliers for a tenant as JSON. |
check-invariants | Evaluates a tenant's access invariants as a member and can fail the build when one is broken. |
whoami | Prints who the credential in BETTER_IAM_TOKEN acts as, and fails when it is no longer valid. |
can | Checks whether the session or API key may perform an action on a resource. |
explain | Shows why another identity would be allowed or denied an action, without signing in as them. |
who-can | Lists every active identity that could perform an action on a resource, with the reason. |
api | Calls any method of the HTTP API as the session or API key and prints the result. |
login | Signs in once and saves the session, so later member commands need no BETTER_IAM_TOKEN. |
logout | Signs a saved session out on the server and forgets it. |
profiles | Lists the sessions saved by login, or chooses or removes one. |
token | Prints the token member commands would act as, for other tools and scripts. |
completion | Prints a shell completion script for every command, flag, and fixed flag value. |
init
Writes a starter better-iam.config.mjs for SQLite, PostgreSQL, or libSQL.
better-iam init [--config better-iam.config.mjs] [--database sqlite|postgres|libsql] [--typescript]- When: once, when you add Better IAM to a project.
- Needs: nothing. It touches no database.
- Fails with:
CONFIG_EXISTSwhen the file already exists (it never overwrites one);INVALID_ARGUMENTfor a--databaseother thansqlite,postgres, orlibsql.
Flags:
--config PATHsets where the file is written (defaultbetter-iam.config.mjs, orbetter-iam.config.tswith--typescript).--database sqlite|postgres|libsqlpicks the storage adapter (defaultsqlite).--typescriptwrites a typedbetter-iam.config.ts(loading it needs Node.js 22.18 or later).
The generated file is a defineConfig factory, so importing it opens no database; the CLI calls it with
{ command, env, cwd }, and your server uses betterIam(await configOptions(config)). It reads its settings from the
environment: BETTER_IAM_SECRET, BETTER_IAM_BASE_URL (default http://localhost:3000),
BETTER_IAM_PREVIOUS_SECRETS (comma-separated, used during a secret rotation), and the database location:
BETTER_IAM_DATABASE for SQLite (default ./better-iam.db), BETTER_IAM_DATABASE_URL and
BETTER_IAM_DATABASE_TOKEN for libSQL, or DATABASE_URL for PostgreSQL. It starts with sign-up disabled, the
catalog permission mode, an example resource type, a cli export with flag defaults, and an empty commands list for
project commands. Edit it, add a sendEmail callback, set BETTER_IAM_SECRET to a random value of at least 32
characters (better-iam secret prints one), then run migrate.
better-iam init --database postgres --config better-iam.config.mjs| Flag | Value | Required | Description |
|---|---|---|---|
--database | sqlite|postgres|libsql | no | Database adapter to configure (default sqlite) |
--typescript | — | no | Write a typed better-iam.config.ts |
migrate
Creates or upgrades the database schema and applies plugin migrations and one-time data upgrades.
better-iam migrate --config better-iam.config.mjs- When: on every deploy, before the new version starts serving, and once before
bootstrap. - Needs: the database the configuration points at. No credential.
- Calls:
iam.initialize().
It is idempotent, and instances migrating at the same time wait for one another instead of failing. Upgrades that
add indexes (0002_query_indexes, 0004_expiry_indexes) build them inside the migration transaction, which blocks
IAM writes on large tables while it runs, and the first run after upgrading to the chained audit log backfills it in
one transaction: run those upgrades in a maintenance window. store-import and store-copy apply only the core
schema, so run migrate after them for plugin migrations. It prints Database and plugin migrations applied.
better-iam migrate --config /etc/better-iam/better-iam.config.mjsbootstrap
Creates the root tenant and the first root administrator of a new installation.
better-iam bootstrap --config better-iam.config.mjs- When: once, after the first
migrate. - Needs:
BETTER_IAM_ROOT_EMAILandBETTER_IAM_ROOT_PASSWORD(at least 12 characters and accepted by your password policy);BETTER_IAM_ROOT_NAMEis optional (defaultRoot administrator). - Fails with:
MISSING_ENVwithout the email or password;ALREADY_INITIALIZEDwhen a root tenant exists;WEAK_PASSWORDorBREACHED_PASSWORDfor a password the policy refuses. - Calls:
iam.bootstrap(), audited asroot:bootstrap.
Secrets come from the environment only, never from arguments, so they stay out of shell history and process lists.
It prints the new root tenant, the administrator's public identity, and mfaEnrollmentRequired: true: root
administrators always need MFA, so the first sign-in enrolls a factor before the account can do anything. Keep the
tenant ID. doctor reports not-bootstrapped until this command has run.
export BETTER_IAM_ROOT_EMAIL=platform-admin@example.com
export BETTER_IAM_ROOT_PASSWORD="$(cat /run/secrets/better-iam-root-password)"
better-iam bootstrap --config better-iam.config.mjsrecover-root
Creates an additional root administrator when no one can sign in as root any more.
better-iam recover-root --config better-iam.config.mjs- When: during an incident, from a trusted machine with the production configuration.
- Needs:
BETTER_IAM_ROOT_EMAIL(an address that is not in the root tenant yet),BETTER_IAM_ROOT_PASSWORD, and optionallyBETTER_IAM_ROOT_NAME. - Fails with:
MISSING_ENV;NOT_INITIALIZEDbeforebootstraphas run;IDENTITY_EXISTSwhen the email already belongs to an identity in the root tenant;WEAK_PASSWORDorBREACHED_PASSWORD. - Calls:
iam.recoverRoot(), audited asroot:recover.
It does not reset an existing administrator's password or MFA. It adds a new root administrator with a verified
email, which enrolls MFA at its first sign-in. Once you are back in, review the
root administrators and repair or remove the lost account. Every run
adds another administrator, so treat it as a break-glass step and alert on root:recover events.
export BETTER_IAM_ROOT_EMAIL=break-glass-2026-09@example.com
export BETTER_IAM_ROOT_PASSWORD="$(cat /run/secrets/break-glass-password)"
better-iam recover-root --config /etc/better-iam/better-iam.config.mjsdoctor
Prints the deployment's health: storage details, audit-chain totals, and configuration and job findings.
better-iam doctor --config better-iam.config.mjs [--strict] [--retention-days N]- When: after every deploy (as a gate with
--strict), and on a schedule to catch jobs that stopped. - Needs: the database. No credential; it only reads.
- Fails with:
DOCTOR_FINDINGSwith--strictwhen any finding is an error or a warning. Without--strictit exits 0 whenever it can connect, even to a database without the IAM schema. - Calls:
iam.selfCheck().
Flags:
--strictturns error and warning findings into a failing exit. Informational findings never fail.--retention-days N(0 to 3650, default 30) is the delivery retention yoursweepruns with, so the sweep backlog is judged the same way.
The JSON holds the Node.js version, rootInitialized and rootCount, auditChains and auditEvents, storage
(adapter, schema version, applied migrations with their times, record counts, and durability settings such as
SQLite's journal mode or PostgreSQL's synchronous_commit), ok, and findings. Each finding has a check id, a
severity, a message, and a fix. Errors are a schema behind this release, no root tenant, unsafe SQLite
durability, and stored secrets no configured secret opens. Warnings include an in-memory database, asynchronous
PostgreSQL commits, a placeholder secret, a short metrics token, no email transport, an unfinished secret rotation,
and jobs that are not running (sweep, purge, outbox, audit hooks, and audit-archive).
better-iam doctor --config better-iam.config.mjs --strict --retention-days 14| Flag | Value | Required | Description |
|---|---|---|---|
--strict | — | no | Exit non-zero when any error or warning is found |
--retention-days | N | no | Delivery retention your sweep uses, to judge its backlog the same way |
secret
Prints a new random value for BETTER_IAM_SECRET.
better-iam secret [--bytes N] [--env]- When: once per deployment, and again when you rotate the secret (put the old value in
BETTER_IAM_PREVIOUS_SECRETSfirst; see secrets). - Needs: nothing. It reads no configuration and stores nothing.
- Fails with:
INVALID_ARGUMENTfor--bytesoutside 24 to 256.
Flags:
--bytes Nsets how many random bytes the secret encodes (default 48, which prints 64 URL-safe characters).--envprints aBETTER_IAM_SECRET=valueline for a.envfile.
The value comes from the operating system's random generator. Treat the output like a password: send it to your secret store, not to a log.
better-iam secret --env >> .env| Flag | Value | Required | Description |
|---|---|---|---|
--bytes | N | no | Random bytes (the secret is their base64url form) (default 48) |
--env | — | no | Print BETTER_IAM_SECRET=value |
outbox
Delivers pending email, SMS, and webhook messages, then dispatches queued audit hooks.
better-iam outbox --config better-iam.config.mjs- When: every minute.
- Needs: the delivery callbacks in the configuration (
sendEmail,sendSms). No credential. - Calls:
iam.auth.dispatchOutbox(), theniam.dispatchAuditHooks().
Messages are written to the outbox in the same transaction as the change that caused them and wait there until this
runs. It prints { delivered, failed, abandoned }. A failed attempt is retried with exponential backoff from 30
seconds to one hour and abandoned after authentication.maxDeliveryAttempts (default 25). Delivery is at least
once, so transports should deduplicate by message ID.
The audit-hook step reaches plugin afterAudit hooks and events.onEvent from the configuration file only. Events
it dispatches are marked delivered and never reach subscribers your application registered with
iam.events.subscribe, so if you use subscribers, run iam.auth.dispatchOutbox() and iam.events.dispatch() in
that process instead of this command.
better-iam outbox --config /etc/better-iam/better-iam.config.mjspurge
Runs the retention worker: expires ended access and removes tenants deleted more than N days ago.
better-iam purge --config better-iam.config.mjs [--retention-days N]- When: hourly, at least daily.
- Needs: no credential. Recorded as
deployment-operator. - Calls:
iam.purgeDeleted().
Flags:
--retention-days N(0 to 3650, default 30) is how long a deleted tenant is kept before its records are removed.0removes deleted tenants on the next run, which cannot be undone.
It disables identities past their scheduled end (identity:expire, sessions revoked), deletes expired temporary
bindings, lapsed memberships, ended activations, and package assignments past their end, marks stale access and
package requests expired, removes expired challenges, rate-limit counters, and network blocks, and deletes every
record of tenants tombstoned before the cutoff (one tenants:purge event per tenant tree). Audit records always
remain. It prints purgedTenants, deletedRecords, expiredBindings, expiredRequests, expiredIdentities,
expiredActivations, expiredMemberships, and expiredAssignments, and it is idempotent.
better-iam purge --config better-iam.config.mjs --retention-days 30| Flag | Value | Required | Description |
|---|---|---|---|
--retention-days | N | no | Keep deleted tenants this many days (default 30) |
sweep
Deletes expired sessions, devices, relationship tuples, OAuth and SAML artifacts, and old deliveries in short batches.
better-iam sweep --config better-iam.config.mjs [--limit N] [--retention-days N]- When: hourly or daily, beside
purge. - Needs: no credential. Deletions are not audited.
- Calls:
iam.sweepExpired().
Flags:
--limit N(1 to 1,000,000, default 10,000) is the most records one run deletes.--retention-days N(0 to 3650, default 30) is how long delivered and abandoned outbox messages and failed Shared Signals deliveries are kept. Pass the same value todoctor --retention-days.
It keeps storage, and scans such as outbox delivery, from growing with traffic. Batches of 500 are deleted in their
own short transactions, so it can run during traffic. API keys, pending deliveries, invitations, access requests,
usage records, and SCIM connections are never deleted by age. It prints { deleted, total, truncated }; when
truncated is true more records are due, so run it again or raise --limit.
better-iam sweep --config better-iam.config.mjs --limit 50000 --retention-days 14| Flag | Value | Required | Description |
|---|---|---|---|
--limit | N | no | Most records deleted per run (default 10000) |
--retention-days | N | no | Keep delivered and failed deliveries this many days (default 30) |
digest
Emails each organization's owners its access report when there is something to report.
better-iam digest --config better-iam.config.mjs [--tenant TENANT_ID] [--within-days N] [--unused-days N]- When: daily, followed by
outbox. - Needs:
authentication.sendEmailin the configuration. No credential; recorded astenant:access-digest. - Fails with:
DELIVERY_REQUIREDwithout an email transport. - Calls:
iam.sendAccessDigest().
Flags:
--tenant IDlimits the run to one organization (default: every active organization).--within-days Nand--unused-days N(each 0 to 3650, default 30) set the report windows, as inreport.
The owners of each organization with findings receive one access-digest email each, with the counts and the full
report as JSON. Organizations with nothing to report, with no owner who has an email address, or already digested in
the last 20 hours are skipped, so a rerun never emails twice. It prints { sent, skipped }.
better-iam digest --config better-iam.config.mjs --within-days 14 && better-iam outbox --config better-iam.config.mjs| Flag | Value | Required | Description |
|---|---|---|---|
--tenant | TENANT_ID | no | Only this organization (default: every active one) |
--within-days | N | no | Report access ending within this many days (default 30) |
--unused-days | N | no | Report keys unused for this many days (default 30) |
remind
Emails each person whose access ends within N days one reminder listing it.
better-iam remind --config better-iam.config.mjs [--tenant TENANT_ID] [--within-days N]- When: daily, beside
digest, followed byoutbox. - Needs:
authentication.sendEmailin the configuration. No credential; recorded asidentity:expiry-reminder. - Fails with:
DELIVERY_REQUIREDwithout an email transport;INVALID_ARGUMENTfor--unused-days, whichreminddoes not take. - Calls:
iam.sendExpiryReminders().
Flags:
--tenant IDlimits the run to one organization.--within-days N(1 to 365, default 7) is how far ahead to look.
It covers the person's own account end date, direct role bindings, temporary group memberships, and access-package
assignments. Each item is reminded once per end date, so reruns are harmless and extended access is reminded again
when its new end comes near. It prints { sent, skipped }.
better-iam remind --config better-iam.config.mjs --within-days 3| Flag | Value | Required | Description |
|---|---|---|---|
--tenant | TENANT_ID | no | Only this organization (default: every active one) |
--within-days | N | no | Remind about access ending within this many days (default 7) |
reconcile
Applies access-package rules (birthright access): people who match a rule receive the package, and automatic holders who stopped matching lose it.
better-iam reconcile --config better-iam.config.mjs [--tenant TENANT_ID [--package PACKAGE_ID [--confirm]]] [--limit N] [--fail-on-attention]- When: every 15 minutes, after
purge. - Needs: no credential and no email transport. Each change runs under the rule owner's grant authority.
- Fails with:
RECONCILE_ATTENTIONwith--fail-on-attentionwhen a change failed, changes were held back, a rule is suspended, or an organization could not be processed;INVALID_ARGUMENTfor--packagewithout--tenantor--confirmwithout--package. - Calls:
iam.reconcilePackages().
Flags:
--tenant IDlimits the run to one organization, and--package ID(with--tenant) to one package.--confirm(with--package) approves the changes the brake held back for that package and applies them in this run.--limit N(1 to 10000, default 1000) caps the changes per organization per run.--fail-on-attentionexits non-zero when anything needs a person.
SCIM provisioning, invitations, and attribute and group changes take effect in rule-based packages only through this
command. It prints assigned, refreshed, restored, ending, revoked, stale, failed, suspended,
braked, and truncated; truncated: true means run it again. Scheduled runs hold back unusually large changes
(braked) until someone confirms them, so a rule edit or a directory glitch cannot revoke everyone at once. See
automatic assignment.
better-iam reconcile --config better-iam.config.mjs --tenant "$TENANT_ID" --package "$PACKAGE_ID" --confirm| Flag | Value | Required | Description |
|---|---|---|---|
--tenant | TENANT_ID | no | Only this organization (default: every active one) |
--package | PACKAGE_ID | no | Only this package (needs --tenant) |
--confirm | — | no | Apply a held-back large change (needs --package) |
--limit | N | no | Most changes per organization and run (default 1000) |
--fail-on-attention | — | no | Exit non-zero when a change failed, was held back, or a rule is suspended |
close-certifications
Closes every auto-closing certification campaign whose due date has passed and applies its decisions.
better-iam close-certifications --config better-iam.config.mjs [--tenant TENANT_ID]- When: hourly or daily, with the other jobs.
- Needs: no credential. Revocations run under each campaign creator's grant authority; recorded as
certification:auto-close. - Calls:
iam.closeOverdueCertifications().
Flags:
--tenant IDlimits the run to one organization.
Only campaigns created with autoClose are touched. Each one closes in its own transaction: revoked bindings are
removed (each audited as iam:bindings:delete), and items nobody decided follow the campaign's undecided setting.
It prints { closed, skipped }, where skipped counts campaigns that are not due yet. See
certifications.
better-iam close-certifications --config better-iam.config.mjs| Flag | Value | Required | Description |
|---|---|---|---|
--tenant | TENANT_ID | no | Only this organization (default: every active one) |
monitor-invariants
Evaluates the invariants of every organization and records an audit event whenever one breaks or recovers.
better-iam monitor-invariants --config better-iam.config.mjs [--tenant TENANT_ID]- When: hourly, and after configuration changes.
- Needs: no credential. Recorded as
deployment-operator. - Calls:
iam.checkInvariants().
Flags:
--tenant IDlimits the run to one organization.
invariant:broken is recorded when an invariant starts failing or gains violators, and invariant:restored when it
passes again, once per change, so a webhook subscribed to invariant:* alerts without repeating itself. It prints
{ checked, broken, restored } and exits 0 whenever it runs: alert from the webhook, or use
check-invariants --fail-on-broken when you need a failing exit.
better-iam monitor-invariants --config better-iam.config.mjs| Flag | Value | Required | Description |
|---|---|---|---|
--tenant | TENANT_ID | no | Only this organization (default: every active one) |
rotate-secrets
Re-seals authenticator secrets, webhook secrets, and pending deliveries with the current deployment secret.
better-iam rotate-secrets --config better-iam.config.mjs [--dry-run]- When: during a secret rotation, after every process runs with the new
secretand the old value inpreviousSecrets(configurations frominitread it fromBETTER_IAM_PREVIOUS_SECRETS, comma-separated). - Needs: no credential.
- Fails with:
UNREADABLE_SECRETS, after printing the result, when stored values open with no configured secret, which means the secret that sealed them is missing frompreviousSecrets. - Calls:
iam.rotateSecrets().
Flags:
--dry-runonly counts what would be re-sealed and changes nothing.
It prints { resealed, unreadable, current, complete, done } and writes in short transactions, so you can stop it
and run it again. Repeat until done is true, wait a day for emailed links and assertions issued under the old
secret to expire, then remove previousSecrets everywhere. Sessions and API keys do not depend on the secret, so
nobody is signed out. See rotating the deployment secret.
better-iam rotate-secrets --config better-iam.config.mjs --dry-run
better-iam rotate-secrets --config better-iam.config.mjs| Flag | Value | Required | Description |
|---|---|---|---|
--dry-run | — | no | Only count what would be re-sealed |
billing-close
Issues billing statements for a month that has ended and deletes raw usage events past their retention.
better-iam billing-close --config better-iam.config.mjs [--period YYYY-MM] [--tenant TENANT_ID] [--draft]- When: daily; accounts already invoiced for the month are skipped, so repeated runs are safe.
- Needs: no credential. Each statement is recorded as
billing:statement; withauthentication.sendEmailit is emailed to the account's billing emails (or owners) asbilling-statement. - Fails with:
INVALID_INPUTfor the current or a future month. - Calls:
iam.billing.closePeriod().
Flags:
--period YYYY-MMis the month to close (default: last month).--tenant IDcloses only the billing account that pays for that tenant.--draftkeeps the invoices as drafts, refreshed on every run, until finalized (also the default with the optionbilling.autoFinalize: false).
Each account with something to bill (usage of meters defined above it, subscription fees and seats, pending invoice items) gets one invoice with its lines, coupons, credit applied earliest expiry first, and a breakdown of usage by project, team, department and person. See billing.
| Flag | Value | Required | Description |
|---|---|---|---|
--period | YYYY-MM | no | The billing month |
--tenant | TENANT_ID | no | Only the billing account that pays for this tenant |
--draft | — | no | Keep the invoices as drafts to review and finalize |
billing-reminders
Reminds billing contacts of unpaid invoices before and after their due date.
better-iam billing-reminders --config better-iam.config.mjs- When: daily.
- Needs: no credential. Each reminder is recorded as
billing:payment-reminder; withauthentication.sendEmailit is emailed to the invoice's billing emails aspayment-reminder. - Calls:
iam.billing.sendPaymentReminders().
Reminders go out at each step of the billing.paymentReminderDays option (default 3 days before the due date, on it,
and 7 and 14 days after), once per step; a step missed while the job did not run is skipped for the latest one reached.
Paid, void and uncollectible invoices get none.
billing-alerts
Checks every spend budget and sends alerts for thresholds reached and projections past the budget.
better-iam billing-alerts --config better-iam.config.mjs [--tenant TENANT_ID]- When: hourly.
- Needs: no credential. Alerts are recorded as
billing:budget-alert(forward them with a webhook) and emailed asspend-alertwhen mail is configured. - Calls:
iam.billing.checkBudgets().
Flags:
--tenant IDchecks only the budgets that tenant owns.
Each threshold alerts once per budget window (a month, quarter or year), and the forecast alert once when the linear projection passes 100%.
| Flag | Value | Required | Description |
|---|---|---|---|
--tenant | TENANT_ID | no | Only budgets this tenant owns (default: every budget) |
billing-anomalies
Checks every billing account for spend spikes and alerts on each once.
better-iam billing-anomalies --config better-iam.config.mjs [--tenant TENANT_ID] [--day YYYY-MM-DD] [--factor N] [--minimum N]- When: daily, shortly after midnight in the billing time zone.
- Needs: no credential. Each spike is recorded as
billing:anomaly; withauthentication.sendEmaileach account's billing emails (or owners) get onespend-anomalyemail listing the largest five. - Fails with:
INVALID_INPUTfor a malformed--day. - Calls:
iam.billing.detectAnomalies().
Flags:
--day YYYY-MM-DDis the day to check (default: yesterday).--factor N(2 to 1000, default 3) is how many times the usual daily spend counts as a spike.--minimum N(default 10) is the smallest spend and increase worth reporting, in currency units.--tenant IDchecks only the billing account that pays for that tenant.
A person, team or meter counts as spiking when its spend on the day is at least the factor times its average over the 14 days before and at least the minimum more; new spending counts when it reaches the minimum.
| Flag | Value | Required | Description |
|---|---|---|---|
--tenant | TENANT_ID | no | Only the billing account that pays for this tenant |
--day | YYYY-MM-DD | no | The day to check |
--factor | N | no | Times the usual daily spend that counts as a spike (default 3) |
--minimum | N | no | Smallest spend and increase worth reporting, in currency units (default 10) |
billing-seats
Records one seat for every active person of every tenant a seats meter reaches, once per day.
better-iam billing-seats --config better-iam.config.mjs [--meter KEY] [--tenant TENANT_ID] [--include-service-accounts]- When: daily.
- Needs: no credential, and a meter with the key (
seatsby default) defined by the platform or an organization. - Calls:
iam.billing.recordSeats().
Flags:
--meter KEYis the meter to record on (defaultseats).--tenant IDlimits the run to that tenant and the tenants below it.--include-service-accountscounts service accounts and agents as seats too.
A sum meter then counts seat-days and a unique meter active seats per month; each seat is attributed to its person,
their teams and department. Tenants the meter does not reach are counted under skippedTenants.
| Flag | Value | Required | Description |
|---|---|---|---|
--meter | KEY | no | The meter seats are recorded on (default seats) |
--tenant | TENANT_ID | no | Only this tenant and the tenants below it |
--include-service-accounts | — | no | Count service accounts and agents as seats too |
spend
Prints the spend of a tenant and the tenants below it for a month.
better-iam spend --config better-iam.config.mjs --tenant TENANT_ID [--period YYYY-MM] [--group-by meter|identity|agent|team|department|tenant|day] [--team TEAM_ID] [--department DEPARTMENT_ID] [--identity IDENTITY_ID] [--meter KEY] [--url URL] [--profile NAME]- When: for finance exports and scripts, or to check a team's spend from a terminal.
- Needs:
BETTER_IAM_TOKENwithiam:billing:readin the tenant. - Fails with:
INVALID_ARGUMENTfor an unknown--group-by;NOT_FOUNDfor a team, department or identity outside the tenant.
Flags:
--tenant ID(orBETTER_IAM_TENANT) is the tenant to report on.--period YYYY-MMis the month (default: the current one, with a forecast).--group-by meter|identity|agent|team|department|tenant|day(defaultmeter).--team ID,--department ID,--identity IDand--meter KEYnarrow the report.
better-iam spend --tenant "$ACME" --group-by team --format table| Flag | Value | Required | Description |
|---|---|---|---|
--tenant | TENANT_ID | yes | The tenant to act in (env BETTER_IAM_TENANT) |
--period | YYYY-MM | no | The billing month |
--group-by | meter|identity|agent|team|department|tenant|day | no | How rows are grouped (default meter) |
--team | TEAM_ID | no | Only this team (with its sub-teams) |
--department | DEPARTMENT_ID | no | Only this department (with those below it) |
--identity | IDENTITY_ID | no | Only this person or account |
--meter | KEY | no | Only this meter |
audit-verify
Recomputes one tenant's audit hash chain straight from storage and fails when it does not verify.
better-iam audit-verify --config better-iam.config.mjs --tenant TENANT_ID- When: during an incident or an audit, after restoring a backup, or on a schedule as a tamper check.
- Needs:
--tenant. No credential, and it records nothing. - Fails with:
AUDIT_CHAIN_BROKENwhen an event was edited or removed, or the chain no longer ends at its recorded head.
It reads every chained event of the tenant, recomputes each hash and link, compares the end with the chain head, and
prints { tenantId, valid, head } with the position of the first failure. Because it needs no credential and writes
nothing, it is safe to run against production at any time; it holds the whole chain in memory. The API equivalent,
authorized and audited, is audit.verify. See
audit chain.
better-iam audit-verify --config better-iam.config.mjs --tenant "$TENANT_ID"| Flag | Value | Required | Description |
|---|---|---|---|
--tenant | TENANT_ID | yes | The tenant whose audit chain to read |
audit-export
Writes one tenant's audit chain to a new JSON Lines file for archiving or outside analysis.
better-iam audit-export --config better-iam.config.mjs --tenant TENANT_ID --output PATH- When: on demand (a legal hold or an auditor's request), or before
audit-prunewhen you do not useaudit-archive. - Needs:
--tenantand--output. No credential, and it records nothing. - Fails with: a generic failure when the output file already exists; it never overwrites one.
Each line is one chained event in sequence order, exactly as stored with its hashes, so the file can be verified on
its own later with verifyAuditChain. It prints { tenantId, output, count, firstSequence, lastSequence, head };
keep the head with the file as the point the chain must end at. For continuous, verified copies use audit-archive.
better-iam audit-export --config better-iam.config.mjs --tenant "$TENANT_ID" --output "audit-$TENANT_ID-$(date +%F).jsonl"| Flag | Value | Required | Description |
|---|---|---|---|
--tenant | TENANT_ID | yes | The tenant whose audit chain to read |
--output | PATH | yes | The .jsonl file to create (it must not exist) |
audit-prune
Deletes one tenant's audit events older than N days and appends a checkpoint so the rest of the chain still verifies.
better-iam audit-prune --config better-iam.config.mjs --tenant TENANT_ID [--retention-days N]- When: on a schedule that matches your retention policy, after the events were archived or exported.
- Needs:
--tenant. No credential; recorded asaudit:prunebydeployment-operator. - Calls:
iam.pruneAudit().
Flags:
--retention-days N(0 to 36500, default 365) is how old an event must be before it is deleted.
It deletes the oldest events up to the first one newer than the cutoff, in one transaction, and records the sequence
and hash the chain now starts after. With auditArchive configured, or once the tenant has an archive cursor, it
never deletes an event the archive does not hold yet, and prints heldForArchive: true when it stopped early. It
prints { deleted, prunedThroughSequence, prunedThroughHash }; a rerun with the same retention deletes nothing new.
Deleted events are gone from the database for good, so archive first.
better-iam audit-prune --config better-iam.config.mjs --tenant "$TENANT_ID" --retention-days 400| Flag | Value | Required | Description |
|---|---|---|---|
--tenant | TENANT_ID | yes | The tenant whose audit chain to read |
--retention-days | N | no | Keep events newer than this many days (default 365) |
audit-archive
Copies every tenant's new audit events, verified and in chain order, to the configured auditArchive.
better-iam audit-archive --config better-iam.config.mjs [--tenant TENANT_ID] [--limit N]- When: every few minutes, at least hourly.
- Needs:
auditArchivein the configuration, for examplecreateJsonlAuditArchive. No credential. - Fails with:
AUDIT_ARCHIVE_FAILED, after printing the result, when a tenant's chain did not verify or the sink failed or refused a conflicting batch;NO_AUDIT_ARCHIVEwhen no archive is configured. - Calls:
iam.archiveAudit().
Flags:
--tenant IDarchives one tenant only.--limit N(a positive integer, default 100,000, at most 10,000,000) caps the events archived per run.
It prints archived per tenant, batches, failed (with AUDIT_CHAIN_BROKEN, ARCHIVE_WRITE_FAILED, or
ARCHIVE_CONFLICT per tenant), gaps, busy, and truncated. Overlapping runs are safe: a tenant another run is
archiving is skipped and listed under busy. Once archiving runs, audit-prune only deletes events the archive
holds. Check the archive on its own with audit-verify-archive. See
continuous audit archiving.
better-iam audit-archive --config /etc/better-iam/better-iam.config.mjs| Flag | Value | Required | Description |
|---|---|---|---|
--tenant | TENANT_ID | no | Only this tenant (default: all) |
--limit | N | no | Most events archived per tenant and run |
audit-verify-archive
Verifies one tenant's archived audit chain from the archive files alone, without the database.
better-iam audit-verify-archive --directory /var/lib/better-iam/audit --tenant TENANT_ID- When: periodically, and before relying on the archive (after an incident, or before discarding database backups).
- Needs:
--directory(the directory given tocreateJsonlAuditArchive) and--tenant. No configuration, database, or credential. - Fails with:
AUDIT_ARCHIVE_INVALIDwhen a sequence is missing, a hash or link does not recompute, or two overlapping files disagree about the same sequence.
Files can overlap after a crash, and each sequence must then carry the identical event. It prints the tenant, the number of files, the number of conflicts, and the verification result. Because it reads only the files, run it where the archive lives, for example on the backup host.
better-iam audit-verify-archive --directory /var/lib/better-iam/audit --tenant "$TENANT_ID"| Flag | Value | Required | Description |
|---|---|---|---|
--directory | PATH | yes | The archive directory (createJsonlAuditArchive) |
--tenant | TENANT_ID | yes | The tenant whose archive to verify |
store-export
Writes every record of the database to a JSON Lines snapshot file.
better-iam store-export --config better-iam.config.mjs --output PATH- When: before moving a deployment to another database or adapter, or as a logical backup.
- Needs:
--output. No credential. - Fails with: a generic failure when the output file already exists. On any failure the partial file is removed.
The file holds a header, one line per record, and a trailer with counts. It is read in one transaction, so the
snapshot is consistent, but that transaction holds the write lock until the export finishes, so IAM writes wait
meanwhile. The file is created readable by its owner only and holds password hashes, sessions, and encrypted secrets:
protect it like the database. It prints { output, records, collections }. Load it with store-import.
better-iam store-export --config better-iam.config.mjs --output /secure/backups/better-iam-snapshot.jsonl| Flag | Value | Required | Description |
|---|---|---|---|
--output | PATH | yes | The snapshot file to create (mode 0600; it must not exist) |
store-import
Loads a snapshot into an empty database in one transaction.
better-iam store-import --config better-iam.config.mjs --input PATH- When: when moving a deployment to a new database, after
store-export. - Needs:
--input, and a configuration whose database holds no IAM records. No credential. - Fails with:
STORE_NOT_EMPTYwhen the target already has records;SNAPSHOT_TRUNCATEDfor a file without its trailer or with counts that disagree with it;SNAPSHOT_INVALIDfor a corrupt line.
It applies the core schema to the target first (not plugin migrations), then inserts every record verbatim in one
transaction, so any failure leaves the database without records. Records keep their IDs, password hashes, encrypted secrets,
and audit chains, so the target configuration must use the same secret. Afterwards run migrate with the same
configuration for plugin migrations, and on PostgreSQL run VACUUM ANALYZE iam_records so lookups use the index
immediately. See storage.
better-iam store-import --config postgres.config.mjs --input /secure/backups/better-iam-snapshot.jsonl| Flag | Value | Required | Description |
|---|---|---|---|
--input | PATH | yes | The snapshot to load |
store-copy
Copies the configured database into the empty database of another configuration in one step, for example from SQLite to PostgreSQL.
better-iam store-copy --config better-iam.config.mjs --target-config PATH- When: when moving a deployment between databases or adapters without an intermediate file.
- Needs:
--target-config, naming a different configuration file whose database is empty. No credential. - Fails with:
INVALID_ARGUMENTwhen--target-configis the same file as--config;SAME_DATABASEwhen both point at the same database;STORE_NOT_EMPTYwhen the target already has records.
It migrates the target's core schema, reads the source in one transaction, and writes the target in one transaction,
so the copy is consistent and all-or-nothing. Use the same secret in both configurations. Then run
migrate --config with the target configuration, point the application at it, and on PostgreSQL run
VACUUM ANALYZE iam_records.
better-iam store-copy --config sqlite.config.mjs --target-config postgres.config.mjs| Flag | Value | Required | Description |
|---|---|---|---|
--target-config | PATH | yes | Configuration of the empty target database |
config-export
Writes a tenant's access model as a JSON configuration document that config-plan and config-apply accept.
better-iam config-export --config better-iam.config.mjs --tenant TENANT_ID [--output PATH] [--url URL] [--profile NAME]- When: once to bring an existing tenant under version control, then whenever you want a snapshot.
- Needs:
--tenantandBETTER_IAM_TOKEN, a session or API key withiam:config:read. - Fails with:
MISSING_ENVwithout a token;ACCESS_DENIEDwithout the permission. - Calls:
config.export.
Flags:
--output PATHwrites a new file (never overwriting one) and prints{ tenantId, output }. Without it, the document goes to standard output.
The document refers to everything by name instead of by ID: tenant-defined resource types, policies, roles, groups
with their members' emails, group bindings, access packages with their automatic-assignment rules, the tenant access
policy, and, when the tenant has any, invariants and agreements. Commit it, review changes as pull requests, and
apply them with config-apply. See configuration as code.
BETTER_IAM_TOKEN="$CONFIG_READER_KEY" better-iam config-export --config better-iam.config.mjs --tenant "$TENANT_ID" --output tenant.json| Flag | Value | Required | Description |
|---|---|---|---|
--tenant | TENANT_ID | yes | The tenant to act in (env BETTER_IAM_TENANT) |
--output | PATH | no | Write to this file instead (.json, or .ts/.mjs for a module); never overwrites |
config-plan
Shows the creates, updates, and deletes that applying a configuration file would make, without changing anything.
better-iam config-plan --config better-iam.config.mjs --tenant TENANT_ID --input PATH [--prune] [--fail-on-drift] [--url URL] [--profile NAME]- When: in CI on every change to the file, and nightly to detect drift made in the console.
- Needs:
--tenant,--input, andBETTER_IAM_TOKENwithiam:config:read. - Fails with:
CONFIG_DRIFTwith--fail-on-driftwhen anything would change;INVALID_INPUTfor an invalid document;MISSING_ENV;ACCESS_DENIED. - Calls:
config.plan.
Flags:
--prunealso plans deletes for the items the file omits, in each kind the file lists. Kinds absent from the file are always left alone.--fail-on-driftexits non-zero, after printing the plan, when it contains any create, update, or delete.
The plan lists every change by kind and name with a summary of counts. Use the same --prune setting here as in
config-apply, so the plan shows exactly what the apply will do.
BETTER_IAM_TOKEN="$CONFIG_READER_KEY" better-iam config-plan --config better-iam.config.mjs --tenant "$TENANT_ID" --input tenant.json --prune --fail-on-drift| Flag | Value | Required | Description |
|---|---|---|---|
--tenant | TENANT_ID | yes | The tenant to act in (env BETTER_IAM_TENANT) |
--input | PATH | yes | Desired configuration: .json, or a .mjs/.js/.ts module exporting it (or a factory) |
--prune | — | no | Also delete items the file omits |
--fail-on-drift | — | no | Exit non-zero when anything would change (CI) |
config-apply
Applies a configuration file to a tenant in one transaction.
better-iam config-apply --config better-iam.config.mjs --tenant TENANT_ID --input PATH [--prune] [--url URL] [--profile NAME]- When: from your deployment pipeline after the plan was reviewed, followed by
check-invariants --fail-on-broken. - Needs:
--tenant,--input, andBETTER_IAM_TOKENwithiam:config:apply, plus the permission and grant authority for every change it makes. - Fails with:
ACCESS_DENIEDwhen any single change is not allowed;SOD_CONFLICTorINVARIANT_VIOLATIONwhen the result would break a separation-of-duties rule or an enforced invariant;INVALID_INPUTfor an invalid document;MISSING_ENV. - Calls:
config.apply, audited asconfig:applywith the change summary.
Flags:
--prunedeletes items of a listed kind that the file omits. Without it, removing an item from the file does not delete it.
Each change is authorized like the equivalent direct API call under the token owner's grant authority, and one
failure rolls the whole apply back, so the tenant never ends up half-configured. It prints the plan it applied. Run
config-plan with the same flags first.
BETTER_IAM_TOKEN="$CONFIG_DEPLOYER_KEY" better-iam config-apply --config better-iam.config.mjs --tenant "$TENANT_ID" --input tenant.json --prune| Flag | Value | Required | Description |
|---|---|---|---|
--tenant | TENANT_ID | yes | The tenant to act in (env BETTER_IAM_TENANT) |
--input | PATH | yes | Desired configuration: .json, or a .mjs/.js/.ts module exporting it (or a factory) |
--prune | — | no | Also delete items the file omits |
config-validate
Checks a tenant configuration file offline, without a database or a token.
better-iam config-validate --input PATH [--tenant TENANT_ID] [--strict]- When: in a pre-commit hook and as the first CI step, before
config-planneeds a token and a network. - Needs:
--input. No configuration, database, or credential. - Fails with:
INVALID_INPUTwhen the document's shape is wrong;CONFIG_WARNINGSwith--strictwhen it names something it does not define.
Flags:
--input PATHis the file: JSON, or a.mjs,.js, or.tsmodule whose default export is the configuration or a factory receiving{ tenantId, env }.--tenant IDis passed to such a factory astenantId.--strictfails on warnings.
It prints valid, a count of items per kind, and warnings: each role, binding, package, or invariant that names a
policy, role, or group the file does not define. Those names must already exist in the tenant, or config-plan fails.
better-iam config-validate --input iam/tenant.config.ts --strict| Flag | Value | Required | Description |
|---|---|---|---|
--input | PATH | yes | Desired configuration: .json, or a .mjs/.js/.ts module exporting it (or a factory) |
--tenant | TENANT_ID | no | Passed to a configuration factory as tenantId |
--strict | — | no | Exit non-zero when a reference is not defined in the file |
analyze
Prints a tenant's access-analysis findings as JSON and can fail when serious ones exist.
better-iam analyze --config better-iam.config.mjs --tenant TENANT_ID [--dormant-days N] [--fail-on high|medium|low] [--url URL] [--profile NAME]- When: nightly, or as a deployment gate.
- Needs:
--tenantandBETTER_IAM_TOKENwithiam:analysis:read. - Fails with:
FINDINGSwith--fail-onwhen an unsuppressed finding at or above that severity exists;INVALID_INPUTfor a--dormant-daysoutside 1 to 3650;MISSING_ENV;ACCESS_DENIED. - Calls:
analysis.findings.
Flags:
--dormant-days N(default 90) is how long an account holding access must go unused before it is reported.--fail-on high|medium|lowexits non-zero when a finding of that severity or higher exists:highfails on high findings only,lowon any finding.
Findings cover dormant access, stale keys, policy lint, and separation-of-duties violations (see the
analysis group). Suppressed findings are left out, so suppress accepted risks with
analysis.suppress and the gate stays meaningful.
BETTER_IAM_TOKEN="$ANALYST_KEY" better-iam analyze --config better-iam.config.mjs --tenant "$TENANT_ID" --dormant-days 60 --fail-on high| Flag | Value | Required | Description |
|---|---|---|---|
--tenant | TENANT_ID | yes | The tenant to act in (env BETTER_IAM_TENANT) |
--dormant-days | N | no | Days without sign-in after which an identity counts as dormant |
--fail-on | high|medium|low | no | Exit non-zero on a finding of this severity or higher |
report
Prints a tenant's access report: what ends soon, unused API keys, live elevations, and pending requests.
better-iam report --config better-iam.config.mjs --tenant TENANT_ID [--within-days N] [--unused-days N] [--url URL] [--profile NAME]- When: nightly, piped into a ticket or a chat channel.
- Needs:
--tenantandBETTER_IAM_TOKENwithiam:identities:read. The binding and key sections also neediam:bindings:readandiam:credentials:readand are left out without them. - Fails with:
MISSING_ENV;ACCESS_DENIED. - Calls:
reports.access.
Flags:
--within-days N(0 to 3650, default 30) reports identities and temporary bindings ending within that many days.--unused-days N(0 to 3650, default 30) reports API keys unused for that many days.
Unlike digest, it acts as a member and emails nobody, so use it when the report should go somewhere other than the
owners' inboxes. See access report.
BETTER_IAM_TOKEN="$REPORTER_KEY" better-iam report --config better-iam.config.mjs --tenant "$TENANT_ID" --within-days 14 | jq '.identities.expiring'| Flag | Value | Required | Description |
|---|---|---|---|
--tenant | TENANT_ID | yes | The tenant to act in (env BETTER_IAM_TENANT) |
--within-days | N | no | Access ending within this many days (default 30) |
--unused-days | N | no | Keys unused for this many days (default 30) |
mine-roles
Prints role-mining suggestions and peer outliers for a tenant as JSON.
better-iam mine-roles --config better-iam.config.mjs --tenant TENANT_ID [--peer-by manager|attribute:NAME] [--url URL] [--profile NAME]- When: weekly, as a snapshot for access reviews.
- Needs:
--tenantandBETTER_IAM_TOKENwithiam:analysis:read. - Fails with:
INVALID_INPUTfor an identity attribute in--peer-bythat is not declared;MISSING_ENV;ACCESS_DENIED. - Calls:
roleMining.suggestandroleMining.outliers.
Flags:
--peer-by manager|attribute:NAMEgroups people for outlier detection by shared manager (the default) or by a declared identity attribute, such asattribute:department.
Suggestions are role bundles to grant as access packages, roles every member of a group holds directly (bind them to
the group instead), direct bindings a group already covers, and duplicate roles. Outliers are roles few peers hold
(access that outlived a move) and roles most peers hold that a person lacks. The command only reads; apply a
suggestion with roleMining.apply or in the console. See
usage and role mining.
BETTER_IAM_TOKEN="$ANALYST_KEY" better-iam mine-roles --config better-iam.config.mjs --tenant "$TENANT_ID" --peer-by attribute:department| Flag | Value | Required | Description |
|---|---|---|---|
--tenant | TENANT_ID | yes | The tenant to act in (env BETTER_IAM_TENANT) |
--peer-by | manager|attribute:NAME | no | How peers are grouped for outliers |
check-invariants
Evaluates a tenant's access invariants as a member and can fail the build when one is broken.
better-iam check-invariants --config better-iam.config.mjs --tenant TENANT_ID [--fail-on-broken] [--url URL] [--profile NAME]- When: in CI after
config-apply, and before releases. - Needs:
--tenantandBETTER_IAM_TOKENwithiam:invariants:read. - Fails with:
INVARIANTS_BROKENwith--fail-on-brokenwhen an invariant is broken or cannot be evaluated;MISSING_ENV;ACCESS_DENIED. - Calls:
invariants.run.
Flags:
--fail-on-brokenexits non-zero, after printing the results, when any invariant failed or could not be evaluated (its resource or group no longer exists, for example).
It prints { generatedAt, summary, results }, with passed, failed, and errors counts and each invariant's
violators. It records no invariant:broken events (that is monitor-invariants), so CI can run it as often as it
likes. See change safety.
BETTER_IAM_TOKEN="$CI_AUDITOR_KEY" better-iam check-invariants --config better-iam.config.mjs --tenant "$TENANT_ID" --fail-on-broken| Flag | Value | Required | Description |
|---|---|---|---|
--tenant | TENANT_ID | yes | The tenant to act in (env BETTER_IAM_TENANT) |
--fail-on-broken | — | no | Exit non-zero when an invariant is broken or unevaluable |
whoami
Prints who the credential in BETTER_IAM_TOKEN acts as, and fails when it is no longer valid.
better-iam whoami --config better-iam.config.mjs [--url URL] [--profile NAME]- When: at the start of a CI job or script, to confirm which identity, tenant, and role it runs as, or to check that a session, API key, role session, or session token (opaque or JWT) has not been revoked.
- Needs:
BETTER_IAM_TOKEN, or a session saved bylogin. No permission; it records no audit event. - Fails with:
MISSING_ENVwithout a token;SESSION_EXPIREDwhen the saved session has ended;UNAUTHENTICATEDwhen the credential is invalid, expired, or revoked;INVALID_ARGUMENTfor a flag it does not take (it takes--config,--url,--profile,--format, and--query). - Calls:
sts.getCallerIdentity.
It prints the identity and its tenant, the tenant the session acts in, the session kind and id, the format, MFA, issue, sign-in, and expiry times, and, when they apply, the role, trust, source tenant, session name, source identity, session tags, JWT audiences, web identity, and impersonator. Hashes, policies, and authority ids are never included.
BETTER_IAM_TOKEN="$ROLE_TOKEN" better-iam whoami --config better-iam.config.mjs | jq '{sessionKind, roleId, expiresAt}'can
Checks whether the session or API key may perform an action on a resource.
better-iam can --config better-iam.config.mjs ACTION RESOURCE --tenant TENANT_ID [--url URL] [--profile NAME]- When: to debug a denial, or in a script that branches on access before it does something.
- Needs:
BETTER_IAM_TOKENor a saved session, and--tenant(orBETTER_IAM_TENANT, or the saved session's). - Fails with:
ACCESS_DENIEDwhen the answer is no (after printing the decision);INVALID_ARGUMENTfor a resource that is nottype/idortype:id. - Calls:
authorize.
It prints the decision with its reason and exits 0 only when allowed, so if better-iam can …; then works. The check
is recorded like any other authorization decision.
better-iam can documents:write document/d1 --tenant "$TENANT_ID"| Flag | Value | Required | Description |
|---|---|---|---|
--tenant | TENANT_ID | yes | The tenant to act in (env BETTER_IAM_TENANT) |
explain
Shows why another identity would be allowed or denied an action, without signing in as them.
better-iam explain --config better-iam.config.mjs ACTION RESOURCE --tenant TENANT_ID --identity ID|EMAIL [--assume-mfa] [--url URL] [--profile NAME]- When: when someone reports a denial, or before granting access, to see which statement decides.
- Needs: a token with
iam:policies:simulate,--tenant, and--identity(an ID or an email). - Fails with:
NOT_FOUNDwhen no single identity has the email;ACCESS_DENIED. - Calls:
policies.simulate.
Flags:
--identity ID|EMAILis the person or service account to explain.--assume-mfaevaluates as if they had signed in with MFA.
better-iam explain documents:write document/d1 --identity alice@acme.test --tenant "$TENANT_ID"| Flag | Value | Required | Description |
|---|---|---|---|
--tenant | TENANT_ID | yes | The tenant to act in (env BETTER_IAM_TENANT) |
--identity | ID|EMAIL | yes | Whose decision to explain |
--assume-mfa | — | no | Evaluate as an MFA session |
who-can
Lists every active identity that could perform an action on a resource, with the reason.
better-iam who-can --config better-iam.config.mjs ACTION RESOURCE --tenant TENANT_ID [--kind user|service] [--assume-mfa] [--limit N] [--url URL] [--profile NAME]- When: in access reviews and audits, and before deleting or sharing a sensitive resource.
- Needs: a token with
iam:policies:simulateand--tenant. - Fails with:
INVALID_ACTIONfor an action the catalog does not know;ACCESS_DENIED. - Calls:
policies.whoCan.
Flags:
--kind user|servicelists only people or only service accounts.--assume-mfaevaluates everyone as if signed in with MFA.--limit Ncaps the list (default 100).
Root administrators are not listed: their override applies everywhere.
better-iam who-can documents:delete document/d1 --tenant "$TENANT_ID" --format table| Flag | Value | Required | Description |
|---|---|---|---|
--tenant | TENANT_ID | yes | The tenant to act in (env BETTER_IAM_TENANT) |
--kind | user|service | no | Only people or only service accounts |
--assume-mfa | — | no | Evaluate as MFA sessions |
--limit | N | no | Most identities listed (default 100) |
api
Calls any method of the HTTP API as the session or API key and prints the result.
better-iam api GROUP.METHOD [key=value ...] [--data JSON|@FILE|-] [--tenant TENANT_ID] [--list] [--url URL] [--profile NAME]- When: for one-off administration from a terminal, and for scripts that need a method no dedicated command covers.
- Needs:
BETTER_IAM_TOKENor a saved session with the method's own permission; public methods such astenants.lookupneed none. - Fails with: whatever the method fails with (
ACCESS_DENIED,INVALID_INPUT,NOT_FOUNDfor an unknown method);INVALID_ARGUMENTfor a malformed route or input item;MISSING_ENVwithout a token. - Calls:
POST {basePath}/{group}/{method}, the same routes and checks as the HTTP API.
Flags:
--data JSON|@FILE|-gives the whole request body, from the argument, a JSON file, or standard input.key=valueitems add fields:name=Admin(string),limit:=10andactions:='["a"]'(JSON),document:=@policy.json(a JSON file),content=@terms.md(a file's text),resource.type=doc(nested), andactions[]=read(append).--tenant IDfillstenantIdwhen the input has none (alsoBETTER_IAM_TENANTor the saved session's tenant).--list [GROUP]lists every route, or one group's, with whether it needs a credential.
Top-level routes are authorize, authorizeMany, and listAccessible; plugin endpoints are plugins/{id}/{path}.
Locally the call has no request-size limit; against --url it goes over HTTP like any client.
better-iam api roles.create name=Reader permissions:='["documents:read"]' --tenant "$TENANT_ID" --query id| Flag | Value | Required | Description |
|---|---|---|---|
--data | JSON|@FILE|- | no | Request body as JSON, a JSON file, or standard input |
--tenant | TENANT_ID | no | tenantId for inputs that do not set one (env BETTER_IAM_TENANT) |
--list | — | no | List routes (optionally of one group) instead of calling one |
login
Signs in once and saves the session, so later member commands need no BETTER_IAM_TOKEN.
better-iam login [--config better-iam.config.mjs | --url URL] [--tenant TENANT_ID | --org SLUG] [--email EMAIL] [--email-code] [--with-token] [--profile NAME]- When: at the start of a terminal session against a deployment, or once per CI job with
--with-token. - Needs:
--tenantor--org,--email(or a prompt), and the password fromBETTER_IAM_PASSWORDor a hidden prompt; the MFA code fromBETTER_IAM_MFA_CODEor a prompt. - Fails with:
INVALID_CREDENTIALS;MFA_ENROLLMENT_REQUIREDwhen the account must enroll an authenticator first;MISSING_ENVwhen there is no password and no terminal to ask in;PROFILE_IN_USEwhen the profile holds a session for another deployment. - Calls:
auth.signIn, thenauth.verifyMfaandsts.getCallerIdentity.
Flags:
--url URLsigns in to a running server; without it, the session is issued through the configuration.--tenant IDor--org SLUGnames the organization.--email-codehas a one-time code emailed instead of using an authenticator, when the organization allows it.--with-tokensaves a token read from standard input (an API key, or a session from elsewhere) after checking it.--profile NAMEsaves under that name (default: the current profile, ordefault) and makes it current. Without it,loginrefuses withPROFILE_IN_USEto replace a profile that holds a session for another deployment.
A saved session is only ever used with the deployment that issued it: with --url or --config naming another one,
member commands fail with MISSING_ENV and say which deployment the session belongs to. --url must use https://
except for localhost.
The session is saved in ~/.config/better-iam/credentials.json (%APPDATA%\better-iam\credentials.json on Windows, or
BETTER_IAM_CREDENTIALS) with owner-only permissions, together with the server or configuration and the tenant, so
later commands need neither --url nor --tenant. Passwords and codes never appear in arguments or in the file.
echo "$CI_API_KEY" | better-iam login --with-token --url https://iam.example.com --profile ci| Flag | Value | Required | Description |
|---|---|---|---|
--tenant | TENANT_ID | no | Organization to sign in to (env BETTER_IAM_TENANT) |
--org | SLUG | no | Organization to sign in to, by its slug |
--email | EMAIL | no | Account email (prompted when absent) (env BETTER_IAM_EMAIL) |
--email-code | — | no | Email a one-time code for MFA instead of using an authenticator |
--with-token | — | no | Save a token read from standard input instead of signing in |
logout
Signs a saved session out on the server and forgets it.
better-iam logout --config better-iam.config.mjs [--url URL] [--profile NAME]- When: when you are done with a deployment, or to replace a session.
- Needs: a saved profile (
--profile, default the current one). - Fails with:
NOT_FOUNDwhen no profile has that name. - Calls:
auth.signOutfor user sessions.
API keys and other machine credentials saved with --with-token are only forgotten, never revoked. The profile is
removed even when the session had already ended.
better-iam logout --profile ciprofiles
Lists the sessions saved by login, or chooses or removes one.
better-iam profiles [use NAME | remove NAME]- When: to see which identity and deployment commands will act as, or to switch between them.
- Needs: nothing; it never prints tokens.
- Fails with:
NOT_FOUNDfor an unknown profile;INVALID_ARGUMENTfor a name with other characters than letters, digits, dot, dash, and underscore.
profiles prints a table of names, servers or configurations, tenants, identities, and expiry, marking the current
profile; profiles use NAME makes one current, and profiles remove NAME forgets one without signing it out. For a
single command, --profile NAME or BETTER_IAM_PROFILE picks another profile, and BETTER_IAM_TOKEN bypasses
profiles entirely.
better-iam profiles use stagingtoken
Prints the token member commands would act as, for other tools and scripts.
better-iam token --config better-iam.config.mjs [--url URL] [--profile NAME]- When: to hand the saved session to a tool that reads
BETTER_IAM_TOKENor anAuthorizationheader. - Needs:
BETTER_IAM_TOKENor a saved session. - Fails with:
MISSING_ENVwithout either;SESSION_EXPIREDwhen the saved session has ended.
The output is a credential: keep it out of logs and shell history.
export BETTER_IAM_TOKEN="$(better-iam token)"completion
Prints a shell completion script for every command, flag, and fixed flag value.
better-iam completion SHELL- When: once, from your shell profile.
- Needs: nothing; project commands from a configuration it finds are included.
- Fails with:
INVALID_ARGUMENTfor a shell other thanbash,zsh,fish, orpowershell.
eval "$(better-iam completion bash)"Was this page helpful?
Last updated on