diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useCan.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useCan.ts index f538e1dff15..13903007cae 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useCan.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useCan.ts @@ -3,10 +3,12 @@ import { hasCapability, type Capability } from "@/utils/capabilities"; import useAuthorized from "./useAuthorized"; +import useIsOrgAdmin from "./useIsOrgAdmin"; const useCan = (capability: Capability): boolean => { const { userRole } = useAuthorized(); - return hasCapability(userRole, capability); + const isOrgAdmin = useIsOrgAdmin(); + return hasCapability(userRole, capability, isOrgAdmin); }; export default useCan; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useIsOrgAdmin.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useIsOrgAdmin.test.ts new file mode 100644 index 00000000000..bb913e2fd64 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useIsOrgAdmin.test.ts @@ -0,0 +1,53 @@ +/* @vitest-environment jsdom */ +import { renderHook } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Organization } from "@/components/networking"; +import useIsOrgAdmin from "./useIsOrgAdmin"; + +const { useAuthorizedMock, useOrganizationsMock } = vi.hoisted(() => ({ + useAuthorizedMock: vi.fn(), + useOrganizationsMock: vi.fn(), +})); + +vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: useAuthorizedMock })); +vi.mock("./organizations/useOrganizations", () => ({ useOrganizations: useOrganizationsMock })); + +const orgWithMembers = (members: { user_id: string; user_role: string }[]): Organization => + ({ organization_id: "org-1", members }) as unknown as Organization; + +const renderAs = (userRole: string, organizations: Organization[] | undefined) => { + useAuthorizedMock.mockReturnValue({ userId: "user-1", userRole }); + useOrganizationsMock.mockReturnValue({ data: organizations }); + return renderHook(() => useIsOrgAdmin()).result; +}; + +describe("useIsOrgAdmin", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("is true for the session a real org admin carries: internal_user plus an org_admin membership", () => { + const result = renderAs("Internal User", [orgWithMembers([{ user_id: "user-1", user_role: "org_admin" }])]); + expect(result.current).toBe(true); + }); + + it("is false for an internal user with no org_admin membership", () => { + const result = renderAs("Internal User", [orgWithMembers([{ user_id: "user-1", user_role: "internal_user" }])]); + expect(result.current).toBe(false); + }); + + it("is false while the organization list is still loading", () => { + const result = renderAs("Internal User", undefined); + expect(result.current).toBe(false); + }); + + it("is true for a session role of org_admin even with no membership rows", () => { + expect(renderAs("org_admin", []).current).toBe(true); + expect(renderAs("Org Admin", []).current).toBe(true); + }); + + it("is false for a proxy admin, who is covered by role-based gates instead", () => { + const result = renderAs("Admin", []); + expect(result.current).toBe(false); + }); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useIsOrgAdmin.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useIsOrgAdmin.ts new file mode 100644 index 00000000000..d93b57a3fc3 --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useIsOrgAdmin.ts @@ -0,0 +1,14 @@ +"use client"; + +import { isOrgAdminForAnyOrg, isOrgAdminSessionRole } from "@/utils/roles"; + +import { useOrganizations } from "./organizations/useOrganizations"; +import useAuthorized from "./useAuthorized"; + +const useIsOrgAdmin = (): boolean => { + const { userId, userRole } = useAuthorized(); + const { data: organizations } = useOrganizations(); + return isOrgAdminSessionRole(userRole) || isOrgAdminForAnyOrg(organizations, userId); +}; + +export default useIsOrgAdmin; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.test.tsx index 3c94977f0dc..0c487af213e 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/components/chat_ui/ChatUI.test.tsx @@ -1,4 +1,5 @@ -import { act, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { act, fireEvent, screen, waitFor } from "@testing-library/react"; +import { renderWithProviders as render } from "@/../tests/test-utils"; import { beforeEach, describe, expect, it, vi } from "vitest"; import ChatUI from "./ChatUI"; import * as fetchModelsModule from "@/components/llm_calls/fetch_models"; diff --git a/ui/litellm-dashboard/src/components/leftnav.test.tsx b/ui/litellm-dashboard/src/components/leftnav.test.tsx index 33a8448da44..8eca990261c 100644 --- a/ui/litellm-dashboard/src/components/leftnav.test.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.test.tsx @@ -3,8 +3,10 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "../../tests/test-utils"; import Sidebar, { menuGroups, getBreadcrumb } from "./leftnav"; -vi.mock("../utils/roles", () => { +vi.mock("../utils/roles", async (importOriginal) => { + const actual = await importOriginal(); return { + ...actual, all_admin_roles: ["admin", "admin_viewer"], old_admin_roles: ["admin", "admin_viewer"], internalUserRoles: ["internal"], @@ -92,6 +94,11 @@ describe("Sidebar (leftnav)", () => { collapsed: false, }; + afterEach(() => { + mockUseAuthorized.mockReset(); + mockUseOrganizations.mockReset(); + }); + it("should link the logo to the UI home route rather than the proxy origin", () => { renderWithProviders(); @@ -175,19 +182,19 @@ describe("Sidebar (leftnav)", () => { }; it("hides Playground from Admin Viewer (cost-incurring action)", () => { - mockUseAuthorized.mockReturnValueOnce(adminViewerAuth); + mockUseAuthorized.mockReturnValue(adminViewerAuth); renderWithProviders(); expect(screen.queryByText("Playground")).not.toBeInTheDocument(); }); it("shows Models + Endpoints to Admin Viewer (read-only)", () => { - mockUseAuthorized.mockReturnValueOnce(adminViewerAuth); + mockUseAuthorized.mockReturnValue(adminViewerAuth); renderWithProviders(); expect(screen.getByText("Models + Endpoints")).toBeInTheDocument(); }); it("shows Agents (under Agentic) to Admin Viewer (read-only)", async () => { - mockUseAuthorized.mockReturnValueOnce(adminViewerAuth); + mockUseAuthorized.mockReturnValue(adminViewerAuth); renderWithProviders(); // Agents is now nested under the "Agentic" submenu — expand parent // first to render the children, then assert Agents is visible. @@ -200,7 +207,7 @@ describe("Sidebar (leftnav)", () => { }); it("shows Logs to Admin Viewer", () => { - mockUseAuthorized.mockReturnValueOnce(adminViewerAuth); + mockUseAuthorized.mockReturnValue(adminViewerAuth); renderWithProviders(); expect(screen.getByText("Logs")).toBeInTheDocument(); }); @@ -384,10 +391,11 @@ describe("Sidebar (leftnav)", () => { }); it("should show Organizations tab for organization admins", () => { - mockUseAuthorized.mockReturnValueOnce({ + mockUseAuthorized.mockReturnValue({ userId: "org-admin-user-id", accessToken: "test-access-token", userRole: "viewer", + isViewOnly: false, token: "test-token", userEmail: "orgadmin@example.com", premiumUser: false, @@ -395,7 +403,7 @@ describe("Sidebar (leftnav)", () => { showSSOBanner: false, }); - mockUseOrganizations.mockReturnValueOnce({ + mockUseOrganizations.mockReturnValue({ data: [ { organization_id: "org-1", diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx index 6d15b9008e2..2ece64271f8 100644 --- a/ui/litellm-dashboard/src/components/leftnav.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.tsx @@ -1,6 +1,6 @@ -import { useOrganizations } from "@/app/(dashboard)/hooks/organizations/useOrganizations"; import { useTeams } from "@/app/(dashboard)/hooks/teams/useTeams"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; +import useIsOrgAdmin from "@/app/(dashboard)/hooks/useIsOrgAdmin"; import { useHealthReadinessDetails } from "@/app/(dashboard)/hooks/healthReadiness/useHealthReadinessDetails"; import { useLogout } from "@/app/(dashboard)/hooks/useLogout"; import { getProxyBaseUrl } from "@/components/networking"; @@ -75,7 +75,6 @@ import { } from "../utils/roles"; import BetaBadge from "./BetaBadge"; import NewBadge from "./common_components/NewBadge"; -import type { Organization } from "./networking"; import SidebarAccountMenu from "./SidebarAccountMenu/SidebarAccountMenu"; import SidebarUsageCard from "./SidebarUsageCard"; import { MIGRATED_PAGES, migratedHref, legacyPageHref } from "@/utils/migratedPages"; @@ -432,7 +431,7 @@ const Sidebar_: React.FC = ({ allowVectorStoresForTeamAdmins, }) => { const { userId, accessToken, userRole, isViewOnly } = useAuthorized(); - const { data: organizations } = useOrganizations(); + const isOrgAdmin = useIsOrgAdmin(); const { data: teams } = useTeams(); const { logoUrl } = useTheme(); const { data: healthData } = useHealthReadinessDetails(accessToken); @@ -459,13 +458,6 @@ const Sidebar_: React.FC = ({ } } - const isOrgAdmin = useMemo(() => { - if (!userId || !organizations) return false; - return organizations.some((org: Organization) => - org.members?.some((member) => member.user_id === userId && member.user_role === "org_admin"), - ); - }, [userId, organizations]); - const isTeamAdmin = useMemo(() => isUserTeamAdminForAnyTeam(teams ?? null, userId ?? ""), [teams, userId]); const filterItemsByRole = (items: MenuItem[]): MenuItem[] => { diff --git a/ui/litellm-dashboard/src/components/view_logs/index.integration.test.tsx b/ui/litellm-dashboard/src/components/view_logs/index.integration.test.tsx index b86ad015b91..f2d70b74c96 100644 --- a/ui/litellm-dashboard/src/components/view_logs/index.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/index.integration.test.tsx @@ -4,12 +4,19 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import SpendLogsTable from "./index"; import { renderWithProviders, testQueryClient } from "../../../tests/test-utils"; -const { useAuthorizedMock } = vi.hoisted(() => ({ useAuthorizedMock: vi.fn() })); +const { useAuthorizedMock, useOrganizationsMock } = vi.hoisted(() => ({ + useAuthorizedMock: vi.fn(), + useOrganizationsMock: vi.fn(), +})); vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: useAuthorizedMock, })); +vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({ + useOrganizations: useOrganizationsMock, +})); + vi.mock("./RequestLogsPanel", () => ({ default: function RequestLogsPanelMock() { return
; @@ -37,8 +44,16 @@ const defaultProps = { premiumUser: true, }; -const renderAs = (sessionRole: string) => { - useAuthorizedMock.mockReturnValue({ accessToken: "sk-test", userRole: sessionRole, premiumUser: true }); +const ORG_ADMIN_MEMBERSHIPS = [{ organization_id: "org-1", members: [{ user_id: "user-1", user_role: "org_admin" }] }]; + +const renderAs = (sessionRole: string, organizations: unknown[] = []) => { + useAuthorizedMock.mockReturnValue({ + accessToken: "sk-test", + userId: "user-1", + userRole: sessionRole, + premiumUser: true, + }); + useOrganizationsMock.mockReturnValue({ data: organizations }); return renderWithProviders(); }; @@ -46,6 +61,7 @@ describe("SpendLogsTable network access by role", () => { beforeEach(() => { testQueryClient.clear(); vi.clearAllMocks(); + useOrganizationsMock.mockReturnValue({ data: [] }); fetchMock.mockImplementation(async (url: string) => { if (String(url).includes("/audit")) { return jsonResponse(emptyAuditLogs); @@ -73,6 +89,16 @@ describe("SpendLogsTable network access by role", () => { expect(requestedUrls().filter((url) => url.includes("/v2/team/list"))).toEqual([]); }); + it("fetches the deleted teams an org admin is entitled to, and still no audit logs", async () => { + renderAs("Internal User", ORG_ADMIN_MEMBERSHIPS); + + await waitFor(() => + expect(requestedUrls().some((url) => url.includes("/v2/team/list") && url.includes("status=deleted"))).toBe(true), + ); + + expect(requestedUrls().filter((url) => url.includes("/audit"))).toEqual([]); + }); + it("fetches deleted teams and audit logs for an admin", async () => { const user = userEvent.setup(); renderAs("Admin"); diff --git a/ui/litellm-dashboard/src/components/view_logs/index.test.tsx b/ui/litellm-dashboard/src/components/view_logs/index.test.tsx index 785fa0cc6f8..ed55d73c62e 100644 --- a/ui/litellm-dashboard/src/components/view_logs/index.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/index.test.tsx @@ -4,12 +4,19 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import SpendLogsTable from "./index"; import { renderWithProviders } from "../../../tests/test-utils"; -const { useAuthorizedMock } = vi.hoisted(() => ({ useAuthorizedMock: vi.fn() })); +const { useAuthorizedMock, useOrganizationsMock } = vi.hoisted(() => ({ + useAuthorizedMock: vi.fn(), + useOrganizationsMock: vi.fn(), +})); vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ default: useAuthorizedMock, })); +vi.mock("@/app/(dashboard)/hooks/organizations/useOrganizations", () => ({ + useOrganizations: useOrganizationsMock, +})); + vi.mock("./RequestLogsPanel", () => ({ default: function RequestLogsPanelMock({ isActive }: { isActive: boolean }) { return
{isActive ? "active" : "inactive"}
; @@ -42,14 +49,20 @@ const defaultProps = { premiumUser: false, }; -const renderAs = (sessionRole: string) => { - useAuthorizedMock.mockReturnValue({ userRole: sessionRole }); +const ORG_ADMIN_MEMBERSHIPS = [{ organization_id: "org-1", members: [{ user_id: "user-1", user_role: "org_admin" }] }]; + +const renderAs = (sessionRole: string, organizations: unknown[] = []) => { + useAuthorizedMock.mockReturnValue({ userId: "user-1", userRole: sessionRole }); + useOrganizationsMock.mockReturnValue({ data: organizations }); return renderWithProviders(); }; +const tabNames = () => screen.getAllByRole("tab").map((tab) => tab.textContent); + describe("SpendLogsTable", () => { beforeEach(() => { - useAuthorizedMock.mockReturnValue({ userRole: "Admin" }); + useAuthorizedMock.mockReturnValue({ userId: "user-1", userRole: "Admin" }); + useOrganizationsMock.mockReturnValue({ data: [] }); }); it("renders the four log tabs", () => { @@ -91,6 +104,44 @@ describe("SpendLogsTable", () => { }); }); + describe("organization admins", () => { + it("shows Deleted Teams to an org admin, whose session role reads as a plain internal user", () => { + renderAs("Internal User", ORG_ADMIN_MEMBERSHIPS); + + expect(screen.getByRole("tab", { name: "Deleted Teams" })).toBeInTheDocument(); + expect(screen.getByTestId("deleted-teams-page")).toBeInTheDocument(); + }); + + it("does not hand an org admin the Audit Logs tab, which the backend still refuses them", () => { + renderAs("Internal User", ORG_ADMIN_MEMBERSHIPS); + + expect(tabNames()).toEqual(["Request Logs", "Deleted Keys", "Deleted Teams"]); + expect(screen.queryByTestId("audit-logs-panel")).not.toBeInTheDocument(); + }); + + it("keeps an internal user in the same org without an org_admin membership at two tabs", () => { + renderAs("Internal User", [ + { organization_id: "org-1", members: [{ user_id: "user-1", user_role: "internal_user" }] }, + ]); + + expect(tabNames()).toEqual(["Request Logs", "Deleted Keys"]); + }); + + it("activates the org admin's selected tab rather than the one at the four-tab index", async () => { + const user = userEvent.setup(); + renderAs("Internal User", ORG_ADMIN_MEMBERSHIPS); + + await user.click(screen.getByRole("tab", { name: "Deleted Teams" })); + + expect(screen.getByRole("tab", { name: "Deleted Teams" })).toHaveAttribute("aria-selected", "true"); + expect(screen.getByTestId("request-logs-panel")).toHaveTextContent("inactive"); + + await user.click(screen.getByRole("tab", { name: "Request Logs" })); + + expect(screen.getByTestId("request-logs-panel")).toHaveTextContent("active"); + }); + }); + describe("tab index mapping", () => { it("activates the panel the admin selected, not the one at the old hardcoded index", async () => { const user = userEvent.setup(); diff --git a/ui/litellm-dashboard/src/utils/capabilities.test.ts b/ui/litellm-dashboard/src/utils/capabilities.test.ts index df09ccecac3..3f5d4b4efbe 100644 --- a/ui/litellm-dashboard/src/utils/capabilities.test.ts +++ b/ui/litellm-dashboard/src/utils/capabilities.test.ts @@ -45,6 +45,43 @@ describe("hasCapability", () => { }); }); +const SESSION_ROLE_AN_ORG_ADMIN_ACTUALLY_CARRIES = "Internal User"; + +const ORG_ADMIN_BACKEND_ACCESS: ReadonlyArray = [ + ["viewDeletedTeams", "GET /v2/team/list?status=deleted -> 200 (scoped to their orgs)", true], + ["viewToolPolicies", "GET /v1/tool/list -> 401", false], + ["viewPolicies", "GET /policies/list -> 401", false], + ["viewPrompts", "GET /prompts/list -> 401", false], + ["viewAuditLogs", "GET /audit -> 401", false], +]; + +describe("hasCapability for organization admins", () => { + it.each(ORG_ADMIN_BACKEND_ACCESS)("%s matches the backend: %s", (capability, _endpoint, isEntitled) => { + expect(hasCapability(SESSION_ROLE_AN_ORG_ADMIN_ACTUALLY_CARRIES, capability, true)).toBe(isEntitled); + }); + + it.each(NON_ADMIN_ROLES)("grants viewDeletedTeams to an org admin whose session role is %s", (role) => { + expect(hasCapability(role, "viewDeletedTeams", true)).toBe(true); + }); + + it.each(ADMIN_ONLY_CAPABILITIES)("leaves %s denied when the caller is not an org admin", (capability) => { + expect(hasCapability(SESSION_ROLE_AN_ORG_ADMIN_ACTUALLY_CARRIES, capability, false)).toBe(false); + expect(hasCapability(SESSION_ROLE_AN_ORG_ADMIN_ACTUALLY_CARRIES, capability)).toBe(false); + }); + + it("keeps the org-admin allowance opt-in per capability", () => { + const orgAdminCapabilities = ADMIN_ONLY_CAPABILITIES.filter((capability) => + hasCapability(SESSION_ROLE_AN_ORG_ADMIN_ACTUALLY_CARRIES, capability, true), + ); + expect(orgAdminCapabilities).toEqual(["viewDeletedTeams"]); + }); + + it("does not let the org-admin allowance reopen the proxy-admin-only viewGlobalSpend gate", () => { + expect(hasCapability(SESSION_ROLE_AN_ORG_ADMIN_ACTUALLY_CARRIES, "viewGlobalSpend", true)).toBe(false); + expect(hasCapability("Org Admin", "viewGlobalSpend", true)).toBe(false); + }); +}); + describe("hasCapability - viewGlobalSpend", () => { it.each(ADMIN_ROLES)("should grant it to %s", (role) => { expect(hasCapability(role, "viewGlobalSpend")).toBe(true); diff --git a/ui/litellm-dashboard/src/utils/capabilities.ts b/ui/litellm-dashboard/src/utils/capabilities.ts index 36e44ca2026..dabbc827bcb 100644 --- a/ui/litellm-dashboard/src/utils/capabilities.ts +++ b/ui/litellm-dashboard/src/utils/capabilities.ts @@ -19,7 +19,14 @@ const CAPABILITY_ROLES = { export type Capability = keyof typeof CAPABILITY_ROLES; -export const hasCapability = (userRole: string | null | undefined, capability: Capability): boolean => - userRole != null && CAPABILITY_ROLES[capability].includes(userRole); +const ORG_ADMIN_CAPABILITIES: ReadonlySet = new Set(["viewDeletedTeams"]); + +export const hasCapability = ( + userRole: string | null | undefined, + capability: Capability, + isOrgAdmin: boolean = false, +): boolean => + (isOrgAdmin && ORG_ADMIN_CAPABILITIES.has(capability)) || + (userRole != null && CAPABILITY_ROLES[capability].includes(userRole)); export const rolesWithCapability = (capability: Capability): string[] => [...CAPABILITY_ROLES[capability]]; diff --git a/ui/litellm-dashboard/src/utils/roles.test.ts b/ui/litellm-dashboard/src/utils/roles.test.ts index 6430b8277f1..b2d7c2c75e5 100644 --- a/ui/litellm-dashboard/src/utils/roles.test.ts +++ b/ui/litellm-dashboard/src/utils/roles.test.ts @@ -3,6 +3,8 @@ import { all_admin_roles, effectiveSessionRole, isAdminRole, + isOrgAdminForAnyOrg, + isOrgAdminSessionRole, isProxyAdminRole, isUserTeamAdminForAnyTeam, isUserTeamAdminForSingleTeam, @@ -11,7 +13,10 @@ import { rolesWithWriteAccess, teamListScopeUserId, } from "./roles"; -import { Team } from "@/components/networking"; +import { Organization, Team } from "@/components/networking"; + +const orgWithMembers = (members: { user_id: string; user_role: string }[]): Organization => + ({ organization_id: "org-1", members }) as unknown as Organization; describe("roles", () => { describe("isAdminRole", () => { @@ -156,6 +161,55 @@ describe("roles", () => { }); }); + describe("isOrgAdminForAnyOrg", () => { + it("returns true when the user holds an org_admin membership in any organization", () => { + const organizations = [ + orgWithMembers([{ user_id: "user-1", user_role: "internal_user" }]), + orgWithMembers([{ user_id: "user-1", user_role: "org_admin" }]), + ]; + expect(isOrgAdminForAnyOrg(organizations, "user-1")).toBe(true); + }); + + it("returns false when the user is only a plain member", () => { + const organizations = [orgWithMembers([{ user_id: "user-1", user_role: "internal_user" }])]; + expect(isOrgAdminForAnyOrg(organizations, "user-1")).toBe(false); + }); + + it("does not credit one user with another user's org_admin membership", () => { + const organizations = [orgWithMembers([{ user_id: "user-2", user_role: "org_admin" }])]; + expect(isOrgAdminForAnyOrg(organizations, "user-1")).toBe(false); + }); + + it("returns false for missing organizations, missing members, or a missing user id", () => { + expect(isOrgAdminForAnyOrg(null, "user-1")).toBe(false); + expect(isOrgAdminForAnyOrg(undefined, "user-1")).toBe(false); + expect(isOrgAdminForAnyOrg([], "user-1")).toBe(false); + expect(isOrgAdminForAnyOrg([{ organization_id: "org-1" } as unknown as Organization], "user-1")).toBe(false); + expect(isOrgAdminForAnyOrg([orgWithMembers([{ user_id: "user-1", user_role: "org_admin" }])], null)).toBe(false); + expect(isOrgAdminForAnyOrg([orgWithMembers([{ user_id: "user-1", user_role: "org_admin" }])], "")).toBe(false); + }); + }); + + describe("isOrgAdminSessionRole", () => { + it("accepts both the raw and the formatted org admin role", () => { + expect(isOrgAdminSessionRole("org_admin")).toBe(true); + expect(isOrgAdminSessionRole(effectiveSessionRole("org_admin"))).toBe(true); + }); + + it("returns false for the role a membership-granted org admin actually carries", () => { + expect(isOrgAdminSessionRole("Internal User")).toBe(false); + expect(isOrgAdminSessionRole("internal_user")).toBe(false); + }); + + it("returns false for admin and missing roles", () => { + expect(isOrgAdminSessionRole("Admin")).toBe(false); + expect(isOrgAdminSessionRole("proxy_admin")).toBe(false); + expect(isOrgAdminSessionRole(null)).toBe(false); + expect(isOrgAdminSessionRole(undefined)).toBe(false); + expect(isOrgAdminSessionRole("")).toBe(false); + }); + }); + describe("rolesAllowedToViewWriteScopedPages", () => { it("includes Admin Viewer (both display and stored forms)", () => { // Admin Viewer follows the read-parity rule — they must be able to diff --git a/ui/litellm-dashboard/src/utils/roles.ts b/ui/litellm-dashboard/src/utils/roles.ts index 17a0ab11824..b6d84b9365d 100644 --- a/ui/litellm-dashboard/src/utils/roles.ts +++ b/ui/litellm-dashboard/src/utils/roles.ts @@ -1,4 +1,11 @@ -import { Member, Team } from "@/components/networking"; +import { Member, Organization, Team } from "@/components/networking"; + +const ORG_ADMIN_MEMBERSHIP_ROLE = "org_admin"; + +interface OrganizationMembership { + user_id?: string | null; + user_role?: string | null; +} // Define admin roles and permissions export const old_admin_roles = ["Admin", "Admin Viewer"]; @@ -39,6 +46,19 @@ export const isUserTeamAdminForSingleTeam = (teamMemberWithRoles: Member[] | nul return teamMemberWithRoles.some((member) => member.user_id === userID && member.role === "admin"); }; +export const isOrgAdminForAnyOrg = ( + organizations: Organization[] | null | undefined, + userID: string | null | undefined, +): boolean => { + if (organizations == null || !userID) { + return false; + } + return organizations.some((org) => { + const members: OrganizationMembership[] = org.members ?? []; + return members.some((member) => member.user_id === userID && member.user_role === ORG_ADMIN_MEMBERSHIP_ROLE); + }); +}; + export const formatUserRole = (userRole: string): string => { if (!userRole) { return "Undefined Role"; @@ -66,6 +86,9 @@ export const formatUserRole = (userRole: string): string => { } }; +export const isOrgAdminSessionRole = (userRole?: string | null): boolean => + userRole === ORG_ADMIN_MEMBERSHIP_ROLE || userRole === formatUserRole(ORG_ADMIN_MEMBERSHIP_ROLE); + const viewOnlyRawRoles = ["proxy_admin_viewer", "internal_user_viewer", "internal_viewer"]; export const effectiveSessionRole = (rawUserRole?: string): string => {