From 447f2b0fbdb29c79805ecd2c19bbc7f1827a57bc Mon Sep 17 00:00:00 2001 From: Frank Deng Date: Fri, 7 Aug 2026 19:36:59 -0700 Subject: [PATCH] fix(proxy): allow internal users to view log details --- litellm/proxy/_types.py | 1 + .../spend_management_endpoints.py | 9 ++- .../proxy/auth/test_route_checks.py | 32 +++++++++ .../test_spend_management_endpoints.py | 71 +++++++++++++++++++ 4 files changed, 112 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index c6653a722d6..22a8f20da7f 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -592,6 +592,7 @@ class LiteLLMRoutes(enum.Enum): "/spend/calculate", "/spend/logs", "/spend/logs/ui", + "/spend/logs/ui/{request_id}", "/spend/logs/session/ui", "/cost/estimate", ] diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py index d030fabe8b5..206de53f7fc 100644 --- a/litellm/proxy/spend_tracking/spend_management_endpoints.py +++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py @@ -3577,7 +3577,14 @@ async def _assert_user_can_view_request_id( include=None, ) if row is None: - return + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={ + "error": "Not authorized to view spend log for request_id={}".format( + request_id + ) + }, + ) if row.user is not None and row.user == user_api_key_dict.user_id: return diff --git a/tests/test_litellm/proxy/auth/test_route_checks.py b/tests/test_litellm/proxy/auth/test_route_checks.py index cf6feabf85f..cc8b8314324 100644 --- a/tests/test_litellm/proxy/auth/test_route_checks.py +++ b/tests/test_litellm/proxy/auth/test_route_checks.py @@ -1296,6 +1296,38 @@ ADMIN_VIEWER_LOGS_PAGE_ROUTES = [ ] +@pytest.mark.parametrize( + "user_role", + [ + LitellmUserRoles.INTERNAL_USER, + LitellmUserRoles.INTERNAL_USER_VIEW_ONLY, + ], +) +def test_internal_user_can_access_spend_log_detail( + user_role: LitellmUserRoles, +) -> None: + user_obj = LiteLLM_UserTable( + user_id="internal_user", + user_email="user@example.com", + user_role=user_role.value, + ) + valid_token = UserAPIKeyAuth( + user_id="internal_user", + user_role=user_role.value, + ) + request = MagicMock(spec=Request) + request.query_params = {} + + RouteChecks.non_proxy_admin_allowed_routes_check( + user_obj=user_obj, + _user_role=user_role.value, + route="/spend/logs/ui/request-id", + request=request, + valid_token=valid_token, + request_data={}, + ) + + @pytest.mark.parametrize("route", ADMIN_VIEWER_LOGS_PAGE_ROUTES) def test_proxy_admin_viewer_can_access_logs_page_endpoints(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 4bcabfe853a..c505b0db341 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 @@ -340,6 +340,77 @@ def test_ui_view_request_response_forbids_non_admin_without_db(client, monkeypat app.dependency_overrides.pop(ps.user_api_key_auth, None) +def test_ui_view_request_response_forbids_missing_ownership_row( + client: TestClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + mock_prisma = MagicMock() + mock_prisma.db.litellm_spendlogs.find_unique = AsyncMock(return_value=None) + payload_logger = MagicMock() + payload_logger.get_request_response_payload = AsyncMock( + return_value={"response": "private response"} + ) + get_custom_loggers = MagicMock(return_value=[payload_logger]) + + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma) + monkeypatch.setattr( + litellm.logging_callback_manager, + "get_active_additional_logging_utils_from_custom_logger", + get_custom_loggers, + ) + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, + user_id="user_1", + ) + try: + response = client.get( + "/spend/logs/ui/missing-request", + headers={"Authorization": "Bearer sk-test"}, + ) + assert response.status_code == 403 + get_custom_loggers.assert_not_called() + payload_logger.get_request_response_payload.assert_not_awaited() + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + +def test_ui_view_request_response_forbids_different_user( + client: TestClient, + monkeypatch: pytest.MonkeyPatch, +) -> None: + spend_log_row = MagicMock() + spend_log_row.user = "user_2" + spend_log_row.team_id = None + mock_prisma = MagicMock() + mock_prisma.db.litellm_spendlogs.find_unique = AsyncMock(return_value=spend_log_row) + payload_logger = MagicMock() + payload_logger.get_request_response_payload = AsyncMock( + return_value={"response": "private response"} + ) + get_custom_loggers = MagicMock(return_value=[payload_logger]) + + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma) + monkeypatch.setattr( + litellm.logging_callback_manager, + "get_active_additional_logging_utils_from_custom_logger", + get_custom_loggers, + ) + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, + user_id="user_1", + ) + try: + response = client.get( + "/spend/logs/ui/other-user-request", + headers={"Authorization": "Bearer sk-test"}, + ) + assert response.status_code == 403 + get_custom_loggers.assert_not_called() + payload_logger.get_request_response_payload.assert_not_awaited() + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + ignored_keys = [ "request_id", "session_id",