diff --git a/litellm/proxy/auth/route_checks.py b/litellm/proxy/auth/route_checks.py index 125bfab4edf..6d97fd16c3e 100644 --- a/litellm/proxy/auth/route_checks.py +++ b/litellm/proxy/auth/route_checks.py @@ -369,7 +369,7 @@ class RouteChecks: route in LiteLLMRoutes.global_spend_tracking_routes.value and getattr(valid_token, "permissions", None) is not None and "get_spend_routes" in getattr(valid_token, "permissions", []) - ): + ) or route in ("/spend/logs", "/spend/logs/v2"): pass elif route == "/lens" or route.startswith("/lens/"): pass # Lens authorizes the delegated role, including its POST-based read endpoints. diff --git a/litellm/proxy/spend_tracking/spend_management_endpoints.py b/litellm/proxy/spend_tracking/spend_management_endpoints.py index fcb9eb4a280..8b5152c5610 100644 --- a/litellm/proxy/spend_tracking/spend_management_endpoints.py +++ b/litellm/proxy/spend_tracking/spend_management_endpoints.py @@ -2741,11 +2741,34 @@ async def ui_view_spend_logs( if status_condition: where_conditions.update(status_condition) - if api_key is not None: - where_conditions["api_key"] = api_key - - if user_id is not None: - where_conditions["user"] = user_id + caller_key_hash: Final = _extract_caller_key_hash( + user_api_key_dict=user_api_key_dict, prisma_client=prisma_client + ) + if not is_admin_view and is_v2 and caller_key_hash is not None: + if api_key is not None: + requested_key_hash: Final = ( + _hash_token_safe(prisma_client, api_key) if api_key.startswith("sk-") else api_key + ) + if requested_key_hash != caller_key_hash: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={"error": f"Not authorized to view spend logs for api_key={api_key}"}, + ) + where_conditions["api_key"] = caller_key_hash + if user_id is not None: + if user_api_key_dict.user_id is None or user_id != user_api_key_dict.user_id: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={"error": f"Not authorized to view spend logs for user_id={user_id}"}, + ) + where_conditions["user"] = user_id + else: + if api_key is not None: + where_conditions["api_key"] = ( + _hash_token_safe(prisma_client, api_key) if api_key.startswith("sk-") and is_v2 else api_key + ) + if user_id is not None: + where_conditions["user"] = user_id if request_id is not None: where_conditions["request_id"] = request_id @@ -2811,6 +2834,7 @@ async def ui_view_spend_logs( ) user_scope_applies: Final = ( not is_admin_view + and (not is_v2 or caller_key_hash is None) and team_id is None and (is_request_id_lookup or _can_user_view_spend_log(user_api_key_dict=user_api_key_dict)) ) @@ -3470,11 +3494,16 @@ async def view_spend_logs( """ from litellm.proxy.proxy_server import prisma_client - if ( - user_api_key_dict.user_role == LitellmUserRoles.INTERNAL_USER - or user_api_key_dict.user_role == LitellmUserRoles.INTERNAL_USER_VIEW_ONLY - ): - user_id = user_api_key_dict.user_id + is_admin: Final = is_admin_view_safe(user_api_key_dict=user_api_key_dict) + caller_key_hash: Final = _extract_caller_key_hash(user_api_key_dict=user_api_key_dict, prisma_client=prisma_client) + effective_api_key, effective_user_id = _resolve_effective_filters( + api_key=api_key, + user_id=user_id, + user_api_key_dict=user_api_key_dict, + is_admin=is_admin, + caller_key_hash=caller_key_hash, + prisma_client=prisma_client, + ) try: verbose_proxy_logger.debug("inside view_spend_logs") @@ -3504,16 +3533,16 @@ async def view_spend_logs( } summary_api_key: Final[str | None] = ( - prisma_client.hash_token(token=api_key) - if api_key is not None and api_key.startswith("sk-") - else api_key + _hash_token_safe(prisma_client, effective_api_key) + if effective_api_key is not None and effective_api_key.startswith("sk-") + else effective_api_key ) - if api_key is not None and isinstance(api_key, str): + if effective_api_key is not None: filter_query["api_key"] = summary_api_key if request_id is not None and isinstance(request_id, str): filter_query["OR"] = _request_id_or_call_id_clause(request_id) - if user_id is not None and isinstance(user_id, str): - filter_query["user"] = user_id + if effective_user_id is not None: + filter_query["user"] = effective_user_id # Check if user wants unsummarized data if not summarize: @@ -3533,7 +3562,7 @@ async def view_spend_logs( end_date_iso=end_date_iso, api_key=summary_api_key, request_id=request_id, - user_id=user_id, + user_id=effective_user_id, ) sql_query, params = summary_sql_and_params rows: Final[Sequence[_SpendDailySummaryRow]] = await _query_raw(prisma_client, sql_query, *params) @@ -3560,16 +3589,17 @@ async def view_spend_logs( else: scoped_filter: Final[dict[str, object]] = {} - if api_key is not None and isinstance(api_key, str): - if api_key.startswith("sk-"): - hashed_token = prisma_client.hash_token(token=api_key) - else: - hashed_token = api_key + if effective_api_key is not None: + hashed_token: Final = ( + _hash_token_safe(prisma_client, effective_api_key) + if effective_api_key.startswith("sk-") + else effective_api_key + ) scoped_filter["api_key"] = hashed_token if request_id is not None and isinstance(request_id, str): scoped_filter["OR"] = _request_id_or_call_id_clause(request_id) - if user_id is not None and isinstance(user_id, str): - scoped_filter["user"] = user_id + if effective_user_id is not None: + scoped_filter["user"] = effective_user_id data = await _find_spend_logs( prisma_client, @@ -4772,6 +4802,56 @@ def is_admin_view_safe(user_api_key_dict: UserAPIKeyAuth) -> bool: _is_admin_view_safe: Final = is_admin_view_safe +def _hash_token_safe(prisma_client: PrismaClient | None, token: str) -> str: + if prisma_client is not None and hasattr(prisma_client, "hash_token"): + return prisma_client.hash_token(token=token) + return hash_token(token) + + +def _extract_caller_key_hash( + user_api_key_dict: UserAPIKeyAuth, + prisma_client: PrismaClient | None = None, +) -> str | None: + raw_or_hash: Final = user_api_key_dict.token or user_api_key_dict.api_key + if raw_or_hash is None: + return None + if raw_or_hash.startswith("sk-"): + return _hash_token_safe(prisma_client, raw_or_hash) + return raw_or_hash + + +def _resolve_effective_filters( + api_key: str | None, + user_id: str | None, + user_api_key_dict: UserAPIKeyAuth, + is_admin: bool, + caller_key_hash: str | None, + prisma_client: PrismaClient | None, +) -> tuple[str | None, str | None]: + if not is_admin and caller_key_hash is not None: + if api_key is not None: + requested_key_hash: Final = ( + _hash_token_safe(prisma_client, api_key) if api_key.startswith("sk-") else api_key + ) + if requested_key_hash != caller_key_hash: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Key not allowed to view spend logs for another key. api_key={api_key}", + ) + if user_id is not None and (user_api_key_dict.user_id is None or user_id != user_api_key_dict.user_id): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail=f"Key not allowed to view spend logs for another user. user_id={user_id}", + ) + return (caller_key_hash, user_api_key_dict.user_id if user_id is None else user_id) + if ( + user_api_key_dict.user_role == LitellmUserRoles.INTERNAL_USER + or user_api_key_dict.user_role == LitellmUserRoles.INTERNAL_USER_VIEW_ONLY + ): + return (api_key, user_api_key_dict.user_id) + return (api_key, user_id) + + async def _can_team_member_view_log( prisma_client: PrismaClient, user_api_key_dict: UserAPIKeyAuth, diff --git a/tests/unit/proxy/auth/test_route_checks.py b/tests/unit/proxy/auth/test_route_checks.py index efb4451f2fc..751b01474b3 100644 --- a/tests/unit/proxy/auth/test_route_checks.py +++ b/tests/unit/proxy/auth/test_route_checks.py @@ -215,7 +215,9 @@ def test_user_banner_read_open_to_non_admin_roles(role): LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value, ], ) -def test_latest_release_info_read_open_to_non_admin_roles(role): # test-quality-ok: allowed path returns None, not raising is the observable +def test_latest_release_info_read_open_to_non_admin_roles( + role, +): # test-quality-ok: allowed path returns None, not raising is the observable user_obj = LiteLLM_UserTable( user_id="test_user", user_email="test@example.com", @@ -4495,7 +4497,7 @@ def test_team_service_account_key_allowed_key_management_routes(route): assert result is None -@pytest.mark.parametrize("route", ["/team/new", "/spend/logs", "/key/delete", "/key/regenerate"]) +@pytest.mark.parametrize("route", ["/team/new", "/key/delete", "/key/regenerate"]) def test_team_service_account_key_rejected_outside_generate_and_update(route): """The service account carve-out covers only /key/generate and /key/update; other key-management routes lack team scoping for a userless caller and stay denied.""" @@ -5102,3 +5104,22 @@ def test_every_dashboard_role_can_send_telemetry_ui_events( ) except HTTPException as exc: pytest.fail(f"{user_role.value} was denied {method} {route}: {exc.detail}") + + +@pytest.mark.parametrize("route", ["/spend/logs", "/spend/logs/v2"]) +def test_spend_logs_routes_allowed_for_plain_virtual_key(route: str) -> None: + request: Final = MagicMock(spec=Request) + request.method = "GET" + request.query_params = {} + valid_token: Final = UserAPIKeyAuth(token="hashed_token_value") + assert ( + RouteChecks.non_proxy_admin_allowed_routes_check( + user_obj=None, + _user_role=None, + route=route, + request=request, + valid_token=valid_token, + request_data={}, + ) + is None + ) diff --git a/tests/unit/proxy/spend_tracking/test_spend_management_endpoints.py b/tests/unit/proxy/spend_tracking/test_spend_management_endpoints.py index e119acd5ffc..030595fafbf 100644 --- a/tests/unit/proxy/spend_tracking/test_spend_management_endpoints.py +++ b/tests/unit/proxy/spend_tracking/test_spend_management_endpoints.py @@ -5901,6 +5901,250 @@ async def test_view_spend_logs_date_range_hashes_sk_api_key(client, monkeypatch) app.dependency_overrides.pop(ps.user_api_key_auth, None) +@pytest.mark.asyncio +async def test_view_spend_logs_plain_virtual_key_defaults_to_own_key_hash(client, monkeypatch): + mock_client: Final = _CapturePrismaClient() + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_client) + + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + token="key-hash-123", + user_role=None, + user_id=None, + ) + try: + response: Final = client.get( + "/spend/logs", + params={"start_date": "2024-01-01", "end_date": "2024-12-31", "summarize": "false"}, + headers={"Authorization": "Bearer sk-test"}, + ) + assert response.status_code == 200 + where: Final = mock_client.db.captured_where + assert where is not None + assert where["api_key"] == "key-hash-123" + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + +@pytest.mark.asyncio +async def test_ui_view_spend_logs_v2_plain_virtual_key_with_matching_sk_raw_api_key(client, monkeypatch): + token_hash: Final = "key-hash-123" + mock_spend_logs: Final = [ + { + "id": "log1", + "request_id": "req1", + "api_key": token_hash, + "user": None, + "team_id": None, + "spend": 0.05, + "startTime": datetime.datetime.now(timezone.utc).isoformat(), + "model": "gpt-4", + }, + ] + + def filter_by_key(where): + if "api_key" in where and where["api_key"] == token_hash: + return [mock_spend_logs[0]] + return [] + + mock_prisma: Final = make_ui_spend_logs_mock_prisma(mock_spend_logs, filter_by_key) + mock_prisma.hash_token = lambda token: token_hash if token == "sk-raw-token" else "other" + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma) + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + token=token_hash, + user_role=None, + user_id=None, + ) + try: + start_date, end_date = _default_date_range() + response: Final = client.get( + "/spend/logs/v2", + params={"api_key": "sk-raw-token", "start_date": start_date, "end_date": end_date}, + headers={"Authorization": "Bearer sk-test"}, + ) + assert response.status_code == 200 + data: Final = response.json() + assert data["total"] == 1 + assert len(data["data"]) == 1 + assert data["data"][0]["api_key"] == token_hash + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + +@pytest.mark.asyncio +async def test_view_spend_logs_plain_virtual_key_with_matching_api_key(client, monkeypatch): + mock_client: Final = _CapturePrismaClient() + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_client) + + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + token="key-hash-123", + user_role=None, + user_id=None, + ) + try: + response: Final = client.get( + "/spend/logs", + params={"api_key": "key-hash-123"}, + headers={"Authorization": "Bearer sk-test"}, + ) + assert response.status_code == 200 + where: Final = mock_client.db.captured_where + assert where is not None + assert where["api_key"] == "key-hash-123" + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + +@pytest.mark.asyncio +async def test_view_spend_logs_plain_virtual_key_rejects_foreign_api_key(client, monkeypatch): + mock_client: Final = _CapturePrismaClient() + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_client) + + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + token="key-hash-123", + user_role=None, + user_id=None, + ) + try: + response: Final = client.get( + "/spend/logs", + params={"api_key": "different-key-hash"}, + headers={"Authorization": "Bearer sk-test"}, + ) + assert response.status_code == 403 + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + +@pytest.mark.asyncio +async def test_view_spend_logs_plain_virtual_key_rejects_foreign_user_id(client, monkeypatch): + mock_client: Final = _CapturePrismaClient() + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_client) + + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + token="key-hash-123", + user_role=None, + user_id=None, + ) + try: + response: Final = client.get( + "/spend/logs", + params={"user_id": "other-user"}, + headers={"Authorization": "Bearer sk-test"}, + ) + assert response.status_code == 403 + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + +@pytest.mark.asyncio +async def test_ui_view_spend_logs_v2_plain_virtual_key_defaults_to_own_key_hash(client, monkeypatch): + mock_spend_logs: Final = [ + { + "id": "log1", + "request_id": "req1", + "api_key": "key-hash-123", + "user": None, + "team_id": None, + "spend": 0.05, + "startTime": datetime.datetime.now(timezone.utc).isoformat(), + "model": "gpt-4", + }, + { + "id": "log2", + "request_id": "req2", + "api_key": "other-key-hash", + "user": None, + "team_id": None, + "spend": 0.10, + "startTime": datetime.datetime.now(timezone.utc).isoformat(), + "model": "gpt-4", + }, + ] + + def filter_by_key(where): + if "api_key" in where and where["api_key"] == "key-hash-123": + return [mock_spend_logs[0]] + return mock_spend_logs + + monkeypatch.setattr( + "litellm.proxy.proxy_server.prisma_client", + make_ui_spend_logs_mock_prisma(mock_spend_logs, filter_by_key), + ) + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + token="key-hash-123", + user_role=None, + user_id=None, + ) + try: + start_date, end_date = _default_date_range() + response: Final = client.get( + "/spend/logs/v2", + params={"start_date": start_date, "end_date": end_date}, + headers={"Authorization": "Bearer sk-test"}, + ) + assert response.status_code == 200 + data: Final = response.json() + assert data["total"] == 1 + assert len(data["data"]) == 1 + assert data["data"][0]["api_key"] == "key-hash-123" + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + +@pytest.mark.asyncio +async def test_ui_view_spend_logs_v2_plain_virtual_key_rejects_foreign_api_key(client, monkeypatch): + monkeypatch.setattr( + "litellm.proxy.proxy_server.prisma_client", + make_ui_spend_logs_mock_prisma([], lambda _where: []), + ) + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + token="key-hash-123", + user_role=None, + user_id=None, + ) + try: + start_date, end_date = _default_date_range() + response: Final = client.get( + "/spend/logs/v2", + params={ + "api_key": "other-key-hash", + "start_date": start_date, + "end_date": end_date, + }, + headers={"Authorization": "Bearer sk-test"}, + ) + assert response.status_code == 403 + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + +@pytest.mark.asyncio +async def test_ui_view_spend_logs_v2_plain_virtual_key_rejects_foreign_user_id(client, monkeypatch): + monkeypatch.setattr( + "litellm.proxy.proxy_server.prisma_client", + make_ui_spend_logs_mock_prisma([], lambda _where: []), + ) + app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth( + token="key-hash-123", + user_role=None, + user_id=None, + ) + try: + start_date, end_date = _default_date_range() + response: Final = client.get( + "/spend/logs/v2", + params={ + "user_id": "other-user", + "start_date": start_date, + "end_date": end_date, + }, + headers={"Authorization": "Bearer sk-test"}, + ) + assert response.status_code == 403 + finally: + app.dependency_overrides.pop(ps.user_api_key_auth, None) + + class _SpendScopeMockPrismaClient: def __init__(self, get_data_returns=None, find_many_returns=None): self._get_data_returns = get_data_returns if get_data_returns is not None else []