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():