diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index 679867935b7..e206a63f4a2 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -770,22 +770,18 @@ async def user_info_v2( detail="user_id is required", ) - user_info = await prisma_client.get_data(user_id=user_id) + # Access control check before DB query to avoid information disclosure + # (different error codes for "not found" vs "not authorized") + is_proxy_admin = user_api_key_dict.user_role in ( + LitellmUserRoles.PROXY_ADMIN, + LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, + ) + is_self = user_id == user_api_key_dict.user_id - if user_info is None: - raise HTTPException( - status_code=404, - detail=f"User {user_id} not found", - ) - - # Access control: non-admin users can only query their own info - # or info of users in teams they admin - if ( - user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN - and user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY - and user_id != user_api_key_dict.user_id - ): - if not await _is_team_admin_for_user( + if not is_proxy_admin and not is_self: + # Need to check team admin status — fetch user first + user_info = await prisma_client.get_data(user_id=user_id) + if user_info is None or not await _is_team_admin_for_user( user_api_key_dict=user_api_key_dict, target_user_teams=user_info.teams, prisma_client=prisma_client, @@ -796,9 +792,15 @@ async def user_info_v2( user_api_key_dict.user_id, user_id ), ) + else: + user_info = await prisma_client.get_data(user_id=user_id) + if user_info is None: + raise HTTPException( + status_code=404, + detail=f"User {user_id} not found", + ) return UserInfoV2Response( - user_id=user_id, user_info=user_info.model_dump(), ) except Exception as e: diff --git a/litellm/types/proxy/management_endpoints/internal_user_endpoints.py b/litellm/types/proxy/management_endpoints/internal_user_endpoints.py index 01a89f53a1f..2cd76be0501 100644 --- a/litellm/types/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/types/proxy/management_endpoints/internal_user_endpoints.py @@ -31,7 +31,6 @@ class UserInfoV2Response(BaseModel): via /key/list and /v2/team/list respectively. """ - user_id: str user_info: LiteLLM_UserTable 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 07d7da910b5..cee940f2322 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 @@ -1336,7 +1336,7 @@ async def test_user_info_v2_returns_user_profile(mocker): request=mock_request, ) - assert response.user_id == "test-user-123" + assert response.user_info.user_id == "test-user-123" assert response.user_info.user_email == "test@example.com" assert response.user_info.teams == ["team-1", "team-2"] # Verify no keys or teams fields on the response @@ -1383,7 +1383,7 @@ async def test_user_info_v2_falls_back_to_caller_user_id(mocker): request=mock_request, ) - assert response.user_id == "caller-user-id" + assert response.user_info.user_id == "caller-user-id" assert response.user_info.user_email == "caller@example.com" @@ -1606,7 +1606,7 @@ async def test_user_info_v2_team_admin_can_query_team_member(mocker): request=mock_request, ) - assert result.user_id == "user-B" + assert result.user_info.user_id == "user-B" @pytest.mark.asyncio