Merge pull request #36335 from BerriAI/litellm_/sleepy-nightingale-0d66fe

fix(ui): gate policy and prompt lookups on an admin capability
This commit is contained in:
yuneng-jiang 2026-08-10 13:53:44 -07:00 committed by GitHub
commit 8335dbbcfd
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
16 changed files with 530 additions and 219 deletions

View file

@ -25,6 +25,7 @@ import React, { useEffect, useRef, useState } from "react";
import { Prism as SyntaxHighlighter } from "react-syntax-highlighter";
import { coy } from "react-syntax-highlighter/dist/esm/styles/prism";
import { v4 as uuidv4 } from "uuid";
import useCan from "@/app/(dashboard)/hooks/useCan";
import GuardrailSelector from "@/components/guardrails/GuardrailSelector";
import PolicySelector from "@/components/policies/PolicySelector";
import MCPToolArgumentsForm, { MCPToolArgumentsFormRef } from "@/components/mcp_tools/MCPToolArgumentsForm";
@ -106,6 +107,7 @@ const ChatUI: React.FC<ChatUIProps> = ({
simplified = false,
fixedModel,
}) => {
const canViewPolicies = useCan("viewPolicies");
const [mcpServers, setMCPServers] = useState<MCPServer[]>([]);
const [mcpToolsets, setMCPToolsets] = useState<MCPToolset[]>([]);
const [isToolsetsInfoModalVisible, setIsToolsetsInfoModalVisible] = useState(false);
@ -1652,32 +1654,34 @@ const ChatUI: React.FC<ChatUIProps> = ({
/>
</div>
<div>
<Text className="font-medium block mb-2 text-gray-700 flex items-center">
<SafetyOutlined className="mr-2" /> Policies
<Tooltip
className="ml-1"
title={
<span>
Select policy/policies to apply to this LLM API call. Policies define which guardrails are
applied based on conditions. You can set up your policies{" "}
<a href="?page=policies" style={{ color: "#1890ff" }}>
here
</a>
.
</span>
}
>
<InfoCircleOutlined />
</Tooltip>
</Text>
<PolicySelector
value={selectedPolicies}
onChange={setSelectedPolicies}
className="mb-4"
accessToken={accessToken || ""}
/>
</div>
{canViewPolicies && (
<div>
<Text className="font-medium block mb-2 text-gray-700 flex items-center">
<SafetyOutlined className="mr-2" /> Policies
<Tooltip
className="ml-1"
title={
<span>
Select policy/policies to apply to this LLM API call. Policies define which guardrails are
applied based on conditions. You can set up your policies{" "}
<a href="?page=policies" style={{ color: "#1890ff" }}>
here
</a>
.
</span>
}
>
<InfoCircleOutlined />
</Tooltip>
</Text>
<PolicySelector
value={selectedPolicies}
onChange={setSelectedPolicies}
className="mb-4"
accessToken={accessToken || ""}
/>
</div>
)}
{/* Code Interpreter Toggle - Only for Responses endpoint */}
{endpointType === EndpointType.RESPONSES && (

View file

@ -6,6 +6,7 @@ import {
type ComplianceFramework,
type CompliancePrompt,
} from "@/data/compliancePrompts";
import useCan from "@/app/(dashboard)/hooks/useCan";
import { getGuardrailsList, testPoliciesAndGuardrails } from "@/components/networking";
import PolicySelector, { getPolicyOptionEntries } from "@/components/policies/PolicySelector";
import { Policy } from "@/components/policies/types";
@ -123,6 +124,7 @@ export default function ComplianceUI({
fixedModel,
proxySettings,
}: ComplianceUIProps) {
const canViewPolicies = useCan("viewPolicies");
const frameworks = getFrameworks();
const [policyValueToLabel, setPolicyValueToLabel] = useState<Map<string, string>>(new Map());
@ -701,29 +703,37 @@ export default function ComplianceUI({
<div className="shrink-0 border-b border-gray-200 px-6 py-4">
<div className="mb-3">
<h3 className="text-sm font-semibold text-gray-900">Test Configuration</h3>
<p className="text-xs text-gray-500 mt-0.5">Select policies, guardrails, or both to test against.</p>
<p className="text-xs text-gray-500 mt-0.5">
{canViewPolicies
? "Select policies, guardrails, or both to test against."
: "Select guardrails to test against."}
</p>
</div>
<div className="flex items-start gap-3 flex-wrap">
<div className="flex-1 min-w-[200px]">
<label className="text-[11px] font-medium text-gray-500 uppercase tracking-wide mb-1.5 block">
Policies
</label>
{accessToken && (
<PolicySelector
value={selectedPolicies}
onChange={setSelectedPolicies}
accessToken={accessToken}
onPoliciesLoaded={handlePoliciesLoaded}
/>
)}
</div>
{canViewPolicies && (
<>
<div className="flex-1 min-w-[200px]">
<label className="text-[11px] font-medium text-gray-500 uppercase tracking-wide mb-1.5 block">
Policies
</label>
{accessToken && (
<PolicySelector
value={selectedPolicies}
onChange={setSelectedPolicies}
accessToken={accessToken}
onPoliciesLoaded={handlePoliciesLoaded}
/>
)}
</div>
<div className="flex flex-col items-center pt-6 shrink-0">
<div className="w-px h-4 bg-gray-200" />
<span className="text-[10px] font-medium text-gray-400 my-1">or</span>
<div className="w-px h-4 bg-gray-200" />
</div>
<div className="flex flex-col items-center pt-6 shrink-0">
<div className="w-px h-4 bg-gray-200" />
<span className="text-[10px] font-medium text-gray-400 my-1">or</span>
<div className="w-px h-4 bg-gray-200" />
</div>
</>
)}
<div className="flex-1 min-w-[200px]">
<label className="text-[11px] font-medium text-gray-500 uppercase tracking-wide mb-1.5 block">

View file

@ -6,9 +6,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
import { useTeamMetadataSchema } from "@/app/(dashboard)/hooks/teams/useTeamMetadataSchema";
import NotificationsManager from "./molecules/notifications_manager";
import { fetchAvailableModelsForTeamOrKey } from "./key_team_helpers/fetch_available_models_team_key";
import { fetchMCPAccessGroups, getGuardrailsList, teamCreateCall } from "./networking";
import { fetchMCPAccessGroups, getGuardrailsList, getPoliciesList, teamCreateCall } from "./networking";
import Teams from "./Teams";
const can = vi.fn();
vi.mock("@/app/(dashboard)/hooks/useCan", () => ({
default: (...args: unknown[]) => can(...args),
}));
const mockTeamInfoView = vi.fn();
const mockUseOrganizations = vi.fn();
@ -173,6 +178,7 @@ const renderWithQueryClient = (
// Re-establish safe defaults before every test (clearAllMocks keeps return values, so restore them here).
beforeEach(() => {
mockTeamsTableProps = null;
can.mockReturnValue(true);
});
describe("Teams - handleCreate organization handling", () => {
@ -956,3 +962,50 @@ describe("Teams - LIT-2530 organization stays optional for proxy admin with a si
});
});
});
describe("Teams - policies field is gated on the viewPolicies capability", () => {
beforeEach(() => {
vi.clearAllMocks();
mockTeamInfoView.mockClear();
vi.mocked(fetchAvailableModelsForTeamOrKey).mockResolvedValue(["gpt-4"]);
vi.mocked(fetchMCPAccessGroups).mockResolvedValue([]);
vi.mocked(getGuardrailsList).mockResolvedValue({ guardrails: [] });
vi.mocked(getPoliciesList).mockResolvedValue({ policies: [] });
mockUseOrganizations.mockReturnValue({ data: null });
});
const openAdditionalSettings = async () => {
renderWithQueryClient(<Teams accessToken="test-token" userID="user-123" userRole="Admin" />);
act(() => {
fireEvent.click(screen.getAllByRole("button", { name: /create team/i })[0]);
});
await waitFor(() => {
expect(screen.getByLabelText(/team name/i)).toBeInTheDocument();
});
fireEvent.click(screen.getByText("Additional Settings"));
await waitFor(() => {
expect(screen.getByTestId("access-group-selector")).toBeInTheDocument();
});
};
it("should render the policies field and load it when the capability is present", async () => {
await openAdditionalSettings();
expect(can).toHaveBeenCalledWith("viewPolicies");
expect(getPoliciesList).toHaveBeenCalledWith("test-token");
expect(screen.getByText("Policies")).toBeInTheDocument();
});
it("should omit the policies field and skip the admin-only list without the capability", async () => {
can.mockReturnValue(false);
await openAdditionalSettings();
expect(getPoliciesList).not.toHaveBeenCalled();
expect(screen.queryByText("Policies")).not.toBeInTheDocument();
});
});

View file

@ -1,4 +1,5 @@
import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
import useCan from "@/app/(dashboard)/hooks/useCan";
import AvailableTeamsPanel from "@/components/team/AvailableTeamsPanel";
import TeamInfoView from "@/components/team/TeamInfo";
import TeamSSOSettings from "@/components/TeamSSOSettings";
@ -108,6 +109,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
const [isTeamDeleting, setIsTeamDeleting] = useState(false);
// Add this state near the other useState declarations
const [guardrailsList, setGuardrailsList] = useState<string[]>([]);
const canViewPolicies = useCan("viewPolicies");
const [policiesList, setPoliciesList] = useState<string[]>([]);
const [loggingSettings, setLoggingSettings] = useState<any[]>([]);
const [modelAliases, setModelAliases] = useState<{ [key: string]: string }>({});
@ -168,8 +170,8 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
};
fetchGuardrails();
fetchPolicies();
}, [accessToken]);
if (canViewPolicies) fetchPolicies();
}, [accessToken, canViewPolicies]);
const handleOk = () => {
setIsTeamModalVisible(false);
@ -795,36 +797,38 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
}
/>
</Form.Item>
<Form.Item
label={
<span>
Policies{" "}
<Tooltip title="Apply policies to this team to control guardrails and other settings">
<a
href="https://docs.litellm.ai/docs/proxy/guardrails/guardrail_policies"
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
>
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</a>
</Tooltip>
</span>
}
name="policies"
className="mt-8"
help="Select existing policies or enter new ones"
>
<Select
mode="tags"
style={{ width: "100%" }}
placeholder="Select or enter policies"
options={policiesList.map((name) => ({
value: name,
label: name,
}))}
/>
</Form.Item>
{canViewPolicies && (
<Form.Item
label={
<span>
Policies{" "}
<Tooltip title="Apply policies to this team to control guardrails and other settings">
<a
href="https://docs.litellm.ai/docs/proxy/guardrails/guardrail_policies"
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
>
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</a>
</Tooltip>
</span>
}
name="policies"
className="mt-8"
help="Select existing policies or enter new ones"
>
<Select
mode="tags"
style={{ width: "100%" }}
placeholder="Select or enter policies"
options={policiesList.map((name) => ({
value: name,
label: name,
}))}
/>
</Form.Item>
)}
<Form.Item
label={
<span>

View file

@ -210,6 +210,7 @@ describe("Sidebar (leftnav)", () => {
userId: "internal-user-id",
accessToken: "test-access-token",
userRole: "internal",
isViewOnly: false,
token: "test-token",
userEmail: "internal@example.com",
premiumUser: false,
@ -244,6 +245,27 @@ describe("Sidebar (leftnav)", () => {
expect(screen.getByText("Tool Policies")).toBeInTheDocument();
});
});
it("should hide the Policies entry from internal users while keeping Guardrails", () => {
mockUseAuthorized.mockReturnValue(internalAuth);
renderWithProviders(<Sidebar {...defaultProps} />);
expect(screen.getByText("Guardrails")).toBeInTheDocument();
expect(screen.queryByText("Policies")).not.toBeInTheDocument();
});
it("should hide the Prompts entry from internal users while keeping other Experimental children", async () => {
mockUseAuthorized.mockReturnValue(internalAuth);
renderWithProviders(<Sidebar {...defaultProps} />);
act(() => {
fireEvent.click(screen.getByText("Experimental"));
});
await waitFor(() => {
expect(screen.getByText("API Playground")).toBeInTheDocument();
});
expect(screen.queryByText("Prompts")).not.toBeInTheDocument();
});
});
it("should show Organizations tab for organization admins", () => {

View file

@ -158,7 +158,7 @@ const menuGroups: MenuGroup[] = [
page: "policies",
label: "Policies",
icon: <ScrollText {...ICON} />,
roles: all_admin_roles,
roles: rolesWithCapability("viewPolicies"),
},
{
key: "tools",
@ -268,7 +268,13 @@ const menuGroups: MenuGroup[] = [
label: "Experimental",
icon: <FlaskConical {...ICON} />,
children: [
{ key: "prompts", page: "prompts", label: "Prompts", icon: <FileText {...ICON} />, roles: all_admin_roles },
{
key: "prompts",
page: "prompts",
label: "Prompts",
icon: <FileText {...ICON} />,
roles: rolesWithCapability("viewPrompts"),
},
{
key: "transform-request",
page: "transform-request",

View file

@ -2,7 +2,7 @@ import { act, fireEvent, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders, screen, waitFor } from "../../../tests/test-utils";
import { Team } from "../key_team_helpers/key_list";
import { userFilterUICall } from "../networking";
import { getPoliciesList, getPromptsList, userFilterUICall } from "../networking";
import CreateKey from "./create_key_button";
const { formMock, setFieldsValueMock, radioGroupValueRef, formStateRef, mockKeyCreateCall, teamDropdownTeamsRef } =
@ -777,4 +777,47 @@ describe("CreateKey", () => {
});
});
});
describe("policy and prompt fields", () => {
const POLICIES_PLACEHOLDER = "Premium feature - Upgrade to set policies by key";
const PROMPTS_PLACEHOLDER = "Premium feature - Upgrade to set prompts by key";
const openModal = () => {
renderWithProviders(<CreateKey {...defaultProps} />);
act(() => {
fireEvent.click(screen.getByRole("button", { name: /create new key/i }));
});
};
beforeEach(() => {
vi.mocked(getPoliciesList).mockResolvedValue({ policies: [{ policy_name: "policy-a" }] });
vi.mocked(getPromptsList).mockResolvedValue({ prompts: [{ prompt_id: "prompt-a" }] } as any);
});
it("should load and offer both selectors for an admin", async () => {
openModal();
await waitFor(() => {
expect(screen.getByRole("option", { name: "policy-a" })).toBeInTheDocument();
expect(screen.getByRole("option", { name: "prompt-a" })).toBeInTheDocument();
});
expect(getPoliciesList).toHaveBeenCalledWith("test-token");
expect(getPromptsList).toHaveBeenCalledWith("test-token");
expect(screen.getByPlaceholderText(POLICIES_PLACEHOLDER)).toBeInTheDocument();
expect(screen.getByPlaceholderText(PROMPTS_PLACEHOLDER)).toBeInTheDocument();
});
it("should omit both selectors and fire neither admin-only request for an internal user", async () => {
authorizedState = { ...defaultAuthorizedState, userRole: "Internal User" };
openModal();
expect(await screen.findByTestId("org-dropdown")).toBeInTheDocument();
expect(getPoliciesList).not.toHaveBeenCalled();
expect(getPromptsList).not.toHaveBeenCalled();
expect(screen.queryByPlaceholderText(POLICIES_PLACEHOLDER)).not.toBeInTheDocument();
expect(screen.queryByPlaceholderText(PROMPTS_PLACEHOLDER)).not.toBeInTheDocument();
});
});
});

