Uh oh!
There was an error while loading. Please reload this page.
fix(plugin-auth): SCIM active:false disables the account again — route the vendor's reconcileUser hook to the platform ban write (#14360) - #14540
Conversation
… the platform ban write SCIM active:false revoked sessions and wrote nothing on stable @better-auth/scim (the vendor's ban write left the package in 1.7.0); sys_user.banned was never set and a local-password user signed straight back in. Wire identity.reconcileUser into the scim() options and route it to the shared ban/unban write in admin-ban-endpoints.ts, inside the SCIM transaction; the engine-level last-administrator guard judges it. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
…M transaction scope leaves (#14522) The (c) face keeps the account enabled and the SCIM 403 shape; the vendor's own scimUser.active write surviving the refusal is pinned as observed and attributed to the adapter's SCIM transaction scoping never engaging on 1.7.2, so the fix for that seam flips the pin deliberately. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) NAME something this change touched and may need an implementation-accuracy re-verification:
What this run could not see
Coarse fallback — 11 page(s) merely mention a changed package (the pre-#9192 predicate, kept for the deliberately-wide backstop): Which tree this was computed onThis run read A worktree cut from an older # while this PR is open — GitHub drops the merge commit once it closes
git fetch origin 32800235fc78c29dffbfc4bdfd601e0028eb72ca && git checkout 32800235fc78c29dffbfc4bdfd601e0028eb72ca
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin fed4fa40924bd1366354ac9b8a55db9d94a7e404 21c7dbe76b7d44df32fbd9f6497db6372b9f2d0f && git checkout -B drift-repro fed4fa40924bd1366354ac9b8a55db9d94a7e404 && git merge --no-ff 21c7dbe76b7d44df32fbd9f6497db6372b9f2d0f
node scripts/docs-audit/affected-docs.mjs --json fed4fa40924bd1366354ac9b8a55db9d94a7e404
|
os-sales
commented
Sep 2, 2026
Contract review round 1 at |
…nt; move the shared ban write off the barrel Contract review round 1 (#14360): a SCIM active:false over an administrator's TIMED ban left the expiry in place, and the vendor's session hook auto-lifts an expired ban - so the principal was re-admitted while the IdP still held them deactivated. The hook now clears banExpires on that row (reason and banned untouched). The shared write moves to the package-internal user-ban-write.ts (not re-exported from index.ts), so no new public symbol ships and the changeset drops to patch. Two faces added: the expiring-ban overlap and POST /Users with active:false. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
os-sales
commented
Sep 2, 2026
Landing provenance — ready + auto-merge at head |
Uh oh!
There was an error while loading. Please reload this page.
Fixes#14360
What this does
Stable
@better-auth/scim(1.7.0 and later; the repo pins and installs1.7.2) no longer writes the admin plugin'sbannedcolumn when an identity provider sendsactive: false— it hands the user's aggregate lifecycle state to an OPTIONAL host callback,identity.reconcileUser, and then only revokes sessions.plugin-authpassed noidentitymember, so a deactivation revoked sessions and wrote nothing:sys_user.bannedstayed false and a user holding a local password signed straight back in, while ADR-0071, the generated docs and the #13816 construction-time refusal all asserted the ban.Limb A of the triage ruling, and only limb A:
auth-manager.ts— thescim({...})options gainidentity.reconcileUser, routed to a new private methodreconcileScimUserLifecyclethat reads the user through the vendor's transaction-bound adapter and reconciles:active: falseon a row that is not banned ⇒ the platform ban write (reasonDeactivated via SCIM, the exact string the vendor wrote itself through 1.6.x; no expiry), and on a row already banned WITH an expiry (an administrator's timed ban) ⇒ the ban is made permanent (banExpirescleared,bannedand the administrator's reason untouched — the vendor's session hook auto-lifts an expired ban and would otherwise admit a principal the IdP still holds deactivated);active: trueon a row banned WITH that reason ⇒ unban; anything else is a no-op (the callback is contractually idempotent and the vendor calls it on EVERY user mutation).user-ban-write.ts(new, PACKAGE-INTERNAL — not re-exported fromsrc/index.ts) holds the existing/admin/ban-user//admin/unban-userwrite asapplyUserBan/applyUserUnbanover a minimalUserBanWritersurface;admin-ban-endpoints.tsand the SCIM hook both call that ONE write (field for field the vendor's ownbanUsershape). No new HTTP endpoint, no new public symbol. The admin mount's behaviour is unchanged (its own suite: 88/88 across the six ban-adjacent files).scim-deactivation-reconcile-user.test.tsdriving@better-auth/scimitself through a realAuthManagerover better-sqlite3 (the simulateddeprovision()helper inlast-admin-guard.test.tsis NOT extended, per triage).@objectstack/plugin-auth, levelpatch(see Contract review).Admin forcing untouched (
admin: pluginConfig.admin ?? scimEffectiveat three sites) — that is the #13816 shape-1 follow-up, not this card. The five stale assertion sites triage listed are untouched and are true again under this change.Premise (re-verified on
origin/main00ff228fewith the package installed)grep -ro "ban" node_modules/@better-auth/scim/dist | wc -l= 0; positive control overnode_modules/better-auth/dist/plugins/admin= 117.dist/index.d.mts:267declaresreconcileUser?(input: SCIMIdentityState, context: SCIMTransactionContext);SCIMIdentityStatecarriesuserId, the aggregateactive(true while ANY participating SCIM source is active),profileSourceIdandsources;SCIMTransactionContextcarries onlydatabase(theDBTransactionAdapter). A throw from the hook:runSCIMApplicationCallbackrethrows a better-authAPIErrorunchanged and wraps anything else as a SCIM 500 with detailSCIM identity reconciliation failedand the original ascause— never swallowed.auth-manager.ts:3265handedscim()exactlyconnections: []andauthentication.verifyBearerToken; repo-widereconcileUser= 0 hits (controlverifyBearerToken= 15).admin-ban-endpoints.tsowns the platform ban write and re-runs the last-LOCAL-credential guard; the last-ADMINISTRATOR guard (ADR-0024 D5.2, plugin-auth: break-glass 守卫 —— SCIM/ban 不得停用最后一个管理员(ADR-0024 D5.2,cloud#621 转入) #5892) is an ENGINEbeforeUpdatehook inlast-admin-guard.ts, registered byauth-plugin.tsatkernel:ready, so it holds on everybannedwrite regardless of caller. Both writes were endpoint-shaped (they parsed aRequest); the write itself is now a plain callable.bannedcolumn and the sign-in refusal:better-auth/dist/plugins/admin/admin.mjs:36-47readsuser?.bannedin thesession.createhook and throwsFORBIDDEN/BANNED_USER.PM hypotheses
runIdentityMutationTransactionwith enough identity (userId+ aggregateactive) to drive the ban write;active: false⇒ ban with the SCIM reason,active: true⇒ unban — but only of a ban carrying the SCIM reason. An administrator's ban (any other reason) is deliberately NOT lifted by the IdP: every SCIM PUT carriesactive: true, so unconditional reconciliation would re-admit a user banned for cause on the next attribute sync. Pinned in face (d). Listed as an open question below in case the maintainer wants IdP-authoritative semantics instead.schemascontains the Error URN,status: "403",detail= the guard's own text naminglast administrator,ADR-0024 D5.2andSCIM deprovision is too broad); the ban never lands and the administrator still signs in. Residual: the vendor's OWNscimUser.active = falsewrite, made before the callback inside what it believes is a transaction, survives the refusal — because the adapter's SCIM: 停在 @better-auth/scim rc.1,等正式版再整体迁移 —— rc.2 换掉了整套模型 #3653 SCIM transaction scoping never opens an engine transaction on 1.7.2 (measured: 0engine.transactionand 0driver.beginTransactioncalls across a SCIM POST + PATCH;inScimRequestScope()false at everysys_user/sys_scim_userwrite). That is a pre-existing defect outside this card's file surface, filed as SCIM provisioning writes run outside any engine transaction on @better-auth/scim 1.7.2 — the #3653scimRequestScopestamped inverifyBearerTokenis not observed at write time (0engine.transactioncalls across POST + PATCH /Users) #14522 (out of scope here); face (c) pins the residual as observed (line 487 on this head) so SCIM provisioning writes run outside any engine transaction on @better-auth/scim 1.7.2 — the #3653scimRequestScopestamped inverifyBearerTokenis not observed at write time (0engine.transactioncalls across POST + PATCH /Users) #14522's landing flips that line deliberately. Operator-visible consequence of that residual: after a refused last-administrator deactivation, every later SCIM mutation of that user — an attribute-only PATCH included — re-attempts the ban and answers 403 until the IdP sendsactive: truefor that user or a second administrator exists (it refuses rather than admits; stated on SCIM provisioning writes run outside any engine transaction on @better-auth/scim 1.7.2 — the #3653scimRequestScopestamped inverifyBearerTokenis not observed at write time (0engine.transactioncalls across POST + PATCH /Users) #14522 as well).One consequence stated rather than left implicit: on 1.7.2 a SCIM
DELETE /Users/{id}tombstones the source instead of deleting the better-auth user, so the aggregate state turns inactive and the hook disables that account (face (f)); re-provisioning through the tombstone re-links the same user and the SCIM ban is lifted.Tests (head
21c7dbe76)pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2 src/scim-deactivation-reconcile-user.test.ts+ the five ban-adjacent siblings (admin-ban-endpoints,admin-remove-user-gate-ordering,break-glass-guard-self-service-target,break-glass-local-credential,session-tombstone) —Test Files 6 passed (6) · Tests 88 passed (88),VERDICT command-exit 0.auth-manager,better-auth-schema-parity,credential-at-rest-posture,last-admin-guard,managed-extension-fields,scim-case-insensitive-identifier) —Test Files 6 passed (6) · Tests 469 passed (469),VERDICT command-exit 0.tsc --noEmit(src program) exit 0;pnpm --filter @objectstack/plugin-auth check:test-typecheck—OK — the test layer compiles under tsconfig.test.json(the new test file is in that program; the src program excludes tests by design). Thetsconfig.examples.jsonhalf of thetypecheckscript resolves the package's owndisttypes, which this worktree has not built — NOT MEASURED locally; CI builds it.51000d482; the wiring line it removes is unchanged on21c7dbe76, so the reading was not re-run) (scratchpad/issue-14360/ablation.sh, trap-restored): removed the three-lineidentitymember from thescim()options — proven on disk (marker count 1 → 0,git diff --stat= 3 deletions) — and ran the suite: 6 red / 3 green, exactly (a), (b), the idempotency case, (c) [the request answers 200 instead of the guard's 403], the (c) positive control and (f) red; (d) attribute-only, (d) administrator-ban and (e) green. Restore viagit checkout HEAD -- path:git diff HEADempty, disk blob = HEAD blobe9d5c5eb…, marker count back to 1. Nodistrebuild is involved: the suite imports./auth-manager.jsfromsrc, and no workspace dependency was mutated.Gates (head
21c7dbe76)node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commandsderived 36 commands from the changeset (re-derived on21c7dbe76after the changed-file set grew byuser-ban-write.ts: identical list); each run at21c7dbe76with its exit captured before any pipe. 33 green, includingcheck-system-context-census(OK — 109 elevation read sites … all anchored; 0 anchors on the two edited files, no census fix needed),check:cross-package-test-inputs,check:test-source-alias,check:engine-double-contract,check:logger-receiver-detach,check-adr-0087-registration(non-breaking changeset),check-changeset-no-major. Three answered exit 3 = PREREQUISITE NOT MET by their own verdict text, recorded as NOT MEASURED locally (CI owns them):check-test-completeness(needs a saved turbo test log),check:dual-build-cjs-loads(needspnpm build),check:type-check-debt(needs the full built closure). Control-character self-scan over the four changed files: clean.Contract review (Clause ②)
active: false, or deleted) is now REJECTED at sign-in with the vendor's403 BANNED_USER(before: sessions revoked, sign-in ACCEPTED); an IdPactive: truere-ACCEPTS a principal whose ban carries the SCIM reason (an administrator's ban stays a rejection). Deactivating the last administrator over SCIM is REJECTED with a 403 SCIM error (before: accepted, disabling nothing).git diff -U0 00ff228fe...HEAD | grep -E '^[+-].*\bexport\b'): fiveexportlines, ALL in the new package-internal modulepackages/plugins/plugin-auth/src/user-ban-write.ts(UserBanWriter,UserBanFields,SCIM_DEACTIVATION_BAN_REASON,applyUserBan,applyUserUnban). NO new export is reachable throughsrc/index.ts: the barrel is untouched (84 lines before and after, zero references touser-ban-write), and the same grep overadmin-ban-endpoints.ts,auth-manager.tsandindex.tsprints nothing. No removals, no signature changes to existing exports. Thescim()options gain the vendor'sidentitymember (not an ObjectStack-authored config key).patch— a bug fix restoring the declared behaviour with no new public symbol. Clause-② staysyes: sign-in accept/reject changes for a class of principals; theneeds:contract-reviewcarriers stay on the card and the PR.Out of scope, filed
scimRequestScopestamped inverifyBearerTokenis not observed at write time (0engine.transactioncalls across POST + PATCH /Users) #14522 — SCIM provisioning writes run outside any engine transaction on 1.7.2 (the SCIM: 停在 @better-auth/scim rc.1,等正式版再整体迁移 —— rc.2 换掉了整套模型 #3653scimRequestScopestamped inverifyBearerTokenis not observed at write time). Unassigned; the residual it leaves on a refused deactivation is pinned in face (c) of the new suite (line 487), and the post-refusal behaviour above is posted on that card.Patch round 2 (head
21c7dbe76) — the in-seat contract review's round-1 verdict, item by itemreconcileScimUserLifecyclereadsbanExpirestoo; in the!state.activebranch a banned row with a non-null expiry gets{ banExpires: null, updatedAt }and returns, otherwise returns —bannedand the administrator'sbanReasonuntouched. Docblock first bullet and the changeset sentence say a deactivation also makes an existing expiring ban permanent. New face under (d): administrator ban withban_expires1.5s ahead,PATCH active: false→ 200, expiry cleared and reason kept, then after the expiry has passed sign-in still answers 403BANNED_USER(status AND code).user-ban-write.ts;admin-ban-endpoints.ts,auth-manager.tsand the suite import it. Barrel export surface unchanged; changesetpatch; the "New exports" paragraph removed.Deactivated via SCIMproduces a ban the IdP can lift); the string is NOT reserved on the admin mount. (4) post-refusal behaviour stated above and on SCIM provisioning writes run outside any engine transaction on @better-auth/scim 1.7.2 — the #3653scimRequestScopestamped inverifyBearerTokenis not observed at write time (0engine.transactioncalls across POST + PATCH /Users) #14522. (6) new face (g):POST /Userswithactive: falseprovisions the account disabled — banned with the SCIM reason, sign-in 403BANNED_USER. (7) SCIM provisioning writes run outside any engine transaction on @better-auth/scim 1.7.2 — the #3653scimRequestScopestamped inverifyBearerTokenis not observed at write time (0engine.transactioncalls across POST + PATCH /Users) #14522 carries the line-487 pin citation.21c7dbe76, verify-lock sessions each under the cap, exits before any pipe). New suite + five ban-adjacent siblings:Test Files 6 passed (6) · Tests 88 passed (88),VERDICT command-exit 0. Six SCIM-adjacent siblings:Test Files 6 passed (6) · Tests 469 passed (469),VERDICT command-exit 0.tsc --noEmitexit 0 (0 errors);check:test-typecheckOK — the test layer compiles under tsconfig.test.json. Gates re-derived on21c7dbe76(identical 36) and all run there: 33 green, the same three prerequisite-not-met (check-test-completeness,check:dual-build-cjs-loads,check:type-check-debt).git merge-tree --write-tree --name-only origin/main HEADaftergit fetch origin main(origin/main=fed4fa409): clean, no conflicted paths. GitHubmergeable_stateread after the push:clean.Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
Generated by Claude Code
Generated by Claude Code