From c493fc855c85041e331f77fc53ad39950089e8f2 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Tue, 1 Sep 2026 15:46:14 -0700 Subject: [PATCH 1/4] fix(ui): show MCP servers and agents inherited from access groups on the team overview The team Overview and Settings tabs fed only object_permission into the Object Permissions card, so a team whose access group grants MCP servers or agents read "MCP Servers 0" and "Agents 0" while the Models card next to it already listed the inherited models. /team/info has returned access_group_mcp_server_ids and access_group_agent_ids for a while, nothing in the dashboard read them. ObjectPermissionsView now accepts the inherited ids and MCPServerPermissions / AgentPermissions merge them into their lists with an Inherited tag, deduped against direct grants, so an admin can tell a group grant from a direct one. Claude-Session: https://claude.ai/code/session_01QvQzYztinxj8ZuD5YxbVdL --- .../components/object_permissions_view.tsx | 12 ++++- .../permissions/AgentPermissions.test.tsx | 50 +++++++++++++++++++ .../permissions/AgentPermissions.tsx | 34 ++++++++++--- .../permissions/MCPServerPermissions.test.tsx | 43 ++++++++++++++++ .../permissions/MCPServerPermissions.tsx | 34 ++++++++++--- .../src/components/team/TeamInfo.test.tsx | 28 +++++++++++ .../src/components/team/TeamInfo.tsx | 10 +++- 7 files changed, 194 insertions(+), 17 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/permissions/AgentPermissions.test.tsx diff --git a/ui/litellm-dashboard/src/components/object_permissions_view.tsx b/ui/litellm-dashboard/src/components/object_permissions_view.tsx index 011791fa18d..7d1c4ce4ac9 100644 --- a/ui/litellm-dashboard/src/components/object_permissions_view.tsx +++ b/ui/litellm-dashboard/src/components/object_permissions_view.tsx @@ -6,6 +6,8 @@ import type { ObjectPermission } from "./object_permission_types"; interface ObjectPermissionsViewProps { objectPermission?: ObjectPermission | null; + inheritedMcpServerIds?: string[]; + inheritedAgentIds?: string[]; variant?: "card" | "inline"; className?: string; accessToken?: string | null; @@ -13,6 +15,8 @@ interface ObjectPermissionsViewProps { export function ObjectPermissionsView({ objectPermission, + inheritedMcpServerIds = [], + inheritedAgentIds = [], variant = "card", className = "", accessToken, @@ -34,9 +38,15 @@ export function ObjectPermissionsView({ mcpAccessGroups={mcpAccessGroups} mcpToolPermissions={mcpToolPermissions} mcpToolsets={mcpToolsets} + inheritedMcpServers={inheritedMcpServerIds} + accessToken={accessToken} + /> + -

Search tools

