diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index e0a2097919b..57f6f31d4d9 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -4172,6 +4172,8 @@ class TeamAccessGroupModelGrant(LiteLLMPydanticObjectBase): access_group_id: str access_group_name: str models: tuple[str, ...] + mcp_server_ids: tuple[str, ...] = () + agent_ids: tuple[str, ...] = () class TeamInfoResponseObjectTeamTable(LiteLLM_TeamTable): diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 714cf252e69..72cdc75c29e 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -4318,6 +4318,8 @@ async def _resolve_team_access_group_resources( access_group_id=group.access_group_id, access_group_name=group.access_group_name, models=tuple(group.access_model_names or ()), + mcp_server_ids=tuple(group.access_mcp_server_ids or ()), + agent_ids=tuple(group.access_agent_ids or ()), ) for group in resolved_groups ), diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py index 30b2ab86b9a..5bb7ceb8fb1 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_endpoints.py @@ -9897,11 +9897,11 @@ class TestResolveTeamAccessGroupResources: assert resolved.access_group_mcp_server_ids == ["mcp-1"] assert resolved.access_group_agent_ids == ["agent-1"] assert [ - (d.access_group_id, d.access_group_name, d.models) + (d.access_group_id, d.access_group_name, d.models, d.mcp_server_ids, d.agent_ids) for d in (resolved.access_group_details or []) ] == [ - ("ag-1", "shared-models", ("gpt-4", "claude-3")), - ("ag-2", "extra-models", ("claude-3", "gemini")), + ("ag-1", "shared-models", ("gpt-4", "claude-3"), ("mcp-1",), ()), + ("ag-2", "extra-models", ("claude-3", "gemini"), (), ("agent-1",)), ] @pytest.mark.asyncio diff --git a/ui/litellm-dashboard/src/components/object_permissions_view.tsx b/ui/litellm-dashboard/src/components/object_permissions_view.tsx index 7d1c4ce4ac9..e90645a9e9f 100644 --- a/ui/litellm-dashboard/src/components/object_permissions_view.tsx +++ b/ui/litellm-dashboard/src/components/object_permissions_view.tsx @@ -3,11 +3,12 @@ import VectorStorePermissions from "./permissions/VectorStorePermissions"; import MCPServerPermissions from "./permissions/MCPServerPermissions"; import AgentPermissions from "./permissions/AgentPermissions"; import type { ObjectPermission } from "./object_permission_types"; +import type { InheritedGrant } from "./permissions/inheritedGrants"; interface ObjectPermissionsViewProps { objectPermission?: ObjectPermission | null; - inheritedMcpServerIds?: string[]; - inheritedAgentIds?: string[]; + inheritedMcpServers?: InheritedGrant[]; + inheritedAgents?: InheritedGrant[]; variant?: "card" | "inline"; className?: string; accessToken?: string | null; @@ -15,8 +16,8 @@ interface ObjectPermissionsViewProps { export function ObjectPermissionsView({ objectPermission, - inheritedMcpServerIds = [], - inheritedAgentIds = [], + inheritedMcpServers = [], + inheritedAgents = [], variant = "card", className = "", accessToken, @@ -38,13 +39,13 @@ export function ObjectPermissionsView({ mcpAccessGroups={mcpAccessGroups} mcpToolPermissions={mcpToolPermissions} mcpToolsets={mcpToolsets} - inheritedMcpServers={inheritedMcpServerIds} + inheritedMcpServers={inheritedMcpServers} accessToken={accessToken} />
diff --git a/ui/litellm-dashboard/src/components/permissions/AgentPermissions.test.tsx b/ui/litellm-dashboard/src/components/permissions/AgentPermissions.test.tsx index 14fe6177ac5..01c7b60b8a5 100644 --- a/ui/litellm-dashboard/src/components/permissions/AgentPermissions.test.tsx +++ b/ui/litellm-dashboard/src/components/permissions/AgentPermissions.test.tsx @@ -1,5 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import AgentPermissions from "./AgentPermissions"; import * as networking from "../networking"; @@ -13,31 +14,51 @@ describe("AgentPermissions", () => { vi.clearAllMocks(); }); - it("lists agents inherited from access groups with an Inherited tag and counts them", async () => { + it("lists agents inherited from access groups, counts them, and names the groups on hover", async () => { + const user = userEvent.setup(); vi.mocked(networking.getAgentsList).mockResolvedValue({ agents: [{ agent_id: agentId, agent_name: "support_agent" }], }); - render(); + render( + , + ); - expect(await screen.findByText(/support_agent/)).toBeInTheDocument(); - expect(screen.getByText("Inherited")).toBeInTheDocument(); + const row = await screen.findByText(/support_agent/); expect(screen.getByText("1")).toBeInTheDocument(); expect(screen.queryByText("No agents or access groups configured")).not.toBeInTheDocument(); expect(networking.getAgentsList).toHaveBeenCalledWith(accessToken); + + await user.hover(row); + expect( + await screen.findByText(`Granted via access groups platform-tools, support. Full ID: ${agentId}`), + ).toBeInTheDocument(); }); it("does not double-list an agent that is both granted directly and inherited", async () => { + const user = userEvent.setup(); vi.mocked(networking.getAgentsList).mockResolvedValue({ agents: [{ agent_id: agentId, agent_name: "support_agent" }], }); - render(); + render( + , + ); - expect(await screen.findByText(/support_agent/)).toBeInTheDocument(); + const row = await screen.findByText(/support_agent/); expect(screen.getAllByText(/support_agent/)).toHaveLength(1); - expect(screen.queryByText("Inherited")).not.toBeInTheDocument(); expect(screen.getByText("1")).toBeInTheDocument(); + + await user.hover(row); + expect(await screen.findByText(`Full ID: ${agentId}`)).toBeInTheDocument(); }); it("shows the empty state when nothing is granted directly or inherited", () => { diff --git a/ui/litellm-dashboard/src/components/permissions/AgentPermissions.tsx b/ui/litellm-dashboard/src/components/permissions/AgentPermissions.tsx index 11f6c414922..d1ca25c975b 100644 --- a/ui/litellm-dashboard/src/components/permissions/AgentPermissions.tsx +++ b/ui/litellm-dashboard/src/components/permissions/AgentPermissions.tsx @@ -3,6 +3,7 @@ import { UserGroupIcon } from "@heroicons/react/outline"; import { Badge } from "@/components/ui/badge"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { getAgentsList } from "../networking"; +import { InheritedGrant, inheritedGrantTooltip } from "./inheritedGrants"; interface Agent { agent_id: string; @@ -14,12 +15,10 @@ interface Agent { interface AgentPermissionsProps { agents: string[]; agentAccessGroups?: string[]; - inheritedAgents?: string[]; + inheritedAgents?: InheritedGrant[]; accessToken?: string | null; } -const INHERITED_AGENT_TOOLTIP = "Granted through one of the team's access groups"; - export function AgentPermissions({ agents, agentAccessGroups = [], @@ -27,7 +26,7 @@ export function AgentPermissions({ accessToken, }: AgentPermissionsProps) { const [agentDetails, setAgentDetails] = useState([]); - const inheritedOnlyAgents = inheritedAgents.filter((agent) => !agents.includes(agent)); + const inheritedOnlyAgents = inheritedAgents.filter((grant) => !agents.includes(grant.id)); const agentIdCount = agents.length + inheritedOnlyAgents.length; // Fetch agent details when component mounts @@ -58,9 +57,9 @@ export function AgentPermissions({ }; const mergedItems = [ - ...agents.map((agent) => ({ type: "agent", value: agent, inherited: false })), - ...inheritedOnlyAgents.map((agent) => ({ type: "agent", value: agent, inherited: true })), - ...agentAccessGroups.map((group) => ({ type: "accessGroup", value: group, inherited: false })), + ...agents.map((agent) => ({ type: "agent", value: agent, tooltip: `Full ID: ${agent}` })), + ...inheritedOnlyAgents.map((grant) => ({ type: "agent", value: grant.id, tooltip: inheritedGrantTooltip(grant) })), + ...agentAccessGroups.map((group) => ({ type: "accessGroup", value: group, tooltip: "" })), ]; const totalCount = mergedItems.length; @@ -86,17 +85,8 @@ export function AgentPermissions({ {getAgentDisplayName(item.value)} - {item.inherited && ( - - Inherited - - )} - - {item.inherited - ? `${INHERITED_AGENT_TOOLTIP}. Full ID: ${item.value}` - : `Full ID: ${item.value}`} - + {item.tooltip} ) : ( diff --git a/ui/litellm-dashboard/src/components/permissions/MCPServerPermissions.test.tsx b/ui/litellm-dashboard/src/components/permissions/MCPServerPermissions.test.tsx index fe7e3a7b971..78ede9865a4 100644 --- a/ui/litellm-dashboard/src/components/permissions/MCPServerPermissions.test.tsx +++ b/ui/litellm-dashboard/src/components/permissions/MCPServerPermissions.test.tsx @@ -407,7 +407,8 @@ describe("MCPServerPermissions", () => { await waitFor(() => expect(screen.getByText("Blocked")).toHaveAttribute("data-variant", "destructive")); }); - it("lists servers inherited from access groups with an Inherited tag and counts them", async () => { + it("lists servers inherited from access groups, counts them, and names the group on hover", async () => { + const user = userEvent.setup(); vi.mocked(networking.fetchMCPServers).mockResolvedValue([ { server_id: mockServerId1, server_name: mockServerName1, alias: mockServerName1 }, ]); @@ -417,19 +418,24 @@ describe("MCPServerPermissions", () => { mcpServers={[]} mcpAccessGroups={[]} mcpToolPermissions={{}} - inheritedMcpServers={[mockServerId1]} + inheritedMcpServers={[{ id: mockServerId1, accessGroupNames: ["platform-tools"] }]} accessToken={mockAccessToken} />, ); - expect(await screen.findByText(/DW_MCP/)).toBeInTheDocument(); - expect(screen.getByText("Inherited")).toBeInTheDocument(); + const row = await screen.findByText(/DW_MCP/); expect(screen.getByText("1")).toBeInTheDocument(); expect(screen.queryByText("No MCP servers, access groups, or toolsets configured")).not.toBeInTheDocument(); expect(networking.fetchMCPServers).toHaveBeenCalledWith(mockAccessToken); + + await user.hover(row); + expect( + await screen.findByText(`Granted via access group platform-tools. Full ID: ${mockServerId1}`), + ).toBeInTheDocument(); }); it("does not double-list a server that is both granted directly and inherited", async () => { + const user = userEvent.setup(); vi.mocked(networking.fetchMCPServers).mockResolvedValue([ { server_id: mockServerId2, server_name: mockServerName2, alias: mockServerName2 }, ]); @@ -439,14 +445,16 @@ describe("MCPServerPermissions", () => { mcpServers={[mockServerId2]} mcpAccessGroups={[]} mcpToolPermissions={{}} - inheritedMcpServers={[mockServerId2]} + inheritedMcpServers={[{ id: mockServerId2, accessGroupNames: ["platform-tools"] }]} accessToken={mockAccessToken} />, ); - expect(await screen.findByText(/Test Server/)).toBeInTheDocument(); + const row = await screen.findByText(/Test Server/); expect(screen.getAllByText(/Test Server/)).toHaveLength(1); - expect(screen.queryByText("Inherited")).not.toBeInTheDocument(); expect(screen.getByText("1")).toBeInTheDocument(); + + await user.hover(row); + expect(await screen.findByText(`Full ID: ${mockServerId2}`)).toBeInTheDocument(); }); }); diff --git a/ui/litellm-dashboard/src/components/permissions/MCPServerPermissions.tsx b/ui/litellm-dashboard/src/components/permissions/MCPServerPermissions.tsx index 52199dab3c7..65f210addfa 100644 --- a/ui/litellm-dashboard/src/components/permissions/MCPServerPermissions.tsx +++ b/ui/litellm-dashboard/src/components/permissions/MCPServerPermissions.tsx @@ -5,18 +5,17 @@ import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip import { fetchMCPServers, fetchMCPToolsets } from "../networking"; import { MCPServer, MCPToolset } from "../mcp_tools/types"; import { ALL_PROXY_MCP_SERVERS_SENTINEL, NO_MCP_SERVERS_SENTINEL } from "../mcp_tools/constants"; +import { InheritedGrant, inheritedGrantTooltip } from "./inheritedGrants"; interface MCPServerPermissionsProps { mcpServers: string[]; mcpAccessGroups?: string[]; mcpToolPermissions?: Record; mcpToolsets?: string[]; - inheritedMcpServers?: string[]; + inheritedMcpServers?: InheritedGrant[]; accessToken?: string | null; } -const INHERITED_MCP_SERVER_TOOLTIP = "Granted through one of the team's access groups"; - export function MCPServerPermissions({ mcpServers, mcpAccessGroups = [], @@ -57,8 +56,8 @@ export function MCPServerPermissions({ const directServerIds = mcpServers.filter( (server) => server !== NO_MCP_SERVERS_SENTINEL && server !== ALL_PROXY_MCP_SERVERS_SENTINEL, ); - const inheritedOnlyServerIds = inheritedMcpServers.filter((server) => !mcpServers.includes(server)); - const serverIdCount = directServerIds.length + inheritedOnlyServerIds.length; + const inheritedOnlyServers = inheritedMcpServers.filter((grant) => !mcpServers.includes(grant.id)); + const serverIdCount = directServerIds.length + inheritedOnlyServers.length; // Fetch MCP server details when component mounts useEffect(() => { @@ -109,9 +108,13 @@ export function MCPServerPermissions({ const grantsAllProxyMcpServers = mcpServers.includes(ALL_PROXY_MCP_SERVERS_SENTINEL); const mergedItems = [ - ...directServerIds.map((server) => ({ type: "server", value: server, inherited: false })), - ...inheritedOnlyServerIds.map((server) => ({ type: "server", value: server, inherited: true })), - ...mcpAccessGroups.map((group) => ({ type: "accessGroup", value: group, inherited: false })), + ...directServerIds.map((server) => ({ type: "server", value: server, tooltip: `Full ID: ${server}` })), + ...inheritedOnlyServers.map((grant) => ({ + type: "server", + value: grant.id, + tooltip: inheritedGrantTooltip(grant), + })), + ...mcpAccessGroups.map((group) => ({ type: "accessGroup", value: group, tooltip: "" })), ]; const totalCount = mergedItems.length + mcpToolsets.length; @@ -160,17 +163,8 @@ export function MCPServerPermissions({ {getMCPServerDisplayName(item.value)} - {item.inherited && ( - - Inherited - - )} - - {item.inherited - ? `${INHERITED_MCP_SERVER_TOOLTIP}. Full ID: ${item.value}` - : `Full ID: ${item.value}`} - + {item.tooltip} ) : (
diff --git a/ui/litellm-dashboard/src/components/permissions/inheritedGrants.test.ts b/ui/litellm-dashboard/src/components/permissions/inheritedGrants.test.ts new file mode 100644 index 00000000000..766f8f23f0b --- /dev/null +++ b/ui/litellm-dashboard/src/components/permissions/inheritedGrants.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; +import { computeInheritedGrants, inheritedGrantTooltip } from "./inheritedGrants"; +import { TeamAccessGroupModelGrant } from "../team/teamModelAccess"; + +const GRANTS: TeamAccessGroupModelGrant[] = [ + { access_group_id: "ag-1", access_group_name: "platform-tools", models: [], mcp_server_ids: ["mcp-1", "mcp-2"] }, + { + access_group_id: "ag-2", + access_group_name: "support", + models: [], + mcp_server_ids: ["mcp-2"], + agent_ids: ["agent-1"], + }, +]; + +describe("computeInheritedGrants", () => { + it("attributes each id to every group that grants it, in group order", () => { + expect(computeInheritedGrants(["mcp-1", "mcp-2"], GRANTS, (g) => g.mcp_server_ids)).toEqual([ + { id: "mcp-1", accessGroupNames: ["platform-tools"] }, + { id: "mcp-2", accessGroupNames: ["platform-tools", "support"] }, + ]); + }); + + it("keeps ids the flat list carries but no group detail explains, with no group names", () => { + expect(computeInheritedGrants(["agent-1", "agent-legacy"], GRANTS, (g) => g.agent_ids)).toEqual([ + { id: "agent-1", accessGroupNames: ["support"] }, + { id: "agent-legacy", accessGroupNames: [] }, + ]); + }); + + it("falls back to the group details when the flat list is missing, without duplicates", () => { + expect(computeInheritedGrants(undefined, GRANTS, (g) => g.mcp_server_ids).map((g) => g.id)).toEqual([ + "mcp-1", + "mcp-2", + ]); + }); + + it("returns nothing when neither source has ids", () => { + expect(computeInheritedGrants(undefined, undefined, (g) => g.agent_ids)).toEqual([]); + }); +}); + +describe("inheritedGrantTooltip", () => { + it("names a single group", () => { + expect(inheritedGrantTooltip({ id: "mcp-1", accessGroupNames: ["platform-tools"] })).toBe( + "Granted via access group platform-tools. Full ID: mcp-1", + ); + }); + + it("lists several groups", () => { + expect(inheritedGrantTooltip({ id: "mcp-2", accessGroupNames: ["platform-tools", "support"] })).toBe( + "Granted via access groups platform-tools, support. Full ID: mcp-2", + ); + }); + + it("stays generic when the proxy did not say which group granted it", () => { + expect(inheritedGrantTooltip({ id: "agent-legacy", accessGroupNames: [] })).toBe( + "Granted via an access group. Full ID: agent-legacy", + ); + }); +}); diff --git a/ui/litellm-dashboard/src/components/permissions/inheritedGrants.ts b/ui/litellm-dashboard/src/components/permissions/inheritedGrants.ts new file mode 100644 index 00000000000..fb78de5ed77 --- /dev/null +++ b/ui/litellm-dashboard/src/components/permissions/inheritedGrants.ts @@ -0,0 +1,26 @@ +import { describeGroups, TeamAccessGroupModelGrant } from "../team/teamModelAccess"; + +export interface InheritedGrant { + id: string; + accessGroupNames: string[]; +} + +export function computeInheritedGrants( + ids: string[] | undefined, + grants: TeamAccessGroupModelGrant[] | undefined, + idsOf: (grant: TeamAccessGroupModelGrant) => string[] | undefined, +): InheritedGrant[] { + const known = grants ?? []; + const allIds = [...new Set([...(ids ?? []), ...known.flatMap((grant) => idsOf(grant) ?? [])])]; + return allIds.map((id) => ({ + id, + accessGroupNames: known + .filter((grant) => (idsOf(grant) ?? []).includes(id)) + .map((grant) => grant.access_group_name), + })); +} + +export const inheritedGrantTooltip = (grant: InheritedGrant): string => { + const source = grant.accessGroupNames.length > 0 ? describeGroups(grant.accessGroupNames) : "an access group"; + return `Granted via ${source}. Full ID: ${grant.id}`; +}; diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx index 40a3d2ada1a..ae78ac06a9c 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx @@ -305,7 +305,8 @@ describe("TeamInfoView", () => { ); }); - it("shows MCP servers and agents inherited from access groups in the Object Permissions card", async () => { + it("shows MCP servers and agents inherited from access groups in the Object Permissions card, naming the group on hover", async () => { + const user = userEvent.setup(); vi.mocked(networking.fetchMCPServers).mockResolvedValue([ { server_id: "mcp-github-1234", server_name: "github", alias: "github" }, ]); @@ -318,16 +319,33 @@ describe("TeamInfoView", () => { access_group_ids: ["ag-1"], access_group_mcp_server_ids: ["mcp-github-1234"], access_group_agent_ids: ["agent-support-5678"], + access_group_details: [ + { + access_group_id: "ag-1", + access_group_name: "platform-tools", + models: [], + mcp_server_ids: ["mcp-github-1234"], + agent_ids: ["agent-support-5678"], + }, + ], }), ); renderWithProviders(); - expect(await screen.findByText(/github \(mcp\.\.\.1234\)/)).toBeInTheDocument(); - expect(await screen.findByText(/support_agent \(age\.\.\.5678\)/)).toBeInTheDocument(); - expect(screen.getAllByText("Inherited")).toHaveLength(2); + const serverRow = await screen.findByText(/github \(mcp\.\.\.1234\)/); + const agentRow = await screen.findByText(/support_agent \(age\.\.\.5678\)/); expect(screen.queryByText("No MCP servers, access groups, or toolsets configured")).not.toBeInTheDocument(); expect(screen.queryByText("No agents or access groups configured")).not.toBeInTheDocument(); + + await user.hover(serverRow); + expect( + await screen.findByText("Granted via access group platform-tools. Full ID: mcp-github-1234"), + ).toBeInTheDocument(); + await user.hover(agentRow); + expect( + await screen.findByText("Granted via access group platform-tools. Full ID: agent-support-5678"), + ).toBeInTheDocument(); }); it("keeps the all-proxy-models badge non-clickable", async () => { diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index 7ebef0691e6..3f6d6a96972 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -58,6 +58,7 @@ import { TeamModelBadge, TeamModelBadgeKind, } from "./teamModelAccess"; +import { computeInheritedGrants } from "../permissions/inheritedGrants"; import MetadataKeyValueFields, { metadataObjectToPairs, metadataPairsSchema, @@ -936,6 +937,17 @@ const TeamInfoView: React.FC = ({ const { team_info: info } = teamData; + const inheritedMcpServers = computeInheritedGrants( + info.access_group_mcp_server_ids, + info.access_group_details, + (grant) => grant.mcp_server_ids, + ); + const inheritedAgents = computeInheritedGrants( + info.access_group_agent_ids, + info.access_group_details, + (grant) => grant.agent_ids, + ); + const initialKillSwitchOn = info.metadata?.disable_global_guardrails === true; const allGuardrails: GuardrailListItem[] = guardrailsData?.guardrails ?? []; @@ -1035,8 +1047,8 @@ const TeamInfoView: React.FC = ({ @@ -1889,8 +1901,8 @@ const TeamInfoView: React.FC = ({ 0 ? models : [NO_DEFAULT_MODELS]; } -const describeGroups = (names: string[]): string => +export const describeGroups = (names: string[]): string => names.length > 1 ? `access groups ${names.join(", ")}` : `access group ${names[0]}`; export function computeTeamModelBadges(