From 139dc60e5b031401511be2ceddf1bbef1da8382a Mon Sep 17 00:00:00 2001 From: ryan-crabbe-berri Date: Sat, 25 Jul 2026 09:50:12 -0700 Subject: [PATCH] feat(proxy): filter /key/list by user email Adds a user_email query param to /key/list that resolves the email to matching user IDs via a case-insensitive substring lookup on the user table, then narrows the key where-clause with an ANDed user_id in-filter so it can only restrict the caller's existing visibility. An email that matches no users returns zero keys instead of silently dropping the filter. The Admin UI Virtual Keys filter drawer gains a User Email field wired through useKeys --- .../key_management_endpoints.py | 21 +++ .../test_key_management_endpoints.py | 124 ++++++++++++++++++ .../src/app/(dashboard)/hooks/keys/useKeys.ts | 2 + .../VirtualKeysPage/VirtualKeysTable.tsx | 9 ++ ui/litellm-dashboard/src/lib/http/schema.d.ts | 2 + 5 files changed, 158 insertions(+) diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index ac6a2a4a7db..bcd3184a993 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -5193,6 +5193,10 @@ async def list_keys( None, description="Filter keys by user ID. Exact match by default; set substring_matching=true (admin only) for case-insensitive substring matching.", ), + user_email: str | None = Query( + None, + 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.", + ), team_id: Optional[str] = Query(None, description="Filter keys by team ID"), organization_id: Optional[str] = Query(None, description="Filter keys by organization ID"), key_hash: Optional[str] = Query(None, description="Filter keys by key hash"), @@ -5321,6 +5325,17 @@ async def list_keys( if not user_id and not is_proxy_admin: 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"}} + ) + ] + if user_email and isinstance(user_email, str) + else None + ) + response = await _list_key_helper( prisma_client=prisma_client, page=page, @@ -5343,6 +5358,7 @@ async def list_keys( agent_id=agent_id, use_substring_matching=use_substring_matching, expires_filter=expires if isinstance(expires, str) else None, + user_ids_for_email=user_ids_for_email, ) verbose_proxy_logger.debug("Successfully prepared response") @@ -5571,6 +5587,7 @@ def _build_key_filter_conditions( agent_id: Optional[str] = None, use_substring_matching: bool = False, expires_filter: str | None = None, + user_ids_for_email: List[str] | None = None, ) -> Dict[str, Union[str, Dict[str, Any], List[Dict[str, Any]]]]: """Build filter conditions for key listing. @@ -5678,6 +5695,8 @@ def _build_key_filter_conditions( where = {"AND": [where, {"access_group_ids": {"hasSome": [access_group_id]}}]} if agent_id and isinstance(agent_id, str): where = {"AND": [where, {"agent_id": agent_id}]} + if user_ids_for_email is not None: + where = {"AND": [where, {"user_id": {"in": user_ids_for_email}}]} if expires_filter is not None and expires_filter in VALID_EXPIRES_FILTER_VALUES: where = {"AND": [where, _build_expires_where_clause(expires_filter, datetime.now(timezone.utc))]} @@ -5710,6 +5729,7 @@ async def _list_key_helper( agent_id: Optional[str] = None, use_substring_matching: bool = False, expires_filter: str | None = None, + user_ids_for_email: List[str] | None = None, ) -> KeyListResponseObject: """ Helper function to list keys @@ -5748,6 +5768,7 @@ async def _list_key_helper( agent_id=agent_id, use_substring_matching=use_substring_matching, expires_filter=expires_filter, + user_ids_for_email=user_ids_for_email, ) # Calculate skip for pagination 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 51f72f91dc3..c1b24ccea56 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 @@ -6255,6 +6255,130 @@ def test_build_key_filter_conditions_agent_id_narrows_visibility(): assert "agent_id" not in json.dumps(where_without) +def test_build_key_filter_conditions_user_email_ids_narrow_visibility(): + """ + Filtering /key/list by user_email resolves to a list of user IDs which must + be ANDed on top of the caller's visibility conditions (narrow, never widen). + An email that matches no users must produce a match-nothing filter rather + than being dropped (dropping it would return every visible key). + """ + from litellm.proxy.management_endpoints.key_management_endpoints import ( + _build_key_filter_conditions, + ) + + where = _build_key_filter_conditions( + user_id="some-user", + team_id=None, + organization_id=None, + key_alias=None, + key_hash=None, + exclude_team_id=None, + admin_team_ids=["team-a"], + member_team_ids=None, + include_created_by_keys=False, + user_ids_for_email=["user-1", "user-2"], + ) + assert where.get("AND"), f"expected top-level AND, got: {where}" + assert {"user_id": {"in": ["user-1", "user-2"]}} in where["AND"], f"user_id in-filter not ANDed: {where}" + + where_no_match = _build_key_filter_conditions( + user_id=None, + team_id=None, + organization_id=None, + key_alias=None, + key_hash=None, + exclude_team_id=None, + admin_team_ids=None, + member_team_ids=None, + include_created_by_keys=False, + user_ids_for_email=[], + ) + assert {"user_id": {"in": []}} in where_no_match["AND"], ( + f"an email matching no users must match nothing, got: {where_no_match}" + ) + + where_without = _build_key_filter_conditions( + user_id="some-user", + team_id=None, + organization_id=None, + key_alias=None, + key_hash=None, + exclude_team_id=None, + admin_team_ids=None, + member_team_ids=None, + include_created_by_keys=False, + ) + assert '"in"' not in json.dumps(where_without) + + +@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. + """ + 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_list_key_helper = AsyncMock( + return_value={"keys": [], "total_count": 0, "current_page": 1, "total_pages": 0} + ) + with ( + patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client), + patch( + "litellm.proxy.management_endpoints.key_management_endpoints.validate_key_list_check", + return_value=None, + ), + patch( + "litellm.proxy.management_endpoints.key_management_endpoints._list_key_helper", + mock_list_key_helper, + ), + ): + admin = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin") + + await list_keys( + request=Mock(), + user_api_key_dict=admin, + user_email="alias@example.com", + include_team_keys=False, + 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_list_key_helper.call_args.kwargs["user_ids_for_email"] == ["user-1", "user-2"] + + mock_find_many.return_value = [] + await list_keys( + request=Mock(), + user_api_key_dict=admin, + user_email="nobody@example.com", + include_team_keys=False, + include_created_by_keys=False, + status=None, + ) + assert mock_list_key_helper.call_args.kwargs["user_ids_for_email"] == [] + + mock_find_many.reset_mock() + await list_keys( + request=Mock(), + user_api_key_dict=admin, + include_team_keys=False, + include_created_by_keys=False, + status=None, + ) + mock_find_many.assert_not_called() + assert mock_list_key_helper.call_args.kwargs["user_ids_for_email"] is None + + @pytest.mark.asyncio async def test_generate_key_negative_max_budget(): """ diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.ts index 0df809bc582..6fc58f90f1c 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/keys/useKeys.ts @@ -32,6 +32,7 @@ export interface KeyListCallOptions { agentID?: string | null; selectedKeyAlias?: string | null; userID?: string | null; + userEmail?: string | null; keyHash?: string | null; sortBy?: string | null; sortOrder?: string | null; @@ -55,6 +56,7 @@ const keyListCall = async (accessToken: string, page: number, pageSize: number, key_alias: options.selectedKeyAlias, key_hash: options.keyHash, user_id: options.userID, + user_email: options.userEmail, page, size: pageSize, sort_by: options.sortBy, diff --git a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx index fe929dc0179..80d789efa21 100644 --- a/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx +++ b/ui/litellm-dashboard/src/components/VirtualKeysPage/VirtualKeysTable.tsx @@ -38,6 +38,7 @@ const FILTER_LABELS: Record = { team_id: "Team", org_id: "Organization", user_id: "User ID", + user_email: "User Email", key_hash: "Key ID", }; @@ -71,6 +72,7 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) { organizationID: getFilterValue("org_id"), selectedKeyAlias: searchQuery.trim() || undefined, userID: getFilterValue("user_id"), + userEmail: getFilterValue("user_email"), keyHash: getFilterValue("key_hash"), sortBy, sortOrder, @@ -233,6 +235,13 @@ export function VirtualKeysTable({ headerActions }: VirtualKeysTableProps) { placeholder="Enter User ID…" /> + + set("user_email", event.target.value)} + placeholder="Enter User Email…" + /> +