From 5baf972a966764b8d5586123f0caa10c0152b230 Mon Sep 17 00:00:00 2001 From: shivam Date: Sat, 11 Apr 2026 18:36:27 -0700 Subject: [PATCH] Allow /global/spend/report when virtual key allowlists the route - Replace admin-only guard with _require_global_spend_report_access: proxy admin roles unchanged; others pass if allowed_routes matches via RouteChecks - Tests for internal user with path or global_spend_report_routes bundle Made-with: Cursor --- .../spend_management_endpoints.py | 21 +++++++++++-- .../proxy/auth/test_route_checks.py | 28 ++++++++++++++++- .../test_spend_management_endpoints.py | 31 +++++++++++++++++++ 3 files changed, 77 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py index 52b656d86c8..8b3d9bc81a8 100644 --- a/litellm/proxy/spend_tracking/spend_management_endpoints.py +++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py @@ -12,6 +12,7 @@ import litellm from litellm._logging import verbose_proxy_logger from litellm.proxy._types import * from litellm.proxy._types import ProviderBudgetResponse, ProviderBudgetResponseObject +from litellm.proxy.auth.route_checks import RouteChecks from litellm.proxy.auth.user_api_key_auth import user_api_key_auth # NOTE: Avoid module-level import from common_utils: proxy_server imports this @@ -31,12 +32,28 @@ else: router = APIRouter() -def _require_proxy_admin_or_viewer(user_api_key_dict: UserAPIKeyAuth) -> None: +_GLOBAL_SPEND_REPORT_ROUTE = "/global/spend/report" + + +def _require_global_spend_report_access(user_api_key_dict: UserAPIKeyAuth) -> None: + """ + Allow proxy admin roles, or any role when the virtual key allowlists this route + (same matching rules as RouteChecks.is_virtual_key_allowed_to_call_route). + """ if user_api_key_dict.user_role in ( LitellmUserRoles.PROXY_ADMIN, LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, ): return + if user_api_key_dict.allowed_routes: + try: + RouteChecks.is_virtual_key_allowed_to_call_route( + route=_GLOBAL_SPEND_REPORT_ROUTE, + valid_token=user_api_key_dict, + ) + return + except HTTPException: + pass raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail={"error": CommonProxyErrors.not_allowed_access.value}, @@ -1021,7 +1038,7 @@ async def get_global_spend_report( ] } """ - _require_proxy_admin_or_viewer(user_api_key_dict) + _require_global_spend_report_access(user_api_key_dict) if start_date is None or end_date is None: raise HTTPException( diff --git a/tests/test_litellm/proxy/auth/test_route_checks.py b/tests/test_litellm/proxy/auth/test_route_checks.py index 8ef10ddf5be..a04acd485c3 100644 --- a/tests/test_litellm/proxy/auth/test_route_checks.py +++ b/tests/test_litellm/proxy/auth/test_route_checks.py @@ -1030,7 +1030,7 @@ def test_proxy_admin_viewer_can_access_global_spend_report(): def test_internal_user_cannot_access_global_spend_report_route(): - """Internal users must not use /global/spend/report (proxy admin roles only).""" + """Without key allowlisting, internal users cannot use /global/spend/report.""" user_obj = LiteLLM_UserTable( user_id="internal_user", @@ -1056,6 +1056,32 @@ def test_internal_user_cannot_access_global_spend_report_route(): assert "Only proxy admin can be used to generate" in str(exc_info.value) +def test_internal_user_can_access_global_spend_report_when_key_allowlists_route(): + """Virtual key with allowed_routes including /global/spend/report passes route check.""" + + 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, + allowed_routes=["/global/spend/report"], + ) + request = MagicMock(spec=Request) + request.query_params = {} + + 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={}, + ) + + @pytest.mark.parametrize("route", ["/audit", "/audit/some-log-id"]) def test_proxy_admin_viewer_can_access_audit_logs(route): """ diff --git a/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py index 24e165a5954..2946231cc14 100644 --- a/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py +++ b/tests/test_litellm/proxy/spend_tracking/test_spend_management_endpoints.py @@ -2761,3 +2761,34 @@ async def test_ui_view_spend_logs_team_member_no_permission_blocked( assert response.status_code == 403 finally: app.dependency_overrides.pop(ps.user_api_key_auth, None) + + +def test_require_global_spend_report_access_internal_with_allowed_routes(): + spend_management_endpoints._require_global_spend_report_access( + UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, + user_id="u1", + allowed_routes=["/global/spend/report"], + ) + ) + + +def test_require_global_spend_report_access_internal_with_route_bundle_name(): + spend_management_endpoints._require_global_spend_report_access( + UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, + user_id="u1", + allowed_routes=["global_spend_report_routes"], + ) + ) + + +def test_require_global_spend_report_access_internal_without_allowed_routes_fails(): + with pytest.raises(HTTPException) as exc_info: + spend_management_endpoints._require_global_spend_report_access( + UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, + user_id="u1", + ) + ) + assert exc_info.value.status_code == 403