mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-06 02:48:13 +00:00
Merge 9b5d82e651 into b71f02dbcf
This commit is contained in:
commit
9e35d5f4da
25 changed files with 1046 additions and 92 deletions
|
|
@ -1556,6 +1556,8 @@ class WebSearchInterceptionLogger(CustomLogger):
|
|||
tool_params: Final[_SearchToolLitellmParams] = search_tool.get("litellm_params", {}) or {}
|
||||
search_litellm_params = dict[str, object](tool_params)
|
||||
search_provider = tool_params.get("search_provider")
|
||||
else:
|
||||
self._authorize_unregistered_search_fallback(kwargs=kwargs)
|
||||
|
||||
# Fallback to perplexity if no router or no search tools configured
|
||||
if not search_provider:
|
||||
|
|
@ -1623,6 +1625,16 @@ class WebSearchInterceptionLogger(CustomLogger):
|
|||
verbose_logger.error("WebSearchInterception: Search failed for '%s': %s", query, e)
|
||||
raise
|
||||
|
||||
def _authorize_unregistered_search_fallback(self, kwargs: Mapping[str, object] | None) -> None:
|
||||
user_api_key_auth: Final = self._get_user_api_key_auth_from_kwargs(kwargs)
|
||||
if user_api_key_auth is None:
|
||||
return
|
||||
|
||||
from litellm.proxy.auth.auth_checks import check_unregistered_search_fallback
|
||||
from litellm.proxy.proxy_server import general_settings
|
||||
|
||||
check_unregistered_search_fallback(valid_token=user_api_key_auth, general_settings=general_settings)
|
||||
|
||||
async def _authorize_search_tool(
|
||||
self,
|
||||
search_tool: Mapping[str, object],
|
||||
|
|
@ -1637,35 +1649,34 @@ class WebSearchInterceptionLogger(CustomLogger):
|
|||
return
|
||||
|
||||
from litellm.proxy.auth.auth_checks import (
|
||||
can_key_call_search_tool,
|
||||
can_team_call_search_tool,
|
||||
can_caller_call_search_tool,
|
||||
get_team_object,
|
||||
)
|
||||
|
||||
await can_key_call_search_tool(
|
||||
search_tool_name=search_tool_name,
|
||||
valid_token=user_api_key_auth,
|
||||
from litellm.proxy.proxy_server import (
|
||||
general_settings,
|
||||
prisma_client,
|
||||
proxy_logging_obj,
|
||||
user_api_key_cache,
|
||||
)
|
||||
|
||||
team_id: Final[str | None] = getattr(user_api_key_auth, "team_id", None)
|
||||
if team_id:
|
||||
from litellm.proxy.proxy_server import (
|
||||
prisma_client,
|
||||
proxy_logging_obj,
|
||||
user_api_key_cache,
|
||||
)
|
||||
|
||||
team_object: Final = await get_team_object(
|
||||
team_object: Final = (
|
||||
await get_team_object(
|
||||
team_id=team_id,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
parent_otel_span=getattr(user_api_key_auth, "parent_otel_span", None),
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
await can_team_call_search_tool(
|
||||
search_tool_name=search_tool_name,
|
||||
team_object=team_object,
|
||||
)
|
||||
if team_id
|
||||
else None
|
||||
)
|
||||
await can_caller_call_search_tool(
|
||||
search_tool_name=search_tool_name,
|
||||
valid_token=user_api_key_auth,
|
||||
team_object=team_object,
|
||||
general_settings=general_settings,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _build_search_request_metadata(
|
||||
|
|
|
|||
|
|
@ -3137,6 +3137,15 @@ class ConfigGeneralSettings(LiteLLMPydanticObjectBase):
|
|||
"(see GitHub issue #12905)."
|
||||
),
|
||||
)
|
||||
default_search_list_deny: bool | None = Field(
|
||||
None,
|
||||
description=(
|
||||
"If True, a key, team or user with no search tool grants is denied every search tool instead of "
|
||||
"being allowed all of them. A team key uses its team's grants, a personal key uses its user's "
|
||||
"grants, and any other non-empty key or user allowlist only narrows that. Proxy admins are exempt. "
|
||||
"Defaults to False, where an empty search tool list allows every search tool."
|
||||
),
|
||||
)
|
||||
user_url_validation: bool | None = Field(
|
||||
None,
|
||||
description=(
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ import math
|
|||
import re
|
||||
import time
|
||||
from collections.abc import Awaitable, Callable, Iterator, Mapping, Sequence
|
||||
from dataclasses import dataclass
|
||||
from types import MappingProxyType
|
||||
from typing import TYPE_CHECKING, Any, Final, Generic, Literal, Optional, Protocol, TypeAlias
|
||||
|
||||
|
|
@ -5244,11 +5245,12 @@ def _search_tool_names_from_object_permission(
|
|||
|
||||
def _can_object_call_search_tool(
|
||||
search_tool_name: str,
|
||||
allowed_search_tools: list[str],
|
||||
object_type: Literal["key", "team", "project"],
|
||||
allowed_search_tools: Sequence[str],
|
||||
object_type: Literal["key", "team", "project", "user"],
|
||||
empty_allows_all: bool = True,
|
||||
) -> Literal[True]:
|
||||
"""
|
||||
Check if an object (key/team/project) can access a specific search tool.
|
||||
Check if an object (key/team/project/user) can access a specific search tool.
|
||||
|
||||
Similar to _can_object_call_model but for search tools.
|
||||
|
||||
|
|
@ -5256,6 +5258,7 @@ def _can_object_call_search_tool(
|
|||
search_tool_name: The search tool being requested
|
||||
allowed_search_tools: List of allowed search tool names for this object
|
||||
object_type: Type of object for error messaging
|
||||
empty_allows_all: Whether an empty allowlist grants every search tool
|
||||
|
||||
Returns:
|
||||
True if access is allowed
|
||||
|
|
@ -5263,18 +5266,20 @@ def _can_object_call_search_tool(
|
|||
Raises:
|
||||
ProxyException if access is denied
|
||||
"""
|
||||
# Empty list means all search tools are allowed
|
||||
if not allowed_search_tools:
|
||||
if not allowed_search_tools and empty_allows_all:
|
||||
return True
|
||||
|
||||
# Check if the search tool is in the allowlist
|
||||
if search_tool_name in allowed_search_tools:
|
||||
return True
|
||||
|
||||
# Access denied
|
||||
raise ProxyException(
|
||||
message=f"{object_type.capitalize()} not allowed to access search tool: {search_tool_name}. "
|
||||
f"Allowed search tools: {allowed_search_tools}",
|
||||
message=(
|
||||
f"{object_type.capitalize()} not allowed to access search tool: {search_tool_name}. "
|
||||
f"Allowed search tools: {list(allowed_search_tools)}"
|
||||
if allowed_search_tools
|
||||
else f"{object_type.capitalize()} not allowed to access search tool: {search_tool_name}. "
|
||||
"No search tools are granted and general_settings.default_search_list_deny is enabled"
|
||||
),
|
||||
type=ProxyErrorTypes.key_model_access_denied,
|
||||
param="search_tool_name",
|
||||
code=status.HTTP_403_FORBIDDEN,
|
||||
|
|
@ -5336,24 +5341,162 @@ async def can_team_call_search_tool(
|
|||
)
|
||||
|
||||
|
||||
async def can_user_view_search_tool(
|
||||
@dataclass(frozen=True)
|
||||
class SearchToolGrants:
|
||||
"""Search tool allowlists of every layer that scopes one caller.
|
||||
|
||||
``team`` is None when the caller has no team and ``user`` is None when the user layer is not consulted.
|
||||
Under ``default_deny`` the owner of the credential must grant the tool: the team for a team key, else the
|
||||
user for a personal key, else the key itself. Every other non-empty allowlist only narrows that grant.
|
||||
"""
|
||||
|
||||
key: tuple[str, ...]
|
||||
team: tuple[str, ...] | None
|
||||
user: tuple[str, ...] | None
|
||||
default_deny: bool
|
||||
|
||||
|
||||
UserObjectPermissionLookup: TypeAlias = Callable[[str, UserAPIKeyAuth], Awaitable[LiteLLM_ObjectPermissionTable | None]]
|
||||
|
||||
|
||||
def check_search_tool_grants(search_tool_name: str, grants: SearchToolGrants) -> Literal[True]:
|
||||
"""Enforce ``grants`` for one search tool, raising a 403 ProxyException when it is not allowed."""
|
||||
_can_object_call_search_tool(
|
||||
search_tool_name=search_tool_name,
|
||||
allowed_search_tools=grants.key,
|
||||
object_type="key",
|
||||
empty_allows_all=not (grants.default_deny and grants.team is None and grants.user is None),
|
||||
)
|
||||
if grants.team is not None:
|
||||
_can_object_call_search_tool(
|
||||
search_tool_name=search_tool_name,
|
||||
allowed_search_tools=grants.team,
|
||||
object_type="team",
|
||||
empty_allows_all=not grants.default_deny,
|
||||
)
|
||||
if grants.user is not None:
|
||||
_can_object_call_search_tool(
|
||||
search_tool_name=search_tool_name,
|
||||
allowed_search_tools=grants.user,
|
||||
object_type="user",
|
||||
empty_allows_all=not (grants.default_deny and grants.team is None),
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
async def get_user_object_permission(
|
||||
user_id: str,
|
||||
valid_token: UserAPIKeyAuth,
|
||||
) -> LiteLLM_ObjectPermissionTable | None:
|
||||
from litellm.proxy.proxy_server import (
|
||||
prisma_client,
|
||||
proxy_logging_obj,
|
||||
user_api_key_cache,
|
||||
)
|
||||
|
||||
try:
|
||||
user_object: Final = await get_user_object(
|
||||
user_id=user_id,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
user_id_upsert=False,
|
||||
parent_otel_span=valid_token.parent_otel_span,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
except UserNotFoundError:
|
||||
return None
|
||||
if user_object is None or user_object.object_permission_id is None:
|
||||
return None
|
||||
object_permission: Final = await get_object_permission(
|
||||
object_permission_id=user_object.object_permission_id,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
parent_otel_span=valid_token.parent_otel_span,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
if object_permission is None:
|
||||
raise ProxyException(
|
||||
message=f"Search tool grants of user {user_id} could not be loaded",
|
||||
type=ProxyErrorTypes.no_db_connection,
|
||||
param="search_tool_name",
|
||||
code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
||||
)
|
||||
return object_permission
|
||||
|
||||
|
||||
def _is_search_default_deny_applied(valid_token: UserAPIKeyAuth, general_settings: Mapping[str, object]) -> bool:
|
||||
return (
|
||||
general_settings.get("default_search_list_deny") is True
|
||||
and valid_token.user_role != LitellmUserRoles.PROXY_ADMIN
|
||||
)
|
||||
|
||||
|
||||
async def resolve_search_tool_grants(
|
||||
valid_token: UserAPIKeyAuth,
|
||||
team_object: LiteLLM_TeamTable | None,
|
||||
general_settings: Mapping[str, object],
|
||||
lookup_user_object_permission: UserObjectPermissionLookup = get_user_object_permission,
|
||||
) -> SearchToolGrants:
|
||||
"""Collect the key, team and user search tool allowlists that scope ``valid_token``.
|
||||
|
||||
``general_settings.default_search_list_deny`` turns a missing, null or empty grant into a denial for
|
||||
every caller except a proxy admin. The user layer is only consulted in that mode.
|
||||
"""
|
||||
default_deny: Final = _is_search_default_deny_applied(valid_token, general_settings)
|
||||
user_id: Final = valid_token.user_id
|
||||
return SearchToolGrants(
|
||||
key=tuple(_search_tool_names_from_object_permission(valid_token.object_permission)),
|
||||
team=(
|
||||
None
|
||||
if team_object is None
|
||||
else tuple(_search_tool_names_from_object_permission(team_object.object_permission))
|
||||
),
|
||||
user=(
|
||||
tuple(_search_tool_names_from_object_permission(await lookup_user_object_permission(user_id, valid_token)))
|
||||
if default_deny and user_id
|
||||
else None
|
||||
),
|
||||
default_deny=default_deny,
|
||||
)
|
||||
|
||||
|
||||
async def can_caller_call_search_tool(
|
||||
search_tool_name: str,
|
||||
valid_token: UserAPIKeyAuth,
|
||||
team_object: LiteLLM_TeamTable | None,
|
||||
) -> bool:
|
||||
"""
|
||||
Boolean variant of the key + team authorization enforced on /search, used to
|
||||
scope /search_tools/list so a non-admin caller only sees tools it may invoke.
|
||||
"""
|
||||
try:
|
||||
await can_key_call_search_tool(
|
||||
search_tool_name=search_tool_name,
|
||||
general_settings: Mapping[str, object],
|
||||
lookup_user_object_permission: UserObjectPermissionLookup = get_user_object_permission,
|
||||
) -> Literal[True]:
|
||||
"""Key, team and user search tool authorization shared by /search, web search interception and discovery."""
|
||||
return check_search_tool_grants(
|
||||
search_tool_name=search_tool_name,
|
||||
grants=await resolve_search_tool_grants(
|
||||
valid_token=valid_token,
|
||||
)
|
||||
await can_team_call_search_tool(
|
||||
search_tool_name=search_tool_name,
|
||||
team_object=team_object,
|
||||
general_settings=general_settings,
|
||||
lookup_user_object_permission=lookup_user_object_permission,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def check_unregistered_search_fallback(
|
||||
valid_token: UserAPIKeyAuth,
|
||||
general_settings: Mapping[str, object],
|
||||
) -> Literal[True]:
|
||||
if _is_search_default_deny_applied(valid_token, general_settings):
|
||||
raise ProxyException(
|
||||
message="No registered search tool is available and general_settings.default_search_list_deny is enabled",
|
||||
type=ProxyErrorTypes.key_model_access_denied,
|
||||
param="search_tool_name",
|
||||
code=status.HTTP_403_FORBIDDEN,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def can_grants_view_search_tool(search_tool_name: str, grants: SearchToolGrants) -> bool:
|
||||
"""Boolean variant of check_search_tool_grants used to scope /search_tools/list."""
|
||||
try:
|
||||
check_search_tool_grants(search_tool_name=search_tool_name, grants=grants)
|
||||
except ProxyException:
|
||||
return False
|
||||
return True
|
||||
|
|
|
|||
|
|
@ -7822,6 +7822,7 @@ class ProxyConfig:
|
|||
"store_prompts_in_spend_logs",
|
||||
"disable_auto_add_proxy_admin_to_teams",
|
||||
"apply_user_budget_to_team_keys",
|
||||
"default_search_list_deny",
|
||||
):
|
||||
if key not in db_values or self.settings.owned_by_config(key):
|
||||
continue
|
||||
|
|
@ -18137,6 +18138,7 @@ _GENERAL_SETTINGS_CONFIG_LIST_FIELD_TYPES: Final[Mapping[str, str]] = MappingPro
|
|||
"cancel_on_disconnect": "Boolean",
|
||||
"disable_auto_add_proxy_admin_to_teams": "Boolean",
|
||||
"apply_user_budget_to_team_keys": "Boolean",
|
||||
"default_search_list_deny": "Boolean",
|
||||
"user_api_key_cache_max_size": "Integer",
|
||||
"transcribe_media_buckets": "List",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -138,39 +138,34 @@ async def search(
|
|||
data["model"] = data["search_tool_name"]
|
||||
search_tool_name_value: Final = data["search_tool_name"]
|
||||
|
||||
# Authorization check: verify key can access this search tool
|
||||
from litellm.proxy.auth.auth_checks import (
|
||||
can_key_call_search_tool,
|
||||
can_team_call_search_tool,
|
||||
can_caller_call_search_tool,
|
||||
get_team_object,
|
||||
)
|
||||
|
||||
try:
|
||||
# Check key-level access
|
||||
await can_key_call_search_tool(
|
||||
search_tool_name=search_tool_name_value,
|
||||
valid_token=user_api_key_dict,
|
||||
from litellm.proxy.proxy_server import (
|
||||
prisma_client,
|
||||
user_api_key_cache,
|
||||
)
|
||||
|
||||
# Check team-level access if key is associated with a team
|
||||
if user_api_key_dict.team_id:
|
||||
from litellm.proxy.proxy_server import (
|
||||
prisma_client,
|
||||
proxy_logging_obj,
|
||||
user_api_key_cache,
|
||||
)
|
||||
|
||||
team_object: Final = await get_team_object(
|
||||
team_object: Final = (
|
||||
await get_team_object(
|
||||
team_id=user_api_key_dict.team_id,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
parent_otel_span=user_api_key_dict.parent_otel_span,
|
||||
proxy_logging_obj=proxy_logging_obj,
|
||||
)
|
||||
await can_team_call_search_tool(
|
||||
search_tool_name=search_tool_name_value,
|
||||
team_object=team_object,
|
||||
)
|
||||
if user_api_key_dict.team_id
|
||||
else None
|
||||
)
|
||||
await can_caller_call_search_tool(
|
||||
search_tool_name=search_tool_name_value,
|
||||
valid_token=user_api_key_dict,
|
||||
team_object=team_object,
|
||||
general_settings=general_settings,
|
||||
)
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.error("Search tool authorization failed for %s: %s", search_tool_name_value, e)
|
||||
raise
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
CRUD ENDPOINTS FOR SEARCH TOOLS
|
||||
"""
|
||||
|
||||
from collections.abc import Awaitable, Callable
|
||||
from collections.abc import Awaitable, Callable, Mapping
|
||||
from datetime import datetime
|
||||
from typing import Any, Final, TypeAlias
|
||||
|
||||
|
|
@ -12,6 +12,7 @@ from pydantic import BaseModel
|
|||
from litellm._logging import verbose_proxy_logger
|
||||
from litellm.constants import UI_SESSION_TOKEN_TEAM_ID
|
||||
from litellm.proxy._types import (
|
||||
LiteLLM_ObjectPermissionTable,
|
||||
LiteLLM_TeamTable,
|
||||
LitellmUserRoles,
|
||||
UserAPIKeyAuth,
|
||||
|
|
@ -49,6 +50,7 @@ def _convert_datetime_to_str(value: datetime | str | None) -> str | None:
|
|||
|
||||
|
||||
TeamObjectLookup: TypeAlias = Callable[[str, UserAPIKeyAuth], Awaitable[LiteLLM_TeamTable]]
|
||||
UserObjectPermissionLookup: TypeAlias = Callable[[str, UserAPIKeyAuth], Awaitable[LiteLLM_ObjectPermissionTable | None]]
|
||||
|
||||
|
||||
async def _refresh_router_search_tools() -> None:
|
||||
|
|
@ -82,6 +84,14 @@ async def _team_object_from_db(team_id: str, user_api_key_dict: UserAPIKeyAuth)
|
|||
)
|
||||
|
||||
|
||||
async def _user_object_permission_from_db(
|
||||
user_id: str, user_api_key_dict: UserAPIKeyAuth
|
||||
) -> LiteLLM_ObjectPermissionTable | None:
|
||||
from litellm.proxy.auth.auth_checks import get_user_object_permission
|
||||
|
||||
return await get_user_object_permission(user_id, user_api_key_dict)
|
||||
|
||||
|
||||
def _allowlist_team_id(user_api_key_dict: UserAPIKeyAuth) -> str | None:
|
||||
"""
|
||||
The team whose object_permission allowlist scopes this caller, or None when there is none.
|
||||
|
|
@ -102,10 +112,12 @@ async def _filter_visible_search_tools(
|
|||
search_tools: list[SearchToolInfoResponse],
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
lookup_team_object: TeamObjectLookup = _team_object_from_db,
|
||||
lookup_user_object_permission: UserObjectPermissionLookup = _user_object_permission_from_db,
|
||||
general_settings: Mapping[str, object] | None = None,
|
||||
) -> list[SearchToolInfoResponse]:
|
||||
"""
|
||||
Drop search tools the caller is not authorized to invoke, applying the same
|
||||
key/team object_permission allowlists enforced on /search. Admins see all tools.
|
||||
key/team/user grants enforced on /search. Admins see all tools.
|
||||
"""
|
||||
if user_api_key_dict.user_role in (
|
||||
LitellmUserRoles.PROXY_ADMIN,
|
||||
|
|
@ -113,23 +125,29 @@ async def _filter_visible_search_tools(
|
|||
):
|
||||
return search_tools
|
||||
|
||||
from litellm.proxy.auth.auth_checks import can_user_view_search_tool
|
||||
from litellm.proxy.auth.auth_checks import (
|
||||
can_grants_view_search_tool,
|
||||
resolve_search_tool_grants,
|
||||
)
|
||||
from litellm.proxy.proxy_server import general_settings as proxy_general_settings
|
||||
|
||||
allowlist_team_id: Final = _allowlist_team_id(user_api_key_dict)
|
||||
team_object: Final[LiteLLM_TeamTable | None] = (
|
||||
await lookup_team_object(allowlist_team_id, user_api_key_dict) if allowlist_team_id else None
|
||||
)
|
||||
grants: Final = await resolve_search_tool_grants(
|
||||
valid_token=user_api_key_dict,
|
||||
team_object=team_object,
|
||||
general_settings=proxy_general_settings if general_settings is None else general_settings,
|
||||
lookup_user_object_permission=lookup_user_object_permission,
|
||||
)
|
||||
|
||||
visible: Final[list[SearchToolInfoResponse]] = []
|
||||
for tool in search_tools:
|
||||
tool_name = tool.get("search_tool_name")
|
||||
if tool_name and await can_user_view_search_tool(
|
||||
search_tool_name=tool_name,
|
||||
valid_token=user_api_key_dict,
|
||||
team_object=team_object,
|
||||
):
|
||||
visible.append(tool)
|
||||
return visible
|
||||
return [
|
||||
tool
|
||||
for tool in search_tools
|
||||
if (tool_name := tool.get("search_tool_name"))
|
||||
and can_grants_view_search_tool(search_tool_name=tool_name, grants=grants)
|
||||
]
|
||||
|
||||
|
||||
@router.get(
|
||||
|
|
|
|||
|
|
@ -367,12 +367,17 @@ ALLOWED_UI_SETTINGS_FIELDS: Final = {
|
|||
|
||||
ENABLE_PTU_COST_ATTRIBUTION_UI_SETTING: Final = "enable_ptu_cost_attribution"
|
||||
APPLY_USER_BUDGET_TO_TEAM_KEYS_UI_SETTING: Final = "apply_user_budget_to_team_keys"
|
||||
DEFAULT_SEARCH_LIST_DENY_UI_SETTING: Final = "default_search_list_deny"
|
||||
|
||||
# UI settings derived from the deployment environment. Deliberately kept out of
|
||||
# ALLOWED_UI_SETTINGS_FIELDS: they are read-only, never persisted, and PATCH
|
||||
# rejects them so an admin cannot flip an env-gated feature at runtime.
|
||||
_DERIVED_UI_SETTINGS_FIELDS: Final[frozenset[str]] = frozenset(
|
||||
{ENABLE_PTU_COST_ATTRIBUTION_UI_SETTING, APPLY_USER_BUDGET_TO_TEAM_KEYS_UI_SETTING}
|
||||
{
|
||||
ENABLE_PTU_COST_ATTRIBUTION_UI_SETTING,
|
||||
APPLY_USER_BUDGET_TO_TEAM_KEYS_UI_SETTING,
|
||||
DEFAULT_SEARCH_LIST_DENY_UI_SETTING,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -380,6 +385,10 @@ def _apply_user_budget_to_team_keys_enabled(settings: Mapping[str, object]) -> b
|
|||
return settings.get(APPLY_USER_BUDGET_TO_TEAM_KEYS_UI_SETTING) is True
|
||||
|
||||
|
||||
def _default_search_list_deny_enabled(settings: Mapping[str, object]) -> bool:
|
||||
return settings.get(DEFAULT_SEARCH_LIST_DENY_UI_SETTING) is True
|
||||
|
||||
|
||||
def _derived_ui_setting_value(key: str) -> object:
|
||||
"""The environment-derived value GET reports for ``key``.
|
||||
|
||||
|
|
@ -396,6 +405,12 @@ def _derived_ui_setting_value(key: str) -> object:
|
|||
return _apply_user_budget_to_team_keys_enabled(
|
||||
cast(Mapping[str, object], general_settings) # cast-ok: proxy_server declares general_settings as bare dict
|
||||
)
|
||||
if key == DEFAULT_SEARCH_LIST_DENY_UI_SETTING:
|
||||
from litellm.proxy.proxy_server import general_settings
|
||||
|
||||
return _default_search_list_deny_enabled(
|
||||
cast(Mapping[str, object], general_settings) # cast-ok: proxy_server declares general_settings as bare dict
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
|
|
@ -792,7 +807,7 @@ def _ui_setting_source(
|
|||
if key == ENABLE_PTU_COST_ATTRIBUTION_UI_SETTING:
|
||||
configured_value: Final = get_secret(PTU_COST_ATTRIBUTION_ENV_VAR, None)
|
||||
return "config" if configured_value is not None or value is True else "default"
|
||||
if key == APPLY_USER_BUDGET_TO_TEAM_KEYS_UI_SETTING:
|
||||
if key in (APPLY_USER_BUDGET_TO_TEAM_KEYS_UI_SETTING, DEFAULT_SEARCH_LIST_DENY_UI_SETTING):
|
||||
return "config" if value is True else "default"
|
||||
return source_for(settings, key, _model_field_default(settings_class, key))
|
||||
|
||||
|
|
@ -1803,6 +1818,7 @@ async def get_ui_settings():
|
|||
APPLY_USER_BUDGET_TO_TEAM_KEYS_UI_SETTING: _derived_ui_setting_value(
|
||||
APPLY_USER_BUDGET_TO_TEAM_KEYS_UI_SETTING
|
||||
),
|
||||
DEFAULT_SEARCH_LIST_DENY_UI_SETTING: _derived_ui_setting_value(DEFAULT_SEARCH_LIST_DENY_UI_SETTING),
|
||||
}
|
||||
)
|
||||
source: Final[Mapping[str, FieldSource]] = MappingProxyType(
|
||||
|
|
|
|||
|
|
@ -1148,3 +1148,99 @@ async def test_create_search_tool_survives_a_failing_router_refresh():
|
|||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["search_tool_name"] == "tavily-search"
|
||||
|
||||
|
||||
def _user_permission_lookup(search_tools: list[str] | None) -> AsyncMock:
|
||||
return AsyncMock(
|
||||
return_value=(
|
||||
None
|
||||
if search_tools is None
|
||||
else LiteLLM_ObjectPermissionTable(object_permission_id="op-user", search_tools=search_tools)
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"general_settings, user_search_tools, expected",
|
||||
[
|
||||
({}, None, ["db-tool-1", "db-tool-2"]),
|
||||
({"default_search_list_deny": False}, [], ["db-tool-1", "db-tool-2"]),
|
||||
({"default_search_list_deny": True}, None, []),
|
||||
({"default_search_list_deny": True}, [], []),
|
||||
({"default_search_list_deny": True}, ["db-tool-2"], ["db-tool-2"]),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_filter_visible_search_tools_personal_key_follows_default_search_list_deny(
|
||||
general_settings, user_search_tools, expected
|
||||
):
|
||||
from litellm.proxy.search_endpoints.search_tool_management import (
|
||||
_filter_visible_search_tools,
|
||||
)
|
||||
|
||||
personal_key = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id="internal_user")
|
||||
team_lookup = AsyncMock()
|
||||
|
||||
visible = await _filter_visible_search_tools(
|
||||
_search_tool_responses("db-tool-1", "db-tool-2"),
|
||||
personal_key,
|
||||
team_lookup,
|
||||
_user_permission_lookup(user_search_tools),
|
||||
general_settings,
|
||||
)
|
||||
|
||||
assert [t["search_tool_name"] for t in visible] == expected
|
||||
team_lookup.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"team_search_tools, expected",
|
||||
[([], []), (["db-tool-1"], ["db-tool-1"])],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_filter_visible_search_tools_team_key_needs_a_team_grant_under_default_search_list_deny(
|
||||
team_search_tools, expected
|
||||
):
|
||||
from litellm.proxy.search_endpoints.search_tool_management import (
|
||||
_filter_visible_search_tools,
|
||||
)
|
||||
|
||||
team_member = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id="internal_user", team_id="team-1")
|
||||
team_lookup = AsyncMock(
|
||||
return_value=LiteLLM_TeamTable(
|
||||
team_id="team-1",
|
||||
object_permission=LiteLLM_ObjectPermissionTable(
|
||||
object_permission_id="op-team", search_tools=team_search_tools
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
visible = await _filter_visible_search_tools(
|
||||
_search_tool_responses("db-tool-1", "db-tool-2"),
|
||||
team_member,
|
||||
team_lookup,
|
||||
_user_permission_lookup(["db-tool-1", "db-tool-2"]),
|
||||
{"default_search_list_deny": True},
|
||||
)
|
||||
|
||||
assert [t["search_tool_name"] for t in visible] == expected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filter_visible_search_tools_admin_sees_every_tool_under_default_search_list_deny():
|
||||
from litellm.proxy.search_endpoints.search_tool_management import (
|
||||
_filter_visible_search_tools,
|
||||
)
|
||||
|
||||
user_lookup = _user_permission_lookup(None)
|
||||
|
||||
visible = await _filter_visible_search_tools(
|
||||
_search_tool_responses("db-tool-1", "db-tool-2"),
|
||||
UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, user_id="admin"),
|
||||
AsyncMock(),
|
||||
user_lookup,
|
||||
{"default_search_list_deny": True},
|
||||
)
|
||||
|
||||
assert [t["search_tool_name"] for t in visible] == ["db-tool-1", "db-tool-2"]
|
||||
user_lookup.assert_not_awaited()
|
||||
|
|
|
|||
|
|
@ -7959,6 +7959,20 @@ async def test_update_general_settings_propagates_apply_user_budget_to_team_keys
|
|||
assert ps.general_settings["apply_user_budget_to_team_keys"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_general_settings_propagates_default_search_list_deny():
|
||||
from litellm.proxy.proxy_server import ProxyConfig
|
||||
|
||||
proxy_config = ProxyConfig()
|
||||
|
||||
with patch("litellm.proxy.proxy_server.general_settings", {}):
|
||||
await proxy_config._update_general_settings(db_general_settings={"default_search_list_deny": "true"})
|
||||
|
||||
import litellm.proxy.proxy_server as ps
|
||||
|
||||
assert ps.general_settings["default_search_list_deny"] is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_general_settings_propagates_spend_log_cleanup_bounds():
|
||||
"""The dashboard writes the cleanup bounds straight to the DB config, so
|
||||
|
|
@ -11596,6 +11610,33 @@ def test_get_config_list_includes_apply_user_budget_to_team_keys(monkeypatch):
|
|||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_get_config_list_includes_default_search_list_deny(monkeypatch):
|
||||
import types
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import litellm.proxy.proxy_server as ps
|
||||
from litellm.proxy._types import LitellmUserRoles, UserAPIKeyAuth
|
||||
from litellm.proxy.proxy_server import app
|
||||
|
||||
mock_prisma = MagicMock()
|
||||
mock_config_table = MagicMock()
|
||||
mock_config_table.find_first = AsyncMock(return_value=None)
|
||||
mock_prisma.db = types.SimpleNamespace(litellm_config=mock_config_table)
|
||||
monkeypatch.setattr(ps, "prisma_client", mock_prisma)
|
||||
app.dependency_overrides[ps.user_api_key_auth] = lambda: UserAPIKeyAuth(
|
||||
user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN
|
||||
)
|
||||
try:
|
||||
resp = TestClient(app).get("/config/list", params={"config_type": "general_settings"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
fields = {item["field_name"]: item for item in resp.json()}
|
||||
assert fields["default_search_list_deny"]["field_type"] == "Boolean"
|
||||
finally:
|
||||
app.dependency_overrides.clear()
|
||||
|
||||
|
||||
def test_get_config_list_includes_user_api_key_cache_max_size(monkeypatch):
|
||||
"""The Admin UI General Settings table renders whatever /config/list returns,
|
||||
so the cache capacity has to be exposed there as an Integer to be editable."""
|
||||
|
|
|
|||
|
|
@ -3766,6 +3766,38 @@ class TestApplyUserBudgetToTeamKeysUISetting:
|
|||
assert "apply_user_budget_to_team_keys" not in ALLOWED_UI_SETTINGS_FIELDS
|
||||
|
||||
|
||||
class TestDefaultSearchListDenyUISetting:
|
||||
"""``default_search_list_deny`` mirrors general_settings so the dashboard can explain empty search grants."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"general_settings, expected",
|
||||
[
|
||||
({}, False),
|
||||
({"default_search_list_deny": False}, False),
|
||||
({"default_search_list_deny": "true"}, False),
|
||||
({"default_search_list_deny": True}, True),
|
||||
({"role_permissions": [object()], "default_search_list_deny": True}, True),
|
||||
],
|
||||
)
|
||||
def test_reported_from_general_settings(self, mock_auth, monkeypatch, general_settings, expected):
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", general_settings)
|
||||
TestApplyUserBudgetToTeamKeysUISetting._mock_prisma(monkeypatch)
|
||||
|
||||
response = client.get("/get/ui_settings")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["values"]["default_search_list_deny"] is expected
|
||||
|
||||
def test_a_persisted_true_cannot_forge_the_derived_value(self, mock_auth, monkeypatch):
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {})
|
||||
TestApplyUserBudgetToTeamKeysUISetting._mock_prisma(monkeypatch, stored={"default_search_list_deny": True})
|
||||
|
||||
response = client.get("/get/ui_settings")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["values"]["default_search_list_deny"] is False
|
||||
|
||||
|
||||
class TestTeamAdminEditableTeamFieldsSetting:
|
||||
"""team_admin_editable_team_fields: the proxy-wide allow-list update_team applies to team admins."""
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,13 @@ from litellm.integrations.websearch_interception.handler import (
|
|||
WebSearchInterceptionLogger,
|
||||
)
|
||||
from litellm.llms.base_llm.search.transformation import SearchResponse
|
||||
from litellm.proxy._types import LiteLLM_ObjectPermissionTable, LiteLLM_TeamTable, ProxyException, UserAPIKeyAuth
|
||||
from litellm.proxy._types import (
|
||||
LiteLLM_ObjectPermissionTable,
|
||||
LiteLLM_TeamTable,
|
||||
LitellmUserRoles,
|
||||
ProxyException,
|
||||
UserAPIKeyAuth,
|
||||
)
|
||||
from litellm.types.utils import LlmProviders
|
||||
|
||||
|
||||
|
|
@ -586,6 +592,116 @@ async def test_execute_search_enforces_team_search_tool_permission(monkeypatch):
|
|||
mock_asearch.assert_not_awaited()
|
||||
|
||||
|
||||
def _single_search_tool_router(search_tool_name):
|
||||
router = MagicMock()
|
||||
router.search_tools = [
|
||||
{
|
||||
"search_tool_name": search_tool_name,
|
||||
"litellm_params": {"search_provider": "tavily", "api_key": "fake-ui-key"},
|
||||
}
|
||||
]
|
||||
return router
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"general_settings, team_search_tools, expect_search",
|
||||
[
|
||||
({}, [], True),
|
||||
({"default_search_list_deny": True}, [], False),
|
||||
({"default_search_list_deny": True}, None, False),
|
||||
({"default_search_list_deny": True}, ["team-search"], True),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_search_team_key_follows_default_search_list_deny(
|
||||
monkeypatch, general_settings, team_search_tools, expect_search
|
||||
):
|
||||
import litellm
|
||||
from litellm.proxy import proxy_server
|
||||
|
||||
logger = WebSearchInterceptionLogger(enabled_providers=["bedrock"], search_tool_name="team-search")
|
||||
mock_asearch = AsyncMock(return_value=SearchResponse(object="search", results=[]))
|
||||
team_object = LiteLLM_TeamTable(
|
||||
team_id="team-1",
|
||||
object_permission=(
|
||||
None
|
||||
if team_search_tools is None
|
||||
else LiteLLM_ObjectPermissionTable(object_permission_id="op-team", search_tools=team_search_tools)
|
||||
),
|
||||
)
|
||||
|
||||
monkeypatch.setattr(proxy_server, "llm_router", _single_search_tool_router("team-search"))
|
||||
monkeypatch.setattr(proxy_server, "general_settings", general_settings)
|
||||
monkeypatch.setattr(litellm, "asearch", mock_asearch)
|
||||
monkeypatch.setattr("litellm.proxy.auth.auth_checks.get_team_object", AsyncMock(return_value=team_object))
|
||||
kwargs = {"metadata": {"user_api_key_auth": UserAPIKeyAuth(team_id="team-1")}}
|
||||
|
||||
if expect_search:
|
||||
await logger._execute_search("what is litellm", kwargs=kwargs)
|
||||
mock_asearch.assert_awaited_once()
|
||||
else:
|
||||
with pytest.raises(ProxyException):
|
||||
await logger._execute_search("what is litellm", kwargs=kwargs)
|
||||
mock_asearch.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_search_key_without_grant_is_denied_under_default_search_list_deny(monkeypatch):
|
||||
import litellm
|
||||
from litellm.proxy import proxy_server
|
||||
|
||||
logger = WebSearchInterceptionLogger(enabled_providers=["bedrock"], search_tool_name="any-search")
|
||||
mock_asearch = AsyncMock(return_value=SearchResponse(object="search", results=[]))
|
||||
|
||||
monkeypatch.setattr(proxy_server, "llm_router", _single_search_tool_router("any-search"))
|
||||
monkeypatch.setattr(proxy_server, "general_settings", {"default_search_list_deny": True})
|
||||
monkeypatch.setattr(litellm, "asearch", mock_asearch)
|
||||
|
||||
with pytest.raises(ProxyException):
|
||||
await logger._execute_search(
|
||||
"what is litellm",
|
||||
kwargs={"metadata": {"user_api_key_auth": UserAPIKeyAuth()}},
|
||||
)
|
||||
|
||||
mock_asearch.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"general_settings, user_role, expect_search",
|
||||
[
|
||||
({}, None, True),
|
||||
({"default_search_list_deny": False}, None, True),
|
||||
({"default_search_list_deny": True}, None, False),
|
||||
({"default_search_list_deny": True}, LitellmUserRoles.PROXY_ADMIN, True),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_search_unregistered_fallback_follows_default_search_list_deny(
|
||||
monkeypatch, general_settings, user_role, expect_search
|
||||
):
|
||||
import litellm
|
||||
from litellm.proxy import proxy_server
|
||||
|
||||
logger = WebSearchInterceptionLogger(enabled_providers=["bedrock"])
|
||||
router = MagicMock()
|
||||
router.search_tools = []
|
||||
mock_asearch = AsyncMock(return_value=SearchResponse(object="search", results=[]))
|
||||
|
||||
monkeypatch.setattr(proxy_server, "llm_router", router)
|
||||
monkeypatch.setattr(proxy_server, "general_settings", general_settings)
|
||||
monkeypatch.setattr(litellm, "asearch", mock_asearch)
|
||||
kwargs = {"metadata": {"user_api_key_auth": UserAPIKeyAuth(user_id="user-1", user_role=user_role)}}
|
||||
|
||||
if expect_search:
|
||||
await logger._execute_search("what is litellm", kwargs=kwargs)
|
||||
assert mock_asearch.await_args.kwargs["search_provider"] == "perplexity"
|
||||
else:
|
||||
with pytest.raises(ProxyException) as exc_info:
|
||||
await logger._execute_search("what is litellm", kwargs=kwargs)
|
||||
assert exc_info.value.code == "403"
|
||||
mock_asearch.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_async_pre_call_deployment_hook_provider_from_top_level_kwargs():
|
||||
"""Test that async_pre_call_deployment_hook finds custom_llm_provider at top-level kwargs.
|
||||
|
|
|
|||
|
|
@ -1491,3 +1491,255 @@ async def test_key_access_group_grants_model_when_get_access_object_raises():
|
|||
finally:
|
||||
for p in patches:
|
||||
p.stop()
|
||||
|
||||
|
||||
from typing import Final
|
||||
from unittest.mock import AsyncMock, MagicMock as _MagicMock
|
||||
|
||||
from litellm.proxy._types import (
|
||||
LiteLLM_ObjectPermissionTable as _ObjectPermission,
|
||||
LitellmUserRoles as _Roles,
|
||||
ProxyException as _ProxyException,
|
||||
)
|
||||
from litellm.proxy.auth.auth_checks import (
|
||||
can_caller_call_search_tool,
|
||||
get_user_object_permission,
|
||||
)
|
||||
from litellm.proxy.common_utils.user_api_key_cache import (
|
||||
object_permission_cache_key,
|
||||
)
|
||||
|
||||
_DENY_ON: Final = {"default_search_list_deny": True}
|
||||
_NO_USER_ROW: Final = "no-user-row"
|
||||
_SEARCH_TOOLS_NULL: Final = "search-tools-null"
|
||||
|
||||
|
||||
def _search_permission(search_tools):
|
||||
return _ObjectPermission(object_permission_id="op", search_tools=search_tools)
|
||||
|
||||
|
||||
def _user_lookup(user_search_tools):
|
||||
async def lookup(user_id, valid_token):
|
||||
if user_search_tools == _NO_USER_ROW:
|
||||
return None
|
||||
if user_search_tools == _SEARCH_TOOLS_NULL:
|
||||
return _search_permission(None)
|
||||
return _search_permission(user_search_tools)
|
||||
|
||||
return lookup
|
||||
|
||||
|
||||
async def _search_tool_allowed(
|
||||
search_tool_name,
|
||||
general_settings,
|
||||
key_tools=None,
|
||||
team_tools=None,
|
||||
has_team=False,
|
||||
user_tools=_NO_USER_ROW,
|
||||
user_id="user-1",
|
||||
user_role=_Roles.INTERNAL_USER,
|
||||
):
|
||||
valid_token = UserAPIKeyAuth(
|
||||
user_id=user_id,
|
||||
user_role=user_role,
|
||||
team_id="team-1" if has_team else None,
|
||||
object_permission=None if key_tools is None else _search_permission(key_tools),
|
||||
)
|
||||
team_object = (
|
||||
LiteLLM_TeamTable(
|
||||
team_id="team-1",
|
||||
object_permission=None if team_tools is None else _search_permission(team_tools),
|
||||
)
|
||||
if has_team
|
||||
else None
|
||||
)
|
||||
try:
|
||||
await can_caller_call_search_tool(
|
||||
search_tool_name=search_tool_name,
|
||||
valid_token=valid_token,
|
||||
team_object=team_object,
|
||||
general_settings=general_settings,
|
||||
lookup_user_object_permission=_user_lookup(user_tools),
|
||||
)
|
||||
except _ProxyException as e:
|
||||
if e.code != "403":
|
||||
raise
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"general_settings",
|
||||
[{}, {"default_search_list_deny": False}, {"default_search_list_deny": None}, {"default_search_list_deny": "true"}],
|
||||
ids=["omitted", "false", "null", "string-true"],
|
||||
)
|
||||
@pytest.mark.parametrize(
|
||||
"caller, expected",
|
||||
[
|
||||
(dict(), True),
|
||||
(dict(key_tools=[]), True),
|
||||
(dict(has_team=True), True),
|
||||
(dict(has_team=True, team_tools=[]), True),
|
||||
(dict(user_tools=[]), True),
|
||||
(dict(user_tools=["search-b"]), True),
|
||||
(dict(key_tools=["search-b"]), False),
|
||||
(dict(has_team=True, team_tools=["search-b"]), False),
|
||||
(dict(key_tools=["search-a"]), True),
|
||||
(dict(has_team=True, team_tools=["search-a"]), True),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_tool_access_is_unchanged_without_default_search_list_deny(general_settings, caller, expected):
|
||||
assert await _search_tool_allowed("search-a", general_settings, **caller) is expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"caller, expected",
|
||||
[
|
||||
pytest.param(dict(), False, id="personal key, no user row"),
|
||||
pytest.param(dict(user_tools=_SEARCH_TOOLS_NULL), False, id="personal key, user search_tools null"),
|
||||
pytest.param(dict(user_tools=[]), False, id="personal key, user search_tools empty"),
|
||||
pytest.param(dict(key_tools=[], user_tools=[]), False, id="personal key, key and user empty"),
|
||||
pytest.param(dict(user_tools=["search-b"]), False, id="personal key, user grants another tool"),
|
||||
pytest.param(dict(user_tools=["search-a"]), True, id="personal key, user grants the tool"),
|
||||
pytest.param(dict(key_tools=["search-a"]), False, id="personal key, key grant cannot stand in for user"),
|
||||
pytest.param(
|
||||
dict(key_tools=["search-b"], user_tools=["search-a", "search-b"]),
|
||||
False,
|
||||
id="personal key, key allowlist narrows user grant",
|
||||
),
|
||||
pytest.param(dict(has_team=True), False, id="team key, team object_permission missing"),
|
||||
pytest.param(dict(has_team=True, team_tools=None), False, id="team key, team search_tools null"),
|
||||
pytest.param(dict(has_team=True, team_tools=[]), False, id="team key, team search_tools empty"),
|
||||
pytest.param(dict(has_team=True, team_tools=["search-a"]), True, id="team key, team grants the tool"),
|
||||
pytest.param(dict(has_team=True, team_tools=["search-b"]), False, id="team key, team grants another tool"),
|
||||
pytest.param(
|
||||
dict(has_team=True, team_tools=[], user_tools=["search-a"]),
|
||||
False,
|
||||
id="team key, user grant cannot stand in for team",
|
||||
),
|
||||
pytest.param(
|
||||
dict(has_team=True, team_tools=[], key_tools=["search-a"]),
|
||||
False,
|
||||
id="team key, key grant cannot expand empty team",
|
||||
),
|
||||
pytest.param(
|
||||
dict(has_team=True, team_tools=["search-a"], user_tools=[]),
|
||||
True,
|
||||
id="team key, empty user list does not revoke team grant",
|
||||
),
|
||||
pytest.param(
|
||||
dict(has_team=True, team_tools=["search-a", "search-b"], user_tools=["search-b"]),
|
||||
False,
|
||||
id="team key, user allowlist narrows team grant",
|
||||
),
|
||||
pytest.param(
|
||||
dict(has_team=True, team_tools=["search-a", "search-b"], key_tools=["search-b"]),
|
||||
False,
|
||||
id="team key, key allowlist narrows team grant",
|
||||
),
|
||||
pytest.param(
|
||||
dict(has_team=True, team_tools=["search-a"], key_tools=["search-a"], user_tools=["search-a"]),
|
||||
True,
|
||||
id="team key, every layer grants the tool",
|
||||
),
|
||||
pytest.param(dict(user_id=None), False, id="key without user or team, no grant"),
|
||||
pytest.param(dict(user_id=None, key_tools=[]), False, id="key without user or team, empty grant"),
|
||||
pytest.param(dict(user_id=None, key_tools=["search-a"]), True, id="key without user or team, key grants"),
|
||||
pytest.param(dict(user_role=_Roles.PROXY_ADMIN), True, id="proxy admin is exempt"),
|
||||
pytest.param(dict(user_role=_Roles.PROXY_ADMIN_VIEW_ONLY), False, id="view-only admin is not exempt"),
|
||||
],
|
||||
)
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_search_list_deny_requires_an_explicit_grant(caller, expected):
|
||||
assert await _search_tool_allowed("search-a", _DENY_ON, **caller) is expected
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_search_list_deny_reads_the_user_grant_on_every_call():
|
||||
user_grants = [["search-a"], []]
|
||||
|
||||
async def lookup(user_id, valid_token):
|
||||
return _search_permission(user_grants.pop(0))
|
||||
|
||||
valid_token = UserAPIKeyAuth(user_id="user-1", user_role=_Roles.INTERNAL_USER)
|
||||
await can_caller_call_search_tool("search-a", valid_token, None, _DENY_ON, lookup)
|
||||
with pytest.raises(_ProxyException):
|
||||
await can_caller_call_search_tool("search-a", valid_token, None, _DENY_ON, lookup)
|
||||
assert user_grants == []
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_search_list_deny_does_not_look_up_the_user_when_off():
|
||||
lookup = AsyncMock(return_value=_search_permission(["search-b"]))
|
||||
valid_token = UserAPIKeyAuth(user_id="user-1", user_role=_Roles.INTERNAL_USER)
|
||||
|
||||
assert await can_caller_call_search_tool("search-a", valid_token, None, {}, lookup) is True
|
||||
lookup.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_default_search_list_deny_propagates_a_user_lookup_failure():
|
||||
lookup = AsyncMock(side_effect=RuntimeError("db down"))
|
||||
valid_token = UserAPIKeyAuth(user_id="user-1", user_role=_Roles.INTERNAL_USER)
|
||||
|
||||
with pytest.raises(RuntimeError, match="db down"):
|
||||
await can_caller_call_search_tool("search-a", valid_token, None, _DENY_ON, lookup)
|
||||
|
||||
|
||||
def _cache_with(*entries):
|
||||
cache = UserApiKeyCache()
|
||||
for key, value in entries:
|
||||
cache.set_cache(key=key, value=value)
|
||||
return cache
|
||||
|
||||
|
||||
def _patch_user_permission_sources(monkeypatch, cache, prisma_client):
|
||||
import litellm.proxy.proxy_server as proxy_server
|
||||
|
||||
monkeypatch.setattr(proxy_server, "user_api_key_cache", cache)
|
||||
monkeypatch.setattr(proxy_server, "prisma_client", prisma_client)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_object_permission_returns_the_linked_row(monkeypatch):
|
||||
user_row = LiteLLM_UserTable(user_id="user-1", object_permission_id="op-user")
|
||||
permission = _ObjectPermission(object_permission_id="op-user", search_tools=["search-a"])
|
||||
cache = _cache_with(("user-1", user_row), (object_permission_cache_key("op-user"), permission))
|
||||
_patch_user_permission_sources(monkeypatch, cache, _MagicMock())
|
||||
|
||||
result = await get_user_object_permission("user-1", UserAPIKeyAuth(user_id="user-1"))
|
||||
|
||||
assert result is not None
|
||||
assert result.search_tools == ["search-a"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_object_permission_is_none_for_a_user_without_a_link(monkeypatch):
|
||||
cache = _cache_with(("user-1", LiteLLM_UserTable(user_id="user-1")))
|
||||
_patch_user_permission_sources(monkeypatch, cache, _MagicMock())
|
||||
|
||||
assert await get_user_object_permission("user-1", UserAPIKeyAuth(user_id="user-1")) is None
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_object_permission_denies_when_the_linked_row_cannot_be_read(monkeypatch):
|
||||
cache = _cache_with(("user-1", LiteLLM_UserTable(user_id="user-1", object_permission_id="op-gone")))
|
||||
prisma_client = _MagicMock()
|
||||
prisma_client.db.litellm_objectpermissiontable.find_unique = AsyncMock(side_effect=RuntimeError("db down"))
|
||||
_patch_user_permission_sources(monkeypatch, cache, prisma_client)
|
||||
|
||||
with pytest.raises(_ProxyException) as exc_info:
|
||||
await get_user_object_permission("user-1", UserAPIKeyAuth(user_id="user-1"))
|
||||
|
||||
assert exc_info.value.code == "503"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_user_object_permission_is_none_for_an_unknown_user(monkeypatch):
|
||||
prisma_client = _MagicMock()
|
||||
prisma_client.db.litellm_usertable.find_unique = AsyncMock(return_value=None)
|
||||
prisma_client.db.litellm_usertable.find_first = AsyncMock(return_value=None)
|
||||
_patch_user_permission_sources(monkeypatch, UserApiKeyCache(), prisma_client)
|
||||
|
||||
assert await get_user_object_permission("ghost", UserAPIKeyAuth(user_id="ghost")) is None
|
||||
|
|
|
|||
0
tests/unit/proxy/search_endpoints/__init__.py
Normal file
0
tests/unit/proxy/search_endpoints/__init__.py
Normal file
113
tests/unit/proxy/search_endpoints/test_endpoints.py
Normal file
113
tests/unit/proxy/search_endpoints/test_endpoints.py
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
from collections.abc import Iterator
|
||||
from typing import Final
|
||||
|
||||
import pytest
|
||||
import respx
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
import litellm
|
||||
from litellm import Router
|
||||
from litellm.proxy import proxy_server
|
||||
from litellm.proxy._types import (
|
||||
LiteLLM_ObjectPermissionTable,
|
||||
LiteLLM_TeamTableCachedObj,
|
||||
LiteLLM_UserTable,
|
||||
LitellmUserRoles,
|
||||
ProxyException,
|
||||
UserAPIKeyAuth,
|
||||
)
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
||||
from litellm.proxy.search_endpoints.endpoints import router
|
||||
|
||||
TAVILY_SEARCH_URL: Final = "https://api.tavily.com/search"
|
||||
TAVILY_RESULT: Final = {"title": "LiteLLM", "url": "https://docs.litellm.ai", "content": "LLM gateway"}
|
||||
|
||||
|
||||
def _search_router() -> Router:
|
||||
return Router(
|
||||
model_list=[],
|
||||
search_tools=[
|
||||
{"search_tool_name": "search-a", "litellm_params": {"search_provider": "tavily", "api_key": "fake"}},
|
||||
],
|
||||
num_retries=0,
|
||||
)
|
||||
|
||||
|
||||
def _client(caller: UserAPIKeyAuth) -> TestClient:
|
||||
app = FastAPI()
|
||||
app.include_router(router)
|
||||
app.add_exception_handler(ProxyException, proxy_server.openai_exception_handler)
|
||||
app.dependency_overrides[user_api_key_auth] = lambda: caller
|
||||
return TestClient(app, raise_server_exceptions=False)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tavily(monkeypatch: pytest.MonkeyPatch) -> Iterator[respx.Route]:
|
||||
monkeypatch.setattr( # test-quality-ok: respx needs HTTPX enabled to fake the provider HTTP boundary.
|
||||
litellm,
|
||||
"disable_aiohttp_transport",
|
||||
True,
|
||||
)
|
||||
litellm.in_memory_llm_clients_cache.flush_cache()
|
||||
with respx.mock(assert_all_called=False) as mock:
|
||||
yield mock.post(TAVILY_SEARCH_URL).respond(200, json={"results": [TAVILY_RESULT]})
|
||||
litellm.in_memory_llm_clients_cache.flush_cache()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def cache(monkeypatch: pytest.MonkeyPatch) -> UserApiKeyCache:
|
||||
user_api_key_cache = UserApiKeyCache()
|
||||
user_api_key_cache.set_cache(key="user-1", value=LiteLLM_UserTable(user_id="user-1"))
|
||||
monkeypatch.setattr(proxy_server, "user_api_key_cache", user_api_key_cache)
|
||||
monkeypatch.setattr(proxy_server, "prisma_client", object())
|
||||
monkeypatch.setattr(proxy_server, "llm_router", _search_router())
|
||||
return user_api_key_cache
|
||||
|
||||
|
||||
def _cache_team(cache: UserApiKeyCache, search_tools: list[str]) -> None:
|
||||
team = LiteLLM_TeamTableCachedObj(
|
||||
team_id="team-1",
|
||||
object_permission=LiteLLM_ObjectPermissionTable(object_permission_id="op-team", search_tools=search_tools),
|
||||
)
|
||||
cache.set_cache(key="team_id:team-1", value=team)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"general_settings, team_search_tools, expected_status",
|
||||
[
|
||||
({}, [], 200),
|
||||
({"default_search_list_deny": False}, [], 200),
|
||||
({"default_search_list_deny": True}, [], 403),
|
||||
({"default_search_list_deny": True}, ["search-b"], 403),
|
||||
({"default_search_list_deny": True}, ["search-a"], 200),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("path", ["/v1/search/search-a", "/search/search-a"])
|
||||
def test_direct_search_team_key_follows_default_search_list_deny(
|
||||
monkeypatch, cache, tavily, path, general_settings, team_search_tools, expected_status
|
||||
):
|
||||
monkeypatch.setattr(proxy_server, "general_settings", general_settings)
|
||||
_cache_team(cache, team_search_tools)
|
||||
caller = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id="user-1", team_id="team-1")
|
||||
|
||||
response = _client(caller).post(path, json={"query": "what is litellm"})
|
||||
|
||||
assert response.status_code == expected_status, response.text
|
||||
if expected_status == 200:
|
||||
assert response.json()["results"][0]["url"] == TAVILY_RESULT["url"]
|
||||
assert tavily.call_count == 1
|
||||
else:
|
||||
assert "search-a" in response.text
|
||||
assert tavily.call_count == 0
|
||||
|
||||
|
||||
def test_direct_search_body_tool_name_is_denied_under_default_search_list_deny(monkeypatch, cache, tavily):
|
||||
monkeypatch.setattr(proxy_server, "general_settings", {"default_search_list_deny": True})
|
||||
caller = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER)
|
||||
|
||||
response = _client(caller).post("/v1/search", json={"search_tool_name": "search-a", "query": "what is litellm"})
|
||||
|
||||
assert response.status_code == 403, response.text
|
||||
assert tavily.call_count == 0
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { useUISettings } from "./useUISettings";
|
||||
|
||||
export const DEFAULT_SEARCH_LIST_DENY_SETTING_KEY = "default_search_list_deny";
|
||||
|
||||
export const useDefaultSearchListDeny = (): boolean =>
|
||||
useUISettings().data?.values?.[DEFAULT_SEARCH_LIST_DENY_SETTING_KEY] === true;
|
||||
|
|
@ -2,7 +2,7 @@ import { getUiSettings } from "@/components/networking";
|
|||
import { useQuery } from "@tanstack/react-query";
|
||||
import { createQueryKeys } from "../common/queryKeysFactory";
|
||||
|
||||
const uiSettingsKeys = createQueryKeys("uiSettings");
|
||||
export const uiSettingsKeys = createQueryKeys("uiSettings");
|
||||
|
||||
/**
|
||||
* UI settings, cached for an hour by default because they rarely change.
|
||||
|
|
|
|||
|
|
@ -1,4 +1,11 @@
|
|||
import { fireEvent, renderWithProviders, screen, within } from "../../../../../tests/test-utils";
|
||||
import {
|
||||
fireEvent,
|
||||
renderWithProviders,
|
||||
screen,
|
||||
testQueryClient,
|
||||
waitFor,
|
||||
within,
|
||||
} from "../../../../../tests/test-utils";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import GeneralSettings from "./general_settings";
|
||||
|
|
@ -253,3 +260,27 @@ it("should delete only the Default setting and retain explicit false and zero",
|
|||
["token", "synthetic_count", 0],
|
||||
]);
|
||||
});
|
||||
|
||||
it("refetches UI settings after a general setting is updated so dependent screens see the new value", async () => {
|
||||
vi.mocked(getGeneralSettingsCall).mockResolvedValue([
|
||||
{
|
||||
field_name: "default_search_list_deny",
|
||||
field_type: "Boolean",
|
||||
field_value: true,
|
||||
field_description: "search deny",
|
||||
stored_in_db: null,
|
||||
},
|
||||
]);
|
||||
testQueryClient.setQueryData(["uiSettings", "list", { params: {} }], { values: { default_search_list_deny: false } });
|
||||
const user = userEvent.setup();
|
||||
renderWithProviders(<GeneralSettings accessToken="token" userRole="Admin" userID="user" />);
|
||||
await user.click(screen.getByRole("tab", { name: "General" }));
|
||||
await user.click(
|
||||
within(await screen.findByRole("row", { name: /default_search_list_deny/ })).getByRole("button", {
|
||||
name: "Update",
|
||||
}),
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(testQueryClient.getQueryState(["uiSettings", "list", { params: {} }])?.isInvalidated).toBe(true),
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
|
|||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { getGeneralSettingsCall, updateConfigFieldSetting, deleteConfigFieldSetting } from "@/components/networking";
|
||||
import { Trash2 } from "lucide-react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { uiSettingsKeys } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
|
||||
import { StatusBadge } from "@/components/shared/table_cells";
|
||||
|
||||
import RouterSettings from "@/components/router_settings";
|
||||
|
|
@ -221,6 +223,8 @@ export const PromptCachingPanel: React.FC<{
|
|||
|
||||
const GeneralSettings: React.FC<GeneralSettingsPageProps> = ({ accessToken, userRole, userID }) => {
|
||||
const [generalSettings, setGeneralSettings] = useState<generalSettingsItem[]>([]);
|
||||
const queryClient = useQueryClient();
|
||||
const refreshUISettings = () => queryClient.invalidateQueries({ queryKey: uiSettingsKeys.all });
|
||||
|
||||
useEffect(() => {
|
||||
if (!accessToken) {
|
||||
|
|
@ -253,7 +257,7 @@ const GeneralSettings: React.FC<GeneralSettingsPageProps> = ({ accessToken, user
|
|||
return;
|
||||
}
|
||||
try {
|
||||
updateConfigFieldSetting(accessToken, fieldName, fieldValue);
|
||||
updateConfigFieldSetting(accessToken, fieldName, fieldValue).then(refreshUISettings);
|
||||
// update value in state
|
||||
|
||||
const updatedSettings = generalSettings.map((setting) =>
|
||||
|
|
@ -271,7 +275,7 @@ const GeneralSettings: React.FC<GeneralSettingsPageProps> = ({ accessToken, user
|
|||
}
|
||||
|
||||
try {
|
||||
deleteConfigFieldSetting(accessToken, fieldName);
|
||||
deleteConfigFieldSetting(accessToken, fieldName).then(refreshUISettings);
|
||||
// update value in state
|
||||
|
||||
const updatedSettings = generalSettings.map((setting) =>
|
||||
|
|
|
|||
|
|
@ -51,6 +51,8 @@ import NumericalInput from "./shared/numerical_input";
|
|||
import { ModelMaxBudget, ModelMaxBudgetField } from "./key_team_helpers/ModelMaxBudgetEditor";
|
||||
import VectorStoreSelector from "./vector_store_management/VectorStoreSelector";
|
||||
import SearchToolSelector from "./search_tools/SearchToolSelector";
|
||||
import { searchToolPermissionCopy } from "./search_tools/searchToolPermissionCopy";
|
||||
import { useDefaultSearchListDeny } from "@/app/(dashboard)/hooks/uiSettings/useDefaultSearchListDeny";
|
||||
import SkillSelector from "./skills/SkillSelector";
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
||||
|
||||
|
|
@ -210,6 +212,7 @@ const getAdminOrganizations = (
|
|||
// @deprecated
|
||||
const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser = false }) => {
|
||||
const { data: organizationsData } = useOrganizations();
|
||||
const searchToolCopy = searchToolPermissionCopy(useDefaultSearchListDeny());
|
||||
const organizations = organizationsData ?? null;
|
||||
const { data: teamMetadataSchemaFields = [], isLoading: isTeamMetadataSchemaLoading } = useTeamMetadataSchema();
|
||||
const queryClient = useQueryClient();
|
||||
|
|
@ -1190,10 +1193,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
|
|||
control={form.control}
|
||||
name="object_permission_search_tools"
|
||||
className="mt-4"
|
||||
label={labelWithHint(
|
||||
"Allowed Search Tools",
|
||||
"Select which search tools this team can access. Leave empty to allow all search tools.",
|
||||
)}
|
||||
label={labelWithHint("Allowed Search Tools", searchToolCopy.hint)}
|
||||
description="Restrict which configured search tools keys on this team may call."
|
||||
>
|
||||
{({ value, onChange }) => (
|
||||
|
|
@ -1201,7 +1201,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
|
|||
onChange={onChange}
|
||||
value={value}
|
||||
accessToken={accessToken || ""}
|
||||
placeholder="Select search tools (optional, empty = all allowed)"
|
||||
placeholder={searchToolCopy.placeholder}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import MCPServerPermissions from "./permissions/MCPServerPermissions";
|
|||
import AgentPermissions from "./permissions/AgentPermissions";
|
||||
import type { ObjectPermission } from "./object_permission_types";
|
||||
import type { InheritedGrant } from "./permissions/inheritedGrants";
|
||||
import { searchToolPermissionCopy, type SearchToolPermissionScope } from "./search_tools/searchToolPermissionCopy";
|
||||
import { useDefaultSearchListDeny } from "@/app/(dashboard)/hooks/uiSettings/useDefaultSearchListDeny";
|
||||
|
||||
interface ObjectPermissionsViewProps {
|
||||
objectPermission?: ObjectPermission | null;
|
||||
|
|
@ -12,6 +14,7 @@ interface ObjectPermissionsViewProps {
|
|||
variant?: "card" | "inline";
|
||||
className?: string;
|
||||
accessToken?: string | null;
|
||||
searchToolScope?: SearchToolPermissionScope;
|
||||
}
|
||||
|
||||
export function ObjectPermissionsView({
|
||||
|
|
@ -21,6 +24,7 @@ export function ObjectPermissionsView({
|
|||
variant = "card",
|
||||
className = "",
|
||||
accessToken,
|
||||
searchToolScope = "team",
|
||||
}: ObjectPermissionsViewProps) {
|
||||
const vectorStores = objectPermission?.vector_stores || [];
|
||||
const mcpServers = objectPermission?.mcp_servers || [];
|
||||
|
|
@ -31,6 +35,7 @@ export function ObjectPermissionsView({
|
|||
const agentAccessGroups = objectPermission?.agent_access_groups || [];
|
||||
const searchTools = objectPermission?.search_tools || [];
|
||||
const skills = objectPermission?.skills || [];
|
||||
const searchToolCopy = searchToolPermissionCopy(useDefaultSearchListDeny(), searchToolScope);
|
||||
|
||||
const content = (
|
||||
<div className={variant === "card" ? "grid grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-3 gap-6" : "space-y-4"}>
|
||||
|
|
@ -52,9 +57,7 @@ export function ObjectPermissionsView({
|
|||
<div className="min-w-0 rounded-md border border-border p-4">
|
||||
<p className="text-sm font-medium text-foreground">Search tools</p>
|
||||
{searchTools.length === 0 ? (
|
||||
<p className="mt-1 block text-xs text-muted-foreground">
|
||||
No restriction — all configured search tools are allowed for this team.
|
||||
</p>
|
||||
<p className="mt-1 block text-xs text-muted-foreground">{searchToolCopy.emptyState}</p>
|
||||
) : (
|
||||
<p className="mt-1 block text-xs break-words text-foreground">{searchTools.join(", ")}</p>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,29 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { searchToolPermissionCopy } from "./searchToolPermissionCopy";
|
||||
|
||||
describe("searchToolPermissionCopy", () => {
|
||||
it("says an empty list allows every search tool when default deny is off", () => {
|
||||
const copy = searchToolPermissionCopy(false);
|
||||
expect(copy.hint).toContain("Leave empty to allow all search tools");
|
||||
expect(copy.placeholder).toBe("Select search tools (optional, empty = all allowed)");
|
||||
expect(copy.emptyState).toContain("all configured search tools are allowed");
|
||||
});
|
||||
|
||||
it("says an empty list denies every search tool when default deny is on", () => {
|
||||
const copy = searchToolPermissionCopy(true);
|
||||
expect(copy.hint).toContain("leaving this empty denies every search tool");
|
||||
expect(copy.placeholder).toBe("Select search tools (empty = none allowed)");
|
||||
expect(copy.emptyState).toContain("no search tool is allowed");
|
||||
expect(Object.values(copy).join(" ")).not.toContain("all allowed");
|
||||
});
|
||||
|
||||
it("describes an empty key-level list as deferring to the team or user grant", () => {
|
||||
expect(searchToolPermissionCopy(true, "key").emptyState).toBe(
|
||||
"No key-level search tools. Default search list deny is on, so this key can use only the search tools granted to its team or user.",
|
||||
);
|
||||
expect(searchToolPermissionCopy(false, "key").emptyState).toBe(
|
||||
"No key-level restriction: this key can use any search tool its team or user allows.",
|
||||
);
|
||||
expect(searchToolPermissionCopy(true, "key").hint).toBe(searchToolPermissionCopy(true).hint);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,30 @@
|
|||
export interface SearchToolPermissionCopy {
|
||||
hint: string;
|
||||
placeholder: string;
|
||||
emptyState: string;
|
||||
}
|
||||
|
||||
export type SearchToolPermissionScope = "team" | "key";
|
||||
|
||||
const keyEmptyState = (defaultSearchListDeny: boolean): string =>
|
||||
defaultSearchListDeny
|
||||
? "No key-level search tools. Default search list deny is on, so this key can use only the search tools granted to its team or user."
|
||||
: "No key-level restriction: this key can use any search tool its team or user allows.";
|
||||
|
||||
export const searchToolPermissionCopy = (
|
||||
defaultSearchListDeny: boolean,
|
||||
scope: SearchToolPermissionScope = "team",
|
||||
): SearchToolPermissionCopy => {
|
||||
const copy = defaultSearchListDeny
|
||||
? {
|
||||
hint: "Select which search tools this team can access. Default search list deny is on, so leaving this empty denies every search tool.",
|
||||
placeholder: "Select search tools (empty = none allowed)",
|
||||
emptyState: "No search tools granted. Default search list deny is on, so no search tool is allowed.",
|
||||
}
|
||||
: {
|
||||
hint: "Select which search tools this team can access. Leave empty to allow all search tools.",
|
||||
placeholder: "Select search tools (optional, empty = all allowed)",
|
||||
emptyState: "No restriction: all configured search tools are allowed for this team.",
|
||||
};
|
||||
return scope === "key" ? { ...copy, emptyState: keyEmptyState(defaultSearchListDeny) } : copy;
|
||||
};
|
||||
|
|
@ -106,6 +106,8 @@ import ObjectPermissionsView from "../object_permissions_view";
|
|||
import NumericalInput from "../shared/numerical_input";
|
||||
import VectorStoreSelector from "../vector_store_management/VectorStoreSelector";
|
||||
import SearchToolSelector from "../search_tools/SearchToolSelector";
|
||||
import { searchToolPermissionCopy } from "../search_tools/searchToolPermissionCopy";
|
||||
import { useDefaultSearchListDeny } from "@/app/(dashboard)/hooks/uiSettings/useDefaultSearchListDeny";
|
||||
import SkillSelector from "../skills/SkillSelector";
|
||||
import EditLoggingSettings from "./EditLoggingSettings";
|
||||
import RouterSettingsAccordion, { RouterSettingsAccordionRef } from "../common_components/RouterSettingsAccordion";
|
||||
|
|
@ -578,6 +580,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
premiumUser = false,
|
||||
onUpdate,
|
||||
}) => {
|
||||
const searchToolCopy = searchToolPermissionCopy(useDefaultSearchListDeny());
|
||||
const teamUpdateSchema = useMemo(
|
||||
() =>
|
||||
teamUpdateFieldsSchema.superRefine((values, ctx) => {
|
||||
|
|
@ -2088,17 +2091,14 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
|
|||
<FormField
|
||||
control={form.control}
|
||||
name="object_permission_search_tools"
|
||||
label={labelWithHint(
|
||||
"Allowed Search Tools",
|
||||
"Select which search tools this team can access. Leave empty to allow all search tools.",
|
||||
)}
|
||||
label={labelWithHint("Allowed Search Tools", searchToolCopy.hint)}
|
||||
>
|
||||
{({ value, onChange }) => (
|
||||
<SearchToolSelector
|
||||
onChange={onChange}
|
||||
value={value}
|
||||
accessToken={accessToken || ""}
|
||||
placeholder="Select search tools (optional, empty = all allowed)"
|
||||
placeholder={searchToolCopy.placeholder}
|
||||
/>
|
||||
)}
|
||||
</FormField>
|
||||
|
|
|
|||
|
|
@ -765,6 +765,7 @@ export default function KeyInfoView({
|
|||
<Card className="block p-6">
|
||||
<ObjectPermissionsView
|
||||
objectPermission={currentKeyData.object_permission}
|
||||
searchToolScope="key"
|
||||
variant="inline"
|
||||
accessToken={accessToken}
|
||||
/>
|
||||
|
|
@ -1171,6 +1172,7 @@ export default function KeyInfoView({
|
|||
|
||||
<ObjectPermissionsView
|
||||
objectPermission={currentKeyData.object_permission}
|
||||
searchToolScope="key"
|
||||
variant="inline"
|
||||
className="pt-4 border-t border-border"
|
||||
accessToken={accessToken}
|
||||
|
|
|
|||
5
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
5
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -28227,6 +28227,11 @@ export interface components {
|
|||
* @description connect to a postgres db - needed for generating temporary keys + tracking spend / key
|
||||
*/
|
||||
database_url?: string | null;
|
||||
/**
|
||||
* Default Search List Deny
|
||||
* @description If True, a key, team or user with no search tool grants is denied every search tool instead of being allowed all of them. A team key uses its team's grants, a personal key uses its user's grants, and any other non-empty key or user allowlist only narrows that. Proxy admins are exempt. Defaults to False, where an empty search tool list allows every search tool.
|
||||
*/
|
||||
default_search_list_deny?: boolean | null;
|
||||
/**
|
||||
* Disable Auto Add Proxy Admin To Teams
|
||||
* @description By default, the user calling /team/new is automatically added to the new team as a team admin. If True, proxy admins are no longer auto-added; members explicitly listed in members_with_roles are unaffected. Default is False.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue