mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-02-20 09:24:10 +01:00
* chore: move database model methods to /packages/data-schemas * chore: add TypeScript ESLint rule to warn on unused variables * refactor: model imports to streamline access - Consolidated model imports across various files to improve code organization and reduce redundancy. - Updated imports for models such as Assistant, Message, Conversation, and others to a unified import path. - Adjusted middleware and service files to reflect the new import structure, ensuring functionality remains intact. - Enhanced test files to align with the new import paths, maintaining test coverage and integrity. * chore: migrate database models to packages/data-schemas and refactor all direct Mongoose Model usage outside of data-schemas * test: update agent model mocks in unit tests - Added `getAgent` mock to `client.test.js` to enhance test coverage for agent-related functionality. - Removed redundant `getAgent` and `getAgents` mocks from `openai.spec.js` and `responses.unit.spec.js` to streamline test setup and reduce duplication. - Ensured consistency in agent mock implementations across test files. * fix: update types in data-schemas * refactor: enhance type definitions in transaction and spending methods - Updated type definitions in `checkBalance.ts` to use specific request and response types. - Refined `spendTokens.ts` to utilize a new `SpendTxData` interface for better clarity and type safety. - Improved transaction handling in `transaction.ts` by introducing `TransactionResult` and `TxData` interfaces, ensuring consistent data structures across methods. - Adjusted unit tests in `transaction.spec.ts` to accommodate new type definitions and enhance robustness. * refactor: streamline model imports and enhance code organization - Consolidated model imports across various controllers and services to a unified import path, improving code clarity and reducing redundancy. - Updated multiple files to reflect the new import structure, ensuring all functionalities remain intact. - Enhanced overall code organization by removing duplicate import statements and optimizing the usage of model methods. * feat: implement loadAddedAgent and refactor agent loading logic - Introduced `loadAddedAgent` function to handle loading agents from added conversations, supporting multi-convo parallel execution. - Created a new `load.ts` file to encapsulate agent loading functionalities, including `loadEphemeralAgent` and `loadAgent`. - Updated the `index.ts` file to export the new `load` module instead of the deprecated `loadAgent`. - Enhanced type definitions and improved error handling in the agent loading process. - Adjusted unit tests to reflect changes in the agent loading structure and ensure comprehensive coverage. * refactor: enhance balance handling with new update interface - Introduced `IBalanceUpdate` interface to streamline balance update operations across the codebase. - Updated `upsertBalanceFields` method signatures in `balance.ts`, `transaction.ts`, and related tests to utilize the new interface for improved type safety. - Adjusted type imports in `balance.spec.ts` to include `IBalanceUpdate`, ensuring consistency in balance management functionalities. - Enhanced overall code clarity and maintainability by refining type definitions related to balance operations. * feat: add unit tests for loadAgent functionality and enhance agent loading logic - Introduced comprehensive unit tests for the `loadAgent` function, covering various scenarios including null and empty agent IDs, loading of ephemeral agents, and permission checks. - Enhanced the `initializeClient` function by moving `getConvoFiles` to the correct position in the database method exports, ensuring proper functionality. - Improved test coverage for agent loading, including handling of non-existent agents and user permissions. * chore: reorder memory method exports for consistency - Moved `deleteAllUserMemories` to the correct position in the exported memory methods, ensuring a consistent and logical order of method exports in `memory.ts`.
548 lines
17 KiB
JavaScript
548 lines
17 KiB
JavaScript
/**
|
|
* @import { TUpdateResourcePermissionsRequest, TUpdateResourcePermissionsResponse } from 'librechat-data-provider'
|
|
*/
|
|
|
|
const mongoose = require('mongoose');
|
|
const { logger } = require('@librechat/data-schemas');
|
|
const { ResourceType, PrincipalType, PermissionBits } = require('librechat-data-provider');
|
|
const { enrichRemoteAgentPrincipals, backfillRemoteAgentPermissions } = require('@librechat/api');
|
|
const {
|
|
bulkUpdateResourcePermissions,
|
|
ensureGroupPrincipalExists,
|
|
getResourcePermissionsMap,
|
|
findAccessibleResources,
|
|
getEffectivePermissions,
|
|
ensurePrincipalExists,
|
|
getAvailableRoles,
|
|
} = require('~/server/services/PermissionService');
|
|
const {
|
|
searchPrincipals: searchLocalPrincipals,
|
|
sortPrincipalsByRelevance,
|
|
calculateRelevanceScore,
|
|
findRoleByIdentifier,
|
|
aggregateAclEntries,
|
|
bulkWriteAclEntries,
|
|
} = require('~/models');
|
|
const {
|
|
entraIdPrincipalFeatureEnabled,
|
|
searchEntraIdPrincipals,
|
|
} = require('~/server/services/GraphApiService');
|
|
|
|
/**
|
|
* Generic controller for resource permission endpoints
|
|
* Delegates validation and logic to PermissionService
|
|
*/
|
|
|
|
/**
|
|
* Validates that the resourceType is one of the supported enum values
|
|
* @param {string} resourceType - The resource type to validate
|
|
* @throws {Error} If resourceType is not valid
|
|
*/
|
|
const validateResourceType = (resourceType) => {
|
|
const validTypes = Object.values(ResourceType);
|
|
if (!validTypes.includes(resourceType)) {
|
|
throw new Error(`Invalid resourceType: ${resourceType}. Valid types: ${validTypes.join(', ')}`);
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Bulk update permissions for a resource (grant, update, remove)
|
|
* @route PUT /api/{resourceType}/{resourceId}/permissions
|
|
* @param {Object} req - Express request object
|
|
* @param {Object} req.params - Route parameters
|
|
* @param {string} req.params.resourceType - Resource type (e.g., 'agent')
|
|
* @param {string} req.params.resourceId - Resource ID
|
|
* @param {TUpdateResourcePermissionsRequest} req.body - Request body
|
|
* @param {Object} res - Express response object
|
|
* @returns {Promise<TUpdateResourcePermissionsResponse>} Updated permissions response
|
|
*/
|
|
const updateResourcePermissions = async (req, res) => {
|
|
try {
|
|
const { resourceType, resourceId } = req.params;
|
|
validateResourceType(resourceType);
|
|
|
|
/** @type {TUpdateResourcePermissionsRequest} */
|
|
const { updated, removed, public: isPublic, publicAccessRoleId } = req.body;
|
|
const { id: userId } = req.user;
|
|
|
|
// Prepare principals for the service call
|
|
const updatedPrincipals = [];
|
|
const revokedPrincipals = [];
|
|
|
|
// Add updated principals
|
|
if (updated && Array.isArray(updated)) {
|
|
updatedPrincipals.push(...updated);
|
|
}
|
|
|
|
// Add public permission if enabled
|
|
if (isPublic && publicAccessRoleId) {
|
|
updatedPrincipals.push({
|
|
type: PrincipalType.PUBLIC,
|
|
id: null,
|
|
accessRoleId: publicAccessRoleId,
|
|
});
|
|
}
|
|
|
|
// Prepare authentication context for enhanced group member fetching
|
|
const useEntraId = entraIdPrincipalFeatureEnabled(req.user);
|
|
const authHeader = req.headers.authorization;
|
|
const accessToken =
|
|
authHeader && authHeader.startsWith('Bearer ') ? authHeader.substring(7) : null;
|
|
const authContext =
|
|
useEntraId && accessToken
|
|
? {
|
|
accessToken,
|
|
sub: req.user.openidId,
|
|
}
|
|
: null;
|
|
|
|
// Ensure updated principals exist in the database before processing permissions
|
|
const validatedPrincipals = [];
|
|
for (const principal of updatedPrincipals) {
|
|
try {
|
|
let principalId;
|
|
|
|
if (principal.type === PrincipalType.PUBLIC) {
|
|
principalId = null; // Public principals don't need database records
|
|
} else if (principal.type === PrincipalType.ROLE) {
|
|
principalId = principal.id; // Role principals use role name as ID
|
|
} else if (principal.type === PrincipalType.USER) {
|
|
principalId = await ensurePrincipalExists(principal);
|
|
} else if (principal.type === PrincipalType.GROUP) {
|
|
// Pass authContext to enable member fetching for Entra ID groups when available
|
|
principalId = await ensureGroupPrincipalExists(principal, authContext);
|
|
} else {
|
|
logger.error(`Unsupported principal type: ${principal.type}`);
|
|
continue; // Skip invalid principal types
|
|
}
|
|
|
|
// Update the principal with the validated ID for ACL operations
|
|
validatedPrincipals.push({
|
|
...principal,
|
|
id: principalId,
|
|
});
|
|
} catch (error) {
|
|
logger.error('Error ensuring principal exists:', {
|
|
principal: {
|
|
type: principal.type,
|
|
id: principal.id,
|
|
name: principal.name,
|
|
source: principal.source,
|
|
},
|
|
error: error.message,
|
|
});
|
|
// Continue with other principals instead of failing the entire operation
|
|
continue;
|
|
}
|
|
}
|
|
|
|
// Add removed principals
|
|
if (removed && Array.isArray(removed)) {
|
|
revokedPrincipals.push(...removed);
|
|
}
|
|
|
|
// If public is disabled, add public to revoked list
|
|
if (!isPublic) {
|
|
revokedPrincipals.push({
|
|
type: PrincipalType.PUBLIC,
|
|
id: null,
|
|
});
|
|
}
|
|
|
|
const results = await bulkUpdateResourcePermissions({
|
|
resourceType,
|
|
resourceId,
|
|
updatedPrincipals: validatedPrincipals,
|
|
revokedPrincipals,
|
|
grantedBy: userId,
|
|
});
|
|
|
|
/** @type {TUpdateResourcePermissionsResponse} */
|
|
const response = {
|
|
message: 'Permissions updated successfully',
|
|
results: {
|
|
principals: results.granted,
|
|
public: isPublic || false,
|
|
publicAccessRoleId: isPublic ? publicAccessRoleId : undefined,
|
|
},
|
|
};
|
|
|
|
res.status(200).json(response);
|
|
} catch (error) {
|
|
logger.error('Error updating resource permissions:', error);
|
|
res.status(400).json({
|
|
error: 'Failed to update permissions',
|
|
details: error.message,
|
|
});
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Get principals with their permission roles for a resource (UI-friendly format)
|
|
* Uses efficient aggregation pipeline to join User/Group data in single query
|
|
* @route GET /api/permissions/{resourceType}/{resourceId}
|
|
*/
|
|
const getResourcePermissions = async (req, res) => {
|
|
try {
|
|
const { resourceType, resourceId } = req.params;
|
|
validateResourceType(resourceType);
|
|
|
|
const results = await aggregateAclEntries([
|
|
// Match ACL entries for this resource
|
|
{
|
|
$match: {
|
|
resourceType,
|
|
resourceId: mongoose.Types.ObjectId.isValid(resourceId)
|
|
? mongoose.Types.ObjectId.createFromHexString(resourceId)
|
|
: resourceId,
|
|
},
|
|
},
|
|
// Lookup AccessRole information
|
|
{
|
|
$lookup: {
|
|
from: 'accessroles',
|
|
localField: 'roleId',
|
|
foreignField: '_id',
|
|
as: 'role',
|
|
},
|
|
},
|
|
// Lookup User information (for user principals)
|
|
{
|
|
$lookup: {
|
|
from: 'users',
|
|
localField: 'principalId',
|
|
foreignField: '_id',
|
|
as: 'userInfo',
|
|
},
|
|
},
|
|
// Lookup Group information (for group principals)
|
|
{
|
|
$lookup: {
|
|
from: 'groups',
|
|
localField: 'principalId',
|
|
foreignField: '_id',
|
|
as: 'groupInfo',
|
|
},
|
|
},
|
|
// Project final structure
|
|
{
|
|
$project: {
|
|
principalType: 1,
|
|
principalId: 1,
|
|
accessRoleId: { $arrayElemAt: ['$role.accessRoleId', 0] },
|
|
userInfo: { $arrayElemAt: ['$userInfo', 0] },
|
|
groupInfo: { $arrayElemAt: ['$groupInfo', 0] },
|
|
},
|
|
},
|
|
]);
|
|
|
|
let principals = [];
|
|
let publicPermission = null;
|
|
|
|
// Process aggregation results
|
|
for (const result of results) {
|
|
if (result.principalType === PrincipalType.PUBLIC) {
|
|
publicPermission = {
|
|
public: true,
|
|
publicAccessRoleId: result.accessRoleId,
|
|
};
|
|
} else if (result.principalType === PrincipalType.USER && result.userInfo) {
|
|
principals.push({
|
|
type: PrincipalType.USER,
|
|
id: result.userInfo._id.toString(),
|
|
name: result.userInfo.name || result.userInfo.username,
|
|
email: result.userInfo.email,
|
|
avatar: result.userInfo.avatar,
|
|
source: !result.userInfo._id ? 'entra' : 'local',
|
|
idOnTheSource: result.userInfo.idOnTheSource || result.userInfo._id.toString(),
|
|
accessRoleId: result.accessRoleId,
|
|
});
|
|
} else if (result.principalType === PrincipalType.GROUP && result.groupInfo) {
|
|
principals.push({
|
|
type: PrincipalType.GROUP,
|
|
id: result.groupInfo._id.toString(),
|
|
name: result.groupInfo.name,
|
|
email: result.groupInfo.email,
|
|
description: result.groupInfo.description,
|
|
avatar: result.groupInfo.avatar,
|
|
source: result.groupInfo.source || 'local',
|
|
idOnTheSource: result.groupInfo.idOnTheSource || result.groupInfo._id.toString(),
|
|
accessRoleId: result.accessRoleId,
|
|
});
|
|
} else if (result.principalType === PrincipalType.ROLE) {
|
|
principals.push({
|
|
type: PrincipalType.ROLE,
|
|
/** Role name as ID */
|
|
id: result.principalId,
|
|
/** Display the role name */
|
|
name: result.principalId,
|
|
description: `System role: ${result.principalId}`,
|
|
accessRoleId: result.accessRoleId,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (resourceType === ResourceType.REMOTE_AGENT) {
|
|
const enricherDeps = {
|
|
aggregateAclEntries,
|
|
bulkWriteAclEntries,
|
|
findRoleByIdentifier,
|
|
logger,
|
|
};
|
|
const enrichResult = await enrichRemoteAgentPrincipals(enricherDeps, resourceId, principals);
|
|
principals = enrichResult.principals;
|
|
backfillRemoteAgentPermissions(enricherDeps, resourceId, enrichResult.entriesToBackfill);
|
|
}
|
|
|
|
// Return response in format expected by frontend
|
|
const response = {
|
|
resourceType,
|
|
resourceId,
|
|
principals,
|
|
public: publicPermission?.public || false,
|
|
...(publicPermission?.publicAccessRoleId && {
|
|
publicAccessRoleId: publicPermission.publicAccessRoleId,
|
|
}),
|
|
};
|
|
|
|
res.status(200).json(response);
|
|
} catch (error) {
|
|
logger.error('Error getting resource permissions principals:', error);
|
|
res.status(500).json({
|
|
error: 'Failed to get permissions principals',
|
|
details: error.message,
|
|
});
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Get available roles for a resource type
|
|
* @route GET /api/{resourceType}/roles
|
|
*/
|
|
const getResourceRoles = async (req, res) => {
|
|
try {
|
|
const { resourceType } = req.params;
|
|
validateResourceType(resourceType);
|
|
|
|
const roles = await getAvailableRoles({ resourceType });
|
|
|
|
res.status(200).json(
|
|
roles.map((role) => ({
|
|
accessRoleId: role.accessRoleId,
|
|
name: role.name,
|
|
description: role.description,
|
|
permBits: role.permBits,
|
|
})),
|
|
);
|
|
} catch (error) {
|
|
logger.error('Error getting resource roles:', error);
|
|
res.status(500).json({
|
|
error: 'Failed to get roles',
|
|
details: error.message,
|
|
});
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Get user's effective permission bitmask for a resource
|
|
* @route GET /api/{resourceType}/{resourceId}/effective
|
|
*/
|
|
const getUserEffectivePermissions = async (req, res) => {
|
|
try {
|
|
const { resourceType, resourceId } = req.params;
|
|
validateResourceType(resourceType);
|
|
|
|
const { id: userId } = req.user;
|
|
|
|
const permissionBits = await getEffectivePermissions({
|
|
userId,
|
|
role: req.user.role,
|
|
resourceType,
|
|
resourceId,
|
|
});
|
|
|
|
res.status(200).json({
|
|
permissionBits,
|
|
});
|
|
} catch (error) {
|
|
logger.error('Error getting user effective permissions:', error);
|
|
res.status(500).json({
|
|
error: 'Failed to get effective permissions',
|
|
details: error.message,
|
|
});
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Search for users and groups to grant permissions
|
|
* Supports hybrid local database + Entra ID search when configured
|
|
* @route GET /api/permissions/search-principals
|
|
*/
|
|
const searchPrincipals = async (req, res) => {
|
|
try {
|
|
const { q: query, limit = 20, types } = req.query;
|
|
|
|
if (!query || query.trim().length === 0) {
|
|
return res.status(400).json({
|
|
error: 'Query parameter "q" is required and must not be empty',
|
|
});
|
|
}
|
|
|
|
if (query.trim().length < 2) {
|
|
return res.status(400).json({
|
|
error: 'Query must be at least 2 characters long',
|
|
});
|
|
}
|
|
|
|
const searchLimit = Math.min(Math.max(1, parseInt(limit) || 10), 50);
|
|
|
|
let typeFilters = null;
|
|
if (types) {
|
|
const typesArray = Array.isArray(types) ? types : types.split(',');
|
|
const validTypes = typesArray.filter((t) =>
|
|
[PrincipalType.USER, PrincipalType.GROUP, PrincipalType.ROLE].includes(t),
|
|
);
|
|
typeFilters = validTypes.length > 0 ? validTypes : null;
|
|
}
|
|
|
|
const localResults = await searchLocalPrincipals(query.trim(), searchLimit, typeFilters);
|
|
let allPrincipals = [...localResults];
|
|
|
|
const useEntraId = entraIdPrincipalFeatureEnabled(req.user);
|
|
|
|
if (useEntraId && localResults.length < searchLimit) {
|
|
try {
|
|
let graphType = 'all';
|
|
if (typeFilters && typeFilters.length === 1) {
|
|
const graphTypeMap = {
|
|
[PrincipalType.USER]: 'users',
|
|
[PrincipalType.GROUP]: 'groups',
|
|
};
|
|
const mappedType = graphTypeMap[typeFilters[0]];
|
|
if (mappedType) {
|
|
graphType = mappedType;
|
|
}
|
|
}
|
|
|
|
const authHeader = req.headers.authorization;
|
|
const accessToken =
|
|
authHeader && authHeader.startsWith('Bearer ') ? authHeader.substring(7) : null;
|
|
|
|
if (accessToken) {
|
|
const graphResults = await searchEntraIdPrincipals(
|
|
accessToken,
|
|
req.user.openidId,
|
|
query.trim(),
|
|
graphType,
|
|
searchLimit - localResults.length,
|
|
);
|
|
|
|
const localEmails = new Set(
|
|
localResults.map((p) => p.email?.toLowerCase()).filter(Boolean),
|
|
);
|
|
const localGroupSourceIds = new Set(
|
|
localResults.map((p) => p.idOnTheSource).filter(Boolean),
|
|
);
|
|
|
|
for (const principal of graphResults) {
|
|
const isDuplicateByEmail =
|
|
principal.email && localEmails.has(principal.email.toLowerCase());
|
|
const isDuplicateBySourceId =
|
|
principal.idOnTheSource && localGroupSourceIds.has(principal.idOnTheSource);
|
|
|
|
if (!isDuplicateByEmail && !isDuplicateBySourceId) {
|
|
allPrincipals.push(principal);
|
|
}
|
|
}
|
|
}
|
|
} catch (graphError) {
|
|
logger.warn('Graph API search failed, falling back to local results:', graphError.message);
|
|
}
|
|
}
|
|
const scoredResults = allPrincipals.map((item) => ({
|
|
...item,
|
|
_searchScore: calculateRelevanceScore(item, query.trim()),
|
|
}));
|
|
|
|
const finalResults = sortPrincipalsByRelevance(scoredResults)
|
|
.slice(0, searchLimit)
|
|
.map((result) => {
|
|
const { _searchScore, ...resultWithoutScore } = result;
|
|
return resultWithoutScore;
|
|
});
|
|
|
|
res.status(200).json({
|
|
query: query.trim(),
|
|
limit: searchLimit,
|
|
types: typeFilters,
|
|
results: finalResults,
|
|
count: finalResults.length,
|
|
sources: {
|
|
local: finalResults.filter((r) => r.source === 'local').length,
|
|
entra: finalResults.filter((r) => r.source === 'entra').length,
|
|
},
|
|
});
|
|
} catch (error) {
|
|
logger.error('Error searching principals:', error);
|
|
res.status(500).json({
|
|
error: 'Failed to search principals',
|
|
details: error.message,
|
|
});
|
|
}
|
|
};
|
|
|
|
/**
|
|
* Get user's effective permissions for all accessible resources of a type
|
|
* @route GET /api/permissions/{resourceType}/effective/all
|
|
*/
|
|
const getAllEffectivePermissions = async (req, res) => {
|
|
try {
|
|
const { resourceType } = req.params;
|
|
validateResourceType(resourceType);
|
|
|
|
const { id: userId } = req.user;
|
|
|
|
// Find all resources the user has at least VIEW access to
|
|
const accessibleResourceIds = await findAccessibleResources({
|
|
userId,
|
|
role: req.user.role,
|
|
resourceType,
|
|
requiredPermissions: PermissionBits.VIEW,
|
|
});
|
|
|
|
if (accessibleResourceIds.length === 0) {
|
|
return res.status(200).json({});
|
|
}
|
|
|
|
// Get effective permissions for all accessible resources
|
|
const permissionsMap = await getResourcePermissionsMap({
|
|
userId,
|
|
role: req.user.role,
|
|
resourceType,
|
|
resourceIds: accessibleResourceIds,
|
|
});
|
|
|
|
// Convert Map to plain object for JSON response
|
|
const result = {};
|
|
for (const [resourceId, permBits] of permissionsMap) {
|
|
result[resourceId] = permBits;
|
|
}
|
|
|
|
res.status(200).json(result);
|
|
} catch (error) {
|
|
logger.error('Error getting all effective permissions:', error);
|
|
res.status(500).json({
|
|
error: 'Failed to get all effective permissions',
|
|
details: error.message,
|
|
});
|
|
}
|
|
};
|
|
|
|
module.exports = {
|
|
updateResourcePermissions,
|
|
getResourcePermissions,
|
|
getResourceRoles,
|
|
getUserEffectivePermissions,
|
|
getAllEffectivePermissions,
|
|
searchPrincipals,
|
|
};
|