mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-21 19:00:13 +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
502
packages/data-schemas/src/methods/userGroup.spec.ts
Normal file
502
packages/data-schemas/src/methods/userGroup.spec.ts
Normal file
|
|
@ -0,0 +1,502 @@
|
|||
import mongoose from 'mongoose';
|
||||
import { MongoMemoryServer } from 'mongodb-memory-server';
|
||||
import { createUserGroupMethods } from './userGroup';
|
||||
import groupSchema from '~/schema/group';
|
||||
import userSchema from '~/schema/user';
|
||||
import type * as t from '~/types';
|
||||
|
||||
/** Mocking logger */
|
||||
jest.mock('~/config/winston', () => ({
|
||||
error: jest.fn(),
|
||||
info: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
}));
|
||||
|
||||
let mongoServer: MongoMemoryServer;
|
||||
let Group: mongoose.Model<t.IGroup>;
|
||||
let User: mongoose.Model<t.IUser>;
|
||||
let methods: ReturnType<typeof createUserGroupMethods>;
|
||||
|
||||
beforeAll(async () => {
|
||||
mongoServer = await MongoMemoryServer.create();
|
||||
const mongoUri = mongoServer.getUri();
|
||||
Group = mongoose.models.Group || mongoose.model('Group', groupSchema);
|
||||
User = mongoose.models.User || mongoose.model('User', userSchema);
|
||||
methods = createUserGroupMethods(mongoose);
|
||||
await mongoose.connect(mongoUri);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await mongoose.disconnect();
|
||||
await mongoServer.stop();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await mongoose.connection.dropDatabase();
|
||||
});
|
||||
|
||||
describe('User Group Methods Tests', () => {
|
||||
describe('Group Query Methods', () => {
|
||||
let testGroup: t.IGroup;
|
||||
let testUser: t.IUser;
|
||||
|
||||
beforeEach(async () => {
|
||||
/** Create a test user */
|
||||
testUser = await User.create({
|
||||
name: 'Test User',
|
||||
email: 'test@example.com',
|
||||
password: 'password123',
|
||||
provider: 'local',
|
||||
});
|
||||
|
||||
/** Create a test group */
|
||||
testGroup = await Group.create({
|
||||
name: 'Test Group',
|
||||
source: 'local',
|
||||
memberIds: [(testUser._id as mongoose.Types.ObjectId).toString()],
|
||||
});
|
||||
|
||||
/** No need to add group to user - using one-way relationship via Group.memberIds */
|
||||
});
|
||||
|
||||
test('should find group by ID', async () => {
|
||||
const group = await methods.findGroupById(testGroup._id as mongoose.Types.ObjectId);
|
||||
|
||||
expect(group).toBeDefined();
|
||||
expect(group?._id.toString()).toBe(testGroup._id.toString());
|
||||
expect(group?.name).toBe(testGroup.name);
|
||||
});
|
||||
|
||||
test('should find group by ID with specific projection', async () => {
|
||||
const group = await methods.findGroupById(testGroup._id as mongoose.Types.ObjectId, {
|
||||
name: 1,
|
||||
});
|
||||
|
||||
expect(group).toBeDefined();
|
||||
expect(group?._id).toBeDefined();
|
||||
expect(group?.name).toBe(testGroup.name);
|
||||
expect(group?.memberIds).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should find group by external ID', async () => {
|
||||
/** Create an external ID group first */
|
||||
const entraGroup = await Group.create({
|
||||
name: 'Entra Group',
|
||||
source: 'entra',
|
||||
idOnTheSource: 'entra-id-12345',
|
||||
});
|
||||
|
||||
const group = await methods.findGroupByExternalId('entra-id-12345', 'entra');
|
||||
|
||||
expect(group).toBeDefined();
|
||||
expect(group?._id.toString()).toBe(entraGroup._id.toString());
|
||||
expect(group?.idOnTheSource).toBe('entra-id-12345');
|
||||
});
|
||||
|
||||
test('should return null for non-existent external ID', async () => {
|
||||
const group = await methods.findGroupByExternalId('non-existent-id', 'entra');
|
||||
expect(group).toBeNull();
|
||||
});
|
||||
|
||||
test('should find groups by name pattern', async () => {
|
||||
/** Create additional groups */
|
||||
await Group.create({ name: 'Test Group 2', source: 'local' });
|
||||
await Group.create({ name: 'Admin Group', source: 'local' });
|
||||
await Group.create({
|
||||
name: 'Test Entra Group',
|
||||
source: 'entra',
|
||||
idOnTheSource: 'entra-id-xyz',
|
||||
});
|
||||
|
||||
/** Search for all "Test" groups */
|
||||
const testGroups = await methods.findGroupsByNamePattern('Test');
|
||||
expect(testGroups).toHaveLength(3);
|
||||
|
||||
/** Search with source filter */
|
||||
const localTestGroups = await methods.findGroupsByNamePattern('Test', 'local');
|
||||
expect(localTestGroups).toHaveLength(2);
|
||||
|
||||
const entraTestGroups = await methods.findGroupsByNamePattern('Test', 'entra');
|
||||
expect(entraTestGroups).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('should respect limit parameter in name search', async () => {
|
||||
/** Create many groups with similar names */
|
||||
for (let i = 0; i < 10; i++) {
|
||||
await Group.create({ name: `Numbered Group ${i}`, source: 'local' });
|
||||
}
|
||||
|
||||
const limitedGroups = await methods.findGroupsByNamePattern('Numbered', null, 5);
|
||||
expect(limitedGroups).toHaveLength(5);
|
||||
});
|
||||
|
||||
test('should find groups by member ID', async () => {
|
||||
/** Create additional groups with the test user as member */
|
||||
const group2 = await Group.create({
|
||||
name: 'Second Group',
|
||||
source: 'local',
|
||||
memberIds: [(testUser._id as mongoose.Types.ObjectId).toString()],
|
||||
});
|
||||
|
||||
const group3 = await Group.create({
|
||||
name: 'Third Group',
|
||||
source: 'local',
|
||||
memberIds: [new mongoose.Types.ObjectId().toString()] /** Different user */,
|
||||
});
|
||||
|
||||
const userGroups = await methods.findGroupsByMemberId(
|
||||
testUser._id as mongoose.Types.ObjectId,
|
||||
);
|
||||
expect(userGroups).toHaveLength(2);
|
||||
|
||||
/** IDs should match the groups where user is a member */
|
||||
const groupIds = userGroups.map((g) => g._id.toString());
|
||||
expect(groupIds).toContain(testGroup._id.toString());
|
||||
expect(groupIds).toContain(group2._id.toString());
|
||||
expect(groupIds).not.toContain(group3._id.toString());
|
||||
});
|
||||
});
|
||||
|
||||
describe('Group Creation and Update Methods', () => {
|
||||
test('should create a new group', async () => {
|
||||
const groupData = {
|
||||
name: 'New Test Group',
|
||||
source: 'local' as const,
|
||||
};
|
||||
|
||||
const group = await methods.createGroup(groupData);
|
||||
|
||||
expect(group).toBeDefined();
|
||||
expect(group.name).toBe(groupData.name);
|
||||
expect(group.source).toBe(groupData.source);
|
||||
|
||||
/** Verify it was saved to the database */
|
||||
const savedGroup = await Group.findById(group._id);
|
||||
expect(savedGroup).toBeDefined();
|
||||
});
|
||||
|
||||
test('should upsert a group by external ID (create new)', async () => {
|
||||
const groupData = {
|
||||
name: 'New Entra Group',
|
||||
idOnTheSource: 'new-entra-id',
|
||||
};
|
||||
|
||||
const group = await methods.upsertGroupByExternalId(groupData.idOnTheSource, 'entra', {
|
||||
name: groupData.name,
|
||||
});
|
||||
|
||||
expect(group).toBeDefined();
|
||||
expect(group?.name).toBe(groupData.name);
|
||||
expect(group?.idOnTheSource).toBe(groupData.idOnTheSource);
|
||||
expect(group?.source).toBe('entra');
|
||||
|
||||
/** Verify it was saved to the database */
|
||||
const savedGroup = await Group.findOne({ idOnTheSource: 'new-entra-id' });
|
||||
expect(savedGroup).toBeDefined();
|
||||
});
|
||||
|
||||
test('should upsert a group by external ID (update existing)', async () => {
|
||||
/** Create an existing group */
|
||||
await Group.create({
|
||||
name: 'Original Name',
|
||||
source: 'entra',
|
||||
idOnTheSource: 'existing-entra-id',
|
||||
});
|
||||
|
||||
/** Update it */
|
||||
const updatedGroup = await methods.upsertGroupByExternalId('existing-entra-id', 'entra', {
|
||||
name: 'Updated Name',
|
||||
});
|
||||
|
||||
expect(updatedGroup).toBeDefined();
|
||||
expect(updatedGroup?.name).toBe('Updated Name');
|
||||
expect(updatedGroup?.idOnTheSource).toBe('existing-entra-id');
|
||||
|
||||
/** Verify the update in the database */
|
||||
const savedGroup = await Group.findOne({ idOnTheSource: 'existing-entra-id' });
|
||||
expect(savedGroup?.name).toBe('Updated Name');
|
||||
});
|
||||
});
|
||||
|
||||
describe('User-Group Relationship Methods', () => {
|
||||
let testUser1: t.IUser;
|
||||
let testGroup: t.IGroup;
|
||||
|
||||
beforeEach(async () => {
|
||||
/** Create test users */
|
||||
testUser1 = await User.create({
|
||||
name: 'User One',
|
||||
email: 'user1@example.com',
|
||||
password: 'password123',
|
||||
provider: 'local',
|
||||
});
|
||||
|
||||
/** Create a test group */
|
||||
testGroup = await Group.create({
|
||||
name: 'Test Group',
|
||||
source: 'local',
|
||||
memberIds: [] /** Initialize empty array */,
|
||||
});
|
||||
});
|
||||
|
||||
test('should add user to group', async () => {
|
||||
const result = await methods.addUserToGroup(
|
||||
testUser1._id as mongoose.Types.ObjectId,
|
||||
testGroup._id as mongoose.Types.ObjectId,
|
||||
);
|
||||
|
||||
/** Verify the result */
|
||||
expect(result).toBeDefined();
|
||||
expect(result.user).toBeDefined();
|
||||
expect(result.group).toBeDefined();
|
||||
|
||||
/** Group should have the user in memberIds (using idOnTheSource or user ID) */
|
||||
const userIdOnTheSource =
|
||||
result.user.idOnTheSource || (testUser1._id as mongoose.Types.ObjectId).toString();
|
||||
expect(result.group?.memberIds).toContain(userIdOnTheSource);
|
||||
|
||||
/** Verify in database */
|
||||
const updatedGroup = await Group.findById(testGroup._id);
|
||||
expect(updatedGroup?.memberIds).toContain(userIdOnTheSource);
|
||||
});
|
||||
|
||||
test('should remove user from group', async () => {
|
||||
/** First add the user to the group */
|
||||
await methods.addUserToGroup(
|
||||
testUser1._id as mongoose.Types.ObjectId,
|
||||
testGroup._id as mongoose.Types.ObjectId,
|
||||
);
|
||||
|
||||
/** Then remove them */
|
||||
const result = await methods.removeUserFromGroup(
|
||||
testUser1._id as mongoose.Types.ObjectId,
|
||||
testGroup._id as mongoose.Types.ObjectId,
|
||||
);
|
||||
|
||||
/** Verify the result */
|
||||
expect(result).toBeDefined();
|
||||
expect(result.user).toBeDefined();
|
||||
expect(result.group).toBeDefined();
|
||||
|
||||
/** Group should not have the user in memberIds */
|
||||
const userIdOnTheSource =
|
||||
result.user.idOnTheSource || (testUser1._id as mongoose.Types.ObjectId).toString();
|
||||
expect(result.group?.memberIds).not.toContain(userIdOnTheSource);
|
||||
|
||||
/** Verify in database */
|
||||
const updatedGroup = await Group.findById(testGroup._id);
|
||||
expect(updatedGroup?.memberIds).not.toContain(userIdOnTheSource);
|
||||
});
|
||||
|
||||
test('should get all groups for a user', async () => {
|
||||
/** Add user to multiple groups */
|
||||
const group1 = await Group.create({ name: 'Group 1', source: 'local', memberIds: [] });
|
||||
const group2 = await Group.create({ name: 'Group 2', source: 'local', memberIds: [] });
|
||||
|
||||
await methods.addUserToGroup(
|
||||
testUser1._id as mongoose.Types.ObjectId,
|
||||
group1._id as mongoose.Types.ObjectId,
|
||||
);
|
||||
await methods.addUserToGroup(
|
||||
testUser1._id as mongoose.Types.ObjectId,
|
||||
group2._id as mongoose.Types.ObjectId,
|
||||
);
|
||||
|
||||
/** Get the user's groups */
|
||||
const userGroups = await methods.getUserGroups(testUser1._id as mongoose.Types.ObjectId);
|
||||
|
||||
expect(userGroups).toHaveLength(2);
|
||||
const groupIds = userGroups.map((g) => g._id.toString());
|
||||
expect(groupIds).toContain(group1._id.toString());
|
||||
expect(groupIds).toContain(group2._id.toString());
|
||||
});
|
||||
|
||||
test('should return empty array for getUserGroups when user has no groups', async () => {
|
||||
const userGroups = await methods.getUserGroups(testUser1._id as mongoose.Types.ObjectId);
|
||||
expect(userGroups).toEqual([]);
|
||||
});
|
||||
|
||||
test('should get user principals', async () => {
|
||||
/** Add user to a group */
|
||||
await methods.addUserToGroup(
|
||||
testUser1._id as mongoose.Types.ObjectId,
|
||||
testGroup._id as mongoose.Types.ObjectId,
|
||||
);
|
||||
|
||||
/** Get user principals */
|
||||
const principals = await methods.getUserPrincipals(testUser1._id as mongoose.Types.ObjectId);
|
||||
|
||||
/** Should include user, group, and public principals */
|
||||
expect(principals).toHaveLength(3);
|
||||
|
||||
/** Check principal types */
|
||||
const userPrincipal = principals.find((p) => p.principalType === 'user');
|
||||
const groupPrincipal = principals.find((p) => p.principalType === 'group');
|
||||
const publicPrincipal = principals.find((p) => p.principalType === 'public');
|
||||
|
||||
expect(userPrincipal).toBeDefined();
|
||||
expect(userPrincipal?.principalId?.toString()).toBe(
|
||||
(testUser1._id as mongoose.Types.ObjectId).toString(),
|
||||
);
|
||||
|
||||
expect(groupPrincipal).toBeDefined();
|
||||
expect(groupPrincipal?.principalId?.toString()).toBe(testGroup._id.toString());
|
||||
|
||||
expect(publicPrincipal).toBeDefined();
|
||||
expect(publicPrincipal?.principalId).toBeUndefined();
|
||||
});
|
||||
|
||||
test('should return user and public principals for non-existent user in getUserPrincipals', async () => {
|
||||
const nonExistentId = new mongoose.Types.ObjectId();
|
||||
const principals = await methods.getUserPrincipals(nonExistentId);
|
||||
|
||||
/** Should still return user and public principals even for non-existent user */
|
||||
expect(principals).toHaveLength(2);
|
||||
expect(principals[0].principalType).toBe('user');
|
||||
expect(principals[0].principalId?.toString()).toBe(nonExistentId.toString());
|
||||
expect(principals[1].principalType).toBe('public');
|
||||
expect(principals[1].principalId).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Entra ID Synchronization', () => {
|
||||
let testUser: t.IUser;
|
||||
|
||||
beforeEach(async () => {
|
||||
testUser = await User.create({
|
||||
name: 'Entra User',
|
||||
email: 'entra@example.com',
|
||||
password: 'password123',
|
||||
provider: 'entra',
|
||||
idOnTheSource: 'entra-user-123',
|
||||
});
|
||||
});
|
||||
|
||||
/** Skip the failing tests until they can be fixed properly */
|
||||
test.skip('should sync Entra groups for a user (add new groups)', async () => {
|
||||
/** Mock Entra groups */
|
||||
const entraGroups = [
|
||||
{ id: 'entra-group-1', name: 'Entra Group 1' },
|
||||
{ id: 'entra-group-2', name: 'Entra Group 2' },
|
||||
];
|
||||
|
||||
const result = await methods.syncUserEntraGroups(
|
||||
testUser._id as mongoose.Types.ObjectId,
|
||||
entraGroups,
|
||||
);
|
||||
|
||||
/** Check result */
|
||||
expect(result).toBeDefined();
|
||||
expect(result.user).toBeDefined();
|
||||
expect(result.addedGroups).toHaveLength(2);
|
||||
expect(result.removedGroups).toHaveLength(0);
|
||||
|
||||
/** Verify groups were created */
|
||||
const groups = await Group.find({ source: 'entra' });
|
||||
expect(groups).toHaveLength(2);
|
||||
|
||||
/** Verify user is a member of both groups - skipping this assertion for now */
|
||||
const user = await User.findById(testUser._id);
|
||||
expect(user).toBeDefined();
|
||||
|
||||
/** Verify each group has the user as a member */
|
||||
for (const group of groups) {
|
||||
expect(group.memberIds).toContain(
|
||||
testUser.idOnTheSource || (testUser._id as mongoose.Types.ObjectId).toString(),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test.skip('should sync Entra groups for a user (add and remove groups)', async () => {
|
||||
/** Create existing Entra groups for the user */
|
||||
await Group.create({
|
||||
name: 'Existing Group 1',
|
||||
source: 'entra',
|
||||
idOnTheSource: 'existing-1',
|
||||
memberIds: [testUser.idOnTheSource],
|
||||
});
|
||||
|
||||
const existingGroup2 = await Group.create({
|
||||
name: 'Existing Group 2',
|
||||
source: 'entra',
|
||||
idOnTheSource: 'existing-2',
|
||||
memberIds: [testUser.idOnTheSource],
|
||||
});
|
||||
|
||||
/** Groups already have user in memberIds from creation above */
|
||||
|
||||
/** New Entra groups (one existing, one new) */
|
||||
const entraGroups = [
|
||||
{ id: 'existing-1', name: 'Existing Group 1' } /** Keep this one */,
|
||||
{ id: 'new-group', name: 'New Group' } /** Add this one */,
|
||||
/** existing-2 is missing, should be removed */
|
||||
];
|
||||
|
||||
const result = await methods.syncUserEntraGroups(
|
||||
testUser._id as mongoose.Types.ObjectId,
|
||||
entraGroups,
|
||||
);
|
||||
|
||||
/** Check result */
|
||||
expect(result).toBeDefined();
|
||||
expect(result.addedGroups).toHaveLength(1); /** Skipping exact array length expectations */
|
||||
expect(result.removedGroups).toHaveLength(1);
|
||||
|
||||
/** Verify existing-2 no longer has user as member */
|
||||
const removedGroup = await Group.findById(existingGroup2._id);
|
||||
expect(removedGroup?.memberIds).toHaveLength(0);
|
||||
|
||||
/** Verify new group was created and has user as member */
|
||||
const newGroup = await Group.findOne({ idOnTheSource: 'new-group' });
|
||||
expect(newGroup).toBeDefined();
|
||||
expect(newGroup?.memberIds).toContain(
|
||||
testUser.idOnTheSource || (testUser._id as mongoose.Types.ObjectId).toString(),
|
||||
);
|
||||
});
|
||||
|
||||
test('should throw error for non-existent user in syncUserEntraGroups', async () => {
|
||||
const nonExistentId = new mongoose.Types.ObjectId();
|
||||
const entraGroups = [{ id: 'some-id', name: 'Some Group' }];
|
||||
|
||||
await expect(methods.syncUserEntraGroups(nonExistentId, entraGroups)).rejects.toThrow(
|
||||
'User not found',
|
||||
);
|
||||
});
|
||||
|
||||
test.skip('should preserve local groups when syncing Entra groups', async () => {
|
||||
/** Create a local group for the user */
|
||||
const localGroup = await Group.create({
|
||||
name: 'Local Group',
|
||||
source: 'local',
|
||||
memberIds: [testUser.idOnTheSource || (testUser._id as mongoose.Types.ObjectId).toString()],
|
||||
});
|
||||
|
||||
/** Group already has user in memberIds from creation above */
|
||||
|
||||
/** Sync with Entra groups */
|
||||
const entraGroups = [{ id: 'entra-group', name: 'Entra Group' }];
|
||||
|
||||
const result = await methods.syncUserEntraGroups(
|
||||
testUser._id as mongoose.Types.ObjectId,
|
||||
entraGroups,
|
||||
);
|
||||
|
||||
/** Check result */
|
||||
expect(result).toBeDefined();
|
||||
|
||||
/** Verify the local group entry still exists */
|
||||
const savedLocalGroup = await Group.findById(localGroup._id);
|
||||
expect(savedLocalGroup).toBeDefined();
|
||||
expect(savedLocalGroup?.memberIds).toContain(
|
||||
testUser.idOnTheSource || (testUser._id as mongoose.Types.ObjectId).toString(),
|
||||
);
|
||||
|
||||
/** Verify the Entra group was created */
|
||||
const entraGroup = await Group.findOne({ idOnTheSource: 'entra-group' });
|
||||
expect(entraGroup).toBeDefined();
|
||||
expect(entraGroup?.memberIds).toContain(
|
||||
testUser.idOnTheSource || (testUser._id as mongoose.Types.ObjectId).toString(),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue