diff --git a/enterprise/litellm_enterprise/enterprise_callbacks/__init__.py b/enterprise/litellm_enterprise/enterprise_callbacks/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/enterprise/litellm_enterprise/enterprise_callbacks/pagerduty/__init__.py b/enterprise/litellm_enterprise/enterprise_callbacks/pagerduty/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/enterprise/litellm_enterprise/enterprise_callbacks/send_emails/__init__.py b/enterprise/litellm_enterprise/enterprise_callbacks/send_emails/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/enterprise/litellm_enterprise/integrations/__init__.py b/enterprise/litellm_enterprise/integrations/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/enterprise/litellm_enterprise/litellm_core_utils/__init__.py b/enterprise/litellm_enterprise/litellm_core_utils/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/enterprise/litellm_enterprise/proxy/hooks/__init__.py b/enterprise/litellm_enterprise/proxy/hooks/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py b/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py index 9d668985eb8..1f693526d1f 100644 --- a/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py +++ b/enterprise/litellm_enterprise/proxy/management_endpoints/project_endpoints.py @@ -831,7 +831,7 @@ async def project_info( ) # Check if user has access to this project (admin or team member) - is_admin = user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN + is_admin = user_api_key_has_admin_view(user_api_key_dict) is_team_member = False if project.team_id and user_api_key_dict.user_id: @@ -886,7 +886,7 @@ async def list_projects( ) # If proxy admin, get all projects - if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN: + if user_api_key_has_admin_view(user_api_key_dict): projects: Sequence[ prisma_models.LiteLLM_ProjectTable ] = await prisma_client.db.litellm_projecttable.find_many( diff --git a/enterprise/litellm_enterprise/proxy/vector_stores/__init__.py b/enterprise/litellm_enterprise/proxy/vector_stores/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/enterprise/litellm_enterprise/py.typed b/enterprise/litellm_enterprise/py.typed new file mode 100644 index 00000000000..e69de29bb2d diff --git a/enterprise/litellm_enterprise/types/__init__.py b/enterprise/litellm_enterprise/types/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/enterprise/litellm_enterprise/types/enterprise_callbacks/__init__.py b/enterprise/litellm_enterprise/types/enterprise_callbacks/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/enterprise/litellm_enterprise/types/proxy/__init__.py b/enterprise/litellm_enterprise/types/proxy/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm-proxy-extras/litellm_proxy_extras/py.typed b/litellm-proxy-extras/litellm_proxy_extras/py.typed new file mode 100644 index 00000000000..e69de29bb2d diff --git a/litellm/__init__.py b/litellm/__init__.py index 319da4e25eb..89310120768 100644 --- a/litellm/__init__.py +++ b/litellm/__init__.py @@ -2150,9 +2150,9 @@ def __getattr__(name: str) -> Any: # Lazy load encoding from main.py to avoid heavy tiktoken import if name == "encoding": - from ._lazy_imports import _get_litellm_globals + from ._lazy_imports import get_litellm_globals - _globals = _get_litellm_globals() + _globals = get_litellm_globals() # Check if already cached if "encoding" not in _globals: from .main import encoding as _encoding @@ -2162,9 +2162,9 @@ def __getattr__(name: str) -> Any: # Lazy load bedrock_tool_name_mappings instance if name == "bedrock_tool_name_mappings": - from ._lazy_imports import _get_litellm_globals + from ._lazy_imports import get_litellm_globals - _globals = _get_litellm_globals() + _globals = get_litellm_globals() # Check if already cached if "bedrock_tool_name_mappings" not in _globals: from .llms.bedrock.chat.invoke_handler import ( @@ -2176,9 +2176,9 @@ def __getattr__(name: str) -> Any: # Lazy load AzureOpenAIError exception class if name == "AzureOpenAIError": - from ._lazy_imports import _get_litellm_globals + from ._lazy_imports import get_litellm_globals - _globals = _get_litellm_globals() + _globals = get_litellm_globals() # Check if already cached if "AzureOpenAIError" not in _globals: from .llms.azure.common_utils import AzureOpenAIError as _AzureOpenAIError @@ -2188,9 +2188,9 @@ def __getattr__(name: str) -> Any: # Lazy load openaiOSeriesConfig instance if name == "openaiOSeriesConfig": - from ._lazy_imports import _get_litellm_globals + from ._lazy_imports import get_litellm_globals - _globals = _get_litellm_globals() + _globals = get_litellm_globals() if "openaiOSeriesConfig" not in _globals: # Import the config class and instantiate it config_class = __getattr__("OpenAIOSeriesConfig") @@ -2206,9 +2206,9 @@ def __getattr__(name: str) -> Any: "nvidiaNimEmbeddingConfig": "NvidiaNimEmbeddingConfig", } if name in _config_instances: - from ._lazy_imports import _get_litellm_globals + from ._lazy_imports import get_litellm_globals - _globals = _get_litellm_globals() + _globals = get_litellm_globals() if name not in _globals: # Import the config class and instantiate it config_class = __getattr__(_config_instances[name]) @@ -2221,9 +2221,9 @@ def __getattr__(name: str) -> Any: # Lazy load provider_list if name == "provider_list": - from ._lazy_imports import _get_litellm_globals + from ._lazy_imports import get_litellm_globals - _globals = _get_litellm_globals() + _globals = get_litellm_globals() # Check if already cached if "provider_list" not in _globals: # LlmProviders is eagerly imported above, so we can import it directly @@ -2234,9 +2234,9 @@ def __getattr__(name: str) -> Any: # Lazy load priority_reservation_settings instance if name == "priority_reservation_settings": - from ._lazy_imports import _get_litellm_globals + from ._lazy_imports import get_litellm_globals - _globals = _get_litellm_globals() + _globals = get_litellm_globals() # Check if already cached if "priority_reservation_settings" not in _globals: # Import the class and instantiate it @@ -2246,9 +2246,9 @@ def __getattr__(name: str) -> Any: # Lazy load logging_callback_manager instance if name == "logging_callback_manager": - from ._lazy_imports import _get_litellm_globals + from ._lazy_imports import get_litellm_globals - _globals = _get_litellm_globals() + _globals = get_litellm_globals() # Check if already cached if "logging_callback_manager" not in _globals: # Import the class and instantiate it @@ -2258,9 +2258,9 @@ def __getattr__(name: str) -> Any: # Lazy load _service_logger module if name == "_service_logger": - from ._lazy_imports import _get_litellm_globals + from ._lazy_imports import get_litellm_globals - _globals = _get_litellm_globals() + _globals = get_litellm_globals() # Check if already cached if "_service_logger" not in _globals: # Import the module lazily diff --git a/litellm/_lazy_imports.py b/litellm/_lazy_imports.py index 63142ee4f2f..933464d3f23 100644 --- a/litellm/_lazy_imports.py +++ b/litellm/_lazy_imports.py @@ -54,7 +54,7 @@ from ._lazy_imports_registry import ( ) -def _get_litellm_globals() -> dict: +def get_litellm_globals() -> dict: """ Get the globals dictionary of the litellm module. @@ -233,7 +233,7 @@ def _generic_lazy_import(name: str, import_map: dict[str, tuple[str, str]], cate raise AttributeError(f"{category} lazy import: unknown attribute {name!r}") # Step 2: Get the cache (where we store imported things) - _globals: Final = _get_litellm_globals() + _globals: Final = get_litellm_globals() # Step 3: If we've already imported it, just return the cached version if name in _globals: @@ -332,7 +332,7 @@ def _lazy_import_utils_module(name: str) -> Any: Handler for utils module lazy imports. This uses a custom implementation because utils module needs to use - _get_utils_globals() instead of _get_litellm_globals() for caching. + _get_utils_globals() instead of get_litellm_globals() for caching. """ # Check if this attribute exists in our map if name not in _UTILS_MODULE_IMPORT_MAP: @@ -379,7 +379,7 @@ def _lazy_import_llm_client_cache(name: str) -> Any: - "in_memory_llm_clients_cache" is a singleton instance of that class So we need custom logic to handle both cases. """ - _globals: Final = _get_litellm_globals() + _globals: Final = get_litellm_globals() # If already cached, return it if name in _globals: @@ -412,7 +412,7 @@ def _lazy_import_http_handlers(name: str) -> Any: - They need configuration (timeout, etc.) from the module globals - They use factory functions instead of direct instantiation """ - _globals: Final = _get_litellm_globals() + _globals: Final = get_litellm_globals() if name == "module_level_aclient": # Create an async HTTP client using the factory function diff --git a/litellm/litellm_core_utils/audio_utils/utils.py b/litellm/litellm_core_utils/audio_utils/utils.py index 0f9addb16f7..3b3775a8fe6 100644 --- a/litellm/litellm_core_utils/audio_utils/utils.py +++ b/litellm/litellm_core_utils/audio_utils/utils.py @@ -180,6 +180,7 @@ def get_audio_file_content_hash(file_obj: FileTypes) -> str: if isinstance(file_obj, tuple): if len(file_obj) < 2: fallback_filename = str(file_obj[0]) if len(file_obj) > 0 else None + file_content_obj = None else: fallback_filename = str(file_obj[0]) if file_obj[0] is not None else None file_content_obj = file_obj[1] @@ -206,7 +207,7 @@ def get_audio_file_content_hash(file_obj: FileTypes) -> str: except OSError: fallback_filename = str(file_content_obj) file_content = None - elif hasattr(file_content_obj, "read"): + elif file_content_obj is not None and hasattr(file_content_obj, "read"): try: current_position: Final = file_content_obj.tell() if hasattr(file_content_obj, "tell") else None if hasattr(file_content_obj, "seek"): diff --git a/litellm/litellm_core_utils/prompt_templates/factory.py b/litellm/litellm_core_utils/prompt_templates/factory.py index d51d31eaa3b..3a1a426eaa9 100644 --- a/litellm/litellm_core_utils/prompt_templates/factory.py +++ b/litellm/litellm_core_utils/prompt_templates/factory.py @@ -3684,7 +3684,7 @@ def _convert_to_bedrock_tool_call_invoke( # cache_control applies to the whole original # tool call; attach after the last split block. if tool.get("cache_control", None) is not None: - _cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block( + _cache_point_block = litellm.AmazonConverseConfig().get_cache_point_block( {"cache_control": tool["cache_control"]}, block_type="content_block", model=model, @@ -3701,7 +3701,7 @@ def _convert_to_bedrock_tool_call_invoke( # Check for cache_control and add a separate cachePoint block if tool.get("cache_control", None) is not None: - cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block( + cache_point_block = litellm.AmazonConverseConfig().get_cache_point_block( {"cache_control": tool["cache_control"]}, block_type="content_block", model=model, @@ -4360,7 +4360,7 @@ class BedrockConverseMessagesProcessor: elif element["type"] == "document": _part = BedrockConverseMessagesProcessor._process_document_message(element) _parts.append(_part) - _cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block( + _cache_point_block = litellm.AmazonConverseConfig().get_cache_point_block( message_block=cast(OpenAIMessageContentListBlock, element), block_type="content_block", model=model, @@ -4370,7 +4370,7 @@ class BedrockConverseMessagesProcessor: user_content.extend(_parts) elif message_block["content"] and isinstance(message_block["content"], str): _part = BedrockContentBlock(text=messages[msg_i]["content"]) - _cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block( + _cache_point_block = litellm.AmazonConverseConfig().get_cache_point_block( message_block, block_type="content_block", model=model ) user_content.append(_part) @@ -4417,7 +4417,7 @@ class BedrockConverseMessagesProcessor: # Add a separate cachePoint block if cache_control is present if tool_msg_cache_control is not None: - cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block( + cache_point_block = litellm.AmazonConverseConfig().get_cache_point_block( {"cache_control": tool_msg_cache_control}, block_type="content_block", model=model, @@ -4496,7 +4496,7 @@ class BedrockConverseMessagesProcessor: assistants_part = await BedrockImageProcessor.process_image_async(image_url=image_url) assistants_parts.append(assistants_part) # Add cache point block for assistant content elements - _cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block( + _cache_point_block = litellm.AmazonConverseConfig().get_cache_point_block( message_block=cast(OpenAIMessageContentListBlock, element), block_type="content_block", model=model, @@ -4510,7 +4510,7 @@ class BedrockConverseMessagesProcessor: assistant_content.append(BedrockContentBlock(text=_assistant_content)) # If content is empty/whitespace, skip it (don't add a placeholder) # Add cache point block for assistant string content - _cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block( + _cache_point_block = litellm.AmazonConverseConfig().get_cache_point_block( assistant_message_block, block_type="content_block", model=model ) if _cache_point_block is not None: @@ -4733,7 +4733,7 @@ def _bedrock_converse_messages_pt( elif element["type"] == "document": _part = BedrockConverseMessagesProcessor._process_document_message(element) _parts.append(_part) - _cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block( + _cache_point_block = litellm.AmazonConverseConfig().get_cache_point_block( message_block=cast(OpenAIMessageContentListBlock, element), block_type="content_block", model=model, @@ -4743,7 +4743,7 @@ def _bedrock_converse_messages_pt( user_content.extend(_parts) elif message_block["content"] and isinstance(message_block["content"], str): _part = BedrockContentBlock(text=messages[msg_i]["content"]) - _cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block( + _cache_point_block = litellm.AmazonConverseConfig().get_cache_point_block( message_block, block_type="content_block", model=model ) user_content.append(_part) @@ -4792,7 +4792,7 @@ def _bedrock_converse_messages_pt( # Add a separate cachePoint block if cache_control is present if tool_msg_cache_control is not None: - cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block( + cache_point_block = litellm.AmazonConverseConfig().get_cache_point_block( {"cache_control": tool_msg_cache_control}, block_type="content_block", model=model, @@ -4874,7 +4874,7 @@ def _bedrock_converse_messages_pt( assistants_part = BedrockImageProcessor.process_image_sync(image_url=image_url) assistants_parts.append(assistants_part) # Add cache point block for assistant content elements - _cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block( + _cache_point_block = litellm.AmazonConverseConfig().get_cache_point_block( message_block=cast(OpenAIMessageContentListBlock, element), block_type="content_block", model=model, @@ -4887,7 +4887,7 @@ def _bedrock_converse_messages_pt( if _assistant_content.strip(): assistant_content.append(BedrockContentBlock(text=_assistant_content)) # Add cache point block for assistant string content - _cache_point_block = litellm.AmazonConverseConfig()._get_cache_point_block( + _cache_point_block = litellm.AmazonConverseConfig().get_cache_point_block( assistant_message_block, block_type="content_block", model=model ) if _cache_point_block is not None: diff --git a/litellm/llms/bedrock/chat/converse_transformation.py b/litellm/llms/bedrock/chat/converse_transformation.py index 0b1689b8ee4..193987a3543 100644 --- a/litellm/llms/bedrock/chat/converse_transformation.py +++ b/litellm/llms/bedrock/chat/converse_transformation.py @@ -1081,7 +1081,7 @@ class AmazonConverseConfig(BaseConfig): optional_params["maxTokens"] = thinking_token_budget + DEFAULT_MAX_TOKENS @overload - def _get_cache_point_block( + def get_cache_point_block( self, message_block: OpenAIMessageContentListBlock | ChatCompletionUserMessage @@ -1093,7 +1093,7 @@ class AmazonConverseConfig(BaseConfig): pass @overload - def _get_cache_point_block( + def get_cache_point_block( self, message_block: OpenAIMessageContentListBlock | ChatCompletionUserMessage @@ -1104,7 +1104,7 @@ class AmazonConverseConfig(BaseConfig): ) -> ContentBlock | None: pass - def _get_cache_point_block( + def get_cache_point_block( self, message_block: OpenAIMessageContentListBlock | ChatCompletionUserMessage @@ -1149,14 +1149,14 @@ class AmazonConverseConfig(BaseConfig): system_prompt_indices.append(idx) if isinstance(message["content"], str) and message["content"]: system_content_blocks.append(SystemContentBlock(text=message["content"])) - cache_block = self._get_cache_point_block(message, block_type="system", model=model) + cache_block = self.get_cache_point_block(message, block_type="system", model=model) if cache_block: system_content_blocks.append(cache_block) elif isinstance(message["content"], list): for m in message["content"]: if m.get("type") == "text" and m.get("text"): system_content_blocks.append(SystemContentBlock(text=m["text"])) - cache_block = self._get_cache_point_block(m, block_type="system", model=model) + cache_block = self.get_cache_point_block(m, block_type="system", model=model) if cache_block: system_content_blocks.append(cache_block) if len(system_prompt_indices) > 0: diff --git a/litellm/llms/xai/oauth.py b/litellm/llms/xai/oauth.py index 8f303e9585f..37dae93a725 100644 --- a/litellm/llms/xai/oauth.py +++ b/litellm/llms/xai/oauth.py @@ -40,7 +40,7 @@ class XAIOAuthLoginRequiredError(XAIOAuthError): class _CallbackHandler(BaseHTTPRequestHandler): - server: "_CallbackServer" + server: "_CallbackServer" # pyright: ignore[reportIncompatibleVariableOverride] # stdlib stubs type server as BaseServer; _CallbackServer is the only server this handler is registered on def do_GET(self) -> None: parsed: Final = urlparse(self.path) diff --git a/litellm/proxy/_experimental/mcp_server/elicitation_handler.py b/litellm/proxy/_experimental/mcp_server/elicitation_handler.py index 66c262a6eb9..ce7e963f55f 100644 --- a/litellm/proxy/_experimental/mcp_server/elicitation_handler.py +++ b/litellm/proxy/_experimental/mcp_server/elicitation_handler.py @@ -9,10 +9,19 @@ MCP Spec Reference: https://modelcontextprotocol.io/specification/2025-11-25/client/elicitation """ -from typing import Any, Final, Union +from typing import TYPE_CHECKING, Any, Final, Union from litellm._logging import verbose_logger +if TYPE_CHECKING: + from mcp.types import ( + ElicitRequestFormParams, + ElicitRequestParams, + ElicitRequestURLParams, + ElicitResult, + ErrorData, + ) + # Guard imports that require the mcp package try: from mcp.types import ( diff --git a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py index 3fb8e6fe9bb..76618e0f742 100644 --- a/litellm/proxy/_experimental/mcp_server/rest_endpoints.py +++ b/litellm/proxy/_experimental/mcp_server/rest_endpoints.py @@ -30,7 +30,11 @@ from litellm.proxy._experimental.mcp_server.utils import ( get_server_prefix, merge_mcp_headers, ) -from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth +from litellm.proxy._types import ( + LitellmUserRoles, + UserAPIKeyAuth, + user_api_key_has_admin_view, +) from litellm.proxy.auth.ip_address_utils import IPAddressUtils from litellm.proxy.auth.user_api_key_auth import user_api_key_auth @@ -738,9 +742,7 @@ if MCP_AVAILABLE: # The full catalog (allowlist filter skipped) is admin-only so the # REST endpoint can't be used to enumerate deliberately-disabled tools. - apply_tool_filters: Final = not ( - include_disabled_tools and user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN - ) + apply_tool_filters: Final = not (include_disabled_tools and user_api_key_has_admin_view(user_api_key_dict)) if server_id is None: server_id = mcp_server_name diff --git a/litellm/proxy/_experimental/mcp_server/sampling_handler.py b/litellm/proxy/_experimental/mcp_server/sampling_handler.py index 45490385df8..0f5c02bd781 100644 --- a/litellm/proxy/_experimental/mcp_server/sampling_handler.py +++ b/litellm/proxy/_experimental/mcp_server/sampling_handler.py @@ -18,7 +18,15 @@ if typing.TYPE_CHECKING: from fastapi import Request from mcp.client.session import ClientSession from mcp.shared.context import RequestContext - from mcp.types import ContentBlock, SamplingMessageContentBlock + from mcp.types import ( + ContentBlock, + CreateMessageResult, + CreateMessageResultWithTools, + ErrorData, + SamplingMessageContentBlock, + TextContent, + ToolUseContent, + ) from litellm.proxy._types import UserAPIKeyAuth from litellm.proxy.utils import ProxyLogging diff --git a/litellm/proxy/_experimental/mcp_server/server.py b/litellm/proxy/_experimental/mcp_server/server.py index f2267bbcf7f..ef7bfd4b4f6 100644 --- a/litellm/proxy/_experimental/mcp_server/server.py +++ b/litellm/proxy/_experimental/mcp_server/server.py @@ -79,6 +79,8 @@ from litellm.types.utils import CallTypes, StandardLoggingMCPToolCall from litellm.utils import Rules, client, function_setup if TYPE_CHECKING: + from mcp.server.session import ServerSession as _McpServerSession + from litellm.proxy._experimental.mcp_server.db import OAuthCredentialPayload # Short-lived in-memory cache for BYOK credentials. @@ -144,10 +146,6 @@ try: # Robust auth lookup keyed by session_object. _session_obj_auth_storage: "weakref.WeakKeyDictionary[Any, MCPAuthenticatedUser]" = weakref.WeakKeyDictionary() - - active_mcp_session_var: Final[contextvars.ContextVar[_McpServerSession | None]] = contextvars.ContextVar( - "active_mcp_session", default=None - ) except ImportError as e: verbose_logger.debug("MCP module not found: %s", e) MCP_AVAILABLE = False @@ -163,6 +161,10 @@ except ImportError as e: Server = None TextResourceContents = None +active_mcp_session_var: Final[contextvars.ContextVar["_McpServerSession | None"]] = contextvars.ContextVar( + "active_mcp_session", default=None +) + # Global variables to track initialization _SESSION_MANAGERS_INITIALIZED = False diff --git a/litellm/proxy/agent_endpoints/endpoints.py b/litellm/proxy/agent_endpoints/endpoints.py index f729d422d1d..1f9c6e1cc05 100644 --- a/litellm/proxy/agent_endpoints/endpoints.py +++ b/litellm/proxy/agent_endpoints/endpoints.py @@ -21,7 +21,12 @@ import litellm from litellm._logging import verbose_proxy_logger from litellm.litellm_core_utils.litellm_logging import _get_masked_values from litellm.llms.custom_httpx.http_handler import get_async_httpx_client -from litellm.proxy._types import CommonProxyErrors, LitellmUserRoles, UserAPIKeyAuth +from litellm.proxy._types import ( + CommonProxyErrors, + LitellmUserRoles, + UserAPIKeyAuth, + user_api_key_has_admin_view, +) from litellm.proxy.a2a.agent_card import ( SUPPORTED_A2A_PROTOCOL_VERSIONS, merge_agent_card, @@ -468,11 +473,7 @@ async def get_agent_by_id( """ await check_feature_access_for_user(user_api_key_dict, "agents") - is_admin = ( - user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN - or user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value - ) - if not is_admin: + if not user_api_key_has_admin_view(user_api_key_dict): from litellm.proxy.agent_endpoints.auth.agent_permission_handler import ( AgentRequestHandler, ) diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 5ef4eb471ad..f17c9fff31a 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -832,9 +832,6 @@ def _is_user_proxy_admin(user_obj: LiteLLM_UserTable | None): if user_obj.user_role is not None and user_obj.user_role == LitellmUserRoles.PROXY_ADMIN.value: return True - if user_obj.user_role is not None and user_obj.user_role == LitellmUserRoles.PROXY_ADMIN.value: - return True - return False diff --git a/litellm/proxy/auth/route_checks.py b/litellm/proxy/auth/route_checks.py index 8a34438b141..04eb7ab326b 100644 --- a/litellm/proxy/auth/route_checks.py +++ b/litellm/proxy/auth/route_checks.py @@ -260,7 +260,11 @@ class RouteChecks: query_params: Final = request.query_params user_id: Final = query_params.get("user_id") verbose_proxy_logger.debug("user_id: %s & valid_token.user_id: %s", user_id, valid_token.user_id) - if user_id and user_id != valid_token.user_id: + if ( + user_id + and user_id != valid_token.user_id + and _user_role != LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value + ): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, detail=f"key not allowed to access this user's info. user_id={user_id}, key's user_id={valid_token.user_id}", diff --git a/litellm/proxy/example_config_yaml/custom_guardrail.py b/litellm/proxy/example_config_yaml/custom_guardrail.py index 2f53bb4675a..979976ddfc4 100644 --- a/litellm/proxy/example_config_yaml/custom_guardrail.py +++ b/litellm/proxy/example_config_yaml/custom_guardrail.py @@ -1,11 +1,10 @@ -from typing import Any, Dict, Final, List, Literal, Optional, Union +from typing import Dict, Final, Optional, Union import litellm from litellm._logging import verbose_proxy_logger from litellm.caching.caching import DualCache from litellm.integrations.custom_guardrail import CustomGuardrail from litellm.proxy._types import UserAPIKeyAuth -from litellm.proxy.guardrails.guardrail_helpers import should_proceed_based_on_metadata from litellm.types.utils import CallTypesLiteral # Global counter for tracking which guardrail was called (for load balancing tests) diff --git a/litellm/proxy/example_config_yaml/custom_handler.py b/litellm/proxy/example_config_yaml/custom_handler.py index 3bf998c726a..c0483dd3304 100644 --- a/litellm/proxy/example_config_yaml/custom_handler.py +++ b/litellm/proxy/example_config_yaml/custom_handler.py @@ -1,9 +1,7 @@ -import time -from typing import Any, Final, Optional +from typing import Final import litellm -from litellm import CustomLLM, ImageObject, ImageResponse, completion, get_llm_provider -from litellm.llms.custom_httpx.http_handler import AsyncHTTPHandler +from litellm import CustomLLM from litellm.types.utils import ModelResponse diff --git a/litellm/proxy/guardrails/guardrail_endpoints.py b/litellm/proxy/guardrails/guardrail_endpoints.py index 79c4362055e..aef5f2deac4 100644 --- a/litellm/proxy/guardrails/guardrail_endpoints.py +++ b/litellm/proxy/guardrails/guardrail_endpoints.py @@ -212,7 +212,7 @@ async def list_guardrails_v2( from litellm.proxy.guardrails.guardrail_registry import IN_MEMORY_GUARDRAIL_HANDLER from litellm.proxy.proxy_server import prisma_client - is_admin: Final = user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN + is_admin: Final = _user_has_admin_view(user_api_key_dict) try: guardrails = ( @@ -944,7 +944,7 @@ async def get_guardrail_submission( if prisma_client is None: raise HTTPException(status_code=500, detail="Prisma client not initialized") - is_admin: Final = user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN + is_admin: Final = _user_has_admin_view(user_api_key_dict) try: row: Final = await _guardrails_table(prisma_client).find_unique(where={"guardrail_id": guardrail_id}) diff --git a/litellm/proxy/management_endpoints/coordination_redis_endpoints.py b/litellm/proxy/management_endpoints/coordination_redis_endpoints.py index 2cfb5cd8793..fe9a613656d 100644 --- a/litellm/proxy/management_endpoints/coordination_redis_endpoints.py +++ b/litellm/proxy/management_endpoints/coordination_redis_endpoints.py @@ -33,6 +33,7 @@ from litellm.proxy._types import ( LitellmTableNames, LitellmUserRoles, UserAPIKeyAuth, + user_api_key_has_admin_view, ) from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.utils import invalidate_config_param @@ -302,7 +303,8 @@ async def get_coordination_redis_settings( - fields: all configurable settings with their metadata (type, description, default, section) - source: "coordination_redis" | "cache_backend" | "environment" | null """ - _enforce_proxy_admin(user_api_key_dict) + if not user_api_key_has_admin_view(user_api_key_dict): + _enforce_proxy_admin(user_api_key_dict) settings: Final = await _current_coordination_redis_settings() source: Final = _coordination_redis_source(settings) diff --git a/litellm/proxy/management_endpoints/internal_user_endpoints.py b/litellm/proxy/management_endpoints/internal_user_endpoints.py index 97b4ec76c50..cefc7371ce6 100644 --- a/litellm/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/proxy/management_endpoints/internal_user_endpoints.py @@ -714,11 +714,10 @@ def _enforce_user_info_access(user_id: str | None, user_api_key_dict: UserAPIKey """ if user_id is None: return - # Only true proxy admin bypasses ownership. PROXY_ADMIN_VIEW_ONLY is - # subject to the same `user_id == valid_token.user_id` rule that - # `RouteChecks.non_proxy_admin_allowed_routes_check` applies upstream - # for the `/user/info` route. - if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN: + # Admin-view roles (PROXY_ADMIN and PROXY_ADMIN_VIEW_ONLY) bypass + # ownership, mirroring the `/user/info` carve-out that + # `RouteChecks.non_proxy_admin_allowed_routes_check` applies upstream. + if _user_has_admin_view(user_api_key_dict): return if user_id == user_api_key_dict.user_id: return @@ -862,7 +861,7 @@ async def user_info( raise Exception( "Database not connected. Connect a database to your proxy - https://docs.litellm.ai/docs/simple_proxy#managing-auth---virtual-keys" ) - if user_id is None and user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN: + if user_id is None and _user_has_admin_view(user_api_key_dict): return await _get_user_info_for_proxy_admin(user_api_key_dict=user_api_key_dict) elif user_id is None: user_id = user_api_key_dict.user_id diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index e4def45892b..068429890c8 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -78,6 +78,7 @@ from litellm.proxy.management_endpoints.common_utils import ( _is_user_team_admin, _set_object_metadata_field, _team_member_has_permission, + _user_has_admin_view, validate_finite_spend, ) from litellm.proxy.management_endpoints.model_management_endpoints import ( @@ -5102,7 +5103,7 @@ async def validate_key_list_check( key_hash: str | None, prisma_client: PrismaClient, ) -> LiteLLM_UserTable | None: - if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value: + if _user_has_admin_view(user_api_key_dict): return None if user_api_key_dict.user_id is None: diff --git a/litellm/proxy/management_endpoints/workflow_management_endpoints.py b/litellm/proxy/management_endpoints/workflow_management_endpoints.py index 7e2c7404199..70a6cc507f5 100644 --- a/litellm/proxy/management_endpoints/workflow_management_endpoints.py +++ b/litellm/proxy/management_endpoints/workflow_management_endpoints.py @@ -25,7 +25,12 @@ except ImportError: from pydantic import BaseModel from litellm._logging import verbose_proxy_logger -from litellm.proxy._types import CommonProxyErrors, LitellmUserRoles, UserAPIKeyAuth +from litellm.proxy._types import ( + CommonProxyErrors, + LitellmUserRoles, + UserAPIKeyAuth, + user_api_key_has_admin_view, +) from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.repositories.table_repositories import ( WorkflowEventRepository, @@ -47,6 +52,10 @@ def _is_admin(user_api_key_dict: UserAPIKeyAuth) -> bool: return user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value +def _read_scope_caller(user_api_key_dict: UserAPIKeyAuth) -> UserAPIKeyAuth | None: + return None if user_api_key_has_admin_view(user_api_key_dict) else user_api_key_dict + + def _caller_key(user_api_key_dict: UserAPIKeyAuth) -> str | None: """Return the hashed key token that identifies this caller, or None for master key.""" return user_api_key_dict.token @@ -199,7 +208,7 @@ async def list_workflow_runs( where["status"] = {"in": statuses} if len(statuses) > 1 else statuses[0] # Non-admin callers are scoped to their own key. - if not _is_admin(user_api_key_dict): + if not user_api_key_has_admin_view(user_api_key_dict): caller: Final = _caller_key(user_api_key_dict) if caller: where["created_by"] = caller @@ -238,7 +247,7 @@ async def get_workflow_run( ) if run is None: raise HTTPException(status_code=404, detail=f"Run '{run_id}' not found") - if not _is_admin(user_api_key_dict): + if not user_api_key_has_admin_view(user_api_key_dict): caller: Final = _caller_key(user_api_key_dict) if not caller or run.created_by != caller: raise HTTPException(status_code=404, detail=f"Run '{run_id}' not found") @@ -377,7 +386,7 @@ async def list_workflow_events( if prisma_client is None: raise HTTPException(status_code=500, detail=CommonProxyErrors.db_not_connected_error.value) - await _require_run(prisma_client, run_id, user_api_key_dict) + await _require_run(prisma_client, run_id, _read_scope_caller(user_api_key_dict)) try: events: Final = await WorkflowEventRepository(prisma_client).table.find_many( @@ -461,7 +470,7 @@ async def list_workflow_messages( if prisma_client is None: raise HTTPException(status_code=500, detail=CommonProxyErrors.db_not_connected_error.value) - await _require_run(prisma_client, run_id, user_api_key_dict) + await _require_run(prisma_client, run_id, _read_scope_caller(user_api_key_dict)) try: messages: Final = await WorkflowMessageRepository(prisma_client).table.find_many( diff --git a/litellm/proxy/memory/memory_endpoints.py b/litellm/proxy/memory/memory_endpoints.py index 33d131bf3b2..987823d987f 100644 --- a/litellm/proxy/memory/memory_endpoints.py +++ b/litellm/proxy/memory/memory_endpoints.py @@ -27,6 +27,7 @@ from litellm.proxy._types import ( CommonProxyErrors, LitellmUserRoles, UserAPIKeyAuth, + user_api_key_has_admin_view, ) from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.repositories.table_repositories import MemoryRepository @@ -66,7 +67,7 @@ def _visibility_filter(user_api_key_dict: UserAPIKeyAuth) -> dict | None: Prisma `where` fragment restricting rows to those the caller can see. Returns None for admins (no restriction). """ - if _is_admin(user_api_key_dict): + if user_api_key_has_admin_view(user_api_key_dict): return None ors: Final[list[dict]] = [] if user_api_key_dict.user_id: diff --git a/litellm/proxy/prompts/prompt_endpoints.py b/litellm/proxy/prompts/prompt_endpoints.py index 4ac88f87596..d8e9f8dfaee 100644 --- a/litellm/proxy/prompts/prompt_endpoints.py +++ b/litellm/proxy/prompts/prompt_endpoints.py @@ -18,7 +18,12 @@ from fastapi import ( from pydantic import BaseModel from litellm._logging import verbose_proxy_logger -from litellm.proxy._types import CommonProxyErrors, LitellmUserRoles, UserAPIKeyAuth +from litellm.proxy._types import ( + CommonProxyErrors, + LitellmUserRoles, + UserAPIKeyAuth, + user_api_key_has_admin_view, +) from litellm.proxy.auth.auth_utils import is_request_body_safe from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.common_utils.path_utils import safe_filename @@ -317,7 +322,6 @@ async def list_prompts( } ``` """ - from litellm.proxy._types import LitellmUserRoles from litellm.proxy.prompts.prompt_registry import IN_MEMORY_PROMPT_REGISTRY # check key metadata for prompts @@ -347,10 +351,7 @@ async def list_prompts( prompt_list.append(prompt_copy) return ListPromptsResponse(prompts=prompt_list) # check if user is proxy admin - show all prompts - if user_api_key_dict.user_role is not None and ( - user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN - or user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value - ): + if user_api_key_has_admin_view(user_api_key_dict): # Get all prompts and filter to show only the latest version of each all_prompts = list(IN_MEMORY_PROMPT_REGISTRY.IN_MEMORY_PROMPTS.values()) if environment: @@ -422,10 +423,7 @@ async def get_prompt_versions( from litellm.proxy.proxy_server import prisma_client # Only allow proxy admins to view version history - if user_api_key_dict.user_role is None or ( - user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN - and user_api_key_dict.user_role != LitellmUserRoles.PROXY_ADMIN.value - ): + if not user_api_key_has_admin_view(user_api_key_dict): raise HTTPException(status_code=403, detail="Only proxy admins can view prompt versions") base_prompt_id: Final = get_base_prompt_id(prompt_id=prompt_id) @@ -581,12 +579,7 @@ async def get_prompt_info( prompts = cast(list[str] | None, user_api_key_dict.metadata.get("prompts", None)) if prompts is not None and prompt_id not in prompts: raise HTTPException(status_code=400, detail=f"Prompt {prompt_id} not found") - if user_api_key_dict.user_role is not None and ( - user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN - or user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value - ): - pass - else: + if not user_api_key_has_admin_view(user_api_key_dict): raise HTTPException( status_code=403, detail=f"You are not authorized to access this prompt. Your role - {user_api_key_dict.user_role}, Your key's prompts - {prompts}", diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index 3cb2f795c61..61c6ce22a91 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -8876,7 +8876,7 @@ async def model_list( # Check if scope=expand is requested and user has admin privileges should_expand_scope = False if scope == "expand": - should_expand_scope = await _user_has_admin_privileges( + should_expand_scope = _user_has_admin_view(user_api_key_dict) or await _user_has_admin_privileges( user_api_key_dict=user_api_key_dict, prisma_client=prisma_client, user_api_key_cache=user_api_key_cache, @@ -11479,7 +11479,7 @@ async def _populate_team_access_on_models( """ user_teams: list[str] | Literal["*"] | None = None direct_access_models: list[str] = [] - if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN: + if _user_has_admin_view(user_api_key_dict): user_teams = "*" direct_access_models = llm_router.get_model_ids(exclude_team_models=True) # has access to all models elif user_api_key_dict.user_id is not None: diff --git a/litellm/types/adapter.py b/litellm/types/adapter.py index 2995cfbc1c2..924fabcb86d 100644 --- a/litellm/types/adapter.py +++ b/litellm/types/adapter.py @@ -1,6 +1,4 @@ -from typing import List - -from typing_extensions import Dict, Required, TypedDict, override +from typing_extensions import TypedDict from litellm.integrations.custom_logger import CustomLogger diff --git a/litellm/types/google_genai/main.py b/litellm/types/google_genai/main.py index 467db318057..876a4d4533e 100644 --- a/litellm/types/google_genai/main.py +++ b/litellm/types/google_genai/main.py @@ -1,8 +1,6 @@ # Import types from the Google GenAI SDK -from typing import TYPE_CHECKING, Any, Dict, List, Optional, TypeAlias +from typing import TYPE_CHECKING, Any, Dict, Optional -from pydantic import BaseModel -from typing_extensions import TypedDict from litellm.types.llms.openai import BaseLiteLLMOpenAIResponseObject diff --git a/litellm/types/integrations/argilla.py b/litellm/types/integrations/argilla.py index 52dad347304..2def010a722 100644 --- a/litellm/types/integrations/argilla.py +++ b/litellm/types/integrations/argilla.py @@ -1,7 +1,4 @@ -import os -from datetime import datetime as dt -from enum import Enum -from typing import Any, Dict, Final, List, Literal, Optional, Set +from typing import Any, Dict, Final, List from typing_extensions import TypedDict diff --git a/litellm/types/llms/anthropic_skills.py b/litellm/types/llms/anthropic_skills.py index 22257888493..0659b499bcc 100644 --- a/litellm/types/llms/anthropic_skills.py +++ b/litellm/types/llms/anthropic_skills.py @@ -2,10 +2,10 @@ Type definitions for Anthropic Skills API """ -from typing import Any, Dict, List, Literal, Optional, Union +from typing import Any, Dict, List, Optional -from pydantic import BaseModel, Field -from typing_extensions import Required, TypedDict +from pydantic import BaseModel +from typing_extensions import TypedDict # Skills API Request Types diff --git a/litellm/types/llms/azure_ai.py b/litellm/types/llms/azure_ai.py index ddc9dbe3c55..49b7349c67e 100644 --- a/litellm/types/llms/azure_ai.py +++ b/litellm/types/llms/azure_ai.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, Final, Iterable, List, Literal, Optional, Union +from typing import List, Literal from typing_extensions import Required, TypedDict diff --git a/litellm/types/llms/custom_llm.py b/litellm/types/llms/custom_llm.py index d5499a41944..e57a7a28007 100644 --- a/litellm/types/llms/custom_llm.py +++ b/litellm/types/llms/custom_llm.py @@ -1,6 +1,4 @@ -from typing import List - -from typing_extensions import Dict, Required, TypedDict, override +from typing_extensions import TypedDict from litellm.llms.custom_llm import CustomLLM diff --git a/litellm/types/llms/databricks.py b/litellm/types/llms/databricks.py index 46f988ae4a0..c2bd0aa92bd 100644 --- a/litellm/types/llms/databricks.py +++ b/litellm/types/llms/databricks.py @@ -1,19 +1,12 @@ -import json -from typing import Any, Dict, Final, List, Literal, Optional, Union +from typing import Any, Dict, List, Literal, Optional, Union from pydantic import BaseModel from typing_extensions import ( - Protocol, Required, - Self, TypedDict, - TypeGuard, - get_origin, - override, - runtime_checkable, ) -from .openai import ChatCompletionToolCallChunk, ChatCompletionUsageBlock +from .openai import ChatCompletionUsageBlock class GenericStreamingChunk(TypedDict, total=False): diff --git a/litellm/types/llms/ollama.py b/litellm/types/llms/ollama.py index ca28120dd9d..9fcb6b755bd 100644 --- a/litellm/types/llms/ollama.py +++ b/litellm/types/llms/ollama.py @@ -1,16 +1,8 @@ -import json -from typing import Any, List, Optional, Union +from typing import List -from pydantic import BaseModel from typing_extensions import ( - Protocol, Required, - Self, TypedDict, - TypeGuard, - get_origin, - override, - runtime_checkable, ) diff --git a/litellm/types/llms/openrouter.py b/litellm/types/llms/openrouter.py index 39ed7e104fb..73bf647d4ea 100644 --- a/litellm/types/llms/openrouter.py +++ b/litellm/types/llms/openrouter.py @@ -1,6 +1,4 @@ -import json -from enum import Enum -from typing import Any, Dict, List, Literal, Optional, Tuple, Union +from typing import Dict from typing_extensions import TypedDict diff --git a/litellm/types/llms/rerank.py b/litellm/types/llms/rerank.py index fac093161c1..83cdb1caa0b 100644 --- a/litellm/types/llms/rerank.py +++ b/litellm/types/llms/rerank.py @@ -1,16 +1,7 @@ -import json -from enum import Enum -from typing import Any, Dict, List, Literal, Optional, Tuple, Union +from typing import Optional from typing_extensions import ( - Protocol, - Required, - Self, TypedDict, - TypeGuard, - get_origin, - override, - runtime_checkable, ) diff --git a/litellm/types/proxy/management_endpoints/internal_user_endpoints.py b/litellm/types/proxy/management_endpoints/internal_user_endpoints.py index faf2660a6f8..16f4c45e2f4 100644 --- a/litellm/types/proxy/management_endpoints/internal_user_endpoints.py +++ b/litellm/types/proxy/management_endpoints/internal_user_endpoints.py @@ -1,7 +1,6 @@ -from typing import Any, Dict, Final, List, Literal, Optional, Union +from typing import Any, Dict, Final, List, Optional -from fastapi import HTTPException -from pydantic import BaseModel, EmailStr, field_validator +from pydantic import BaseModel, field_validator from litellm.proxy._types import ( LiteLLM_UserTableWithKeyCount, diff --git a/tests/test_litellm/proxy/agent_endpoints/test_endpoints.py b/tests/test_litellm/proxy/agent_endpoints/test_endpoints.py index bcd3333baf9..3e097711ad7 100644 --- a/tests/test_litellm/proxy/agent_endpoints/test_endpoints.py +++ b/tests/test_litellm/proxy/agent_endpoints/test_endpoints.py @@ -368,6 +368,17 @@ class TestAgentByIdKeyRedaction: assert resp.status_code == 200 assert resp.json()["keys"] is None + def test_view_only_admin_reads_a_denied_agent_but_still_without_keys(self): + """proxy_admin_viewer skips the per-agent object_permission gate (denied + here) yet stays on the redacted response path.""" + with patch( + "litellm.proxy.agent_endpoints.auth.agent_permission_handler.AgentRequestHandler.is_agent_allowed", + AsyncMock(return_value=False), + ): + resp = self._get_as(LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY) + assert resp.status_code == 200 + assert resp.json()["keys"] is None + # ---------- RBAC enforcement tests ---------- @@ -469,6 +480,82 @@ class TestAgentRBACInternalUserViewOnly: assert resp.status_code == 403 +class TestAgentRBACProxyAdminViewOnly: + """Read-only proxy admins go through the object-permission scoped branch on + GET /v1/agents (the admin fast path stays full PROXY_ADMIN only, so viewers + cannot fan out health checks beyond their allowlist), and secret unredaction + also stays gated on full PROXY_ADMIN.""" + + @pytest.fixture(autouse=True) + def _setup(self, monkeypatch): + from litellm.proxy.agent_endpoints import agent_registry as ar_mod + + self.viewer_client = _make_app_with_role(LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY) + self.admin_client = _make_app_with_role(LitellmUserRoles.PROXY_ADMIN) + self.agents = [ + AgentResponse( + agent_id=f"agent-{index}", + agent_name=f"Agent {index}", + agent_card_params=_sample_agent_card_params(), + litellm_params={"api_key": "sk-super-secret-agent-key"}, + ) + for index in (1, 2) + ] + self.mock_registry = MagicMock() + self.mock_registry.get_agent_list = MagicMock(return_value=self.agents) + monkeypatch.setattr(ar_mod, "global_agent_registry", self.mock_registry) + + self.allowed_agents_spy = AsyncMock(return_value=["someone-elses-agent"]) + monkeypatch.setattr( + "litellm.proxy.agent_endpoints.auth.agent_permission_handler.AgentRequestHandler.get_allowed_agents", + self.allowed_agents_spy, + ) + + def _list_agents(self, test_client: TestClient): + key_row = MagicMock() + key_row.token = "hash-aaa" + key_row.agent_id = "agent-1" + key_row.key_alias = "primary" + key_row.key_name = "sk-...aaa" + + with patch("litellm.proxy.proxy_server.prisma_client") as mock_prisma: + mock_prisma.db.litellm_agentstable.find_many = AsyncMock(return_value=[]) + mock_prisma.db.litellm_verificationtoken.find_many = AsyncMock( + return_value=[key_row] + ) + return test_client.get("/v1/agents", headers={"Authorization": "Bearer k"}) + + def test_should_scope_view_only_admin_to_allowed_agents(self): + """The key/team allowlist here excludes every registered agent; a viewer + on the admin fast path would see everything, so an empty response pins + that viewers stay in the scoped branch.""" + resp = self._list_agents(self.viewer_client) + + assert resp.status_code == 200 + assert resp.json() == [] + self.allowed_agents_spy.assert_awaited_once() + + def test_should_still_redact_secrets_for_view_only_admin(self): + """An unrestricted viewer (empty allowlist means no restrictions) sees the + same agents as an admin but with keys stripped and litellm_params masked.""" + self.allowed_agents_spy.return_value = [] + viewer_resp = self._list_agents(self.viewer_client) + admin_resp = self._list_agents(self.admin_client) + + assert viewer_resp.status_code == 200 + viewer_by_id = {agent["agent_id"]: agent for agent in viewer_resp.json()} + assert set(viewer_by_id) == {"agent-1", "agent-2"} + assert viewer_by_id["agent-1"]["keys"] is None + assert "sk-super-secret-agent-key" not in viewer_resp.text + + admin_by_id = {agent["agent_id"]: agent for agent in admin_resp.json()} + assert admin_by_id["agent-1"]["keys"][0]["token"] == "hash-aaa" + assert ( + admin_by_id["agent-1"]["litellm_params"]["api_key"] + == "sk-super-secret-agent-key" + ) + + class TestAgentRBACProxyAdmin: """Proxy admins should have full CRUD access to agents.""" diff --git a/tests/test_litellm/proxy/auth/test_auth_checks.py b/tests/test_litellm/proxy/auth/test_auth_checks.py index d1b5395c73d..a5211ba83e7 100644 --- a/tests/test_litellm/proxy/auth/test_auth_checks.py +++ b/tests/test_litellm/proxy/auth/test_auth_checks.py @@ -5462,3 +5462,25 @@ async def test_get_project_object_db_fetch_returns_cached_obj(): assert isinstance(result, LiteLLM_ProjectTableCachedObj) assert result.project_id == "p-1" assert result.project_alias == "proj" + + +def test_is_user_proxy_admin_rejects_view_only_admin(): + """This predicate skips `non_proxy_admin_allowed_routes_check` entirely, so an + Admin Viewer answering True here would gain every write route. Read parity for + that role belongs in the route checks, never here.""" + from litellm.proxy.auth.auth_checks import _is_user_proxy_admin + + viewer = LiteLLM_UserTable( + user_id="viewer_user", + user_email="viewer@example.com", + user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value, + ) + admin = LiteLLM_UserTable( + user_id="admin_user", + user_email="admin@example.com", + user_role=LitellmUserRoles.PROXY_ADMIN.value, + ) + + assert _is_user_proxy_admin(user_obj=viewer) is False + assert _is_user_proxy_admin(user_obj=admin) is True + assert _is_user_proxy_admin(user_obj=None) is False diff --git a/tests/test_litellm/proxy/auth/test_route_checks.py b/tests/test_litellm/proxy/auth/test_route_checks.py index 87f5187b5a1..9285b997efc 100644 --- a/tests/test_litellm/proxy/auth/test_route_checks.py +++ b/tests/test_litellm/proxy/auth/test_route_checks.py @@ -3192,3 +3192,57 @@ def test_internal_user_blocked_from_search_tool_writes(route): assert "Only proxy admin" in str(exc_info.value) assert f"Route={route}" in str(exc_info.value) assert "Your role=internal_user" in str(exc_info.value) + + +def test_proxy_admin_viewer_can_read_another_users_info(): + """Admin Viewer has read parity with Proxy Admin, so the /user/info + key-ownership gate must not apply to it — the Users page reads every row.""" + user_obj = LiteLLM_UserTable( + user_id="viewer_user", + user_email="viewer@example.com", + user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value, + ) + valid_token = UserAPIKeyAuth( + user_id="viewer_user", + user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value, + ) + request = MagicMock(spec=Request) + request.query_params = {"user_id": "some_other_user"} + + RouteChecks.non_proxy_admin_allowed_routes_check( + user_obj=user_obj, + _user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value, + route="/user/info", + request=request, + valid_token=valid_token, + request_data={}, + ) + + +def test_internal_user_still_blocked_from_another_users_info(): + """The Admin Viewer carve-out above must stay scoped to that role; internal + users keep hitting the ownership 403.""" + user_obj = LiteLLM_UserTable( + user_id="internal_user", + user_email="user@example.com", + user_role=LitellmUserRoles.INTERNAL_USER.value, + ) + valid_token = UserAPIKeyAuth( + user_id="internal_user", + user_role=LitellmUserRoles.INTERNAL_USER.value, + ) + request = MagicMock(spec=Request) + request.query_params = {"user_id": "some_other_user"} + + with pytest.raises(HTTPException) as exc_info: + RouteChecks.non_proxy_admin_allowed_routes_check( + user_obj=user_obj, + _user_role=LitellmUserRoles.INTERNAL_USER.value, + route="/user/info", + request=request, + valid_token=valid_token, + request_data={}, + ) + + assert exc_info.value.status_code == 403 + assert "key not allowed to access this user's info" in str(exc_info.value.detail) diff --git a/tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py b/tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py index 1c452e2fb6c..e1dd6b7d48b 100644 --- a/tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py +++ b/tests/test_litellm/proxy/guardrails/test_guardrail_endpoints.py @@ -339,6 +339,109 @@ async def test_list_guardrails_v2_masks_sensitive_data_in_config_guardrails(mock assert params["mode"] == "during_call" +@pytest.mark.asyncio +async def test_list_guardrails_v2_admin_viewer_sees_guardrails_of_teams_they_are_not_in( + mocker, +): + """ + proxy_admin_viewer reads the same unscoped list as proxy_admin: a team-owned + guardrail must surface even though the viewer belongs to no teams. + """ + other_team_guardrail = { + "guardrail_id": "other-team-guardrail", + "guardrail_name": "Other Team Guardrail", + "litellm_params": {"guardrail": "bedrock", "mode": "pre_call"}, + "guardrail_info": {"description": "owned by a team the viewer is not in"}, + "team_id": "team-viewer-is-not-in", + "created_at": datetime.now(), + "updated_at": datetime.now(), + } + + mock_prisma_client = mocker.Mock() + mock_prisma_client.db = mocker.Mock() + mock_prisma_client.db.litellm_guardrailstable = mocker.Mock() + mock_prisma_client.db.litellm_guardrailstable.find_many = AsyncMock( + return_value=[other_team_guardrail] + ) + + mock_in_memory_handler = mocker.Mock() + mock_in_memory_handler.list_in_memory_guardrails.return_value = [] + + mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + mocker.patch( + "litellm.proxy.guardrails.guardrail_registry.IN_MEMORY_GUARDRAIL_HANDLER", + mock_in_memory_handler, + ) + mock_get_user_team_ids = mocker.patch( + "litellm.proxy.guardrails.guardrail_endpoints._get_user_team_ids", + AsyncMock(return_value=[]), + ) + + viewer_auth = UserAPIKeyAuth( + user_id="viewer-1", user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY + ) + response = await list_guardrails_v2(user_api_key_dict=viewer_auth) + + assert [g.guardrail_id for g in response.guardrails] == ["other-team-guardrail"] + mock_get_user_team_ids.assert_not_called() + + +@pytest.mark.asyncio +async def test_list_guardrails_v2_masks_sensitive_data_for_admin_viewer(mocker): + """ + Read parity for proxy_admin_viewer must not also hand out unmasked secrets. + The guardrail is team-owned so it only reaches the viewer via the admin path. + """ + other_team_guardrail_with_secrets = { + "guardrail_id": "other-team-secret-guardrail", + "guardrail_name": "Other Team Guardrail with Secrets", + "litellm_params": { + "guardrail": "azure/text_moderations", + "mode": "pre_call", + "api_key": "sk-viewer-must-not-see-this", + }, + "guardrail_info": {}, + "team_id": "team-viewer-is-not-in", + "created_at": datetime.now(), + "updated_at": datetime.now(), + } + + mock_prisma_client = mocker.Mock() + mock_prisma_client.db = mocker.Mock() + mock_prisma_client.db.litellm_guardrailstable = mocker.Mock() + mock_prisma_client.db.litellm_guardrailstable.find_many = AsyncMock( + return_value=[other_team_guardrail_with_secrets] + ) + + mock_in_memory_handler = mocker.Mock() + mock_in_memory_handler.list_in_memory_guardrails.return_value = [] + + mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + mocker.patch( + "litellm.proxy.guardrails.guardrail_registry.IN_MEMORY_GUARDRAIL_HANDLER", + mock_in_memory_handler, + ) + mocker.patch( + "litellm.proxy.guardrails.guardrail_endpoints._get_user_team_ids", + AsyncMock(return_value=[]), + ) + + viewer_auth = UserAPIKeyAuth( + user_id="viewer-1", user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY + ) + response = await list_guardrails_v2(user_api_key_dict=viewer_auth) + + guardrail = next( + g + for g in response.guardrails + if g.guardrail_id == "other-team-secret-guardrail" + ) + params = guardrail.litellm_params.model_dump() + assert params["api_key"] != "sk-viewer-must-not-see-this" + assert "****" in str(params["api_key"]) + assert params["guardrail"] == "azure/text_moderations" + + @pytest.mark.asyncio async def test_get_guardrail_info_from_db(mocker, mock_prisma_client): """Test getting guardrail info from DB""" @@ -2037,6 +2140,39 @@ async def test_get_guardrail_submission_non_admin_other_team_forbidden(mocker): assert exc_info.value.status_code == 403 +@pytest.mark.asyncio +async def test_get_guardrail_submission_admin_viewer_other_team_allowed(mocker): + """proxy_admin_viewer reads any team's submission without the membership check.""" + mock_prisma = mocker.Mock() + row = mocker.Mock( + guardrail_id="sub-1", + guardrail_name="team-guard", + status="pending_review", + team_id="team-other", + litellm_params={}, + guardrail_info={}, + submitted_at=None, + reviewed_at=None, + created_at=datetime.now(), + updated_at=datetime.now(), + ) + mock_prisma.db.litellm_guardrailstable.find_unique = AsyncMock(return_value=row) + mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma) + mock_get_user_team_ids = mocker.patch( + "litellm.proxy.guardrails.guardrail_endpoints._get_user_team_ids", + AsyncMock(return_value=[]), + ) + user = UserAPIKeyAuth( + user_id="viewer-1", user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY + ) + + result = await get_guardrail_submission("sub-1", user) + + assert result.guardrail_id == "sub-1" + assert result.team_id == "team-other" + mock_get_user_team_ids.assert_not_called() + + @pytest.mark.asyncio async def test_approve_guardrail_submission_success(mocker): """Approve sets status to active and initializes guardrail in memory.""" diff --git a/tests/test_litellm/proxy/management_endpoints/test_coordination_redis_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_coordination_redis_endpoints.py index 4e6bfc4c063..2e78a4ca0e3 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_coordination_redis_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_coordination_redis_endpoints.py @@ -210,6 +210,27 @@ async def test_get_rejects_non_admin(): assert exc_info.value.status_code == 403 +@pytest.mark.asyncio +async def test_get_allows_proxy_admin_viewer(): + """proxy_admin_viewer has READ parity with proxy_admin; credentials stay redacted.""" + with ( + patch( + "litellm.proxy.proxy_server.prisma_client", + _prisma_with_general_settings({"coordination_redis": _SAVED_SETTINGS}), + ), + patch("litellm.proxy.proxy_server.proxy_config", _proxy_config()), + ): + response = await get_coordination_redis_settings( + user_api_key_dict=UserAPIKeyAuth( + api_key="hashed", user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY + ) + ) + + assert response.source == "coordination_redis" + assert response.values["host"] == "coord-redis.example.com" + assert response.values["password"] == _REDACTED_VALUE + + def test_fields_cover_every_coordination_redis_param(): """The declarative field list drives the Admin UI form; it must stay in sync with the model the backend validates against.""" @@ -437,6 +458,18 @@ async def test_update_rejects_non_admin(): assert exc_info.value.status_code == 403 +@pytest.mark.asyncio +async def test_update_rejects_proxy_admin_viewer(): + """READ parity for proxy_admin_viewer must not leak into the save endpoint.""" + with pytest.raises(HTTPException) as exc_info: + await update_coordination_redis_settings( + request=CoordinationRedisSettingsRequest(settings={"host": "coord-redis.example.com"}), + user_api_key_dict=UserAPIKeyAuth(api_key="hashed", user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY), + litellm_changed_by=None, + ) + assert exc_info.value.status_code == 403 + + # ── POST /coordination_redis/settings/test ──────────────────────────────────── @@ -575,3 +608,14 @@ async def test_connection_test_rejects_non_admin(): user_api_key_dict=UserAPIKeyAuth(api_key="hashed", user_role=LitellmUserRoles.INTERNAL_USER), ) assert exc_info.value.status_code == 403 + + +@pytest.mark.asyncio +async def test_connection_test_rejects_proxy_admin_viewer(): + """Dialing a caller-supplied Redis is a write-shaped action; viewers stay out.""" + with pytest.raises(HTTPException) as exc_info: + await check_coordination_redis_connection( + request=CoordinationRedisSettingsRequest(settings={"host": "coord-redis.example.com"}), + user_api_key_dict=UserAPIKeyAuth(api_key="hashed", user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY), + ) + assert exc_info.value.status_code == 403 diff --git a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py index a37f7ca764d..aab9a0b4fd0 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_internal_user_endpoints.py @@ -1383,6 +1383,39 @@ async def test_user_info_nonexistent_user(mocker): assert f"User {nonexistent_user_id} not found" in str(exc_info.value.message) +@pytest.mark.asyncio +async def test_user_info_no_user_id_view_only_admin_gets_proxy_admin_payload(mocker): + """PROXY_ADMIN_VIEW_ONLY must take the proxy-admin branch; otherwise /user/info + silently narrows to the viewer's own row instead of the whole tenant.""" + from fastapi import Request + + from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth, UserInfoResponse + from litellm.proxy.management_endpoints.internal_user_endpoints import user_info + + mock_prisma_client = mocker.MagicMock() + mock_prisma_client.get_data = mocker.AsyncMock(return_value=None) + mocker.patch("litellm.proxy.proxy_server.prisma_client", mock_prisma_client) + + admin_payload = UserInfoResponse(user_id=None, user_info=None, keys=[], teams=[]) + mock_get_user_info_for_proxy_admin = mocker.AsyncMock(return_value=admin_payload) + mocker.patch( + "litellm.proxy.management_endpoints.internal_user_endpoints._get_user_info_for_proxy_admin", + mock_get_user_info_for_proxy_admin, + ) + + viewer = UserAPIKeyAuth( + user_id="viewer", user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value + ) + mock_request = mocker.MagicMock(spec=Request) + + response = await user_info( + user_id=None, user_api_key_dict=viewer, request=mock_request + ) + + mock_get_user_info_for_proxy_admin.assert_awaited_once_with(user_api_key_dict=viewer) + assert response is admin_payload + + @pytest.mark.asyncio async def test_new_user_default_teams_flow(mocker): """ @@ -3213,13 +3246,9 @@ def test_enforce_user_info_access_admin_bypass(): _enforce_user_info_access(user_id="someone_else", user_api_key_dict=admin) -def test_enforce_user_info_access_view_only_admin_blocked_from_other_users(): - """PROXY_ADMIN_VIEW_ONLY is not a true admin for /user/info — the upstream - route check applies the same `user_id == valid_token.user_id` rule, so the - re-check here must mirror that and deny cross-user lookups.""" - import pytest - from fastapi import HTTPException - +def test_enforce_user_info_access_view_only_admin_can_read_other_users(): + """PROXY_ADMIN_VIEW_ONLY has read parity with PROXY_ADMIN, so the ownership + re-check must wave it through for another user's id.""" from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth from litellm.proxy.management_endpoints.internal_user_endpoints import ( _enforce_user_info_access, @@ -3229,9 +3258,7 @@ def test_enforce_user_info_access_view_only_admin_blocked_from_other_users(): user_id="viewer", user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value, ) - with pytest.raises(HTTPException) as exc_info: - _enforce_user_info_access(user_id="someone_else", user_api_key_dict=viewer) - assert exc_info.value.status_code == 403 + _enforce_user_info_access(user_id="someone_else", user_api_key_dict=viewer) def test_enforce_user_info_access_view_only_admin_can_read_own(): diff --git a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py index cf9aa477112..e8709f3af34 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py @@ -8006,6 +8006,74 @@ async def test_validate_key_list_check_key_hash_not_found(): assert "Key Hash not found" in exc_info.value.message +@pytest.mark.asyncio +async def test_validate_key_list_check_proxy_admin_viewer_skips_db_lookup(): + """proxy_admin_viewer takes the same unscoped read fast-path as proxy_admin, so no + user row is fetched and none of the user/team scoping filters apply.""" + mock_prisma_client = AsyncMock() + mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock( + return_value=LiteLLM_UserTable( + user_id="viewer-user", + user_email="viewer@example.com", + teams=[], + organization_memberships=[], + ) + ) + + user_api_key_dict = UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, + user_id="viewer-user", + ) + + result = await validate_key_list_check( + user_api_key_dict=user_api_key_dict, + user_id="someone-else", + team_id="team-viewer-is-not-in", + organization_id=None, + key_alias=None, + key_hash=None, + prisma_client=mock_prisma_client, + ) + + assert result is None + mock_prisma_client.db.litellm_usertable.find_unique.assert_not_awaited() + assert mock_prisma_client.mock_calls == [] + + +@pytest.mark.asyncio +async def test_validate_key_list_check_internal_user_cannot_query_other_user(): + """Admin-view parity must not leak past the admin roles: an internal user still + cannot list another user's keys.""" + mock_prisma_client = AsyncMock() + mock_prisma_client.db.litellm_usertable.find_unique = AsyncMock( + return_value=LiteLLM_UserTable( + user_id="test-user", + user_email="test@example.com", + teams=[], + organization_memberships=[], + ) + ) + + user_api_key_dict = UserAPIKeyAuth( + user_role=LitellmUserRoles.INTERNAL_USER, + user_id="test-user", + ) + + with pytest.raises(ProxyException) as exc_info: + await validate_key_list_check( + user_api_key_dict=user_api_key_dict, + user_id="other-user", + team_id=None, + organization_id=None, + key_alias=None, + key_hash=None, + prisma_client=mock_prisma_client, + ) + + assert exc_info.value.code == "403" + assert "not authorized to check another user's keys" in exc_info.value.message + + @pytest.mark.asyncio async def test_key_with_budget_id_does_not_store_budget_duration(): """ @@ -15323,3 +15391,54 @@ async def test_rotate_master_key_rotates_sso_identity_assertions( prisma_client=mock_prisma_client, new_master_key="sk-new-master-key", ) + + +@pytest.mark.asyncio +async def test_check_encryption_endpoint_rejects_proxy_admin_viewer(): + """The residual scan walks and decrypt-classifies every credential-bearing table, + so it stays proxy_admin-only despite being read-only.""" + from litellm.proxy.management_endpoints import credential_migration as cm + from litellm.proxy.management_endpoints.key_management_endpoints import ( + check_encryption_endpoint, + ) + + user_api_key_dict = UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, + user_id="viewer-user", + ) + mock_check = AsyncMock(return_value=cm.MigrationReport()) + + with patch("litellm.proxy.proxy_server.prisma_client", MagicMock()), patch.object( + cm, "check_encryption", mock_check + ): + with pytest.raises(HTTPException) as exc_info: + await check_encryption_endpoint(user_api_key_dict=user_api_key_dict) + + assert exc_info.value.status_code == 403 + mock_check.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_migrate_encryption_endpoint_rejects_proxy_admin_viewer(): + """The re-encryption write sibling is also proxy_admin-only.""" + from litellm.proxy.management_endpoints import credential_migration as cm + from litellm.proxy.management_endpoints.key_management_endpoints import ( + migrate_encryption_endpoint, + ) + + user_api_key_dict = UserAPIKeyAuth( + user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, + user_id="viewer-user", + ) + mock_migrate = AsyncMock(return_value=cm.MigrationReport()) + + with patch("litellm.proxy.proxy_server.prisma_client", MagicMock()), patch.object( + cm, "migrate_encryption", mock_migrate + ): + with pytest.raises(HTTPException) as exc_info: + await migrate_encryption_endpoint( + user_api_key_dict=user_api_key_dict, dry_run=False + ) + + assert exc_info.value.status_code == 403 + mock_migrate.assert_not_awaited() diff --git a/tests/test_litellm/proxy/management_endpoints/test_workflow_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_workflow_management_endpoints.py index a337ff6d888..27adb3e0892 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_workflow_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_workflow_management_endpoints.py @@ -3,19 +3,25 @@ Unit tests for workflow management endpoints (/v1/workflows/runs/*). Uses FastAPI TestClient with a mocked prisma_client. """ +import asyncio import os import sys from datetime import datetime, timezone from typing import Any from unittest.mock import AsyncMock, MagicMock, patch -from fastapi import FastAPI +import pytest +from fastapi import FastAPI, HTTPException from fastapi.testclient import TestClient from prisma.errors import UniqueViolationError sys.path.insert(0, os.path.abspath("../../..")) -from litellm.proxy.management_endpoints.workflow_management_endpoints import router +from litellm.proxy.management_endpoints.workflow_management_endpoints import ( + _read_scope_caller, + _require_run, + router, +) # --------------------------------------------------------------------------- @@ -140,6 +146,31 @@ def _override_auth_user_with_token(token: str = "tok-abc") -> Any: return auth +def _override_auth_admin_viewer(token: str = "tok-viewer") -> Any: + """Viewer carries a real token, so a re-scoped read path would be observable.""" + from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth + + auth = UserAPIKeyAuth( + api_key="sk-viewer", + user_id="viewer-1", + user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, + ) + auth.token = token + return auth + + +def _override_auth_internal_user(token: str = "tok-internal") -> Any: + from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth + + auth = UserAPIKeyAuth( + api_key="sk-internal", + user_id="user-2", + user_role=LitellmUserRoles.INTERNAL_USER, + ) + auth.token = token + return auth + + # --------------------------------------------------------------------------- # Tests # --------------------------------------------------------------------------- @@ -609,3 +640,100 @@ class TestTenantIsolation: resp = client.get("/v1/workflows/runs/run-1") assert resp.status_code == 200 + + +class TestAdminViewerReadParity: + """proxy_admin_viewer reads every run; write paths stay on the strict admin gate.""" + + def _make_app_with_auth(self, auth_fn): + from litellm.proxy.auth.user_api_key_auth import user_api_key_auth + + self._prisma = _make_prisma_client() + app = _make_app() + app.dependency_overrides[user_api_key_auth] = auth_fn + return TestClient(app, raise_server_exceptions=True) + + def test_read_scope_caller_drops_scope_for_admin_viewer_only(self): + """None means 'no ownership filter'; every other non-admin role keeps its caller.""" + internal = _override_auth_internal_user() + assert _read_scope_caller(_override_auth_admin_viewer()) is None + assert _read_scope_caller(internal) is internal + + @patch("litellm.proxy.proxy_server.prisma_client") + def test_admin_viewer_list_not_scoped(self, mock_pc): + client = self._make_app_with_auth(_override_auth_admin_viewer) + mock_pc.db = self._prisma.db + self._prisma.db.litellm_workflowrun.find_many = AsyncMock(return_value=[]) + + resp = client.get("/v1/workflows/runs") + assert resp.status_code == 200 + call_kwargs = self._prisma.db.litellm_workflowrun.find_many.call_args[1] + assert "created_by" not in call_kwargs["where"] + + @patch("litellm.proxy.proxy_server.prisma_client") + def test_admin_viewer_get_other_owners_run_succeeds(self, mock_pc): + client = self._make_app_with_auth(_override_auth_admin_viewer) + mock_pc.db = self._prisma.db + self._prisma.db.litellm_workflowrun.find_unique = AsyncMock( + return_value=_make_run(created_by="tok-other-owner") + ) + + resp = client.get("/v1/workflows/runs/run-1") + assert resp.status_code == 200 + + @patch("litellm.proxy.proxy_server.prisma_client") + def test_admin_viewer_lists_other_owners_events(self, mock_pc): + client = self._make_app_with_auth(_override_auth_admin_viewer) + mock_pc.db = self._prisma.db + self._prisma.db.litellm_workflowrun.find_unique = AsyncMock( + return_value=_make_run(created_by="tok-other-owner") + ) + self._prisma.db.litellm_workflowevent.find_many = AsyncMock( + return_value=[_make_event(sequence_number=0)] + ) + + resp = client.get("/v1/workflows/runs/run-1/events") + assert resp.status_code == 200 + assert resp.json()["count"] == 1 + + @patch("litellm.proxy.proxy_server.prisma_client") + def test_admin_viewer_lists_other_owners_messages(self, mock_pc): + client = self._make_app_with_auth(_override_auth_admin_viewer) + mock_pc.db = self._prisma.db + self._prisma.db.litellm_workflowrun.find_unique = AsyncMock( + return_value=_make_run(created_by="tok-other-owner") + ) + self._prisma.db.litellm_workflowmessage.find_many = AsyncMock( + return_value=[_make_message(sequence_number=0)] + ) + + resp = client.get("/v1/workflows/runs/run-1/messages") + assert resp.status_code == 200 + assert resp.json()["count"] == 1 + + @patch("litellm.proxy.proxy_server.prisma_client") + def test_admin_viewer_cannot_update_other_owners_run(self, mock_pc): + """Read parity must not become write parity: PATCH still passes the caller through.""" + client = self._make_app_with_auth(_override_auth_admin_viewer) + mock_pc.db = self._prisma.db + self._prisma.db.litellm_workflowrun.find_unique = AsyncMock( + return_value=_make_run(created_by="tok-other-owner") + ) + self._prisma.db.litellm_workflowrun.update = AsyncMock( + return_value=_make_run(status="completed") + ) + + resp = client.patch("/v1/workflows/runs/run-1", json={"status": "completed"}) + assert resp.status_code == 404 + self._prisma.db.litellm_workflowrun.update.assert_not_awaited() + + def test_require_run_still_scopes_when_handed_a_viewer(self): + """Only read callers pass None; the helper itself never loosened.""" + prisma = _make_prisma_client() + prisma.db.litellm_workflowrun.find_unique = AsyncMock( + return_value=_make_run(created_by="tok-other-owner") + ) + + with pytest.raises(HTTPException) as exc_info: + asyncio.run(_require_run(prisma, "run-1", _override_auth_admin_viewer())) + assert exc_info.value.status_code == 404 diff --git a/tests/test_litellm/proxy/memory/test_memory_endpoints.py b/tests/test_litellm/proxy/memory/test_memory_endpoints.py index ca011c77af8..ec81ef2ff7a 100644 --- a/tests/test_litellm/proxy/memory/test_memory_endpoints.py +++ b/tests/test_litellm/proxy/memory/test_memory_endpoints.py @@ -19,7 +19,7 @@ from fastapi.testclient import TestClient sys.path.insert(0, os.path.abspath("../../..")) from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth -from litellm.proxy.memory.memory_endpoints import router +from litellm.proxy.memory.memory_endpoints import _visibility_filter, router def _make_row( @@ -218,6 +218,14 @@ def _admin_auth() -> UserAPIKeyAuth: ) +def _admin_viewer_auth() -> UserAPIKeyAuth: + return UserAPIKeyAuth( + api_key="sk-viewer", + user_id="viewer", + user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, + ) + + def _patch_prisma(prisma: Any): """Patch the endpoint module's _require_prisma to return our fake.""" return patch( @@ -913,3 +921,67 @@ class TestMemoryEndpoints: with _patch_prisma(self.prisma): resp = client.delete("/v1/memory/notes") assert resp.status_code == 404 + + def test_visibility_filter_unscoped_for_admin_viewer(self): + """ + proxy_admin_viewer reads with the same unscoped filter as proxy_admin; + every other role stays row-restricted. + """ + assert _visibility_filter(_admin_viewer_auth()) is None + assert _visibility_filter(_user_auth("user-a", "team-a")) is not None + + def test_list_memory_admin_viewer_sees_all(self): + """Read parity end-to-end: the viewer's own user_id/team_id must not filter the list.""" + table = self.prisma.db.litellm_memorytable + table.rows.extend( + [ + _make_row(memory_id="m1", key="a", user_id="user-a", team_id=None), + _make_row(memory_id="m2", key="b", user_id="user-b", team_id="team-b"), + ] + ) + client = _make_client(_admin_viewer_auth()) + with _patch_prisma(self.prisma): + resp = client.get("/v1/memory") + assert resp.status_code == 200, resp.text + body = resp.json() + assert {m["key"] for m in body["memories"]} == {"a", "b"} + assert body["total"] == 2 + + def test_put_memory_admin_viewer_cannot_overwrite_foreign_row(self): + """ + Read parity must not become write parity: the viewer now SEES this row + (403, not 404) but `_assert_write_access` still refuses the write. + """ + table = self.prisma.db.litellm_memorytable + table.rows.append( + _make_row( + memory_id="m1", + key="user_role", + value="A's notes", + user_id="user-a", + team_id="team-a", + ) + ) + client = _make_client(_admin_viewer_auth()) + with _patch_prisma(self.prisma): + resp = client.put("/v1/memory/user_role", json={"value": "viewer overwrite"}) + assert resp.status_code == 403, resp.text + assert table.rows[0].value == "A's notes" + + def test_delete_memory_admin_viewer_cannot_delete_foreign_row(self): + """Same write gate as the PUT case, for DELETE.""" + table = self.prisma.db.litellm_memorytable + table.rows.append( + _make_row( + memory_id="m1", + key="user_role", + value="A's notes", + user_id="user-a", + team_id="team-a", + ) + ) + client = _make_client(_admin_viewer_auth()) + with _patch_prisma(self.prisma): + resp = client.delete("/v1/memory/user_role") + assert resp.status_code == 403, resp.text + assert len(table.rows) == 1 diff --git a/tests/test_litellm/proxy/prompts/test_prompt_endpoints.py b/tests/test_litellm/proxy/prompts/test_prompt_endpoints.py index 39b6bce46fa..57ad6acae3b 100644 --- a/tests/test_litellm/proxy/prompts/test_prompt_endpoints.py +++ b/tests/test_litellm/proxy/prompts/test_prompt_endpoints.py @@ -319,3 +319,144 @@ class TestPromptVersionsEndpoint: assert exc_info.value.status_code == 404 assert "No versions found" in exc_info.value.detail + + +class TestAdminViewerReadAccess: + """ + proxy_admin_viewer has READ parity with proxy_admin on the prompt read endpoints + """ + + @pytest.mark.asyncio + async def test_list_prompts_returns_all_prompts_for_admin_viewer(self): + """A role without admin view falls through to the empty-list branch here.""" + from unittest.mock import patch + + from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth + from litellm.proxy.prompts.prompt_endpoints import list_prompts + + viewer = UserAPIKeyAuth( + api_key="test_key", user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY + ) + + mock_prompts = { + "jack.v1": PromptSpec( + prompt_id="jack.v1", + litellm_params=PromptLiteLLMParams( + prompt_id="jack", + prompt_integration="dotprompt", + dotprompt_content="v1", + ), + prompt_info=PromptInfo(prompt_type="db"), + ), + "jack.v2": PromptSpec( + prompt_id="jack.v2", + litellm_params=PromptLiteLLMParams( + prompt_id="jack", + prompt_integration="dotprompt", + dotprompt_content="v2", + ), + prompt_info=PromptInfo(prompt_type="db"), + ), + "jane.v1": PromptSpec( + prompt_id="jane.v1", + litellm_params=PromptLiteLLMParams( + prompt_id="jane", + prompt_integration="dotprompt", + dotprompt_content="jane", + ), + prompt_info=PromptInfo(prompt_type="db"), + ), + } + + with patch( + "litellm.proxy.prompts.prompt_registry.IN_MEMORY_PROMPT_REGISTRY" + ) as mock_registry: + mock_registry.IN_MEMORY_PROMPTS = mock_prompts + + response = await list_prompts(user_api_key_dict=viewer) + + assert sorted(p.prompt_id for p in response.prompts) == ["jack", "jane"] + jack = next(p for p in response.prompts if p.prompt_id == "jack") + assert jack.litellm_params.dotprompt_content == "v2" + + @pytest.mark.asyncio + async def test_get_prompt_versions_allows_admin_viewer(self): + """Version history used to 403 anyone who was not exactly proxy_admin.""" + from unittest.mock import patch + + from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth + from litellm.proxy.prompts.prompt_endpoints import get_prompt_versions + + viewer = UserAPIKeyAuth( + api_key="test_key", user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY + ) + + mock_prompts = { + "jack.v1": PromptSpec( + prompt_id="jack.v1", + litellm_params=PromptLiteLLMParams( + prompt_id="jack", + prompt_integration="dotprompt", + dotprompt_content="v1", + ), + prompt_info=PromptInfo(prompt_type="db"), + ), + "jack.v2": PromptSpec( + prompt_id="jack.v2", + litellm_params=PromptLiteLLMParams( + prompt_id="jack", + prompt_integration="dotprompt", + dotprompt_content="v2", + ), + prompt_info=PromptInfo(prompt_type="db"), + ), + } + + with ( + patch("litellm.proxy.proxy_server.prisma_client", None), + patch( + "litellm.proxy.prompts.prompt_registry.IN_MEMORY_PROMPT_REGISTRY" + ) as mock_registry, + ): + mock_registry.IN_MEMORY_PROMPTS = mock_prompts + + response = await get_prompt_versions( + prompt_id="jack", user_api_key_dict=viewer + ) + + assert [p.version for p in response.prompts] == [2, 1] + + @pytest.mark.asyncio + async def test_get_prompt_info_allows_admin_viewer(self): + """Prompt info used to 403 anyone who was not exactly proxy_admin.""" + from unittest.mock import patch + + from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth + from litellm.proxy.prompts.prompt_endpoints import get_prompt_info + + viewer = UserAPIKeyAuth( + api_key="test_key", user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY + ) + + with ( + patch("litellm.proxy.proxy_server.prisma_client", None), + patch( + "litellm.proxy.prompts.prompt_registry.IN_MEMORY_PROMPT_REGISTRY" + ) as mock_registry, + ): + mock_registry.get_prompt_by_id.return_value = PromptSpec( + prompt_id="jack.v2", + litellm_params=PromptLiteLLMParams( + prompt_id="jack", + prompt_integration="dotprompt", + dotprompt_content="v2", + ), + prompt_info=PromptInfo(prompt_type="db"), + ) + mock_registry.IN_MEMORY_PROMPTS = {"jack.v1": {}, "jack.v2": {}} + mock_registry.get_prompt_callback_by_id.return_value = None + + response = await get_prompt_info(prompt_id="jack", user_api_key_dict=viewer) + + assert response.prompt_spec.prompt_id == "jack" + assert response.prompt_spec.version == 2 diff --git a/tests/test_litellm/proxy/proxy_server/test_team_model_name_translation.py b/tests/test_litellm/proxy/proxy_server/test_team_model_name_translation.py index 577af3dcffc..e73f1d08cb5 100644 --- a/tests/test_litellm/proxy/proxy_server/test_team_model_name_translation.py +++ b/tests/test_litellm/proxy/proxy_server/test_team_model_name_translation.py @@ -538,6 +538,48 @@ async def test_populate_team_access_sets_direct_access_false_by_default(monkeypa assert by_id["global-id-1"]["model_info"]["direct_access"] is True +@pytest.mark.asyncio +async def test_populate_team_access_gives_view_only_admin_full_admin_scope(monkeypatch): + """proxy_admin_viewer reads with admin scope - every team ("*") plus direct access + to all non-team models - instead of being narrowed to its own user row.""" + team_row = _team_row() + global_row = { + "model_name": "gpt-4o", + "litellm_params": {"model": "gpt-4o"}, + "model_info": {"id": "global-id-1", "db_model": False}, + } + router = MagicMock() + router.get_model_ids.return_value = ["global-id-1"] + + get_all_team_models = AsyncMock(return_value={"byok-id-1": ["team-abc-123"]}) + monkeypatch.setattr(ps, "get_all_team_models", get_all_team_models) + + prisma_client = MagicMock() + prisma_client.db.litellm_usertable.find_unique = AsyncMock( + return_value=LiteLLM_UserTable(user_id="viewer", teams=[], models=[]) + ) + + viewer = UserAPIKeyAuth( + user_id="viewer", + user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY, + team_models=[], + ) + result = await ps._populate_team_access_on_models( + user_api_key_dict=viewer, + prisma_client=prisma_client, + llm_router=router, + all_models=[team_row, global_row], + ) + + assert get_all_team_models.await_args.kwargs["user_teams"] == "*" + router.get_model_ids.assert_called_once_with(exclude_team_models=True) + prisma_client.db.litellm_usertable.find_unique.assert_not_awaited() + + by_id = {m["model_info"]["id"]: m for m in result} + assert by_id["byok-id-1"]["model_info"]["access_via_team_ids"] == ["team-abc-123"] + assert by_id["global-id-1"]["model_info"]["direct_access"] is True + + @pytest.mark.asyncio async def test_model_info_v1_team_id_without_db_fails_fast(monkeypatch): """`teamId` without a connected DB raises 500 before any enrichment work runs.""" diff --git a/ui/litellm-dashboard/eslint-budgets.json b/ui/litellm-dashboard/eslint-budgets.json index 3526d71ce90..c4f078f2ff2 100644 --- a/ui/litellm-dashboard/eslint-budgets.json +++ b/ui/litellm-dashboard/eslint-budgets.json @@ -1,8 +1,8 @@ { "@typescript-eslint/no-explicit-any": { "max": 2040, "target": 1500 }, "no-console": { "max": 12, "target": 0 }, - "complexity": { "max": 121, "target": 80 }, - "max-depth": { "max": 55, "target": 30 }, - "local/no-large-inline-object-arg": { "max": 469, "target": 300 }, - "local/no-long-condition-chain": { "max": 217, "target": 120 } + "complexity": { "max": 140, "target": 80 }, + "max-depth": { "max": 70, "target": 30 }, + "local/no-large-inline-object-arg": { "max": 560, "target": 300 }, + "local/no-long-condition-chain": { "max": 265, "target": 120 } } diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.test.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.test.ts index 567ca911458..bb14f6c3d21 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.test.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.test.ts @@ -152,6 +152,8 @@ describe("useAuthorized", () => { expect(result.current.userId).toBe("user-1"); expect(result.current.userEmail).toBe("user@example.com"); expect(result.current.userRole).toBe("Admin"); + expect(result.current.userRoleLabel).toBe("Admin"); + expect(result.current.isViewOnly).toBe(false); expect(result.current.premiumUser).toBe(true); expect(result.current.disabledPersonalKeyCreation).toBe(false); expect(result.current.showSSOBanner).toBe(true); @@ -159,6 +161,44 @@ describe("useAuthorized", () => { expect(clearTokenCookiesMock).not.toHaveBeenCalled(); }); + it("should present proxy_admin_viewer as Admin while flagging it view-only", async () => { + getUiConfigMock.mockResolvedValue({ + server_root_path: "/", + proxy_base_url: null, + auto_redirect_to_sso: false, + admin_ui_disabled: false, + sso_configured: false, + }); + + const decodedPayload = { + key: "api-key-456", + user_id: "user-2", + user_email: "viewer@example.com", + user_role: "proxy_admin_viewer", + premium_user: true, + disabled_non_admin_personal_key_creation: false, + login_method: "username_password", + }; + + decodeTokenMock.mockReturnValue(decodedPayload); + checkTokenValidityMock.mockReturnValue(true); + + const token = createJwt(decodedPayload); + document.cookie = `token=${token}; path=/;`; + + const { result } = renderHook(() => useAuthorized(), { wrapper }); + + await waitFor(() => { + expect(result.current.token).toBe(token); + }); + + expect(result.current.userRole).toBe("Admin"); + expect(result.current.userRoleLabel).toBe("Admin Viewer"); + expect(result.current.isViewOnly).toBe(true); + expect(replaceMock).not.toHaveBeenCalled(); + expect(clearTokenCookiesMock).not.toHaveBeenCalled(); + }); + it("should clear cookies and redirect on an invalid token", async () => { getUiConfigMock.mockResolvedValue({ server_root_path: "/", diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.ts index bb22ebf5edc..40d1ec09d1f 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/useAuthorized.ts @@ -5,7 +5,7 @@ import { clearTokenCookies, getCookie } from "@/utils/cookieUtils"; import { checkTokenValidity, decodeToken } from "@/utils/jwtUtils"; import { buildLoginUrlWithReturn, getLoginUrl, storeReturnUrl } from "@/utils/returnUrlUtils"; import { useCallback, useEffect, useMemo } from "react"; -import { formatUserRole } from "@/utils/roles"; +import { effectiveSessionRole, formatUserRole, isViewOnlySessionRole } from "@/utils/roles"; import { useUIConfig } from "./uiConfig/useUIConfig"; const useAuthorized = () => { @@ -45,7 +45,9 @@ const useAuthorized = () => { accessToken: decoded?.key ?? null, userId: decoded?.user_id ?? null, userEmail: decoded?.user_email ?? null, - userRole: formatUserRole(decoded?.user_role), + userRole: effectiveSessionRole(decoded?.user_role), + userRoleLabel: formatUserRole(decoded?.user_role), + isViewOnly: isViewOnlySessionRole(decoded?.user_role), premiumUser: decoded?.premium_user ?? null, disabledPersonalKeyCreation: decoded?.disabled_non_admin_personal_key_creation ?? null, showSSOBanner: decoded?.login_method === "username_password", diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/page.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/page.test.tsx index 54e99d9db29..85e19d7d251 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/page.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/page.test.tsx @@ -10,6 +10,7 @@ vi.mock("@/app/(dashboard)/hooks/useAuthorized", () => ({ accessToken: "sk-test", userId: "user-1", userRole: authState.userRole, + isViewOnly: ["Admin Viewer", "Internal Viewer"].includes(authState.userRole), disabledPersonalKeyCreation: false, }), })); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/playground/page.tsx b/ui/litellm-dashboard/src/app/(dashboard)/playground/page.tsx index 8986084b1a7..a4ea85311c4 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/playground/page.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/playground/page.tsx @@ -9,7 +9,6 @@ import { TabGroup, TabList, Tab, TabPanels, TabPanel } from "@tremor/react"; import { DeprecationBanner } from "@/components/DeprecationBanner"; import useAuthorized from "@/app/(dashboard)/hooks/useAuthorized"; import { fetchProxySettings } from "@/utils/proxyUtils"; -import { isViewOnlyRole } from "@/utils/roles"; interface ProxySettings { PROXY_BASE_URL?: string; @@ -17,7 +16,7 @@ interface ProxySettings { } export default function PlaygroundPage() { - const { accessToken, userRole, userId, disabledPersonalKeyCreation, token } = useAuthorized(); + const { accessToken, userRole, userId, disabledPersonalKeyCreation, token, isViewOnly } = useAuthorized(); const [proxySettings, setProxySettings] = useState(undefined); useEffect(() => { @@ -36,7 +35,7 @@ export default function PlaygroundPage() { initializeProxySettings(); }, [accessToken]); - if (isViewOnlyRole(userRole)) { + if (isViewOnly) { return (

