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…" + /> +