mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-16 16:30:15 +01:00
🔐 feat: Granular Role-based Permissions + Entra ID Group Discovery (#7804)
WIP: pre-granular-permissions commit
feat: Add category and support contact fields to Agent schema and UI components
Revert "feat: Add category and support contact fields to Agent schema and UI components"
This reverts commit c43a52b4c9.
Fix: Update import for renderHook in useAgentCategories.spec.tsx
fix: Update icon rendering in AgentCategoryDisplay tests to use empty spans
refactor: Improve category synchronization logic and clean up AgentConfig component
refactor: Remove unused UI flow translations from translation.json
feat: agent marketplace features
🔐 feat: Granular Role-based Permissions + Entra ID Group Discovery (#7804)
This commit is contained in:
parent
aa42759ffd
commit
66bd419baa
147 changed files with 17564 additions and 645 deletions
|
|
@ -4,7 +4,8 @@ const { logger } = require('@librechat/data-schemas');
|
|||
const { SystemRoles, Tools, actionDelimiter } = require('librechat-data-provider');
|
||||
const { GLOBAL_PROJECT_NAME, EPHEMERAL_AGENT_ID, mcp_delimiter } =
|
||||
require('librechat-data-provider').Constants;
|
||||
const { CONFIG_STORE, STARTUP_CONFIG } = require('librechat-data-provider').CacheKeys;
|
||||
// Default category value for new agents
|
||||
const AgentCategory = require('./AgentCategory');
|
||||
const {
|
||||
getProjectByName,
|
||||
addAgentIdsToProject,
|
||||
|
|
@ -12,7 +13,82 @@ const {
|
|||
removeAgentFromAllProjects,
|
||||
} = require('./Project');
|
||||
const { getCachedTools } = require('~/server/services/Config');
|
||||
const getLogStores = require('~/cache/getLogStores');
|
||||
|
||||
// Category values are now imported from shared constants
|
||||
|
||||
// Add category field to the Agent schema if it doesn't already exist
|
||||
if (!agentSchema.paths.category) {
|
||||
agentSchema.add({
|
||||
category: {
|
||||
type: String,
|
||||
trim: true,
|
||||
validate: {
|
||||
validator: async function (value) {
|
||||
if (!value) return true; // Allow empty values (will use default)
|
||||
|
||||
// Check if category exists in database
|
||||
const validCategories = await AgentCategory.getValidCategoryValues();
|
||||
return validCategories.includes(value);
|
||||
},
|
||||
message: function (props) {
|
||||
return `"${props.value}" is not a valid agent category. Please check available categories.`;
|
||||
},
|
||||
},
|
||||
index: true,
|
||||
default: 'general',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Add support_contact field to the Agent schema if it doesn't already exist
|
||||
if (!agentSchema.paths.support_contact) {
|
||||
agentSchema.add({
|
||||
support_contact: {
|
||||
type: Object,
|
||||
default: {},
|
||||
name: {
|
||||
type: String,
|
||||
minlength: [3, 'Support contact name must be at least 3 characters.'],
|
||||
trim: true,
|
||||
},
|
||||
email: {
|
||||
type: String,
|
||||
match: [
|
||||
/^\w+([.-]?\w+)*@\w+([.-]?\w+)*(\.\w{2,3})+$/,
|
||||
'Please enter a valid email address.',
|
||||
],
|
||||
trim: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Add promotion field to the Agent schema if it doesn't already exist
|
||||
if (!agentSchema.paths.is_promoted) {
|
||||
agentSchema.add({
|
||||
is_promoted: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
index: true, // Index for efficient promoted agent queries
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Add additional indexes for marketplace functionality
|
||||
agentSchema.index({ projectIds: 1, is_promoted: 1, updatedAt: -1 }); // Optimize promoted agents query
|
||||
agentSchema.index({ category: 1, projectIds: 1, updatedAt: -1 }); // Optimize category filtering
|
||||
agentSchema.index({ projectIds: 1, category: 1 }); // Optimize aggregation pipeline
|
||||
|
||||
// Text indexes for search functionality
|
||||
agentSchema.index(
|
||||
{ name: 'text', description: 'text' },
|
||||
{
|
||||
weights: {
|
||||
name: 3, // Name matches are 3x more important than description matches
|
||||
description: 1,
|
||||
},
|
||||
},
|
||||
);
|
||||
const { getActions } = require('./Action');
|
||||
const { Agent } = require('~/db/models');
|
||||
|
||||
|
|
@ -23,7 +99,7 @@ const { Agent } = require('~/db/models');
|
|||
* @throws {Error} If the agent creation fails.
|
||||
*/
|
||||
const createAgent = async (agentData) => {
|
||||
const { author, ...versionData } = agentData;
|
||||
const { author: _author, ...versionData } = agentData;
|
||||
const timestamp = new Date();
|
||||
const initialAgentData = {
|
||||
...agentData,
|
||||
|
|
@ -34,6 +110,7 @@ const createAgent = async (agentData) => {
|
|||
updatedAt: timestamp,
|
||||
},
|
||||
],
|
||||
category: agentData.category || 'general',
|
||||
};
|
||||
return (await Agent.create(initialAgentData)).toObject();
|
||||
};
|
||||
|
|
@ -131,29 +208,7 @@ const loadAgent = async ({ req, agent_id, endpoint, model_parameters }) => {
|
|||
}
|
||||
|
||||
agent.version = agent.versions ? agent.versions.length : 0;
|
||||
|
||||
if (agent.author.toString() === req.user.id) {
|
||||
return agent;
|
||||
}
|
||||
|
||||
if (!agent.projectIds) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cache = getLogStores(CONFIG_STORE);
|
||||
/** @type {TStartupConfig} */
|
||||
const cachedStartupConfig = await cache.get(STARTUP_CONFIG);
|
||||
let { instanceProjectId } = cachedStartupConfig ?? {};
|
||||
if (!instanceProjectId) {
|
||||
instanceProjectId = (await getProjectByName(GLOBAL_PROJECT_NAME, '_id'))._id.toString();
|
||||
}
|
||||
|
||||
for (const projectObjectId of agent.projectIds) {
|
||||
const projectId = projectObjectId.toString();
|
||||
if (projectId === instanceProjectId) {
|
||||
return agent;
|
||||
}
|
||||
}
|
||||
return agent;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -183,7 +238,7 @@ const isDuplicateVersion = (updateData, currentData, versions, actionsHash = nul
|
|||
'actionsHash', // Exclude actionsHash from direct comparison
|
||||
];
|
||||
|
||||
const { $push, $pull, $addToSet, ...directUpdates } = updateData;
|
||||
const { $push: _$push, $pull: _$pull, $addToSet: _$addToSet, ...directUpdates } = updateData;
|
||||
|
||||
if (Object.keys(directUpdates).length === 0 && !actionsHash) {
|
||||
return null;
|
||||
|
|
@ -278,7 +333,14 @@ const updateAgent = async (searchParameter, updateData, options = {}) => {
|
|||
|
||||
const currentAgent = await Agent.findOne(searchParameter);
|
||||
if (currentAgent) {
|
||||
const { __v, _id, id, versions, author, ...versionData } = currentAgent.toObject();
|
||||
const {
|
||||
__v,
|
||||
_id,
|
||||
id: __id,
|
||||
versions,
|
||||
author: _author,
|
||||
...versionData
|
||||
} = currentAgent.toObject();
|
||||
const { $push, $pull, $addToSet, ...directUpdates } = updateData;
|
||||
|
||||
let actionsHash = null;
|
||||
|
|
@ -462,8 +524,110 @@ const deleteAgent = async (searchParameter) => {
|
|||
return agent;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get agents by accessible IDs with optional cursor-based pagination.
|
||||
* @param {Object} params - The parameters for getting accessible agents.
|
||||
* @param {Array} [params.accessibleIds] - Array of agent ObjectIds the user has ACL access to.
|
||||
* @param {Object} [params.otherParams] - Additional query parameters (including author filter).
|
||||
* @param {number} [params.limit] - Number of agents to return (max 100). If not provided, returns all agents.
|
||||
* @param {string} [params.after] - Cursor for pagination - get agents after this cursor. // base64 encoded JSON string with updatedAt and _id.
|
||||
* @returns {Promise<Object>} A promise that resolves to an object containing the agents data and pagination info.
|
||||
*/
|
||||
const getListAgentsByAccess = async ({
|
||||
accessibleIds = [],
|
||||
otherParams = {},
|
||||
limit = null,
|
||||
after = null,
|
||||
}) => {
|
||||
const isPaginated = limit !== null && limit !== undefined;
|
||||
const normalizedLimit = isPaginated ? Math.min(Math.max(1, parseInt(limit) || 20), 100) : null;
|
||||
|
||||
// Build base query combining ACL accessible agents with other filters
|
||||
const baseQuery = { ...otherParams };
|
||||
|
||||
if (accessibleIds.length > 0) {
|
||||
baseQuery._id = { $in: accessibleIds };
|
||||
}
|
||||
|
||||
// Add cursor condition
|
||||
if (after) {
|
||||
try {
|
||||
const cursor = JSON.parse(Buffer.from(after, 'base64').toString('utf8'));
|
||||
const { updatedAt, _id } = cursor;
|
||||
|
||||
const cursorCondition = {
|
||||
$or: [
|
||||
{ updatedAt: { $lt: new Date(updatedAt) } },
|
||||
{ updatedAt: new Date(updatedAt), _id: { $gt: mongoose.Types.ObjectId(_id) } },
|
||||
],
|
||||
};
|
||||
|
||||
// Merge cursor condition with base query
|
||||
if (Object.keys(baseQuery).length > 0) {
|
||||
baseQuery.$and = [{ ...baseQuery }, cursorCondition];
|
||||
// Remove the original conditions from baseQuery to avoid duplication
|
||||
Object.keys(baseQuery).forEach((key) => {
|
||||
if (key !== '$and') delete baseQuery[key];
|
||||
});
|
||||
} else {
|
||||
Object.assign(baseQuery, cursorCondition);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn('Invalid cursor:', error.message);
|
||||
}
|
||||
}
|
||||
|
||||
let query = Agent.find(baseQuery, {
|
||||
id: 1,
|
||||
_id: 1,
|
||||
name: 1,
|
||||
avatar: 1,
|
||||
author: 1,
|
||||
projectIds: 1,
|
||||
description: 1,
|
||||
updatedAt: 1,
|
||||
}).sort({ updatedAt: -1, _id: 1 });
|
||||
|
||||
// Only apply limit if pagination is requested
|
||||
if (isPaginated) {
|
||||
query = query.limit(normalizedLimit + 1);
|
||||
}
|
||||
|
||||
const agents = await query.lean();
|
||||
|
||||
const hasMore = isPaginated ? agents.length > normalizedLimit : false;
|
||||
const data = (isPaginated ? agents.slice(0, normalizedLimit) : agents).map((agent) => {
|
||||
if (agent.author) {
|
||||
agent.author = agent.author.toString();
|
||||
}
|
||||
return agent;
|
||||
});
|
||||
|
||||
// Generate next cursor only if paginated
|
||||
let nextCursor = null;
|
||||
if (isPaginated && hasMore && data.length > 0) {
|
||||
const lastAgent = agents[normalizedLimit - 1];
|
||||
nextCursor = Buffer.from(
|
||||
JSON.stringify({
|
||||
updatedAt: lastAgent.updatedAt.toISOString(),
|
||||
_id: lastAgent._id.toString(),
|
||||
}),
|
||||
).toString('base64');
|
||||
}
|
||||
|
||||
return {
|
||||
object: 'list',
|
||||
data,
|
||||
first_id: data.length > 0 ? data[0].id : null,
|
||||
last_id: data.length > 0 ? data[data.length - 1].id : null,
|
||||
has_more: hasMore,
|
||||
after: nextCursor,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all agents.
|
||||
* @deprecated Use getListAgentsByAccess for ACL-aware agent listing
|
||||
* @param {Object} searchParameter - The search parameters to find matching agents.
|
||||
* @param {string} searchParameter.author - The user ID of the agent's author.
|
||||
* @returns {Promise<Object>} A promise that resolves to an object containing the agents data and pagination info.
|
||||
|
|
@ -482,13 +646,15 @@ const getListAgents = async (searchParameter) => {
|
|||
const agents = (
|
||||
await Agent.find(query, {
|
||||
id: 1,
|
||||
_id: 0,
|
||||
_id: 1,
|
||||
name: 1,
|
||||
avatar: 1,
|
||||
author: 1,
|
||||
projectIds: 1,
|
||||
description: 1,
|
||||
// @deprecated - isCollaborative replaced by ACL permissions
|
||||
isCollaborative: 1,
|
||||
category: 1,
|
||||
}).lean()
|
||||
).map((agent) => {
|
||||
if (agent.author?.toString() !== author) {
|
||||
|
|
@ -671,6 +837,7 @@ module.exports = {
|
|||
revertAgentVersion,
|
||||
updateAgentProjects,
|
||||
addAgentResourceFile,
|
||||
getListAgentsByAccess,
|
||||
removeAgentResourceFiles,
|
||||
generateActionMetadataHash,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1612,7 +1612,7 @@ describe('models/Agent', () => {
|
|||
expect(result.version).toBe(1);
|
||||
});
|
||||
|
||||
test('should return null when user is not author and agent has no projectIds', async () => {
|
||||
test('should return agent even when user is not author (permissions checked at route level)', async () => {
|
||||
const authorId = new mongoose.Types.ObjectId();
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
const agentId = `agent_${uuidv4()}`;
|
||||
|
|
@ -1633,7 +1633,11 @@ describe('models/Agent', () => {
|
|||
model_parameters: { model: 'gpt-4' },
|
||||
});
|
||||
|
||||
expect(result).toBeFalsy();
|
||||
// With the new permission system, loadAgent returns the agent regardless of permissions
|
||||
// Permission checks are handled at the route level via middleware
|
||||
expect(result).toBeTruthy();
|
||||
expect(result.id).toBe(agentId);
|
||||
expect(result.name).toBe('Test Agent');
|
||||
});
|
||||
|
||||
test('should handle ephemeral agent with no MCP servers', async () => {
|
||||
|
|
@ -1741,7 +1745,7 @@ describe('models/Agent', () => {
|
|||
}
|
||||
});
|
||||
|
||||
test('should handle loadAgent with agent from different project', async () => {
|
||||
test('should return agent from different project (permissions checked at route level)', async () => {
|
||||
const authorId = new mongoose.Types.ObjectId();
|
||||
const userId = new mongoose.Types.ObjectId();
|
||||
const agentId = `agent_${uuidv4()}`;
|
||||
|
|
@ -1764,7 +1768,11 @@ describe('models/Agent', () => {
|
|||
model_parameters: { model: 'gpt-4' },
|
||||
});
|
||||
|
||||
expect(result).toBeFalsy();
|
||||
// With the new permission system, loadAgent returns the agent regardless of permissions
|
||||
// Permission checks are handled at the route level via middleware
|
||||
expect(result).toBeTruthy();
|
||||
expect(result.id).toBe(agentId);
|
||||
expect(result.name).toBe('Project Agent');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
125
api/models/AgentCategory.js
Normal file
125
api/models/AgentCategory.js
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
const mongoose = require('mongoose');
|
||||
|
||||
/**
|
||||
* AgentCategory Schema - Dynamic agent category management
|
||||
* Focused implementation for core features only
|
||||
*/
|
||||
const agentCategorySchema = new mongoose.Schema(
|
||||
{
|
||||
// Unique identifier for the category (e.g., 'general', 'hr', 'finance')
|
||||
value: {
|
||||
type: String,
|
||||
required: true,
|
||||
unique: true,
|
||||
trim: true,
|
||||
lowercase: true,
|
||||
index: true,
|
||||
},
|
||||
|
||||
// Display label for the category
|
||||
label: {
|
||||
type: String,
|
||||
required: true,
|
||||
trim: true,
|
||||
},
|
||||
|
||||
// Description of the category
|
||||
description: {
|
||||
type: String,
|
||||
trim: true,
|
||||
default: '',
|
||||
},
|
||||
|
||||
// Display order for sorting categories
|
||||
order: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
index: true,
|
||||
},
|
||||
|
||||
// Whether the category is active and should be displayed
|
||||
isActive: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
index: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
timestamps: true,
|
||||
}
|
||||
);
|
||||
|
||||
// Indexes for performance
|
||||
agentCategorySchema.index({ isActive: 1, order: 1 });
|
||||
|
||||
/**
|
||||
* Get all active categories sorted by order
|
||||
* @returns {Promise<AgentCategory[]>} Array of active categories
|
||||
*/
|
||||
agentCategorySchema.statics.getActiveCategories = function() {
|
||||
return this.find({ isActive: true })
|
||||
.sort({ order: 1, label: 1 })
|
||||
.lean();
|
||||
};
|
||||
|
||||
/**
|
||||
* Get categories with agent counts
|
||||
* @returns {Promise<AgentCategory[]>} Categories with agent counts
|
||||
*/
|
||||
agentCategorySchema.statics.getCategoriesWithCounts = async function() {
|
||||
const Agent = mongoose.model('agent');
|
||||
|
||||
// Aggregate to get agent counts per category
|
||||
const categoryCounts = await Agent.aggregate([
|
||||
{ $match: { category: { $exists: true, $ne: null } } },
|
||||
{ $group: { _id: '$category', count: { $sum: 1 } } },
|
||||
]);
|
||||
|
||||
// Create a map for quick lookup
|
||||
const countMap = new Map(categoryCounts.map(c => [c._id, c.count]));
|
||||
|
||||
// Get all active categories and add counts
|
||||
const categories = await this.getActiveCategories();
|
||||
|
||||
return categories.map(category => ({
|
||||
...category,
|
||||
agentCount: countMap.get(category.value) || 0,
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Get valid category values for Agent model validation
|
||||
* @returns {Promise<string[]>} Array of valid category values
|
||||
*/
|
||||
agentCategorySchema.statics.getValidCategoryValues = function() {
|
||||
return this.find({ isActive: true })
|
||||
.distinct('value')
|
||||
.lean();
|
||||
};
|
||||
|
||||
/**
|
||||
* Seed initial categories from existing constants
|
||||
*/
|
||||
agentCategorySchema.statics.seedCategories = async function(categories) {
|
||||
const operations = categories.map((category, index) => ({
|
||||
updateOne: {
|
||||
filter: { value: category.value },
|
||||
update: {
|
||||
$setOnInsert: {
|
||||
value: category.value,
|
||||
label: category.label || category.value,
|
||||
description: category.description || '',
|
||||
order: category.order || index,
|
||||
isActive: true,
|
||||
},
|
||||
},
|
||||
upsert: true,
|
||||
},
|
||||
}));
|
||||
|
||||
return this.bulkWrite(operations);
|
||||
};
|
||||
|
||||
const AgentCategory = mongoose.model('AgentCategory', agentCategorySchema);
|
||||
|
||||
module.exports = AgentCategory;
|
||||
|
|
@ -54,6 +54,7 @@
|
|||
"@librechat/data-schemas": "*",
|
||||
"@modelcontextprotocol/sdk": "^1.17.1",
|
||||
"@node-saml/passport-saml": "^5.1.0",
|
||||
"@microsoft/microsoft-graph-client": "^3.0.7",
|
||||
"@waylaidwanderer/fetch-event-source": "^3.0.1",
|
||||
"axios": "^1.8.2",
|
||||
"bcryptjs": "^2.4.3",
|
||||
|
|
|
|||
437
api/server/controllers/PermissionsController.js
Normal file
437
api/server/controllers/PermissionsController.js
Normal file
|
|
@ -0,0 +1,437 @@
|
|||
/**
|
||||
* @import { TUpdateResourcePermissionsRequest, TUpdateResourcePermissionsResponse } from 'librechat-data-provider'
|
||||
*/
|
||||
|
||||
const mongoose = require('mongoose');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const {
|
||||
getAvailableRoles,
|
||||
ensurePrincipalExists,
|
||||
getEffectivePermissions,
|
||||
ensureGroupPrincipalExists,
|
||||
bulkUpdateResourcePermissions,
|
||||
} = require('~/server/services/PermissionService');
|
||||
const { AclEntry } = require('~/db/models');
|
||||
const {
|
||||
searchPrincipals: searchLocalPrincipals,
|
||||
sortPrincipalsByRelevance,
|
||||
calculateRelevanceScore,
|
||||
} = require('~/models');
|
||||
const {
|
||||
searchEntraIdPrincipals,
|
||||
entraIdPrincipalFeatureEnabled,
|
||||
} = require('~/server/services/GraphApiService');
|
||||
|
||||
/**
|
||||
* Generic controller for resource permission endpoints
|
||||
* Delegates validation and logic to PermissionService
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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;
|
||||
/** @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: '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 === 'public') {
|
||||
principalId = null; // Public principals don't need database records
|
||||
} else if (principal.type === 'user') {
|
||||
principalId = await ensurePrincipalExists(principal);
|
||||
} else if (principal.type === '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: '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;
|
||||
|
||||
// Use aggregation pipeline for efficient single-query data retrieval
|
||||
const results = await AclEntry.aggregate([
|
||||
// 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] },
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const principals = [];
|
||||
let publicPermission = null;
|
||||
|
||||
// Process aggregation results
|
||||
for (const result of results) {
|
||||
if (result.principalType === 'public') {
|
||||
publicPermission = {
|
||||
public: true,
|
||||
publicAccessRoleId: result.accessRoleId,
|
||||
};
|
||||
} else if (result.principalType === 'user' && result.userInfo) {
|
||||
principals.push({
|
||||
type: '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 === 'group' && result.groupInfo) {
|
||||
principals.push({
|
||||
type: '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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
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;
|
||||
const { id: userId } = req.user;
|
||||
|
||||
const permissionBits = await getEffectivePermissions({
|
||||
userId,
|
||||
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, type } = 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);
|
||||
const typeFilter = ['user', 'group'].includes(type) ? type : null;
|
||||
|
||||
const localResults = await searchLocalPrincipals(query.trim(), searchLimit, typeFilter);
|
||||
let allPrincipals = [...localResults];
|
||||
|
||||
const useEntraId = entraIdPrincipalFeatureEnabled(req.user);
|
||||
|
||||
if (useEntraId && localResults.length < searchLimit) {
|
||||
try {
|
||||
const graphTypeMap = {
|
||||
user: 'users',
|
||||
group: 'groups',
|
||||
null: 'all',
|
||||
};
|
||||
|
||||
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(),
|
||||
graphTypeMap[typeFilter],
|
||||
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()),
|
||||
}));
|
||||
|
||||
allPrincipals = sortPrincipalsByRelevance(scoredResults)
|
||||
.slice(0, searchLimit)
|
||||
.map((result) => {
|
||||
const { _searchScore, ...resultWithoutScore } = result;
|
||||
return resultWithoutScore;
|
||||
});
|
||||
res.status(200).json({
|
||||
query: query.trim(),
|
||||
limit: searchLimit,
|
||||
type: typeFilter,
|
||||
results: allPrincipals,
|
||||
count: allPrincipals.length,
|
||||
sources: {
|
||||
local: allPrincipals.filter((r) => r.source === 'local').length,
|
||||
entra: allPrincipals.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,
|
||||
};
|
||||
255
api/server/controllers/agents/marketplace.js
Normal file
255
api/server/controllers/agents/marketplace.js
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
const AgentCategory = require('~/models/AgentCategory');
|
||||
const mongoose = require('mongoose');
|
||||
const { logger } = require('~/config');
|
||||
|
||||
// Get the Agent model
|
||||
const Agent = mongoose.model('agent');
|
||||
|
||||
// Default page size for agent browsing
|
||||
const DEFAULT_PAGE_SIZE = 6;
|
||||
|
||||
/**
|
||||
* Common pagination utility for agent queries
|
||||
*
|
||||
* @param {Object} filter - MongoDB filter object
|
||||
* @param {number} page - Page number (1-based)
|
||||
* @param {number} limit - Items per page
|
||||
* @returns {Promise<Object>} Paginated results with agents and pagination info
|
||||
*/
|
||||
const paginateAgents = async (filter, page = 1, limit = DEFAULT_PAGE_SIZE) => {
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
// Get total count for pagination
|
||||
const total = await Agent.countDocuments(filter);
|
||||
|
||||
// Get agents with pagination
|
||||
const agents = await Agent.find(filter)
|
||||
.select('id name description avatar category support_contact authorName')
|
||||
.sort({ updatedAt: -1 })
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.lean();
|
||||
|
||||
// Calculate if there are more agents to load
|
||||
const hasMore = total > page * limit;
|
||||
|
||||
return {
|
||||
agents,
|
||||
pagination: {
|
||||
current: page,
|
||||
hasMore,
|
||||
total,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Get promoted/top picks agents with pagination
|
||||
* Can also return all agents when showAll=true parameter is provided
|
||||
*
|
||||
* @param {Object} req - Express request object
|
||||
* @param {Object} res - Express response object
|
||||
*/
|
||||
const getPromotedAgents = async (req, res) => {
|
||||
try {
|
||||
const page = parseInt(req.query.page) || 1;
|
||||
const limit = parseInt(req.query.limit) || DEFAULT_PAGE_SIZE;
|
||||
|
||||
// Check if this is a request for "all" agents via query parameter
|
||||
const showAllAgents = req.query.showAll === 'true';
|
||||
|
||||
// Base filter for shared agents only
|
||||
const filter = {
|
||||
projectIds: { $exists: true, $ne: [] }, // Only get shared agents
|
||||
};
|
||||
|
||||
// Only add promoted filter if not requesting all agents
|
||||
if (!showAllAgents) {
|
||||
filter.is_promoted = true; // Only get promoted agents
|
||||
}
|
||||
|
||||
const result = await paginateAgents(filter, page, limit);
|
||||
res.status(200).json(result);
|
||||
} catch (error) {
|
||||
logger.error('[/Agents/Marketplace] Error fetching promoted agents:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to fetch promoted agents',
|
||||
userMessage: 'Unable to load agents. Please try refreshing the page.',
|
||||
suggestion: 'Try refreshing the page or check your network connection',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get agents by category with pagination
|
||||
*
|
||||
* @param {Object} req - Express request object
|
||||
* @param {Object} res - Express response object
|
||||
*/
|
||||
const getAgentsByCategory = async (req, res) => {
|
||||
try {
|
||||
const { category } = req.params;
|
||||
const page = parseInt(req.query.page) || 1;
|
||||
const limit = parseInt(req.query.limit) || DEFAULT_PAGE_SIZE;
|
||||
|
||||
const filter = {
|
||||
category,
|
||||
projectIds: { $exists: true, $ne: [] }, // Only get shared agents
|
||||
};
|
||||
|
||||
const result = await paginateAgents(filter, page, limit);
|
||||
|
||||
// Get category description from database
|
||||
const categoryDoc = await AgentCategory.findOne({ value: category, isActive: true });
|
||||
const categoryInfo = {
|
||||
name: category,
|
||||
description: categoryDoc?.description || '',
|
||||
total: result.pagination.total,
|
||||
};
|
||||
|
||||
res.status(200).json({
|
||||
...result,
|
||||
category: categoryInfo,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(
|
||||
`[/Agents/Marketplace] Error fetching agents for category ${req.params.category}:`,
|
||||
error,
|
||||
);
|
||||
res.status(500).json({
|
||||
error: 'Failed to fetch agents by category',
|
||||
userMessage: `Unable to load agents for this category. Please try a different category.`,
|
||||
suggestion: 'Try selecting a different category or refresh the page',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Search agents with filters
|
||||
*
|
||||
* @param {Object} req - Express request object
|
||||
* @param {Object} res - Express response object
|
||||
*/
|
||||
const searchAgents = async (req, res) => {
|
||||
try {
|
||||
const { q, category } = req.query;
|
||||
const page = parseInt(req.query.page) || 1;
|
||||
const limit = parseInt(req.query.limit) || DEFAULT_PAGE_SIZE;
|
||||
|
||||
if (!q || q.trim() === '') {
|
||||
return res.status(400).json({
|
||||
error: 'Search query is required',
|
||||
userMessage: 'Please enter a search term to find agents',
|
||||
suggestion: 'Enter a search term to find agents by name or description',
|
||||
});
|
||||
}
|
||||
|
||||
// Build search filter
|
||||
const filter = {
|
||||
projectIds: { $exists: true, $ne: [] }, // Only get shared agents
|
||||
$or: [
|
||||
{ name: { $regex: q, $options: 'i' } }, // Case-insensitive name search
|
||||
{ description: { $regex: q, $options: 'i' } },
|
||||
],
|
||||
};
|
||||
|
||||
// Add category filter if provided
|
||||
if (category && category !== 'all') {
|
||||
filter.category = category;
|
||||
}
|
||||
|
||||
const result = await paginateAgents(filter, page, limit);
|
||||
|
||||
res.status(200).json({
|
||||
...result,
|
||||
query: q,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('[/Agents/Marketplace] Error searching agents:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to search agents',
|
||||
userMessage: 'Search is temporarily unavailable. Please try again.',
|
||||
suggestion: 'Try a different search term or check your network connection',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all agent categories with counts
|
||||
*
|
||||
* @param {Object} _req - Express request object (unused)
|
||||
* @param {Object} res - Express response object
|
||||
*/
|
||||
const getAgentCategories = async (_req, res) => {
|
||||
try {
|
||||
// Get categories with agent counts from database
|
||||
const categories = await AgentCategory.getCategoriesWithCounts();
|
||||
|
||||
// Get count of promoted agents for Top Picks
|
||||
const promotedCount = await Agent.countDocuments({
|
||||
projectIds: { $exists: true, $ne: [] },
|
||||
is_promoted: true,
|
||||
});
|
||||
|
||||
// Convert to marketplace format (TCategory structure)
|
||||
const formattedCategories = categories.map((category) => ({
|
||||
value: category.value,
|
||||
label: category.label,
|
||||
count: category.agentCount,
|
||||
description: category.description,
|
||||
}));
|
||||
|
||||
// Add promoted category if agents exist
|
||||
if (promotedCount > 0) {
|
||||
formattedCategories.unshift({
|
||||
value: 'promoted',
|
||||
label: 'Promoted',
|
||||
count: promotedCount,
|
||||
description: 'Our recommended agents',
|
||||
});
|
||||
}
|
||||
|
||||
// Get total count of all shared agents for "All" category
|
||||
const totalAgents = await Agent.countDocuments({
|
||||
projectIds: { $exists: true, $ne: [] },
|
||||
});
|
||||
|
||||
// Add "All" category at the end
|
||||
formattedCategories.push({
|
||||
value: 'all',
|
||||
label: 'All',
|
||||
count: totalAgents,
|
||||
description: 'All available agents',
|
||||
});
|
||||
|
||||
res.status(200).json(formattedCategories);
|
||||
} catch (error) {
|
||||
logger.error('[/Agents/Marketplace] Error fetching agent categories:', error);
|
||||
res.status(500).json({
|
||||
error: 'Failed to fetch agent categories',
|
||||
userMessage: 'Unable to load categories. Please refresh the page.',
|
||||
suggestion: 'Try refreshing the page or check your network connection',
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get all agents with pagination (for "all" category)
|
||||
* This is an alias for getPromotedAgents with showAll=true for backwards compatibility
|
||||
*
|
||||
* @param {Object} req - Express request object
|
||||
* @param {Object} res - Express response object
|
||||
*/
|
||||
const getAllAgents = async (req, res) => {
|
||||
// Set showAll parameter and delegate to getPromotedAgents
|
||||
req.query.showAll = 'true';
|
||||
return getPromotedAgents(req, res);
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getPromotedAgents,
|
||||
getAgentsByCategory,
|
||||
searchAgents,
|
||||
getAgentCategories,
|
||||
getAllAgents,
|
||||
};
|
||||
|
|
@ -1,13 +1,12 @@
|
|||
const { z } = require('zod');
|
||||
const fs = require('fs').promises;
|
||||
const { nanoid } = require('nanoid');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { logger, PermissionBits } = require('@librechat/data-schemas');
|
||||
const { agentCreateSchema, agentUpdateSchema } = require('@librechat/api');
|
||||
const {
|
||||
Tools,
|
||||
Constants,
|
||||
FileSources,
|
||||
SystemRoles,
|
||||
FileSources,
|
||||
EToolResources,
|
||||
actionDelimiter,
|
||||
removeNullishValues,
|
||||
|
|
@ -17,16 +16,20 @@ const {
|
|||
createAgent,
|
||||
updateAgent,
|
||||
deleteAgent,
|
||||
getListAgents,
|
||||
getListAgentsByAccess,
|
||||
} = require('~/models/Agent');
|
||||
const {
|
||||
grantPermission,
|
||||
findAccessibleResources,
|
||||
findPubliclyAccessibleResources,
|
||||
hasPublicPermission,
|
||||
} = require('~/server/services/PermissionService');
|
||||
const { getStrategyFunctions } = require('~/server/services/Files/strategies');
|
||||
const { resizeAvatar } = require('~/server/services/Files/images/avatar');
|
||||
const { refreshS3Url } = require('~/server/services/Files/S3/crud');
|
||||
const { filterFile } = require('~/server/services/Files/process');
|
||||
const { updateAction, getActions } = require('~/models/Action');
|
||||
const { getCachedTools } = require('~/server/services/Config');
|
||||
const { updateAgentProjects } = require('~/models/Agent');
|
||||
const { getProjectByName } = require('~/models/Project');
|
||||
const { revertAgentVersion } = require('~/models/Agent');
|
||||
const { deleteFileByFilter } = require('~/models/File');
|
||||
|
||||
|
|
@ -67,6 +70,27 @@ const createAgentHandler = async (req, res) => {
|
|||
}
|
||||
|
||||
const agent = await createAgent(agentData);
|
||||
|
||||
// Automatically grant owner permissions to the creator
|
||||
try {
|
||||
await grantPermission({
|
||||
principalType: 'user',
|
||||
principalId: userId,
|
||||
resourceType: 'agent',
|
||||
resourceId: agent._id,
|
||||
accessRoleId: 'agent_owner',
|
||||
grantedBy: userId,
|
||||
});
|
||||
logger.debug(
|
||||
`[createAgent] Granted owner permissions to user ${userId} for agent ${agent.id}`,
|
||||
);
|
||||
} catch (permissionError) {
|
||||
logger.error(
|
||||
`[createAgent] Failed to grant owner permissions for agent ${agent.id}:`,
|
||||
permissionError,
|
||||
);
|
||||
}
|
||||
|
||||
res.status(201).json(agent);
|
||||
} catch (error) {
|
||||
if (error instanceof z.ZodError) {
|
||||
|
|
@ -89,21 +113,14 @@ const createAgentHandler = async (req, res) => {
|
|||
* @returns {Promise<Agent>} 200 - success response - application/json
|
||||
* @returns {Error} 404 - Agent not found
|
||||
*/
|
||||
const getAgentHandler = async (req, res) => {
|
||||
const getAgentHandler = async (req, res, expandProperties = false) => {
|
||||
try {
|
||||
const id = req.params.id;
|
||||
const author = req.user.id;
|
||||
|
||||
let query = { id, author };
|
||||
|
||||
const globalProject = await getProjectByName(Constants.GLOBAL_PROJECT_NAME, ['agentIds']);
|
||||
if (globalProject && (globalProject.agentIds?.length ?? 0) > 0) {
|
||||
query = {
|
||||
$or: [{ id, $in: globalProject.agentIds }, query],
|
||||
};
|
||||
}
|
||||
|
||||
const agent = await getAgent(query);
|
||||
// Permissions are validated by middleware before calling this function
|
||||
// Simply load the agent by ID
|
||||
const agent = await getAgent({ id });
|
||||
|
||||
if (!agent) {
|
||||
return res.status(404).json({ error: 'Agent not found' });
|
||||
|
|
@ -120,23 +137,45 @@ const getAgentHandler = async (req, res) => {
|
|||
}
|
||||
|
||||
agent.author = agent.author.toString();
|
||||
|
||||
// @deprecated - isCollaborative replaced by ACL permissions
|
||||
agent.isCollaborative = !!agent.isCollaborative;
|
||||
|
||||
// Check if agent is public
|
||||
const isPublic = await hasPublicPermission({
|
||||
resourceType: 'agent',
|
||||
resourceId: agent._id,
|
||||
requiredPermissions: PermissionBits.VIEW,
|
||||
});
|
||||
agent.isPublic = isPublic;
|
||||
|
||||
if (agent.author !== author) {
|
||||
delete agent.author;
|
||||
}
|
||||
|
||||
if (!agent.isCollaborative && agent.author !== author && req.user.role !== SystemRoles.ADMIN) {
|
||||
if (!expandProperties) {
|
||||
// VIEW permission: Basic agent info only
|
||||
return res.status(200).json({
|
||||
_id: agent._id,
|
||||
id: agent.id,
|
||||
name: agent.name,
|
||||
description: agent.description,
|
||||
avatar: agent.avatar,
|
||||
author: agent.author,
|
||||
provider: agent.provider,
|
||||
model: agent.model,
|
||||
projectIds: agent.projectIds,
|
||||
// @deprecated - isCollaborative replaced by ACL permissions
|
||||
isCollaborative: agent.isCollaborative,
|
||||
isPublic: agent.isPublic,
|
||||
version: agent.version,
|
||||
// Safe metadata
|
||||
createdAt: agent.createdAt,
|
||||
updatedAt: agent.updatedAt,
|
||||
});
|
||||
}
|
||||
|
||||
// EDIT permission: Full agent details including sensitive configuration
|
||||
return res.status(200).json(agent);
|
||||
} catch (error) {
|
||||
logger.error('[/Agents/:id] Error retrieving agent', error);
|
||||
|
|
@ -157,43 +196,20 @@ const updateAgentHandler = async (req, res) => {
|
|||
try {
|
||||
const id = req.params.id;
|
||||
const validatedData = agentUpdateSchema.parse(req.body);
|
||||
const { projectIds, removeProjectIds, ...updateData } = removeNullishValues(validatedData);
|
||||
const isAdmin = req.user.role === SystemRoles.ADMIN;
|
||||
const { _id, ...updateData } = removeNullishValues(validatedData);
|
||||
const existingAgent = await getAgent({ id });
|
||||
|
||||
if (!existingAgent) {
|
||||
return res.status(404).json({ error: 'Agent not found' });
|
||||
}
|
||||
|
||||
const isAuthor = existingAgent.author.toString() === req.user.id;
|
||||
const hasEditPermission = existingAgent.isCollaborative || isAdmin || isAuthor;
|
||||
|
||||
if (!hasEditPermission) {
|
||||
return res.status(403).json({
|
||||
error: 'You do not have permission to modify this non-collaborative agent',
|
||||
});
|
||||
}
|
||||
|
||||
/** @type {boolean} */
|
||||
const isProjectUpdate = (projectIds?.length ?? 0) > 0 || (removeProjectIds?.length ?? 0) > 0;
|
||||
|
||||
let updatedAgent =
|
||||
Object.keys(updateData).length > 0
|
||||
? await updateAgent({ id }, updateData, {
|
||||
updatingUserId: req.user.id,
|
||||
skipVersioning: isProjectUpdate,
|
||||
})
|
||||
: existingAgent;
|
||||
|
||||
if (isProjectUpdate) {
|
||||
updatedAgent = await updateAgentProjects({
|
||||
user: req.user,
|
||||
agentId: id,
|
||||
projectIds,
|
||||
removeProjectIds,
|
||||
});
|
||||
}
|
||||
|
||||
// Add version count to the response
|
||||
updatedAgent.version = updatedAgent.versions ? updatedAgent.versions.length : 0;
|
||||
|
||||
|
|
@ -321,6 +337,26 @@ const duplicateAgentHandler = async (req, res) => {
|
|||
newAgentData.actions = agentActions;
|
||||
const newAgent = await createAgent(newAgentData);
|
||||
|
||||
// Automatically grant owner permissions to the duplicator
|
||||
try {
|
||||
await grantPermission({
|
||||
principalType: 'user',
|
||||
principalId: userId,
|
||||
resourceType: 'agent',
|
||||
resourceId: newAgent._id,
|
||||
accessRoleId: 'agent_owner',
|
||||
grantedBy: userId,
|
||||
});
|
||||
logger.debug(
|
||||
`[duplicateAgent] Granted owner permissions to user ${userId} for duplicated agent ${newAgent.id}`,
|
||||
);
|
||||
} catch (permissionError) {
|
||||
logger.error(
|
||||
`[duplicateAgent] Failed to grant owner permissions for duplicated agent ${newAgent.id}:`,
|
||||
permissionError,
|
||||
);
|
||||
}
|
||||
|
||||
return res.status(201).json({
|
||||
agent: newAgent,
|
||||
actions: newActionsList,
|
||||
|
|
@ -347,7 +383,7 @@ const deleteAgentHandler = async (req, res) => {
|
|||
if (!agent) {
|
||||
return res.status(404).json({ error: 'Agent not found' });
|
||||
}
|
||||
await deleteAgent({ id, author: req.user.id });
|
||||
await deleteAgent({ id });
|
||||
return res.json({ message: 'Agent deleted' });
|
||||
} catch (error) {
|
||||
logger.error('[/Agents/:id] Error deleting Agent', error);
|
||||
|
|
@ -356,7 +392,7 @@ const deleteAgentHandler = async (req, res) => {
|
|||
};
|
||||
|
||||
/**
|
||||
*
|
||||
* Lists agents using ACL-aware permissions (ownership + explicit shares).
|
||||
* @route GET /Agents
|
||||
* @param {object} req - Express Request
|
||||
* @param {object} req.query - Request query
|
||||
|
|
@ -365,9 +401,31 @@ const deleteAgentHandler = async (req, res) => {
|
|||
*/
|
||||
const getListAgentsHandler = async (req, res) => {
|
||||
try {
|
||||
const data = await getListAgents({
|
||||
author: req.user.id,
|
||||
const userId = req.user.id;
|
||||
|
||||
// Get agent IDs the user has VIEW access to via ACL
|
||||
const accessibleIds = await findAccessibleResources({
|
||||
userId,
|
||||
resourceType: 'agent',
|
||||
requiredPermissions: PermissionBits.VIEW,
|
||||
});
|
||||
const publiclyAccessibleIds = await findPubliclyAccessibleResources({
|
||||
resourceType: 'agent',
|
||||
requiredPermissions: PermissionBits.VIEW,
|
||||
});
|
||||
// Use the new ACL-aware function
|
||||
const data = await getListAgentsByAccess({
|
||||
accessibleIds,
|
||||
otherParams: {}, // Can add query params here if needed
|
||||
});
|
||||
if (data?.data?.length) {
|
||||
data.data = data.data.map((agent) => {
|
||||
if (publiclyAccessibleIds.some((id) => id.equals(agent._id))) {
|
||||
agent.isPublic = true;
|
||||
}
|
||||
return agent;
|
||||
});
|
||||
}
|
||||
return res.json(data);
|
||||
} catch (error) {
|
||||
logger.error('[/Agents] Error listing Agents', error);
|
||||
|
|
|
|||
|
|
@ -115,6 +115,8 @@ const startServer = async () => {
|
|||
app.use('/api/agents', routes.agents);
|
||||
app.use('/api/banner', routes.banner);
|
||||
app.use('/api/memories', routes.memories);
|
||||
app.use('/api/permissions', routes.accessPermissions);
|
||||
|
||||
app.use('/api/tags', routes.tags);
|
||||
app.use('/api/mcp', routes.mcp);
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,97 @@
|
|||
const { logger } = require('@librechat/data-schemas');
|
||||
const { Constants, isAgentsEndpoint } = require('librechat-data-provider');
|
||||
const { canAccessResource } = require('./canAccessResource');
|
||||
const { getAgent } = require('~/models/Agent');
|
||||
|
||||
/**
|
||||
* Agent ID resolver function for agent_id from request body
|
||||
* Resolves custom agent ID (e.g., "agent_abc123") to MongoDB ObjectId
|
||||
* This is used specifically for chat routes where agent_id comes from request body
|
||||
*
|
||||
* @param {string} agentCustomId - Custom agent ID from request body
|
||||
* @returns {Promise<Object|null>} Agent document with _id field, or null if not found
|
||||
*/
|
||||
const resolveAgentIdFromBody = async (agentCustomId) => {
|
||||
// Handle ephemeral agents - they don't need permission checks
|
||||
if (agentCustomId === Constants.EPHEMERAL_AGENT_ID) {
|
||||
return null; // No permission check needed for ephemeral agents
|
||||
}
|
||||
|
||||
return await getAgent({ id: agentCustomId });
|
||||
};
|
||||
|
||||
/**
|
||||
* Middleware factory that creates middleware to check agent access permissions from request body.
|
||||
* This middleware is specifically designed for chat routes where the agent_id comes from req.body
|
||||
* instead of route parameters.
|
||||
*
|
||||
* @param {Object} options - Configuration options
|
||||
* @param {number} options.requiredPermission - The permission bit required (1=view, 2=edit, 4=delete, 8=share)
|
||||
* @returns {Function} Express middleware function
|
||||
*
|
||||
* @example
|
||||
* // Basic usage for agent chat (requires VIEW permission)
|
||||
* router.post('/chat',
|
||||
* canAccessAgentFromBody({ requiredPermission: PermissionBits.VIEW }),
|
||||
* buildEndpointOption,
|
||||
* chatController
|
||||
* );
|
||||
*/
|
||||
const canAccessAgentFromBody = (options) => {
|
||||
const { requiredPermission } = options;
|
||||
|
||||
// Validate required options
|
||||
if (!requiredPermission || typeof requiredPermission !== 'number') {
|
||||
throw new Error('canAccessAgentFromBody: requiredPermission is required and must be a number');
|
||||
}
|
||||
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
const { endpoint, agent_id } = req.body;
|
||||
let agentId = agent_id;
|
||||
|
||||
if (!isAgentsEndpoint(endpoint)) {
|
||||
agentId = Constants.EPHEMERAL_AGENT_ID;
|
||||
}
|
||||
|
||||
if (!agentId) {
|
||||
return res.status(400).json({
|
||||
error: 'Bad Request',
|
||||
message: 'agent_id is required in request body',
|
||||
});
|
||||
}
|
||||
|
||||
// Skip permission checks for ephemeral agents
|
||||
if (agentId === Constants.EPHEMERAL_AGENT_ID) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const agentAccessMiddleware = canAccessResource({
|
||||
resourceType: 'agent',
|
||||
requiredPermission,
|
||||
resourceIdParam: 'agent_id', // This will be ignored since we use custom resolver
|
||||
idResolver: () => resolveAgentIdFromBody(agentId),
|
||||
});
|
||||
|
||||
const tempReq = {
|
||||
...req,
|
||||
params: {
|
||||
...req.params,
|
||||
agent_id: agentId,
|
||||
},
|
||||
};
|
||||
|
||||
return agentAccessMiddleware(tempReq, res, next);
|
||||
} catch (error) {
|
||||
logger.error('Failed to validate agent access permissions', error);
|
||||
return res.status(500).json({
|
||||
error: 'Internal Server Error',
|
||||
message: 'Failed to validate agent access permissions',
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
canAccessAgentFromBody,
|
||||
};
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
const { getAgent } = require('~/models/Agent');
|
||||
const { canAccessResource } = require('./canAccessResource');
|
||||
|
||||
/**
|
||||
* Agent ID resolver function
|
||||
* Resolves custom agent ID (e.g., "agent_abc123") to MongoDB ObjectId
|
||||
*
|
||||
* @param {string} agentCustomId - Custom agent ID from route parameter
|
||||
* @returns {Promise<Object|null>} Agent document with _id field, or null if not found
|
||||
*/
|
||||
const resolveAgentId = async (agentCustomId) => {
|
||||
return await getAgent({ id: agentCustomId });
|
||||
};
|
||||
|
||||
/**
|
||||
* Agent-specific middleware factory that creates middleware to check agent access permissions.
|
||||
* This middleware extends the generic canAccessResource to handle agent custom ID resolution.
|
||||
*
|
||||
* @param {Object} options - Configuration options
|
||||
* @param {number} options.requiredPermission - The permission bit required (1=view, 2=edit, 4=delete, 8=share)
|
||||
* @param {string} [options.resourceIdParam='id'] - The name of the route parameter containing the agent custom ID
|
||||
* @returns {Function} Express middleware function
|
||||
*
|
||||
* @example
|
||||
* // Basic usage for viewing agents
|
||||
* router.get('/agents/:id',
|
||||
* canAccessAgentResource({ requiredPermission: 1 }),
|
||||
* getAgent
|
||||
* );
|
||||
*
|
||||
* @example
|
||||
* // Custom resource ID parameter and edit permission
|
||||
* router.patch('/agents/:agent_id',
|
||||
* canAccessAgentResource({
|
||||
* requiredPermission: 2,
|
||||
* resourceIdParam: 'agent_id'
|
||||
* }),
|
||||
* updateAgent
|
||||
* );
|
||||
*/
|
||||
const canAccessAgentResource = (options) => {
|
||||
const { requiredPermission, resourceIdParam = 'id' } = options;
|
||||
|
||||
if (!requiredPermission || typeof requiredPermission !== 'number') {
|
||||
throw new Error('canAccessAgentResource: requiredPermission is required and must be a number');
|
||||
}
|
||||
|
||||
return canAccessResource({
|
||||
resourceType: 'agent',
|
||||
requiredPermission,
|
||||
resourceIdParam,
|
||||
idResolver: resolveAgentId,
|
||||
});
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
canAccessAgentResource,
|
||||
};
|
||||
|
|
@ -0,0 +1,384 @@
|
|||
const mongoose = require('mongoose');
|
||||
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||
const { canAccessAgentResource } = require('./canAccessAgentResource');
|
||||
const { User, Role, AclEntry } = require('~/db/models');
|
||||
const { createAgent } = require('~/models/Agent');
|
||||
|
||||
describe('canAccessAgentResource middleware', () => {
|
||||
let mongoServer;
|
||||
let req, res, next;
|
||||
let testUser;
|
||||
|
||||
beforeAll(async () => {
|
||||
mongoServer = await MongoMemoryServer.create();
|
||||
const mongoUri = mongoServer.getUri();
|
||||
await mongoose.connect(mongoUri);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await mongoose.disconnect();
|
||||
await mongoServer.stop();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await mongoose.connection.dropDatabase();
|
||||
await Role.create({
|
||||
name: 'test-role',
|
||||
permissions: {
|
||||
AGENTS: {
|
||||
USE: true,
|
||||
CREATE: true,
|
||||
SHARED_GLOBAL: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Create a test user
|
||||
testUser = await User.create({
|
||||
email: 'test@example.com',
|
||||
name: 'Test User',
|
||||
username: 'testuser',
|
||||
role: 'test-role',
|
||||
});
|
||||
|
||||
req = {
|
||||
user: { id: testUser._id.toString(), role: 'test-role' },
|
||||
params: {},
|
||||
};
|
||||
res = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
json: jest.fn(),
|
||||
};
|
||||
next = jest.fn();
|
||||
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('middleware factory', () => {
|
||||
test('should throw error if requiredPermission is not provided', () => {
|
||||
expect(() => canAccessAgentResource({})).toThrow(
|
||||
'canAccessAgentResource: requiredPermission is required and must be a number',
|
||||
);
|
||||
});
|
||||
|
||||
test('should throw error if requiredPermission is not a number', () => {
|
||||
expect(() => canAccessAgentResource({ requiredPermission: '1' })).toThrow(
|
||||
'canAccessAgentResource: requiredPermission is required and must be a number',
|
||||
);
|
||||
});
|
||||
|
||||
test('should create middleware with default resourceIdParam', () => {
|
||||
const middleware = canAccessAgentResource({ requiredPermission: 1 });
|
||||
expect(typeof middleware).toBe('function');
|
||||
expect(middleware.length).toBe(3); // Express middleware signature
|
||||
});
|
||||
|
||||
test('should create middleware with custom resourceIdParam', () => {
|
||||
const middleware = canAccessAgentResource({
|
||||
requiredPermission: 2,
|
||||
resourceIdParam: 'agent_id',
|
||||
});
|
||||
expect(typeof middleware).toBe('function');
|
||||
expect(middleware.length).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('permission checking with real agents', () => {
|
||||
test('should allow access when user is the agent author', async () => {
|
||||
// Create an agent owned by the test user
|
||||
const agent = await createAgent({
|
||||
id: `agent_${Date.now()}`,
|
||||
name: 'Test Agent',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
author: testUser._id,
|
||||
});
|
||||
|
||||
// Create ACL entry for the author (owner permissions)
|
||||
await AclEntry.create({
|
||||
principalType: 'user',
|
||||
principalId: testUser._id,
|
||||
principalModel: 'User',
|
||||
resourceType: 'agent',
|
||||
resourceId: agent._id,
|
||||
permBits: 15, // All permissions (1+2+4+8)
|
||||
grantedBy: testUser._id,
|
||||
});
|
||||
|
||||
req.params.id = agent.id;
|
||||
|
||||
const middleware = canAccessAgentResource({ requiredPermission: 1 }); // VIEW permission
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should deny access when user is not the author and has no ACL entry', async () => {
|
||||
// Create an agent owned by a different user
|
||||
const otherUser = await User.create({
|
||||
email: 'other@example.com',
|
||||
name: 'Other User',
|
||||
username: 'otheruser',
|
||||
role: 'test-role',
|
||||
});
|
||||
|
||||
const agent = await createAgent({
|
||||
id: `agent_${Date.now()}`,
|
||||
name: 'Other User Agent',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
author: otherUser._id,
|
||||
});
|
||||
|
||||
// Create ACL entry for the other user (owner)
|
||||
await AclEntry.create({
|
||||
principalType: 'user',
|
||||
principalId: otherUser._id,
|
||||
principalModel: 'User',
|
||||
resourceType: 'agent',
|
||||
resourceId: agent._id,
|
||||
permBits: 15, // All permissions
|
||||
grantedBy: otherUser._id,
|
||||
});
|
||||
|
||||
req.params.id = agent.id;
|
||||
|
||||
const middleware = canAccessAgentResource({ requiredPermission: 1 }); // VIEW permission
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Forbidden',
|
||||
message: 'Insufficient permissions to access this agent',
|
||||
});
|
||||
});
|
||||
|
||||
test('should allow access when user has ACL entry with sufficient permissions', async () => {
|
||||
// Create an agent owned by a different user
|
||||
const otherUser = await User.create({
|
||||
email: 'other2@example.com',
|
||||
name: 'Other User 2',
|
||||
username: 'otheruser2',
|
||||
role: 'test-role',
|
||||
});
|
||||
|
||||
const agent = await createAgent({
|
||||
id: `agent_${Date.now()}`,
|
||||
name: 'Shared Agent',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
author: otherUser._id,
|
||||
});
|
||||
|
||||
// Create ACL entry granting view permission to test user
|
||||
await AclEntry.create({
|
||||
principalType: 'user',
|
||||
principalId: testUser._id,
|
||||
principalModel: 'User',
|
||||
resourceType: 'agent',
|
||||
resourceId: agent._id,
|
||||
permBits: 1, // VIEW permission
|
||||
grantedBy: otherUser._id,
|
||||
});
|
||||
|
||||
req.params.id = agent.id;
|
||||
|
||||
const middleware = canAccessAgentResource({ requiredPermission: 1 }); // VIEW permission
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should deny access when ACL permissions are insufficient', async () => {
|
||||
// Create an agent owned by a different user
|
||||
const otherUser = await User.create({
|
||||
email: 'other3@example.com',
|
||||
name: 'Other User 3',
|
||||
username: 'otheruser3',
|
||||
role: 'test-role',
|
||||
});
|
||||
|
||||
const agent = await createAgent({
|
||||
id: `agent_${Date.now()}`,
|
||||
name: 'Limited Access Agent',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
author: otherUser._id,
|
||||
});
|
||||
|
||||
// Create ACL entry granting only view permission
|
||||
await AclEntry.create({
|
||||
principalType: 'user',
|
||||
principalId: testUser._id,
|
||||
principalModel: 'User',
|
||||
resourceType: 'agent',
|
||||
resourceId: agent._id,
|
||||
permBits: 1, // VIEW permission only
|
||||
grantedBy: otherUser._id,
|
||||
});
|
||||
|
||||
req.params.id = agent.id;
|
||||
|
||||
const middleware = canAccessAgentResource({ requiredPermission: 2 }); // EDIT permission required
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Forbidden',
|
||||
message: 'Insufficient permissions to access this agent',
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle non-existent agent', async () => {
|
||||
req.params.id = 'agent_nonexistent';
|
||||
|
||||
const middleware = canAccessAgentResource({ requiredPermission: 1 });
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(404);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
error: 'Not Found',
|
||||
message: 'agent not found',
|
||||
});
|
||||
});
|
||||
|
||||
test('should use custom resourceIdParam', async () => {
|
||||
const agent = await createAgent({
|
||||
id: `agent_${Date.now()}`,
|
||||
name: 'Custom Param Agent',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
author: testUser._id,
|
||||
});
|
||||
|
||||
// Create ACL entry for the author
|
||||
await AclEntry.create({
|
||||
principalType: 'user',
|
||||
principalId: testUser._id,
|
||||
principalModel: 'User',
|
||||
resourceType: 'agent',
|
||||
resourceId: agent._id,
|
||||
permBits: 15, // All permissions
|
||||
grantedBy: testUser._id,
|
||||
});
|
||||
|
||||
req.params.agent_id = agent.id; // Using custom param name
|
||||
|
||||
const middleware = canAccessAgentResource({
|
||||
requiredPermission: 1,
|
||||
resourceIdParam: 'agent_id',
|
||||
});
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('permission levels', () => {
|
||||
let agent;
|
||||
|
||||
beforeEach(async () => {
|
||||
agent = await createAgent({
|
||||
id: `agent_${Date.now()}`,
|
||||
name: 'Permission Test Agent',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
author: testUser._id,
|
||||
});
|
||||
|
||||
// Create ACL entry with all permissions for the owner
|
||||
await AclEntry.create({
|
||||
principalType: 'user',
|
||||
principalId: testUser._id,
|
||||
principalModel: 'User',
|
||||
resourceType: 'agent',
|
||||
resourceId: agent._id,
|
||||
permBits: 15, // All permissions (1+2+4+8)
|
||||
grantedBy: testUser._id,
|
||||
});
|
||||
|
||||
req.params.id = agent.id;
|
||||
});
|
||||
|
||||
test('should support view permission (1)', async () => {
|
||||
const middleware = canAccessAgentResource({ requiredPermission: 1 });
|
||||
await middleware(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should support edit permission (2)', async () => {
|
||||
const middleware = canAccessAgentResource({ requiredPermission: 2 });
|
||||
await middleware(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should support delete permission (4)', async () => {
|
||||
const middleware = canAccessAgentResource({ requiredPermission: 4 });
|
||||
await middleware(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should support share permission (8)', async () => {
|
||||
const middleware = canAccessAgentResource({ requiredPermission: 8 });
|
||||
await middleware(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should support combined permissions', async () => {
|
||||
const viewAndEdit = 1 | 2; // 3
|
||||
const middleware = canAccessAgentResource({ requiredPermission: viewAndEdit });
|
||||
await middleware(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('integration with agent operations', () => {
|
||||
test('should work with agent CRUD operations', async () => {
|
||||
const agentId = `agent_${Date.now()}`;
|
||||
|
||||
// Create agent
|
||||
const agent = await createAgent({
|
||||
id: agentId,
|
||||
name: 'Integration Test Agent',
|
||||
provider: 'openai',
|
||||
model: 'gpt-4',
|
||||
author: testUser._id,
|
||||
description: 'Testing integration',
|
||||
});
|
||||
|
||||
// Create ACL entry for the author
|
||||
await AclEntry.create({
|
||||
principalType: 'user',
|
||||
principalId: testUser._id,
|
||||
principalModel: 'User',
|
||||
resourceType: 'agent',
|
||||
resourceId: agent._id,
|
||||
permBits: 15, // All permissions
|
||||
grantedBy: testUser._id,
|
||||
});
|
||||
|
||||
req.params.id = agentId;
|
||||
|
||||
// Test view access
|
||||
const viewMiddleware = canAccessAgentResource({ requiredPermission: 1 });
|
||||
await viewMiddleware(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Update the agent
|
||||
const { updateAgent } = require('~/models/Agent');
|
||||
await updateAgent({ id: agentId }, { description: 'Updated description' });
|
||||
|
||||
// Test edit access
|
||||
const editMiddleware = canAccessAgentResource({ requiredPermission: 2 });
|
||||
await editMiddleware(req, res, next);
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
157
api/server/middleware/accessResources/canAccessResource.js
Normal file
157
api/server/middleware/accessResources/canAccessResource.js
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
const { logger } = require('@librechat/data-schemas');
|
||||
const { SystemRoles } = require('librechat-data-provider');
|
||||
const { checkPermission } = require('~/server/services/PermissionService');
|
||||
|
||||
/**
|
||||
* Generic base middleware factory that creates middleware to check resource access permissions.
|
||||
* This middleware expects MongoDB ObjectIds as resource identifiers for ACL permission checks.
|
||||
*
|
||||
* @param {Object} options - Configuration options
|
||||
* @param {string} options.resourceType - The type of resource (e.g., 'agent', 'file', 'project')
|
||||
* @param {number} options.requiredPermission - The permission bit required (1=view, 2=edit, 4=delete, 8=share)
|
||||
* @param {string} [options.resourceIdParam='resourceId'] - The name of the route parameter containing the resource ID
|
||||
* @param {Function} [options.idResolver] - Optional function to resolve custom IDs to ObjectIds
|
||||
* @returns {Function} Express middleware function
|
||||
*
|
||||
* @example
|
||||
* // Direct usage with ObjectId (for resources that use MongoDB ObjectId in routes)
|
||||
* router.get('/prompts/:promptId',
|
||||
* canAccessResource({ resourceType: 'prompt', requiredPermission: 1 }),
|
||||
* getPrompt
|
||||
* );
|
||||
*
|
||||
* @example
|
||||
* // Usage with custom ID resolver (for resources that use custom string IDs)
|
||||
* router.get('/agents/:id',
|
||||
* canAccessResource({
|
||||
* resourceType: 'agent',
|
||||
* requiredPermission: 1,
|
||||
* resourceIdParam: 'id',
|
||||
* idResolver: (customId) => resolveAgentId(customId)
|
||||
* }),
|
||||
* getAgent
|
||||
* );
|
||||
*/
|
||||
const canAccessResource = (options) => {
|
||||
const {
|
||||
resourceType,
|
||||
requiredPermission,
|
||||
resourceIdParam = 'resourceId',
|
||||
idResolver = null,
|
||||
} = options;
|
||||
|
||||
if (!resourceType || typeof resourceType !== 'string') {
|
||||
throw new Error('canAccessResource: resourceType is required and must be a string');
|
||||
}
|
||||
|
||||
if (!requiredPermission || typeof requiredPermission !== 'number') {
|
||||
throw new Error('canAccessResource: requiredPermission is required and must be a number');
|
||||
}
|
||||
|
||||
return async (req, res, next) => {
|
||||
try {
|
||||
// Extract resource ID from route parameters
|
||||
const rawResourceId = req.params[resourceIdParam];
|
||||
|
||||
if (!rawResourceId) {
|
||||
logger.warn(`[canAccessResource] Missing ${resourceIdParam} in route parameters`);
|
||||
return res.status(400).json({
|
||||
error: 'Bad Request',
|
||||
message: `${resourceIdParam} is required`,
|
||||
});
|
||||
}
|
||||
|
||||
// Check if user is authenticated
|
||||
if (!req.user || !req.user.id) {
|
||||
logger.warn(
|
||||
`[canAccessResource] Unauthenticated request for ${resourceType} ${rawResourceId}`,
|
||||
);
|
||||
return res.status(401).json({
|
||||
error: 'Unauthorized',
|
||||
message: 'Authentication required',
|
||||
});
|
||||
}
|
||||
// if system admin let through
|
||||
if (req.user.role === SystemRoles.ADMIN) {
|
||||
return next();
|
||||
}
|
||||
const userId = req.user.id;
|
||||
let resourceId = rawResourceId;
|
||||
let resourceInfo = null;
|
||||
|
||||
// Resolve custom ID to ObjectId if resolver is provided
|
||||
if (idResolver) {
|
||||
logger.debug(
|
||||
`[canAccessResource] Resolving ${resourceType} custom ID ${rawResourceId} to ObjectId`,
|
||||
);
|
||||
|
||||
const resolutionResult = await idResolver(rawResourceId);
|
||||
|
||||
if (!resolutionResult) {
|
||||
logger.warn(`[canAccessResource] ${resourceType} not found: ${rawResourceId}`);
|
||||
return res.status(404).json({
|
||||
error: 'Not Found',
|
||||
message: `${resourceType} not found`,
|
||||
});
|
||||
}
|
||||
|
||||
// Handle different resolver return formats
|
||||
if (typeof resolutionResult === 'string' || resolutionResult._id) {
|
||||
resourceId = resolutionResult._id || resolutionResult;
|
||||
resourceInfo = typeof resolutionResult === 'object' ? resolutionResult : null;
|
||||
} else {
|
||||
resourceId = resolutionResult;
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
`[canAccessResource] Resolved ${resourceType} ${rawResourceId} to ObjectId ${resourceId}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Check permissions using PermissionService with ObjectId
|
||||
const hasPermission = await checkPermission({
|
||||
userId,
|
||||
resourceType,
|
||||
resourceId,
|
||||
requiredPermission,
|
||||
});
|
||||
|
||||
if (hasPermission) {
|
||||
logger.debug(
|
||||
`[canAccessResource] User ${userId} has permission ${requiredPermission} on ${resourceType} ${rawResourceId} (${resourceId})`,
|
||||
);
|
||||
|
||||
req.resourceAccess = {
|
||||
resourceType,
|
||||
resourceId, // MongoDB ObjectId for ACL operations
|
||||
customResourceId: rawResourceId, // Original ID from route params
|
||||
permission: requiredPermission,
|
||||
userId,
|
||||
...(resourceInfo && { resourceInfo }),
|
||||
};
|
||||
|
||||
return next();
|
||||
}
|
||||
|
||||
logger.warn(
|
||||
`[canAccessResource] User ${userId} denied access to ${resourceType} ${rawResourceId} ` +
|
||||
`(required permission: ${requiredPermission})`,
|
||||
);
|
||||
|
||||
return res.status(403).json({
|
||||
error: 'Forbidden',
|
||||
message: `Insufficient permissions to access this ${resourceType}`,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error(`[canAccessResource] Error checking access for ${resourceType}:`, error);
|
||||
return res.status(500).json({
|
||||
error: 'Internal Server Error',
|
||||
message: 'Failed to check resource access permissions',
|
||||
});
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
canAccessResource,
|
||||
};
|
||||
9
api/server/middleware/accessResources/index.js
Normal file
9
api/server/middleware/accessResources/index.js
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
const { canAccessResource } = require('./canAccessResource');
|
||||
const { canAccessAgentResource } = require('./canAccessAgentResource');
|
||||
const { canAccessAgentFromBody } = require('./canAccessAgentFromBody');
|
||||
|
||||
module.exports = {
|
||||
canAccessResource,
|
||||
canAccessAgentResource,
|
||||
canAccessAgentFromBody,
|
||||
};
|
||||
|
|
@ -8,6 +8,7 @@ const concurrentLimiter = require('./concurrentLimiter');
|
|||
const validateEndpoint = require('./validateEndpoint');
|
||||
const requireLocalAuth = require('./requireLocalAuth');
|
||||
const canDeleteAccount = require('./canDeleteAccount');
|
||||
const accessResources = require('./accessResources');
|
||||
const setBalanceConfig = require('./setBalanceConfig');
|
||||
const requireLdapAuth = require('./requireLdapAuth');
|
||||
const abortMiddleware = require('./abortMiddleware');
|
||||
|
|
@ -29,6 +30,7 @@ module.exports = {
|
|||
...validate,
|
||||
...limiters,
|
||||
...roles,
|
||||
...accessResources,
|
||||
noIndex,
|
||||
checkBan,
|
||||
uaParser,
|
||||
|
|
|
|||
251
api/server/middleware/roles/access.spec.js
Normal file
251
api/server/middleware/roles/access.spec.js
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
const mongoose = require('mongoose');
|
||||
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||
const { checkAccess, generateCheckAccess } = require('./access');
|
||||
const { PermissionTypes, Permissions } = require('librechat-data-provider');
|
||||
const { Role } = require('~/db/models');
|
||||
|
||||
// Mock only the logger
|
||||
jest.mock('~/config', () => ({
|
||||
logger: {
|
||||
warn: jest.fn(),
|
||||
error: jest.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('Access Middleware', () => {
|
||||
let mongoServer;
|
||||
let req, res, next;
|
||||
|
||||
beforeAll(async () => {
|
||||
mongoServer = await MongoMemoryServer.create();
|
||||
const mongoUri = mongoServer.getUri();
|
||||
await mongoose.connect(mongoUri);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await mongoose.disconnect();
|
||||
await mongoServer.stop();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await mongoose.connection.dropDatabase();
|
||||
|
||||
// Create test roles
|
||||
await Role.create({
|
||||
name: 'user',
|
||||
permissions: {
|
||||
[PermissionTypes.AGENTS]: {
|
||||
[Permissions.USE]: true,
|
||||
[Permissions.CREATE]: false,
|
||||
[Permissions.SHARED_GLOBAL]: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await Role.create({
|
||||
name: 'admin',
|
||||
permissions: {
|
||||
[PermissionTypes.AGENTS]: {
|
||||
[Permissions.USE]: true,
|
||||
[Permissions.CREATE]: true,
|
||||
[Permissions.SHARED_GLOBAL]: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
req = {
|
||||
user: { id: 'user123', role: 'user' },
|
||||
body: {},
|
||||
};
|
||||
res = {
|
||||
status: jest.fn().mockReturnThis(),
|
||||
json: jest.fn(),
|
||||
};
|
||||
next = jest.fn();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('checkAccess', () => {
|
||||
test('should return false if user is not provided', async () => {
|
||||
const result = await checkAccess(null, PermissionTypes.AGENTS, [Permissions.USE]);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test('should return true if user has required permission', async () => {
|
||||
const result = await checkAccess(req.user, PermissionTypes.AGENTS, [Permissions.USE]);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test('should return false if user lacks required permission', async () => {
|
||||
const result = await checkAccess(req.user, PermissionTypes.AGENTS, [Permissions.CREATE]);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test('should return true if user has any of multiple permissions', async () => {
|
||||
const result = await checkAccess(req.user, PermissionTypes.AGENTS, [
|
||||
Permissions.USE,
|
||||
Permissions.CREATE,
|
||||
]);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test('should check body properties when permission is not directly granted', async () => {
|
||||
// User role doesn't have CREATE permission, but bodyProps allows it
|
||||
const bodyProps = {
|
||||
[Permissions.CREATE]: ['agentId', 'name'],
|
||||
};
|
||||
|
||||
const checkObject = { agentId: 'agent123' };
|
||||
|
||||
const result = await checkAccess(
|
||||
req.user,
|
||||
PermissionTypes.AGENTS,
|
||||
[Permissions.CREATE],
|
||||
bodyProps,
|
||||
checkObject,
|
||||
);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
test('should return false if role is not found', async () => {
|
||||
req.user.role = 'nonexistent';
|
||||
const result = await checkAccess(req.user, PermissionTypes.AGENTS, [Permissions.USE]);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test('should return false if role has no permissions for the requested type', async () => {
|
||||
await Role.create({
|
||||
name: 'limited',
|
||||
permissions: {
|
||||
// Explicitly set AGENTS permissions to false
|
||||
[PermissionTypes.AGENTS]: {
|
||||
[Permissions.USE]: false,
|
||||
[Permissions.CREATE]: false,
|
||||
[Permissions.SHARED_GLOBAL]: false,
|
||||
},
|
||||
// Has permissions for other types
|
||||
[PermissionTypes.PROMPTS]: {
|
||||
[Permissions.USE]: true,
|
||||
},
|
||||
},
|
||||
});
|
||||
req.user.role = 'limited';
|
||||
|
||||
const result = await checkAccess(req.user, PermissionTypes.AGENTS, [Permissions.USE]);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
test('should handle admin role with all permissions', async () => {
|
||||
req.user.role = 'admin';
|
||||
|
||||
const createResult = await checkAccess(req.user, PermissionTypes.AGENTS, [
|
||||
Permissions.CREATE,
|
||||
]);
|
||||
expect(createResult).toBe(true);
|
||||
|
||||
const shareResult = await checkAccess(req.user, PermissionTypes.AGENTS, [
|
||||
Permissions.SHARED_GLOBAL,
|
||||
]);
|
||||
expect(shareResult).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateCheckAccess', () => {
|
||||
test('should call next() when user has required permission', async () => {
|
||||
const middleware = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.USE]);
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should return 403 when user lacks permission', async () => {
|
||||
const middleware = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.CREATE]);
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith({ message: 'Forbidden: Insufficient permissions' });
|
||||
});
|
||||
|
||||
test('should check body properties when configured', async () => {
|
||||
req.body = { agentId: 'agent123', description: 'test' };
|
||||
|
||||
const bodyProps = {
|
||||
[Permissions.CREATE]: ['agentId'],
|
||||
};
|
||||
|
||||
const middleware = generateCheckAccess(
|
||||
PermissionTypes.AGENTS,
|
||||
[Permissions.CREATE],
|
||||
bodyProps,
|
||||
);
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
expect(res.status).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should handle database errors gracefully', async () => {
|
||||
// Create a user with an invalid role that will cause getRoleByName to fail
|
||||
req.user.role = { invalid: 'object' }; // This will cause an error when querying
|
||||
|
||||
const middleware = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.USE]);
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
message: expect.stringContaining('Server error:'),
|
||||
});
|
||||
});
|
||||
|
||||
test('should work with multiple permission types', async () => {
|
||||
req.user.role = 'admin';
|
||||
|
||||
const middleware = generateCheckAccess(PermissionTypes.AGENTS, [
|
||||
Permissions.USE,
|
||||
Permissions.CREATE,
|
||||
Permissions.SHARED_GLOBAL,
|
||||
]);
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(next).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('should handle missing user gracefully', async () => {
|
||||
req.user = null;
|
||||
|
||||
const middleware = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.USE]);
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(500);
|
||||
expect(res.json).toHaveBeenCalledWith({
|
||||
message: expect.stringContaining('Server error:'),
|
||||
});
|
||||
});
|
||||
|
||||
test('should handle role with no AGENTS permissions', async () => {
|
||||
await Role.create({
|
||||
name: 'noaccess',
|
||||
permissions: {
|
||||
// Explicitly set AGENTS with all permissions false
|
||||
[PermissionTypes.AGENTS]: {
|
||||
[Permissions.USE]: false,
|
||||
[Permissions.CREATE]: false,
|
||||
[Permissions.SHARED_GLOBAL]: false,
|
||||
},
|
||||
},
|
||||
});
|
||||
req.user.role = 'noaccess';
|
||||
|
||||
const middleware = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.USE]);
|
||||
await middleware(req, res, next);
|
||||
|
||||
expect(next).not.toHaveBeenCalled();
|
||||
expect(res.status).toHaveBeenCalledWith(403);
|
||||
expect(res.json).toHaveBeenCalledWith({ message: 'Forbidden: Insufficient permissions' });
|
||||
});
|
||||
});
|
||||
});
|
||||
62
api/server/routes/accessPermissions.js
Normal file
62
api/server/routes/accessPermissions.js
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
const express = require('express');
|
||||
const { PermissionBits } = require('@librechat/data-schemas');
|
||||
const {
|
||||
getUserEffectivePermissions,
|
||||
updateResourcePermissions,
|
||||
getResourcePermissions,
|
||||
getResourceRoles,
|
||||
searchPrincipals,
|
||||
} = require('~/server/controllers/PermissionsController');
|
||||
const { requireJwtAuth, checkBan, uaParser, canAccessResource } = require('~/server/middleware');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Apply common middleware
|
||||
router.use(requireJwtAuth);
|
||||
router.use(checkBan);
|
||||
router.use(uaParser);
|
||||
|
||||
/**
|
||||
* Generic routes for resource permissions
|
||||
* Pattern: /api/permissions/{resourceType}/{resourceId}
|
||||
*/
|
||||
|
||||
/**
|
||||
* GET /api/permissions/search-principals
|
||||
* Search for users and groups to grant permissions
|
||||
*/
|
||||
router.get('/search-principals', searchPrincipals);
|
||||
|
||||
/**
|
||||
* GET /api/permissions/{resourceType}/roles
|
||||
* Get available roles for a resource type
|
||||
*/
|
||||
router.get('/:resourceType/roles', getResourceRoles);
|
||||
|
||||
/**
|
||||
* GET /api/permissions/{resourceType}/{resourceId}
|
||||
* Get all permissions for a specific resource
|
||||
*/
|
||||
router.get('/:resourceType/:resourceId', getResourcePermissions);
|
||||
|
||||
/**
|
||||
* PUT /api/permissions/{resourceType}/{resourceId}
|
||||
* Bulk update permissions for a specific resource
|
||||
*/
|
||||
router.put(
|
||||
'/:resourceType/:resourceId',
|
||||
canAccessResource({
|
||||
resourceType: 'agent',
|
||||
requiredPermission: PermissionBits.SHARE,
|
||||
resourceIdParam: 'resourceId',
|
||||
}),
|
||||
updateResourcePermissions,
|
||||
);
|
||||
|
||||
/**
|
||||
* GET /api/permissions/{resourceType}/{resourceId}/effective
|
||||
* Get user's effective permissions for a specific resource
|
||||
*/
|
||||
router.get('/:resourceType/:resourceId/effective', getUserEffectivePermissions);
|
||||
|
||||
module.exports = router;
|
||||
|
|
@ -1,9 +1,8 @@
|
|||
const express = require('express');
|
||||
const { nanoid } = require('nanoid');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { generateCheckAccess } = require('@librechat/api');
|
||||
const { logger, PermissionBits } = require('@librechat/data-schemas');
|
||||
const {
|
||||
SystemRoles,
|
||||
Permissions,
|
||||
PermissionTypes,
|
||||
actionDelimiter,
|
||||
|
|
@ -12,6 +11,7 @@ const {
|
|||
const { encryptMetadata, domainParser } = require('~/server/services/ActionService');
|
||||
const { updateAction, getActions, deleteAction } = require('~/models/Action');
|
||||
const { isActionDomainAllowed } = require('~/server/services/domains');
|
||||
const { canAccessAgentResource } = require('~/server/middleware');
|
||||
const { getAgent, updateAgent } = require('~/models/Agent');
|
||||
const { getRoleByName } = require('~/models/Role');
|
||||
|
||||
|
|
@ -23,12 +23,6 @@ const checkAgentCreate = generateCheckAccess({
|
|||
getRoleByName,
|
||||
});
|
||||
|
||||
// If the user has ADMIN role
|
||||
// then action edition is possible even if not owner of the assistant
|
||||
const isAdmin = (req) => {
|
||||
return req.user.role === SystemRoles.ADMIN;
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves all user's actions
|
||||
* @route GET /actions/
|
||||
|
|
@ -37,9 +31,8 @@ const isAdmin = (req) => {
|
|||
*/
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const admin = isAdmin(req);
|
||||
// If admin, get all actions, otherwise only user's actions
|
||||
const searchParams = admin ? {} : { user: req.user.id };
|
||||
// Get all actions for the user (admin permissions handled by middleware if needed)
|
||||
const searchParams = { user: req.user.id };
|
||||
res.json(await getActions(searchParams));
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
|
|
@ -55,106 +48,111 @@ router.get('/', async (req, res) => {
|
|||
* @param {ActionMetadata} req.body.metadata - Metadata for the action.
|
||||
* @returns {Object} 200 - success response - application/json
|
||||
*/
|
||||
router.post('/:agent_id', checkAgentCreate, async (req, res) => {
|
||||
try {
|
||||
const { agent_id } = req.params;
|
||||
router.post(
|
||||
'/:agent_id',
|
||||
canAccessAgentResource({
|
||||
requiredPermission: PermissionBits.EDIT,
|
||||
resourceIdParam: 'agent_id',
|
||||
}),
|
||||
checkAgentCreate,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { agent_id } = req.params;
|
||||
|
||||
/** @type {{ functions: FunctionTool[], action_id: string, metadata: ActionMetadata }} */
|
||||
const { functions, action_id: _action_id, metadata: _metadata } = req.body;
|
||||
if (!functions.length) {
|
||||
return res.status(400).json({ message: 'No functions provided' });
|
||||
}
|
||||
|
||||
let metadata = await encryptMetadata(removeNullishValues(_metadata, true));
|
||||
const isDomainAllowed = await isActionDomainAllowed(metadata.domain);
|
||||
if (!isDomainAllowed) {
|
||||
return res.status(400).json({ message: 'Domain not allowed' });
|
||||
}
|
||||
|
||||
let { domain } = metadata;
|
||||
domain = await domainParser(domain, true);
|
||||
|
||||
if (!domain) {
|
||||
return res.status(400).json({ message: 'No domain provided' });
|
||||
}
|
||||
|
||||
const action_id = _action_id ?? nanoid();
|
||||
const initialPromises = [];
|
||||
const admin = isAdmin(req);
|
||||
|
||||
// If admin, can edit any agent, otherwise only user's agents
|
||||
const agentQuery = admin ? { id: agent_id } : { id: agent_id, author: req.user.id };
|
||||
// TODO: share agents
|
||||
initialPromises.push(getAgent(agentQuery));
|
||||
if (_action_id) {
|
||||
initialPromises.push(getActions({ action_id }, true));
|
||||
}
|
||||
|
||||
/** @type {[Agent, [Action|undefined]]} */
|
||||
const [agent, actions_result] = await Promise.all(initialPromises);
|
||||
if (!agent) {
|
||||
return res.status(404).json({ message: 'Agent not found for adding action' });
|
||||
}
|
||||
|
||||
if (actions_result && actions_result.length) {
|
||||
const action = actions_result[0];
|
||||
metadata = { ...action.metadata, ...metadata };
|
||||
}
|
||||
|
||||
const { actions: _actions = [], author: agent_author } = agent ?? {};
|
||||
const actions = [];
|
||||
for (const action of _actions) {
|
||||
const [_action_domain, current_action_id] = action.split(actionDelimiter);
|
||||
if (current_action_id === action_id) {
|
||||
continue;
|
||||
/** @type {{ functions: FunctionTool[], action_id: string, metadata: ActionMetadata }} */
|
||||
const { functions, action_id: _action_id, metadata: _metadata } = req.body;
|
||||
if (!functions.length) {
|
||||
return res.status(400).json({ message: 'No functions provided' });
|
||||
}
|
||||
|
||||
actions.push(action);
|
||||
}
|
||||
|
||||
actions.push(`${domain}${actionDelimiter}${action_id}`);
|
||||
|
||||
/** @type {string[]}} */
|
||||
const { tools: _tools = [] } = agent;
|
||||
|
||||
const tools = _tools
|
||||
.filter((tool) => !(tool && (tool.includes(domain) || tool.includes(action_id))))
|
||||
.concat(functions.map((tool) => `${tool.function.name}${actionDelimiter}${domain}`));
|
||||
|
||||
// Force version update since actions are changing
|
||||
const updatedAgent = await updateAgent(
|
||||
agentQuery,
|
||||
{ tools, actions },
|
||||
{
|
||||
updatingUserId: req.user.id,
|
||||
forceVersion: true,
|
||||
},
|
||||
);
|
||||
|
||||
// Only update user field for new actions
|
||||
const actionUpdateData = { metadata, agent_id };
|
||||
if (!actions_result || !actions_result.length) {
|
||||
// For new actions, use the agent owner's user ID
|
||||
actionUpdateData.user = agent_author || req.user.id;
|
||||
}
|
||||
|
||||
/** @type {[Action]} */
|
||||
const updatedAction = await updateAction({ action_id }, actionUpdateData);
|
||||
|
||||
const sensitiveFields = ['api_key', 'oauth_client_id', 'oauth_client_secret'];
|
||||
for (let field of sensitiveFields) {
|
||||
if (updatedAction.metadata[field]) {
|
||||
delete updatedAction.metadata[field];
|
||||
let metadata = await encryptMetadata(removeNullishValues(_metadata, true));
|
||||
const isDomainAllowed = await isActionDomainAllowed(metadata.domain);
|
||||
if (!isDomainAllowed) {
|
||||
return res.status(400).json({ message: 'Domain not allowed' });
|
||||
}
|
||||
}
|
||||
|
||||
res.json([updatedAgent, updatedAction]);
|
||||
} catch (error) {
|
||||
const message = 'Trouble updating the Agent Action';
|
||||
logger.error(message, error);
|
||||
res.status(500).json({ message });
|
||||
}
|
||||
});
|
||||
let { domain } = metadata;
|
||||
domain = await domainParser(domain, true);
|
||||
|
||||
if (!domain) {
|
||||
return res.status(400).json({ message: 'No domain provided' });
|
||||
}
|
||||
|
||||
const action_id = _action_id ?? nanoid();
|
||||
const initialPromises = [];
|
||||
|
||||
// Permissions already validated by middleware - load agent directly
|
||||
initialPromises.push(getAgent({ id: agent_id }));
|
||||
if (_action_id) {
|
||||
initialPromises.push(getActions({ action_id }, true));
|
||||
}
|
||||
|
||||
/** @type {[Agent, [Action|undefined]]} */
|
||||
const [agent, actions_result] = await Promise.all(initialPromises);
|
||||
if (!agent) {
|
||||
return res.status(404).json({ message: 'Agent not found for adding action' });
|
||||
}
|
||||
|
||||
if (actions_result && actions_result.length) {
|
||||
const action = actions_result[0];
|
||||
metadata = { ...action.metadata, ...metadata };
|
||||
}
|
||||
|
||||
const { actions: _actions = [], author: agent_author } = agent ?? {};
|
||||
const actions = [];
|
||||
for (const action of _actions) {
|
||||
const [_action_domain, current_action_id] = action.split(actionDelimiter);
|
||||
if (current_action_id === action_id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
actions.push(action);
|
||||
}
|
||||
|
||||
actions.push(`${domain}${actionDelimiter}${action_id}`);
|
||||
|
||||
/** @type {string[]}} */
|
||||
const { tools: _tools = [] } = agent;
|
||||
|
||||
const tools = _tools
|
||||
.filter((tool) => !(tool && (tool.includes(domain) || tool.includes(action_id))))
|
||||
.concat(functions.map((tool) => `${tool.function.name}${actionDelimiter}${domain}`));
|
||||
|
||||
// Force version update since actions are changing
|
||||
const updatedAgent = await updateAgent(
|
||||
{ id: agent_id },
|
||||
{ tools, actions },
|
||||
{
|
||||
updatingUserId: req.user.id,
|
||||
forceVersion: true,
|
||||
},
|
||||
);
|
||||
|
||||
// Only update user field for new actions
|
||||
const actionUpdateData = { metadata, agent_id };
|
||||
if (!actions_result || !actions_result.length) {
|
||||
// For new actions, use the agent owner's user ID
|
||||
actionUpdateData.user = agent_author || req.user.id;
|
||||
}
|
||||
|
||||
/** @type {[Action]} */
|
||||
const updatedAction = await updateAction({ action_id }, actionUpdateData);
|
||||
|
||||
const sensitiveFields = ['api_key', 'oauth_client_id', 'oauth_client_secret'];
|
||||
for (let field of sensitiveFields) {
|
||||
if (updatedAction.metadata[field]) {
|
||||
delete updatedAction.metadata[field];
|
||||
}
|
||||
}
|
||||
|
||||
res.json([updatedAgent, updatedAction]);
|
||||
} catch (error) {
|
||||
const message = 'Trouble updating the Agent Action';
|
||||
logger.error(message, error);
|
||||
res.status(500).json({ message });
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* Deletes an action for a specific agent.
|
||||
|
|
@ -163,52 +161,56 @@ router.post('/:agent_id', checkAgentCreate, async (req, res) => {
|
|||
* @param {string} req.params.action_id - The ID of the action to delete.
|
||||
* @returns {Object} 200 - success response - application/json
|
||||
*/
|
||||
router.delete('/:agent_id/:action_id', checkAgentCreate, async (req, res) => {
|
||||
try {
|
||||
const { agent_id, action_id } = req.params;
|
||||
const admin = isAdmin(req);
|
||||
router.delete(
|
||||
'/:agent_id/:action_id',
|
||||
canAccessAgentResource({
|
||||
requiredPermission: PermissionBits.EDIT,
|
||||
resourceIdParam: 'agent_id',
|
||||
}),
|
||||
checkAgentCreate,
|
||||
async (req, res) => {
|
||||
try {
|
||||
const { agent_id, action_id } = req.params;
|
||||
|
||||
// If admin, can delete any agent, otherwise only user's agents
|
||||
const agentQuery = admin ? { id: agent_id } : { id: agent_id, author: req.user.id };
|
||||
const agent = await getAgent(agentQuery);
|
||||
if (!agent) {
|
||||
return res.status(404).json({ message: 'Agent not found for deleting action' });
|
||||
}
|
||||
|
||||
const { tools = [], actions = [] } = agent;
|
||||
|
||||
let domain = '';
|
||||
const updatedActions = actions.filter((action) => {
|
||||
if (action.includes(action_id)) {
|
||||
[domain] = action.split(actionDelimiter);
|
||||
return false;
|
||||
// Permissions already validated by middleware - load agent directly
|
||||
const agent = await getAgent({ id: agent_id });
|
||||
if (!agent) {
|
||||
return res.status(404).json({ message: 'Agent not found for deleting action' });
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
domain = await domainParser(domain, true);
|
||||
const { tools = [], actions = [] } = agent;
|
||||
|
||||
if (!domain) {
|
||||
return res.status(400).json({ message: 'No domain provided' });
|
||||
let domain = '';
|
||||
const updatedActions = actions.filter((action) => {
|
||||
if (action.includes(action_id)) {
|
||||
[domain] = action.split(actionDelimiter);
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
domain = await domainParser(domain, true);
|
||||
|
||||
if (!domain) {
|
||||
return res.status(400).json({ message: 'No domain provided' });
|
||||
}
|
||||
|
||||
const updatedTools = tools.filter((tool) => !(tool && tool.includes(domain)));
|
||||
|
||||
// Force version update since actions are being removed
|
||||
await updateAgent(
|
||||
{ id: agent_id },
|
||||
{ tools: updatedTools, actions: updatedActions },
|
||||
{ updatingUserId: req.user.id, forceVersion: true },
|
||||
);
|
||||
await deleteAction({ action_id });
|
||||
res.status(200).json({ message: 'Action deleted successfully' });
|
||||
} catch (error) {
|
||||
const message = 'Trouble deleting the Agent Action';
|
||||
logger.error(message, error);
|
||||
res.status(500).json({ message });
|
||||
}
|
||||
|
||||
const updatedTools = tools.filter((tool) => !(tool && tool.includes(domain)));
|
||||
|
||||
// Force version update since actions are being removed
|
||||
await updateAgent(
|
||||
agentQuery,
|
||||
{ tools: updatedTools, actions: updatedActions },
|
||||
{ updatingUserId: req.user.id, forceVersion: true },
|
||||
);
|
||||
// If admin, can delete any action, otherwise only user's actions
|
||||
const actionQuery = admin ? { action_id } : { action_id, user: req.user.id };
|
||||
await deleteAction(actionQuery);
|
||||
res.status(200).json({ message: 'Action deleted successfully' });
|
||||
} catch (error) {
|
||||
const message = 'Trouble deleting the Agent Action';
|
||||
logger.error(message, error);
|
||||
res.status(500).json({ message });
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
module.exports = router;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
const express = require('express');
|
||||
const { PermissionBits } = require('@librechat/data-schemas');
|
||||
const { generateCheckAccess, skipAgentCheck } = require('@librechat/api');
|
||||
const { PermissionTypes, Permissions } = require('librechat-data-provider');
|
||||
const {
|
||||
|
|
@ -7,6 +8,7 @@ const {
|
|||
// validateModel,
|
||||
validateConvoAccess,
|
||||
buildEndpointOption,
|
||||
canAccessAgentFromBody,
|
||||
} = require('~/server/middleware');
|
||||
const { initializeClient } = require('~/server/services/Endpoints/agents');
|
||||
const AgentController = require('~/server/controllers/agents/request');
|
||||
|
|
@ -23,8 +25,12 @@ const checkAgentAccess = generateCheckAccess({
|
|||
skipCheck: skipAgentCheck,
|
||||
getRoleByName,
|
||||
});
|
||||
const checkAgentResourceAccess = canAccessAgentFromBody({
|
||||
requiredPermission: PermissionBits.VIEW,
|
||||
});
|
||||
|
||||
router.use(checkAgentAccess);
|
||||
router.use(checkAgentResourceAccess);
|
||||
router.use(validateConvoAccess);
|
||||
router.use(buildEndpointOption);
|
||||
router.use(setHeaders);
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ const {
|
|||
const { isEnabled } = require('~/server/utils');
|
||||
const { v1 } = require('./v1');
|
||||
const chat = require('./chat');
|
||||
const marketplace = require('./marketplace');
|
||||
|
||||
const { LIMIT_CONCURRENT_MESSAGES, LIMIT_MESSAGE_IP, LIMIT_MESSAGE_USER } = process.env ?? {};
|
||||
|
||||
|
|
@ -37,4 +38,7 @@ if (isEnabled(LIMIT_MESSAGE_USER)) {
|
|||
chatRouter.use('/', chat);
|
||||
router.use('/chat', chatRouter);
|
||||
|
||||
// Add marketplace routes
|
||||
router.use('/marketplace', marketplace);
|
||||
|
||||
module.exports = router;
|
||||
|
|
|
|||
46
api/server/routes/agents/marketplace.js
Normal file
46
api/server/routes/agents/marketplace.js
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
const express = require('express');
|
||||
const { requireJwtAuth, checkBan, generateCheckAccess } = require('~/server/middleware');
|
||||
const { PermissionTypes, Permissions } = require('librechat-data-provider');
|
||||
const marketplace = require('~/server/controllers/agents/marketplace');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
// Apply middleware for authentication and authorization
|
||||
router.use(requireJwtAuth);
|
||||
router.use(checkBan);
|
||||
|
||||
// Check if user has permission to use agents
|
||||
const checkAgentAccess = generateCheckAccess(PermissionTypes.AGENTS, [Permissions.USE]);
|
||||
router.use(checkAgentAccess);
|
||||
|
||||
/**
|
||||
* Get all agent categories with counts
|
||||
* @route GET /agents/marketplace/categories
|
||||
*/
|
||||
router.get('/categories', marketplace.getAgentCategories);
|
||||
|
||||
/**
|
||||
* Get promoted/top picks agents with pagination
|
||||
* @route GET /agents/marketplace/promoted
|
||||
*/
|
||||
router.get('/promoted', marketplace.getPromotedAgents);
|
||||
|
||||
/**
|
||||
* Get all agents with pagination (for "all" category)
|
||||
* @route GET /agents/marketplace/all
|
||||
*/
|
||||
router.get('/all', marketplace.getAllAgents);
|
||||
|
||||
/**
|
||||
* Search agents with filters
|
||||
* @route GET /agents/marketplace/search
|
||||
*/
|
||||
router.get('/search', marketplace.searchAgents);
|
||||
|
||||
/**
|
||||
* Get agents by category with pagination
|
||||
* @route GET /agents/marketplace/category/:category
|
||||
*/
|
||||
router.get('/category/:category', marketplace.getAgentsByCategory);
|
||||
|
||||
module.exports = router;
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
const express = require('express');
|
||||
const { generateCheckAccess } = require('@librechat/api');
|
||||
const { PermissionBits } = require('@librechat/data-schemas');
|
||||
const { PermissionTypes, Permissions } = require('librechat-data-provider');
|
||||
const { requireJwtAuth } = require('~/server/middleware');
|
||||
const { requireJwtAuth, canAccessAgentResource } = require('~/server/middleware');
|
||||
const v1 = require('~/server/controllers/agents/v1');
|
||||
const { getRoleByName } = require('~/models/Role');
|
||||
const actions = require('./actions');
|
||||
|
|
@ -53,13 +54,38 @@ router.use('/tools', tools);
|
|||
router.post('/', checkAgentCreate, v1.createAgent);
|
||||
|
||||
/**
|
||||
* Retrieves an agent.
|
||||
* Retrieves basic agent information (VIEW permission required).
|
||||
* Returns safe, non-sensitive agent data for viewing purposes.
|
||||
* @route GET /agents/:id
|
||||
* @param {string} req.params.id - Agent identifier.
|
||||
* @returns {Agent} 200 - Success response - application/json
|
||||
* @returns {Agent} 200 - Basic agent info - application/json
|
||||
*/
|
||||
router.get('/:id', checkAgentAccess, v1.getAgent);
|
||||
router.get(
|
||||
'/:id',
|
||||
checkAgentAccess,
|
||||
canAccessAgentResource({
|
||||
requiredPermission: PermissionBits.VIEW,
|
||||
resourceIdParam: 'id',
|
||||
}),
|
||||
v1.getAgent,
|
||||
);
|
||||
|
||||
/**
|
||||
* Retrieves full agent details including sensitive configuration (EDIT permission required).
|
||||
* Returns complete agent data for editing/configuration purposes.
|
||||
* @route GET /agents/:id/expanded
|
||||
* @param {string} req.params.id - Agent identifier.
|
||||
* @returns {Agent} 200 - Full agent details - application/json
|
||||
*/
|
||||
router.get(
|
||||
'/:id/expanded',
|
||||
checkAgentAccess,
|
||||
canAccessAgentResource({
|
||||
requiredPermission: PermissionBits.EDIT,
|
||||
resourceIdParam: 'id',
|
||||
}),
|
||||
(req, res) => v1.getAgent(req, res, true), // Expanded version
|
||||
);
|
||||
/**
|
||||
* Updates an agent.
|
||||
* @route PATCH /agents/:id
|
||||
|
|
@ -67,7 +93,15 @@ router.get('/:id', checkAgentAccess, v1.getAgent);
|
|||
* @param {AgentUpdateParams} req.body - The agent update parameters.
|
||||
* @returns {Agent} 200 - Success response - application/json
|
||||
*/
|
||||
router.patch('/:id', checkGlobalAgentShare, v1.updateAgent);
|
||||
router.patch(
|
||||
'/:id',
|
||||
checkGlobalAgentShare,
|
||||
canAccessAgentResource({
|
||||
requiredPermission: PermissionBits.EDIT,
|
||||
resourceIdParam: 'id',
|
||||
}),
|
||||
v1.updateAgent,
|
||||
);
|
||||
|
||||
/**
|
||||
* Duplicates an agent.
|
||||
|
|
@ -75,7 +109,15 @@ router.patch('/:id', checkGlobalAgentShare, v1.updateAgent);
|
|||
* @param {string} req.params.id - Agent identifier.
|
||||
* @returns {Agent} 201 - Success response - application/json
|
||||
*/
|
||||
router.post('/:id/duplicate', checkAgentCreate, v1.duplicateAgent);
|
||||
router.post(
|
||||
'/:id/duplicate',
|
||||
checkAgentCreate,
|
||||
canAccessAgentResource({
|
||||
requiredPermission: PermissionBits.VIEW,
|
||||
resourceIdParam: 'id',
|
||||
}),
|
||||
v1.duplicateAgent,
|
||||
);
|
||||
|
||||
/**
|
||||
* Deletes an agent.
|
||||
|
|
@ -83,7 +125,15 @@ router.post('/:id/duplicate', checkAgentCreate, v1.duplicateAgent);
|
|||
* @param {string} req.params.id - Agent identifier.
|
||||
* @returns {Agent} 200 - success response - application/json
|
||||
*/
|
||||
router.delete('/:id', checkAgentCreate, v1.deleteAgent);
|
||||
router.delete(
|
||||
'/:id',
|
||||
checkAgentCreate,
|
||||
canAccessAgentResource({
|
||||
requiredPermission: PermissionBits.DELETE,
|
||||
resourceIdParam: 'id',
|
||||
}),
|
||||
v1.deleteAgent,
|
||||
);
|
||||
|
||||
/**
|
||||
* Reverts an agent to a previous version.
|
||||
|
|
@ -110,6 +160,14 @@ router.get('/', checkAgentAccess, v1.getListAgents);
|
|||
* @param {string} [req.body.metadata] - Optional metadata for the agent's avatar.
|
||||
* @returns {Object} 200 - success response - application/json
|
||||
*/
|
||||
avatar.post('/:agent_id/avatar/', checkAgentAccess, v1.uploadAgentAvatar);
|
||||
avatar.post(
|
||||
'/:agent_id/avatar/',
|
||||
checkAgentAccess,
|
||||
canAccessAgentResource({
|
||||
requiredPermission: PermissionBits.EDIT,
|
||||
resourceIdParam: 'agent_id',
|
||||
}),
|
||||
v1.uploadAgentAvatar,
|
||||
);
|
||||
|
||||
module.exports = { v1: router, avatar };
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
const accessPermissions = require('./accessPermissions');
|
||||
const assistants = require('./assistants');
|
||||
const categories = require('./categories');
|
||||
const tokenizer = require('./tokenizer');
|
||||
|
|
@ -28,6 +29,7 @@ const user = require('./user');
|
|||
const mcp = require('./mcp');
|
||||
|
||||
module.exports = {
|
||||
mcp,
|
||||
edit,
|
||||
auth,
|
||||
keys,
|
||||
|
|
@ -55,5 +57,5 @@ module.exports = {
|
|||
assistants,
|
||||
categories,
|
||||
staticRoute,
|
||||
mcp,
|
||||
accessPermissions,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ const {
|
|||
setBalanceConfig,
|
||||
checkDomainAllowed,
|
||||
} = require('~/server/middleware');
|
||||
const { syncUserEntraGroupMemberships } = require('~/server/services/PermissionService');
|
||||
const { setAuthTokens, setOpenIDAuthTokens } = require('~/server/services/AuthService');
|
||||
|
||||
const router = express.Router();
|
||||
|
|
@ -36,6 +37,7 @@ const oauthHandler = async (req, res) => {
|
|||
req.user.provider == 'openid' &&
|
||||
isEnabled(process.env.OPENID_REUSE_TOKENS) === true
|
||||
) {
|
||||
await syncUserEntraGroupMemberships(req.user, req.user.tokenset.access_token);
|
||||
setOpenIDAuthTokens(req.user.tokenset, res);
|
||||
} else {
|
||||
await setAuthTokens(req.user._id, res);
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
jest.mock('~/models', () => ({
|
||||
initializeRoles: jest.fn(),
|
||||
seedDefaultRoles: jest.fn(),
|
||||
}));
|
||||
jest.mock('~/models/Role', () => ({
|
||||
updateAccessPermissions: jest.fn(),
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ const {
|
|||
const { azureAssistantsDefaults, assistantsConfigSetup } = require('./start/assistants');
|
||||
const { initializeAzureBlobService } = require('./Files/Azure/initialize');
|
||||
const { initializeFirebase } = require('./Files/Firebase/initialize');
|
||||
const { seedDefaultRoles, initializeRoles } = require('~/models');
|
||||
const loadCustomConfig = require('./Config/loadCustomConfig');
|
||||
const handleRateLimits = require('./Config/handleRateLimits');
|
||||
const { loadDefaultInterface } = require('./start/interface');
|
||||
|
|
@ -24,7 +25,6 @@ const { processModelSpecs } = require('./start/modelSpecs');
|
|||
const { initializeS3 } = require('./Files/S3/initialize');
|
||||
const { loadAndFormatTools } = require('./ToolService');
|
||||
const { isEnabled } = require('~/server/utils');
|
||||
const { initializeRoles } = require('~/models');
|
||||
const { setCachedTools } = require('./Config');
|
||||
const paths = require('~/config/paths');
|
||||
|
||||
|
|
@ -35,6 +35,7 @@ const paths = require('~/config/paths');
|
|||
*/
|
||||
const AppService = async (app) => {
|
||||
await initializeRoles();
|
||||
await seedDefaultRoles();
|
||||
/** @type {TCustomConfig} */
|
||||
const config = (await loadCustomConfig()) ?? {};
|
||||
const configDefaults = getConfigDefaults();
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ jest.mock('./Files/Firebase/initialize', () => ({
|
|||
}));
|
||||
jest.mock('~/models', () => ({
|
||||
initializeRoles: jest.fn(),
|
||||
seedDefaultRoles: jest.fn(),
|
||||
}));
|
||||
jest.mock('~/models/Role', () => ({
|
||||
updateAccessPermissions: jest.fn(),
|
||||
|
|
|
|||
525
api/server/services/GraphApiService.js
Normal file
525
api/server/services/GraphApiService.js
Normal file
|
|
@ -0,0 +1,525 @@
|
|||
const client = require('openid-client');
|
||||
const { isEnabled } = require('@librechat/api');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
const { CacheKeys } = require('librechat-data-provider');
|
||||
const { Client } = require('@microsoft/microsoft-graph-client');
|
||||
const { getOpenIdConfig } = require('~/strategies/openidStrategy');
|
||||
const getLogStores = require('~/cache/getLogStores');
|
||||
|
||||
/**
|
||||
* @import { TPrincipalSearchResult, TGraphPerson, TGraphUser, TGraphGroup, TGraphPeopleResponse, TGraphUsersResponse, TGraphGroupsResponse } from 'librechat-data-provider'
|
||||
*/
|
||||
|
||||
/**
|
||||
* Checks if Entra ID principal search feature is enabled based on environment variables and user authentication
|
||||
* @param {Object} user - User object from request
|
||||
* @param {string} user.provider - Authentication provider
|
||||
* @param {string} user.openidId - OpenID subject identifier
|
||||
* @returns {boolean} True if Entra ID principal search is enabled and user is authenticated via OpenID
|
||||
*/
|
||||
const entraIdPrincipalFeatureEnabled = (user) => {
|
||||
return (
|
||||
isEnabled(process.env.USE_ENTRA_ID_FOR_PEOPLE_SEARCH) &&
|
||||
isEnabled(process.env.OPENID_REUSE_TOKENS) &&
|
||||
user?.provider === 'openid' &&
|
||||
user?.openidId
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates a Microsoft Graph client with on-behalf-of token exchange
|
||||
* @param {string} accessToken - OpenID Connect access token from user
|
||||
* @param {string} sub - Subject identifier from token claims
|
||||
* @returns {Promise<Client>} Authenticated Graph API client
|
||||
*/
|
||||
const createGraphClient = async (accessToken, sub) => {
|
||||
try {
|
||||
// Reason: Use existing OpenID configuration and token exchange pattern from openidStrategy.js
|
||||
const openidConfig = getOpenIdConfig();
|
||||
const exchangedToken = await exchangeTokenForGraphAccess(openidConfig, accessToken, sub);
|
||||
|
||||
const graphClient = Client.init({
|
||||
authProvider: (done) => {
|
||||
done(null, exchangedToken);
|
||||
},
|
||||
});
|
||||
|
||||
return graphClient;
|
||||
} catch (error) {
|
||||
logger.error('[createGraphClient] Error creating Graph client:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Exchange OpenID token for Graph API access using on-behalf-of flow
|
||||
* Similar to exchangeAccessTokenIfNeeded in openidStrategy.js but for Graph scopes
|
||||
* @param {Configuration} config - OpenID configuration
|
||||
* @param {string} accessToken - Original access token
|
||||
* @param {string} sub - Subject identifier
|
||||
* @returns {Promise<string>} Graph API access token
|
||||
*/
|
||||
const exchangeTokenForGraphAccess = async (config, accessToken, sub) => {
|
||||
try {
|
||||
const tokensCache = getLogStores(CacheKeys.OPENID_EXCHANGED_TOKENS);
|
||||
const cacheKey = `${sub}:graph`;
|
||||
|
||||
const cachedToken = await tokensCache.get(cacheKey);
|
||||
if (cachedToken) {
|
||||
return cachedToken.access_token;
|
||||
}
|
||||
|
||||
const graphScopes = process.env.OPENID_GRAPH_SCOPES || 'User.Read,People.Read,Group.Read.All';
|
||||
const scopeString = graphScopes
|
||||
.split(',')
|
||||
.map((scope) => `https://graph.microsoft.com/${scope}`)
|
||||
.join(' ');
|
||||
|
||||
const grantResponse = await client.genericGrantRequest(
|
||||
config,
|
||||
'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
{
|
||||
scope: scopeString,
|
||||
assertion: accessToken,
|
||||
requested_token_use: 'on_behalf_of',
|
||||
},
|
||||
);
|
||||
|
||||
await tokensCache.set(
|
||||
cacheKey,
|
||||
{
|
||||
access_token: grantResponse.access_token,
|
||||
},
|
||||
grantResponse.expires_in * 1000,
|
||||
);
|
||||
|
||||
return grantResponse.access_token;
|
||||
} catch (error) {
|
||||
logger.error('[exchangeTokenForGraphAccess] Token exchange failed:', error);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Search for principals (people and groups) using Microsoft Graph API
|
||||
* Uses searchContacts first, then searchUsers and searchGroups to fill remaining slots
|
||||
* @param {string} accessToken - OpenID Connect access token
|
||||
* @param {string} sub - Subject identifier
|
||||
* @param {string} query - Search query string
|
||||
* @param {string} type - Type filter ('users', 'groups', or 'all')
|
||||
* @param {number} limit - Maximum number of results
|
||||
* @returns {Promise<TPrincipalSearchResult[]>} Array of principal search results
|
||||
*/
|
||||
const searchEntraIdPrincipals = async (accessToken, sub, query, type = 'all', limit = 10) => {
|
||||
try {
|
||||
if (!query || query.trim().length < 2) {
|
||||
return [];
|
||||
}
|
||||
const graphClient = await createGraphClient(accessToken, sub);
|
||||
let allResults = [];
|
||||
|
||||
if (type === 'users' || type === 'all') {
|
||||
const contactResults = await searchContacts(graphClient, query, limit);
|
||||
allResults.push(...contactResults);
|
||||
}
|
||||
if (allResults.length >= limit) {
|
||||
return allResults.slice(0, limit);
|
||||
}
|
||||
|
||||
if (type === 'users') {
|
||||
const userResults = await searchUsers(graphClient, query, limit);
|
||||
allResults.push(...userResults);
|
||||
} else if (type === 'groups') {
|
||||
const groupResults = await searchGroups(graphClient, query, limit);
|
||||
allResults.push(...groupResults);
|
||||
} else if (type === 'all') {
|
||||
const [userResults, groupResults] = await Promise.all([
|
||||
searchUsers(graphClient, query, limit),
|
||||
searchGroups(graphClient, query, limit),
|
||||
]);
|
||||
|
||||
allResults.push(...userResults, ...groupResults);
|
||||
}
|
||||
|
||||
const seenIds = new Set();
|
||||
const uniqueResults = allResults.filter((result) => {
|
||||
if (seenIds.has(result.idOnTheSource)) {
|
||||
return false;
|
||||
}
|
||||
seenIds.add(result.idOnTheSource);
|
||||
return true;
|
||||
});
|
||||
|
||||
return uniqueResults.slice(0, limit);
|
||||
} catch (error) {
|
||||
logger.error('[searchEntraIdPrincipals] Error searching principals:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get current user's Entra ID group memberships from Microsoft Graph
|
||||
* Uses /me/memberOf endpoint to get groups the user is a member of
|
||||
* @param {string} accessToken - OpenID Connect access token
|
||||
* @param {string} sub - Subject identifier
|
||||
* @returns {Promise<Array<string>>} Array of group ID strings (GUIDs)
|
||||
*/
|
||||
const getUserEntraGroups = async (accessToken, sub) => {
|
||||
try {
|
||||
const graphClient = await createGraphClient(accessToken, sub);
|
||||
|
||||
const groupsResponse = await graphClient.api('/me/memberOf').select('id').get();
|
||||
|
||||
return (groupsResponse.value || []).map((group) => group.id);
|
||||
} catch (error) {
|
||||
logger.error('[getUserEntraGroups] Error fetching user groups:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get current user's owned Entra ID groups from Microsoft Graph
|
||||
* Uses /me/ownedObjects/microsoft.graph.group endpoint to get groups the user owns
|
||||
* @param {string} accessToken - OpenID Connect access token
|
||||
* @param {string} sub - Subject identifier
|
||||
* @returns {Promise<Array<string>>} Array of group ID strings (GUIDs)
|
||||
*/
|
||||
const getUserOwnedEntraGroups = async (accessToken, sub) => {
|
||||
try {
|
||||
const graphClient = await createGraphClient(accessToken, sub);
|
||||
|
||||
const groupsResponse = await graphClient
|
||||
.api('/me/ownedObjects/microsoft.graph.group')
|
||||
.select('id')
|
||||
.get();
|
||||
|
||||
return (groupsResponse.value || []).map((group) => group.id);
|
||||
} catch (error) {
|
||||
logger.error('[getUserOwnedEntraGroups] Error fetching user owned groups:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get group members from Microsoft Graph API
|
||||
* Recursively fetches all members using pagination (@odata.nextLink)
|
||||
* @param {string} accessToken - OpenID Connect access token
|
||||
* @param {string} sub - Subject identifier
|
||||
* @param {string} groupId - Entra ID group object ID
|
||||
* @returns {Promise<Array>} Array of member IDs (idOnTheSource values)
|
||||
*/
|
||||
const getGroupMembers = async (accessToken, sub, groupId) => {
|
||||
try {
|
||||
const graphClient = await createGraphClient(accessToken, sub);
|
||||
const allMembers = [];
|
||||
let nextLink = `/groups/${groupId}/members`;
|
||||
|
||||
while (nextLink) {
|
||||
const membersResponse = await graphClient.api(nextLink).select('id').top(999).get();
|
||||
|
||||
const members = membersResponse.value || [];
|
||||
allMembers.push(...members.map((member) => member.id));
|
||||
|
||||
nextLink = membersResponse['@odata.nextLink']
|
||||
? membersResponse['@odata.nextLink'].split('/v1.0')[1]
|
||||
: null;
|
||||
}
|
||||
|
||||
return allMembers;
|
||||
} catch (error) {
|
||||
logger.error('[getGroupMembers] Error fetching group members:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
/**
|
||||
* Get group owners from Microsoft Graph API
|
||||
* Recursively fetches all owners using pagination (@odata.nextLink)
|
||||
* @param {string} accessToken - OpenID Connect access token
|
||||
* @param {string} sub - Subject identifier
|
||||
* @param {string} groupId - Entra ID group object ID
|
||||
* @returns {Promise<Array>} Array of owner IDs (idOnTheSource values)
|
||||
*/
|
||||
const getGroupOwners = async (accessToken, sub, groupId) => {
|
||||
try {
|
||||
const graphClient = await createGraphClient(accessToken, sub);
|
||||
const allOwners = [];
|
||||
let nextLink = `/groups/${groupId}/owners`;
|
||||
|
||||
while (nextLink) {
|
||||
const ownersResponse = await graphClient.api(nextLink).select('id').top(999).get();
|
||||
|
||||
const owners = ownersResponse.value || [];
|
||||
allOwners.push(...owners.map((member) => member.id));
|
||||
|
||||
nextLink = ownersResponse['@odata.nextLink']
|
||||
? ownersResponse['@odata.nextLink'].split('/v1.0')[1]
|
||||
: null;
|
||||
}
|
||||
|
||||
return allOwners;
|
||||
} catch (error) {
|
||||
logger.error('[getGroupOwners] Error fetching group owners:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
/**
|
||||
* Search for contacts (users only) using Microsoft Graph /me/people endpoint
|
||||
* Returns mapped TPrincipalSearchResult objects for users only
|
||||
* @param {Client} graphClient - Authenticated Microsoft Graph client
|
||||
* @param {string} query - Search query string
|
||||
* @param {number} limit - Maximum number of results (default: 10)
|
||||
* @returns {Promise<TPrincipalSearchResult[]>} Array of mapped user contact results
|
||||
*/
|
||||
const searchContacts = async (graphClient, query, limit = 10) => {
|
||||
try {
|
||||
if (!query || query.trim().length < 2) {
|
||||
return [];
|
||||
}
|
||||
if (
|
||||
process.env.OPENID_GRAPH_SCOPES &&
|
||||
!process.env.OPENID_GRAPH_SCOPES.toLowerCase().includes('people.read')
|
||||
) {
|
||||
logger.warn('[searchContacts] People.Read scope is not enabled, skipping contact search');
|
||||
return [];
|
||||
}
|
||||
// Reason: Search only for OrganizationUser (person) type, not groups
|
||||
const filter = "personType/subclass eq 'OrganizationUser'";
|
||||
|
||||
let apiCall = graphClient
|
||||
.api('/me/people')
|
||||
.search(`"${query}"`)
|
||||
.select(
|
||||
'id,displayName,givenName,surname,userPrincipalName,jobTitle,department,companyName,scoredEmailAddresses,personType,phones',
|
||||
)
|
||||
.header('ConsistencyLevel', 'eventual')
|
||||
.filter(filter)
|
||||
.top(limit);
|
||||
|
||||
const contactsResponse = await apiCall.get();
|
||||
return (contactsResponse.value || []).map(mapContactToTPrincipalSearchResult);
|
||||
} catch (error) {
|
||||
logger.error('[searchContacts] Error searching contacts:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Search for users using Microsoft Graph /users endpoint
|
||||
* Returns mapped TPrincipalSearchResult objects
|
||||
* @param {Client} graphClient - Authenticated Microsoft Graph client
|
||||
* @param {string} query - Search query string
|
||||
* @param {number} limit - Maximum number of results (default: 10)
|
||||
* @returns {Promise<TPrincipalSearchResult[]>} Array of mapped user results
|
||||
*/
|
||||
const searchUsers = async (graphClient, query, limit = 10) => {
|
||||
try {
|
||||
if (!query || query.trim().length < 2) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Reason: Search users by display name, email, and user principal name
|
||||
const usersResponse = await graphClient
|
||||
.api('/users')
|
||||
.search(
|
||||
`"displayName:${query}" OR "userPrincipalName:${query}" OR "mail:${query}" OR "givenName:${query}" OR "surname:${query}"`,
|
||||
)
|
||||
.select(
|
||||
'id,displayName,givenName,surname,userPrincipalName,jobTitle,department,companyName,mail,phones',
|
||||
)
|
||||
.header('ConsistencyLevel', 'eventual')
|
||||
.top(limit)
|
||||
.get();
|
||||
|
||||
return (usersResponse.value || []).map(mapUserToTPrincipalSearchResult);
|
||||
} catch (error) {
|
||||
logger.error('[searchUsers] Error searching users:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Search for groups using Microsoft Graph /groups endpoint
|
||||
* Returns mapped TPrincipalSearchResult objects, includes all group types
|
||||
* @param {Client} graphClient - Authenticated Microsoft Graph client
|
||||
* @param {string} query - Search query string
|
||||
* @param {number} limit - Maximum number of results (default: 10)
|
||||
* @returns {Promise<TPrincipalSearchResult[]>} Array of mapped group results
|
||||
*/
|
||||
const searchGroups = async (graphClient, query, limit = 10) => {
|
||||
try {
|
||||
if (!query || query.trim().length < 2) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Reason: Search all groups by display name and email without filtering group types
|
||||
const groupsResponse = await graphClient
|
||||
.api('/groups')
|
||||
.search(`"displayName:${query}" OR "mail:${query}" OR "mailNickname:${query}"`)
|
||||
.select('id,displayName,mail,mailNickname,description,groupTypes,resourceProvisioningOptions')
|
||||
.header('ConsistencyLevel', 'eventual')
|
||||
.top(limit)
|
||||
.get();
|
||||
|
||||
return (groupsResponse.value || []).map(mapGroupToTPrincipalSearchResult);
|
||||
} catch (error) {
|
||||
logger.error('[searchGroups] Error searching groups:', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Test Graph API connectivity and permissions
|
||||
* @param {string} accessToken - OpenID Connect access token
|
||||
* @param {string} sub - Subject identifier
|
||||
* @returns {Promise<Object>} Test results with available permissions
|
||||
*/
|
||||
const testGraphApiAccess = async (accessToken, sub) => {
|
||||
try {
|
||||
const graphClient = await createGraphClient(accessToken, sub);
|
||||
const results = {
|
||||
userAccess: false,
|
||||
peopleAccess: false,
|
||||
groupsAccess: false,
|
||||
usersEndpointAccess: false,
|
||||
groupsEndpointAccess: false,
|
||||
errors: [],
|
||||
};
|
||||
|
||||
// Test User.Read permission
|
||||
try {
|
||||
await graphClient.api('/me').select('id,displayName').get();
|
||||
results.userAccess = true;
|
||||
} catch (error) {
|
||||
results.errors.push(`User.Read: ${error.message}`);
|
||||
}
|
||||
|
||||
// Test People.Read permission with OrganizationUser filter
|
||||
try {
|
||||
await graphClient
|
||||
.api('/me/people')
|
||||
.filter("personType/subclass eq 'OrganizationUser'")
|
||||
.top(1)
|
||||
.get();
|
||||
results.peopleAccess = true;
|
||||
} catch (error) {
|
||||
results.errors.push(`People.Read (OrganizationUser): ${error.message}`);
|
||||
}
|
||||
|
||||
// Test People.Read permission with UnifiedGroup filter
|
||||
try {
|
||||
await graphClient
|
||||
.api('/me/people')
|
||||
.filter("personType/subclass eq 'UnifiedGroup'")
|
||||
.top(1)
|
||||
.get();
|
||||
results.groupsAccess = true;
|
||||
} catch (error) {
|
||||
results.errors.push(`People.Read (UnifiedGroup): ${error.message}`);
|
||||
}
|
||||
|
||||
// Test /users endpoint access (requires User.Read.All or similar)
|
||||
try {
|
||||
await graphClient
|
||||
.api('/users')
|
||||
.search('"displayName:test"')
|
||||
.select('id,displayName,userPrincipalName')
|
||||
.top(1)
|
||||
.get();
|
||||
results.usersEndpointAccess = true;
|
||||
} catch (error) {
|
||||
results.errors.push(`Users endpoint: ${error.message}`);
|
||||
}
|
||||
|
||||
// Test /groups endpoint access (requires Group.Read.All or similar)
|
||||
try {
|
||||
await graphClient
|
||||
.api('/groups')
|
||||
.search('"displayName:test"')
|
||||
.select('id,displayName,mail')
|
||||
.top(1)
|
||||
.get();
|
||||
results.groupsEndpointAccess = true;
|
||||
} catch (error) {
|
||||
results.errors.push(`Groups endpoint: ${error.message}`);
|
||||
}
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
logger.error('[testGraphApiAccess] Error testing Graph API access:', error);
|
||||
return {
|
||||
userAccess: false,
|
||||
peopleAccess: false,
|
||||
groupsAccess: false,
|
||||
usersEndpointAccess: false,
|
||||
groupsEndpointAccess: false,
|
||||
errors: [error.message],
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Map Graph API user object to TPrincipalSearchResult format
|
||||
* @param {TGraphUser} user - Raw user object from Graph API
|
||||
* @returns {TPrincipalSearchResult} Mapped user result
|
||||
*/
|
||||
const mapUserToTPrincipalSearchResult = (user) => {
|
||||
return {
|
||||
id: null,
|
||||
type: 'user',
|
||||
name: user.displayName,
|
||||
email: user.mail || user.userPrincipalName,
|
||||
username: user.userPrincipalName,
|
||||
source: 'entra',
|
||||
idOnTheSource: user.id,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Map Graph API group object to TPrincipalSearchResult format
|
||||
* @param {TGraphGroup} group - Raw group object from Graph API
|
||||
* @returns {TPrincipalSearchResult} Mapped group result
|
||||
*/
|
||||
const mapGroupToTPrincipalSearchResult = (group) => {
|
||||
return {
|
||||
id: null,
|
||||
type: 'group',
|
||||
name: group.displayName,
|
||||
email: group.mail || group.userPrincipalName,
|
||||
description: group.description,
|
||||
source: 'entra',
|
||||
idOnTheSource: group.id,
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Map Graph API /me/people contact object to TPrincipalSearchResult format
|
||||
* Handles both user and group contacts from the people endpoint
|
||||
* @param {TGraphPerson} contact - Raw contact object from Graph API /me/people
|
||||
* @returns {TPrincipalSearchResult} Mapped contact result
|
||||
*/
|
||||
const mapContactToTPrincipalSearchResult = (contact) => {
|
||||
const isGroup = contact.personType?.class === 'Group';
|
||||
const primaryEmail = contact.scoredEmailAddresses?.[0]?.address;
|
||||
|
||||
return {
|
||||
id: null,
|
||||
type: isGroup ? 'group' : 'user',
|
||||
name: contact.displayName,
|
||||
email: primaryEmail,
|
||||
username: !isGroup ? contact.userPrincipalName : undefined,
|
||||
source: 'entra',
|
||||
idOnTheSource: contact.id,
|
||||
};
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
getGroupMembers,
|
||||
getGroupOwners,
|
||||
createGraphClient,
|
||||
getUserEntraGroups,
|
||||
getUserOwnedEntraGroups,
|
||||
testGraphApiAccess,
|
||||
searchEntraIdPrincipals,
|
||||
exchangeTokenForGraphAccess,
|
||||
entraIdPrincipalFeatureEnabled,
|
||||
};
|
||||
720
api/server/services/GraphApiService.spec.js
Normal file
720
api/server/services/GraphApiService.spec.js
Normal file
|
|
@ -0,0 +1,720 @@
|
|||
jest.mock('@microsoft/microsoft-graph-client');
|
||||
jest.mock('~/strategies/openidStrategy');
|
||||
jest.mock('~/cache/getLogStores');
|
||||
jest.mock('@librechat/data-schemas', () => ({
|
||||
...jest.requireActual('@librechat/data-schemas'),
|
||||
logger: {
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
},
|
||||
}));
|
||||
jest.mock('~/config', () => ({
|
||||
logger: {
|
||||
error: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
},
|
||||
createAxiosInstance: jest.fn(() => ({
|
||||
create: jest.fn(),
|
||||
defaults: {},
|
||||
})),
|
||||
}));
|
||||
jest.mock('~/utils', () => ({
|
||||
logAxiosError: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('~/server/services/Config', () => ({}));
|
||||
jest.mock('~/server/services/Files/strategies', () => ({
|
||||
getStrategyFunctions: jest.fn(),
|
||||
}));
|
||||
|
||||
const mongoose = require('mongoose');
|
||||
const client = require('openid-client');
|
||||
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||
const { Client } = require('@microsoft/microsoft-graph-client');
|
||||
const { getOpenIdConfig } = require('~/strategies/openidStrategy');
|
||||
const getLogStores = require('~/cache/getLogStores');
|
||||
const GraphApiService = require('./GraphApiService');
|
||||
|
||||
describe('GraphApiService', () => {
|
||||
let mongoServer;
|
||||
let mockGraphClient;
|
||||
let mockTokensCache;
|
||||
let mockOpenIdConfig;
|
||||
|
||||
beforeAll(async () => {
|
||||
mongoServer = await MongoMemoryServer.create();
|
||||
const mongoUri = mongoServer.getUri();
|
||||
await mongoose.connect(mongoUri);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await mongoose.disconnect();
|
||||
await mongoServer.stop();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Clean up environment variables
|
||||
delete process.env.OPENID_GRAPH_SCOPES;
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
jest.clearAllMocks();
|
||||
await mongoose.connection.dropDatabase();
|
||||
|
||||
// Set up environment variable for People.Read scope
|
||||
process.env.OPENID_GRAPH_SCOPES = 'User.Read,People.Read,Group.Read.All';
|
||||
|
||||
// Mock Graph client
|
||||
mockGraphClient = {
|
||||
api: jest.fn().mockReturnThis(),
|
||||
search: jest.fn().mockReturnThis(),
|
||||
filter: jest.fn().mockReturnThis(),
|
||||
select: jest.fn().mockReturnThis(),
|
||||
header: jest.fn().mockReturnThis(),
|
||||
top: jest.fn().mockReturnThis(),
|
||||
get: jest.fn(),
|
||||
};
|
||||
|
||||
Client.init.mockReturnValue(mockGraphClient);
|
||||
|
||||
// Mock tokens cache
|
||||
mockTokensCache = {
|
||||
get: jest.fn(),
|
||||
set: jest.fn(),
|
||||
};
|
||||
getLogStores.mockReturnValue(mockTokensCache);
|
||||
|
||||
// Mock OpenID config
|
||||
mockOpenIdConfig = {
|
||||
client_id: 'test-client-id',
|
||||
issuer: 'https://test-issuer.com',
|
||||
};
|
||||
getOpenIdConfig.mockReturnValue(mockOpenIdConfig);
|
||||
|
||||
// Mock openid-client (using the existing jest mock configuration)
|
||||
if (client.genericGrantRequest) {
|
||||
client.genericGrantRequest.mockResolvedValue({
|
||||
access_token: 'mocked-graph-token',
|
||||
expires_in: 3600,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('Dependency Contract Tests', () => {
|
||||
it('should fail if getOpenIdConfig interface changes', () => {
|
||||
// Reason: Ensure getOpenIdConfig returns expected structure
|
||||
const config = getOpenIdConfig();
|
||||
|
||||
expect(config).toBeDefined();
|
||||
expect(typeof config).toBe('object');
|
||||
// Add specific property checks that GraphApiService depends on
|
||||
expect(config).toHaveProperty('client_id');
|
||||
expect(config).toHaveProperty('issuer');
|
||||
|
||||
// Ensure the function is callable
|
||||
expect(typeof getOpenIdConfig).toBe('function');
|
||||
});
|
||||
|
||||
it('should fail if openid-client.genericGrantRequest interface changes', () => {
|
||||
// Reason: Ensure client.genericGrantRequest maintains expected signature
|
||||
if (client.genericGrantRequest) {
|
||||
expect(typeof client.genericGrantRequest).toBe('function');
|
||||
|
||||
// Test that it accepts the expected parameters
|
||||
const mockCall = client.genericGrantRequest(
|
||||
mockOpenIdConfig,
|
||||
'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
{
|
||||
scope: 'test-scope',
|
||||
assertion: 'test-token',
|
||||
requested_token_use: 'on_behalf_of',
|
||||
},
|
||||
);
|
||||
|
||||
expect(mockCall).toBeDefined();
|
||||
}
|
||||
});
|
||||
|
||||
it('should fail if Microsoft Graph Client interface changes', () => {
|
||||
// Reason: Ensure Graph Client maintains expected fluent API
|
||||
expect(typeof Client.init).toBe('function');
|
||||
|
||||
const client = Client.init({ authProvider: jest.fn() });
|
||||
expect(client).toHaveProperty('api');
|
||||
expect(typeof client.api).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createGraphClient', () => {
|
||||
it('should create graph client with exchanged token', async () => {
|
||||
const accessToken = 'test-access-token';
|
||||
const sub = 'test-user-id';
|
||||
|
||||
const result = await GraphApiService.createGraphClient(accessToken, sub);
|
||||
|
||||
expect(getOpenIdConfig).toHaveBeenCalled();
|
||||
expect(Client.init).toHaveBeenCalledWith({
|
||||
authProvider: expect.any(Function),
|
||||
});
|
||||
expect(result).toBe(mockGraphClient);
|
||||
});
|
||||
|
||||
it('should handle token exchange errors gracefully', async () => {
|
||||
if (client.genericGrantRequest) {
|
||||
client.genericGrantRequest.mockRejectedValue(new Error('Token exchange failed'));
|
||||
}
|
||||
|
||||
await expect(GraphApiService.createGraphClient('invalid-token', 'test-user')).rejects.toThrow(
|
||||
'Token exchange failed',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('exchangeTokenForGraphAccess', () => {
|
||||
it('should return cached token if available', async () => {
|
||||
const cachedToken = { access_token: 'cached-token' };
|
||||
mockTokensCache.get.mockResolvedValue(cachedToken);
|
||||
|
||||
const result = await GraphApiService.exchangeTokenForGraphAccess(
|
||||
mockOpenIdConfig,
|
||||
'test-token',
|
||||
'test-user',
|
||||
);
|
||||
|
||||
expect(result).toBe('cached-token');
|
||||
expect(mockTokensCache.get).toHaveBeenCalledWith('test-user:graph');
|
||||
if (client.genericGrantRequest) {
|
||||
expect(client.genericGrantRequest).not.toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
|
||||
it('should exchange token and cache result', async () => {
|
||||
mockTokensCache.get.mockResolvedValue(null);
|
||||
|
||||
const result = await GraphApiService.exchangeTokenForGraphAccess(
|
||||
mockOpenIdConfig,
|
||||
'test-token',
|
||||
'test-user',
|
||||
);
|
||||
|
||||
if (client.genericGrantRequest) {
|
||||
expect(client.genericGrantRequest).toHaveBeenCalledWith(
|
||||
mockOpenIdConfig,
|
||||
'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
{
|
||||
scope:
|
||||
'https://graph.microsoft.com/User.Read https://graph.microsoft.com/People.Read https://graph.microsoft.com/Group.Read.All',
|
||||
assertion: 'test-token',
|
||||
requested_token_use: 'on_behalf_of',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
expect(mockTokensCache.set).toHaveBeenCalledWith(
|
||||
'test-user:graph',
|
||||
{ access_token: 'mocked-graph-token' },
|
||||
3600000,
|
||||
);
|
||||
|
||||
expect(result).toBe('mocked-graph-token');
|
||||
});
|
||||
|
||||
it('should use custom scopes from environment', async () => {
|
||||
const originalEnv = process.env.OPENID_GRAPH_SCOPES;
|
||||
process.env.OPENID_GRAPH_SCOPES = 'Custom.Read,Custom.Write';
|
||||
|
||||
mockTokensCache.get.mockResolvedValue(null);
|
||||
|
||||
await GraphApiService.exchangeTokenForGraphAccess(
|
||||
mockOpenIdConfig,
|
||||
'test-token',
|
||||
'test-user',
|
||||
);
|
||||
|
||||
if (client.genericGrantRequest) {
|
||||
expect(client.genericGrantRequest).toHaveBeenCalledWith(
|
||||
mockOpenIdConfig,
|
||||
'urn:ietf:params:oauth:grant-type:jwt-bearer',
|
||||
{
|
||||
scope:
|
||||
'https://graph.microsoft.com/Custom.Read https://graph.microsoft.com/Custom.Write',
|
||||
assertion: 'test-token',
|
||||
requested_token_use: 'on_behalf_of',
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
process.env.OPENID_GRAPH_SCOPES = originalEnv;
|
||||
});
|
||||
});
|
||||
|
||||
describe('searchEntraIdPrincipals', () => {
|
||||
// Mock data used by multiple tests
|
||||
const mockContactsResponse = {
|
||||
value: [
|
||||
{
|
||||
id: 'contact-user-1',
|
||||
displayName: 'John Doe',
|
||||
userPrincipalName: 'john@company.com',
|
||||
mail: 'john@company.com',
|
||||
personType: { class: 'Person', subclass: 'OrganizationUser' },
|
||||
scoredEmailAddresses: [{ address: 'john@company.com', relevanceScore: 0.9 }],
|
||||
},
|
||||
{
|
||||
id: 'contact-group-1',
|
||||
displayName: 'Marketing Team',
|
||||
mail: 'marketing@company.com',
|
||||
personType: { class: 'Group', subclass: 'UnifiedGroup' },
|
||||
scoredEmailAddresses: [{ address: 'marketing@company.com', relevanceScore: 0.8 }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockUsersResponse = {
|
||||
value: [
|
||||
{
|
||||
id: 'dir-user-1',
|
||||
displayName: 'Jane Smith',
|
||||
userPrincipalName: 'jane@company.com',
|
||||
mail: 'jane@company.com',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const mockGroupsResponse = {
|
||||
value: [
|
||||
{
|
||||
id: 'dir-group-1',
|
||||
displayName: 'Development Team',
|
||||
mail: 'dev@company.com',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
// Reset mock call history for each test
|
||||
jest.clearAllMocks();
|
||||
|
||||
// Re-apply the Client.init mock after clearAllMocks
|
||||
Client.init.mockReturnValue(mockGraphClient);
|
||||
|
||||
// Re-apply openid-client mock
|
||||
if (client.genericGrantRequest) {
|
||||
client.genericGrantRequest.mockResolvedValue({
|
||||
access_token: 'mocked-graph-token',
|
||||
expires_in: 3600,
|
||||
});
|
||||
}
|
||||
|
||||
// Re-apply cache mock
|
||||
mockTokensCache.get.mockResolvedValue(null); // Force token exchange
|
||||
mockTokensCache.set.mockResolvedValue();
|
||||
getLogStores.mockReturnValue(mockTokensCache);
|
||||
getOpenIdConfig.mockReturnValue(mockOpenIdConfig);
|
||||
});
|
||||
|
||||
it('should return empty results for short queries', async () => {
|
||||
const result = await GraphApiService.searchEntraIdPrincipals('token', 'user', 'a', 'all', 10);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
expect(mockGraphClient.api).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should search contacts first and additional users for users type', async () => {
|
||||
// Mock responses for this specific test
|
||||
const contactsFilteredResponse = {
|
||||
value: [
|
||||
{
|
||||
id: 'contact-user-1',
|
||||
displayName: 'John Doe',
|
||||
userPrincipalName: 'john@company.com',
|
||||
mail: 'john@company.com',
|
||||
personType: { class: 'Person', subclass: 'OrganizationUser' },
|
||||
scoredEmailAddresses: [{ address: 'john@company.com', relevanceScore: 0.9 }],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockGraphClient.get
|
||||
.mockResolvedValueOnce(contactsFilteredResponse) // contacts call
|
||||
.mockResolvedValueOnce(mockUsersResponse); // users call
|
||||
|
||||
const result = await GraphApiService.searchEntraIdPrincipals(
|
||||
'token',
|
||||
'user',
|
||||
'john',
|
||||
'users',
|
||||
10,
|
||||
);
|
||||
|
||||
// Should call contacts first with user filter
|
||||
expect(mockGraphClient.api).toHaveBeenCalledWith('/me/people');
|
||||
expect(mockGraphClient.search).toHaveBeenCalledWith('"john"');
|
||||
expect(mockGraphClient.filter).toHaveBeenCalledWith(
|
||||
"personType/subclass eq 'OrganizationUser'",
|
||||
);
|
||||
|
||||
// Should call users endpoint for additional results
|
||||
expect(mockGraphClient.api).toHaveBeenCalledWith('/users');
|
||||
expect(mockGraphClient.search).toHaveBeenCalledWith(
|
||||
'"displayName:john" OR "userPrincipalName:john" OR "mail:john" OR "givenName:john" OR "surname:john"',
|
||||
);
|
||||
|
||||
// Should return TPrincipalSearchResult array
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result).toHaveLength(2); // 1 from contacts + 1 from users
|
||||
expect(result[0]).toMatchObject({
|
||||
id: null,
|
||||
type: 'user',
|
||||
name: 'John Doe',
|
||||
email: 'john@company.com',
|
||||
source: 'entra',
|
||||
idOnTheSource: 'contact-user-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('should search groups endpoint only for groups type', async () => {
|
||||
// Mock responses for this specific test - only groups endpoint called
|
||||
mockGraphClient.get.mockResolvedValueOnce(mockGroupsResponse); // only groups call
|
||||
|
||||
const result = await GraphApiService.searchEntraIdPrincipals(
|
||||
'token',
|
||||
'user',
|
||||
'team',
|
||||
'groups',
|
||||
10,
|
||||
);
|
||||
|
||||
// Should NOT call contacts for groups type
|
||||
expect(mockGraphClient.api).not.toHaveBeenCalledWith('/me/people');
|
||||
|
||||
// Should call groups endpoint only
|
||||
expect(mockGraphClient.api).toHaveBeenCalledWith('/groups');
|
||||
expect(mockGraphClient.search).toHaveBeenCalledWith(
|
||||
'"displayName:team" OR "mail:team" OR "mailNickname:team"',
|
||||
);
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result).toHaveLength(1); // 1 from groups only
|
||||
});
|
||||
|
||||
it('should search all endpoints for all type', async () => {
|
||||
// Mock responses for this specific test
|
||||
mockGraphClient.get
|
||||
.mockResolvedValueOnce(mockContactsResponse) // contacts call (both user and group)
|
||||
.mockResolvedValueOnce(mockUsersResponse) // users call
|
||||
.mockResolvedValueOnce(mockGroupsResponse); // groups call
|
||||
|
||||
const result = await GraphApiService.searchEntraIdPrincipals(
|
||||
'token',
|
||||
'user',
|
||||
'test',
|
||||
'all',
|
||||
10,
|
||||
);
|
||||
|
||||
// Should call contacts first with user filter
|
||||
expect(mockGraphClient.api).toHaveBeenCalledWith('/me/people');
|
||||
expect(mockGraphClient.search).toHaveBeenCalledWith('"test"');
|
||||
expect(mockGraphClient.filter).toHaveBeenCalledWith(
|
||||
"personType/subclass eq 'OrganizationUser'",
|
||||
);
|
||||
|
||||
// Should call both users and groups endpoints
|
||||
expect(mockGraphClient.api).toHaveBeenCalledWith('/users');
|
||||
expect(mockGraphClient.api).toHaveBeenCalledWith('/groups');
|
||||
|
||||
expect(Array.isArray(result)).toBe(true);
|
||||
expect(result).toHaveLength(4); // 2 from contacts + 1 from users + 1 from groups
|
||||
});
|
||||
|
||||
it('should early exit if contacts reach limit', async () => {
|
||||
// Mock contacts to return exactly the limit
|
||||
const limitedContactsResponse = {
|
||||
value: Array(10).fill({
|
||||
id: 'contact-1',
|
||||
displayName: 'Contact User',
|
||||
mail: 'contact@company.com',
|
||||
personType: { class: 'Person', subclass: 'OrganizationUser' },
|
||||
}),
|
||||
};
|
||||
|
||||
mockGraphClient.get.mockResolvedValueOnce(limitedContactsResponse);
|
||||
|
||||
const result = await GraphApiService.searchEntraIdPrincipals(
|
||||
'token',
|
||||
'user',
|
||||
'test',
|
||||
'all',
|
||||
10,
|
||||
);
|
||||
|
||||
// Should call contacts first
|
||||
expect(mockGraphClient.api).toHaveBeenCalledWith('/me/people');
|
||||
expect(mockGraphClient.search).toHaveBeenCalledWith('"test"');
|
||||
// Should not call users endpoint since limit was reached
|
||||
expect(mockGraphClient.api).not.toHaveBeenCalledWith('/users');
|
||||
|
||||
expect(result).toHaveLength(10);
|
||||
});
|
||||
|
||||
it('should deduplicate results based on idOnTheSource', async () => {
|
||||
// Mock responses with duplicate IDs
|
||||
const duplicateContactsResponse = {
|
||||
value: [
|
||||
{
|
||||
id: 'duplicate-id',
|
||||
displayName: 'John Doe',
|
||||
mail: 'john@company.com',
|
||||
personType: { class: 'Person', subclass: 'OrganizationUser' },
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const duplicateUsersResponse = {
|
||||
value: [
|
||||
{
|
||||
id: 'duplicate-id', // Same ID as contact
|
||||
displayName: 'John Doe',
|
||||
mail: 'john@company.com',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockGraphClient.get
|
||||
.mockResolvedValueOnce(duplicateContactsResponse)
|
||||
.mockResolvedValueOnce(duplicateUsersResponse);
|
||||
|
||||
const result = await GraphApiService.searchEntraIdPrincipals(
|
||||
'token',
|
||||
'user',
|
||||
'john',
|
||||
'users',
|
||||
10,
|
||||
);
|
||||
|
||||
// Should only return one result despite duplicate IDs
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].idOnTheSource).toBe('duplicate-id');
|
||||
});
|
||||
|
||||
it('should handle Graph API errors gracefully', async () => {
|
||||
mockGraphClient.get.mockRejectedValue(new Error('Graph API error'));
|
||||
|
||||
const result = await GraphApiService.searchEntraIdPrincipals(
|
||||
'token',
|
||||
'user',
|
||||
'test',
|
||||
'all',
|
||||
10,
|
||||
);
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getUserEntraGroups', () => {
|
||||
it('should fetch user groups from memberOf endpoint', async () => {
|
||||
const mockGroupsResponse = {
|
||||
value: [
|
||||
{
|
||||
id: 'group-1',
|
||||
},
|
||||
{
|
||||
id: 'group-2',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
mockGraphClient.get.mockResolvedValue(mockGroupsResponse);
|
||||
|
||||
const result = await GraphApiService.getUserEntraGroups('token', 'user');
|
||||
|
||||
expect(mockGraphClient.api).toHaveBeenCalledWith('/me/memberOf');
|
||||
expect(mockGraphClient.select).toHaveBeenCalledWith('id');
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result).toEqual(['group-1', 'group-2']);
|
||||
});
|
||||
|
||||
it('should return empty array on error', async () => {
|
||||
mockGraphClient.get.mockRejectedValue(new Error('API error'));
|
||||
|
||||
const result = await GraphApiService.getUserEntraGroups('token', 'user');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle empty response', async () => {
|
||||
const mockGroupsResponse = {
|
||||
value: [],
|
||||
};
|
||||
|
||||
mockGraphClient.get.mockResolvedValue(mockGroupsResponse);
|
||||
|
||||
const result = await GraphApiService.getUserEntraGroups('token', 'user');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
it('should handle missing value property', async () => {
|
||||
mockGraphClient.get.mockResolvedValue({});
|
||||
|
||||
const result = await GraphApiService.getUserEntraGroups('token', 'user');
|
||||
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('testGraphApiAccess', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should test all permissions and return success results', async () => {
|
||||
// Mock successful responses for all tests
|
||||
mockGraphClient.get
|
||||
.mockResolvedValueOnce({ id: 'user-123', displayName: 'Test User' }) // /me test
|
||||
.mockResolvedValueOnce({ value: [] }) // people OrganizationUser test
|
||||
.mockResolvedValueOnce({ value: [] }) // people UnifiedGroup test
|
||||
.mockResolvedValueOnce({ value: [] }) // /users endpoint test
|
||||
.mockResolvedValueOnce({ value: [] }); // /groups endpoint test
|
||||
|
||||
const result = await GraphApiService.testGraphApiAccess('token', 'user');
|
||||
|
||||
expect(result).toEqual({
|
||||
userAccess: true,
|
||||
peopleAccess: true,
|
||||
groupsAccess: true,
|
||||
usersEndpointAccess: true,
|
||||
groupsEndpointAccess: true,
|
||||
errors: [],
|
||||
});
|
||||
|
||||
// Verify all endpoints were tested
|
||||
expect(mockGraphClient.api).toHaveBeenCalledWith('/me');
|
||||
expect(mockGraphClient.api).toHaveBeenCalledWith('/me/people');
|
||||
expect(mockGraphClient.api).toHaveBeenCalledWith('/users');
|
||||
expect(mockGraphClient.api).toHaveBeenCalledWith('/groups');
|
||||
expect(mockGraphClient.filter).toHaveBeenCalledWith(
|
||||
"personType/subclass eq 'OrganizationUser'",
|
||||
);
|
||||
expect(mockGraphClient.filter).toHaveBeenCalledWith("personType/subclass eq 'UnifiedGroup'");
|
||||
expect(mockGraphClient.search).toHaveBeenCalledWith('"displayName:test"');
|
||||
});
|
||||
|
||||
it('should handle partial failures and record errors', async () => {
|
||||
// Mock mixed success/failure responses
|
||||
mockGraphClient.get
|
||||
.mockResolvedValueOnce({ id: 'user-123', displayName: 'Test User' }) // /me success
|
||||
.mockRejectedValueOnce(new Error('People access denied')) // people OrganizationUser fail
|
||||
.mockResolvedValueOnce({ value: [] }) // people UnifiedGroup success
|
||||
.mockRejectedValueOnce(new Error('Users endpoint access denied')) // /users fail
|
||||
.mockResolvedValueOnce({ value: [] }); // /groups success
|
||||
|
||||
const result = await GraphApiService.testGraphApiAccess('token', 'user');
|
||||
|
||||
expect(result).toEqual({
|
||||
userAccess: true,
|
||||
peopleAccess: false,
|
||||
groupsAccess: true,
|
||||
usersEndpointAccess: false,
|
||||
groupsEndpointAccess: true,
|
||||
errors: [
|
||||
'People.Read (OrganizationUser): People access denied',
|
||||
'Users endpoint: Users endpoint access denied',
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle complete Graph client creation failure', async () => {
|
||||
// Mock token exchange failure to test error handling
|
||||
if (client.genericGrantRequest) {
|
||||
client.genericGrantRequest.mockRejectedValue(new Error('Token exchange failed'));
|
||||
}
|
||||
|
||||
const result = await GraphApiService.testGraphApiAccess('invalid-token', 'user');
|
||||
|
||||
expect(result).toEqual({
|
||||
userAccess: false,
|
||||
peopleAccess: false,
|
||||
groupsAccess: false,
|
||||
usersEndpointAccess: false,
|
||||
groupsEndpointAccess: false,
|
||||
errors: ['Token exchange failed'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should record all permission errors', async () => {
|
||||
// Mock all requests to fail
|
||||
mockGraphClient.get
|
||||
.mockRejectedValueOnce(new Error('User.Read denied'))
|
||||
.mockRejectedValueOnce(new Error('People.Read OrganizationUser denied'))
|
||||
.mockRejectedValueOnce(new Error('People.Read UnifiedGroup denied'))
|
||||
.mockRejectedValueOnce(new Error('Users directory access denied'))
|
||||
.mockRejectedValueOnce(new Error('Groups directory access denied'));
|
||||
|
||||
const result = await GraphApiService.testGraphApiAccess('token', 'user');
|
||||
|
||||
expect(result).toEqual({
|
||||
userAccess: false,
|
||||
peopleAccess: false,
|
||||
groupsAccess: false,
|
||||
usersEndpointAccess: false,
|
||||
groupsEndpointAccess: false,
|
||||
errors: [
|
||||
'User.Read: User.Read denied',
|
||||
'People.Read (OrganizationUser): People.Read OrganizationUser denied',
|
||||
'People.Read (UnifiedGroup): People.Read UnifiedGroup denied',
|
||||
'Users endpoint: Users directory access denied',
|
||||
'Groups endpoint: Groups directory access denied',
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('should test new endpoints with correct search patterns', async () => {
|
||||
// Mock successful responses for endpoint testing
|
||||
mockGraphClient.get
|
||||
.mockResolvedValueOnce({ id: 'user-123', displayName: 'Test User' }) // /me
|
||||
.mockResolvedValueOnce({ value: [] }) // people OrganizationUser
|
||||
.mockResolvedValueOnce({ value: [] }) // people UnifiedGroup
|
||||
.mockResolvedValueOnce({ value: [] }) // /users
|
||||
.mockResolvedValueOnce({ value: [] }); // /groups
|
||||
|
||||
await GraphApiService.testGraphApiAccess('token', 'user');
|
||||
|
||||
// Verify /users endpoint test
|
||||
expect(mockGraphClient.api).toHaveBeenCalledWith('/users');
|
||||
expect(mockGraphClient.search).toHaveBeenCalledWith('"displayName:test"');
|
||||
expect(mockGraphClient.select).toHaveBeenCalledWith('id,displayName,userPrincipalName');
|
||||
|
||||
// Verify /groups endpoint test
|
||||
expect(mockGraphClient.api).toHaveBeenCalledWith('/groups');
|
||||
expect(mockGraphClient.select).toHaveBeenCalledWith('id,displayName,mail');
|
||||
});
|
||||
|
||||
it('should handle endpoint-specific permission failures', async () => {
|
||||
// Mock specific endpoint failures
|
||||
mockGraphClient.get
|
||||
.mockResolvedValueOnce({ id: 'user-123', displayName: 'Test User' }) // /me success
|
||||
.mockResolvedValueOnce({ value: [] }) // people OrganizationUser success
|
||||
.mockResolvedValueOnce({ value: [] }) // people UnifiedGroup success
|
||||
.mockRejectedValueOnce(new Error('Insufficient privileges')) // /users fail (User.Read.All needed)
|
||||
.mockRejectedValueOnce(new Error('Access denied to groups')); // /groups fail (Group.Read.All needed)
|
||||
|
||||
const result = await GraphApiService.testGraphApiAccess('token', 'user');
|
||||
|
||||
expect(result).toEqual({
|
||||
userAccess: true,
|
||||
peopleAccess: true,
|
||||
groupsAccess: true,
|
||||
usersEndpointAccess: false,
|
||||
groupsEndpointAccess: false,
|
||||
errors: [
|
||||
'Users endpoint: Insufficient privileges',
|
||||
'Groups endpoint: Access denied to groups',
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
721
api/server/services/PermissionService.js
Normal file
721
api/server/services/PermissionService.js
Normal file
|
|
@ -0,0 +1,721 @@
|
|||
const mongoose = require('mongoose');
|
||||
const { getTransactionSupport, logger } = require('@librechat/data-schemas');
|
||||
const { isEnabled } = require('~/server/utils');
|
||||
const {
|
||||
entraIdPrincipalFeatureEnabled,
|
||||
getUserEntraGroups,
|
||||
getUserOwnedEntraGroups,
|
||||
getGroupMembers,
|
||||
getGroupOwners,
|
||||
} = require('~/server/services/GraphApiService');
|
||||
const {
|
||||
findGroupByExternalId,
|
||||
findRoleByIdentifier,
|
||||
getUserPrincipals,
|
||||
createGroup,
|
||||
createUser,
|
||||
updateUser,
|
||||
findUser,
|
||||
grantPermission: grantPermissionACL,
|
||||
findAccessibleResources: findAccessibleResourcesACL,
|
||||
hasPermission,
|
||||
getEffectivePermissions: getEffectivePermissionsACL,
|
||||
findEntriesByPrincipalsAndResource,
|
||||
} = require('~/models');
|
||||
const { AclEntry, AccessRole, Group } = require('~/db/models');
|
||||
|
||||
/** @type {boolean|null} */
|
||||
let transactionSupportCache = null;
|
||||
|
||||
/**
|
||||
* @import { TPrincipal } from 'librechat-data-provider'
|
||||
*/
|
||||
/**
|
||||
* Grant a permission to a principal for a resource using a role
|
||||
* @param {Object} params - Parameters for granting role-based permission
|
||||
* @param {string} params.principalType - 'user', 'group', or 'public'
|
||||
* @param {string|mongoose.Types.ObjectId|null} params.principalId - The ID of the principal (null for 'public')
|
||||
* @param {string} params.resourceType - Type of resource (e.g., 'agent')
|
||||
* @param {string|mongoose.Types.ObjectId} params.resourceId - The ID of the resource
|
||||
* @param {string} params.accessRoleId - The ID of the role (e.g., 'agent_viewer', 'agent_editor')
|
||||
* @param {string|mongoose.Types.ObjectId} params.grantedBy - User ID granting the permission
|
||||
* @param {mongoose.ClientSession} [params.session] - Optional MongoDB session for transactions
|
||||
* @returns {Promise<Object>} The created or updated ACL entry
|
||||
*/
|
||||
const grantPermission = async ({
|
||||
principalType,
|
||||
principalId,
|
||||
resourceType,
|
||||
resourceId,
|
||||
accessRoleId,
|
||||
grantedBy,
|
||||
session,
|
||||
}) => {
|
||||
try {
|
||||
if (!['user', 'group', 'public'].includes(principalType)) {
|
||||
throw new Error(`Invalid principal type: ${principalType}`);
|
||||
}
|
||||
|
||||
if (principalType !== 'public' && !principalId) {
|
||||
throw new Error('Principal ID is required for user and group principals');
|
||||
}
|
||||
|
||||
if (principalId && !mongoose.Types.ObjectId.isValid(principalId)) {
|
||||
throw new Error(`Invalid principal ID: ${principalId}`);
|
||||
}
|
||||
|
||||
if (!resourceId || !mongoose.Types.ObjectId.isValid(resourceId)) {
|
||||
throw new Error(`Invalid resource ID: ${resourceId}`);
|
||||
}
|
||||
|
||||
// Get the role to determine permission bits
|
||||
const role = await findRoleByIdentifier(accessRoleId);
|
||||
if (!role) {
|
||||
throw new Error(`Role ${accessRoleId} not found`);
|
||||
}
|
||||
|
||||
// Ensure the role is for the correct resource type
|
||||
if (role.resourceType !== resourceType) {
|
||||
throw new Error(
|
||||
`Role ${accessRoleId} is for ${role.resourceType} resources, not ${resourceType}`,
|
||||
);
|
||||
}
|
||||
return await grantPermissionACL(
|
||||
principalType,
|
||||
principalId,
|
||||
resourceType,
|
||||
resourceId,
|
||||
role.permBits,
|
||||
grantedBy,
|
||||
session,
|
||||
role._id,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(`[PermissionService.grantPermission] Error: ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if a user has specific permission bits on a resource
|
||||
* @param {Object} params - Parameters for checking permissions
|
||||
* @param {string|mongoose.Types.ObjectId} params.userId - The ID of the user
|
||||
* @param {string} params.resourceType - Type of resource (e.g., 'agent')
|
||||
* @param {string|mongoose.Types.ObjectId} params.resourceId - The ID of the resource
|
||||
* @param {number} params.requiredPermissions - The permission bits required (e.g., 1 for VIEW, 3 for VIEW+EDIT)
|
||||
* @returns {Promise<boolean>} Whether the user has the required permission bits
|
||||
*/
|
||||
const checkPermission = async ({ userId, resourceType, resourceId, requiredPermission }) => {
|
||||
try {
|
||||
if (typeof requiredPermission !== 'number' || requiredPermission < 1) {
|
||||
throw new Error('requiredPermission must be a positive number');
|
||||
}
|
||||
|
||||
// Get all principals for the user (user + groups + public)
|
||||
const principals = await getUserPrincipals(userId);
|
||||
|
||||
if (principals.length === 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return await hasPermission(principals, resourceType, resourceId, requiredPermission);
|
||||
} catch (error) {
|
||||
logger.error(`[PermissionService.checkPermission] Error: ${error.message}`);
|
||||
// Re-throw validation errors
|
||||
if (error.message.includes('requiredPermission must be')) {
|
||||
throw error;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get effective permission bitmask for a user on a resource
|
||||
* @param {Object} params - Parameters for getting effective permissions
|
||||
* @param {string|mongoose.Types.ObjectId} params.userId - The ID of the user
|
||||
* @param {string} params.resourceType - Type of resource (e.g., 'agent')
|
||||
* @param {string|mongoose.Types.ObjectId} params.resourceId - The ID of the resource
|
||||
* @returns {Promise<number>} Effective permission bitmask
|
||||
*/
|
||||
const getEffectivePermissions = async ({ userId, resourceType, resourceId }) => {
|
||||
try {
|
||||
// Get all principals for the user (user + groups + public)
|
||||
const principals = await getUserPrincipals(userId);
|
||||
|
||||
if (principals.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
return await getEffectivePermissionsACL(principals, resourceType, resourceId);
|
||||
} catch (error) {
|
||||
logger.error(`[PermissionService.getEffectivePermissions] Error: ${error.message}`);
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Find all resources of a specific type that a user has access to with specific permission bits
|
||||
* @param {Object} params - Parameters for finding accessible resources
|
||||
* @param {string|mongoose.Types.ObjectId} params.userId - The ID of the user
|
||||
* @param {string} params.resourceType - Type of resource (e.g., 'agent')
|
||||
* @param {number} params.requiredPermissions - The minimum permission bits required (e.g., 1 for VIEW, 3 for VIEW+EDIT)
|
||||
* @returns {Promise<Array>} Array of resource IDs
|
||||
*/
|
||||
const findAccessibleResources = async ({ userId, resourceType, requiredPermissions }) => {
|
||||
try {
|
||||
if (typeof requiredPermissions !== 'number' || requiredPermissions < 1) {
|
||||
throw new Error('requiredPermissions must be a positive number');
|
||||
}
|
||||
|
||||
// Get all principals for the user (user + groups + public)
|
||||
const principalsList = await getUserPrincipals(userId);
|
||||
|
||||
if (principalsList.length === 0) {
|
||||
return [];
|
||||
}
|
||||
return await findAccessibleResourcesACL(principalsList, resourceType, requiredPermissions);
|
||||
} catch (error) {
|
||||
logger.error(`[PermissionService.findAccessibleResources] Error: ${error.message}`);
|
||||
// Re-throw validation errors
|
||||
if (error.message.includes('requiredPermissions must be')) {
|
||||
throw error;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Find all publicly accessible resources of a specific type
|
||||
* @param {Object} params - Parameters for finding publicly accessible resources
|
||||
* @param {string} params.resourceType - Type of resource (e.g., 'agent')
|
||||
* @param {number} params.requiredPermissions - The minimum permission bits required (e.g., 1 for VIEW, 3 for VIEW+EDIT)
|
||||
* @returns {Promise<Array>} Array of resource IDs
|
||||
*/
|
||||
const findPubliclyAccessibleResources = async ({ resourceType, requiredPermissions }) => {
|
||||
try {
|
||||
if (typeof requiredPermissions !== 'number' || requiredPermissions < 1) {
|
||||
throw new Error('requiredPermissions must be a positive number');
|
||||
}
|
||||
|
||||
// Find all public ACL entries where the public principal has at least the required permission bits
|
||||
const entries = await AclEntry.find({
|
||||
principalType: 'public',
|
||||
resourceType,
|
||||
permBits: { $bitsAllSet: requiredPermissions },
|
||||
}).distinct('resourceId');
|
||||
|
||||
return entries;
|
||||
} catch (error) {
|
||||
logger.error(`[PermissionService.findPubliclyAccessibleResources] Error: ${error.message}`);
|
||||
// Re-throw validation errors
|
||||
if (error.message.includes('requiredPermissions must be')) {
|
||||
throw error;
|
||||
}
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Get available roles for a resource type
|
||||
* @param {Object} params - Parameters for getting available roles
|
||||
* @param {string} params.resourceType - Type of resource (e.g., 'agent')
|
||||
* @returns {Promise<Array>} Array of role definitions
|
||||
*/
|
||||
const getAvailableRoles = async ({ resourceType }) => {
|
||||
try {
|
||||
return await AccessRole.find({ resourceType }).lean();
|
||||
} catch (error) {
|
||||
logger.error(`[PermissionService.getAvailableRoles] Error: ${error.message}`);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Ensures a principal exists in the database based on TPrincipal data
|
||||
* Creates user if it doesn't exist locally (for Entra ID users)
|
||||
* @param {Object} principal - TPrincipal object from frontend
|
||||
* @param {string} principal.type - 'user', 'group', or 'public'
|
||||
* @param {string} [principal.id] - Local database ID (null for Entra ID principals not yet synced)
|
||||
* @param {string} principal.name - Display name
|
||||
* @param {string} [principal.email] - Email address
|
||||
* @param {string} [principal.source] - 'local' or 'entra'
|
||||
* @param {string} [principal.idOnTheSource] - Entra ID object ID for external principals
|
||||
* @returns {Promise<string|null>} Returns the principalId for database operations, null for public
|
||||
*/
|
||||
const ensurePrincipalExists = async function (principal) {
|
||||
if (principal.type === 'public') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (principal.id) {
|
||||
return principal.id;
|
||||
}
|
||||
|
||||
if (principal.type === 'user' && principal.source === 'entra') {
|
||||
if (!principal.email || !principal.idOnTheSource) {
|
||||
throw new Error('Entra ID user principals must have email and idOnTheSource');
|
||||
}
|
||||
|
||||
let existingUser = await findUser({ idOnTheSource: principal.idOnTheSource });
|
||||
|
||||
if (!existingUser) {
|
||||
existingUser = await findUser({ email: principal.email.toLowerCase() });
|
||||
}
|
||||
|
||||
if (existingUser) {
|
||||
if (!existingUser.idOnTheSource && principal.idOnTheSource) {
|
||||
await updateUser(existingUser._id, {
|
||||
idOnTheSource: principal.idOnTheSource,
|
||||
provider: 'openid',
|
||||
});
|
||||
}
|
||||
return existingUser._id.toString();
|
||||
}
|
||||
|
||||
const userData = {
|
||||
name: principal.name,
|
||||
email: principal.email.toLowerCase(),
|
||||
emailVerified: false,
|
||||
provider: 'openid',
|
||||
idOnTheSource: principal.idOnTheSource,
|
||||
};
|
||||
|
||||
const userId = await createUser(userData, true, false);
|
||||
return userId.toString();
|
||||
}
|
||||
|
||||
if (principal.type === 'group') {
|
||||
throw new Error('Group principals should be handled by group-specific methods');
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported principal type: ${principal.type}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Ensures a group principal exists in the database based on TPrincipal data
|
||||
* Creates group if it doesn't exist locally (for Entra ID groups)
|
||||
* For Entra ID groups, always synchronizes member IDs when authentication context is provided
|
||||
* @param {Object} principal - TPrincipal object from frontend
|
||||
* @param {string} principal.type - Must be 'group'
|
||||
* @param {string} [principal.id] - Local database ID (null for Entra ID principals not yet synced)
|
||||
* @param {string} principal.name - Display name
|
||||
* @param {string} [principal.email] - Email address
|
||||
* @param {string} [principal.description] - Group description
|
||||
* @param {string} [principal.source] - 'local' or 'entra'
|
||||
* @param {string} [principal.idOnTheSource] - Entra ID object ID for external principals
|
||||
* @param {Object} [authContext] - Optional authentication context for fetching member data
|
||||
* @param {string} [authContext.accessToken] - Access token for Graph API calls
|
||||
* @param {string} [authContext.sub] - Subject identifier
|
||||
* @returns {Promise<string>} Returns the groupId for database operations
|
||||
*/
|
||||
const ensureGroupPrincipalExists = async function (principal, authContext = null) {
|
||||
if (principal.type !== 'group') {
|
||||
throw new Error(`Invalid principal type: ${principal.type}. Expected 'group'`);
|
||||
}
|
||||
|
||||
if (principal.source === 'entra') {
|
||||
if (!principal.name || !principal.idOnTheSource) {
|
||||
throw new Error('Entra ID group principals must have name and idOnTheSource');
|
||||
}
|
||||
|
||||
let memberIds = [];
|
||||
if (authContext && authContext.accessToken && authContext.sub) {
|
||||
try {
|
||||
memberIds = await getGroupMembers(
|
||||
authContext.accessToken,
|
||||
authContext.sub,
|
||||
principal.idOnTheSource,
|
||||
);
|
||||
|
||||
// Include group owners as members if feature is enabled
|
||||
if (isEnabled(process.env.ENTRA_ID_INCLUDE_OWNERS_AS_MEMBERS)) {
|
||||
const ownerIds = await getGroupOwners(
|
||||
authContext.accessToken,
|
||||
authContext.sub,
|
||||
principal.idOnTheSource,
|
||||
);
|
||||
if (ownerIds && ownerIds.length > 0) {
|
||||
memberIds.push(...ownerIds);
|
||||
// Remove duplicates
|
||||
memberIds = [...new Set(memberIds)];
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error('Failed to fetch group members from Graph API:', error);
|
||||
}
|
||||
}
|
||||
|
||||
let existingGroup = await findGroupByExternalId(principal.idOnTheSource, 'entra');
|
||||
|
||||
if (!existingGroup && principal.email) {
|
||||
existingGroup = await Group.findOne({ email: principal.email.toLowerCase() }).lean();
|
||||
}
|
||||
|
||||
if (existingGroup) {
|
||||
const updateData = {};
|
||||
let needsUpdate = false;
|
||||
|
||||
if (!existingGroup.idOnTheSource && principal.idOnTheSource) {
|
||||
updateData.idOnTheSource = principal.idOnTheSource;
|
||||
updateData.source = 'entra';
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
if (principal.description && existingGroup.description !== principal.description) {
|
||||
updateData.description = principal.description;
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
if (principal.email && existingGroup.email !== principal.email.toLowerCase()) {
|
||||
updateData.email = principal.email.toLowerCase();
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
if (authContext && authContext.accessToken && authContext.sub) {
|
||||
updateData.memberIds = memberIds;
|
||||
needsUpdate = true;
|
||||
}
|
||||
|
||||
if (needsUpdate) {
|
||||
await Group.findByIdAndUpdate(existingGroup._id, { $set: updateData }, { new: true });
|
||||
}
|
||||
|
||||
return existingGroup._id.toString();
|
||||
}
|
||||
|
||||
const groupData = {
|
||||
name: principal.name,
|
||||
source: 'entra',
|
||||
idOnTheSource: principal.idOnTheSource,
|
||||
memberIds: memberIds, // Store idOnTheSource values of group members (empty if no auth context)
|
||||
};
|
||||
|
||||
if (principal.email) {
|
||||
groupData.email = principal.email.toLowerCase();
|
||||
}
|
||||
|
||||
if (principal.description) {
|
||||
groupData.description = principal.description;
|
||||
}
|
||||
|
||||
const newGroup = await createGroup(groupData);
|
||||
return newGroup._id.toString();
|
||||
}
|
||||
if (principal.id && authContext == null) {
|
||||
return principal.id;
|
||||
}
|
||||
|
||||
throw new Error(`Unsupported group principal source: ${principal.source}`);
|
||||
};
|
||||
|
||||
/**
|
||||
* Synchronize user's Entra ID group memberships on sign-in
|
||||
* Gets user's group IDs from GraphAPI and updates memberships only for existing groups in database
|
||||
* Optionally includes groups the user owns if ENTRA_ID_INCLUDE_OWNERS_AS_MEMBERS is enabled
|
||||
* @param {Object} user - User object with authentication context
|
||||
* @param {string} user.openidId - User's OpenID subject identifier
|
||||
* @param {string} user.idOnTheSource - User's Entra ID (oid from token claims)
|
||||
* @param {string} user.provider - Authentication provider ('openid')
|
||||
* @param {string} accessToken - Access token for Graph API calls
|
||||
* @param {mongoose.ClientSession} [session] - Optional MongoDB session for transactions
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const syncUserEntraGroupMemberships = async (user, accessToken, session = null) => {
|
||||
try {
|
||||
if (!entraIdPrincipalFeatureEnabled(user) || !accessToken || !user.idOnTheSource) {
|
||||
return;
|
||||
}
|
||||
|
||||
const memberGroupIds = await getUserEntraGroups(accessToken, user.openidId);
|
||||
let allGroupIds = [...(memberGroupIds || [])];
|
||||
|
||||
// Include owned groups if feature is enabled
|
||||
if (isEnabled(process.env.ENTRA_ID_INCLUDE_OWNERS_AS_MEMBERS)) {
|
||||
const ownedGroupIds = await getUserOwnedEntraGroups(accessToken, user.openidId);
|
||||
if (ownedGroupIds && ownedGroupIds.length > 0) {
|
||||
allGroupIds.push(...ownedGroupIds);
|
||||
// Remove duplicates
|
||||
allGroupIds = [...new Set(allGroupIds)];
|
||||
}
|
||||
}
|
||||
|
||||
if (!allGroupIds || allGroupIds.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const sessionOptions = session ? { session } : {};
|
||||
|
||||
await Group.updateMany(
|
||||
{
|
||||
idOnTheSource: { $in: allGroupIds },
|
||||
source: 'entra',
|
||||
memberIds: { $ne: user.idOnTheSource },
|
||||
},
|
||||
{ $addToSet: { memberIds: user.idOnTheSource } },
|
||||
sessionOptions,
|
||||
);
|
||||
|
||||
await Group.updateMany(
|
||||
{
|
||||
source: 'entra',
|
||||
memberIds: user.idOnTheSource,
|
||||
idOnTheSource: { $nin: allGroupIds },
|
||||
},
|
||||
{ $pull: { memberIds: user.idOnTheSource } },
|
||||
sessionOptions,
|
||||
);
|
||||
} catch (error) {
|
||||
logger.error(`[PermissionService.syncUserEntraGroupMemberships] Error syncing groups:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Check if public has a specific permission on a resource
|
||||
* @param {Object} params - Parameters for checking public permission
|
||||
* @param {string} params.resourceType - Type of resource (e.g., 'agent')
|
||||
* @param {string|mongoose.Types.ObjectId} params.resourceId - The ID of the resource
|
||||
* @param {number} params.requiredPermissions - The permission bits required (e.g., 1 for VIEW, 3 for VIEW+EDIT)
|
||||
* @returns {Promise<boolean>} Whether public has the required permission bits
|
||||
*/
|
||||
const hasPublicPermission = async ({ resourceType, resourceId, requiredPermissions }) => {
|
||||
try {
|
||||
if (typeof requiredPermissions !== 'number' || requiredPermissions < 1) {
|
||||
throw new Error('requiredPermissions must be a positive number');
|
||||
}
|
||||
|
||||
// Use public principal to check permissions
|
||||
const publicPrincipal = [{ principalType: 'public' }];
|
||||
|
||||
const entries = await findEntriesByPrincipalsAndResource(
|
||||
publicPrincipal,
|
||||
resourceType,
|
||||
resourceId,
|
||||
);
|
||||
|
||||
// Check if any entry has the required permission bits
|
||||
return entries.some((entry) => (entry.permBits & requiredPermissions) === requiredPermissions);
|
||||
} catch (error) {
|
||||
logger.error(`[PermissionService.hasPublicPermission] Error: ${error.message}`);
|
||||
// Re-throw validation errors
|
||||
if (error.message.includes('requiredPermissions must be')) {
|
||||
throw error;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Bulk update permissions for a resource (grant, update, revoke)
|
||||
* Efficiently handles multiple permission changes in a single transaction
|
||||
*
|
||||
* @param {Object} params - Parameters for bulk permission update
|
||||
* @param {string} params.resourceType - Type of resource (e.g., 'agent')
|
||||
* @param {string|mongoose.Types.ObjectId} params.resourceId - The ID of the resource
|
||||
* @param {Array<TPrincipal>} params.updatedPrincipals - Array of principals to grant/update permissions for
|
||||
* @param {Array<TPrincipal>} params.revokedPrincipals - Array of principals to revoke permissions from
|
||||
* @param {string|mongoose.Types.ObjectId} params.grantedBy - User ID making the changes
|
||||
* @param {mongoose.ClientSession} [params.session] - Optional MongoDB session for transactions
|
||||
* @returns {Promise<Object>} Results object with granted, updated, revoked arrays and error details
|
||||
*/
|
||||
const bulkUpdateResourcePermissions = async ({
|
||||
resourceType,
|
||||
resourceId,
|
||||
updatedPrincipals = [],
|
||||
revokedPrincipals = [],
|
||||
grantedBy,
|
||||
session,
|
||||
}) => {
|
||||
const supportsTransactions = await getTransactionSupport(mongoose, transactionSupportCache);
|
||||
transactionSupportCache = supportsTransactions;
|
||||
let localSession = session;
|
||||
let shouldEndSession = false;
|
||||
|
||||
try {
|
||||
if (!Array.isArray(updatedPrincipals)) {
|
||||
throw new Error('updatedPrincipals must be an array');
|
||||
}
|
||||
|
||||
if (!Array.isArray(revokedPrincipals)) {
|
||||
throw new Error('revokedPrincipals must be an array');
|
||||
}
|
||||
|
||||
if (!resourceId || !mongoose.Types.ObjectId.isValid(resourceId)) {
|
||||
throw new Error(`Invalid resource ID: ${resourceId}`);
|
||||
}
|
||||
|
||||
if (!localSession && supportsTransactions) {
|
||||
localSession = await mongoose.startSession();
|
||||
localSession.startTransaction();
|
||||
shouldEndSession = true;
|
||||
}
|
||||
|
||||
const sessionOptions = localSession ? { session: localSession } : {};
|
||||
|
||||
const roles = await AccessRole.find({ resourceType }).lean();
|
||||
const rolesMap = new Map();
|
||||
roles.forEach((role) => {
|
||||
rolesMap.set(role.accessRoleId, role);
|
||||
});
|
||||
|
||||
const results = {
|
||||
granted: [],
|
||||
updated: [],
|
||||
revoked: [],
|
||||
errors: [],
|
||||
};
|
||||
|
||||
const bulkWrites = [];
|
||||
|
||||
for (const principal of updatedPrincipals) {
|
||||
try {
|
||||
if (!principal.accessRoleId) {
|
||||
results.errors.push({
|
||||
principal,
|
||||
error: 'accessRoleId is required for updated principals',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const role = rolesMap.get(principal.accessRoleId);
|
||||
if (!role) {
|
||||
results.errors.push({
|
||||
principal,
|
||||
error: `Role ${principal.accessRoleId} not found`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const query = {
|
||||
principalType: principal.type,
|
||||
resourceType,
|
||||
resourceId,
|
||||
};
|
||||
|
||||
if (principal.type !== 'public') {
|
||||
query.principalId = principal.id;
|
||||
}
|
||||
|
||||
const update = {
|
||||
$set: {
|
||||
permBits: role.permBits,
|
||||
roleId: role._id,
|
||||
grantedBy,
|
||||
grantedAt: new Date(),
|
||||
},
|
||||
$setOnInsert: {
|
||||
principalType: principal.type,
|
||||
resourceType,
|
||||
resourceId,
|
||||
...(principal.type !== 'public' && {
|
||||
principalId: principal.id,
|
||||
principalModel: principal.type === 'user' ? 'User' : 'Group',
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
bulkWrites.push({
|
||||
updateOne: {
|
||||
filter: query,
|
||||
update: update,
|
||||
upsert: true,
|
||||
},
|
||||
});
|
||||
|
||||
results.granted.push({
|
||||
type: principal.type,
|
||||
id: principal.id,
|
||||
name: principal.name,
|
||||
email: principal.email,
|
||||
source: principal.source,
|
||||
avatar: principal.avatar,
|
||||
description: principal.description,
|
||||
idOnTheSource: principal.idOnTheSource,
|
||||
accessRoleId: principal.accessRoleId,
|
||||
memberCount: principal.memberCount,
|
||||
memberIds: principal.memberIds,
|
||||
});
|
||||
} catch (error) {
|
||||
results.errors.push({
|
||||
principal,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (bulkWrites.length > 0) {
|
||||
await AclEntry.bulkWrite(bulkWrites, sessionOptions);
|
||||
}
|
||||
|
||||
const deleteQueries = [];
|
||||
for (const principal of revokedPrincipals) {
|
||||
try {
|
||||
const query = {
|
||||
principalType: principal.type,
|
||||
resourceType,
|
||||
resourceId,
|
||||
};
|
||||
|
||||
if (principal.type !== 'public') {
|
||||
query.principalId = principal.id;
|
||||
}
|
||||
|
||||
deleteQueries.push(query);
|
||||
|
||||
results.revoked.push({
|
||||
type: principal.type,
|
||||
id: principal.id,
|
||||
name: principal.name,
|
||||
email: principal.email,
|
||||
source: principal.source,
|
||||
avatar: principal.avatar,
|
||||
description: principal.description,
|
||||
idOnTheSource: principal.idOnTheSource,
|
||||
memberCount: principal.memberCount,
|
||||
});
|
||||
} catch (error) {
|
||||
results.errors.push({
|
||||
principal,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (deleteQueries.length > 0) {
|
||||
await AclEntry.deleteMany(
|
||||
{
|
||||
$or: deleteQueries,
|
||||
},
|
||||
sessionOptions,
|
||||
);
|
||||
}
|
||||
|
||||
if (shouldEndSession && supportsTransactions) {
|
||||
await localSession.commitTransaction();
|
||||
}
|
||||
|
||||
return results;
|
||||
} catch (error) {
|
||||
if (shouldEndSession && supportsTransactions) {
|
||||
await localSession.abortTransaction();
|
||||
}
|
||||
logger.error(`[PermissionService.bulkUpdateResourcePermissions] Error: ${error.message}`);
|
||||
throw error;
|
||||
} finally {
|
||||
if (shouldEndSession && localSession) {
|
||||
localSession.endSession();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
grantPermission,
|
||||
checkPermission,
|
||||
getEffectivePermissions,
|
||||
findAccessibleResources,
|
||||
findPubliclyAccessibleResources,
|
||||
hasPublicPermission,
|
||||
getAvailableRoles,
|
||||
bulkUpdateResourcePermissions,
|
||||
ensurePrincipalExists,
|
||||
ensureGroupPrincipalExists,
|
||||
syncUserEntraGroupMemberships,
|
||||
};
|
||||
1058
api/server/services/PermissionService.spec.js
Normal file
1058
api/server/services/PermissionService.spec.js
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -381,6 +381,7 @@ async function setupOpenId() {
|
|||
email: userinfo.email || '',
|
||||
emailVerified: userinfo.email_verified || false,
|
||||
name: fullName,
|
||||
idOnTheSource: userinfo.oid,
|
||||
};
|
||||
|
||||
const balanceConfig = await getBalanceConfig();
|
||||
|
|
@ -391,6 +392,7 @@ async function setupOpenId() {
|
|||
user.openidId = userinfo.sub;
|
||||
user.username = username;
|
||||
user.name = fullName;
|
||||
user.idOnTheSource = userinfo.oid;
|
||||
}
|
||||
|
||||
if (!!userinfo && userinfo.picture && !user.avatar?.includes('manual=true')) {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue