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.
This commit is contained in:
lengkejun 2026-05-22 14:50:08 +08:00
parent f48a87ef12
commit 406fd7aa5a
2 changed files with 124 additions and 1 deletions

View file

@ -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:

View file

@ -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