diff --git a/litellm/integrations/websearch_interception/handler.py b/litellm/integrations/websearch_interception/handler.py index 6ebf485d717..760a1fe4719 100644 --- a/litellm/integrations/websearch_interception/handler.py +++ b/litellm/integrations/websearch_interception/handler.py @@ -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( diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index d421363ee92..8d13ad9c5fa 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -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=( diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index e8f335cb348..ede61de4921 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -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 diff --git a/litellm/proxy/proxy_server.py b/litellm/proxy/proxy_server.py index a994293a4d8..edc6eba8a85 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -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", } diff --git a/litellm/proxy/search_endpoints/endpoints.py b/litellm/proxy/search_endpoints/endpoints.py index 2676682c59d..d4a155e56a5 100644 --- a/litellm/proxy/search_endpoints/endpoints.py +++ b/litellm/proxy/search_endpoints/endpoints.py @@ -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 diff --git a/litellm/proxy/search_endpoints/search_tool_management.py b/litellm/proxy/search_endpoints/search_tool_management.py index 81a008cf4c8..e874b690309 100644 --- a/litellm/proxy/search_endpoints/search_tool_management.py +++ b/litellm/proxy/search_endpoints/search_tool_management.py @@ -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( diff --git a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py index 227f0e7f795..5b4e86c9d02 100644 --- a/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py +++ b/litellm/proxy/ui_crud_endpoints/proxy_setting_endpoints.py @@ -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( diff --git a/tests/test_litellm/proxy/management_endpoints/search_endpoints/test_search_tool_management.py b/tests/test_litellm/proxy/management_endpoints/search_endpoints/test_search_tool_management.py index 70e9a96b316..369dc7911e7 100644 --- a/tests/test_litellm/proxy/management_endpoints/search_endpoints/test_search_tool_management.py +++ b/tests/test_litellm/proxy/management_endpoints/search_endpoints/test_search_tool_management.py @@ -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() diff --git a/tests/test_litellm/proxy/test_proxy_server.py b/tests/test_litellm/proxy/test_proxy_server.py index 23d319159ca..ab05573d9a6 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -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.""" diff --git a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py index 0d7a713a380..e3387698d38 100644 --- a/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py +++ b/tests/test_litellm/proxy/ui_crud_endpoints/test_proxy_setting_endpoints.py @@ -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.""" diff --git a/tests/unit/integrations/websearch_interception/test_websearch_interception_handler.py b/tests/unit/integrations/websearch_interception/test_websearch_interception_handler.py index a5ab28ba72a..4f7775b30de 100644 --- a/tests/unit/integrations/websearch_interception/test_websearch_interception_handler.py +++ b/tests/unit/integrations/websearch_interception/test_websearch_interception_handler.py @@ -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. diff --git a/tests/unit/proxy/auth/test_auth_checks.py b/tests/unit/proxy/auth/test_auth_checks.py index 2538556d3b5..86e0d9ba302 100644 --- a/tests/unit/proxy/auth/test_auth_checks.py +++ b/tests/unit/proxy/auth/test_auth_checks.py @@ -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 diff --git a/tests/unit/proxy/search_endpoints/__init__.py b/tests/unit/proxy/search_endpoints/__init__.py new file mode 100644 index 00000000000..e69de29bb2d diff --git a/tests/unit/proxy/search_endpoints/test_endpoints.py b/tests/unit/proxy/search_endpoints/test_endpoints.py new file mode 100644 index 00000000000..03b5ed92005 --- /dev/null +++ b/tests/unit/proxy/search_endpoints/test_endpoints.py @@ -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 diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useDefaultSearchListDeny.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useDefaultSearchListDeny.ts new file mode 100644 index 00000000000..5f73fb9a9bd --- /dev/null +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useDefaultSearchListDeny.ts @@ -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; diff --git a/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useUISettings.ts b/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useUISettings.ts index 749fc98c0d8..7c6176389ec 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useUISettings.ts +++ b/ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useUISettings.ts @@ -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. diff --git a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.integration.test.tsx b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.integration.test.tsx index cf5cad564ea..4a9c9f4e944 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.integration.test.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.integration.test.tsx @@ -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(); + 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), + ); +}); diff --git a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx index 3d0a2e438ac..79c949bd5b2 100644 --- a/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx +++ b/ui/litellm-dashboard/src/app/(dashboard)/router-settings/_components/general_settings.tsx @@ -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 = ({ accessToken, userRole, userID }) => { const [generalSettings, setGeneralSettings] = useState([]); + const queryClient = useQueryClient(); + const refreshUISettings = () => queryClient.invalidateQueries({ queryKey: uiSettingsKeys.all }); useEffect(() => { if (!accessToken) { @@ -253,7 +257,7 @@ const GeneralSettings: React.FC = ({ 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 = ({ accessToken, user } try { - deleteConfigFieldSetting(accessToken, fieldName); + deleteConfigFieldSetting(accessToken, fieldName).then(refreshUISettings); // update value in state const updatedSettings = generalSettings.map((setting) => diff --git a/ui/litellm-dashboard/src/components/Teams.tsx b/ui/litellm-dashboard/src/components/Teams.tsx index c2a23cef83a..0c9922ea4fa 100644 --- a/ui/litellm-dashboard/src/components/Teams.tsx +++ b/ui/litellm-dashboard/src/components/Teams.tsx @@ -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 = ({ 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 = ({ 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 = ({ accessToken, userID, userRole, premiumUser onChange={onChange} value={value} accessToken={accessToken || ""} - placeholder="Select search tools (optional, empty = all allowed)" + placeholder={searchToolCopy.placeholder} /> )} diff --git a/ui/litellm-dashboard/src/components/object_permissions_view.tsx b/ui/litellm-dashboard/src/components/object_permissions_view.tsx index ae46729222a..a2310690df7 100644 --- a/ui/litellm-dashboard/src/components/object_permissions_view.tsx +++ b/ui/litellm-dashboard/src/components/object_permissions_view.tsx @@ -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 = (
@@ -52,9 +57,7 @@ export function ObjectPermissionsView({

Search tools

{searchTools.length === 0 ? ( -

- No restriction — all configured search tools are allowed for this team. -

+

{searchToolCopy.emptyState}

) : (

{searchTools.join(", ")}

)} diff --git a/ui/litellm-dashboard/src/components/search_tools/searchToolPermissionCopy.test.ts b/ui/litellm-dashboard/src/components/search_tools/searchToolPermissionCopy.test.ts new file mode 100644 index 00000000000..46bd633a90c --- /dev/null +++ b/ui/litellm-dashboard/src/components/search_tools/searchToolPermissionCopy.test.ts @@ -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); + }); +}); diff --git a/ui/litellm-dashboard/src/components/search_tools/searchToolPermissionCopy.ts b/ui/litellm-dashboard/src/components/search_tools/searchToolPermissionCopy.ts new file mode 100644 index 00000000000..f914917acb5 --- /dev/null +++ b/ui/litellm-dashboard/src/components/search_tools/searchToolPermissionCopy.ts @@ -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; +}; diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index 5dfcf1d8e35..c51e25fc578 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -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 = ({ premiumUser = false, onUpdate, }) => { + const searchToolCopy = searchToolPermissionCopy(useDefaultSearchListDeny()); const teamUpdateSchema = useMemo( () => teamUpdateFieldsSchema.superRefine((values, ctx) => { @@ -2088,17 +2091,14 @@ const TeamInfoView: React.FC = ({ {({ value, onChange }) => ( )} diff --git a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx index cfb5e9fa1f8..e783e1627a0 100644 --- a/ui/litellm-dashboard/src/components/templates/key_info_view.tsx +++ b/ui/litellm-dashboard/src/components/templates/key_info_view.tsx @@ -765,6 +765,7 @@ export default function KeyInfoView({ @@ -1171,6 +1172,7 @@ export default function KeyInfoView({