Skip to content

admin-lifecycle: remove-user is non-atomic and can never succeed — 409 leaks as a bodyless HTTP 500, credential rows deleted without rollback #7724

Description

@huangyiirene

Symptom

As a genuine better-auth admin, POST /api/v1/auth/admin/remove-user {userId} on a user that owns a record fails, and the failure is non-atomic:

  • Observed: HTTP 500 with an empty body. sys_user is still readable; the owned record still carries its owner_id; and the user's credential sys_account rows have already been deleted — so that email can no longer sign in (401) while still occupying the org roster. Reproduced twice; verified across three victims (accounts=0 for all three vs accounts=1 for untouched users).
  • Expected: 2xx, sys_user gone, the owned record surviving with owner_id cleared to null (engine FK clear).

The server log gives the real cause — a 409 leaking to the client as a bodyless 500:

Cannot delete sys_user (): 1 dependent sys_member record(s) reference it via user_id
(user_id is required, so it cannot be cleared)… set deleteBehavior:cascade on sys_member.user_id
{ code: DELETE_RESTRICTED, status: 409 }

Root cause

Three compounding problems, located on origin/main:

  1. Every user is auto-bound to the default org at sign-up/create (the membership reconciler in packages/plugins/plugin-auth/src/reconcile-membership.ts runs as a user.create.after hook), and sys_member.user_id is a required FK with no cascade — packages/platform-objects/src/identity/sys-member.object.ts declares user_id: Field.lookup('sys_user', { required: true }) and sets no deleteBehavior:'cascade'. So the engine's referential restrict vetoes the sys_user delete every time — remove-user can never succeed on this deployment.
  2. The operator cannot clear the blocker either. DELETE /api/v1/data/sys_member/{id}405sys-member.object.ts sets enable.apiMethods = ['get', 'list'] (writes owned by better-auth / refused by the identity write guard).
  3. No rollback. The account/session deletions performed by better-auth's admin remove-user handler are not rolled back when the engine refuses the sys_user delete, leaving an orphaned, un-authenticatable identity on the roster.

On top of that, the engine's DELETE_RESTRICTED (409) surfaces through the better-auth admin route as a bodyless HTTP 500 rather than a structured 409 envelope (the generic REST data route maps this correctly in packages/rest/src/rest-server.ts, but the better-auth admin path does not).

This is not the #3023 owner-anchor regression — the veto comes from the engine's referential restrict on sys_member.user_id, not from the owner-anchor transfer guard.

The break-glass guard at packages/plugins/plugin-auth/src/auth-manager.ts (ctx.path === '/admin/remove-user') refuses removing the last local-credential holder, but does not cascade sys_member, roll back the credential deletes, or map the 409 — so none of the three problems above are addressed by it.

Reproduction

  1. As a genuine better-auth admin: POST /api/v1/auth/admin/create-user {email:…} → 200.
  2. Create a record owned by that user.
  3. POST /api/v1/auth/admin/remove-user {userId}.
  4. Observe HTTP 500 with an empty body; re-read shows sys_user still present, the record still owned, but the user's sys_account rows gone (subsequent sign-in 401).

Related

Shares the bodyless-500 statusCode-leak class with the invitation-acceptance failure from the same run — #7725 (accept-invitation → bodyless 500 on a UNIQUE-constraint collision).

Source

Extracted from the QA run #7663 (framework 92f26f7, console 09987b680).

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions