diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.test.tsx index 33c64ecf18a..c6d5a410418 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.test.tsx @@ -1,5 +1,11 @@ import { fireEvent, render } from "@testing-library/react"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { useAuthorizedMock } = vi.hoisted(() => ({ useAuthorizedMock: vi.fn() })); + +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: useAuthorizedMock, +})); vi.mock("./UsageTab", () => ({ __esModule: true, default: () =>
})); vi.mock("./PromptCompressionTab", () => ({ __esModule: true, default: () =>
})); @@ -11,9 +17,16 @@ vi.mock("./AutoRouterBenchmarksTab", () => ({ import CostOptimizationView from "./CostOptimizationView"; -const renderView = () => render(); +const renderView = (userRole = "Admin") => { + useAuthorizedMock.mockReturnValue({ accessToken: "test-token", userId: "u1", userRole }); + return render(); +}; describe("CostOptimizationView", () => { + beforeEach(() => { + useAuthorizedMock.mockReturnValue({ accessToken: "test-token", userId: "u1", userRole: "Admin" }); + }); + it("renders the four cost-optimization tabs", () => { const { getByText } = renderView(); @@ -34,4 +47,29 @@ describe("CostOptimizationView", () => { expect(getByRole("tab", { name: "Overall" })).toHaveAttribute("aria-selected", "false"); expect(getByRole("tab", { name: "Prompt Compression" })).toHaveAttribute("aria-selected", "true"); }); + + // Unlike the other three pages in this cleanup, Cost Optimization keeps its + // nav entry for internal users: the Overall tab runs on /user/daily/activity, + // which every role may call. Only the tabs reading proxy-wide config and + // telemetry (/config/list, /auto_router/benchmarks, guardrail management) + // are proxy-admin-only, so those are what disappear. + describe("proxy-admin-only tabs", () => { + it.each(["Internal User", "Internal Viewer", "Org Admin"])("shows %s the Overall tab only", (userRole) => { + const { getByRole, queryByRole } = renderView(userRole); + + expect(getByRole("tab", { name: "Overall" })).toBeInTheDocument(); + expect(queryByRole("tab", { name: "Prompt Compression" })).not.toBeInTheDocument(); + expect(queryByRole("tab", { name: "Prompt Caching" })).not.toBeInTheDocument(); + expect(queryByRole("tab", { name: "Auto-Router" })).not.toBeInTheDocument(); + }); + + it("never mounts the panels behind the admin-only endpoints for an internal user", () => { + const { getByTestId, queryByTestId } = renderView("Internal User"); + + expect(getByTestId("usage-tab")).toBeInTheDocument(); + expect(queryByTestId("compression-tab")).not.toBeInTheDocument(); + expect(queryByTestId("caching-tab")).not.toBeInTheDocument(); + expect(queryByTestId("autorouter-benchmarks-tab")).not.toBeInTheDocument(); + }); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx index 6af1e8d0441..517a0d9bd85 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/CostOptimizationView.tsx @@ -4,6 +4,7 @@ import React from "react"; import { PiggyBank } from "lucide-react"; import { Alert, Tabs } from "antd"; +import useCan from "@/app/(dashboard)/hooks/useCan"; import UsageTab from "./UsageTab"; import PromptCompressionTab from "./PromptCompressionTab"; import PromptCachingTab from "./PromptCachingTab"; @@ -18,6 +19,7 @@ interface CostOptimizationViewProps { const CostOptimizationView: React.FC = ({ accessToken, userId, userRole }) => { const activity = useDailyActivityRange(accessToken, userId, userRole); + const canViewProxyWideCostData = useCan("viewProxyWideCostData"); const items = [ { @@ -25,21 +27,25 @@ const CostOptimizationView: React.FC = ({ accessToken label: "Overall", children: , }, - { - key: "compression", - label: "Prompt Compression", - children: , - }, - { - key: "caching", - label: "Prompt Caching", - children: , - }, - { - key: "autorouter-usage", - label: "Auto-Router", - children: , - }, + ...(canViewProxyWideCostData + ? [ + { + key: "compression", + label: "Prompt Compression", + children: , + }, + { + key: "caching", + label: "Prompt Caching", + children: , + }, + { + key: "autorouter-usage", + label: "Auto-Router", + children: , + }, + ] + : []), ]; return ( diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.test.tsx index 96d4644804b..ad68111bba7 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.test.tsx @@ -7,6 +7,12 @@ import type { DailyData, SpendMetrics } from "@/components/UsagePage/types"; const mockGetToolSpend = vi.fn(); +const { useAuthorizedMock } = vi.hoisted(() => ({ useAuthorizedMock: vi.fn() })); + +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: useAuthorizedMock, +})); + vi.mock("@/components/networking", () => ({ getToolSpend: (...args: unknown[]) => mockGetToolSpend(...args), })); @@ -88,11 +94,18 @@ interface RenderOptions { toolSpend?: ToolSpendResponse; from?: Date; to?: Date; + userRole?: string; } const renderWith = (results: DailyData[], options: RenderOptions = {}) => { - const { toolSpend = emptyToolSpend, from = new Date(2026, 6, 1), to = new Date(2026, 6, 14) } = options; + const { + toolSpend = emptyToolSpend, + from = new Date(2026, 6, 1), + to = new Date(2026, 6, 14), + userRole = "Admin", + } = options; mockGetToolSpend.mockResolvedValue(toolSpend); + useAuthorizedMock.mockReturnValue({ accessToken: "test-token", userId: "u1", userRole }); return render( { const toolLegends = getAllByTestId("chart-legend").filter((legend) => legend.textContent === "search,read_file"); expect(toolLegends).toHaveLength(1); }); + + // `/v1/tool/spend` is proxy-admin-only while the daily-activity charts around + // it are not, so this one card is dropped rather than the whole tab. + describe("proxy-admin-only spend-by-tool card", () => { + const toolSpend = { + by_tool: [{ tool_name: "search", spend: 4.0, call_count: 3, total_tokens: 150 }], + daily: [{ date: "2026-07-12", tool_name: "search", spend: 4.0, call_count: 3 }], + start_date: "2026-07-12", + end_date: "2026-07-12", + }; + + it.each(["Internal User", "Internal Viewer", "Org Admin"])( + "hides the card and never calls the endpoint for %s", + async (userRole) => { + const { queryByText, getByTestId } = renderWith([day("2026-07-12", { compression_savings_spend: 0.04 })], { + toolSpend, + userRole, + }); + + // Liveness gate: the daily-activity charts still render for this role, + // so the absence below is the gate, not an empty tab. + expect(getByTestId("donut-chart")).toBeInTheDocument(); + expect(queryByText("Spend by tool")).not.toBeInTheDocument(); + await vi.waitFor(() => expect(mockGetToolSpend).not.toHaveBeenCalled()); + }, + ); + + it("keeps the card and the endpoint call for an admin", async () => { + const { findByText } = renderWith([day("2026-07-12", { compression_savings_spend: 0.04 })], { toolSpend }); + + expect(await findByText("Spend by tool")).toBeInTheDocument(); + expect(mockGetToolSpend).toHaveBeenCalled(); + }); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.tsx index bd9d4f3c873..f7d61eacb53 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/cost-optimization/_components/UsageTab.tsx @@ -8,6 +8,7 @@ import AdvancedDatePicker from "@/components/shared/advanced_date_picker"; import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import useCan from "@/app/(dashboard)/hooks/useCan"; import { getToolSpend, ToolSpendResponse } from "@/components/networking"; import { SpendMetrics } from "@/components/UsagePage/types"; import { formatNumberWithCommas } from "@/utils/dataUtils"; @@ -82,12 +83,13 @@ const UsageTab: React.FC = ({ accessToken, activity }) => { const startTime = dateValue.from ?? null; const endTime = dateValue.to ?? null; - const toolSpendEnabled = !!accessToken && !!startTime && !!endTime; + const canViewProxyWideCostData = useCan("viewProxyWideCostData"); + const toolSpendEnabled = canViewProxyWideCostData && !!accessToken && !!startTime && !!endTime; const rangeKey = startTime && endTime ? `${isoDay(startTime)}|${isoDay(endTime)}` : ""; const [toolSpendState, setToolSpendState] = useState<{ key: string; data: ToolSpendResponse } | null>(null); useEffect(() => { - if (!accessToken || !startTime || !endTime) return; + if (!canViewProxyWideCostData || !accessToken || !startTime || !endTime) return; let cancelled = false; getToolSpend(accessToken, isoDay(startTime), isoDay(endTime)) .then((res) => { @@ -99,7 +101,7 @@ const UsageTab: React.FC = ({ accessToken, activity }) => { return () => { cancelled = true; }; - }, [accessToken, startTime, endTime, rangeKey]); + }, [canViewProxyWideCostData, accessToken, startTime, endTime, rangeKey]); const toolSpend = toolSpendState?.key === rangeKey ? toolSpendState.data : null; const toolSpendLoading = toolSpendEnabled && toolSpend === null; @@ -273,55 +275,57 @@ const UsageTab: React.FC = ({ accessToken, activity }) => {
- - - Spend by tool -

- Spend on requests that invoked each tool (MCP and client-side tools); declaring a tool without invoking it - does not count. A request that invoked multiple tools counts its full spend toward each, so this attributes - rather than partitions spend. -

-
- - {topTools.length === 0 ? ( -

- {toolSpendLoading ? "Loading..." : "No tool usage in this range."} + {canViewProxyWideCostData && ( + + + Spend by tool +

+ Spend on requests that invoked each tool (MCP and client-side tools); declaring a tool without invoking it + does not count. A request that invoked multiple tools counts its full spend toward each, so this + attributes rather than partitions spend.

- ) : ( -
-
-

Total by tool

- + + + {topTools.length === 0 ? ( +

+ {toolSpendLoading ? "Loading..." : "No tool usage in this range."} +

+ ) : ( +
+
+

Total by tool

+ +
+
+

Daily spend by tool

+ + +
-
-

Daily spend by tool

- - -
-
- )} - - + )} + + + )}
); }; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/page.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/page.integration.test.tsx new file mode 100644 index 00000000000..d4c68841299 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/page.integration.test.tsx @@ -0,0 +1,53 @@ +import { screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import GuardrailsMonitor from "./page"; +import { renderWithProviders, testQueryClient } from "../../../../tests/test-utils"; + +const { useAuthorizedMock } = vi.hoisted(() => ({ useAuthorizedMock: vi.fn() })); + +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: useAuthorizedMock, +})); + +const fetchMock = vi.fn(); + +const requestedUrls = () => fetchMock.mock.calls.map(([url]) => String(url)); + +const renderAs = (userRole: string) => { + useAuthorizedMock.mockReturnValue({ accessToken: "sk-test", userId: "u1", userRole }); + return renderWithProviders(); +}; + +// `/guardrails/usage/*` aggregates across tenants and is listed in +// admin_viewer_routes, so it is proxy-admin-only. Nothing on this page works +// for a non-admin, hence the whole page is gated rather than a section of it. +describe("Guardrails Monitor page access by role", () => { + beforeEach(() => { + testQueryClient.clear(); + vi.clearAllMocks(); + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + json: async () => ({ rows: [], chart: [], totalRequests: 0, totalBlocked: 0, passRate: 100 }), + }); + vi.stubGlobal("fetch", fetchMock); + }); + + it("fetches the guardrails usage overview for an admin", async () => { + renderAs("Admin"); + + await waitFor(() => expect(requestedUrls().some((url) => url.includes("/guardrails/usage/overview"))).toBe(true)); + }); + + it.each(["Internal User", "Internal Viewer", "Org Admin", "Unknown Role"])( + "renders the admin-only notice and fires no usage request for %s", + async (userRole) => { + renderAs(userRole); + + expect(await screen.findByText("Guardrails Monitor is only available to admin users.")).toBeInTheDocument(); + await waitFor(() => expect(fetchMock).not.toHaveBeenCalled()); + expect(requestedUrls().filter((url) => url.includes("/guardrails/usage"))).toEqual([]); + }, + ); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/page.tsx index 0c4e69c2d80..255769182bf 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails-monitor/page.tsx @@ -1,9 +1,17 @@ "use client"; import GuardrailsMonitorView from "./_components/GuardrailsMonitorView"; +import { AdminOnlyNotice } from "@/components/shared/AdminOnlyNotice"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import useCan from "@/app/(dashboard)/hooks/useCan"; export default function GuardrailsMonitor() { const { accessToken } = useAuthorized(); + const canViewGuardrailUsage = useCan("viewGuardrailUsage"); + + if (!canViewGuardrailUsage) { + return ; + } + return ; } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/memory/page.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/memory/page.integration.test.tsx new file mode 100644 index 00000000000..8d15bb59187 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/memory/page.integration.test.tsx @@ -0,0 +1,59 @@ +import { screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import Memory from "./page"; +import { renderWithProviders, testQueryClient } from "../../../../tests/test-utils"; + +const { useAuthorizedMock } = vi.hoisted(() => ({ useAuthorizedMock: vi.fn() })); + +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: useAuthorizedMock, +})); + +const fetchMock = vi.fn(); + +const requestedUrls = () => fetchMock.mock.calls.map(([url]) => String(url)); + +const renderAs = (userRole: string) => { + useAuthorizedMock.mockReturnValue({ accessToken: "sk-test", userId: "u1", userRole }); + return renderWithProviders(); +}; + +// `/v1/memory` scopes rows per caller in the handler, but the route gate keeps +// it proxy-admin-only, so a non-admin deep-linking to /ui/memory gets a 401. +describe("Memory page access by role", () => { + beforeEach(() => { + testQueryClient.clear(); + vi.clearAllMocks(); + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + text: async () => "", + json: async () => ({ memories: [], total: 0 }), + }); + vi.stubGlobal("fetch", fetchMock); + }); + + it("lists memory entries for an admin", async () => { + renderAs("Admin"); + + await waitFor(() => expect(requestedUrls().some((url) => url.includes("/v1/memory"))).toBe(true)); + }); + + it.each(["Internal User", "Internal Viewer", "Org Admin", "Unknown Role"])( + "renders the admin-only notice and fires no memory request for %s", + async (userRole) => { + renderAs(userRole); + + expect(await screen.findByText("Memory is only available to admin users.")).toBeInTheDocument(); + await waitFor(() => expect(fetchMock).not.toHaveBeenCalled()); + expect(requestedUrls().filter((url) => url.includes("/v1/memory"))).toEqual([]); + }, + ); + + it("hides the deprecation banner along with the page body for a denied role", () => { + renderAs("Internal User"); + + expect(screen.queryByText(/draft deprecation list/i)).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/memory/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/memory/page.tsx index b88996c5396..7b1b6223372 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/memory/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/memory/page.tsx @@ -2,10 +2,18 @@ import { MemoryView } from "./_components/MemoryView"; import { DeprecationBanner } from "@/components/DeprecationBanner"; +import { AdminOnlyNotice } from "@/components/shared/AdminOnlyNotice"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import useCan from "@/app/(dashboard)/hooks/useCan"; export default function Memory() { const { accessToken, userRole, userId } = useAuthorized(); + const canViewMemory = useCan("viewMemory"); + + if (!canViewMemory) { + return ; + } + return ( <> diff --git a/ui/litellm-dashboard/src/app/(dashboard)/workflows/page.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/workflows/page.integration.test.tsx new file mode 100644 index 00000000000..6b332faf704 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/workflows/page.integration.test.tsx @@ -0,0 +1,59 @@ +import { screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import Workflows from "./page"; +import { renderWithProviders, testQueryClient } from "../../../../tests/test-utils"; + +const { useAuthorizedMock } = vi.hoisted(() => ({ useAuthorizedMock: vi.fn() })); + +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ + default: useAuthorizedMock, +})); + +const fetchMock = vi.fn(); + +const requestedUrls = () => fetchMock.mock.calls.map(([url]) => String(url)); + +const renderAs = (userRole: string) => { + useAuthorizedMock.mockReturnValue({ accessToken: "sk-test", userId: "u1", userRole }); + return renderWithProviders(); +}; + +// Deep-linking to /ui/workflows bypasses the sidebar, so the page itself has to +// refuse the render. `/v1/workflows/runs` is proxy-admin-only, so any request +// from a non-admin is the 401 this gate exists to stop. +describe("Workflows page access by role", () => { + beforeEach(() => { + testQueryClient.clear(); + vi.clearAllMocks(); + fetchMock.mockResolvedValue({ + ok: true, + status: 200, + statusText: "OK", + json: async () => ({ runs: [], count: 0 }), + }); + vi.stubGlobal("fetch", fetchMock); + }); + + it("lists workflow runs for an admin", async () => { + renderAs("Admin"); + + await waitFor(() => expect(requestedUrls().some((url) => url.includes("/v1/workflows/runs"))).toBe(true)); + }); + + it.each(["Internal User", "Internal Viewer", "Org Admin", "Unknown Role"])( + "renders the admin-only notice and fires no workflow request for %s", + async (userRole) => { + renderAs(userRole); + + expect(await screen.findByText("Workflow Runs is only available to admin users.")).toBeInTheDocument(); + await waitFor(() => expect(fetchMock).not.toHaveBeenCalled()); + expect(requestedUrls().filter((url) => url.includes("/v1/workflows"))).toEqual([]); + }, + ); + + it("hides the deprecation banner along with the page body for a denied role", () => { + renderAs("Internal User"); + + expect(screen.queryByText(/draft deprecation list/i)).not.toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/workflows/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/workflows/page.tsx index 89dd30f7392..51db7579f82 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/workflows/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/workflows/page.tsx @@ -2,10 +2,18 @@ import WorkflowRuns from "./WorkflowRuns"; import { DeprecationBanner } from "@/components/DeprecationBanner"; +import { AdminOnlyNotice } from "@/components/shared/AdminOnlyNotice"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import useCan from "@/app/(dashboard)/hooks/useCan"; export default function Workflows() { const { accessToken } = useAuthorized(); + const canViewWorkflowRuns = useCan("viewWorkflowRuns"); + + if (!canViewWorkflowRuns) { + return ; + } + return ( <> diff --git a/ui/litellm-dashboard/src/components/leftnav.test.tsx b/ui/litellm-dashboard/src/components/leftnav.test.tsx index f795076ff03..e7e32718a27 100644 --- a/ui/litellm-dashboard/src/components/leftnav.test.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.test.tsx @@ -268,6 +268,96 @@ describe("Sidebar (leftnav)", () => { }); }); + // Workflow Runs, Memory and Guardrails Monitor render a shell and then 401 + // for every non-proxy-admin role, because their page-load routes sit outside + // internal_user_routes / self_managed_routes. Cost Optimization does not: + // its primary call is /user/daily/activity, which every role may make, so + // the entry stays and only its proxy-wide tabs are gated inside the page. + describe("capability-gated pages whose data is proxy-admin-only", () => { + const authFor = (userRole: string) => ({ + userId: "some-user-id", + accessToken: "test-access-token", + userRole, + isViewOnly: false, + token: "test-token", + userEmail: "someone@example.com", + premiumUser: false, + disabledPersonalKeyCreation: false, + showSSOBanner: false, + }); + + afterEach(() => { + mockUseAuthorized.mockReset(); + }); + + it("hides Workflow Runs and Memory from an internal user under Agentic", async () => { + mockUseAuthorized.mockReturnValue(authFor("internal")); + renderWithProviders(); + + act(() => { + fireEvent.click(screen.getByText("Agentic")); + }); + // Liveness gate: the sibling Agents child stays visible to this role, so + // the absences below mean the gate fired, not that the group never opened. + await waitFor(() => { + expect(screen.getByText("Agents")).toBeInTheDocument(); + }); + expect(screen.queryByText("Workflow Runs")).not.toBeInTheDocument(); + expect(screen.queryByText("Memory")).not.toBeInTheDocument(); + }); + + // An org admin's session role is "Org Admin", which no capability list + // carries, and the proxy denies these routes to org admins too because + // `_user_is_org_admin` needs an organization_id the page-load GET never sends. + // Agents is already out of reach for this role, so gating the other two + // empties the Agentic group entirely and the parent must go with it rather + // than degrade into a leaf link to the non-route `?page=agentic`. + it("drops the whole Agentic group for an org admin once its last child is gated", () => { + mockUseAuthorized.mockReturnValue(authFor("org_admin")); + renderWithProviders(); + + // Liveness gate: Logs carries no role list, so it proves the sidebar rendered. + expect(screen.getByText("Logs")).toBeInTheDocument(); + expect(screen.queryByText("Agentic")).not.toBeInTheDocument(); + expect(screen.queryByText("Workflow Runs")).not.toBeInTheDocument(); + expect(screen.queryByText("Memory")).not.toBeInTheDocument(); + }); + + it("keeps the Agentic group for an internal user, who can still see Agents", () => { + mockUseAuthorized.mockReturnValue(authFor("internal")); + renderWithProviders(); + + expect(screen.getByText("Agentic")).toBeInTheDocument(); + }); + + it("shows Workflow Runs and Memory to admins", async () => { + renderWithProviders(); + + act(() => { + fireEvent.click(screen.getByText("Agentic")); + }); + await waitFor(() => { + expect(screen.getByText("Workflow Runs")).toBeInTheDocument(); + }); + expect(screen.getByText("Memory")).toBeInTheDocument(); + }); + + it("hides Guardrails Monitor from an internal user while keeping Usage and Cost Optimization", () => { + mockUseAuthorized.mockReturnValue(authFor("internal")); + renderWithProviders(); + + expect(screen.queryByText("Guardrails Monitor")).not.toBeInTheDocument(); + expect(screen.getByText("Usage")).toBeInTheDocument(); + expect(screen.getByText("Cost Optimization")).toBeInTheDocument(); + }); + + it("shows Guardrails Monitor to admins", () => { + renderWithProviders(); + + expect(screen.getByText("Guardrails Monitor")).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 12d124b059e..2b9b4859b16 100644 --- a/ui/litellm-dashboard/src/components/leftnav.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.tsx @@ -146,8 +146,20 @@ const menuGroups: MenuGroup[] = [ icon: , roles: rolesAllowedToViewWriteScopedPages, }, - { key: "workflows", page: "workflows", label: "Workflow Runs", icon: }, - { key: "memory", page: "memory", label: "Memory", icon: }, + { + key: "workflows", + page: "workflows", + label: "Workflow Runs", + icon: , + roles: rolesWithCapability("viewWorkflowRuns"), + }, + { + key: "memory", + page: "memory", + label: "Memory", + icon: , + roles: rolesWithCapability("viewMemory"), + }, ], }, { key: "mcp-servers", page: "mcp-servers", label: "MCP Servers", icon: }, @@ -206,7 +218,7 @@ const menuGroups: MenuGroup[] = [ page: "guardrails-monitor", label: "Guardrails Monitor", icon: , - roles: [...all_admin_roles, ...internalUserRoles], + roles: rolesWithCapability("viewGuardrailUsage"), }, ], }, @@ -455,6 +467,9 @@ const Sidebar_: React.FC = ({ return items .map((item) => ({ ...item, children: item.children ? filterItemsByRole(item.children) : undefined })) .filter((item) => { + // A parent whose children were all filtered out renders as a leaf link + // to its own page id, which is not a real route. Drop it instead. + if (item.children && item.children.length === 0) return false; if (item.key === "llm-playground" && isViewOnly) return false; if (item.key === "organizations" || item.key === "users") { const hasRoleAccess = !item.roles || item.roles.includes(userRole) || isOrgAdmin; diff --git a/ui/litellm-dashboard/src/components/shared/AdminOnlyNotice.tsx b/ui/litellm-dashboard/src/components/shared/AdminOnlyNotice.tsx new file mode 100644 index 00000000000..afc855cd9dd --- /dev/null +++ b/ui/litellm-dashboard/src/components/shared/AdminOnlyNotice.tsx @@ -0,0 +1,14 @@ +"use client"; + +import React from "react"; + +interface AdminOnlyNoticeProps { + pageTitle: string; +} + +export const AdminOnlyNotice: React.FC = ({ pageTitle }) => ( +
+

{pageTitle}

+

{pageTitle} is only available to admin users.

+
+); diff --git a/ui/litellm-dashboard/src/utils/capabilities.test.ts b/ui/litellm-dashboard/src/utils/capabilities.test.ts index 858658309ec..2910e4ff359 100644 --- a/ui/litellm-dashboard/src/utils/capabilities.test.ts +++ b/ui/litellm-dashboard/src/utils/capabilities.test.ts @@ -67,6 +67,36 @@ describe.each(["viewAuditLogs", "viewDeletedTeams"] as const)("hasCapability - % ); }); +// `useAuthorized` supplies `userRole` as the formatted session role from +// `effectiveSessionRole`, which collapses proxy_admin_viewer to "Admin" and +// renders an org admin as "Org Admin". The four sidebar pages behind these +// capabilities call proxy-admin-only routes: `_user_is_org_admin` needs an +// `organization_id` in the request data, which a page-load GET never carries, +// so an org admin is denied at the proxy exactly as it is here. +describe.each(["viewWorkflowRuns", "viewMemory", "viewGuardrailUsage", "viewProxyWideCostData"] as const)( + "hasCapability - %s", + (capability) => { + it.each(["Admin", "Admin Viewer", "proxy_admin", "proxy_admin_viewer"])("should grant it to %s", (role) => { + expect(hasCapability(role, capability)).toBe(true); + }); + + it.each([ + "Internal User", + "Internal Viewer", + "internal_user", + "internal_user_viewer", + "Org Admin", + "App User", + "Unknown Role", + "", + null, + undefined, + ])("should deny it to %s", (role) => { + expect(hasCapability(role, capability)).toBe(false); + }); + }, +); + describe("rolesWithCapability", () => { it("should return a copy so callers cannot mutate the capability map", () => { const roles = rolesWithCapability("viewToolPolicies"); diff --git a/ui/litellm-dashboard/src/utils/capabilities.ts b/ui/litellm-dashboard/src/utils/capabilities.ts index 8171ef9a512..e981b9d774f 100644 --- a/ui/litellm-dashboard/src/utils/capabilities.ts +++ b/ui/litellm-dashboard/src/utils/capabilities.ts @@ -6,6 +6,10 @@ const CAPABILITY_ROLES = { viewDeletedTeams: all_admin_roles, viewPolicies: all_admin_roles, viewPrompts: all_admin_roles, + viewWorkflowRuns: all_admin_roles, + viewMemory: all_admin_roles, + viewGuardrailUsage: all_admin_roles, + viewProxyWideCostData: all_admin_roles, } as const satisfies Record; export type Capability = keyof typeof CAPABILITY_ROLES;