From 6a248be76f0893c33e426b30ce7c8644c3fc8014 Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Thu, 23 Jul 2026 14:28:24 -0700 Subject: [PATCH] fix(ui): gate team guardrail admin actions behind proxy admin role Internal (non-admin) users could see Approve/Reject buttons and the forward-key / header edit controls on the Submitted Guardrails tab even though the backend approve, reject and update endpoints are PROXY_ADMIN only and reject those calls. The controls now render only for a true proxy admin, gated on isProxyAdminRole so Admin Viewer (which the backend also rejects) no longer sees them either. Non-admin team members keep full read visibility: submission status, team, configured headers and the forward-key state (shown as a read-only label instead of a toggle), so they can still see the status of a guardrail they submitted and the guardrails attached to their team. userRole is threaded from GuardrailsPanel into TeamGuardrailsTab to drive the gating --- .../_components/GuardrailsPanel.tsx | 2 +- .../_components/TeamGuardrailsTab.test.tsx | 115 +++++++++ .../_components/TeamGuardrailsTab.tsx | 221 ++++++++++-------- 3 files changed, 238 insertions(+), 100 deletions(-) create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/TeamGuardrailsTab.test.tsx diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailsPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailsPanel.tsx index 33c6933634c..11b6125df44 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailsPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/GuardrailsPanel.tsx @@ -233,7 +233,7 @@ const GuardrailsPanel: React.FC = ({ accessToken, userRole { key: "submitted", label: "Submitted Guardrails", - children: , + children: , }, ]} /> diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/TeamGuardrailsTab.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/TeamGuardrailsTab.test.tsx new file mode 100644 index 00000000000..c9f3034e9e5 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/TeamGuardrailsTab.test.tsx @@ -0,0 +1,115 @@ +import { render, screen, fireEvent, within } from "@testing-library/react"; +import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; +import { TeamGuardrailsTab } from "./TeamGuardrailsTab"; +import { listGuardrailSubmissions, type GuardrailSubmissionItem } from "@/components/networking"; + +vi.mock("@/components/networking", () => ({ + listGuardrailSubmissions: vi.fn(), + approveGuardrailSubmission: vi.fn(), + rejectGuardrailSubmission: vi.fn(), + updateGuardrailCall: vi.fn(), +})); + +vi.mock("@/app/(dashboard)/hooks/guardrails/useRegisterGuardrail", () => ({ + useRegisterGuardrail: () => ({ mutateAsync: vi.fn(), isPending: false }), +})); + +const pendingSubmission: GuardrailSubmissionItem = { + guardrail_id: "gd-1", + guardrail_name: "PII Scanner", + status: "pending_review", + team_id: "team-alpha", + team_guardrail: true, + litellm_params: { + api_base: "https://guard.example.com/scan", + forward_api_key: true, + headers: [{ key: "X-Api-Key", value: "secret" }], + extra_headers: ["x-request-id"], + method: "POST", + }, + guardrail_info: { description: "Scans for PII", model: "gpt-4o" }, + submitted_by_email: "member@example.com", + submitted_at: "2026-05-01T00:00:00Z", +}; + +beforeAll(() => { + Object.defineProperty(window, "matchMedia", { + writable: true, + value: vi.fn().mockImplementation((query: string) => ({ + matches: false, + media: query, + onchange: null, + addListener: vi.fn(), + removeListener: vi.fn(), + addEventListener: vi.fn(), + removeEventListener: vi.fn(), + dispatchEvent: vi.fn(), + })), + }); +}); + +beforeEach(() => { + vi.mocked(listGuardrailSubmissions).mockResolvedValue({ + submissions: [pendingSubmission], + summary: { total: 1, pending_review: 1, active: 0, rejected: 0 }, + }); +}); + +async function renderTab(userRole?: string) { + render(); + return screen.findByText("PII Scanner"); +} + +describe("TeamGuardrailsTab role gating", () => { + it("shows submission status and team to a non-admin submitter", async () => { + const heading = await renderTab("Internal User"); + const card = heading.closest(".bg-white") as HTMLElement; + // Point 1: the submitter can see the status after submitting + expect(within(card).getByText("Pending Review")).toBeInTheDocument(); + // Point 2: the team member can see which team the guardrail is attached to + expect(within(card).getByText("Team: team-alpha")).toBeInTheDocument(); + }); + + it("hides Approve/Reject and the forward-key toggle from a non-admin", async () => { + await renderTab("Internal User"); + expect(screen.queryByRole("button", { name: "Approve" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Reject" })).not.toBeInTheDocument(); + expect(screen.queryByText("Forward API Key")).not.toBeInTheDocument(); + }); + + it("hides Approve/Reject from an Admin Viewer (backend rejects them too)", async () => { + await renderTab("Admin Viewer"); + expect(screen.queryByRole("button", { name: "Approve" })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Reject" })).not.toBeInTheDocument(); + }); + + it("shows Approve/Reject and the forward-key toggle to a proxy admin", async () => { + await renderTab("Admin"); + expect(screen.getByRole("button", { name: "Approve" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Reject" })).toBeInTheDocument(); + expect(screen.getByText("Forward API Key")).toBeInTheDocument(); + }); + + it("shows header editors in the detail panel only to a proxy admin", async () => { + await renderTab("Admin"); + fireEvent.click(screen.getByRole("button", { name: "Review" })); + const panel = screen.getByText("Forward LiteLLM API Key").closest("div"); + expect(panel).not.toBeNull(); + expect(screen.getByPlaceholderText("Header name (e.g. X-API-Key)")).toBeInTheDocument(); + // Forward-key is an interactive toggle for admins, not a static label + expect(screen.queryByText(/^(Enabled|Disabled)$/)).not.toBeInTheDocument(); + }); + + it("renders the detail panel read-only for a non-admin", async () => { + await renderTab("Internal User"); + fireEvent.click(screen.getByRole("button", { name: "Review" })); + // Configured headers are still visible to read + expect(screen.getByText("X-Api-Key: secret")).toBeInTheDocument(); + expect(screen.getByText("x-request-id")).toBeInTheDocument(); + // But no editing affordances + expect(screen.queryByPlaceholderText("Header name (e.g. X-API-Key)")).not.toBeInTheDocument(); + expect(screen.queryByPlaceholderText("e.g. x-request-id")).not.toBeInTheDocument(); + // Forward-key renders as a read-only state, not a toggle + expect(screen.getByText("Enabled")).toBeInTheDocument(); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/TeamGuardrailsTab.tsx b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/TeamGuardrailsTab.tsx index 4217a765732..635f327f764 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/TeamGuardrailsTab.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/guardrails/_components/TeamGuardrailsTab.tsx @@ -27,6 +27,7 @@ import { import NotificationsManager from "@/components/molecules/notifications_manager"; import TeamDropdown from "@/components/common_components/team_dropdown"; import { useRegisterGuardrail } from "@/app/(dashboard)/hooks/guardrails/useRegisterGuardrail"; +import { isProxyAdminRole } from "@/utils/roles"; type GuardrailStatus = "active" | "pending" | "rejected"; @@ -212,6 +213,7 @@ type GuardrailCardProps = { guardrail: TeamGuardrail; isSelected: boolean; isHeadersExpanded: boolean; + isAdmin: boolean; onSelect: () => void; onToggleForwardKey: () => void; onToggleHeaders: () => void; @@ -223,6 +225,7 @@ function GuardrailCard({ guardrail: g, isSelected, isHeadersExpanded, + isAdmin, onSelect, onToggleForwardKey, onToggleHeaders, @@ -264,10 +267,12 @@ function GuardrailCard({
-
- Forward API Key - -
+ {isAdmin && ( +
+ Forward API Key + +
+ )}
- {g.status === "pending" && ( + {isAdmin && g.status === "pending" && ( <>
- + {isAdmin ? ( + + ) : ( + {g.forwardKey ? "Enabled" : "Disabled"} + )}

When enabled, the caller's LiteLLM API key is forwarded as an{" "} @@ -456,28 +467,63 @@ function DetailPanel({ {h.key}: {h.value} - + {isAdmin && ( + + )} ))} )} -

- setNewStaticHeaderKey(e.target.value)} - placeholder="Header name (e.g. X-API-Key)" - className="flex-1 min-w-0 text-xs font-mono border border-gray-200 rounded-sm px-2 py-1.5 text-gray-700 placeholder-gray-400 focus:outline-hidden focus:ring-1 focus:ring-blue-500" - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); + {isAdmin && ( +
+ setNewStaticHeaderKey(e.target.value)} + placeholder="Header name (e.g. X-API-Key)" + className="flex-1 min-w-0 text-xs font-mono border border-gray-200 rounded-sm px-2 py-1.5 text-gray-700 placeholder-gray-400 focus:outline-hidden focus:ring-1 focus:ring-blue-500" + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + const key = newStaticHeaderKey.trim(); + const value = newStaticHeaderValue.trim(); + if (key && !g.customHeaders.some((h) => h.key.toLowerCase() === key.toLowerCase())) { + onUpdateCustomHeaders([...g.customHeaders, { key, value }]); + setNewStaticHeaderKey(""); + setNewStaticHeaderValue(""); + } + } + }} + /> + setNewStaticHeaderValue(e.target.value)} + placeholder="Value" + className="flex-1 min-w-0 text-xs font-mono border border-gray-200 rounded-sm px-2 py-1.5 text-gray-700 placeholder-gray-400 focus:outline-hidden focus:ring-1 focus:ring-blue-500" + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + const key = newStaticHeaderKey.trim(); + const value = newStaticHeaderValue.trim(); + if (key && !g.customHeaders.some((h) => h.key.toLowerCase() === key.toLowerCase())) { + onUpdateCustomHeaders([...g.customHeaders, { key, value }]); + setNewStaticHeaderKey(""); + setNewStaticHeaderValue(""); + } + } + }} + /> + -
+ }} + className="text-xs font-medium text-blue-600 hover:text-blue-700 border border-blue-200 bg-blue-50 hover:bg-blue-100 px-2 py-1.5 rounded-sm transition-colors shrink-0" + > + Add + +
+ )}
@@ -546,50 +561,54 @@ function DetailPanel({ className="flex items-center justify-between gap-2 text-xs font-mono bg-gray-50 border border-gray-200 rounded-sm px-2 py-1.5" > {name} - + {isAdmin && ( + + )} ))} )} -
- setNewExtraHeader(e.target.value)} - placeholder="e.g. x-request-id" - className="flex-1 min-w-0 text-xs font-mono border border-gray-200 rounded-sm px-2 py-1.5 text-gray-700 placeholder-gray-400 focus:outline-hidden focus:ring-1 focus:ring-blue-500" - onKeyDown={(e) => { - if (e.key === "Enter") { - e.preventDefault(); + {isAdmin && ( +
+ setNewExtraHeader(e.target.value)} + placeholder="e.g. x-request-id" + className="flex-1 min-w-0 text-xs font-mono border border-gray-200 rounded-sm px-2 py-1.5 text-gray-700 placeholder-gray-400 focus:outline-hidden focus:ring-1 focus:ring-blue-500" + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + const name = newExtraHeader.trim().toLowerCase(); + if (name && !g.extraHeaders.map((h) => h.toLowerCase()).includes(name)) { + onUpdateExtraHeaders([...g.extraHeaders, name]); + setNewExtraHeader(""); + } + } + }} + /> + -
+ }} + className="text-xs font-medium text-blue-600 hover:text-blue-700 border border-blue-200 bg-blue-50 hover:bg-blue-100 px-2 py-1.5 rounded-sm transition-colors" + > + Add + +
+ )}
- {g.status === "pending" && ( + {isAdmin && g.status === "pending" && (