View file

@ -5,6 +5,7 @@ import { useProjects } from "@/app/(dashboard)/hooks/projects/useProjects";
import { useTags } from "@/app/(dashboard)/hooks/tags/useTags";
import { useUISettings } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import useCan from "@/app/(dashboard)/hooks/useCan";
import { formatNumberWithCommas } from "@/utils/dataUtils";
import { InfoCircleOutlined } from "@ant-design/icons";
import { useQueryClient } from "@tanstack/react-query";
@ -147,6 +148,8 @@ export const fetchUserModels = async (
const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOpenCreate, prefillData }) => {
const { accessToken, userId: userID, userRole, premiumUser } = useAuthorized();
const canEditGuardrails = premiumUser || (userRole != null && rolesWithWriteAccess.includes(userRole));
const canViewPolicies = useCan("viewPolicies");
const canViewPrompts = useCan("viewPrompts");
const { data: organizations, isLoading: isOrganizationsLoading } = useOrganizations();
const { data: projects, isLoading: isProjectsLoading } = useProjects();
const { data: uiSettingsData } = useUISettings();
@ -275,9 +278,9 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
};
fetchGuardrails();
fetchPolicies();
fetchPrompts();
}, [accessToken]);
if (canViewPolicies) fetchPolicies();
if (canViewPrompts) fetchPrompts();
}, [accessToken, canViewPolicies, canViewPrompts]);
// Fetch possible user roles when component mounts
useEffect(() => {
@ -1251,74 +1254,78 @@ const CreateKey: React.FC<CreateKeyProps> = ({ team, teams, data, addKey, autoOp
>
<Switch disabled={!canEditGuardrails} checkedChildren="Yes" unCheckedChildren="No" />
</Form.Item>
<Form.Item
label={
<span>
Policies{" "}
<Tooltip title="Apply policies to this key to control guardrails and other settings">
<a
href="https://docs.litellm.ai/docs/proxy/guardrails/guardrail_policies"
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()} // Prevent accordion from collapsing when clicking link
>
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</a>
</Tooltip>
</span>
}
name="policies"
className="mt-4"
help={
premiumUser
? "Select existing policies or enter new ones"
: "Premium feature - Upgrade to set policies by key"
}
>
<Select
mode="tags"
style={{ width: "100%" }}
disabled={!premiumUser}
placeholder={
!premiumUser ? "Premium feature - Upgrade to set policies by key" : "Select or enter policies"
{canViewPolicies && (
<Form.Item
label={
<span>
Policies{" "}
<Tooltip title="Apply policies to this key to control guardrails and other settings">
<a
href="https://docs.litellm.ai/docs/proxy/guardrails/guardrail_policies"
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()} // Prevent accordion from collapsing when clicking link
>
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</a>
</Tooltip>
</span>
}
options={policiesList.map((name) => ({ value: name, label: name }))}
/>
</Form.Item>
<Form.Item
label={
<span>
Prompts{" "}
<Tooltip title="Allow this key to use specific prompt templates">
<a
href="https://docs.litellm.ai/docs/proxy/prompt_management"
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()} // Prevent accordion from collapsing when clicking link
>
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</a>
</Tooltip>
</span>
}
name="prompts"
className="mt-4"
help={
premiumUser
? "Select existing prompts or enter new ones"
: "Premium feature - Upgrade to set prompts by key"
}
>
<Select
mode="tags"
style={{ width: "100%" }}
disabled={!premiumUser}
placeholder={
!premiumUser ? "Premium feature - Upgrade to set prompts by key" : "Select or enter prompts"
name="policies"
className="mt-4"
help={
premiumUser
? "Select existing policies or enter new ones"
: "Premium feature - Upgrade to set policies by key"
}
options={promptsList.map((name) => ({ value: name, label: name }))}
/>
</Form.Item>
>
<Select
mode="tags"
style={{ width: "100%" }}
disabled={!premiumUser}
placeholder={
!premiumUser ? "Premium feature - Upgrade to set policies by key" : "Select or enter policies"
}
options={policiesList.map((name) => ({ value: name, label: name }))}
/>
</Form.Item>
)}
{canViewPrompts && (
<Form.Item
label={
<span>
Prompts{" "}
<Tooltip title="Allow this key to use specific prompt templates">
<a
href="https://docs.litellm.ai/docs/proxy/prompt_management"
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()} // Prevent accordion from collapsing when clicking link
>
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</a>
</Tooltip>
</span>
}
name="prompts"
className="mt-4"
help={
premiumUser
? "Select existing prompts or enter new ones"
: "Premium feature - Upgrade to set prompts by key"
}
>
<Select
mode="tags"
style={{ width: "100%" }}
disabled={!premiumUser}
placeholder={
!premiumUser ? "Premium feature - Upgrade to set prompts by key" : "Select or enter prompts"
}
options={promptsList.map((name) => ({ value: name, label: name }))}
/>
</Form.Item>
)}
<Form.Item
label={
<span>

View file

@ -7,6 +7,11 @@ import { Policy } from "./types";
vi.mock("../networking");
const can = vi.fn();
vi.mock("@/app/(dashboard)/hooks/useCan", () => ({
default: (...args: unknown[]) => can(...args),
}));
const makePolicy = (overrides: Partial<Policy>): Policy => ({
policy_id: "uuid-1",
policy_name: "test-policy",
@ -76,6 +81,7 @@ describe("PolicySelector", () => {
beforeEach(() => {
vi.clearAllMocks();
can.mockReturnValue(true);
});
it("should render", () => {
@ -114,4 +120,18 @@ describe("PolicySelector", () => {
renderWithProviders(<PolicySelector accessToken="" onChange={mockOnChange} />);
expect(networking.getPoliciesList).not.toHaveBeenCalled();
});
it("should render nothing and skip the admin-only fetch without the viewPolicies capability", async () => {
can.mockReturnValue(false);
vi.mocked(networking.getPoliciesList).mockResolvedValue({ policies: [] });
const { container } = renderWithProviders(<PolicySelector accessToken="tok" onChange={mockOnChange} />);
await waitFor(() => {
expect(can).toHaveBeenCalledWith("viewPolicies");
});
expect(networking.getPoliciesList).not.toHaveBeenCalled();
expect(screen.queryByRole("combobox")).not.toBeInTheDocument();
expect(container).toBeEmptyDOMElement();
});
});

View file

@ -1,5 +1,6 @@
import React, { useEffect, useState } from "react";
import { Select } from "antd";
import useCan from "@/app/(dashboard)/hooks/useCan";
import { Policy } from "./types";
import { getPoliciesList } from "../networking";
@ -51,12 +52,13 @@ const PolicySelector: React.FC<PolicySelectorProps> = ({
disabled,
onPoliciesLoaded,
}) => {
const canViewPolicies = useCan("viewPolicies");
const [policies, setPolicies] = useState<Policy[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
const fetchPolicies = async () => {
if (!accessToken) return;
if (!accessToken || !canViewPolicies) return;
setLoading(true);
try {
@ -73,12 +75,16 @@ const PolicySelector: React.FC<PolicySelectorProps> = ({
};
fetchPolicies();
}, [accessToken, onPoliciesLoaded]);
}, [accessToken, canViewPolicies, onPoliciesLoaded]);
const handlePolicyChange = (selectedValues: string[]) => {
onChange(selectedValues);
};
if (!canViewPolicies) {
return null;
}
return (
<div>
<Select

View file

@ -37,6 +37,11 @@ vi.mock("@/components/networking", () => ({
getPassThroughEndpointsCall: vi.fn(),
}));
const can = vi.fn();
vi.mock("@/app/(dashboard)/hooks/useCan", () => ({
default: (...args: unknown[]) => can(...args),
}));
vi.mock("@/components/utils/dataUtils", () => ({
copyToClipboard: vi.fn().mockResolvedValue(true),
formatNumberWithCommas: vi.fn((value: number) => value.toLocaleString()),
@ -242,6 +247,7 @@ describe("TeamInfoView", () => {
} as any);
vi.mocked(useTeamMetadataSchema).mockReturnValue({ data: [], isLoading: false } as any);
can.mockReturnValue(true);
vi.mocked(networking.getGuardrailsList).mockResolvedValue({ guardrails: [] });
vi.mocked(networking.getPoliciesList).mockResolvedValue({ policies: [] });
vi.mocked(networking.fetchMCPAccessGroups).mockResolvedValue([]);
@ -647,6 +653,43 @@ describe("TeamInfoView", () => {
});
describe("settings and editing", () => {
const policiesFormFieldLabel = () => screen.queryByText("Policies", { selector: "span" });
it("should offer the policies field and load it for a caller with the viewPolicies capability", async () => {
const user = userEvent.setup({ delay: null });
vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData());
renderWithProviders(<TeamInfoView {...defaultProps} />);
await waitFor(() => {
expect(networking.getPoliciesList).toHaveBeenCalled();
});
expect(can).toHaveBeenCalledWith("viewPolicies");
await user.click(screen.getByRole("tab", { name: "Settings" }));
await user.click(await screen.findByRole("button", { name: /edit settings/i }));
await waitFor(() => {
expect(policiesFormFieldLabel()).toBeInTheDocument();
});
});
it("should omit the policies field and skip the admin-only list without the capability", async () => {
can.mockReturnValue(false);
const user = userEvent.setup({ delay: null });
vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData());
renderWithProviders(<TeamInfoView {...defaultProps} />);
await user.click(await screen.findByRole("tab", { name: "Settings" }));
await user.click(await screen.findByRole("button", { name: /edit settings/i }));
expect(await screen.findByLabelText("Team Name")).toBeInTheDocument();
expect(networking.getPoliciesList).not.toHaveBeenCalled();
expect(policiesFormFieldLabel()).not.toBeInTheDocument();
});
it("should open edit mode when edit button is clicked", async () => {
const user = userEvent.setup({ delay: null });
vi.mocked(networking.teamInfoCall).mockResolvedValue(createMockTeamData());

View file

@ -1,4 +1,5 @@
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
import useCan from "@/app/(dashboard)/hooks/useCan";
import { organizationKeys, useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations";
import { useQueryClient } from "@tanstack/react-query";
import UserSearchModal from "@/components/common_components/user_search_modal";
@ -199,6 +200,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
const [copiedStates, setCopiedStates] = useState<Record<string, boolean>>({});
const { data: guardrailsData, isLoading: isGuardrailsLoading } = useGuardrails();
const globalGuardrailNames = guardrailsData?.globalGuardrailNames ?? new Set<string>();
const canViewPolicies = useCan("viewPolicies");
const [policiesList, setPoliciesList] = useState<string[]>([]);
const [policyGuardrails, setPolicyGuardrails] = useState<Record<string, string[]>>({});
const [loadingPolicies, setLoadingPolicies] = useState(false);
@ -298,8 +300,8 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
}
};
fetchPolicies();
}, [accessToken]);
if (canViewPolicies) fetchPolicies();
}, [accessToken, canViewPolicies]);
// Fetch resolved guardrails for all policies
useEffect(() => {
@ -1338,30 +1340,32 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
<Switch checkedChildren="Yes" unCheckedChildren="No" />
</Form.Item>
<Form.Item
label={
<span>
Policies{" "}
<Tooltip title="Apply policies to this team to control guardrails and other settings">
<a
href="https://docs.litellm.ai/docs/proxy/guardrails/guardrail_policies"
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
>
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</a>
</Tooltip>
</span>
}
name="policies"
>
<Select
mode="tags"
placeholder="Select or enter policies"
options={policiesList.map((name) => ({ value: name, label: name }))}
/>
</Form.Item>
{canViewPolicies && (
<Form.Item
label={
<span>
Policies{" "}
<Tooltip title="Apply policies to this team to control guardrails and other settings">
<a
href="https://docs.litellm.ai/docs/proxy/guardrails/guardrail_policies"
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
>
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</a>
</Tooltip>
</span>
}
name="policies"
>
<Select
mode="tags"
placeholder="Select or enter policies"
options={policiesList.map((name) => ({ value: name, label: name }))}
/>
</Form.Item>
)}
<Form.Item
label={

View file

@ -3,9 +3,14 @@ import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../../../tests/test-utils";
import { KeyResponse } from "../key_team_helpers/key_list";
import { modelAvailableCall } from "../networking";
import { getPoliciesList, getPromptsList, modelAvailableCall } from "../networking";
import { KeyEditView } from "./key_edit_view";
const can = vi.fn();
vi.mock("@/app/(dashboard)/hooks/useCan", () => ({
default: (...args: unknown[]) => can(...args),
}));
vi.mock("../networking", async () => {
const actual = await vi.importActual("../networking");
return {
@ -212,6 +217,45 @@ describe("KeyEditView", () => {
beforeEach(() => {
vi.clearAllMocks();
can.mockReturnValue(true);
});
describe("policy and prompt fields", () => {
const renderAs = (userRole: string) =>
renderWithProviders(
<KeyEditView
keyData={MOCK_KEY_DATA}
onCancel={() => {}}
onSubmit={async () => {}}
accessToken="test-token"
userID="user-123"
userRole={userRole}
premiumUser={true}
/>,
);
it("should render both fields and load prompts for an admin", async () => {
renderAs("Admin");
await waitFor(() => {
expect(getPromptsList).toHaveBeenCalledWith("test-token");
});
expect(screen.getByText("Prompts", { selector: "label" })).toBeInTheDocument();
expect(screen.getByText("Policies")).toBeInTheDocument();
});
it("should omit both fields and fire neither admin-only request for an internal user", async () => {
renderAs("Internal User");
await waitFor(() => {
expect(modelAvailableCall).toHaveBeenCalled();
});
expect(getPromptsList).not.toHaveBeenCalled();
expect(getPoliciesList).not.toHaveBeenCalled();
expect(screen.queryByText("Prompts", { selector: "label" })).not.toBeInTheDocument();
expect(screen.queryByText("Policies")).not.toBeInTheDocument();
});
});
it("should call onCancel when cancel button is clicked", async () => {

View file

@ -7,6 +7,7 @@ import { InfoCircleOutlined } from "@ant-design/icons";
import { TextInput, Button as TremorButton } from "@tremor/react";
import { Form, Input, Select, Switch, Tooltip } from "antd";
import { useEffect, useState } from "react";
import { hasCapability } from "../../utils/capabilities";
import { isProxyAdminRole, rolesWithWriteAccess } from "../../utils/roles";
import AgentSelector from "../agent_management/AgentSelector";
import AccessGroupSelector from "../common_components/AccessGroupSelector";
@ -62,6 +63,8 @@ export function KeyEditView({
premiumUser = false,
}: KeyEditViewProps) {
const canEditGuardrails = premiumUser || (userRole != null && rolesWithWriteAccess.includes(userRole));
const canViewPolicies = hasCapability(userRole, "viewPolicies");
const canViewPrompts = hasCapability(userRole, "viewPrompts");
const canEditEstimates = userRole != null && isProxyAdminRole(userRole);
const estimateTooltip = estimateTooltips(canEditEstimates);
const [form] = Form.useForm();
@ -129,9 +132,9 @@ export function KeyEditView({
}
};
fetchPrompts();
if (canViewPrompts) fetchPrompts();
fetchModels();
}, [userID, userRole, accessToken, team, keyData.team_id]);
}, [userID, userRole, accessToken, team, keyData.team_id, canViewPrompts]);
// Sync disabled callbacks with form when component mounts
useEffect(() => {
@ -599,27 +602,29 @@ export function KeyEditView({
<Switch disabled={!canEditGuardrails} checkedChildren="Yes" unCheckedChildren="No" />
</Form.Item>
<Form.Item
label={
<span>
Policies{" "}
<Tooltip title="Apply policies to this key to control guardrails and other settings">
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</Tooltip>
</span>
}
name="policies"
>
{accessToken && (
<PolicySelector
onChange={(v) => {
form.setFieldValue("policies", v);
}}
accessToken={accessToken}
disabled={!premiumUser}
/>
)}
</Form.Item>
{canViewPolicies && (
<Form.Item
label={
<span>
Policies{" "}
<Tooltip title="Apply policies to this key to control guardrails and other settings">
<InfoCircleOutlined style={{ marginLeft: "4px" }} />
</Tooltip>
</span>
}
name="policies"
>
{accessToken && (
<PolicySelector
onChange={(v) => {
form.setFieldValue("policies", v);
}}
accessToken={accessToken}
disabled={!premiumUser}
/>
)}
</Form.Item>
)}
<Form.Item label="Tags" name="tags">
<Select
@ -634,23 +639,25 @@ export function KeyEditView({
/>
</Form.Item>
<Form.Item label="Prompts" name="prompts">
<Tooltip title={!premiumUser ? "Setting prompts by key is a premium feature" : ""} placement="top">
<Select
mode="tags"
style={{ width: "100%" }}
disabled={!premiumUser}
placeholder={
!premiumUser
? "Premium feature - Upgrade to set prompts by key"
: Array.isArray(keyData.metadata?.prompts) && keyData.metadata.prompts.length > 0
? `Current: ${keyData.metadata.prompts.join(", ")}`
: "Select or enter prompts"
}
options={promptsList.map((name) => ({ value: name, label: name }))}
/>
</Tooltip>
</Form.Item>
{canViewPrompts && (
<Form.Item label="Prompts" name="prompts">
<Tooltip title={!premiumUser ? "Setting prompts by key is a premium feature" : ""} placement="top">
<Select
mode="tags"
style={{ width: "100%" }}
disabled={!premiumUser}
placeholder={
!premiumUser
? "Premium feature - Upgrade to set prompts by key"
: Array.isArray(keyData.metadata?.prompts) && keyData.metadata.prompts.length > 0
? `Current: ${keyData.metadata.prompts.join(", ")}`
: "Select or enter prompts"
}
options={promptsList.map((name) => ({ value: name, label: name }))}
/>
</Tooltip>
</Form.Item>
)}
<Form.Item
label={

View file

@ -16,6 +16,42 @@ describe("hasCapability", () => {
expect(hasCapability(role, "viewToolPolicies")).toBe(false);
},
);
it.each(["Admin", "Admin Viewer", "proxy_admin", "proxy_admin_viewer"])("should grant viewPolicies to %s", (role) => {
expect(hasCapability(role, "viewPolicies")).toBe(true);
});
it.each([
"Internal User",
"Internal Viewer",
"internal_user",
"App User",
"Org Admin",
"Unknown Role",
"",
null,
undefined,
])("should deny viewPolicies to %s", (role) => {
expect(hasCapability(role, "viewPolicies")).toBe(false);
});
it.each(["Admin", "Admin Viewer", "proxy_admin", "proxy_admin_viewer"])("should grant viewPrompts to %s", (role) => {
expect(hasCapability(role, "viewPrompts")).toBe(true);
});
it.each([
"Internal User",
"Internal Viewer",
"internal_user",
"App User",
"Org Admin",
"Unknown Role",
"",
null,
undefined,
])("should deny viewPrompts to %s", (role) => {
expect(hasCapability(role, "viewPrompts")).toBe(false);
});
});
describe.each(["viewAuditLogs", "viewDeletedTeams"] as const)("hasCapability - %s", (capability) => {

View file

@ -4,6 +4,8 @@ const CAPABILITY_ROLES = {
viewToolPolicies: all_admin_roles,
viewAuditLogs: all_admin_roles,
viewDeletedTeams: all_admin_roles,
viewPolicies: all_admin_roles,
viewPrompts: all_admin_roles,
} as const satisfies Record<string, readonly string[]>;
export type Capability = keyof typeof CAPABILITY_ROLES;