{searchTools.length === 0 ? ( diff --git a/ui/litellm-dashboard/src/components/permissions/AgentPermissions.test.tsx b/ui/litellm-dashboard/src/components/permissions/AgentPermissions.test.tsx new file mode 100644 index 00000000000..14fe6177ac5 --- /dev/null +++ b/ui/litellm-dashboard/src/components/permissions/AgentPermissions.test.tsx @@ -0,0 +1,50 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import AgentPermissions from "./AgentPermissions"; +import * as networking from "../networking"; + +vi.mock("../networking"); + +describe("AgentPermissions", () => { + const accessToken = "test-token"; + const agentId = "90337622-756e-4f25-98f0-01fc8174aa24"; + + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("lists agents inherited from access groups with an Inherited tag and counts them", async () => { + vi.mocked(networking.getAgentsList).mockResolvedValue({ + agents: [{ agent_id: agentId, agent_name: "support_agent" }], + }); + + render(); + + expect(await screen.findByText(/support_agent/)).toBeInTheDocument(); + expect(screen.getByText("Inherited")).toBeInTheDocument(); + expect(screen.getByText("1")).toBeInTheDocument(); + expect(screen.queryByText("No agents or access groups configured")).not.toBeInTheDocument(); + expect(networking.getAgentsList).toHaveBeenCalledWith(accessToken); + }); + + it("does not double-list an agent that is both granted directly and inherited", async () => { + vi.mocked(networking.getAgentsList).mockResolvedValue({ + agents: [{ agent_id: agentId, agent_name: "support_agent" }], + }); + + render(); + + expect(await screen.findByText(/support_agent/)).toBeInTheDocument(); + expect(screen.getAllByText(/support_agent/)).toHaveLength(1); + expect(screen.queryByText("Inherited")).not.toBeInTheDocument(); + expect(screen.getByText("1")).toBeInTheDocument(); + }); + + it("shows the empty state when nothing is granted directly or inherited", () => { + render(); + + expect(screen.getByText("No agents or access groups configured")).toBeInTheDocument(); + expect(screen.getByText("0")).toBeInTheDocument(); + expect(networking.getAgentsList).not.toHaveBeenCalled(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/permissions/AgentPermissions.tsx b/ui/litellm-dashboard/src/components/permissions/AgentPermissions.tsx index d84bd1834f8..21bacc23ce1 100644 --- a/ui/litellm-dashboard/src/components/permissions/AgentPermissions.tsx +++ b/ui/litellm-dashboard/src/components/permissions/AgentPermissions.tsx @@ -14,16 +14,26 @@ interface Agent { interface AgentPermissionsProps { agents: string[]; agentAccessGroups?: string[]; + inheritedAgents?: string[]; accessToken?: string | null; } -export function AgentPermissions({ agents, agentAccessGroups = [], accessToken }: AgentPermissionsProps) { +const INHERITED_AGENT_TOOLTIP = "Granted through one of the team's access groups"; + +export function AgentPermissions({ + agents, + agentAccessGroups = [], + inheritedAgents = [], + accessToken, +}: AgentPermissionsProps) { const [agentDetails, setAgentDetails] = useState([]); + const inheritedOnlyAgents = inheritedAgents.filter((agent) => !agents.includes(agent)); + const agentIdCount = agents.length + inheritedOnlyAgents.length; // Fetch agent details when component mounts useEffect(() => { const fetchAgentDetails = async () => { - if (accessToken && agents.length > 0) { + if (accessToken && agentIdCount > 0) { try { const response = await getAgentsList(accessToken); if (response && response.agents && Array.isArray(response.agents)) { @@ -35,7 +45,7 @@ export function AgentPermissions({ agents, agentAccessGroups = [], accessToken } } }; fetchAgentDetails(); - }, [accessToken, agents.length]); + }, [accessToken, agentIdCount]); // Function to get display name for agent const getAgentDisplayName = (agentId: string) => { @@ -47,10 +57,11 @@ export function AgentPermissions({ agents, agentAccessGroups = [], accessToken } return agentId; }; - // Merge agents and access groups into one list + // Merge agents, inherited agents and access groups into one list const mergedItems = [ - ...agents.map((agent) => ({ type: "agent", value: agent })), - ...agentAccessGroups.map((group) => ({ type: "accessGroup", value: group })), + ...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 })), ]; const totalCount = mergedItems.length; @@ -76,8 +87,17 @@ export function AgentPermissions({ agents, agentAccessGroups = [], accessToken } {getAgentDisplayName(item.value)} + {item.inherited && ( + + Inherited + + )} - {`Full ID: ${item.value}`} + + {item.inherited + ? `${INHERITED_AGENT_TOOLTIP}. Full ID: ${item.value}` + : `Full ID: ${item.value}`} + ) : ( diff --git a/ui/litellm-dashboard/src/components/permissions/MCPServerPermissions.test.tsx b/ui/litellm-dashboard/src/components/permissions/MCPServerPermissions.test.tsx index c2df945f367..fe7e3a7b971 100644 --- a/ui/litellm-dashboard/src/components/permissions/MCPServerPermissions.test.tsx +++ b/ui/litellm-dashboard/src/components/permissions/MCPServerPermissions.test.tsx @@ -406,4 +406,47 @@ 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 () => { + vi.mocked(networking.fetchMCPServers).mockResolvedValue([ + { server_id: mockServerId1, server_name: mockServerName1, alias: mockServerName1 }, + ]); + + render( + , + ); + + expect(await screen.findByText(/DW_MCP/)).toBeInTheDocument(); + expect(screen.getByText("Inherited")).toBeInTheDocument(); + expect(screen.getByText("1")).toBeInTheDocument(); + expect(screen.queryByText("No MCP servers, access groups, or toolsets configured")).not.toBeInTheDocument(); + expect(networking.fetchMCPServers).toHaveBeenCalledWith(mockAccessToken); + }); + + it("does not double-list a server that is both granted directly and inherited", async () => { + vi.mocked(networking.fetchMCPServers).mockResolvedValue([ + { server_id: mockServerId2, server_name: mockServerName2, alias: mockServerName2 }, + ]); + + render( + , + ); + + expect(await screen.findByText(/Test Server/)).toBeInTheDocument(); + expect(screen.getAllByText(/Test Server/)).toHaveLength(1); + expect(screen.queryByText("Inherited")).not.toBeInTheDocument(); + expect(screen.getByText("1")).toBeInTheDocument(); + }); }); diff --git a/ui/litellm-dashboard/src/components/permissions/MCPServerPermissions.tsx b/ui/litellm-dashboard/src/components/permissions/MCPServerPermissions.tsx index f11bc6b9627..742409f3f8d 100644 --- a/ui/litellm-dashboard/src/components/permissions/MCPServerPermissions.tsx +++ b/ui/litellm-dashboard/src/components/permissions/MCPServerPermissions.tsx @@ -11,14 +11,18 @@ interface MCPServerPermissionsProps { mcpAccessGroups?: string[]; mcpToolPermissions?: Record; mcpToolsets?: string[]; + inheritedMcpServers?: string[]; accessToken?: string | null; } +const INHERITED_MCP_SERVER_TOOLTIP = "Granted through one of the team's access groups"; + export function MCPServerPermissions({ mcpServers, mcpAccessGroups = [], mcpToolPermissions = {}, mcpToolsets = [], + inheritedMcpServers = [], accessToken, }: MCPServerPermissionsProps) { const [mcpServerDetails, setMCPServerDetails] = useState([]); @@ -50,10 +54,16 @@ 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; + // Fetch MCP server details when component mounts useEffect(() => { const fetchMCPServerDetails = async () => { - if (accessToken && mcpServers.length > 0) { + if (accessToken && serverIdCount > 0) { try { const response = await fetchMCPServers(accessToken); if (response && Array.isArray(response)) { @@ -67,7 +77,7 @@ export function MCPServerPermissions({ } }; fetchMCPServerDetails(); - }, [accessToken, mcpServers.length]); + }, [accessToken, serverIdCount]); // Fetch toolset details useEffect(() => { @@ -98,12 +108,11 @@ export function MCPServerPermissions({ const blocksAllMcpServers = mcpServers.includes(NO_MCP_SERVERS_SENTINEL); const grantsAllProxyMcpServers = mcpServers.includes(ALL_PROXY_MCP_SERVERS_SENTINEL); - // Merge servers and access groups into one list + // Merge servers, inherited servers and access groups into one list const mergedItems = [ - ...mcpServers - .filter((server) => server !== NO_MCP_SERVERS_SENTINEL && server !== ALL_PROXY_MCP_SERVERS_SENTINEL) - .map((server) => ({ type: "server", value: server })), - ...mcpAccessGroups.map((group) => ({ type: "accessGroup", value: group })), + ...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 })), ]; const totalCount = mergedItems.length + mcpToolsets.length; @@ -152,8 +161,17 @@ export function MCPServerPermissions({ {getMCPServerDisplayName(item.value)} + {item.inherited && ( + + Inherited + + )} - {`Full ID: ${item.value}`} + + {item.inherited + ? `${INHERITED_MCP_SERVER_TOOLTIP}. Full ID: ${item.value}` + : `Full ID: ${item.value}`} + ) : (
diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx index ca1e0413dcb..40a3d2ada1a 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx @@ -38,6 +38,9 @@ vi.mock("@/components/networking", () => ({ organizationInfoCall: vi.fn(), getRouterSettingsCall: vi.fn().mockResolvedValue({ fields: [] }), getPassThroughEndpointsCall: vi.fn(), + fetchMCPServers: vi.fn().mockResolvedValue([]), + fetchMCPToolsets: vi.fn().mockResolvedValue([]), + getAgentsList: vi.fn().mockResolvedValue({ agents: [] }), })); const can = vi.fn(); @@ -302,6 +305,31 @@ describe("TeamInfoView", () => { ); }); + it("shows MCP servers and agents inherited from access groups in the Object Permissions card", async () => { + vi.mocked(networking.fetchMCPServers).mockResolvedValue([ + { server_id: "mcp-github-1234", server_name: "github", alias: "github" }, + ]); + vi.mocked(networking.getAgentsList).mockResolvedValue({ + agents: [{ agent_id: "agent-support-5678", agent_name: "support_agent" }], + }); + vi.mocked(networking.teamInfoCall).mockResolvedValue( + createMockTeamData({ + object_permission: null, + access_group_ids: ["ag-1"], + access_group_mcp_server_ids: ["mcp-github-1234"], + access_group_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); + expect(screen.queryByText("No MCP servers, access groups, or toolsets configured")).not.toBeInTheDocument(); + expect(screen.queryByText("No agents or access groups configured")).not.toBeInTheDocument(); + }); + it("keeps the all-proxy-models badge non-clickable", async () => { vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData({ models: ["all-proxy-models"] })); diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index 44f0d420fd6..7ebef0691e6 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -1033,7 +1033,13 @@ const TeamInfoView: React.FC = ({
- + = ({ Date: Tue, 1 Sep 2026 16:25:15 -0700 Subject: [PATCH 2/4] test(ui): query the screen in models page tests and drop restating comments The merge of #38872 into staging kept the destructured render queries in the models-and-endpoints page test, which pushes testing-library/prefer-screen-queries to 21 against a budget of 18 and fails frontend-lint for every PR on top of it. Two comments that only restated the list merge below them are gone as well. Claude-Session: https://claude.ai/code/session_01QvQzYztinxj8ZuD5YxbVdL --- .../app/(dashboard)/models-and-endpoints/page.test.tsx | 10 +++++----- .../src/components/permissions/AgentPermissions.tsx | 1 - .../components/permissions/MCPServerPermissions.tsx | 1 - 3 files changed, 5 insertions(+), 7 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.test.tsx index 1199b66621f..84a05113177 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.test.tsx @@ -111,17 +111,17 @@ describe("ModelsAndEndpointsPage", () => { // POST /model/new 403s a proxy_admin_viewer, so the form's tab must not render for one. it("hides the Add Model tab for a view-only admin session", () => { mockUseAuthorized.mockReturnValue(VIEW_ONLY_ADMIN); - const { getByRole, queryByRole } = renderPage(); - expect(queryByRole("tab", { name: "Add Model" })).not.toBeInTheDocument(); - expect(getByRole("tab", { name: "All Models" })).toBeInTheDocument(); + renderPage(); + expect(screen.queryByRole("tab", { name: "Add Model" })).not.toBeInTheDocument(); + expect(screen.getByRole("tab", { name: "All Models" })).toBeInTheDocument(); }); // Read parity: the Auto-Routers list stays reachable for a view-only admin; only the // create affordance inside it is withheld, which AutoRoutersTabPanel decides. it("keeps the Auto-Routers tab for a view-only admin session", () => { mockUseAuthorized.mockReturnValue(VIEW_ONLY_ADMIN); - const { getByRole } = renderPage(); - expect(getByRole("tab", { name: /Auto-Routers/ })).toBeInTheDocument(); + renderPage(); + expect(screen.getByRole("tab", { name: /Auto-Routers/ })).toBeInTheDocument(); }); // Auto-routers are excluded from the All Models table, so this tab is their home: the only diff --git a/ui/litellm-dashboard/src/components/permissions/AgentPermissions.tsx b/ui/litellm-dashboard/src/components/permissions/AgentPermissions.tsx index 21bacc23ce1..11f6c414922 100644 --- a/ui/litellm-dashboard/src/components/permissions/AgentPermissions.tsx +++ b/ui/litellm-dashboard/src/components/permissions/AgentPermissions.tsx @@ -57,7 +57,6 @@ export function AgentPermissions({ return agentId; }; - // Merge agents, inherited agents and access groups into one list const mergedItems = [ ...agents.map((agent) => ({ type: "agent", value: agent, inherited: false })), ...inheritedOnlyAgents.map((agent) => ({ type: "agent", value: agent, inherited: true })), diff --git a/ui/litellm-dashboard/src/components/permissions/MCPServerPermissions.tsx b/ui/litellm-dashboard/src/components/permissions/MCPServerPermissions.tsx index 742409f3f8d..52199dab3c7 100644 --- a/ui/litellm-dashboard/src/components/permissions/MCPServerPermissions.tsx +++ b/ui/litellm-dashboard/src/components/permissions/MCPServerPermissions.tsx @@ -108,7 +108,6 @@ export function MCPServerPermissions({ const blocksAllMcpServers = mcpServers.includes(NO_MCP_SERVERS_SENTINEL); const grantsAllProxyMcpServers = mcpServers.includes(ALL_PROXY_MCP_SERVERS_SENTINEL); - // Merge servers, inherited servers and access groups into one list const mergedItems = [ ...directServerIds.map((server) => ({ type: "server", value: server, inherited: false })), ...inheritedOnlyServerIds.map((server) => ({ type: "server", value: server, inherited: true })), From 2fd6e190517b7ce050c603489556efdb9870137a Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Thu, 3 Sep 2026 10:20:10 -0700 Subject: [PATCH 3/4] fix(ui): name the granting access group on hover instead of an Inherited tag `/team/info` access_group_details now carries mcp_server_ids and agent_ids per group next to models, so the dashboard can say which group granted a server or agent. The Object Permissions rows drop the Inherited badge and the row tooltip reads "Granted via access group . Full ID: ", listing every group when more than one grants the same id and falling back to "an access group" when the proxy did not say. Claude-Session: https://claude.ai/code/session_01QvQzYztinxj8ZuD5YxbVdL --- litellm/proxy/_types.py | 2 + .../management_endpoints/team_endpoints.py | 2 + .../test_team_endpoints.py | 6 +- .../components/object_permissions_view.tsx | 13 ++-- .../permissions/AgentPermissions.test.tsx | 35 ++++++++--- .../permissions/AgentPermissions.tsx | 24 +++----- .../permissions/MCPServerPermissions.test.tsx | 22 ++++--- .../permissions/MCPServerPermissions.tsx | 30 ++++----- .../permissions/inheritedGrants.test.ts | 61 +++++++++++++++++++ .../components/permissions/inheritedGrants.ts | 26 ++++++++ .../src/components/team/TeamInfo.test.tsx | 26 ++++++-- .../src/components/team/TeamInfo.tsx | 20 ++++-- .../src/components/team/teamModelAccess.ts | 4 +- 13 files changed, 204 insertions(+), 67 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/permissions/inheritedGrants.test.ts create mode 100644 ui/litellm-dashboard/src/components/permissions/inheritedGrants.ts 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( From 4d2ffe2e8e8dd1769a8fd2f1a3ebc857a4a3701c Mon Sep 17 00:00:00 2001 From: ryan Date: Thu, 3 Sep 2026 18:38:22 +0000 Subject: [PATCH 4/4] test(ui): hoist inherited-grant fixture out of the inline createMockTeamData arg Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/components/team/TeamInfo.test.tsx | 32 +++++++++---------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx index ae78ac06a9c..a9c1077e96b 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.test.tsx @@ -313,23 +313,21 @@ describe("TeamInfoView", () => { vi.mocked(networking.getAgentsList).mockResolvedValue({ agents: [{ agent_id: "agent-support-5678", agent_name: "support_agent" }], }); - vi.mocked(networking.teamInfoCall).mockResolvedValue( - createMockTeamData({ - object_permission: null, - 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"], - }, - ], - }), - ); + const platformToolsGroup = { + access_group_id: "ag-1", + access_group_name: "platform-tools", + models: [], + mcp_server_ids: ["mcp-github-1234"], + agent_ids: ["agent-support-5678"], + }; + const inheritedGrants = { + object_permission: null, + access_group_ids: ["ag-1"], + access_group_mcp_server_ids: ["mcp-github-1234"], + access_group_agent_ids: ["agent-support-5678"], + access_group_details: [platformToolsGroup], + }; + vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData(inheritedGrants)); renderWithProviders();