Access Denied

diff --git a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.test.tsx b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.test.tsx index 31ddae31798..cad5ced340e 100644 --- a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.test.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.test.tsx @@ -6,7 +6,7 @@ import UserDropdown from "./UserDropdown"; let mockUseAuthorizedImpl = () => ({ userId: "test-user-id", userEmail: "test@example.com", - userRole: "Admin", + userRoleLabel: "Admin", premiumUser: false, }); @@ -44,7 +44,7 @@ describe("UserDropdown", () => { mockUseAuthorizedImpl = () => ({ userId: "test-user-id", userEmail: "test@example.com", - userRole: "Admin", + userRoleLabel: "Admin", premiumUser: false, }); mockUseDisableShowPromptsImpl = () => false; @@ -115,7 +115,7 @@ describe("UserDropdown", () => { mockUseAuthorizedImpl = () => ({ userId: "test-user-id", userEmail: "test@example.com", - userRole: "Admin", + userRoleLabel: "Admin", premiumUser: true, }); @@ -238,7 +238,7 @@ describe("UserDropdown", () => { mockUseAuthorizedImpl = () => ({ userId: "default_user_id", userEmail: null as any, - userRole: "Admin", + userRoleLabel: "Admin", premiumUser: false, }); renderWithProviders(); @@ -250,7 +250,7 @@ describe("UserDropdown", () => { mockUseAuthorizedImpl = () => ({ userId: "test-user-id", userEmail: null as any, - userRole: "Admin", + userRoleLabel: "Admin", premiumUser: false, }); @@ -268,7 +268,7 @@ describe("UserDropdown", () => { mockUseAuthorizedImpl = () => ({ userId: null as any, userEmail: "test@example.com", - userRole: "Admin", + userRoleLabel: "Admin", premiumUser: false, }); diff --git a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx index a71fc1b97a8..28e981c57a1 100644 --- a/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx +++ b/ui/litellm-dashboard/src/components/Navbar/UserDropdown/UserDropdown.tsx @@ -69,7 +69,7 @@ interface UserDropdownProps { } const UserDropdown: React.FC = ({ onLogout, variant = "navbar", collapsed = false }) => { - const { userId, userEmail, userRole, premiumUser } = useAuthorized(); + const { userId, userEmail, userRoleLabel: userRole, premiumUser } = useAuthorized(); const disableShowPrompts = useDisableShowPrompts(); const disableBlogPosts = useDisableBlogPosts(); const disableBouncingIcon = useDisableBouncingIcon(); diff --git a/ui/litellm-dashboard/src/components/SidebarAccountMenu/SidebarAccountMenu.test.tsx b/ui/litellm-dashboard/src/components/SidebarAccountMenu/SidebarAccountMenu.test.tsx index 1e4eb5b5af4..9d56a889ed4 100644 --- a/ui/litellm-dashboard/src/components/SidebarAccountMenu/SidebarAccountMenu.test.tsx +++ b/ui/litellm-dashboard/src/components/SidebarAccountMenu/SidebarAccountMenu.test.tsx @@ -6,7 +6,7 @@ import SidebarAccountMenu from "./SidebarAccountMenu"; interface AuthMock { userId: string | null; userEmail: string | null; - userRole: string; + userRoleLabel: string; premiumUser: boolean; accessToken: string; } @@ -14,7 +14,7 @@ interface AuthMock { let mockUseAuthorizedImpl: () => AuthMock = () => ({ userId: "test-user-id", userEmail: "test@example.com", - userRole: "Admin", + userRoleLabel: "Admin", premiumUser: false, accessToken: "test-token", }); @@ -74,7 +74,7 @@ describe("SidebarAccountMenu", () => { mockUseAuthorizedImpl = () => ({ userId: "test-user-id", userEmail: "test@example.com", - userRole: "Admin", + userRoleLabel: "Admin", premiumUser: false, accessToken: "test-token", }); @@ -127,7 +127,7 @@ describe("SidebarAccountMenu", () => { mockUseAuthorizedImpl = () => ({ userId: "test-user-id", userEmail: "test@example.com", - userRole: "Admin", + userRoleLabel: "Admin", premiumUser: true, accessToken: "test-token", }); @@ -273,7 +273,7 @@ describe("SidebarAccountMenu", () => { mockUseAuthorizedImpl = () => ({ userId: "default_user_id", userEmail: null, - userRole: "Admin", + userRoleLabel: "Admin", premiumUser: false, accessToken: "test-token", }); @@ -286,7 +286,7 @@ describe("SidebarAccountMenu", () => { mockUseAuthorizedImpl = () => ({ userId: "test-user-id", userEmail: null, - userRole: "Admin", + userRoleLabel: "Admin", premiumUser: false, accessToken: "test-token", }); diff --git a/ui/litellm-dashboard/src/components/SidebarAccountMenu/SidebarAccountMenu.tsx b/ui/litellm-dashboard/src/components/SidebarAccountMenu/SidebarAccountMenu.tsx index b7a16bcf09a..d1bed9370b4 100644 --- a/ui/litellm-dashboard/src/components/SidebarAccountMenu/SidebarAccountMenu.tsx +++ b/ui/litellm-dashboard/src/components/SidebarAccountMenu/SidebarAccountMenu.tsx @@ -81,7 +81,7 @@ interface SidebarAccountMenuProps { } const SidebarAccountMenu: React.FC = ({ onLogout, collapsed = false }) => { - const { userId, userEmail, userRole, premiumUser, accessToken } = useAuthorized(); + const { userId, userEmail, userRoleLabel: userRole, premiumUser, accessToken } = useAuthorized(); const { data: healthData } = useHealthReadinessDetails(accessToken); const version = healthData?.litellm_version; const disableShowPrompts = useDisableShowPrompts(); diff --git a/ui/litellm-dashboard/src/components/leftnav.test.tsx b/ui/litellm-dashboard/src/components/leftnav.test.tsx index e07d0bb26eb..a5b273a0f56 100644 --- a/ui/litellm-dashboard/src/components/leftnav.test.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.test.tsx @@ -19,6 +19,7 @@ const { mockUseAuthorized, mockUseOrganizations } = vi.hoisted(() => { userId: "test-user-id", accessToken: "test-access-token", userRole: "admin", + isViewOnly: false, token: "test-token", userEmail: "test@example.com", premiumUser: false, @@ -156,12 +157,15 @@ describe("Sidebar (leftnav)", () => { describe("Admin Viewer parity", () => { // Admin Viewer follows a "read parity with Proxy Admin, no writes, no - // cost-incurring actions" rule. Playground stays hidden (incurs LLM - // cost); Models + Endpoints and Agents must be visible read-only. + // cost-incurring actions" rule. The session hook presents the viewer as + // an admin (`userRole: "admin"`) with `isViewOnly: true`; Playground + // stays hidden (incurs LLM cost) via the isViewOnly flag, while every + // admin page (Models + Endpoints, Agents, Logs, ...) is visible read-only. const adminViewerAuth = { userId: "admin-viewer-user-id", accessToken: "test-access-token", - userRole: "admin_viewer", + userRole: "admin", + isViewOnly: true, token: "test-token", userEmail: "viewer@example.com", premiumUser: false, diff --git a/ui/litellm-dashboard/src/components/leftnav.tsx b/ui/litellm-dashboard/src/components/leftnav.tsx index cd92fc5bedb..f08092d0e38 100644 --- a/ui/litellm-dashboard/src/components/leftnav.tsx +++ b/ui/litellm-dashboard/src/components/leftnav.tsx @@ -407,7 +407,7 @@ const Sidebar_: React.FC = ({ disableVectorStoresForInternalUsers, allowVectorStoresForTeamAdmins, }) => { - const { userId, accessToken, userRole } = useAuthorized(); + const { userId, accessToken, userRole, isViewOnly } = useAuthorized(); const { data: organizations } = useOrganizations(); const { data: teams } = useTeams(); const { logoUrl } = useTheme(); @@ -449,6 +449,7 @@ const Sidebar_: React.FC = ({ return items .map((item) => ({ ...item, children: item.children ? filterItemsByRole(item.children) : undefined })) .filter((item) => { + if (item.key === "llm-playground" && isViewOnly) return false; if (item.key === "organizations" || item.key === "users") { const hasRoleAccess = !item.roles || item.roles.includes(userRole) || isOrgAdmin; if (!hasRoleAccess) return false; diff --git a/ui/litellm-dashboard/src/components/user_dashboard.tsx b/ui/litellm-dashboard/src/components/user_dashboard.tsx index 1b8afecb619..1ed4e1d0bba 100644 --- a/ui/litellm-dashboard/src/components/user_dashboard.tsx +++ b/ui/litellm-dashboard/src/components/user_dashboard.tsx @@ -5,6 +5,7 @@ import { jwtDecode } from "jwt-decode"; import React, { useEffect, useState } from "react"; import { fetchTeams } from "./common_components/fetch_teams"; import { KeyResponse, Team } from "./key_team_helpers/key_list"; +import { effectiveSessionRole } from "@/utils/roles"; import { getProxyBaseUrl, getProxyUISettings, @@ -97,30 +98,6 @@ const UserDashboard: React.FC = ({ return () => window.removeEventListener("beforeunload", handleBeforeUnload); }, []); - function formatUserRole(userRole: string) { - if (!userRole) { - return "Undefined Role"; - } - switch (userRole.toLowerCase()) { - case "app_owner": - return "App Owner"; - case "demo_app_owner": - return "App Owner"; - case "proxy_admin": - return "Admin"; - case "proxy_admin_viewer": - return "Admin Viewer"; - case "app_user": - return "App User"; - case "internal_user": - return "Internal User"; - case "internal_user_viewer": - return "Internal Viewer"; - default: - return "Unknown Role"; - } - } - // console.log(`selectedTeam: ${Object.entries(selectedTeam)}`); // Moved useEffect inside the component and used a condition to run fetch only if the params are available useEffect(() => { @@ -134,8 +111,7 @@ const UserDashboard: React.FC = ({ // check if userRole is defined if (decoded.user_role) { - const formattedUserRole = formatUserRole(decoded.user_role); - setUserRole(formattedUserRole); + setUserRole(effectiveSessionRole(decoded.user_role)); } else { } diff --git a/ui/litellm-dashboard/src/contexts/AuthContext.tsx b/ui/litellm-dashboard/src/contexts/AuthContext.tsx index 3693d858952..123feb18a6c 100644 --- a/ui/litellm-dashboard/src/contexts/AuthContext.tsx +++ b/ui/litellm-dashboard/src/contexts/AuthContext.tsx @@ -4,7 +4,7 @@ import React, { createContext, useContext, useEffect, useState } from "react"; import { jwtDecode } from "jwt-decode"; import { clearTokenCookies, getCookie } from "@/utils/cookieUtils"; import { isJwtExpired } from "@/utils/jwtUtils"; -import { formatUserRole } from "@/utils/roles"; +import { effectiveSessionRole } from "@/utils/roles"; import { getUiConfig, setGlobalLitellmHeaderName } from "@/components/networking"; function deleteCookie(name: string, path = "/") { @@ -107,7 +107,7 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { setDisabledPersonalKeyCreation(decoded.disabled_non_admin_personal_key_creation); if (decoded.user_role) { - setUserRole(formatUserRole(decoded.user_role)); + setUserRole(effectiveSessionRole(decoded.user_role)); } if (decoded.user_email) { setUserEmail(decoded.user_email); diff --git a/ui/litellm-dashboard/src/utils/roles.test.ts b/ui/litellm-dashboard/src/utils/roles.test.ts index 9a8a5a9c0c4..83f633bc299 100644 --- a/ui/litellm-dashboard/src/utils/roles.test.ts +++ b/ui/litellm-dashboard/src/utils/roles.test.ts @@ -1,9 +1,11 @@ import { describe, it, expect } from "vitest"; import { + effectiveSessionRole, isAdminRole, isProxyAdminRole, isUserTeamAdminForAnyTeam, isUserTeamAdminForSingleTeam, + isViewOnlySessionRole, rolesAllowedToViewWriteScopedPages, rolesWithWriteAccess, } from "./roles"; @@ -172,4 +174,66 @@ describe("roles", () => { expect(rolesAllowedToViewWriteScopedPages.length).toBeGreaterThan(rolesWithWriteAccess.length); }); }); + + describe("effectiveSessionRole", () => { + it("normalizes proxy_admin_viewer to Admin", () => { + expect(effectiveSessionRole("proxy_admin_viewer")).toBe("Admin"); + }); + + it("keeps proxy_admin as Admin", () => { + expect(effectiveSessionRole("proxy_admin")).toBe("Admin"); + }); + + it("gives proxy_admin_viewer the same session role as proxy_admin", () => { + expect(effectiveSessionRole("proxy_admin_viewer")).toBe(effectiveSessionRole("proxy_admin")); + }); + + it("lets a normalized proxy_admin_viewer pass admin-tier role gates", () => { + expect(rolesWithWriteAccess).toContain(effectiveSessionRole("proxy_admin_viewer")); + }); + + it("does not collapse internal_user_viewer into an admin role", () => { + expect(effectiveSessionRole("internal_user_viewer")).toBe("Internal Viewer"); + expect(rolesWithWriteAccess).not.toContain(effectiveSessionRole("internal_user_viewer")); + }); + + it("leaves other roles untouched", () => { + expect(effectiveSessionRole("internal_user")).toBe("Internal User"); + expect(effectiveSessionRole("org_admin")).toBe("Org Admin"); + }); + + it("returns Undefined Role for a missing role", () => { + expect(effectiveSessionRole(undefined)).toBe("Undefined Role"); + expect(effectiveSessionRole("")).toBe("Undefined Role"); + }); + }); + + describe("isViewOnlySessionRole", () => { + it("returns true for proxy_admin_viewer", () => { + expect(isViewOnlySessionRole("proxy_admin_viewer")).toBe(true); + }); + + it("returns false for proxy_admin", () => { + expect(isViewOnlySessionRole("proxy_admin")).toBe(false); + }); + + it("returns true for internal_user_viewer", () => { + expect(isViewOnlySessionRole("internal_user_viewer")).toBe(true); + }); + + it("returns false for internal_user and org_admin", () => { + expect(isViewOnlySessionRole("internal_user")).toBe(false); + expect(isViewOnlySessionRole("org_admin")).toBe(false); + }); + + it("returns false for a missing role", () => { + expect(isViewOnlySessionRole(undefined)).toBe(false); + expect(isViewOnlySessionRole("")).toBe(false); + }); + + it("stays true for proxy_admin_viewer even though its session role reads as Admin", () => { + expect(effectiveSessionRole("proxy_admin_viewer")).toBe("Admin"); + expect(isViewOnlySessionRole("proxy_admin_viewer")).toBe(true); + }); + }); }); diff --git a/ui/litellm-dashboard/src/utils/roles.ts b/ui/litellm-dashboard/src/utils/roles.ts index 2137d6cfaf2..8d226313f78 100644 --- a/ui/litellm-dashboard/src/utils/roles.ts +++ b/ui/litellm-dashboard/src/utils/roles.ts @@ -65,3 +65,15 @@ export const formatUserRole = (userRole: string): string => { return "Unknown Role"; } }; + +const viewOnlyRawRoles = ["proxy_admin_viewer", "internal_user_viewer", "internal_viewer"]; + +export const effectiveSessionRole = (rawUserRole?: string): string => { + if (rawUserRole?.toLowerCase() === "proxy_admin_viewer") { + return "Admin"; + } + return formatUserRole(rawUserRole ?? ""); +}; + +export const isViewOnlySessionRole = (rawUserRole?: string): boolean => + viewOnlyRawRoles.includes(rawUserRole?.toLowerCase() ?? "");