diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index 35cf4772426..3ac99618968 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -5176,7 +5176,31 @@ async def get_member_team_ids( VALID_EXPIRES_FILTER_VALUES = frozenset({"active", "expired"}) -USER_EMAIL_KEY_FILTER_MAX_USERS = 1000 +USER_EMAIL_KEY_FILTER_MAX_USERS = 50_000 + + +async def _resolve_user_ids_for_email_filter(prisma_client: PrismaClient, user_email: str) -> List[str]: + """Resolve a user_email fragment to the complete set of matching user IDs. + + Raw SQL keeps this bounded: only the user_id column is fetched (find_many + materializes full rows), and LIKE wildcards in the fragment are escaped so + user input cannot widen the match. Matching more than + USER_EMAIL_KEY_FILTER_MAX_USERS users is refused rather than truncated: + a silently partial id list would make /key/list drop visible keys. + """ + escaped = user_email.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") + rows = await prisma_client.db.query_raw( + 'SELECT user_id FROM "LiteLLM_UserTable" WHERE user_email ILIKE $1 LIMIT $2', + f"%{escaped}%", + USER_EMAIL_KEY_FILTER_MAX_USERS + 1, + ) + user_ids = [row["user_id"] for row in rows] + if len(user_ids) > USER_EMAIL_KEY_FILTER_MAX_USERS: + raise HTTPException( + status_code=400, + detail={"error": "user_email filter matched too many users; use a more specific email fragment"}, + ) + return user_ids @router.get( @@ -5196,7 +5220,7 @@ async def list_keys( ), user_email: str | None = Query( None, - description="Filter keys by the owning user's email. Case-insensitive substring match against the user table, capped at the first 1000 matching users; only keys whose user_id belongs to a matching user are returned.", + description="Filter keys by the owning user's email. Case-insensitive substring match against the user table; only keys whose user_id belongs to a matching user are returned. Fragments matching more than 50,000 users are rejected with a 400.", ), team_id: Optional[str] = Query(None, description="Filter keys by team ID"), organization_id: Optional[str] = Query(None, description="Filter keys by organization ID"), @@ -5327,13 +5351,7 @@ async def list_keys( user_id = user_api_key_dict.user_id user_ids_for_email = ( - [ - user.user_id - for user in await UserRepository(prisma_client).table.find_many( - where={"user_email": {"contains": user_email, "mode": "insensitive"}}, - take=USER_EMAIL_KEY_FILTER_MAX_USERS, - ) - ] + await _resolve_user_ids_for_email_filter(prisma_client, user_email) if user_email and isinstance(user_email, str) else None ) diff --git a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py index 329f0dd3bf1..64d07ae10e5 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py @@ -6314,19 +6314,16 @@ def test_build_key_filter_conditions_user_email_ids_narrow_visibility(): @pytest.mark.asyncio async def test_list_keys_user_email_resolves_to_user_ids(): """ - list_keys must resolve the user_email filter to user IDs via a - case-insensitive substring lookup on the user table and pass them to - _list_key_helper. No email filter (including the Query default object - from direct calls) must skip the lookup entirely. + list_keys must resolve the user_email filter to user IDs via a raw + id-only case-insensitive substring lookup on the user table and pass + them to _list_key_helper. No email filter (including the Query default + object from direct calls) must skip the lookup entirely. """ - from types import SimpleNamespace from unittest.mock import Mock mock_prisma_client = AsyncMock() - mock_find_many = AsyncMock( - return_value=[SimpleNamespace(user_id="user-1"), SimpleNamespace(user_id="user-2")] - ) - mock_prisma_client.db.litellm_usertable.find_many = mock_find_many + mock_query_raw = AsyncMock(return_value=[{"user_id": "user-1"}, {"user_id": "user-2"}]) + mock_prisma_client.db.query_raw = mock_query_raw mock_list_key_helper = AsyncMock( return_value={"keys": [], "total_count": 0, "current_page": 1, "total_pages": 0} ) @@ -6351,15 +6348,13 @@ async def test_list_keys_user_email_resolves_to_user_ids(): include_created_by_keys=False, status=None, ) - assert mock_find_many.call_args.kwargs["where"] == { - "user_email": {"contains": "alias@example.com", "mode": "insensitive"} - } - assert mock_find_many.call_args.kwargs["take"] == 1000, ( - "user lookup must be bounded; a broad substring like '@' matches every user" - ) + sql, pattern, limit = mock_query_raw.call_args.args + assert "ILIKE" in sql and 'FROM "LiteLLM_UserTable"' in sql + assert pattern == "%alias@example.com%" + assert limit == 50_001, "lookup must be bounded so an over-broad fragment cannot fetch the whole table" assert mock_list_key_helper.call_args.kwargs["user_ids_for_email"] == ["user-1", "user-2"] - mock_find_many.return_value = [] + mock_query_raw.return_value = [] await list_keys( request=Mock(), user_api_key_dict=admin, @@ -6370,7 +6365,7 @@ async def test_list_keys_user_email_resolves_to_user_ids(): ) assert mock_list_key_helper.call_args.kwargs["user_ids_for_email"] == [] - mock_find_many.reset_mock() + mock_query_raw.reset_mock() await list_keys( request=Mock(), user_api_key_dict=admin, @@ -6378,10 +6373,37 @@ async def test_list_keys_user_email_resolves_to_user_ids(): include_created_by_keys=False, status=None, ) - mock_find_many.assert_not_called() + mock_query_raw.assert_not_called() assert mock_list_key_helper.call_args.kwargs["user_ids_for_email"] is None +@pytest.mark.asyncio +async def test_resolve_user_ids_for_email_filter_escapes_wildcards_and_refuses_overflow(): + """ + LIKE wildcards typed by the user must match literally, and a fragment + matching more than the ceiling must 400 instead of silently truncating + the id list (truncation would make /key/list drop visible keys). + """ + from litellm.proxy.management_endpoints.key_management_endpoints import ( + USER_EMAIL_KEY_FILTER_MAX_USERS, + _resolve_user_ids_for_email_filter, + ) + + mock_prisma_client = AsyncMock() + mock_query_raw = AsyncMock(return_value=[{"user_id": "user-1"}]) + mock_prisma_client.db.query_raw = mock_query_raw + + result = await _resolve_user_ids_for_email_filter(mock_prisma_client, "50%_off\\weird") + assert result == ["user-1"] + assert mock_query_raw.call_args.args[1] == "%50\\%\\_off\\\\weird%" + + mock_query_raw.return_value = [{"user_id": f"user-{i}"} for i in range(USER_EMAIL_KEY_FILTER_MAX_USERS + 1)] + with pytest.raises(HTTPException) as exc_info: + await _resolve_user_ids_for_email_filter(mock_prisma_client, "@") + assert exc_info.value.status_code == 400 + assert "too many users" in str(exc_info.value.detail) + + @pytest.mark.asyncio async def test_generate_key_negative_max_budget(): """ diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index af06c4eed3a..5a61e845b04 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -42813,7 +42813,7 @@ export interface operations { size?: number; /** @description Filter keys by user ID. Exact match by default; set substring_matching=true (admin only) for case-insensitive substring matching. */ user_id?: string | null; - /** @description Filter keys by the owning user's email. Case-insensitive substring match against the user table, capped at the first 1000 matching users; only keys whose user_id belongs to a matching user are returned. */ + /** @description Filter keys by the owning user's email. Case-insensitive substring match against the user table; only keys whose user_id belongs to a matching user are returned. Fragments matching more than 50,000 users are rejected with a 400. */ user_email?: string | null; /** @description Filter keys by team ID */ team_id?: string | null;