From 01eee0944c837eaa017ec897b7031cfc2c367d46 Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 24 Apr 2026 22:46:07 -0700 Subject: [PATCH] [Fix] Restrict /global/spend/* routes to admin roles The routes in `global_spend_tracking_routes` (e.g. /global/spend/report, /global/spend/teams, /global/spend/keys) return spend aggregated across every team, customer, and api_key in the proxy. They were included in `internal_user_routes` and `internal_user_view_only_routes`, so non-admin roles could read proxy-wide spend. Drop them from both non-admin route lists. PROXY_ADMIN and PROXY_ADMIN_VIEW_ONLY access is preserved through their existing branches in route_checks.py, and the `get_spend_routes` permission opt-in continues to grant access for keys that need it. Updates two pre-existing test parametrizations whose expected results flip from True to False, and adds parametrized coverage over every route in `global_spend_tracking_routes` for: PROXY_ADMIN_VIEW_ONLY allowed, INTERNAL_USER blocked, INTERNAL_USER_VIEW_ONLY blocked, INTERNAL_USER + get_spend_routes permission allowed. --- litellm/proxy/_types.py | 10 +- .../test_role_based_access.py | 3 +- .../test_user_api_key_auth.py | 6 +- .../proxy/auth/test_route_checks.py | 125 ++++++++++++++++++ 4 files changed, 137 insertions(+), 7 deletions(-) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 08b3238bc6..3c6580d639 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -651,6 +651,11 @@ class LiteLLMRoutes(enum.Enum): "/health/services", ] + info_routes + # Routes in `global_spend_tracking_routes` return proxy-wide spend across + # every team, customer, and api_key. They are intentionally NOT included + # here — non-admin roles must not see other tenants' spend. Admin roles go + # through their own branches in `route_checks.py`, and a key minted with + # the `get_spend_routes` permission retains explicit opt-in access. internal_user_routes = ( [ "/global/activity", @@ -662,13 +667,10 @@ class LiteLLMRoutes(enum.Enum): "/v2/guardrails/list", ] + spend_tracking_routes - + global_spend_tracking_routes + key_management_routes ) - internal_user_view_only_routes = ( - spend_tracking_routes + global_spend_tracking_routes - ) + internal_user_view_only_routes = spend_tracking_routes self_managed_routes = [ "/team/member_add", diff --git a/tests/proxy_admin_ui_tests/test_role_based_access.py b/tests/proxy_admin_ui_tests/test_role_based_access.py index 7eadeefb8a..9398428bd6 100644 --- a/tests/proxy_admin_ui_tests/test_role_based_access.py +++ b/tests/proxy_admin_ui_tests/test_role_based_access.py @@ -459,7 +459,8 @@ async def test_org_admin_create_user_team_wrong_org_permissions(prisma_client): ("/key/generate", LitellmUserRoles.PROXY_ADMIN, True), ("/key/regenerate", LitellmUserRoles.PROXY_ADMIN, True), # # Internal User checks - allowed routes - ("/global/spend/logs", LitellmUserRoles.INTERNAL_USER, True), + # /global/spend/logs returns proxy-wide spend; non-admin roles must be blocked + ("/global/spend/logs", LitellmUserRoles.INTERNAL_USER, False), ("/key/delete", LitellmUserRoles.INTERNAL_USER, True), ("/key/generate", LitellmUserRoles.INTERNAL_USER, True), ("/key/82akk800000000jjsk/regenerate", LitellmUserRoles.INTERNAL_USER, True), diff --git a/tests/proxy_unit_tests/test_user_api_key_auth.py b/tests/proxy_unit_tests/test_user_api_key_auth.py index f847b8aa65..3239b95d50 100644 --- a/tests/proxy_unit_tests/test_user_api_key_auth.py +++ b/tests/proxy_unit_tests/test_user_api_key_auth.py @@ -366,7 +366,6 @@ async def test_auth_with_allowed_routes(route, should_raise_error): ("/global/spend/logs", "proxy_admin", True), ("/global/activity/cache_hits", "proxy_admin", True), # Internal User - allowed read-only routes - ("/global/spend/logs", "internal_user", True), ("/spend/logs/ui", "internal_user", True), ("/global/activity/cache_hits", "internal_user", True), ("/health/services", "internal_user", True), @@ -375,9 +374,12 @@ async def test_auth_with_allowed_routes(route, should_raise_error): ("/config/pass_through_endpoint", "internal_user", False), ("/config/field/update", "internal_user", False), ("/organization/member_add", "internal_user", False), + # Internal User - BLOCKED from proxy-wide spend routes + ("/global/spend/logs", "internal_user", False), # Internal User Viewer - allowed spend routes only ("/spend/logs/ui", "internal_user_viewer", True), - ("/global/spend/all_tag_names", "internal_user_viewer", True), + # Internal User Viewer - BLOCKED from proxy-wide spend routes + ("/global/spend/all_tag_names", "internal_user_viewer", False), # Internal User Viewer - blocked from admin routes ("/config/update", "internal_user_viewer", False), ("/key/generate", "internal_user_viewer", False), diff --git a/tests/test_litellm/proxy/auth/test_route_checks.py b/tests/test_litellm/proxy/auth/test_route_checks.py index bfebc7145d..a5d405cfc2 100644 --- a/tests/test_litellm/proxy/auth/test_route_checks.py +++ b/tests/test_litellm/proxy/auth/test_route_checks.py @@ -1036,6 +1036,131 @@ def test_proxy_admin_viewer_can_access_global_spend_tags(): ) +# Routes returning proxy-wide spend across every team / customer / api_key. +# Sourced from `LiteLLMRoutes.global_spend_tracking_routes` so any future +# additions to that list are exercised by these tests automatically. +from litellm.proxy._types import LiteLLMRoutes + +GLOBAL_SPEND_ROUTES = LiteLLMRoutes.global_spend_tracking_routes.value + + +@pytest.mark.parametrize("route", GLOBAL_SPEND_ROUTES) +def test_internal_user_blocked_from_global_spend_routes(route): + """ + Non-admin INTERNAL_USER role must NOT be able to read proxy-wide spend. + These routes return spend across every team, customer, and api_key. + """ + 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=route, + request=request, + valid_token=valid_token, + request_data={}, + ) + assert "Only proxy admin" in str(exc_info.value) + + +@pytest.mark.parametrize("route", GLOBAL_SPEND_ROUTES) +def test_internal_user_view_only_blocked_from_global_spend_routes(route): + """ + INTERNAL_USER_VIEW_ONLY must also be blocked from proxy-wide spend routes. + """ + user_obj = LiteLLM_UserTable( + user_id="viewer_user", + user_email="viewer@example.com", + user_role=LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value, + ) + valid_token = UserAPIKeyAuth( + user_id="viewer_user", + user_role=LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.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_VIEW_ONLY.value, + route=route, + request=request, + valid_token=valid_token, + request_data={}, + ) + assert "Only proxy admin" in str(exc_info.value) + + +@pytest.mark.parametrize("route", GLOBAL_SPEND_ROUTES) +def test_proxy_admin_viewer_can_access_all_global_spend_routes(route): + """ + PROXY_ADMIN_VIEW_ONLY ("view all keys, view all spend") must retain access + to every route in `global_spend_tracking_routes`. + """ + user_obj = LiteLLM_UserTable( + user_id="admin_viewer", + user_email="admin_viewer@example.com", + user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value, + ) + valid_token = UserAPIKeyAuth( + user_id="admin_viewer", + user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value, + ) + request = MagicMock(spec=Request) + request.query_params = {} + + RouteChecks.non_proxy_admin_allowed_routes_check( + user_obj=user_obj, + _user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value, + route=route, + request=request, + valid_token=valid_token, + request_data={}, + ) + + +@pytest.mark.parametrize("route", GLOBAL_SPEND_ROUTES) +def test_get_spend_routes_permission_keeps_access_for_internal_user(route): + """ + A key minted with the `get_spend_routes` permission is an explicit + admin opt-in and must continue to grant access even though the caller's + role would otherwise be blocked. + """ + user_obj = LiteLLM_UserTable( + user_id="internal_user_with_permission", + user_email="user@example.com", + user_role=LitellmUserRoles.INTERNAL_USER.value, + ) + valid_token = UserAPIKeyAuth( + user_id="internal_user_with_permission", + user_role=LitellmUserRoles.INTERNAL_USER.value, + permissions={"get_spend_routes": True}, + ) + 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=route, + 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): """