Uh oh!
There was an error while loading. Please reload this page.
fix(plugin-auth): open the SCIM request scope at handleRequest so SCIM provisioning runs inside one engine transaction (#14522) - #14624
Conversation
…M provisioning runs inside one engine transaction
The adapter's `transaction` config opens a real `engine.transaction()` only
while `inScimRequestScope()` reads true. The scope was stamped with
`AsyncLocalStorage.enterWith` inside the `verifyBearerToken` callback handed
to `@better-auth/scim`, and on 1.7.2 the store never reached the writes: an
`enterWith` marks only the async resource it runs in and its descendants,
and the vendor resumes the endpoint handler from a continuation captured
before the verifier ran. Measured: zero `engine.transaction` and zero
`driver.beginTransaction` calls across POST /Users + PATCH /Users/{id},
`inScimRequestScope()` false inside every sys_user / sys_scim_user write.
`AuthManager.handleRequest` now opens the scope with `run(...)` around every
request whose better-auth endpoint path is under `/scim/v2` — the same seam
the actor-attribution scope and the subject-erasure transaction use, and
exactly as narrow as before: non-SCIM flows keep their sequential posture.
A refused last-administrator deactivation now rolls the vendor's own
`scimUser.active = false` write back; the #14360 suite's face (c) pin on that
residual is flipped from `false` to `true` deliberately. A new runtime pin
(`scim-transaction-scope.test.ts`) observes each SCIM mutation calling
`engine.transaction`, a failed provisioning leaving no partial identity, and
sign-up/sign-in opening zero transactions.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8…im-transaction-scope
📓 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 3700abebe9135748c5eabe9d57041eefdf9c72a3 && git checkout 3700abebe9135748c5eabe9d57041eefdf9c72a3
# afterwards, rebuild it from the two parents, which stay fetchable
git fetch origin 9d7f7259fa96bd79138d3e55c2801886f56221bb ec72782592f9b2af872b8aa0c9600ef3bb6ab0ce && git checkout -B drift-repro 9d7f7259fa96bd79138d3e55c2801886f56221bb && git merge --no-ff ec72782592f9b2af872b8aa0c9600ef3bb6ab0ce
node scripts/docs-audit/affected-docs.mjs --json 9d7f7259fa96bd79138d3e55c2801886f56221bb
|
os-sales
commented
Sep 2, 2026
Landing provenance — ready + auto-merge at head |
Fixes#14522
What changed
@better-auth/scimwraps every User/Group mutation inrunIdentityMutationTransaction→@better-auth/core'srunWithTransaction(adapter, fn)→adapter.transaction(...). On this adapter,transactionopens a realengine.transaction()only whileinScimRequestScope()reads true. That scope was stamped withAsyncLocalStorage.enterWithinside theverifyBearerTokencallback handed to the SCIM plugin — and at write time the store was gone, so the vendor's transaction callback ran with no engine transaction at all (the#3653scoping note inobjectql-adapter.tsdeclared the opposite).The fix opens the scope where it is observed:
AuthManager.handleRequestnow wraps the whole request inscimRequestScope.run({ scim: true }, ...)when the better-auth endpoint path is under/scim/v2— the same door the actor-attribution scope (runWithAuthActorScope) and the subject-erasure transaction (runSubjectErasureAtomically) already use, keyed on the samebetterAuthEndpointPath(request)reading.runhas a callback boundary; everyals.runthe vendor performs underneath nests inside it. The verifier no longer stamps anything.scimRequestScope/inScimRequestScopestay in use, unchanged in shape.Files:
auth-manager.ts(the door scope +SCIM_PROTOCOL_PATH_PREFIX, the verifier's stamp removed, thereconcileScimUserLifecycledoc corrected),objectql-adapter.ts(the#3653note rewritten — where the scope is stamped now and why the verifier stamp never propagated),scim-connection-service.ts(the ALS doc rewritten),user-ban-write.ts(one stale "once that transaction is real" clause corrected — prose only, the write is untouched),scim-deactivation-reconcile-user.test.ts(face (c) line 487 flippedfalse→truedeliberately, comment rewritten; line 501 untouched), newscim-transaction-scope.test.ts(the runtime pin), onepatchchangeset for@objectstack/plugin-auth.Triage scope guards (14522#issuecomment-5507946166, quoted verbatim)
How each is met: (1) the door scope is keyed on the endpoint path prefix
/scim/v2— the predicate the vendor's own after-hook matcher uses — and nothing else changed in the adapter's conditional; pin (c) below measures sign-up + sign-in at ZEROengine.transactioncalls, and the FULL plugin-auth suite is green. (2)scim-transaction-scope.test.ts(a) observesPOST /UsersandPATCH /Users/{id}each callingengine.transaction≥ 1 anddriver.beginTransaction≥ 1, with every identity write sampled INSIDE the scope. (3) line 487 flippedfalse→truewith a rewritten comment; the changeset names the flip. (4) both items recorded as NOT MEASURED below.Step 0 — base
git merge-base --is-ancestor 21c7dbe76b7d44df32fbd9f6497db6372b9f2d0f origin/mainexits 1, and that is the squash, not a missing landing: PR #14540 landed as7303cbf4d("… (#14360) (#14540)") onorigin/main, and the file to flip is onorigin/mainwith a blob byte-identical to the PR head's (git rev-parseofscim-deactivation-reconcile-user.test.tsat21c7dbe76and atorigin/mainboth8a4d445f4ed811c9d257e211910dffe9f96ebae0;git diff --statbetween them exits 0 printing nothing). Worktree cut fromorigin/mainf60ab90ae(BASE), not from the PR branch;origin/main(2a2653619) merged in afterwards asec7278259(clean, no regen-pending, plugin-auth untouched onmainin that range).Premise checks (on
origin/mainbefore any edit)objectql-adapter.ts:829if (!inScimRequestScope()) return cb(wrappedAdapter as never);(:8imports it);auth-manager.ts:3279scimRequestScope.enterWith({ scim: true });inside theverifyBearerTokencallback (:3269destructures it from the dynamic import);scim-connection-service.ts:63the ALS,:66-67the reader. The adapter still reads the ALS; the stamp still lives in the verifier.scim-transaction-scope.test.tson the unfixed tree (f60ab90ae+ the test file only):engine.transaction0 calls onPOST /scim/v2/Users, writes sampledinsert:sys_scim_connection_binding:scim insert:sys_user:NO-SCOPE insert:sys_scim_subject:NO-SCOPE insert:sys_scim_user:NO-SCOPE update:sys_scim_subject:NO-SCOPE update:sys_scim_connection_binding:NO-SCOPE; 0 calls onPATCH /scim/v2/Users/{id}active:false, writesupdate:sys_scim_subject:NO-SCOPE update:sys_user:NO-SCOPE update:sys_scim_user:NO-SCOPE update:sys_user:NO-SCOPE update:sys_scim_connection_binding:NO-SCOPE; the (b) atomicity case:sys_user survived the failed provisioning. Both requests answered 201 / 200. Note the onescim-scoped write: the vendor's connection-binding insert, made by the auth middleware right afterawait verifyBearerToken()— a DESCENDANT of the stamp — is the only write that saw it, which is exactly theenterWithshape.expect(await scimActive(h, owner.scimId)).toBe(false);under the comment block naming this card; line 501 is the positive control'sfalse.@better-auth/scim1.7.2 /better-auth1.7.2 /@better-auth/core1.7.2 (pnpm storenode_modules/.pnpm/@better-auth+scim@1.7.2_…).@better-auth/scim/dist/index.mjs:4002runIdentityMutationTransaction→:4011runWithTransaction(adapter, async () => callback(await getCurrentAdapter(adapter)))(imported from@better-auth/core/context,:5);@better-auth/core/dist/context/transaction.mjsrunWithTransaction→adapter.transaction(async (trx) => als.run({ adapter: trx, pendingHooks, isTransactionActive: true }, fn)). The three SCIM mutation sites::6527(POST /Users),:6749,:6839(PATCH / PUT).assertNativeSCIMTransactionsat:5499asks onlytypeof adapter.options?.adapterConfig.transaction === "function".packages/spec/**andcontent/docs/releases/**untouched (diff file list above).Hypotheses (by measurement)
betterAuth({...})with a globalhooks.beforedoingalsA.enterWith(...), a plugin endpoint whoseusemiddleware doesalsB.enterWith(...)(the verifier's shape), the request issued insidealsC.run(...), and a fourth ALS opened at the door and MUTATED fromhooks.before; the handler read all four through a nestedals.run(the shaperunWithTransactionuses). Reading:beforeHookEnterWith: null,useMiddlewareEnterWith: null,doorRun: { via: "door run" },beforeHookMutatedDoorStore: { flag: true },endpointContextPath: "/probe/scope". So the boundary is theawaitof the middleware chain in the endpoint frame — better-call's endpoint runner awaits theusemiddlewares (the verifier's caller) anddispatchAuthEndpointawaitsrunBeforeHooks(...)(better-auth/dist/api/dispatch.mjs:205-228, which then re-enters the handler under a secondrunWithEndpointContext) — from continuations captured BEFORE the stamp. AnenterWithmarks the async resource it runs in and that resource's descendants only.enterWithinhooks.before) is REFUTED by the probe (null). Candidate (b) — reading the vendor's endpoint context at write time — is VIABLE (tryGetCurrentAuthEndpointContext()?.pathreads/probe/scopethrough the nestedals.run;@better-auth/core/contextexports it, andsession-tombstone.ts:231already imports from that module), but it retiresscimRequestScope/inScimRequestScope, which the dispatch declares an export removal (Clause-②yes+ stop), and couples the adapter to the endpoint-context API. Chosen: therun-scoped stamp at the door (doorRunvisible;beforeHookMutatedDoorStoreshows the samerun-at-the-door mechanism the actor-attribution scope relies on). It is the house pattern for both a request-scoped ALS and endpoint-path-keyed atomicity, keeps both symbols in use, and is exactly as narrow: SCIM protocol requests only.scim-transaction-scope.test.ts5/5 andscim-deactivation-reconcile-user.test.ts11/11 (Test Files 2 passed (2) · Tests 16 passed (16)), including the flipped face (c) — the refused last-administrator deactivation now leaves the SCIM resource readingactive: truewhile the positive control'sfalseon line 501 still passes.handleRequestwritesys_user/sys_account/sys_sessionwithengine.transaction0 anddriver.beginTransaction0, no write in the SCIM scope. FULL@objectstack/plugin-authsuite:pnpm --filter @objectstack/plugin-auth test(the package'svitest run,VITEST_MAX_WORKERSfromscripts/vitest-worker-cap.mjs) onec7278259→Test Files 91 passed (91) · Tests 1835 passed (1835),VERDICT command-exit 0(held the lock 155 s). The six SCIM-adjacent siblings the dispatch names (credential-at-rest-posture,scim-case-insensitive-identifier,better-auth-schema-parity,last-admin-guard,managed-extension-fields,auth-manager) are inside that run. The two measured breakages the#3653note records (sign-up 500s on the memory engine; the 180 s hook-timeout deadlock on single-connection sqlite) did not return.git diff -U0 origin/main...HEAD | grep -E '^[+-].*\bexport\b'→ nothing (grep exit 1). Clause-② no. Changesetpatchfor@objectstack/plugin-auth.Tests (head
ec7278259)pnpm --filter @objectstack/plugin-auth exec vitest run --maxWorkers=2 src/scim-transaction-scope.test.ts …→Tests 3 failed | 3 passed (6)— (a) POST, (a) PATCH, (b) red with the readings quoted under P2; (c), (d) green (they measure the unchanged non-SCIM posture);VERDICT command-exit 1.… src/scim-transaction-scope.test.ts src/scim-deactivation-reconcile-user.test.ts→Test Files 2 passed (2) · Tests 16 passed (16),VERDICT command-exit 0.pnpm --filter @objectstack/plugin-auth typecheck(src +tsconfig.examples.json+check:test-typecheck) →check:test-typecheck: OK — … 10 file(s) / 94 error(s) / 23 pinned signature(s) held in test-typecheck-debt.json(ledger unchanged),VERDICT command-exit 0. (A first run failed only onexamples/basic-usage.tsresolving the package's own unbuiltdist/— PREREQUISITE NOT MET, cleared bypnpm --filter @objectstack/plugin-auth build.)pnpm --filter @objectstack/plugin-auth test(the package'svitest run,VITEST_MAX_WORKERSfromscripts/vitest-worker-cap.mjs) onec7278259→Test Files 91 passed (91) · Tests 1835 passed (1835),VERDICT command-exit 0(held the lock 155 s). The six SCIM-adjacent siblings the dispatch names (credential-at-rest-posture,scim-case-insensitive-identifier,better-auth-schema-parity,last-admin-guard,managed-extension-fields,auth-manager) are inside that run. The two measured breakages the#3653note records (sign-up 500s on the memory engine; the 180 s hook-timeout deadlock on single-connection sqlite) did not return.pnpm check:system-context-census→OK — 109 elevation read sites in 20 packages across 45 files, all anchored; no re-anchor ofcontent/docs/permissions/system-context.mdxneeded (working tree clean after the gate).pnpm check:nul-bytes→OK (scanned 7980 text file(s) … no raw ASCII control bytes);grep -naPcontrol-byte self-scan over the seven touched files: 0 hits.Ablation (committed tree)
Mutation = the OLD mechanism restored on the committed tree
ec7278259, inauth-manager.tsonly: the door scope disabled (const runRequest = (false as boolean) && isScimProtocolPath(endpointPath)), andscimRequestScope.enterWith({ scim: true })put back as the first line of theverifyBearerTokencallback (with its destructure). Applied byperl -0pi, proven on disk by anchored counts, never by the editor's exit code:ABLATION-14522-door=1 (expect 1) · ABLATION-14522-import=1 (expect 1) · ABLATION-14522-enterWith=1 (expect 1) · original-door-line=0 (expect 0) · enterWith-total=2 (expect 1)— the2is the marker line plus the comment line "⛔ NoscimRequestScope.enterWith(...)here", i.e. one real call; pre-mutation blob4e1bede81930d4e97cf9aca641a4036d79bd63a3= HEAD blob, post-mutation blob7165d4eefaac562a1633f185d640374047d689f1. No build leg: the subject (AuthManager) is imported by both test files relatively fromsrc(./auth-manager.js), not through a packageexportsmap, so nodist/is on the resolution path andablation-dist-preflightdoes not apply.Reading (predicted direction: pins (a)/(b) and #14360 face (c) red, (c)/(d) green):
Test Files 2 failed (2) · Tests 4 failed | 12 passed (16)— red:(a) POST /Users …,(a) PATCH /Users/{id} active:false …,(b) a failure on the sys_scim_user write rolls … back, and(c) the IdP gets a 403 SCIM error naming the invariant, and the account stays active(the flipped line 487 readsfalseagain under the old mechanism); green: (c) sign-up/sign-in zero transactions, (d) SCIM read, and the other ten #14360 cases.ABLATION vitest exit: 1;VERDICT command-exit 0(the wrapper's own exit is the script's, which restores and exits 0).Restore =
git checkout HEAD -- ABS_PATHintrap … EXIT INT TERM, proven:RESTORE: hash-object=4e1bede81930d4e97cf9aca641a4036d79bd63a3 HEAD-blob=4e1bede81930d4e97cf9aca641a4036d79bd63a3 MATCH · git diff HEAD --stat: [] · porcelain: [].Gates (derived on the final head with
node scripts/pm/dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, no paths passed)Derived at
ec7278259(the derivation's own stderr line: "gate list derived from the tree of 'objectstack-ai/objectstack' at commit ec72782"; it notes the tree is 10 commits behindorigin/main2514d49f3— post-merge landings, none under plugin-auth, and the merge-tree against thatorigin/mainis clean). The list is byte-identical to the pre-merge derivation ona88c952fd(36 commands). Run unlocked in the foreground in batches under the ~10-minute cap — the lock script's own header classescheck:*gates as unlocked sibling work, and the lock was saturated by a sibling's gate batches (two 540 s queue timeouts on the full-suite call before it acquired) — with each command's exit captured to a results file BEFORE any pipe. Batch 2 was cap-killed (exit 143) afterscripts/pm/check-half-states.mjsalone took 517 s; the two commands it left unrun (check:engine-double-contract,check:logger-receiver-detach) were re-run in a fourth call.OK — 109 elevation read sites in 20 packages across 45 files, all anchored), check-tenant-audit-census, check-undeclared-dep-imports, docs-audit/check-affected-docs, docs-audit/check-drift-comment, pm/check-half-states (report-only patrol; its155 half-state(s) foundis a board census, not a verdict on this PR), pm/release-rehearsal-clone --self-test, check:changeset-gate-self-tests, check:dispatcher-error-vocabulary, check:doc-authoring, check:engine-double-contract, check:logger-receiver-detach, check:objectql-double-limit, check:objectui-changeset, check:page-declaration-shape, check:pm-half-states, check:published-files, check:query-options-erasure, check:slot-lookup, check:test-source-alias, check:type-check-coverage (OK — 69/79 workspace packages type-checked (plus the root), 10 in the DEBT ledger), check:type-source-resolution, check:where-matcher.check-test-completeness.mjs("PREREQUISITE NOT MET — this gate grades a savedturbo run testlog, and no log was named … record this gate as NOT MEASURED");check:dual-build-cjs-loads("PREREQUISITE NOT MET — this gate reads built output, and some package has no dist/ … Runpnpm buildfirst. ⛔ This is NOT a pass: nothing was measured");check:type-check-debt("--re-measurecannot run: 29 workspace dependenc(ies) of the ledgered packages have no built type entry point on disk … Build the closure first, exactly as lint.yml does"). A whole-workspace build is a repo-level run this container cannot fit under the cap; CI owns all three.pnpm lint(eslint . --no-inline-config, repo-wide) is CI's run; the local reading is a PROVEN narrowing, three pieces: ① the population is read fromeslint.config.mjsitself — its own comment at line 328 states "(noparserOptions.project, no typed@typescript-eslintrules)", and everyparserOptionsblock in the file carries onlyecmaVersion/sourceType, so linting is not type-aware; ②pnpm exec eslint --no-inline-config --format jsonover the six touched.tsfiles →files linted: 6 | errors: 0 | warnings: 0, exit 0; ③ invariance: with no type-aware rules a file's verdict depends only on its own bytes, so this diff cannot move the verdict of any untouched file — the narrowing excludes nothing.NOT MEASURED (scope guard 4)
@better-auth/scim1.7.0-rc.1 — not re-measured. The#3653note may well have been true when written (a different dispatch shape); the rewritten prose says the stamp "never reached this seam" on 1.7.2 and does not claim it never did.:memory:only. The mechanism is adapter-side (where the ALS store is read), not driver-side, so the reading should carry;driver.beginTransaction≥ 1 is pinned on sqlite only.Log levels
No new log site at any level. The vendor's
[Better Auth]: back-channel logout planning failed … no such table: sys_oauth_access_tokenERROR seen in the suite output is pre-existing harness noise (the SCIM harnesses register no OAuth objects), filed separately.Out of scope
Filed as #14615 (
finding, unassigned, after a targeted MCPsearch_issuesdedup — four non-matching closed hits — with a positive control that returned #14522): the plugin-auth SCIM harnesses (the #14360 suite and, by copying its shape, the new pin) register no OAuth objects, so every driven sign-in prints a Better Auth ERRORback-channel logout planning failed … no such table: sys_oauth_access_token. Green-run noise, not a failure; not touched here.Session: https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
🤖 Generated with Claude Code
https://claude.ai/code/session_01AUF1NoViznQK32gqpK8wS8
Generated by Claude Code