fix(customer_endpoints): restrict /customer/daily/activity to admin-only

This commit is contained in:
Varshith 2026-05-26 01:12:29 -05:00
parent 96a2e8b16d
commit 3b8109a8ec
2 changed files with 111 additions and 0 deletions

View file

@ -891,6 +891,19 @@ async def get_customer_daily_activity(
detail={"error": CommonProxyErrors.db_not_connected_error.value},
)
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
)
},
)
# Parse comma-separated ids
end_user_ids_list = end_user_ids.split(",") if end_user_ids else None
exclude_end_user_ids_list: Optional[List[str]] = None

View file

@ -409,3 +409,101 @@ 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_is_rejected(monkeypatch):
"""
Security regression: any non-admin caller must receive 401 from
/customer/daily/activity and /end_user/daily/activity.
Before this fix, the endpoint performed no role check. A caller with
user_role=INTERNAL_USER could omit end_user_ids, causing entity_id=None
to flow into get_daily_activity where the SQL builder treats it as no
filter — returning every tenant's spend across the full
LiteLLM_DailyEndUserSpend table.
LiteLLM_EndUserTable has no per-tenant ownership column, so non-admin
scoping is not possible. The correct fix is admin-only, matching the
existing /customer/list gate.
"""
from litellm.proxy.management_endpoints import customer_endpoints
from litellm.proxy.management_endpoints.customer_endpoints import (
get_customer_daily_activity,
)
mock_prisma_client = MagicMock()
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
get_daily_activity_mock = AsyncMock()
monkeypatch.setattr(
customer_endpoints, "get_daily_activity", get_daily_activity_mock
)
non_admin_key = UserAPIKeyAuth(
user_id="regular-user-abc",
user_role=LitellmUserRoles.INTERNAL_USER,
)
with pytest.raises(HTTPException) as exc_info:
await get_customer_daily_activity(
end_user_ids=None,
start_date="2025-01-01",
end_date="2025-01-31",
model=None,
api_key=None,
page=1,
page_size=10,
exclude_end_user_ids=None,
user_api_key_dict=non_admin_key,
)
assert exc_info.value.status_code == 401
assert "Admin-only endpoint" in str(exc_info.value.detail)
get_daily_activity_mock.assert_not_called()
@pytest.mark.asyncio
async def test_get_customer_daily_activity_service_account_key_is_rejected(monkeypatch):
"""
Security regression: service-account keys (user_id=None, role=INTERNAL_USER)
must be rejected at the admin gate before reaching get_daily_activity.
A service-account key with end_user_ids omitted is the worst-case caller:
entity_id=None and no user identity to scope by — the SQL builder would
return the full LiteLLM_DailyEndUserSpend table with no WHERE clause.
"""
from litellm.proxy.management_endpoints import customer_endpoints
from litellm.proxy.management_endpoints.customer_endpoints import (
get_customer_daily_activity,
)
mock_prisma_client = MagicMock()
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
get_daily_activity_mock = AsyncMock()
monkeypatch.setattr(
customer_endpoints, "get_daily_activity", get_daily_activity_mock
)
service_account_key = UserAPIKeyAuth(
user_id=None,
user_role=LitellmUserRoles.INTERNAL_USER,
)
with pytest.raises(HTTPException) as exc_info:
await get_customer_daily_activity(
end_user_ids=None,
start_date="2025-01-01",
end_date="2025-01-31",
model=None,
api_key=None,
page=1,
page_size=10,
exclude_end_user_ids=None,
user_api_key_dict=service_account_key,
)
assert exc_info.value.status_code == 401
assert "Admin-only endpoint" in str(exc_info.value.detail)
get_daily_activity_mock.assert_not_called()