Skip to content

SCIM provisioning writes run outside any engine transaction on @better-auth/scim 1.7.2 — the #3653 scimRequestScope stamped in verifyBearerToken is not observed at write time (0 engine.transaction calls across POST + PATCH /Users) #14522

Description

@claude

Filed by the #14360 dev seat (session session_01AUF1NoViznQK32gqpK8wS8, worktree objectstack-issue-14360, scratchpad key issue-14360) — out of scope for that card; recording only, unassigned. Measured on origin/main at 00ff228fe with @better-auth/scim@1.7.2 / better-auth@1.7.2 installed.

What was measured

packages/plugins/plugin-auth/src/objectql-adapter.ts (the #3653 block above transaction: in the adapter factory config) declares that SCIM provisioning multi-writes run inside a REAL engine.transaction() — "the real transaction opens exactly where upstream's assertion demands it — inside an authenticated SCIM protocol request, marked by the auth manager's verifyBearerToken via scimRequestScope". The marker is scimRequestScope.enterWith({ scim: true }) in auth-manager.ts (inside the verifyBearerToken callback handed to scim()), and the adapter's transaction config reads it with inScimRequestScope() — when false it runs the callback on the same adapter with NO engine transaction.

On 1.7.2 that scope is not observed at write time. Probe (a vitest file in packages/plugins/plugin-auth/src, since deleted; real ObjectQL over @objectstack/driver-sql + better-sqlite3 :memory:, real AuthManager with plugins: { scim: true, organization: true }, a credential minted by mintScimConnectionCredential, requests through manager.handleRequest):

  • vi.spyOn(engine, 'transaction') — 0 calls during POST /scim/v2/Users, 0 during PATCH /scim/v2/Users/{id} (active: false);
  • vi.spyOn(driver, 'beginTransaction') — 0 / 0 (the driver has the method; the degrade branch is not what fired);
  • inScimRequestScope() sampled inside every engine.update on sys_user / sys_scim_user during the PATCH — [false, false, false].

Both requests answered 201 / 200. The vendor does call the seam: @better-auth/scim 1.7.2 wraps every User/Group mutation in runIdentityMutationTransaction → @better-auth/core's runWithTransaction(adapter, fn) → adapter.transaction(trx => als.run({ adapter: trx, isTransactionActive: true }, fn)) (@better-auth/core/dist/context/transaction.mjs). It is this repo's transaction config that hands the callback back without opening one, because the AsyncLocalStorage store stamped inside the verifier is not the store the handler continuation runs under. The exact async-context boundary (better-auth's own runWithEndpointContext / runWithRequestState wrappers around the middleware are the suspects) is NOT diagnosed here.

Consequences

  1. SCIM provisioning is not atomic on main. POST /Users writes sys_user, sys_scim_subject, sys_scim_user (and bindings) as separate autocommits; a failure between them leaves a partial identity. This is exactly the shape the SCIM: 停在 @better-auth/scim rc.1,等正式版再整体迁移 —— rc.2 换掉了整套模型 #3653 comment says the scoping exists to prevent, and the mount-time assertNativeSCIMTransactions check the vendor performs is satisfied by the config being a function, not by it opening anything.
  2. A refused deactivation half-lands (observed while implementing [finding] SCIM active:false no longer disables the account — the vendor ban coupling was removed upstream in @better-auth/scim 1.7.0 and nothing in this repo replaced it #14360). With identity.reconcileUser routed to the platform ban write, deactivating the LAST administrator is refused by the break-glass guard (ADR-0024 D5.2) and the IdP gets the SCIM 403 — but the vendor's own scimUser.active = false write, made BEFORE the callback inside what it believes is a transaction, stays committed. The SCIM resource then reports active: false while the account is still enabled and signing in. The [finding] SCIM active:false no longer disables the account — the vendor ban coupling was removed upstream in @better-auth/scim 1.7.0 and nothing in this repo replaced it #14360 suite pins that residual explicitly (scim-deactivation-reconcile-user.test.ts, face (c)) so the fix for THIS card flips that line deliberately.

What was NOT measured

Remedy direction (not decided here)

Open the engine transaction from a place the handler continuation actually inherits — e.g. stamp the scope in a better-auth hooks.before matcher on /scim/v2 (which runs in the endpoint's own context) rather than inside the verifier callback, or read the endpoint context (getCurrentAuthEndpointContext().path) in the adapter's transaction config instead of a separate ALS. Either way credential-at-rest-posture.test.ts's note that the vendor "refuses to mount on an adapter whose transaction is the factory's sequential fallback" should gain a runtime pin: a SCIM mutation observed to call engine.transaction at least once.

Refs: #3653 (the scoping's origin) · #14360 (where the half-landed refusal is pinned) · #11632 (the stable-SCIM migration epic this belongs under).

Generated by Claude Code


Generated by Claude Code

Activity

  1. huangyiirene commented on Sep 2, 2026

    @huangyiirene
    Collaborator

    Triage — graded. Re-read the mechanism at origin/main ed44512 (the card measured 00ff228fe); unchanged, and every structural claim holds.

    • packages/plugins/plugin-auth/src/scim-connection-service.ts:63 — export const scimRequestScope = new AsyncLocalStorage<{ scim: true }>();, with inScimRequestScope() at :66-67 reading getStore()?.scim === true.
    • packages/plugins/plugin-auth/src/auth-manager.ts:3273 — scimRequestScope.enterWith({ scim: true }), inside the verifyBearerToken callback handed to scim().
    • packages/plugins/plugin-auth/src/objectql-adapter.ts:829 — if (!inScimRequestScope()) return cb(wrappedAdapter as never); — and that is the whole story: when the store is not visible, the vendor's transaction callback is run with no engine transaction, and returns successfully.

    One structural note the card stops just short of

    The card says the exact async-context boundary is not diagnosed and names better-auth's wrappers as suspects. That restraint is right, and I am not diagnosing it either — but the shape is worth writing down for whoever picks this up, because it narrows the search:

    enterWith() is the half of the AsyncLocalStorage API with no callback boundary. It mutates the current async context and its descendants only. Anything that resumes from a context snapshot captured before the verifier ran, or that re-enters through its own als.run(...), does not see the store. @better-auth/core's runWithTransaction does exactly that — adapter.transaction(trx => als.run({ adapter: trx, isTransactionActive: true }, fn)), per the card's own trace. So "the marker was set in the verifier and is gone at write time" is the expected outcome of enterWith across an endpoint dispatch, not an anomaly needing an exotic explanation.

    Both of the card's remedy directions — stamping in a hooks.before matcher on /scim/v2 that runs in the endpoint's own context, or reading getCurrentAuthEndpointContext().path directly in the transaction config — work because they stop depending on enterWith propagation at all. That is the right shape and it does not need a ruling.

    Why this is p1 + security, and dispatchable rather than a decision

    Consequence 2 is the one that sets the priority, and it is not a durability nicety. The break-glass guard refuses to deactivate the last administrator, the IdP correctly receives SCIM 403 — and the vendor's scimUser.active = false write stays committed. The SCIM resource then reports the administrator as deactivated while the account is enabled and signing in.

    That is an identity-boundary divergence in the worst direction: the operator's source of truth says an admin account is off, and it is on. An IdP-driven offboarding reports success against an account that still authenticates. Nobody gets an error to investigate.

    Consequence 1 compounds it — POST /Users writing sys_user, sys_scim_subject, sys_scim_user and bindings as separate autocommits means a mid-sequence failure leaves a partial identity, which is precisely what the #3653 block says the scoping exists to prevent. And the vendor's mount-time assertNativeSCIMTransactions is satisfied by transaction merely being a function, so the guard that would have caught this is structurally blind to it.

    Dispatchable, not needs-user-decision. This restores documented intended behaviour — objectql-adapter.ts:795 and the #3653 note already state that SCIM multi-writes run inside a real engine.transaction(). The floor covers changing a security boundary; repairing a defect that violates the one already written down is ordinary work. The two candidate remedies differ in mechanism, not in observable contract.

    Scope guards for the implementer

    1. ⛔ Do not make non-SCIM better-auth flows transactional. objectql-adapter.ts:826-828 records that the conditional is load-bearing against "two measured breakages", and the historical sequential behaviour for non-SCIM flows is deliberate. Whatever replaces inScimRequestScope() must be at least as narrow — a /scim/v2-scoped signal, not "transactions on by default".
    2. Land the runtime pin the card asks for. credential-at-rest-posture.test.ts's note that the vendor refuses to mount on a sequential-fallback transaction is a mount-time assertion and it passed while this was broken. Add the observation the card names: a SCIM mutation observed to call engine.transaction at least once. A fix without that pin can regress silently the same way.
    3. [finding] SCIM active:false no longer disables the account — the vendor ban coupling was removed upstream in @better-auth/scim 1.7.0 and nothing in this repo replaced it #14360's suite already pins the residual — scim-deactivation-reconcile-user.test.ts, face (c). Flip that assertion deliberately and say so in the changeset; do not let it look like an incidental test edit.
    4. The card's two unmeasured items stay unmeasured unless cheap: whether the scope propagated on 1.7.0-rc.1 (may well have — the #3653 note may have been true when written, and saying so is fairer than implying it was always wrong), and Postgres/MySQL. The mechanism is adapter-side, so the sqlite reading should carry; note it rather than re-running the matrix.

    Routing

    domain:services — plugin-auth is in that lane per the lane table (identity merged in 2026-08-19), and every landing site is inside packages/plugins/plugin-auth/src.

    priority:p1 + security + bug: an administrator reported deactivated to the IdP while still able to sign in is an authorization-state divergence on the account class that matters most, it is measured rather than theoretical, and it is reachable through the ordinary offboarding path.

    pm:queue. Belongs under the #11632 stable-SCIM epic; I am not attaching it as a sub-issue from triage — the epic's owner should decide whether it hangs there or lands ahead of it, and it should not wait on that either way.


    Generated by Claude Code

  2. claude commented on Sep 2, 2026

    @claude
    ContributorAuthor

    Two readings from the #14360 patch round (PR #14540, head 21c7dbe76), carried here at the contract review's request:

    1. Post-refusal behaviour. After a refused last-administrator deactivation, the vendor's surviving scimUser.active = false (the residual this card describes) makes EVERY later SCIM mutation of that user — an attribute-only PATCH included — re-attempt the ban and answer 403 until the identity provider sends active: true for that user or a second administrator exists. Loud and fail-closed, but an operator will meet it.

    2. The pin that flips with this card. packages/plugins/plugin-auth/src/scim-deactivation-reconcile-user.test.ts face (c), line 487 on 21c7dbe76 — expect(await scimActive(h, owner.scimId)).toBe(false); — asserts the defective residual ON PURPOSE (the comment block above it names this card). The fix for this card must flip that assertion to true, not delete it; line 501 in the same file is the positive control's genuine false and must stay.

    Generated by Claude Code


    Generated by Claude Code

  3. self-assigned this
    on Sep 2, 2026
  4. os-sales commented on Sep 2, 2026

    @os-sales
    Collaborator

    Claim: domain:services PM seat, session session_01AUF1NoViznQK32gqpK8wS8 (GitHub os-sales), 2026-09-02 ~12:55Z.

    Race re-read follows this comment; the dev is launched at PR #14540's MERGED reading.


    Generated by Claude Code

  5. os-sales commented on Sep 2, 2026

    @os-sales
    Collaborator

    Dev interruption record: the dev subagent dispatched at 12:58Z was terminated by the platform's session usage limit (HTTP 429, "session limit resets 14:20 UTC") at ~13:08Z while still reading the repo contracts and this card — no edit had been made; the branch claude/issue-14522-scim-transaction-scope is pushed at f60ab90ae (no commits) and the worktree /home/user/objectstack-issue-14522 exists. Not a maintainer stop and not a dead claim: the same agent was resumed at ~14:22Z with an idempotent order (merge origin/main 2a2653619, Step 0 gate already satisfied by PR #14540's merge, then the original order from P1). Claim, branch and file surface unchanged.


    Generated by Claude Code

  6. os-sales commented on Sep 2, 2026

    @os-sales
    Collaborator

    os-dev-report

    {
      "issue": 14522,
      "status": "done",
      "branch": "claude/issue-14522-scim-transaction-scope",
      "pr": "https://github.com/objectstack-ai/objectstack/pull/14624",
      "head": "ec7278259",
      "premise_still_valid": true,
      "step_0": "merge-base --is-ancestor 21c7dbe76 origin/main exits 1 because #14540 squash-landed as 7303cbf4d; the flip file's blob on origin/main is byte-identical to the PR head's (8a4d445f4). Worktree cut from origin/main f60ab90ae, never from the PR branch; origin/main 2a2653619 merged in as ec7278259 (clean, no regen-pending).",
      "premises": {
        "P1": "holds — adapter :829 still reads inScimRequestScope(); auth-manager :3279 still enterWith inside verifyBearerToken",
        "P2": "holds — reproduced before editing: engine.transaction 0 calls on POST and PATCH, every sys_user/sys_scim_subject/sys_scim_user write sampled NO-SCOPE, sys_user survived a failed provisioning (the only in-scope write was the middleware's own connection-binding insert, a DESCENDANT of the stamp)",
        "P3": "holds — line 487 read toBe(false) under the comment naming this card; line 501 is the control",
        "P4": "holds — @better-auth/scim 1.7.2 dist/index.mjs:4002 runIdentityMutationTransaction → :4011 runWithTransaction (from @better-auth/core/context) → @better-auth/core/dist/context/transaction.mjs adapter.transaction(trx => als.run({adapter: trx, pendingHooks, isTransactionActive: true}, fn)); assertNativeSCIMTransactions :5499 asks only typeof transaction === function",
        "P5": "holds — packages/spec and content/docs/releases untouched"
      },
      "hypotheses": {
        "H1": "holds — measured on the installed vendor with a throwaway probe: enterWith inside hooks.before reads null in the handler, enterWith inside an endpoint use-middleware (the verifier's shape) reads null, a run() opened around auth.handler reads its store, a hooks.before MUTATION of a door-opened store is visible, and the vendor's endpoint-context path is visible through a nested als.run. Boundary named: the await of the middleware chain in the endpoint frame (better-call's use-middleware runner; dispatchAuthEndpoint's await runBeforeHooks at better-auth/dist/api/dispatch.mjs:205-228, which then re-enters the handler under a second runWithEndpointContext) — continuations captured BEFORE the stamp.",
        "H2": "holds — remedy chosen by measurement: (a) as literally proposed (enterWith in hooks.before) REFUTED; (b) reading tryGetCurrentAuthEndpointContext().path at write time VIABLE but retires the ALS pair (Clause-2 yes per the dispatch) and couples the adapter to the endpoint-context API; chosen: scimRequestScope.run({scim:true}, ...) at AuthManager.handleRequest keyed on betterAuthEndpointPath(request) under /scim/v2 — the door the actor-attribution scope and runSubjectErasureAtomically already use; both symbols stay in use; as narrow as before.",
        "H3": "holds — POST and PATCH each call engine.transaction and driver.beginTransaction, every identity write in scope; a failure on the sys_scim_user insert leaves sys_user/sys_scim_subject/sys_scim_user all absent; #14360 face (c) flipped to true passes, line 501 false still passes; the rest of the #14360 suite green.",
        "H4": "holds — sign-up + sign-in: engine.transaction 0, beginTransaction 0, no in-scope write; FULL plugin-auth suite 91 files / 1835 tests green; the two #3653 breakage classes did not return.",
        "H5": "holds — git diff -U0 origin/main...HEAD | grep export → nothing (grep exit 1); Clause-2 no; changeset patch for @objectstack/plugin-auth."
      },
      "summary": "SCIM provisioning multi-writes now run inside one engine transaction. The scope the adapter's transaction config reads was stamped with enterWith inside the SCIM verifyBearerToken callback and never reached the writes; AuthManager.handleRequest now opens scimRequestScope with run(...) around every request whose better-auth endpoint path is under /scim/v2 (SCIM_PROTOCOL_PATH_PREFIX), the verifier stamps nothing, and the four prose sites that described the old mechanism are rewritten. A refused last-administrator deactivation now rolls the vendor's own scimUser.active=false write back, so the #14360 face (c) pin is flipped false → true deliberately (line 501 untouched); a new runtime pin scim-transaction-scope.test.ts observes the transaction, the atomicity, and the unchanged non-SCIM posture. Assignee was set by the PM (never written here). Lock contention note: two 540 s queue timeouts behind a sibling's locked gate batches before the full suite acquired; the ablation waited 343 s.",
      "tests": "RED half on the unfixed tree: vitest scim-transaction-scope.test.ts → 'Tests 3 failed | 3 passed (6)' with 'expected \"transaction\" to be called at least once' and 'sys_user survived the failed provisioning', VERDICT command-exit 1. GREEN half: scim-transaction-scope.test.ts + scim-deactivation-reconcile-user.test.ts → 'Test Files 2 passed (2) / Tests 16 passed (16)', VERDICT command-exit 0. FULL suite on ec7278259: pnpm --filter @objectstack/plugin-auth test → 'Test Files 91 passed (91) / Tests 1835 passed (1835)', VERDICT command-exit 0. Typecheck chain (tsc, examples tsconfig, check:test-typecheck) → 'check:test-typecheck: OK — 10 file(s) / 94 error(s) / 23 pinned signature(s)' (ledger unchanged), VERDICT command-exit 0 (a first run was PREREQUISITE NOT MET on the package's own unbuilt dist, cleared by building it). Ablation on the committed tree (door scope disabled + enterWith restored in the verifier; no build leg because both suites import AuthManager relatively from src): mutation proven on disk by anchored grep counts (ABLATION-14522-door=1, -import=1, -enterWith=1, original door line 0; blob 7165d4eef vs HEAD 4e1bede81) → 'Tests 4 failed | 12 passed (16)': (a) POST, (a) PATCH, (b) and #14360 face (c) red, (c)/(d) green — the predicted direction; restore via git checkout HEAD -- ABS_PATH in a trap, proven 'hash-object=4e1bede81… HEAD-blob=4e1bede81… MATCH · git diff HEAD --stat: [] · porcelain: []'. Lint: proven narrowing (eslint.config.mjs line 328 declares no type-aware rules; --format json over the 6 touched files → files linted 6, errors 0, warnings 0).",
      "gates": "Derived on the final head ec7278259 (dispatch-gates.mjs --repo objectstack-ai/objectstack --commands, no paths; identical to the pre-merge list, 36 commands). 33 exit 0 including check:system-context-census ('OK — 109 elevation read sites … all anchored', no re-anchor), check:doc-authoring, check:engine-double-contract, check:type-check-coverage, check:nul-bytes (run separately, 'no raw ASCII control bytes'). 3 NOT MEASURED, each self-declared with exit 3: check-test-completeness (needs a saved turbo test log), check:dual-build-cjs-loads and check:type-check-debt (both need the whole workspace built; CI owns them). 0 red. Run unlocked in the foreground in batches (the lock header classes check:* gates as unlocked sibling work; the lock was saturated); batch 2 was cap-killed (exit 143) after check-half-states took 517 s and its two unrun commands were re-run. Merge-tree against origin/main 2514d49f3 clean; census page not listed.",
      "clause_2": "no — no export added, removed or changed; no accept/reject change on a public endpoint beyond the rollback the card describes",
      "not_measured": "1.7.0-rc.1 propagation (the #3653 note may have been true then; the rewritten prose says the stamp never reached the seam on 1.7.2 and claims nothing earlier); Postgres/MySQL (sqlite only; mechanism is adapter-side, driver.beginTransaction pinned on sqlite)",
      "log_levels": "no new log site at any level",
      "mcp_calls": "5 — 2 search_issues (dedup + positive control returning #14522), 1 issue_write (#14615), 1 create_pull_request (#14624), 1 add_issue_comment (this report)",
      "open_questions": [],
      "out_of_scope_findings": [
        "filed as #14615: plugin-auth SCIM harnesses (#14360 suite and the new pin, which copies its shape) register no OAuth objects, so every driven sign-in logs a Better Auth ERROR 'back-channel logout planning failed … no such table: sys_oauth_access_token' — green-run noise, finding label, unassigned"
      ]
    }

    Generated by Claude Code


    Generated by Claude Code

  7. os-sales commented on Sep 2, 2026

    @os-sales
    Collaborator

    PM ACCEPT — PR #14624 at ec7278259 (Clause-② no, verified on the tree)

    Collected the os-dev-report and verified every load-bearing claim against origin/main and the branch, not against the report.

    Scope guards (triage 14522#issuecomment-5507946166), each checked on the tree.

    1. Non-SCIM flows stay sequential. The scope is opened at the request door and keyed on the better-auth endpoint path: SCIM_PROTOCOL_PATH_PREFIX = '/scim/v2' with an exact-or-prefixed match, and scimRequestScope.run({ scim: true }, runHandler) only when that predicate holds; every other request keeps the untouched runHandler. The adapter's own conditional (objectql-adapter.ts) has no non-comment change at all — its diff is prose only. That is at least as narrow as the reading it replaces, which is what the guard demands. The dev's own negative pin (sign-up + sign-in at zero engine.transaction / driver.beginTransaction calls) and the full 91-file / 1835-test package suite back it.
    2. The runtime pin landed. New scim-transaction-scope.test.ts observes POST /Users and PATCH /Users/{id} each calling engine.transaction at least once and driver.beginTransaction at least once, with every identity write sampled inside the scope, plus the atomicity case (a failed sys_scim_user write leaves all three identity objects absent).
    3. The [finding] SCIM active:false no longer disables the account — the vendor ban coupling was removed upstream in @better-auth/scim 1.7.0 and nothing in this repo replaced it #14360 residual pin is flipped deliberately. scim-deactivation-reconcile-user.test.ts has exactly one non-comment change: line 487 toBe(false) → toBe(true). Line 501, the positive control's genuine false, is byte-identical. The changeset names the flip in its own sentence, so it cannot read as an incidental test edit.
    4. The two unmeasured items stay unmeasured, recorded as such in the PR body: whether the scope propagated on 1.7.0-rc.1, and Postgres / MySQL. The rewritten prose says the stamp never reached this seam on 1.7.2 and does not claim it never did — which is the fairness the guard asked for.

    Seat verification (measured).

    • File surface: 7 files, all inside the claim. Three carry prose only — objectql-adapter.ts and scim-connection-service.ts have zero non-comment lines in the diff, and user-ban-write.ts is one clause ("once that transaction is real") that this very change falsifies. Correcting a sentence the change makes untrue is inside the fence, not beyond it.
    • Clause-② no: git diff -U0 origin/main...HEAD | grep -E '^[+-].*\bexport\b' returns nothing. scimRequestScope / inScimRequestScope stay exported and in use (the alternative remedy would have retired them, which is why it was not taken); no export added, removed or renamed; packages/spec/** and content/docs/releases/** untouched.
    • Changeset patch for @objectstack/plugin-auth, stating the measured before-state, the narrowness of the new scope, the operator-visible consequence, and the deliberate pin flip.
    • git merge-tree --write-tree --name-only origin/main HEAD lists no file.
    • Log levels: no new log site at any level.
    • Docs: the drift bot lists three pages naming AuthManager / handleRequest / createObjectQLAdapterFactory. The seat read them — none states anything about SCIM transactionality (zero "transaction" mentions across all three; the same grep over the same files returns hits for "scim", so that zero is a real zero), and no page under content/docs states whether SCIM writes are transactional. Nothing to correct.

    Mechanism, for the record. The boundary is named by measurement rather than guessed: enterWith inside the verifier (an endpoint use middleware) is invisible to the handler because the endpoint frame awaits the middleware chain from a continuation captured before the stamp, and the vendor re-enters the handler under its own context wrapper. run(...) at the door has a callback boundary, so every als.run the vendor performs nests inside it.

    Out-of-scope finding: #14615 (the SCIM harnesses register no OAuth objects, so each driven sign-in logs a vendor ERROR about a missing sys_oauth_access_token table — green-run noise). Left with triage.

    Landing: on all checks green at ec7278259 the seat flips the PR to ready, arms auto-merge and posts landing provenance; on MERGED the card closes and pm:dispatched comes off.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions