mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-09-29 04:12:38 +00:00
🧳 feat: Register Principal-Owned Code Environments (#15365)
* feat: add principal-owned code environments * fix: address code environment CI coverage * fix: expose principal code environments in endpoint config * fix: reject missing code environment bodies * fix: harden code environment lifecycle * fix: revalidate principal code environments * fix: synchronize code environment authorization * fix: bind code environments to current principals * fix: fail closed on code ACL cache errors * fix: prevent code environment override shadowing * fix: preserve principal environment defaults * fix: suppress revoked code environment aliases * fix: fail closed on environment augmentation * fix: narrow code environment defaults * fix: isolate code environment fallback * style: sort code config imports
This commit is contained in:
parent
0389a92452
commit
fcae1025c0
48 changed files with 2178 additions and 35 deletions
|
|
@ -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) {
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -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);
|
||||
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 });
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
@ -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),
|
||||
|
|
@ -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', () => ({
|
||||
|
|
|
|||
|
|
@ -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', () => ({
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -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(),
|
||||
|
|
@ -136,6 +139,8 @@ jest.mock('~/server/services/Schedules', () => ({
|
|||
|
||||
jest.mock('~/server/services/Config', () => ({
|
||||
getAppConfig: jest.fn(),
|
||||
invalidateCodeEnvironmentConfigCache: (...args) =>
|
||||
mockInvalidateCodeEnvironmentConfigCache(...args),
|
||||
}));
|
||||
|
||||
jest.mock('~/cache', () => ({
|
||||
|
|
@ -169,6 +174,7 @@ function stubDeletionMocks() {
|
|||
mockDeleteUserAgents.mockResolvedValue();
|
||||
mockDeleteUserPrompts.mockResolvedValue();
|
||||
mockDeleteUserSkills.mockResolvedValue(0);
|
||||
mockInvalidateCodeEnvironmentConfigCache.mockResolvedValue(undefined);
|
||||
mockGetCleanupBlockingJobIdsForUser.mockResolvedValue([]);
|
||||
mockAbortJob.mockResolvedValue({ success: true });
|
||||
mockDrainAgentTriggerDeliveriesForUser.mockResolvedValue();
|
||||
|
|
@ -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],
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue