Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 42 additions & 6 deletions litellm/proxy/management_endpoints/internal_user_endpoints.py
Original file line number Diff line number Diff line change
Expand Up @@ -1911,6 +1911,10 @@ async def get_user_daily_activity(
default=None,
description="Filter by specific API key",
),
user_id: Optional[str] = fastapi.Query(
default=None,
description="Filter by specific user ID. Admins can filter by any user or omit for global view. Non-admins must provide their own user_id.",
),
page: int = fastapi.Query(
default=1, description="Page number for pagination", ge=1
),
Expand Down Expand Up @@ -1955,9 +1959,21 @@ async def get_user_daily_activity(
)

try:
entity_id: Optional[str] = None
if not _user_has_admin_view(user_api_key_dict):
entity_id = user_api_key_dict.user_id
is_admin = _user_has_admin_view(user_api_key_dict)

if is_admin:
entity_id = user_id # None means global view, otherwise filter by user
else:
if user_id is None:
user_id = user_api_key_dict.user_id
if user_id != user_api_key_dict.user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"error": "Non-admin users can only view their own spend data."
},
)
entity_id = user_id

return await get_daily_activity(
prisma_client=prisma_client,
Expand All @@ -1974,6 +1990,8 @@ async def get_user_daily_activity(
timezone_offset_minutes=timezone,
)

except HTTPException:
raise
except Exception as e:
verbose_proxy_logger.exception(
"/spend/daily/analytics: Exception occured - {}".format(str(e))
Expand Down Expand Up @@ -2008,6 +2026,10 @@ async def get_user_daily_activity_aggregated(
default=None,
description="Filter by specific API key",
),
user_id: Optional[str] = fastapi.Query(
default=None,
description="Filter by specific user ID. Admins can filter by any user or omit for global view. Non-admins must provide their own user_id.",
),
timezone: Optional[int] = fastapi.Query(
default=None,
description="Timezone offset in minutes from UTC (e.g., 480 for PST). "
Expand All @@ -2034,9 +2056,21 @@ async def get_user_daily_activity_aggregated(
)

try:
entity_id: Optional[str] = None
if not _user_has_admin_view(user_api_key_dict):
entity_id = user_api_key_dict.user_id
is_admin = _user_has_admin_view(user_api_key_dict)

if is_admin:
entity_id = user_id # None means global view, otherwise filter by user
else:
if user_id is None:
user_id = user_api_key_dict.user_id
if user_id != user_api_key_dict.user_id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"error": "Non-admin users can only view their own spend data."
},
)
entity_id = user_id

return await get_daily_activity_aggregated(
prisma_client=prisma_client,
Expand All @@ -2051,6 +2085,8 @@ async def get_user_daily_activity_aggregated(
timezone_offset_minutes=timezone,
)

except HTTPException:
raise
except Exception as e:
verbose_proxy_logger.exception(
"/user/daily/activity/aggregated: Exception occured - {}".format(str(e))
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1167,4 +1167,136 @@ def test_generate_request_base_validator():

# Test with None
req = GenerateRequestBase(max_budget=None)
assert req.max_budget is None
assert req.max_budget is None


@pytest.mark.asyncio
async def test_get_user_daily_activity_non_admin_cannot_view_other_users(monkeypatch):
"""
Test that non-admin users cannot view another user's daily activity data.
The endpoint should raise 403 when user_id does not match the caller's own user_id.
Also verifies that omitting user_id defaults to the caller's own user_id.
"""
from unittest.mock import AsyncMock, MagicMock, patch

from fastapi import HTTPException

from litellm.proxy.management_endpoints.internal_user_endpoints import (
get_user_daily_activity,
)

# Mock the prisma client so the DB-not-connected check passes
mock_prisma_client = MagicMock()
monkeypatch.setattr(
"litellm.proxy.proxy_server.prisma_client", mock_prisma_client
)

# Non-admin caller
non_admin_key_dict = UserAPIKeyAuth(
user_id="regular-user-123",
user_role=LitellmUserRoles.INTERNAL_USER,
)

# Case 1: Non-admin tries to view a different user's data — should get 403
with pytest.raises(HTTPException) as exc_info:
await get_user_daily_activity(
start_date="2025-01-01",
end_date="2025-01-31",
model=None,
api_key=None,
user_id="other-user-456",
page=1,
page_size=50,
timezone=None,
user_api_key_dict=non_admin_key_dict,
)

assert exc_info.value.status_code == 403
assert "Non-admin users can only view their own spend data" in str(
exc_info.value.detail
)

# Case 2: Non-admin omits user_id — should default to their own user_id
mock_response = MagicMock()
with patch(
"litellm.proxy.management_endpoints.internal_user_endpoints.get_daily_activity",
new_callable=AsyncMock,
return_value=mock_response,
) as mock_get_daily:
result = await get_user_daily_activity(
start_date="2025-01-01",
end_date="2025-01-31",
model=None,
api_key=None,
user_id=None,
page=1,
page_size=50,
timezone=None,
user_api_key_dict=non_admin_key_dict,
)

# Verify it called get_daily_activity with the caller's own user_id
mock_get_daily.assert_called_once()
call_kwargs = mock_get_daily.call_args
assert call_kwargs.kwargs["entity_id"] == "regular-user-123"


@pytest.mark.asyncio
async def test_get_user_daily_activity_aggregated_admin_global_view(monkeypatch):
"""
Test that admin users can call the aggregated endpoint without a user_id
to get a global view. Also verifies that the correct arguments are forwarded
to the underlying get_daily_activity_aggregated helper.
"""
from unittest.mock import AsyncMock, MagicMock

from litellm.proxy.management_endpoints.internal_user_endpoints import (
get_user_daily_activity_aggregated,
)

# Mock the prisma client
mock_prisma_client = MagicMock()
monkeypatch.setattr(
"litellm.proxy.proxy_server.prisma_client", mock_prisma_client
)

# Mock the downstream helper so we don't need a real DB
mock_response = MagicMock()
mock_get_daily_agg = AsyncMock(return_value=mock_response)
monkeypatch.setattr(
"litellm.proxy.management_endpoints.internal_user_endpoints.get_daily_activity_aggregated",
mock_get_daily_agg,
)

# Admin caller
admin_key_dict = UserAPIKeyAuth(
user_id="admin-user-001",
user_role=LitellmUserRoles.PROXY_ADMIN,
)

# Admin calls without user_id → global view (entity_id=None)
result = await get_user_daily_activity_aggregated(
start_date="2025-02-01",
end_date="2025-02-28",
model="gpt-4",
api_key=None,
user_id=None,
timezone=480,
user_api_key_dict=admin_key_dict,
)

assert result is mock_response

# Verify the helper was called with the right parameters
mock_get_daily_agg.assert_called_once_with(
prisma_client=mock_prisma_client,
table_name="litellm_dailyuserspend",
entity_id_field="user_id",
entity_id=None, # global view: no user_id filter
entity_metadata_field=None,
start_date="2025-02-01",
end_date="2025-02-28",
model="gpt-4",
api_key=None,
timezone_offset_minutes=480,
)
Loading
Loading