Skip to content

Commit e7a7506

Browse files
huangyiireneclaude
andauthored
fix(identity): make admin remove-user atomic, cascade sys_member, and map DELETE_RESTRICTED to a 409 (#7879)
* fix(identity): make remove-user atomic, cascade sys_member, map DELETE_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 * test(plugin-auth): pin remove-user atomicity, cascade and 409 mapping Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D6Qi9sYxhaRwj7TYiD5MWg * test(plugin-auth): enable the admin plugin in the #7724 harness Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D6Qi9sYxhaRwj7TYiD5MWg * fix(identity): changeset + type the composed handler runner Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D6Qi9sYxhaRwj7TYiD5MWg --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4b5702a commit e7a7506

6 files changed

Lines changed: 773 additions & 9 deletions

File tree

.changeset/olive-moons-shave.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/platform-objects': patch
3+
'@objectstack/plugin-auth': patch
4+
---
5+
6+
Fix `POST /api/v1/auth/admin/remove-user`, which could never succeed and left the identity un-authenticatable when it failed.
7+
8+
Three compounding problems on the better-auth admin removal path:
9+
10+
- **`sys_member.user_id` declared no `deleteBehavior`.** A `lookup` defaults to `set_null`, and the engine escalates a defaulted `set_null` on a REQUIRED foreign key to `restrict` — so the membership every user gets at sign-up (and, since the invitation-adoption change, keeps after accepting an invitation) vetoed every `sys_user` delete. The field now declares `deleteBehavior: 'cascade'`. The last-administrator invariant is unaffected: it is enforced by a `beforeDelete` hook on `sys_member`, and the engine's cascade recurses through the public `delete()`, so that hook still runs.
11+
- **The removal was not atomic.** better-auth deletes the sessions, then the accounts, then the user, in three calls with no transaction, so anything refusing the last one left the credential rows deleted and the user row behind — an identity still on the org roster that can no longer sign in. Subject-erasure requests now run inside one engine transaction and roll back as a unit. Datasources whose driver has no transaction support keep the previous behaviour and log the engine's existing warning.
12+
- **A referential refusal reached the client as an HTTP 500 with an empty body.** The auth adapter mapped engine validation errors and policy refusals to better-auth `APIError`s but not referential ones, so a `DELETE_RESTRICTED` escaped unmapped. It now surfaces as a structured 409 carrying the dependent object, the dependent count and the remedy.

packages/platform-objects/src/identity/sys-member.object.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,27 @@ export const SysMember = ObjectSchema.create({
164164
user_id: Field.lookup('sys_user', {
165165
label: 'User',
166166
required: true,
167+
// [#7724] A membership without its user is meaningless, so deleting the
168+
// user takes its memberships with it. This must be DECLARED: a `lookup`
169+
// defaults to `set_null`, and the engine escalates a *defaulted*
170+
// `set_null` on a REQUIRED foreign key to `restrict` (you cannot null a
171+
// NOT NULL column). That escalation vetoed every `sys_user` delete on any
172+
// deployment where the membership reconciler had run — i.e. all of them,
173+
// since `reconcile-membership.ts` binds every user to the default org at
174+
// sign-up, and (since #7796) invitation acceptance ADOPTS that same row.
175+
// So `/admin/remove-user` could never succeed, and the operator could not
176+
// clear the blocker by hand either: `enable.apiMethods` below is read-only.
177+
//
178+
// Audited before declaring it, because the engine's own error naming
179+
// `deleteBehavior:'cascade'` is a suggestion, not an audit: nothing
180+
// depends on the restrict. In particular it is NOT an accidental
181+
// last-administrator guard — that invariant is enforced by a `beforeDelete`
182+
// hook registered on `sys_member` itself (ADR-0024 D5.2,
183+
// `last-admin-guard.ts`), and the engine's cascade recurses through the
184+
// PUBLIC `delete()` precisely so the child's own hooks and events fire.
185+
// The guard therefore still refuses a cascade that would take the last
186+
// administrator's standing away; it simply refuses it one row deeper.
187+
deleteBehavior: 'cascade',
167188
}),
168189

169190
// [ADR-0108 / #3723] The framework's four roles — the WHOLE list. Nothing

packages/plugins/plugin-auth/src/auth-manager.ts

Lines changed: 121 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { postureEnforcesWall, type TenancyPosture } from '@objectstack/spec/secu
2222
import { MCP_OAUTH_SCOPES } from '@objectstack/spec/ai';
2323
import { createObjectQLAdapterFactory, withSystemReadContext } from './objectql-adapter.js';
2424
import { runWithAuthActorScope, setAuthActorResolver } from './auth-actor-attribution.js';
25+
import { SESSION_ERASURE_PATHS } from './session-tombstone.js';
2526
import {
2627
invitationRoleCapFailure,
2728
isPlainMemberInvitation,
@@ -165,6 +166,26 @@ function installWebContainerRequestStatePolyfill(): void {
165166
}
166167
}
167168

169+
/**
170+
* [#7724] Carries better-auth's own error `Response` out through the engine
171+
* transaction that must roll back because of it.
172+
*
173+
* better-auth's HTTP entrypoint CATCHES every fault and RETURNS a `Response` —
174+
* it does not throw. A `try`/`catch`-shaped unit of work therefore sees a clean
175+
* return on the exact path it exists to undo, commits, and the partial writes
176+
* land anyway. So the failure signal has to be re-raised from the response
177+
* status, and the response itself has to survive the throw that rolls the
178+
* transaction back — that is the whole job of this class. It never escapes
179+
* `runSubjectErasureAtomically`, which unwraps it back into the response the
180+
* client was always going to get.
181+
*/
182+
class SubjectErasureRollback extends Error {
183+
constructor(readonly response: Response) {
184+
super(`subject-erasure unit of work rolled back (HTTP ${response.status})`);
185+
this.name = 'SubjectErasureRollback';
186+
}
187+
}
188+
168189
function readBooleanEnv(name: string, legacyName?: string): boolean | undefined {
169190
const env = (globalThis as any)?.process?.env as Record<string, string | undefined> | undefined;
170191
const raw = env?.[name] ?? (legacyName ? env?.[legacyName] : undefined);
@@ -3069,9 +3090,28 @@ export class AuthManager {
30693090
// costs nothing: the scope starts empty, the before-hook drops a resolver
30703091
// in, and the session is looked up only if some write asks. Attribution
30713092
// only — the authorization subject of those writes is unchanged (system).
3072-
const response = await runWithAuthActorScope(() =>
3073-
runWithRequestState(new WeakMap(), () => auth.handler(request)),
3074-
);
3093+
// `await`, not a bare return: both scope helpers are generic over their
3094+
// callback, so the composed call is typed `Promise< Promise< Response > >`.
3095+
// The previous single call site flattened it with the `await` below.
3096+
const runHandler = async (): Promise<Response> =>
3097+
await runWithAuthActorScope(() =>
3098+
runWithRequestState(new WeakMap(), () => auth.handler(request)),
3099+
);
3100+
3101+
// [#7724] A subject-erasure request is ONE unit of work, and better-auth
3102+
// does not treat it as one: `internalAdapter.deleteUser` deletes the
3103+
// sessions, then the accounts, then the user, in three unrelated adapter
3104+
// calls with no transaction (verified in better-auth 1.7.0-rc.2 —
3105+
// `dist/db/internal-adapter.mjs` mentions no transaction at all). Anything
3106+
// that refuses the LAST of those three leaves the first two committed: the
3107+
// credential rows are gone, the `sys_user` row is not, and the deployment
3108+
// is left with an identity that still occupies the org roster and can no
3109+
// longer sign in. Nothing tells the operator, and there is no way back.
3110+
const endpointPath = this.betterAuthEndpointPath(request);
3111+
const response =
3112+
endpointPath !== undefined && SESSION_ERASURE_PATHS.has(endpointPath)
3113+
? await this.runSubjectErasureAtomically(runHandler)
3114+
: await runHandler();
30753115

30763116
if (response.status >= 500) {
30773117
try {
@@ -3085,6 +3125,84 @@ export class AuthManager {
30853125
return response;
30863126
}
30873127

3128+
/**
3129+
* The better-auth endpoint path (`/admin/remove-user`) this request addresses,
3130+
* or `undefined` when it is not under the configured `basePath`.
3131+
*
3132+
* The same spelling better-auth's own `ctx.path` uses, so the sets keyed by it
3133+
* — `SESSION_ERASURE_PATHS`, the break-glass guard's path tests — are all
3134+
* talking about one thing.
3135+
*/
3136+
private betterAuthEndpointPath(request: Request): string | undefined {
3137+
let pathname: string;
3138+
try {
3139+
pathname = new URL(request.url).pathname;
3140+
} catch {
3141+
return undefined;
3142+
}
3143+
const configured = this.config.basePath || '/api/v1/auth';
3144+
const base = (configured.startsWith('/') ? configured : `/${configured}`).replace(/\/+$/, '');
3145+
if (!pathname.startsWith(base)) return undefined;
3146+
const endpoint = pathname.slice(base.length).replace(/\/+$/, '');
3147+
return endpoint.startsWith('/') ? endpoint : undefined;
3148+
}
3149+
3150+
/**
3151+
* [#7724] Run a subject-erasure request as ONE unit of work: every write it
3152+
* makes commits together, or none of them do.
3153+
*
3154+
* Placed at the REQUEST seam rather than inside better-auth's route, because
3155+
* re-implementing `/admin/remove-user` here would duplicate its permission
3156+
* check, its self-removal check and its not-found check — and a duplicated
3157+
* security check is where the two copies drift apart. This wrapper reads no
3158+
* bodies and makes no authorization decision; better-auth's handler runs
3159+
* exactly as before, and the only thing added is the transaction it runs in.
3160+
*
3161+
* The engine's `transaction()` (ADR-0034) publishes its handle into the
3162+
* ambient store, so every adapter write on the way down joins it without the
3163+
* adapter knowing — which is why this needs no change in `objectql-adapter.ts`.
3164+
*
3165+
* Two declared limits, both inherited rather than introduced:
3166+
* - a datasource whose driver has no `beginTransaction` runs the callback
3167+
* with no transaction and no rollback (ADR-0119 D1). The engine warns once
3168+
* per driver. Failing CLOSED instead (`{ require: true }`) was considered
3169+
* and rejected: it would make user removal impossible on those datasources,
3170+
* which is the very defect this card is fixing.
3171+
* - side effects outside the datasource (a sent email, secondary-storage
3172+
* session state) are not transactional and are not undone by a rollback.
3173+
* `/admin/remove-user` sends nothing, so no path here relies on it.
3174+
*/
3175+
private async runSubjectErasureAtomically(
3176+
run: () => Promise<Response>,
3177+
): Promise<Response> {
3178+
const engine = this.config.dataEngine as
3179+
| (IDataEngine & {
3180+
transaction?: <T>(callback: (trxCtx: any, info: any) => Promise<T>) => Promise<T>;
3181+
})
3182+
| undefined;
3183+
// `transaction` is an ObjectQL capability, not an `IDataEngine` member — an
3184+
// engine without it (a test double, a foreign engine) keeps the previous
3185+
// behaviour rather than being refused.
3186+
if (typeof engine?.transaction !== 'function') return run();
3187+
3188+
try {
3189+
return await engine.transaction(async () => {
3190+
const response = await run();
3191+
// better-auth RETURNS its faults; see `SubjectErasureRollback`. Any 4xx/5xx
3192+
// means the erasure did not complete, so whatever part of it already
3193+
// landed must not survive. 2xx commits; so does the 302 that
3194+
// `/delete-user/callback` answers with on success.
3195+
if (response.status >= 400) throw new SubjectErasureRollback(response);
3196+
return response;
3197+
});
3198+
} catch (err) {
3199+
// The rollback has happened by the time this runs — hand the client the
3200+
// response better-auth composed, now with no partial writes behind it.
3201+
if (err instanceof SubjectErasureRollback) return err.response;
3202+
throw err;
3203+
}
3204+
}
3205+
30883206
/**
30893207
* Get the better-auth API for programmatic access
30903208
* Use this for server-side operations (e.g., creating users, checking sessions)

packages/plugins/plugin-auth/src/objectql-adapter.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -460,6 +460,92 @@ describe('withValidationErrorMapping – ObjectQL ValidationError → better-aut
460460
await expect(adapter.update()).rejects.toBe(boom);
461461
});
462462

463+
// [#7724] The third arm. A referential veto is raised by the ENGINE, well
464+
// below the layers that know better-auth exists, so it carried neither the
465+
// validation envelope nor the policy-refusal code and fell through to
466+
// `throw err` — reaching an admin as a 500 with an EMPTY body for a refusal
467+
// the engine had explained in full.
468+
describe('a referential delete restriction (DELETE_RESTRICTED) → 409', () => {
469+
// Faithful mimic of the engine's envelope (`packages/objectql/src/engine.ts`,
470+
// ADR-0112 + #7307's message split).
471+
const restricted = () => {
472+
const err: any = new Error('Cannot delete User: 1 or more Member records still reference it.');
473+
err.code = 'DELETE_RESTRICTED';
474+
err.status = 409;
475+
err.object = 'sys_user';
476+
err.dependentObject = 'sys_member';
477+
err.dependentCount = 3;
478+
err.developerMessage =
479+
'Cannot delete sys_user: 3 dependent sys_member record(s) reference it via user_id ' +
480+
"(user_id is required, so it cannot be cleared). Delete or reassign them first, " +
481+
"or set deleteBehavior:'cascade' on sys_member.user_id.";
482+
return err;
483+
};
484+
485+
it('maps it to a 409 APIError instead of letting it escape as a bodyless 500', async () => {
486+
const adapter = withValidationErrorMapping({
487+
delete: async () => {
488+
throw restricted();
489+
},
490+
});
491+
492+
let caught: any;
493+
try {
494+
await adapter.delete();
495+
} catch (e) {
496+
caught = e;
497+
}
498+
499+
// Both halves of the envelope, per ADR-0112: a throw alone cannot tell
500+
// "refused with the wrong envelope" apart from "refused correctly" —
501+
// the unfixed path throws too, it just throws something better-auth
502+
// cannot render.
503+
expect(isAPIError(caught)).toBe(true);
504+
expect(caught.statusCode).toBe(409);
505+
expect(caught.body).toMatchObject({
506+
code: 'DELETE_RESTRICTED',
507+
message: 'Cannot delete User: 1 or more Member records still reference it.',
508+
});
509+
});
510+
511+
it('carries the structured half through — the remedy stays reachable', async () => {
512+
// #7307's reasoning at the REST mapping, applied to this transport:
513+
// dropping `developerMessage` here would move the defect rather than fix
514+
// it, and it discloses nothing `dependentObject` does not already.
515+
const adapter = withValidationErrorMapping({
516+
delete: async () => {
517+
throw restricted();
518+
},
519+
});
520+
521+
const caught: any = await adapter.delete().catch((e: unknown) => e);
522+
expect(caught.body.dependentObject).toBe('sys_member');
523+
expect(caught.body.dependentCount).toBe(3);
524+
expect(caught.body.developerMessage).toContain("deleteBehavior:'cascade'");
525+
});
526+
527+
it('omits the structured keys when the engine did not supply them', async () => {
528+
// A bare `DELETE_RESTRICTED` must still map — the arm keys off `code`,
529+
// not off the optional detail — and must not invent `dependentCount: 0`,
530+
// which would read as "no dependents" on the error that exists to say
531+
// there are some.
532+
const bare: any = new Error('Cannot delete: dependent records exist');
533+
bare.code = 'DELETE_RESTRICTED';
534+
const adapter = withValidationErrorMapping({
535+
delete: async () => {
536+
throw bare;
537+
},
538+
});
539+
540+
const caught: any = await adapter.delete().catch((e: unknown) => e);
541+
expect(caught.statusCode).toBe(409);
542+
expect(caught.body.code).toBe('DELETE_RESTRICTED');
543+
expect(caught.body).not.toHaveProperty('dependentObject');
544+
expect(caught.body).not.toHaveProperty('dependentCount');
545+
expect(caught.body).not.toHaveProperty('developerMessage');
546+
});
547+
});
548+
463549
it('passes successful results through untouched and leaves non-function props alone', async () => {
464550
const adapter = withValidationErrorMapping({
465551
create: async (x: number) => x + 1,

packages/plugins/plugin-auth/src/objectql-adapter.ts

Lines changed: 63 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -497,10 +497,51 @@ function isEnginePolicyRefusal(err: unknown): err is { code?: string; message?:
497497
return (err as { code?: unknown }).code === 'PERMISSION_DENIED';
498498
}
499499

500+
/**
501+
* [#7724] A REFERENTIAL refusal — the engine's `cascadeDeleteRelations` found
502+
* dependent rows it may neither cascade nor null, so it vetoed the delete
503+
* (`DELETE_RESTRICTED`, 409, ADR-0112).
504+
*
505+
* The third shape in this file, and the one that shows why the set had to be
506+
* widened rather than left at two. The two arms above both map errors raised by
507+
* code that *knows about better-auth* — the record validator and this package's
508+
* own policy guards. A referential restrict is raised by the ENGINE, several
509+
* layers below, and carries neither signature; `rethrowAsBetterAuthError` fell
510+
* through to `throw err`, better-auth's router saw an unhandled fault, and the
511+
* admin caller got a **500 with an empty body** for a refusal the engine had
512+
* explained in full. The client is told nothing at all — not the status, not the
513+
* dependent object, not the remedy.
514+
*
515+
* Mapped HERE, at the adapter, rather than at the REST transport: this is the
516+
* seam where an engine error crosses into better-auth, so one arm covers every
517+
* better-auth endpoint that deletes through the adapter. `rest-server.ts`'s
518+
* `mapDataError` already maps the same code correctly for the generic data
519+
* routes and is deliberately untouched — the two transports map the one engine
520+
* error independently, exactly as they already do for the two arms above.
521+
*
522+
* The structured half of the envelope rides along unchanged (`developerMessage`
523+
* / `dependentObject` / `dependentCount`), for the reason #7307 gives at the
524+
* REST mapping: dropping the remedy at the transport moves the defect rather
525+
* than fixing it, and the fields disclose nothing the envelope did not carry.
526+
*/
527+
function isReferentialDeleteRestriction(
528+
err: unknown,
529+
): err is {
530+
code?: string;
531+
message?: string;
532+
developerMessage?: string;
533+
dependentObject?: string;
534+
dependentCount?: number;
535+
} {
536+
if (!err || typeof err !== 'object') return false;
537+
return (err as { code?: unknown }).code === 'DELETE_RESTRICTED';
538+
}
539+
500540
/**
501541
* Re-throw `err` as a better-auth `APIError` when it is an ObjectQL validation
502-
* failure or an engine policy refusal; otherwise re-throw it verbatim. Always
503-
* throws — the return type is `never`.
542+
* failure (400), an engine policy refusal (403) or a referential delete
543+
* restriction (409); otherwise re-throw it verbatim. Always throws — the return
544+
* type is `never`.
504545
*/
505546
async function rethrowAsBetterAuthError(err: unknown): Promise<never> {
506547
if (isObjectQLValidationError(err)) {
@@ -525,15 +566,31 @@ async function rethrowAsBetterAuthError(err: unknown): Promise<never> {
525566
code: 'PERMISSION_DENIED',
526567
});
527568
}
569+
if (isReferentialDeleteRestriction(err)) {
570+
const { APIError } = await import('better-auth/api');
571+
throw new APIError('CONFLICT', {
572+
message:
573+
typeof err.message === 'string' && err.message.trim()
574+
? err.message
575+
: 'Cannot delete: dependent records exist',
576+
code: 'DELETE_RESTRICTED',
577+
...(typeof err.developerMessage === 'string' && err.developerMessage.length > 0
578+
? { developerMessage: err.developerMessage }
579+
: {}),
580+
...(err.dependentObject ? { dependentObject: err.dependentObject } : {}),
581+
...(typeof err.dependentCount === 'number' ? { dependentCount: err.dependentCount } : {}),
582+
});
583+
}
528584
throw err;
529585
}
530586

531587
/**
532588
* Wrap every function-valued method of a better-auth adapter so an ObjectQL
533-
* `ValidationError` (400) or an engine policy refusal (403) thrown from the
534-
* underlying engine surfaces as a 4xx `APIError` instead of an opaque 500.
535-
* Non-function properties pass through untouched, and every error that carries
536-
* neither signature is re-thrown verbatim.
589+
* `ValidationError` (400), an engine policy refusal (403) or a referential
590+
* delete restriction (409, #7724) thrown from the underlying engine surfaces as
591+
* a 4xx `APIError` instead of an opaque 500. Non-function properties pass
592+
* through untouched, and every error that carries none of those signatures is
593+
* re-thrown verbatim.
537594
*/
538595
export function withValidationErrorMapping<A extends Record<string, any>>(adapter: A): A {
539596
const out: Record<string, any> = {};

0 commit comments

Comments
 (0)