Uh oh!
There was an error while loading. Please reload this page.
fix(identity): make admin remove-user atomic, cascade sys_member, and map DELETE_RESTRICTED to a 409 - #7879
Conversation
…E_RESTRICTED Three compounding problems on the better-auth admin remove-user path (#7724). Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D6Qi9sYxhaRwj7TYiD5MWg
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D6Qi9sYxhaRwj7TYiD5MWg
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D6Qi9sYxhaRwj7TYiD5MWg
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D6Qi9sYxhaRwj7TYiD5MWg
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 2 package(s): 9 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
⛔ 2 release-owned page(s) also reference the affected code. These are read-only:
|
Uh oh!
There was an error while loading. Please reload this page.
Fixes#7724
All three compounding problems, plus the status-code leak. The envelope fix is deliberately not allowed to stand in for the atomicity fix — per the recorded scope, "a structured 409 on an operation that can never succeed is still an operation that can never succeed".
1. The referential veto —
sys_member.user_idnow declaresdeleteBehavior: 'cascade'A
lookupdefaults toset_null, and the engine escalates a defaultedset_nullon a REQUIRED foreign key torestrict(packages/objectql/src/engine.ts,cascadeDeleteRelations).user_idis required and nothing declared a behaviour, so the membership the reconciler creates for every user at sign-up vetoed everysys_userdelete —remove-usercould never succeed on any deployment.The audit the card asked for, before adding the cascade: nothing depends on the restrict. It is specifically not an accidental last-administrator guard. That invariant is enforced by a
beforeDeletehook registered onsys_memberitself (last-admin-guard.ts, ADR-0024 D5.2), and the engine's cascade recurses through the publicdelete()precisely so the child's own hooks and events fire. The guard therefore still refuses a cascade that would take the last administrator's standing away — it just refuses it one row deeper.Per #7796 this also removes memberships that invitation acceptance adopted, which is correct and is covered by a dedicated test.
2. The non-atomicity — the harmful half
Verified at source rather than inferred: better-auth
1.7.0-rc.2'sinternalAdapter.deleteUser(dist/db/internal-adapter.mjs) deletes sessions, then accounts, then the user, in three separate adapter calls. The stringtransactiondoes not occur in that file at all. So anything refusing the third call leaves the first two committed — credential rows gone,sys_userrow still on the org roster, that identity permanently un-authenticatable.Subject-erasure requests now run inside one engine transaction (
ObjectQL.transaction, ADR-0034), opened atAuthManager.handleRequest— a seam the platform owns. Re-implementing better-auth's route was rejected for the reason #7725 gives: duplicated security checks are where bypasses live. This wrapper reads no bodies and makes no authorization decision.One subtlety worth flagging for review: better-auth returns its faults as a
Responserather than throwing, so a plaintry/catchunit of work would commit on exactly the path it exists to undo. The rollback is therefore triggered from the response status and the response carried back out through a private sentinel.The route set is the existing exported
SESSION_ERASURE_PATHS, not a new list.Two declared limits, both inherited rather than introduced, and both documented at the call site: a driver with no
beginTransactiondegrades with the engine's existing warning (failing closed was considered and rejected — it would make removal impossible on those datasources, the very defect being fixed); and non-datasource side effects are not transactional.3. The bodyless 500
The PM's hypothesis was correct and is confirmed — the fix belongs at the adapter, not at the REST mapping.
rethrowAsBetterAuthErrorinobjectql-adapter.tsmapped engine validation errors and policy refusals, but a referentialDELETE_RESTRICTEDcarries neither signature and fell through tothrow err, which better-auth's router renders as a 500 with an empty body. A third arm maps it toAPIError('CONFLICT')carryingdeveloperMessage/dependentObject/dependentCount.packages/rest/src/rest-server.tsis untouched. ItsmapDataErroralready handles this code correctly for the generic data routes; the two transports map the one engine error independently, exactly as they already did for the other two arms.Verification — predicted before mutating, then measured
Every pin's ablation direction was written down before the mutation was run.
deleteBehavior: 'cascade'DELETE_RESTRICTEDDELETE_RESTRICTEDarmexpected 500 to be 409, andthe response body is emptyexpected [] to deeply equal [ '7a34…' ]Two honest deviations, reported rather than smoothed over:
expected 409 to be 403. Not a false pin: with the restrict live, the membership veto fires before that test's own injected refusal, so a different — still structured — refusal wins.The ablation also incidentally confirmed the fake is faithful: with the declaration removed the field reads back
set_null, which the fake escalates torestrictexactly as the engine does.The fake engine is driven by the real declaration, not by a constant. It reads
SysMember.fields.user_idand reproduces the engine's own arithmetic over it, so reverting the object file changes the fake's behaviour. Its transaction really snapshots and restores. Both write verbs are pinned toassertEngineDeleteDispatch/assertEngineUpdateDispatch.Local gates run:
@objectstack/plugin-auth1048/1048,@objectstack/platform-objects339/339, bothtypecheckclean,check:nul-bytes,check:docs-audit-scope,check:engine-double-contract,check:error-code-casing, andeslint --no-inline-configon every changed file. Consumer sweep in the prefix direction (--filter '...@objectstack/platform-objects' --filter '...@objectstack/plugin-auth'— 51 packages, the ones that DEPEND ON these): build and typecheck clean, tests exit 0 with 19,135 passed.typecheckcaught a real defect the green suite could not see: the composed scope helpers are typedPromise< Promise< Response > >, which the previous single call site flattened with itsawait.Generated by Claude Code