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
This commit is contained in:
ryan-crabbe-berri 2026-07-23 14:28:24 -07:00
parent 1b2a7ce518
commit 6a248be76f
3 changed files with 238 additions and 100 deletions

View file

@ -233,7 +233,7 @@ const GuardrailsPanel: React.FC<GuardrailsPanelProps> = ({ accessToken, userRole
{
key: "submitted",
label: "Submitted Guardrails",
children: <TeamGuardrailsTab accessToken={accessToken} />,
children: <TeamGuardrailsTab accessToken={accessToken} userRole={userRole} />,
},
]}
/>

View file

@ -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(<TeamGuardrailsTab accessToken="sk-test" userRole={userRole} />);
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();
});
});

View file

@ -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({
</div>
</div>
<div className="flex flex-col items-end gap-2 shrink-0">
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500 whitespace-nowrap">Forward API Key</span>
<Toggle enabled={g.forwardKey} onToggle={onToggleForwardKey} />
</div>
{isAdmin && (
<div className="flex items-center gap-2">
<span className="text-xs text-gray-500 whitespace-nowrap">Forward API Key</span>
<Toggle enabled={g.forwardKey} onToggle={onToggleForwardKey} />
</div>
)}
<div className="flex items-center gap-2 mt-1">
<button
type="button"
@ -276,7 +281,7 @@ function GuardrailCard({
>
{isSelected ? "Close" : "Review"}
</button>
{g.status === "pending" && (
{isAdmin && g.status === "pending" && (
<>
<button
type="button"
@ -348,6 +353,7 @@ function ConfigRow({ label, children }: { label: string; children: React.ReactNo
type DetailPanelProps = {
guardrail: TeamGuardrail;
isAdmin: boolean;
onClose: () => void;
onApprove: () => void;
onReject: () => void;
@ -358,6 +364,7 @@ type DetailPanelProps = {
function DetailPanel({
guardrail: g,
isAdmin,
onClose,
onApprove,
onReject,
@ -425,7 +432,11 @@ function DetailPanel({
<KeyIcon className="h-3.5 w-3.5 text-blue-500" />
<span className="text-xs font-semibold text-blue-800">Forward LiteLLM API Key</span>
</div>
<Toggle enabled={g.forwardKey} onToggle={onToggleForwardKey} />
{isAdmin ? (
<Toggle enabled={g.forwardKey} onToggle={onToggleForwardKey} />
) : (
<span className="text-xs font-semibold text-blue-800">{g.forwardKey ? "Enabled" : "Disabled"}</span>
)}
</div>
<p className="text-xs text-blue-700 leading-relaxed">
When enabled, the caller&apos;s LiteLLM API key is forwarded as an{" "}
@ -456,28 +467,63 @@ function DetailPanel({
<span className="text-gray-700 truncate">
{h.key}: {h.value}
</span>
<button
type="button"
onClick={() => onUpdateCustomHeaders(g.customHeaders.filter((_, idx) => idx !== i))}
className="text-gray-400 hover:text-red-600 shrink-0"
aria-label={`Remove ${h.key}`}
>
<XIcon className="h-3.5 w-3.5" />
</button>
{isAdmin && (
<button
type="button"
onClick={() => onUpdateCustomHeaders(g.customHeaders.filter((_, idx) => idx !== i))}
className="text-gray-400 hover:text-red-600 shrink-0"
aria-label={`Remove ${h.key}`}
>
<XIcon className="h-3.5 w-3.5" />
</button>
)}
</li>
))}
</ul>
)}
<div className="flex flex-col gap-2 sm:flex-row sm:items-end">
<input
type="text"
value={newStaticHeaderKey}
onChange={(e) => 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 && (
<div className="flex flex-col gap-2 sm:flex-row sm:items-end">
<input
type="text"
value={newStaticHeaderKey}
onChange={(e) => 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("");
}
}
}}
/>
<input
type="text"
value={newStaticHeaderValue}
onChange={(e) => 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("");
}
}
}}
/>
<button
type="button"
onClick={() => {
const key = newStaticHeaderKey.trim();
const value = newStaticHeaderValue.trim();
if (key && !g.customHeaders.some((h) => h.key.toLowerCase() === key.toLowerCase())) {
@ -485,44 +531,13 @@ function DetailPanel({
setNewStaticHeaderKey("");
setNewStaticHeaderValue("");
}
}
}}
/>
<input
type="text"
value={newStaticHeaderValue}
onChange={(e) => 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("");
}
}
}}
/>
<button
type="button"
onClick={() => {
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
</button>
</div>
}}
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
</button>
</div>
)}
</div>
<div>
<div className="flex items-center gap-1.5 mb-2">
@ -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"
>
<span className="text-gray-700 truncate">{name}</span>
<button
type="button"
onClick={() => onUpdateExtraHeaders(g.extraHeaders.filter((_, idx) => idx !== i))}
className="text-gray-400 hover:text-red-600 shrink-0"
aria-label={`Remove ${name}`}
>
<XIcon className="h-3.5 w-3.5" />
</button>
{isAdmin && (
<button
type="button"
onClick={() => onUpdateExtraHeaders(g.extraHeaders.filter((_, idx) => idx !== i))}
className="text-gray-400 hover:text-red-600 shrink-0"
aria-label={`Remove ${name}`}
>
<XIcon className="h-3.5 w-3.5" />
</button>
)}
</li>
))}
</ul>
)}
<div className="flex gap-2">
<input
type="text"
value={newExtraHeader}
onChange={(e) => 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 && (
<div className="flex gap-2">
<input
type="text"
value={newExtraHeader}
onChange={(e) => 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("");
}
}
}}
/>
<button
type="button"
onClick={() => {
const name = newExtraHeader.trim().toLowerCase();
if (name && !g.extraHeaders.map((h) => h.toLowerCase()).includes(name)) {
onUpdateExtraHeaders([...g.extraHeaders, name]);
setNewExtraHeader("");
}
}
}}
/>
<button
type="button"
onClick={() => {
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
</button>
</div>
}}
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
</button>
</div>
)}
</div>
<div className="border border-gray-200 rounded-lg overflow-hidden">
<button
@ -635,7 +654,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"
@ -719,9 +738,11 @@ function ConfirmDialog({ action, guardrailName, onConfirm, onCancel }: ConfirmDi
interface TeamGuardrailsTabProps {
accessToken: string | null;
userRole?: string;
}
export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) {
export function TeamGuardrailsTab({ accessToken, userRole }: TeamGuardrailsTabProps) {
const isAdmin = userRole ? isProxyAdminRole(userRole) : false;
const [guardrails, setGuardrails] = useState<TeamGuardrail[]>([]);
const [summary, setSummary] = useState({
total: 0,
@ -922,6 +943,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)}
@ -934,6 +956,7 @@ export function TeamGuardrailsTab({ accessToken }: TeamGuardrailsTabProps) {
{selected && (
<DetailPanel
guardrail={selected}
isAdmin={isAdmin}
onClose={() => setSelectedId(null)}
onApprove={() => setConfirmAction({ id: selected.id, action: "approve" })}
onReject={() => setConfirmAction({ id: selected.id, action: "reject" })}