From 8f0644e63ff48cae494a0c60378f6fafbff21c0d Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Mon, 10 Aug 2026 15:00:45 -0700 Subject: [PATCH 1/2] fix(ui): scope Virtual Keys and Logs team lists to the caller The Virtual Keys table and the Logs page team filter both asked for every team on the proxy, which /v2/team/list and /team/list reject with a 401 for any role below proxy admin or org admin. Both endpoints answer the same request with the caller's own teams when it carries a user_id, so send one. Only the two unscoped call sites change. The remaining callers either already role-branch or render on surfaces gated to roles the endpoints answer broadly, and scoping those would shrink the list they see: a proxy admin scoped to their own id gets nothing back, and an org admin scoped on /team/list loses the org teams they administer but do not belong to. The shared helper reads the display-form session role rather than all_admin_roles, which mixes display labels with raw role names and so does not match the "Org Admin" value the dashboard actually holds. --- .../(dashboard)/hooks/teams/useTeams.test.ts | 73 +++++++++++++++++++ .../app/(dashboard)/hooks/teams/useTeams.ts | 19 +++-- .../key_team_helpers/filter_helpers.test.ts | 43 ++++++++++- .../key_team_helpers/filter_helpers.ts | 10 ++- .../view_logs/log_filter_logic.test.tsx | 37 ++++++++++ .../components/view_logs/log_filter_logic.tsx | 7 +- ui/litellm-dashboard/src/utils/roles.test.ts | 40 ++++++++++ ui/litellm-dashboard/src/utils/roles.ts | 9 +++ 8 files changed, 225 insertions(+), 13 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.test.ts index 66dfc43cebb..8980c772c9b 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.test.ts @@ -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,65 @@ 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)); + + // A scoped call that comes back empty is the failure this guards against: the + // 401 disappears but the page still shows no teams. + 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"); + }); }); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.ts index 4061026b94d..e209a1d7273 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.ts @@ -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 => { const ALL_TEAMS_PAGE_SIZE = 100; -const fetchAllTeamsPaged = async (accessToken: string): Promise => { - const firstPage: TeamsResponse = await teamListCall(accessToken, 1, ALL_TEAMS_PAGE_SIZE); +const fetchAllTeamsPaged = async (accessToken: string, userID: string | null): Promise => { + 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 => { - const { accessToken } = useAuthorized(); + const { accessToken, userId, userRole } = useAuthorized(); + const scopedUserID = teamListScopeUserId(userRole, userId); return useQuery({ 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, }); diff --git a/ui/litellm-dashboard/src/components/key_team_helpers/filter_helpers.test.ts b/ui/litellm-dashboard/src/components/key_team_helpers/filter_helpers.test.ts index 637325ad98d..15c45153026 100644 --- a/ui/litellm-dashboard/src/components/key_team_helpers/filter_helpers.test.ts +++ b/ui/litellm-dashboard/src/components/key_team_helpers/filter_helpers.test.ts @@ -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(); + }); +}); diff --git a/ui/litellm-dashboard/src/components/key_team_helpers/filter_helpers.ts b/ui/litellm-dashboard/src/components/key_team_helpers/filter_helpers.ts index fb701b4656b..7eef4d3a8b3 100644 --- a/ui/litellm-dashboard/src/components/key_team_helpers/filter_helpers.ts +++ b/ui/litellm-dashboard/src/components/key_team_helpers/filter_helpers.ts @@ -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 => { +export const fetchAllTeams = async ( + accessToken: string | null, + organizationId?: string | null, + userID?: string | null, +): Promise => { 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]; diff --git a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.test.tsx b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.test.tsx index 080bf6b380a..45ef1d017ac 100644 --- a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.test.tsx @@ -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,41 @@ 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"); + // Without the scope the request 401s and the filter falls back to an empty + // list, so the rows matter as much as the argument. + 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(); diff --git a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx index 8244066cadb..e1089c6a16c 100644 --- a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx @@ -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 = { - 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, diff --git a/ui/litellm-dashboard/src/utils/roles.test.ts b/ui/litellm-dashboard/src/utils/roles.test.ts index 83f633bc299..209353d3e3d 100644 --- a/ui/litellm-dashboard/src/utils/roles.test.ts +++ b/ui/litellm-dashboard/src/utils/roles.test.ts @@ -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,42 @@ describe("roles", () => { expect(isViewOnlySessionRole("proxy_admin_viewer")).toBe(true); }); }); + + describe("teamListScopeUserId", () => { + const SESSION_USER_ID = "user-1"; + + // The truth table is driven through effectiveSessionRole rather than hand-written + // labels, so it keeps holding if the raw -> display mapping ever moves. + 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", () => { + // all_admin_roles mixes display labels with raw role names, so isAdminRole is + // false for the value useAuthorized actually supplies for an org admin. Relying + // on it here would scope org admins down to their direct memberships. + 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(); + }); + }); }); diff --git a/ui/litellm-dashboard/src/utils/roles.ts b/ui/litellm-dashboard/src/utils/roles.ts index 8d226313f78..17a0ab11824 100644 --- a/ui/litellm-dashboard/src/utils/roles.ts +++ b/ui/litellm-dashboard/src/utils/roles.ts @@ -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; From e7450b11ba562e265650e5543a049270d7ee06f7 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Mon, 10 Aug 2026 15:38:59 -0700 Subject: [PATCH 2/2] test(ui): drop redundant commentary from the team-list scoping tests The removed comments restated the test names and the assertions directly below them. The reasoning they carried is already recorded in the commit that introduced the fix and in the pull request body. --- .../src/app/(dashboard)/hooks/teams/useTeams.test.ts | 2 -- .../src/components/view_logs/log_filter_logic.test.tsx | 2 -- ui/litellm-dashboard/src/utils/roles.test.ts | 5 ----- 3 files changed, 9 deletions(-) diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.test.ts index 8980c772c9b..fa3f15124cf 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/teams/useTeams.test.ts @@ -913,8 +913,6 @@ describe("useAllTeams", () => { await waitFor(() => expect(result.current.isSuccess).toBe(true)); - // A scoped call that comes back empty is the failure this guards against: the - // 401 disappears but the page still shows no teams. 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"); diff --git a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.test.tsx b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.test.tsx index 45ef1d017ac..17d26dc00f3 100644 --- a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.test.tsx @@ -210,8 +210,6 @@ describe("useLogFilterLogic", () => { await waitFor(() => expect(fetchAllTeams).toHaveBeenCalled()); expect(fetchAllTeams).toHaveBeenCalledWith("test-token", null, "member-7"); - // Without the scope the request 401s and the filter falls back to an empty - // list, so the rows matter as much as the argument. await waitFor(() => expect(result.current.allTeams).toEqual(callerTeams)); }); diff --git a/ui/litellm-dashboard/src/utils/roles.test.ts b/ui/litellm-dashboard/src/utils/roles.test.ts index 209353d3e3d..6430b8277f1 100644 --- a/ui/litellm-dashboard/src/utils/roles.test.ts +++ b/ui/litellm-dashboard/src/utils/roles.test.ts @@ -242,8 +242,6 @@ describe("roles", () => { describe("teamListScopeUserId", () => { const SESSION_USER_ID = "user-1"; - // The truth table is driven through effectiveSessionRole rather than hand-written - // labels, so it keeps holding if the raw -> display mapping ever moves. it.each(["proxy_admin", "proxy_admin_viewer", "org_admin"])( "leaves %s unscoped so the endpoint keeps returning its broad list", (rawRole) => { @@ -268,9 +266,6 @@ describe("roles", () => { }); it("keeps Org Admin broad even though all_admin_roles carries only the raw org_admin", () => { - // all_admin_roles mixes display labels with raw role names, so isAdminRole is - // false for the value useAuthorized actually supplies for an org admin. Relying - // on it here would scope org admins down to their direct memberships. 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();