mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-03-16 12:46:34 +01:00
🛡️ fix: Enforce MULTI_CONVO and agent ACL checks on addedConvo (#12243)
* 🛡️ 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
This commit is contained in:
parent
07d0ce4ce9
commit
8dc6d60750
3 changed files with 637 additions and 40 deletions
|
|
@ -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`);
|
||||
|
|
|
|||
|
|
@ -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<Object|null>} Agent document with _id field, or null if not found
|
||||
* @returns {Promise<Object|null>} 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<void>}
|
||||
*/
|
||||
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<void>}
|
||||
*/
|
||||
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({
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue