diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useCan.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useCan.ts new file mode 100644 index 00000000000..f538e1dff15 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useCan.ts @@ -0,0 +1,12 @@ +"use client"; + +import { hasCapability, type Capability } from "@/utils/capabilities"; + +import useAuthorized from "./useAuthorized"; + +const useCan = (capability: Capability): boolean => { + const { userRole } = useAuthorized(); + return hasCapability(userRole, capability); +}; + +export default useCan; diff --git a/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesPanel.test.tsx b/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesPanel.test.tsx index 0a0b1c09fbb..3c8a0da3347 100644 --- a/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesPanel.test.tsx +++ b/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesPanel.test.tsx @@ -21,6 +21,11 @@ vi.mock("@/components/molecules/notifications_manager", () => ({ default: { fromBackend: (...args: unknown[]) => fromBackend(...args) }, })); +const can = vi.fn(); +vi.mock("@/app/(dashboard)/hooks/useCan", () => ({ + default: (...args: unknown[]) => can(...args), +})); + const NOW = new Date("2026-07-21T12:00:00Z"); const TOOLS: ToolRow[] = [ @@ -104,6 +109,7 @@ beforeEach(() => { fetchToolsList.mockReset().mockResolvedValue(TOOLS); updateToolPolicy.mockReset().mockResolvedValue({}); fromBackend.mockReset(); + can.mockReset().mockReturnValue(true); Element.prototype.scrollIntoView = vi.fn(); }); @@ -112,6 +118,18 @@ afterEach(() => { }); describe("ToolPoliciesPanel data loading", () => { + it("should not fetch tools when the caller lacks the viewToolPolicies capability", async () => { + can.mockReturnValue(false); + renderPanel(); + + await act(async () => { + vi.advanceTimersByTime(1_000); + }); + + expect(can).toHaveBeenCalledWith("viewToolPolicies"); + expect(fetchToolsList).not.toHaveBeenCalled(); + }); + it("should load tools once and never auto-refresh on a timer", async () => { renderPanel(); await waitForRows(); diff --git a/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesPanel.tsx b/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesPanel.tsx index 1b559352469..df5d8553948 100644 --- a/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesPanel.tsx +++ b/ui/litellm-dashboard/src/components/ToolPolicies/ToolPoliciesPanel.tsx @@ -1,12 +1,14 @@ "use client"; -import { useQuery, useQueryClient, type UseQueryOptions } from "@tanstack/react-query"; +import { useQuery, useQueryClient } from "@tanstack/react-query"; import React, { useCallback, useMemo, useState } from "react"; +import useCan from "@/app/(dashboard)/hooks/useCan"; import { MetricCard } from "@/components/GuardrailsMonitor/MetricCard"; import NotificationsManager from "@/components/molecules/notifications_manager"; -import { fetchToolsList, ToolRow, updateToolPolicy } from "@/components/networking"; +import { ToolRow, updateToolPolicy } from "@/components/networking"; +import { toolPoliciesListOptions } from "./toolPoliciesQueries"; import { ToolPoliciesTable } from "./ToolPoliciesTable"; function getUTCDateKey(date: Date): string { @@ -41,8 +43,6 @@ const withTool = (names: ReadonlySet, toolName: string): ReadonlySet, toolName: string): ReadonlySet => new Set([...names].filter((name) => name !== toolName)); -const TOOLS_QUERY_KEY = "tool-policies"; - interface ToolPoliciesPanelProps { accessToken: string | null; onSelectTool: (toolName: string) => void; @@ -50,19 +50,12 @@ interface ToolPoliciesPanelProps { export const ToolPoliciesPanel: React.FC = ({ accessToken, onSelectTool }) => { const queryClient = useQueryClient(); + const canViewToolPolicies = useCan("viewToolPolicies"); const [savingInput, setSavingInput] = useState>(() => new Set()); const [savingOutput, setSavingOutput] = useState>(() => new Set()); - const queryKey = useMemo(() => [TOOLS_QUERY_KEY, accessToken], [accessToken]); - - const queryOptions: UseQueryOptions = { - queryKey, - queryFn: async () => (accessToken === null ? [] : fetchToolsList(accessToken)), - enabled: accessToken !== null, - refetchOnWindowFocus: false, - refetchOnReconnect: false, - }; - const query = useQuery(queryOptions); + const listOptions = useMemo(() => toolPoliciesListOptions(accessToken), [accessToken]); + const query = useQuery({ ...listOptions, enabled: canViewToolPolicies && accessToken !== null }); const tools = useMemo(() => query.data ?? [], [query.data]); @@ -70,12 +63,12 @@ export const ToolPoliciesPanel: React.FC = ({ accessToke // and overwrite the row we just wrote with its pre-save snapshot. const patchTool = useCallback( async (toolName: string, patch: Partial) => { - await queryClient.cancelQueries({ queryKey }); - queryClient.setQueryData(queryKey, (previous) => + await queryClient.cancelQueries({ queryKey: listOptions.queryKey }); + queryClient.setQueryData(listOptions.queryKey, (previous) => (previous ?? []).map((tool) => (tool.tool_name === toolName ? { ...tool, ...patch } : tool)), ); }, - [queryClient, queryKey], + [queryClient, listOptions], ); const handleInputPolicyChange = useCallback( diff --git a/ui/litellm-dashboard/src/components/ToolPolicies/toolPoliciesQueries.ts b/ui/litellm-dashboard/src/components/ToolPolicies/toolPoliciesQueries.ts new file mode 100644 index 00000000000..558f8c95c2c --- /dev/null +++ b/ui/litellm-dashboard/src/components/ToolPolicies/toolPoliciesQueries.ts @@ -0,0 +1,16 @@ +import { queryOptions } from "@tanstack/react-query"; + +import { fetchToolsList, type ToolRow } from "@/components/networking"; + +export const toolPoliciesKeys = { + all: ["tool-policies"] as const, + list: (accessToken: string | null) => [...toolPoliciesKeys.all, accessToken] as const, +}; + +export const toolPoliciesListOptions = (accessToken: string | null) => + queryOptions({ + queryKey: toolPoliciesKeys.list(accessToken), + queryFn: async (): Promise => (accessToken === null ? [] : fetchToolsList(accessToken)), + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }); diff --git a/ui/litellm-dashboard/src/components/ToolPoliciesView.test.tsx b/ui/litellm-dashboard/src/components/ToolPoliciesView.test.tsx index 34c697a98d1..74e3a316850 100644 --- a/ui/litellm-dashboard/src/components/ToolPoliciesView.test.tsx +++ b/ui/litellm-dashboard/src/components/ToolPoliciesView.test.tsx @@ -1,10 +1,15 @@ import React from "react"; -import { describe, it, expect, vi } from "vitest"; +import { beforeEach, describe, it, expect, vi } from "vitest"; import { screen } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { renderWithProviders } from "../../tests/test-utils"; import ToolPoliciesView from "./ToolPoliciesView"; +const can = vi.fn(); +vi.mock("@/app/(dashboard)/hooks/useCan", () => ({ + default: (...args: unknown[]) => can(...args), +})); + vi.mock("@/components/ToolDetail", () => ({ ToolDetail: ({ toolName, onBack }: { toolName: string; onBack: () => void }) => (
@@ -26,6 +31,18 @@ vi.mock("@/components/ToolPolicies/ToolPoliciesPanel", () => ({ })); describe("ToolPoliciesView", () => { + beforeEach(() => { + can.mockReset().mockReturnValue(true); + }); + + it("should show an admin-only notice instead of the overview when the caller lacks access", () => { + can.mockReturnValue(false); + renderWithProviders(); + + expect(screen.getByText(/only available to admin users/i)).toBeInTheDocument(); + expect(screen.queryByText("Tool Policies Overview")).not.toBeInTheDocument(); + }); + it("should render the overview by default", () => { renderWithProviders(); diff --git a/ui/litellm-dashboard/src/components/ToolPoliciesView.tsx b/ui/litellm-dashboard/src/components/ToolPoliciesView.tsx index bdff40153b9..b2d53985b29 100644 --- a/ui/litellm-dashboard/src/components/ToolPoliciesView.tsx +++ b/ui/litellm-dashboard/src/components/ToolPoliciesView.tsx @@ -1,6 +1,7 @@ "use client"; import React, { useState } from "react"; +import useCan from "@/app/(dashboard)/hooks/useCan"; import { ToolDetail } from "@/components/ToolDetail"; import { ToolPoliciesPanel } from "@/components/ToolPolicies/ToolPoliciesPanel"; @@ -11,6 +12,7 @@ interface ToolPoliciesViewProps { } export default function ToolPoliciesView({ accessToken }: ToolPoliciesViewProps) { + const canViewToolPolicies = useCan("viewToolPolicies"); const [view, setView] = useState({ type: "overview" }); const handleSelectTool = (toolName: string) => { @@ -21,6 +23,15 @@ export default function ToolPoliciesView({ accessToken }: ToolPoliciesViewProps) setView({ type: "overview" }); }; + if (!canViewToolPolicies) { + return ( +
+

Tool Policies

+

Tool Policies is only available to admin users.

+
+ ); + } + return (
{view.type === "detail" ? ( diff --git a/ui/litellm-dashboard/src/components/leftnav.test.tsx b/ui/litellm-dashboard/src/components/leftnav.test.tsx index dc893643559..e07d0bb26eb 100644 --- a/ui/litellm-dashboard/src/components/leftnav.test.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.test.tsx @@ -1,5 +1,5 @@ import { act, fireEvent, screen, waitFor } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "../../tests/test-utils"; import Sidebar, { menuGroups, getBreadcrumb } from "./leftnav"; @@ -201,6 +201,47 @@ describe("Sidebar (leftnav)", () => { }); }); + describe("capability-gated Tools children", () => { + const internalAuth = { + userId: "internal-user-id", + accessToken: "test-access-token", + userRole: "internal", + token: "test-token", + userEmail: "internal@example.com", + premiumUser: false, + disabledPersonalKeyCreation: false, + showSSOBanner: false, + }; + + afterEach(() => { + mockUseAuthorized.mockReset(); + }); + + it("should hide Tool Policies from internal users while keeping other Tools children", async () => { + mockUseAuthorized.mockReturnValue(internalAuth); + renderWithProviders(); + + act(() => { + fireEvent.click(screen.getByText("Tools")); + }); + await waitFor(() => { + expect(screen.getByText("Search Tools")).toBeInTheDocument(); + }); + expect(screen.queryByText("Tool Policies")).not.toBeInTheDocument(); + }); + + it("should show Tool Policies to admins", async () => { + renderWithProviders(); + + act(() => { + fireEvent.click(screen.getByText("Tools")); + }); + await waitFor(() => { + expect(screen.getByText("Tool Policies")).toBeInTheDocument(); + }); + }); + }); + it("should show Organizations tab for organization admins", () => { mockUseAuthorized.mockReturnValueOnce({ userId: "org-admin-user-id", diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx index af76fccf9eb..cd92fc5bedb 100644 --- a/ui/litellm-dashboard/src/components/leftnav.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.tsx @@ -64,6 +64,7 @@ import { import Link from "next/link"; import { useMemo, useState } from "react"; import { cn } from "@/lib/cva.config"; +import { rolesWithCapability } from "../utils/capabilities"; import { all_admin_roles, internalUserRoles, @@ -167,7 +168,13 @@ const menuGroups: MenuGroup[] = [ children: [ { key: "search-tools", page: "search-tools", label: "Search Tools", icon: }, { key: "vector-stores", page: "vector-stores", label: "Vector Stores", icon: }, - { key: "tool-policies", page: "tool-policies", label: "Tool Policies", icon: }, + { + key: "tool-policies", + page: "tool-policies", + label: "Tool Policies", + icon: , + roles: rolesWithCapability("viewToolPolicies"), + }, ], }, ], diff --git a/ui/litellm-dashboard/src/utils/capabilities.test.ts b/ui/litellm-dashboard/src/utils/capabilities.test.ts new file mode 100644 index 00000000000..84ceae16fc1 --- /dev/null +++ b/ui/litellm-dashboard/src/utils/capabilities.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from "vitest"; + +import { hasCapability, rolesWithCapability } from "./capabilities"; + +describe("hasCapability", () => { + it.each(["Admin", "Admin Viewer", "proxy_admin", "proxy_admin_viewer"])( + "should grant viewToolPolicies to %s", + (role) => { + expect(hasCapability(role, "viewToolPolicies")).toBe(true); + }, + ); + + it.each(["Internal User", "Internal Viewer", "App User", "Unknown Role", "", null, undefined])( + "should deny viewToolPolicies to %s", + (role) => { + expect(hasCapability(role, "viewToolPolicies")).toBe(false); + }, + ); +}); + +describe("rolesWithCapability", () => { + it("should return a copy so callers cannot mutate the capability map", () => { + const roles = rolesWithCapability("viewToolPolicies"); + const removed = roles.pop(); + expect(hasCapability(removed, "viewToolPolicies")).toBe(true); + }); +}); diff --git a/ui/litellm-dashboard/src/utils/capabilities.ts b/ui/litellm-dashboard/src/utils/capabilities.ts new file mode 100644 index 00000000000..77ead2568fb --- /dev/null +++ b/ui/litellm-dashboard/src/utils/capabilities.ts @@ -0,0 +1,12 @@ +import { all_admin_roles } from "./roles"; + +const CAPABILITY_ROLES = { + viewToolPolicies: all_admin_roles, +} as const satisfies Record; + +export type Capability = keyof typeof CAPABILITY_ROLES; + +export const hasCapability = (userRole: string | null | undefined, capability: Capability): boolean => + userRole != null && CAPABILITY_ROLES[capability].includes(userRole); + +export const rolesWithCapability = (capability: Capability): string[] => [...CAPABILITY_ROLES[capability]];