fix(proxy): explain enterprise spend permission rejections

This commit is contained in:
G1lollipop 2026-10-16 20:12:52 -05:00
parent aa32e4cc88
commit 14e7f8327b
2 changed files with 91 additions and 5 deletions

View file

@ -5052,10 +5052,16 @@ 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"])
saved_permissions: Final = TypeAdapter(dict[str, object] | None).validate_json(saved_token["permissions"])
if saved_permissions is not None and "get_spend_routes" in saved_permissions and premium_user is not True:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail={
"error": "get_spend_routes permission is only available for LiteLLM Enterprise users. "
f"{CommonProxyErrors.not_premium_user.value}"
},
)
saved_token["permissions"] = saved_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

@ -1,7 +1,7 @@
import asyncio
from collections.abc import Mapping
from contextlib import ExitStack
from typing import Final
from typing import Final, Literal
from types import SimpleNamespace
import json
from datetime import datetime, timedelta, timezone
@ -99,6 +99,7 @@ from litellm.proxy.management_endpoints.key_management_endpoints import (
validate_key_team_change,
)
from litellm.proxy.proxy_server import app
from litellm.types.proxy.common_proxy_errors import CommonProxyErrors
from litellm.types.proxy.management_endpoints.key_management_endpoints import (
BulkUpdateKeyRequest,
BulkUpdateKeyResponse,
@ -17941,6 +17942,85 @@ async def test_permissions_admin_can_set_any(monkeypatch):
assert result is not None
@pytest.mark.asyncio
@pytest.mark.parametrize(
("request_type", "table_name", "premium_user", "permission_value"),
[
pytest.param("key", "key", False, True, id="key-non-premium-true"),
pytest.param("key", "key", None, True, id="key-premium-unavailable-true"),
pytest.param("key", "key", False, False, id="key-non-premium-false"),
pytest.param("key", "key", False, "false", id="key-non-premium-string-false"),
pytest.param("user", None, False, True, id="user-non-premium-true"),
pytest.param("user", None, None, True, id="user-premium-unavailable-true"),
],
)
async def test_generate_key_helper_rejects_get_spend_routes_for_non_premium(
monkeypatch: pytest.MonkeyPatch,
request_type: Literal["key", "user"],
table_name: Literal["key"] | None,
premium_user: bool | None,
permission_value: bool | str,
) -> None:
mock_prisma_client: Final = AsyncMock()
monkeypatch.setattr("litellm.proxy.proxy_server.prisma_client", mock_prisma_client)
monkeypatch.setattr("litellm.proxy.proxy_server.premium_user", premium_user)
with pytest.raises(HTTPException) as exc_info:
await generate_key_helper_fn(
request_type=request_type,
token="sk-test-generate-key-license-check",
table_name=table_name,
permissions={"get_spend_routes": permission_value},
)
assert exc_info.value.status_code == 403
assert exc_info.value.detail == {
"error": (
"get_spend_routes permission is only available for LiteLLM Enterprise users. "
f"{CommonProxyErrors.not_premium_user.value}"
)
}
mock_prisma_client.insert_data.assert_not_awaited()
@pytest.mark.asyncio
@pytest.mark.parametrize(
("premium_user", "permissions"),
[
pytest.param(True, {"get_spend_routes": True}, id="licensed-global-spend"),
pytest.param(False, None, id="no-permissions"),
pytest.param(False, {}, id="empty-permissions"),
pytest.param(False, {"allow_pii_controls": True}, id="ordinary-permission"),
pytest.param(False, {"ordinary_permission": "get_spend_routes"}, id="substring-in-value"),
],
)
async def test_generate_key_helper_persists_allowed_permissions(
monkeypatch: pytest.MonkeyPatch,
premium_user: bool,
permissions: dict[str, object] | None,
) -> None:
mock_prisma_client: Final = AsyncMock()
mock_prisma_client.insert_data.return_value = SimpleNamespace(
token=None,
litellm_budget_table=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", premium_user)
result: Final = await generate_key_helper_fn(
request_type="key",
token="sk-test-generate-key-license-check",
table_name="key",
permissions=permissions,
)
mock_prisma_client.insert_data.assert_awaited_once()
assert result["permissions"] == json.dumps(permissions)
assert json.loads(mock_prisma_client.insert_data.call_args.kwargs["data"]["permissions"]) == permissions
@pytest.mark.asyncio
async def test_permissions_explicit_empty_rejected_for_non_admin_on_generate(monkeypatch):
"""`_common_key_generation_helper` rejects a non-admin when