🪓 feat: Add Tenant-Scoped Agent Deletion (#15573)

* feat: add Agent Management delete endpoint

* fix: scope agent deletion cleanup by tenant
This commit is contained in:
Dustin Healy 2026-09-04 06:51:12 -07:00 • committed by GitHub
parent 6510908329
commit 5840e2f2ae
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 485 additions and 17 deletions

View file

@ -104,6 +104,7 @@ const {
CONTENT_TRAVERSAL_MAX_DEPTH,
createAgentManagementAuth,
createAgentManagementCreateHandler,
createAgentManagementDeleteHandler,
createAgentManagementReadHandlers,
createAgentManagementUpdateHandler,
mergeDeploymentSkillIds,
@ -436,12 +437,17 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
getRoleByName,
createAgent: createAgentHandler,
});
const checkEditPermission = async ({ userId: accessibleUserId, resourceType, resourceId }) =>
const checkAgentPermission = async ({
userId: accessibleUserId,
resourceType,
resourceId,
requiredPermission,
}) =>
(await AclEntry.exists({
principalId: accessibleUserId,
resourceType,
resourceId,
permBits: { $bitsAllSet: PermissionBits.EDIT },
permBits: { $bitsAllSet: requiredPermission },
})) != null;
const hasCapability = jest.fn().mockResolvedValue(false);
const reads = createAgentManagementReadHandlers({
@ -454,16 +460,23 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
resourceType,
permBits: { $bitsAllSet: PermissionBits.EDIT },
}),
checkPermission: checkEditPermission,
checkPermission: checkAgentPermission,
hasCapability,
});
const update = createAgentManagementUpdateHandler({
getRoleByName,
getAgentWithVersionCount: db.getAgentWithVersionCount,
checkPermission: checkEditPermission,
checkPermission: checkAgentPermission,
hasCapability,
updateAgent: updateAgentHandler,
});
const remove = createAgentManagementDeleteHandler({
getRoleByName,
getAgentWithVersionCount: db.getAgentWithVersionCount,
checkPermission: checkAgentPermission,
hasCapability,
deleteAgent: db.deleteAgent,
});
const app = express();
app.use(express.json());
app.use('/api/agents/v1/agents', auth);
@ -477,6 +490,7 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
req.config = {};
return update(req, res);
});
app.delete('/api/agents/v1/agents/:id', remove);
const createdResponse = await request(app)
.post('/api/agents/v1/agents')
@ -517,14 +531,47 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
expect(listedResponse.body.data).toEqual([createdResponse.body]);
const otherTenantId = `tenant-${nanoid(8)}`;
await tenantStorage.run({ tenantId: otherTenantId }, () =>
Agent.create({
id: createdResponse.body.id,
name: 'Other tenant Agent',
const currentTenantGraphId = `agent_${nanoid()}`;
const otherTenantGraphId = `agent_${nanoid()}`;
await tenantStorage.run({ tenantId }, async () => {
await Agent.create({
id: currentTenantGraphId,
name: 'Current tenant graph',
provider: 'openai',
model: 'gpt-4',
author: new mongoose.Types.ObjectId(),
}),
author: principal._id,
edges: [{ from: '', to: createdResponse.body.id, edgeType: 'handoff' }],
});
await User.updateOne(
{ _id: principal._id },
{ $set: { favorites: [{ agentId: createdResponse.body.id }] } },
);
});
const otherTenantPrincipal = await tenantStorage.run(
{ tenantId: otherTenantId },
async () => {
const owner = await createOwner();
await Agent.create({
id: createdResponse.body.id,
name: 'Other tenant Agent',
provider: 'openai',
model: 'gpt-4',
author: owner._id,
});
await Agent.create({
id: otherTenantGraphId,
name: 'Other tenant graph',
provider: 'openai',
model: 'gpt-4',
author: owner._id,
edges: [{ from: '', to: createdResponse.body.id, edgeType: 'handoff' }],
});
await User.updateOne(
{ _id: owner._id },
{ $set: { favorites: [{ agentId: createdResponse.body.id }] } },
);
return owner;
},
);
const updatedResponse = await request(app)
@ -580,6 +627,55 @@ describe('Agent Controllers - Mass Assignment Protection', () => {
resourceType: ResourceType.AGENT,
}),
);
const deletedResponse = await request(app)
.delete(`/api/agents/v1/agents/${createdResponse.body.id}`)
.set('Authorization', 'Bearer valid-token');
expect(deletedResponse.status).toBe(200);
expect(deletedResponse.body).toEqual({ id: createdResponse.body.id, deleted: true });
const [retrievedAfterDelete, repeatedDelete] = await Promise.all([
request(app)
.get(`/api/agents/v1/agents/${createdResponse.body.id}`)
.set('Authorization', 'Bearer valid-token'),
request(app)
.delete(`/api/agents/v1/agents/${createdResponse.body.id}`)
.set('Authorization', 'Bearer valid-token'),
]);
expect(retrievedAfterDelete.status).toBe(404);
expect(repeatedDelete.status).toBe(404);
await expect(Agent.exists({ id: createdResponse.body.id, tenantId })).resolves.toBeNull();
await expect(
Agent.exists({ id: createdResponse.body.id, tenantId: otherTenantId }),
).resolves.not.toBeNull();
await expect(
Agent.exists({
id: currentTenantGraphId,
tenantId,
'edges.to': createdResponse.body.id,
}),
).resolves.toBeNull();
await expect(
Agent.exists({
id: otherTenantGraphId,
tenantId: otherTenantId,
'edges.to': createdResponse.body.id,
}),
).resolves.not.toBeNull();
await expect(
User.exists({
_id: principal._id,
tenantId,
'favorites.agentId': createdResponse.body.id,
}),
).resolves.toBeNull();
await expect(
User.exists({
_id: otherTenantPrincipal._id,
tenantId: otherTenantId,
'favorites.agentId': createdResponse.body.id,
}),
).resolves.not.toBeNull();
});
test('management creation rejects caller-controlled ownership', async () => {

View file

@ -23,6 +23,14 @@ const mockCreateAgentManagementUpdateHandler = jest.fn((deps) => {
mockUpdateDeps = deps;
return mockUpdate;
});
const mockDelete = jest.fn((_req, res) =>
res.status(200).json({ id: 'agent-deleted', deleted: true }),
);
let mockDeleteDeps;
const mockCreateAgentManagementDeleteHandler = jest.fn((deps) => {
mockDeleteDeps = deps;
return mockDelete;
});
const mockBrowserCreate = jest.fn();
const mockBrowserUpdate = jest.fn();
const mockCheckBan = jest.fn((_req, _res, next) => next());
@ -43,6 +51,7 @@ jest.mock('../middleware', () => ({
jest.mock('@librechat/api', () => ({
mapAgentManagementError: mockMapAgentManagementError,
createAgentManagementCreateHandler: mockCreateAgentManagementCreateHandler,
createAgentManagementDeleteHandler: mockCreateAgentManagementDeleteHandler,
createAgentManagementReadHandlers: mockCreateAgentManagementReadHandlers,
createAgentManagementUpdateHandler: mockCreateAgentManagementUpdateHandler,
}));
@ -64,6 +73,7 @@ jest.mock('~/models', () => ({
getRoleByName: jest.fn(),
getAgentWithVersionCount: jest.fn(),
getAgentManagementListByAccess: jest.fn(),
deleteAgent: jest.fn(),
}));
const router = require('../management');
@ -149,4 +159,19 @@ describe('Agent Management route boundary', () => {
expect(mockUpdateDeps.hasCapability).toEqual(expect.any(Function));
expect(mockUpdateDeps.updateAgent).toBe(mockBrowserUpdate);
});
it('dispatches authenticated deletion requests with the shared Agent dependencies', async () => {
const response = await request(app)
.delete('/api/agents/v1/agents/agent-deleted')
.set('Authorization', 'Bearer valid-token');
expect(response.status).toBe(200);
expect(response.body).toEqual({ id: 'agent-deleted', deleted: true });
expect(mockDelete).toHaveBeenCalledTimes(1);
expect(mockDeleteDeps.getRoleByName).toEqual(expect.any(Function));
expect(mockDeleteDeps.getAgentWithVersionCount).toEqual(expect.any(Function));
expect(mockDeleteDeps.checkPermission).toEqual(expect.any(Function));
expect(mockDeleteDeps.hasCapability).toEqual(expect.any(Function));
expect(mockDeleteDeps.deleteAgent).toEqual(expect.any(Function));
});
});

View file

@ -1,6 +1,7 @@
const express = require('express');
const {
createAgentManagementCreateHandler,
createAgentManagementDeleteHandler,
createAgentManagementReadHandlers,
createAgentManagementUpdateHandler,
mapAgentManagementError,
@ -32,6 +33,13 @@ const updateHandler = createAgentManagementUpdateHandler({
hasCapability,
updateAgent: v1.updateAgent,
});
const deleteHandler = createAgentManagementDeleteHandler({
getRoleByName: db.getRoleByName,
getAgentWithVersionCount: db.getAgentWithVersionCount,
checkPermission,
hasCapability,
deleteAgent: db.deleteAgent,
});
router.use(requireAgentManagementAuth);
router.use(checkBan);
@ -40,6 +48,7 @@ router.post('/', configMiddleware, createHandler);
router.get('/', readHandlers.list);
router.get('/:id', readHandlers.get);
router.patch('/:id', configMiddleware, updateHandler);
router.delete('/:id', deleteHandler);
router.use((_req, res) => {
const { status, body } = mapAgentManagementError('not_found');

View file

@ -0,0 +1,187 @@
import { Types } from 'mongoose';
import {
PermissionBits,
Permissions,
PermissionTypes,
ResourceType,
} from 'librechat-data-provider';
import type { IAgent, IRole, IUser } from '@librechat/data-schemas';
import type { Request, Response } from 'express';
import type { AgentManagementDeleteDeps } from './deletion';
import { createAgentManagementDeleteHandler } from './deletion';
jest.mock('@librechat/data-schemas', () => {
const actual = jest.requireActual('@librechat/data-schemas');
return {
...actual,
logger: { warn: jest.fn(), error: jest.fn() },
};
});
const user = {
id: new Types.ObjectId().toString(),
tenantId: 'tenant-a',
role: 'USER',
} as IUser;
const objectId = new Types.ObjectId();
const existingAgent = {
_id: objectId,
id: 'agent-existing',
name: 'Existing Agent',
provider: 'openAI',
model: 'gpt-5',
};
function makeRequest(overrides: Partial<Request> = {}): Request {
return {
user,
params: { id: existingAgent.id },
...overrides,
} as Request;
}
function makeResponse(): Response {
const response = {} as Response;
response.status = jest.fn(() => response);
response.json = jest.fn(() => response);
return response;
}
function makeDeps(overrides: Partial<AgentManagementDeleteDeps> = {}): AgentManagementDeleteDeps {
return {
getRoleByName: jest.fn().mockResolvedValue({
permissions: {
[PermissionTypes.AGENTS]: {
[Permissions.USE]: true,
[Permissions.CREATE]: true,
},
},
} as IRole),
getAgentWithVersionCount: jest.fn().mockResolvedValue(existingAgent),
checkPermission: jest.fn().mockResolvedValue(true),
hasCapability: jest.fn().mockResolvedValue(false),
deleteAgent: jest.fn().mockResolvedValue(existingAgent as IAgent),
...overrides,
};
}
describe('Agent Management delete handler', () => {
it('deletes the tenant-scoped Agent and returns a minimal tombstone', async () => {
const deps = makeDeps();
const response = makeResponse();
await createAgentManagementDeleteHandler(deps)(makeRequest(), response);
expect(deps.getAgentWithVersionCount).toHaveBeenCalledWith({
id: existingAgent.id,
tenantId: user.tenantId,
});
expect(deps.checkPermission).toHaveBeenCalledWith({
userId: user.id,
role: user.role,
resourceType: ResourceType.AGENT,
resourceId: objectId,
requiredPermission: PermissionBits.DELETE,
});
expect(deps.deleteAgent).toHaveBeenCalledWith({
id: existingAgent.id,
tenantId: user.tenantId,
});
expect(response.status).toHaveBeenCalledWith(200);
expect(response.json).toHaveBeenCalledWith({ id: existingAgent.id, deleted: true });
});
it('requires the same AGENTS USE and CREATE permissions as browser deletion', async () => {
const deps = makeDeps({
getRoleByName: jest.fn().mockResolvedValue({
permissions: {
[PermissionTypes.AGENTS]: {
[Permissions.USE]: true,
[Permissions.CREATE]: false,
},
},
} as IRole),
});
const response = makeResponse();
await createAgentManagementDeleteHandler(deps)(makeRequest(), response);
expect(response.status).toHaveBeenCalledWith(403);
expect(deps.getAgentWithVersionCount).not.toHaveBeenCalled();
expect(deps.deleteAgent).not.toHaveBeenCalled();
});
it('fails closed without a tenant-bound authenticated user', async () => {
const deps = makeDeps();
const response = makeResponse();
await createAgentManagementDeleteHandler(deps)(
makeRequest({ user: { ...user, tenantId: undefined } as IUser }),
response,
);
expect(response.status).toHaveBeenCalledWith(403);
expect(deps.getRoleByName).not.toHaveBeenCalled();
expect(deps.deleteAgent).not.toHaveBeenCalled();
});
it('does not disclose or delete an Agent outside the authenticated tenant', async () => {
const deps = makeDeps({ getAgentWithVersionCount: jest.fn().mockResolvedValue(null) });
const response = makeResponse();
await createAgentManagementDeleteHandler(deps)(makeRequest(), response);
expect(response.status).toHaveBeenCalledWith(404);
expect(deps.checkPermission).not.toHaveBeenCalled();
expect(deps.deleteAgent).not.toHaveBeenCalled();
});
it('requires DELETE permission on the tenant-scoped Agent', async () => {
const deps = makeDeps({ checkPermission: jest.fn().mockResolvedValue(false) });
const response = makeResponse();
await createAgentManagementDeleteHandler(deps)(makeRequest(), response);
expect(response.status).toHaveBeenCalledWith(403);
expect(deps.deleteAgent).not.toHaveBeenCalled();
});
it('preserves the existing manage-agents capability bypass', async () => {
const deps = makeDeps({
hasCapability: jest.fn().mockResolvedValue(true),
checkPermission: jest.fn().mockResolvedValue(false),
});
const response = makeResponse();
await createAgentManagementDeleteHandler(deps)(makeRequest(), response);
expect(deps.checkPermission).not.toHaveBeenCalled();
expect(deps.deleteAgent).toHaveBeenCalled();
});
it('returns not found if the Agent disappears before the atomic delete', async () => {
const deps = makeDeps({ deleteAgent: jest.fn().mockResolvedValue(null) });
const response = makeResponse();
await createAgentManagementDeleteHandler(deps)(makeRequest(), response);
expect(response.status).toHaveBeenCalledWith(404);
expect(response.json).toHaveBeenCalledWith({
error: { code: 'not_found', message: 'Agent not found' },
});
});
it('does not expose errors thrown by the shared deletion path', async () => {
const deps = makeDeps({
deleteAgent: jest.fn().mockRejectedValue(new Error('database connection secret')),
});
const response = makeResponse();
await createAgentManagementDeleteHandler(deps)(makeRequest(), response);
expect(response.status).toHaveBeenCalledWith(500);
expect(response.json).toHaveBeenCalledWith({
error: { code: 'internal_error', message: 'Internal server error' },
});
});
});

View file

@ -0,0 +1,111 @@
import { logger, ResourceCapabilityMap } from '@librechat/data-schemas';
import {
PermissionBits,
Permissions,
PermissionTypes,
ResourceType,
} from 'librechat-data-provider';
import type { IAgent, IRole, IUser, SystemCapability } from '@librechat/data-schemas';
import type { Request, Response } from 'express';
import type { Types } from 'mongoose';
import type { AgentManagementProjectionSource } from './management';
import { agentManagementDeleteResponseSchema, mapAgentManagementError } from './management';
import { checkAccessWithRequestCache } from '../middleware/access';
type AgentManagementRecord = AgentManagementProjectionSource & { _id: Types.ObjectId };
export interface AgentManagementDeleteDeps {
getRoleByName: (roleName: string, fieldsToSelect?: string | string[]) => Promise<IRole | null>;
getAgentWithVersionCount: (search: {
id: string;
tenantId: string;
}) => Promise<AgentManagementRecord | null>;
checkPermission: (params: {
userId: string;
role?: string;
resourceType: ResourceType;
resourceId: Types.ObjectId;
requiredPermission: PermissionBits;
}) => Promise<boolean>;
hasCapability: (user: IUser, capability: SystemCapability) => Promise<boolean>;
deleteAgent: (search: { id: string; tenantId: string }) => Promise<IAgent | null>;
}
function sendError(res: Response, code: Parameters<typeof mapAgentManagementError>[0]) {
const mapped = mapAgentManagementError(code);
return res.status(mapped.status).json(mapped.body);
}
async function hasManageAgentsCapability(user: IUser, deps: AgentManagementDeleteDeps) {
const capability = ResourceCapabilityMap[ResourceType.AGENT];
try {
return capability != null && (await deps.hasCapability(user, capability));
} catch (error) {
logger.warn(
`[AgentManagement] Agent capability check failed, denying bypass: ${(error as Error).message}`,
);
return false;
}
}
/** Authorize a tenant-scoped deletion, then reuse the Agent model's existing cleanup path. */
export function createAgentManagementDeleteHandler(
deps: AgentManagementDeleteDeps,
): (req: Request, res: Response) => Promise<Response> {
return async function deleteAgent(req: Request, res: Response): Promise<Response> {
try {
const user = req.user as IUser | undefined;
if (!user?.id || !user.tenantId) {
return sendError(res, 'permission_denied');
}
const canDelete = await checkAccessWithRequestCache({
req,
user,
permissionType: PermissionTypes.AGENTS,
permissions: [Permissions.USE, Permissions.CREATE],
getRoleByName: deps.getRoleByName,
});
if (!canDelete) {
return sendError(res, 'permission_denied');
}
const agent = await deps.getAgentWithVersionCount({
id: req.params.id,
tenantId: user.tenantId,
});
if (!agent) {
return sendError(res, 'not_found');
}
const canManageAll = await hasManageAgentsCapability(user, deps);
if (
!canManageAll &&
!(await deps.checkPermission({
userId: user.id,
role: user.role,
resourceType: ResourceType.AGENT,
resourceId: agent._id,
requiredPermission: PermissionBits.DELETE,
}))
) {
return sendError(res, 'permission_denied');
}
const deleted = await deps.deleteAgent({ id: req.params.id, tenantId: user.tenantId });
if (!deleted) {
return sendError(res, 'not_found');
}
return res.status(200).json(
agentManagementDeleteResponseSchema.parse({
id: req.params.id,
deleted: true,
}),
);
} catch (error) {
logger.error('[AgentManagement] Error deleting Agent', error);
return sendError(res, 'internal_error');
}
};
}

View file

@ -12,6 +12,7 @@ export * from './context';
export * from './control';
export * from './conversation';
export * from './creates';
export * from './deletion';
export * from './discovery';
export * from './edges';
export * from './errors';

View file

@ -2,6 +2,7 @@ import { z } from 'zod';
import { MAX_SUBAGENTS, setMaxSubagents } from 'librechat-data-provider';
import {
agentManagementCreateSchema,
agentManagementDeleteResponseSchema,
agentManagementListResponseSchema,
agentManagementListSchema,
agentManagementResponseSchema,
@ -167,6 +168,19 @@ describe('Agent Management contract', () => {
});
describe('responses', () => {
it('validates the minimal deletion tombstone', () => {
expect(
agentManagementDeleteResponseSchema.parse({ id: 'agent_public_id', deleted: true }),
).toEqual({ id: 'agent_public_id', deleted: true });
expect(
agentManagementDeleteResponseSchema.safeParse({
id: 'agent_public_id',
deleted: true,
tenantId: 'tenant-secret',
}).success,
).toBe(false);
});
it('allowlists supported configuration and stable metadata', () => {
const response = projectAgentManagementResponse(persistedAgent);

View file

@ -69,6 +69,10 @@ export type AgentManagementListResponse = {
has_more: boolean;
after: string | null;
};
export type AgentManagementDeleteResponse = {
id: string;
deleted: true;
};
export type AgentManagementErrorCode =
| 'invalid_request'
| 'not_found'
@ -196,6 +200,14 @@ export const agentManagementListResponseSchema: z.ZodType<AgentManagementListRes
}
});
/** Minimal tombstone returned after an Agent is successfully deleted. */
export const agentManagementDeleteResponseSchema: z.ZodType<AgentManagementDeleteResponse> = z
.object({
id: z.string().min(1),
deleted: z.literal(true),
})
.strict();
export const agentManagementErrorCodeSchema: z.ZodType<AgentManagementErrorCode> = z.enum([
'invalid_request',
'not_found',

View file

@ -93,14 +93,19 @@ function createEdgeCleanupPipeline(agentIds: string[]): PipelineStage[] {
];
}
/** Removes deleted agent references from every active graph that contains them. */
async function removeAgentIdsFromEdges(Agent: Model<IAgent>, agentIds: string[]): Promise<void> {
/** Removes deleted agent references from active graphs in the requested tenant. */
async function removeAgentIdsFromEdges(
Agent: Model<IAgent>,
agentIds: string[],
tenantId?: string,
): Promise<void> {
if (agentIds.length === 0) {
return;
}
await Agent.updateMany(
{
...(tenantId !== undefined ? { tenantId } : {}),
$or: [{ 'edges.from': { $in: agentIds } }, { 'edges.to': { $in: agentIds } }],
},
createEdgeCleanupPipeline(agentIds),
@ -1063,6 +1068,7 @@ export function createAgentMethods(
const User = mongoose.models.User as Model<unknown>;
const agent = await Agent.findOneAndDelete(searchParameter);
if (agent) {
const deletedAgent = agent as unknown as { id: string; tenantId?: string };
await Promise.all([
removeAllPermissions({
resourceType: ResourceType.AGENT,
@ -1074,14 +1080,17 @@ export function createAgentMethods(
}),
]);
try {
await removeAgentIdsFromEdges(Agent, [(agent as unknown as { id: string }).id]);
await removeAgentIdsFromEdges(Agent, [deletedAgent.id], deletedAgent.tenantId);
} catch (error) {
logger.error('[deleteAgent] Error removing agent from handoff edges', error);
}
try {
await User.updateMany(
{ 'favorites.agentId': (agent as unknown as { id: string }).id },
{ $pull: { favorites: { agentId: (agent as unknown as { id: string }).id } } },
{
...(deletedAgent.tenantId !== undefined ? { tenantId: deletedAgent.tenantId } : {}),
'favorites.agentId': deletedAgent.id,
},
{ $pull: { favorites: { agentId: deletedAgent.id } } },
);
} catch (error) {
logger.error('[deleteAgent] Error removing agent from user favorites', error);
@ -1452,13 +1461,17 @@ export function createAgentMethods(
const Agent = mongoose.models.Agent as Model<IAgent>;
const User = mongoose.models.User as Model<unknown>;
const agent = await Agent.findOne({ _id: resourceId }, { id: 1 }).lean();
const agent = await Agent.findOne({ _id: resourceId }, { id: 1, tenantId: 1 }).lean();
if (!agent) {
return;
}
await User.updateMany(
{ _id: { $in: userIds }, 'favorites.agentId': agent.id },
{
_id: { $in: userIds },
...(agent.tenantId !== undefined ? { tenantId: agent.tenantId } : {}),
'favorites.agentId': agent.id,
},
{ $pull: { favorites: { agentId: agent.id } } },
);
}