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
This commit is contained in:
er-s-an 2026-10-17 09:32:14 +08:00
parent dddbaa803d
commit 7f54c36ee1
2 changed files with 102 additions and 4 deletions

View file

@ -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"))

View file

@ -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.