fix(ui): gate policy and prompt lookups on an admin capability

/policies/list and /prompts/list are default-deny for internal_user, but the
Virtual Keys create/edit flow, the Teams forms and the Playground called them
on mount, so every internal user landing on the dashboard fired two requests
that 401. Add viewPolicies and viewPrompts to the capability map and use them
to gate the nav entry, the form field and the fetch together, following the
pattern from the Tool Policies migration. Non-admins now see no policy or
prompt selector at all rather than an empty dropdown.
This commit is contained in:
Yuneng Jiang 2026-08-08 20:34:56 -07:00
parent e9d1ea59fb
commit 2502ee4a2a
No known key found for this signature in database
16 changed files with 533 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,51 @@ 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();
// The modal still renders the rest of the advanced settings, so a missing
// selector is a real omission rather than an un-rendered tree.
await waitFor(() => {
expect(screen.getByTestId("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

@ -22,6 +22,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()),
@ -227,6 +232,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([]);
@ -631,6 +637,46 @@ describe("TeamInfoView", () => {
});
describe("settings and editing", () => {
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 }));
// `selector` skips the read-only Policies card, which renders the team's own
// policy names from team info and needs no admin list.
await waitFor(() => {
expect(screen.getByText("Policies", { selector: "span" })).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 }));
// Team Name proves the edit form rendered, so a missing Policies field is a real omission.
await waitFor(() => {
expect(screen.getByLabelText("Team Name")).toBeInTheDocument();
});
expect(networking.getPoliciesList).not.toHaveBeenCalled();
expect(screen.queryByText("Policies", { selector: "span" })).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";
@ -196,6 +197,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);
@ -293,8 +295,8 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
}
};
fetchPolicies();
}, [accessToken]);
if (canViewPolicies) fetchPolicies();
}, [accessToken, canViewPolicies]);
// Fetch resolved guardrails for all policies
useEffect(() => {
@ -1284,30 +1286,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,17 @@ 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";
// PolicySelector gates itself on the session capability; in this bare template
// render there is no session, so stub the hook to an admin to keep the selector
// mounted and prove the gate under test is KeyEditView's own.
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 +220,46 @@ 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");
// Models still loads, so the form really rendered and the fields are absent by gate.
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 { rolesWithWriteAccess } from "../../utils/roles";
import AgentSelector from "../agent_management/AgentSelector";
import AccessGroupSelector from "../common_components/AccessGroupSelector";
@ -83,6 +84,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 [form] = Form.useForm();
const [promptsList, setPromptsList] = useState<string[]>([]);
const [tagsList, setTagsList] = useState<Record<string, Tag>>({});
@ -148,9 +151,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(() => {
@ -610,27 +613,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
@ -645,23 +650,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,34 @@ describe("hasCapability", () => {
expect(hasCapability(role, "viewToolPolicies")).toBe(false);
},
);
it.each(["Admin", "Admin Viewer", "proxy_admin", "proxy_admin_viewer", "org_admin"])(
"should grant viewPolicies to %s",
(role) => {
expect(hasCapability(role, "viewPolicies")).toBe(true);
},
);
it.each(["Internal User", "Internal Viewer", "internal_user", "App User", "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", "org_admin"])(
"should grant viewPrompts to %s",
(role) => {
expect(hasCapability(role, "viewPrompts")).toBe(true);
},
);
it.each(["Internal User", "Internal Viewer", "internal_user", "App User", "Unknown Role", "", null, undefined])(
"should deny viewPrompts to %s",
(role) => {
expect(hasCapability(role, "viewPrompts")).toBe(false);
},
);
});
describe("rolesWithCapability", () => {

View file

@ -2,6 +2,8 @@ import { all_admin_roles } from "./roles";
const CAPABILITY_ROLES = {
viewToolPolicies: 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;