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:
Yuneng Jiang 2026-08-04 09:28:28 -07:00
parent abe3289398
commit 22b60624ad
No known key found for this signature in database
10 changed files with 174 additions and 20 deletions

View 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;

View file

@ -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();

View file

@ -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(

View file

@ -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,
});

View file

@ -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" />);

View file

@ -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" ? (

View file

@ -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",

View file

@ -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"),
},
],
},
],

View 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);
});
});

View 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]];