LibreChat/packages/data-schemas/src/methods/accessRole.spec.ts
Danny Avila 65c81955f0
🔐 feat: Granular Role-based Permissions + Entra ID Group Discovery (#7804)
* feat: Add granular role-based permissions system with Entra ID integration

      - Implement RBAC with viewer/editor/owner roles using bitwise permissions
      - Add AccessRole, AclEntry, and Group models for permission management
      - Create PermissionService for core permission logic and validation
      - Integrate Microsoft Graph API for Entra ID user/group search
      - Add middleware for resource access validation with custom ID resolvers
      - Implement bulk permission updates with transaction support
      - Create permission management UI with people picker and role selection
      - Add public sharing capabilities for resources
      - Include database migration for existing agent ownership
      - Support hybrid local/Entra ID identity management
      - Add comprehensive test coverage for all new services

chore: Update @librechat/data-schemas to version 0.0.9 and export common module in index.ts

fix: Update userGroup tests to mock logger correctly and change principalId expectation from null to undefined

* fix(data-schemas): use partial index for group idOnTheSource uniqueness

Replace sparse index with partial filter expression to allow multiple local groups
while maintaining unique constraint for external source IDs. The sparse option
on compound indexes doesn't work as expected when one field is always present.

* fix: imports in migrate-agent-permissions.js

* chore(data-schemas): add comprehensive README for data schemas package

- Introduced a detailed README.md file outlining the structure, architecture patterns, and best practices for the LibreChat Data Schemas package.
- Included guidelines for creating new entities, type definitions, schema files, model factory functions, and database methods.
- Added examples and common patterns to enhance understanding and usage of the package.

* chore: remove unused translation keys from localization file

* ci: fix existing tests based off new permission handling

- Renamed test cases to reflect changes in permission checks being handled at the route level.
- Updated assertions to verify that agents are returned regardless of user permissions due to the new permission system.
- Adjusted mocks in AppService and PermissionService tests to ensure proper functionality without relying on actual implementations.

* ci: add unit tests for access control middleware

- Introduced tests for the `canAccessAgentResource` middleware to validate permission checks for agent resources.
- Implemented tests for various scenarios including user roles, ACL entries, and permission levels.
- Added tests for the `checkAccess` function to ensure proper permission handling based on user roles and permissions.
- Utilized MongoDB in-memory server for isolated test environments.

* refactor: remove unused mocks from GraphApiService tests

* ci: enhance AgentFooter tests with improved mocks and permission handling

- Updated mocks for `useWatch`, `useAuthContext`, `useHasAccess`, and `useResourcePermissions` to streamline test setup.
- Adjusted assertions to reflect changes in UI based on agent ID and user roles.
- Replaced `share-agent` component with `grant-access-dialog` in tests to align with recent UI updates.
- Added tests for handling null agent data and permissions loading scenarios.

* ci: enhance GraphApiService tests with MongoDB in-memory server

- Updated test setup to use MongoDB in-memory server for isolated testing.
- Refactored beforeEach to beforeAll for database connection management.
- Cleared database before each test to ensure a clean state.
- Retained existing mocks while improving test structure for better clarity.

* ci: enhance GraphApiService tests with additional logger mocks

- Added mock implementation for logger methods in GraphApiService tests to improve error and debug logging during test execution.
- Ensured existing mocks remain intact while enhancing test coverage and clarity.

* chore: address ESLint Warnings

* - add cursor-based pagination to getListAgentsByAccess and update handler
- add index on updatedAt and _id in agent schema for improved query performance

* refactor permission service with reuse of model methods from data-schema package

* - Fix ObjectId comparison in getListAgentsHandler using .equals() method instead of strict equality
  - Add findPubliclyAccessibleResources function to PermissionService for bulk public resource queries
  - Add hasPublicPermission function to PermissionService for individual resource public permission checks
  - Update getAgentHandler to use hasPublicPermission for accurate individual agent public status
  - Replace instanceProjectId-based global checks with isPublic property from backend in client code
  - Add isPublic property to Agent type definition
  - Add NODE_TLS_REJECT_UNAUTHORIZED debug setting to VS Code launch config

* feat: add check for People.Read scope in searchContacts

* fix: add roleId parameter to grantPermission and update tests for GraphApiService

* refactor: remove problematic projection pipelines in getResourcePermissions for document db aws compatibility

* feat: enhance agent permissions migration with DocumentDB compatibility and add dry-run script

* feat: add support for including Entra ID group owners as members in permissions management + fix Group members  paging

* feat: enforce at least one owner requirement for permission updates and add corresponding localization messages

* refactor: remove German locale (must be added via i18n)

* chore: linting in `api/models/Agent.js` and removed unused variables

* chore: linting, remove unused vars, and remove project-related parameters from `updateAgentHandler`

* chore: address ESLint errors

* chore: revert removal of unused vars for versioning

---------

Co-authored-by: Atef Bellaaj <slalom.bellaaj@external.daimlertruck.com>
2025-06-23 10:50:07 -04:00

312 lines
11 KiB
TypeScript

import mongoose from 'mongoose';
import { MongoMemoryServer } from 'mongodb-memory-server';
import { createAccessRoleMethods } from './accessRole';
import { PermissionBits, RoleBits } from '~/common';
import accessRoleSchema from '~/schema/accessRole';
import type * as t from '~/types';
let mongoServer: MongoMemoryServer;
let AccessRole: mongoose.Model<t.IAccessRole>;
let methods: ReturnType<typeof createAccessRoleMethods>;
beforeAll(async () => {
mongoServer = await MongoMemoryServer.create();
const mongoUri = mongoServer.getUri();
AccessRole = mongoose.models.AccessRole || mongoose.model('AccessRole', accessRoleSchema);
methods = createAccessRoleMethods(mongoose);
await mongoose.connect(mongoUri);
});
afterAll(async () => {
await mongoose.disconnect();
await mongoServer.stop();
});
beforeEach(async () => {
await mongoose.connection.dropDatabase();
});
describe('AccessRole Model Tests', () => {
describe('Basic CRUD Operations', () => {
const sampleRole: t.AccessRole = {
accessRoleId: 'test_viewer',
name: 'Test Viewer',
description: 'Test role for viewer permissions',
resourceType: 'agent',
permBits: RoleBits.VIEWER,
};
test('should create a new role', async () => {
const role = await methods.createRole(sampleRole);
expect(role).toBeDefined();
expect(role.accessRoleId).toBe(sampleRole.accessRoleId);
expect(role.name).toBe(sampleRole.name);
expect(role.permBits).toBe(sampleRole.permBits);
});
test('should find a role by its ID', async () => {
const createdRole = await methods.createRole(sampleRole);
const foundRole = await methods.findRoleById(createdRole._id);
expect(foundRole).toBeDefined();
expect(foundRole?._id.toString()).toBe(createdRole._id.toString());
expect(foundRole?.accessRoleId).toBe(sampleRole.accessRoleId);
});
test('should find a role by its identifier', async () => {
await methods.createRole(sampleRole);
const foundRole = await methods.findRoleByIdentifier(sampleRole.accessRoleId);
expect(foundRole).toBeDefined();
expect(foundRole?.accessRoleId).toBe(sampleRole.accessRoleId);
expect(foundRole?.name).toBe(sampleRole.name);
});
test('should update an existing role', async () => {
await methods.createRole(sampleRole);
const updatedData = {
name: 'Updated Test Role',
description: 'Updated description',
};
const updatedRole = await methods.updateRole(sampleRole.accessRoleId, updatedData);
expect(updatedRole).toBeDefined();
expect(updatedRole?.name).toBe(updatedData.name);
expect(updatedRole?.description).toBe(updatedData.description);
// Check that other fields remain unchanged
expect(updatedRole?.accessRoleId).toBe(sampleRole.accessRoleId);
expect(updatedRole?.permBits).toBe(sampleRole.permBits);
});
test('should delete a role', async () => {
await methods.createRole(sampleRole);
const deleteResult = await methods.deleteRole(sampleRole.accessRoleId);
expect(deleteResult.deletedCount).toBe(1);
const foundRole = await methods.findRoleByIdentifier(sampleRole.accessRoleId);
expect(foundRole).toBeNull();
});
test('should get all roles', async () => {
const roles = [
sampleRole,
{
accessRoleId: 'test_editor',
name: 'Test Editor',
description: 'Test role for editor permissions',
resourceType: 'agent',
permBits: RoleBits.EDITOR,
},
];
await Promise.all(roles.map((role) => methods.createRole(role)));
const allRoles = await methods.getAllRoles();
expect(allRoles).toHaveLength(2);
expect(allRoles.map((r) => r.accessRoleId).sort()).toEqual(
['test_editor', 'test_viewer'].sort(),
);
});
});
describe('Resource and Permission Queries', () => {
beforeEach(async () => {
await AccessRole.deleteMany({});
// Create sample roles for testing
await Promise.all([
methods.createRole({
accessRoleId: 'agent_viewer',
name: 'Agent Viewer',
description: 'Can view agents',
resourceType: 'agent',
permBits: RoleBits.VIEWER,
}),
methods.createRole({
accessRoleId: 'agent_editor',
name: 'Agent Editor',
description: 'Can edit agents',
resourceType: 'agent',
permBits: RoleBits.EDITOR,
}),
methods.createRole({
accessRoleId: 'project_viewer',
name: 'Project Viewer',
description: 'Can view projects',
resourceType: 'project',
permBits: RoleBits.VIEWER,
}),
methods.createRole({
accessRoleId: 'project_editor',
name: 'Project Editor',
description: 'Can edit projects',
resourceType: 'project',
permBits: RoleBits.EDITOR,
}),
]);
});
test('should find roles by resource type', async () => {
const agentRoles = await methods.findRolesByResourceType('agent');
expect(agentRoles).toHaveLength(2);
expect(agentRoles.map((r) => r.accessRoleId).sort()).toEqual(
['agent_editor', 'agent_viewer'].sort(),
);
const projectRoles = await methods.findRolesByResourceType('project');
expect(projectRoles).toHaveLength(2);
expect(projectRoles.map((r) => r.accessRoleId).sort()).toEqual(
['project_editor', 'project_viewer'].sort(),
);
});
test('should find role by permissions', async () => {
const viewerRole = await methods.findRoleByPermissions('agent', RoleBits.VIEWER);
expect(viewerRole).toBeDefined();
expect(viewerRole?.accessRoleId).toBe('agent_viewer');
const editorRole = await methods.findRoleByPermissions('agent', RoleBits.EDITOR);
expect(editorRole).toBeDefined();
expect(editorRole?.accessRoleId).toBe('agent_editor');
});
test('should return null when no role matches the permissions', async () => {
// Create a custom permission that doesn't match any existing role
const customPerm = PermissionBits.VIEW | PermissionBits.SHARE;
const role = await methods.findRoleByPermissions('agent', customPerm);
expect(role).toBeNull();
});
});
describe('seedDefaultRoles', () => {
beforeEach(async () => {
await AccessRole.deleteMany({});
});
test('should seed default roles', async () => {
const result = await methods.seedDefaultRoles();
// Verify the result contains the default roles
expect(Object.keys(result).sort()).toEqual(
['agent_editor', 'agent_owner', 'agent_viewer'].sort(),
);
// Verify each role exists in the database
const agentViewerRole = await methods.findRoleByIdentifier('agent_viewer');
expect(agentViewerRole).toBeDefined();
expect(agentViewerRole?.permBits).toBe(RoleBits.VIEWER);
const agentEditorRole = await methods.findRoleByIdentifier('agent_editor');
expect(agentEditorRole).toBeDefined();
expect(agentEditorRole?.permBits).toBe(RoleBits.EDITOR);
const agentOwnerRole = await methods.findRoleByIdentifier('agent_owner');
expect(agentOwnerRole).toBeDefined();
expect(agentOwnerRole?.permBits).toBe(RoleBits.OWNER);
});
test('should not modify existing roles when seeding', async () => {
// Create a modified version of a default role
const customRole = {
accessRoleId: 'agent_viewer',
name: 'Custom Viewer',
description: 'Custom viewer description',
resourceType: 'agent',
permBits: RoleBits.VIEWER,
};
await methods.createRole(customRole);
// Seed default roles
await methods.seedDefaultRoles();
// Verify the custom role was not modified
const role = await methods.findRoleByIdentifier('agent_viewer');
expect(role?.name).toBe(customRole.name);
expect(role?.description).toBe(customRole.description);
});
});
describe('getRoleForPermissions', () => {
beforeEach(async () => {
await AccessRole.deleteMany({});
// Create sample roles with ascending permission levels
await Promise.all([
methods.createRole({
accessRoleId: 'agent_viewer',
name: 'Agent Viewer',
resourceType: 'agent',
permBits: RoleBits.VIEWER, // 1
}),
methods.createRole({
accessRoleId: 'agent_editor',
name: 'Agent Editor',
resourceType: 'agent',
permBits: RoleBits.EDITOR, // 3
}),
methods.createRole({
accessRoleId: 'agent_manager',
name: 'Agent Manager',
resourceType: 'agent',
permBits: RoleBits.MANAGER, // 7
}),
methods.createRole({
accessRoleId: 'agent_owner',
name: 'Agent Owner',
resourceType: 'agent',
permBits: RoleBits.OWNER, // 15
}),
]);
});
test('should find exact matching role', async () => {
const role = await methods.getRoleForPermissions('agent', RoleBits.EDITOR);
expect(role).toBeDefined();
expect(role?.accessRoleId).toBe('agent_editor');
expect(role?.permBits).toBe(RoleBits.EDITOR);
});
test('should find closest compatible role without exceeding permissions', async () => {
// Create a custom permission between VIEWER and EDITOR
const customPerm = PermissionBits.VIEW | PermissionBits.SHARE; // 9
// Should return VIEWER (1) as closest matching role without exceeding the permission bits
const role = await methods.getRoleForPermissions('agent', customPerm);
expect(role).toBeDefined();
expect(role?.accessRoleId).toBe('agent_viewer');
});
test('should return null when no compatible role is found', async () => {
// Create a permission that doesn't match any existing permission pattern
const invalidPerm = 100;
const role = await methods.getRoleForPermissions('agent', invalidPerm as PermissionBits);
expect(role).toBeNull();
});
test('should find role for resource-specific permissions', async () => {
// Create a role for a different resource type
await methods.createRole({
accessRoleId: 'project_viewer',
name: 'Project Viewer',
resourceType: 'project',
permBits: RoleBits.VIEWER,
});
// Query for agent roles
const agentRole = await methods.getRoleForPermissions('agent', RoleBits.VIEWER);
expect(agentRole).toBeDefined();
expect(agentRole?.accessRoleId).toBe('agent_viewer');
// Query for project roles
const projectRole = await methods.getRoleForPermissions('project', RoleBits.VIEWER);
expect(projectRole).toBeDefined();
expect(projectRole?.accessRoleId).toBe('project_viewer');
});
});
});