mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-18 01:10:14 +01:00
🗨️ feat: Granular Prompt Permissions via ACL and Permission Bits
feat: Implement prompt permissions management and access control middleware fix: agent deletion process to remove associated permissions and ACL entries fix: Import Permissions for enhanced access control in GrantAccessDialog feat: use PromptGroup for access control - Added migration script for PromptGroup permissions, categorizing groups into global view access and private groups. - Created unit tests for the migration script to ensure correct categorization and permission granting. - Introduced middleware for checking access permissions on PromptGroups and prompts via their groups. - Updated routes to utilize new access control middleware for PromptGroups. - Enhanced access role definitions to include roles specific to PromptGroups. - Modified ACL entry schema and types to accommodate PromptGroup resource type. - Updated data provider to include new access role identifiers for PromptGroups. feat: add generic access management dialogs and hooks for resource permissions fix: remove duplicate imports in FileContext component fix: remove duplicate mongoose dependency in package.json feat: add access permissions handling for dynamic resource types and add promptGroup roles feat: implement centralized role localization and update access role types refactor: simplify author handling in prompt group routes and enhance ACL checks feat: implement addPromptToGroup functionality and update PromptForm to use it feat: enhance permission handling in ChatGroupItem, DashGroupItem, and PromptForm components chore: rename migration script for prompt group permissions and update package.json scripts chore: update prompt tests
This commit is contained in:
parent
7e7e75714e
commit
ae732b2ebc
46 changed files with 3505 additions and 408 deletions
|
|
@ -4,7 +4,6 @@ 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;
|
||||
// Default category value for new agents
|
||||
const {
|
||||
getProjectByName,
|
||||
addAgentIdsToProject,
|
||||
|
|
@ -12,12 +11,14 @@ const {
|
|||
removeAgentFromAllProjects,
|
||||
} = require('./Project');
|
||||
const { getCachedTools } = require('~/server/services/Config');
|
||||
|
||||
// Category values are now imported from shared constants
|
||||
// Schema fields (category, support_contact, is_promoted) are defined in @librechat/data-schemas
|
||||
const { getActions } = require('./Action');
|
||||
const { removeAllPermissions } = require('~/server/services/PermissionService');
|
||||
const { Agent } = require('~/db/models');
|
||||
|
||||
/**
|
||||
* Category values are now imported from shared constants
|
||||
*/
|
||||
const { getActions } = require('./Action');
|
||||
|
||||
/**
|
||||
* Create an agent with the provided data.
|
||||
* @param {Object} agentData - The agent data to create.
|
||||
|
|
@ -509,6 +510,10 @@ const deleteAgent = async (searchParameter) => {
|
|||
const agent = await Agent.findOneAndDelete(searchParameter);
|
||||
if (agent) {
|
||||
await removeAgentFromAllProjects(agent.id);
|
||||
await removeAllPermissions({
|
||||
resourceType: 'agent',
|
||||
resourceId: agent._id,
|
||||
});
|
||||
}
|
||||
return agent;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ const {
|
|||
revertAgentVersion,
|
||||
} = require('./Agent');
|
||||
const { getCachedTools } = require('~/server/services/Config');
|
||||
const permissionService = require('~/server/services/PermissionService');
|
||||
const { AclEntry } = require('~/db/models');
|
||||
|
||||
/**
|
||||
* @type {import('mongoose').Model<import('@librechat/data-schemas').IAgent>}
|
||||
|
|
@ -407,12 +409,26 @@ describe('models/Agent', () => {
|
|||
|
||||
describe('Agent CRUD Operations', () => {
|
||||
let mongoServer;
|
||||
let AccessRole;
|
||||
|
||||
beforeAll(async () => {
|
||||
mongoServer = await MongoMemoryServer.create();
|
||||
const mongoUri = mongoServer.getUri();
|
||||
Agent = mongoose.models.Agent || mongoose.model('Agent', agentSchema);
|
||||
await mongoose.connect(mongoUri);
|
||||
|
||||
// Initialize models
|
||||
const dbModels = require('~/db/models');
|
||||
AccessRole = dbModels.AccessRole;
|
||||
|
||||
// Create necessary access roles for agents
|
||||
await AccessRole.create({
|
||||
accessRoleId: 'agent_owner',
|
||||
name: 'Owner',
|
||||
description: 'Full control over agents',
|
||||
resourceType: 'agent',
|
||||
permBits: 15, // VIEW | EDIT | DELETE | SHARE
|
||||
});
|
||||
}, 20000);
|
||||
|
||||
afterAll(async () => {
|
||||
|
|
@ -468,6 +484,51 @@ describe('models/Agent', () => {
|
|||
expect(agentAfterDelete).toBeNull();
|
||||
});
|
||||
|
||||
test('should remove ACL entries when deleting an agent', async () => {
|
||||
const agentId = `agent_${uuidv4()}`;
|
||||
const authorId = new mongoose.Types.ObjectId();
|
||||
|
||||
// Create agent
|
||||
const agent = await createAgent({
|
||||
id: agentId,
|
||||
name: 'Agent With Permissions',
|
||||
provider: 'test',
|
||||
model: 'test-model',
|
||||
author: authorId,
|
||||
});
|
||||
|
||||
// Grant permissions (simulating sharing)
|
||||
await permissionService.grantPermission({
|
||||
principalType: 'user',
|
||||
principalId: authorId,
|
||||
resourceType: 'agent',
|
||||
resourceId: agent._id,
|
||||
accessRoleId: 'agent_owner',
|
||||
grantedBy: authorId,
|
||||
});
|
||||
|
||||
// Verify ACL entry exists
|
||||
const aclEntriesBefore = await AclEntry.find({
|
||||
resourceType: 'agent',
|
||||
resourceId: agent._id,
|
||||
});
|
||||
expect(aclEntriesBefore).toHaveLength(1);
|
||||
|
||||
// Delete the agent
|
||||
await deleteAgent({ id: agentId });
|
||||
|
||||
// Verify agent is deleted
|
||||
const agentAfterDelete = await getAgent({ id: agentId });
|
||||
expect(agentAfterDelete).toBeNull();
|
||||
|
||||
// Verify ACL entries are removed
|
||||
const aclEntriesAfter = await AclEntry.find({
|
||||
resourceType: 'agent',
|
||||
resourceId: agent._id,
|
||||
});
|
||||
expect(aclEntriesAfter).toHaveLength(0);
|
||||
});
|
||||
|
||||
test('should list agents by author', async () => {
|
||||
const authorId = new mongoose.Types.ObjectId();
|
||||
const otherAuthorId = new mongoose.Types.ObjectId();
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ const {
|
|||
removeGroupIdsFromProject,
|
||||
removeGroupFromAllProjects,
|
||||
} = require('./Project');
|
||||
const { removeAllPermissions } = require('~/server/services/PermissionService');
|
||||
const { PromptGroup, Prompt } = require('~/db/models');
|
||||
const { escapeRegExp } = require('~/server/utils');
|
||||
|
||||
|
|
@ -100,10 +101,6 @@ const getAllPromptGroups = async (req, filter) => {
|
|||
try {
|
||||
const { name, ...query } = filter;
|
||||
|
||||
if (!query.author) {
|
||||
throw new Error('Author is required');
|
||||
}
|
||||
|
||||
let searchShared = true;
|
||||
let searchSharedOnly = false;
|
||||
if (name) {
|
||||
|
|
@ -153,10 +150,6 @@ const getPromptGroups = async (req, filter) => {
|
|||
const validatedPageNumber = Math.max(parseInt(pageNumber, 10), 1);
|
||||
const validatedPageSize = Math.max(parseInt(pageSize, 10), 1);
|
||||
|
||||
if (!query.author) {
|
||||
throw new Error('Author is required');
|
||||
}
|
||||
|
||||
let searchShared = true;
|
||||
let searchSharedOnly = false;
|
||||
if (name) {
|
||||
|
|
@ -221,12 +214,16 @@ const getPromptGroups = async (req, filter) => {
|
|||
* @returns {Promise<TDeletePromptGroupResponse>}
|
||||
*/
|
||||
const deletePromptGroup = async ({ _id, author, role }) => {
|
||||
const query = { _id, author };
|
||||
const groupQuery = { groupId: new ObjectId(_id), author };
|
||||
if (role === SystemRoles.ADMIN) {
|
||||
delete query.author;
|
||||
delete groupQuery.author;
|
||||
// Build query - with ACL, author is optional
|
||||
const query = { _id };
|
||||
const groupQuery = { groupId: new ObjectId(_id) };
|
||||
|
||||
// Legacy: Add author filter if provided (backward compatibility)
|
||||
if (author && role !== SystemRoles.ADMIN) {
|
||||
query.author = author;
|
||||
groupQuery.author = author;
|
||||
}
|
||||
|
||||
const response = await PromptGroup.deleteOne(query);
|
||||
|
||||
if (!response || response.deletedCount === 0) {
|
||||
|
|
@ -235,6 +232,13 @@ const deletePromptGroup = async ({ _id, author, role }) => {
|
|||
|
||||
await Prompt.deleteMany(groupQuery);
|
||||
await removeGroupFromAllProjects(_id);
|
||||
|
||||
try {
|
||||
await removeAllPermissions({ resourceType: 'promptGroup', resourceId: _id });
|
||||
} catch (error) {
|
||||
logger.error('Error removing promptGroup permissions:', error);
|
||||
}
|
||||
|
||||
return { message: 'Prompt group deleted successfully' };
|
||||
};
|
||||
|
||||
|
|
@ -424,12 +428,32 @@ module.exports = {
|
|||
throw new Error('Failed to delete the prompt');
|
||||
}
|
||||
|
||||
// Remove all ACL entries for this prompt
|
||||
try {
|
||||
await removeAllPermissions({
|
||||
resourceType: 'prompt',
|
||||
resourceId: promptId,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error removing prompt permissions:', error);
|
||||
}
|
||||
|
||||
const remainingPrompts = await Prompt.find({ groupId })
|
||||
.select('_id')
|
||||
.sort({ createdAt: 1 })
|
||||
.lean();
|
||||
|
||||
if (remainingPrompts.length === 0) {
|
||||
// Remove all ACL entries for the promptGroup when deleting the last prompt
|
||||
try {
|
||||
await removeAllPermissions({
|
||||
resourceType: 'promptGroup',
|
||||
resourceId: groupId,
|
||||
});
|
||||
} catch (error) {
|
||||
logger.error('Error removing promptGroup permissions:', error);
|
||||
}
|
||||
|
||||
await PromptGroup.deleteOne({ _id: groupId });
|
||||
await removeGroupFromAllProjects(groupId);
|
||||
|
||||
|
|
|
|||
560
api/models/Prompt.spec.js
Normal file
560
api/models/Prompt.spec.js
Normal file
|
|
@ -0,0 +1,560 @@
|
|||
const { ObjectId } = require('mongodb');
|
||||
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||
const mongoose = require('mongoose');
|
||||
const { SystemRoles } = require('librechat-data-provider');
|
||||
const { logger, PermissionBits } = require('@librechat/data-schemas');
|
||||
|
||||
// Mock the config/connect module to prevent connection attempts during tests
|
||||
jest.mock('../../config/connect', () => jest.fn().mockResolvedValue(true));
|
||||
|
||||
const dbModels = require('~/db/models');
|
||||
|
||||
// Disable console for tests
|
||||
logger.silent = true;
|
||||
|
||||
let mongoServer;
|
||||
let Prompt, PromptGroup, AclEntry, AccessRole, User, Group, Project;
|
||||
let promptFns, permissionService;
|
||||
let testUsers, testGroups, testRoles;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Set up MongoDB memory server
|
||||
mongoServer = await MongoMemoryServer.create();
|
||||
const mongoUri = mongoServer.getUri();
|
||||
await mongoose.connect(mongoUri);
|
||||
|
||||
// Initialize models
|
||||
Prompt = dbModels.Prompt;
|
||||
PromptGroup = dbModels.PromptGroup;
|
||||
AclEntry = dbModels.AclEntry;
|
||||
AccessRole = dbModels.AccessRole;
|
||||
User = dbModels.User;
|
||||
Group = dbModels.Group;
|
||||
Project = dbModels.Project;
|
||||
|
||||
promptFns = require('~/models/Prompt');
|
||||
permissionService = require('~/server/services/PermissionService');
|
||||
|
||||
// Create test data
|
||||
await setupTestData();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await mongoose.disconnect();
|
||||
await mongoServer.stop();
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
async function setupTestData() {
|
||||
// Create access roles for promptGroups
|
||||
testRoles = {
|
||||
viewer: await AccessRole.create({
|
||||
accessRoleId: 'promptGroup_viewer',
|
||||
name: 'Viewer',
|
||||
description: 'Can view promptGroups',
|
||||
resourceType: 'promptGroup',
|
||||
permBits: PermissionBits.VIEW,
|
||||
}),
|
||||
editor: await AccessRole.create({
|
||||
accessRoleId: 'promptGroup_editor',
|
||||
name: 'Editor',
|
||||
description: 'Can view and edit promptGroups',
|
||||
resourceType: 'promptGroup',
|
||||
permBits: PermissionBits.VIEW | PermissionBits.EDIT,
|
||||
}),
|
||||
owner: await AccessRole.create({
|
||||
accessRoleId: 'promptGroup_owner',
|
||||
name: 'Owner',
|
||||
description: 'Full control over promptGroups',
|
||||
resourceType: 'promptGroup',
|
||||
permBits:
|
||||
PermissionBits.VIEW | PermissionBits.EDIT | PermissionBits.DELETE | PermissionBits.SHARE,
|
||||
}),
|
||||
};
|
||||
|
||||
// Create test users
|
||||
testUsers = {
|
||||
owner: await User.create({
|
||||
name: 'Prompt Owner',
|
||||
email: 'owner@example.com',
|
||||
role: SystemRoles.USER,
|
||||
}),
|
||||
editor: await User.create({
|
||||
name: 'Prompt Editor',
|
||||
email: 'editor@example.com',
|
||||
role: SystemRoles.USER,
|
||||
}),
|
||||
viewer: await User.create({
|
||||
name: 'Prompt Viewer',
|
||||
email: 'viewer@example.com',
|
||||
role: SystemRoles.USER,
|
||||
}),
|
||||
admin: await User.create({
|
||||
name: 'Admin User',
|
||||
email: 'admin@example.com',
|
||||
role: SystemRoles.ADMIN,
|
||||
}),
|
||||
noAccess: await User.create({
|
||||
name: 'No Access User',
|
||||
email: 'noaccess@example.com',
|
||||
role: SystemRoles.USER,
|
||||
}),
|
||||
};
|
||||
|
||||
// Create test groups
|
||||
testGroups = {
|
||||
editors: await Group.create({
|
||||
name: 'Prompt Editors',
|
||||
description: 'Group with editor access',
|
||||
}),
|
||||
viewers: await Group.create({
|
||||
name: 'Prompt Viewers',
|
||||
description: 'Group with viewer access',
|
||||
}),
|
||||
};
|
||||
|
||||
await Project.create({
|
||||
name: 'Global',
|
||||
description: 'Global project',
|
||||
promptGroupIds: [],
|
||||
});
|
||||
}
|
||||
|
||||
describe('Prompt ACL Permissions', () => {
|
||||
describe('Creating Prompts with Permissions', () => {
|
||||
it('should grant owner permissions when creating a prompt', async () => {
|
||||
// First create a group
|
||||
const testGroup = await PromptGroup.create({
|
||||
name: 'Test Group',
|
||||
category: 'testing',
|
||||
author: testUsers.owner._id,
|
||||
authorName: testUsers.owner.name,
|
||||
productionId: new mongoose.Types.ObjectId(),
|
||||
});
|
||||
|
||||
const promptData = {
|
||||
prompt: {
|
||||
prompt: 'Test prompt content',
|
||||
name: 'Test Prompt',
|
||||
type: 'text',
|
||||
groupId: testGroup._id,
|
||||
},
|
||||
author: testUsers.owner._id,
|
||||
};
|
||||
|
||||
await promptFns.savePrompt(promptData);
|
||||
|
||||
// Manually grant permissions as would happen in the route
|
||||
await permissionService.grantPermission({
|
||||
principalType: 'user',
|
||||
principalId: testUsers.owner._id,
|
||||
resourceType: 'promptGroup',
|
||||
resourceId: testGroup._id,
|
||||
accessRoleId: 'promptGroup_owner',
|
||||
grantedBy: testUsers.owner._id,
|
||||
});
|
||||
|
||||
// Check ACL entry
|
||||
const aclEntry = await AclEntry.findOne({
|
||||
resourceType: 'promptGroup',
|
||||
resourceId: testGroup._id,
|
||||
principalType: 'user',
|
||||
principalId: testUsers.owner._id,
|
||||
});
|
||||
|
||||
expect(aclEntry).toBeTruthy();
|
||||
expect(aclEntry.permBits).toBe(testRoles.owner.permBits);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Accessing Prompts', () => {
|
||||
let testPromptGroup;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a prompt group
|
||||
testPromptGroup = await PromptGroup.create({
|
||||
name: 'Test Group',
|
||||
author: testUsers.owner._id,
|
||||
authorName: testUsers.owner.name,
|
||||
productionId: new ObjectId(),
|
||||
});
|
||||
|
||||
// Create a prompt
|
||||
await Prompt.create({
|
||||
prompt: 'Test prompt for access control',
|
||||
name: 'Access Test Prompt',
|
||||
author: testUsers.owner._id,
|
||||
groupId: testPromptGroup._id,
|
||||
type: 'text',
|
||||
});
|
||||
|
||||
// Grant owner permissions
|
||||
await permissionService.grantPermission({
|
||||
principalType: 'user',
|
||||
principalId: testUsers.owner._id,
|
||||
resourceType: 'promptGroup',
|
||||
resourceId: testPromptGroup._id,
|
||||
accessRoleId: 'promptGroup_owner',
|
||||
grantedBy: testUsers.owner._id,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await Prompt.deleteMany({});
|
||||
await PromptGroup.deleteMany({});
|
||||
await AclEntry.deleteMany({});
|
||||
});
|
||||
|
||||
it('owner should have full access to their prompt', async () => {
|
||||
const hasAccess = await permissionService.checkPermission({
|
||||
userId: testUsers.owner._id,
|
||||
resourceType: 'promptGroup',
|
||||
resourceId: testPromptGroup._id,
|
||||
requiredPermission: PermissionBits.VIEW,
|
||||
});
|
||||
|
||||
expect(hasAccess).toBe(true);
|
||||
|
||||
const canEdit = await permissionService.checkPermission({
|
||||
userId: testUsers.owner._id,
|
||||
resourceType: 'promptGroup',
|
||||
resourceId: testPromptGroup._id,
|
||||
requiredPermission: PermissionBits.EDIT,
|
||||
});
|
||||
|
||||
expect(canEdit).toBe(true);
|
||||
});
|
||||
|
||||
it('user with viewer role should only have view access', async () => {
|
||||
// Grant viewer permissions
|
||||
await permissionService.grantPermission({
|
||||
principalType: 'user',
|
||||
principalId: testUsers.viewer._id,
|
||||
resourceType: 'promptGroup',
|
||||
resourceId: testPromptGroup._id,
|
||||
accessRoleId: 'promptGroup_viewer',
|
||||
grantedBy: testUsers.owner._id,
|
||||
});
|
||||
|
||||
const canView = await permissionService.checkPermission({
|
||||
userId: testUsers.viewer._id,
|
||||
resourceType: 'promptGroup',
|
||||
resourceId: testPromptGroup._id,
|
||||
requiredPermission: PermissionBits.VIEW,
|
||||
});
|
||||
|
||||
const canEdit = await permissionService.checkPermission({
|
||||
userId: testUsers.viewer._id,
|
||||
resourceType: 'promptGroup',
|
||||
resourceId: testPromptGroup._id,
|
||||
requiredPermission: PermissionBits.EDIT,
|
||||
});
|
||||
|
||||
expect(canView).toBe(true);
|
||||
expect(canEdit).toBe(false);
|
||||
});
|
||||
|
||||
it('user without permissions should have no access', async () => {
|
||||
const hasAccess = await permissionService.checkPermission({
|
||||
userId: testUsers.noAccess._id,
|
||||
resourceType: 'promptGroup',
|
||||
resourceId: testPromptGroup._id,
|
||||
requiredPermission: PermissionBits.VIEW,
|
||||
});
|
||||
|
||||
expect(hasAccess).toBe(false);
|
||||
});
|
||||
|
||||
it('admin should have access regardless of permissions', async () => {
|
||||
// Admin users should work through normal permission system
|
||||
// The middleware layer handles admin bypass, not the permission service
|
||||
const hasAccess = await permissionService.checkPermission({
|
||||
userId: testUsers.admin._id,
|
||||
resourceType: 'promptGroup',
|
||||
resourceId: testPromptGroup._id,
|
||||
requiredPermission: PermissionBits.VIEW,
|
||||
});
|
||||
|
||||
// Without explicit permissions, even admin won't have access at this layer
|
||||
expect(hasAccess).toBe(false);
|
||||
|
||||
// The actual admin bypass happens in the middleware layer (canAccessPromptResource)
|
||||
// which checks req.user.role === SystemRoles.ADMIN
|
||||
});
|
||||
});
|
||||
|
||||
describe('Group-based Access', () => {
|
||||
let testPromptGroup;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a prompt group first
|
||||
testPromptGroup = await PromptGroup.create({
|
||||
name: 'Group Access Test Group',
|
||||
author: testUsers.owner._id,
|
||||
authorName: testUsers.owner.name,
|
||||
productionId: new ObjectId(),
|
||||
});
|
||||
|
||||
await Prompt.create({
|
||||
prompt: 'Group access test prompt',
|
||||
name: 'Group Test',
|
||||
author: testUsers.owner._id,
|
||||
groupId: testPromptGroup._id,
|
||||
type: 'text',
|
||||
});
|
||||
|
||||
// Add users to groups
|
||||
await User.findByIdAndUpdate(testUsers.editor._id, {
|
||||
$push: { groups: testGroups.editors._id },
|
||||
});
|
||||
|
||||
await User.findByIdAndUpdate(testUsers.viewer._id, {
|
||||
$push: { groups: testGroups.viewers._id },
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await Prompt.deleteMany({});
|
||||
await AclEntry.deleteMany({});
|
||||
await User.updateMany({}, { $set: { groups: [] } });
|
||||
});
|
||||
|
||||
it('group members should inherit group permissions', async () => {
|
||||
// Create a prompt group
|
||||
const testPromptGroup = await PromptGroup.create({
|
||||
name: 'Group Test Group',
|
||||
author: testUsers.owner._id,
|
||||
authorName: testUsers.owner.name,
|
||||
productionId: new ObjectId(),
|
||||
});
|
||||
|
||||
// Add editor to the editors group
|
||||
await Group.findByIdAndUpdate(testGroups.editors._id, {
|
||||
$push: { memberIds: testUsers.editor._id },
|
||||
});
|
||||
|
||||
const prompt = await promptFns.savePrompt({
|
||||
author: testUsers.owner._id,
|
||||
prompt: {
|
||||
prompt: 'Group test prompt',
|
||||
name: 'Group Test',
|
||||
groupId: testPromptGroup._id,
|
||||
type: 'text',
|
||||
},
|
||||
});
|
||||
|
||||
// Check if savePrompt returned an error
|
||||
if (!prompt || !prompt.prompt) {
|
||||
throw new Error(`Failed to save prompt: ${prompt?.message || 'Unknown error'}`);
|
||||
}
|
||||
|
||||
// Grant edit permissions to the group
|
||||
await permissionService.grantPermission({
|
||||
principalType: 'group',
|
||||
principalId: testGroups.editors._id,
|
||||
resourceType: 'promptGroup',
|
||||
resourceId: testPromptGroup._id,
|
||||
accessRoleId: 'promptGroup_editor',
|
||||
grantedBy: testUsers.owner._id,
|
||||
});
|
||||
|
||||
// Check if group member has access
|
||||
const hasAccess = await permissionService.checkPermission({
|
||||
userId: testUsers.editor._id,
|
||||
resourceType: 'promptGroup',
|
||||
resourceId: testPromptGroup._id,
|
||||
requiredPermission: PermissionBits.EDIT,
|
||||
});
|
||||
|
||||
expect(hasAccess).toBe(true);
|
||||
|
||||
// Check that non-member doesn't have access
|
||||
const nonMemberAccess = await permissionService.checkPermission({
|
||||
userId: testUsers.viewer._id,
|
||||
resourceType: 'promptGroup',
|
||||
resourceId: testPromptGroup._id,
|
||||
requiredPermission: PermissionBits.EDIT,
|
||||
});
|
||||
|
||||
expect(nonMemberAccess).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Public Access', () => {
|
||||
let publicPromptGroup, privatePromptGroup;
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create separate prompt groups for public and private access
|
||||
publicPromptGroup = await PromptGroup.create({
|
||||
name: 'Public Access Test Group',
|
||||
author: testUsers.owner._id,
|
||||
authorName: testUsers.owner.name,
|
||||
productionId: new ObjectId(),
|
||||
});
|
||||
|
||||
privatePromptGroup = await PromptGroup.create({
|
||||
name: 'Private Access Test Group',
|
||||
author: testUsers.owner._id,
|
||||
authorName: testUsers.owner.name,
|
||||
productionId: new ObjectId(),
|
||||
});
|
||||
|
||||
// Create prompts in their respective groups
|
||||
await Prompt.create({
|
||||
prompt: 'Public prompt',
|
||||
name: 'Public',
|
||||
author: testUsers.owner._id,
|
||||
groupId: publicPromptGroup._id,
|
||||
type: 'text',
|
||||
});
|
||||
|
||||
await Prompt.create({
|
||||
prompt: 'Private prompt',
|
||||
name: 'Private',
|
||||
author: testUsers.owner._id,
|
||||
groupId: privatePromptGroup._id,
|
||||
type: 'text',
|
||||
});
|
||||
|
||||
// Grant public view access to publicPromptGroup
|
||||
await permissionService.grantPermission({
|
||||
principalType: 'public',
|
||||
principalId: null,
|
||||
resourceType: 'promptGroup',
|
||||
resourceId: publicPromptGroup._id,
|
||||
accessRoleId: 'promptGroup_viewer',
|
||||
grantedBy: testUsers.owner._id,
|
||||
});
|
||||
|
||||
// Grant only owner access to privatePromptGroup
|
||||
await permissionService.grantPermission({
|
||||
principalType: 'user',
|
||||
principalId: testUsers.owner._id,
|
||||
resourceType: 'promptGroup',
|
||||
resourceId: privatePromptGroup._id,
|
||||
accessRoleId: 'promptGroup_owner',
|
||||
grantedBy: testUsers.owner._id,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await Prompt.deleteMany({});
|
||||
await PromptGroup.deleteMany({});
|
||||
await AclEntry.deleteMany({});
|
||||
});
|
||||
|
||||
it('public prompt should be accessible to any user', async () => {
|
||||
const hasAccess = await permissionService.checkPermission({
|
||||
userId: testUsers.noAccess._id,
|
||||
resourceType: 'promptGroup',
|
||||
resourceId: publicPromptGroup._id,
|
||||
requiredPermission: PermissionBits.VIEW,
|
||||
includePublic: true,
|
||||
});
|
||||
|
||||
expect(hasAccess).toBe(true);
|
||||
});
|
||||
|
||||
it('private prompt should not be accessible to unauthorized users', async () => {
|
||||
const hasAccess = await permissionService.checkPermission({
|
||||
userId: testUsers.noAccess._id,
|
||||
resourceType: 'promptGroup',
|
||||
resourceId: privatePromptGroup._id,
|
||||
requiredPermission: PermissionBits.VIEW,
|
||||
includePublic: true,
|
||||
});
|
||||
|
||||
expect(hasAccess).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Prompt Deletion', () => {
|
||||
let testPromptGroup;
|
||||
|
||||
it('should remove ACL entries when prompt is deleted', async () => {
|
||||
testPromptGroup = await PromptGroup.create({
|
||||
name: 'Deletion Test Group',
|
||||
author: testUsers.owner._id,
|
||||
authorName: testUsers.owner.name,
|
||||
productionId: new ObjectId(),
|
||||
});
|
||||
|
||||
const prompt = await promptFns.savePrompt({
|
||||
author: testUsers.owner._id,
|
||||
prompt: {
|
||||
prompt: 'To be deleted',
|
||||
name: 'Delete Test',
|
||||
groupId: testPromptGroup._id,
|
||||
type: 'text',
|
||||
},
|
||||
});
|
||||
|
||||
// Check if savePrompt returned an error
|
||||
if (!prompt || !prompt.prompt) {
|
||||
throw new Error(`Failed to save prompt: ${prompt?.message || 'Unknown error'}`);
|
||||
}
|
||||
|
||||
const testPromptId = prompt.prompt._id;
|
||||
const promptGroupId = testPromptGroup._id;
|
||||
|
||||
// Grant permission
|
||||
await permissionService.grantPermission({
|
||||
principalType: 'user',
|
||||
principalId: testUsers.owner._id,
|
||||
resourceType: 'promptGroup',
|
||||
resourceId: testPromptGroup._id,
|
||||
accessRoleId: 'promptGroup_owner',
|
||||
grantedBy: testUsers.owner._id,
|
||||
});
|
||||
|
||||
// Verify ACL entry exists
|
||||
const beforeDelete = await AclEntry.find({
|
||||
resourceType: 'promptGroup',
|
||||
resourceId: testPromptGroup._id,
|
||||
});
|
||||
expect(beforeDelete).toHaveLength(1);
|
||||
|
||||
// Delete the prompt
|
||||
await promptFns.deletePrompt({
|
||||
promptId: testPromptId,
|
||||
groupId: promptGroupId,
|
||||
author: testUsers.owner._id,
|
||||
role: SystemRoles.USER,
|
||||
});
|
||||
|
||||
// Verify ACL entries are removed
|
||||
const aclEntries = await AclEntry.find({
|
||||
resourceType: 'promptGroup',
|
||||
resourceId: testPromptGroup._id,
|
||||
});
|
||||
|
||||
expect(aclEntries).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Backwards Compatibility', () => {
|
||||
it('should handle prompts without ACL entries gracefully', async () => {
|
||||
// Create a prompt group first
|
||||
const promptGroup = await PromptGroup.create({
|
||||
name: 'Legacy Test Group',
|
||||
author: testUsers.owner._id,
|
||||
authorName: testUsers.owner.name,
|
||||
productionId: new ObjectId(),
|
||||
});
|
||||
|
||||
// Create a prompt without ACL entries (legacy prompt)
|
||||
const legacyPrompt = await Prompt.create({
|
||||
prompt: 'Legacy prompt without ACL',
|
||||
name: 'Legacy',
|
||||
author: testUsers.owner._id,
|
||||
groupId: promptGroup._id,
|
||||
type: 'text',
|
||||
});
|
||||
|
||||
// The system should handle this gracefully
|
||||
const prompt = await promptFns.getPrompt({ _id: legacyPrompt._id });
|
||||
expect(prompt).toBeTruthy();
|
||||
expect(prompt._id.toString()).toBe(legacyPrompt._id.toString());
|
||||
});
|
||||
});
|
||||
});
|
||||
273
api/models/PromptGroupMigration.spec.js
Normal file
273
api/models/PromptGroupMigration.spec.js
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
const { ObjectId } = require('mongodb');
|
||||
const { MongoMemoryServer } = require('mongodb-memory-server');
|
||||
const mongoose = require('mongoose');
|
||||
const { logger, PermissionBits } = require('@librechat/data-schemas');
|
||||
const { Constants } = require('librechat-data-provider');
|
||||
|
||||
// Mock the config/connect module to prevent connection attempts during tests
|
||||
jest.mock('../../config/connect', () => jest.fn().mockResolvedValue(true));
|
||||
|
||||
// Disable console for tests
|
||||
logger.silent = true;
|
||||
|
||||
describe('PromptGroup Migration Script', () => {
|
||||
let mongoServer;
|
||||
let Prompt, PromptGroup, AclEntry, AccessRole, User, Project;
|
||||
let migrateToPromptGroupPermissions;
|
||||
let testOwner, testProject;
|
||||
let ownerRole, viewerRole;
|
||||
|
||||
beforeAll(async () => {
|
||||
// Set up MongoDB memory server
|
||||
mongoServer = await MongoMemoryServer.create();
|
||||
const mongoUri = mongoServer.getUri();
|
||||
await mongoose.connect(mongoUri);
|
||||
|
||||
// Initialize models
|
||||
const dbModels = require('~/db/models');
|
||||
Prompt = dbModels.Prompt;
|
||||
PromptGroup = dbModels.PromptGroup;
|
||||
AclEntry = dbModels.AclEntry;
|
||||
AccessRole = dbModels.AccessRole;
|
||||
User = dbModels.User;
|
||||
Project = dbModels.Project;
|
||||
|
||||
// Create test user
|
||||
testOwner = await User.create({
|
||||
name: 'Test Owner',
|
||||
email: 'owner@test.com',
|
||||
role: 'USER',
|
||||
});
|
||||
|
||||
// Create test project with the proper name
|
||||
const projectName = Constants.GLOBAL_PROJECT_NAME || 'instance';
|
||||
testProject = await Project.create({
|
||||
name: projectName,
|
||||
description: 'Global project',
|
||||
promptGroupIds: [],
|
||||
});
|
||||
|
||||
// Create promptGroup access roles
|
||||
ownerRole = await AccessRole.create({
|
||||
accessRoleId: 'promptGroup_owner',
|
||||
name: 'Owner',
|
||||
description: 'Full control over promptGroups',
|
||||
resourceType: 'promptGroup',
|
||||
permBits:
|
||||
PermissionBits.VIEW | PermissionBits.EDIT | PermissionBits.DELETE | PermissionBits.SHARE,
|
||||
});
|
||||
|
||||
viewerRole = await AccessRole.create({
|
||||
accessRoleId: 'promptGroup_viewer',
|
||||
name: 'Viewer',
|
||||
description: 'Can view promptGroups',
|
||||
resourceType: 'promptGroup',
|
||||
permBits: PermissionBits.VIEW,
|
||||
});
|
||||
|
||||
await AccessRole.create({
|
||||
accessRoleId: 'promptGroup_editor',
|
||||
name: 'Editor',
|
||||
description: 'Can view and edit promptGroups',
|
||||
resourceType: 'promptGroup',
|
||||
permBits: PermissionBits.VIEW | PermissionBits.EDIT,
|
||||
});
|
||||
|
||||
// Import migration function
|
||||
const migration = require('../../config/migrate-prompt-permissions');
|
||||
migrateToPromptGroupPermissions = migration.migrateToPromptGroupPermissions;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await mongoose.disconnect();
|
||||
await mongoServer.stop();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
// Clean up before each test
|
||||
await Prompt.deleteMany({});
|
||||
await PromptGroup.deleteMany({});
|
||||
await AclEntry.deleteMany({});
|
||||
// Reset the project's promptGroupIds array
|
||||
testProject.promptGroupIds = [];
|
||||
await testProject.save();
|
||||
});
|
||||
|
||||
it('should categorize promptGroups correctly in dry run', async () => {
|
||||
// Create global prompt group (in Global project)
|
||||
const globalPromptGroup = await PromptGroup.create({
|
||||
name: 'Global Group',
|
||||
author: testOwner._id,
|
||||
authorName: testOwner.name,
|
||||
productionId: new ObjectId(),
|
||||
});
|
||||
|
||||
// Create private prompt group (not in any project)
|
||||
const privatePromptGroup = await PromptGroup.create({
|
||||
name: 'Private Group',
|
||||
author: testOwner._id,
|
||||
authorName: testOwner.name,
|
||||
productionId: new ObjectId(),
|
||||
});
|
||||
|
||||
// Add global group to project's promptGroupIds array
|
||||
testProject.promptGroupIds = [globalPromptGroup._id];
|
||||
await testProject.save();
|
||||
|
||||
const result = await migrateToPromptGroupPermissions({ dryRun: true });
|
||||
|
||||
expect(result.dryRun).toBe(true);
|
||||
expect(result.summary.total).toBe(2);
|
||||
expect(result.summary.globalViewAccess).toBe(1);
|
||||
expect(result.summary.privateGroups).toBe(1);
|
||||
});
|
||||
|
||||
it('should grant appropriate permissions during migration', async () => {
|
||||
// Create prompt groups
|
||||
const globalPromptGroup = await PromptGroup.create({
|
||||
name: 'Global Group',
|
||||
author: testOwner._id,
|
||||
authorName: testOwner.name,
|
||||
productionId: new ObjectId(),
|
||||
});
|
||||
|
||||
const privatePromptGroup = await PromptGroup.create({
|
||||
name: 'Private Group',
|
||||
author: testOwner._id,
|
||||
authorName: testOwner.name,
|
||||
productionId: new ObjectId(),
|
||||
});
|
||||
|
||||
// Add global group to project's promptGroupIds array
|
||||
testProject.promptGroupIds = [globalPromptGroup._id];
|
||||
await testProject.save();
|
||||
|
||||
const result = await migrateToPromptGroupPermissions({ dryRun: false });
|
||||
|
||||
expect(result.migrated).toBe(2);
|
||||
expect(result.errors).toBe(0);
|
||||
expect(result.ownerGrants).toBe(2);
|
||||
expect(result.publicViewGrants).toBe(1);
|
||||
|
||||
// Check global promptGroup permissions
|
||||
const globalOwnerEntry = await AclEntry.findOne({
|
||||
resourceType: 'promptGroup',
|
||||
resourceId: globalPromptGroup._id,
|
||||
principalType: 'user',
|
||||
principalId: testOwner._id,
|
||||
});
|
||||
expect(globalOwnerEntry).toBeTruthy();
|
||||
expect(globalOwnerEntry.permBits).toBe(ownerRole.permBits);
|
||||
|
||||
const globalPublicEntry = await AclEntry.findOne({
|
||||
resourceType: 'promptGroup',
|
||||
resourceId: globalPromptGroup._id,
|
||||
principalType: 'public',
|
||||
});
|
||||
expect(globalPublicEntry).toBeTruthy();
|
||||
expect(globalPublicEntry.permBits).toBe(viewerRole.permBits);
|
||||
|
||||
// Check private promptGroup permissions
|
||||
const privateOwnerEntry = await AclEntry.findOne({
|
||||
resourceType: 'promptGroup',
|
||||
resourceId: privatePromptGroup._id,
|
||||
principalType: 'user',
|
||||
principalId: testOwner._id,
|
||||
});
|
||||
expect(privateOwnerEntry).toBeTruthy();
|
||||
expect(privateOwnerEntry.permBits).toBe(ownerRole.permBits);
|
||||
|
||||
const privatePublicEntry = await AclEntry.findOne({
|
||||
resourceType: 'promptGroup',
|
||||
resourceId: privatePromptGroup._id,
|
||||
principalType: 'public',
|
||||
});
|
||||
expect(privatePublicEntry).toBeNull();
|
||||
});
|
||||
|
||||
it('should skip promptGroups that already have ACL entries', async () => {
|
||||
// Create prompt groups
|
||||
const promptGroup1 = await PromptGroup.create({
|
||||
name: 'Group 1',
|
||||
author: testOwner._id,
|
||||
authorName: testOwner.name,
|
||||
productionId: new ObjectId(),
|
||||
});
|
||||
|
||||
const promptGroup2 = await PromptGroup.create({
|
||||
name: 'Group 2',
|
||||
author: testOwner._id,
|
||||
authorName: testOwner.name,
|
||||
productionId: new ObjectId(),
|
||||
});
|
||||
|
||||
// Grant permission to one promptGroup manually (simulating it already has ACL)
|
||||
await AclEntry.create({
|
||||
principalType: 'user',
|
||||
principalId: testOwner._id,
|
||||
principalModel: 'User',
|
||||
resourceType: 'promptGroup',
|
||||
resourceId: promptGroup1._id,
|
||||
permBits: ownerRole.permBits,
|
||||
roleId: ownerRole._id,
|
||||
grantedBy: testOwner._id,
|
||||
grantedAt: new Date(),
|
||||
});
|
||||
|
||||
const result = await migrateToPromptGroupPermissions({ dryRun: false });
|
||||
|
||||
// Should only migrate promptGroup2, skip promptGroup1
|
||||
expect(result.migrated).toBe(1);
|
||||
expect(result.errors).toBe(0);
|
||||
|
||||
// Verify promptGroup2 now has permissions
|
||||
const group2Entry = await AclEntry.findOne({
|
||||
resourceType: 'promptGroup',
|
||||
resourceId: promptGroup2._id,
|
||||
});
|
||||
expect(group2Entry).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should handle promptGroups with prompts correctly', async () => {
|
||||
// Create a promptGroup with some prompts
|
||||
const promptGroup = await PromptGroup.create({
|
||||
name: 'Group with Prompts',
|
||||
author: testOwner._id,
|
||||
authorName: testOwner.name,
|
||||
productionId: new ObjectId(),
|
||||
});
|
||||
|
||||
// Create some prompts in this group
|
||||
await Prompt.create({
|
||||
prompt: 'First prompt',
|
||||
author: testOwner._id,
|
||||
groupId: promptGroup._id,
|
||||
type: 'text',
|
||||
});
|
||||
|
||||
await Prompt.create({
|
||||
prompt: 'Second prompt',
|
||||
author: testOwner._id,
|
||||
groupId: promptGroup._id,
|
||||
type: 'text',
|
||||
});
|
||||
|
||||
const result = await migrateToPromptGroupPermissions({ dryRun: false });
|
||||
|
||||
expect(result.migrated).toBe(1);
|
||||
expect(result.errors).toBe(0);
|
||||
|
||||
// Verify the promptGroup has permissions
|
||||
const groupEntry = await AclEntry.findOne({
|
||||
resourceType: 'promptGroup',
|
||||
resourceId: promptGroup._id,
|
||||
});
|
||||
expect(groupEntry).toBeTruthy();
|
||||
|
||||
// Verify no prompt-level permissions were created
|
||||
const promptEntries = await AclEntry.find({
|
||||
resourceType: 'prompt',
|
||||
});
|
||||
expect(promptEntries).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue