mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-30 01:52:18 +00:00
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:
parent
f48a87ef12
commit
406fd7aa5a
2 changed files with 124 additions and 1 deletions
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue