🗨️ 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:
Danny Avila 2025-07-26 12:28:31 -04:00
parent 7e7e75714e
commit ae732b2ebc
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
46 changed files with 3505 additions and 408 deletions

View file

@ -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;