mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-04 02:31:27 +00:00
fix(ui): hide guardrail review buttons from non-admin users
The team guardrail submissions list rendered Approve/Reject buttons for non-admin users even though the backend correctly rejected the calls. Thread userRole from the page through GuardrailsPanel into TeamGuardrailsTab and gate the row-card and detail-panel review buttons on isAdmin so the UI matches the backend authorization. Defense in depth only — the backend remains the source of truth and is double-gated at both the route admin check and the explicit endpoint role check. Refs LIT-2494
This commit is contained in:
parent
b5d3a5fc85
commit
fe240e608e
4 changed files with 95 additions and 6 deletions
|
|
@ -4,9 +4,9 @@ import GuardrailsPanel from "@/components/guardrails";
|
|||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||
|
||||
const GuardrailsPage = () => {
|
||||
const { accessToken } = useAuthorized();
|
||||
const { accessToken, userRole } = useAuthorized();
|
||||
|
||||
return <GuardrailsPanel accessToken={accessToken} />;
|
||||
return <GuardrailsPanel accessToken={accessToken} userRole={userRole} />;
|
||||
};
|
||||
|
||||
export default GuardrailsPage;
|
||||
|
|
|
|||
|
|
@ -252,7 +252,7 @@ const GuardrailsPanel: React.FC<GuardrailsPanelProps> = ({ accessToken, userRole
|
|||
{
|
||||
key: "submitted",
|
||||
label: "Submitted Guardrails",
|
||||
children: <TeamGuardrailsTab accessToken={accessToken} />,
|
||||
children: <TeamGuardrailsTab accessToken={accessToken} userRole={userRole} />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -25,6 +25,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 { isAdminRole } from "@/utils/roles";
|
||||
|
||||
type GuardrailStatus = "active" | "pending" | "rejected";
|
||||
|
||||
|
|
@ -229,6 +230,7 @@ type GuardrailCardProps = {
|
|||
guardrail: TeamGuardrail;
|
||||
isSelected: boolean;
|
||||
isHeadersExpanded: boolean;
|
||||
isAdmin: boolean;
|
||||
onSelect: () => void;
|
||||
onToggleForwardKey: () => void;
|
||||
onToggleHeaders: () => void;
|
||||
|
|
@ -240,6 +242,7 @@ function GuardrailCard({
|
|||
guardrail: g,
|
||||
isSelected,
|
||||
isHeadersExpanded,
|
||||
isAdmin,
|
||||
onSelect,
|
||||
onToggleForwardKey,
|
||||
onToggleHeaders,
|
||||
|
|
@ -304,7 +307,7 @@ function GuardrailCard({
|
|||
>
|
||||
{isSelected ? "Close" : "Review"}
|
||||
</button>
|
||||
{g.status === "pending" && (
|
||||
{isAdmin && g.status === "pending" && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -391,6 +394,7 @@ function ConfigRow({
|
|||
|
||||
type DetailPanelProps = {
|
||||
guardrail: TeamGuardrail;
|
||||
isAdmin: boolean;
|
||||
onClose: () => void;
|
||||
onApprove: () => void;
|
||||
onReject: () => void;
|
||||
|
|
@ -403,6 +407,7 @@ type DetailPanelProps = {
|
|||
|
||||
function DetailPanel({
|
||||
guardrail: g,
|
||||
isAdmin,
|
||||
onClose,
|
||||
onApprove,
|
||||
onReject,
|
||||
|
|
@ -709,7 +714,7 @@ function DetailPanel({
|
|||
<ExternalLinkIcon className="h-4 w-4" />
|
||||
Test Endpoint
|
||||
</button>
|
||||
{g.status === "pending" && (
|
||||
{isAdmin && g.status === "pending" && (
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -800,9 +805,11 @@ function ConfirmDialog({
|
|||
|
||||
interface TeamGuardrailsTabProps {
|
||||
accessToken: string | null;
|
||||
userRole?: string;
|
||||
}
|
||||
|
||||
export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) {
|
||||
export function TeamGuardrailsTab({ accessToken, userRole }: TeamGuardrailsTabProps) {
|
||||
const isAdmin = userRole ? isAdminRole(userRole) : false;
|
||||
const [guardrails, setGuardrails] = useState<TeamGuardrail[]>([]);
|
||||
const [summary, setSummary] = useState({
|
||||
total: 0,
|
||||
|
|
@ -1041,6 +1048,7 @@ export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) {
|
|||
guardrail={g}
|
||||
isSelected={selectedId === g.id}
|
||||
isHeadersExpanded={expandedHeaders.has(g.id)}
|
||||
isAdmin={isAdmin}
|
||||
onSelect={() => setSelectedId(selectedId === g.id ? null : g.id)}
|
||||
onToggleForwardKey={() => toggleForwardKey(g.id)}
|
||||
onToggleHeaders={() => toggleHeaders(g.id)}
|
||||
|
|
@ -1053,6 +1061,7 @@ export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) {
|
|||
{selected && (
|
||||
<DetailPanel
|
||||
guardrail={selected}
|
||||
isAdmin={isAdmin}
|
||||
onClose={() => setSelectedId(null)}
|
||||
onApprove={() =>
|
||||
setConfirmAction({ id: selected.id, action: "approve" })
|
||||
|
|
|
|||
80
ui/litellm-dashboard/tests/team_guardrails_tab.test.tsx
Normal file
80
ui/litellm-dashboard/tests/team_guardrails_tab.test.tsx
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderWithProviders, screen } from "./test-utils";
|
||||
import { TeamGuardrailsTab } from "../src/components/guardrails/TeamGuardrailsTab";
|
||||
|
||||
vi.mock("../src/components/networking", () => ({
|
||||
listGuardrailSubmissions: vi.fn(),
|
||||
approveGuardrailSubmission: vi.fn(),
|
||||
rejectGuardrailSubmission: vi.fn(),
|
||||
updateGuardrailCall: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../src/app/(dashboard)/hooks/guardrails/useRegisterGuardrail", () => ({
|
||||
useRegisterGuardrail: () => ({
|
||||
mutateAsync: vi.fn(),
|
||||
isPending: false,
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("../src/components/common_components/team_dropdown", () => ({
|
||||
default: () => null,
|
||||
}));
|
||||
|
||||
import { listGuardrailSubmissions } from "../src/components/networking";
|
||||
|
||||
const pendingSubmission = {
|
||||
guardrail_id: "guard-1",
|
||||
guardrail_name: "test-pending-guardrail",
|
||||
status: "pending_review",
|
||||
team_id: "team-1",
|
||||
team_guardrail: true,
|
||||
litellm_params: {
|
||||
guardrail: "generic_guardrail_api",
|
||||
mode: "pre_call",
|
||||
api_base: "https://example.com/guard",
|
||||
},
|
||||
guardrail_info: {},
|
||||
submitted_at: "2026-05-09T00:00:00Z",
|
||||
};
|
||||
|
||||
describe("TeamGuardrailsTab — approve/reject role gate", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(listGuardrailSubmissions).mockResolvedValue({
|
||||
submissions: [pendingSubmission],
|
||||
summary: { total: 1, pending_review: 1, active: 0, rejected: 0 },
|
||||
});
|
||||
});
|
||||
|
||||
it("hides Approve and Reject buttons for an internal user on a pending submission", async () => {
|
||||
renderWithProviders(
|
||||
<TeamGuardrailsTab accessToken="test-token" userRole="Internal User" />
|
||||
);
|
||||
|
||||
// Wait for the submission row to render
|
||||
await screen.findByText("test-pending-guardrail");
|
||||
|
||||
expect(screen.queryByRole("button", { name: /approve/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /reject/i })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("shows Approve and Reject buttons for an admin on a pending submission", async () => {
|
||||
renderWithProviders(
|
||||
<TeamGuardrailsTab accessToken="test-token" userRole="Admin" />
|
||||
);
|
||||
|
||||
await screen.findByText("test-pending-guardrail");
|
||||
|
||||
expect(screen.getByRole("button", { name: /approve/i })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: /reject/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("hides Approve and Reject buttons when userRole is undefined (defaults to non-admin)", async () => {
|
||||
renderWithProviders(<TeamGuardrailsTab accessToken="test-token" />);
|
||||
|
||||
await screen.findByText("test-pending-guardrail");
|
||||
|
||||
expect(screen.queryByRole("button", { name: /approve/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: /reject/i })).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue