diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AccessGroupBudgetColumns.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AccessGroupBudgetColumns.tsx index c51c4ca1490..4401941c2f4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AccessGroupBudgetColumns.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/components/AccessGroupBudgetColumns.tsx @@ -19,14 +19,30 @@ import { ModelAccessGroup } from "@/app/(dashboard)/hooks/modelAccessGroups/useM const budgetDecimals = (maxBudget: number | null | undefined): number => maxBudget != null && maxBudget > 0 && maxBudget < 0.01 ? 5 : 2; +/** + * A group name is a free-text path segment on the budget routes, so a `/` in it splits the path and + * no encoding recovers it. Such a group is listed but its budget is unreachable. + */ +export const isBudgetAddressable = (accessGroup: string): boolean => !accessGroup.includes("/"); + +const writeBlockedReason = (accessGroup: ModelAccessGroup, canWrite: boolean): string | undefined => { + if (!canWrite) return "Only a proxy admin can change an access group budget"; + if (!isBudgetAddressable(accessGroup.access_group)) { + return "A budget cannot be set on a group whose name contains a slash"; + } + return undefined; +}; + interface AccessGroupRowActionsProps { accessGroup: ModelAccessGroup; + canWrite: boolean; onSetBudget: (accessGroup: ModelAccessGroup) => void; onClearBudget: (accessGroup: ModelAccessGroup) => void; } -function AccessGroupRowActions({ accessGroup, onSetBudget, onClearBudget }: AccessGroupRowActionsProps) { +function AccessGroupRowActions({ accessGroup, canWrite, onSetBudget, onClearBudget }: AccessGroupRowActionsProps) { const hasBudget = accessGroup.budget != null; + const blocked = writeBlockedReason(accessGroup, canWrite); return ( @@ -38,15 +54,20 @@ function AccessGroupRowActions({ accessGroup, onSetBudget, onClearBudget }: Acce - onSetBudget(accessGroup)}> + onSetBudget(accessGroup)} + > {hasBudget ? "Edit budget" : "Set budget"} onClearBudget(accessGroup)} > @@ -58,11 +79,13 @@ function AccessGroupRowActions({ accessGroup, onSetBudget, onClearBudget }: Acce } interface AccessGroupBudgetColumnsDeps { + canWrite: boolean; onSetBudget: (accessGroup: ModelAccessGroup) => void; onClearBudget: (accessGroup: ModelAccessGroup) => void; } export const getAccessGroupBudgetColumns = ({ + canWrite, onSetBudget, onClearBudget, }: AccessGroupBudgetColumnsDeps): ColumnDef[] => [ @@ -132,7 +155,12 @@ export const getAccessGroupBudgetColumns = ({ enableHiding: false, cell: ({ row }) => (
- +
), }, diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AccessGroupBudgetsPanel.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AccessGroupBudgetsPanel.integration.test.tsx index 1342e85b669..4d0818987ce 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AccessGroupBudgetsPanel.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AccessGroupBudgetsPanel.integration.test.tsx @@ -4,11 +4,16 @@ import userEvent from "@testing-library/user-event"; import React from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; -const { GET, PUT, DELETE } = vi.hoisted(() => ({ GET: vi.fn(), PUT: vi.fn(), DELETE: vi.fn() })); +const { GET, PUT, DELETE, userRole } = vi.hoisted(() => ({ + GET: vi.fn(), + PUT: vi.fn(), + DELETE: vi.fn(), + userRole: { current: "Admin" }, +})); vi.mock("@/lib/http/api", () => ({ fetchClient: { GET, PUT, DELETE } })); vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ - default: () => ({ accessToken: "sk-test", userRole: "Admin" }), + default: () => ({ accessToken: "sk-test", userRole: userRole.current }), })); import AccessGroupBudgetsPanel from "./AccessGroupBudgetsPanel"; @@ -51,6 +56,7 @@ const openActions = async (accessGroup: string) => { describe("AccessGroupBudgetsPanel", () => { beforeEach(() => { vi.clearAllMocks(); + userRole.current = "Admin"; GET.mockResolvedValue({ data: { access_groups: [BUDGETED_GROUP, FREE_GROUP] } }); PUT.mockResolvedValue({ data: { access_group: "shared", spend: 0, budget: null } }); DELETE.mockResolvedValue({ data: { access_group: "premium", budget_deleted: true, message: "ok" } }); @@ -120,6 +126,27 @@ describe("AccessGroupBudgetsPanel", () => { expect(PUT).not.toHaveBeenCalled(); }); + it("offers an admin viewer no way to start a write the proxy would reject with a 403", async () => { + userRole.current = "Admin Viewer"; + renderPanel(); + + expect(await screen.findByText("premium")).toBeInTheDocument(); + + await openActions("premium"); + + expect(await screen.findByTestId("access-group-action-set-budget")).toHaveAttribute("aria-disabled", "true"); + expect(screen.getByTestId("access-group-action-clear-budget")).toHaveAttribute("aria-disabled", "true"); + }); + + it("does not offer a budget on a group whose name a path segment cannot carry", async () => { + GET.mockResolvedValue({ data: { access_groups: [{ ...FREE_GROUP, access_group: "openai/prod" }] } }); + renderPanel(); + + await openActions("openai/prod"); + + expect(await screen.findByTestId("access-group-action-set-budget")).toHaveAttribute("aria-disabled", "true"); + }); + it("clears a budget only after the confirmation is accepted", async () => { renderPanel(); await openActions("premium"); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AccessGroupBudgetsPanel.tsx b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AccessGroupBudgetsPanel.tsx index 3e1e6924bb8..ee8baab81ab 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AccessGroupBudgetsPanel.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/models-and-endpoints/panels/AccessGroupBudgetsPanel.tsx @@ -7,6 +7,8 @@ import React, { useMemo, useState } from "react"; import DeleteResourceModal from "@/components/common_components/DeleteResourceModal"; import { DataTable } from "@/components/shared/DataTable"; import { toast } from "@/lib/toast"; +import { isProxyAdminRole } from "@/utils/roles"; +import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { ModelAccessGroup, useModelAccessGroups } from "@/app/(dashboard)/hooks/modelAccessGroups/useModelAccessGroups"; import { useDeleteModelAccessGroupBudget } from "@/app/(dashboard)/hooks/modelAccessGroups/useDeleteModelAccessGroupBudget"; import { @@ -33,6 +35,7 @@ function EmptyState() { } export default function AccessGroupBudgetsPanel() { + const { userRole } = useAuthorized(); const { data: accessGroups, isLoading } = useModelAccessGroups(); const setBudget = useSetModelAccessGroupBudget(); const clearBudget = useDeleteModelAccessGroupBudget(); @@ -41,9 +44,10 @@ export default function AccessGroupBudgetsPanel() { const [editing, setEditing] = useState(null); const [clearing, setClearing] = useState(null); + const canWrite = isProxyAdminRole(userRole ?? ""); const columns = useMemo( - () => getAccessGroupBudgetColumns({ onSetBudget: setEditing, onClearBudget: setClearing }), - [], + () => getAccessGroupBudgetColumns({ canWrite, onSetBudget: setEditing, onClearBudget: setClearing }), + [canWrite], ); const handleSubmit = (params: SetModelAccessGroupBudgetParams) => {