mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-15 23:31:29 +00:00
Merge pull request #40907 from BerriAI/litellm_key_alias_substring_non_admin
fix(proxy): allow key_alias substring matching on /key/list for non-admins
This commit is contained in:
commit
d4a72e7372
3 changed files with 124 additions and 15 deletions
|
|
@ -5971,7 +5971,7 @@ async def list_keys(
|
|||
key_hash: str | None = Query(None, description="Filter keys by key hash"),
|
||||
key_alias: str | None = Query(
|
||||
None,
|
||||
description="Filter keys by key alias. Exact match by default; set substring_matching=true (admin only) for case-insensitive substring matching.",
|
||||
description="Filter keys by key alias. Exact match by default; set substring_matching=true for case-insensitive substring matching.",
|
||||
),
|
||||
search: str | None = Query(
|
||||
None,
|
||||
|
|
@ -5992,7 +5992,7 @@ async def list_keys(
|
|||
agent_id: str | None = Query(None, description="Filter keys by agent ID"),
|
||||
substring_matching: bool = Query(
|
||||
False,
|
||||
description="If true (proxy admins only), match user_id/key_alias as case-insensitive substrings instead of exact values. Defaults to false: /key/list matched these exactly before substring search was added, and an exact user_id/key_alias filter must never return another user's keys.",
|
||||
description="If true, match key_alias (any caller) and user_id (proxy admins only) as case-insensitive substrings instead of exact values. Defaults to false: /key/list matched these exactly before substring search was added, and an exact user_id filter must never return another user's keys.",
|
||||
),
|
||||
expires: str | None = Query(
|
||||
None,
|
||||
|
|
@ -6086,13 +6086,14 @@ async def list_keys(
|
|||
LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
|
||||
]
|
||||
|
||||
# Substring matching is opt-in (admin-only). /key/list matched user_id and
|
||||
# key_alias exactly before substring search was added; auto-applying a
|
||||
# substring match to every admin call broke that contract and let a caller
|
||||
# passing an exact user_id (e.g. an integration scoping to one user with an
|
||||
# admin key) receive other users' keys (user_id="alice" -> "alice2"). Exact
|
||||
# by default restores the prior behavior; the dashboard opts in explicitly.
|
||||
# Substring matching is opt-in. /key/list matched user_id and key_alias
|
||||
# exactly before substring search was added; auto-applying a substring
|
||||
# match to every admin call broke that contract and let a caller passing
|
||||
# an exact user_id (e.g. an integration scoping to one user with an admin
|
||||
# key) receive other users' keys (user_id="alice" -> "alice2"). Exact by
|
||||
# default restores the prior behavior; the dashboard opts in explicitly.
|
||||
use_substring_matching: Final = substring_matching and is_proxy_admin
|
||||
use_key_alias_substring_matching: Final = substring_matching
|
||||
|
||||
# Admins may omit user_id to list all keys; non-admins are scoped to self.
|
||||
if not user_id and not is_proxy_admin:
|
||||
|
|
@ -6119,6 +6120,7 @@ async def list_keys(
|
|||
access_group_id=access_group_id,
|
||||
agent_id=agent_id,
|
||||
use_substring_matching=use_substring_matching,
|
||||
use_key_alias_substring_matching=use_key_alias_substring_matching,
|
||||
expires_filter=expires if isinstance(expires, str) else None,
|
||||
search=search,
|
||||
)
|
||||
|
|
@ -6364,6 +6366,7 @@ def _build_key_filter_conditions(
|
|||
access_group_id: str | None = None,
|
||||
agent_id: str | None = None,
|
||||
use_substring_matching: bool = False,
|
||||
use_key_alias_substring_matching: bool = False,
|
||||
expires_filter: str | None = None,
|
||||
search: str | None = None,
|
||||
) -> Mapping[str, object]:
|
||||
|
|
@ -6459,7 +6462,7 @@ def _build_key_filter_conditions(
|
|||
*(
|
||||
(
|
||||
{"key_alias": {"contains": key_alias, "mode": "insensitive"}}
|
||||
if use_substring_matching
|
||||
if use_key_alias_substring_matching
|
||||
else {"key_alias": key_alias},
|
||||
)
|
||||
if key_alias and isinstance(key_alias, str)
|
||||
|
|
@ -6505,6 +6508,7 @@ async def _list_key_helper(
|
|||
access_group_id: str | None = None,
|
||||
agent_id: str | None = None,
|
||||
use_substring_matching: bool = False,
|
||||
use_key_alias_substring_matching: bool = False,
|
||||
expires_filter: str | None = None,
|
||||
search: str | None = None,
|
||||
) -> KeyListResponseObject:
|
||||
|
|
@ -6544,6 +6548,7 @@ async def _list_key_helper(
|
|||
access_group_id=access_group_id,
|
||||
agent_id=agent_id,
|
||||
use_substring_matching=use_substring_matching,
|
||||
use_key_alias_substring_matching=use_key_alias_substring_matching,
|
||||
expires_filter=expires_filter,
|
||||
search=search,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ from litellm.proxy._types import (
|
|||
LiteLLM_BudgetTable,
|
||||
LiteLLM_OrganizationTable,
|
||||
LiteLLM_ProjectTableCachedObj,
|
||||
LiteLLM_TeamTable,
|
||||
LiteLLM_TeamTableCachedObj,
|
||||
LiteLLM_UserTable,
|
||||
LiteLLM_VerificationToken,
|
||||
|
|
@ -6414,7 +6415,7 @@ def test_build_key_filter_conditions_key_alias_narrows_team_admin_visibility():
|
|||
admin_team_ids=["team-a"],
|
||||
member_team_ids=["team-a"],
|
||||
include_created_by_keys=False,
|
||||
use_substring_matching=True,
|
||||
use_key_alias_substring_matching=True,
|
||||
)
|
||||
assert {"key_alias": {"contains": "member-key", "mode": "insensitive"}} in where_substring["AND"], (
|
||||
f"substring key_alias not ANDed: {where_substring}"
|
||||
|
|
@ -9359,7 +9360,7 @@ async def test_build_key_filter_team_id_scoped():
|
|||
async def test_build_key_filter_admin_substring_matching():
|
||||
"""
|
||||
Admin callers get substring (contains + insensitive) matching for user_id
|
||||
and key_alias when use_substring_matching=True.
|
||||
and key_alias when both substring flags are set.
|
||||
"""
|
||||
from litellm.proxy.management_endpoints.key_management_endpoints import (
|
||||
_build_key_filter_conditions,
|
||||
|
|
@ -9379,6 +9380,7 @@ async def test_build_key_filter_admin_substring_matching():
|
|||
member_team_ids=None,
|
||||
include_created_by_keys=False,
|
||||
use_substring_matching=True,
|
||||
use_key_alias_substring_matching=True,
|
||||
)
|
||||
|
||||
assert where["AND"][0]["user_id"] == {"contains": user_id, "mode": "insensitive"}
|
||||
|
|
@ -15150,8 +15152,8 @@ async def test_list_keys_admin_substring_opt_in():
|
|||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_keys_non_admin_cannot_opt_into_substring():
|
||||
"""substring_matching is admin-only: a non-admin requesting it still gets
|
||||
exact matching, scoped to their own user_id."""
|
||||
"""user_id substring matching is admin-only: a non-admin requesting it still
|
||||
gets exact matching, scoped to their own user_id."""
|
||||
user = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id="alice")
|
||||
kwargs = await _list_keys_capture_helper_kwargs(
|
||||
user, user_id=None, substring_matching=True
|
||||
|
|
@ -15160,6 +15162,108 @@ async def test_list_keys_non_admin_cannot_opt_into_substring():
|
|||
assert kwargs["user_id"] == "alice"
|
||||
|
||||
|
||||
def _prisma_where_matches(row, where):
|
||||
for field, expected in where.items():
|
||||
if field == "AND":
|
||||
if not all(_prisma_where_matches(row, child) for child in expected):
|
||||
return False
|
||||
elif field == "OR":
|
||||
if not any(_prisma_where_matches(row, child) for child in expected):
|
||||
return False
|
||||
elif isinstance(expected, dict):
|
||||
value = getattr(row, field)
|
||||
if "in" in expected and value not in expected["in"]:
|
||||
return False
|
||||
if "not" in expected and value == expected["not"]:
|
||||
return False
|
||||
if "contains" in expected:
|
||||
haystack, needle = value or "", expected["contains"]
|
||||
if expected.get("mode") == "insensitive":
|
||||
haystack, needle = haystack.lower(), needle.lower()
|
||||
if needle not in haystack:
|
||||
return False
|
||||
elif getattr(row, field) != expected:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
class _InMemoryVerificationTokenTable:
|
||||
def __init__(self, rows):
|
||||
self.rows = rows
|
||||
|
||||
async def find_many(self, where, **kwargs):
|
||||
return [row for row in self.rows if _prisma_where_matches(row, where)]
|
||||
|
||||
async def count(self, where):
|
||||
return len(await self.find_many(where))
|
||||
|
||||
|
||||
def _team_key(token, key_alias, user_id):
|
||||
return LiteLLM_VerificationToken(token=token, key_alias=key_alias, user_id=user_id, team_id="team-a")
|
||||
|
||||
|
||||
_TEAM_A_KEYS = (
|
||||
_team_key("tok-alice-first", "app_llmhub_first.last", "alice"),
|
||||
_team_key("tok-alice-other", "alice_other_key", "alice"),
|
||||
_team_key("tok-bob-first", "bob_First_key", "bob"),
|
||||
_team_key("tok-svc-first", "service_first_key", None),
|
||||
)
|
||||
|
||||
|
||||
def _list_team_a_keys_as(user_role, members_with_roles, query):
|
||||
from fastapi import FastAPI
|
||||
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.management_endpoints.key_management_endpoints import router
|
||||
|
||||
mock_prisma_client = AsyncMock()
|
||||
mock_prisma_client.db.litellm_verificationtoken = _InMemoryVerificationTokenTable(_TEAM_A_KEYS)
|
||||
mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock(
|
||||
return_value=LiteLLM_UserTable(user_id="alice", teams=["team-a"], organization_memberships=[])
|
||||
)
|
||||
mock_prisma_client.db.litellm_teamtable.find_many = AsyncMock(
|
||||
return_value=[LiteLLM_TeamTable(team_id="team-a", members_with_roles=members_with_roles)]
|
||||
)
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(router)
|
||||
test_app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(user_role=user_role, user_id="alice")
|
||||
with patch( # test-quality-ok: /key/list reads the prisma client from the proxy_server module global, no injection point
|
||||
"litellm.proxy.proxy_server.prisma_client", mock_prisma_client
|
||||
):
|
||||
response = TestClient(test_app).get(
|
||||
f"/key/list?team_id=team-a&include_team_keys=true&include_created_by_keys=true&{query}"
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
return sorted(response.json()["keys"])
|
||||
|
||||
|
||||
_ALICE_TEAM_ADMIN = [Member(user_id="alice", role="admin"), Member(user_id="bob", role="user")]
|
||||
_ALICE_TEAM_MEMBER = [Member(user_id="alice", role="user"), Member(user_id="bob", role="user")]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"user_role",
|
||||
[LitellmUserRoles.INTERNAL_USER, LitellmUserRoles.INTERNAL_USER_VIEW_ONLY, LitellmUserRoles.TEAM],
|
||||
)
|
||||
def test_list_keys_team_admin_key_alias_substring_returns_every_matching_team_key(user_role):
|
||||
keys = _list_team_a_keys_as(user_role, _ALICE_TEAM_ADMIN, "key_alias=first&substring_matching=true")
|
||||
assert keys == ["tok-alice-first", "tok-bob-first", "tok-svc-first"]
|
||||
|
||||
|
||||
def test_list_keys_team_member_key_alias_substring_stays_within_own_visibility():
|
||||
keys = _list_team_a_keys_as(
|
||||
LitellmUserRoles.INTERNAL_USER, _ALICE_TEAM_MEMBER, "key_alias=first&substring_matching=true"
|
||||
)
|
||||
assert keys == ["tok-alice-first", "tok-svc-first"]
|
||||
|
||||
|
||||
def test_list_keys_key_alias_stays_exact_without_substring_matching():
|
||||
assert _list_team_a_keys_as(LitellmUserRoles.INTERNAL_USER, _ALICE_TEAM_ADMIN, "key_alias=first") == []
|
||||
assert _list_team_a_keys_as(
|
||||
LitellmUserRoles.INTERNAL_USER, _ALICE_TEAM_ADMIN, "key_alias=app_llmhub_first.last"
|
||||
) == ["tok-alice-first"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_keys_search_is_honored_for_non_admin():
|
||||
"""LIT-4741: unlike substring_matching, `search` is not admin-gated. A non-admin's
|
||||
|
|
|
|||
4
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
4
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -50450,7 +50450,7 @@ export interface operations {
|
|||
organization_id?: string | null;
|
||||
/** @description Filter keys by key hash */
|
||||
key_hash?: string | null;
|
||||
/** @description Filter keys by key alias. Exact match by default; set substring_matching=true (admin only) for case-insensitive substring matching. */
|
||||
/** @description Filter keys by key alias. Exact match by default; set substring_matching=true for case-insensitive substring matching. */
|
||||
key_alias?: string | null;
|
||||
/** @description Combined search: matches keys whose token (key hash) equals the value OR whose key_alias contains it (case-insensitive). */
|
||||
search?: string | null;
|
||||
|
|
@ -50474,7 +50474,7 @@ export interface operations {
|
|||
access_group_id?: string | null;
|
||||
/** @description Filter keys by agent ID */
|
||||
agent_id?: string | null;
|
||||
/** @description If true (proxy admins only), match user_id/key_alias as case-insensitive substrings instead of exact values. Defaults to false: /key/list matched these exactly before substring search was added, and an exact user_id/key_alias filter must never return another user's keys. */
|
||||
/** @description If true, match key_alias (any caller) and user_id (proxy admins only) as case-insensitive substrings instead of exact values. Defaults to false: /key/list matched these exactly before substring search was added, and an exact user_id filter must never return another user's keys. */
|
||||
substring_matching?: boolean;
|
||||
/** @description Filter keys by expiration. 'expired' returns keys whose expires is in the past; 'active' returns keys that never expire or expire in the future. Omit to return keys regardless of expiration. */
|
||||
expires?: string | null;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue