refactor(proxy): resolve /key/list user_email via raw id-only lookup

Replaces the take-1000 find_many with a raw SQL query that fetches only
user_id, escapes LIKE wildcards in the fragment, and returns the
complete match set so the key filter never silently drops visible keys.
Fragments matching more than 50k users get a 400 telling the caller to
narrow the search instead of building an IN clause that large
This commit is contained in:
ryan-crabbe-berri 2026-07-25 17:31:30 -07:00
parent 74cb382932
commit 45f3ac556b
3 changed files with 68 additions and 28 deletions

View file

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

View file

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

View file

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