From 20787ba186473d574d667467517b6bd333c0458f Mon Sep 17 00:00:00 2001 From: ryan Date: Sat, 12 Sep 2026 21:52:49 +0000 Subject: [PATCH 1/4] 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> --- .../key_management_endpoints.py | 25 ++++--- .../test_key_management_endpoints.py | 66 +++++++++++++++++-- 2 files changed, 78 insertions(+), 13 deletions(-) diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index 749a940de0e..e935a5d9093 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -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, ) 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 65cc23ea67f..71a3b169798 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 @@ -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 From a3ebeae28b5cd13ec9fbeda031285a4d882cb556 Mon Sep 17 00:00:00 2001 From: ryan Date: Sat, 12 Sep 2026 22:05:20 +0000 Subject: [PATCH 2/4] chore(ui): regenerate schema.d.ts for /key/list substring_matching descriptions Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- ui/litellm-dashboard/src/lib/http/schema.d.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 6826cded6f5..c99d0ad695e 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -50297,7 +50297,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; @@ -50321,7 +50321,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; From df6ec79810f7b45edf8ac416bf6784463e18da75 Mon Sep 17 00:00:00 2001 From: ryan Date: Sat, 12 Sep 2026 22:15:11 +0000 Subject: [PATCH 3/4] test(proxy): assert /key/list alias substring results end to end for non-admins Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../key_management_endpoints.py | 2 - .../test_key_management_endpoints.py | 140 ++++++++++++------ 2 files changed, 92 insertions(+), 50 deletions(-) diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index e935a5d9093..24dd26df65d 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -6081,8 +6081,6 @@ async def list_keys( # 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 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 71a3b169798..bb1f5108d70 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 @@ -18,6 +18,7 @@ from litellm.proxy._types import ( LiteLLM_BudgetTable, LiteLLM_OrganizationTable, LiteLLM_ProjectTableCachedObj, + LiteLLM_TeamTable, LiteLLM_TeamTableCachedObj, LiteLLM_UserTable, LiteLLM_VerificationToken, @@ -9385,34 +9386,6 @@ async def test_build_key_filter_admin_substring_matching(): 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(): """ @@ -15188,33 +15161,104 @@ async def test_list_keys_non_admin_cannot_opt_into_substring(): assert kwargs["user_id"] == "alice" -@pytest.mark.asyncio +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("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], ) -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" +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"] -@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 +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 kwargs["use_key_alias_substring_matching"] is False + 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 From 02dbff485b68e9ff65eac7aaf9b58892451bd998 Mon Sep 17 00:00:00 2001 From: ryan Date: Sat, 12 Sep 2026 22:32:14 +0000 Subject: [PATCH 4/4] test(proxy): mark prisma_client patch in /key/list alias test with test-quality reason Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../management_endpoints/test_key_management_endpoints.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 bb1f5108d70..45126db648c 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 @@ -15226,7 +15226,9 @@ def _list_team_a_keys_as(user_role, members_with_roles, query): 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("litellm.proxy.proxy_server.prisma_client", mock_prisma_client): + 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}" )