From 44b95bbfcbc72d23bd67dbb71b23112bd1295583 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 24 Jul 2026 16:21:46 -0700 Subject: [PATCH] fix(logs): scope the End User filter to the caller's teams and bound its scan The End User filter listed every row of LiteLLM_EndUserTable, which is both unscoped and the wrong source. Team admins and internal users can open the Logs page, and their log view is already restricted to their own requests plus the teams they administer, but the filter dropdown offered them every end user on the proxy. Team attribution only exists on spend logs, so /customer/aliases now reads LiteLLM_SpendLogs and applies the same scoping /spend/logs/ui does: a proxy admin sees the whole window, everyone else sees ("user" = caller OR team_id IN permitted_teams), reusing _get_permitted_team_ids_for_spend_logs so the two paths cannot drift. A caller with neither matches FALSE rather than falling through to unscoped, and a failed team lookup degrades to own-rows-only. Querying spend logs safely is the other half. start_date/end_date are now required, so the query always has the indexed startTime bound, and the inner scan is capped at MAX_SPENDLOG_ROWS_TO_SCAN_FOR_FILTERS rows ordered by startTime DESC. DISTINCT therefore runs over a bounded row set instead of the whole table the way /global/all_end_users does. Also adds /customer/aliases to spend_tracking_routes. Without it RouteChecks rejects INTERNAL_USER and INTERNAL_USER_VIEW_ONLY before the handler runs, which would have made the scoping above dead code; a test pins the route to the same access tier as /spend/logs/ui. The dropdown now shows the end users present in the window the table is showing, so the filter list matches what it filters. formatLogsWindow is shared with the logs query so the two windows cannot diverge. --- litellm/constants.py | 3 + litellm/proxy/_types.py | 4 + .../customer_endpoints.py | 135 ++++++-- .../test_customer_endpoints.py | 302 ++++++++++++------ .../hooks/customers/useEndUserAliases.ts | 14 +- .../view_logs/RequestLogsFilters.test.tsx | 19 +- .../view_logs/RequestLogsFilters.tsx | 24 +- .../components/view_logs/RequestLogsPanel.tsx | 8 +- .../components/view_logs/RequestLogsTable.tsx | 6 +- .../components/view_logs/log_filter_logic.tsx | 21 +- ui/litellm-dashboard/src/lib/http/schema.d.ts | 22 +- 11 files changed, 397 insertions(+), 161 deletions(-) diff --git a/litellm/constants.py b/litellm/constants.py index b9b9c0ba604..f0a7690096a 100644 --- a/litellm/constants.py +++ b/litellm/constants.py @@ -1316,6 +1316,9 @@ STANDARD_CUSTOMER_ID_HEADERS = [ MAX_SPENDLOG_ROWS_TO_QUERY = int( os.getenv("MAX_SPENDLOG_ROWS_TO_QUERY", 1_000_000) ) # if spendLogs has more than 1M rows, do not query the DB +MAX_SPENDLOG_ROWS_TO_SCAN_FOR_FILTERS = int( + os.getenv("MAX_SPENDLOG_ROWS_TO_SCAN_FOR_FILTERS", 10_000) +) # hard cap on rows a UI filter dropdown may scan out of LiteLLM_SpendLogs DEFAULT_SOFT_BUDGET = float( os.getenv("DEFAULT_SOFT_BUDGET", 50.0) ) # by default all litellm proxy keys have a soft budget of 50.0 diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index ef160a68656..7575091be54 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -629,6 +629,10 @@ class LiteLLMRoutes(enum.Enum): "/spend/logs/v2", "/spend/logs/ui", "/spend/logs/session/ui", + # Reads end users out of spend logs, scoped to the caller's own rows and + # permitted teams exactly like /spend/logs/ui — it belongs to the same + # access tier, not to customer management. + "/customer/aliases", "/cost/estimate", ] diff --git a/litellm/proxy/management_endpoints/customer_endpoints.py b/litellm/proxy/management_endpoints/customer_endpoints.py index 0d1ea994ae9..8c0e67e7d0a 100644 --- a/litellm/proxy/management_endpoints/customer_endpoints.py +++ b/litellm/proxy/management_endpoints/customer_endpoints.py @@ -10,7 +10,8 @@ All /customer management endpoints """ #### END-USER/CUSTOMER MANAGEMENT #### -from datetime import datetime, timedelta +from collections.abc import MutableSequence +from datetime import datetime, timedelta, timezone from typing import Annotated, Any, List, Optional import fastapi @@ -19,6 +20,7 @@ from pydantic import BaseModel import litellm from litellm._logging import verbose_proxy_logger +from litellm.constants import MAX_SPENDLOG_ROWS_TO_SCAN_FOR_FILTERS from litellm.litellm_core_utils.duration_parser import duration_in_seconds from litellm.proxy._types import * from litellm.proxy.auth.user_api_key_auth import user_api_key_auth @@ -27,7 +29,7 @@ from litellm.proxy.management_helpers.object_permission_utils import ( _set_object_permission, handle_update_object_permission_common, ) -from litellm.proxy.utils import handle_exception_on_proxy +from litellm.proxy.utils import PrismaClient, handle_exception_on_proxy from litellm.repositories.budget_repository import BudgetRepository from litellm.repositories.table_repositories import EndUserRepository from litellm.types.proxy.management_endpoints.common_daily_activity import ( @@ -786,17 +788,61 @@ async def list_end_user( raise handle_exception_on_proxy(e) -def _require_customer_read_access(user_api_key_dict: UserAPIKeyAuth) -> None: - if user_api_key_dict.user_role not in ( - LitellmUserRoles.PROXY_ADMIN, - LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, - ): +def _parse_spend_log_window_bound(value: str, param: str) -> datetime: + try: + return datetime.strptime(value.strip(), "%Y-%m-%d %H:%M:%S").replace(tzinfo=timezone.utc) + except ValueError: raise HTTPException( - status_code=401, - detail={"error": "Admin-only endpoint. Your user role={}".format(user_api_key_dict.user_role)}, + status_code=400, + detail={"error": f"Invalid {param}: {value}. Expected 'YYYY-MM-DD HH:MM:SS'"}, ) +async def _build_end_user_scope_condition( + user_api_key_dict: UserAPIKeyAuth, + prisma_client: PrismaClient, + query_params: MutableSequence[Any], +) -> str | None: + """SQL predicate restricting end users to the logs this caller may read. + + Returns None when the caller is a proxy admin (no restriction). Mirrors the + scoping ``/spend/logs/ui`` applies, so the dropdown can never offer an + end user whose rows the caller could not open. + """ + from litellm.proxy.spend_tracking.spend_management_endpoints import ( + _get_permitted_team_ids_for_spend_logs, + _is_admin_view_safe, + ) + + if _is_admin_view_safe(user_api_key_dict=user_api_key_dict): + return None + + try: + permitted_team_ids = await _get_permitted_team_ids_for_spend_logs( + prisma_client=prisma_client, + user_api_key_dict=user_api_key_dict, + ) + except Exception: + permitted_team_ids = [] + + caller_user_id = user_api_key_dict.user_id + user_clause: tuple[str, ...] = () + if caller_user_id is not None: + query_params.append(caller_user_id) + user_clause = (f'"user" = ${len(query_params)}',) + + 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})",) + + scope_parts = user_clause + team_clause + if not scope_parts: + return "FALSE" + return f"({' OR '.join(scope_parts)})" + + @router.get( "/customer/aliases", tags=["Customer Management"], @@ -805,6 +851,8 @@ def _require_customer_read_access(user_api_key_dict: UserAPIKeyAuth) -> None: ) async def list_customer_aliases( user_api_key_dict: Annotated[UserAPIKeyAuth, Depends(user_api_key_auth)], + start_date: Annotated[str, Query(description="Window start, 'YYYY-MM-DD HH:MM:SS' (UTC)")], + end_date: Annotated[str, Query(description="Window end, 'YYYY-MM-DD HH:MM:SS' (UTC)")], page: Annotated[int, Query(ge=1, description="Page number")] = 1, size: Annotated[int, Query(ge=1, le=100, description="Page size")] = 50, search: Annotated[ @@ -813,52 +861,75 @@ async def list_customer_aliases( ] = None, ) -> CustomerAliasesResponse: """ - [Admin-only] List customer ids with pagination and optional search. + List the end users seen in spend logs over a time window, for UI filter dropdowns. - Lightweight counterpart to `/customer/list`, for UI filter dropdowns. - `/customer/list` returns every customer with its budget and object-permission - relations eagerly loaded, which is unusable once LiteLLM_EndUserTable grows - (end-user rows are created automatically per distinct `user` seen in traffic). + Scoped like `/spend/logs/ui`: a proxy admin sees every end user in the window, + anyone else sees only end users from their own requests or from teams they + administer (or hold the `/spend/logs` permission on). + + Reads spend logs rather than LiteLLM_EndUserTable because only spend logs carry + the team attribution this scoping needs. The window is required and the inner + scan is capped at MAX_SPENDLOG_ROWS_TO_SCAN_FOR_FILTERS rows, so the query + cannot degrade into a full-table scan the way `/global/all_end_users` does. Example curl: ``` - curl --location 'http://0.0.0.0:4000/customer/aliases?page=1&size=50&search=acme' \ + curl --location 'http://0.0.0.0:4000/customer/aliases?start_date=2026-07-23%2000:00:00&end_date=2026-07-24%2000:00:00&size=50&search=acme' \ --header 'Authorization: Bearer sk-1234' ``` """ try: from litellm.proxy.proxy_server import prisma_client - _require_customer_read_access(user_api_key_dict) - if prisma_client is None: raise HTTPException( status_code=400, detail={"error": CommonProxyErrors.db_not_connected_error.value}, ) - where_parts = ["user_id IS NOT NULL", "user_id != ''"] - query_params: List[Any] = [] + start_dt = _parse_spend_log_window_bound(start_date, "start_date") + end_dt = _parse_spend_log_window_bound(end_date, "end_date") + + query_params: List[Any] = [start_dt, end_dt] + where_parts = [ + "\"startTime\" >= ($1::timestamptz AT TIME ZONE 'UTC')", + "\"startTime\" <= ($2::timestamptz AT TIME ZONE 'UTC')", + "end_user IS NOT NULL", + "end_user != ''", + ] if search: # Escape LIKE metacharacters so a literal '_' or '%' matches itself. escaped = search.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") query_params.append(f"%{escaped}%") - where_parts.append(f"user_id ILIKE ${len(query_params)} ESCAPE '\\'") + where_parts.append(f"end_user ILIKE ${len(query_params)} ESCAPE '\\'") - where_sql = " AND ".join(where_parts) - - # size + 1: one row beyond the page reveals has_more without a COUNT(*). - limit_params = query_params + [size + 1, (page - 1) * size] - aliases_sql = ( - f"SELECT user_id" - f' FROM "LiteLLM_EndUserTable"' - f" WHERE {where_sql}" - f" ORDER BY user_id ASC" - f" LIMIT ${len(limit_params) - 1} OFFSET ${len(limit_params)}" + scope_condition = await _build_end_user_scope_condition( + user_api_key_dict=user_api_key_dict, + prisma_client=prisma_client, + query_params=query_params, ) - rows = await prisma_client.db.query_raw(aliases_sql, *limit_params) - aliases: List[str] = [row["user_id"] for row in rows if row.get("user_id")] + if scope_condition is not None: + where_parts.append(scope_condition) + + # The inner LIMIT is the safety bound: it walks the startTime index newest + # first and stops, so DISTINCT never runs over an unbounded row set. + # size + 1: one row beyond the page reveals has_more without a COUNT(*). + params = query_params + [MAX_SPENDLOG_ROWS_TO_SCAN_FOR_FILTERS, size + 1, (page - 1) * size] + scan_idx = len(params) - 2 + aliases_sql = ( + f"SELECT DISTINCT end_user FROM (" + f" SELECT end_user" + f' FROM "LiteLLM_SpendLogs"' + f" WHERE {' AND '.join(where_parts)}" + f' ORDER BY "startTime" DESC' + f" LIMIT ${scan_idx}" + f") recent" + f" ORDER BY end_user ASC" + f" LIMIT ${scan_idx + 1} OFFSET ${scan_idx + 2}" + ) + rows = await prisma_client.db.query_raw(aliases_sql, *params) + aliases: List[str] = [row["end_user"] for row in rows if row.get("end_user")] return CustomerAliasesResponse( aliases=aliases[:size], 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 891b3454af8..5954d07bbb8 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_customer_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_customer_endpoints.py @@ -1,3 +1,4 @@ +from datetime import datetime, timezone from typing import List from unittest.mock import AsyncMock, MagicMock, patch @@ -9,6 +10,7 @@ from fastapi.testclient import TestClient from litellm.proxy._types import ( LiteLLM_EndUserTable, + LiteLLMRoutes, LitellmUserRoles, ProxyException, ) @@ -784,67 +786,188 @@ def test_char_delete_body(mock_prisma_client, mock_user_api_key_auth): } -def _mock_alias_rows(mock_prisma_client, user_ids: List[str]) -> AsyncMock: - query_raw = AsyncMock(return_value=[{"user_id": uid} for uid in user_ids]) +WINDOW = "start_date=2026-07-23+00%3A00%3A00&end_date=2026-07-24+00%3A00%3A00" + + +def _mock_alias_rows(mock_prisma_client, end_users: List[str]) -> AsyncMock: + query_raw = AsyncMock(return_value=[{"end_user": eu} for eu in end_users]) mock_prisma_client.db.query_raw = query_raw return query_raw -def test_customer_aliases_projects_only_user_id_and_never_loads_relations( - mock_prisma_client, mock_user_api_key_auth -): - """The whole point of this endpoint: no full rows, no eager relations. +def _as_role(role: LitellmUserRoles, user_id: str = "u1"): + """Override auth for one request; returns a context-manager-free setter/teardown pair.""" + original = app.dependency_overrides.copy() + app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(user_id=user_id, user_role=role) + return original - /customer/list does find_many(include={budget, object_permission}) over the - entire table; this must stay a single-column, bounded query. - """ + +def test_customer_aliases_reads_spend_logs_not_the_end_user_table(mock_prisma_client, mock_user_api_key_auth): + """Team scoping only exists in spend logs, so that is the source of truth.""" query_raw = _mock_alias_rows(mock_prisma_client, ["a", "b"]) - response = client.get("/customer/aliases", headers={"Authorization": "Bearer k"}) + response = client.get(f"/customer/aliases?{WINDOW}", headers={"Authorization": "Bearer k"}) assert response.status_code == 200 - assert response.json() == { - "aliases": ["a", "b"], - "current_page": 1, - "size": 50, - "has_more": False, - } - mock_prisma_client.db.litellm_endusertable.find_many.assert_not_called() + assert response.json() == {"aliases": ["a", "b"], "current_page": 1, "size": 50, "has_more": False} sql = query_raw.call_args.args[0] - assert "SELECT user_id" in sql - assert '"LiteLLM_EndUserTable"' in sql - assert "JOIN" not in sql.upper() - assert "COUNT(" not in sql.upper() + assert '"LiteLLM_SpendLogs"' in sql + assert "LiteLLM_EndUserTable" not in sql + mock_prisma_client.db.litellm_endusertable.find_many.assert_not_called() + + +def test_customer_aliases_caps_the_rows_it_scans(mock_prisma_client, mock_user_api_key_auth): + """The inner LIMIT is the crash guard: DISTINCT must never see an unbounded set.""" + from litellm.constants import MAX_SPENDLOG_ROWS_TO_SCAN_FOR_FILTERS + + query_raw = _mock_alias_rows(mock_prisma_client, []) + + client.get(f"/customer/aliases?{WINDOW}", headers={"Authorization": "Bearer k"}) + + sql = query_raw.call_args.args[0] + inner = sql[sql.index("FROM (") : sql.index(") recent")] + assert "LIMIT $3" in inner + assert query_raw.call_args.args[3] == MAX_SPENDLOG_ROWS_TO_SCAN_FOR_FILTERS + assert 'ORDER BY "startTime" DESC' in inner + + +def test_customer_aliases_requires_a_time_window(mock_prisma_client, mock_user_api_key_auth): + """No window means no index bound, which is the unbounded scan we must not allow.""" + _mock_alias_rows(mock_prisma_client, []) + + assert client.get("/customer/aliases", headers={"Authorization": "Bearer k"}).status_code == 422 + assert ( + client.get( + "/customer/aliases?start_date=2026-07-23+00%3A00%3A00", headers={"Authorization": "Bearer k"} + ).status_code + == 422 + ) + + +def test_customer_aliases_bounds_the_window_on_the_indexed_start_time(mock_prisma_client, mock_user_api_key_auth): + query_raw = _mock_alias_rows(mock_prisma_client, []) + + client.get(f"/customer/aliases?{WINDOW}", headers={"Authorization": "Bearer k"}) + + sql = query_raw.call_args.args[0] + assert "\"startTime\" >= ($1::timestamptz AT TIME ZONE 'UTC')" in sql + assert "\"startTime\" <= ($2::timestamptz AT TIME ZONE 'UTC')" in sql + assert query_raw.call_args.args[1] == datetime(2026, 7, 23, tzinfo=timezone.utc) + assert query_raw.call_args.args[2] == datetime(2026, 7, 24, tzinfo=timezone.utc) + + +def test_customer_aliases_rejects_a_malformed_window(mock_prisma_client, mock_user_api_key_auth): + _mock_alias_rows(mock_prisma_client, []) + + response = client.get( + f"/customer/aliases?start_date=yesterday&end_date=2026-07-24+00%3A00%3A00", + headers={"Authorization": "Bearer k"}, + ) + + assert response.status_code == 400 + + +def test_customer_aliases_applies_no_scope_for_a_proxy_admin(mock_prisma_client, mock_user_api_key_auth): + query_raw = _mock_alias_rows(mock_prisma_client, []) + + client.get(f"/customer/aliases?{WINDOW}", headers={"Authorization": "Bearer k"}) + + sql = query_raw.call_args.args[0] + assert '"user" =' not in sql + assert "team_id IN" not in sql + + +@pytest.mark.parametrize("role", [LitellmUserRoles.INTERNAL_USER, LitellmUserRoles.INTERNAL_USER_VIEW_ONLY]) +def test_customer_aliases_scopes_a_team_admin_to_their_own_rows_and_teams(mock_prisma_client, role): + """A team admin must not see end users belonging to teams they cannot read.""" + query_raw = _mock_alias_rows(mock_prisma_client, ["cust-a"]) + original = _as_role(role, user_id="team-admin-1") + try: + with patch( + "litellm.proxy.spend_tracking.spend_management_endpoints._get_permitted_team_ids_for_spend_logs", + new=AsyncMock(return_value=["team-a", "team-b"]), + ): + response = client.get(f"/customer/aliases?{WINDOW}", headers={"Authorization": "Bearer k"}) + finally: + app.dependency_overrides = original + + 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") + + +def test_customer_aliases_scopes_a_teamless_user_to_their_own_rows(mock_prisma_client): + query_raw = _mock_alias_rows(mock_prisma_client, []) + original = _as_role(LitellmUserRoles.INTERNAL_USER, user_id="solo") + try: + with patch( + "litellm.proxy.spend_tracking.spend_management_endpoints._get_permitted_team_ids_for_spend_logs", + new=AsyncMock(return_value=[]), + ): + response = client.get(f"/customer/aliases?{WINDOW}", headers={"Authorization": "Bearer k"}) + finally: + app.dependency_overrides = original + + 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 query_raw.call_args.args[3] == "solo" + + +def test_customer_aliases_returns_nothing_when_the_caller_owns_no_scope(mock_prisma_client): + """Unidentifiable caller must match no rows, never fall through to unscoped.""" + query_raw = _mock_alias_rows(mock_prisma_client, []) + original = _as_role(LitellmUserRoles.INTERNAL_USER, user_id=None) + try: + with patch( + "litellm.proxy.spend_tracking.spend_management_endpoints._get_permitted_team_ids_for_spend_logs", + new=AsyncMock(return_value=[]), + ): + response = client.get(f"/customer/aliases?{WINDOW}", headers={"Authorization": "Bearer k"}) + finally: + app.dependency_overrides = original + + assert response.status_code == 200 + assert "FALSE" in query_raw.call_args.args[0] + + +def test_customer_aliases_scopes_when_permitted_team_lookup_fails(mock_prisma_client): + """A failed team lookup must degrade to own-rows-only, never to unscoped.""" + query_raw = _mock_alias_rows(mock_prisma_client, []) + original = _as_role(LitellmUserRoles.INTERNAL_USER, user_id="solo") + try: + with patch( + "litellm.proxy.spend_tracking.spend_management_endpoints._get_permitted_team_ids_for_spend_logs", + new=AsyncMock(side_effect=RuntimeError("db down")), + ): + response = client.get(f"/customer/aliases?{WINDOW}", headers={"Authorization": "Bearer k"}) + finally: + app.dependency_overrides = original + + assert response.status_code == 200 + sql = query_raw.call_args.args[0] + assert '("user" = $3)' in sql + assert "team_id IN" not in sql def test_customer_aliases_fetches_one_extra_row_and_trims_it(mock_prisma_client, mock_user_api_key_auth): - """has_more is derived from a size+1 fetch; the sentinel row must not leak.""" query_raw = _mock_alias_rows(mock_prisma_client, [f"u{i}" for i in range(4)]) - response = client.get("/customer/aliases?size=3", headers={"Authorization": "Bearer k"}) + response = client.get(f"/customer/aliases?{WINDOW}&size=3", headers={"Authorization": "Bearer k"}) assert response.status_code == 200 - body = response.json() - assert body["aliases"] == ["u0", "u1", "u2"] - assert body["has_more"] is True - assert query_raw.call_args.args[1:] == (4, 0) - - -def test_customer_aliases_reports_no_more_pages_on_a_short_page(mock_prisma_client, mock_user_api_key_auth): - _mock_alias_rows(mock_prisma_client, ["u0", "u1"]) - - response = client.get("/customer/aliases?size=3", headers={"Authorization": "Bearer k"}) - - assert response.status_code == 200 - assert response.json()["has_more"] is False + assert response.json()["aliases"] == ["u0", "u1", "u2"] + assert response.json()["has_more"] is True + assert query_raw.call_args.args[4:] == (4, 0) def test_customer_aliases_reports_no_more_pages_on_an_exactly_full_page(mock_prisma_client, mock_user_api_key_auth): _mock_alias_rows(mock_prisma_client, ["u0", "u1", "u2"]) - response = client.get("/customer/aliases?size=3", headers={"Authorization": "Bearer k"}) + response = client.get(f"/customer/aliases?{WINDOW}&size=3", headers={"Authorization": "Bearer k"}) - assert response.status_code == 200 assert response.json()["aliases"] == ["u0", "u1", "u2"] assert response.json()["has_more"] is False @@ -852,90 +975,71 @@ def test_customer_aliases_reports_no_more_pages_on_an_exactly_full_page(mock_pri def test_customer_aliases_offsets_by_page(mock_prisma_client, mock_user_api_key_auth): query_raw = _mock_alias_rows(mock_prisma_client, []) - response = client.get("/customer/aliases?page=3&size=25", headers={"Authorization": "Bearer k"}) + response = client.get(f"/customer/aliases?{WINDOW}&page=3&size=25", headers={"Authorization": "Bearer k"}) - assert response.status_code == 200 assert response.json()["current_page"] == 3 - assert query_raw.call_args.args[1:] == (26, 50) - - -def test_customer_aliases_without_search_issues_no_like_filter(mock_prisma_client, mock_user_api_key_auth): - query_raw = _mock_alias_rows(mock_prisma_client, []) - - client.get("/customer/aliases", headers={"Authorization": "Bearer k"}) - - sql = query_raw.call_args.args[0] - assert "ILIKE" not in sql.upper() - assert query_raw.call_args.args[1:] == (51, 0) + assert query_raw.call_args.args[4:] == (26, 50) def test_customer_aliases_search_escapes_like_metacharacters(mock_prisma_client, mock_user_api_key_auth): - """End-user ids routinely contain '_'; an unescaped one is a wildcard. - - Without ESCAPE, searching 'device_id' also matches 'deviceXid'. - """ + """End-user ids routinely contain '_'; unescaped it is a wildcard.""" query_raw = _mock_alias_rows(mock_prisma_client, []) - client.get("/customer/aliases?search=device_id%25", headers={"Authorization": "Bearer k"}) + client.get(f"/customer/aliases?{WINDOW}&search=device_id%25", headers={"Authorization": "Bearer k"}) - sql = query_raw.call_args.args[0] - assert "ILIKE $1 ESCAPE" in sql - assert query_raw.call_args.args[1] == r"%device\_id\%%" - assert query_raw.call_args.args[2:] == (51, 0) + assert "end_user ILIKE $3 ESCAPE" in query_raw.call_args.args[0] + assert query_raw.call_args.args[3] == r"%device\_id\%%" -def test_customer_aliases_search_placeholder_precedes_limit_and_offset(mock_prisma_client, mock_user_api_key_auth): +def test_customer_aliases_search_placeholder_precedes_scan_limit_and_offset(mock_prisma_client, mock_user_api_key_auth): query_raw = _mock_alias_rows(mock_prisma_client, []) - client.get("/customer/aliases?search=acme&size=10", headers={"Authorization": "Bearer k"}) + client.get(f"/customer/aliases?{WINDOW}&search=acme&size=10", headers={"Authorization": "Bearer k"}) sql = query_raw.call_args.args[0] - assert "LIMIT $2 OFFSET $3" in sql - assert query_raw.call_args.args[1:] == ("%acme%", 11, 0) + assert "LIMIT $4" in sql + assert "LIMIT $5 OFFSET $6" in sql + assert query_raw.call_args.args[3] == "%acme%" + assert query_raw.call_args.args[5:] == (11, 0) + + +def test_customer_aliases_caps_page_size(mock_prisma_client, mock_user_api_key_auth): + _mock_alias_rows(mock_prisma_client, []) + + response = client.get(f"/customer/aliases?{WINDOW}&size=100000", headers={"Authorization": "Bearer k"}) + + assert response.status_code == 422 @pytest.mark.parametrize( "role", [ + LitellmUserRoles.PROXY_ADMIN, + LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, LitellmUserRoles.INTERNAL_USER, LitellmUserRoles.INTERNAL_USER_VIEW_ONLY, - LitellmUserRoles.TEAM, - LitellmUserRoles.CUSTOMER, ], ) -def test_customer_aliases_rejects_non_admin_roles(mock_prisma_client, role): - """Mirrors /customer/list: this exposes every customer id on the proxy.""" - _mock_alias_rows(mock_prisma_client, ["secret-customer"]) - original_overrides = app.dependency_overrides.copy() - app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth(user_id="u", user_role=role) - try: - response = client.get("/customer/aliases", headers={"Authorization": "Bearer k"}) - finally: - app.dependency_overrides = original_overrides +def test_customer_aliases_is_reachable_by_every_role_that_can_open_the_logs_page(role): + """Route-level auth gate, which the dependency_overrides in the other tests bypass. - assert response.status_code == 401 - assert "secret-customer" not in response.text + Handler-side team scoping is dead code if RouteChecks rejects the role first, + so pin that /customer/aliases travels in the same access tier as /spend/logs/ui. + """ + from litellm.proxy.auth.route_checks import RouteChecks + for allowed in ( + LiteLLMRoutes.internal_user_routes.value, + LiteLLMRoutes.internal_user_view_only_routes.value, + ): + assert ("/spend/logs/ui" in allowed) == ("/customer/aliases" in allowed) -def test_customer_aliases_allows_admin_viewer(mock_prisma_client): - _mock_alias_rows(mock_prisma_client, ["a"]) - original_overrides = app.dependency_overrides.copy() - app.dependency_overrides[user_api_key_auth] = lambda: UserAPIKeyAuth( - user_id="u", user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY - ) - try: - response = client.get("/customer/aliases", headers={"Authorization": "Bearer k"}) - finally: - app.dependency_overrides = original_overrides - - assert response.status_code == 200 - assert response.json()["aliases"] == ["a"] - - -def test_customer_aliases_caps_page_size(mock_prisma_client, mock_user_api_key_auth): - """An unbounded size would reintroduce the very problem this endpoint fixes.""" - _mock_alias_rows(mock_prisma_client, []) - - response = client.get("/customer/aliases?size=100000", headers={"Authorization": "Bearer k"}) - - assert response.status_code == 422 + if role in (LitellmUserRoles.INTERNAL_USER, LitellmUserRoles.INTERNAL_USER_VIEW_ONLY): + allowed_routes = ( + LiteLLMRoutes.internal_user_routes.value + if role == LitellmUserRoles.INTERNAL_USER + else LiteLLMRoutes.internal_user_view_only_routes.value + ) + assert RouteChecks.check_route_access(route="/customer/aliases", allowed_routes=allowed_routes) + else: + assert "/customer/aliases" in LiteLLMRoutes.admin_viewer_routes.value diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/customers/useEndUserAliases.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/customers/useEndUserAliases.ts index b28c2328607..2625361231f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/customers/useEndUserAliases.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/customers/useEndUserAliases.ts @@ -1,18 +1,22 @@ import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { $api } from "@/lib/http/api"; import type { components } from "@/lib/http/schema"; -import { all_admin_roles } from "@/utils/roles"; type EndUserAliasesPage = components["schemas"]["CustomerAliasesResponse"]; -export const useInfiniteEndUserAliases = (size: number = 50, search?: string) => { - const { accessToken, userRole } = useAuthorized(); - const query = { size, ...(search !== undefined && search !== "" ? { search } : {}) }; +export interface EndUserAliasesWindow { + start_date: string; + end_date: string; +} + +export const useInfiniteEndUserAliases = (window: EndUserAliasesWindow, size: number = 50, search?: string) => { + const { accessToken } = useAuthorized(); + const query = { ...window, size, ...(search !== undefined && search !== "" ? { search } : {}) }; const options = { pageParamName: "page", initialPageParam: 1, getNextPageParam: (lastPage: EndUserAliasesPage) => (lastPage.has_more ? lastPage.current_page + 1 : undefined), - enabled: Boolean(accessToken) && all_admin_roles.includes(userRole ?? ""), + enabled: Boolean(accessToken), }; return $api.useInfiniteQuery("get", "/customer/aliases", { params: { query } }, options); }; diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx index 896c3767203..acfd7c63c64 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.test.tsx @@ -30,9 +30,13 @@ const emptyInfiniteQuery = { isLoading: false, }; +const LOGS_WINDOW = { start_date: "2026-07-23 00:00:00", end_date: "2026-07-24 00:00:00" }; + function renderFilters(filters: Record = {}) { const set = vi.fn(); - renderWithProviders( filters[id]} set={set} teams={[]} />); + renderWithProviders( + filters[id]} set={set} teams={[]} logsWindow={LOGS_WINDOW} />, + ); return { set }; } @@ -91,11 +95,11 @@ describe("RequestLogsFilters", () => { expect(useInfiniteModelInfo).toHaveBeenCalledWith(50, undefined); }); - it("asks the server for a bounded page of end users instead of the whole customer table", async () => { + it("asks the server for a bounded page of end users scoped to the visible time window", async () => { renderFilters(); await waitFor(() => expect(useInfiniteEndUserAliases).toHaveBeenCalled()); - expect(useInfiniteEndUserAliases).toHaveBeenCalledWith(50, undefined); + expect(useInfiniteEndUserAliases).toHaveBeenCalledWith(LOGS_WINDOW, 50, undefined); }); it("pushes the End User query to the server rather than filtering a preloaded list", async () => { @@ -106,7 +110,7 @@ describe("RequestLogsFilters", () => { await user.click(input); await user.type(input, "acme"); - await waitFor(() => expect(useInfiniteEndUserAliases).toHaveBeenCalledWith(50, "acme")); + await waitFor(() => expect(useInfiniteEndUserAliases).toHaveBeenCalledWith(LOGS_WINDOW, 50, "acme")); }); it("renders only the end users the current page returned", async () => { @@ -143,4 +147,11 @@ describe("RequestLogsFilters", () => { await waitFor(() => expect(fetchNextPage).toHaveBeenCalled()); }); + + it("scopes the End User lookup to the window the logs table is showing", async () => { + const otherWindow = { start_date: "2026-01-01 00:00:00", end_date: "2026-01-02 00:00:00" }; + renderWithProviders( undefined} set={vi.fn()} teams={[]} logsWindow={otherWindow} />); + + await waitFor(() => expect(useInfiniteEndUserAliases).toHaveBeenCalledWith(otherWindow, 50, undefined)); + }); }); diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx index e79481979b3..054caf46943 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsFilters.tsx @@ -21,7 +21,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import type { Team } from "../key_team_helpers/key_list"; import { ERROR_CODE_OPTIONS } from "./constants"; -import { LOG_FILTER_IDS } from "./log_filter_logic"; +import { LOG_FILTER_IDS, type LogsWindow } from "./log_filter_logic"; const ALL_VALUE = "all"; const PAGE_SIZE = 50; @@ -144,9 +144,18 @@ function ModelFilterField({ value, onChange }: { value: string; onChange: (value ); } -function EndUserFilterField({ value, onChange }: { value: string; onChange: (value: string | undefined) => void }) { +function EndUserFilterField({ + value, + onChange, + logsWindow, +}: { + value: string; + onChange: (value: string | undefined) => void; + logsWindow: LogsWindow; +}) { const [search, setSearch] = useState(""); const { data, fetchNextPage, hasNextPage, isFetchingNextPage, isLoading } = useInfiniteEndUserAliases( + logsWindow, PAGE_SIZE, emptyToUndefined(search), ); @@ -174,7 +183,7 @@ function EndUserFilterField({ value, onChange }: { value: string; onChange: (val isLoading={isLoading} isFetchingNextPage={isFetchingNextPage} placeholder="Search an end user" - emptyText="No end users found" + emptyText="No end users in this time range" /> ); @@ -233,9 +242,10 @@ interface RequestLogsFiltersProps { get: (columnId: string) => unknown; set: (columnId: string, value: unknown) => void; teams: Team[]; + logsWindow: LogsWindow; } -export function RequestLogsFilters({ get, set, teams }: RequestLogsFiltersProps) { +export function RequestLogsFilters({ get, set, teams, logsWindow }: RequestLogsFiltersProps) { const valueOf = (id: string): string => asString(get(id)); const setter = (id: string) => (next: string | undefined) => set(id, next); @@ -269,7 +279,11 @@ export function RequestLogsFilters({ get, set, teams }: RequestLogsFiltersProps) teamId={valueOf(LOG_FILTER_IDS.TEAM_ID)} /> - + diff --git a/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx index 5669b67e00a..cc5e6e899f5 100644 --- a/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/RequestLogsPanel.tsx @@ -12,7 +12,7 @@ import { keyInfoV1Call } from "../networking"; import KeyInfoView from "../templates/key_info_view"; import type { LogEntry } from "./columns"; import { AGENT_CALL_TYPES, MCP_CALL_TYPES } from "./constants"; -import { DEFAULT_LOGS_SORTING, LOG_FILTER_IDS, useLogFilterLogic } from "./log_filter_logic"; +import { DEFAULT_LOGS_SORTING, formatLogsWindow, LOG_FILTER_IDS, useLogFilterLogic } from "./log_filter_logic"; import { LogDetailsDrawer } from "./LogDetailsDrawer"; import { LiveTailBanner, LogsTableToolbar } from "./LogsTableToolbar"; import { RequestLogsTable } from "./RequestLogsTable"; @@ -76,6 +76,11 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID, sorting, }); + const logsWindow = useMemo( + () => formatLogsWindow(startTime, endTime, isCustomDate), + [startTime, endTime, isCustomDate], + ); + const keyInfoQueryOptions: UseQueryOptions = { queryKey: ["requestLogsKeyInfo", selectedKeyIdInfoView, accessToken], queryFn: async () => { @@ -232,6 +237,7 @@ export default function RequestLogsPanel({ accessToken, token, userRole, userID, onKeyHashClick={handleKeyHashClick} onSessionClick={handleSessionClick} teams={allTeams ?? []} + logsWindow={logsWindow} toolbarChildren={ void; onSessionClick: (sessionId: string) => void; teams: Team[]; + logsWindow: LogsWindow; toolbarChildren?: ReactNode; } @@ -67,6 +68,7 @@ export function RequestLogsTable({ onKeyHashClick, onSessionClick, teams, + logsWindow, toolbarChildren, }: RequestLogsTableProps) { const [filtersOpen, setFiltersOpen] = useState(false); @@ -120,7 +122,7 @@ export function RequestLogsTable({ title="Filters" description="Narrow down request logs" > - {({ get, set }) => } + {({ get, set }) => } )} diff --git a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx index ae229056fa3..9b3e2334a35 100644 --- a/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx +++ b/ui/litellm-dashboard/src/components/view_logs/log_filter_logic.tsx @@ -44,6 +44,18 @@ export const LOG_FILTER_LABELS: Record = { [LOG_FILTER_IDS.PUBLIC_MODEL_OR_SEARCH_TOOL]: "Public model / search tool", }; +export interface LogsWindow { + start_date: string; + end_date: string; +} + +export const formatLogsWindow = (startTime: string, endTime: string, isCustomDate: boolean): LogsWindow => ({ + start_date: moment(startTime).utc().format("YYYY-MM-DD HH:mm:ss"), + end_date: isCustomDate + ? moment(endTime).utc().format("YYYY-MM-DD HH:mm:ss") + : moment().utc().format("YYYY-MM-DD HH:mm:ss"), +}); + export const LIVE_TAIL_INTERVAL_MS = 15000; export const getLiveTailRefetchInterval = (isLiveTail: boolean, pageIndex: number): number | false => @@ -119,17 +131,14 @@ export function useLogFilterLogic({ }; } - const formattedStartTime = moment(startTime).utc().format("YYYY-MM-DD HH:mm:ss"); - const formattedEndTime = isCustomDate - ? moment(endTime).utc().format("YYYY-MM-DD HH:mm:ss") - : moment().utc().format("YYYY-MM-DD HH:mm:ss"); + const window = formatLogsWindow(startTime, endTime, isCustomDate); const userIdFilter = getFilterValue(columnFilters, LOG_FILTER_IDS.USER_ID); return await uiSpendLogsCall({ accessToken, - start_date: formattedStartTime, - end_date: formattedEndTime, + start_date: window.start_date, + end_date: window.end_date, page: pagination.pageIndex + 1, page_size: pageSize, params: { diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index ee2f549d3f2..a8b5dd5a28a 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -2781,16 +2781,20 @@ export interface paths { }; /** * List Customer Aliases - * @description [Admin-only] List customer ids with pagination and optional search. + * @description List the end users seen in spend logs over a time window, for UI filter dropdowns. * - * Lightweight counterpart to `/customer/list`, for UI filter dropdowns. - * `/customer/list` returns every customer with its budget and object-permission - * relations eagerly loaded, which is unusable once LiteLLM_EndUserTable grows - * (end-user rows are created automatically per distinct `user` seen in traffic). + * Scoped like `/spend/logs/ui`: a proxy admin sees every end user in the window, + * anyone else sees only end users from their own requests or from teams they + * administer (or hold the `/spend/logs` permission on). + * + * Reads spend logs rather than LiteLLM_EndUserTable because only spend logs carry + * the team attribution this scoping needs. The window is required and the inner + * scan is capped at MAX_SPENDLOG_ROWS_TO_SCAN_FOR_FILTERS rows, so the query + * cannot degrade into a full-table scan the way `/global/all_end_users` does. * * Example curl: * ``` - * curl --location 'http://0.0.0.0:4000/customer/aliases?page=1&size=50&search=acme' --header 'Authorization: Bearer sk-1234' + * curl --location 'http://0.0.0.0:4000/customer/aliases?start_date=2026-07-23%2000:00:00&end_date=2026-07-24%2000:00:00&size=50&search=acme' --header 'Authorization: Bearer sk-1234' * ``` */ get: operations["list_customer_aliases_customer_aliases_get"]; @@ -38450,7 +38454,11 @@ export interface operations { }; list_customer_aliases_customer_aliases_get: { parameters: { - query?: { + query: { + /** @description Window start, 'YYYY-MM-DD HH:MM:SS' (UTC) */ + start_date: string; + /** @description Window end, 'YYYY-MM-DD HH:MM:SS' (UTC) */ + end_date: string; /** @description Page number */ page?: number; /** @description Page size */