mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
feat(ui): add role capability gating, migrate Tool Policies route
Internal users saw the Tool Policies page but its /v1/tool/list call always returned 401. This adds a single source of truth for which roles may trigger which UI fetches (utils/capabilities.ts) plus a useCan hook, and wires the Tool Policies route through it: the nav item, the page, and the query all read the same capability, so the sidebar hides the entry, deep links render an admin-only notice, and the query never fires. The tools list call also moves onto a queryOptions factory
This commit is contained in:
parent
abe3289398
commit
22b60624ad
10 changed files with 174 additions and 20 deletions
12
ui/litellm-dashboard/src/app/(dashboard)/hooks/useCan.ts
Normal file
12
ui/litellm-dashboard/src/app/(dashboard)/hooks/useCan.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
"use client";
|
||||
|
||||
import { hasCapability, type Capability } from "@/utils/capabilities";
|
||||
|
||||
import useAuthorized from "./useAuthorized";
|
||||
|
||||
const useCan = (capability: Capability): boolean => {
|
||||
const { userRole } = useAuthorized();
|
||||
return hasCapability(userRole, capability);
|
||||
};
|
||||
|
||||
export default useCan;
|
||||
|
|
@ -21,6 +21,11 @@ vi.mock("@/components/molecules/notifications_manager", () => ({
|
|||
default: { fromBackend: (...args: unknown[]) => fromBackend(...args) },
|
||||
}));
|
||||
|
||||
const can = vi.fn();
|
||||
vi.mock("@/app/(dashboard)/hooks/useCan", () => ({
|
||||
default: (...args: unknown[]) => can(...args),
|
||||
}));
|
||||
|
||||
const NOW = new Date("2026-07-21T12:00:00Z");
|
||||
|
||||
const TOOLS: ToolRow[] = [
|
||||
|
|
@ -104,6 +109,7 @@ beforeEach(() => {
|
|||
fetchToolsList.mockReset().mockResolvedValue(TOOLS);
|
||||
updateToolPolicy.mockReset().mockResolvedValue({});
|
||||
fromBackend.mockReset();
|
||||
can.mockReset().mockReturnValue(true);
|
||||
Element.prototype.scrollIntoView = vi.fn();
|
||||
});
|
||||
|
||||
|
|
@ -112,6 +118,18 @@ afterEach(() => {
|
|||
});
|
||||
|
||||
describe("ToolPoliciesPanel data loading", () => {
|
||||
it("should not fetch tools when the caller lacks the viewToolPolicies capability", async () => {
|
||||
can.mockReturnValue(false);
|
||||
renderPanel();
|
||||
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(1_000);
|
||||
});
|
||||
|
||||
expect(can).toHaveBeenCalledWith("viewToolPolicies");
|
||||
expect(fetchToolsList).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should load tools once and never auto-refresh on a timer", async () => {
|
||||
renderPanel();
|
||||
await waitForRows();
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
"use client";
|
||||
|
||||
import { useQuery, useQueryClient, type UseQueryOptions } from "@tanstack/react-query";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
|
||||
import useCan from "@/app/(dashboard)/hooks/useCan";
|
||||
import { MetricCard } from "@/components/GuardrailsMonitor/MetricCard";
|
||||
import NotificationsManager from "@/components/molecules/notifications_manager";
|
||||
import { fetchToolsList, ToolRow, updateToolPolicy } from "@/components/networking";
|
||||
import { ToolRow, updateToolPolicy } from "@/components/networking";
|
||||
|
||||
import { toolPoliciesListOptions } from "./toolPoliciesQueries";
|
||||
import { ToolPoliciesTable } from "./ToolPoliciesTable";
|
||||
|
||||
function getUTCDateKey(date: Date): string {
|
||||
|
|
@ -41,8 +43,6 @@ const withTool = (names: ReadonlySet<string>, toolName: string): ReadonlySet<str
|
|||
const withoutTool = (names: ReadonlySet<string>, toolName: string): ReadonlySet<string> =>
|
||||
new Set([...names].filter((name) => name !== toolName));
|
||||
|
||||
const TOOLS_QUERY_KEY = "tool-policies";
|
||||
|
||||
interface ToolPoliciesPanelProps {
|
||||
accessToken: string | null;
|
||||
onSelectTool: (toolName: string) => void;
|
||||
|
|
@ -50,19 +50,12 @@ interface ToolPoliciesPanelProps {
|
|||
|
||||
export const ToolPoliciesPanel: React.FC<ToolPoliciesPanelProps> = ({ accessToken, onSelectTool }) => {
|
||||
const queryClient = useQueryClient();
|
||||
const canViewToolPolicies = useCan("viewToolPolicies");
|
||||
const [savingInput, setSavingInput] = useState<ReadonlySet<string>>(() => new Set());
|
||||
const [savingOutput, setSavingOutput] = useState<ReadonlySet<string>>(() => new Set());
|
||||
|
||||
const queryKey = useMemo(() => [TOOLS_QUERY_KEY, accessToken], [accessToken]);
|
||||
|
||||
const queryOptions: UseQueryOptions<ToolRow[]> = {
|
||||
queryKey,
|
||||
queryFn: async () => (accessToken === null ? [] : fetchToolsList(accessToken)),
|
||||
enabled: accessToken !== null,
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
};
|
||||
const query = useQuery(queryOptions);
|
||||
const listOptions = useMemo(() => toolPoliciesListOptions(accessToken), [accessToken]);
|
||||
const query = useQuery({ ...listOptions, enabled: canViewToolPolicies && accessToken !== null });
|
||||
|
||||
const tools = useMemo(() => query.data ?? [], [query.data]);
|
||||
|
||||
|
|
@ -70,12 +63,12 @@ export const ToolPoliciesPanel: React.FC<ToolPoliciesPanelProps> = ({ accessToke
|
|||
// and overwrite the row we just wrote with its pre-save snapshot.
|
||||
const patchTool = useCallback(
|
||||
async (toolName: string, patch: Partial<ToolRow>) => {
|
||||
await queryClient.cancelQueries({ queryKey });
|
||||
queryClient.setQueryData<ToolRow[]>(queryKey, (previous) =>
|
||||
await queryClient.cancelQueries({ queryKey: listOptions.queryKey });
|
||||
queryClient.setQueryData(listOptions.queryKey, (previous) =>
|
||||
(previous ?? []).map((tool) => (tool.tool_name === toolName ? { ...tool, ...patch } : tool)),
|
||||
);
|
||||
},
|
||||
[queryClient, queryKey],
|
||||
[queryClient, listOptions],
|
||||
);
|
||||
|
||||
const handleInputPolicyChange = useCallback(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,16 @@
|
|||
import { queryOptions } from "@tanstack/react-query";
|
||||
|
||||
import { fetchToolsList, type ToolRow } from "@/components/networking";
|
||||
|
||||
export const toolPoliciesKeys = {
|
||||
all: ["tool-policies"] as const,
|
||||
list: (accessToken: string | null) => [...toolPoliciesKeys.all, accessToken] as const,
|
||||
};
|
||||
|
||||
export const toolPoliciesListOptions = (accessToken: string | null) =>
|
||||
queryOptions({
|
||||
queryKey: toolPoliciesKeys.list(accessToken),
|
||||
queryFn: async (): Promise<ToolRow[]> => (accessToken === null ? [] : fetchToolsList(accessToken)),
|
||||
refetchOnWindowFocus: false,
|
||||
refetchOnReconnect: false,
|
||||
});
|
||||
|
|
@ -1,10 +1,15 @@
|
|||
import React from "react";
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { beforeEach, describe, it, expect, vi } from "vitest";
|
||||
import { screen } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders } from "../../tests/test-utils";
|
||||
import ToolPoliciesView from "./ToolPoliciesView";
|
||||
|
||||
const can = vi.fn();
|
||||
vi.mock("@/app/(dashboard)/hooks/useCan", () => ({
|
||||
default: (...args: unknown[]) => can(...args),
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ToolDetail", () => ({
|
||||
ToolDetail: ({ toolName, onBack }: { toolName: string; onBack: () => void }) => (
|
||||
<div>
|
||||
|
|
@ -26,6 +31,18 @@ vi.mock("@/components/ToolPolicies/ToolPoliciesPanel", () => ({
|
|||
}));
|
||||
|
||||
describe("ToolPoliciesView", () => {
|
||||
beforeEach(() => {
|
||||
can.mockReset().mockReturnValue(true);
|
||||
});
|
||||
|
||||
it("should show an admin-only notice instead of the overview when the caller lacks access", () => {
|
||||
can.mockReturnValue(false);
|
||||
renderWithProviders(<ToolPoliciesView accessToken="token" />);
|
||||
|
||||
expect(screen.getByText(/only available to admin users/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText("Tool Policies Overview")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should render the overview by default", () => {
|
||||
renderWithProviders(<ToolPoliciesView accessToken="token" />);
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import useCan from "@/app/(dashboard)/hooks/useCan";
|
||||
import { ToolDetail } from "@/components/ToolDetail";
|
||||
import { ToolPoliciesPanel } from "@/components/ToolPolicies/ToolPoliciesPanel";
|
||||
|
||||
|
|
@ -11,6 +12,7 @@ interface ToolPoliciesViewProps {
|
|||
}
|
||||
|
||||
export default function ToolPoliciesView({ accessToken }: ToolPoliciesViewProps) {
|
||||
const canViewToolPolicies = useCan("viewToolPolicies");
|
||||
const [view, setView] = useState<View>({ type: "overview" });
|
||||
|
||||
const handleSelectTool = (toolName: string) => {
|
||||
|
|
@ -21,6 +23,15 @@ export default function ToolPoliciesView({ accessToken }: ToolPoliciesViewProps)
|
|||
setView({ type: "overview" });
|
||||
};
|
||||
|
||||
if (!canViewToolPolicies) {
|
||||
return (
|
||||
<div className="p-6 w-full min-w-0 flex-1">
|
||||
<h1 className="text-2xl font-semibold text-gray-900 mb-2">Tool Policies</h1>
|
||||
<p className="text-sm text-gray-500">Tool Policies is only available to admin users.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 w-full min-w-0 flex-1">
|
||||
{view.type === "detail" ? (
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { act, fireEvent, screen, waitFor } from "@testing-library/react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { renderWithProviders } from "../../tests/test-utils";
|
||||
import Sidebar, { menuGroups, getBreadcrumb } from "./leftnav";
|
||||
|
||||
|
|
@ -201,6 +201,47 @@ describe("Sidebar (leftnav)", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("capability-gated Tools children", () => {
|
||||
const internalAuth = {
|
||||
userId: "internal-user-id",
|
||||
accessToken: "test-access-token",
|
||||
userRole: "internal",
|
||||
token: "test-token",
|
||||
userEmail: "internal@example.com",
|
||||
premiumUser: false,
|
||||
disabledPersonalKeyCreation: false,
|
||||
showSSOBanner: false,
|
||||
};
|
||||
|
||||
afterEach(() => {
|
||||
mockUseAuthorized.mockReset();
|
||||
});
|
||||
|
||||
it("should hide Tool Policies from internal users while keeping other Tools children", async () => {
|
||||
mockUseAuthorized.mockReturnValue(internalAuth);
|
||||
renderWithProviders(<Sidebar {...defaultProps} />);
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByText("Tools"));
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Search Tools")).toBeInTheDocument();
|
||||
});
|
||||
expect(screen.queryByText("Tool Policies")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show Tool Policies to admins", async () => {
|
||||
renderWithProviders(<Sidebar {...defaultProps} />);
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByText("Tools"));
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Tool Policies")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should show Organizations tab for organization admins", () => {
|
||||
mockUseAuthorized.mockReturnValueOnce({
|
||||
userId: "org-admin-user-id",
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ import {
|
|||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { cn } from "@/lib/cva.config";
|
||||
import { rolesWithCapability } from "../utils/capabilities";
|
||||
import {
|
||||
all_admin_roles,
|
||||
internalUserRoles,
|
||||
|
|
@ -167,7 +168,13 @@ const menuGroups: MenuGroup[] = [
|
|||
children: [
|
||||
{ key: "search-tools", page: "search-tools", label: "Search Tools", icon: <Search {...ICON} /> },
|
||||
{ key: "vector-stores", page: "vector-stores", label: "Vector Stores", icon: <Database {...ICON} /> },
|
||||
{ key: "tool-policies", page: "tool-policies", label: "Tool Policies", icon: <ShieldCheck {...ICON} /> },
|
||||
{
|
||||
key: "tool-policies",
|
||||
page: "tool-policies",
|
||||
label: "Tool Policies",
|
||||
icon: <ShieldCheck {...ICON} />,
|
||||
roles: rolesWithCapability("viewToolPolicies"),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
|
|
|||
27
ui/litellm-dashboard/src/utils/capabilities.test.ts
Normal file
27
ui/litellm-dashboard/src/utils/capabilities.test.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { hasCapability, rolesWithCapability } from "./capabilities";
|
||||
|
||||
describe("hasCapability", () => {
|
||||
it.each(["Admin", "Admin Viewer", "proxy_admin", "proxy_admin_viewer"])(
|
||||
"should grant viewToolPolicies to %s",
|
||||
(role) => {
|
||||
expect(hasCapability(role, "viewToolPolicies")).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["Internal User", "Internal Viewer", "App User", "Unknown Role", "", null, undefined])(
|
||||
"should deny viewToolPolicies to %s",
|
||||
(role) => {
|
||||
expect(hasCapability(role, "viewToolPolicies")).toBe(false);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("rolesWithCapability", () => {
|
||||
it("should return a copy so callers cannot mutate the capability map", () => {
|
||||
const roles = rolesWithCapability("viewToolPolicies");
|
||||
const removed = roles.pop();
|
||||
expect(hasCapability(removed, "viewToolPolicies")).toBe(true);
|
||||
});
|
||||
});
|
||||
12
ui/litellm-dashboard/src/utils/capabilities.ts
Normal file
12
ui/litellm-dashboard/src/utils/capabilities.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { all_admin_roles } from "./roles";
|
||||
|
||||
const CAPABILITY_ROLES = {
|
||||
viewToolPolicies: all_admin_roles,
|
||||
} as const satisfies Record<string, readonly string[]>;
|
||||
|
||||
export type Capability = keyof typeof CAPABILITY_ROLES;
|
||||
|
||||
export const hasCapability = (userRole: string | null | undefined, capability: Capability): boolean =>
|
||||
userRole != null && CAPABILITY_ROLES[capability].includes(userRole);
|
||||
|
||||
export const rolesWithCapability = (capability: Capability): string[] => [...CAPABILITY_ROLES[capability]];
|
||||
Loading…
Add table
Reference in a new issue