Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions api/server/controllers/PermissionsController.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const {
searchEntraIdPrincipals,
} = require('~/server/services/GraphApiService');
const db = require('~/models');
const { invalidateCodeEnvironmentConfigCache } = require('~/server/services/Config');

const matchesCurrentTenant = (principal, tenantId) => {
if (!tenantId || tenantId === SYSTEM_TENANT_ID) {
Expand Down Expand Up @@ -157,6 +158,14 @@ const updateResourcePermissions = async (req, res) => {
grantedBy: userId,
});

if (resourceType === ResourceType.CODE_ENVIRONMENT) {
await invalidateCodeEnvironmentConfigCache(req.user.tenantId).catch((error) => {
// Cached environment metadata is authorization-filtered against the live ACL on every
// read, so a failed revision write may delay a grant but cannot preserve a revocation.
logger.error('[PermissionsController] code environment cache invalidation failed:', error);
});
}

const isAgentResource =
resourceType === ResourceType.AGENT || resourceType === ResourceType.REMOTE_AGENT;
const revokedUserIds = results.revoked
Expand Down
6 changes: 5 additions & 1 deletion api/server/controllers/UserController.js
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ const {
cancelAgentTriggerUserPurge,
purgeAgentTriggerDeliveriesForUser,
} = require('~/server/services/Agents/triggers');
const { getAppConfig } = require('~/server/services/Config');
const { getAppConfig, invalidateCodeEnvironmentConfigCache } = require('~/server/services/Config');
const { randomUUID } = require('node:crypto');
const {
quiesceUserSchedules,
Expand Down Expand Up @@ -460,6 +460,10 @@ const deleteUserController = async (req, res) => {
await db.deleteAllUserMemories(user.id);
await db.deleteUserPrompts(user.id);
await db.deleteUserSkills(user.id);
await db.deleteUserCodeEnvironments(user.id);
Comment thread
danny-avila marked this conversation as resolved.
await invalidateCodeEnvironmentConfigCache(user.tenantId).catch((error) => {
logger.error('[deleteUserController] code environment cache invalidation failed:', error);
});
await deleteUserMcpServers(user.id);
await db.deleteActions({ user: user.id });
await db.deleteTokens({ userId: user.id });
Expand Down
4 changes: 4 additions & 0 deletions api/server/controllers/UserController.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const mockCancelAndDrainSubagentThreads = jest.fn().mockResolvedValue(undefined)
const mockQuiesceUserSchedules = jest.fn().mockResolvedValue(true);
const mockRestoreUserSchedules = jest.fn().mockResolvedValue(undefined);
const mockGetWebSearchInstallEntries = jest.fn();
const mockInvalidateCodeEnvironmentConfigCache = jest.fn().mockResolvedValue(undefined);

jest.mock('@librechat/data-schemas', () => {
const actual = jest.requireActual('@librechat/data-schemas');
Expand Down Expand Up @@ -43,6 +44,7 @@ jest.mock('~/models', () => {
cancelAgentTriggerUserDeletion: jest.fn().mockResolvedValue(true),
deleteUserPrompts: jest.fn().mockResolvedValue(undefined),
deleteUserSkills: jest.fn().mockResolvedValue(undefined),
deleteUserCodeEnvironments: jest.fn().mockResolvedValue(undefined),
deleteMessages: jest.fn().mockResolvedValue(undefined),
deleteBalances: jest.fn().mockResolvedValue(undefined),
deleteActions: jest.fn().mockResolvedValue(undefined),
Expand Down Expand Up @@ -119,6 +121,8 @@ jest.mock('~/server/services/Config', () => ({
getMCPManager: jest.fn(),
getFlowStateManager: jest.fn(),
getMCPServersRegistry: jest.fn(),
invalidateCodeEnvironmentConfigCache: (...args) =>
mockInvalidateCodeEnvironmentConfigCache(...args),
}));

jest.mock('~/cache', () => ({
Expand Down
49 changes: 49 additions & 0 deletions api/server/controllers/__tests__/PermissionsController.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ jest.mock('@librechat/api', () => ({
backfillRemoteAgentPermissions: jest.fn(),
}));

const mockInvalidateCodeEnvironmentConfigCache = jest.fn().mockResolvedValue(undefined);
jest.mock('~/server/services/Config', () => ({
invalidateCodeEnvironmentConfigCache: (...args) =>
mockInvalidateCodeEnvironmentConfigCache(...args),
}));

const mockBulkUpdateResourcePermissions = jest.fn();

jest.mock('~/server/services/PermissionService', () => ({
Expand Down Expand Up @@ -380,6 +386,49 @@ describe('PermissionsController', () => {
expect(mockRemoveAgentFromUserFavorites).not.toHaveBeenCalled();
});

it('invalidates shared environment configuration after code environment ACL changes', async () => {
const req = createMockReq({
params: { resourceType: ResourceType.CODE_ENVIRONMENT, resourceId: agentObjectId },
body: {
updated: [{ type: PrincipalType.USER, id: revokedUserId }],
removed: [],
public: false,
},
user: { id: 'user-1', role: 'USER', tenantId: 'tenant-a' },
});
const res = createMockRes();

await updateResourcePermissions(req, res);

expect(mockInvalidateCodeEnvironmentConfigCache).toHaveBeenCalledWith('tenant-a');
expect(res.status).toHaveBeenCalledWith(200);
});

it('keeps the committed ACL response successful when cache invalidation fails', async () => {
mockInvalidateCodeEnvironmentConfigCache.mockRejectedValueOnce(
new Error('redis unavailable'),
);
const req = createMockReq({
params: { resourceType: ResourceType.CODE_ENVIRONMENT, resourceId: agentObjectId },
body: {
updated: [],
removed: [{ type: PrincipalType.USER, id: revokedUserId }],
public: false,
},
user: { id: 'user-1', role: 'USER', tenantId: 'tenant-a' },
});
const res = createMockRes();

await updateResourcePermissions(req, res);

expect(mockInvalidateCodeEnvironmentConfigCache).toHaveBeenCalledWith('tenant-a');
expect(res.status).toHaveBeenCalledWith(200);
expect(mockLogger.error).toHaveBeenCalledWith(
'[PermissionsController] code environment cache invalidation failed:',
expect.any(Error),
);
});

it('handles agent not found gracefully', async () => {
mockRemoveAgentFromUserFavorites.mockResolvedValue(undefined);

Expand Down
7 changes: 7 additions & 0 deletions api/server/controllers/__tests__/deleteUser.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ const mockDeleteToolCalls = jest.fn();
const mockDeleteUserAgents = jest.fn();
const mockDeleteUserPrompts = jest.fn();
const mockDeleteUserSkills = jest.fn();
const mockDeleteUserCodeEnvironments = jest.fn();
const mockInvalidateCodeEnvironmentConfigCache = jest.fn();
const mockGetCleanupBlockingJobIdsForUser = jest.fn();
const mockAbortJob = jest.fn();
const mockDrainAgentTriggerDeliveriesForUser = jest.fn();
Expand Down Expand Up @@ -77,6 +79,7 @@ jest.mock('~/models', () => ({
deleteUserAgents: (...args) => mockDeleteUserAgents(...args),
deleteUserPrompts: (...args) => mockDeleteUserPrompts(...args),
deleteUserSkills: (...args) => mockDeleteUserSkills(...args),
deleteUserCodeEnvironments: (...args) => mockDeleteUserCodeEnvironments(...args),
deleteTransactions: jest.fn(),
deleteBalances: jest.fn(),
deleteAllAgentApiKeys: jest.fn(),
Expand Down Expand Up @@ -136,6 +139,8 @@ jest.mock('~/server/services/Schedules', () => ({

jest.mock('~/server/services/Config', () => ({
getAppConfig: jest.fn(),
invalidateCodeEnvironmentConfigCache: (...args) =>
mockInvalidateCodeEnvironmentConfigCache(...args),
}));

jest.mock('~/cache', () => ({
Expand Down Expand Up @@ -169,6 +174,7 @@ function stubDeletionMocks() {
mockDeleteUserAgents.mockResolvedValue();
mockDeleteUserPrompts.mockResolvedValue();
mockDeleteUserSkills.mockResolvedValue(0);
mockInvalidateCodeEnvironmentConfigCache.mockResolvedValue(undefined);
mockGetCleanupBlockingJobIdsForUser.mockResolvedValue([]);
mockAbortJob.mockResolvedValue({ success: true });
mockDrainAgentTriggerDeliveriesForUser.mockResolvedValue();
Expand Down Expand Up @@ -201,6 +207,7 @@ describe('deleteUserController - 2FA enforcement', () => {
expect(mockDeleteUserAgents).toHaveBeenCalledWith('user1');
expect(mockDeleteUserPrompts).toHaveBeenCalledWith('user1');
expect(mockDeleteUserSkills).toHaveBeenCalledWith('user1');
expect(mockInvalidateCodeEnvironmentConfigCache).toHaveBeenCalledWith(undefined);
expect(mockVerifyOTPOrBackupCode).not.toHaveBeenCalled();
expect(mockBeginAgentTriggerUserDeletion.mock.invocationCallOrder[0]).toBeLessThan(
mockPrepareAgentTriggerUserPurge.mock.invocationCallOrder[0],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ const { ResourceType } = require('librechat-data-provider');
*/
const HANDLED_RESOURCE_TYPES = {
[ResourceType.AGENT]: 'deleteUserAgents',
[ResourceType.CODE_ENVIRONMENT]: 'deleteUserCodeEnvironments',
[ResourceType.REMOTE_AGENT]: 'deleteUserAgents',
[ResourceType.PROMPTGROUP]: 'deleteUserPrompts',
[ResourceType.MCPSERVER]: 'deleteUserMcpServers',
Expand Down
1 change: 1 addition & 0 deletions api/server/experimental.js
Original file line number Diff line number Diff line change
Expand Up @@ -521,6 +521,7 @@ if (cluster.isMaster) {
app.use('/api/admin', routes.adminAuth);
app.use('/api/admin/skills', routes.adminSkills);
app.use('/api/admin/code-environments', routes.adminCodeEnvironments);
app.use('/api/code-environments', routes.codeEnvironments);
app.use('/api/actions', routes.actions);
app.use('/api/keys', routes.keys);
app.use('/api/api-keys', routes.apiKeys);
Expand Down
1 change: 1 addition & 0 deletions api/server/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -368,6 +368,7 @@ const startServer = async () => {
app.use('/api/admin', routes.adminAuth);
app.use('/api/admin/config', routes.adminConfig);
app.use('/api/admin/code-environments', routes.adminCodeEnvironments);
app.use('/api/code-environments', routes.codeEnvironments);
app.use('/api/admin/langfuse', routes.adminLangfuse);
app.use('/api/admin/grants', routes.adminGrants);
app.use('/api/admin/groups', routes.adminGroups);
Expand Down
6 changes: 6 additions & 0 deletions api/server/routes/accessPermissions.js
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,12 @@ const checkResourcePermissionAccess = (requiredPermission) => (req, res, next) =
resourceIdParam: 'resourceId',
idResolver: getSkillById,
});
} else if (resourceType === ResourceType.CODE_ENVIRONMENT) {
middleware = canAccessResource({
resourceType: ResourceType.CODE_ENVIRONMENT,
requiredPermission,
resourceIdParam: 'resourceId',
});
Comment thread
danny-avila marked this conversation as resolved.
} else if (resourceType === ResourceType.SHARED_LINK) {
middleware = canAccessResource({
resourceType: ResourceType.SHARED_LINK,
Expand Down
3 changes: 3 additions & 0 deletions api/server/routes/admin/users.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const {
purgeAgentTriggerDeliveriesForUser,
} = require('~/server/services/Agents/triggers');
const db = require('~/models');
const { invalidateCodeEnvironmentConfigCache } = require('~/server/services/Config');

const router = express.Router();

Expand All @@ -27,6 +28,8 @@ const handlers = createAdminUsersHandlers({
cancelAgentTriggerUserPurge,
purgeAgentTriggerDeliveriesForUser,
deleteUserById: db.deleteUserById,
deleteUserCodeEnvironments: db.deleteUserCodeEnvironments,
invalidateCodeEnvironmentConfigCache,
deleteConfig: db.deleteConfig,
deleteAclEntries: db.deleteAclEntries,
});
Expand Down
27 changes: 27 additions & 0 deletions api/server/routes/code-environments.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
const express = require('express');
const { createCodeEnvironmentHttpHandlers } = require('@librechat/api');
const { SystemCapabilities } = require('@librechat/data-schemas');
const { requireCapability } = require('~/server/middleware/roles/capabilities');
const { getAppConfig, getCodeEnvironmentRegistry } = require('~/server/services/Config');
const { requireJwtAuth } = require('~/server/middleware');

const router = express.Router();
let handlers;
function getHandlers() {
if (handlers == null) {
handlers = createCodeEnvironmentHttpHandlers({
getAppConfig,
registry: getCodeEnvironmentRegistry(),
});
}
return handlers;
}
const requireCodeEnvironmentManage = requireCapability(SystemCapabilities.MANAGE_CODE_ENVIRONMENTS);

router.use(requireJwtAuth);
router.get('/', (req, res, next) => getHandlers().list(req, res, next));
router.post('/', requireCodeEnvironmentManage, (req, res, next) =>
getHandlers().register(req, res, next),
);

module.exports = router;
77 changes: 77 additions & 0 deletions api/server/routes/code-environments.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
const express = require('express');
const request = require('supertest');

const middlewareCalls = [];
const mockRequireJwtAuth = jest.fn((req, _res, next) => {
middlewareCalls.push('jwt');
req.user = { id: '68b2f0c498f24c1e78fa0001', role: 'USER' };
next();
});
const mockRequireCapability = jest.fn((capability) => (req, _res, next) => {
middlewareCalls.push(capability);
next();
});
const mockRegistry = {};
const mockGetCodeEnvironmentRegistry = jest.fn(() => mockRegistry);
const mockHandlers = {
list: jest.fn((_req, res) => res.status(200).json({ environments: [] })),
register: jest.fn((_req, res) => res.status(201).json({ environment: { id: 'code-1' } })),
};

jest.mock('@librechat/data-schemas', () => ({
SystemCapabilities: { MANAGE_CODE_ENVIRONMENTS: 'manage:code_environments' },
}));

jest.mock('@librechat/api', () => ({
createCodeEnvironmentRegistry: jest.fn(() => mockRegistry),
createCodeEnvironmentHttpHandlers: jest.fn(() => mockHandlers),
}));

jest.mock('~/server/middleware/roles/capabilities', () => ({
requireCapability: mockRequireCapability,
}));

jest.mock('~/server/middleware', () => ({ requireJwtAuth: mockRequireJwtAuth }));
jest.mock('~/server/services/Config', () => ({
getAppConfig: jest.fn(),
getCodeEnvironmentRegistry: mockGetCodeEnvironmentRegistry,
}));

function createApp() {
delete require.cache[require.resolve('./code-environments')];
const router = require('./code-environments');
const app = express();
app.use(express.json());
app.use('/api/code-environments', router);
return app;
}

describe('code environment routes', () => {
beforeEach(() => {
middlewareCalls.length = 0;
jest.clearAllMocks();
});

it('defers registry initialization until the route is requested', () => {
createApp();

expect(mockGetCodeEnvironmentRegistry).not.toHaveBeenCalled();
});

it('allows authenticated discovery without the management capability', async () => {
await request(createApp()).get('/api/code-environments').expect(200, { environments: [] });

expect(middlewareCalls).toEqual(['jwt']);
expect(mockHandlers.list).toHaveBeenCalledTimes(1);
});

it('requires the management capability before registration', async () => {
await request(createApp())
.post('/api/code-environments')
.send({ name: 'Personal VM' })
.expect(201);

expect(middlewareCalls).toEqual(['jwt', 'manage:code_environments']);
expect(mockHandlers.register).toHaveBeenCalledTimes(1);
});
});
2 changes: 1 addition & 1 deletion api/server/routes/endpoints.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ const tokenConfigController = require('~/server/controllers/TokenConfigControlle

const router = express.Router();
/** Auth required for role/tenant-scoped endpoint config resolution. */
router.get('/', requireJwtAuth, endpointController);
router.get('/', requireJwtAuth, configMiddleware, endpointController);
router.get('/token-config', requireJwtAuth, configMiddleware, tokenConfigController);

module.exports = router;
2 changes: 2 additions & 0 deletions api/server/routes/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ const categories = require('./categories');
const adminAuth = require('./admin/auth');
const adminConfig = require('./admin/config');
const adminCodeEnvironments = require('./admin/code');
const codeEnvironments = require('./code-environments');
const adminLangfuse = require('./admin/langfuse');
const adminGrants = require('./admin/grants');
const adminGroups = require('./admin/groups');
Expand Down Expand Up @@ -49,6 +50,7 @@ module.exports = {
adminAuth,
adminConfig,
adminCodeEnvironments,
codeEnvironments,
adminLangfuse,
adminGrants,
adminGroups,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,10 @@ jest.mock('~/server/services/start/tools', () => ({
jest.mock('../loadCustomConfig', () => jest.fn().mockResolvedValue({}));

jest.mock('@librechat/data-schemas', () => {
const actual = jest.requireActual('@librechat/data-schemas');
return { ...actual, AppService: jest.fn(() => ({ availableTools: {} })) };
return {
AppService: jest.fn(() => ({ availableTools: {} })),
logger: { error: jest.fn() },
};
});

jest.mock('~/models', () => ({
Expand All @@ -37,6 +39,8 @@ jest.mock('@librechat/api', () => ({
clearOverrideCache: mockClearOverrideCache,
})),
clearMcpConfigCache: mockClearMcpConfigCache,
createCodeEnvironmentRegistry: jest.fn(() => ({})),
mergeAccessibleCodeEnvironments: jest.fn(({ appConfig }) => appConfig),
}));

// ── Tests ──────────────────────────────────────────────────────────────
Expand Down
Loading
Loading