mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
fix(proxy): allow key_alias substring matching on /key/list for non-admins
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
parent
2f46425732
commit
20787ba186
2 changed files with 78 additions and 13 deletions
|
|
@ -5960,7 +5960,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,
|
||||
|
|
@ -5981,7 +5981,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,
|
||||
|
|
@ -6075,13 +6075,16 @@ 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.
|
||||
# user_id substring stays admin-only: non-admins are scoped to their own
|
||||
# user_id below. key_alias is a global AND filter, so it only narrows.
|
||||
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:
|
||||
|
|
@ -6108,6 +6111,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,
|
||||
)
|
||||
|
|
@ -6353,6 +6357,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]:
|
||||
|
|
@ -6448,7 +6453,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)
|
||||
|
|
@ -6494,6 +6499,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:
|
||||
|
|
@ -6533,6 +6539,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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -6413,7 +6413,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}"
|
||||
|
|
@ -9358,7 +9358,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,
|
||||
|
|
@ -9378,12 +9378,41 @@ 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"}
|
||||
assert {"key_alias": {"contains": key_alias, "mode": "insensitive"}} in where["AND"]
|
||||
|
||||
|
||||
def test_build_key_filter_key_alias_substring_keeps_user_id_exact():
|
||||
"""A non-admin searching a team's keys by partial alias gets a substring
|
||||
key_alias filter while their own-user scoping stays exact, so alias search
|
||||
can never widen visibility to another user (user_id="alice" -> "alice2")."""
|
||||
from litellm.proxy.management_endpoints.key_management_endpoints import (
|
||||
_build_key_filter_conditions,
|
||||
)
|
||||
|
||||
where = _build_key_filter_conditions(
|
||||
user_id="alice",
|
||||
team_id="team-a",
|
||||
organization_id=None,
|
||||
key_alias="first",
|
||||
key_hash=None,
|
||||
exclude_team_id=None,
|
||||
admin_team_ids=None,
|
||||
member_team_ids=["team-a"],
|
||||
include_created_by_keys=False,
|
||||
use_substring_matching=False,
|
||||
use_key_alias_substring_matching=True,
|
||||
)
|
||||
|
||||
assert {"key_alias": {"contains": "first", "mode": "insensitive"}} in where["AND"]
|
||||
assert {"key_alias": "first"} not in where["AND"]
|
||||
assert json.dumps({"user_id": "alice"}) in json.dumps(where)
|
||||
assert '"contains": "alice"' not in json.dumps(where)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_build_key_filter_non_admin_exact_matching():
|
||||
"""
|
||||
|
|
@ -15149,8 +15178,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
|
||||
|
|
@ -15159,6 +15188,35 @@ async def test_list_keys_non_admin_cannot_opt_into_substring():
|
|||
assert kwargs["user_id"] == "alice"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"user_role",
|
||||
[LitellmUserRoles.INTERNAL_USER, LitellmUserRoles.INTERNAL_USER_VIEW_ONLY, LitellmUserRoles.TEAM],
|
||||
)
|
||||
async def test_list_keys_non_admin_key_alias_substring_is_honored(user_role):
|
||||
"""Team admins and internal users searching a team's keys by a partial alias
|
||||
(key_alias=first for app_llmhub_first.last) must get substring matching on
|
||||
key_alias, while user_id substring matching stays admin-only."""
|
||||
user = UserAPIKeyAuth(user_role=user_role, user_id="alice")
|
||||
kwargs = await _list_keys_capture_helper_kwargs(
|
||||
user, user_id=None, key_alias="first", team_id="team-a", substring_matching=True
|
||||
)
|
||||
assert kwargs["use_key_alias_substring_matching"] is True
|
||||
assert kwargs["use_substring_matching"] is False
|
||||
assert kwargs["key_alias"] == "first"
|
||||
assert kwargs["user_id"] == "alice"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_keys_key_alias_substring_defaults_off():
|
||||
"""Without substring_matching, key_alias stays an exact filter for every role."""
|
||||
user = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id="alice")
|
||||
kwargs = await _list_keys_capture_helper_kwargs(
|
||||
user, user_id=None, key_alias="first", substring_matching=False
|
||||
)
|
||||
assert kwargs["use_key_alias_substring_matching"] is False
|
||||
|
||||
|
||||
@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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue