diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 4dbae6394f6..175da4a8a75 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -849,9 +849,12 @@ class LiteLLMRoutes(enum.Enum): "/prompt/list", "/prompt/info", "/vector_store/info", - # Project read routes - endpoint scopes results to caller's teams (non-admin) + # Project routes - reads scope results to caller's teams; /new and + # /update require proxy admin or admin of the project's team in the endpoint "/project/list", "/project/info", + "/project/new", + "/project/update", # Endpoint enforces proxy-admin vs team-admin model access itself. "/health/test_connection", # Invitation routes - org/team admins checked in endpoint via _user_has_admin_privileges diff --git a/tests/test_litellm/proxy/auth/test_route_checks.py b/tests/test_litellm/proxy/auth/test_route_checks.py index 5b15d4a7d5e..6b17263972b 100644 --- a/tests/test_litellm/proxy/auth/test_route_checks.py +++ b/tests/test_litellm/proxy/auth/test_route_checks.py @@ -3638,3 +3638,41 @@ def test_agent_registry_route_gate_open_to_non_admin_roles(user_role, method, ro valid_token=valid_token, request_data={}, ) + + +@pytest.mark.parametrize("route", ["/project/new", "/project/update"]) +def test_project_write_routes_reach_endpoint_for_internal_user(route): + """A team admin is an internal_user at the route gate. /project/new and + /project/update must pass it so the endpoint can apply its own proxy-admin + or team-admin check instead of the gate 403ing every non-proxy-admin.""" + + valid_token = UserAPIKeyAuth(user_id="test_user", user_role=LitellmUserRoles.INTERNAL_USER.value) + request = MagicMock(spec=Request) + request.method = "POST" + request.query_params = {} + + RouteChecks.non_proxy_admin_allowed_routes_check( + user_obj=LiteLLM_UserTable(user_id="test_user", user_role=LitellmUserRoles.INTERNAL_USER.value), + _user_role=LitellmUserRoles.INTERNAL_USER.value, + route=route, + request=request, + valid_token=valid_token, + request_data={"team_id": "team-1"}, + ) + + +def test_project_delete_route_stays_proxy_admin_only(): + valid_token = UserAPIKeyAuth(user_id="test_user", user_role=LitellmUserRoles.INTERNAL_USER.value) + request = MagicMock(spec=Request) + request.method = "DELETE" + request.query_params = {} + + with pytest.raises(Exception, match="Only proxy admin can be used"): + RouteChecks.non_proxy_admin_allowed_routes_check( + user_obj=LiteLLM_UserTable(user_id="test_user", user_role=LitellmUserRoles.INTERNAL_USER.value), + _user_role=LitellmUserRoles.INTERNAL_USER.value, + route="/project/delete", + request=request, + valid_token=valid_token, + request_data={}, + ) diff --git a/ui/litellm-dashboard/src/components/leftnav.test.tsx b/ui/litellm-dashboard/src/components/leftnav.test.tsx index 6eb0218c41d..44b186762d7 100644 --- a/ui/litellm-dashboard/src/components/leftnav.test.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.test.tsx @@ -3,6 +3,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { renderWithProviders } from "../../tests/test-utils"; import Sidebar, { menuGroups, getBreadcrumb } from "./leftnav"; +const teamAdminState = vi.hoisted(() => ({ isTeamAdmin: false })); + vi.mock("../utils/roles", async (importOriginal) => { const actual = await importOriginal(); return { @@ -13,7 +15,7 @@ vi.mock("../utils/roles", async (importOriginal) => { rolesWithWriteAccess: ["admin", "internal"], rolesAllowedToViewWriteScopedPages: ["admin", "internal", "admin_viewer"], isAdminRole: (role: string) => role === "admin" || role === "admin_viewer", - isUserTeamAdminForAnyTeam: () => false, + isUserTeamAdminForAnyTeam: () => teamAdminState.isTeamAdmin, }; }); @@ -586,6 +588,50 @@ describe("Sidebar (leftnav)", () => { expect(container.querySelector('a[href*="projects"]')).toBeNull(); }); + describe("Projects visibility for delegated admins", () => { + const internalAuth = { + userId: "internal-user-id", + accessToken: "test-access-token", + userRole: "internal", + isViewOnly: false, + token: "test-token", + userEmail: "internal@example.com", + premiumUser: false, + disabledPersonalKeyCreation: false, + showSSOBanner: false, + }; + + afterEach(() => { + mockUseAuthorized.mockReset(); + mockUseOrganizations.mockReset(); + teamAdminState.isTeamAdmin = false; + }); + + it("hides Projects from a plain internal user", () => { + mockUseAuthorized.mockReturnValue(internalAuth); + const { container } = renderWithProviders(); + expect(container.querySelector('a[href*="projects"]')).toBeNull(); + }); + + it("shows Projects to an internal user who administers a team", () => { + mockUseAuthorized.mockReturnValue(internalAuth); + teamAdminState.isTeamAdmin = true; + const { container } = renderWithProviders(); + expect(container.querySelector('a[href*="projects"]')).toHaveTextContent("Projects"); + }); + + it("shows Projects to an internal user who administers an organization", () => { + mockUseAuthorized.mockReturnValue(internalAuth); + mockUseOrganizations.mockReturnValue({ + data: [{ organization_id: "org-1", members: [{ user_id: "internal-user-id", user_role: "org_admin" }] }], + isLoading: false, + error: null, + }); + const { container } = renderWithProviders(); + expect(container.querySelector('a[href*="projects"]')).toHaveTextContent("Projects"); + }); + }); + it("keeps a readable collapsed-rail tooltip for items whose label carries a badge", () => { const { container } = renderWithProviders(); diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx index 9d772f45153..7e67d288421 100644 --- a/ui/litellm-dashboard/src/components/leftnav.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.tsx @@ -475,7 +475,10 @@ const Sidebar_: React.FC = ({ if (!isAdmin && enabledPagesInternalUsers != null) return enabledPagesInternalUsers.includes(item.page); return true; } - if (item.key === "projects" && !enableProjectsUI) return false; + if (item.key === "projects") { + if (!enableProjectsUI) return false; + return isAdmin || isOrgAdmin || isTeamAdmin; + } if ( !isAdmin && item.key === "agents" &&