From 2b1e56b877cc869abbc1a5e97e01f8570c5279e3 Mon Sep 17 00:00:00 2001 From: derhornspieler <15236687+derhornspieler@users.noreply.github.com> Date: Sun, 23 Aug 2026 20:13:54 -0400 Subject: [PATCH] fix(ui): hide the Add Provider wizard from view-only admins effectiveSessionRole deliberately reports a proxy_admin_viewer session as "Admin", so gating the tab on all_admin_roles showed a read-only admin a wizard whose every step is a write the proxy then refuses. The first step solicits a provider API key, so a viewer types a real secret into a form that 403s on submit. Only the raw-role isViewOnly separates the two, which is the same mechanism the Playground already uses for this carve-out. Both conjuncts of the gate are covered: dropping either one fails a test. The five older admin panels on this page have the same gap, but they predate this PR and fixing them is a page-wide change, so they are left for a follow-up. --- .../models-and-endpoints/page.test.tsx | 18 ++++++++++++++++++ .../(dashboard)/models-and-endpoints/page.tsx | 8 +++++--- 2 files changed, 23 insertions(+), 3 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 521f89a39f2..d2bd1094548 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 @@ -40,6 +40,15 @@ vi.mock("./useModelDashboardData", () => ({ const ADMIN = { accessToken: "at", token: "t", userRole: "Admin", userId: "u1", premiumUser: false }; const NON_ADMIN = { accessToken: "at", token: "t", userRole: "Internal User", userId: "u1", premiumUser: false }; +// effectiveSessionRole reports a proxy_admin_viewer session as "Admin"; only isViewOnly tells them apart. +const ADMIN_VIEWER = { + accessToken: "at", + token: "t", + userRole: "Admin", + userId: "u1", + premiumUser: false, + isViewOnly: true, +}; const renderPage = () => { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false, gcTime: 0 } } }); @@ -68,9 +77,17 @@ describe("ModelsAndEndpointsPage", () => { expect(getByRole("tab", { name: "All Models" })).toBeInTheDocument(); expect(getByRole("tab", { name: "LLM Credentials" })).toBeInTheDocument(); expect(getByRole("tab", { name: "Health Status" })).toBeInTheDocument(); + expect(getByRole("tab", { name: "Add Provider" })).toBeInTheDocument(); expect(getByTestId("panel-all-models")).toBeInTheDocument(); }); + it("hides the write-only Add Provider tab from a view-only admin", () => { + mockUseAuthorized.mockReturnValue(ADMIN_VIEWER); + const { getByRole, queryByRole } = renderPage(); + expect(getByRole("tab", { name: "All Models" })).toBeInTheDocument(); + expect(queryByRole("tab", { name: "Add Provider" })).not.toBeInTheDocument(); + }); + it("switches tabs in-memory, mounting only the active panel", async () => { const user = userEvent.setup(); const { getByRole, getByTestId, queryByTestId } = renderPage(); @@ -97,6 +114,7 @@ describe("ModelsAndEndpointsPage", () => { const { queryByRole } = renderPage(); expect(queryByRole("tab", { name: "LLM Credentials" })).not.toBeInTheDocument(); expect(queryByRole("tab", { name: "Health Status" })).not.toBeInTheDocument(); + expect(queryByRole("tab", { name: "Add Provider" })).not.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/app/(dashboard)/models-and-endpoints/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx index 327770d8a75..498fee6fc0f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/page.tsx @@ -80,7 +80,7 @@ const renderPanel = (key: string) => { }; export default function ModelsAndEndpointsPage() { - const { accessToken, userRole, userId: userID, premiumUser } = useAuthorized(); + const { accessToken, userRole, userId: userID, premiumUser, isViewOnly } = useAuthorized(); const { data: teams } = useTeams(); const { data: uiSettings } = useUISettings(); const queryClient = useQueryClient(); @@ -106,12 +106,14 @@ export default function ModelsAndEndpointsPage() { "", ...(canCreate ? (["add"] as const) : []), ...(isAdmin || canCreate ? (["auto-routers"] as const) : []), - ...(isAdmin ? (["add-provider"] as const) : []), + // effectiveSessionRole reports proxy_admin_viewer as "Admin", so isAdmin alone would show + // a viewer this write-only wizard; only the raw-role isViewOnly separates them. + ...(isAdmin && !isViewOnly ? (["add-provider"] as const) : []), ...(isAdmin ? (["llm-credentials", "pass-through", "health", "retry-settings", "model-group-alias", "price-data"] as const) : []), ], - [canCreate, isAdmin], + [canCreate, isAdmin, isViewOnly], ); const allModelsLabel = isAdmin ? "All Models" : "Your Models";