Files
LibreChat/packages/data-schemas/src/methods/aclEntry.ts
Atef Bellaaj 1edec579a5 🏗️ feat: Dynamic MCP Server Infrastructure with Access Control (#10787)
* Feature: Dynamic MCP Server with Full UI Management

* 🚦 feat: Add MCP Connection Status icons to MCPBuilder panel (#10805)

* feature: Add MCP server connection status icons to MCPBuilder panel

* refactor: Simplify MCPConfigDialog rendering in MCPBuilderPanel

---------

Co-authored-by: Atef Bellaaj <slalom.bellaaj@external.daimlertruck.com>
Co-authored-by: Danny Avila <danny@librechat.ai>

* fix: address code review feedback for MCP server management

- Fix OAuth secret preservation to avoid mutating input parameter
  by creating a merged config copy in ServerConfigsDB.update()

- Improve error handling in getResourcePermissionsMap to propagate
  critical errors instead of silently returning empty Map

- Extract duplicated MCP server filter logic by exposing selectableServers
  from useMCPServerManager hook and using it in MCPSelect component

* test: Update PermissionService tests to throw errors on invalid resource types

- Changed the test for handling invalid resource types to ensure it throws an error instead of returning an empty permissions map.
- Updated the expectation to check for the specific error message when an invalid resource type is provided.

* feat: Implement retry logic for MCP server creation to handle race conditions

- Enhanced the createMCPServer method to include retry logic with exponential backoff for handling duplicate key errors during concurrent server creation.
- Updated tests to verify that all concurrent requests succeed and that unique server names are generated.
- Added a helper function to identify MongoDB duplicate key errors, improving error handling during server creation.

* refactor: StatusIcon to use CircleCheck for connected status

- Replaced the PlugZap icon with CircleCheck in the ConnectedStatusIcon component to better represent the connected state.
- Ensured consistent icon usage across the component for improved visual clarity.

* test: Update AccessControlService tests to throw errors on invalid resource types

- Modified the test for invalid resource types to ensure it throws an error with a specific message instead of returning an empty permissions map.
- This change enhances error handling and improves test coverage for the AccessControlService.

* fix: Update error message for missing server name in MCP server retrieval

- Changed the error message returned when the server name is not provided from 'MCP ID is required' to 'Server name is required' for better clarity and accuracy in the API response.

---------

Co-authored-by: Atef Bellaaj <slalom.bellaaj@external.daimlertruck.com>
Co-authored-by: Danny Avila <danny@librechat.ai>
2025-12-04 15:37:23 -05:00

365 lines
12 KiB
TypeScript

import { Types } from 'mongoose';
import { PrincipalType, PrincipalModel } from 'librechat-data-provider';
import type { Model, DeleteResult, ClientSession } from 'mongoose';
import type { IAclEntry } from '~/types';
export function createAclEntryMethods(mongoose: typeof import('mongoose')) {
/**
* Find ACL entries for a specific principal (user or group)
* @param principalType - The type of principal ('user', 'group')
* @param principalId - The ID of the principal
* @param resourceType - Optional filter by resource type
* @returns Array of ACL entries
*/
async function findEntriesByPrincipal(
principalType: string,
principalId: string | Types.ObjectId,
resourceType?: string,
): Promise<IAclEntry[]> {
const AclEntry = mongoose.models.AclEntry as Model<IAclEntry>;
const query: Record<string, unknown> = { principalType, principalId };
if (resourceType) {
query.resourceType = resourceType;
}
return await AclEntry.find(query).lean();
}
/**
* Find ACL entries for a specific resource
* @param resourceType - The type of resource ('agent', 'project', 'file')
* @param resourceId - The ID of the resource
* @returns Array of ACL entries
*/
async function findEntriesByResource(
resourceType: string,
resourceId: string | Types.ObjectId,
): Promise<IAclEntry[]> {
const AclEntry = mongoose.models.AclEntry as Model<IAclEntry>;
return await AclEntry.find({ resourceType, resourceId }).lean();
}
/**
* Find all ACL entries for a set of principals (including public)
* @param principalsList - List of principals, each containing { principalType, principalId }
* @param resourceType - The type of resource
* @param resourceId - The ID of the resource
* @returns Array of matching ACL entries
*/
async function findEntriesByPrincipalsAndResource(
principalsList: Array<{ principalType: string; principalId?: string | Types.ObjectId }>,
resourceType: string,
resourceId: string | Types.ObjectId,
): Promise<IAclEntry[]> {
const AclEntry = mongoose.models.AclEntry as Model<IAclEntry>;
const principalsQuery = principalsList.map((p) => ({
principalType: p.principalType,
...(p.principalType !== PrincipalType.PUBLIC && { principalId: p.principalId }),
}));
return await AclEntry.find({
$or: principalsQuery,
resourceType,
resourceId,
}).lean();
}
/**
* Check if a set of principals has a specific permission on a resource
* @param principalsList - List of principals, each containing { principalType, principalId }
* @param resourceType - The type of resource
* @param resourceId - The ID of the resource
* @param permissionBit - The permission bit to check (use PermissionBits enum)
* @returns Whether any of the principals has the permission
*/
async function hasPermission(
principalsList: Array<{ principalType: string; principalId?: string | Types.ObjectId }>,
resourceType: string,
resourceId: string | Types.ObjectId,
permissionBit: number,
): Promise<boolean> {
const AclEntry = mongoose.models.AclEntry as Model<IAclEntry>;
const principalsQuery = principalsList.map((p) => ({
principalType: p.principalType,
...(p.principalType !== PrincipalType.PUBLIC && { principalId: p.principalId }),
}));
const entry = await AclEntry.findOne({
$or: principalsQuery,
resourceType,
resourceId,
permBits: { $bitsAllSet: permissionBit },
}).lean();
return !!entry;
}
/**
* Get the combined effective permissions for a set of principals on a resource
* @param principalsList - List of principals, each containing { principalType, principalId }
* @param resourceType - The type of resource
* @param resourceId - The ID of the resource
* @returns {Promise<number>} Effective permission bitmask
*/
async function getEffectivePermissions(
principalsList: Array<{ principalType: string; principalId?: string | Types.ObjectId }>,
resourceType: string,
resourceId: string | Types.ObjectId,
): Promise<number> {
const aclEntries = await findEntriesByPrincipalsAndResource(
principalsList,
resourceType,
resourceId,
);
let effectiveBits = 0;
for (const entry of aclEntries) {
effectiveBits |= entry.permBits;
}
return effectiveBits;
}
/**
* Get effective permissions for multiple resources in a single query (BATCH)
* Returns a map of resourceId → effectivePermissionBits
*
* @param principalsList - List of principals (user + groups + public)
* @param resourceType - The type of resource ('MCPSERVER', 'AGENT', etc.)
* @param resourceIds - Array of resource IDs to check
* @returns {Promise<Map<string, number>>} Map of resourceId → permission bits
*
* @example
* const principals = await getUserPrincipals({ userId, role });
* const serverIds = [id1, id2, id3];
* const permMap = await getEffectivePermissionsForResources(
* principals,
* ResourceType.MCPSERVER,
* serverIds
* );
* // permMap.get(id1.toString()) → 7 (VIEW|EDIT|DELETE)
*/
async function getEffectivePermissionsForResources(
principalsList: Array<{ principalType: string; principalId?: string | Types.ObjectId }>,
resourceType: string,
resourceIds: Array<string | Types.ObjectId>,
): Promise<Map<string, number>> {
if (!Array.isArray(resourceIds) || resourceIds.length === 0) {
return new Map();
}
const AclEntry = mongoose.models.AclEntry as Model<IAclEntry>;
const principalsQuery = principalsList.map((p) => ({
principalType: p.principalType,
...(p.principalType !== PrincipalType.PUBLIC && { principalId: p.principalId }),
}));
// Batch query for all resources at once
const aclEntries = await AclEntry.find({
$or: principalsQuery,
resourceType,
resourceId: { $in: resourceIds },
}).lean();
// Compute effective permissions per resource
const permissionsMap = new Map<string, number>();
for (const entry of aclEntries) {
const rid = entry.resourceId.toString();
const currentBits = permissionsMap.get(rid) || 0;
permissionsMap.set(rid, currentBits | entry.permBits);
}
return permissionsMap;
}
/**
* Grant permission to a principal for a resource
* @param principalType - The type of principal ('user', 'group', 'public')
* @param principalId - The ID of the principal (null for 'public')
* @param resourceType - The type of resource
* @param resourceId - The ID of the resource
* @param permBits - The permission bits to grant
* @param grantedBy - The ID of the user granting the permission
* @param session - Optional MongoDB session for transactions
* @param roleId - Optional role ID to associate with this permission
* @returns The created or updated ACL entry
*/
async function grantPermission(
principalType: string,
principalId: string | Types.ObjectId | null,
resourceType: string,
resourceId: string | Types.ObjectId,
permBits: number,
grantedBy: string | Types.ObjectId,
session?: ClientSession,
roleId?: string | Types.ObjectId,
): Promise<IAclEntry | null> {
const AclEntry = mongoose.models.AclEntry as Model<IAclEntry>;
const query: Record<string, unknown> = {
principalType,
resourceType,
resourceId,
};
if (principalType !== PrincipalType.PUBLIC) {
query.principalId =
typeof principalId === 'string' && principalType !== PrincipalType.ROLE
? new Types.ObjectId(principalId)
: principalId;
if (principalType === PrincipalType.USER) {
query.principalModel = PrincipalModel.USER;
} else if (principalType === PrincipalType.GROUP) {
query.principalModel = PrincipalModel.GROUP;
} else if (principalType === PrincipalType.ROLE) {
query.principalModel = PrincipalModel.ROLE;
}
}
const update = {
$set: {
permBits,
grantedBy,
grantedAt: new Date(),
...(roleId && { roleId }),
},
};
const options = {
upsert: true,
new: true,
...(session ? { session } : {}),
};
return await AclEntry.findOneAndUpdate(query, update, options);
}
/**
* Revoke permissions from a principal for a resource
* @param principalType - The type of principal ('user', 'group', 'public')
* @param principalId - The ID of the principal (null for 'public')
* @param resourceType - The type of resource
* @param resourceId - The ID of the resource
* @param session - Optional MongoDB session for transactions
* @returns The result of the delete operation
*/
async function revokePermission(
principalType: string,
principalId: string | Types.ObjectId | null,
resourceType: string,
resourceId: string | Types.ObjectId,
session?: ClientSession,
): Promise<DeleteResult> {
const AclEntry = mongoose.models.AclEntry as Model<IAclEntry>;
const query: Record<string, unknown> = {
principalType,
resourceType,
resourceId,
};
if (principalType !== PrincipalType.PUBLIC) {
query.principalId =
typeof principalId === 'string' && principalType !== PrincipalType.ROLE
? new Types.ObjectId(principalId)
: principalId;
}
const options = session ? { session } : {};
return await AclEntry.deleteOne(query, options);
}
/**
* Modify existing permission bits for a principal on a resource
* @param principalType - The type of principal ('user', 'group', 'public')
* @param principalId - The ID of the principal (null for 'public')
* @param resourceType - The type of resource
* @param resourceId - The ID of the resource
* @param addBits - Permission bits to add
* @param removeBits - Permission bits to remove
* @param session - Optional MongoDB session for transactions
* @returns The updated ACL entry
*/
async function modifyPermissionBits(
principalType: string,
principalId: string | Types.ObjectId | null,
resourceType: string,
resourceId: string | Types.ObjectId,
addBits?: number | null,
removeBits?: number | null,
session?: ClientSession,
): Promise<IAclEntry | null> {
const AclEntry = mongoose.models.AclEntry as Model<IAclEntry>;
const query: Record<string, unknown> = {
principalType,
resourceType,
resourceId,
};
if (principalType !== PrincipalType.PUBLIC) {
query.principalId =
typeof principalId === 'string' && principalType !== PrincipalType.ROLE
? new Types.ObjectId(principalId)
: principalId;
}
const update: Record<string, unknown> = {};
if (addBits) {
update.$bit = { permBits: { or: addBits } };
}
if (removeBits) {
if (!update.$bit) update.$bit = {};
const bitUpdate = update.$bit as Record<string, unknown>;
bitUpdate.permBits = { ...(bitUpdate.permBits as Record<string, unknown>), and: ~removeBits };
}
const options = {
new: true,
...(session ? { session } : {}),
};
return await AclEntry.findOneAndUpdate(query, update, options);
}
/**
* Find all resources of a specific type that a set of principals has access to
* @param principalsList - List of principals, each containing { principalType, principalId }
* @param resourceType - The type of resource
* @param requiredPermBit - Required permission bit (use PermissionBits enum)
* @returns Array of resource IDs
*/
async function findAccessibleResources(
principalsList: Array<{ principalType: string; principalId?: string | Types.ObjectId }>,
resourceType: string,
requiredPermBit: number,
): Promise<Types.ObjectId[]> {
const AclEntry = mongoose.models.AclEntry as Model<IAclEntry>;
const principalsQuery = principalsList.map((p) => ({
principalType: p.principalType,
...(p.principalType !== PrincipalType.PUBLIC && { principalId: p.principalId }),
}));
const entries = await AclEntry.find({
$or: principalsQuery,
resourceType,
permBits: { $bitsAllSet: requiredPermBit },
}).distinct('resourceId');
return entries;
}
return {
findEntriesByPrincipal,
findEntriesByResource,
findEntriesByPrincipalsAndResource,
hasPermission,
getEffectivePermissions,
getEffectivePermissionsForResources,
grantPermission,
revokePermission,
modifyPermissionBits,
findAccessibleResources,
};
}
export type AclEntryMethods = ReturnType<typeof createAclEntryMethods>;