mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-20 00:11:50 +00:00
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
This commit is contained in:
parent
fc5ab31fba
commit
139dc60e5b
5 changed files with 158 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ const FILTER_LABELS: Record<string, string> = {
|
|||
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…"
|
||||
/>
|
||||
</DataTableFilterField>
|
||||
<DataTableFilterField label="User Email">
|
||||
<Input
|
||||
value={(get("user_email") as string) ?? ""}
|
||||
onChange={(event) => set("user_email", event.target.value)}
|
||||
placeholder="Enter User Email…"
|
||||
/>
|
||||
</DataTableFilterField>
|
||||
<DataTableFilterField label="Key ID">
|
||||
<Input
|
||||
value={(get("key_hash") as string) ?? ""}
|
||||
|
|
|
|||
2
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
2
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -42813,6 +42813,8 @@ 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; only keys whose user_id belongs to a matching user are returned. */
|
||||
user_email?: string | null;
|
||||
/** @description Filter keys by team ID */
|
||||
team_id?: string | null;
|
||||
/** @description Filter keys by organization ID */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue