Merge pull request #36472 from BerriAI/litellm_/modest-mcclintock-5b4d30

fix(ui): scope Virtual Keys and Logs team lists to the caller
This commit is contained in:
yuneng-jiang 2026-08-10 16:46:30 -07:00 • committed by GitHub
commit 487f8b2408
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 216 additions and 13 deletions

View file

@ -820,6 +820,18 @@ describe("useAllTeams", () => {
});
const requestedPage = (url: string) => new URLSearchParams(url.split("?")[1]).get("page");
const requestedUserId = (url: string) => new URLSearchParams(url.split("?")[1]).get("user_id");
const asRole = (userRole: string, userId = "test-user-id") =>
mockUseAuthorized.mockReturnValue({
accessToken: "test-access-token",
userId,
userRole,
token: "test-token",
userEmail: "test@example.com",
premiumUser: false,
disabledPersonalKeyCreation: null,
showSSOBanner: false,
});
it("paginates /v2/team/list to completion and concatenates every page", async () => {
fetchMock.mockImplementation((url: string) =>
@ -892,4 +904,63 @@ describe("useAllTeams", () => {
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
});
it("scopes the request to the caller for an internal user and returns their teams", async () => {
asRole("Internal User", "member-7");
fetchMock.mockResolvedValue(pageResponse(mockTeams, 1, 1));
const { result } = renderHook(() => useAllTeams(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(result.current.data).toEqual(mockTeams);
expect(result.current.data?.length).toBeGreaterThan(0);
expect(requestedUserId(fetchMock.mock.calls[0][0] as string)).toBe("member-7");
});
it("carries user_id on every page of a scoped multi-page result", async () => {
asRole("Internal Viewer", "member-7");
fetchMock.mockImplementation((url: string) =>
Promise.resolve(
requestedPage(url) === "1" ? pageResponse([mockTeams[0]], 1, 2) : pageResponse([mockTeams[1]], 2, 2),
),
);
const { result } = renderHook(() => useAllTeams(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(fetchMock).toHaveBeenCalledTimes(2);
const scopes = fetchMock.mock.calls.map((call) => requestedUserId(call[0] as string));
expect(scopes).toEqual(["member-7", "member-7"]);
});
it.each(["Admin", "Admin Viewer", "Org Admin"])(
"sends no user_id for %s so the broad list is left intact",
async (userRole) => {
asRole(userRole);
fetchMock.mockResolvedValue(pageResponse(mockTeams, 1, 1));
const { result } = renderHook(() => useAllTeams(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(requestedUserId(fetchMock.mock.calls[0][0] as string)).toBeNull();
},
);
it("refetches when the scope changes even though the access token has not", async () => {
asRole("Internal User", "member-7");
fetchMock.mockResolvedValue(pageResponse(mockTeams, 1, 1));
const { result, rerender } = renderHook(() => useAllTeams(), { wrapper });
await waitFor(() => expect(result.current.isSuccess).toBe(true));
expect(fetchMock).toHaveBeenCalledTimes(1);
asRole("Internal User", "member-8");
rerender();
await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2));
expect(requestedUserId(fetchMock.mock.calls[1][0] as string)).toBe("member-8");
});
});

View file

@ -5,6 +5,7 @@ import { fetchTeams } from "@/app/(dashboard)/networking";
import { createQueryKeys } from "@/app/(dashboard)/hooks/common/queryKeysFactory";
import { teamInfoCall } from "@/components/networking";
import { getProxyBaseUrl, getGlobalLitellmHeaderName, deriveErrorMessage, handleError } from "@/components/networking";
import { teamListScopeUserId } from "@/utils/roles";
export interface TeamsResponse {
teams: Team[];
@ -116,24 +117,30 @@ export const useTeams = (): UseQueryResult<Team[]> => {
const ALL_TEAMS_PAGE_SIZE = 100;
const fetchAllTeamsPaged = async (accessToken: string): Promise<Team[]> => {
const firstPage: TeamsResponse = await teamListCall(accessToken, 1, ALL_TEAMS_PAGE_SIZE);
const fetchAllTeamsPaged = async (accessToken: string, userID: string | null): Promise<Team[]> => {
const firstPage: TeamsResponse = await teamListCall(accessToken, 1, ALL_TEAMS_PAGE_SIZE, { userID });
const totalPages = firstPage.total_pages ?? 1;
if (totalPages <= 1) return firstPage.teams;
const remainingPages: TeamsResponse[] = await Promise.all(
Array.from({ length: totalPages - 1 }, (_, i) => teamListCall(accessToken, i + 2, ALL_TEAMS_PAGE_SIZE)),
Array.from({ length: totalPages - 1 }, (_, i) => teamListCall(accessToken, i + 2, ALL_TEAMS_PAGE_SIZE, { userID })),
);
return [firstPage, ...remainingPages].flatMap((page) => page.teams);
};
export const useAllTeams = (): UseQueryResult<Team[]> => {
const { accessToken } = useAuthorized();
const { accessToken, userId, userRole } = useAuthorized();
const scopedUserID = teamListScopeUserId(userRole, userId);
return useQuery<Team[]>({
queryKey: teamKeys.list({
filters: { scope: "all", pageSize: ALL_TEAMS_PAGE_SIZE, accessToken: accessToken ?? "" },
filters: {
scope: "all",
pageSize: ALL_TEAMS_PAGE_SIZE,
accessToken: accessToken ?? "",
userID: scopedUserID ?? "",
},
}),
queryFn: async () => await fetchAllTeamsPaged(accessToken!),
queryFn: async () => await fetchAllTeamsPaged(accessToken!, scopedUserID),
enabled: Boolean(accessToken),
staleTime: 30000,
});

View file

@ -1,11 +1,12 @@
import { describe, expect, it, vi } from "vitest";
import { fetchTeamFilterOptions } from "./filter_helpers";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { fetchAllTeams, fetchTeamFilterOptions } from "./filter_helpers";
const mockKeyListCall = vi.fn();
const mockTeamListCall = vi.fn();
vi.mock("@/components/networking", () => ({
keyListCall: (...args: unknown[]) => mockKeyListCall(...args),
teamListCall: vi.fn(),
teamListCall: (...args: unknown[]) => mockTeamListCall(...args),
organizationListCall: vi.fn(),
}));
@ -78,3 +79,39 @@ describe("fetchTeamFilterOptions", () => {
expect(result).toEqual({ keyAliases: [], organizationIds: [], userIds: [] });
});
});
describe("fetchAllTeams", () => {
beforeEach(() => {
mockTeamListCall.mockReset();
});
it("forwards the scoping user id to /team/list and returns the rows it answers with", async () => {
mockTeamListCall.mockResolvedValue([{ team_id: "team-a" }, { team_id: "team-b" }]);
const teams = await fetchAllTeams("tok-123", null, "member-7");
expect(mockTeamListCall).toHaveBeenCalledWith("tok-123", null, "member-7");
expect(teams.map((team) => team.team_id)).toEqual(["team-a", "team-b"]);
});
it("sends no user id when the caller is entitled to the broad list", async () => {
mockTeamListCall.mockResolvedValue([]);
await fetchAllTeams("tok-123");
expect(mockTeamListCall).toHaveBeenCalledWith("tok-123", null, null);
});
it("keeps the organization filter independent of the scoping user id", async () => {
mockTeamListCall.mockResolvedValue([]);
await fetchAllTeams("tok-123", "org-1", "member-7");
expect(mockTeamListCall).toHaveBeenCalledWith("tok-123", "org-1", "member-7");
});
it("returns an empty list without calling the endpoint when there is no access token", async () => {
expect(await fetchAllTeams(null, null, "member-7")).toEqual([]);
expect(mockTeamListCall).not.toHaveBeenCalled();
});
});

View file

@ -114,9 +114,15 @@ export const fetchTeamFilterOptions = async (
* Fetches all teams across all pages
* @param accessToken The access token for API authentication
* @param organizationId Optional organization ID to filter teams
* @param userID Scopes the list to that user's teams. Required for roles the endpoint
* does not grant a broad list to; see `teamListScopeUserId`
* @returns Array of all teams
*/
export const fetchAllTeams = async (accessToken: string | null, organizationId?: string | null): Promise<Team[]> => {
export const fetchAllTeams = async (
accessToken: string | null,
organizationId?: string | null,
userID?: string | null,
): Promise<Team[]> => {
if (!accessToken) return [];
try {
@ -125,7 +131,7 @@ export const fetchAllTeams = async (accessToken: string | null, organizationId?:
let hasMorePages = true;
while (hasMorePages) {
const response = await teamListCall(accessToken, organizationId || null, null);
const response = await teamListCall(accessToken, organizationId || null, userID ?? null);
// Add teams from this page
allTeams = [...allTeams, ...response];

View file

@ -26,6 +26,8 @@ vi.mock("@/components/key_team_helpers/filter_helpers", () => ({
}));
import { uiSpendLogsCall } from "../networking";
import { fetchAllTeams } from "@/components/key_team_helpers/filter_helpers";
import type { Team } from "../key_team_helpers/key_list";
const emptyResponse: PaginatedResponse = {
data: [],
@ -198,6 +200,39 @@ describe("useLogFilterLogic", () => {
});
});
describe("team filter list scope", () => {
const callerTeams = [{ team_id: "team-a" }, { team_id: "team-b" }] as Team[];
it("scopes /team/list to an internal user and still surfaces their teams", async () => {
vi.mocked(fetchAllTeams).mockResolvedValue(callerTeams);
const { result } = renderFilterHook({ userRole: "Internal User", userID: "member-7" });
await waitFor(() => expect(fetchAllTeams).toHaveBeenCalled());
expect(fetchAllTeams).toHaveBeenCalledWith("test-token", null, "member-7");
await waitFor(() => expect(result.current.allTeams).toEqual(callerTeams));
});
it("scopes /team/list for an internal viewer", async () => {
vi.mocked(fetchAllTeams).mockResolvedValue(callerTeams);
renderFilterHook({ userRole: "Internal Viewer", userID: "member-7" });
await waitFor(() => expect(fetchAllTeams).toHaveBeenCalledWith("test-token", null, "member-7"));
});
it.each(["Admin", "Admin Viewer", "Org Admin"])(
"leaves /team/list unscoped for %s so the broad list survives",
async (userRole) => {
vi.mocked(fetchAllTeams).mockResolvedValue(callerTeams);
renderFilterHook({ userRole, userID: "member-7" });
await waitFor(() => expect(fetchAllTeams).toHaveBeenCalledWith("test-token", null, null));
},
);
});
it("returns an empty payload and does not crash when the call fails", async () => {
vi.mocked(uiSpendLogsCall).mockRejectedValue(new Error("boom"));
const { result } = renderFilterHook();

View file

@ -4,6 +4,7 @@ import type { ColumnFiltersState, PaginationState, SortingState } from "@tanstac
import { uiSpendLogsCall } from "../networking";
import { Team } from "../key_team_helpers/key_list";
import { fetchAllTeams } from "../../components/key_team_helpers/filter_helpers";
import { teamListScopeUserId } from "../../utils/roles";
import { defaultPageSize } from "../constants";
import { LOGS_SORT_FIELD_MAP, type LogEntry, type LogsSortField } from "./columns";
@ -194,11 +195,13 @@ export function useLogFilterLogic({
total_pages: 0,
};
const teamListUserID = teamListScopeUserId(userRole, userID);
const allTeamsQueryOptions: UseQueryOptions<Team[], Error> = {
queryKey: ["allTeamsForLogFilters", accessToken],
queryKey: ["allTeamsForLogFilters", accessToken, teamListUserID],
queryFn: async () => {
if (!accessToken) return [];
const teamsData = await fetchAllTeams(accessToken);
const teamsData = await fetchAllTeams(accessToken, null, teamListUserID);
return teamsData || [];
},
enabled: !!accessToken,

View file

@ -1,5 +1,6 @@
import { describe, it, expect } from "vitest";
import {
all_admin_roles,
effectiveSessionRole,
isAdminRole,
isProxyAdminRole,
@ -8,6 +9,7 @@ import {
isViewOnlySessionRole,
rolesAllowedToViewWriteScopedPages,
rolesWithWriteAccess,
teamListScopeUserId,
} from "./roles";
import { Team } from "@/components/networking";
@ -236,4 +238,37 @@ describe("roles", () => {
expect(isViewOnlySessionRole("proxy_admin_viewer")).toBe(true);
});
});
describe("teamListScopeUserId", () => {
const SESSION_USER_ID = "user-1";
it.each(["proxy_admin", "proxy_admin_viewer", "org_admin"])(
"leaves %s unscoped so the endpoint keeps returning its broad list",
(rawRole) => {
expect(teamListScopeUserId(effectiveSessionRole(rawRole), SESSION_USER_ID)).toBeNull();
},
);
it.each(["internal_user", "internal_user_viewer", "internal_viewer", "app_user"])(
"scopes %s to its own user id, which is what the endpoint authorizes on",
(rawRole) => {
expect(teamListScopeUserId(effectiveSessionRole(rawRole), SESSION_USER_ID)).toBe(SESSION_USER_ID);
},
);
it("also accepts the Admin Viewer label that formatUserRole emits", () => {
expect(teamListScopeUserId("Admin Viewer", SESSION_USER_ID)).toBeNull();
});
it("scopes an unknown or absent role rather than assuming a broad list", () => {
expect(teamListScopeUserId(null, SESSION_USER_ID)).toBe(SESSION_USER_ID);
expect(teamListScopeUserId("Undefined Role", SESSION_USER_ID)).toBe(SESSION_USER_ID);
});
it("keeps Org Admin broad even though all_admin_roles carries only the raw org_admin", () => {
expect(all_admin_roles).not.toContain(effectiveSessionRole("org_admin"));
expect(isAdminRole(effectiveSessionRole("org_admin"))).toBe(false);
expect(teamListScopeUserId(effectiveSessionRole("org_admin"), SESSION_USER_ID)).toBeNull();
});
});
});

View file

@ -77,3 +77,12 @@ export const effectiveSessionRole = (rawUserRole?: string): string => {
export const isViewOnlySessionRole = (rawUserRole?: string): boolean =>
viewOnlyRawRoles.includes(rawUserRole?.toLowerCase() ?? "");
// Session roles (the value `useAuthorized().userRole` supplies) that /team/list and
// /v2/team/list already answer with a broad list: proxy-wide for admins, org-wide for
// org admins. Sending a user_id for those narrows the response to direct memberships,
// so only the roles the endpoints would otherwise reject carry one.
const sessionRolesWithBroadTeamList: string[] = ["Admin", "Admin Viewer", "Org Admin"];
export const teamListScopeUserId = (userRole: string | null, userId: string | null): string | null =>
sessionRolesWithBroadTeamList.includes(userRole ?? "") ? null : userId;