mirror of
https://github.com/tiennm99/litellm.git
synced 2026-07-10 01:08:06 +00:00
[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.
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user