From 8dc6d60750df434682a9f519ec944529c470dd7e Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sun, 15 Mar 2026 17:12:45 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20fix:=20Enforce=20MULTI?= =?UTF-8?q?=5FCONVO=20and=20agent=20ACL=20checks=20on=20addedConvo=20(#122?= =?UTF-8?q?43)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 🛡️ fix: Enforce MULTI_CONVO and agent ACL checks on addedConvo addedConvo.agent_id was passed through to loadAddedAgent without any permission check, enabling an authenticated user to load and execute another user's private agent via the parallel multi-convo feature. The middleware now chains a checkAddedConvoAccess gate after the primary agent check: when req.body.addedConvo is present it verifies the user has MULTI_CONVO:USE role permission, and when the addedConvo agent_id is a real (non-ephemeral) agent it runs the same canAccessResource ACL check used for the primary agent. * refactor: Harden addedConvo middleware and avoid duplicate agent fetch - Convert checkAddedConvoAccess to curried factory matching Express middleware signature: (requiredPermission) => (req, res, next) - Call checkPermission directly for the addedConvo agent instead of routing through canAccessResource's tempReq pattern; this avoids orphaning the resolved agent document and enables caching it on req.resolvedAddedAgent for downstream loadAddedAgent - Update loadAddedAgent to use req.resolvedAddedAgent when available, eliminating a duplicate getAgent DB call per chat request - Validate addedConvo is a plain object and agent_id is a string before passing to isEphemeralAgentId (prevents TypeError on object injection, returns 400-equivalent early exit instead of 500) - Fix JSDoc: "VIEW access" → "same permission as primary agent", add @param/@returns to helpers, restore @example on factory - Fix redundant return await in resolveAgentIdFromBody * test: Add canAccessAgentFromBody spec covering IDOR fix 26 integration tests using MongoMemoryServer with real models, ACL entries, and PermissionService — no mocks for core logic. Covered paths: - Factory validation (requiredPermission type check) - Primary agent: missing agent_id, ephemeral, non-agents endpoint - addedConvo absent / invalid shape (string, array, object injection) - MULTI_CONVO:USE gate: denied, missing role, ADMIN bypass - Agent resource ACL: no ACL → 403, insufficient bits → 403, nonexistent agent → 404, valid ACL → next + cached on req - End-to-end: both real agents, primary denied short-circuits, ephemeral primary + real addedConvo --- api/models/loadAddedAgent.js | 12 +- .../accessResources/canAccessAgentFromBody.js | 156 ++++-- .../canAccessAgentFromBody.spec.js | 509 ++++++++++++++++++ 3 files changed, 637 insertions(+), 40 deletions(-) create mode 100644 api/server/middleware/accessResources/canAccessAgentFromBody.spec.js diff --git a/api/models/loadAddedAgent.js b/api/models/loadAddedAgent.js index aa83375eae..101ee96685 100644 --- a/api/models/loadAddedAgent.js +++ b/api/models/loadAddedAgent.js @@ -48,14 +48,14 @@ const loadAddedAgent = async ({ req, conversation, primaryAgent }) => { return null; } - // If there's an agent_id, load the existing agent if (conversation.agent_id && !isEphemeralAgentId(conversation.agent_id)) { - if (!getAgent) { - throw new Error('getAgent not initialized - call setGetAgent first'); + let agent = req.resolvedAddedAgent; + if (!agent) { + if (!getAgent) { + throw new Error('getAgent not initialized - call setGetAgent first'); + } + agent = await getAgent({ id: conversation.agent_id }); } - const agent = await getAgent({ - id: conversation.agent_id, - }); if (!agent) { logger.warn(`[loadAddedAgent] Agent ${conversation.agent_id} not found`); diff --git a/api/server/middleware/accessResources/canAccessAgentFromBody.js b/api/server/middleware/accessResources/canAccessAgentFromBody.js index f8112af14d..572a86f5e5 100644 --- a/api/server/middleware/accessResources/canAccessAgentFromBody.js +++ b/api/server/middleware/accessResources/canAccessAgentFromBody.js @@ -1,42 +1,144 @@ const { logger } = require('@librechat/data-schemas'); const { Constants, + Permissions, ResourceType, + SystemRoles, + PermissionTypes, isAgentsEndpoint, isEphemeralAgentId, } = require('librechat-data-provider'); +const { checkPermission } = require('~/server/services/PermissionService'); const { canAccessResource } = require('./canAccessResource'); +const { getRoleByName } = require('~/models/Role'); const { getAgent } = require('~/models/Agent'); /** - * Agent ID resolver function for agent_id from request body - * Resolves custom agent ID (e.g., "agent_abc123") to MongoDB ObjectId - * This is used specifically for chat routes where agent_id comes from request body - * + * Resolves custom agent ID (e.g., "agent_abc123") to a MongoDB document. * @param {string} agentCustomId - Custom agent ID from request body - * @returns {Promise} Agent document with _id field, or null if not found + * @returns {Promise} Agent document with _id field, or null if ephemeral/not found */ const resolveAgentIdFromBody = async (agentCustomId) => { - // Handle ephemeral agents - they don't need permission checks - // Real agent IDs always start with "agent_", so anything else is ephemeral if (isEphemeralAgentId(agentCustomId)) { - return null; // No permission check needed for ephemeral agents + return null; } - - return await getAgent({ id: agentCustomId }); + return getAgent({ id: agentCustomId }); }; /** - * Middleware factory that creates middleware to check agent access permissions from request body. - * This middleware is specifically designed for chat routes where the agent_id comes from req.body - * instead of route parameters. + * Creates a `canAccessResource` middleware for the given agent ID + * and chains to the provided continuation on success. + * + * @param {string} agentId - The agent's custom string ID (e.g., "agent_abc123") + * @param {number} requiredPermission - Permission bit(s) required + * @param {import('express').Request} req + * @param {import('express').Response} res - Written on deny; continuation called on allow + * @param {Function} continuation - Called when the permission check passes + * @returns {Promise} + */ +const checkAgentResourceAccess = (agentId, requiredPermission, req, res, continuation) => { + const middleware = canAccessResource({ + resourceType: ResourceType.AGENT, + requiredPermission, + resourceIdParam: 'agent_id', + idResolver: () => resolveAgentIdFromBody(agentId), + }); + + const tempReq = { + ...req, + params: { ...req.params, agent_id: agentId }, + }; + + return middleware(tempReq, res, continuation); +}; + +/** + * Middleware factory that validates MULTI_CONVO:USE role permission and, when + * addedConvo.agent_id is a non-ephemeral agent, the same resource-level permission + * required for the primary agent (`requiredPermission`). Caches the resolved agent + * document on `req.resolvedAddedAgent` to avoid a duplicate DB fetch in `loadAddedAgent`. + * + * @param {number} requiredPermission - Permission bit(s) to check on the added agent resource + * @returns {(req: import('express').Request, res: import('express').Response, next: Function) => Promise} + */ +const checkAddedConvoAccess = (requiredPermission) => async (req, res, next) => { + const addedConvo = req.body?.addedConvo; + if (!addedConvo || typeof addedConvo !== 'object' || Array.isArray(addedConvo)) { + return next(); + } + + try { + if (!req.user?.role) { + return res.status(403).json({ + error: 'Forbidden', + message: 'Insufficient permissions for multi-conversation', + }); + } + + if (req.user.role !== SystemRoles.ADMIN) { + const role = await getRoleByName(req.user.role); + const hasMultiConvo = role?.permissions?.[PermissionTypes.MULTI_CONVO]?.[Permissions.USE]; + if (!hasMultiConvo) { + return res.status(403).json({ + error: 'Forbidden', + message: 'Multi-conversation feature is not enabled', + }); + } + } + + const addedAgentId = addedConvo.agent_id; + if (!addedAgentId || typeof addedAgentId !== 'string' || isEphemeralAgentId(addedAgentId)) { + return next(); + } + + if (req.user.role === SystemRoles.ADMIN) { + return next(); + } + + const agent = await resolveAgentIdFromBody(addedAgentId); + if (!agent) { + return res.status(404).json({ + error: 'Not Found', + message: `${ResourceType.AGENT} not found`, + }); + } + + const hasPermission = await checkPermission({ + userId: req.user.id, + role: req.user.role, + resourceType: ResourceType.AGENT, + resourceId: agent._id, + requiredPermission, + }); + + if (!hasPermission) { + return res.status(403).json({ + error: 'Forbidden', + message: `Insufficient permissions to access this ${ResourceType.AGENT}`, + }); + } + + req.resolvedAddedAgent = agent; + return next(); + } catch (error) { + logger.error('Failed to validate addedConvo access permissions', error); + return res.status(500).json({ + error: 'Internal Server Error', + message: 'Failed to validate addedConvo access permissions', + }); + } +}; + +/** + * Middleware factory that checks agent access permissions from request body. + * Validates both the primary agent_id and, when present, addedConvo.agent_id + * (which also requires MULTI_CONVO:USE role permission). * * @param {Object} options - Configuration options * @param {number} options.requiredPermission - The permission bit required (1=view, 2=edit, 4=delete, 8=share) * @returns {Function} Express middleware function * * @example - * // Basic usage for agent chat (requires VIEW permission) * router.post('/chat', * canAccessAgentFromBody({ requiredPermission: PermissionBits.VIEW }), * buildEndpointOption, @@ -46,11 +148,12 @@ const resolveAgentIdFromBody = async (agentCustomId) => { const canAccessAgentFromBody = (options) => { const { requiredPermission } = options; - // Validate required options if (!requiredPermission || typeof requiredPermission !== 'number') { throw new Error('canAccessAgentFromBody: requiredPermission is required and must be a number'); } + const addedConvoMiddleware = checkAddedConvoAccess(requiredPermission); + return async (req, res, next) => { try { const { endpoint, agent_id } = req.body; @@ -67,28 +170,13 @@ const canAccessAgentFromBody = (options) => { }); } - // Skip permission checks for ephemeral agents - // Real agent IDs always start with "agent_", so anything else is ephemeral + const afterPrimaryCheck = () => addedConvoMiddleware(req, res, next); + if (isEphemeralAgentId(agentId)) { - return next(); + return afterPrimaryCheck(); } - const agentAccessMiddleware = canAccessResource({ - resourceType: ResourceType.AGENT, - requiredPermission, - resourceIdParam: 'agent_id', // This will be ignored since we use custom resolver - idResolver: () => resolveAgentIdFromBody(agentId), - }); - - const tempReq = { - ...req, - params: { - ...req.params, - agent_id: agentId, - }, - }; - - return agentAccessMiddleware(tempReq, res, next); + return checkAgentResourceAccess(agentId, requiredPermission, req, res, afterPrimaryCheck); } catch (error) { logger.error('Failed to validate agent access permissions', error); return res.status(500).json({ diff --git a/api/server/middleware/accessResources/canAccessAgentFromBody.spec.js b/api/server/middleware/accessResources/canAccessAgentFromBody.spec.js new file mode 100644 index 0000000000..47f1130d13 --- /dev/null +++ b/api/server/middleware/accessResources/canAccessAgentFromBody.spec.js @@ -0,0 +1,509 @@ +const mongoose = require('mongoose'); +const { + ResourceType, + SystemRoles, + PrincipalType, + PrincipalModel, +} = require('librechat-data-provider'); +const { MongoMemoryServer } = require('mongodb-memory-server'); +const { canAccessAgentFromBody } = require('./canAccessAgentFromBody'); +const { User, Role, AclEntry } = require('~/db/models'); +const { createAgent } = require('~/models/Agent'); + +describe('canAccessAgentFromBody middleware', () => { + let mongoServer; + let req, res, next; + let testUser, otherUser; + + beforeAll(async () => { + mongoServer = await MongoMemoryServer.create(); + await mongoose.connect(mongoServer.getUri()); + }); + + afterAll(async () => { + await mongoose.disconnect(); + await mongoServer.stop(); + }); + + beforeEach(async () => { + await mongoose.connection.dropDatabase(); + + await Role.create({ + name: 'test-role', + permissions: { + AGENTS: { USE: true, CREATE: true, SHARE: true }, + MULTI_CONVO: { USE: true }, + }, + }); + + await Role.create({ + name: 'no-multi-convo', + permissions: { + AGENTS: { USE: true, CREATE: true, SHARE: true }, + MULTI_CONVO: { USE: false }, + }, + }); + + await Role.create({ + name: SystemRoles.ADMIN, + permissions: { + AGENTS: { USE: true, CREATE: true, SHARE: true }, + MULTI_CONVO: { USE: true }, + }, + }); + + testUser = await User.create({ + email: 'test@example.com', + name: 'Test User', + username: 'testuser', + role: 'test-role', + }); + + otherUser = await User.create({ + email: 'other@example.com', + name: 'Other User', + username: 'otheruser', + role: 'test-role', + }); + + req = { + user: { id: testUser._id, role: testUser.role }, + params: {}, + body: { + endpoint: 'agents', + agent_id: 'ephemeral_primary', + }, + }; + res = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + next = jest.fn(); + + jest.clearAllMocks(); + }); + + describe('middleware factory', () => { + test('throws if requiredPermission is missing', () => { + expect(() => canAccessAgentFromBody({})).toThrow( + 'canAccessAgentFromBody: requiredPermission is required and must be a number', + ); + }); + + test('throws if requiredPermission is not a number', () => { + expect(() => canAccessAgentFromBody({ requiredPermission: '1' })).toThrow( + 'canAccessAgentFromBody: requiredPermission is required and must be a number', + ); + }); + + test('returns a middleware function', () => { + const middleware = canAccessAgentFromBody({ requiredPermission: 1 }); + expect(typeof middleware).toBe('function'); + expect(middleware.length).toBe(3); + }); + }); + + describe('primary agent checks', () => { + test('returns 400 when agent_id is missing on agents endpoint', async () => { + req.body.agent_id = undefined; + const middleware = canAccessAgentFromBody({ requiredPermission: 1 }); + await middleware(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(400); + }); + + test('proceeds for ephemeral primary agent without addedConvo', async () => { + const middleware = canAccessAgentFromBody({ requiredPermission: 1 }); + await middleware(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + + test('proceeds for non-agents endpoint (ephemeral fallback)', async () => { + req.body.endpoint = 'openAI'; + req.body.agent_id = undefined; + const middleware = canAccessAgentFromBody({ requiredPermission: 1 }); + await middleware(req, res, next); + + expect(next).toHaveBeenCalled(); + }); + }); + + describe('addedConvo — absent or invalid shape', () => { + test('calls next when addedConvo is absent', async () => { + const middleware = canAccessAgentFromBody({ requiredPermission: 1 }); + await middleware(req, res, next); + + expect(next).toHaveBeenCalled(); + }); + + test('calls next when addedConvo is a string', async () => { + req.body.addedConvo = 'not-an-object'; + const middleware = canAccessAgentFromBody({ requiredPermission: 1 }); + await middleware(req, res, next); + + expect(next).toHaveBeenCalled(); + }); + + test('calls next when addedConvo is an array', async () => { + req.body.addedConvo = [{ agent_id: 'agent_something' }]; + const middleware = canAccessAgentFromBody({ requiredPermission: 1 }); + await middleware(req, res, next); + + expect(next).toHaveBeenCalled(); + }); + }); + + describe('addedConvo — MULTI_CONVO permission gate', () => { + test('returns 403 when user lacks MULTI_CONVO:USE', async () => { + req.user.role = 'no-multi-convo'; + req.body.addedConvo = { agent_id: 'agent_x', endpoint: 'agents', model: 'gpt-4' }; + + const middleware = canAccessAgentFromBody({ requiredPermission: 1 }); + await middleware(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ message: 'Multi-conversation feature is not enabled' }), + ); + }); + + test('returns 403 when user.role is missing', async () => { + req.user = { id: testUser._id }; + req.body.addedConvo = { agent_id: 'agent_x', endpoint: 'agents', model: 'gpt-4' }; + + const middleware = canAccessAgentFromBody({ requiredPermission: 1 }); + await middleware(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + }); + + test('ADMIN bypasses MULTI_CONVO check', async () => { + req.user.role = SystemRoles.ADMIN; + req.body.addedConvo = { agent_id: 'ephemeral_x', endpoint: 'agents', model: 'gpt-4' }; + + const middleware = canAccessAgentFromBody({ requiredPermission: 1 }); + await middleware(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + }); + + describe('addedConvo — agent_id shape validation', () => { + test('calls next when agent_id is ephemeral', async () => { + req.body.addedConvo = { agent_id: 'ephemeral_xyz', endpoint: 'agents', model: 'gpt-4' }; + + const middleware = canAccessAgentFromBody({ requiredPermission: 1 }); + await middleware(req, res, next); + + expect(next).toHaveBeenCalled(); + }); + + test('calls next when agent_id is absent', async () => { + req.body.addedConvo = { endpoint: 'agents', model: 'gpt-4' }; + + const middleware = canAccessAgentFromBody({ requiredPermission: 1 }); + await middleware(req, res, next); + + expect(next).toHaveBeenCalled(); + }); + + test('calls next when agent_id is not a string (object injection)', async () => { + req.body.addedConvo = { agent_id: { $gt: '' }, endpoint: 'agents', model: 'gpt-4' }; + + const middleware = canAccessAgentFromBody({ requiredPermission: 1 }); + await middleware(req, res, next); + + expect(next).toHaveBeenCalled(); + }); + }); + + describe('addedConvo — agent resource ACL (IDOR prevention)', () => { + let addedAgent; + + beforeEach(async () => { + addedAgent = await createAgent({ + id: `agent_added_${Date.now()}`, + name: 'Private Agent', + provider: 'openai', + model: 'gpt-4', + author: otherUser._id, + }); + + await AclEntry.create({ + principalType: PrincipalType.USER, + principalId: otherUser._id, + principalModel: PrincipalModel.USER, + resourceType: ResourceType.AGENT, + resourceId: addedAgent._id, + permBits: 15, + grantedBy: otherUser._id, + }); + }); + + test('returns 403 when requester has no ACL for the added agent', async () => { + req.body.addedConvo = { agent_id: addedAgent.id, endpoint: 'agents', model: 'gpt-4' }; + + const middleware = canAccessAgentFromBody({ requiredPermission: 1 }); + await middleware(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + expect(res.json).toHaveBeenCalledWith( + expect.objectContaining({ + message: 'Insufficient permissions to access this agent', + }), + ); + }); + + test('returns 404 when added agent does not exist', async () => { + req.body.addedConvo = { + agent_id: 'agent_nonexistent_999', + endpoint: 'agents', + model: 'gpt-4', + }; + + const middleware = canAccessAgentFromBody({ requiredPermission: 1 }); + await middleware(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(404); + }); + + test('proceeds when requester has ACL for the added agent', async () => { + await AclEntry.create({ + principalType: PrincipalType.USER, + principalId: testUser._id, + principalModel: PrincipalModel.USER, + resourceType: ResourceType.AGENT, + resourceId: addedAgent._id, + permBits: 1, + grantedBy: otherUser._id, + }); + + req.body.addedConvo = { agent_id: addedAgent.id, endpoint: 'agents', model: 'gpt-4' }; + + const middleware = canAccessAgentFromBody({ requiredPermission: 1 }); + await middleware(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + + test('denies when ACL permission bits are insufficient', async () => { + await AclEntry.create({ + principalType: PrincipalType.USER, + principalId: testUser._id, + principalModel: PrincipalModel.USER, + resourceType: ResourceType.AGENT, + resourceId: addedAgent._id, + permBits: 1, + grantedBy: otherUser._id, + }); + + req.body.addedConvo = { agent_id: addedAgent.id, endpoint: 'agents', model: 'gpt-4' }; + + const middleware = canAccessAgentFromBody({ requiredPermission: 2 }); + await middleware(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + }); + + test('caches resolved agent on req.resolvedAddedAgent', async () => { + await AclEntry.create({ + principalType: PrincipalType.USER, + principalId: testUser._id, + principalModel: PrincipalModel.USER, + resourceType: ResourceType.AGENT, + resourceId: addedAgent._id, + permBits: 1, + grantedBy: otherUser._id, + }); + + req.body.addedConvo = { agent_id: addedAgent.id, endpoint: 'agents', model: 'gpt-4' }; + + const middleware = canAccessAgentFromBody({ requiredPermission: 1 }); + await middleware(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(req.resolvedAddedAgent).toBeDefined(); + expect(req.resolvedAddedAgent._id.toString()).toBe(addedAgent._id.toString()); + }); + + test('ADMIN bypasses agent resource ACL for addedConvo', async () => { + req.user.role = SystemRoles.ADMIN; + req.body.addedConvo = { agent_id: addedAgent.id, endpoint: 'agents', model: 'gpt-4' }; + + const middleware = canAccessAgentFromBody({ requiredPermission: 1 }); + await middleware(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + expect(req.resolvedAddedAgent).toBeUndefined(); + }); + }); + + describe('end-to-end: primary real agent + addedConvo real agent', () => { + let primaryAgent, addedAgent; + + beforeEach(async () => { + primaryAgent = await createAgent({ + id: `agent_primary_${Date.now()}`, + name: 'Primary Agent', + provider: 'openai', + model: 'gpt-4', + author: testUser._id, + }); + + await AclEntry.create({ + principalType: PrincipalType.USER, + principalId: testUser._id, + principalModel: PrincipalModel.USER, + resourceType: ResourceType.AGENT, + resourceId: primaryAgent._id, + permBits: 15, + grantedBy: testUser._id, + }); + + addedAgent = await createAgent({ + id: `agent_added_${Date.now()}`, + name: 'Added Agent', + provider: 'openai', + model: 'gpt-4', + author: otherUser._id, + }); + + await AclEntry.create({ + principalType: PrincipalType.USER, + principalId: otherUser._id, + principalModel: PrincipalModel.USER, + resourceType: ResourceType.AGENT, + resourceId: addedAgent._id, + permBits: 15, + grantedBy: otherUser._id, + }); + + req.body.agent_id = primaryAgent.id; + }); + + test('both checks pass when user has ACL for both agents', async () => { + await AclEntry.create({ + principalType: PrincipalType.USER, + principalId: testUser._id, + principalModel: PrincipalModel.USER, + resourceType: ResourceType.AGENT, + resourceId: addedAgent._id, + permBits: 1, + grantedBy: otherUser._id, + }); + + req.body.addedConvo = { agent_id: addedAgent.id, endpoint: 'agents', model: 'gpt-4' }; + + const middleware = canAccessAgentFromBody({ requiredPermission: 1 }); + await middleware(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + expect(req.resolvedAddedAgent).toBeDefined(); + }); + + test('primary passes but addedConvo denied → 403', async () => { + req.body.addedConvo = { agent_id: addedAgent.id, endpoint: 'agents', model: 'gpt-4' }; + + const middleware = canAccessAgentFromBody({ requiredPermission: 1 }); + await middleware(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + }); + + test('primary denied → 403 without reaching addedConvo check', async () => { + const foreignAgent = await createAgent({ + id: `agent_foreign_${Date.now()}`, + name: 'Foreign Agent', + provider: 'openai', + model: 'gpt-4', + author: otherUser._id, + }); + + await AclEntry.create({ + principalType: PrincipalType.USER, + principalId: otherUser._id, + principalModel: PrincipalModel.USER, + resourceType: ResourceType.AGENT, + resourceId: foreignAgent._id, + permBits: 15, + grantedBy: otherUser._id, + }); + + req.body.agent_id = foreignAgent.id; + req.body.addedConvo = { agent_id: addedAgent.id, endpoint: 'agents', model: 'gpt-4' }; + + const middleware = canAccessAgentFromBody({ requiredPermission: 1 }); + await middleware(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + }); + }); + + describe('ephemeral primary + real addedConvo agent', () => { + let addedAgent; + + beforeEach(async () => { + addedAgent = await createAgent({ + id: `agent_added_${Date.now()}`, + name: 'Added Agent', + provider: 'openai', + model: 'gpt-4', + author: otherUser._id, + }); + + await AclEntry.create({ + principalType: PrincipalType.USER, + principalId: otherUser._id, + principalModel: PrincipalModel.USER, + resourceType: ResourceType.AGENT, + resourceId: addedAgent._id, + permBits: 15, + grantedBy: otherUser._id, + }); + }); + + test('runs full addedConvo ACL check even when primary is ephemeral', async () => { + req.body.addedConvo = { agent_id: addedAgent.id, endpoint: 'agents', model: 'gpt-4' }; + + const middleware = canAccessAgentFromBody({ requiredPermission: 1 }); + await middleware(req, res, next); + + expect(next).not.toHaveBeenCalled(); + expect(res.status).toHaveBeenCalledWith(403); + }); + + test('proceeds when user has ACL for added agent (ephemeral primary)', async () => { + await AclEntry.create({ + principalType: PrincipalType.USER, + principalId: testUser._id, + principalModel: PrincipalModel.USER, + resourceType: ResourceType.AGENT, + resourceId: addedAgent._id, + permBits: 1, + grantedBy: otherUser._id, + }); + + req.body.addedConvo = { agent_id: addedAgent.id, endpoint: 'agents', model: 'gpt-4' }; + + const middleware = canAccessAgentFromBody({ requiredPermission: 1 }); + await middleware(req, res, next); + + expect(next).toHaveBeenCalled(); + expect(res.status).not.toHaveBeenCalled(); + }); + }); +});