mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-16 08:20:14 +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
273
config/migrate-agent-permissions.js
Normal file
273
config/migrate-agent-permissions.js
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
//TODO: needs testing and validation before running in production
|
||||
console.log('needs testing and validation before running in production...');
|
||||
const path = require('path');
|
||||
const { logger } = require('@librechat/data-schemas');
|
||||
require('module-alias')({ base: path.resolve(__dirname, '..', 'api') });
|
||||
|
||||
const { GLOBAL_PROJECT_NAME } = require('librechat-data-provider').Constants;
|
||||
const connect = require('./connect');
|
||||
|
||||
const { grantPermission } = require('~/server/services/PermissionService');
|
||||
const { getProjectByName } = require('~/models/Project');
|
||||
const { findRoleByIdentifier } = require('~/models');
|
||||
const { Agent } = require('~/db/models');
|
||||
|
||||
async function migrateAgentPermissionsEnhanced({ dryRun = true, batchSize = 100 } = {}) {
|
||||
await connect();
|
||||
|
||||
logger.info('Starting Enhanced Agent Permissions Migration', { dryRun, batchSize });
|
||||
|
||||
// Verify required roles exist
|
||||
const ownerRole = await findRoleByIdentifier('agent_owner');
|
||||
const viewerRole = await findRoleByIdentifier('agent_viewer');
|
||||
const editorRole = await findRoleByIdentifier('agent_editor');
|
||||
|
||||
if (!ownerRole || !viewerRole || !editorRole) {
|
||||
throw new Error('Required roles not found. Run role seeding first.');
|
||||
}
|
||||
|
||||
// Get global project agent IDs (stores agent.id, not agent._id)
|
||||
const globalProject = await getProjectByName(GLOBAL_PROJECT_NAME, ['agentIds']);
|
||||
const globalAgentIds = new Set(globalProject?.agentIds || []);
|
||||
|
||||
logger.info(`Found ${globalAgentIds.size} agents in global project`);
|
||||
|
||||
// Find agents without ACL entries using DocumentDB-compatible approach
|
||||
const agentsToMigrate = await Agent.aggregate([
|
||||
{
|
||||
$lookup: {
|
||||
from: 'aclentries',
|
||||
localField: '_id',
|
||||
foreignField: 'resourceId',
|
||||
as: 'aclEntries',
|
||||
},
|
||||
},
|
||||
{
|
||||
$addFields: {
|
||||
userAclEntries: {
|
||||
$filter: {
|
||||
input: '$aclEntries',
|
||||
as: 'aclEntry',
|
||||
cond: {
|
||||
$and: [
|
||||
{ $eq: ['$$aclEntry.resourceType', 'agent'] },
|
||||
{ $eq: ['$$aclEntry.principalType', 'user'] },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
$match: {
|
||||
author: { $exists: true, $ne: null },
|
||||
userAclEntries: { $size: 0 },
|
||||
},
|
||||
},
|
||||
{
|
||||
$project: {
|
||||
_id: 1,
|
||||
id: 1,
|
||||
name: 1,
|
||||
author: 1,
|
||||
isCollaborative: 1,
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
const categories = {
|
||||
globalEditAccess: [], // Global project + collaborative -> Public EDIT
|
||||
globalViewAccess: [], // Global project + not collaborative -> Public VIEW
|
||||
privateAgents: [], // Not in global project -> Private (owner only)
|
||||
};
|
||||
|
||||
agentsToMigrate.forEach((agent) => {
|
||||
const isGlobal = globalAgentIds.has(agent.id);
|
||||
const isCollab = agent.isCollaborative;
|
||||
|
||||
if (isGlobal && isCollab) {
|
||||
categories.globalEditAccess.push(agent);
|
||||
} else if (isGlobal && !isCollab) {
|
||||
categories.globalViewAccess.push(agent);
|
||||
} else {
|
||||
categories.privateAgents.push(agent);
|
||||
|
||||
// Log warning if private agent claims to be collaborative
|
||||
if (isCollab) {
|
||||
logger.warn(
|
||||
`Agent "${agent.name}" (${agent.id}) has isCollaborative=true but is not in global project`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
logger.info('Agent categorization:', {
|
||||
globalEditAccess: categories.globalEditAccess.length,
|
||||
globalViewAccess: categories.globalViewAccess.length,
|
||||
privateAgents: categories.privateAgents.length,
|
||||
total: agentsToMigrate.length,
|
||||
});
|
||||
|
||||
if (dryRun) {
|
||||
return {
|
||||
migrated: 0,
|
||||
errors: 0,
|
||||
dryRun: true,
|
||||
summary: {
|
||||
globalEditAccess: categories.globalEditAccess.length,
|
||||
globalViewAccess: categories.globalViewAccess.length,
|
||||
privateAgents: categories.privateAgents.length,
|
||||
total: agentsToMigrate.length,
|
||||
},
|
||||
details: {
|
||||
globalEditAccess: categories.globalEditAccess.map((a) => ({
|
||||
name: a.name,
|
||||
id: a.id,
|
||||
permissions: 'Owner + Public EDIT',
|
||||
})),
|
||||
globalViewAccess: categories.globalViewAccess.map((a) => ({
|
||||
name: a.name,
|
||||
id: a.id,
|
||||
permissions: 'Owner + Public VIEW',
|
||||
})),
|
||||
privateAgents: categories.privateAgents.map((a) => ({
|
||||
name: a.name,
|
||||
id: a.id,
|
||||
permissions: 'Owner only',
|
||||
})),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const results = {
|
||||
migrated: 0,
|
||||
errors: 0,
|
||||
publicViewGrants: 0,
|
||||
publicEditGrants: 0,
|
||||
ownerGrants: 0,
|
||||
};
|
||||
|
||||
// Process in batches
|
||||
for (let i = 0; i < agentsToMigrate.length; i += batchSize) {
|
||||
const batch = agentsToMigrate.slice(i, i + batchSize);
|
||||
|
||||
logger.info(
|
||||
`Processing batch ${Math.floor(i / batchSize) + 1}/${Math.ceil(agentsToMigrate.length / batchSize)}`,
|
||||
);
|
||||
|
||||
for (const agent of batch) {
|
||||
try {
|
||||
const isGlobal = globalAgentIds.has(agent.id);
|
||||
const isCollab = agent.isCollaborative;
|
||||
|
||||
// Always grant owner permission to author
|
||||
await grantPermission({
|
||||
principalType: 'user',
|
||||
principalId: agent.author,
|
||||
resourceType: 'agent',
|
||||
resourceId: agent._id,
|
||||
accessRoleId: 'agent_owner',
|
||||
grantedBy: agent.author,
|
||||
});
|
||||
results.ownerGrants++;
|
||||
|
||||
// Determine public permissions for global project agents only
|
||||
let publicRoleId = null;
|
||||
let description = 'Private';
|
||||
|
||||
if (isGlobal) {
|
||||
if (isCollab) {
|
||||
// Global project + collaborative = Public EDIT access
|
||||
publicRoleId = 'agent_editor';
|
||||
description = 'Global Edit';
|
||||
results.publicEditGrants++;
|
||||
} else {
|
||||
// Global project + not collaborative = Public VIEW access
|
||||
publicRoleId = 'agent_viewer';
|
||||
description = 'Global View';
|
||||
results.publicViewGrants++;
|
||||
}
|
||||
|
||||
// Grant public permission
|
||||
await grantPermission({
|
||||
principalType: 'public',
|
||||
principalId: null,
|
||||
resourceType: 'agent',
|
||||
resourceId: agent._id,
|
||||
accessRoleId: publicRoleId,
|
||||
grantedBy: agent.author,
|
||||
});
|
||||
}
|
||||
|
||||
results.migrated++;
|
||||
logger.debug(`Migrated agent "${agent.name}" [${description}]`, {
|
||||
agentId: agent.id,
|
||||
author: agent.author,
|
||||
isGlobal,
|
||||
isCollab,
|
||||
publicRole: publicRoleId,
|
||||
});
|
||||
} catch (error) {
|
||||
results.errors++;
|
||||
logger.error(`Failed to migrate agent "${agent.name}"`, {
|
||||
agentId: agent.id,
|
||||
author: agent.author,
|
||||
error: error.message,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Brief pause between batches
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
}
|
||||
|
||||
logger.info('Enhanced migration completed', results);
|
||||
return results;
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
const dryRun = process.argv.includes('--dry-run');
|
||||
const batchSize =
|
||||
parseInt(process.argv.find((arg) => arg.startsWith('--batch-size='))?.split('=')[1]) || 100;
|
||||
|
||||
migrateAgentPermissionsEnhanced({ dryRun, batchSize })
|
||||
.then((result) => {
|
||||
if (dryRun) {
|
||||
console.log('\n=== DRY RUN RESULTS ===');
|
||||
console.log(`Total agents to migrate: ${result.summary.total}`);
|
||||
console.log(`- Global Edit Access: ${result.summary.globalEditAccess} agents`);
|
||||
console.log(`- Global View Access: ${result.summary.globalViewAccess} agents`);
|
||||
console.log(`- Private Agents: ${result.summary.privateAgents} agents`);
|
||||
|
||||
if (result.details.globalEditAccess.length > 0) {
|
||||
console.log('\nGlobal Edit Access agents:');
|
||||
result.details.globalEditAccess.forEach((agent, i) => {
|
||||
console.log(` ${i + 1}. "${agent.name}" (${agent.id})`);
|
||||
});
|
||||
}
|
||||
|
||||
if (result.details.globalViewAccess.length > 0) {
|
||||
console.log('\nGlobal View Access agents:');
|
||||
result.details.globalViewAccess.forEach((agent, i) => {
|
||||
console.log(` ${i + 1}. "${agent.name}" (${agent.id})`);
|
||||
});
|
||||
}
|
||||
|
||||
if (result.details.privateAgents.length > 0) {
|
||||
console.log('\nPrivate agents:');
|
||||
result.details.privateAgents.forEach((agent, i) => {
|
||||
console.log(` ${i + 1}. "${agent.name}" (${agent.id})`);
|
||||
});
|
||||
}
|
||||
} else {
|
||||
console.log('\nMigration Results:', JSON.stringify(result, null, 2));
|
||||
}
|
||||
process.exit(0);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error('Enhanced migration failed:', error);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { migrateAgentPermissionsEnhanced };
|
||||
106
config/seed-categories.js
Normal file
106
config/seed-categories.js
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
const connectDb = require('../api/lib/db/connectDb');
|
||||
const AgentCategory = require('../api/models/AgentCategory');
|
||||
|
||||
// Define category constants directly since the constants file was removed
|
||||
const CATEGORY_VALUES = {
|
||||
GENERAL: 'general',
|
||||
HR: 'hr',
|
||||
RD: 'rd',
|
||||
FINANCE: 'finance',
|
||||
IT: 'it',
|
||||
SALES: 'sales',
|
||||
AFTERSALES: 'aftersales',
|
||||
};
|
||||
|
||||
const CATEGORY_DESCRIPTIONS = {
|
||||
general: 'General purpose agents for common tasks and inquiries',
|
||||
hr: 'Agents specialized in HR processes, policies, and employee support',
|
||||
rd: 'Agents focused on R&D processes, innovation, and technical research',
|
||||
finance: 'Agents specialized in financial analysis, budgeting, and accounting',
|
||||
it: 'Agents for IT support, technical troubleshooting, and system administration',
|
||||
sales: 'Agents focused on sales processes, customer relations, and marketing',
|
||||
aftersales: 'Agents specialized in post-sale support, maintenance, and customer service',
|
||||
};
|
||||
|
||||
/**
|
||||
* Seed agent categories from existing constants into MongoDB
|
||||
* This migration creates the initial category data in the database
|
||||
*/
|
||||
async function seedCategories() {
|
||||
try {
|
||||
await connectDb();
|
||||
console.log('Connected to database');
|
||||
|
||||
// Prepare category data from existing constants
|
||||
const categoryData = [
|
||||
{
|
||||
value: CATEGORY_VALUES.GENERAL,
|
||||
label: 'General',
|
||||
description: CATEGORY_DESCRIPTIONS.general,
|
||||
order: 0,
|
||||
},
|
||||
{
|
||||
value: CATEGORY_VALUES.HR,
|
||||
label: 'Human Resources',
|
||||
description: CATEGORY_DESCRIPTIONS.hr,
|
||||
order: 1,
|
||||
},
|
||||
{
|
||||
value: CATEGORY_VALUES.RD,
|
||||
label: 'Research & Development',
|
||||
description: CATEGORY_DESCRIPTIONS.rd,
|
||||
order: 2,
|
||||
},
|
||||
{
|
||||
value: CATEGORY_VALUES.FINANCE,
|
||||
label: 'Finance',
|
||||
description: CATEGORY_DESCRIPTIONS.finance,
|
||||
order: 3,
|
||||
},
|
||||
{
|
||||
value: CATEGORY_VALUES.IT,
|
||||
label: 'Information Technology',
|
||||
description: CATEGORY_DESCRIPTIONS.it,
|
||||
order: 4,
|
||||
},
|
||||
{
|
||||
value: CATEGORY_VALUES.SALES,
|
||||
label: 'Sales & Marketing',
|
||||
description: CATEGORY_DESCRIPTIONS.sales,
|
||||
order: 5,
|
||||
},
|
||||
{
|
||||
value: CATEGORY_VALUES.AFTERSALES,
|
||||
label: 'After Sales',
|
||||
description: CATEGORY_DESCRIPTIONS.aftersales,
|
||||
order: 6,
|
||||
},
|
||||
];
|
||||
|
||||
console.log('Seeding categories...');
|
||||
const result = await AgentCategory.seedCategories(categoryData);
|
||||
|
||||
console.log(`Successfully seeded ${result.upsertedCount} new categories`);
|
||||
console.log(`Modified ${result.modifiedCount} existing categories`);
|
||||
|
||||
// Verify the seeded data
|
||||
const categories = await AgentCategory.getActiveCategories();
|
||||
console.log('Active categories in database:');
|
||||
categories.forEach((cat) => {
|
||||
console.log(` - ${cat.value}: ${cat.label} (order: ${cat.order})`);
|
||||
});
|
||||
|
||||
console.log('Category seeding completed successfully');
|
||||
process.exit(0);
|
||||
} catch (error) {
|
||||
console.error('Error seeding categories:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Run if called directly
|
||||
if (require.main === module) {
|
||||
seedCategories();
|
||||
}
|
||||
|
||||
module.exports = seedCategories;
|
||||
Loading…
Add table
Add a link
Reference in a new issue