mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
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:
commit
487f8b2408
8 changed files with 216 additions and 13 deletions
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue