🩻 feat(insights): Scope Insights to Authorized Agents (#15549)
Some checks failed
Docker Dev Images Build / publish (push) Has been cancelled
Sync Helm Chart Tags / Ignore non-main push (push) Has been cancelled
Sync Helm Chart Tags / Sync chart tags (push) Has been cancelled

* feat(insights): add agent-scoped access

* fix(insights): exclude unattributed assistant messages

* fix(sharing): key role insight toggles by principal

* fix(sharing): preserve insight grant snapshot

* fix(permissions): preserve insight bits atomically

* fix(permissions): guard insight audit rollback

* fix(insights): keep admin grant controls accessible

* fix(permissions): reconcile duplicate principal updates

* fix(insights): show automatic admin access

* fix(permissions): harden insights access updates

* fix(insights): simplify authorization and preserve client state
This commit is contained in:
Ravi Kumar L 2026-09-05 02:16:58 +02:00 • committed by GitHub
parent 57c52de33c
commit 000349681e
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
68 changed files with 3682 additions and 747 deletions

View file

@ -5,9 +5,18 @@
const mongoose = require('mongoose');
const { logger, getTenantId, SYSTEM_TENANT_ID } = require('@librechat/data-schemas');
const { ResourceType, PrincipalType, PermissionBits } = require('librechat-data-provider');
const { enrichRemoteAgentPrincipals, backfillRemoteAgentPermissions } = require('@librechat/api');
const {
enrichRemoteAgentPrincipals,
backfillRemoteAgentPermissions,
auditInsightsPermissionChanges,
getInsightsPrincipalState,
maskAgentInsightsBit,
sanitizeInsightsPermissionPrincipals,
validateInsightsPermissionUpdates,
} = require('@librechat/api');
const {
bulkUpdateResourcePermissions,
restoreInsightsPermissionChanges,
ensureGroupPrincipalExists,
getResourcePermissionsMap,
findAccessibleResources,
@ -65,14 +74,24 @@ const updateResourcePermissions = async (req, res) => {
/** @type {TUpdateResourcePermissionsRequest} */
const { updated, removed, public: isPublic, publicAccessRoleId } = req.body;
const { id: userId } = req.user;
const updatedList = Array.isArray(updated) ? updated : [];
const removedList = Array.isArray(removed) ? removed : [];
const insightsValidation = validateInsightsPermissionUpdates({
resourceType,
userRole: req.user.role,
updatedPrincipals: updatedList,
});
if (insightsValidation) {
return res.status(insightsValidation.status).json({ error: insightsValidation.error });
}
// Prepare principals for the service call
const updatedPrincipals = [];
const revokedPrincipals = [];
// Add updated principals
if (updated && Array.isArray(updated)) {
updatedPrincipals.push(...updated);
if (updatedList.length > 0) {
updatedPrincipals.push(...updatedList);
}
// Add public permission if enabled
@ -138,8 +157,8 @@ const updateResourcePermissions = async (req, res) => {
}
// Add removed principals
if (removed && Array.isArray(removed)) {
revokedPrincipals.push(...removed);
if (removedList.length > 0) {
revokedPrincipals.push(...removedList);
}
// If public is explicitly disabled, add public to revoked list
@ -158,6 +177,24 @@ const updateResourcePermissions = async (req, res) => {
grantedBy: userId,
});
await auditInsightsPermissionChanges({
req,
resourceId,
changes: results.insightsChanges ?? [],
failClosed: process.env.AUDIT_LOG_FAIL_CLOSED === 'true',
deps: {
getAgent: db.getAgent,
recordAuditEntry: db.recordAuditEntry,
restoreInsightsPermissionChanges: (changes) =>
restoreInsightsPermissionChanges({
resourceType: ResourceType.AGENT,
resourceId,
changes,
}),
logger,
},
});
if (resourceType === ResourceType.CODE_ENVIRONMENT) {
await invalidateCodeEnvironmentConfigCache(req.user.tenantId).catch((error) => {
// Cached environment metadata is authorization-filtered against the live ACL on every
@ -179,10 +216,15 @@ const updateResourcePermissions = async (req, res) => {
}
/** @type {TUpdateResourcePermissionsResponse} */
const responsePrincipals = sanitizeInsightsPermissionPrincipals({
resourceType,
userRole: req.user.role,
principals: results.granted,
});
const response = {
message: 'Permissions updated successfully',
results: {
principals: results.granted,
principals: responsePrincipals,
...(isPublic !== undefined ? { public: isPublic } : {}),
publicAccessRoleId: isPublic ? publicAccessRoleId : undefined,
},
@ -191,7 +233,7 @@ const updateResourcePermissions = async (req, res) => {
res.status(200).json(response);
} catch (error) {
logger.error('Error updating resource permissions:', error);
res.status(400).json({
res.status(error.statusCode ?? 400).json({
error: 'Failed to update permissions',
details: error.message,
});
@ -254,6 +296,7 @@ const getResourcePermissions = async (req, res) => {
accessRoleId: { $arrayElemAt: ['$role.accessRoleId', 0] },
userInfo: { $arrayElemAt: ['$userInfo', 0] },
groupInfo: { $arrayElemAt: ['$groupInfo', 0] },
permBits: 1,
},
},
]);
@ -281,6 +324,12 @@ const getResourcePermissions = async (req, res) => {
source: !result.userInfo._id ? 'entra' : 'local',
idOnTheSource: result.userInfo.idOnTheSource || result.userInfo._id.toString(),
accessRoleId: result.accessRoleId,
...getInsightsPrincipalState({
principalType: PrincipalType.USER,
principalRole: result.userInfo.role,
requesterRole: req.user.role,
permBits: result.permBits,
}),
});
} else if (
result.principalType === PrincipalType.GROUP &&
@ -297,6 +346,11 @@ const getResourcePermissions = async (req, res) => {
source: result.groupInfo.source || 'local',
idOnTheSource: result.groupInfo.idOnTheSource || result.groupInfo._id.toString(),
accessRoleId: result.accessRoleId,
...getInsightsPrincipalState({
principalType: PrincipalType.GROUP,
requesterRole: req.user.role,
permBits: result.permBits,
}),
});
} else if (result.principalType === PrincipalType.ROLE) {
principals.push({
@ -307,6 +361,12 @@ const getResourcePermissions = async (req, res) => {
name: result.principalId,
description: `System role: ${result.principalId}`,
accessRoleId: result.accessRoleId,
...getInsightsPrincipalState({
principalType: PrincipalType.ROLE,
principalRole: result.principalId,
requesterRole: req.user.role,
permBits: result.permBits,
}),
});
}
}
@ -391,7 +451,11 @@ const getUserEffectivePermissions = async (req, res) => {
});
res.status(200).json({
permissionBits,
permissionBits: maskAgentInsightsBit({
resourceType,
userRole: req.user.role,
permBits: permissionBits,
}),
});
} catch (error) {
logger.error('Error getting user effective permissions:', error);
@ -556,7 +620,11 @@ const getAllEffectivePermissions = async (req, res) => {
// Convert Map to plain object for JSON response
const result = {};
for (const [resourceId, permBits] of permissionsMap) {
result[resourceId] = permBits;
result[resourceId] = maskAgentInsightsBit({
resourceType,
userRole: req.user.role,
permBits,
});
}
res.status(200).json(result);

View file

@ -9,17 +9,21 @@ jest.mock('@librechat/data-schemas', () => ({
SYSTEM_TENANT_ID: '__SYSTEM__',
}));
const { AccessRoleIds, ResourceType, PrincipalType } =
const { AccessRoleIds, ResourceType, PrincipalType, SystemRoles } =
jest.requireActual('librechat-data-provider');
jest.mock('librechat-data-provider', () => ({
...jest.requireActual('librechat-data-provider'),
}));
jest.mock('@librechat/api', () => ({
enrichRemoteAgentPrincipals: jest.fn(),
backfillRemoteAgentPermissions: jest.fn(),
}));
jest.mock('@librechat/api', () => {
const actual = jest.requireActual('@librechat/api');
return {
...actual,
enrichRemoteAgentPrincipals: jest.fn(),
backfillRemoteAgentPermissions: jest.fn(),
};
});
const mockInvalidateCodeEnvironmentConfigCache = jest.fn().mockResolvedValue(undefined);
jest.mock('~/server/services/Config', () => ({
@ -28,9 +32,11 @@ jest.mock('~/server/services/Config', () => ({
}));
const mockBulkUpdateResourcePermissions = jest.fn();
const mockRestoreInsightsPermissionChanges = jest.fn();
jest.mock('~/server/services/PermissionService', () => ({
bulkUpdateResourcePermissions: (...args) => mockBulkUpdateResourcePermissions(...args),
restoreInsightsPermissionChanges: (...args) => mockRestoreInsightsPermissionChanges(...args),
ensureGroupPrincipalExists: jest.fn(),
getEffectivePermissions: jest.fn(),
ensurePrincipalExists: jest.fn(),
@ -40,6 +46,7 @@ jest.mock('~/server/services/PermissionService', () => ({
}));
const mockRemoveAgentFromUserFavorites = jest.fn();
const mockRecordAuditEntry = jest.fn();
jest.mock('~/models', () => ({
aggregateAclEntries: jest.fn(),
@ -47,6 +54,8 @@ jest.mock('~/models', () => ({
sortPrincipalsByRelevance: jest.fn(),
calculateRelevanceScore: jest.fn(),
removeAgentFromUserFavorites: (...args) => mockRemoveAgentFromUserFavorites(...args),
getAgent: jest.fn(),
recordAuditEntry: (...args) => mockRecordAuditEntry(...args),
}));
jest.mock('~/server/services/GraphApiService', () => ({
@ -221,6 +230,7 @@ describe('PermissionsController', () => {
name: 'Current User',
email: 'current-user@example.com',
avatar: 'current-user.png',
role: SystemRoles.ADMIN,
},
},
{
@ -247,6 +257,7 @@ describe('PermissionsController', () => {
type: PrincipalType.USER,
id: userId.toString(),
email: 'current-user@example.com',
isAdmin: true,
}),
expect.objectContaining({
type: PrincipalType.GROUP,
@ -262,6 +273,7 @@ describe('PermissionsController', () => {
const revokedUserId = new mongoose.Types.ObjectId().toString();
beforeEach(() => {
delete process.env.AUDIT_LOG_FAIL_CLOSED;
mockBulkUpdateResourcePermissions.mockResolvedValue({
granted: [],
updated: [],
@ -270,6 +282,210 @@ describe('PermissionsController', () => {
});
mockRemoveAgentFromUserFavorites.mockResolvedValue(undefined);
db.getAgent.mockResolvedValue({ _id: agentObjectId, id: 'agent-a', name: 'Agent A' });
mockRecordAuditEntry.mockResolvedValue({});
mockRestoreInsightsPermissionChanges.mockResolvedValue(undefined);
});
it('rejects Insights permission changes from non-admin users', async () => {
const req = createMockReq({
params: { resourceType: ResourceType.AGENT, resourceId: agentObjectId },
body: {
updated: [
{
type: PrincipalType.ROLE,
id: 'USER',
accessRoleId: AccessRoleIds.AGENT_VIEWER,
viewInsights: true,
},
],
removed: [],
},
});
const res = createMockRes();
await updateResourcePermissions(req, res);
expect(res.status).toHaveBeenCalledWith(403);
expect(mockBulkUpdateResourcePermissions).not.toHaveBeenCalled();
});
it('rejects non-boolean Insights permission values', async () => {
const req = createMockReq({
params: { resourceType: ResourceType.AGENT, resourceId: agentObjectId },
body: {
updated: [
{
type: PrincipalType.ROLE,
id: 'USER',
accessRoleId: AccessRoleIds.AGENT_VIEWER,
viewInsights: null,
},
],
removed: [],
},
user: { id: 'admin-id', role: SystemRoles.ADMIN },
});
const res = createMockRes();
await updateResourcePermissions(req, res);
expect(res.status).toHaveBeenCalledWith(400);
expect(mockBulkUpdateResourcePermissions).not.toHaveBeenCalled();
});
it('audits each actual Insights access transition made by an admin', async () => {
mockBulkUpdateResourcePermissions.mockResolvedValue({
granted: [
{
type: PrincipalType.ROLE,
id: 'USER',
accessRoleId: AccessRoleIds.AGENT_VIEWER,
viewInsights: true,
},
],
updated: [],
revoked: [],
insightsChanges: [
{ action: 'assigned', principal: { type: PrincipalType.ROLE, id: 'USER' } },
],
errors: [],
});
const req = createMockReq({
params: { resourceType: ResourceType.AGENT, resourceId: agentObjectId },
body: {
updated: [
{
type: PrincipalType.ROLE,
id: 'USER',
accessRoleId: AccessRoleIds.AGENT_VIEWER,
viewInsights: true,
},
],
removed: [],
},
user: {
_id: new mongoose.Types.ObjectId(),
id: 'admin-id',
name: 'Admin',
role: SystemRoles.ADMIN,
tenantId: 'tenant-a',
},
});
const res = createMockRes();
await updateResourcePermissions(req, res);
expect(mockRecordAuditEntry).toHaveBeenCalledWith(
expect.objectContaining({
action: 'permission.insights_assigned',
tenantId: 'tenant-a',
target: expect.objectContaining({ id: 'agent-a' }),
metadata: { principalType: PrincipalType.ROLE, principalId: 'USER' },
}),
{ failClosed: false },
);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json.mock.calls[0][0].results.principals[0]).toEqual(
expect.objectContaining({ viewInsights: true }),
);
});
it('restores unaudited Insights transitions when fail-closed auditing fails', async () => {
process.env.AUDIT_LOG_FAIL_CLOSED = 'true';
const changes = [{ action: 'assigned', principal: { type: PrincipalType.ROLE, id: 'USER' } }];
mockBulkUpdateResourcePermissions.mockResolvedValue({
granted: [],
updated: [],
revoked: [],
insightsChanges: changes,
errors: [],
});
mockRecordAuditEntry.mockRejectedValue(new Error('audit unavailable'));
const req = createMockReq({
params: { resourceType: ResourceType.AGENT, resourceId: agentObjectId },
body: {
updated: [
{
type: PrincipalType.ROLE,
id: 'USER',
accessRoleId: AccessRoleIds.AGENT_VIEWER,
viewInsights: true,
},
],
removed: [],
},
user: { id: 'admin-id', role: SystemRoles.ADMIN, tenantId: 'tenant-a' },
});
const res = createMockRes();
await updateResourcePermissions(req, res);
expect(mockRestoreInsightsPermissionChanges).toHaveBeenCalledWith({
resourceType: ResourceType.AGENT,
resourceId: agentObjectId,
changes,
});
expect(res.status).toHaveBeenCalledWith(500);
});
it('keeps a successful permission response when audit target lookup fails open', async () => {
mockBulkUpdateResourcePermissions.mockResolvedValue({
granted: [],
updated: [],
revoked: [],
insightsChanges: [
{ action: 'assigned', principal: { type: PrincipalType.ROLE, id: 'USER' } },
],
errors: [],
});
db.getAgent.mockRejectedValue(new Error('agent lookup unavailable'));
const req = createMockReq({
params: { resourceType: ResourceType.AGENT, resourceId: agentObjectId },
user: { id: 'admin-id', role: SystemRoles.ADMIN, tenantId: 'tenant-a' },
});
const res = createMockRes();
await updateResourcePermissions(req, res);
expect(mockRestoreInsightsPermissionChanges).not.toHaveBeenCalled();
expect(res.status).toHaveBeenCalledWith(200);
});
it('does not expose the protected bit in non-admin mutation responses', async () => {
mockBulkUpdateResourcePermissions.mockResolvedValue({
granted: [
{
type: PrincipalType.USER,
id: revokedUserId,
accessRoleId: AccessRoleIds.AGENT_EDITOR,
viewInsights: true,
},
],
updated: [],
revoked: [],
insightsChanges: [],
errors: [],
});
const req = createMockReq({
params: { resourceType: ResourceType.AGENT, resourceId: agentObjectId },
body: {
updated: [
{
type: PrincipalType.USER,
id: revokedUserId,
accessRoleId: AccessRoleIds.AGENT_EDITOR,
},
],
removed: [],
},
});
const res = createMockRes();
await updateResourcePermissions(req, res);
expect(res.status).toHaveBeenCalledWith(200);
expect(res.json.mock.calls[0][0].results.principals[0]).not.toHaveProperty('viewInsights');
});
it('removes agent from revoked users favorites on AGENT resource type', async () => {

View file

@ -3610,9 +3610,11 @@ describe('ResumableAgentController resume metadata', () => {
const req = createFailedRequest({
conversationId: undefined,
clientRequestId: 'failed-new-conversation',
agent_id: 'unverified-agent',
parentMessageId: '00000000-0000-0000-0000-000000000000',
endpointOption: {
endpoint: 'azureOpenAI',
agent_id: 'unverified-agent',
modelOptions: { model: 'gpt-4o' },
chatProjectId: '507f1f77bcf86cd799439011',
},
@ -3643,7 +3645,7 @@ describe('ResumableAgentController resume metadata', () => {
model: 'gpt-4o',
chatProjectId: '507f1f77bcf86cd799439011',
}),
expect.any(Object),
expect.objectContaining({ initialAgentId: null }),
);
});
});

View file

@ -272,6 +272,7 @@ async function saveErrorTurn(
liveUserMessage,
liveResponseMessageId,
sender,
initialAgentId,
},
) {
try {
@ -413,7 +414,15 @@ async function saveErrorTurn(
await saveConvo(
reqCtx,
{ conversationId, ...convoFields },
seedConvo ? { context } : { context, noUpsert: true },
seedConvo
? {
context,
initialAgentId:
typeof initialAgentId === 'string' && !isEphemeralAgentId(initialAgentId)
? initialAgentId
: null,
}
: { context, noUpsert: true },
);
} catch (err) {
logger.error('[AgentController] Failed to persist error turn', err);
@ -1371,6 +1380,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
});
let client = null;
let verifiedInitialAgentId = null;
let jobCreatedAt;
let providerExecutionId;
let releaseEventChildLease;
@ -1763,6 +1773,12 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
});
startupTelemetry?.mark('client_initialized');
client = result.client;
if (
typeof client?.options?.agent?.id === 'string' &&
!isEphemeralAgentId(client.options.agent.id)
) {
verifiedInitialAgentId = client.options.agent.id;
}
/** Request-shape validation rejects every known edit/regenerate path, but
* the client owns the final persistence decision. Fail closed if a future
@ -2997,6 +3013,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
liveUserMessage: userMessage,
liveResponseMessageId,
sender: client?.sender,
initialAgentId: verifiedInitialAgentId,
}),
})) === true;
/** A true completion means this owner won the terminal CAS and
@ -3201,6 +3218,7 @@ const ResumableAgentController = async (req, res, next, initializeClient, addTit
endpointOption,
isNewConvo,
errorText: initializationError,
initialAgentId: verifiedInitialAgentId,
}),
})
: GenerationJobManager.completeJob(streamId, initializationError, jobCreatedAt);

View file

@ -453,11 +453,14 @@ async function saveConversation(req, conversationId, agentId, agent) {
{
conversationId,
endpoint: EModelEndpoint.agents,
agentId,
agent_id: agentId,
...(title != null && { title }),
model: agent?.model,
},
{ context: 'Responses API - save conversation' },
{
context: 'Responses API - save conversation',
initialAgentId: agent?.id === agentId ? agentId : null,
},
);
}