From 21e38578458f75f32a238cdb31ac2bf793d3c93a Mon Sep 17 00:00:00 2001 From: jesus Date: Tue, 8 Sep 2026 15:19:17 +0000 Subject: [PATCH 1/8] fix(proxy): let team and org admins reach /project/new and /project/update, and show Projects in their nav Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/_types.py | 5 +- .../proxy/auth/test_route_checks.py | 38 +++++++++++++++ .../src/components/leftnav.test.tsx | 48 ++++++++++++++++++- .../src/components/leftnav.tsx | 5 +- 4 files changed, 93 insertions(+), 3 deletions(-) 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" && From 548d3d1b0a7fcdbf3eb03d5345c489eef709e98a Mon Sep 17 00:00:00 2001 From: jesus Date: Tue, 8 Sep 2026 15:34:28 +0000 Subject: [PATCH 2/8] fix(proxy): recognize team admins from members_with_roles in project create/update permission check Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../management_endpoints/project_endpoints.py | 18 ++++++----- .../test_project_endpoints_prisma.py | 30 +++++++++++++++++++ 2 files changed, 40 insertions(+), 8 deletions(-) diff --git a/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py b/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py index b2eda76f9ae..d652670c220 100644 --- a/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py +++ b/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py @@ -22,7 +22,7 @@ from litellm._uuid import uuid from litellm.proxy._types import * from litellm.proxy.auth.auth_checks import delete_cached_project_object from litellm.proxy.auth.user_api_key_auth import user_api_key_auth -from litellm.proxy.management_endpoints.common_utils import _set_object_metadata_field +from litellm.proxy.management_endpoints.common_utils import _is_user_team_admin, _set_object_metadata_field from litellm.proxy.management_helpers.utils import ( management_endpoint_wrapper, ) @@ -105,14 +105,16 @@ async def _check_user_permission_for_project( if not team_id or not user_api_key_dict.user_id: return False - team = team_object - if team is None: - team = await _team_table(prisma_client).find_unique(where={"team_id": team_id}) + team_row: Final = ( + team_object + if team_object is not None + else await _team_table(prisma_client).find_unique(where={"team_id": team_id}) + ) + if team_row is None: + return False - if team and team.admins: - return user_api_key_dict.user_id in team.admins - - return False + team: Final = LiteLLM_TeamTable.model_validate(team_row.model_dump()) + return _is_user_team_admin(user_api_key_dict, team) or user_api_key_dict.user_id in (team.admins or []) async def _validate_team_exists( diff --git a/tests/enterprise/litellm_enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py b/tests/enterprise/litellm_enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py index c23b203feba..d1f39fa8df6 100644 --- a/tests/enterprise/litellm_enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py +++ b/tests/enterprise/litellm_enterprise/proxy/management_endpoints/test_project_endpoints_prisma.py @@ -1368,3 +1368,33 @@ async def test_new_project_flag_on_access_group_model_returns_400(monkeypatch): assert "prod-models" in str(exc_info.value) assert "expand to multiple models at request time" in str(exc_info.value) + + +@pytest.mark.asyncio +async def test_check_user_permission_for_project_uses_members_with_roles(): + """Team admins added via /team/member_add live in members_with_roles, not the legacy admins list.""" + from litellm.proxy._types import LiteLLM_TeamTable, Member + from litellm_enterprise.proxy.management_endpoints.project_endpoints import ( + _check_user_permission_for_project, + ) + + team = LiteLLM_TeamTable( + team_id="team-1", + admins=[], + members_with_roles=[ + Member(user_id="team-admin", role="admin"), + Member(user_id="plain-member", role="user"), + ], + ) + + async def check(user_id: str) -> bool: + return await _check_user_permission_for_project( + user_api_key_dict=UserAPIKeyAuth(user_id=user_id, user_role=LitellmUserRoles.INTERNAL_USER), + team_id="team-1", + prisma_client=mock.MagicMock(), + team_object=team, + ) + + assert await check("team-admin") is True + assert await check("plain-member") is False + assert await check("stranger") is False From 7ef3d1e7e370fa72539ffeadea6bb80b57b860f5 Mon Sep 17 00:00:00 2001 From: jesus Date: Tue, 8 Sep 2026 15:38:44 +0000 Subject: [PATCH 3/8] test(ui): query Projects nav link by role instead of container in leftnav tests Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/components/leftnav.test.tsx | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/ui/litellm-dashboard/src/components/leftnav.test.tsx b/ui/litellm-dashboard/src/components/leftnav.test.tsx index 44b186762d7..bcb4243497f 100644 --- a/ui/litellm-dashboard/src/components/leftnav.test.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.test.tsx @@ -609,15 +609,18 @@ describe("Sidebar (leftnav)", () => { it("hides Projects from a plain internal user", () => { mockUseAuthorized.mockReturnValue(internalAuth); - const { container } = renderWithProviders(); - expect(container.querySelector('a[href*="projects"]')).toBeNull(); + renderWithProviders(); + expect(screen.queryByRole("link", { name: "Projects" })).not.toBeInTheDocument(); }); 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"); + renderWithProviders(); + expect(screen.getByRole("link", { name: "Projects" })).toHaveAttribute( + "href", + expect.stringContaining("projects"), + ); }); it("shows Projects to an internal user who administers an organization", () => { @@ -627,8 +630,11 @@ describe("Sidebar (leftnav)", () => { isLoading: false, error: null, }); - const { container } = renderWithProviders(); - expect(container.querySelector('a[href*="projects"]')).toHaveTextContent("Projects"); + renderWithProviders(); + expect(screen.getByRole("link", { name: "Projects" })).toHaveAttribute( + "href", + expect.stringContaining("projects"), + ); }); }); From 194c06a30ce4cf91208d1cc76fc1444741a32c7f Mon Sep 17 00:00:00 2001 From: jesus Date: Tue, 8 Sep 2026 15:39:56 +0000 Subject: [PATCH 4/8] test(ui): assert Projects nav item by visible text Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ui/litellm-dashboard/src/components/leftnav.test.tsx | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/ui/litellm-dashboard/src/components/leftnav.test.tsx b/ui/litellm-dashboard/src/components/leftnav.test.tsx index bcb4243497f..da143955fc3 100644 --- a/ui/litellm-dashboard/src/components/leftnav.test.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.test.tsx @@ -610,17 +610,14 @@ describe("Sidebar (leftnav)", () => { it("hides Projects from a plain internal user", () => { mockUseAuthorized.mockReturnValue(internalAuth); renderWithProviders(); - expect(screen.queryByRole("link", { name: "Projects" })).not.toBeInTheDocument(); + expect(screen.queryByText("Projects")).not.toBeInTheDocument(); }); it("shows Projects to an internal user who administers a team", () => { mockUseAuthorized.mockReturnValue(internalAuth); teamAdminState.isTeamAdmin = true; renderWithProviders(); - expect(screen.getByRole("link", { name: "Projects" })).toHaveAttribute( - "href", - expect.stringContaining("projects"), - ); + expect(screen.getByText("Projects")).toBeInTheDocument(); }); it("shows Projects to an internal user who administers an organization", () => { @@ -631,10 +628,7 @@ describe("Sidebar (leftnav)", () => { error: null, }); renderWithProviders(); - expect(screen.getByRole("link", { name: "Projects" })).toHaveAttribute( - "href", - expect.stringContaining("projects"), - ); + expect(screen.getByText("Projects")).toBeInTheDocument(); }); }); From b0b595c1779641983e8d7e8694db4d427351095c Mon Sep 17 00:00:00 2001 From: jesus Date: Tue, 8 Sep 2026 17:41:19 +0000 Subject: [PATCH 5/8] revert(ui): keep Projects nav visible to every role when the Projects UI flag is on Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../src/components/leftnav.test.tsx | 48 +------------------ .../src/components/leftnav.tsx | 5 +- 2 files changed, 2 insertions(+), 51 deletions(-) diff --git a/ui/litellm-dashboard/src/components/leftnav.test.tsx b/ui/litellm-dashboard/src/components/leftnav.test.tsx index da143955fc3..6eb0218c41d 100644 --- a/ui/litellm-dashboard/src/components/leftnav.test.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.test.tsx @@ -3,8 +3,6 @@ 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 { @@ -15,7 +13,7 @@ vi.mock("../utils/roles", async (importOriginal) => { rolesWithWriteAccess: ["admin", "internal"], rolesAllowedToViewWriteScopedPages: ["admin", "internal", "admin_viewer"], isAdminRole: (role: string) => role === "admin" || role === "admin_viewer", - isUserTeamAdminForAnyTeam: () => teamAdminState.isTeamAdmin, + isUserTeamAdminForAnyTeam: () => false, }; }); @@ -588,50 +586,6 @@ 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); - renderWithProviders(); - expect(screen.queryByText("Projects")).not.toBeInTheDocument(); - }); - - it("shows Projects to an internal user who administers a team", () => { - mockUseAuthorized.mockReturnValue(internalAuth); - teamAdminState.isTeamAdmin = true; - renderWithProviders(); - expect(screen.getByText("Projects")).toBeInTheDocument(); - }); - - 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, - }); - renderWithProviders(); - expect(screen.getByText("Projects")).toBeInTheDocument(); - }); - }); - 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 7e67d288421..9d772f45153 100644 --- a/ui/litellm-dashboard/src/components/leftnav.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.tsx @@ -475,10 +475,7 @@ const Sidebar_: React.FC = ({ if (!isAdmin && enabledPagesInternalUsers != null) return enabledPagesInternalUsers.includes(item.page); return true; } - if (item.key === "projects") { - if (!enableProjectsUI) return false; - return isAdmin || isOrgAdmin || isTeamAdmin; - } + if (item.key === "projects" && !enableProjectsUI) return false; if ( !isAdmin && item.key === "agents" && From 4fb50da3e78c88ea1f0f1bdf583f5d0a2585f436 Mon Sep 17 00:00:00 2001 From: jesus Date: Tue, 8 Sep 2026 17:45:41 +0000 Subject: [PATCH 6/8] test(proxy): assert project write routes are self-managed so the gate test carries an assertion Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- tests/test_litellm/proxy/auth/test_route_checks.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/test_litellm/proxy/auth/test_route_checks.py b/tests/test_litellm/proxy/auth/test_route_checks.py index 6b17263972b..ef03789f86a 100644 --- a/tests/test_litellm/proxy/auth/test_route_checks.py +++ b/tests/test_litellm/proxy/auth/test_route_checks.py @@ -3659,6 +3659,7 @@ def test_project_write_routes_reach_endpoint_for_internal_user(route): valid_token=valid_token, request_data={"team_id": "team-1"}, ) + assert RouteChecks.check_route_access(route=route, allowed_routes=LiteLLMRoutes.self_managed_routes.value) def test_project_delete_route_stays_proxy_admin_only(): From e693652cdd19a1237f505ca45230ba3c066f4167 Mon Sep 17 00:00:00 2001 From: jesus Date: Tue, 8 Sep 2026 17:52:52 +0000 Subject: [PATCH 7/8] refactor(proxy): inline team-admin role check in project permission helper to avoid private cross-module import Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../proxy/management_endpoints/project_endpoints.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py b/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py index d652670c220..87b467cce4f 100644 --- a/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py +++ b/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py @@ -22,7 +22,7 @@ from litellm._uuid import uuid from litellm.proxy._types import * from litellm.proxy.auth.auth_checks import delete_cached_project_object from litellm.proxy.auth.user_api_key_auth import user_api_key_auth -from litellm.proxy.management_endpoints.common_utils import _is_user_team_admin, _set_object_metadata_field +from litellm.proxy.management_endpoints.common_utils import _set_object_metadata_field from litellm.proxy.management_helpers.utils import ( management_endpoint_wrapper, ) @@ -114,7 +114,11 @@ async def _check_user_permission_for_project( return False team: Final = LiteLLM_TeamTable.model_validate(team_row.model_dump()) - return _is_user_team_admin(user_api_key_dict, team) or user_api_key_dict.user_id in (team.admins or []) + is_role_admin: Final = any( + member.user_id is not None and member.user_id == user_api_key_dict.user_id and member.role == "admin" + for member in team.members_with_roles + ) + return is_role_admin or user_api_key_dict.user_id in (team.admins or []) async def _validate_team_exists( From 941eaf3ab8074b4ecf77134a11a68a1c1bfe6c73 Mon Sep 17 00:00:00 2001 From: jesus Date: Tue, 8 Sep 2026 18:03:45 +0000 Subject: [PATCH 8/8] fix(proxy): read team members straight off the team row in the project permission check Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../proxy/management_endpoints/project_endpoints.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py b/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py index 87b467cce4f..ee3bae1fba9 100644 --- a/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py +++ b/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py @@ -43,6 +43,7 @@ if TYPE_CHECKING: router = APIRouter() _OBJECT_PERMISSION_PAYLOAD: Final = TypeAdapter(dict[str, object]) +_TEAM_MEMBERS: Final = TypeAdapter(list[Member]) def _team_table(prisma_client: PrismaClient) -> TableActions["prisma_models.LiteLLM_TeamTable"]: @@ -113,12 +114,13 @@ async def _check_user_permission_for_project( if team_row is None: return False - team: Final = LiteLLM_TeamTable.model_validate(team_row.model_dump()) + raw_members: Final = team_row.members_with_roles + members: Final = _TEAM_MEMBERS.validate_python(raw_members) if isinstance(raw_members, list) else () is_role_admin: Final = any( member.user_id is not None and member.user_id == user_api_key_dict.user_id and member.role == "admin" - for member in team.members_with_roles + for member in members ) - return is_role_admin or user_api_key_dict.user_id in (team.admins or []) + return is_role_admin or user_api_key_dict.user_id in (team_row.admins or []) async def _validate_team_exists(