| |
| |
| |
|
|
| const mongoose = require('mongoose'); |
| const { logger } = require('@librechat/data-schemas'); |
| const { ResourceType, PrincipalType } = require('librechat-data-provider'); |
| const { |
| bulkUpdateResourcePermissions, |
| ensureGroupPrincipalExists, |
| getEffectivePermissions, |
| ensurePrincipalExists, |
| getAvailableRoles, |
| } = require('~/server/services/PermissionService'); |
| const { AclEntry } = require('~/db/models'); |
| const { |
| searchPrincipals: searchLocalPrincipals, |
| sortPrincipalsByRelevance, |
| calculateRelevanceScore, |
| } = require('~/models'); |
| const { |
| entraIdPrincipalFeatureEnabled, |
| searchEntraIdPrincipals, |
| } = require('~/server/services/GraphApiService'); |
|
|
| |
| |
| |
| |
|
|
| |
| |
| |
| |
| |
| const validateResourceType = (resourceType) => { |
| const validTypes = Object.values(ResourceType); |
| if (!validTypes.includes(resourceType)) { |
| throw new Error(`Invalid resourceType: ${resourceType}. Valid types: ${validTypes.join(', ')}`); |
| } |
| }; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const updateResourcePermissions = async (req, res) => { |
| try { |
| const { resourceType, resourceId } = req.params; |
| validateResourceType(resourceType); |
|
|
| |
| const { updated, removed, public: isPublic, publicAccessRoleId } = req.body; |
| const { id: userId } = req.user; |
|
|
| |
| const updatedPrincipals = []; |
| const revokedPrincipals = []; |
|
|
| |
| if (updated && Array.isArray(updated)) { |
| updatedPrincipals.push(...updated); |
| } |
|
|
| |
| if (isPublic && publicAccessRoleId) { |
| updatedPrincipals.push({ |
| type: PrincipalType.PUBLIC, |
| id: null, |
| accessRoleId: publicAccessRoleId, |
| }); |
| } |
|
|
| |
| 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; |
|
|
| |
| const validatedPrincipals = []; |
| for (const principal of updatedPrincipals) { |
| try { |
| let principalId; |
|
|
| if (principal.type === PrincipalType.PUBLIC) { |
| principalId = null; |
| } else if (principal.type === PrincipalType.ROLE) { |
| principalId = principal.id; |
| } else if (principal.type === PrincipalType.USER) { |
| principalId = await ensurePrincipalExists(principal); |
| } else if (principal.type === PrincipalType.GROUP) { |
| |
| principalId = await ensureGroupPrincipalExists(principal, authContext); |
| } else { |
| logger.error(`Unsupported principal type: ${principal.type}`); |
| continue; |
| } |
|
|
| |
| 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; |
| } |
| } |
|
|
| |
| if (removed && Array.isArray(removed)) { |
| revokedPrincipals.push(...removed); |
| } |
|
|
| |
| if (!isPublic) { |
| revokedPrincipals.push({ |
| type: PrincipalType.PUBLIC, |
| id: null, |
| }); |
| } |
|
|
| const results = await bulkUpdateResourcePermissions({ |
| resourceType, |
| resourceId, |
| updatedPrincipals: validatedPrincipals, |
| revokedPrincipals, |
| grantedBy: userId, |
| }); |
|
|
| |
| 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, |
| }); |
| } |
| }; |
|
|
| |
| |
| |
| |
| |
| const getResourcePermissions = async (req, res) => { |
| try { |
| const { resourceType, resourceId } = req.params; |
| validateResourceType(resourceType); |
|
|
| |
| const results = await AclEntry.aggregate([ |
| |
| { |
| $match: { |
| resourceType, |
| resourceId: mongoose.Types.ObjectId.isValid(resourceId) |
| ? mongoose.Types.ObjectId.createFromHexString(resourceId) |
| : resourceId, |
| }, |
| }, |
| |
| { |
| $lookup: { |
| from: 'accessroles', |
| localField: 'roleId', |
| foreignField: '_id', |
| as: 'role', |
| }, |
| }, |
| |
| { |
| $lookup: { |
| from: 'users', |
| localField: 'principalId', |
| foreignField: '_id', |
| as: 'userInfo', |
| }, |
| }, |
| |
| { |
| $lookup: { |
| from: 'groups', |
| localField: 'principalId', |
| foreignField: '_id', |
| as: 'groupInfo', |
| }, |
| }, |
| |
| { |
| $project: { |
| principalType: 1, |
| principalId: 1, |
| accessRoleId: { $arrayElemAt: ['$role.accessRoleId', 0] }, |
| userInfo: { $arrayElemAt: ['$userInfo', 0] }, |
| groupInfo: { $arrayElemAt: ['$groupInfo', 0] }, |
| }, |
| }, |
| ]); |
|
|
| const principals = []; |
| let publicPermission = null; |
|
|
| |
| 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, |
| |
| id: result.principalId, |
| |
| name: result.principalId, |
| description: `System role: ${result.principalId}`, |
| accessRoleId: result.accessRoleId, |
| }); |
| } |
| } |
|
|
| |
| 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, |
| }); |
| } |
| }; |
|
|
| |
| |
| |
| |
| 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, |
| }); |
| } |
| }; |
|
|
| |
| |
| |
| |
| 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, |
| }); |
| } |
| }; |
|
|
| |
| |
| |
| |
| |
| 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, |
| }); |
| } |
| }; |
|
|
| module.exports = { |
| updateResourcePermissions, |
| getResourcePermissions, |
| getResourceRoles, |
| getUserEffectivePermissions, |
| searchPrincipals, |
| }; |
|
|