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 */