mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
fix(ui): gate four sidebar pages on the roles their endpoints allow
Workflow Runs, Memory and Guardrails Monitor were visible to every role while their page-load routes are proxy-admin-only, so a non-admin got a page shell and a 401. Cost Optimization was half-broken the same way: its Overall charts run on /user/daily/activity, which every role may call, but tool spend, prompt caching, prompt compression and auto-router benchmarks are all proxy-admin-only. Add viewWorkflowRuns, viewMemory, viewGuardrailUsage and viewProxyWideCostData, each gating the nav entry, the page and the request together. The first three hide their page, including the direct-URL path, since nothing on them works for a non-admin. Cost Optimization keeps its page and drops only the parts a non-admin cannot read. Gating both Agentic children left roles with no visible child rendering the parent as a leaf link to ?page=agentic, which is not a route, so a parent whose children are all filtered out is now dropped. Role lists follow what the proxy actually grants: proxy_admin and proxy_admin_viewer are served, and org admins are not, because _user_is_org_admin needs an organization_id that a page-load GET never carries.
This commit is contained in:
parent
444b275ac3
commit
255d65192e
15 changed files with 514 additions and 71 deletions
|
|
@ -1,5 +1,11 @@
|
||||||
import { fireEvent, render } from "@testing-library/react";
|
import { fireEvent, render } from "@testing-library/react";
|
||||||
import { describe, expect, it, vi } from "vitest";
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const { useAuthorizedMock } = vi.hoisted(() => ({ useAuthorizedMock: vi.fn() }));
|
||||||
|
|
||||||
|
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
||||||
|
default: useAuthorizedMock,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("./UsageTab", () => ({ __esModule: true, default: () => <div data-testid="usage-tab" /> }));
|
vi.mock("./UsageTab", () => ({ __esModule: true, default: () => <div data-testid="usage-tab" /> }));
|
||||||
vi.mock("./PromptCompressionTab", () => ({ __esModule: true, default: () => <div data-testid="compression-tab" /> }));
|
vi.mock("./PromptCompressionTab", () => ({ __esModule: true, default: () => <div data-testid="compression-tab" /> }));
|
||||||
|
|
@ -11,9 +17,16 @@ vi.mock("./AutoRouterBenchmarksTab", () => ({
|
||||||
|
|
||||||
import CostOptimizationView from "./CostOptimizationView";
|
import CostOptimizationView from "./CostOptimizationView";
|
||||||
|
|
||||||
const renderView = () => render(<CostOptimizationView accessToken="test-token" userId="u1" userRole="proxy_admin" />);
|
const renderView = (userRole = "Admin") => {
|
||||||
|
useAuthorizedMock.mockReturnValue({ accessToken: "test-token", userId: "u1", userRole });
|
||||||
|
return render(<CostOptimizationView accessToken="test-token" userId="u1" userRole={userRole} />);
|
||||||
|
};
|
||||||
|
|
||||||
describe("CostOptimizationView", () => {
|
describe("CostOptimizationView", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useAuthorizedMock.mockReturnValue({ accessToken: "test-token", userId: "u1", userRole: "Admin" });
|
||||||
|
});
|
||||||
|
|
||||||
it("renders the four cost-optimization tabs", () => {
|
it("renders the four cost-optimization tabs", () => {
|
||||||
const { getByText } = renderView();
|
const { getByText } = renderView();
|
||||||
|
|
||||||
|
|
@ -34,4 +47,29 @@ describe("CostOptimizationView", () => {
|
||||||
expect(getByRole("tab", { name: "Overall" })).toHaveAttribute("aria-selected", "false");
|
expect(getByRole("tab", { name: "Overall" })).toHaveAttribute("aria-selected", "false");
|
||||||
expect(getByRole("tab", { name: "Prompt Compression" })).toHaveAttribute("aria-selected", "true");
|
expect(getByRole("tab", { name: "Prompt Compression" })).toHaveAttribute("aria-selected", "true");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Unlike the other three pages in this cleanup, Cost Optimization keeps its
|
||||||
|
// nav entry for internal users: the Overall tab runs on /user/daily/activity,
|
||||||
|
// which every role may call. Only the tabs reading proxy-wide config and
|
||||||
|
// telemetry (/config/list, /auto_router/benchmarks, guardrail management)
|
||||||
|
// are proxy-admin-only, so those are what disappear.
|
||||||
|
describe("proxy-admin-only tabs", () => {
|
||||||
|
it.each(["Internal User", "Internal Viewer", "Org Admin"])("shows %s the Overall tab only", (userRole) => {
|
||||||
|
const { getByRole, queryByRole } = renderView(userRole);
|
||||||
|
|
||||||
|
expect(getByRole("tab", { name: "Overall" })).toBeInTheDocument();
|
||||||
|
expect(queryByRole("tab", { name: "Prompt Compression" })).not.toBeInTheDocument();
|
||||||
|
expect(queryByRole("tab", { name: "Prompt Caching" })).not.toBeInTheDocument();
|
||||||
|
expect(queryByRole("tab", { name: "Auto-Router" })).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never mounts the panels behind the admin-only endpoints for an internal user", () => {
|
||||||
|
const { getByTestId, queryByTestId } = renderView("Internal User");
|
||||||
|
|
||||||
|
expect(getByTestId("usage-tab")).toBeInTheDocument();
|
||||||
|
expect(queryByTestId("compression-tab")).not.toBeInTheDocument();
|
||||||
|
expect(queryByTestId("caching-tab")).not.toBeInTheDocument();
|
||||||
|
expect(queryByTestId("autorouter-benchmarks-tab")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import React from "react";
|
||||||
import { PiggyBank } from "lucide-react";
|
import { PiggyBank } from "lucide-react";
|
||||||
import { Alert, Tabs } from "antd";
|
import { Alert, Tabs } from "antd";
|
||||||
|
|
||||||
|
import useCan from "@/app/(dashboard)/hooks/useCan";
|
||||||
import UsageTab from "./UsageTab";
|
import UsageTab from "./UsageTab";
|
||||||
import PromptCompressionTab from "./PromptCompressionTab";
|
import PromptCompressionTab from "./PromptCompressionTab";
|
||||||
import PromptCachingTab from "./PromptCachingTab";
|
import PromptCachingTab from "./PromptCachingTab";
|
||||||
|
|
@ -18,6 +19,7 @@ interface CostOptimizationViewProps {
|
||||||
|
|
||||||
const CostOptimizationView: React.FC<CostOptimizationViewProps> = ({ accessToken, userId, userRole }) => {
|
const CostOptimizationView: React.FC<CostOptimizationViewProps> = ({ accessToken, userId, userRole }) => {
|
||||||
const activity = useDailyActivityRange(accessToken, userId, userRole);
|
const activity = useDailyActivityRange(accessToken, userId, userRole);
|
||||||
|
const canViewProxyWideCostData = useCan("viewProxyWideCostData");
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{
|
{
|
||||||
|
|
@ -25,21 +27,25 @@ const CostOptimizationView: React.FC<CostOptimizationViewProps> = ({ accessToken
|
||||||
label: "Overall",
|
label: "Overall",
|
||||||
children: <UsageTab accessToken={accessToken} activity={activity} />,
|
children: <UsageTab accessToken={accessToken} activity={activity} />,
|
||||||
},
|
},
|
||||||
{
|
...(canViewProxyWideCostData
|
||||||
key: "compression",
|
? [
|
||||||
label: "Prompt Compression",
|
{
|
||||||
children: <PromptCompressionTab accessToken={accessToken} />,
|
key: "compression",
|
||||||
},
|
label: "Prompt Compression",
|
||||||
{
|
children: <PromptCompressionTab accessToken={accessToken} />,
|
||||||
key: "caching",
|
},
|
||||||
label: "Prompt Caching",
|
{
|
||||||
children: <PromptCachingTab accessToken={accessToken} activity={activity} />,
|
key: "caching",
|
||||||
},
|
label: "Prompt Caching",
|
||||||
{
|
children: <PromptCachingTab accessToken={accessToken} activity={activity} />,
|
||||||
key: "autorouter-usage",
|
},
|
||||||
label: "Auto-Router",
|
{
|
||||||
children: <AutoRouterBenchmarksTab accessToken={accessToken} />,
|
key: "autorouter-usage",
|
||||||
},
|
label: "Auto-Router",
|
||||||
|
children: <AutoRouterBenchmarksTab accessToken={accessToken} />,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,12 @@ import type { DailyData, SpendMetrics } from "@/components/UsagePage/types";
|
||||||
|
|
||||||
const mockGetToolSpend = vi.fn();
|
const mockGetToolSpend = vi.fn();
|
||||||
|
|
||||||
|
const { useAuthorizedMock } = vi.hoisted(() => ({ useAuthorizedMock: vi.fn() }));
|
||||||
|
|
||||||
|
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
||||||
|
default: useAuthorizedMock,
|
||||||
|
}));
|
||||||
|
|
||||||
vi.mock("@/components/networking", () => ({
|
vi.mock("@/components/networking", () => ({
|
||||||
getToolSpend: (...args: unknown[]) => mockGetToolSpend(...args),
|
getToolSpend: (...args: unknown[]) => mockGetToolSpend(...args),
|
||||||
}));
|
}));
|
||||||
|
|
@ -88,11 +94,18 @@ interface RenderOptions {
|
||||||
toolSpend?: ToolSpendResponse;
|
toolSpend?: ToolSpendResponse;
|
||||||
from?: Date;
|
from?: Date;
|
||||||
to?: Date;
|
to?: Date;
|
||||||
|
userRole?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const renderWith = (results: DailyData[], options: RenderOptions = {}) => {
|
const renderWith = (results: DailyData[], options: RenderOptions = {}) => {
|
||||||
const { toolSpend = emptyToolSpend, from = new Date(2026, 6, 1), to = new Date(2026, 6, 14) } = options;
|
const {
|
||||||
|
toolSpend = emptyToolSpend,
|
||||||
|
from = new Date(2026, 6, 1),
|
||||||
|
to = new Date(2026, 6, 14),
|
||||||
|
userRole = "Admin",
|
||||||
|
} = options;
|
||||||
mockGetToolSpend.mockResolvedValue(toolSpend);
|
mockGetToolSpend.mockResolvedValue(toolSpend);
|
||||||
|
useAuthorizedMock.mockReturnValue({ accessToken: "test-token", userId: "u1", userRole });
|
||||||
return render(
|
return render(
|
||||||
<UsageTab
|
<UsageTab
|
||||||
accessToken="test-token"
|
accessToken="test-token"
|
||||||
|
|
@ -374,4 +387,38 @@ describe("UsageTab", () => {
|
||||||
const toolLegends = getAllByTestId("chart-legend").filter((legend) => legend.textContent === "search,read_file");
|
const toolLegends = getAllByTestId("chart-legend").filter((legend) => legend.textContent === "search,read_file");
|
||||||
expect(toolLegends).toHaveLength(1);
|
expect(toolLegends).toHaveLength(1);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// `/v1/tool/spend` is proxy-admin-only while the daily-activity charts around
|
||||||
|
// it are not, so this one card is dropped rather than the whole tab.
|
||||||
|
describe("proxy-admin-only spend-by-tool card", () => {
|
||||||
|
const toolSpend = {
|
||||||
|
by_tool: [{ tool_name: "search", spend: 4.0, call_count: 3, total_tokens: 150 }],
|
||||||
|
daily: [{ date: "2026-07-12", tool_name: "search", spend: 4.0, call_count: 3 }],
|
||||||
|
start_date: "2026-07-12",
|
||||||
|
end_date: "2026-07-12",
|
||||||
|
};
|
||||||
|
|
||||||
|
it.each(["Internal User", "Internal Viewer", "Org Admin"])(
|
||||||
|
"hides the card and never calls the endpoint for %s",
|
||||||
|
async (userRole) => {
|
||||||
|
const { queryByText, getByTestId } = renderWith([day("2026-07-12", { compression_savings_spend: 0.04 })], {
|
||||||
|
toolSpend,
|
||||||
|
userRole,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Liveness gate: the daily-activity charts still render for this role,
|
||||||
|
// so the absence below is the gate, not an empty tab.
|
||||||
|
expect(getByTestId("donut-chart")).toBeInTheDocument();
|
||||||
|
expect(queryByText("Spend by tool")).not.toBeInTheDocument();
|
||||||
|
await vi.waitFor(() => expect(mockGetToolSpend).not.toHaveBeenCalled());
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("keeps the card and the endpoint call for an admin", async () => {
|
||||||
|
const { findByText } = renderWith([day("2026-07-12", { compression_savings_spend: 0.04 })], { toolSpend });
|
||||||
|
|
||||||
|
expect(await findByText("Spend by tool")).toBeInTheDocument();
|
||||||
|
expect(mockGetToolSpend).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import AdvancedDatePicker from "@/components/shared/advanced_date_picker";
|
||||||
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover";
|
||||||
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import useCan from "@/app/(dashboard)/hooks/useCan";
|
||||||
import { getToolSpend, ToolSpendResponse } from "@/components/networking";
|
import { getToolSpend, ToolSpendResponse } from "@/components/networking";
|
||||||
import { SpendMetrics } from "@/components/UsagePage/types";
|
import { SpendMetrics } from "@/components/UsagePage/types";
|
||||||
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
import { formatNumberWithCommas } from "@/utils/dataUtils";
|
||||||
|
|
@ -82,12 +83,13 @@ const UsageTab: React.FC<UsageTabProps> = ({ accessToken, activity }) => {
|
||||||
const startTime = dateValue.from ?? null;
|
const startTime = dateValue.from ?? null;
|
||||||
const endTime = dateValue.to ?? null;
|
const endTime = dateValue.to ?? null;
|
||||||
|
|
||||||
const toolSpendEnabled = !!accessToken && !!startTime && !!endTime;
|
const canViewProxyWideCostData = useCan("viewProxyWideCostData");
|
||||||
|
const toolSpendEnabled = canViewProxyWideCostData && !!accessToken && !!startTime && !!endTime;
|
||||||
const rangeKey = startTime && endTime ? `${isoDay(startTime)}|${isoDay(endTime)}` : "";
|
const rangeKey = startTime && endTime ? `${isoDay(startTime)}|${isoDay(endTime)}` : "";
|
||||||
const [toolSpendState, setToolSpendState] = useState<{ key: string; data: ToolSpendResponse } | null>(null);
|
const [toolSpendState, setToolSpendState] = useState<{ key: string; data: ToolSpendResponse } | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!accessToken || !startTime || !endTime) return;
|
if (!canViewProxyWideCostData || !accessToken || !startTime || !endTime) return;
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
getToolSpend(accessToken, isoDay(startTime), isoDay(endTime))
|
getToolSpend(accessToken, isoDay(startTime), isoDay(endTime))
|
||||||
.then((res) => {
|
.then((res) => {
|
||||||
|
|
@ -99,7 +101,7 @@ const UsageTab: React.FC<UsageTabProps> = ({ accessToken, activity }) => {
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [accessToken, startTime, endTime, rangeKey]);
|
}, [canViewProxyWideCostData, accessToken, startTime, endTime, rangeKey]);
|
||||||
|
|
||||||
const toolSpend = toolSpendState?.key === rangeKey ? toolSpendState.data : null;
|
const toolSpend = toolSpendState?.key === rangeKey ? toolSpendState.data : null;
|
||||||
const toolSpendLoading = toolSpendEnabled && toolSpend === null;
|
const toolSpendLoading = toolSpendEnabled && toolSpend === null;
|
||||||
|
|
@ -273,55 +275,57 @@ const UsageTab: React.FC<UsageTabProps> = ({ accessToken, activity }) => {
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card>
|
{canViewProxyWideCostData && (
|
||||||
<CardHeader>
|
<Card>
|
||||||
<CardTitle>Spend by tool</CardTitle>
|
<CardHeader>
|
||||||
<p className="text-sm text-muted-foreground">
|
<CardTitle>Spend by tool</CardTitle>
|
||||||
Spend on requests that invoked each tool (MCP and client-side tools); declaring a tool without invoking it
|
<p className="text-sm text-muted-foreground">
|
||||||
does not count. A request that invoked multiple tools counts its full spend toward each, so this attributes
|
Spend on requests that invoked each tool (MCP and client-side tools); declaring a tool without invoking it
|
||||||
rather than partitions spend.
|
does not count. A request that invoked multiple tools counts its full spend toward each, so this
|
||||||
</p>
|
attributes rather than partitions spend.
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{topTools.length === 0 ? (
|
|
||||||
<p className="py-8 text-center text-sm text-muted-foreground">
|
|
||||||
{toolSpendLoading ? "Loading..." : "No tool usage in this range."}
|
|
||||||
</p>
|
</p>
|
||||||
) : (
|
</CardHeader>
|
||||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
<CardContent>
|
||||||
<div>
|
{topTools.length === 0 ? (
|
||||||
<p className="mb-2 text-sm font-medium text-muted-foreground">Total by tool</p>
|
<p className="py-8 text-center text-sm text-muted-foreground">
|
||||||
<BarChart
|
{toolSpendLoading ? "Loading..." : "No tool usage in this range."}
|
||||||
data={topToolsChart}
|
</p>
|
||||||
index="tool_name"
|
) : (
|
||||||
categories={["spend"]}
|
<div className="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
colors={toolColors}
|
<div>
|
||||||
colorByDatum
|
<p className="mb-2 text-sm font-medium text-muted-foreground">Total by tool</p>
|
||||||
layout="vertical"
|
<BarChart
|
||||||
yAxisWidth={140}
|
data={topToolsChart}
|
||||||
maxBarSize={64}
|
index="tool_name"
|
||||||
showLegend={false}
|
categories={["spend"]}
|
||||||
valueFormatter={usd}
|
colors={toolColors}
|
||||||
/>
|
colorByDatum
|
||||||
|
layout="vertical"
|
||||||
|
yAxisWidth={140}
|
||||||
|
maxBarSize={64}
|
||||||
|
showLegend={false}
|
||||||
|
valueFormatter={usd}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="mb-2 text-sm font-medium text-muted-foreground">Daily spend by tool</p>
|
||||||
|
<CustomLegend categories={topToolNames} colors={toolColors} />
|
||||||
|
<BarChart
|
||||||
|
data={dailyToolSeries}
|
||||||
|
index="date"
|
||||||
|
categories={topToolNames}
|
||||||
|
colors={toolColors}
|
||||||
|
stack
|
||||||
|
maxBarSize={64}
|
||||||
|
valueFormatter={usd}
|
||||||
|
showLegend={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
)}
|
||||||
<p className="mb-2 text-sm font-medium text-muted-foreground">Daily spend by tool</p>
|
</CardContent>
|
||||||
<CustomLegend categories={topToolNames} colors={toolColors} />
|
</Card>
|
||||||
<BarChart
|
)}
|
||||||
data={dailyToolSeries}
|
|
||||||
index="date"
|
|
||||||
categories={topToolNames}
|
|
||||||
colors={toolColors}
|
|
||||||
stack
|
|
||||||
maxBarSize={64}
|
|
||||||
valueFormatter={usd}
|
|
||||||
showLegend={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
import { screen, waitFor } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import GuardrailsMonitor from "./page";
|
||||||
|
import { renderWithProviders, testQueryClient } from "../../../../tests/test-utils";
|
||||||
|
|
||||||
|
const { useAuthorizedMock } = vi.hoisted(() => ({ useAuthorizedMock: vi.fn() }));
|
||||||
|
|
||||||
|
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
||||||
|
default: useAuthorizedMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const fetchMock = vi.fn();
|
||||||
|
|
||||||
|
const requestedUrls = () => fetchMock.mock.calls.map(([url]) => String(url));
|
||||||
|
|
||||||
|
const renderAs = (userRole: string) => {
|
||||||
|
useAuthorizedMock.mockReturnValue({ accessToken: "sk-test", userId: "u1", userRole });
|
||||||
|
return renderWithProviders(<GuardrailsMonitor />);
|
||||||
|
};
|
||||||
|
|
||||||
|
// `/guardrails/usage/*` aggregates across tenants and is listed in
|
||||||
|
// admin_viewer_routes, so it is proxy-admin-only. Nothing on this page works
|
||||||
|
// for a non-admin, hence the whole page is gated rather than a section of it.
|
||||||
|
describe("Guardrails Monitor page access by role", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
testQueryClient.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
fetchMock.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
statusText: "OK",
|
||||||
|
json: async () => ({ rows: [], chart: [], totalRequests: 0, totalBlocked: 0, passRate: 100 }),
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fetches the guardrails usage overview for an admin", async () => {
|
||||||
|
renderAs("Admin");
|
||||||
|
|
||||||
|
await waitFor(() => expect(requestedUrls().some((url) => url.includes("/guardrails/usage/overview"))).toBe(true));
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["Internal User", "Internal Viewer", "Org Admin", "Unknown Role"])(
|
||||||
|
"renders the admin-only notice and fires no usage request for %s",
|
||||||
|
async (userRole) => {
|
||||||
|
renderAs(userRole);
|
||||||
|
|
||||||
|
expect(await screen.findByText("Guardrails Monitor is only available to admin users.")).toBeInTheDocument();
|
||||||
|
await waitFor(() => expect(fetchMock).not.toHaveBeenCalled());
|
||||||
|
expect(requestedUrls().filter((url) => url.includes("/guardrails/usage"))).toEqual([]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
@ -1,9 +1,17 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import GuardrailsMonitorView from "./_components/GuardrailsMonitorView";
|
import GuardrailsMonitorView from "./_components/GuardrailsMonitorView";
|
||||||
|
import { AdminOnlyNotice } from "@/components/shared/AdminOnlyNotice";
|
||||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||||
|
import useCan from "@/app/(dashboard)/hooks/useCan";
|
||||||
|
|
||||||
export default function GuardrailsMonitor() {
|
export default function GuardrailsMonitor() {
|
||||||
const { accessToken } = useAuthorized();
|
const { accessToken } = useAuthorized();
|
||||||
|
const canViewGuardrailUsage = useCan("viewGuardrailUsage");
|
||||||
|
|
||||||
|
if (!canViewGuardrailUsage) {
|
||||||
|
return <AdminOnlyNotice pageTitle="Guardrails Monitor" />;
|
||||||
|
}
|
||||||
|
|
||||||
return <GuardrailsMonitorView accessToken={accessToken} />;
|
return <GuardrailsMonitorView accessToken={accessToken} />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { screen, waitFor } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import Memory from "./page";
|
||||||
|
import { renderWithProviders, testQueryClient } from "../../../../tests/test-utils";
|
||||||
|
|
||||||
|
const { useAuthorizedMock } = vi.hoisted(() => ({ useAuthorizedMock: vi.fn() }));
|
||||||
|
|
||||||
|
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
||||||
|
default: useAuthorizedMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const fetchMock = vi.fn();
|
||||||
|
|
||||||
|
const requestedUrls = () => fetchMock.mock.calls.map(([url]) => String(url));
|
||||||
|
|
||||||
|
const renderAs = (userRole: string) => {
|
||||||
|
useAuthorizedMock.mockReturnValue({ accessToken: "sk-test", userId: "u1", userRole });
|
||||||
|
return renderWithProviders(<Memory />);
|
||||||
|
};
|
||||||
|
|
||||||
|
// `/v1/memory` scopes rows per caller in the handler, but the route gate keeps
|
||||||
|
// it proxy-admin-only, so a non-admin deep-linking to /ui/memory gets a 401.
|
||||||
|
describe("Memory page access by role", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
testQueryClient.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
fetchMock.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
statusText: "OK",
|
||||||
|
text: async () => "",
|
||||||
|
json: async () => ({ memories: [], total: 0 }),
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists memory entries for an admin", async () => {
|
||||||
|
renderAs("Admin");
|
||||||
|
|
||||||
|
await waitFor(() => expect(requestedUrls().some((url) => url.includes("/v1/memory"))).toBe(true));
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["Internal User", "Internal Viewer", "Org Admin", "Unknown Role"])(
|
||||||
|
"renders the admin-only notice and fires no memory request for %s",
|
||||||
|
async (userRole) => {
|
||||||
|
renderAs(userRole);
|
||||||
|
|
||||||
|
expect(await screen.findByText("Memory is only available to admin users.")).toBeInTheDocument();
|
||||||
|
await waitFor(() => expect(fetchMock).not.toHaveBeenCalled());
|
||||||
|
expect(requestedUrls().filter((url) => url.includes("/v1/memory"))).toEqual([]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("hides the deprecation banner along with the page body for a denied role", () => {
|
||||||
|
renderAs("Internal User");
|
||||||
|
|
||||||
|
expect(screen.queryByText(/draft deprecation list/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -2,10 +2,18 @@
|
||||||
|
|
||||||
import { MemoryView } from "./_components/MemoryView";
|
import { MemoryView } from "./_components/MemoryView";
|
||||||
import { DeprecationBanner } from "@/components/DeprecationBanner";
|
import { DeprecationBanner } from "@/components/DeprecationBanner";
|
||||||
|
import { AdminOnlyNotice } from "@/components/shared/AdminOnlyNotice";
|
||||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||||
|
import useCan from "@/app/(dashboard)/hooks/useCan";
|
||||||
|
|
||||||
export default function Memory() {
|
export default function Memory() {
|
||||||
const { accessToken, userRole, userId } = useAuthorized();
|
const { accessToken, userRole, userId } = useAuthorized();
|
||||||
|
const canViewMemory = useCan("viewMemory");
|
||||||
|
|
||||||
|
if (!canViewMemory) {
|
||||||
|
return <AdminOnlyNotice pageTitle="Memory" />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DeprecationBanner featureName="Memory" />
|
<DeprecationBanner featureName="Memory" />
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
import { screen, waitFor } from "@testing-library/react";
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import Workflows from "./page";
|
||||||
|
import { renderWithProviders, testQueryClient } from "../../../../tests/test-utils";
|
||||||
|
|
||||||
|
const { useAuthorizedMock } = vi.hoisted(() => ({ useAuthorizedMock: vi.fn() }));
|
||||||
|
|
||||||
|
vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({
|
||||||
|
default: useAuthorizedMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const fetchMock = vi.fn();
|
||||||
|
|
||||||
|
const requestedUrls = () => fetchMock.mock.calls.map(([url]) => String(url));
|
||||||
|
|
||||||
|
const renderAs = (userRole: string) => {
|
||||||
|
useAuthorizedMock.mockReturnValue({ accessToken: "sk-test", userId: "u1", userRole });
|
||||||
|
return renderWithProviders(<Workflows />);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Deep-linking to /ui/workflows bypasses the sidebar, so the page itself has to
|
||||||
|
// refuse the render. `/v1/workflows/runs` is proxy-admin-only, so any request
|
||||||
|
// from a non-admin is the 401 this gate exists to stop.
|
||||||
|
describe("Workflows page access by role", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
testQueryClient.clear();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
fetchMock.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
statusText: "OK",
|
||||||
|
json: async () => ({ runs: [], count: 0 }),
|
||||||
|
});
|
||||||
|
vi.stubGlobal("fetch", fetchMock);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("lists workflow runs for an admin", async () => {
|
||||||
|
renderAs("Admin");
|
||||||
|
|
||||||
|
await waitFor(() => expect(requestedUrls().some((url) => url.includes("/v1/workflows/runs"))).toBe(true));
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each(["Internal User", "Internal Viewer", "Org Admin", "Unknown Role"])(
|
||||||
|
"renders the admin-only notice and fires no workflow request for %s",
|
||||||
|
async (userRole) => {
|
||||||
|
renderAs(userRole);
|
||||||
|
|
||||||
|
expect(await screen.findByText("Workflow Runs is only available to admin users.")).toBeInTheDocument();
|
||||||
|
await waitFor(() => expect(fetchMock).not.toHaveBeenCalled());
|
||||||
|
expect(requestedUrls().filter((url) => url.includes("/v1/workflows"))).toEqual([]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it("hides the deprecation banner along with the page body for a denied role", () => {
|
||||||
|
renderAs("Internal User");
|
||||||
|
|
||||||
|
expect(screen.queryByText(/draft deprecation list/i)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
@ -2,10 +2,18 @@
|
||||||
|
|
||||||
import WorkflowRuns from "./WorkflowRuns";
|
import WorkflowRuns from "./WorkflowRuns";
|
||||||
import { DeprecationBanner } from "@/components/DeprecationBanner";
|
import { DeprecationBanner } from "@/components/DeprecationBanner";
|
||||||
|
import { AdminOnlyNotice } from "@/components/shared/AdminOnlyNotice";
|
||||||
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized";
|
||||||
|
import useCan from "@/app/(dashboard)/hooks/useCan";
|
||||||
|
|
||||||
export default function Workflows() {
|
export default function Workflows() {
|
||||||
const { accessToken } = useAuthorized();
|
const { accessToken } = useAuthorized();
|
||||||
|
const canViewWorkflowRuns = useCan("viewWorkflowRuns");
|
||||||
|
|
||||||
|
if (!canViewWorkflowRuns) {
|
||||||
|
return <AdminOnlyNotice pageTitle="Workflow Runs" />;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<DeprecationBanner featureName="Workflows" />
|
<DeprecationBanner featureName="Workflows" />
|
||||||
|
|
|
||||||
|
|
@ -268,6 +268,96 @@ describe("Sidebar (leftnav)", () => {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Workflow Runs, Memory and Guardrails Monitor render a shell and then 401
|
||||||
|
// for every non-proxy-admin role, because their page-load routes sit outside
|
||||||
|
// internal_user_routes / self_managed_routes. Cost Optimization does not:
|
||||||
|
// its primary call is /user/daily/activity, which every role may make, so
|
||||||
|
// the entry stays and only its proxy-wide tabs are gated inside the page.
|
||||||
|
describe("capability-gated pages whose data is proxy-admin-only", () => {
|
||||||
|
const authFor = (userRole: string) => ({
|
||||||
|
userId: "some-user-id",
|
||||||
|
accessToken: "test-access-token",
|
||||||
|
userRole,
|
||||||
|
isViewOnly: false,
|
||||||
|
token: "test-token",
|
||||||
|
userEmail: "someone@example.com",
|
||||||
|
premiumUser: false,
|
||||||
|
disabledPersonalKeyCreation: false,
|
||||||
|
showSSOBanner: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
mockUseAuthorized.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides Workflow Runs and Memory from an internal user under Agentic", async () => {
|
||||||
|
mockUseAuthorized.mockReturnValue(authFor("internal"));
|
||||||
|
renderWithProviders(<Sidebar {...defaultProps} />);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
fireEvent.click(screen.getByText("Agentic"));
|
||||||
|
});
|
||||||
|
// Liveness gate: the sibling Agents child stays visible to this role, so
|
||||||
|
// the absences below mean the gate fired, not that the group never opened.
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Agents")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.queryByText("Workflow Runs")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Memory")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
// An org admin's session role is "Org Admin", which no capability list
|
||||||
|
// carries, and the proxy denies these routes to org admins too because
|
||||||
|
// `_user_is_org_admin` needs an organization_id the page-load GET never sends.
|
||||||
|
// Agents is already out of reach for this role, so gating the other two
|
||||||
|
// empties the Agentic group entirely and the parent must go with it rather
|
||||||
|
// than degrade into a leaf link to the non-route `?page=agentic`.
|
||||||
|
it("drops the whole Agentic group for an org admin once its last child is gated", () => {
|
||||||
|
mockUseAuthorized.mockReturnValue(authFor("org_admin"));
|
||||||
|
renderWithProviders(<Sidebar {...defaultProps} />);
|
||||||
|
|
||||||
|
// Liveness gate: Logs carries no role list, so it proves the sidebar rendered.
|
||||||
|
expect(screen.getByText("Logs")).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Agentic")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Workflow Runs")).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("Memory")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the Agentic group for an internal user, who can still see Agents", () => {
|
||||||
|
mockUseAuthorized.mockReturnValue(authFor("internal"));
|
||||||
|
renderWithProviders(<Sidebar {...defaultProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Agentic")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows Workflow Runs and Memory to admins", async () => {
|
||||||
|
renderWithProviders(<Sidebar {...defaultProps} />);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
fireEvent.click(screen.getByText("Agentic"));
|
||||||
|
});
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText("Workflow Runs")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
expect(screen.getByText("Memory")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides Guardrails Monitor from an internal user while keeping Usage and Cost Optimization", () => {
|
||||||
|
mockUseAuthorized.mockReturnValue(authFor("internal"));
|
||||||
|
renderWithProviders(<Sidebar {...defaultProps} />);
|
||||||
|
|
||||||
|
expect(screen.queryByText("Guardrails Monitor")).not.toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Usage")).toBeInTheDocument();
|
||||||
|
expect(screen.getByText("Cost Optimization")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows Guardrails Monitor to admins", () => {
|
||||||
|
renderWithProviders(<Sidebar {...defaultProps} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("Guardrails Monitor")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("should show Organizations tab for organization admins", () => {
|
it("should show Organizations tab for organization admins", () => {
|
||||||
mockUseAuthorized.mockReturnValueOnce({
|
mockUseAuthorized.mockReturnValueOnce({
|
||||||
userId: "org-admin-user-id",
|
userId: "org-admin-user-id",
|
||||||
|
|
|
||||||
|
|
@ -146,8 +146,20 @@ const menuGroups: MenuGroup[] = [
|
||||||
icon: <Bot {...ICON} />,
|
icon: <Bot {...ICON} />,
|
||||||
roles: rolesAllowedToViewWriteScopedPages,
|
roles: rolesAllowedToViewWriteScopedPages,
|
||||||
},
|
},
|
||||||
{ key: "workflows", page: "workflows", label: "Workflow Runs", icon: <Workflow {...ICON} /> },
|
{
|
||||||
{ key: "memory", page: "memory", label: "Memory", icon: <Database {...ICON} /> },
|
key: "workflows",
|
||||||
|
page: "workflows",
|
||||||
|
label: "Workflow Runs",
|
||||||
|
icon: <Workflow {...ICON} />,
|
||||||
|
roles: rolesWithCapability("viewWorkflowRuns"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "memory",
|
||||||
|
page: "memory",
|
||||||
|
label: "Memory",
|
||||||
|
icon: <Database {...ICON} />,
|
||||||
|
roles: rolesWithCapability("viewMemory"),
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{ key: "mcp-servers", page: "mcp-servers", label: "MCP Servers", icon: <Server {...ICON} /> },
|
{ key: "mcp-servers", page: "mcp-servers", label: "MCP Servers", icon: <Server {...ICON} /> },
|
||||||
|
|
@ -206,7 +218,7 @@ const menuGroups: MenuGroup[] = [
|
||||||
page: "guardrails-monitor",
|
page: "guardrails-monitor",
|
||||||
label: "Guardrails Monitor",
|
label: "Guardrails Monitor",
|
||||||
icon: <HeartPulse {...ICON} />,
|
icon: <HeartPulse {...ICON} />,
|
||||||
roles: [...all_admin_roles, ...internalUserRoles],
|
roles: rolesWithCapability("viewGuardrailUsage"),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
@ -455,6 +467,9 @@ const Sidebar_: React.FC<SidebarProps> = ({
|
||||||
return items
|
return items
|
||||||
.map((item) => ({ ...item, children: item.children ? filterItemsByRole(item.children) : undefined }))
|
.map((item) => ({ ...item, children: item.children ? filterItemsByRole(item.children) : undefined }))
|
||||||
.filter((item) => {
|
.filter((item) => {
|
||||||
|
// A parent whose children were all filtered out renders as a leaf link
|
||||||
|
// to its own page id, which is not a real route. Drop it instead.
|
||||||
|
if (item.children && item.children.length === 0) return false;
|
||||||
if (item.key === "llm-playground" && isViewOnly) return false;
|
if (item.key === "llm-playground" && isViewOnly) return false;
|
||||||
if (item.key === "organizations" || item.key === "users") {
|
if (item.key === "organizations" || item.key === "users") {
|
||||||
const hasRoleAccess = !item.roles || item.roles.includes(userRole) || isOrgAdmin;
|
const hasRoleAccess = !item.roles || item.roles.includes(userRole) || isOrgAdmin;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
interface AdminOnlyNoticeProps {
|
||||||
|
pageTitle: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const AdminOnlyNotice: React.FC<AdminOnlyNoticeProps> = ({ pageTitle }) => (
|
||||||
|
<div className="p-6 w-full min-w-0 flex-1">
|
||||||
|
<h1 className="text-2xl font-semibold text-gray-900 mb-2">{pageTitle}</h1>
|
||||||
|
<p className="text-sm text-gray-500">{pageTitle} is only available to admin users.</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
@ -67,6 +67,36 @@ describe.each(["viewAuditLogs", "viewDeletedTeams"] as const)("hasCapability - %
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// `useAuthorized` supplies `userRole` as the formatted session role from
|
||||||
|
// `effectiveSessionRole`, which collapses proxy_admin_viewer to "Admin" and
|
||||||
|
// renders an org admin as "Org Admin". The four sidebar pages behind these
|
||||||
|
// capabilities call proxy-admin-only routes: `_user_is_org_admin` needs an
|
||||||
|
// `organization_id` in the request data, which a page-load GET never carries,
|
||||||
|
// so an org admin is denied at the proxy exactly as it is here.
|
||||||
|
describe.each(["viewWorkflowRuns", "viewMemory", "viewGuardrailUsage", "viewProxyWideCostData"] as const)(
|
||||||
|
"hasCapability - %s",
|
||||||
|
(capability) => {
|
||||||
|
it.each(["Admin", "Admin Viewer", "proxy_admin", "proxy_admin_viewer"])("should grant it to %s", (role) => {
|
||||||
|
expect(hasCapability(role, capability)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
"Internal User",
|
||||||
|
"Internal Viewer",
|
||||||
|
"internal_user",
|
||||||
|
"internal_user_viewer",
|
||||||
|
"Org Admin",
|
||||||
|
"App User",
|
||||||
|
"Unknown Role",
|
||||||
|
"",
|
||||||
|
null,
|
||||||
|
undefined,
|
||||||
|
])("should deny it to %s", (role) => {
|
||||||
|
expect(hasCapability(role, capability)).toBe(false);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
describe("rolesWithCapability", () => {
|
describe("rolesWithCapability", () => {
|
||||||
it("should return a copy so callers cannot mutate the capability map", () => {
|
it("should return a copy so callers cannot mutate the capability map", () => {
|
||||||
const roles = rolesWithCapability("viewToolPolicies");
|
const roles = rolesWithCapability("viewToolPolicies");
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,10 @@ const CAPABILITY_ROLES = {
|
||||||
viewDeletedTeams: all_admin_roles,
|
viewDeletedTeams: all_admin_roles,
|
||||||
viewPolicies: all_admin_roles,
|
viewPolicies: all_admin_roles,
|
||||||
viewPrompts: all_admin_roles,
|
viewPrompts: all_admin_roles,
|
||||||
|
viewWorkflowRuns: all_admin_roles,
|
||||||
|
viewMemory: all_admin_roles,
|
||||||
|
viewGuardrailUsage: all_admin_roles,
|
||||||
|
viewProxyWideCostData: all_admin_roles,
|
||||||
} as const satisfies Record<string, readonly string[]>;
|
} as const satisfies Record<string, readonly string[]>;
|
||||||
|
|
||||||
export type Capability = keyof typeof CAPABILITY_ROLES;
|
export type Capability = keyof typeof CAPABILITY_ROLES;
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue