mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-14 23:21:35 +00:00
Merge remote-tracking branch 'origin/litellm_internal_staging' into litellm_lit5214_custom_tiers
This commit is contained in:
commit
c28dcf8007
73 changed files with 1184 additions and 223 deletions
0
enterprise/litellm_enterprise/integrations/__init__.py
Normal file
0
enterprise/litellm_enterprise/integrations/__init__.py
Normal file
0
enterprise/litellm_enterprise/proxy/hooks/__init__.py
Normal file
0
enterprise/litellm_enterprise/proxy/hooks/__init__.py
Normal file
|
|
@ -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(
|
||||
|
|
|
|||
0
enterprise/litellm_enterprise/py.typed
Normal file
0
enterprise/litellm_enterprise/py.typed
Normal file
0
enterprise/litellm_enterprise/types/__init__.py
Normal file
0
enterprise/litellm_enterprise/types/__init__.py
Normal file
0
enterprise/litellm_enterprise/types/proxy/__init__.py
Normal file
0
enterprise/litellm_enterprise/types/proxy/__init__.py
Normal file
0
litellm-proxy-extras/litellm_proxy_extras/py.typed
Normal file
0
litellm-proxy-extras/litellm_proxy_extras/py.typed
Normal file
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"):
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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})
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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}",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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():
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: "/",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}),
|
||||
}));
|
||||
|
|
|
|||
|
|
@ -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<ProxySettings | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -36,7 +35,7 @@ export default function PlaygroundPage() {
|
|||
initializeProxySettings();
|
||||
}, [accessToken]);
|
||||
|
||||
if (isViewOnlyRole(userRole)) {
|
||||
if (isViewOnly) {
|
||||
return (
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-2 p-8 text-center">
|
||||
<h1 className="text-2xl font-semibold">Access Denied</h1>
|
||||
|
|
|
|||
|
|
@ -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(<UserDropdown onLogout={mockOnLogout} />);
|
||||
|
|
@ -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,
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -69,7 +69,7 @@ interface UserDropdownProps {
|
|||
}
|
||||
|
||||
const UserDropdown: React.FC<UserDropdownProps> = ({ 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();
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
});
|
||||
|
|
|
|||
|
|
@ -81,7 +81,7 @@ interface SidebarAccountMenuProps {
|
|||
}
|
||||
|
||||
const SidebarAccountMenu: React.FC<SidebarAccountMenuProps> = ({ 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();
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -407,7 +407,7 @@ const Sidebar_: React.FC<SidebarProps> = ({
|
|||
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<SidebarProps> = ({
|
|||
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;
|
||||
|
|
|
|||
|
|
@ -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<UserDashboardProps> = ({
|
|||
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<UserDashboardProps> = ({
|
|||
|
||||
// check if userRole is defined
|
||||
if (decoded.user_role) {
|
||||
const formattedUserRole = formatUserRole(decoded.user_role);
|
||||
setUserRole(formattedUserRole);
|
||||
setUserRole(effectiveSessionRole(decoded.user_role));
|
||||
} else {
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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() ?? "");
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue