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 <yuneng-jiang@users.noreply.github.com>
This commit is contained in:
Cursor Agent 2026-03-07 19:15:16 +00:00
parent 78834b5fa4
commit a710b8136f
2 changed files with 102 additions and 18 deletions

View file

@ -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}}
}

View file

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