From a710b8136f5b1d9fcb2b8329f898fc5a8e0f03d2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sat, 7 Mar 2026 19:15:16 +0000 Subject: [PATCH] fix: allow team admins to search users in /user/filter/ui endpoint The /user/filter/ui endpoint was restricted to proxy admins and org admins only, which prevented team admins from searching for users when adding members to their teams via the UI. This change adds team admin as an allowed role for the endpoint. When a team admin searches, they can see all users (not scoped to a specific org) so they can find and add any existing user to their team. Also adds a test for the team admin case and updates the existing 403 test to account for the new team admin check. Co-authored-by: yuneng-jiang --- .../internal_user_endpoints.py | 45 +++++++---- .../test_internal_user_endpoints.py | 75 ++++++++++++++++++- 2 files changed, 102 insertions(+), 18 deletions(-) diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index 92862ed9dc4..2ec81c9c3f4 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -31,7 +31,10 @@ from litellm.proxy.management_endpoints.common_daily_activity import ( get_daily_activity_aggregated, ) from litellm.proxy.auth.auth_checks import get_user_object -from litellm.proxy.management_endpoints.common_utils import _user_has_admin_view +from litellm.proxy.management_endpoints.common_utils import ( + _is_user_team_admin, + _user_has_admin_view, +) from litellm.proxy.management_endpoints.key_management_endpoints import ( generate_key_helper_fn, prepare_metadata_fields, @@ -1871,14 +1874,16 @@ async def ui_view_users( raise HTTPException(status_code=500, detail={"error": "No db connected"}) try: - # Restrict by caller role: proxy admin sees all; org admin sees only their org(s); others 403 + # Restrict by caller role: proxy admin sees all; org admin sees only their org(s); + # team admin sees all (needed to add members to their teams); others 403 is_proxy_admin = _user_has_admin_view(user_api_key_dict) + is_team_admin = False if not is_proxy_admin: if user_api_key_dict.user_id is None: raise HTTPException( status_code=403, detail={ - "error": "Only proxy admins and organization admins can search users." + "error": "Only proxy admins, organization admins, and team admins can search users." }, ) try: @@ -1890,18 +1895,17 @@ async def ui_view_users( proxy_logging_obj=proxy_logging_obj, ) except ValueError: - # get_user_object raises ValueError when user not found (user_id_upsert=False) raise HTTPException( status_code=403, detail={ - "error": "Only proxy admins and organization admins can search users." + "error": "Only proxy admins, organization admins, and team admins can search users." }, ) if caller_user is None: raise HTTPException( status_code=403, detail={ - "error": "Only proxy admins and organization admins can search users." + "error": "Only proxy admins, organization admins, and team admins can search users." }, ) org_admin_org_ids = [ @@ -1909,13 +1913,26 @@ async def ui_view_users( for m in (caller_user.organization_memberships or []) if m.user_role == LitellmUserRoles.ORG_ADMIN.value ] + if not org_admin_org_ids: - raise HTTPException( - status_code=403, - detail={ - "error": "Only proxy admins and organization admins can search users." - }, - ) + caller_team_ids = caller_user.teams or [] + if caller_team_ids: + teams = await prisma_client.db.litellm_teamtable.find_many( + where={"team_id": {"in": caller_team_ids}} + ) + for team in teams: + team_obj = LiteLLM_TeamTable(**team.model_dump()) + if _is_user_team_admin(user_api_key_dict, team_obj): + is_team_admin = True + break + + if not is_team_admin: + raise HTTPException( + status_code=403, + detail={ + "error": "Only proxy admins, organization admins, and team admins can search users." + }, + ) # Calculate offset for pagination skip = (page - 1) * page_size @@ -1935,8 +1952,8 @@ async def ui_view_users( "mode": "insensitive", # Case-insensitive search } - # Org admins: only users in their org(s) - if not is_proxy_admin: + # Org admins: only users in their org(s); team admins can see all users + if not is_proxy_admin and not is_team_admin: where_conditions["organization_memberships"] = { "some": {"organization_id": {"in": org_admin_org_ids}} } diff --git a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py index 16b5feb108a..f1cd870a5fe 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py @@ -151,9 +151,9 @@ async def test_ui_view_users_org_admin_filtered_by_org(mocker): @pytest.mark.asyncio -async def test_ui_view_users_non_org_admin_returns_403(mocker): +async def test_ui_view_users_non_org_admin_non_team_admin_returns_403(mocker): """ - Caller is not proxy admin and not org admin: endpoint returns 403. + Caller is not proxy admin, not org admin, and not team admin: endpoint returns 403. """ from fastapi import HTTPException @@ -162,9 +162,10 @@ async def test_ui_view_users_non_org_admin_returns_403(mocker): mocker.patch("litellm.proxy.proxy_server.user_api_key_cache", mocker.MagicMock()) mocker.patch("litellm.proxy.proxy_server.proxy_logging_obj", mocker.MagicMock()) - # Caller has no org admin membership + # Caller has no org admin membership and no teams caller_user = mocker.MagicMock() caller_user.organization_memberships = [] # not an org admin + caller_user.teams = [] # not on any teams async def mock_get_user_object(*args, **kwargs): return caller_user @@ -184,7 +185,73 @@ async def test_ui_view_users_non_org_admin_returns_403(mocker): ) assert exc_info.value.status_code == 403 - assert "Only proxy admins and organization admins" in str(exc_info.value.detail) + assert "team admins" in str(exc_info.value.detail) + + +@pytest.mark.asyncio +async def test_ui_view_users_team_admin_allowed(mocker): + """ + Team admin: endpoint allows access and does not apply org filtering. + """ + from litellm.proxy._types import LiteLLM_TeamTable, Member + + mock_prisma_client = mocker.MagicMock() + mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + mocker.patch("litellm.proxy.proxy_server.user_api_key_cache", mocker.MagicMock()) + mocker.patch("litellm.proxy.proxy_server.proxy_logging_obj", mocker.MagicMock()) + + # Caller has no org admin membership but is a team admin + caller_user = mocker.MagicMock() + caller_user.organization_memberships = [] + caller_user.teams = ["team-123"] + + async def mock_get_user_object(*args, **kwargs): + return caller_user + + mocker.patch( + "litellm.proxy.management_endpoints.internal_user_endpoints.get_user_object", + side_effect=mock_get_user_object, + ) + + # Mock the team lookup returning a team where the caller is admin + team_mock = mocker.MagicMock() + team_mock.model_dump.return_value = { + "team_id": "team-123", + "team_alias": "TestTeam", + "members_with_roles": [ + {"user_id": "team-admin-user", "user_email": "admin@test.com", "role": "admin"} + ], + "metadata": {}, + "blocked": False, + } + + async def mock_find_many_teams(*args, **kwargs): + return [team_mock] + + mock_prisma_client.db.litellm_teamtable.find_many = mock_find_many_teams + + # Mock user search results + user_result = mocker.MagicMock() + user_result.model_dump.return_value = { + "user_id": "found-user", + "user_email": "found@test.com", + } + + async def mock_find_many_users(*args, **kwargs): + return [user_result] + + mock_prisma_client.db.litellm_usertable.find_many = mock_find_many_users + + response = await ui_view_users( + user_api_key_dict=UserAPIKeyAuth(user_id="team-admin-user", user_role=None), + user_id=None, + user_email="found", + page=1, + page_size=50, + ) + + assert len(response) == 1 + assert response[0].user_email == "found@test.com" def test_user_daily_activity_types():