Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
4f30f39
fix: Run the Erasure Cascade in Admin User Deletion
berry-13 Oct 13, 2026
bccc2e4
refactor: Share Generation Stop Logic Between Self-Service and Admin …
berry-13 Oct 13, 2026
a68a7ce
refactor: Move Admin Erasure Dependency Assembly Into packages/api
berry-13 Oct 13, 2026
feb0fa2
fix: Bind getCleanupJob When Waiting for Generation Persistence
berry-13 Oct 13, 2026
5388abb
test: Pin the Deletion Fence Before Admin Generation Stop
berry-13 Oct 13, 2026
429663c
fix: Resolve the Erasure Target Config Fail Closed
berry-13 Oct 13, 2026
6cb7d89
fix: Carry the Target Profile Into Admin Erasure Storage Cleanup
berry-13 Oct 13, 2026
bf50f1e
fix: Keep the Erasure Target Contract Storage Neutral and Reuse the L…
berry-13 Oct 14, 2026
912506f
fix: Refuse New Generations Once Account Deletion Has Begun
berry-13 Oct 14, 2026
8223c5b
fix: Enforce Account Deletion Admission Once in createJob and Narrow …
berry-13 Oct 14, 2026
4600f30
fix: Install the Admission Guard in Every Entrypoint and Purge Refuse…
berry-13 Oct 14, 2026
fb560c1
fix: Guarantee Refused Job Purge and Sanitize Admission Failures
berry-13 Oct 14, 2026
5965bbe
fix: Route Remote Admission Through createJob and Keep Refusal Cleanu…
berry-13 Oct 14, 2026
30de716
fix: Log Admission Cleanup Failures With Safe Error Metadata
berry-13 Oct 14, 2026
4cdf30c
refactor: Check Account Deletion Before Creating a Generation and Dra…
berry-13 Oct 14, 2026
3d78be3
refactor: Order Admission Against the Deletion Marker Without a Grace…
berry-13 Oct 14, 2026
076beb3
fix: Sanitize Post-Commit Admission Retirement Failures
berry-13 Oct 14, 2026
9dc6298
fix: Wire Shared-Server MCP OAuth Cleanup Into Admin Erasure
berry-13 Oct 14, 2026
9bbd434
fix: Scope Admin Erasure Lookups to the Target Tenant and Await Share…
berry-13 Oct 14, 2026
717e202
fix: Resolve the Erasure Target Config Under the Target Tenant
berry-13 Oct 14, 2026
5118d04
fix: Run Admin Erasure OAuth Fencing in the Target Tenant
berry-13 Oct 14, 2026
294e04d
fix: Match the Split OAuth Invalidate and Disconnect Hooks in Admin E…
berry-13 Oct 14, 2026
f085493
refactor: Inject the Tenant Executor Into the Erasure Resolver
berry-13 Oct 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
37 changes: 2 additions & 35 deletions api/server/controllers/UserController.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,7 @@ const {
getWebSearchInstallEntries,
getWebSearchUninstallFields,
openCheckpointDeletion,
isStopConfirmed,
waitForGenerationPersistence,
stopUserGenerations,
deleteAllSharedLinksWithCleanup,
eraseUserData,
deleteUserMCPServers,
Expand Down Expand Up @@ -410,39 +409,7 @@ const deleteUserController = async (req, res) => {
if (!(await quiesceUserSchedules(user.id, scheduleSuspensionToken))) {
throw new Error('Scheduled executions could not be confirmed stopped');
}
const activeAgentRuns = await GenerationJobManager.getAccountCleanupJobIdsForUser(
user.id,
tenantId,
);
const activeAgentJobs = await Promise.all(
activeAgentRuns.map(async (streamId) => ({
streamId,
job: await GenerationJobManager.getCleanupJob(streamId),
})),
);
const ownedAgentJobs = activeAgentJobs.filter(
({ job }) =>
job?.metadata?.userId === user.id &&
(!job.metadata.tenantId || job.metadata.tenantId === tenantId),
);
const stopResults = await Promise.all(
ownedAgentJobs.map(({ streamId, job }) =>
GenerationJobManager.abortJob(streamId, {
expectedCreatedAt: job.createdAt,
awaitProviderDrain: true,
}),
),
);
if (stopResults.some((result) => !isStopConfirmed(result))) {
throw new Error('Agent generations could not be confirmed stopped');
}
await Promise.all(
ownedAgentJobs.map(({ streamId, job }) =>
waitForGenerationPersistence(streamId, job.createdAt, (id) =>
GenerationJobManager.getCleanupJob(id),
),
),
);
await stopUserGenerations(user.id, tenantId, GenerationJobManager);

const appConfig =
req.config ??
Expand Down
8 changes: 2 additions & 6 deletions api/server/controllers/__tests__/deleteUser.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -77,13 +77,9 @@ jest.mock('@librechat/api', () => ({
abortJob: (...args) => mockAbortJob(...args),
},
getOwnedAgentCheckpointScope: jest.fn(() => undefined),
isStopConfirmed: jest.fn(
(result) => result?.success === true || result?.failureReason === 'already_settled',
),
deleteOwnedAgentCheckpoints: jest.fn(),
waitForGenerationPersistence: jest.requireActual(
'../../../../packages/api/src/stream/persistence.ts',
).waitForGenerationPersistence,
stopUserGenerations: jest.requireActual('../../../../packages/api/src/user/generations.ts')
.stopUserGenerations,
openCheckpointDeletion: jest.fn(async (userId, tenantId, _root, cfg) => ({
remember: jest.fn(),
cleanup: () =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1100,7 +1100,12 @@ describe('ResumableAgentController resume metadata', () => {
});

it('rejects any session fenced after authentication before generation execution starts', async () => {
mockIsAgentTriggerPrincipalActive.mockResolvedValue(false);
mockGenerationJobManager.createJob.mockRejectedValueOnce(
Object.assign(new Error('Account deletion is in progress'), {
code: 'ACCOUNT_DELETION_IN_PROGRESS',
status: 409,
}),
);
const req = {
user: { id: 'user-123' },
body: {
Expand All @@ -1118,10 +1123,6 @@ describe('ResumableAgentController resume metadata', () => {
await AgentController(req, res, jest.fn(), initializeClient, null);

expect(mockGenerationJobManager.createJob).toHaveBeenCalledTimes(1);
expect(mockIsAgentTriggerPrincipalActive).toHaveBeenCalledWith('user-123');
expect(mockGenerationJobManager.createJob.mock.invocationCallOrder[0]).toBeLessThan(
mockIsAgentTriggerPrincipalActive.mock.invocationCallOrder[0],
);
expect(mockGenerationJobManager.beginProviderExecution).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(409);
expect(res.json).toHaveBeenCalledWith({
Expand All @@ -1132,15 +1133,8 @@ describe('ResumableAgentController resume metadata', () => {
});
expect(initializeClient).not.toHaveBeenCalled();
expect(mockAcceptAgentStartupTelemetry).not.toHaveBeenCalled();
expect(mockGenerationJobManager.completeJob).toHaveBeenCalledWith(
'conversation-123',
JSON.stringify({
status: 409,
code: 'ACCOUNT_DELETION_IN_PROGRESS',
error: 'Account deletion is in progress',
}),
1000,
);
/** createJob retired the refused job itself, so the controller has none to complete. */
expect(mockGenerationJobManager.completeJob).not.toHaveBeenCalled();
expect(mockGenerationJobManager.releaseGeneration).toHaveBeenCalledWith(
'user-123',
'trigger-request',
Expand Down Expand Up @@ -1239,10 +1233,6 @@ describe('ResumableAgentController resume metadata', () => {

await AgentController(req, res, jest.fn(), initializeClient, null);

expect(mockIsAgentTriggerPrincipalActive).toHaveBeenCalledWith('user-123');
expect(mockIsAgentTriggerPrincipalActive.mock.invocationCallOrder[0]).toBeLessThan(
mockGenerationJobManager.beginProviderExecution.mock.invocationCallOrder[0],
);
expect(initializeClient).not.toHaveBeenCalled();
expect(mockGenerationJobManager.completeJob).toHaveBeenCalledWith(
'conversation-123',
Expand Down
15 changes: 2 additions & 13 deletions api/server/controllers/agents/request.js
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,6 @@ const {
claimAgentEventActorSuspension,
settleAgentEventActorSuspension,
stampConvoLastResponse,
isAgentTriggerPrincipalActive,
isSubagentOwnerAdmissible,
appendConvoMessageReference,
} = require('~/models');
Expand Down Expand Up @@ -1755,18 +1754,8 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
req.turnStartedAt = jobCreatedAt;
providerExecutionId = job.metadata?.providerExecutionId;

/** Authentication can precede a slow admission path. Recheck the durable
* account-deletion fence after the job is committed but before execution
* starts. This ordering closes both sides of the race for ordinary and
* trigger-scoped sessions: a fence that wins first rejects this run; a
* fence that starts after this read must observe the already-created job
* in account deletion's active-generation drain. */
if (!(await isAgentTriggerPrincipalActive(userId))) {
throw Object.assign(new Error('Account deletion is in progress'), {
code: 'ACCOUNT_DELETION_IN_PROGRESS',
status: 409,
});
}
/** Account-deletion admission is enforced inside `createJob`, after the job is committed, so a
* fence that wins first refuses this run and one that starts later finds the job in its drain. */
if (req._agentEventBindingParentConversationId != null) {
/** The generation job is the durable marker that a deletion on another replica
* can abort. Recheck only after that marker exists: either the deletion fence
Expand Down
1 change: 1 addition & 0 deletions api/server/experimental.js
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,7 @@ if (cluster.isMaster) {
// but an already-fired scheduled generation can still reach HITL here. Settle
// its durable run when the generic approval runtime expires it.
GenerationJobManager.setApprovalExpiredHandler(recordExpiredScheduleApproval);
GenerationJobManager.setAdmissionGuard(agentEventMethods.isAgentTriggerPrincipalActive);
GenerationJobManager.setTerminalHostActionHandler(
createAgentEventTerminalHandler(agentEventMethods, {
resumeDetachedAction: resumeAgentEventDetachedAction,
Expand Down
1 change: 1 addition & 0 deletions api/server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ const configureGenerationStreams = () => {
cleanupOnComplete: !isEnabled(process.env.STREAM_KEEP_COMPLETED_JOBS),
});
GenerationJobManager.setApprovalExpiredHandler(recordExpiredScheduleApproval);
GenerationJobManager.setAdmissionGuard(agentEventMethods.isAgentTriggerPrincipalActive);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Install the admission guard in the clustered server

When the app is launched via npm run backend:experimental, api/server/experimental.js:366-372 initializes GenerationJobManager but never calls setAdmissionGuard. Because this patch also removes the controller-level isAgentTriggerPrincipalActive check, a generation authenticated before self-deletion can commit after that worker's deletion drain and start without ever observing the deletion fence, allowing it to recreate user data after erasure. Configure the same guard in every server entrypoint or retain a shared request-level barrier.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in fb444cc: experimental.js installs the same admission guard.

GenerationJobManager.setTerminalHostActionHandler(
createAgentEventTerminalHandler(agentEventMethods, {
resumeDetachedAction: resumeAgentEventDetachedAction,
Expand Down
44 changes: 41 additions & 3 deletions api/server/routes/admin/users.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
const express = require('express');
const mongoose = require('mongoose');
const { createAdminUsersHandlers, revokeUserCodeEnvironmentWorkers } = require('@librechat/api');
const { SystemCapabilities } = require('@librechat/data-schemas');
const {
GenerationJobManager,
openCheckpointDeletion,
deleteAllSharedLinksWithCleanup,
createErasureResolver,
createAdminUsersHandlers,
revokeUserCodeEnvironmentWorkers,
} = require('@librechat/api');
const { SystemCapabilities, runAsSystem, tenantStorage } = require('@librechat/data-schemas');
const { requireCapability } = require('~/server/middleware/roles/capabilities');
const { requireJwtAuth } = require('~/server/middleware');
const {
Expand All @@ -10,11 +17,37 @@ const {
cancelAgentTriggerUserPurge,
purgeAgentTriggerDeliveriesForUser,
} = require('~/server/services/Agents/triggers');
const { processDeleteRequest } = require('~/server/services/Files/process');
const { invalidateCachedTools } = require('~/server/services/Config/getCachedTools');
const { getMCPManager, getMCPServersRegistry } = require('~/config');
const { maybeUninstallOAuthMCP } = require('~/server/services/MCP/oauthCleanup');
const subagentThreadTaskStore = require('~/server/services/Endpoints/agents/subagentThreadStore');
const {
quiesceUserSchedules,
restoreUserSchedulesFromDeletion,
} = require('~/server/services/Schedules');
const db = require('~/models');
const { getAppConfig, invalidateCodeEnvironmentConfigCache } = require('~/server/services/Config');

const router = express.Router();

const resolveErasure = createErasureResolver({
withTenant: (tenantId, operation) =>
tenantId ? tenantStorage.run({ tenantId }, operation) : runAsSystem(operation),
db,
getAppConfig,
getMCPManager,
processDeleteRequest,
invalidateCachedTools,
mcpOAuth: {
getRegistry: getMCPServersRegistry,
getUserPrincipals: db.getUserPrincipals,
uninstallOAuthMCP: maybeUninstallOAuthMCP,
},
openCheckpointDeletion,
deleteSharedLinks: (userId) => deleteAllSharedLinksWithCleanup(userId, { awaitCleanup: true }),
});

const requireAdminAccess = requireCapability(SystemCapabilities.ACCESS_ADMIN);
const requireReadUsers = requireCapability(SystemCapabilities.READ_USERS);
// const requireManageUsers = requireCapability(SystemCapabilities.MANAGE_USERS);
Expand All @@ -40,7 +73,12 @@ const handlers = createAdminUsersHandlers({
deleteConfig: db.deleteConfig,
deleteAclEntries: db.deleteAclEntries,
deletePasskeysByUser: db.deletePasskeysByUser,
deleteTokens: db.deleteTokens,
resolveErasure,
cancelSubagentTasks: (userId, tenantId) =>
subagentThreadTaskStore.cancelAndDrainForOwner(userId, tenantId),
quiesceUserSchedules,
restoreUserSchedulesFromDeletion,
generationJobs: GenerationJobManager,
Comment on lines +76 to +81

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Expose the admin-erasure handler through the product flow

This adds the full admin-erasure dependency graph, but the repository-wide search finds its only HTTP binding still commented out at api/server/routes/admin/users.js:80, and there is no other caller of handlers.deleteUser; consequently none of this cascade can run when an administrator uses the shipped application. Wire the handler to the capability-protected route and provide the corresponding frontend entry point so the advertised admin deletion behavior is observable.

AGENTS.md reference: AGENTS.md:L50-L53

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Accepted: mounting the admin DELETE route is a recorded non-goal; it stays commented out on dev.

});

router.use(requireJwtAuth, requireAdminAccess);
Expand Down
121 changes: 112 additions & 9 deletions packages/api/src/admin/users.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ import type { Response } from 'express';
import type { ServerRequest } from '~/types/http';
import type { AdminUsersDeps } from './users';
import { createAdminUsersHandlers } from './users';
import { eraseUserData } from '~/user/erasure';

jest.mock('~/user/erasure', () => ({
eraseUserData: jest.fn().mockResolvedValue(undefined),
}));

jest.mock('@librechat/data-schemas', () => ({
...jest.requireActual('@librechat/data-schemas'),
Expand Down Expand Up @@ -68,12 +73,24 @@ function createDeps(overrides: Partial<AdminUsersDeps> = {}): AdminUsersDeps {
deleteConfig: jest.fn().mockResolvedValue(null),
deleteAclEntries: jest.fn().mockResolvedValue(undefined),
deletePasskeysByUser: jest.fn().mockResolvedValue(0),
deleteTokens: jest.fn().mockResolvedValue({ deletedCount: 0 }),
resolveErasure: jest.fn().mockResolvedValue({ deps: {} }),
cancelSubagentTasks: jest.fn().mockResolvedValue(undefined),
quiesceUserSchedules: jest.fn().mockResolvedValue(true),
restoreUserSchedulesFromDeletion: jest.fn().mockResolvedValue(undefined),
generationJobs: {
getAccountCleanupJobIdsForUser: jest.fn().mockResolvedValue([]),
getCleanupJob: jest.fn().mockResolvedValue(undefined),
abortJob: jest.fn().mockResolvedValue(null),
},
...overrides,
};
}

describe('createAdminUsersHandlers', () => {
beforeEach(() => {
jest.mocked(eraseUserData).mockClear();
});

describe('listUsers', () => {
it('returns paginated users with total count', async () => {
const users = [
Expand Down Expand Up @@ -462,32 +479,118 @@ describe('createAdminUsersHandlers', () => {
expect(deps.deletePasskeysByUser).toHaveBeenCalledWith(validUserId);
});

it("removes the user's stored credentials before the user", async () => {
const deps = createDeps();
it('runs the shared erasure cascade for the target before removing the user', async () => {
const checkpointer = { ttl: 60 };
const erasureDeps = { marker: true };
const deps = createDeps({
findUsers: jest.fn().mockResolvedValue([mockUser({ role: 'USER', tenantId: 'tenant-1' })]),
resolveErasure: jest.fn().mockResolvedValue({ deps: erasureDeps, checkpointer }),
});
const handlers = createAdminUsersHandlers(deps);
const { req, res, status } = createReqRes({ params: { id: validUserId } });

await handlers.deleteUser(req, res);

expect(status).toHaveBeenCalledWith(200);
expect(deps.deleteTokens).toHaveBeenCalledWith({ userId: validUserId });
expect(jest.mocked(deps.deleteTokens).mock.invocationCallOrder[0]).toBeLessThan(
expect(deps.resolveErasure).toHaveBeenCalledWith(
expect.objectContaining({
id: validUserId,
role: 'USER',
tenantId: 'tenant-1',
idOnTheSource: null,
profile: expect.objectContaining({ role: 'USER' }),
}),
);
expect(eraseUserData).toHaveBeenCalledWith(
{ userId: validUserId, tenantId: 'tenant-1', checkpointer },
erasureDeps,
);
expect(jest.mocked(eraseUserData).mock.invocationCallOrder[0]).toBeLessThan(
jest.mocked(deps.deleteUserById).mock.invocationCallOrder[0],
);
});

it('keeps the user, and releases the fence, when its credentials cannot be removed', async () => {
it('keeps the user, and releases the fence, when the erasure cascade fails', async () => {
jest.mocked(eraseUserData).mockRejectedValueOnce(new Error('storage down'));
const deps = createDeps();
const handlers = createAdminUsersHandlers(deps);
const { req, res, status, json } = createReqRes({ params: { id: validUserId } });

await handlers.deleteUser(req, res);

expect(status).toHaveBeenCalledWith(500);
expect(json).toHaveBeenCalledWith({ error: 'Failed to delete user' });
expect(deps.deleteUserById).not.toHaveBeenCalled();
expect(deps.cancelAgentTriggerUserDeletion).toHaveBeenCalled();
expect(deps.cancelAgentTriggerUserPurge).toHaveBeenCalled();
});

it('suspends schedules before erasing and restores them when the deletion fails', async () => {
jest.mocked(eraseUserData).mockRejectedValueOnce(new Error('storage down'));
const deps = createDeps();
const handlers = createAdminUsersHandlers(deps);
const { req, res, status } = createReqRes({ params: { id: validUserId } });

await handlers.deleteUser(req, res);

const token = jest.mocked(deps.quiesceUserSchedules).mock.calls[0][1];
expect(status).toHaveBeenCalledWith(500);
expect(deps.restoreUserSchedulesFromDeletion).toHaveBeenCalledWith(validUserId, token);
expect(
jest.mocked(deps.restoreUserSchedulesFromDeletion).mock.invocationCallOrder[0],
).toBeLessThan(jest.mocked(deps.cancelAgentTriggerUserDeletion).mock.invocationCallOrder[0]);
});

it('takes the trigger deletion fence before listing generations to stop', async () => {
const deps = createDeps();
const handlers = createAdminUsersHandlers(deps);
const { req, res } = createReqRes({ params: { id: validUserId } });

await handlers.deleteUser(req, res);

expect(
jest.mocked(deps.beginAgentTriggerUserDeletion).mock.invocationCallOrder[0],
).toBeLessThan(
jest.mocked(deps.generationJobs.getAccountCleanupJobIdsForUser).mock.invocationCallOrder[0],
);
});

it('does not erase when schedules cannot be confirmed stopped', async () => {
const deps = createDeps({ quiesceUserSchedules: jest.fn().mockResolvedValue(false) });
const handlers = createAdminUsersHandlers(deps);
const { req, res, status } = createReqRes({ params: { id: validUserId } });

await handlers.deleteUser(req, res);

expect(status).toHaveBeenCalledWith(500);
expect(eraseUserData).not.toHaveBeenCalled();
expect(deps.deleteUserById).not.toHaveBeenCalled();
});

it('aborts the cleanup jobs of the target and refuses to erase when a stop is unconfirmed', async () => {
const abortJob = jest.fn().mockResolvedValue(null);
const deps = createDeps({
deleteTokens: jest.fn().mockRejectedValue(new Error('mongo down')),
generationJobs: {
getAccountCleanupJobIdsForUser: jest.fn().mockResolvedValue(['s1', 's2']),
getCleanupJob: jest.fn(async (id: string) => ({
createdAt: 5,
metadata: { userId: id === 's1' ? validUserId : 'someone-else' },
})),
abortJob,
} as unknown as AdminUsersDeps['generationJobs'],
});
const handlers = createAdminUsersHandlers(deps);
const { req, res, status } = createReqRes({ params: { id: validUserId } });

await handlers.deleteUser(req, res);

expect(abortJob).toHaveBeenCalledTimes(1);
expect(abortJob).toHaveBeenCalledWith('s1', {
expectedCreatedAt: 5,
awaitProviderDrain: true,
});
expect(status).toHaveBeenCalledWith(500);
expect(deps.deleteUserById).not.toHaveBeenCalled();
expect(deps.cancelAgentTriggerUserDeletion).toHaveBeenCalled();
expect(eraseUserData).not.toHaveBeenCalled();
});

it('returns success even when cascade cleanup partially fails', async () => {
Expand Down
Loading
Loading