From ef34e44d8ba4edda04904903630099b67a01bbfb Mon Sep 17 00:00:00 2001 From: Yuneng Jiang Date: Fri, 18 Sep 2026 21:00:18 -0700 Subject: [PATCH] fix(proxy): parse role_permissions where it is read load_config used to return a local general_settings dict that it had normalized in place, turning the configured role_permissions entries into RoleBasedPermissions objects. It now returns the SettingsStore, which never saw that write, so JWT auth received raw dicts and every request failed with "'dict' object has no attribute 'role'" whenever role_permissions was set. Convert the entries in the consumer instead, with a TypeAdapter, so the value is parsed wherever it comes from. load_config keeps validating at boot, so a malformed entry still fails startup rather than the first request. --- litellm/proxy/auth/auth_checks.py | 22 +++--- litellm/proxy/proxy_server.py | 6 +- tests/test_litellm/proxy/test_proxy_server.py | 74 +++++++++++++++++++ 3 files changed, 87 insertions(+), 15 deletions(-) diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 4de00f19db3..5db8b62e84d 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -15,10 +15,10 @@ import re import time from collections.abc import Awaitable, Callable, Iterator, Mapping, Sequence from types import MappingProxyType -from typing import TYPE_CHECKING, Any, Final, Literal, Optional, Protocol, cast +from typing import TYPE_CHECKING, Any, Final, Literal, Optional, Protocol from fastapi import HTTPException, Request, status -from pydantic import BaseModel +from pydantic import BaseModel, TypeAdapter from typing_extensions import ReadOnly, TypedDict import litellm @@ -2414,22 +2414,22 @@ def _update_last_db_access_time(key: str, value: object | None, last_db_access_t last_db_access_time[key] = (value, time.time()) +ROLE_BASED_PERMISSIONS_ADAPTER: Final[TypeAdapter[list[RoleBasedPermissions]]] = TypeAdapter(list[RoleBasedPermissions]) + + def _get_role_based_permissions( rbac_role: RBAC_ROLES, - general_settings: dict, + general_settings: Mapping[str, object], key: Literal["models", "routes"], ) -> list[str] | None: """ Get the role based permissions from the general settings. """ - role_based_permissions: Final = cast( - list[RoleBasedPermissions] | None, - general_settings.get("role_permissions", []), - ) - if role_based_permissions is None: + configured: Final = general_settings.get("role_permissions") + if configured is None: return None - for role_based_permission in role_based_permissions: + for role_based_permission in ROLE_BASED_PERMISSIONS_ADAPTER.validate_python(configured): if role_based_permission.role == rbac_role: return role_based_permission.models if key == "models" else role_based_permission.routes @@ -2438,7 +2438,7 @@ def _get_role_based_permissions( def get_role_based_models( rbac_role: RBAC_ROLES, - general_settings: dict, + general_settings: Mapping[str, object], ) -> list[str] | None: """ Get the models allowed for a user role. @@ -2455,7 +2455,7 @@ def get_role_based_models( def get_role_based_routes( rbac_role: RBAC_ROLES, - general_settings: dict, + general_settings: Mapping[str, object], ) -> list[str] | None: """ Get the routes allowed for a user role. diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 7791f034fba..daf94b79849 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -111,7 +111,6 @@ from litellm.proxy._types import ( PassThroughGenericEndpoint, ProxyErrorTypes, ProxyException, - RoleBasedPermissions, SpecialModelNames, SupportedDBObjectType, TeamDefaultSettings, @@ -317,6 +316,7 @@ from litellm.proxy.analytics_endpoints.analytics_endpoints import ( router as analytics_router, ) from litellm.proxy.auth.auth_checks import ( + ROLE_BASED_PERMISSIONS_ADAPTER, ExperimentalUIJWTToken, can_key_call_resolved_model, get_team_object, @@ -6307,9 +6307,7 @@ class ProxyConfig: ### RBAC ### rbac_role_permissions: Final = general_settings.get("role_permissions", None) if rbac_role_permissions is not None: - general_settings["role_permissions"] = [ # validate role permissions - RoleBasedPermissions(**role_permission) for role_permission in rbac_role_permissions - ] + ROLE_BASED_PERMISSIONS_ADAPTER.validate_python(rbac_role_permissions) ### SSRF URL VALIDATION SETTINGS ### _apply_ssrf_general_settings(general_settings) diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index 8ec24e25326..263300d12b1 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -3237,6 +3237,80 @@ async def test_load_config_max_budget_env_var_coerced_to_float(tmp_path, monkeyp litellm.max_budget = original_max_budget +@pytest.mark.asyncio +async def test_load_config_role_permissions_usable_by_jwt_auth(tmp_path): + from litellm.proxy.auth.auth_checks import get_role_based_models, get_role_based_routes + from litellm.proxy.proxy_server import ProxyConfig + + config_file: Final = tmp_path / "config.yaml" + config_file.write_text( + yaml.dump( + { + "model_list": [], + "general_settings": { + "role_permissions": [ + { + "role": "proxy_admin", + "models": ["admin-only-model"], + "routes": ["/v1/embeddings"], + }, + { + "role": "internal_user", + "models": ["shared-model"], + "routes": ["/v1/chat/completions"], + }, + ] + }, + } + ) + ) + + _, _, settings = await ProxyConfig().load_config(router=MagicMock(), config_file_path=str(config_file)) + + assert get_role_based_models(rbac_role="internal_user", general_settings=settings) == ["shared-model"] + assert get_role_based_routes(rbac_role="internal_user", general_settings=settings) == ["/v1/chat/completions"] + assert get_role_based_models(rbac_role="proxy_admin", general_settings=settings) == ["admin-only-model"] + assert get_role_based_routes(rbac_role="proxy_admin", general_settings=settings) == ["/v1/embeddings"] + assert get_role_based_models(rbac_role="team", general_settings=settings) is None + + +@pytest.mark.asyncio +async def test_load_config_without_role_permissions_leaves_every_role_unrestricted(tmp_path): + from litellm.proxy.auth.auth_checks import get_role_based_models, get_role_based_routes + from litellm.proxy.proxy_server import ProxyConfig + + config_file: Final = tmp_path / "config.yaml" + config_file.write_text( + yaml.dump({"model_list": [], "general_settings": {"max_parallel_requests": 7}}) + ) + + _, _, settings = await ProxyConfig().load_config(router=MagicMock(), config_file_path=str(config_file)) + + assert settings["max_parallel_requests"] == 7 + assert get_role_based_models(rbac_role="internal_user", general_settings=settings) is None + assert get_role_based_routes(rbac_role="internal_user", general_settings=settings) is None + + +@pytest.mark.asyncio +async def test_load_config_rejects_malformed_role_permissions(tmp_path): + from pydantic import ValidationError + + from litellm.proxy.proxy_server import ProxyConfig + + config_file: Final = tmp_path / "config.yaml" + config_file.write_text( + yaml.dump( + { + "model_list": [], + "general_settings": {"role_permissions": [{"role": "not_a_real_role", "models": ["gpt-4o"]}]}, + } + ) + ) + + with pytest.raises(ValidationError): + await ProxyConfig().load_config(router=MagicMock(), config_file_path=str(config_file)) + + def test_max_ui_session_budget_default_is_one_dollar(): """LIT-4662: the dashboard session budget default is a product decision; the old 0.25 default locked admins out of auto router Test Connection and the