From 7f54c36ee191a22e5ca900e541b559ec217f6cf4 Mon Sep 17 00:00:00 2001 From: er-s-an <92594860+er-s-an@users.noreply.github.com> Date: Sat, 17 Oct 2026 09:32:14 +0800 Subject: [PATCH] fix(proxy): return 403 for Enterprise-only get_spend_routes on /key/generate Without a licence, requesting get_spend_routes raised ValueError and the helper turned it into a bare 500. Raise HTTPException 403 that names the Enterprise requirement, matching other premium gates. Also only refuse when get_spend_routes is truthy so an explicit false is not blocked by a substring check on the serialized permissions JSON. Fixes #47011 --- .../key_management_endpoints.py | 22 ++++- .../test_key_management_endpoints.py | 84 +++++++++++++++++++ 2 files changed, 102 insertions(+), 4 deletions(-) diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index 0ad4258fb04..5b85cde5e77 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -5259,10 +5259,24 @@ async def generate_key_helper_fn( if isinstance(saved_token["metadata"], str): saved_token["metadata"] = json.loads(saved_token["metadata"]) if isinstance(saved_token["permissions"], str): - if "get_spend_routes" in saved_token["permissions"] and premium_user is not True: - raise ValueError("get_spend_routes permission is only available for LiteLLM Enterprise users") - - saved_token["permissions"] = json.loads(saved_token["permissions"]) + parsed_permissions = json.loads(saved_token["permissions"]) + # Refuse only when the permission is requested (truthy). An explicit + # false must not trip a substring check on the serialized JSON. + if ( + isinstance(parsed_permissions, dict) + and parsed_permissions.get("get_spend_routes") + and premium_user is not True + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={ + "error": ( + "get_spend_routes permission is only available for " + f"LiteLLM Enterprise users. {CommonProxyErrors.not_premium_user.value}" + ) + }, + ) + saved_token["permissions"] = parsed_permissions if isinstance(saved_token["model_max_budget"], str): saved_token["model_max_budget"] = json.loads(saved_token["model_max_budget"]) router_settings = cast(dict | None, saved_token.get("router_settings")) diff --git a/tests/unit/proxy/management_endpoints/test_key_management_endpoints.py b/tests/unit/proxy/management_endpoints/test_key_management_endpoints.py index d6ee9b1d29e..537fcb623cf 100644 --- a/tests/unit/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/unit/proxy/management_endpoints/test_key_management_endpoints.py @@ -951,6 +951,90 @@ async def test_generate_key_helper_fn_with_access_group_ids(monkeypatch): assert captured_key_data.get("access_group_ids") == ["ag-1", "ag-2"] +@pytest.mark.asyncio +async def test_generate_key_helper_fn_get_spend_routes_non_premium_returns_403( + monkeypatch, +): + """Without Enterprise, requesting get_spend_routes must be a 403 that + names the licence — not a bare 500 Internal Server Error. (#47011)""" + mock_prisma_client = AsyncMock() + mock_prisma_client.jsonify_object = lambda data: data # type: ignore + mock_prisma_client.db = MagicMock() + mock_prisma_client.db.litellm_objectpermissiontable = MagicMock() + mock_prisma_client.db.litellm_objectpermissiontable.create = AsyncMock( + return_value=MagicMock(object_permission_id=None) + ) + mock_prisma_client.db.query_raw = AsyncMock(return_value=[]) + mock_prisma_client.insert_data = AsyncMock( + return_value=MagicMock( + token="hashed_token_spend", + litellm_budget_table=None, + object_permission=None, + created_at=None, + updated_at=None, + ) + ) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", False) + + from fastapi import HTTPException + + from litellm.proxy.management_endpoints.key_management_endpoints import ( + generate_key_helper_fn, + ) + + with pytest.raises(HTTPException) as exc_info: + await generate_key_helper_fn( + request_type="key", + table_name="key", + user_id="test-user", + permissions={"get_spend_routes": True}, + ) + assert exc_info.value.status_code == 403 + detail = str(exc_info.value.detail) + assert "get_spend_routes" in detail + assert "Enterprise" in detail + + +@pytest.mark.asyncio +async def test_generate_key_helper_fn_get_spend_routes_false_allowed_non_premium( + monkeypatch, +): + """Explicit get_spend_routes=false must not be refused on OSS. (#47011)""" + mock_prisma_client = AsyncMock() + mock_prisma_client.jsonify_object = lambda data: data # type: ignore + mock_prisma_client.db = MagicMock() + mock_prisma_client.db.litellm_objectpermissiontable = MagicMock() + mock_prisma_client.db.litellm_objectpermissiontable.create = AsyncMock( + return_value=MagicMock(object_permission_id=None) + ) + mock_prisma_client.db.query_raw = AsyncMock(return_value=[]) + mock_prisma_client.insert_data = AsyncMock( + return_value=MagicMock( + token="hashed_token_spend_false", + litellm_budget_table=None, + object_permission=None, + created_at=None, + updated_at=None, + ) + ) + monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", False) + + from litellm.proxy.management_endpoints.key_management_endpoints import ( + generate_key_helper_fn, + ) + + result = await generate_key_helper_fn( + request_type="key", + table_name="key", + user_id="test-user", + permissions={"get_spend_routes": False}, + ) + assert result is not None + assert result.get("token") or result.get("key") or result.get("token_id") + + @pytest.mark.asyncio async def test_generate_key_helper_fn_with_budget_fallbacks(monkeypatch): """Regression: /key/generate must accept `budget_fallbacks` end-to-end.