Changelog
Release notes for the Better IAM packages and documentation site, every notable change in each version, newest first.
Changelog
0.3.1 (2026-10-07)
Security fixes for security clearances (only deployments that set the clearances option are affected; nothing else changes). An adversarial review of 0.3.0 found ways for labels to stop applying: moving a tenant to a parent without a scheme switched enforcement off for its labels (now refused, and labels without a scheme in force refuse everyone), deleting a managed resource and registering it again under another parent dropped the label it inherited (now kept), and labels on AI models in an organization did not reach its projects. SSH host labels now reach logins, verifiable-credential offers check the holder's clearance, scheme edits can no longer re-rank a level, and label types are validated. Upgrade if you enabled clearances.
- Security clearances: labels no longer slip away from what they protect (only with the
clearancesoption).tenants.reparentrefuses (RESOURCE_IN_USE) a move that would change the classification scheme in force for a subtree holding labels or live (interim, active, suspended) clearances; before, moving a project below an organization without a scheme dropped every label in it, so root and members could read up. A label left in a tenant no scheme applies to now refuses everyone, root included, instead of being ignored.resources.deletekeeps what a registration inherited from its managed parents as its own label (audited asclassification:labelwithreason: 'resource-deleted'), so deleting it and registering it again under another parent no longer declassifies it; deleting is refused while that inherited label cannot be read under the scheme in force.- An administrator's verifiable credential offer (
iam:vc:issue) needs the holder to dominate the credential type's label when it is offered (auditedACCESS_DENIED), when the wallet redeems it, and in everyverifiableCredentials.sweep(), which now revokes any credential (access-changed) whose holder no longer does, not only self-service ones. - A label on
ssh-host/{host}applies to every login of the host (ssh-login/{host}/{login}, those added later included), whatever itsinheritToChildren, so certificates,myAccessand the SSH sweep follow it; plans forssh-loginanswerUNSUPPORTED_FILTERwhile a host is labeled. - Labels on
model/{name}in any tenant above the deciding one apply too, so a model labeled where it is defined is refused in the projects that inherit it (inference calls,listMine, plans). - An application action on
iam/{type}/{id}(plugin endpoints included) also applies whatresolveResourceanswers for{type}/{id}itself, itsclassificationand the parent it reports, and refuses when that resolver fails, so the alias never reads lower. - Label types must be resource type names (no
/,INVALID_INPUTotherwise), so{type}/{id}keys can no longer collide and block labeling a resource whose id holds a slash. updateSchemenever re-ranks a level, in use or not (labels your resolver asserts and adefaultLabelmay use any), and a removed level comes back only at its former rank (RESOURCE_IN_USE); the scheme remembers removed levels (at most 500).requireLabels: ['*']no longer covers the built-inmodel-tooltype, which refused everyinference:use-tooldecision.- Policy lint treats the
principal.clearance*keys as server keys only when the deployment enables clearances, as before the option existed. - Decisions read the scheme once:
resolvetakes the scheme the prepared decision already loaded (preparedLabeling), and the root check is not repeated. - Docs:
declassifyof a label written under another scheme still needs a clearance that dominates it as the scheme in force reads it; only a label naming what the scheme does not define may be repaired by anyone with the permission.
0.3.0 (2026-10-07)
Third release: 24 packages. Three new access-governance domains: B2B guest collaboration (outside people invited with a sponsor, an end date and cross-tenant admission rules), license management (products, seat pools, assignments, seniority and waiting lists, and principal.licenses / identity.licenses for policies and birthright packages), and security clearances with mandatory access control (classification schemes, adjudicated clearances and compartment read-ins, IAM-held resource labels, and a "no read up" check on every decision path, off unless the new clearances option is set). Threat detection gains list filters and a classified-access rule, and installing better-iam no longer installs every framework. Breaking: guest, guestSponsorId, homeTenantId and licenses are reserved principal names; with clearances enabled, more names are reserved and root administrators no longer read up on labeled resources.
- Installing
better-iamno longer installs every framework:@better-iam/next,@better-iam/react,@better-iam/vue,@better-iam/svelte,@better-iam/nestjsand@better-iam/react-routermark their framework peers optional inpeerDependenciesMeta(the umbrella already did), so npm stops auto-installing Next.js, React, Vue, Svelte, NestJS and React Router for apps that use none of them. Apps using a binding keep their framework installed as before. - Security clearances on the decision path (only with the new
clearancesoption; without it nothing changes): the first step of every evaluation is the mandatory check, keyed on the evaluated action (neveriam:*), so no grant, relationship, ownership or delegation confirmation opens a labeled resource above the session's clearance, and the refusal (CLEARANCE_REQUIRED, masked asACCESS_DENIEDwherever reasons are masked) uses nothing up.resolveattaches every non-platform resource's effective label as the server-ownedResolvedResource.classification(nullwhen unlabeled);resolveResourcemay return aclassificationof its own, which can only raise the label. Root keeps its override except on labeled application resources (clearances.appliesToRoot, default true): its prepared decision then becomes an evaluator, recognized everywhere through the newisRootOverridehelper (usage, audit, cross-tenant SSH certificates and their sweep, credential requests), withdecideOnas the one way to apply a prepared decision. Policies seeprincipal.clearanceLevel,principal.clearanceRank,principal.clearanceStatus,principal.clearanceCompartmentsandprincipal.clearanceCitizenship, andresource.classification,resource.classificationRank,resource.compartments,resource.nofornandresource.releasableTo(written after the resource's attributes; policy lint knows them, the resource keys through the newPolicyLintContext.clearances;policies.testdefaults them to an uncleared person and an unlabeled resource).listAccessiblereads each type's labels in one batch, data filter plans (root's too) AND a clearance filter in (corePlanInput.mandatory) and refuse conditions on the derived resource keys (PlanInput.derivedResourceKeys,UNSUPPORTED_FILTER), SSH logins and hosts and credential types carry their labels like resolved resources,accessPaths.findoffers no path for a clearance refusal, and a clearance refusal is audited with{ mandatory: 'clearance' }. Enforced access invariants are re-checked aroundiam:clearances:adjudicate,iam:classifications:manageandiam:classifications:declassify.AgentDecisionScopegainsagentIds(the acting agent and every agent of a hand-off chain), andisServerOwnedKey(key, clearances)treats the clearance keys as the server's only when the option is set.- Breaking only with the option:
clearanceLevel,clearanceRank,clearanceStatus,clearanceCompartmentsandclearanceCitizenshipbecome reserved principal names andclassification,classificationRank,compartments,nofornandreleasableToreserved resource attribute names (INVALID_CONFIGat startup,INVALID_INPUTfor tenant-registered types), and values of those keys thatresolveContextor a plugin supplies are removed before the server sets its own. - Breaking only with the option: root administrators no longer read up. With
clearances.appliesToRootleft at its default (true), root is refused (CLEARANCE_REQUIRED,ACCESS_DENIEDto callers) on every labeled application resource above its own clearance, inauthorize,listAccessibleand data filter plans alike; a clearance is adjudicated in its holder's own tenant, so root holds one only under a scheme the root tenant defines, and under an organization's scheme it reads none of that organization's labeled resources. Everyiam:*action keeps the override, cross-tenant SSH staysROOT_SSH_RESTRICTED, andappliesToRoot: falserestores the old override. In a tenant whose scheme requires labels, a plugin endpoint with its own (non-iam:) action on aniam/{type}/{id}record of a required type is refused until that record is labeled (or the scheme sets adefaultLabel), asauthorizeis. - Fail closed at the edges: a stored label written under another scheme (a tenant moved below an ancestor that defines its own) refuses every party, and only
declassifyreplaces it; data filter plans for application (unmanaged) types answerUNSUPPORTED_FILTERwhile any label in the tenant passes down to children, since the parents a resolver reports are invisible to a filter; an application action oniam/{type}/{id}gets the answer the named resource gets, a missing required label included; andrevokeanddebrief, which shareiam:clearances:adjudicatewith grants, are never blocked by an enforced "expect allow" access invariant ("expect deny" ones still apply). - Enforcement review fixes:
policies.effectiveActions,policies.whoCan, access invariants andimpact.previewoniam/{type}/{id}with an application action, and plugin endpoints with their own action on a record they name, now apply the named resource's label asauthorizedoes (they resolved it without one and reported, or allowed, access the label refuses; only a platform resource resolved for aniam:action goes unlabeled), and the alias also joins what was resolved for it (a resolver's ownclassification, the parent its attributes report), which it ignored; and a resource inherits the labels of both its registration's parent and the parent its attributes report, so registering a tenant type named like an application type (tenant-defined mode) and a record of a resource no longer drops what the application's reported parent passes down, and such a type is planned as an application type for the application's actions (UNSUPPORTED_FILTERwhile labels pass down, or when the scheme requires labels on it).
- Breaking only with the option:
- Security clearances, the domain and API (new
clearancesoption,{ appliesToRoot?: boolean }, validated at construction; without it the newclearancesAPI group answersFEATURE_DISABLED(403) for every method and nothing is read): a tenant defines one classification scheme (defineSchemefrom theus,uk,natoorcorporatetemplate or a definition,updateScheme,getScheme,templates) with ranked levels, compartments, owner countries, the NOFORN and REL TO caveats, the resource types that must carry a label (requireLabels,*for every application type) and an optionaldefaultLabel, aguestCeiling,interimAllowedand theadjudicationmode. The scheme in force is the one defined closest to the root, so defining one below or above another isCONFLICT; new levels go above every kept level and a level or compartment that a live clearance or a label uses cannot be removed or re-ranked (RESOURCE_IN_USE). Officersgrant,update,readIn(optionally backed by an NDA agreement: the compartment counts only while the person's acceptance of its current version is current),debrief,suspend(iam:clearances:suspend, no recent sign-in, for incidents),reinstateandrevokeone clearance per identity: level, adjudicated citizenship (the only source of NOFORN and REL TO decisions, never identity attributes), interim status, investigation, reinvestigation date and end;get,list,explain(officers only: which dimension a person fails on a resource) andmine(no permission). Nobody adjudicates their own clearance, owners and root included (an auditedACCESS_DENIED); guests never adjudicate, are held to the guest ceiling and are never read into compartments; underwithin-ownadjudication an officer grants only levels and compartments their own clearance holds, except that an owner or root may bootstrap one nobody in the scheme's subtree holds yet (audited withbootstrap: true). Status, expiry, the guest ceiling and NDA acceptance are evaluated at decision time. Labels are IAM-held and outlive their resources:labelonly raises (iam:classifications:label),declassifylowers or removes (iam:classifications:declassify, recent sign-in, and only someone whose clearance dominates the current label),getLabelandlistLabels; a label markedinheritToChildrenapplies to every managed descendant (16 levels at most, fail closed beyond). Offboarding terminates the leaver's clearance and debriefs every compartment (clearancesTerminated, reported when non-zero), deletion keeps it as terminated history, and the instance jobiam.clearances.sendReminders()(daily) emails the scheme tenant's owners and the scheme'snotify.emailsabout reinvestigations due and clearances ending within 60 days (templateclearance-reminder); suspensions, reinstatements and revocations email the person (templateclearance-status, unlessnotifyPerson: false). Both templates name the level only, never compartments; link builderlinks.clearances. Permissionsiam:clearances:read,iam:clearances:adjudicate,iam:clearances:suspend,iam:classifications:manage,iam:classifications:labelandiam:classifications:declassifyoniam/clearances[/{identityId}],iam/classifications/schemeandiam/classifications/labels[/{type}/{id}]; audited asclearance:grant,clearance:update,clearance:read-in,clearance:debrief,clearance:suspend,clearance:reinstate,clearance:revoke,clearance:terminate,clearance:reminder,classification:scheme-define,classification:scheme-update,classification:labelandclassification:declassify, with level and compartment ids only. New tenant-scoped collectionsclassificationSchemes,clearancesandresourceLabels, purged with their tenant; exportsattachLabel,clearanceScope,labelsForType,effectiveScheme,labelKey,clearanceCollections,clearanceLimitsand the module's types.- Review fixes: under
within-ownadjudication,reinstatealso requires the officer to hold every compartment the suspended clearance is read into (reinstating gives them back), so an officer outside a compartment can no longer restore someone's access to it; and a clearance revoked for cause staysrevokedthrough offboarding (noclearancesTerminated) and identity deletion, instead of being rewritten asterminatedand dropping out oflist({ status: 'revoked' }). - Console (the console's configuration enables
clearances): Security › Clearances shows your own clearance and the register (level, status and what decisions count, read-in count, reinvestigation and end dates; filtered by status, level or ending soon, with tiles for clearances in force, suspended, not counted, ending and reinvestigations due) and grants clearances; each person's clearance page shows the record, read-ins with their NDA and whether they count, and the history from the audit trail, and grants, changes (only the fields edited are sent), reads in and debriefs, suspends (optionally linked to a threat incident, without telling the person), reinstates and revokes, asking for the password again where a recent sign-in is required. Security › Classification defines the scheme from a template or a definition, edits its settings, default label, levels and compartments, lists labels with raise and declassify forms, labels a resource, looks up a resource's own and inherited label, and gives officers theexplaintool. Member pages gain a Clearance card. Every form is offered only to thoseauthorizeManyallows, and compartment names reach only holders ofiam:clearances:read(others see compartment ids). The console runsiam.clearances.sendRemindersdaily. - Docs: new guide
docs/security-clearances.md(the model and the Bell-LaPadula simple security property, what is and is not enforced, turning it on and what breaks, schemes and templates, clearances and adjudication with the bootstrap, labels and inheritance, parties per session kind, root, policy keys, listings, data filters and reviews,explain, incidents, reminders, emails and limits), linked from the README;docs/events.mdgains Security clearance events (everyclearance:*andclassification:*action and the{ mandatory: 'clearance' }refusal marker),docs/policies.mdthe clearance and classification context keys and the check in the evaluation order,docs/data-filtering.mdthe clearance filter, anddocs/security.mdthe root note. The docs site gains theclearancesAPI reference (descriptions/api/clearances.md, listed under Access model) and theiam.clearances.sendRemindersjob,CLEARANCE_REQUIREDwith the error codes and in the decision reason tables, the clearance step in the decision order, and clearance notes onauthorize,listAccessible,planResources, thefiltersgroup,whoCan,FEATURE_DISABLED,RESOURCE_IN_USEandUNSUPPORTED_FILTER.docs/api-reference.mdis regenerated (adds theclearancesandlicensesgroups). - Threat detection: a new rule
classified-access-attempts(high, MITRE ATT&CK T1213, on by default: 3 within an hour, tunable from 1 to 1000 within one minute to seven days) counts clearance refusals (denyevents marked{ mandatory: 'clearance' }) per responsible actor, the administrator behind a "view as" session included, and reports the actions and how many resources, never a label (threats.ruleslists 22 rules). A "view as" refusal caused by the administrator's own clearance is nowCLEARANCE_REQUIREDrather thanIMPERSONATOR_DENIED, so it is audited with the marker and counted against the administrator. A new response actionsuspend-clearance(threats.respondand playbooks) suspends the identity's clearance until an officer'sclearances.reinstate(threats.releasenever touches it). By hand it also needsiam:clearances:suspendoniam/clearances/{identityId}(checked before anything about the clearance is read; never while impersonating or as a guest); playbooks skip owners and root administrators (protected), people without a clearance (no-clearance) and suspended ones (already-applied); and writing a playbook with it, adding it to one, changing such a playbook's trigger or switching one on needsiam:clearances:suspendoniam/clearances. Without the option it isFEATURE_DISABLED(a stored playbook's is skipped asfeature-disabled). Audited asclearance:suspendandthreat:suspend-clearance; the person is not emailed. The console offers it on incidents and in playbooks to people who may suspend clearances.
- Review fixes: under
- Threat detection API:
threats.listIncidentstakesstatusas one status or a list (['open', 'investigating']lists every active incident in one call, ordered and paged together);threats.timelinetakesuntil(epoch milliseconds, aftersince;sincethen defaults to seven days before it) to bound the window; detections and incidents returned by thethreatsgroup give an identity subject of the tenant itskind(user,service, oragent, looked up when read and never stored; new typesThreatSubjectView,ThreatDetectionView,ThreatIncidentView); andthreats.reportSuspiciousandthreats.configurename people in their detections as the detection rules do,Carol (carol@acme.test)rather thanCarol. The console's threats overview reads active incidents with one call (and offers an "open or investigating" filter on the incidents page), links agent subjects to agent pages, and bounds an incident's identity timeline to the incident's window. - B2B guest collaboration: a new
guestsAPI group invites people from outside the organization (or from another tenant) as guests. A guest is an ordinary person of the host tenant with a server-ownedIdentity.guestmarker (GuestProfile: sponsor, since, home tenant and domain), so bindings, groups, packages, separation of duties, invariants, certifications and offboarding apply unchanged.inviteemails a personal invitation (templateguest-invitation, link builderlinks.guestInvitation) naming a sponsor (the caller by default: an active member who is not a guest), optional roles, groups and access packages authorized likeidentities.invite, how long access lasts (accessDays, default 90, at most 365) and how long the invitation stays valid (1 to 30 days, default 14); one pending invitation per address. The publicredeem(rate limited, sets the session cookie) re-checks everything, creates the guest with a password and a verified email, grants the roles, groups and packages under the inviter's authority until the guest's access ends, applies separation-of-duties rules and enforced invariants, runs birthright rules, and signs the guest in.listInvitations,revokeInvitation,resendInvitation(sends only an invitation that can still be redeemed: the settings, the sponsor and the inviter's rights are checked again),list(by sponsor, status, or access ending soon, with the last sign-in),get,attest(the sponsor renews access without any permission, others neediam:guests:manage; guests renew nobody's access; what the invitation granted is renewed with it),setSponsor,remove(disables the guest and removes everything they hold),convertToMember,mine(the guests a person sponsors),getSettingsandconfigure(unknown settings are refused). Cross-tenant access settings per tenant: inbound (allowGuests, allowed and blocked domains, partner organizations) and outbound (allowGuestInvitations, partners) rules, where an address on a domain another tenant verified is that tenant's member and needs both sides to allow it; the default access and review periods; and aguestBoundaryceiling on every guest's decisions. Policies seeprincipal.guest(always),principal.guestSponsorIdandprincipal.homeTenantId; birthright package rules gainidentity.guest, and rules (also onboarding and workflow scopes) that never test it no longer match guests. The instance jobsiam.guests.sendReviewReminders()(templateguest-review; each review date and each access end is reminded once) andiam.guests.sweep()(lapsed invitations, ended guests, missing sponsors, templateguest-sponsor-missingto owners) run hourly. Offboarding hands a leaver's guests to the successor (guestsReassigned,guestsUnsponsored, reported when non-zero), deletion flags them, a guest's email address cannot be changed (by administrators or through the guest's ownauth.requestEmailChange), and redeemed, revoked and expired invitations are swept 90 days after they lapsed. Permissionsiam:guests:read,iam:guests:invite,iam:guests:manageandiam:guests:settingsoniam/guests,iam/guests/{identityId},iam/guests/invitations[/{id}]andiam/guests/settings; audited asguest:invite,guest:invitation-revoke,guest:invitation-resend,guest:redeem,guest:attest,guest:sponsor-change,guest:sponsor-missing,guest:remove,guest:convert,guest:settings,guest:expireandguest:review-reminder. New tenant-scoped collectionsguestInvitations,guestAccountsandcrossTenantAccess, purged with their tenant; new typesGuestProfile,GuestView,GuestPage,GuestInvitationView,GuestInviteInput,GuestSettings,GuestSettingsInput,GuestSettingsView,GuestRemoval,GuestReminderResult,GuestSweepResultandIamGuests. New error:GUEST_NOT_ALLOWED. Guide:docs/guests.md.- Breaking:
guest,guestSponsorIdandhomeTenantIdare reserved principal names, so a deployment that declares an identity attribute with one of those names fails at startup withINVALID_CONFIG. Values ofprincipal.guest,principal.guestSponsorIdandprincipal.homeTenantIdthatresolveContextor a plugin supplies are now removed before the server sets its own. - Console: Directory › Guests lists the guests with their sponsor, access end, next review and last sign-in (filtered by sponsor, status or access ending soon, with tiles for guests needing a sponsor, reviews due and access ending), shows each person the guests they sponsor with one-click renewal, and invites guests with roles, groups and packages; a guest's page renews access, changes the sponsor, makes the guest a member or removes them, and shows what the invitation granted. Guest invitations can be filtered, sent again or revoked, and Directory › Cross-tenant access edits the inbound and outbound rules, access and review periods and the guest boundary. Member pages show a Guest badge and a Guest card with the sponsor. Invitees accept at
/cloud/join?kind=guest. The console runsiam.guests.sweepandiam.guests.sendReviewRemindershourly. - Review fixes: an invited address's domain is compared, stored and emailed in canonical form (lowercase, punycode, no trailing dot), so
gil@globex.test.or an internationalized spelling no longer slips past verified domains, blocked domains, partner entries and the home organization's outbound settings, at invitation or redemption (a domain that is not a DNS name isINVALID_INPUT). Naming someone else as the sponsor needsiam:guests:manage, and an assumed role cannot invite (redemption could never re-check its rights). Guests never make anyone a member or choose a sponsor, themselves included. Renewing a guest who owns the organization, or clearing their end withconvertToMember, needs an owner or root caller, asidentities.updatedoes.removereleases the guest's license seats at once. Redemption counts the client's budget before the organization-wide one. An overdue review is reminded once also for a guest without an end. Delegation tokens for a guest's delegations stay within the guest boundary, when issued and on live verification. The workflowdisablestep revokes the guest invitations a person sent or sponsors, asidentities.setStatusdoes. Inviting a former guest's address explains that their disabled account must be deleted first.
- Breaking:
- License management: a new
licensesAPI group tracks who holds a seat of what you sell or buy. Products (createProduct,updateProduct,retireProduct,listProducts,getProduct) have a permanent key, a name, a description and up to 50 feature keys; the root tenant defines platform products that every tenant sees (root administrators only), and any other tenant defines products for itself and the tenants below it, never repeating a key an enclosing tenant defines (a later enclosing key marks the lower productshadowed). Retiring a product releases its seats in every tenant and keeps its pools and assignments as read-only history. Pools (addPool,updatePool,removePool,listPools) give a tenant seats of a product, optionally fromstartsAtuntilendsAtand referencing the billing subscription that paid for them; capacity is granted by the product's owner (root for platform products, a tenant's managers for its own products, to itself or a tenant below it) and never by the tenant that consumes it, and ended pools are kept 400 days as purchase history. Products are assigned to people of any kind and to groups (assign,assignManyfor up to 100 people,unassign,listAssignments; a team through its backing group). Seats are materialized per person (listSeats): the claimants (direct assignees and live members of assigned groups, active and unexpired identities, guests included) are ranked by seniority, the firstcapacityhold an active seat and the rest wait in order with theirposition, and a capacity cut moves the newest active seats back to the waiting list instead of failing. Seats move in the same transaction as assignments, pool changes, retirement, group membership changes from every writer (groups, teams and team sync, SCIM, packages), group deletion and identity deletion, and right after disabling or offboarding a person.usagereports capacity, active, waiting, available and reclaimable seats per product;minegives people their own licenses, waiting places and feature keys without a permission (not audited);getSettingsandconfigureturn on inactive-seat reclaim (reclaimAfterDays, 7 to 365) and waiting-list email (notifyWaiting, templateslicense-waitingandlicense-activated). What a license unlocks uses existing governance: the policy context keyprincipal.licenses(the keys of the products the principal holds an active seat for in the decision's tenant, read only when a document names it; assumed roles hold none; simulations,whoCanand access invariants see the real seats;policies.testdefaults it to[]; policy lint knows it), the birthright package rule keyidentity.licenses(product keys, checked when a package is saved and by bothconfig.planandconfig.apply; seats held only through memberships a package created do not count;autoAssign.warningsflags rules that test it; a rule whose product disappears is suspended), andiam.licenses.features(identityId, tenantId)/iam.licenses.products(identityId, tenantId)for application code. Scheduler jobsiam.licenses.reconcile({ tenantId? })(hourly: pool terms, lapsed memberships and accounts, repairs; birthright packages follow for the people whose seats changed) andiam.licenses.reclaim({ tenantId? })(daily: removes the direct assignments of people with no sign-in or credential use forreclaimAfterDays). Assignment and pool changes run the tenant's enforced access invariants, and capacity granted from an enclosing tenant runs the receiving tenant's as well (INVARIANT_VIOLATION, nothing saved). Permissionsiam:licenses:read,iam:licenses:manageandiam:licenses:assignoniam/licenses/products[/{id}],iam/licenses/pools[/{id}],iam/licenses/assignments,iam/licenses/seatsandiam/licenses/settings(creating and retiring products need a recent sign-in); audited aslicense:product-create,license:product-update,license:product-retire,license:pool-add,license:pool-update,license:pool-remove,license:assign,license:unassign,license:seat-activate,license:seat-waiting,license:seat-release,license:reclaimandlicense:settings. CollectionslicenseProducts,licensePools,licenseAssignments,licenseSeatsandlicenseSettings, removed with their tenant;iam.sweepExpired()removes pools 400 days after they ended. ExportslicenseCapacity,licenseCollections,licenseContextKey,licenseKey,liveLicensePool,maxLicenseProductsPerTenant,maxLicenseFeatureKeys,maxLicensePoolsPerProduct,maxLicensePoolQuantity,maxLicenseAssignmentsPerProductand the module's types. Guide:docs/licenses.md.- Breaking:
licensesis a reserved principal name, so a deployment that declares an identity attribute calledlicensesfails at startup withINVALID_CONFIG, andprincipal.licensesvalues supplied byresolveContextor a plugin are removed before the server sets its own. - Console: Organization › Licenses shows seats in use per product, every product the organization can use, the waiting list, and the reclaim and waiting-list email settings, and lets managers define products; each product's page shows its pools (seats granted to the organization or its projects, changed and removed there for the organization's own products), the people and groups given it (one, several at once, or a group), the active seats with who has been inactive, and the waiting list in order. Member pages gain a Licenses card that gives or takes back a product. The console runs
iam.licenses.reconcilehourly andiam.licenses.reclaimdaily. - Review fixes: when an enclosing tenant (the platform included) defines a key a tenant below already uses, the enclosing product wins: the lower product is
shadowedand its seats no longer grant the key or its feature keys (principal.licenses,identity.licenses,iam.licenses.products/features,licenses.mine), so no tenant can claim a key such asenterprisefirst and mint seats that count as the platform's. A tenant moved out from under a product's definer (tenants.reparent) stops holding it at once, the nextiam.licenses.reconcile()releases its seats (assignments stay), and the definer can stillremovePoolthe capacity it granted there. Changing the members of a group a product is assigned to (or of a group that team sync copies into a licensed team) throughgroups.addMember,addMembersandupdateMember, packages, workflows or configuration sync needsiam:licenses:assignas well, and theidentity.licensesrule warning now names team maintainers and directory sync as the other ways in. Seats now follow the backing groups team sync fills (the synced teams' and those above them), accepted member invitations into licensed groups, SCIM deactivation, reactivation and DELETE of a person (new optional SCIM host callbackidentityStatusChanged), and SCIM DELETE of a group, which also removes the group's license assignments (callbackgroupDeleted), all in the same transaction.groups.addMembers, offboarding and configuration sync reconcile each product once per batch instead of once per membership. The reclaim job leaves the assignments of retired products alone. - Feature keys and seat claims:
iam.licenses.features(identityId, tenantId, { trustedTenantId? })counts only products defined by the trusted tenant or a tenant above it, the root tenant (platform products) by default, so a tenant can no longer mint a paid feature by defining a product of its own that lists its key.licenses.minemarks each licenseplatformanddefinedBy; its top-levelfeatureKeysnow holds platform products' keys only, andallFeatureKeys(new) every product's. Every other writer that puts people into a licensed group now needsiam:licenses:assigntoo (ACCESS_DENIED, audited as a denial):identities.createManyandidentities.invitewithgroupIds(re-checked at acceptance, and byidentities.resendInvitation, which now refuses withINVITATION_INVALIDto send an invitation acceptance would refuse), guest invitations (re-checked at redemption),guests.convertToMemberwithclearExpiryfor a licensed group the guest would stay in for good, access packages (assign, approve, extend, saving a rule; an automatic rule whose owner lacks it has its additions suspended as an issue), onboarding completion groups, team administrators (iam:teams:update) on a licensed team or one below it, and configuration sync. Team maintainers, directory sync (SCIM) and renewing a guest (guests.attest, by the sponsor or an administrator) stay exempt by design; a renewal now lengthens only the memberships the invitation created at redemption (GuestGrants.packageMembershipIds), so a group a package gained since, or a membership the guest holds by hand or through another package, keeps its end.
- Breaking:
0.2.0 (2026-09-24)
Second release, and the first under Apache-2.0: 24 packages, with the new @better-iam/ldap. New security services (key management, secrets vault, tokenization, private CA, SSH certificates, verifiable credentials, an LDAP gateway, identity threat detection, device posture, a Shared Signals receiver), governance additions (privacy and consent, lifecycle workflows, a compliance center, an application catalog), data filtering and API usage plans, a redesigned documentation site, and fixes from three security reviews.
- Relicensed under the Apache License 2.0 (was MIT): the root and every package's
LICENSEfile,licensein every manifest (validate-packagesnow requiresApache-2.0), a rootNOTICE, and the READMEs and docs. Version 0.1.0 on npm remains MIT; the next published version carries Apache-2.0. Package code is otherwise unchanged since 0.1.0. - Redesigned the documentation site in black and white on BoardUI. The BoardUI components and design tokens are source files in
apps/docs, and a monochrome layer (styles/monochrome.css) makes controls black in light mode and white in dark mode. Fumadocs' palette maps onto the same tokens, so the docs and the landing pages match. There are no radial gradients: states are told apart by weight (solid, outlined, dashed, hatched), code blocks and Mermaid diagrams are grayscale, and social cards are monochrome. - Icons come from react-icons: Remix Icon (the set BoardUI is drawn with) through the
lib/icons.tsregistry for frontmatter,meta.jsonand MDX, and Simple Icons for framework logos in the sidebar, the framework cards and the landing page.lucide-reactwas removed. - New landing page layout: every band sits between the same two page-length rails with crosshairs where they meet section lines, and marketing pages scroll smoothly with Lenis (in-page links glide to their section below the sticky bars).
- A new hero: an editorial headline and a walkthrough panel whose three steps (Authenticate, Authorize, Audit) play real code beside a live result. The audit scene verifies its chain in the browser with
verifyAuditChain. - The pipeline chapter is now a compact trace: a timeline sized to each stage's share of the request auto-advances, and stage panels slide sideways beside the code. It is no longer pinned to page scrolling.
- Every chapter is more interactive, with hover states and scroll-in animations: the decision demo uses BoardUI switches and lights its checks in order, clicking a tenant suspends it down the tree, the elevation timeline can be scrubbed, the audit chain breaks visibly when edited and can be restored, and federation lanes light up on hover.
- Diagrams that play by themselves hold while the pointer is over them.
- A new hero: an editorial headline and a walkthrough panel whose three steps (Authenticate, Authorize, Audit) play real code beside a live result. The audit scene verifies its chain in the browser with
- BoardUI-styled search trigger, theme toggle, callouts, cards and page components in the docs, and a restyled policy playground. BoardUI's MIT notice is in
apps/docs/THIRD-PARTY-NOTICES.md. - The site header's call to action is now a GitHub button, and repository links (GitHub, "Edit this page", source files, issues) default to github.com/Better-IAM/better-iam.
- Credits Sean Filimon, the creator of Better IAM, in page metadata, the site footer, the hero, the docs sidebar, at the end of every docs page, on social cards, in
llms.txt, in the FAQ, and on the Introduction page. - Search and social metadata for the documentation site. Favicons (SVG that follows the light or dark color scheme, a 16/32/48 px ICO, and an Apple touch icon), a web app manifest with regular and maskable icons, and the same browser icons in the console. Every page has a canonical URL, keywords, and complete Open Graph and X card tags. Social cards are redesigned 1200×630 images in the site's typefaces, with new cards for the home page and the playground. Docs pages add article tags, Slack preview labels, and schema.org
TechArticleandBreadcrumbListdata, and the home page describes the site and the software in JSON-LD. Docs frontmatter gains an optionalmetaTitlefor pages whose sidebar title is too terse on its own (31 pages use it), API reference groups are titled "{group} API", and 9 over-long descriptions were shortened to fit search snippets. Optional build variables:DOCS_X_HANDLEand search console verification tokens. The Markdown copy of each page sends a canonicalLinkheader, the 404 page is titled "Page not found", and the console is markednoindex. - Every published package has its own npm description and more search keywords. Most packages previously shared one generic description.
- Search engine indexing for better-iam.com.
www.and Railway-generated hosts redirect permanently to the canonical origin, and/docsnow redirects to/docs/introductionpermanently instead of temporarily.llms.txtandllms-full.txtare markednoindex. Bing and the other IndexNow engines get changed pages after every successful Railway deploy (apps/docs/scripts/indexnow.mjs,.github/workflows/indexnow.yml). - Key management (KMS): a new
keysAPI group gives each tenant keys whose material never leaves the server. AES-256-GCM keys encrypt and decrypt up to 4 KiB, bound to an encryption context, and issue data keys for envelope encryption;reEncryptmoves ciphertexts between keys. ECDSA (P-256, P-384), Ed25519 and RSA keys sign and verify, RSA keys can also encrypt with OAEP, and HMAC keys compute and check MACs.signJwtandverifyJwtwork with signing and MAC keys, andpublicKey/jwkspublish public keys for offline verification. Keys have aliases, tags, versions with on-demand and automatic rotation, disable/enable, and deletion after a 7 to 30 day waiting period. Policies authorizeiam:kms:*actions oniam/kms/{keyId}with conditions on key tags, spec, aliases, encryption context and algorithm, and key grants delegate named operations to one identity, re-checked against the grantor's own access on every use. Alias names are authorized oniam/kms/alias/{name}, andsignJwtexposes the token'ssub,issandaudto policies. Every call is audited without plaintexts, and plugins never see plaintexts or tokens. Material is sealed under the deployment secret and re-sealed byiam.rotateSecrets(). The scheduler jobiam.kms.maintain()rotates keys, destroys deleted ones and removes lapsed grants. New errors:KEY_STATE_INVALID,INVALID_CIPHERTEXT,KEY_MATERIAL_UNAVAILABLE. Guide:docs/key-management.md. - Private certificate authority: a new
pkiAPI group runs X.509 certificate authorities per tenant, for mutual TLS, internal HTTPS and workload identity. Roots and intermediates (path lengths, name constraints, CRL distribution points) sign with KMS keys pinned to one version.issueCertificatetakes a PKCS#10 request (createCertificateRequestbuilds one without OpenSSL) and decidesiam:pki:issueseparately for every name on the certificate (resource.name,resource.nameType, validity, usage, key type), so policies can limit teams to their own DNS names.requestCertificategives a service account, agent or person a SPIFFE X.509-SVID for its own identity. Revocation (a subordinate CA's included), cached signed CRLs served publicly throughiam.pki.crlResponse, trust bundles, andiam.pki.verifyfor servers that terminate mutual TLS (it checks the stored chain, revocation and authority state, and returns the workload identity). New errors:INVALID_CSR,NAME_NOT_PERMITTED,AUTHORITY_UNAVAILABLE,CERTIFICATE_INVALID. Guide:docs/private-ca.md. - Data protection by tokenization: a new
protectionAPI group replaces sensitive values with tokens. Profiles per data type (card,ssn,email,phone,generic) choose format-preserving tokens (a card token keeps its length and last four digits but never passes the Luhn check) or randomtok_tokens, deterministic tokens for joins, a display mask and a retention. Values are validated, normalized, and encrypted with one data key per call wrapped under a tenant KMS key.tokenize,detokenize(with a requiredpurpose, visible to policies asresource.purpose),maskanddeleteTokens(by token, or by value through a keyed fingerprint, for erasure requests) are separate permissions oniam/protection/{profile}. The audit trail records counts and purposes, never values or tokens.iam.protection.sweep()enforces retention. Guide:docs/data-protection.md. - Key management, private CA and data protection hardening (security reviews): keys a certificate authority or a data protection profile uses are managed by it (
managedBy,KEY_MANAGEDfor direct KMS use); changing a key's tags or aliases can never give the caller a KMS action they are refused now; delegated agents use up one confirmation per KMS, CA or data protection call; refusals are audited with the profile and purpose. The CA decides canonical names (wildcards asnameType: wildcard), requires the common name among the alternative names, reserves SPIFFE identity paths forrequestCertificate, caps SVIDs at a day and at the credential and identity lifetimes, keeps trust domains unique, enforces path lengths over the whole chain, revokes descendant authorities with their parent, bundles roots only, andiam.pki.verifyrequirestenantIdand a live identity. Data protection masks never show more than half a value (except first six and last four digits of 15+ digit cards), mask styles are per data type and policies see the style used, letters and digits of every script are masked and replaced, text is NFC-normalized and control, bidi and unpaired-surrogate characters are refused, format-preservingssn/phoneprofiles are deterministic, SSN tokens avoid ITIN ranges, tokens are unique per profile (hashed key), the lookup key is wrapped under the profile's KMS key, customer keys need the caller's own KMS permission on every call,tokenizeno longer returnscreated, shortening retention needsiam:protection:delete, sweeps run in batches and are audited, profiles reporttokens, and deleting a profile retires its key. Migration0006_protection_indexesindexes value fingerprints (SQLite and libSQL). - Secrets vault: a new
vaultAPI group keeps each tenant's secrets under path-like names, authorized asiam:vault:read|reveal|write|rotate|lease|manageoniam/vault/secrets/{name}, with the secret's tags as policy attributes (resource.tag.{key}). Secrets aretextorjson, versioned with stage labels (current,previous,pending, custom), kept tomaxVersions, and can be disabled or destroyed per version. Rotation stages a generated value (CSPRNG generators with charsets, exclusions and character classes), lets a deployment-configured rotator apply it to the system it unlocks, and promotes it; failures keep the pending value for an idempotent retry and are recorded redacted. Check-out policies hand shared privileged credentials out one holder at a time, for a limited time, with a reason, rotating them when they come back. Dynamic secrets mint a credential per caller through an engine (vault.engines) with renewable, revocable leases. Values are sealed with the deployment secret, bound to tenant, secret and version, or under a customer-managed KMS key (kmsKey), so disabling the key shreds them. Deletion has a 7 to 30 day recovery window.accessLogshows who used a secret. Server code reads secrets and resolvesvault://name#fieldreferences throughiam.vault, whose jobsrotateDue,expireLeasesandpurgeDeletedrun the schedule. Values never leave through "view as" sessions. New errors:SECRET_PENDING_DELETION,CHECKOUT_REQUIRED,SECRET_CHECKED_OUT,VERSION_DISABLED,VERSION_DESTROYED,ROTATION_FAILED,ENGINE_FAILED. Guide:docs/secrets-vault.md. - Data filtering (query planning):
iam.planResourcesand thefiltersAPI group (plan, andplanForfor an administrator's preview withiam:policies:simulate) answer "which resources of this type may the caller perform this action on?" as a filter over the resource'sidand attributes, by partially evaluating the same grants, denies, boundaries, relationships, sessions and delegations a decision uses (always,never, orconditional). The planner,planResourcesin@better-iam/core, mirrors the engine exactly (a randomized differential test compares it withevaluatePolicyand with SQLite), and the filter compiles withfilterToSql(PostgreSQL and SQLite, parameterized and NULL-safe),filterToPrisma,filterToMongo,filterMatchesanddescribeFilter. The Express, Hono, Fastify, SvelteKit and React Router integrations gainplan({ action, type })on their per-request helpers, and the console a Data filters preview page. Prepared decisions expose their inputs read-only (DecisionInputs). New error:UNSUPPORTED_FILTER. Guide:docs/data-filtering.md. - SSH certificate authority (
sshoption): a newsshAPI group makes Better IAM the certificate authority for your servers. People, service accounts and agents get short-lived OpenSSH user certificates (issueCertificate, CLIssh-cert) naming one principal{login}@{host}per host and login their policies allow:ssh:loginonssh-login/{host}/{login}with the host's labels as attributes, andssh:port-forward/ssh:agent-forward/ssh:x11-forwardonssh-host/{host}. Certificates live only as long as the session or API key that requested them and the time-limited grants (just-in-time activations, temporary bindings and memberships, access windows) that allowed them; agents acting for people get ten minutes. Tenant settings can require MFA, FIDO security keys (withverify-required) or source-address binding. Hosts enroll once with a join token (enrollHost, CLIssh-host-enroll), get a host certificate, the trusted user authority, per-login principals files, a key revocation list and an sshd_config drop-in, and check in with a renewal token (syncHost, CLIssh-host-sync) that can never move them to another key. The host authority only vouches for names the organization owns (single labels, private addresses, its verified domains, withinhostPatterns), each unique to one host and within the administrator's owniam/ssh/hosts/{name}scope; host keys can never be authority keys or another host's key; members' clients trust it for the enrolled names only, and the public trust route lists no host names. Revocation is by two KRLs,userfor sshdRevokedKeysandhostfor clients'RevokedHostKeys;iam.ssh.sweep()(CLIssh-sweep) re-validates each certificate's own session and revokes those whose holder became inactive, whose session ended, or whose hosts, logins or forwarding policies no longer allow. Suspended organizations still publish revocations (all user certificates). Renewal tokens rotate with every renewed host certificate, public host routes and issuance are rate limited, and refused host calls are audited. Authorities rotate in two phases (pending, active, previous, retired), are sealed under the deployment secret and re-sealed byiam.rotateSecrets().whoCanLoginreviews host access, and the platform root override never opens an organization's hosts. New errors:SSH_NOT_CONFIGURED,SECURITY_KEY_REQUIRED,SOURCE_ADDRESS_UNKNOWN,TOO_MANY_HOSTS,ROOT_SSH_RESTRICTED,HOST_OUTSIDE_PATTERNS,HOST_NAME_TAKEN,HOST_KEY_IN_USE,HOST_KEY_CHANGED. Guide:docs/ssh-access.md. - LDAP directory gateway: the new
@better-iam/ldappackage (alsobetter-iam/ldap) is a read-only LDAPv3 server (createLdapServer({ iam, tls })) so applications that only speak LDAP (VPNs, NAS devices, CI servers, wikis) look up people and groups and check passwords against Better IAM. Each organization publishes its directory under its own base DN with the newldapAPI group (getSettings,updateSettings,directory; permissionsiam:ldap:read,iam:ldap:manage): people underou=people(uidas email, local part or ID), groups asgroupOfNameswithmember/memberOf, optionally service accounts, and only the identity attributes it selects. People bind with their password as a Better IAM sign-in (rate limits, lockouts, IP rules and alerts apply; people who use MFA append their current code), exactly one attempt per bind; service accounts bind with an API key. Binds without TLS are refused except from loopback, names with empty passwords are refused, and searches are authorized as the bound account: the whole directory withiam:ldap:read(audited asldap:directory:read), otherwise only its own entry. Search (all RFC 4515 filters, paged results), compare and WhoAmI are supported; writes are refused. New error:LDAP_BASE_TAKEN. Guide:docs/ldap.md. - Verifiable credentials (
verifiableCredentialsoption): every organization issues SD-JWT VCs (IETF SD-JWT-based Verifiable Credentials, RFC 9901 selective disclosure) that people keep in digital wallets. Credential types (iam:vc:manage) map account data to claims: email, name, organization, teams, department, declared identity attributes, static values, each selectively disclosable unless marked otherwise. People get credentials for themselves where policies allowvc:requestoncredential-type/{name}(and MFA when a type requires it), through OpenID4VCI (createOfferreturns anopenid-credential-offer://link with a pre-authorized code and optional PIN, served by issuer endpoints mounted at{basePath}/vc/{tenantId}with/.well-knownmetadata, token, nonce and credential endpoints) or directly withrequest; administrators offer them to others (iam:vc:issue). Credentials are bound to a holder key proven with anopenid4vci-proof+jwtover a single-use nonce. Revocation and suspension use an IETF Token Status List with random, never-reused indexes, served asapplication/statuslist+jwt; holders can revoke their own, andiam.verifiableCredentials.sweep()revokes those of people who left or may no longer request them. Self-service credentials end with the time-limited grant that allowsvc:request(expiring bindings and memberships, just-in-time activations, access windows) or the role session they came from, and delegated agent sessions cannot obtain them.verify(public) andiam.verifiableCredentials.verifycheck signature, disclosures, key binding (audience and nonce, both required, freshness,sd_hash) and status. Proof nonces are signed rather than stored, public issuer endpoints only read, wrong wallet PINs and refused redemptions are audited as deniedvc:offer:redeem, and a type'svctmay not claim another organization's issuer. Issuer ES256 keys are sealed under the deployment secret and rotate withrotateKey/retireKey.issueSdJwt,presentSdJwt,verifySdJwt,signStatusList,readStatusListandverifyHolderProofare exported for wallets and verifiers. New errors:TYPE_DISABLED,IDENTITY_INACTIVE,CLAIM_UNAVAILABLE,INVALID_PROOF,INVALID_NONCE,ACCESS_EXPIRING. Guide:docs/verifiable-credentials.md. - Privacy and consent management: a new
privacyAPI group records each tenant's processing purposes with their legal basis (consent opt-in or opt-out, legitimate interests, contract, legal obligation, vital interests, public task), data categories and retention, and each person's decision on them, made by the person (mine,decide), recorded by the application for its own customers (record,importDecisions, oriam.privacy.recordwithout a credential) and checked withcheck,filterSubjects,audienceoriam.privacy.check. Purposes have versions that ask for consent again and optional consent lifetimes. Every decision appends to an append-only history and returns an HMAC-signed consent receipt thatverifyReceiptchecks, across secret rotations. Policies seeprincipal.consents, the purposes that may be processed for the person now. Data-subject requests (access, portability, erasure, rectification, restriction, objection, opt-out) are filed by the person, by staff with a verification record, or through an optional public form confirmed by email (submitPublic,confirmPublic); requests known only by an email address are linked to their subject (linkRequest) or declined asno-data. They run on GDPR, UK GDPR, CCPA, LGPD and PIPEDA deadlines with one extension where the law allows, and fulfilment builds exports (downloadable for a limited time, then swept), erases accounts and privacy records (an erased application subject stays suppressed asERASED), restricts processing, or withdraws consents. Legal holds refuse erasure and every deletion of the person's account.iam.privacy.sendDeadlineReminders()reminds handlers of due and overdue requests and lapses unconfirmed ones. Authorized asiam:privacy:read|manage|record|check|handle; audited asprivacy:*events; emailsprivacy-request-verify,privacy-request-received,privacy-request-updateandprivacy-request-duewith thelinks.privacybuilder. Console pages: Organization › Privacy and Home › Your privacy. New errors:LEGAL_HOLD,CONFIRMATION_INVALID. Guide:docs/privacy.md. - Security fixes from a review of authentication, sessions, temporary credentials and the framework integrations:
- SvelteKit
protectrules now match the path SvelteKit actually routes (percent-decoded, and the matched route id forreroutealiases); before,/%61dminskipped a/adminrule. Remote functions still need their own guards. - A password alone can no longer enroll an authenticator over an existing factor: when a passkey is registered, the MFA challenge answers
enrollmentRequired: false(newauthenticatorEnrolledfield) andbeginMfa/confirmMfafrom the login challenge are refused withMFA_REQUIRED. - NestJS:
@Public()no longer turns@Authorize,@RequireMfa,@Credentialsor@RequireClaimsinto no-ops for anonymous or invalid credentials (401 instead); CSRF checks also cover GraphQL over HTTP; the assertion guard accepts a lowercasebearerscheme. roles.assumerefuses delegated agent sessions (CREDENTIAL_CHAINING_DISABLED), so an agent cannot mint a role JWT that outlives its delegation.- An identity past its
expiresAtis refused at sign-in and on every session use in the authentication service itself, not only in authorization decisions. startPasswordlessreplaces earlier codes and links for the destination; the first emailed sign-in to a self-registered account whose address was never verified ends its sessions and removes the password chosen at sign-up (newIdentity.unprovenPassword, cleared by verifying the address or resetting the password; the owner sets a password through reset), so an account registered in someone else's name cannot be kept by its registrant; verify-email and password-reset links only work for the address they were sent to; emailed MFA codes are stored with a keyed digest; new authenticator secrets are 160 bits.reauthenticaterefuses an expired password (PASSWORD_EXPIRED) and honoursemailPassword: false.- Impersonation sessions also end when the administrator's session idles out (the administrator's tenant idle timeout), when the tenant turns
allowImpersonationoff, or when the member becomes an owner or root administrator. - Lowering
sessionLifetimeMsortrustedDeviceDaysnow also shortens sessions and remembered devices issued under the longer value. Network blocks also judge the address presenting a user session, andbindSessionsToIpalso applies when a user session is the source of a role session. - Passkey sign-in with an email answers an unknown address, or one without passkeys, with the same shape of options as a real account, so it no longer reveals which accounts exist.
- Assertions: scoped credentials (API keys with scopes, role sessions with a session policy) are refused like scoped session tokens, and only the canonical spelling of a signature verifies (server and
@better-iam/nextedge verifier). - Web-identity trusts: a
StringLikepin ontoken.submay use a wildcard only after two complete segments (repo:acme/*is accepted;repo:*, which admits every repository on the platform, andrepo:acme*, which also admitsacme-evil, are refused withWEAK_TRUST_CONDITIONS). Existing trusts are not re-validated: review them withtrust.list. With a recorded client address,sts.assumeRoleWithWebIdentitycountsmaxExchangesPerWindowper trust and address (ceiling: ten budgets per trust), so anyone posting junk tokens to a public trust id can no longer block the real workload. - Delegation tokens never carry a scope that a deny among the person's grants could touch, including denies whose author's authority was revoked (these still apply to decisions).
- Sealed values require a 128-bit GCM tag and a 96-bit IV; delegation-token and agent-card verifiers fail closed on a NaN or out-of-range clock tolerance; web-identity replay records are kept for the largest possible clock tolerance.
Origin: nullis refused by the middleware and Next.js CSRF checks (it equals the.originof trusted entries such ascapacitor://localhost);auth/confirmMfais pinned to the organization address like other sign-in calls; the Fastify and h3 fallback paths cap request bodies at 2 MiB; the Next edge middleware treats only/api/iamand/api/iam/*as the IAM API.- Sign-in notices summarize the User-Agent ("Chrome on Windows", new
describeUserAgent) instead of quoting attacker-chosen text, email subjects are kept on one line, and display names have control characters replaced at creation.
- SvelteKit
- Security fixes from a review of authorization and the management API (each covered by
tests/security-*.test.ts):- "View as" sessions: every check an operation makes on the side (may this role be granted, may this group be changed) now needs the impersonating administrator's rights too, not only the first one. No grant authority can be used while impersonating (
IMPERSONATION_RESTRICTED), and view-as sessions can neither decide access requests nor change or remove bindings the member issued. Before, support staff could "view as" a broader administrator to create accounts with that administrator's roles or approve their own requests. - Policy matching: a pattern's
*now also matches a literal*in the value, so a Deny onfile/shared/*coversfile/shared/*x.?is one character for astral characters on every path. Evaluation has a per-call work budget and refuses with reasonevaluation-limitpast it;*-only globs match by native substring search. One craftedpolicies.testcall could block the server for seconds. - Denies outlive their author: when the authority behind a binding, role or attached policy is revoked (or its administrator offboarded), what it granted lapses and what it denied still applies. An editor can no longer remove or change a deny statement that another authority relies on (it attached the policy, bound the role, or its role inherits it):
PROTECTED_RESOURCE, unless root. - Separation-of-duties rules follow role inheritance, and
roles.updateis checked like other granting operations. - Tenant-defined mode: a type only a tenant registered answers from its registry for its own
{type}:{verb}actions; the application's actions on an undeclared type always go toresolveResource, so an organization cannot registerdocumentand pass checks on another organization's documents. - A principal of another tenant is refused before any resource or action is looked up, and the refusal is recorded in its own tenant (metadata
targetTenantId) instead of the target's audit chain and webhooks.authorizereturns a denial instead ofNOT_FOUND/RESOURCE_MISMATCHfor such callers. audit.verify(andverifyAuditChainwith ahead) checks the whole stored chain: new failuresmissing-prefix(oldest events deleted without anaudit:prunecheckpoint) andunchained(an event inserted without chain fields); deleting every event fails the head check. Startup backfill chains only tenants without a chain yet.- Teams: moving a team, or opening an admins-only team to its maintainers, needs grant authority over what the teams hold; the maintainer path no longer overrides an explicit deny. Onboarding completion groups, group-to-team sync, team leave (when a team carries a deny) and maintainer standing through admins-only teams are re-checked against grant authority.
- Access packages:
extendand membership hand-over only lengthen what the package still bundles, automatic grants run the enforced access invariants, andautoAssign.warningsflags rules on facts other administrators control. - Identities and tenants: non-owners cannot disable, expire or delete owners; administrators cannot edit their own attributes, manager or expiry, and setting attributes or a manager at creation needs
iam:identities:update; invitations are re-checked against the inviter at redemption; child tenants inherit the stricter of their parent's plan limits; subtree webhooks and the root tenant's sign-in policy, sessions and network blocks are root's; scoped API keys only issue keys within their scopes; demoting an owner revokes the owner grant authority. - Billing: plan prices apply only to usage inside the subscription and trials once per account and plan; seat recording uses internal keys callers cannot claim; sub-accounts inherit contract terms; usage cannot be attributed to platform identities or teams; payment terms and built-in meter keys are the platform's; CSV exports neutralize formulas.
- Also: plugin endpoints can name the record they act on (
PluginEndpoint.resource), so per-record policies apply; resource IDs cannot contain*or?;resources.listandrelationships.listleave out records a policy keeps from the caller; approvers can shorten but not lengthen an activation; role mining is linear on crafted inputs; certification auto-close acts only for a creator still in good standing; analysis suppressions need a current finding.
- "View as" sessions: every check an operation makes on the side (may this role be granted, may this group be changed) now needs the impersonating administrator's rights too, not only the first one. No grant authority can be used while impersonating (
- Identity lifecycle workflows (joiner, mover, leaver): a new
workflowsAPI group runs steps for each person a trigger fires for. Triggers arejoiner(people who join after the workflow is enabled, or everyone withincludeExisting),mover(watched attributes ormanagerIdchanged, against a baseline taken when the workflow starts),leaver(an active person was disabled),date(days before or aftercreatedAt,expiresAtor an ISO date attribute such asstartDate; a moved date fires again) andmanual. A scope in the access-package rule language picks the people. Steps add to and remove from groups (remove-from-all-groupsleaves team and package memberships alone), assign and revoke packages (rule-made assignments stay with their rule), send emails with placeholders (workflow-message) to the person, their manager, or addresses of members and verified domains only, sign people out, disable, enable, set attributes and expiry, delete (last step only), emitworkflow:eventfor webhooks, and wait up to five times. A run keeps the steps it started with and the rights of the owner who approved them (the workflow's owner when it started, or whoever retried it), re-checked at every step with the owner's MFA state (OWNER_INACTIVEwhen the owner is gone);updatewithactiveRuns: 'cancel'stops runs in progress. Steps never strip or edit owners or root administrators (PROTECTED_RESOURCE), and nobody changes their own account through a workflow they own, start or retry. A daily brake (maxRunsPerDay, 25 when a step takes access away, else 200) starts runs up to the day's allowance, holds the rest, and recordsworkflow:brake.previewshows who is in scope, who would start and upcoming dates;run,retryRun,cancelRunandevaluatehandle runs by hand, and the readers show names only to callers withiam:identities:read.iam.workflows.runDue()(every few minutes) andiam.workflows.subscribe()(withiam.dispatchAuditHooks();iam.workflows.idle()waits for its background evaluations) run them; finished runs are swept after 180 days. Authorized asiam:workflows:read|manage|run; audited asworkflow:run:start,workflow:step,workflow:run:complete,workflow:run:fail,workflow:brakeandworkflow:event. Console: Access › Workflows. New error:OWNER_INACTIVE. Guide:docs/workflows.md. - Identity threat detection and response (ITDR): a new
threatsAPI group watches each tenant's audit trail for identity attacks. Twenty-one detection rules, each mapped to a MITRE ATT&CK technique:password-spray,brute-force,brute-force-success,mfa-bombardment,session-hijack,new-network(privileged people, oreveryone),dormant-reactivated,account-takeover-persistence(sign-in methods changed after a risky sign-in),privilege-escalation,root-admin-granted,mass-deletion,directory-mass-change,impersonation-burst,denial-burst(AI agents included),recon-burst,guardrail-weakened,token-replay,audit-tampering,invariant-broken,user-reportedandupstream-signal(an upstream provider's Shared Signals event about a matched person: compromise, a disabled account, or medium/high risk). The scheduler jobiam.detectThreats({ tenantId?, maxEvents? })(CLIdetect-threats --tenant --max-events, every minute) reads each active tenant's audit chain from a sequence cursor, at most 2000 events per run by default, verifies the hash chain as it goes (a break raisesaudit-tampering; prune checkpoints are followed), and never slows a sign-in. Detections are deduplicated per triggering event or burst, grouped into one open incident per subject (identity, network, tenant or SCIM connection), and raise a per-identity risk score (10/25/50/80 points by severity, decaying with the tenant's half-life, levelsnone,low,medium,high, administrator overrides). Methods: settings (rules,getSettings,configure: per-ruleenabled,severity,thresholdandwindowMswithin each rule's bounds, trusted networks, dormancy, risk half-life, notification recipients, the containment brake), detections (listDetections,getDetection,dismissDetection), incidents (listIncidents,getIncident,updateIncident,addNote,resolveIncident,summary), responses (respondwithrevoke-sessions,forget-devices,contain,block-networkandnotify;release), risk (listRisk,getRisk,setRisk,timeline), playbooks (listPlaybooks,createPlaybook,updatePlaybook,deletePlaybook),detect(run now), and self-servicereportSuspicious("this wasn't me": no permission, ends the person's other sessions and forgets their devices). Authorized asiam:threats:read|manage|respondoniam/threats/.... Automatic responses never contain owners or root administrators, contain at mostmaxAutomaticContainments(3) identities per tenant and run, and never block trusted networks; nobody clears risk raised about themselves. Policy context keysprincipal.riskLevelandprincipal.riskScore, read only when a document names them (simulated principals seenoneand 0). Audited asthreat:detection,threat:incident-open,threat:risk-change,threat:settings,threat:detection-dismiss,threat:incident-update,threat:note,threat:incident-resolve,threat:risk-override,threat:revoke-sessions,threat:forget-devices,threat:contain,threat:release,threat:block-network,threat:notify,threat:response-braked,threat:playbook-create|update|deleteandthreat:user-report; Shared Signals sendsthreat:revoke-sessionsas CAEPsession-revokedandthreat:containas RISCaccount-disabled. Email templatethreat-alertwith thelinks.threatsbuilder. CollectionsthreatDetections,threatIncidents,threatNotes,identityRisk,threatBaselines,threatCursors,threatSettings,threatPlaybooks,threatResponses. ExportsthreatRules,riskLevelFor,effectiveRisk,resolveThreatSettingsand the module's types. Guide:docs/threat-detection.md.- Breaking:
riskLevelandriskScoreare reserved identity attribute names (declaring either fails withINVALID_CONFIG), andprincipal.riskLevel/principal.riskScoresupplied byresolveContextor a plugin are removed before the server sets its own values.
- Breaking:
- Device posture: a new
devicesAPI group lets policies ask which device a request comes from and whether an MDM or EDR finds it compliant. A browser or agent enrolls a public key once (enroll, self-service for people: EC P-256 or Ed25519, identified by its RFC 7638 thumbprint) and then sends a short-lived proof in thex-better-iam-deviceheader: a compact JWS (typ: device-proof+jwt,kidthe thumbprint,iatwithin 300 seconds,sidthe presenting session) whose algorithm is pinned by the enrolled key, whose header may carry no key material, and which verifies only with the session it names. Decisions verify it only when a document namesrequest.device, never write, and treat a missing or invalid proof as no device. Policies seerequest.deviceAssurance(none,registered,managed,compliant),request.deviceManaged,request.deviceCompliantand, when a proof verified,request.deviceIdandrequest.devicePlatform;policies.testdefaults the first three tononeand false. People list, check and retire their own devices (mine,check,retireMine). Administrators uselist,get,update(rename, reassign, mark lost),retire,deleteandremoveKey; one-time enrollment codes (createEnrollment,listEnrollments,revokeEnrollment;biam_denr_…, stored hashed) that bind a browser or agent key to a managed record; compliance requirements (getSettings,configure: encryption, screen lock, firewall, EDR health, jailbreak, minimum OS version per platform, check-in age); and MDM/EDR integrations (listIntegrations,createIntegration,updateIntegration,deleteIntegration; Intune, Jamf, Kandji, Workspace ONE, Google endpoint management, CrowdStrike, SentinelOne or custom). Integrations post up to 500 devices per call withreport: matched by vendor id, with owner (email or id), posture, OS version, and the thumbprint of a key the vendor's agent enrolled. Authorized asiam:devices:read|manage|reportoniam/devices…(reportoniam/devices/integrations/{id}, meant for a service account's API key); audited asdevice:enroll,device:retire,device:update,device:delete,device:key-remove,device:enrollment-create,device:enrollment-revoke,device:settings,device:integration-create,device:integration-update,device:integration-delete,device:reportanddevice:compliance-change. The HTTP handler allows the header cross-origin, the Next.js and Express/Hono/Fastify/SvelteKit in-process clients forward it, andAuthenticatedPrincipal.deviceProofcarries it (unverified) to decisions. Deleting a person retires their self-enrolled devices and unassigns their managed ones; the retention sweep deletes expired enrollment codes. New client entry point@better-iam/client/device(alsobetter-iam/client/device):createDeviceProver(creates and keeps the key pair, returns the public JWK and thumbprint, caches proofs, andheaders(sessionId)plugs intoClientOptions.headers),indexedDbKeyStore(a non-extractable key in the browser) andmemoryKeyStore; WebCrypto only, for browsers and Node 22 or later. Registered devices are separate from remembered devices and never satisfy MFA. Guide:docs/device-posture.md.- Behaviour change:
request.deviceAssurance,request.deviceManaged,request.deviceCompliant,request.deviceIdandrequest.devicePlatformare now server-owned context keys, so values thatresolveContextor a plugin supplies under those names are removed before the server assigns its own. Applications that passed their own device signals under these names should rename them.
- Behaviour change:
- Compliance center: a new
complianceAPI group runs 14 automated checks over each tenant's identity and access state (mfa-enforced,mfa-coverage,password-length,session-lifetime,inactive-accounts,leaver-access,stale-api-keys,api-key-lifetime,access-reviews,privileged-access,owner-redundancy,separation-of-duties,audit-integrity,privacy-deadlines), with parameters, mapped to requirements of SOC 2, ISO/IEC 27001:2022, NIST SP 800-53 Rev. 5 and GDPR as evidence for them (not full coverage). Tenants adopt a framework (adoptFramework, one shared control per check) or add controls of their own (createControl, free-form mappings), evaluate them (evaluate, or the daily scheduler jobiam.compliance.evaluateAll()), accept findings with exceptions that expire within a year, see each requirement's status (status), and export evidence packs (exportEvidence) that carry the latest results, active exceptions, recent runs and the audit chain head. Packs are signed with an Ed25519 key derived from the deployment secret:evidenceKeyspublishes the public keys,verifyEvidencePackchecks a pack offline andverifyEvidenceon the server (earlier HMAC-signed packs still verify, across secret rotations). Exceptions take a second person's approval (approveException; revoked ones are kept as history), the audit-integrity check verifies the chain incrementally from a checkpoint, checks read outside transactions, results report when they are stale, and CLIcompliance-evaluateruns the evaluation from a scheduler. Each run's results digest is recorded in the audit chain. Results and runs are kept 400 days. Authorized asiam:compliance:read|evaluate|manage; audited ascompliance:evaluate,compliance:control:fail,compliance:control:recover,compliance:framework:adopt,compliance:control:create|update|delete,compliance:exception(proposed),compliance:exception:approve,compliance:exception:revokeandcompliance:evidence-export. ExportscomplianceChecksandcomplianceFrameworks. Console: Governance › Compliance. Guide:docs/compliance.md. - Shared Signals receiver: Better IAM now receives OpenID Shared Signals (SSF 1.0 with CAEP and RISC) from upstream identity providers. A new
signalsAPI group registers each organization's transmitters as signal sources (createSource,listSources,getSource,updateSource,deleteSource,rotatePushToken; at most 20 per organization, one per issuer). A source has an issuer with up to 5 aliases, accepted audiences, static public keys, a key URL or SSF discovery (/.well-known/ssf-configuration, then the legacy RISC document), an algorithm allowlist (never HMAC ornone), push or poll delivery, subject mapping, and an action per event type. Events arrive by RFC 8935 push at{signals.pushPath}/{sourceId}(default/api/iam/signals/push/{sourceId}:application/secevent+jwt, an optional bearer token shown once and stored hashed, 64 KiB bodies, rate limited per source, 202 or{ err, description }with 400, 401, 404, 413, 415, 429 and 503 while keys cannot be fetched) or by RFC 8936 poll through the new scheduler jobiam.signals.poll()(acknowledgements only after the event is stored, sent with the next request;setErrsfor refused events; up to five requests per source and run; the poll token sealed with the deployment secret and re-sealed byiam.rotateSecrets()).iam.signals.receive(sourceId, set)hands in SETs from custom transports, andsignals.pollpolls one source on demand. Each SET is checked for size (16 KiB), compact JWS form, no key headers (jwk,jku,x5u,x5c) orcrit, the allowed algorithm,typsecevent+jwt(per-sourcerequireTyp: falsefor legacy Google RISC), signature, issuer, audience,iatwithin seven days, one event, nononceand a consistent subject, then deduplicated per organization on issuer andjti. Subjects (RFC 9493 formats, SSFcomplex, legacy RISCsubject_type) map to people of the source's organization only:iss_subthrough the federation links of chosen sign-in connections,iss_subandopaquethrough theexternalIdof users provisioned by chosen SCIM connections, and email oracct:subjects only at domains the organization verified. Service accounts, agents and deleted identities never match. Events are recorded for 90 days with a status (applied,recorded,unmatched,ignored,failed;listEvents,getEvent, andreprocessafter the mapping is fixed). The only direct action isrevoke-sessions(every session except API keys) for the event types a source chooses, never against root administrators from other organizations. Everything is audited under names the transmitter does not publish, so nothing echoes back upstream:signal:received(actorsignal:{sourceId}, withsourceId,eventType,jti,status,identityId,reasonAdmin,currentLevelandcredentialTypefor threat detection),signal:revoke-sessions,signal:source-create|update|delete|rotateandsignal:reprocess. Authorized asiam:signals:read|manageoniam/signals/sourcesandiam/signals/events. Deployment optionsignals(pushPath,allowPrivateNetworks,allowInsecureLocalhost); key, discovery and poll requests go through the SSRF-guarded transport. CollectionssignalSourcesandsignalEvents.@better-iam/coreexports the pure pieces:signalEventUris,signalEventType,parseSubjectIdentifier,legacyRiscSubject,readSecurityEvent,userSubjectsandSignalFormatError. New error:SIGNAL_REJECTED. Guide:docs/shared-signals-receiver.md. - Application catalog and My apps: a new
applicationsAPI group lists the tools an organization gives its people, each an OpenID Connect client of this deployment (oauthClientId, kindoidc) or a plain link (link), with a category, logo, owners, andeveryoneorassignedvisibility (at most 500 per tenant). Apps are assigned to people and groups, optionally until a date (assign,unassign,listAssignments); people without an app can request it through a requestable access package (requestPackageId). People see their apps withmineand open them withlaunch, which records and audits the launch and returns the app's URL (refused in "view as" sessions withIMPERSONATION_RESTRICTED). A deployment's sign-in pages can refuse people who lack an OIDC app:iam.applications.allowed(...),checkover the API, and the OAuth provider's new optionalclientAllowed(identityId, tenantId, clientId)hook, which the protocol host wires to the catalog.usageshows launches per app and the direct assignments nobody uses, andremoveUnusedremoves them. Deleting or clearing an app that governs an OAuth client needsreleaseClient: true. Deleting a person or group releases their assignments. Authorized asiam:applications:read|manage|assign; audited asapp:*events. ReactuseMyApps. Console: Home › My apps (a launcher grouped by category) and Access › Applications. Guide:docs/applications.md. - API usage plans and quotas: a new
quotasAPI group limits how much of your API (or anything else you count) a caller may use. A plan counts one meter with a token-bucket throttle (ratePerSecond,burst) and/or at most one limit perminute,hour,day,weekandmonth(days, weeks and months in the plan'stimeZone), per subject or pooled for the tenant (scope: 'tenant'). Plans are assigned to API keys, identities (people, service accounts, agents) and groups; the most specific applies (the presented key, the acting agent, the identity, its highest-priority group, then the tenant'sdefaultplan), and a meter without a plan is unlimited.iam.quotas.enforcethrowsQUOTA_EXCEEDED(429, withretryAfterMs),consume(in process or over HTTP) returns the decision instead,statusreads what is left without counting, andconsumeForcounts work your code attributes itself; a refused call counts nothing. Counters live in the database, so every instance sees the same totals, andiam.sweepExpired()removes old ones.alertThresholdsrecordquota:thresholdonce per window, and the first refusal of a period window recordsquota:exceeded, for webhooks (throttle refusals are not audited). MethodscreatePlan,updatePlan,deletePlan,getPlan,listPlans,assign,unassign,listAssignments,usage,reset; authorized asiam:quotas:read|manageoniam/quotas…. New error:QUOTA_EXCEEDED. Guide:docs/quotas.md. - Security fixes from a review of the protocols, integrations, storage, CLI and console (each covered by
tests/security-*.test.ts):- Outbound requests to addresses tenant administrators choose go through one SSRF guard (
createGuardedFetch, exported from@better-iam/auth): webhooks, outbound SCIM targets, Shared Signals streams and organization-chosen inference base URLs refuse loopback, private, link-local, CGNAT, reserved and metadata addresses unless the deployment setsevents.allowPrivateNetworks, the SCIM provisioner's or SSF transmitter'sallowPrivateNetworks, orinference.allowPrivateNetworks. Changing a SCIM target's or SSF stream's URL requires its token or authorization again. A guarded response body cancelled by its reader no longer crashes the process. - SAML: managed connections get a random
providerKeythat keys their account links (deleting a connection unlinks them), trust-widening edits are re-checked and audited, IdP-initiated responses are judged fresh byIssueInstant, and only encrypted assertions that are direct children of the response count. - OAuth and OpenID Connect:
identity_idappears only on client-credentials tokens, the registration gate normalizes its path,iamclaims come from live grants, token exchange refuses revoked or disconnected subjects and DPoP-only clients, DPoP proofs have a replay cache,prompt=loginandmax_ageare honored at the end of an interaction, and resource servers can pin atenantId. - SCIM: outbound provisioning adopts existing remote accounts only by verified email and escapes filter values; inbound provisioning honors plan limits, only changes the status of accounts it provisioned, revokes fully on deprovisioning, and audits every change.
- Shared Signals transmitter subjects use verified emails only and carry a
tenant_idclaim. Federation never provisions just-in-time into the root tenant, and OAuth sign-in connections acceptallowedEmailDomains. - Delegation confirmations are single-use. Inference models on a parent tenant's provider and platform-set budgets need root, and a provider key must be supplied again when its base URL changes. Configuration sync needs a recent sign-in to change the tenant access policy (audited as
tenant:access-policy), hides SCIM groups named like administrator groups, and keeps team approver groups and binding dates; pruning agents through AI configuration sync needs a recent sign-in. Plugin endpoints of@better-iam/projectsname the record they act on. - CLI:
helpand shell completion never import a discovered configuration file, discovery stops at the repository root or home directory and skips files others could plant, acli.defaults['*']entry no longer fills destructive retention flags such asaudit-prune's (only a default for that very command does), exports and profiles are written with mode 0600, and terminal control sequences in server data are neutralized in text output. - A2A and MCP gates refuse non-string skill and tool names. The console sends security headers and refuses the in-memory mail inbox in production.
- Outbound requests to addresses tenant administrators choose go through one SSRF guard (
0.1.0 (2026-09-23)
First public release: all 23 packages published to npm as better-iam and @better-iam/*.
-
Licensed under MIT (was proprietary /
UNLICENSED). Every package now carriesrepository,homepage,bugs,keywords, andpublishConfig.access: public;validate-packagesenforces them. CI publishes all packages to npm with provenance after a green push tomainthat carries a version not yet on npm, then tags it and creates a GitHub release (needs theNPM_TOKENsecret). -
Billing and spend tracking for people, teams, departments, projects and organizations. Guide
docs/billing.md.- New
billingAPI group (meters:createMeter,updateMeter,deleteMeter,listMeters; rate card:setPrice,listPrices,quote; usage:record,recordMany,listUsage; spend:spend,trend,mySpend,teamSpend,departmentSpend,accounts; budgets:createBudget,updateBudget,deleteBudget,listBudgets,check; credits:grantCredit,revokeCredit,listCredits; profiles:getProfile,setProfile,deleteProfile; statements:listStatements,getStatement,previewStatement,closePeriod,markPaid,voidStatement; actionsiam:billing:read|manage|record; collectionsbillingMeters,billingPrices,billingUsage,billingRollups,billingBudgets,billingBudgetAlerts,billingCredits,billingProfiles,billingStatements). - Platform meters (root) and organization chargeback meters; per-unit, graduated, volume and package prices with free allowances and negotiated prices per organization;
sumandunique(active users) meters; reported-cost meters. Each meter's monthly total is priced once per billing account and shared out by quantity, so person, team, department and project spend add up to the account's charges. Usage is attributed at record time to the identity, their teams and department (agents to their sponsor's), with tags and idempotency keys. - Spend budgets per month, quarter or year for a tenant, team, department or person with threshold and forecast alerts (
spend-alertemail, auditbilling:budget-alert) and optional enforcement (SPEND_LIMIT_REACHED, 402). Team maintainers and department heads read their own spend without a permission. - Billing profiles (a project becomes its own billing account only from its parent), credits, and monthly statements with lines, credit, a breakdown by project/team/department/person and a content hash (
billing-statementemail,TemplateLinks.billing); late usage for an invoiced month fails withBILLING_PERIOD_CLOSED. New codesBILLING_PERIOD_CLOSED,METER_ARCHIVED,SPEND_LIMIT_REACHED. - Contract terms per billing account (
billing.setTerms, root-only;billing.getTerms; collectionbillingTerms, auditbilling:terms): a discount off the subtotal, a minimum monthly commitment with a true-up line, and tax; statements carrydiscount,commitmentandtax(in the content hash when present, so earlier statements still verify). - Policy context keys
principal.spendExceeded(an enforced budget covering the principal is spent) andprincipal.budgetsExceeded(names of spent covering budgets), read only when a document names them; reserved as identity attribute names. - Insights: spend spikes (
billing.anomalies; daily jobiam.billing.detectAnomalieswith auditbilling:anomalyand thespend-anomalyemail), showback (spend({ shareUnattributed: true })spreads unattributed spend over people, teams or departments by their share), and CSV exports (billing.exportSpend,billing.exportStatement; console downloads). iam.billing(record,recordMany,check,spend, and the jobscheckBudgets,recordSeats,closePeriod,detectAnomalies), optionbilling(currency, time zone, team attribution, payment terms, statement prefix, usage retention),ctx.billing.recordPriced(inference calls land on the built-ininferencemeter). CLIbilling-close,billing-alerts,billing-seats,billing-anomalies,spend. Console: organization Billing page (spend by person/team/department/project/meter/day, trend, budgets, statements, meters, profile, chargeback), statement page, and the admin Billing page (accounts, close a month, credit, prices, mark paid/void).- Invoicing in the style of Stripe and Orb. Invoice lifecycle
draft→finalized→paid/uncollectible/void(billing.finalizeInvoice,markUncollectible;closePeriod({ draft })and optionbilling.autoFinalize); invoice items (createInvoiceItem,listInvoiceItems,deleteInvoiceItem; negative amounts are credits, a negative balance becomes account credit); payments (recordPayment, partial and over-payments; trustediam.billing.recordPaymentwith an idempotency key for payment-processor webhooks); credit notes (createCreditNote,listCreditNotes, numbered{invoice}-CN-01); payment reminders (iam.billing.sendPaymentReminders, optionbilling.paymentReminderDays,payment-reminderemail, CLIbilling-reminders); a printable invoice (renderInvoice, optionbilling.issuer). Plans with fees, seats and meter prices, billed in advance or arrears (createPlan,updatePlan,listPlans); subscriptions with trials, prorated first invoices, seat changes, plan changes and cancellation (subscribe,updateSubscription,changePlan,cancelSubscription,resumeSubscription,listSubscriptions; self-serve plans for account billing managers); coupons (createCoupon,deactivateCoupon,listCoupons,redeemCoupon,listDiscounts,removeDiscount). Invoice lines show the tiers a quantity used, free units, service periods and prorations; prices takeminimumAmount/maximumAmount. Invoice amounts are whole cents. Summaries gainamountPaidMicros,amountDueMicros,billingReason; accountsoutstandingMicros;closePeriodresultsdrafted. CollectionsbillingInvoiceItems,billingCreditNotes,billingPlans,billingSubscriptions,billingCoupons,billingDiscounts. Console: invoice page (tiers, payments, credit notes, print), subscription/plans/promotion-code/next-invoice cards on the organization Billing page, and plans, subscriptions, coupons, invoice items, payments, credit notes and finalize/write-off on the admin page.
- New
-
Teams inside organizations and departments. Guide
docs/teams-and-departments.md.- New
teamsAPI group (create,update,delete,list,get,listMembers,addMember,addMembers,updateMember,removeMember,candidates,listForIdentity,listMine,requestToJoin,cancelRequest,listRequests,approveRequest,denyRequest,leave,reconcile; actionsiam:teams:create|read|update|delete; collectionsteams,teamMembers,teamJoinRequests). Teams nest (parentId, ten levels), have maintainers who manage membership without an administrator permission (memberManagement: 'admins'turns that off), take join requests (joinPolicy: 'request', emailsteam-join-request/team-join-decided,TemplateLinks.team), and grant access through a team-managed backing group (team.groupId,Group.teamId) that holds the members of the team and of every team below it. The groups API, access packages, invitations, and onboarding refuse backing groups (new codeTEAM_MANAGED, 409); configuration sync ignores them. - New
departmentsAPI group (create,update,delete,list,tree,get,listMembers,assign,unassign,ofIdentity,mine(a person's own department and, for heads, the people they lead, no permission),importFromAttribute,syncManagers; actionsiam:departments:read|manage; collectionsdepartments,departmentMembers): a department tree with heads, codes and cost centers, one department per person, import from a string identity attribute, and department heads as managers for manager-routed approvals. - Policy context keys
principal.teams,principal.departments,principal.departmentId(read only when a policy names them; reserved as identity attribute names). Offboarding reportsteamsLeftanddepartmentsReassignedwhen non-zero. HelpersteamsOf,primaryTeamOf,teamMaintainers,isTeamMaintainer,departmentOf,departmentPath,departmentHeads. Access analysis reportsteam-maintainers-grant-admin(high),team-without-maintainer, anddepartment-without-head(finding subjects gainteamanddepartment). Configuration as code gains theteams(by slug: parent, department, join settings, maintainers, members, roles) anddepartments(by name: code, parent, head, cost center, members) kinds (TenantConfigTeam,TenantConfigDepartment; change kindsteam,department). - Birthright access by org structure: access package rules may test
identity.teams(a person's teams and those above them) andidentity.departments(their department and those above it); team and department changes made through the API reconcile the people they touch at once; a team or department a rule names cannot be deleted (RESOURCE_IN_USE); configuration documents name them by slug and name (teams and departments are applied before packages, deleted after); console team and department pages list their birthright packages. A team membership copied by team sync counts only through a group membership no package created.departments.suggestBirthright/teams.suggestBirthright(iam:analysis:read) propose such packages from the roles and groups most of a unit already holds by hand, each ready forpackages.create(console: "Suggested birthright access" with a Create package button). Team membership reviews:teams.startReview/decideReview/completeReview/cancelReview/getReview/listReviews(maintainers decide keep or remove, nobody decides on themselves, removals apply on completion withonUndecidedfor the rest), the scheduler jobiam.closeOverdueTeamReviews(), emailteam-review-requested, auditteam:review:*,listMine.reviews, collectionsteamReviews/teamReviewItems, and a Membership review card on the console team page. - Console: Teams and Departments pages (org chart, join requests, maintainer view), teams and department on member pages, team-backed groups marked on the Groups page. React and Vue:
useTeams(@better-iam/react,@better-iam/vue). The retention sweep (sweepExpired) deletes ended team memberships (teamMembers). Team sync:Team.syncGroupIdskeeps a team's members in step with up to ten ordinary groups (SCIM directory groups included, through the protocol host's group sync); synced memberships carrysource: 'sync', mirror the source membership's end, and can only be changed through the source group;teams.reconcilealso returnssynced; the configteamskind gainssyncGroups.
- New
-
AI agents as accounts, delegation, and MCP authorization. Guide
docs/agents.md.Identity.kindgains'agent'with anIdentity.agentprofile (AgentProfile:sponsorId,model,provider,purpose,url,protocols,delegable,maxDelegatedSessionSeconds,boundary,suspended). NewagentsAPI group (create,get,list,listMine,update,suspend,resume,delete,standing; actionsiam:agents:create|read|update|delete; plan limitTenantLimits.agents). An agent's keys (credentials.createnow accepts agents) work only while its sponsor is an active person of the tenant; sponsors can suspend their agents without a permission; offboarding hands agents to the successor (agentsReassigned, oragentsUnsponsoredwithout one). Auditagent:create|suspend|resume|sponsor-change. New codeINVALID_SPONSOR.- Delegation: new
delegationsAPI group (grant,request,approve,deny,revoke,get,list,listMine,assume; actionsiam:delegations:read|revoke; collectiondelegations). A person lets an agent act for them within a scope; the agent opensbiam_dlg_…sessions of the new kind'delegated'(Session.agentId,Session.delegationId) that act as the person, bounded by the delegation scope, the agent'sboundaryand an optional scope-down policy, re-validated on every use. Agents can request consent (delegation-requestemail template,TemplateLinks.delegation). Offboarding or deleting a person revokes their delegations. New codesDELEGATION_EXISTS,DELEGATION_PENDING,DELEGATION_INACTIVE,DELEGATION_NOT_ALLOWED. - Policy context keys
principal.delegated(always present),principal.delegationId,principal.agentId,principal.agentSponsorId,principal.agentModel,principal.agentProvider;principal.kindisagentfor an agent's own key.AuditSessionContextandsts.getCallerIdentitycarryagentIdanddelegationId. Delegated sessions are temporary (temporarySessionKinds), never recent, owner or root, cannot mint session tokens or assertions, and are swept bysweepExpired.credentialTokenKindsgainsdlg; update secret-scanning rules tocredentialTokenScanPattern. - Human-in-the-loop confirmations: a delegation's
confirmaction patterns hold those actions back until the person approves each call (delegations.requestConfirmationby the delegated session,decideConfirmationby the person,listConfirmations,getConfirmation; collectiondelegationConfirmations; email templatedelegation-confirmation; auditdelegation:confirmation-request|confirm|reject). An approval opens exactly that action on that resource forvalidSeconds. - Accountability:
agents.activity(everything an agent did, for its sponsor oriam:agents:read),delegations.activity(everything done under a delegation, for the people involved),agents.catalog(the delegable agents people may pick). Access analysis reportsagent-without-sponsor,agent-admin,unbounded-agent,broad-delegation, andunused-delegation.@better-iam/nextIamPrincipalaccepts delegated sessions (session.agentId,session.delegationId) and agent identities. Metrics gainbetter_iam_sessions_live{kind="delegated"}. - Console: new "AI" section with Agents (sponsor kill switch, keys, delegation switch, sponsor hand-over), Delegations (approve requests and confirmations, grant with a confirm list, organization-wide list), and Models & budgets pages.
- New package
@better-iam/mcp(better-iam/mcp):createMcpGateputs tool-level authorization in front of any Streamable HTTP MCP server (Better IAM credentials decided by policy, OAuth tokens by scope, RFC 9728 metadata and challenges, refusedtools/call, filteredtools/listin JSON and SSE);createMcpAuthorizerfor SDK tool handlers. For delegated callers whose delegation holds a tool's action back, the gate files the confirmation request and returns its id in_meta['better-iam/confirmationId'].tenantIdis required: decisions are made in the organization that runs the server, never in the caller's own tenant, and OAuth tokens for another tenant are refused. Bodies are read with a byte limit and forwarded as parsed. - Inference model
fallbacks(up to five): when a provider is unreachable or answers 429/5xx/529, the gateway tries the next fallback the caller may use in the same wire format, metering the failed attempt and naming the serving model inx-better-iam-modelwithx-better-iam-fallback-from. Also in theinferenceModelsconfig kind. - Delegation tokens:
delegations.issueTokengives an agent acting for a person (delegated session) a short-lived JWT for one service outside Better IAM (typbiam-delegation+jwt, signed with thea2acard keys;subthe person, nested RFC 8693actclaims for the agent chain,scopefrom the delegation). Audiences must matchAgentProfile.tokenAudiencesof every agent in the chain (new profile field, inagents.catalogand theagentsconfig kind). Scopes must be allowed outright by every limit on the session, and may not be actions the person confirms call by call or ones a deny among the person's own grants could touch. Audience patterns match by URL structure (*only as a leading host label or in the path). Lifetime is 30 s to 1 h (5 min by default); audited asdelegation:token-issue; issued tokens are recorded (collectiondelegationTokens, swept at expiry) soliveverification re-checks the chain, the acting key, the audience and the scopes.@better-iam/a2againsverifyDelegationToken(offline, trusted issuers, optional replay check;DelegationTokenError) andcreateDelegationTokenCache.iam.a2a.verifyDelegationToken(token, { audience, live })verifies in the deployment, with an optional live check of the chain (DELEGATION_TOKEN_INVALID). Core exportsreadDelegationTokenClaims,delegationActor,delegationTokenType,delegationTokenLimits,delegationTokenScopePattern. - Provider-run tool governance:
InferenceModel.providerTools(allowby default,deny, orpolicy) controls tools the provider runs itself (web search/fetch, code execution/interpreter, file search, image generation, computer use, remote MCP servers). Withpolicy, each tool is decided asinference:use-toolon the new resource typemodel-tool(model-tool/web_search,model-tool/mcp:{host}; attributeskind,host). The gateway reads tools from Anthropictools/mcp_servers, OpenAI Responsestoolsand Chat Completionsweb_search_options, and refuses with 403TOOL_NOT_ALLOWEDbefore the provider is called (a Responsespromptcounts as the toolprompt; non-canonical MCP URLs read asmcp:invalid; more than 256 tools or 64 MCP servers are refused);InvocationCheckreasonTOOL_NOT_ALLOWEDwithtool.inference.checkandiam.inference.authorizetaketools. ExportsproviderToolsOf,inferenceToolAction,inferenceToolResourceType,ProviderToolsMode;providerToolsis in theinferenceModelsconfig kind. - Inference gateway endpoints: OpenAI Responses (
POST /v1/responses,max_output_tokenscapped, usage fromresponse.completedwhen streamed), Embeddings (POST /v1/embeddings) and Anthropic token counting (POST /v1/messages/count_tokens, checked but not metered). Responses conversations stay with their caller:previous_response_idmust name a response the same person (through the same agent) created through the gateway, else 404RESPONSE_NOT_FOUND;conversation,backgroundand references to objects stored at the provider (item_reference,file_id, Anthropicfilesources, existing containers) answer 400UNSUPPORTED_PARAMETER; a continued response must be at the provider that answered it. Streams that end without the provider's final counts are metered from estimates (request size, generated characters) rather than as free. New collectioninferenceResponses(swept with usage records),GatewayRuntime.ownsResponse, exportedUsageFormat. - Access analysis reports
open-handoff(delegations handed on to any agent) andagent-denials(agents refused 20+ times in a day). agents.suspendAll(organization-wide emergency stop, optionally by sponsor, provider or model; auditagent:suspend-all) andagents.directory(the current attested A2A cards of the tenant's agents in good standing, by skill or protocol; collectionagentCards, filled bysignCard, swept when expired).- Configuration as code for AI: new kinds
agents(by name, sponsor by email, profile),inferenceModels(the tenant's own models, provider by name) andinferenceBudgets(subjecttenant,{ group },{ identity }or{ agent }) inconfig.export|plan|apply(newserver/src/ai-sync.ts; change kindsagent,inferenceModel,inferenceBudget). Keys, provider keys and delegations stay runtime state. - Delegation spending caps:
spend: { period, maxCostUsd?, maxTokens?, maxRequests? }ongrant,request,approve(nullremoves) andhandoffcaps the AI model calls made under a delegation and the hand-offs below it (checked and metered like an inference budget;BUDGET_EXCEEDED, audit ondelegation:{id}); summaries reportspendwith what the current window used. Console Delegations page shows "AI spend: $x of $y today". - Hand-offs between agents: a person may let their agent hand parts of a delegation on to other agents (
handoff: { agents?, depth? }ongrant,request,approve; new methoddelegations.handofffor the delegated session; auditdelegation:handoff). A hand-off is a delegation withparentIdandchainthat the receiving agent assumes with its own key. It is bounded by every delegation above it, every agent's ceiling in the chain, and the handing session's scope-down policy, key scopes and key issuer, and it is checked link by link on every use. Revoking a delegation revokes the hand-offs below it (handoffsRevoked), the person'sconfirmlist travels down, anddelegations.activityincludes the hand-offs' events. A hand-off is bound to the handing agent's API key (ends with it, never outlasts it), the handing agents' budgets count its calls, and an agent's requestedhandofftakes effect only when the person restates it onapprove. New policy context keyprincipal.delegationChain.inference.recordmeters a ticket as the caller the check saw, even after their session ended. - Agent-to-agent (A2A): option
a2a(card-signing keys,jwksUrl,cardLifetimeSeconds,issuer) andagents.signCard, which attests an agent's A2A card. IAM confines the card's endpoints to the agent's registered origin, setsprovider.organizationto the tenant, adds the extensionurn:better-iam:a2a:attestation:v1, and signs the RFC 8785 canonical card as a detached JWS (auditagent:card-sign).iam.a2a.jwks()andjwksResponse()publish the keys.@better-iam/coreexportscanonicalizeJson(RFC 8785). - New package
@better-iam/a2a(better-iam/a2a):verifyAgentCardanddiscoverAgentcheck that a remote agent is attested by a trusted deployment;createCardAttestorkeeps an agent's served card signed;createA2aGateandcreateA2aAuthorizerauthorize A2A JSON-RPC servers in a requiredtenantId. They decide messages by skill or an agent-wide rule, keep tasks and conversations (andreferenceTaskIds) private to the caller who started them, refuse ambiguous skills and non-POST requests, filter the extended card's skills, and ask a person to confirm when their delegation requires it (A2A_ACCESS_DENIED-32050,A2A_CONFIRMATION_REQUESTED-32051).trustedIssuersbinds verification keys to attestation issuers.withHandoff/handoffOf(handoffMetadataKey) carry a delegation hand-off in an A2A message's metadata to the called agent. - React:
useDelegations,useConfirmations,useAgentCataloganduseModels(@better-iam/react). The key that opens a delegated session now also caps that session (its scopes or session policy, and its issuer's authority).
-
Inference access control for AI models (
inferenceoption). Guidedocs/inference.md.- Resource type
modeland actioninference:invoke: model access is an ordinary policy decision with model attributes (resource.tier,resource.provider, prices, ...). NewinferenceAPI group (createProvider,updateProvider,deleteProvider,listProviders,createModel,updateModel,deleteModel,listModels,listMine,setBudget,deleteBudget,listBudgets,usage,myUsage,check,record; actionsiam:inference:manage|read|record; collectionsinferenceProviders,inferenceModels,inferenceBudgets,inferenceCounters,inferenceUsage,inferenceTickets). - Provider keys (
anthropic,openai,openai-compatible) are sealed with the deployment secret and re-sealed byrotateSecrets; custom base URLs need root unlessinference.allowCustomBaseUrls. Models and providers are inherited by sub-tenants. - Budgets cap tokens, cost and/or request count (
maxRequests) per minute (a rate limit), hour, day or month for a tenant, a group or an identity (an agent's budget covers its delegated sessions too), shared or per identity, optionally over some models;BUDGET_EXCEEDED, auditinference:budget-exceededandinference:budget-alert. Every call is metered (inferenceUsage, cost in micro-dollars) and reported by identity, agent, model or day. iam.inference:gateway()(AnthropicPOST /v1/messages, OpenAIPOST /v1/chat/completions,GET /v1/models; streaming passthrough with usage capture; provider keys never leave the server; output held to the model'smaxOutputTokens),authorize,record,models. External gateways useinference.checktickets andinference.record. ExportscreateInferenceGateway,usageFrom,usageCost,budgetWindow. Metered calls also feed the billing ledger as theinferencemeter when billing is configured. The console mounts the gateway at/api/ai.
- Resource type
-
Customizable onboarding at platform, organization, and project level. Guide
docs/onboarding.md.- New
onboardingAPI group (createFlow,updateFlow,deleteFlow,listFlows,getFlow,effective,setSettings,mine,submitStep,setup,submitSetupStep,progress,memberProgress,verifyStep,resetProgress; actionsiam:onboarding:read|manage; collectionsonboardingFlows,onboardingSettings,onboardingProgress). - Member flows onboard people (forms, acknowledgements, self-attested or administrator-verified tasks, terms of use, email verification, MFA, passkeys); tenant flows are setup checklists for the organizations or projects below, with live checks (verified domain, members, owners, MFA policy, terms of use, sign-in alias, SSO, directory sync, member onboarding).
- Flows defined at the platform reach every tenant below; organizations and projects add their own, switch off unlocked inherited flows, and override the welcome screen. Flows can target people with the access-package rule language, ask only newcomers (or everyone with
includeExisting), fill empty identity attributes of the defining tenant's own people from answers, and add people to groups on completion. - Policy context keys
principal.onboardingandprincipal.pendingOnboarding(read only when a condition names them). Audit eventsonboarding:step,onboarding:complete,onboarding:verify,onboarding:reject,onboarding:reset,onboarding:groups. Deleting a person removes their answers. - Console: Get started checklist and banner, organization/project Onboarding page with a flow builder and templates, Setup checklist page and overview card, and a platform Onboarding page in the administration panel.
- New
-
Organization sign-in addresses, custom hostnames, and regions. Guide
docs/hosts-and-regions.md(site: Deployment → Sign-in addresses and regions).hosts.patternsgives every organization with an alias its own address, like an AWS account sign-in URL:'{tenant}.signin.example.com', with the region if you like ('{tenant}.signin.{region}.example.com'), and'{tenant}.localhost:3000'in development. Requests on an organization's address are pinned to it: public sign-in calls act in it (tenantIdmay be left out), and another organization'stenantId, session, API key, or page is refused withHOST_MISMATCH(403). Organization origins are trusted like the deployment's own; cookies stay host-only.nodeHandlernow keeps the request'sHostwhen addresses are configured, andhosts.forwardedHostreadsX-Forwarded-Hostbehind your own proxy.- Custom hostnames (
hosts.customHostnames): newhostnamesAPI group (add,list,verify,setPrimary,delete; actionsiam:hostnames:*; collectionstenantHostnames,hostnameOwners) verifieslogin.acme.comwith a DNS TXT record (throughdomains.resolveTxt) and returns the CNAME to publish (hosts.cnameTarget). New codesHOSTNAME_TAKEN(409) andHOSTNAME_NOT_ALLOWED. Passkey ceremonies on a hostname outside the RP ID answerFEATURE_DISABLED. - Regions (
regions: { current, regions, locate? }):Tenant.region(inherited from ancestors; organizations under the root default to the creating region),tenants.create({ region }), root-onlytenants.setRegion(audittenant:region).tenants.lookup,domains.discover, public sign-in calls, and organization addresses answerWRONG_REGION(421) withregionandlocation(the sign-in URL in the home region) everywhere else;IamClientErrorgainsregionandlocation.regions.locate(alias)redirects aliases that live in another region's database. tenants.lookup({ host }), andregion/signInUrlintenants.lookupanddomains.discoverresults.iam.hosts(region,resolve,signInUrl,allowedfor on-demand TLS checks). Every email and SMS message carriessignInUrl, whichrenderDeliveryMessagepasses to link builders; link builders now also receive the tenant ID for sign-in emails whose payload lacks it.- The Next.js and Express/Hono/Fastify/SvelteKit integrations keep the visitor's host on in-process calls, so server actions and loaders are pinned too.
-
Developer experience: the CLI is rebuilt on a declarative command registry, and everything it does is available from code. Guide
docs/cli.md.- Every existing command keeps its flags, error codes, and results. New for all of them:
--flag=value,better-iam help <command>/<command> --helpwith each flag's default and environment variable, "did you mean" suggestions,help --json(a machine-readable manifest, alsocliManifest()),--format json|compact|tableand--query PATH,CODE: messageplus aHint:line on failure, and exit status 2 for command-line mistakes (1 otherwise). - Configuration discovery:
--config, thenBETTER_IAM_CONFIG, then the nearestbetter-iam.config.{mjs,js,ts,mts,cjs}in the working directory or a parent, thenBETTER_IAM_DATABASE_URL+BETTER_IAM_SECRETwith no file (configFromEnv). Factories receive{ command, env, cwd }. A configuration module may exportcli = { defaults }(flag defaults per command or'*') andcommands(project commands built withdefineCommand, which get the same parsing, help, output, and completion).init --typescriptwritesbetter-iam.config.ts; the template is a side-effect-freedefineConfigfactory. - Token commands (
config-*,analyze,report,mine-roles,check-invariants,whoami, and the new ones below) run in process or against a running server with--url/BETTER_IAM_URL, and read--tenantfromBETTER_IAM_TENANT. - New commands:
api GROUP.METHODcalls any HTTP API route (HTTPie-stylekey=value,key:=json,key:=@file.json, nested keys,--data @file|-;api --listshows every route and whether it needs a credential);login(password fromBETTER_IAM_PASSWORDor a hidden prompt, authenticator or--email-codeMFA,--with-tokenfor API keys) saves sessions as profiles in an owner-only credentials file, withlogout,profiles [use|remove], andtoken;can,explain, andwho-cananswer authorization questions;config-validatechecks tenant configuration offline (--strictfails on names the file does not define);secretprints a new deployment secret;completion bash|zsh|fish|powershell. - Configuration as code:
config-plan/config-apply/config-validateaccept.mjs/.js/.tsmodules whose default export is the configuration or a factory of{ tenantId, env }, andconfig-export --output x.ts|x.mjswrites a typed module. - Code:
@better-iam/serverexportsdefineConfig,configOptions, anddefineTenantConfig;@better-iam/cliexportsrunCli,createCli,defineCommand,main,runBinary,loadConfig,configFromEnv,findConfigFile,lintTenantConfig,loadTenantConfig,localTransport/remoteTransport,listRoutes,createProfileStore,formatResult, andselectPath.runCli'siogains optionalerr,prompt,stdin,cwd, andfetch. The umbrellabetter-iampackage now installs thebetter-iambinary. - Saved sessions stay with the deployment that issued them: a profile's token is never sent to another
--url/BETTER_IAM_URLor used with another--config(the command fails withMISSING_ENVand says why), andloginrefuses (PROFILE_IN_USE) to replace a profile saved for another deployment unless--profilenames it.--urlmust behttps://except for localhost, and--urlwith--configis refused.
- Every existing command keeps its flags, error codes, and results. New for all of them:
-
Feature flags at platform and tenant level. Guide
docs/feature-flags.md.- New
featuresAPI group:create,update,delete,list,setTarget,listTargets,setOverride, andevaluate. Catalog actionsiam:features:read,iam:features:manage, andiam:features:overrideact oniam/features/{key}. Audit actionsfeature:create,feature:update,feature:delete,feature:target, andfeature:override. New errorFEATURE_LOCKED. - Flags on the root tenant are platform flags. Any other tenant's flags reach its own subtree, and a key belongs to the tenant closest to the root, so tenants cannot shadow platform flags.
- Resolution order: kill switch, then the closest target or tenant override (a tenant's override beats a target on the same tenant, and a
lockedtarget silences overrides beneath it), then a stable percentage rollout per branch, then the default. Targets can lapse (expiresAt), andinternalflags stay hidden from tenants. iam.features(evaluate,values,isEnabled) for server code. React hooksuseFeatureFlagsanduseFeatureFlag.rolloutBucketis exported.- Decisions expose the flags that are on as the
tenant.featurescontext key, read only when a condition names it and stripped fromresolveContext/plugin context.policies.testfills it in, and policy lint knows it. - Console: an Administration "Feature flags" page and an organization "Features" page.
- Collections
featureFlagsandfeatureTargetsare removed with their tenant on purge.
- New
-
Temporary credentials (STS): AssumeRole parity, session tokens, signed session JWTs, and OIDC web-identity federation. Guide
docs/temporary-credentials.md.roles.assumetakessessionName,sourceIdentity,tags,formatandaudience, and accepts API keys and session tokens as sources. The source's scopes or session policy bound only theiam:roles:assumedecision (re-checked on every use), not the role session, which acts with the role's permissions within the trustceilingand its ownpolicy. Durations are capped by the newsts.maxRoleSessionSeconds(default 3600, up to 43200) and a per-trustmaxSessionSeconds; a duration outside the range isINVALID_INPUT.role:assumedis recorded in the target tenant.- Identity trusts gain
maxSessionSeconds,passSourceAttributes,allowedTagKeys,sourceIdentityModeanddescription; new root-onlytrust.update. Newanalysisfindingtrust-passes-foreign-attributes. sts.getSessionToken(iam:session-tokens:create): abiam_sts_token for the caller's own identity from a user session or API key, bounded by an optionalpolicyand the source's scopes, with an optional TOTPmfaCodestep-up (auth:mfa:step-up) for the MFA-then-assume pattern; capped bysts.maxSessionTokenSecondsandsts.maxSessionTokensPerIdentity. New errorCREDENTIAL_CHAINING_DISABLED.sts.getCallerIdentityfor every credential kind (CLIbetter-iam whoami), androles.listSessionswith allowlist projections that never carry hashes, policies or authority ids.- Revoke older sessions:
roles.revokeSessions,trust.revokeSessionsandoidcProviders.revokeSessions(iam:roles:revoke-sessions) move asessionsRevokedBeforewatermark and delete the matching rows;trust.revokenow deletes the trust's live sessions.identities.revokeSessions({ keepApiKeys: true })ends everything but the API keys.oidcProviders.updatealso ends every session issued through the provider (watermark plus deletion, for good) when it changesjwks,jwksUri,algorithms,audiences,maxTokenLifetimeSecondsorclockToleranceSeconds, or disables the provider; a rename, areplayProtectionchange or enabling keeps them. Rotating staticjwkstherefore makes workloads exchange again. - Session JWTs (
format: 'jwt', EdDSA/ES256) with the newsts.jwtoption,GET {basePath}/.well-known/jwks.json,iam.sessionTokens(jwks(),verify()), and the runtime-neutralcreateSessionTokenVerifier(@better-iam/server/session-tokens, umbrellabetter-iam/session-tokens) withverifyRequest. IAM accepts a JWT bearer only with a matching stored session row. - Web identity (
sts.webIdentity, off by default): tenant-managedoidcProviders,trust.create({ kind: 'web-identity' })with claimconditionsovertoken.<claim>keys (atoken.subpin is mandatory, elseWEAK_TRUST_CONDITIONS), the publicsts.assumeRoleWithWebIdentityexchange (uniformWEB_IDENTITY_REJECTED, per-trust rate limit, single-use replay protection, SSRF-guarded key fetch), and thetrust.evaluateWebIdentitydry run. - Session-aware policy keys:
principal.sessionId,tokenIssueTime,authTime,mfaTime,sessionTagKeys,sessionTags.<key>,sourceTenantId,sessionName,sourceIdentity,webIdentityProvider,webIdentitySubject, andrequest.sourceIp; audit events and webhook bodies carrysessionContext. New audit actionsrole:assumed,role:assumed-with-web-identity,session-token:issued,role:sessions-revoked,auth:mfa:step-up; new catalog actionsiam:trust:update,iam:roles:revoke-sessions,iam:session-tokens:create,iam:oidc-providers:*.@better-iam/servernow depends onjose. - Integrations: the client accepts JWTs and prefixed tokens,
@better-iam/nextprincipals add kindsession-tokenwithsessionName/sourceIdentity, the NestJS guard's CSRF check matches the bearer scheme case-insensitively, and the console labels the new kinds. - Migration: new tokens are prefixed and checksummed (
biam_ses_,biam_key_,biam_rol_,biam_sts_, 58 characters;credentialTokenScanPatternfor scanners). Legacy unprefixed tokens still work until they expire. Tooling that assumed 43-character tokens needs updating. - Migration:
auth.requireRecentnow refuses temporary credentials (role sessions and session tokens) withRECENT_AUTH_REQUIRED. Role sessions previously passed for five minutes after their source signed in; perform those operations from a signed-in session. - Migration: sign-in results no longer carry
tokenHash:SessionResult.sessionis nowSafeSessionfor every flow, in process and over HTTP. - Migration: new cross-tenant identity trusts default to
passSourceAttributes: false, so source attributes no longer reach their role sessions unless enabled. Existing trusts keep passing them; turn it off withtrust.update(the new finding lists them).trust.createandtrust.listreturn the public projection (requiresExternalId, neverexternalIdHash), and theroles.assumeresponse is a superset of the previous{ token, session }. - Migration:
credentials.revokeonly accepts API keys (INVALID_CREDENTIALfor any other session id). - Migration: the new server-owned context keys (and every existing
principal.*key,request.time,request.sourceIpandprincipal.sessionTags.*) now strip same-named values supplied byresolveContextor plugins, even when the server leaves the key absent. The new session names are reserved: identity attributes namedsessionId,tokenIssueTime,authTime,mfaTime,sourceTenantId,sessionName,sourceIdentity,sessionTags,sessionTagKeys,webIdentityProviderorwebIdentitySubjectfail withINVALID_CONFIG.
-
React Router: new package
@better-iam/react-router(umbrellabetter-iam/react-router) for framework mode, v7.9+ and v8.createIamRouter(iam, options)gives:middlewarefor the root route: per-request helpers in the router context, with cookies from the in-process client put on the responseapifor anapi/iam/*resource routeguard(loader, spec): login and step-up redirects (.data-awarenext), and a 403data({ code, message })for error boundaries ordeniedRedirectaction(fn, spec): an Origin check first, then IAM refusals returned asdata({ code, message }, { status })helpers(args),requireSession,require, andsessionData
Guide
docs/react-router.md, runnableexamples/react-router(react-router-serve, typegen +tsc, smoke test), teststests/react-router.test.ts. -
Express, Hono, and Fastify: new package
@better-iam/middleware(umbrellabetter-iam/express,better-iam/hono,better-iam/fastify, andbetter-iam/middleware).createIamExpress/createIamHono/createIamFastifydo four things:- serve the IAM API (Express and Fastify through
iam.nodeHandler, so node-only protocol mounts listed inservework too; Express also accepts bodies a parser already consumed and restores a stripped mount path) - attach per-request helpers (
req.iam,c.get('iam'),request.iam): memoizedgetSession,requireSession,require, batchedcan,authorize,listAccessible,assertion,credential,signOut, and an in-process typedclientwhose cookies land on the response - provide
requireSession({ stepUp })/authorize(action, { resource, tenantId, stepUp })route guards, which also refuse cookie-authenticated unsafe requests from untrusted origins (UNTRUSTED_ORIGIN/CSRF_REJECTED;trustedOrigins,csrf: false, exportedcheckRequestOrigin) - map refusals to the IAM JSON envelope, or to
loginPath/stepUpPathredirects for page navigations (errorHandler/onError)
The framework-neutral core (
createRequestHelpers,enforceGuard,refusalResponse,checkStepUp,IamRequestError) now also backs@better-iam/svelte/kit. Guidedocs/node-frameworks.md, teststests/middleware.test.ts(real Express and Fastify servers, a Hono app). - serve the IAM API (Express and Fastify through
-
SvelteKit and Svelte: new package
@better-iam/svelte(umbrellabetter-iam/svelteandbetter-iam/svelte/kit).createIamKit(iam, options)gives SvelteKit apps:- a
handlehook that serves the IAM API and enforcesprotectpath rules (login redirect with?next=, step-up page,authorizewithdeniedRedirector a 403 carrying the IAM code) - per-request
event.locals.iam:getSession,requireSession,require, batchedcan,authorize,listAccessible,assertion,credential,signOut, and an in-process typedclientwhoseSet-Cookieanswers land inevent.cookies, so sign-in forms work without JavaScript guard(load, spec)for server loads,action(fn, spec)for form actions (IAM refusals becomefail(status, { code, message })),sessionData(event),safeRedirectPath, andcheckStepUp
The browser entry
createIam({ client, initialSession })gives Svelte 4/5 stores:session,authorize,can, andaccessible. Inputs can be stores, andinitialvalues come from server loads, so hydration needs no second request. It also exportssetIamContext/getIamContext. Guidedocs/sveltekit.md, runnableexamples/sveltekit(adapter-node, smoke test,svelte-check), teststests/svelte.test.tsandtests/svelte-kit.test.ts. - a
-
Storage queries run in SQL:
find()filters become typed JSON conditions evaluated by the database, with pagination in SQL when the whole filter is expressible. Hot lookup fields (INDEXED_FIELDS: session token hashes, identity/group/role ids, email, OAuth artifact hashes, delivery queues) are indexed: partial expression indexes on SQLite/libSQL, onejsonb_path_opsGIN index on PostgreSQL, and id-ordered tenant and natural-key indexes. A session lookup among 20,000 sessions on SQLite fell from ~26 ms to ~0.25 ms. The SQLite adapter caches prepared statements. Schema steps are recorded by name iniam_migrations(new migration0002_query_indexes). Results are ordered by id in code-point order on every adapter. Identifiers (collection, id, tenant, natural key) with an unpaired surrogate are refused (INVALID_RECORD) instead of silently aliasing the U+FFFD spelling; reads and deletes treat them as absent. Field values still accept any string; the PostgreSQL adapter stores U+0000 and unpaired surrogates in a reversible jsonb-safe encoding (encodeJsonbDocument/decodeJsonbDocument), and migration0002rewrites existing rows that need it. SQLite and libSQL compare numbers by their JSON text, so integers above 2^53 and extreme exponents match exactly. PostgreSQL migrations wait up to ten minutes for another instance's migration. New exports:RecordDriver.query,planQuery,MAX_QUERY_CONDITIONS,JSONB_ESCAPE_KEY,sqliteSelect,postgresSelect,applyMigrations,schemaMigrations,matchesFilter,compareIds,storableString, and the adapter conformance suite@better-iam/core/conformance(adapterConformanceCases,runAdapterConformance), now run against SQLite, libSQL, and PostgreSQL. -
Storage operations:
- Store snapshots move a deployment between databases and adapters, for example SQLite to PostgreSQL. CLI
store-export --output FILEwrites a consistent JSON Lines snapshot with a header and a counting trailer.store-import --input FILEloads it into an empty database in one transaction and rolls back on a truncated, corrupt, or refused snapshot.store-copy --target-config FILEcopies directly. Copying a database onto itself fails withSAME_DATABASE. Core exportsexportStore,importStore, andcopyStore. - SQLite and libSQL: a second adapter instance of the same kind on the same file, used inside the first one's transaction, now fails with
DATABASE_IN_USEinstead of waiting forever for its own caller's lock. Paths are compared after resolving links, Windows short names, andfile://URLs. A SQLite and a libSQL adapter on one file still wait for the busy timeout and fail withSTORAGE_BUSY. - Retention sweep:
iam.sweepExpired({ limit, batchSize, deliveryRetentionMs, graceMs })and CLIsweep [--limit N] [--retention-days N]delete, in short batches, records that nothing uses any more and that until now accumulated forever. API keys and other session kinds, invitations, access requests, usage records, and pending deliveries are kept. Migration0004_expiry_indexesindexesexpiresAt,deliveredAt, andfailedAtacross each collection; the sweep walks these indexes once per run and steps over records it keeps. Shared Signals deliveries now recordfailedAt, and dispatched audit hooks recorddeliveredAt(tests/retention.test.ts). Swept:- user and role sessions, trusted devices, and relationship tuples past expiry;
- OAuth artifacts and login states (grants 31 days after expiry), and SAML request, relay, and replay records;
- delivered or abandoned outbox messages and abandoned Shared Signals deliveries past the retention;
- dispatched audit hook rows.
find()accepts an id cursor (after) for keyset pagination, served by the primary-key and tenant indexes; snapshots page with it, so exporting a large audit log does not slow down page by page.- Migration
0005_lookup_indexesindexessourceSessionIdandtrustId(SQLite and libSQL). Lookup indexes are now listed per schema step (LOOKUP_INDEX_STEPS), so released steps never change;INDEXED_FIELDSis their union. - Continuous audit archiving: the
auditArchive: { write(batch), batchSize }option andiam.archiveAudit()(CLIaudit-archive [--tenant ID] [--limit N]) copy each tenant's audit chain to independent storage.- Batches are verified and in chain order, and a per-tenant cursor records what has been archived.
- A per-tenant lease lets one run at a time archive a tenant (
busylists the others). createJsonlAuditArchive({ directory })is a crash-safe, write-once file sink (ARCHIVE_CONFLICTfor a different batch under an existing name). CLIaudit-verify-archive --directory DIR --tenant IDverifies an archive without the database.- Broken chains (
AUDIT_CHAIN_BROKEN), sink failures, and gaps are reported rather than skipped. - Once a tenant has an archive cursor,
pruneAuditdeletes only events the archive holds, in every process, and reportsheldForArchive;doctorreportsaudit-archive-behind(tests/audit-archive.test.ts). better-iam initconfigurations readBETTER_IAM_PREVIOUS_SECRETS.
- Deployment secret rotation: the new
previousSecretsoption keeps authenticator secrets, webhook secrets, pending deliveries, emailed links, and assertions sealed or signed under an old secret working. New values usesecret.iam.rotateSecrets()(CLIrotate-secrets [--dry-run]) re-seals stored values with the current secret.iam.assertionKeys()lets downstream services accept both keys during the switch.verifyAssertion,IamAssertionModule.forRoot, and the Next.jsverifyAssertionTokenall accept a key list. The run reportscompleteanddone, anddoctornever calls a partial sample done (secret-rotation-unverified). The deployment guide describes a staged rollout that is safe with several instances. The outbound SCIM provisioner takespreviousEncryptionKeysand offersrotateKeys(), for keys an application derives from the secret.doctorreports values still pending rotation, and values no configured secret opens (the secret was replaced outright). Auth exportsopenSecret, anddecryptSecretaccepts a list of secrets (tests/secret-rotation.test.ts). - Deployment self-check:
iam.selfCheck()reports findings with a severity and a fix: schema behind, not bootstrapped, SQLite durability that can corrupt, in-memory or asynchronous-commit storage, a placeholder secret, a short metrics token, no email transport, and scheduled jobs that are not running (sweep backlog, lapsed recordspurgeshould have removed, stalled or abandoned outbox messages, stalled audit hooks).doctorprints them, anddoctor --strictexits non-zero on any error or warning (tests/self-check.test.ts). doctoraddsstoragefrom the new optionalIamStore.describe(): schema version, applied migrations, record counts per collection, and adapter settings. A database that cannot be reached failsdescribe()instead of reporting as empty.- Ordered reads: optional
IamStore.findOrderedand thefindOrderedhelper page by a numeric field in SQL (migration0003_ordered_indexes). AuditlistandexportandpruneAudituse it, so their cost follows the page size instead of the log length. OptionalIamStore.collections()lists collections. - SQLite file databases default to WAL with
synchronous = FULL;journalMode: 'delete'anddurability: 'normal'opt out. - Session validation writes
lastSeenAtat most once a minute, or once per tenth of the idle timeout when that is shorter. - Authorization reads a subject's bindings with indexed lookups per group instead of scanning the tenant's bindings.
instrumentStoreandsummarizeStoreCallsreport storage calls without values;pnpm bench:scalemeasures hot paths at a configurable deployment size.- Tests:
tests/store-snapshot.test.ts,tests/store-describe.test.ts, andtests/retention.test.ts.
- Store snapshots move a deployment between databases and adapters, for example SQLite to PostgreSQL. CLI
-
Security: an assumed-role session whose source account owned another tenant passed the target tenant's owner gates (
identities.setOwner, owner offboarding) and satisfiedprincipal.owner/principal.rootAdminpolicy conditions there. Those gates now require an owner of the target tenant on an ordinary session, and role sessions reportprincipal.ownerandprincipal.rootAdminas false (tests/owner-gates.test.ts). -
auth.getSessionandauth.listSessionsno longer return a session'suniqueKey, which held its token hash;SafeSessionomits it. -
Role mining:
roleMining.suggest(iam:analysis:read) finds direct bindings a group already covers, roles every member of a group holds directly, roles with identical grants, and role bundles many people hold together (closed itemsets, skipped when a package or inheriting role already matches);roleMining.apply(iam:analysis:update) carries out the binding suggestions in one transaction under the original grant authority;roleMining.outliersreports access few peers (same manager or identity attribute) hold and access most peers hold that a person lacks. CLImine-roles, console Role mining page. -
Access usage tracking: the
accessUsageoption records which actions each identity was allowed to use (authorization checks and provisioning operations, not root overrides or impersonation), buffered in memory and written in batches to the newaccessUsage/accessUsageTrackingcollections (iam.flushAccessUsage()writes immediately).roleMining.usagelists it androleMining.rightSizereports bindings whose holders used none or only some of a role's actions in a window, plus per role the actions nobody used. Console: a Least privilege card on the Role mining page (the console enables tracking). -
Console redesign: grouped, collapsible sidebar sections with a "Jump to…" page filter; a Governance hub per organization (urgency-ranked "Needs attention" list and tiles for findings, invariants, separation of duties, unused access, role mining, certifications, approvals, and terms of use); a "Governance at a glance" card on the overview; and an Organization health page in the administration panel that ranks every organization by findings and broken invariants.
-
Review fixes for the governance features: the usage recorder's timer and early writes no longer inherit the async context of the transaction that recorded first (which broke periodic writes and could abort
authorize), failed writes back off;listAccessiblecounts as usage and root overrides do not; enforced invariants also guard resource deletion, root grants, campaign closes, and agreement publishing, evaluate every subject, and refuse changes that make them unevaluable; role mining no longer treats package-owned memberships as cover or suggests group bindings for groups with inactive members; sign-in evidence (recommendations and the dormant-access finding) ignores "view as" sessions;accessPaths.findmasks deny reasons;impact.previewis refused while impersonating. -
Review recommendations:
roleMining.reviewRecommendationssuggests keep or revoke for each access-certification item from account status, recorded usage (when it covers the window), or the last sign-in, with the reason; the console's campaign page shows them. -
Change impact preview:
impact.preview(iam:policies:simulate) applies a candidate role update, policy document, or role deletion with the real validation and edit rights inside an always-rolled-back transaction and reports, per holder of the affected roles (including inheritors and group members) and per resource, the actions gained and lost. Console: a Change impact page. -
Access invariants:
invariants.create/update/delete/list/run(built-in actionsiam:invariants:read|manage, collectionaccessInvariants) state who may, or must never, perform an action on a resource (one identity, a group, everyone with an attribute value, or everyone).runevaluates them;enforcemode re-checks them around every access-changing operation and refuses a change that newly breaks one withINVARIANT_VIOLATION, while pre-existing violations do not block unrelated work.impact.previewreports invariants a candidate change would break or fix. Console: an Access invariants page. CLIcheck-invariants --tenant ID [--fail-on-broken]. Scheduler jobiam.checkInvariants()/ CLImonitor-invariantsrecordsinvariant:brokenandinvariant:restoredaudit events once per status change (lastCheckon each invariant). -
Terms of use:
agreements.create/update/delete/list/status(built-in actionsiam:agreements:read|manage, collectionsagreements/agreementAcceptances) publish versioned agreements with optional periodic re-acceptance; members callagreements.listMine/acceptwithout a permission (auditedagreement:accept). Policies see the new context keysprincipal.agreementsandprincipal.pendingAgreements(known to the linter and reserved from identity attributes), so a deny statement can require acceptance. Console: a Terms of use page and an acceptance banner for members. -
Self-service access paths:
accessPaths.find({ tenantId, action, resource })tells a denied person what they could do themselves — step up to MFA, accept pending terms of use, activate one of their eligible bindings, or request a requestable package — each verified by simulating it in a rolled-back transaction; needs only an ordinary session. -
@better-iam/reactand@better-iam/vue:useAgreements(pending terms of use andaccept) anduseAccessPaths(self-service ways to be allowed). -
Configuration as code covers
invariants(subjects by group name, member email, attribute, or everyone) andagreements(a content change publishes a new version); both kinds are exported only when present, so existing documents are unchanged. -
Birthright access packages:
autoAssignrules (policy-condition include/exclude clauses over identity attributes, kind, owner flag, email, email domain and verification, manager, and direct group memberships) give a package to every matching identity and take it away when they stop matching, with an optional grace period. Rules run under their owner's grant authority and rights, re-checked on every run (suspended, never widened, when the owner leaves or loses rights). They reconcile after identity changes and rule saves, throughpackages.reconcile, and through the new scheduler jobiam.reconcilePackages()/better-iam reconcile; brakes hold back unusually large unattended changes until confirmed. Also:packages.previewAutoAssign,listAssignments({ source }),automaticon assignments,packages.assigntaking over automatic assignments, configuration documents carryingautoAssign(with group names), the console rule editor and preview, the new collectionpackageRuleIssues, andpackage:auto-*audit events. -
@better-iam/nextauth forms, step-up, service credentials, and background work:- Auth forms.
iamNext.authActions(options)(andcreateAuthActions) returns drop-in server actions:signIn(password or emailed sign-in code, then authenticator, emailed, or recovery codes, first-time enrollment with one-time recovery codes, 'keep me signed in' and 'remember this device'),reauthenticate(step-up that ends the session it replaces),signOut(clears a stale cookie too, through the newclearSessionCookie()),requestPasswordReset,resetPassword,signUp,verifyEmail, andacceptInvitation. The organization is resolved fromtenantId, anorgslug,resolveTenant, or the email's verified domain. They return a serializableAuthFormStatethat never holds passwords, codes, tokens, or sessions.@better-iam/next/clientadds matching unstyled, accessible forms that work without JavaScript (SignInForm,ReauthenticateForm,PasswordResetRequestForm,PasswordResetForm,SignUpForm,InvitationForm, each with a*Viewtwin). - Step-up.
stepUp: { mfa: true | 'fresh', maxAgeMs }onpage,route,action,requireSession,pages.withSession, andpages.apiredirects pages tostepUpPath?next=&reason=and answers handlers and actions withMFA_REQUIRED,RECENT_AUTH_REQUIRED, orIMPERSONATION_RESTRICTED(403).checkStepUpis exported, and page errors carry aBETTER_IAM_STEP_UP:digest forerror.tsx. - Service credentials.
apiRoute()accepts API keys and assumed roles throughiam.authenticateand passes a whitelistedIamPrincipal. - Background work.
dispatchAfterResponseschedules outbox and event dispatch withafter()after every in-process call and every POST to the mounts.iamNext.backgroundoffersdispatch(),schedule(), andcron(), a route for schedulers: bearerCRON_SECRET, fails closed, and runs isolated tasks (purge, audit retention, digest, reminders, outbox, events). - Hardening.
route(),apiRoute(), andpages.api()refuse cookie-authenticated cross-origin mutations (CSRF_REJECTED/UNTRUSTED_ORIGIN; newtrustedOriginsoption) and map onlyIamError/IamClientErrorto responses.safeRedirectPathno longer lets dot segments (/.//evil.example) normalize into a protocol-relative redirect.sessionForClient()andpages.withSessionprops drop the session'suniqueKey. Sessions refused for network or tenant-tree reasons read as signed out. The umbrellabetter-iam/next/clientre-exports by name, because Next rejectsexport *in a'use client'module. - Example.
examples/nextjsuses all of this: the login, step-up, and password-reset pages, a development inbox,apiRoute, and a cron route.
- Auth forms.
-
SCIM directory sync: the enterprise extension's
managerbecomesIdentity.managerId(mapManager, on by default; by SCIM ID,externalId, oruserName, back-filled when the manager arrives later, loops and self-references skipped, administrator-set managers never cleared). SCIM can no longer reactivate or regroup an identity an administrator deleted.createScimServiceserves a JSON administration API for consoles at{adminBasePath}/connections/{list,create,rotate,revoke,groups,mappings}(default/scim/admin; CSRF header, Origin check, 64 KiB bodies) and gainslistGroups. Console: a Directory sync page (connections with their SCIM endpoints, one-time tokens, rotation, revocation, and group-to-role mappings), inbound SCIM mounted at/api/iam/scim/v2, and the console's IAM route now forwardsPUT,PATCH, andDELETE. -
Policy linter:
analysis.lintPolicy(and the exportedlintPolicy) checks a candidate or stored document against the catalog and reports statements that are valid but probably wrong: unknown context keys such asrequest.ip, denies on optional keys that silently never fire, negated conditions over unresolvable variables, arrays compared with string operators, type mismatches, shadowed allows, duplicates, and wildcard administration. Its shadowing check is budgeted against adversarial documents. Console: a Lint card with a draft checker on each policy page. -
Access analysis gains
standing-privileged-access,unused-eligible-binding,orphaned-manager,manager-cycle, andpolicy-lintfindings. -
Manager-review certifications:
reviewerMode: 'manager'routes each person's items to their manager, who decides them withcertifications.reviewwithout holding a certification permission;certifications.listMine,certifications.remind(certification-reminderemails), andautoClosewith the deployment jobiam.closeOverdueCertifications/ CLIclose-certifications.renderDeliveryMessagerenderscertification-reviewandcertification-reminderwith alinks.certificationbutton. Console: "Assigned to you" review queue, reviewer column, "Remind reviewers", and manager-mode, due-date, and auto-close fields. -
Separation of duties also covers access-package assignment and approved package requests.
-
Access-package and access-lifecycle hardening from a multi-agent review:
packages.extendnow needs the rightsassignneeds, plus a grant authority, whenever it lengthens an assignment or removes its end. It moves the package's bindings to the extender's authority. Shortening still needs onlyiam:packages:assign.- Request and activation decisions are refused from impersonation sessions (
IMPERSONATION_RESTRICTED). packages.listApprovalslists only requests the caller holdsiam:packages:approveon.- Requests that name approvers but would reach none are refused.
- A request lapses no later than the end it asks for.
listRequestsreports lapsed requests asexpired. - Tightening a package cancels pending requests that no longer fit it. A direct
assignmarks the pending request approved. - Package bindings are the assignment's own (their own uniqueness key), so they never replace or depend on hand-made grants.
- Packages that share a group hand its membership to each other.
- Hand edits (
bindings.update,groups.updateMember, re-adding a lapsed member) take a package's record over. - Assignments whose bindings no longer grant are reported
brokenand can be assigned or requested again. - Offboarding revokes package assignments under package ownership.
groups.deleterefuses an approver group still in use.- Offboarding hands reports to a successor without creating self-management or cycles. A manager must be active, and tombstones drop their
managerId. - The access report ignores disabled expired identities, lapsed memberships, and activations that can no longer grant. The purge worker removes the activations of purged bindings and disabled identities.
- Expiry-reminder dedupe is kept in a new
expiryReminderMarkscollection, so it survives audit pruning. - Configuration documents validate package and binding durations, treating
nullas no cap, and match package names case-insensitively, so a case-only difference is a rename. bindings.updateaccepts amanagerApproval-only change.- CLI
remind --within-daysis limited to 1–365. - The console member page keeps name and attributes, manager, and deactivation in separate forms.
-
Shared Signals Framework transmitter:
createSharedSignalsTransmitter(better-iam/oauth) turns IAM activity into signed Security Event Tokens (RFC 8417) and pushes them (RFC 8935) to each tenant's receivers. It covers CAEPsession-revoked(sign-outs and administrator or tenant-wide revocations) andcredential-change(password, authenticator, passkey), and RISCidentifier-changed(email),account-disabled(offboarding, expiry), andaccount-purged. Subjects areiss_suboremail, events carryinitiating_entityand the audit event astxn. Streams are managed by tenant administrators (createStream/listStreams/getStream/updateStream/deleteStream, built-in actionsiam:ssf:streams:*on the new internal resource typessf; audited) with encrypted receiver credentials, event and subject filters, pause and resume,verifyStreamverification events, delivery history, retries with backoff (dispatch), and/.well-known/ssf-configurationmetadata. New collectionsssfStreamsandssfDeliveries. -
MCP-ready OAuth: dynamic client registration (RFC 7591) at the discovered
registration_endpointwhencreateOAuthProvider({ registration })is set. Registrations present a tenant-scoped token fromcreateRegistrationToken(listRegistrationTokensandrevokeRegistrationTokenmanage them; tokens are stored hashed with a use limit) or pass the host'sregistration.anonymoushook. New clients are bound to that tenant, public with PKCE unlessallowConfidential, limited to the authorization code and refresh grants, HTTPS or loopback or reverse-domain redirect URIs, and the allowed scopes and resources. Nothing the provider would fetch can be registered. They are audited and listed withregisteredVia. New collectionoauthRegistrationTokens. RFC 9728 protected resource metadata (protectedResourceMetadata,protectedResourceMetadataUrl,createProtectedResourceHandler), and the verifier'schallenge(error, realm, { resourceMetadata, scopes })points clients at it.createResourceGuardcombines metadata serving, token verification, and RFC 6750 challenges into onecheck(request)for MCP servers and other APIs. Plain OAuth 2.1 authorization codes (noopenid) now receive a refresh token when the client is registered for the refresh grant, since MCP hosts send neitheroffline_accessnorprompt=consent. OpenID requests are unchanged.tests/oauth-mcp-integration.test.tsruns the whole chain against a real server: challenge, discovery, registration, consent, the guarded call, and revocation on sign-out. -
Console email verification:
/cloud/verify-emailcompletes averify-emaillink on a click, the account page shows an unverified address with a "Resend verification email" button, and the deliveries page opens verification links in development. -
Security notices link home:
renderDeliveryMessageaccepts the message'stenantIdand alinks.account({ tenantId })builder;new-sign-inandsign-in-failuresemails then carry a "Review your account" button (text and HTML). The console's deliveries page points it at the organization's account page. -
Cookie options:
http.cookieSameSite(laxby default, orstrict) andhttp.persistentCookies(trueby default). A request that issues a session may sendX-Better-IAM-Persistent: 0(or1) to receive a browser-session cookie that disappears when the browser closes, or a lasting one, regardless of the default; the server session keeps its own lifetime either way. Preflight responses now also allowX-Request-IdandX-Better-IAM-Persistent. Console: a "Keep me signed in on this browser" checkbox on the login page, carried through the second-factor step. -
Discoverable passkey sign-in:
auth.beginPasskeyAuthentication({ tenantId })without an email issues options that name no credential, so the authenticator offers its own passkeys for the relying party, andfinishPasskeyAuthenticationfinds the account from the presented credential (user handle and ownership are still checked; disabled accounts are refused). Such anonymous starts are limited per address at ten times the ordinary allowance. Console: the login page offers passkeys in the email field through browser autofill as soon as it loads, and "Sign in with a passkey" no longer needs an email. -
Client options:
onUnauthenticated(error)runs once per request the server refused asUNAUTHENTICATED(a lapsed or revoked session; never a wrong password), so an application redirects to its login page in one place;retryRateLimited(trueor{ maxWaitMs }, off by default) retries aRATE_LIMITEDcall once after the server'sretryAfterMswhen that wait is short enough;requestId(trueor a function) sends anX-Request-Idon every request, which the server echoes and records on its spans, andIamClientError.requestIdcarries it for support tickets. -
Named passkeys:
finishPasskeyRegistrationaccepts aname(at most 64 characters; the default names the device kind from the authenticator's transports),listPasskeysreturnsname,createdAt,lastUsedAt(stamped by passkey sign-ins and passkey MFA),deviceType,backedUp,aaguid, andtransportsnewest first, andauth.renamePasskey({ id, name })relabels one (audited asauth:passkey:rename). Console: the account page names each passkey with "synced" or "this device only", when it was added and last used, an inline rename, and a name suggestion (browser and system) when adding one. -
Tenant policy
bindSessionsToIp: a user session is accepted only from the client IP it was issued from; presented from another address it is refused withSESSION_NETWORK_MISMATCH(401) and the attempt is recorded in the person's trail asauth:session:mismatchwith both addresses, so a stolen cookie is useless elsewhere and the person signs in again from the new network. Sessions and requests without a recorded address are not judged. Console: the toggle and status on organization settings. -
Network blocks:
security.blockNetwork({ tenantId, network, reason, durationMs?, platform? })(iam:security:manage, recent authentication, audited assecurity:network-block) refuses every authentication flow and live session whose recorded client IP falls in an IPv4/IPv6 address or CIDR block withIP_BLOCKED, before rate limits or credentials are examined; root administrators setplatformblocks on the root tenant for the whole installation, organizations block for themselves, adurationMs(one minute to a year) makes a block lapse,security.unblockNetworklifts it, andsecurity.listBlocks(iam:security:read) reports them withactive. The caller's own address is refused. Console: "Block for a day" per source address and a platform block list on the admin Sign-in failures page, and a "Blocked networks" card on organization settings. -
Brute-force defenses:
authentication.rateLimits.ipAttempts(off by default) caps attempts per client IP and tenant across every authentication flow, on top of the per-account limits, so credential stuffing and password spraying from one address run out no matter how many accounts they name (needs a recorded IP;identities.unlocknever clears it).authentication.failedSignInAlerts(off by default; needssendEmail) queues onesign-in-failuresemail (attempts,time,ip,userAgent; rendered byrenderDeliveryMessage) the moment a person's failed attempts since their last sign-in reach that number, once per streak. The console enables the alert at five attempts whenever it has a mail transport, and the admin panel gains a Sign-in failures page: failed attempts across organizations for the last hour, day, or week, grouped by source address and by targeted account (with the account's current streak and an unlock), plus the latest attempts. Console session, device, and activity lists now name clients ("Chrome 128 on Windows · 203.0.113.7") instead of showing raw user-agent strings. -
Idle-timeout warning:
auth.getSessionreturnslimits(lifetimeMs,idleTimeoutMs, andidleExpiresAt, honouring the tenant policy), and both console panels warn two minutes before a session lapses for inactivity (or reaches its maximum length) with a countdown, "Stay signed in", and "Sign out now", returning to the login page once it has lapsed (which then explains the inactivity or the reached lifetime via?reason=idle|expired); activity in the tab keeps the session alive without a prompt. -
Sign-in record and failed attempts: a wrong password, authenticator or emailed code, or recovery code presented for a real, active account is recorded as an
auth:signin:failaudit event (metadata.reason,ip,userAgent) in its own transaction and counted per person;auth:session:createevents carry the method and client too. Every new session from a sign-in flow carriessession.previousSignIn(previous sign-in time and client,failedAttemptssince, latest failed attempt's time and client; absent on a first sign-in) and the count restarts.auth.listSecurityEventsreturns eventmetadata. Console: a dismissable notice above every page when attempts failed since the previous sign-in, and "Previous sign-in" / "Failed attempts since" on the account page, whose security activity now names the reason and client of each event. -
docs/enterprise.mdwalks one customer organization through verified domains, SAML or Entra ID single sign-on, sign-in policy, SCIM provisioning in and out, end-to-end offboarding, and audit. The console runs a periodic outbound provisioning sync so that expiries, which emit no event, still reach connected applications. -
HTTP hygiene and account status: every JSON response carries
X-Content-Type-Options: nosniffandReferrer-Policy: no-referrerbesideCache-Control: no-store; a plainX-Request-Idis echoed on responses and recorded asrequestIdonhttpspans.auth.listSessionsmarks the calling session withcurrent, andauth.mfaStatusreports the authenticator state, unused recovery codes, passkeys, remembered devices, and whether the session passed MFA (the console account page warns when recovery codes run low). The admin panel gains a Live sessions page: every unexpired session across organizations, filtered by organization, kind, name, email, or IP, with impersonators marked and a per-person sign-out. -
Console sign-in links and codes: the login page offers "Email me a sign-in link or code" (passwordless email is enabled whenever the console server has a mail transport); a link is redeemed at
/cloud/magicon a click, a six-digit code is typed on the login page itself (startPasswordlesswithkind: 'code',finishPasswordlesswith the code, resend and back), and the sharedMfaChallengestep (authenticator, enrollment, recovery code, emailed code, passkey, remember-device) now serves password and link sign-ins alike;mfaChallengeStateprepares it from anymfaRequiredoutcome. The deliveries page opensmagic-linklinks in development.tenants.usagereportsmfaEnrolled(people with an authenticator or a passkey) and the settings usage card shows MFA adoption. -
Managers:
Identity.managerId(identities.create/update({ managerId | null }): another active identity of the tenant, never oneself or a report, so reporting lines stay acyclic),identities.listReports, and offboarding hands a manager's reports to the successor (reportsReassigned) while deleting an identity clears them.managerApprovalon an eligible binding or an access package lets the requester's manager decide on activation and package requests (alongside an approver group, if one is named) and emails them each request. The configuration document carries the flag; the console shows the manager and reports on the member page and offers the option on the assign-a-role and package forms. -
Expiry reminders and assignment extension:
iam.sendExpiryReminders({ tenantId?, withinMs? })(CLIremind, a deployment operation likedigest) emails each person whose account, direct role bindings, group memberships, or package assignments end within the window (seven days by default) oneexpiry-remindermessage listing them (itemsas JSON with kind, name, and end), once per item and end date, recorded asidentity:expiry-reminder; moving an end brings a fresh reminder.packages.extend({ packageId, identityId, expiresAt | null })moves the end of an assignment and of every binding and membership it created together (audited aspackage:extend; console card on the Access packages page). -
Self-service package requests: a
requestablepackage (optionally with anapproverGroupId) can be asked for withpackages.request({ packageId, expiresAt?, justification? })underiam:packages:request; the request waits for the tenant'sapprovalLifetimeMs, approver-group members are emailed (package-request), andpackages.approveRequest/denyRequest(iam:packages:approveon the package, group membership when one is set, never one's own) decide it, assigning under the approver's authority and emailing the requester (package-decided).packages.cancelRequest,listRequests({ packageId?, identityId?, status? }),listApprovals, andlistMine(requestable packages with the caller's status on each) complete the flow; lapsed requests are swept by the purge worker (expiredRequests), offboarding cancels pending ones, the configuration document carriesrequestable/approverGroup, and everything is audited aspackage:request,package:request-approved|denied|cancelled.packages.revokenow needs onlyiam:packages:assignon the package (the assignment owns its records, whichever authority issued them). Console: request/approve cards on the Elevate page, request settings and a requests table on the Access packages page. -
Access packages:
packages.create/update/delete/get/listbundle roles and groups (AccessPackage, actionsiam:packages:create|read|update|delete|assign).packages.assign({ packageId, identityId, expiresAt?, justification? })grants the whole bundle in one transaction as ordinary identity bindings and group memberships tagged with the assignment, skipping what the person already holds, honoring the package'smaxDurationMsandrequireJustification, and requiringiam:bindings:createon each role andiam:groups:updateon each group besidesiam:packages:assign;packages.revokeremoves exactly what the assignment added;packages.listAssignmentslists holders. Assignments end with their expiry (PurgeResult.expiredAssignments), go with offboarding and deletion, block deleting a packaged role or group or a held package (RESOURCE_IN_USE), travel with the configuration document (packagesby name), and are audited aspackage:assign/package:revoke. Console: Access packages page. -
Access digest:
iam.sendAccessDigest({ tenantId?, withinMs?, unusedForMs?, minimumIntervalMs? })(CLIdigest, a deployment operation likepurge) emails the owners of every active organization whose access report has findings anaccess-digestmessage (counts plus the full report as JSON), at most once per interval (20 hours by default) per organization, recorded astenant:access-digest.groups.addMembersadds up to 100 members in one transaction with an optional shared expiry; the console group page adds several at once. -
Temporary group memberships and future-dated bindings:
groups.addMember({ expiresAt })andgroups.updateMember({ expiresAt | null })make a membership end by itself (with every grant and activation it carried);listMembersandidentities.listGroupsreportmembershipExpiresAt, re-adding a lapsed member renews it, and the purge worker removes lapsed records (expiredMemberships). Bindings acceptstartsAt(bindings.create/update,nullclears): the grant is listed with its start but applies only from then. The access report gainsstartingbindings andexpiringMemberships. Console: "Member until" on the group page, "Starts on" / "Ends on" when assigning a role, and membership expiry on the member page. -
Tenant access policy:
tenants.setAccessPolicy({ tenantId, accessPolicy | null })(iam:tenants:update, recent authentication, audited astenant:access-policy) sets organization-wide floors for just-in-time activation:maxActivationMscaps every binding,requireJustification/requireMfa/requireApprovalapply to every eligible binding, andapprovalLifetimeMssets how long requests wait. Bindings can only be stricter. Configuration sync carriesaccessPolicy({}clears it). Console: an "Elevation defaults" card on the Configuration page. -
Access report:
reports.access({ tenantId, withinMs?, unusedForMs? })(iam:identities:read; the binding and credential sections neediam:bindings:readandiam:credentials:readand are named inomittedotherwise) returns identities and temporary bindings ending within the window, live activations, the number of pending activation requests, and API keys nobody used or that end soon. CLIreport --tenant ID [--within-days N] [--unused-days N]prints it as theBETTER_IAM_TOKENholder. Console: a Reports page with clear-deadline, make-permanent, end-activation, revoke, and rotate actions, and an activation history card on the member page. -
Approval-gated activation: eligible bindings accept
requireApprovalandapproverGroupId;bindings.activatethen records a pending request (lapsing after 24 hours) and emails the approver group (activation-request).bindings.approveActivation/bindings.denyActivation(iam:bindings:approveon the role; approver-group members or root when a group is set; never one's own request) make the role live for the requested or a shorter duration, or refuse it, and email the requester (activation-decided).bindings.listApprovalslists the requests a person may decide on,listActivationsacceptsstatus,listMinereportspendingActivation, and requesters cancel withdeactivate. Audited asbinding:activation-requested|approved|denied. Configuration sync carriesrequireApprovalandapproverGroupby name. Console: the Elevate page shows pending requests, lets approvers decide, and the member page sets the approval rules. -
credentials.create({ scopes })restricts an API key to an action allowlist (compiled to a session policy;CredentialSummary.scopesreports it), andbindings.list({ expiresBefore })reports temporary bindings ending before a time. Console: a scopes field when issuing keys. -
Tenant policy
requireMfaForOwnersrequires a second factor from owners only (their non-MFA sessions stop at the next use; they enroll on the next sign-in). Console: the toggle in settings, a "Change email" card on the account page (auth.requestEmailChange), and/cloud/confirm-emailfor the confirmation link; the deliveries page opensemail-changelinks in development. -
Microsoft Entra ID sign-in:
createOAuthLoginconnections acceptkind: 'microsoft'withmicrosoftTenant(organizationsby default,common,consumers, or one tenant) and an optional sovereign-cloudissuer. Multi-tenant connections requireallowedMicrosoftTenants(Entratidvalues), and each ID token is validated against the concrete issuer of its own directory. Emails count as verified only with Entra'sxms_edovclaim (protection against "nOAuth" email spoofing).begin(connectionId, credential, { loginHint, domainHint, prompt })and thelogin_hint/domain_hint/promptquery parameters of the start route forward validated sign-in hints (Microsoftdomain_hint, Googlehd, GitHublogin). -
Delivery templates and back-off:
renderDeliveryMessagefrom@better-iam/auth/templates(alsobetter-iam/auth/templates, a subpath without native dependencies; re-exported from the main auth entrypoint) renders every built-in outbox template (verify-email,password-reset,email-change,magic-link,code,mfa-code,new-sign-in,owner-invitation,member-invitation) into{ subject, text, html }with your own link builders.RATE_LIMITEDerrors carryretryAfterMs; HTTP responses addRetry-After, andIamClientError.retryAfterMsexposes it. The console's deliveries page shows each message's rendered subject. -
Role inheritance:
roles.create/roles.updateacceptinherits(role IDs; at most 20, no cycles, no protected roles, empty list clears); a role grants its own policies plus, recursively, the grants of the roles it inherits, each bounded by the inheriting role's authority ceilings as well as the inherited role's. Deleting an inherited role is refused withRESOURCE_IN_USE. Configuration sync exports and appliesinheritsby name. Console: an Inherits card on the role page. -
Access windows: bindings accept
window: { from, to, timeZone, days? }(bindings.create/update,nullclears); outside the recurring window the binding grants nothing.identities.listBindingsreportsinWindow; configuration sync carries windows on group bindings. Console: the window on the member page's role table and assignment form. -
Offboarding:
identities.offboard({ tenantId, identityId, reason, successorId? })(recent authentication,iam:identities:update) disables an identity and, in one transaction, revokes its sessions and keys, removes its role bindings (under the caller's authority), group memberships, activations, relationships, and pending access requests, revokes the grant authorities it holds, removes ownership likesetOwner, and reassigns the managed resources it owns to a successor or reports them. Audited asidentity:offboardwith the reason and counts. Console: an Offboard card on the member page. -
CLI
config-plan --fail-on-driftexits non-zero (CONFIG_DRIFT) after printing the plan when the tenant differs from the file, for CI checks. -
Outbound SCIM provisioning:
createScimProvisioner({ ...iam.protocolHost, encryptionKey })frombetter-iam/scimkeeps downstream SCIM 2.0 applications in step with a tenant's active members, or with members of chosen groups. It creates users (adopting existing ones byexternalIdoruserName), pushes changes, and deactivates or deletes (deprovision) people who are disabled, deleted, expired, or leave scope. Identity attributes map totitleand the enterprise extension (attributeMapping). Targets are managed throughcreateTarget,listTargets,getTarget,updateTarget, anddeleteTarget(built-in actionsiam:scim:targets:create|read|update|delete|sync; audited), with encrypted write-only bearer tokens.syncTargetruns on demand,syncAllsuits schedulers, andsubscribe(iam.events)syncs after member changes. Each run reports created/updated/deactivated/deleted/unchanged/failed counts and failure details inlastRun. New collectionsprovisioningTargets,provisioningLinks, andprovisioningGroupLinksare removed with their tenant.pushGroupsalso maintains the scoped groups downstream (display name and provisioned members; adopted, updated, or deleted as scope changes).provisioner.handlerserves the operations as a JSON API (basePath, IAM CSRF rule and envelope) foriam.useProtocol. Console: an App provisioning page connects applications, scopes them to groups, and syncs, pauses, or removes them. It shows per-run results and failures, and syncs follow member changes automatically.previewTarget(and a Preview button) reports what the next sync would change using read-only lookups only. Memberships past theirexpiresAtno longer count toward a target's scope or pushed groups, and access-package assignments (iam:packages:*,package:*events) trigger a sync. -
Passkeys as the second factor: a sign-in that returns
mfaRequirednow reportspasskeyAvailablewhen the person has a passkey registered;auth.beginPasskeyMfa({ tenantId, challenge })andauth.finishPasskeyMfa({ tenantId, challengeId, response, rememberDevice? })complete MFA with a user-verified WebAuthn assertion bound to that login challenge (both challenges are consumed; remembered devices supported). Console: passkey registration and removal on the account page, "Sign in with a passkey" on the login page, and "Use a passkey" at the MFA step (all throughbetter-iam/client/passkeys).tests/support/webauthn.tsprovides a virtual P-256 authenticator for tests. -
Tenant-managed SAML connections: with
serviceProvider(one deployment-wide SP key pair and base URL) and the host'sauthorize,createSamlServiceaddscreateConnection,listConnections,getConnection,updateConnection, anddeleteConnection(built-in actionsiam:saml:connections:create|read|update|deleteon the new internal resource typesaml; audited). Organizations enter their IdP's metadata XML, or its sign-on URL, issuer, and certificates, and get fixed{basePath}/{id}/metadata|acs|loginURLs. The declarativeattributeMappingfeeds identity attributes, summaries report certificate fingerprints and expiry for rollover, andenabled: falsestops sign-ins.parseIdpMetadata,normalizeCertificate, andcertificateInfoare exported,getMetadata(id)serves both configured and managed connections, andsamlConnectionsis removed with its tenant. IdP-initiated SAML sign-in is available per connection (allowIdpInitiated, configured or managed): responses answering no request are fully validated and each assertion ID is accepted once (samlAssertions);idpInitiated(connectionId, samlResponse)is the direct call andvalidateSamlEnvelopeacceptsnullas the expected request. -
Nuxt and Vue. New package
@better-iam/vue(alsobetter-iam/vue) for Vue 3.3+.createIam({ client })is the plugin, anduseSession,useAuthorize,useCan, anduseAccessibletake refs or getters and re-run when the input or the signed-in identity changes.<IamCan>hasdefault/fallback/loadingslots. Server rendering awaits queries throughonServerPrefetchand hands the results to the browser through anIamHydrationstore (createHydration), so hydration neither refetches nor mismatches. New package@better-iam/nuxtis a Nuxt 3.14+/4 module (betterIamconfig key). It mounts the IAM API at/api/iam/**in Nitro from the file named byinstance(default~~/server/iam), initializes the instance on first use, and loads the session during SSR through an in-process client bound to each request (event.context.betterIam). Page access comes fromdefinePageMeta({ iam: true | false | { action, resource?, tenantId?, redirectTo? } })with optionalrequireAuth: 302 tologinPath?next=when signed out, and a 403 error page on the server and on client navigation. The module auto-importsuseIamSession/useIamClient/useIamAuthorize/useIamCan/useIamAccessibleand<IamCan>(sessions typed from the registered instance) and the server utilitiesgetIamSession,requireIamSession,requireIamAccess,iamCan,issueIamAssertion,iamCredential, anduseIam.@better-iam/nuxt/h3exportscreateIamH3for any h3 v1/v2 or Nitro app (per-event session memo, h3-compatibleIamH3Errorwithdata.code, Web or Node request bodies). The framework-agnostic session store moved to@better-iam/client/session(better-iam/client/session);@better-iam/reactre-exports it unchanged, andisUnauthenticatedalso recognizes in-processIamErrors with status 401/403.examples/nuxtis a runnable Nuxt 4 app with a production smoke test, anddocs/nuxt.mdis the guide. -
Separation of duties:
sod.create/list/update/delete/violations(built-in actionsiam:sod:read|manage) declare roles nobody may hold together. Prevent rules refuse, withSOD_CONFLICT, any binding, group membership, bulk onboarding, access-request approval, configuration apply, or invitation acceptance that would create a new conflict, while pre-existing conflicts are reported instead of blocking; detect rules only report. The access analysis gains a high-severityseparation-of-dutiesfinding. Console: a Separation of duties page. -
docs/authentication.mdis a full guide to sign-in methods, MFA (authenticator, recovery codes, emailed codes, remembered devices), sessions and recent authentication, account recovery, tenant authentication policies, impersonation, HTTP cookie behaviour, and the deployment options and email templates. The console gains self-service password recovery: "Forgot your password?" on the login page,/cloud/resetto request the email and to choose a new password from its link, and the deliveries page openspassword-resetlinks in development. -
Configuration as code: the
configAPI group (iam:config:read,iam:config:apply) exports a tenant's roles, policies, groups (with member emails), tenant-defined resource types, and group role bindings as one JSON document keyed by name (config.export), computes the creates, updates, and deletes a document implies (config.plan, withprunefor items a listed kind omits), and applies it in a single transaction where every change is authorized like the direct call and one refusal rolls everything back (config.apply, audited asconfig:apply). CLI:config-export,config-plan,config-apply [--prune]as theBETTER_IAM_TOKENholder.validateTenantConfigand theTenantConfig/ConfigPlantypes are exported; the mutation helpers behindroles,policies,groups,resourceTypes, andbindingsare shared with the API. Console: a Configuration page. -
Just-in-time roles:
bindings.create/updateaccepteligible,maxActivationMs(default one hour, up to seven days),requireJustification, andrequireMfa; an eligible binding grants nothing until its subject (directly or through a group) callsbindings.activate(iam:bindings:activateoniam/{roleId}), for a bounded time, from an ordinary session, never while impersonating.bindings.deactivateends one's own activation,bindings.revokeActivationends someone else's (like deleting the binding),bindings.listActivationslists them,bindings.listMineshows members their own roles and what they may activate, andidentities.listBindingsreportsactivation. Activations end with the membership, the binding, or the role, and the purge worker sweeps expired ones (expiredActivations). Audited asbinding:activate(with the justification) andbinding:deactivate. Console: an Elevate page, eligibility on the member and role pages. -
Identity expiry:
Identity.expiresAtschedules deactivation for contractors and temporary service accounts (identities.create/createMany/update,serviceAccounts.create/update;nullclears it). Past the deadline every credential of the identity is refused (UNAUTHENTICATED),identities.list({ expiresBefore })reports upcoming expiries, and the purge worker disables such identities, revokes their sessions, and recordsidentity:expire(expiredIdentities); re-enabling requires clearing or extending the deadline first. The last owner cannot be scheduled away. Console: the deadline on the member page and when creating service accounts. -
API key hygiene: keys carry a
nameanddescription(credentials.create,credentials.update, which can also move the expiry within a year under recent authentication),credentials.getandcredentials.listreturnlastUsedAt(recorded at most once a minute when the key authenticates a request) andcredentials.list({ unusedForMs })finds keys nobody has used, including never-used ones; rotation keeps the label and starts the usage history over. Console: labeled key issuance and an "unused 30d" flag. -
@better-iam/adapter-sqlite: closing a store before its first query now releases the database file (Kysely opens the driver lazily, so the eagerly opened connection was left to garbage collection). -
Emailed MFA codes:
authentication.mfaEmailCodesand the tenant policy fieldmfaEmailCodeslet people without an authenticator satisfy an MFA requirement with a one-time code (auth.requestMfaCodefor a login challenge whose sign-in reportedemailCodeAvailable, thenverifyMfa); codes are hashed, single-use, bound to the challenge, and expire with it. Never offered to root administrators or to people with an authenticator. Console: "Email me a code instead" on the MFA step and the policy toggle in settings. The console's IAM route now answersGET, so/api/iam/healthand/api/iam/metricswork behind Next.js. -
CLI
analyze --tenant ID [--dormant-days N] [--fail-on high|medium|low]prints access-analysis findings as theBETTER_IAM_TOKENholder and exits non-zero at the chosen severity, for nightly jobs and deployment gates. -
Access certification campaigns:
certifications.create/list/get/decide/close/delete(built-in actionsiam:certifications:read|review|manage) snapshot a tenant's role bindings for designated reviewers, forbid self-review, and on close remove revoked (optionally undecided) bindings under the closer's authority, recording each item's outcome. Console: a Certifications page with per-binding keep/revoke buttons. -
Network allowlists: the tenant authentication policy gains
allowedIpRanges(IPv4/IPv6 addresses or CIDR blocks); sessions, including impersonation, are refused withIP_NOT_ALLOWEDwhen the recorded client IP is outside them, and existing sessions from outside stop working at their next use.@better-iam/coreexportsisIpRangeandipMatches. The console settings page edits the list, and the admin panel gains an Operations page (health, outbox backlog, active sessions, and the process's Prometheus counters and latency histograms; the console enablesobservability.metricsand exposesGET /api/iam/metricswhenMETRICS_TOKENis set). -
@better-iam/nextApp Router integration:iamNext.client()is the typed API bound to the current request, calling the IAM handler in process and writing the cookies it issues throughcookies(), so server actions sign people in and out (and through MFA) without client JavaScript.page(render, spec),route(handler, spec), andaction(fn, spec)wrap pages and layouts, route handlers (JSON error envelope with the server's status; cookie or bearer API key), and server actions (ActionResultforuseActionState; Next control flow propagates) with authentication and optionalauthorize: { action, resource, tenantId }. The ambientgetSession()is memoized per request with Reactcache(cacheoption), reads cookies a server action just set, andsessionForClient()returns JSON forIamProvider initialSession.interrupts: trueuses Next'sunauthorized()/forbidden();requireTenantSession({ slug })/tenant(slug)serve/[org]/...routes (unknown aliases callnotFound(), other organizations go to/login?org=);handlers()addsGETfor/healthand/metrics. Middleware gainspublicPathsglobs,signedInRedirect(bare login visits only; guards add?next=when they reject a stale cookie, so it cannot loop), andnext(passNextResponse.next) to forwardx-better-iam-pathname, which becomes the default?next=. The new edge-safe@better-iam/next/edgesubpath (better-iam/next/edge) holds the middleware plus Web CryptoverifyAssertionToken/withAssertion(offline assertion checks for downstream services),verifyWebhook/createWebhookHandler(signed webhook receiver with secret rotation, freshness, size limits, and retry-friendly failures), andsafeRedirectPathagainst open redirects. The server instance reports its HTTPendpoint(origin,basePath,secure).docs/nextjs.mdis a full guide, andexamples/nextjsis a runnable App Router application covering each helper. -
@better-iam/nextPages Router and client components:iamNext.pages.withSession(gssp, spec)(getServerSidePropswith login redirects,notFoundor redirect on denial, and the session as JSON props),pages.api(handler, spec)(API routes with the JSON error envelope),pages.client(req, res)(in-process client appending cookies to the response),pages.getSession(req), andpages.handler()forpages/api/iam/[...path].ts(accepts bodies Next already parsed).interrupts: 'forbidden'interrupts denials only and keeps login redirects. The new'use client'entry@better-iam/next/client(better-iam/next/client) addsIamNextProvider/useRouterSync()(callsrouter.refresh()when the signed-in identity changes in the browser) anduseSignOut({ redirectTo }).iamNext.allowed(action, resource?)and the async server component<iamNext.Can action resource fallback>batch every check made during a render into one deduplicatedauthorizeManyper tenant (50 per call) and reuse answers for the rest of the request. -
New package
@better-iam/nestjs(alsobetter-iam/nestjs) for NestJS 11 and 12 on Express or Fastify.IamModule.forRoot/forRootAsyncprovidesIamService(principal,authorize,require,can,listAccessible,assertion, all per request) andIamGuard. Options install the guard globally (guard), serve the IAM HTTP API from the Nest app (mount, which also handles bodies Nest has already parsed), and registerIamExceptionFiltersoIamErrors keep their status and{ error: { code, message } }body. Decorators:@Public,@Authorize(action, { resource, tenant })(rules add up across class and method; values come from params, query, body, headers, GraphQL arguments or WebSocket message fields, or functions),@RequireMfa,@Credentials('api-key'),@CurrentPrincipal/@CurrentIdentity/@CurrentSession/@TenantId, and@OnIamEvent(pattern)for provider methods (with optionaldispatchIntervalMs). The guard rejects cookie-authenticated unsafe requests from foreign origins (csrf) and supports HTTP, GraphQL and WebSocket contexts. Downstream services useIamAssertionModule.forRoot({ key, audience })withIamAssertionGuard,@AssertionClaims()and@RequireClaims({ roles, groups, mfa, kinds }). That path loads only the new@better-iam/server/assertionssubpath, so a verifying service doesn't need the server's native dependencies.@FilterAccessible(action, { type, id?, path? })trims list responses to the resources the caller may act on through thelistAccessiblereverse query, so filtering doesn't write an audit denial per item.forRootAsyncalso takesuseClass/useExisting(IamOptionsFactory).IamService.health()probes the IAM database in process, andIamService.credential(request)returns the caller's credential. The mount path defaults to the server's reportedendpoint.basePath.examples/nestjsis a runnable Express app with a smoke test (pnpm --filter @better-iam/example-nestjs smoke).@better-iam/nestjs/testing(better-iam/nestjs/testing) exportscreateTestingIam, an in-memory stand-in for tests: principals chosen by bearer token, decisions made by a callback, registered resources for@FilterAccessible, adecisionslog, andemit()for@OnIamEventhandlers. -
OAuth authorization server: client management (
listClients,getClient,updateClient,rotateClientSecret; new built-in actioniam:oauth:clients:update). Narrowing a client's grant types, scopes, or resources, or requiring DPoP, revokes everything issued to it. Connected apps:listGrants,revokeGrant,revokeGrants(self-service, oriam:oauth:grants:read|revokefor other accounts; auditediam:oauth:RevokeGrant), and repeated consent in one provider session extends a single grant. Resource servers: theresourceServersoption (RFC 8707 resource indicators) issues audience-restricted JWT access tokens (RFC 9068) to clients registered withresources. The newcreateAccessTokenVerifierverifies them offline, including DPoP proofs (RFC 9449) with replay detection. Clients can setrequireDpop/requirePushedAuthorization, the provider takesrequirePushedAuthorizationRequests/dpopNonceSecret, andinteractionDetailsaddsclientNameandresources. Confidential clients can authenticate withclient_secret_postorprivate_key_jwt(tokenEndpointAuthMethod, withjwks/jwksUri; single-use assertions, key rotation throughupdateClient). OpenID back-channel logout: clients registerbackchannelLogoutUri, andlogoutEndedSessions()notifies them and revokes consents whose IAM session ended (auditediam:oauth:SessionLogout). Outbound provider requests allow loopback targets only underallowInsecureLocalhost. Per-clientaccessTokenTtl/refreshTokenTtlcan shorten token lifetimes below the provider and resource server defaults. Clients carry consent-screen branding (logoUri,clientUri,policyUri,tosUri) and afirstPartyflag, reported byinteractionDetails(...).client. Token exchange (RFC 8693): confidential clients registered forurn:ietf:params:oauth:grant-type:token-exchangetrade an account's access token for a token to one of their resources, with the client asactand a lifetime capped by the subject token.authorizeTokenExchangeadds policy, exchanges are audited asiam:oauth:TokenExchange, and the verifier exposesactor. Consent now grants requested resource scopes, so browser flows withresourcereceive resource-server tokens. -
Access analysis:
analysis.findings(iam:analysis:read) reports unrestricted administrator policies, service-wide action wildcards, administrators without a second factor, service accounts with full administration, dormant members who still hold access, trusts without MFA, and unattached policies, unused or empty roles, and member-less groups with bindings, ordered by severity with deterministic IDs;analysis.suppress/unsuppress(iam:analysis:update) record accepted risks. Console: a Security findings page. -
Verified domains and home-realm discovery: a new
domainsAPI group (add,list,verify,delete; built-in actionsiam:domains:create|read|update|delete) lets an organization prove control of an email domain with a DNS TXT record; each domain is verified by at most one tenant and consumer mailbox providers are refused. Publicdomains.discover({ email })returns the owning tenant with its alias, accepted sign-in methods, and MFA requirement. Thedomainsoption injects the TXT resolver, record label, and blocked list. Console: a Domains page and "use your work email" on the organization picker. -
Security activity and sign-in alerts:
auth.listSecurityEventsreturns the caller's ownauth:*audit trail (impersonators named) for account pages;authentication.signInNotificationsand the tenant policy fieldnotifyNewSignInqueue anew-sign-inemail when a session starts from a client (user agent + IP) that none of the person's live sessions or remembered devices has used. Console: "Recent security activity" on the account page and the alert toggle in settings. -
Password policy: tenant authentication policies gain
passwordHistory(refuse the last 1–24 passwords;PASSWORD_REUSED),passwordMaxAgeDays(a verified but expired password is refused withPASSWORD_EXPIREDuntil reset),passwordMinClasses(2–4 character classes), andpasswordRejectPersonalInfo.authentication.passwordPolicyadds deployment-wide screening: a built-in common/sequential/low-variety screen (on by default),isBreached(BREACHED_PASSWORD;pwnedPasswords()is a k-anonymity Have I Been Pwned client), and a customcheck. Identities recordpasswordChangedAt; previous hashes live in the newpasswordHistorycollection (newest 24, removed with the identity or tenant).@better-iam/authexportspwnedPasswords,isCommonPassword, andcharacterClasses. Console settings edit the new rules. -
Trusted devices ("remember this device"):
verifyMfaandconfirmMfaacceptrememberDeviceand return adeviceToken(withdeviceExpiresAt) that lets the same browser skip MFA on latersignIn/finishPasswordlesscalls;authentication.trustedDeviceLifetimeMscaps the deployment (30 days by default, 0 disables) and the tenant policy gainstrustedDeviceDays. Root administrators are never remembered; password, email, and factor changes forget every device; sessions recordtrustedDeviceId;auth.listTrustedDevices,revokeTrustedDevice, andrevokeTrustedDevicesmanage them (audited asauth:device:trust/auth:device:revoke). The HTTP handler keeps the token in abetter-iam.devicecookie and injects it into sign-in bodies. Console: a "remember this device" checkbox at MFA, a remembered-devices card on the account page, and the policy field in settings. -
SCIM protocol coverage: RFC 7644 filters (
and/or/not, grouping, value paths such asemails[type eq "work"], sub-attributes, schema-qualified enterprise attributes,gt/ge/lt/le) evaluated against rendered resources;sortBy/sortOrder;attributes/excludedAttributesprojection;POST {Users|Groups}/.search;POST /Bulk(100 operations, per-operation transactions,bulkIdforward references,failOnErrors);If-None-Match. PATCH gains sub-attribute and value-path targets (name.givenName,emails[type eq "work"].value,urn:…:enterprise:2.0:User:department), listed-member removal and Entra-style"True"/"False"booleans, and no longer dropstitleor the enterprise extension on unrelated changes.scim.listConnectionsreports usage (lastUsedAt, user/group counts) andscim.rotateTokenreplaces a connection's token without losing provisioned state. Discovery advertises bulk, sorting and the enterprise extension schema. -
Operations endpoints:
observability.metricskeeps Prometheus-style counters and histograms from spans (iam.metrics.render()/snapshot(), exportedcreateMetrics), served atGET {basePath}/metricswhen abearerTokenis configured;GET {basePath}/healthreports database reachability. Caller-controlled names collapse and series are capped, so scrapes stay bounded;gauges: trueadds outbox-backlog and live-session gauges read from storage on each scrape. The console audit log gains a search form (action glob, actor, resource, outcome, time range) with pagination and actor names. -
Impersonation ("view as"):
identities.impersonateopens a member session for support when the tenant's authentication policy setsallowImpersonation(new built-in actioniam:identities:impersonate, recent authentication, a recorded reason, at most eight hours and never beyond the administrator's own session). Owners, root administrators, service accounts, and the caller are never eligible. Such sessions carryimpersonatorId, cannot perform recent-authentication operations, re-authenticate, assume roles, grant OAuth consent, or impersonate further, end with the administrator's session (auth.endSession), and are excluded frommaxSessions. Audit events and webhook bodies gainimpersonatorId, policies gainprincipal.impersonated/principal.impersonatorId, assertions gainimpersonatorId,AuthMethodgainsimpersonation, and the HTTP layer never sets a cookie for the impersonation token. Console: settings toggle, "View as" on the member page, a banner with a stop button, and attribution in the audit log and session lists. -
Tenant authentication policy gains
maxSessions(concurrent sessions per person; the oldest ends).identities.requestPasswordResetqueues a reset email for a member on an administrator's behalf.docs/api-reference.mdis generated from a live instance bypnpm docs:api; the HTTP route tables (routeGroups,publicApiMethods,publicAuthMethods,authenticatedAuthMethods) are exported from the server package. -
tenantDefaultsoption: plan limits and an authentication policy applied to every tenant created withtenants.create, validated at construction.tenants.resendInvitationandidentities.resendInvitationrenew an invitation's token and lifetime and queue the email again; the console offers both. -
Tenant authentication policy gains
maxAttempts(tighter rate limits for the tenant's authentication flows) andminPasswordLength(enforced on creation, reset, and change).identities.updateacceptsemailfor administrator-driven address changes (recent authentication, unverified, sessions revoked, audited asidentity:email-change). -
Federated attributes: OAuth/OIDC sign-in connections and SAML connections accept
mapAttributes; mapped values are validated againstpermissions.identityAttributesand stored on the identity at every sign-in (FederatedLogin.attributes). -
Audit retention:
iam.pruneAudit({ tenantId, retentionMs })and CLIaudit-prunedelete a tenant's oldest events behind anaudit:prunecheckpoint that keeps the chain verifiable. -
Access reviews accept platform resources (
iam/...) as the reviewed resource. -
OAuth/OIDC provider: the built-in
iamscope addsroles,groups, andattributesclaims (live when read, expired bindings excluded) to userinfo. -
apps/console: member search and pagination on the members page. -
Directory:
identities.listacceptsquery(name or email, case-insensitive),limit, andoffset, ordered by name;identities.listSessionsreturns a member's active sessions for administrators (iam:identities:read), without token material. -
docs/recipes.mdcollects copy-ready examples for sharing, reviews, policy testing, tenant auth policies, audit archives, assertions, observability, incident response, exports, limits, webhooks, bulk onboarding, and libSQL. -
Policy tooling:
policies.restoreVersionrolls a policy back to an earlier version (re-validated, kept in history) andpolicies.testevaluates a candidate document against an action, resource, and supplied context without storing it; the console policy page offers both. -
Account unlock:
identities.unlockclears the sign-in, recovery, and MFA rate-limit counters of an identity (recent authentication,iam:identities:update, audited asidentity:unlock).RateLimiter.resetis optional for custom limiters; the built-in limiters implement it. -
SCIM: users carry
titleand the enterprise extension (department,division,manager, …);mapAttributesturns them into declared identity attributes, validated throughprotocolHost.validateIdentityAttributes. -
Plan limits and usage: root sets
tenants.setLimits(members, service accounts, groups, roles, policies, resources, webhooks; audited astenant:limits); every creation path, including invitation acceptance, self-registration, federation, SCIM, and bulk creation, fails withLIMIT_EXCEEDEDpast a limit.tenants.usagereports counts, active sessions, and limits. The admin panel edits limits and both consoles show usage. -
Webhook filters: subscriptions accept
outcomesandresources(glob patterns over the resource ID) in addition to action patterns. -
Bulk onboarding:
identities.createManycreates up to 100 identities atomically with optional passwords, declared attributes, roles (authorized like invitations, bound under the caller's grant authority), and groups. -
Webhook redelivery: outbox messages remember the audit event behind a delivery (
reference;listDeliveriesexposeseventId) andwebhooks.redeliverqueues that event again, rebuilt from the audit record and signed with the current secret. The console offers it per delivery. -
@better-iam/next:iamNext.assertion({ tenantId, audience, ttlSeconds?, claims? })issues a stateless assertion for the current request's session. -
apps/console: member attributes (department, title) editable on the member page; the admin audit page reports chain integrity. -
Data-subject export and incident response:
identities.exportreturns everything a tenant stores about one identity (no secrets; audit trail when the caller may read it), audited asidentity:export;identities.revokeSessionsends one identity's sessions without disabling it;tenants.revokeSessionsends every session in a tenant (includeSelfoptional). All three require recent authentication. The console offers them on the member page and in organization settings. -
New package
@better-iam/adapter-libsql(better-iam/adapter-libsql): libSQL persistence through@libsql/clientfor local files, encrypted files, embedded replicas, and remote Turso or sqld databases, with the same schema, transaction guarantees, and error mapping as the SQLite adapter.better-iam init --database libsqlscaffolds it. -
Session metadata: sessions issued through the HTTP handler record
client.userAgent;http.clientInfo(request)derivesip,userAgent, and a devicelabelbehind a trusted proxy;iam.auth.withClient(info, fn)scopes details for direct calls.auth.revokeOtherSessionsends every other session of the caller (recent authentication required, audited asauth:session:revoke-others); the console lists devices and offers "Sign out other sessions". -
CLI:
audit-verify --tenant IDrecomputes a tenant's audit chain from storage (non-zero exit when broken) andaudit-export --tenant ID --output FILEwrites it as JSON Lines;doctorreports chained tenants and events. -
apps/console: workspace sharing through relationships (workspace page and member page), an access-reviews page (who can, effective actions), audit chain verification and JSONL export, the organization authentication policy in settings, relations on resource types, and the sign-in method on sessions. -
Observability:
observability.onSpanreceives a timed span for every provisioning operation,authorize/authorizeMany/listAccessiblequery, authentication call, and HTTP request, with tenant, outcome (ok,denied,error), error code, status, and duration. Handler failures are ignored. -
Stateless assertions (
assertionsAPI group):assertions.issue({ tenantId, audience, ttlSeconds?, claims? })returns a short-lived HS256 JWT describing the caller for a service, authorized asiam:assertions:createoniam/{audience}.iam.assertionKey()derives the verification key from the deployment secret andverifyAssertion(token, { key, audience, issuer? })checks tokens offline. -
Tenant authentication policies:
tenants.setAuthPolicysets or clearsrequireMfa,allowedMethods,sessionLifetimeMs, andsessionIdleTimeoutMsper tenant (recent authentication andiam:tenants:update; audited astenant:auth-policy). Policies only tighten the deployment's configuration; method restrictions are enforced before credentials are examined (METHOD_NOT_ALLOWED), sessions are re-validated on use, and MFA cannot be disabled while required. User sessions record their sign-inmethod, exposed to policies asprincipal.authMethod.iam.auth.mfaRequired,tenantRequiresMfa, andsessionLimitsare public for trusted integrations. -
Audit hash chain: every audit event carries
sequence,previousHash, andhash(SHA-256 over canonical JSON) linked per tenant throughauditChains; all writers (server, authentication, SCIM, OAuth provider) append throughappendAuditEvent.audit.verifychecks a tenant's chain or a window of it,audit.exportpages events as JSON Lines, andverifyAuditChain/auditEventHash/canonicalJson(core andbetter-iam) verify archives offline.initialize()backfills unchained events once. Webhook bodies includesequenceandhash. -
Access reviews under
iam:policies:simulate:policies.whoCanlists the identities that could perform an action on a resource (withkind,assumeMfa, and pagination),policies.effectiveActionslists the actions an identity holds on a resource across the catalog or a chosen list, andpolicies.simulateacceptsassumeMfa. -
Relationships (
relationshipsAPI group): resource types declarerelations(inpermissions.resourceTypes, pluginresourceTypes, andresourceTypes.register/update);relationships.create/list/deletebind identities or groups to one resource under a declared relation with optional expiry, authorized byiam:relationships:create/read/deleteoniam/{type}/{id}. Evaluation exposes the principal's live relations on the resource asresource.relationsand on its registered parent asresource.parentRelations;iam/{type}/{id}administrative checks on registered managed resources now carry that resource's attributes, owner, parent, and relations. Tuples are removed with their identity, group, or resource, and a relation still in use cannot be dropped from its type. -
Condition operators:
StringNotEquals,StringEqualsIgnoreCase,StringNotEqualsIgnoreCase,StringNotLike,StringLikeIgnoreCase,NumericNotEquals,NumericLessThanEquals,NumericGreaterThanEquals,NotIpAddress,ArrayContains, andArrayContainsAll. Negated operators andArrayContainsAllrequire every listed value; missing or wrongly typed context never satisfies any operator. -
Principal context:
principal.kind,principal.owner,principal.rootAdmin,principal.sessionKind,principal.groups, andprincipal.rolesare available to conditions and variables.permissions.identityAttributesdeclares typed attributes thatidentities.updateandserviceAccounts.updateset and policies read asprincipal.{name};Identity.attributesandIdentity.descriptionare part of the public identity. -
Plugin contract: plugins may contribute
resourceTypes,hooks.beforeOperation/afterOperation(run inside the operation transaction; throwing aborts it), andresolveContext; plugin endpoints receivedeliverto queue email/SMS through the host outbox. -
Policy variables:
${principal.id}and any other trusted context key can appear in resource patterns (after the type segment) and inStringEquals/StringLikevalues. Substituted values match literally, unresolved variables never match, and malformed references are rejected at validation. -
Reverse queries:
iam.listAccessible/client.listAccessiblereturn the registered resources of a managed type that the caller may perform an action on, evaluating grants once per query.resources.registerManyregisters up to 100 resources atomically with per-item authorization. -
Temporary bindings:
bindings.createacceptsexpiresAt; expired bindings grant nothing and are hidden from effective views (bindings.listhasincludeExpired);bindings.updateextends, shortens, or clears the expiry.purgeDeletednow also removes expired bindings and expires stale access requests, and reportsexpiredBindings/expiredRequests. -
Access requests (
accessRequestsAPI group): members withiam:access-requests:createrequest roles with a justification and optional duration; reviewers withiam:access-requests:reviewapprove (creating bindings under their own grant authority, subject toiam:bindings:createon each role) or deny; requesters cancel; pending requests expire afteraccessRequests.lifetimeMs(default 7 days). -
Events and webhooks: every audit event is now an event.
iam.events.subscribe(pattern, handler)andevents.onEventreceive committed events from the dispatcher (iam.events.dispatch, alias ofdispatchAuditHooks, now returns{ dispatched }). ThewebhooksAPI group subscribes HTTPS endpoints per tenant (root may subscribe a subtree) to event patterns; deliveries are queued in the same transaction as the audit record, signed with a per-subscription secret (X-Better-IAM-Signature, verify withverifyWebhookSignature), retried with exponential backoff, and listed throughwebhooks.listDeliveries.events.deliverWebhookreplaces the built-in HTTPS transport. -
Outbox:
dispatchOutboxreturns{ delivered, failed, abandoned }, retries with backoff (30 seconds doubling to one hour), abandons messages afterauthentication.maxDeliveryAttempts(default 25) withfailedAt/lastError, and dispatches in creation order. -
Configurable rate limits:
authentication.rateLimitssetsattempts(default 10),sensitiveAttempts(default 5),windowMs(default 15 minutes), and a pluggablelimiter;createMemoryRateLimiter()is provided for single-process deployments and tests. -
Administration fills:
identities.delete(tombstones the identity, revokes credentials, factors, bindings, memberships, authorities, and links;identities.listgainskind,status, andincludeDeletedfilters),serviceAccounts.list/get/update/setStatus/deletewith an optionaldescription,credentials.list(iam:credentials:read),trust.list(iam:trust:read),root.listAdministrators, andaudit.listfilters (actorId, globaction,resourceId,outcome,from,to) with newest-first ordering.Identity.statusgainsdeleted. -
Internal restructure with no public API change: the server is split into focused modules (
options,catalog,context,events,decisions,principals,operations,flows,lifecycle,federation,http, and one file per API group), the authentication service is assembled from feature classes over a shared base withoutboxandrate-limitmodules, SCIM separates its filter, discovery documents, provisioning, and handler, the OAuth provider adapter lives in its own module,tenantTreeActivein core replaces four private tenant-tree walkers, and the whole repository is formatted with Prettier. -
New packages:
@better-iam/react(IamProvider,useSession,useAuthorize,useAccessible,Can, and the framework-agnosticcreateSessionStore) and@better-iam/next(createIamNextwithgetSession,requireSession,require,can,handlers, pluscreateIamMiddleware), exposed asbetter-iam/reactandbetter-iam/next. -
apps/console: a Next.js administration panel (/admin, root administrators with MFA: organizations, root administrators, catalog, audit, deliveries) and multi-tenant cloud console (/cloud, alias sign-in, invitations, workspaces as managed resources, members, roles, groups, policies, resource types, resource registry, service accounts and API keys, account settings with sessions/MFA/linked accounts). Built oniam.api.*from server components, the typed client through/api/iam, andiam.requirefor enforced pages. -
Fixed
SafeIdentity/SafeSession(returned bygetSession,listSessions,groups.listMembers) losing their known properties:Omitover the record index signature erased them; they are now mapped types. -
Resource catalog:
permissions.resourceTypesdeclares application-owned and IAM-managed resource types with actions, typed attributes, and parents; managed resources are registered through the newresourcesAPI and resolved without an application callback. Tenants intenant-definedmode register their own managed types and{type}:{verb}actions through theresourceTypesandactionsAPIs. Policies, inline role documents, boundaries, ceilings, and session policies are validated against the catalog (INVALID_ACTION,INVALID_RESOURCE_TYPE). -
Breaking: tenant-defined actions no longer use the
tenant/{tenantId}/prefix;actions.listreturns{ name, source, resourceType, description }objects. Addedactions.unregister. -
Roles:
roles.create/updateacceptdescription, an inlinedocument, or apermissionslist; addedroles.get,roles.listBindings,bindings.list,bindings.readaction,identities.listBindings(effective roles through groups),identities.listGroups,identities.get,identities.update,groups.get,groups.update,groups.listMembers,policies.get,policies.listVersions, and policy/group descriptions.policies.updateaccepts name/description changes. -
Organizations as accounts: optional globally unique tenant
slugontenants.create,bootstrap, andtenants.setSlug; publictenants.lookupresolves an active tenant for alias-based sign-in. Member invitations (identities.invite,listInvitations,revokeInvitation, publicacceptInvitation) onboard people into an existing tenant with roles and groups applied under the inviter's authority.identities.createno longer requires a password.links.listreturns linked accounts for account switchers. -
iam.authorizeManyandclient.authorizeManyevaluate up to 50 checks in one transaction for UI state. Invitation responses no longer include token hashes. -
Application-level integration test (
tests/application.test.ts) exercising the HTTP handler and typed client end to end, with compile-time inference checks; the example application demonstrates aliases, invitations, permission roles, resource types, and managed workspaces. -
Tenant lifecycle administration: rename and move tenants with hierarchy, depth, cycle, and grant-authority validation; list and revoke owner invitations; delete pending tenants; retention purging through
iam.purgeDeletedand the CLIpurgecommand, with audit records preserved. -
@better-iam/projectsreference plugin: tenant-scoped project records throughprojects:read/projects:writeplugin endpoints (create, list, get, update, archive, restore), including purge cleanup of purged tenants' records. -
Foundations: the initial independent authentication and IAM implementation, with a tenant hierarchy, isolated identities, policies and delegation, root authority, service credentials, role assumption, password/MFA/passkey flows, federation/provisioning packages, a typed client, a CLI, SQL adapters, tests, examples, and synchronized packaging.
Better IAM is created by Sean Filimon
Last updated