mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-07-10 08:13:46 +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>
358 lines
10 KiB
TypeScript
358 lines
10 KiB
TypeScript
import { z } from 'zod';
|
|
|
|
/**
|
|
* Granular Permission System Types for Agent Sharing
|
|
*
|
|
* This file contains TypeScript interfaces and Zod schemas for the enhanced
|
|
* agent permission system that supports sharing with specific users/groups
|
|
* and Entra ID integration.
|
|
*/
|
|
|
|
// ===== ENUMS & CONSTANTS =====
|
|
|
|
/**
|
|
* Principal types for permission system
|
|
*/
|
|
export enum PrincipalType {
|
|
USER = 'user',
|
|
GROUP = 'group',
|
|
PUBLIC = 'public',
|
|
ROLE = 'role',
|
|
}
|
|
|
|
/**
|
|
* Principal model types for MongoDB references
|
|
*/
|
|
export enum PrincipalModel {
|
|
USER = 'User',
|
|
GROUP = 'Group',
|
|
ROLE = 'Role',
|
|
}
|
|
|
|
/**
|
|
* Source of the principal (local LibreChat or external Entra ID)
|
|
*/
|
|
export type TPrincipalSource = 'local' | 'entra';
|
|
|
|
/**
|
|
* Access levels for agents
|
|
*/
|
|
export type TAccessLevel = 'none' | 'viewer' | 'editor' | 'owner';
|
|
|
|
/**
|
|
* Resource types for permission system
|
|
*/
|
|
export enum ResourceType {
|
|
AGENT = 'agent',
|
|
PROMPTGROUP = 'promptGroup',
|
|
MCPSERVER = 'mcpServer',
|
|
REMOTE_AGENT = 'remoteAgent',
|
|
SKILL = 'skill',
|
|
SHARED_LINK = 'sharedLink',
|
|
}
|
|
|
|
/**
|
|
* Permission bit constants for bitwise operations
|
|
*/
|
|
export enum PermissionBits {
|
|
/** 001 - Can view and use agent */
|
|
VIEW = 1,
|
|
/** 010 - Can modify agent settings */
|
|
EDIT = 2,
|
|
/** 100 - Can delete agent */
|
|
DELETE = 4,
|
|
/** 1000 - Can share agent with others (future) */
|
|
SHARE = 8,
|
|
}
|
|
|
|
/**
|
|
* Standard access role IDs
|
|
*/
|
|
export enum AccessRoleIds {
|
|
AGENT_VIEWER = 'agent_viewer',
|
|
AGENT_EDITOR = 'agent_editor',
|
|
AGENT_OWNER = 'agent_owner',
|
|
PROMPTGROUP_VIEWER = 'promptGroup_viewer',
|
|
PROMPTGROUP_EDITOR = 'promptGroup_editor',
|
|
PROMPTGROUP_OWNER = 'promptGroup_owner',
|
|
MCPSERVER_VIEWER = 'mcpServer_viewer',
|
|
MCPSERVER_EDITOR = 'mcpServer_editor',
|
|
MCPSERVER_OWNER = 'mcpServer_owner',
|
|
REMOTE_AGENT_VIEWER = 'remoteAgent_viewer',
|
|
REMOTE_AGENT_EDITOR = 'remoteAgent_editor',
|
|
REMOTE_AGENT_OWNER = 'remoteAgent_owner',
|
|
SKILL_VIEWER = 'skill_viewer',
|
|
SKILL_EDITOR = 'skill_editor',
|
|
SKILL_OWNER = 'skill_owner',
|
|
SHARED_LINK_VIEWER = 'sharedLink_viewer',
|
|
SHARED_LINK_OWNER = 'sharedLink_owner',
|
|
}
|
|
|
|
// ===== ZOD SCHEMAS =====
|
|
|
|
/**
|
|
* Principal schema - represents a user, group, role, or public access
|
|
*/
|
|
export const principalSchema = z.object({
|
|
type: z.nativeEnum(PrincipalType),
|
|
id: z.string().optional(), // undefined for 'public' type, role name for 'role' type
|
|
name: z.string().optional(),
|
|
email: z.string().optional(), // for user and group types
|
|
source: z.enum(['local', 'entra']).optional(),
|
|
avatar: z.string().optional(), // for user and group types
|
|
description: z.string().optional(), // for group and role types
|
|
idOnTheSource: z.string().optional(), // Entra ID for users/groups
|
|
accessRoleId: z.nativeEnum(AccessRoleIds).optional(), // Access role ID for permissions
|
|
memberCount: z.number().optional(), // for group type
|
|
});
|
|
|
|
/**
|
|
* Access role schema - defines named permission sets
|
|
*/
|
|
export const accessRoleSchema = z.object({
|
|
accessRoleId: z.nativeEnum(AccessRoleIds),
|
|
name: z.string(),
|
|
description: z.string().optional(),
|
|
resourceType: z.nativeEnum(ResourceType).default(ResourceType.AGENT),
|
|
permBits: z.number(),
|
|
});
|
|
|
|
/**
|
|
* Permission entry schema - represents a single ACL entry
|
|
*/
|
|
export const permissionEntrySchema = z.object({
|
|
id: z.string(),
|
|
principalType: z.nativeEnum(PrincipalType),
|
|
principalId: z.string().optional(), // undefined for 'public'
|
|
principalName: z.string().optional(),
|
|
role: accessRoleSchema,
|
|
grantedBy: z.string(),
|
|
grantedAt: z.string(), // ISO date string
|
|
inheritedFrom: z.string().optional(), // for project-level inheritance
|
|
source: z.enum(['local', 'entra']).optional(),
|
|
});
|
|
|
|
/**
|
|
* Resource permissions response schema
|
|
*/
|
|
export const resourcePermissionsResponseSchema = z.object({
|
|
resourceType: z.nativeEnum(ResourceType),
|
|
resourceId: z.string(),
|
|
permissions: z.array(permissionEntrySchema),
|
|
});
|
|
|
|
/**
|
|
* Update resource permissions request schema
|
|
* This matches the user's requirement for the frontend DTO structure
|
|
*/
|
|
export const updateResourcePermissionsRequestSchema = z.object({
|
|
updated: principalSchema.array(),
|
|
removed: principalSchema.array(),
|
|
public: z.boolean().optional(),
|
|
publicAccessRoleId: z.string().optional(),
|
|
});
|
|
|
|
/**
|
|
* Update resource permissions response schema
|
|
* Returns the updated permissions with accessRoleId included
|
|
*/
|
|
export const updateResourcePermissionsResponseSchema = z.object({
|
|
message: z.string(),
|
|
results: z.object({
|
|
principals: principalSchema.array(),
|
|
public: z.boolean().optional(),
|
|
publicAccessRoleId: z.string().optional(),
|
|
}),
|
|
});
|
|
|
|
// ===== TYPESCRIPT TYPES =====
|
|
|
|
/**
|
|
* Principal - represents a user, group, or public access
|
|
*/
|
|
export type TPrincipal = z.infer<typeof principalSchema>;
|
|
|
|
/**
|
|
* Access role - defines named permission sets
|
|
*/
|
|
export type TAccessRole = z.infer<typeof accessRoleSchema>;
|
|
|
|
/**
|
|
* Permission entry - represents a single ACL entry
|
|
*/
|
|
export type TPermissionEntry = z.infer<typeof permissionEntrySchema>;
|
|
|
|
/**
|
|
* Resource permissions response
|
|
*/
|
|
export type TResourcePermissionsResponse = z.infer<typeof resourcePermissionsResponseSchema>;
|
|
|
|
/**
|
|
* Update resource permissions request
|
|
* This matches the user's requirement for the frontend DTO structure
|
|
*/
|
|
export type TUpdateResourcePermissionsRequest = z.infer<
|
|
typeof updateResourcePermissionsRequestSchema
|
|
>;
|
|
|
|
/**
|
|
* Update resource permissions response
|
|
* Returns the updated permissions with accessRoleId included
|
|
*/
|
|
export type TUpdateResourcePermissionsResponse = z.infer<
|
|
typeof updateResourcePermissionsResponseSchema
|
|
>;
|
|
|
|
/**
|
|
* Principal search request parameters
|
|
*/
|
|
export type TPrincipalSearchParams = {
|
|
q: string;
|
|
limit?: number;
|
|
types?: Array<PrincipalType.USER | PrincipalType.GROUP | PrincipalType.ROLE>;
|
|
};
|
|
|
|
/**
|
|
* Principal search result item
|
|
*/
|
|
export type TPrincipalSearchResult = {
|
|
id?: string | null; // null for Entra ID principals that don't exist locally yet
|
|
type: PrincipalType.USER | PrincipalType.GROUP | PrincipalType.ROLE;
|
|
name: string;
|
|
email?: string; // for users and groups
|
|
username?: string; // for users
|
|
avatar?: string; // for users and groups
|
|
provider?: string; // for users
|
|
source: 'local' | 'entra';
|
|
memberCount?: number; // for groups
|
|
description?: string; // for groups
|
|
idOnTheSource?: string; // Entra ID for users (maps to openidId) and groups (maps to idOnTheSource)
|
|
};
|
|
|
|
/**
|
|
* Principal search response
|
|
*/
|
|
export type TPrincipalSearchResponse = {
|
|
query: string;
|
|
limit: number;
|
|
types?: Array<PrincipalType.USER | PrincipalType.GROUP | PrincipalType.ROLE> | null;
|
|
results: TPrincipalSearchResult[];
|
|
count: number;
|
|
sources: {
|
|
local: number;
|
|
entra: number;
|
|
};
|
|
};
|
|
|
|
/**
|
|
* Available roles response
|
|
*/
|
|
export type TAvailableRolesResponse = {
|
|
resourceType: ResourceType;
|
|
roles: TAccessRole[];
|
|
};
|
|
|
|
/**
|
|
* Get resource permissions response schema
|
|
* This matches the enhanced aggregation-based endpoint response format
|
|
*/
|
|
export const getResourcePermissionsResponseSchema = z.object({
|
|
resourceType: z.nativeEnum(ResourceType),
|
|
resourceId: z.nativeEnum(AccessRoleIds),
|
|
principals: z.array(principalSchema),
|
|
public: z.boolean(),
|
|
publicAccessRoleId: z.nativeEnum(AccessRoleIds).optional(),
|
|
});
|
|
|
|
/**
|
|
* Get resource permissions response type
|
|
* This matches the enhanced aggregation-based endpoint response format
|
|
*/
|
|
export type TGetResourcePermissionsResponse = z.infer<typeof getResourcePermissionsResponseSchema>;
|
|
|
|
/**
|
|
* Effective permissions response schema
|
|
* Returns just the permission bitmask for a user on a resource
|
|
*/
|
|
export const effectivePermissionsResponseSchema = z.object({
|
|
permissionBits: z.number(),
|
|
});
|
|
|
|
/**
|
|
* Effective permissions response type
|
|
* Returns just the permission bitmask for a user on a resource
|
|
*/
|
|
export type TEffectivePermissionsResponse = z.infer<typeof effectivePermissionsResponseSchema>;
|
|
|
|
/**
|
|
* All effective permissions response type
|
|
* Map of resourceId to permissionBits for all accessible resources
|
|
*/
|
|
export type TAllEffectivePermissionsResponse = Record<string, number>;
|
|
|
|
// ===== UTILITY TYPES =====
|
|
|
|
/**
|
|
* Permission check result
|
|
*/
|
|
export interface TPermissionCheck {
|
|
canView: boolean;
|
|
canEdit: boolean;
|
|
canDelete: boolean;
|
|
canShare: boolean;
|
|
accessLevel: TAccessLevel;
|
|
}
|
|
|
|
// ===== HELPER FUNCTIONS =====
|
|
|
|
/**
|
|
* Convert permission bits to access level
|
|
*/
|
|
export function permBitsToAccessLevel(permBits: number): TAccessLevel {
|
|
if ((permBits & PermissionBits.DELETE) > 0) return 'owner';
|
|
if ((permBits & PermissionBits.EDIT) > 0) return 'editor';
|
|
if ((permBits & PermissionBits.VIEW) > 0) return 'viewer';
|
|
return 'none';
|
|
}
|
|
|
|
/**
|
|
* Convert access role ID to permission bits
|
|
*/
|
|
export function accessRoleToPermBits(accessRoleId: string): number {
|
|
switch (accessRoleId) {
|
|
case AccessRoleIds.AGENT_VIEWER:
|
|
case AccessRoleIds.PROMPTGROUP_VIEWER:
|
|
case AccessRoleIds.MCPSERVER_VIEWER:
|
|
case AccessRoleIds.REMOTE_AGENT_VIEWER:
|
|
case AccessRoleIds.SKILL_VIEWER:
|
|
case AccessRoleIds.SHARED_LINK_VIEWER:
|
|
return PermissionBits.VIEW;
|
|
case AccessRoleIds.AGENT_EDITOR:
|
|
case AccessRoleIds.PROMPTGROUP_EDITOR:
|
|
case AccessRoleIds.MCPSERVER_EDITOR:
|
|
case AccessRoleIds.REMOTE_AGENT_EDITOR:
|
|
case AccessRoleIds.SKILL_EDITOR:
|
|
return PermissionBits.VIEW | PermissionBits.EDIT;
|
|
case AccessRoleIds.AGENT_OWNER:
|
|
case AccessRoleIds.PROMPTGROUP_OWNER:
|
|
case AccessRoleIds.MCPSERVER_OWNER:
|
|
case AccessRoleIds.REMOTE_AGENT_OWNER:
|
|
case AccessRoleIds.SKILL_OWNER:
|
|
case AccessRoleIds.SHARED_LINK_OWNER:
|
|
return (
|
|
PermissionBits.VIEW | PermissionBits.EDIT | PermissionBits.DELETE | PermissionBits.SHARE
|
|
);
|
|
default:
|
|
return PermissionBits.VIEW;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Check if permission bitmask contains other bitmask
|
|
* @param permissions - The permission bitmask to check
|
|
* @param requiredPermission - The required permission bit(s)
|
|
* @returns {boolean} Whether permissions contains requiredPermission
|
|
*/
|
|
export function hasPermissions(permissions: number, requiredPermission: number): boolean {
|
|
return (permissions & requiredPermission) === requiredPermission;
|
|
}
|