🔐 feat: Granular Role-based Permissions + Entra ID Group Discovery (#7804)

WIP: pre-granular-permissions commit

feat: Add category and support contact fields to Agent schema and UI components

Revert "feat: Add category and support contact fields to Agent schema and UI components"

This reverts commit c43a52b4c9.

Fix: Update import for renderHook in useAgentCategories.spec.tsx

fix: Update icon rendering in AgentCategoryDisplay tests to use empty spans

refactor: Improve category synchronization logic and clean up AgentConfig component

refactor: Remove unused UI flow translations from translation.json

feat: agent marketplace features

🔐 feat: Granular Role-based Permissions + Entra ID Group Discovery (#7804)
This commit is contained in:
Danny Avila 2025-06-23 10:22:27 -04:00
parent abc32e66ce
commit 76d75030b9
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
147 changed files with 17564 additions and 645 deletions

View file

@ -0,0 +1,292 @@
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 type TPrincipalType = 'user' | 'group' | 'public';
/**
* 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';
/**
* Permission bit constants for bitwise operations
*/
export const PERMISSION_BITS = {
VIEW: 1, // 001 - Can view and use agent
EDIT: 2, // 010 - Can modify agent settings
DELETE: 4, // 100 - Can delete agent
SHARE: 8, // 1000 - Can share agent with others (future)
} as const;
/**
* Standard access role IDs
*/
export const ACCESS_ROLE_IDS = {
AGENT_VIEWER: 'agent_viewer',
AGENT_EDITOR: 'agent_editor',
AGENT_OWNER: 'agent_owner', // Future use
} as const;
// ===== ZOD SCHEMAS =====
/**
* Principal schema - represents a user, group, or public access
*/
export const principalSchema = z.object({
type: z.enum(['user', 'group', 'public']),
id: z.string().optional(), // undefined for 'public' 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 type
idOnTheSource: z.string().optional(), // Entra ID for users/groups
accessRoleId: z.string().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.string(),
name: z.string(),
description: z.string().optional(),
resourceType: z.string().default('agent'),
permBits: z.number(),
});
/**
* Permission entry schema - represents a single ACL entry
*/
export const permissionEntrySchema = z.object({
id: z.string(),
principalType: z.enum(['user', 'group', 'public']),
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.string(),
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(),
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(),
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; // search query (required)
limit?: number; // max results (1-50, default 10)
type?: 'user' | 'group'; // filter by type (optional)
};
/**
* Principal search result item
*/
export type TPrincipalSearchResult = {
id?: string | null; // null for Entra ID principals that don't exist locally yet
type: 'user' | 'group';
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;
type?: 'user' | 'group';
results: TPrincipalSearchResult[];
count: number;
sources: {
local: number;
entra: number;
};
};
/**
* Available roles response
*/
export type TAvailableRolesResponse = {
resourceType: string;
roles: TAccessRole[];
};
/**
* Get resource permissions response schema
* This matches the enhanced aggregation-based endpoint response format
*/
export const getResourcePermissionsResponseSchema = z.object({
resourceType: z.string(),
resourceId: z.string(),
principals: z.array(principalSchema),
public: z.boolean(),
publicAccessRoleId: z.string().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>;
// ===== 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 & PERMISSION_BITS.DELETE) > 0) return 'owner';
if ((permBits & PERMISSION_BITS.EDIT) > 0) return 'editor';
if ((permBits & PERMISSION_BITS.VIEW) > 0) return 'viewer';
return 'none';
}
/**
* Convert access role ID to permission bits
*/
export function accessRoleToPermBits(accessRoleId: string): number {
switch (accessRoleId) {
case ACCESS_ROLE_IDS.AGENT_VIEWER:
return PERMISSION_BITS.VIEW;
case ACCESS_ROLE_IDS.AGENT_EDITOR:
return PERMISSION_BITS.VIEW | PERMISSION_BITS.EDIT;
case ACCESS_ROLE_IDS.AGENT_OWNER:
return PERMISSION_BITS.VIEW | PERMISSION_BITS.EDIT | PERMISSION_BITS.DELETE;
default:
return PERMISSION_BITS.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;
}

View file

@ -306,6 +306,32 @@ export const memories = () => '/api/memories';
export const memory = (key: string) => `${memories()}/${encodeURIComponent(key)}`;
export const memoryPreferences = () => `${memories()}/preferences`;
export const searchPrincipals = (params: q.PrincipalSearchParams) => {
const { q: query, limit, type } = params;
let url = `/api/permissions/search-principals?q=${encodeURIComponent(query)}`;
if (limit !== undefined) {
url += `&limit=${limit}`;
}
if (type !== undefined) {
url += `&type=${type}`;
}
return url;
};
export const getAccessRoles = (resourceType: string) => `/api/permissions/${resourceType}/roles`;
export const getResourcePermissions = (resourceType: string, resourceId: string) =>
`/api/permissions/${resourceType}/${resourceId}`;
export const updateResourcePermissions = (resourceType: string, resourceId: string) =>
`/api/permissions/${resourceType}/${resourceId}`;
export const getEffectivePermissions = (resourceType: string, resourceId: string) =>
`/api/permissions/${resourceType}/${resourceId}/effective`;
// SharePoint Graph API Token
export const graphToken = (scopes: string) =>
`/api/auth/graph-token?scopes=${encodeURIComponent(scopes)}`;

View file

@ -10,6 +10,7 @@ import * as config from './config';
import request from './request';
import * as s from './schemas';
import * as r from './roles';
import * as permissions from './accessPermissions';
export function revokeUserKey(name: string): Promise<unknown> {
return request.delete(endpoints.revokeUserKey(name));
@ -413,6 +414,14 @@ export const getAgentById = ({ agent_id }: { agent_id: string }): Promise<a.Agen
);
};
export const getExpandedAgentById = ({ agent_id }: { agent_id: string }): Promise<a.Agent> => {
return request.get(
endpoints.agents({
path: `${agent_id}/expanded`,
}),
);
};
export const updateAgent = ({
agent_id,
data,
@ -462,6 +471,80 @@ export const revertAgentVersion = ({
version_index: number;
}): Promise<a.Agent> => request.post(endpoints.revertAgentVersion(agent_id), { version_index });
/* Marketplace */
/**
* Get agent categories with counts for marketplace tabs
*/
export const getAgentCategories = (): Promise<t.TMarketplaceCategory[]> => {
return request.get(endpoints.agents({ path: 'marketplace/categories' }));
};
/**
* Get promoted/top picks agents with pagination
*/
export const getPromotedAgents = (params: {
page?: number;
limit?: number;
showAll?: string; // Add showAll parameter to get all shared agents instead of just promoted
}): Promise<a.AgentListResponse> => {
return request.get(
endpoints.agents({
path: 'marketplace/promoted',
options: params,
}),
);
};
/**
* Get all agents with pagination (for "all" category)
*/
export const getAllAgents = (params: {
page?: number;
limit?: number;
}): Promise<a.AgentListResponse> => {
return request.get(
endpoints.agents({
path: 'marketplace/all',
options: params,
}),
);
};
/**
* Get agents by category with pagination
*/
export const getAgentsByCategory = (params: {
category: string;
page?: number;
limit?: number;
}): Promise<a.AgentListResponse> => {
const { category, ...options } = params;
return request.get(
endpoints.agents({
path: `marketplace/category/${category}`,
options,
}),
);
};
/**
* Search agents in marketplace
*/
export const searchAgents = (params: {
q: string;
category?: string;
page?: number;
limit?: number;
}): Promise<a.AgentListResponse> => {
return request.get(
endpoints.agents({
path: 'marketplace/search',
options: params,
}),
);
};
/* Tools */
export const getAvailableAgentTools = (): Promise<s.TPlugin[]> => {
@ -859,6 +942,38 @@ export const createMemory = (data: {
return request.post(endpoints.memories(), data);
};
export function searchPrincipals(
params: q.PrincipalSearchParams,
): Promise<q.PrincipalSearchResponse> {
return request.get(endpoints.searchPrincipals(params));
}
export function getAccessRoles(resourceType: string): Promise<q.AccessRolesResponse> {
return request.get(endpoints.getAccessRoles(resourceType));
}
export function getResourcePermissions(
resourceType: string,
resourceId: string,
): Promise<permissions.TGetResourcePermissionsResponse> {
return request.get(endpoints.getResourcePermissions(resourceType, resourceId));
}
export function updateResourcePermissions(
resourceType: string,
resourceId: string,
data: permissions.TUpdateResourcePermissionsRequest,
): Promise<permissions.TUpdateResourcePermissionsResponse> {
return request.put(endpoints.updateResourcePermissions(resourceType, resourceId), data);
}
export function getEffectivePermissions(
resourceType: string,
resourceId: string,
): Promise<permissions.TEffectivePermissionsResponse> {
return request.get(endpoints.getEffectivePermissions(resourceType, resourceId));
}
// SharePoint Graph API Token
export function getGraphApiToken(params: q.GraphTokenParams): Promise<q.GraphTokenResponse> {
return request.get(endpoints.graphToken(params.scopes));

View file

@ -25,6 +25,9 @@ export * from './types/mutations';
export * from './types/queries';
export * from './types/runs';
export * from './types/web';
export * from './types/graph';
/* access permissions */
export * from './accessPermissions';
/* query/mutation keys */
export * from './keys';
/* api call helpers */

View file

@ -50,6 +50,10 @@ export enum QueryKeys {
banner = 'banner',
/* Memories */
memories = 'memories',
principalSearch = 'principalSearch',
accessRoles = 'accessRoles',
resourcePermissions = 'resourcePermissions',
effectivePermissions = 'effectivePermissions',
graphToken = 'graphToken',
}

View file

@ -9,9 +9,13 @@ import { defaultOrderQuery } from '../types/assistants';
import { MCPServerConnectionStatusResponse } from '../types/queries';
import * as dataService from '../data-service';
import * as m from '../types/mutations';
import * as q from '../types/queries';
import { QueryKeys } from '../keys';
import * as s from '../schemas';
import * as t from '../types';
import * as permissions from '../accessPermissions';
export { hasPermissions } from '../accessPermissions';
export const useGetSharedMessages = (
shareId: string,
@ -382,6 +386,106 @@ export const useUpdateFeedbackMutation = (
);
};
export const useSearchPrincipalsQuery = (
params: q.PrincipalSearchParams,
config?: UseQueryOptions<q.PrincipalSearchResponse>,
): QueryObserverResult<q.PrincipalSearchResponse> => {
return useQuery<q.PrincipalSearchResponse>(
[QueryKeys.principalSearch, params],
() => dataService.searchPrincipals(params),
{
enabled: !!params.q && params.q.length >= 2,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
staleTime: 30000,
...config,
},
);
};
export const useGetAccessRolesQuery = (
resourceType: string,
config?: UseQueryOptions<q.AccessRolesResponse>,
): QueryObserverResult<q.AccessRolesResponse> => {
return useQuery<q.AccessRolesResponse>(
[QueryKeys.accessRoles, resourceType],
() => dataService.getAccessRoles(resourceType),
{
enabled: !!resourceType,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
staleTime: 5 * 60 * 1000, // Cache for 5 minutes
...config,
},
);
};
export const useGetResourcePermissionsQuery = (
resourceType: string,
resourceId: string,
config?: UseQueryOptions<permissions.TGetResourcePermissionsResponse>,
): QueryObserverResult<permissions.TGetResourcePermissionsResponse> => {
return useQuery<permissions.TGetResourcePermissionsResponse>(
[QueryKeys.resourcePermissions, resourceType, resourceId],
() => dataService.getResourcePermissions(resourceType, resourceId),
{
enabled: !!resourceType && !!resourceId,
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
staleTime: 2 * 60 * 1000, // Cache for 2 minutes
...config,
},
);
};
export const useUpdateResourcePermissionsMutation = (): UseMutationResult<
permissions.TUpdateResourcePermissionsResponse,
Error,
{
resourceType: string;
resourceId: string;
data: permissions.TUpdateResourcePermissionsRequest;
}
> => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: ({ resourceType, resourceId, data }) =>
dataService.updateResourcePermissions(resourceType, resourceId, data),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({
queryKey: [QueryKeys.accessRoles, variables.resourceType],
});
queryClient.invalidateQueries({
queryKey: [QueryKeys.resourcePermissions, variables.resourceType, variables.resourceId],
});
queryClient.invalidateQueries({
queryKey: [QueryKeys.effectivePermissions, variables.resourceType, variables.resourceId],
});
},
});
};
export const useGetEffectivePermissionsQuery = (
resourceType: string,
resourceId: string,
config?: UseQueryOptions<permissions.TEffectivePermissionsResponse>,
): QueryObserverResult<permissions.TEffectivePermissionsResponse> => {
return useQuery<permissions.TEffectivePermissionsResponse>({
queryKey: [QueryKeys.effectivePermissions, resourceType, resourceId],
queryFn: () => dataService.getEffectivePermissions(resourceType, resourceId),
enabled: !!resourceType && !!resourceId,
refetchOnWindowFocus: false,
staleTime: 30000,
...config,
});
};
export const useMCPServerConnectionStatusQuery = (
serverName: string,
config?: UseQueryOptions<MCPServerConnectionStatusResponse>,

View file

@ -177,11 +177,17 @@ export const defaultAgentFormValues = {
provider: {},
projectIds: [],
artifacts: '',
/** @deprecated Use ACL permissions instead */
isCollaborative: false,
recursion_limit: undefined,
[Tools.execute_code]: false,
[Tools.file_search]: false,
[Tools.web_search]: false,
category: 'general',
support_contact: {
name: '',
email: '',
},
};
export const ImageVisionTool: FunctionTool = {

View file

@ -161,6 +161,11 @@ export type TCategory = {
label: string;
};
export type TMarketplaceCategory = TCategory & {
count: number;
description?: string;
};
export type TError = {
message: string;
code?: number | string;

View file

@ -198,6 +198,7 @@ export interface AgentFileResource extends AgentBaseResource {
}
export type Agent = {
_id?: string;
id: string;
name: string | null;
author?: string | null;
@ -217,6 +218,7 @@ export type Agent = {
model: string | null;
model_parameters: AgentModelParameters;
conversation_starters?: string[];
/** @deprecated Use ACL permissions instead */
isCollaborative?: boolean;
tool_resources?: AgentToolResources;
agent_ids?: string[];
@ -224,6 +226,7 @@ export type Agent = {
hide_sequential_outputs?: boolean;
artifacts?: ArtifactModes;
recursion_limit?: number;
isPublic?: boolean;
version?: number;
};

View file

@ -0,0 +1,145 @@
/**
* Microsoft Graph API type definitions
* Based on Microsoft Graph REST API v1.0 documentation
*/
/**
* Person type information from Microsoft Graph People API
*/
export interface TGraphPersonType {
/** Classification of the entity: "Person" or "Group" */
class: 'Person' | 'Group';
/** Specific subtype: e.g., "OrganizationUser", "UnifiedGroup" */
subclass: string;
}
/**
* Scored email address from Microsoft Graph People API
*/
export interface TGraphScoredEmailAddress {
/** Email address */
address: string;
/** Relevance score (0.0 to 1.0) */
relevanceScore: number;
}
/**
* Phone number from Microsoft Graph API
*/
export interface TGraphPhone {
/** Type of phone number */
type: string;
/** Phone number */
number: string;
}
/**
* Person/Contact result from Microsoft Graph /me/people endpoint
*/
export interface TGraphPerson {
/** Unique identifier */
id: string;
/** Display name */
displayName: string;
/** Given name (first name) */
givenName?: string;
/** Surname (last name) */
surname?: string;
/** User principal name */
userPrincipalName?: string;
/** Job title */
jobTitle?: string;
/** Department */
department?: string;
/** Company name */
companyName?: string;
/** Primary email address */
mail?: string;
/** Scored email addresses with relevance */
scoredEmailAddresses?: TGraphScoredEmailAddress[];
/** Person type classification */
personType?: TGraphPersonType;
/** Phone numbers */
phones?: TGraphPhone[];
}
/**
* User result from Microsoft Graph /users endpoint
*/
export interface TGraphUser {
/** Unique identifier */
id: string;
/** Display name */
displayName: string;
/** Given name (first name) */
givenName?: string;
/** Surname (last name) */
surname?: string;
/** User principal name */
userPrincipalName: string;
/** Primary email address */
mail?: string;
/** Job title */
jobTitle?: string;
/** Department */
department?: string;
/** Office location */
officeLocation?: string;
/** Business phone numbers */
businessPhones?: string[];
/** Mobile phone number */
mobilePhone?: string;
}
/**
* Group result from Microsoft Graph /groups endpoint
*/
export interface TGraphGroup {
/** Unique identifier */
id: string;
/** Display name */
displayName: string;
/** Group email address */
mail?: string;
/** Mail nickname */
mailNickname?: string;
/** Group description */
description?: string;
/** Group types (e.g., ["Unified"] for Microsoft 365 groups) */
groupTypes?: string[];
/** Whether group is mail-enabled */
mailEnabled?: boolean;
/** Whether group is security-enabled */
securityEnabled?: boolean;
/** Resource provisioning options */
resourceProvisioningOptions?: string[];
}
/**
* Response wrapper for Microsoft Graph API list endpoints
*/
export interface TGraphListResponse<T> {
/** Array of results */
value: T[];
/** OData context */
'@odata.context'?: string;
/** Next page link */
'@odata.nextLink'?: string;
/** Count of results (if requested) */
'@odata.count'?: number;
}
/**
* Response from /me/people endpoint
*/
export type TGraphPeopleResponse = TGraphListResponse<TGraphPerson>;
/**
* Response from /users endpoint
*/
export type TGraphUsersResponse = TGraphListResponse<TGraphUser>;
/**
* Response from /groups endpoint
*/
export type TGraphGroupsResponse = TGraphListResponse<TGraphGroup>;

View file

@ -125,6 +125,47 @@ export type MemoriesResponse = {
usagePercentage: number | null;
};
export type PrincipalSearchParams = {
q: string;
limit?: number;
type?: 'user' | 'group';
};
export type PrincipalSearchResult = {
id?: string | null;
type: 'user' | 'group';
name: string;
email?: string;
username?: string;
avatar?: string;
provider?: string;
source: 'local' | 'entra';
memberCount?: number;
description?: string;
idOnTheSource?: string;
};
export type PrincipalSearchResponse = {
query: string;
limit: number;
type?: 'user' | 'group';
results: PrincipalSearchResult[];
count: number;
sources: {
local: number;
entra: number;
};
};
export type AccessRole = {
accessRoleId: string;
name: string;
description: string;
permBits: number;
};
export type AccessRolesResponse = AccessRole[];
export interface MCPServerStatus {
requiresOAuth: boolean;
connectionState: 'disconnected' | 'connecting' | 'connected' | 'error';