mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
Merge pull request #36469 from BerriAI/litellm_/nifty-knuth-f7f2c6
fix(ui): gate the Old Usage page behind a proxy-admin capability
This commit is contained in:
commit
022c0cce95
7 changed files with 158 additions and 20 deletions
|
|
@ -1,6 +1,6 @@
|
|||
import React from "react";
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { screen, waitFor, within } from "@testing-library/react";
|
||||
import { act, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { renderWithProviders } from "../../../../../tests/test-utils";
|
||||
import UsagePage from "./usage";
|
||||
|
|
@ -49,6 +49,14 @@ const renderUsage = (overrides: Partial<React.ComponentProps<typeof UsagePage>>
|
|||
/>,
|
||||
);
|
||||
|
||||
// Width of this window is guarded by "proves the flush window is wide enough".
|
||||
const flushPendingRequests = async () => {
|
||||
await act(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
});
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
networking.getProxyUISettings.mockResolvedValue(UNLIMITED_SETTINGS);
|
||||
|
|
@ -185,18 +193,65 @@ describe("old usage page", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("as a non-admin", () => {
|
||||
it("renders only the All Up tab and skips admin-only queries", async () => {
|
||||
renderUsage({ userRole: "Internal User" });
|
||||
// org_admin is an organization membership role; those users reach the UI as "Internal User".
|
||||
describe.each(["Internal User", "Internal Viewer", "internal_user", "internal_user_viewer", "Org Admin"])(
|
||||
"as %s",
|
||||
(userRole) => {
|
||||
it("shows the admin-only notice instead of the usage dashboard", async () => {
|
||||
renderUsage({ userRole });
|
||||
|
||||
expect(await screen.findByText(/Proxy-wide usage is only available to admin users/i)).toBeInTheDocument();
|
||||
expect(screen.queryByRole("tab", { name: "All Up" })).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("fires no /global/spend or /global/activity request", async () => {
|
||||
renderUsage({ userRole });
|
||||
|
||||
await screen.findByText(/Proxy-wide usage is only available to admin users/i);
|
||||
await flushPendingRequests();
|
||||
|
||||
expect(networking.getProxyUISettings).not.toHaveBeenCalled();
|
||||
expect(networking.adminSpendLogsCall).not.toHaveBeenCalled();
|
||||
expect(networking.adminTopKeysCall).not.toHaveBeenCalled();
|
||||
expect(networking.adminTopModelsCall).not.toHaveBeenCalled();
|
||||
expect(networking.adminTopEndUsersCall).not.toHaveBeenCalled();
|
||||
expect(networking.teamSpendLogsCall).not.toHaveBeenCalled();
|
||||
expect(networking.tagsSpendLogsCall).not.toHaveBeenCalled();
|
||||
expect(networking.allTagNamesCall).not.toHaveBeenCalled();
|
||||
expect(networking.adminspendByProvider).not.toHaveBeenCalled();
|
||||
expect(networking.adminGlobalActivity).not.toHaveBeenCalled();
|
||||
expect(networking.adminGlobalActivityPerModel).not.toHaveBeenCalled();
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
describe("the admin-only gate", () => {
|
||||
it("proves the flush window is wide enough to catch a leaked request", async () => {
|
||||
renderUsage({ userRole: "Admin" });
|
||||
|
||||
await flushPendingRequests();
|
||||
|
||||
expect(networking.getProxyUISettings).toHaveBeenCalled();
|
||||
expect(networking.adminSpendLogsCall).toHaveBeenCalled();
|
||||
expect(networking.tagsSpendLogsCall).toHaveBeenCalled();
|
||||
expect(networking.adminGlobalActivity).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still lets an admin through, so the notice is a real gate and not a dead branch", async () => {
|
||||
renderUsage({ userRole: "Admin" });
|
||||
|
||||
expect(await screen.findByRole("tab", { name: "All Up" })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("tab", { name: "Team Based Usage" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("tab", { name: "Customer Usage" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("tab", { name: "Tag Based Usage" })).not.toBeInTheDocument();
|
||||
|
||||
expect(screen.queryByText(/Proxy-wide usage is only available to admin users/i)).not.toBeInTheDocument();
|
||||
await waitFor(() => expect(networking.adminSpendLogsCall).toHaveBeenCalled());
|
||||
expect(networking.teamSpendLogsCall).not.toHaveBeenCalled();
|
||||
expect(networking.adminTopEndUsersCall).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not put the session token in the provider spend query", async () => {
|
||||
renderUsage({ userRole: "Admin", token: "session-jwt-value" });
|
||||
|
||||
await waitFor(() => expect(networking.adminspendByProvider).toHaveBeenCalled());
|
||||
const callArgs = networking.adminspendByProvider.mock.calls[0];
|
||||
expect(callArgs).not.toContain("session-jwt-value");
|
||||
expect(callArgs[0]).toBe("sk-test");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -37,6 +37,7 @@ import {
|
|||
} from "@/components/networking";
|
||||
import TopKeyView from "@/components/UsagePage/components/EntityUsage/TopKeyView";
|
||||
import { MoneyCell } from "@/components/shared/table_cells";
|
||||
import { hasCapability } from "@/utils/capabilities";
|
||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||
|
||||
interface UsagePageProps {
|
||||
|
|
@ -90,6 +91,7 @@ const TeamSpendBarList: React.FC<{ data: TeamSpendTotal[] }> = ({ data }) => {
|
|||
};
|
||||
|
||||
const UsagePage: React.FC<UsagePageProps> = ({ accessToken, token, userRole, userID, keys, premiumUser }) => {
|
||||
const canViewGlobalSpend = hasCapability(userRole, "viewGlobalSpend");
|
||||
const currentDate = new Date();
|
||||
const [keySpendData, setKeySpendData] = useState<any[]>([]);
|
||||
const [topKeys, setTopKeys] = useState<any[]>([]);
|
||||
|
|
@ -155,8 +157,11 @@ const UsagePage: React.FC<UsagePageProps> = ({ accessToken, token, userRole, use
|
|||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!canViewGlobalSpend) {
|
||||
return;
|
||||
}
|
||||
updateTagSpendData(dateValue.from, dateValue.to);
|
||||
}, [dateValue, selectedTags]);
|
||||
}, [canViewGlobalSpend, dateValue, selectedTags]);
|
||||
|
||||
const updateEndUserData = async (
|
||||
startTime: Date | undefined,
|
||||
|
|
@ -319,10 +324,7 @@ const UsagePage: React.FC<UsagePageProps> = ({ accessToken, token, userRole, use
|
|||
|
||||
const fetchProviderSpend = () =>
|
||||
fetchAndSetData(
|
||||
() =>
|
||||
accessToken && token
|
||||
? adminspendByProvider(accessToken, token, startTime, endTime)
|
||||
: Promise.reject("No access token or token"),
|
||||
() => (accessToken ? adminspendByProvider(accessToken, startTime, endTime) : Promise.reject("No access token")),
|
||||
setSpendByProvider,
|
||||
"Error fetching provider spend",
|
||||
);
|
||||
|
|
@ -467,6 +469,9 @@ const UsagePage: React.FC<UsagePageProps> = ({ accessToken, token, userRole, use
|
|||
|
||||
useEffect(() => {
|
||||
const initlizeUsageData = async () => {
|
||||
if (!canViewGlobalSpend) {
|
||||
return;
|
||||
}
|
||||
if (accessToken && token && userRole && userID) {
|
||||
const proxy_settings: ProxySettings | undefined = await fetchProxySettings();
|
||||
if (proxy_settings) {
|
||||
|
|
@ -493,7 +498,24 @@ const UsagePage: React.FC<UsagePageProps> = ({ accessToken, token, userRole, use
|
|||
};
|
||||
|
||||
initlizeUsageData();
|
||||
}, [accessToken, token, userRole, userID, startTime, endTime]);
|
||||
}, [canViewGlobalSpend, accessToken, token, userRole, userID, startTime, endTime]);
|
||||
|
||||
if (!canViewGlobalSpend) {
|
||||
return (
|
||||
<div className="w-full p-8">
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Usage</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Proxy-wide usage is only available to admin users. Your own usage is on the Usage page.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (proxySettings?.DISABLE_EXPENSIVE_DB_QUERIES) {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import Sidebar, { menuGroups, getBreadcrumb } from "./leftnav";
|
|||
vi.mock("../utils/roles", () => {
|
||||
return {
|
||||
all_admin_roles: ["admin", "admin_viewer"],
|
||||
old_admin_roles: ["admin", "admin_viewer"],
|
||||
internalUserRoles: ["internal"],
|
||||
rolesWithWriteAccess: ["admin", "internal"],
|
||||
rolesAllowedToViewWriteScopedPages: ["admin", "internal", "admin_viewer"],
|
||||
|
|
@ -266,6 +267,30 @@ describe("Sidebar (leftnav)", () => {
|
|||
});
|
||||
expect(screen.queryByText("Prompts")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should hide Old Usage 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("Old Usage")).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("should show Old Usage to admins", async () => {
|
||||
renderWithProviders(<Sidebar {...defaultProps} />);
|
||||
|
||||
act(() => {
|
||||
fireEvent.click(screen.getByText("Experimental"));
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText("Old Usage")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should show Organizations tab for organization admins", () => {
|
||||
|
|
|
|||
|
|
@ -289,7 +289,13 @@ const menuGroups: MenuGroup[] = [
|
|||
icon: <Tags {...ICON} />,
|
||||
roles: all_admin_roles,
|
||||
},
|
||||
{ key: "4", page: "usage", label: "Old Usage", icon: <BarChart3 {...ICON} /> },
|
||||
{
|
||||
key: "4",
|
||||
page: "usage",
|
||||
label: "Old Usage",
|
||||
icon: <BarChart3 {...ICON} />,
|
||||
roles: rolesWithCapability("viewGlobalSpend"),
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
|
|
|||
|
|
@ -2099,7 +2099,6 @@ export const adminTopEndUsersCall = async (
|
|||
|
||||
export const adminspendByProvider = async (
|
||||
accessToken: string,
|
||||
keyToken: string | null,
|
||||
startTime: string | undefined,
|
||||
endTime: string | undefined,
|
||||
) => {
|
||||
|
|
@ -2108,7 +2107,6 @@ export const adminspendByProvider = async (
|
|||
accessToken,
|
||||
query: {
|
||||
...(startTime && endTime ? { start_date: startTime, end_date: endTime } : {}),
|
||||
...(keyToken ? { api_key: keyToken } : {}),
|
||||
},
|
||||
});
|
||||
return data;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { hasCapability, rolesWithCapability, type Capability } from "./capabilities";
|
||||
import { effectiveSessionRole } from "./roles";
|
||||
|
||||
const ADMIN_ROLES = ["Admin", "Admin Viewer", "proxy_admin", "proxy_admin_viewer"];
|
||||
const NON_ADMIN_ROLES = [
|
||||
|
|
@ -37,6 +38,34 @@ describe("hasCapability", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("hasCapability - viewGlobalSpend", () => {
|
||||
it.each(ADMIN_ROLES)("should grant it to %s", (role) => {
|
||||
expect(hasCapability(role, "viewGlobalSpend")).toBe(true);
|
||||
});
|
||||
|
||||
it.each([...NON_ADMIN_ROLES, "internal_user_viewer", "org_admin"])("should deny it to %s", (role) => {
|
||||
expect(hasCapability(role, "viewGlobalSpend")).toBe(false);
|
||||
});
|
||||
|
||||
it("should deny it to every role an org admin or team admin can present at runtime", () => {
|
||||
const orgAdminSessionRole = effectiveSessionRole("internal_user");
|
||||
const teamAdminSessionRole = effectiveSessionRole("internal_user");
|
||||
|
||||
expect(orgAdminSessionRole).toBe("Internal User");
|
||||
expect(hasCapability(orgAdminSessionRole, "viewGlobalSpend")).toBe(false);
|
||||
expect(hasCapability(teamAdminSessionRole, "viewGlobalSpend")).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["proxy_admin", true],
|
||||
["proxy_admin_viewer", true],
|
||||
["internal_user", false],
|
||||
["internal_user_viewer", false],
|
||||
] as const)("should match the backend for a %s session", (rawRole, expected) => {
|
||||
expect(hasCapability(effectiveSessionRole(rawRole), "viewGlobalSpend")).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rolesWithCapability", () => {
|
||||
it("should return a copy so callers cannot mutate the capability map", () => {
|
||||
const roles = rolesWithCapability("viewToolPolicies");
|
||||
|
|
|
|||
|
|
@ -1,4 +1,6 @@
|
|||
import { all_admin_roles } from "./roles";
|
||||
import { all_admin_roles, old_admin_roles } from "./roles";
|
||||
|
||||
const proxyAdminOnlyRoles = [...old_admin_roles, "proxy_admin", "proxy_admin_viewer"];
|
||||
|
||||
const CAPABILITY_ROLES = {
|
||||
viewToolPolicies: all_admin_roles,
|
||||
|
|
@ -8,6 +10,7 @@ const CAPABILITY_ROLES = {
|
|||
viewPrompts: all_admin_roles,
|
||||
viewOrganizationUsage: all_admin_roles,
|
||||
viewAgentUsage: all_admin_roles,
|
||||
viewGlobalSpend: proxyAdminOnlyRoles,
|
||||
} as const satisfies Record<string, readonly string[]>;
|
||||
|
||||
export type Capability = keyof typeof CAPABILITY_ROLES;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue