From 00e45f2cc0f5186a79dcf3faae1419b958a246c5 Mon Sep 17 00:00:00 2001 From: shivam Date: Sat, 11 Apr 2026 18:16:37 -0700 Subject: [PATCH] Restrict /global/spend/report to proxy admin and proxy admin viewer - Move route out of global_spend_tracking_routes; add global_spend_report_routes - Allow proxy_admin_viewer via route_checks; extend default JWT admin_allowed_routes - Enforce roles in get_global_spend_report handler for defense in depth - Add route_checks tests for viewer access and internal user denial Made-with: Cursor --- litellm/proxy/_types.py | 6 ++- litellm/proxy/auth/route_checks.py | 5 ++ .../spend_management_endpoints.py | 16 +++++- .../proxy/auth/test_route_checks.py | 52 +++++++++++++++++++ 4 files changed, 77 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 0bbee56d5e0..c35bb277acb 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -586,12 +586,15 @@ class LiteLLMRoutes(enum.Enum): "/global/spend/end_users", "/global/spend/models", "/global/predict/spend/logs", - "/global/spend/report", "/global/spend/provider", "/global/spend/tags", "/global/spend/all_tag_names", ] + global_spend_report_routes = [ + "/global/spend/report", + ] + public_routes = set( [ "/routes", @@ -4191,6 +4194,7 @@ class LiteLLM_JWTAuth(LiteLLMPydanticObjectBase): "management_routes", "spend_tracking_routes", "global_spend_tracking_routes", + "global_spend_report_routes", "info_routes", ] team_id_jwt_field: Optional[str] = None diff --git a/litellm/proxy/auth/route_checks.py b/litellm/proxy/auth/route_checks.py index 26bbdef3090..558caef6a28 100644 --- a/litellm/proxy/auth/route_checks.py +++ b/litellm/proxy/auth/route_checks.py @@ -665,6 +665,11 @@ class RouteChecks: # Allow access to global spend tracking routes (read-only spend endpoints) # proxy_admin_viewer role description: "view all keys, view all spend" return + elif RouteChecks.check_route_access( + route=route, allowed_routes=LiteLLMRoutes.global_spend_report_routes.value + ): + # Global spend report: proxy_admin + proxy_admin_viewer only (not org admin) + return else: # For other routes, block access raise HTTPException( diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py index 7d67cbd3630..52b656d86c8 100644 --- a/litellm/proxy/spend_tracking/spend_management_endpoints.py +++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py @@ -31,6 +31,18 @@ else: router = APIRouter() +def _require_proxy_admin_or_viewer(user_api_key_dict: UserAPIKeyAuth) -> None: + if user_api_key_dict.user_role in ( + LitellmUserRoles.PROXY_ADMIN, + LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, + ): + return + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={"error": CommonProxyErrors.not_allowed_access.value}, + ) + + @router.get( "/spend/keys", tags=["Budget & Spend Tracking"], @@ -945,12 +957,12 @@ async def get_global_spend_provider( @router.get( "/global/spend/report", tags=["Budget & Spend Tracking"], - dependencies=[Depends(user_api_key_auth)], responses={ 200: {"model": List[LiteLLM_SpendLogs]}, }, ) async def get_global_spend_report( + user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth), start_date: Optional[str] = fastapi.Query( default=None, description="Time from which to start viewing spend", @@ -1009,6 +1021,8 @@ async def get_global_spend_report( ] } """ + _require_proxy_admin_or_viewer(user_api_key_dict) + if start_date is None or end_date is None: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, diff --git a/tests/test_litellm/proxy/auth/test_route_checks.py b/tests/test_litellm/proxy/auth/test_route_checks.py index f1344a302d7..8ef10ddf5be 100644 --- a/tests/test_litellm/proxy/auth/test_route_checks.py +++ b/tests/test_litellm/proxy/auth/test_route_checks.py @@ -1004,6 +1004,58 @@ def test_proxy_admin_viewer_can_access_global_spend_tags(): ) +def test_proxy_admin_viewer_can_access_global_spend_report(): + """proxy_admin_viewer should pass route checks for /global/spend/report.""" + + user_obj = LiteLLM_UserTable( + user_id="viewer_user", + user_email="viewer@example.com", + user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value, + ) + valid_token = UserAPIKeyAuth( + user_id="viewer_user", + user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value, + ) + request = MagicMock(spec=Request) + request.query_params = {"start_date": "2025-05-12", "end_date": "2025-10-09"} + + RouteChecks.non_proxy_admin_allowed_routes_check( + user_obj=user_obj, + _user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value, + route="/global/spend/report", + request=request, + valid_token=valid_token, + request_data={}, + ) + + +def test_internal_user_cannot_access_global_spend_report_route(): + """Internal users must not use /global/spend/report (proxy admin roles only).""" + + user_obj = LiteLLM_UserTable( + user_id="internal_user", + user_email="user@example.com", + user_role=LitellmUserRoles.INTERNAL_USER.value, + ) + valid_token = UserAPIKeyAuth( + user_id="internal_user", + user_role=LitellmUserRoles.INTERNAL_USER.value, + ) + request = MagicMock(spec=Request) + request.query_params = {} + + with pytest.raises(Exception) as exc_info: + RouteChecks.non_proxy_admin_allowed_routes_check( + user_obj=user_obj, + _user_role=LitellmUserRoles.INTERNAL_USER.value, + route="/global/spend/report", + request=request, + valid_token=valid_token, + request_data={}, + ) + assert "Only proxy admin can be used to generate" in str(exc_info.value) + + @pytest.mark.parametrize("route", ["/audit", "/audit/some-log-id"]) def test_proxy_admin_viewer_can_access_audit_logs(route): """