diff --git a/api/models/Agent.js b/api/models/Agent.js index 8cfca747ec..508740b3d7 100644 --- a/api/models/Agent.js +++ b/api/models/Agent.js @@ -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; }; diff --git a/api/models/Agent.spec.js b/api/models/Agent.spec.js index 13c3a38f27..449d8103c0 100644 --- a/api/models/Agent.spec.js +++ b/api/models/Agent.spec.js @@ -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} @@ -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(); diff --git a/api/models/Prompt.js b/api/models/Prompt.js index 9499e19c8e..7394968ba4 100644 --- a/api/models/Prompt.js +++ b/api/models/Prompt.js @@ -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} */ 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); diff --git a/api/models/Prompt.spec.js b/api/models/Prompt.spec.js new file mode 100644 index 0000000000..df45d27cdf --- /dev/null +++ b/api/models/Prompt.spec.js @@ -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()); + }); + }); +}); diff --git a/api/models/PromptGroupMigration.spec.js b/api/models/PromptGroupMigration.spec.js new file mode 100644 index 0000000000..1f95f299f2 --- /dev/null +++ b/api/models/PromptGroupMigration.spec.js @@ -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); + }); +}); diff --git a/api/server/middleware/accessResources/canAccessPromptGroupResource.js b/api/server/middleware/accessResources/canAccessPromptGroupResource.js new file mode 100644 index 0000000000..b18d49fbf2 --- /dev/null +++ b/api/server/middleware/accessResources/canAccessPromptGroupResource.js @@ -0,0 +1,60 @@ +const { getPromptGroup } = require('~/models/Prompt'); +const { canAccessResource } = require('./canAccessResource'); + +/** + * PromptGroup ID resolver function + * Resolves promptGroup ID to MongoDB ObjectId + * + * @param {string} groupId - PromptGroup ID from route parameter + * @returns {Promise} PromptGroup document with _id field, or null if not found + */ +const resolvePromptGroupId = async (groupId) => { + return await getPromptGroup({ _id: groupId }); +}; + +/** + * PromptGroup-specific middleware factory that creates middleware to check promptGroup access permissions. + * This middleware extends the generic canAccessResource to handle promptGroup 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='groupId'] - The name of the route parameter containing the promptGroup ID + * @returns {Function} Express middleware function + * + * @example + * // Basic usage for viewing promptGroups + * router.get('/prompts/groups/:groupId', + * canAccessPromptGroupResource({ requiredPermission: 1 }), + * getPromptGroup + * ); + * + * @example + * // Custom resource ID parameter and edit permission + * router.patch('/prompts/groups/:id', + * canAccessPromptGroupResource({ + * requiredPermission: 2, + * resourceIdParam: 'id' + * }), + * updatePromptGroup + * ); + */ +const canAccessPromptGroupResource = (options) => { + const { requiredPermission, resourceIdParam = 'groupId' } = options; + + if (!requiredPermission || typeof requiredPermission !== 'number') { + throw new Error( + 'canAccessPromptGroupResource: requiredPermission is required and must be a number', + ); + } + + return canAccessResource({ + resourceType: 'promptGroup', + requiredPermission, + resourceIdParam, + idResolver: resolvePromptGroupId, + }); +}; + +module.exports = { + canAccessPromptGroupResource, +}; diff --git a/api/server/middleware/accessResources/canAccessPromptResource.js b/api/server/middleware/accessResources/canAccessPromptResource.js new file mode 100644 index 0000000000..730abcb7da --- /dev/null +++ b/api/server/middleware/accessResources/canAccessPromptResource.js @@ -0,0 +1,58 @@ +const { getPrompt } = require('~/models/Prompt'); +const { canAccessResource } = require('./canAccessResource'); + +/** + * Prompt ID resolver function + * Resolves prompt ID to MongoDB ObjectId + * + * @param {string} promptId - Prompt ID from route parameter + * @returns {Promise} Prompt document with _id field, or null if not found + */ +const resolvePromptId = async (promptId) => { + return await getPrompt({ _id: promptId }); +}; + +/** + * Prompt-specific middleware factory that creates middleware to check prompt access permissions. + * This middleware extends the generic canAccessResource to handle prompt 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='promptId'] - The name of the route parameter containing the prompt ID + * @returns {Function} Express middleware function + * + * @example + * // Basic usage for viewing prompts + * router.get('/prompts/:promptId', + * canAccessPromptResource({ requiredPermission: 1 }), + * getPrompt + * ); + * + * @example + * // Custom resource ID parameter and edit permission + * router.patch('/prompts/:id', + * canAccessPromptResource({ + * requiredPermission: 2, + * resourceIdParam: 'id' + * }), + * updatePrompt + * ); + */ +const canAccessPromptResource = (options) => { + const { requiredPermission, resourceIdParam = 'promptId' } = options; + + if (!requiredPermission || typeof requiredPermission !== 'number') { + throw new Error('canAccessPromptResource: requiredPermission is required and must be a number'); + } + + return canAccessResource({ + resourceType: 'prompt', + requiredPermission, + resourceIdParam, + idResolver: resolvePromptId, + }); +}; + +module.exports = { + canAccessPromptResource, +}; diff --git a/api/server/middleware/accessResources/canAccessPromptViaGroup.js b/api/server/middleware/accessResources/canAccessPromptViaGroup.js new file mode 100644 index 0000000000..9be82dc9ea --- /dev/null +++ b/api/server/middleware/accessResources/canAccessPromptViaGroup.js @@ -0,0 +1,54 @@ +const { getPrompt } = require('~/models/Prompt'); +const { canAccessResource } = require('./canAccessResource'); + +/** + * Prompt to PromptGroup ID resolver function + * Resolves prompt ID to its parent promptGroup ID + * + * @param {string} promptId - Prompt ID from route parameter + * @returns {Promise} Object with promptGroup's _id field, or null if not found + */ +const resolvePromptToGroupId = async (promptId) => { + const prompt = await getPrompt({ _id: promptId }); + if (!prompt || !prompt.groupId) { + return null; + } + // Return an object with _id that matches the promptGroup ID + return { _id: prompt.groupId }; +}; + +/** + * Middleware factory that checks promptGroup permissions when accessing individual prompts. + * This allows permission management at the promptGroup level while still supporting + * individual prompt access patterns. + * + * @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='promptId'] - The name of the route parameter containing the prompt ID + * @returns {Function} Express middleware function + * + * @example + * // Check promptGroup permissions when viewing a prompt + * router.get('/prompts/:promptId', + * canAccessPromptViaGroup({ requiredPermission: 1 }), + * getPrompt + * ); + */ +const canAccessPromptViaGroup = (options) => { + const { requiredPermission, resourceIdParam = 'promptId' } = options; + + if (!requiredPermission || typeof requiredPermission !== 'number') { + throw new Error('canAccessPromptViaGroup: requiredPermission is required and must be a number'); + } + + return canAccessResource({ + resourceType: 'promptGroup', + requiredPermission, + resourceIdParam, + idResolver: resolvePromptToGroupId, + }); +}; + +module.exports = { + canAccessPromptViaGroup, +}; diff --git a/api/server/middleware/accessResources/index.js b/api/server/middleware/accessResources/index.js index b1bffa94e3..650feb5751 100644 --- a/api/server/middleware/accessResources/index.js +++ b/api/server/middleware/accessResources/index.js @@ -1,9 +1,15 @@ const { canAccessResource } = require('./canAccessResource'); const { canAccessAgentResource } = require('./canAccessAgentResource'); const { canAccessAgentFromBody } = require('./canAccessAgentFromBody'); +const { canAccessPromptResource } = require('./canAccessPromptResource'); +const { canAccessPromptViaGroup } = require('./canAccessPromptViaGroup'); +const { canAccessPromptGroupResource } = require('./canAccessPromptGroupResource'); module.exports = { canAccessResource, canAccessAgentResource, canAccessAgentFromBody, + canAccessPromptResource, + canAccessPromptViaGroup, + canAccessPromptGroupResource, }; diff --git a/api/server/routes/accessPermissions.js b/api/server/routes/accessPermissions.js index 814fa233ca..32979e36cc 100644 --- a/api/server/routes/accessPermissions.js +++ b/api/server/routes/accessPermissions.js @@ -46,11 +46,35 @@ router.get('/:resourceType/:resourceId', getResourcePermissions); */ router.put( '/:resourceType/:resourceId', - canAccessResource({ - resourceType: 'agent', - requiredPermission: PermissionBits.SHARE, - resourceIdParam: 'resourceId', - }), + // Use middleware that dynamically handles resource type and permissions + (req, res, next) => { + const { resourceType } = req.params; + + // Define resource-specific middleware based on resourceType + let middleware; + + if (resourceType === 'agent') { + middleware = canAccessResource({ + resourceType: 'agent', + requiredPermission: PermissionBits.SHARE, + resourceIdParam: 'resourceId', + }); + } else if (resourceType === 'promptGroup') { + middleware = canAccessResource({ + resourceType: 'promptGroup', + requiredPermission: PermissionBits.SHARE, + resourceIdParam: 'resourceId', + }); + } else { + return res.status(400).json({ + error: 'Bad Request', + message: `Unsupported resource type: ${resourceType}`, + }); + } + + // Execute the middleware + middleware(req, res, next); + }, updateResourcePermissions, ); diff --git a/api/server/routes/prompts.js b/api/server/routes/prompts.js index c18418cba5..8f01d9bee7 100644 --- a/api/server/routes/prompts.js +++ b/api/server/routes/prompts.js @@ -1,5 +1,5 @@ const express = require('express'); -const { logger } = require('@librechat/data-schemas'); +const { logger, PermissionBits } = require('@librechat/data-schemas'); const { generateCheckAccess } = require('@librechat/api'); const { Permissions, SystemRoles, PermissionTypes } = require('librechat-data-provider'); const { @@ -16,7 +16,17 @@ const { // updatePromptLabels, makePromptProduction, } = require('~/models/Prompt'); -const { requireJwtAuth } = require('~/server/middleware'); +const { + canAccessPromptGroupResource, + canAccessPromptViaGroup, + requireJwtAuth, +} = require('~/server/middleware'); +const { + grantPermission, + getEffectivePermissions, + findAccessibleResources, + findPubliclyAccessibleResources, +} = require('~/server/services/PermissionService'); const { getRoleByName } = require('~/models/Role'); const router = express.Router(); @@ -48,43 +58,52 @@ router.use(checkPromptAccess); * Route to get single prompt group by its ID * GET /groups/:groupId */ -router.get('/groups/:groupId', async (req, res) => { - let groupId = req.params.groupId; - const author = req.user.id; +router.get( + '/groups/:groupId', + canAccessPromptGroupResource({ + requiredPermission: PermissionBits.VIEW, + }), + async (req, res) => { + const { groupId } = req.params; - const query = { - _id: groupId, - $or: [{ projectIds: { $exists: true, $ne: [], $not: { $size: 0 } } }, { author }], - }; + try { + const group = await getPromptGroup({ _id: groupId }); - if (req.user.role === SystemRoles.ADMIN) { - delete query.$or; - } + if (!group) { + return res.status(404).send({ message: 'Prompt group not found' }); + } - try { - const group = await getPromptGroup(query); - - if (!group) { - return res.status(404).send({ message: 'Prompt group not found' }); + res.status(200).send(group); + } catch (error) { + logger.error('Error getting prompt group', error); + res.status(500).send({ message: 'Error getting prompt group' }); } - - res.status(200).send(group); - } catch (error) { - logger.error('Error getting prompt group', error); - res.status(500).send({ message: 'Error getting prompt group' }); - } -}); + }, +); /** - * Route to fetch all prompt groups - * GET /groups + * Route to fetch all prompt groups (ACL-aware) + * GET /all */ router.get('/all', async (req, res) => { try { - const groups = await getAllPromptGroups(req, { - author: req.user._id, + const userId = req.user.id; + + // Get promptGroup IDs the user has VIEW access to via ACL + const accessibleIds = await findAccessibleResources({ + userId, + resourceType: 'promptGroup', + requiredPermissions: PermissionBits.VIEW, }); - res.status(200).send(groups); + + const groups = await getAllPromptGroups(req, {}); + + // Filter the results to only include accessible groups + const accessibleGroups = groups.filter((group) => + accessibleIds.some((id) => id.toString() === group._id.toString()), + ); + + res.status(200).send(accessibleGroups); } catch (error) { logger.error(error); res.status(500).send({ error: 'Error getting prompt groups' }); @@ -92,15 +111,44 @@ router.get('/all', async (req, res) => { }); /** - * Route to fetch paginated prompt groups with filters + * Route to fetch paginated prompt groups with filters (ACL-aware) * GET /groups */ router.get('/groups', async (req, res) => { try { - const filter = req.query; - /* Note: The aggregation requires an ObjectId */ - filter.author = req.user._id; + const userId = req.user.id; + const filter = { ...req.query }; + delete filter.author; // Remove author filter as we'll use ACL + + // Get promptGroup IDs the user has VIEW access to via ACL + const accessibleIds = await findAccessibleResources({ + userId, + resourceType: 'promptGroup', + requiredPermissions: PermissionBits.VIEW, + }); + + // Get publicly accessible promptGroups + const publiclyAccessibleIds = await findPubliclyAccessibleResources({ + resourceType: 'promptGroup', + requiredPermissions: PermissionBits.VIEW, + }); + const groups = await getPromptGroups(req, filter); + + if (groups.promptGroups && groups.promptGroups.length > 0) { + groups.promptGroups = groups.promptGroups.filter((group) => + accessibleIds.some((id) => id.toString() === group._id.toString()), + ); + + // Mark public groups + groups.promptGroups = groups.promptGroups.map((group) => { + if (publiclyAccessibleIds.some((id) => id.equals(group._id))) { + group.isPublic = true; + } + return group; + }); + } + res.status(200).send(groups); } catch (error) { logger.error(error); @@ -109,16 +157,17 @@ router.get('/groups', async (req, res) => { }); /** - * Updates or creates a prompt + promptGroup + * Creates a new prompt group with initial prompt * @param {object} req * @param {TCreatePrompt} req.body * @param {Express.Response} res */ -const createPrompt = async (req, res) => { +const createNewPromptGroup = async (req, res) => { try { const { prompt, group } = req.body; - if (!prompt) { - return res.status(400).send({ error: 'Prompt is required' }); + + if (!prompt || !group || !group.name) { + return res.status(400).send({ error: 'Prompt and group name are required' }); } const saveData = { @@ -128,21 +177,81 @@ const createPrompt = async (req, res) => { authorName: req.user.name, }; - /** @type {TCreatePromptResponse} */ - let result; - if (group && group.name) { - result = await createPromptGroup(saveData); - } else { - result = await savePrompt(saveData); + const result = await createPromptGroup(saveData); + + // Grant owner permissions to the creator on the new promptGroup + if (result.prompt && result.prompt._id && result.prompt.groupId) { + try { + await grantPermission({ + principalType: 'user', + principalId: req.user.id, + resourceType: 'promptGroup', + resourceId: result.prompt.groupId, + accessRoleId: 'promptGroup_owner', + grantedBy: req.user.id, + }); + logger.debug( + `[createPromptGroup] Granted owner permissions to user ${req.user.id} for promptGroup ${result.prompt.groupId}`, + ); + } catch (permissionError) { + logger.error( + `[createPromptGroup] Failed to grant owner permissions for promptGroup ${result.prompt.groupId}:`, + permissionError, + ); + } } + res.status(200).send(result); } catch (error) { logger.error(error); - res.status(500).send({ error: 'Error saving prompt' }); + res.status(500).send({ error: 'Error creating prompt group' }); } }; -router.post('/', checkPromptCreate, createPrompt); +/** + * Adds a new prompt to an existing prompt group + * @param {object} req + * @param {TCreatePrompt} req.body + * @param {Express.Response} res + */ +const addPromptToGroup = async (req, res) => { + try { + const { groupId } = req.params; + const { prompt } = req.body; + + if (!prompt) { + return res.status(400).send({ error: 'Prompt is required' }); + } + + // Ensure the prompt is associated with the correct group + prompt.groupId = groupId; + + const saveData = { + prompt, + author: req.user.id, + authorName: req.user.name, + }; + + const result = await savePrompt(saveData); + res.status(200).send(result); + } catch (error) { + logger.error(error); + res.status(500).send({ error: 'Error adding prompt to group' }); + } +}; + +// Create new prompt group (requires CREATE permission) +router.post('/', checkPromptCreate, createNewPromptGroup); + +// Add prompt to existing group (requires EDIT permission on the group) +router.post( + '/groups/:groupId/prompts', + checkPromptAccess, + canAccessPromptGroupResource({ + requiredPermission: PermissionBits.EDIT, + }), + addPromptToGroup, +); /** * Updates a prompt group @@ -168,35 +277,73 @@ const patchPromptGroup = async (req, res) => { } }; -router.patch('/groups/:groupId', checkGlobalPromptShare, patchPromptGroup); +router.patch( + '/groups/:groupId', + checkGlobalPromptShare, + canAccessPromptGroupResource({ + requiredPermission: PermissionBits.EDIT, + }), + patchPromptGroup, +); -router.patch('/:promptId/tags/production', checkPromptCreate, async (req, res) => { - try { +router.patch( + '/:promptId/tags/production', + checkPromptCreate, + canAccessPromptViaGroup({ + requiredPermission: PermissionBits.EDIT, + resourceIdParam: 'promptId', + }), + async (req, res) => { + try { + const { promptId } = req.params; + const result = await makePromptProduction(promptId); + res.status(200).send(result); + } catch (error) { + logger.error(error); + res.status(500).send({ error: 'Error updating prompt production' }); + } + }, +); + +router.get( + '/:promptId', + canAccessPromptViaGroup({ + requiredPermission: PermissionBits.VIEW, + resourceIdParam: 'promptId', + }), + async (req, res) => { const { promptId } = req.params; - const result = await makePromptProduction(promptId); - res.status(200).send(result); - } catch (error) { - logger.error(error); - res.status(500).send({ error: 'Error updating prompt production' }); - } -}); - -router.get('/:promptId', async (req, res) => { - const { promptId } = req.params; - const author = req.user.id; - const query = { _id: promptId, author }; - if (req.user.role === SystemRoles.ADMIN) { - delete query.author; - } - const prompt = await getPrompt(query); - res.status(200).send(prompt); -}); + const prompt = await getPrompt({ _id: promptId }); + res.status(200).send(prompt); + }, +); router.get('/', async (req, res) => { try { const author = req.user.id; const { groupId } = req.query; - const query = { groupId, author }; + + // If requesting prompts for a specific group, check permissions + if (groupId) { + const permissions = await getEffectivePermissions({ + userId: req.user.id, + resourceType: 'promptGroup', + resourceId: groupId, + }); + + if (!(permissions & PermissionBits.VIEW)) { + return res + .status(403) + .send({ error: 'Insufficient permissions to view prompts in this group' }); + } + + // If user has access, fetch all prompts in the group (not just their own) + const prompts = await getPrompts({ groupId }); + return res.status(200).send(prompts); + } + + // If no groupId, return user's own prompts + const query = { author }; if (req.user.role === SystemRoles.ADMIN) { delete query.author; } @@ -240,7 +387,8 @@ const deletePromptController = async (req, res) => { const deletePromptGroupController = async (req, res) => { try { const { groupId: _id } = req.params; - const message = await deletePromptGroup({ _id, author: req.user.id, role: req.user.role }); + // Don't pass author - permissions are now checked by middleware + const message = await deletePromptGroup({ _id, role: req.user.role }); res.send(message); } catch (error) { logger.error('Error deleting prompt group', error); @@ -248,7 +396,22 @@ const deletePromptGroupController = async (req, res) => { } }; -router.delete('/:promptId', checkPromptCreate, deletePromptController); -router.delete('/groups/:groupId', checkPromptCreate, deletePromptGroupController); +router.delete( + '/:promptId', + checkPromptCreate, + canAccessPromptViaGroup({ + requiredPermission: PermissionBits.DELETE, + resourceIdParam: 'promptId', + }), + deletePromptController, +); +router.delete( + '/groups/:groupId', + checkPromptCreate, + canAccessPromptGroupResource({ + requiredPermission: PermissionBits.DELETE, + }), + deletePromptGroupController, +); module.exports = router; diff --git a/api/server/routes/prompts.test.js b/api/server/routes/prompts.test.js new file mode 100644 index 0000000000..20c4e435a9 --- /dev/null +++ b/api/server/routes/prompts.test.js @@ -0,0 +1,612 @@ +const request = require('supertest'); +const express = require('express'); +const { MongoMemoryServer } = require('mongodb-memory-server'); +const mongoose = require('mongoose'); +const { ObjectId } = require('mongodb'); +const { SystemRoles } = require('librechat-data-provider'); +const { PermissionBits } = require('@librechat/data-schemas'); + +// Mock modules before importing +jest.mock('~/server/services/Config', () => ({ + getCachedTools: jest.fn().mockResolvedValue({}), + getCustomConfig: jest.fn(), +})); + +jest.mock('~/models/Role', () => ({ + getRoleByName: jest.fn(), +})); + +jest.mock('~/server/middleware', () => ({ + requireJwtAuth: (req, res, next) => next(), + canAccessPromptResource: jest.requireActual('~/server/middleware').canAccessPromptResource, + canAccessPromptViaGroup: jest.requireActual('~/server/middleware').canAccessPromptViaGroup, + canAccessPromptGroupResource: + jest.requireActual('~/server/middleware').canAccessPromptGroupResource, +})); + +let app; +let mongoServer; +let promptRoutes; +let Prompt, PromptGroup, AclEntry, AccessRole, User; +let testUsers, testRoles; +let grantPermission; + +// Helper function to set user in middleware +function setTestUser(app, user) { + app.use((req, res, next) => { + req.user = { + ...(user.toObject ? user.toObject() : user), + id: user.id || user._id.toString(), + _id: user._id, + name: user.name, + role: user.role, + }; + if (user.role === SystemRoles.ADMIN) { + console.log('Setting admin user with role:', req.user.role); + } + next(); + }); +} + +beforeAll(async () => { + 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; + + // Import permission service + const permissionService = require('~/server/services/PermissionService'); + grantPermission = permissionService.grantPermission; + + // Create test data + await setupTestData(); + + // Setup Express app + app = express(); + app.use(express.json()); + + // Mock authentication middleware - default to owner + setTestUser(app, testUsers.owner); + + // Import routes after mocks are set up + promptRoutes = require('./prompts'); + app.use('/api/prompts', promptRoutes); +}); + +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', + resourceType: 'promptGroup', + permBits: PermissionBits.VIEW, + }), + editor: await AccessRole.create({ + accessRoleId: 'promptGroup_editor', + name: 'Editor', + resourceType: 'promptGroup', + permBits: PermissionBits.VIEW | PermissionBits.EDIT, + }), + owner: await AccessRole.create({ + accessRoleId: 'promptGroup_owner', + name: 'Owner', + resourceType: 'promptGroup', + permBits: + PermissionBits.VIEW | PermissionBits.EDIT | PermissionBits.DELETE | PermissionBits.SHARE, + }), + }; + + // Create test users + testUsers = { + owner: await User.create({ + id: new ObjectId().toString(), + _id: new ObjectId(), + name: 'Prompt Owner', + email: 'owner@example.com', + role: SystemRoles.USER, + }), + viewer: await User.create({ + id: new ObjectId().toString(), + _id: new ObjectId(), + name: 'Prompt Viewer', + email: 'viewer@example.com', + role: SystemRoles.USER, + }), + editor: await User.create({ + id: new ObjectId().toString(), + _id: new ObjectId(), + name: 'Prompt Editor', + email: 'editor@example.com', + role: SystemRoles.USER, + }), + noAccess: await User.create({ + id: new ObjectId().toString(), + _id: new ObjectId(), + name: 'No Access', + email: 'noaccess@example.com', + role: SystemRoles.USER, + }), + admin: await User.create({ + id: new ObjectId().toString(), + _id: new ObjectId(), + name: 'Admin', + email: 'admin@example.com', + role: SystemRoles.ADMIN, + }), + }; + + // Mock getRoleByName + const { getRoleByName } = require('~/models/Role'); + getRoleByName.mockImplementation((roleName) => { + switch (roleName) { + case SystemRoles.USER: + return { permissions: { PROMPTS: { USE: true, CREATE: true } } }; + case SystemRoles.ADMIN: + return { permissions: { PROMPTS: { USE: true, CREATE: true, SHARED_GLOBAL: true } } }; + default: + return null; + } + }); +} + +describe('Prompt Routes - ACL Permissions', () => { + let consoleErrorSpy; + + beforeEach(() => { + consoleErrorSpy = jest.spyOn(console, 'error').mockImplementation(); + }); + + afterEach(() => { + consoleErrorSpy.mockRestore(); + }); + + // Simple test to verify route is loaded + it('should have routes loaded', async () => { + // This should at least not crash + const response = await request(app).get('/api/prompts/test-404'); + console.log('Test 404 response status:', response.status); + console.log('Test 404 response body:', response.body); + // We expect a 401 or 404, not 500 + expect(response.status).not.toBe(500); + }); + + describe('POST /api/prompts - Create Prompt', () => { + afterEach(async () => { + await Prompt.deleteMany({}); + await PromptGroup.deleteMany({}); + await AclEntry.deleteMany({}); + }); + + it('should create a prompt and grant owner permissions', async () => { + const promptData = { + prompt: { + prompt: 'Test prompt content', + type: 'text', + }, + group: { + name: 'Test Prompt Group', + }, + }; + + const response = await request(app).post('/api/prompts').send(promptData); + + if (response.status !== 200) { + console.log('POST /api/prompts error status:', response.status); + console.log('POST /api/prompts error body:', response.body); + console.log('Console errors:', consoleErrorSpy.mock.calls); + } + + console.log('POST response:', response.body); + + expect(response.status).toBe(200); + expect(response.body.prompt).toBeDefined(); + expect(response.body.prompt.prompt).toBe(promptData.prompt.prompt); + + // Check ACL entry was created + const aclEntry = await AclEntry.findOne({ + resourceType: 'promptGroup', + resourceId: response.body.prompt.groupId, + principalType: 'user', + principalId: testUsers.owner._id, + }); + + expect(aclEntry).toBeTruthy(); + expect(aclEntry.roleId.toString()).toBe(testRoles.owner._id.toString()); + }); + + it('should create a prompt group with prompt and grant owner permissions', async () => { + const promptData = { + prompt: { + prompt: 'Group prompt content', + // Remove 'name' from prompt - it's not in the schema + }, + group: { + name: 'Test Group', + category: 'testing', + }, + }; + + const response = await request(app).post('/api/prompts').send(promptData).expect(200); + + expect(response.body.prompt).toBeDefined(); + expect(response.body.group).toBeDefined(); + expect(response.body.group.name).toBe(promptData.group.name); + + // Check ACL entry was created for the promptGroup + const aclEntry = await AclEntry.findOne({ + resourceType: 'promptGroup', + resourceId: response.body.group._id, + principalType: 'user', + principalId: testUsers.owner._id, + }); + + expect(aclEntry).toBeTruthy(); + }); + }); + + describe('GET /api/prompts/:promptId - Get Prompt', () => { + let testPrompt; + let testGroup; + + beforeEach(async () => { + // Create a prompt group first + testGroup = await PromptGroup.create({ + name: 'Test Group', + category: 'testing', + author: testUsers.owner._id, + authorName: testUsers.owner.name, + productionId: new ObjectId(), + }); + + // Create a prompt + testPrompt = await Prompt.create({ + prompt: 'Test prompt for retrieval', + name: 'Get Test', + author: testUsers.owner._id, + type: 'text', + groupId: testGroup._id, + }); + }); + + afterEach(async () => { + await Prompt.deleteMany({}); + await PromptGroup.deleteMany({}); + await AclEntry.deleteMany({}); + }); + + it('should retrieve prompt when user has view permissions', async () => { + // Grant view permissions on the promptGroup + await grantPermission({ + principalType: 'user', + principalId: testUsers.owner._id, + resourceType: 'promptGroup', + resourceId: testGroup._id, + accessRoleId: 'promptGroup_viewer', + grantedBy: testUsers.owner._id, + }); + + const response = await request(app).get(`/api/prompts/${testPrompt._id}`).expect(200); + + expect(response.body._id).toBe(testPrompt._id.toString()); + expect(response.body.prompt).toBe(testPrompt.prompt); + }); + + it('should deny access when user has no permissions', async () => { + // Change the user to one without access + setTestUser(app, testUsers.noAccess); + + const response = await request(app).get(`/api/prompts/${testPrompt._id}`).expect(403); + + // Verify error response + expect(response.body.error).toBe('Forbidden'); + expect(response.body.message).toBe('Insufficient permissions to access this promptGroup'); + }); + + it('should allow admin access without explicit permissions', async () => { + // First, reset the app to remove previous middleware + app = express(); + app.use(express.json()); + + // Set admin user BEFORE adding routes + app.use((req, res, next) => { + req.user = { + ...testUsers.admin.toObject(), + id: testUsers.admin._id.toString(), + _id: testUsers.admin._id, + name: testUsers.admin.name, + role: testUsers.admin.role, + }; + next(); + }); + + // Now add the routes + const promptRoutes = require('./prompts'); + app.use('/api/prompts', promptRoutes); + + console.log('Admin user:', testUsers.admin); + console.log('Admin role:', testUsers.admin.role); + console.log('SystemRoles.ADMIN:', SystemRoles.ADMIN); + + const response = await request(app).get(`/api/prompts/${testPrompt._id}`).expect(200); + + expect(response.body._id).toBe(testPrompt._id.toString()); + }); + }); + + describe('DELETE /api/prompts/:promptId - Delete Prompt', () => { + let testPrompt; + let testGroup; + + beforeEach(async () => { + // Create group with prompt + testGroup = await PromptGroup.create({ + name: 'Delete Test Group', + category: 'testing', + author: testUsers.owner._id, + authorName: testUsers.owner.name, + productionId: new ObjectId(), + }); + + testPrompt = await Prompt.create({ + prompt: 'Test prompt for deletion', + name: 'Delete Test', + author: testUsers.owner._id, + type: 'text', + groupId: testGroup._id, + }); + + // Add prompt to group + testGroup.productionId = testPrompt._id; + testGroup.promptIds = [testPrompt._id]; + await testGroup.save(); + + // Grant owner permissions on the promptGroup + await grantPermission({ + principalType: 'user', + principalId: testUsers.owner._id, + resourceType: 'promptGroup', + resourceId: testGroup._id, + accessRoleId: 'promptGroup_owner', + grantedBy: testUsers.owner._id, + }); + }); + + afterEach(async () => { + await Prompt.deleteMany({}); + await PromptGroup.deleteMany({}); + await AclEntry.deleteMany({}); + }); + + it('should delete prompt when user has delete permissions', async () => { + const response = await request(app) + .delete(`/api/prompts/${testPrompt._id}`) + .query({ groupId: testGroup._id.toString() }) + .expect(200); + + expect(response.body.prompt).toBe('Prompt deleted successfully'); + + // Verify prompt was deleted + const deletedPrompt = await Prompt.findById(testPrompt._id); + expect(deletedPrompt).toBeNull(); + + // Verify ACL entries were removed + const aclEntries = await AclEntry.find({ + resourceType: 'promptGroup', + resourceId: testGroup._id, + }); + expect(aclEntries).toHaveLength(0); + }); + + it('should deny deletion when user lacks delete permissions', async () => { + // Create a prompt as a different user (not the one trying to delete) + const authorPrompt = await Prompt.create({ + prompt: 'Test prompt by another user', + name: 'Another User Prompt', + author: testUsers.editor._id, // Different author + type: 'text', + groupId: testGroup._id, + }); + + // Grant only viewer permissions to viewer user on the promptGroup + await grantPermission({ + principalType: 'user', + principalId: testUsers.viewer._id, + resourceType: 'promptGroup', + resourceId: testGroup._id, + accessRoleId: 'promptGroup_viewer', + grantedBy: testUsers.editor._id, + }); + + // Recreate app with viewer user + app = express(); + app.use(express.json()); + app.use((req, res, next) => { + req.user = { + ...testUsers.viewer.toObject(), + id: testUsers.viewer._id.toString(), + _id: testUsers.viewer._id, + name: testUsers.viewer.name, + role: testUsers.viewer.role, + }; + next(); + }); + const promptRoutes = require('./prompts'); + app.use('/api/prompts', promptRoutes); + + await request(app) + .delete(`/api/prompts/${authorPrompt._id}`) + .query({ groupId: testGroup._id.toString() }) + .expect(403); + + // Verify prompt still exists + const prompt = await Prompt.findById(authorPrompt._id); + expect(prompt).toBeTruthy(); + }); + }); + + describe('PATCH /api/prompts/:promptId/tags/production - Make Production', () => { + let testPrompt; + let testGroup; + + beforeEach(async () => { + // Create group + testGroup = await PromptGroup.create({ + name: 'Production Test Group', + category: 'testing', + author: testUsers.owner._id, + authorName: testUsers.owner.name, + productionId: new ObjectId(), + }); + + testPrompt = await Prompt.create({ + prompt: 'Test prompt for production', + name: 'Production Test', + author: testUsers.owner._id, + type: 'text', + groupId: testGroup._id, + }); + }); + + afterEach(async () => { + await Prompt.deleteMany({}); + await PromptGroup.deleteMany({}); + await AclEntry.deleteMany({}); + }); + + it('should make prompt production when user has edit permissions', async () => { + // Grant edit permissions on the promptGroup + await grantPermission({ + principalType: 'user', + principalId: testUsers.owner._id, + resourceType: 'promptGroup', + resourceId: testGroup._id, + accessRoleId: 'promptGroup_editor', + grantedBy: testUsers.owner._id, + }); + + // Recreate app to ensure fresh middleware + app = express(); + app.use(express.json()); + app.use((req, res, next) => { + req.user = { + ...testUsers.owner.toObject(), + id: testUsers.owner._id.toString(), + _id: testUsers.owner._id, + name: testUsers.owner.name, + role: testUsers.owner.role, + }; + next(); + }); + const promptRoutes = require('./prompts'); + app.use('/api/prompts', promptRoutes); + + const response = await request(app) + .patch(`/api/prompts/${testPrompt._id}/tags/production`) + .expect(200); + + expect(response.body.message).toBe('Prompt production made successfully'); + + // Verify the group was updated + const updatedGroup = await PromptGroup.findById(testGroup._id); + expect(updatedGroup.productionId.toString()).toBe(testPrompt._id.toString()); + }); + + it('should deny making production when user lacks edit permissions', async () => { + // Grant only view permissions to viewer on the promptGroup + await grantPermission({ + principalType: 'user', + principalId: testUsers.viewer._id, + resourceType: 'promptGroup', + resourceId: testGroup._id, + accessRoleId: 'promptGroup_viewer', + grantedBy: testUsers.owner._id, + }); + + // Recreate app with viewer user + app = express(); + app.use(express.json()); + app.use((req, res, next) => { + req.user = { + ...testUsers.viewer.toObject(), + id: testUsers.viewer._id.toString(), + _id: testUsers.viewer._id, + name: testUsers.viewer.name, + role: testUsers.viewer.role, + }; + next(); + }); + const promptRoutes = require('./prompts'); + app.use('/api/prompts', promptRoutes); + + await request(app).patch(`/api/prompts/${testPrompt._id}/tags/production`).expect(403); + + // Verify prompt hasn't changed + const unchangedGroup = await PromptGroup.findById(testGroup._id); + expect(unchangedGroup.productionId.toString()).not.toBe(testPrompt._id.toString()); + }); + }); + + describe('Public Access', () => { + let publicPrompt; + let publicGroup; + + beforeEach(async () => { + // Create a prompt group + publicGroup = await PromptGroup.create({ + name: 'Public Test Group', + category: 'testing', + author: testUsers.owner._id, + authorName: testUsers.owner.name, + productionId: new ObjectId(), + }); + + // Create a public prompt + publicPrompt = await Prompt.create({ + prompt: 'Public prompt content', + name: 'Public Test', + author: testUsers.owner._id, + type: 'text', + groupId: publicGroup._id, + }); + + // Grant public viewer access on the promptGroup + await grantPermission({ + principalType: 'public', + principalId: null, + resourceType: 'promptGroup', + resourceId: publicGroup._id, + accessRoleId: 'promptGroup_viewer', + grantedBy: testUsers.owner._id, + }); + }); + + afterEach(async () => { + await Prompt.deleteMany({}); + await PromptGroup.deleteMany({}); + await AclEntry.deleteMany({}); + }); + + it('should allow any user to view public prompts', async () => { + // Change user to someone without explicit permissions + setTestUser(app, testUsers.noAccess); + + const response = await request(app).get(`/api/prompts/${publicPrompt._id}`).expect(200); + + expect(response.body._id).toBe(publicPrompt._id.toString()); + }); + }); +}); diff --git a/api/server/services/PermissionService.js b/api/server/services/PermissionService.js index 85a263d455..4057c2c5b6 100644 --- a/api/server/services/PermissionService.js +++ b/api/server/services/PermissionService.js @@ -706,6 +706,31 @@ const bulkUpdateResourcePermissions = async ({ } }; +/** + * Remove all permissions for a specific resource + * @param {Object} params - Parameters for removing permissions + * @param {string} params.resourceType - Type of resource (e.g., 'agent', 'prompt') + * @param {string|mongoose.Types.ObjectId} params.resourceId - The ID of the resource + * @returns {Promise} Delete result + */ +const removeAllPermissions = async ({ resourceType, resourceId }) => { + try { + if (!resourceId || !mongoose.Types.ObjectId.isValid(resourceId)) { + throw new Error(`Invalid resource ID: ${resourceId}`); + } + + const result = await AclEntry.deleteMany({ + resourceType, + resourceId, + }); + + return result; + } catch (error) { + logger.error(`[PermissionService.removeAllPermissions] Error: ${error.message}`); + throw error; + } +}; + module.exports = { grantPermission, checkPermission, @@ -718,4 +743,5 @@ module.exports = { ensurePrincipalExists, ensureGroupPrincipalExists, syncUserEntraGroupMemberships, + removeAllPermissions, }; diff --git a/client/src/components/Prompts/Groups/ChatGroupItem.tsx b/client/src/components/Prompts/Groups/ChatGroupItem.tsx index 818b018cfe..9fb27ad227 100644 --- a/client/src/components/Prompts/Groups/ChatGroupItem.tsx +++ b/client/src/components/Prompts/Groups/ChatGroupItem.tsx @@ -7,8 +7,9 @@ import { DropdownMenuContent, DropdownMenuTrigger, } from '@librechat/client'; +import { PERMISSION_BITS } from 'librechat-data-provider'; import type { TPromptGroup } from 'librechat-data-provider'; -import { useLocalize, useSubmitMessage, useCustomLink, useAuthContext } from '~/hooks'; +import { useLocalize, useSubmitMessage, useCustomLink, useResourcePermissions } from '~/hooks'; import VariableDialog from '~/components/Prompts/Groups/VariableDialog'; import PreviewPrompt from '~/components/Prompts/PreviewPrompt'; import ListCard from '~/components/Prompts/Groups/ListCard'; @@ -22,7 +23,6 @@ function ChatGroupItem({ instanceProjectId?: string; }) { const localize = useLocalize(); - const { user } = useAuthContext(); const { submitPrompt } = useSubmitMessage(); const [isPreviewDialogOpen, setPreviewDialogOpen] = useState(false); const [isVariableDialogOpen, setVariableDialogOpen] = useState(false); @@ -32,7 +32,10 @@ function ChatGroupItem({ () => instanceProjectId != null && group.projectIds?.includes(instanceProjectId), [group, instanceProjectId], ); - const isOwner = useMemo(() => user?.id === group.author, [user, group]); + + // Check permissions for the promptGroup + const { hasPermission } = useResourcePermissions('promptGroup', group._id || ''); + const canEdit = hasPermission(PERMISSION_BITS.EDIT); const onCardClick: React.MouseEventHandler = () => { const text = group.productionPrompt?.prompt; @@ -108,10 +111,10 @@ function ChatGroupItem({