From fc278bbf9f6dd752a5b444e3a7765e175dd27e76 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 24 Jul 2026 17:39:29 -0700 Subject: [PATCH] refactor(logs): match ui_view_spend_logs' team-scope clause shape The scope predicate expanded permitted team ids into an IN list with one placeholder each, copied from /key/aliases. ui_view_spend_logs, which owns the same page and the same scoping rules, builds ("user" = $X OR team_id = ANY($Y::text[])) instead: a single array parameter whatever the team count, and no placeholder arithmetic to keep in step with the rest of the query. Same semantics, but the two clauses now read identically, so a future change to how spend logs are scoped is harder to apply to one and miss in the other. --- .../proxy/management_endpoints/customer_endpoints.py | 7 ++++--- .../management_endpoints/test_customer_endpoints.py | 12 +++++++----- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/litellm/proxy/management_endpoints/customer_endpoints.py b/litellm/proxy/management_endpoints/customer_endpoints.py index cc6bacbd21e..f32b0d0c3fc 100644 --- a/litellm/proxy/management_endpoints/customer_endpoints.py +++ b/litellm/proxy/management_endpoints/customer_endpoints.py @@ -833,9 +833,10 @@ async def _build_end_user_scope_condition( team_clause: tuple[str, ...] = () if permitted_team_ids: - placeholders = ", ".join(f"${len(query_params) + i + 1}" for i in range(len(permitted_team_ids))) - query_params.extend(permitted_team_ids) - team_clause = (f"team_id IN ({placeholders})",) + # = ANY(::text[]) rather than an expanded IN list, matching the clause + # ui_view_spend_logs builds: one parameter whatever the team count. + query_params.append(permitted_team_ids) + team_clause = (f"team_id = ANY(${len(query_params)}::text[])",) scope_parts = user_clause + team_clause if not scope_parts: diff --git a/tests/test_litellm/proxy/management_endpoints/test_customer_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_customer_endpoints.py index db91984f63d..b3768419a37 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_customer_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_customer_endpoints.py @@ -885,7 +885,7 @@ def test_customer_aliases_applies_no_scope_for_a_proxy_admin(mock_prisma_client, sql = query_raw.call_args.args[0] assert '"user" =' not in sql - assert "team_id IN" not in sql + assert "team_id" not in sql @pytest.mark.parametrize("role", [LitellmUserRoles.INTERNAL_USER, LitellmUserRoles.INTERNAL_USER_VIEW_ONLY]) @@ -904,8 +904,10 @@ def test_customer_aliases_scopes_a_team_admin_to_their_own_rows_and_teams(mock_p assert response.status_code == 200 sql = query_raw.call_args.args[0] - assert '("user" = $3 OR team_id IN ($4, $5))' in sql - assert query_raw.call_args.args[3:6] == ("team-admin-1", "team-a", "team-b") + # Same clause shape ui_view_spend_logs builds, so the two cannot diverge. + assert '("user" = $3 OR team_id = ANY($4::text[]))' in sql + assert query_raw.call_args.args[3] == "team-admin-1" + assert query_raw.call_args.args[4] == ["team-a", "team-b"] def test_customer_aliases_scopes_a_teamless_user_to_their_own_rows(mock_prisma_client): @@ -923,7 +925,7 @@ def test_customer_aliases_scopes_a_teamless_user_to_their_own_rows(mock_prisma_c assert response.status_code == 200 sql = query_raw.call_args.args[0] assert '("user" = $3)' in sql - assert "team_id IN" not in sql + assert "team_id" not in sql assert query_raw.call_args.args[3] == "solo" @@ -960,7 +962,7 @@ def test_customer_aliases_scopes_when_permitted_team_lookup_fails(mock_prisma_cl assert response.status_code == 200 sql = query_raw.call_args.args[0] assert '("user" = $3)' in sql - assert "team_id IN" not in sql + assert "team_id" not in sql def test_customer_aliases_fetches_one_extra_row_and_trims_it(mock_prisma_client, mock_user_api_key_auth):