mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-09-21 15:45:22 +00:00
* feat: Add granular access control to shared links via ACL system * fix(shared-links): preserve isPublic on failed migration grants Transient ACL failures during auto-migration permanently stranded links — $unset ran unconditionally, removing the legacy flag that triggers retry. Now only $unset isPublic after all grants succeed. * fix(config): skip isPublic unset for failed ACL grants Bulk migration unconditionally removed isPublic from all links, even those whose ACL writes failed. Failed links then lost the legacy marker needed for auto-migration retry. Now tracks failed link IDs per-batch and excludes them from the $unset step. Also adds sharedLink to AccessRole resourceType schema enum — was missing, only worked because seedDefaultRoles uses findOneAndUpdate which bypasses validation. * ci(config): add jest config and PR workflow for migration tests config/__tests__/ specs depend on api/jest.config.js module mappings but had no dedicated runner. Adds config/jest.config.js extending api config with absolutized paths, npm test:config script, and a GitHub Actions workflow triggered by changes to config/, api/models/, api/db/, or packages/ ACL code. * fix(permissions): honor boolean sharedLinks config SHARED_LINKS has no USE permission, so boolean config produced an empty update payload — gate conditions only matched object form, making `sharedLinks: false` a no-op on existing perms. * fix(share): resolve role before creating shared link Role lookup between create and grant left an orphaned link without ACL entries if getRoleByName threw — retry then hit "Share already exists" with no recovery path. * fix: Restore Public ACL Access Checks * fix: Type Public ACL Lookup * fix: Preserve Private Legacy Shared Links * chore: Promote Shared Link Permission Migration * fix: Address Shared Link Review Findings * fix: Repair Shared Link CI Follow-Up * fix: Narrow Shared Link Mongoose Test Mock * fix: Address Shared Link Review Follow-Ups * fix: Close Shared Link Review Gaps * fix: Guard Missing Shared Link Permission Backfill * test: Add Shared Link Mock E2E * test: Stabilize Shared Link Mock E2E --------- Co-authored-by: Danny Avila <danny@librechat.ai>
176 lines
5.6 KiB
TypeScript
176 lines
5.6 KiB
TypeScript
import { Permissions, PermissionTypes, permissionsSchema } from './permissions';
|
|
import { SystemRoles, roleDefaults } from './roles';
|
|
|
|
const RESOURCE_MANAGEMENT_FIELDS: Permissions[] = [
|
|
Permissions.CREATE,
|
|
Permissions.SHARE,
|
|
Permissions.SHARE_PUBLIC,
|
|
];
|
|
|
|
/**
|
|
* Permission types where CREATE/SHARE/SHARE_PUBLIC must default to false for USER.
|
|
* MEMORIES is excluded: its CREATE/READ/UPDATE apply to the user's own private data.
|
|
* AGENTS/PROMPTS are excluded: CREATE=true is intentional (users own their agents/prompts).
|
|
* Add new types here if they gate shared/multi-user resources.
|
|
*/
|
|
const RESOURCE_PERMISSION_TYPES: PermissionTypes[] = [
|
|
PermissionTypes.MCP_SERVERS,
|
|
PermissionTypes.REMOTE_AGENTS,
|
|
];
|
|
|
|
describe('roleDefaults', () => {
|
|
describe('USER role', () => {
|
|
const userPerms = roleDefaults[SystemRoles.USER].permissions;
|
|
|
|
it('should have explicit values for every field in every multi-field permission type', () => {
|
|
const schemaShape = permissionsSchema.shape;
|
|
|
|
for (const [permType, subSchema] of Object.entries(schemaShape)) {
|
|
const fieldNames = Object.keys(subSchema.shape);
|
|
if (fieldNames.length <= 1) {
|
|
continue;
|
|
}
|
|
|
|
const userValues = userPerms[permType as PermissionTypes] as Record<string, boolean>;
|
|
|
|
for (const field of fieldNames) {
|
|
expect({
|
|
permType,
|
|
field,
|
|
value: userValues[field],
|
|
}).toEqual(
|
|
expect.objectContaining({
|
|
permType,
|
|
field,
|
|
value: expect.any(Boolean),
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('should never grant CREATE, SHARE, or SHARE_PUBLIC by default for resource-management types', () => {
|
|
for (const permType of RESOURCE_PERMISSION_TYPES) {
|
|
const permissions = userPerms[permType] as Record<string, boolean>;
|
|
for (const field of RESOURCE_MANAGEMENT_FIELDS) {
|
|
if (permissions[field] === undefined) {
|
|
continue;
|
|
}
|
|
expect({
|
|
permType,
|
|
field,
|
|
value: permissions[field],
|
|
}).toEqual(
|
|
expect.objectContaining({
|
|
permType,
|
|
field,
|
|
value: false,
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
});
|
|
|
|
it('should cover every permission type that has CREATE, SHARE, or SHARE_PUBLIC fields', () => {
|
|
const schemaShape = permissionsSchema.shape;
|
|
const restrictedSet = new Set<string>(RESOURCE_PERMISSION_TYPES);
|
|
|
|
for (const [permType, subSchema] of Object.entries(schemaShape)) {
|
|
const fieldNames = Object.keys(subSchema.shape);
|
|
const hasResourceFields = fieldNames.some((f) =>
|
|
RESOURCE_MANAGEMENT_FIELDS.includes(f as Permissions),
|
|
);
|
|
if (!hasResourceFields) {
|
|
continue;
|
|
}
|
|
|
|
const isTracked =
|
|
restrictedSet.has(permType) ||
|
|
permType === PermissionTypes.MEMORIES ||
|
|
permType === PermissionTypes.PROMPTS ||
|
|
permType === PermissionTypes.AGENTS ||
|
|
permType === PermissionTypes.SKILLS ||
|
|
permType === PermissionTypes.SHARED_LINKS;
|
|
|
|
expect({
|
|
permType,
|
|
tracked: isTracked,
|
|
}).toEqual(
|
|
expect.objectContaining({
|
|
permType,
|
|
tracked: true,
|
|
}),
|
|
);
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('ADMIN role', () => {
|
|
const adminPerms = roleDefaults[SystemRoles.ADMIN].permissions;
|
|
|
|
it('should have explicit values for every field in every permission type', () => {
|
|
const schemaShape = permissionsSchema.shape;
|
|
|
|
for (const [permType, subSchema] of Object.entries(schemaShape)) {
|
|
const fieldNames = Object.keys(subSchema.shape);
|
|
const adminValues = adminPerms[permType as PermissionTypes] as Record<string, boolean>;
|
|
|
|
for (const field of fieldNames) {
|
|
expect({
|
|
permType,
|
|
field,
|
|
value: adminValues[field],
|
|
}).toEqual(
|
|
expect.objectContaining({
|
|
permType,
|
|
field,
|
|
value: expect.any(Boolean),
|
|
}),
|
|
);
|
|
}
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('SKILLS permission defaults', () => {
|
|
it('grants ADMIN all four skill permissions by default', () => {
|
|
const adminSkills = roleDefaults[SystemRoles.ADMIN].permissions[
|
|
PermissionTypes.SKILLS
|
|
] as Record<string, boolean>;
|
|
expect(adminSkills).toEqual({
|
|
[Permissions.USE]: true,
|
|
[Permissions.CREATE]: true,
|
|
[Permissions.SHARE]: true,
|
|
[Permissions.SHARE_PUBLIC]: true,
|
|
});
|
|
});
|
|
|
|
it('grants USER USE+CREATE but no sharing by default', () => {
|
|
const userSkills = roleDefaults[SystemRoles.USER].permissions[
|
|
PermissionTypes.SKILLS
|
|
] as Record<string, boolean>;
|
|
expect(userSkills).toEqual({
|
|
[Permissions.USE]: true,
|
|
[Permissions.CREATE]: true,
|
|
[Permissions.SHARE]: false,
|
|
[Permissions.SHARE_PUBLIC]: false,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('MCP_SERVERS.CONFIGURE_OBO defaults', () => {
|
|
it('grants ADMIN CONFIGURE_OBO by default', () => {
|
|
const adminMcp = roleDefaults[SystemRoles.ADMIN].permissions[
|
|
PermissionTypes.MCP_SERVERS
|
|
] as Record<string, boolean>;
|
|
expect(adminMcp[Permissions.CONFIGURE_OBO]).toBe(true);
|
|
});
|
|
|
|
it('does not grant CONFIGURE_OBO to USER by default — gates the OBO config layer', () => {
|
|
const userMcp = roleDefaults[SystemRoles.USER].permissions[
|
|
PermissionTypes.MCP_SERVERS
|
|
] as Record<string, boolean>;
|
|
expect(userMcp[Permissions.CONFIGURE_OBO]).toBe(false);
|
|
});
|
|
});
|
|
});
|