From 406fd7aa5a51fb02dc88712e07eefda4d734b8c1 Mon Sep 17 00:00:00 2001 From: lengkejun Date: Fri, 22 May 2026 14:50:08 +0800 Subject: [PATCH] fix(proxy): require admin on /customer/daily/activity (cross-tenant disclosure) The /customer/daily/activity and /end_user/daily/activity handlers had no authorization check. Any authenticated key (including service-account keys with user_id=None) could omit the end_user_ids query parameter; the call flowed into get_daily_activity with entity_id=None, which the underlying _build_where_conditions treats as "no filter", returning every tenant's end-user spend rows. LiteLLM_EndUserTable has no per-tenant ownership column, so there is no safe non-admin scoping. Mirror the /customer/list policy and require PROXY_ADMIN or PROXY_ADMIN_VIEW_ONLY. Same shape as the prior /user/daily/activity disclosure fixed in 0f98f3754f. See also closed issue #19194. Tests cover: non-admin roles return 401, service-account-style key (user_id=None, no role) returns 401, PROXY_ADMIN_VIEW_ONLY still works. --- .../customer_endpoints.py | 23 +++- .../test_customer_endpoints.py | 102 ++++++++++++++++++ 2 files changed, 124 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/management_endpoints/customer_endpoints.py b/litellm/proxy/management_endpoints/customer_endpoints.py index 1fd8320db20..9fbdf598f1d 100644 --- a/litellm/proxy/management_endpoints/customer_endpoints.py +++ b/litellm/proxy/management_endpoints/customer_endpoints.py @@ -881,8 +881,29 @@ async def get_customer_daily_activity( user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), ): """ - Get daily activity for specific organizations or all accessible organizations. + Get daily activity for specific end-users or all end-users. + + Admin-only — mirrors the access policy on /customer/list. End-users have + no per-tenant ownership column on LiteLLM_EndUserTable, so there is no + safe way to scope this endpoint to a non-admin caller's data. """ + # Admin-only. Without this gate, any authenticated key could omit + # end_user_ids, the underlying daily-activity builder would treat + # entity_id=None as "no filter", and the response would expose every + # tenant's end-user spend data (cross-tenant disclosure). + if ( + user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN + and user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY + ): + raise HTTPException( + status_code=401, + detail={ + "error": "Admin-only endpoint. Your user role={}".format( + user_api_key_dict.user_role + ) + }, + ) + from litellm.proxy.proxy_server import prisma_client if prisma_client is None: 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 d8a674c2681..e6dd9c37602 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_customer_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_customer_endpoints.py @@ -409,3 +409,105 @@ async def test_get_customer_daily_activity_with_end_user_aliases(monkeypatch): "end-user-1": {"alias": "Customer One"}, "end-user-2": {"alias": "Customer Two"}, } + + +@pytest.mark.asyncio +async def test_get_customer_daily_activity_non_admin_blocked(monkeypatch): + """Non-admin callers must be rejected with 401. + + Regression test for cross-tenant disclosure: prior to the fix, any + authenticated key (including service-account keys with user_id=None) could + call /customer/daily/activity with no end_user_ids and receive every + tenant's end-user spend data. The handler must mirror /customer/list and + refuse non-admin callers. + """ + from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth + from litellm.proxy.management_endpoints import customer_endpoints + from litellm.proxy.management_endpoints.customer_endpoints import ( + get_customer_daily_activity, + ) + + get_daily_activity_mock = AsyncMock() + monkeypatch.setattr( + customer_endpoints, "get_daily_activity", get_daily_activity_mock + ) + + non_admin_roles = [ + LitellmUserRoles.INTERNAL_USER, + LitellmUserRoles.INTERNAL_USER_VIEW_ONLY, + LitellmUserRoles.TEAM, + LitellmUserRoles.CUSTOMER, + ] + + for role in non_admin_roles: + auth = UserAPIKeyAuth(user_role=role, user_id="someone") + with pytest.raises(HTTPException) as exc: + await get_customer_daily_activity( + end_user_ids=None, + start_date="2024-01-01", + end_date="2024-01-31", + model=None, + api_key=None, + page=1, + page_size=10, + exclude_end_user_ids=None, + user_api_key_dict=auth, + ) + assert exc.value.status_code == 401 + assert "Admin-only" in exc.value.detail["error"] + + # Service-account-style key: user_id=None, no role. + auth_no_role = UserAPIKeyAuth(user_id=None) + with pytest.raises(HTTPException) as exc: + await get_customer_daily_activity( + end_user_ids=None, + start_date="2024-01-01", + end_date="2024-01-31", + model=None, + api_key=None, + page=1, + page_size=10, + exclude_end_user_ids=None, + user_api_key_dict=auth_no_role, + ) + assert exc.value.status_code == 401 + + get_daily_activity_mock.assert_not_called() + + +@pytest.mark.asyncio +async def test_get_customer_daily_activity_admin_view_only_allowed(monkeypatch): + """PROXY_ADMIN_VIEW_ONLY is treated as admin for this read-only endpoint.""" + from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth + from litellm.proxy.management_endpoints import customer_endpoints + from litellm.proxy.management_endpoints.customer_endpoints import ( + get_customer_daily_activity, + ) + + mock_prisma_client = AsyncMock() + mock_prisma_client.db.litellm_endusertable.find_many = AsyncMock(return_value=[]) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + + mocked_response = MagicMock(name="SpendAnalyticsPaginatedResponse") + get_daily_activity_mock = AsyncMock(return_value=mocked_response) + monkeypatch.setattr( + customer_endpoints, "get_daily_activity", get_daily_activity_mock + ) + + auth = UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, user_id="viewer1" + ) + result = await get_customer_daily_activity( + end_user_ids=None, + start_date="2024-01-01", + end_date="2024-01-31", + model=None, + api_key=None, + page=1, + page_size=10, + exclude_end_user_ids=None, + user_api_key_dict=auth, + ) + + get_daily_activity_mock.assert_awaited_once() + assert result is mocked_response