diff --git a/api/server/controllers/agents/v1.spec.js b/api/server/controllers/agents/v1.spec.js index f111233735..7fc941f83c 100644 --- a/api/server/controllers/agents/v1.spec.js +++ b/api/server/controllers/agents/v1.spec.js @@ -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 () => { diff --git a/api/server/routes/agents/__tests__/management.spec.js b/api/server/routes/agents/__tests__/management.spec.js index 3015bef0bd..3a022079b5 100644 --- a/api/server/routes/agents/__tests__/management.spec.js +++ b/api/server/routes/agents/__tests__/management.spec.js @@ -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)); + }); }); diff --git a/api/server/routes/agents/management.js b/api/server/routes/agents/management.js index 5aac27959e..f44bd8721e 100644 --- a/api/server/routes/agents/management.js +++ b/api/server/routes/agents/management.js @@ -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'); diff --git a/packages/api/src/agents/deletion.spec.ts b/packages/api/src/agents/deletion.spec.ts new file mode 100644 index 0000000000..62460b7a87 --- /dev/null +++ b/packages/api/src/agents/deletion.spec.ts @@ -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 { + 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 { + 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' }, + }); + }); +}); diff --git a/packages/api/src/agents/deletion.ts b/packages/api/src/agents/deletion.ts new file mode 100644 index 0000000000..49f0d4119b --- /dev/null +++ b/packages/api/src/agents/deletion.ts @@ -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; + getAgentWithVersionCount: (search: { + id: string; + tenantId: string; + }) => Promise; + checkPermission: (params: { + userId: string; + role?: string; + resourceType: ResourceType; + resourceId: Types.ObjectId; + requiredPermission: PermissionBits; + }) => Promise; + hasCapability: (user: IUser, capability: SystemCapability) => Promise; + deleteAgent: (search: { id: string; tenantId: string }) => Promise; +} + +function sendError(res: Response, code: Parameters[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 { + return async function deleteAgent(req: Request, res: Response): Promise { + 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'); + } + }; +} diff --git a/packages/api/src/agents/index.ts b/packages/api/src/agents/index.ts index 0e8d701956..7342529123 100644 --- a/packages/api/src/agents/index.ts +++ b/packages/api/src/agents/index.ts @@ -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'; diff --git a/packages/api/src/agents/management.spec.ts b/packages/api/src/agents/management.spec.ts index 9c89adb1d6..ebc3bc1f90 100644 --- a/packages/api/src/agents/management.spec.ts +++ b/packages/api/src/agents/management.spec.ts @@ -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); diff --git a/packages/api/src/agents/management.ts b/packages/api/src/agents/management.ts index 440e9af445..10be8e113a 100644 --- a/packages/api/src/agents/management.ts +++ b/packages/api/src/agents/management.ts @@ -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 = z + .object({ + id: z.string().min(1), + deleted: z.literal(true), + }) + .strict(); + export const agentManagementErrorCodeSchema: z.ZodType = z.enum([ 'invalid_request', 'not_found', diff --git a/packages/data-schemas/src/methods/agent.ts b/packages/data-schemas/src/methods/agent.ts index a398465dd2..13706a7782 100644 --- a/packages/data-schemas/src/methods/agent.ts +++ b/packages/data-schemas/src/methods/agent.ts @@ -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, agentIds: string[]): Promise { +/** Removes deleted agent references from active graphs in the requested tenant. */ +async function removeAgentIdsFromEdges( + Agent: Model, + agentIds: string[], + tenantId?: string, +): Promise { 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; 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; const User = mongoose.models.User as Model; - 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 } } }, ); }