From 023a02ac65836b5dba46373c57d827860c0ff61b Mon Sep 17 00:00:00 2001 From: mrinal Date: Tue, 29 Sep 2026 04:21:31 +0000 Subject: [PATCH] feat(proxy): add default_search_list_deny for opt-in search tool access Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../websearch_interception/handler.py | 35 ++- litellm/proxy/_types.py | 9 + litellm/proxy/auth/auth_checks.py | 169 ++++++++++-- litellm/proxy/proxy_server.py | 2 + litellm/proxy/search_endpoints/endpoints.py | 35 ++- .../search_tool_management.py | 44 ++- .../proxy_setting_endpoints.py | 20 +- .../test_search_tool_management.py | 96 +++++++ tests/test_litellm/proxy/test_proxy_server.py | 41 +++ .../test_proxy_setting_endpoints.py | 32 +++ .../test_websearch_interception_handler.py | 74 +++++ tests/unit/proxy/auth/test_auth_checks.py | 252 ++++++++++++++++++ tests/unit/proxy/search_endpoints/__init__.py | 0 .../proxy/search_endpoints/test_endpoints.py | 99 +++++++ .../uiSettings/useDefaultSearchListDeny.ts | 6 + ui/litellm-dashboard/src/components/Teams.tsx | 10 +- .../components/object_permissions_view.tsx | 7 +- .../searchToolPermissionCopy.test.ts | 19 ++ .../search_tools/searchToolPermissionCopy.ts | 18 ++ .../src/components/team/TeamInfo.tsx | 10 +- ui/litellm-dashboard/src/lib/http/schema.d.ts | 5 + 21 files changed, 895 insertions(+), 88 deletions(-) create mode 100644 tests/unit/proxy/search_endpoints/__init__.py create mode 100644 tests/unit/proxy/search_endpoints/test_endpoints.py create mode 100644 ui/litellm-dashboard/src/app/(dashboard)/hooks/uiSettings/useDefaultSearchListDeny.ts create mode 100644 ui/litellm-dashboard/src/components/search_tools/searchToolPermissionCopy.test.ts create mode 100644 ui/litellm-dashboard/src/components/search_tools/searchToolPermissionCopy.ts diff --git a/litellm/integrations/websearch_interception/handler.py b/litellm/integrations/websearch_interception/handler.py index 6ebf485d717..42fb98a7ca2 100644 --- a/litellm/integrations/websearch_interception/handler.py +++ b/litellm/integrations/websearch_interception/handler.py @@ -1637,35 +1637,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 d9fb053035b..33070bdaeb1 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -3135,6 +3135,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 12d420141f1..d01bf4a3498 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 @@ -5231,11 +5232,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. @@ -5243,6 +5245,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 @@ -5250,18 +5253,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, @@ -5323,24 +5328,144 @@ 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.key_model_access_denied, + param="search_tool_name", + code=status.HTTP_403_FORBIDDEN, + ) + return object_permission + + +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 = ( + general_settings.get("default_search_list_deny") is True + and valid_token.user_role != LitellmUserRoles.PROXY_ADMIN + ) + 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 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 7cd116c23d7..5ba293aef4c 100644 --- a/litellm/proxy/proxy_server.py +++ b/litellm/proxy/proxy_server.py @@ -7669,6 +7669,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 @@ -17983,6 +17984,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 df05cf0987e..74542eb96b8 100644 --- a/tests/test_litellm/proxy/test_proxy_server.py +++ b/tests/test_litellm/proxy/test_proxy_server.py @@ -7960,6 +7960,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 @@ -11597,6 +11611,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..05a8c046093 100644 --- a/tests/unit/integrations/websearch_interception/test_websearch_interception_handler.py +++ b/tests/unit/integrations/websearch_interception/test_websearch_interception_handler.py @@ -586,6 +586,80 @@ 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.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..56686c142d4 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 == "403" + + +@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..3b8a55310ee --- /dev/null +++ b/tests/unit/proxy/search_endpoints/test_endpoints.py @@ -0,0 +1,99 @@ +from unittest.mock import AsyncMock, MagicMock + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient + +import litellm.proxy.proxy_server as proxy_server +from litellm.llms.base_llm.search.transformation import SearchResponse +from litellm.proxy._types import ( + LiteLLM_ObjectPermissionTable, + LiteLLM_TeamTable, + 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 + + +def _search_router() -> MagicMock: + llm_router = MagicMock() + llm_router.search_tools = [ + {"search_tool_name": "search-a", "litellm_params": {"search_provider": "tavily", "api_key": "fake"}}, + ] + llm_router.asearch = AsyncMock(return_value=SearchResponse(object="search", results=[])) + return llm_router + + +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 user_without_search_grants(monkeypatch: pytest.MonkeyPatch) -> None: + cache = UserApiKeyCache() + cache.set_cache(key="user-1", value=LiteLLM_UserTable(user_id="user-1")) + monkeypatch.setattr(proxy_server, "user_api_key_cache", cache) + monkeypatch.setattr(proxy_server, "prisma_client", MagicMock()) + + +@pytest.fixture +def team_with_search_tools(monkeypatch: pytest.MonkeyPatch, user_without_search_grants: None): + def set_team_search_tools(search_tools: list[str]) -> None: + team = LiteLLM_TeamTable( + team_id="team-1", + object_permission=LiteLLM_ObjectPermissionTable(object_permission_id="op-team", search_tools=search_tools), + ) + monkeypatch.setattr("litellm.proxy.auth.auth_checks.get_team_object", AsyncMock(return_value=team)) + + return set_team_search_tools + + +@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, team_with_search_tools, path, general_settings, team_search_tools, expected_status +): + llm_router = _search_router() + monkeypatch.setattr(proxy_server, "llm_router", llm_router) + monkeypatch.setattr(proxy_server, "general_settings", general_settings) + team_with_search_tools(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()["object"] == "search" + llm_router.asearch.assert_awaited_once() + else: + assert "search-a" in response.text + llm_router.asearch.assert_not_awaited() + + +def test_direct_search_body_tool_name_is_denied_under_default_search_list_deny(monkeypatch): + llm_router = _search_router() + monkeypatch.setattr(proxy_server, "llm_router", llm_router) + 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 + llm_router.asearch.assert_not_awaited() 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/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..fcb8a7a3995 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 } from "./search_tools/searchToolPermissionCopy"; +import { useDefaultSearchListDeny } from "@/app/(dashboard)/hooks/uiSettings/useDefaultSearchListDeny"; interface ObjectPermissionsViewProps { objectPermission?: ObjectPermission | null; @@ -31,6 +33,7 @@ export function ObjectPermissionsView({ const agentAccessGroups = objectPermission?.agent_access_groups || []; const searchTools = objectPermission?.search_tools || []; const skills = objectPermission?.skills || []; + const searchToolCopy = searchToolPermissionCopy(useDefaultSearchListDeny()); const content = (
@@ -52,9 +55,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..750d0f3bb19 --- /dev/null +++ b/ui/litellm-dashboard/src/components/search_tools/searchToolPermissionCopy.test.ts @@ -0,0 +1,19 @@ +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"); + }); +}); 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..75d5f8d350d --- /dev/null +++ b/ui/litellm-dashboard/src/components/search_tools/searchToolPermissionCopy.ts @@ -0,0 +1,18 @@ +export interface SearchToolPermissionCopy { + hint: string; + placeholder: string; + emptyState: string; +} + +export const searchToolPermissionCopy = (defaultSearchListDeny: boolean): SearchToolPermissionCopy => + 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.", + }; diff --git a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx index 3845f94593d..b7e7836dc5e 100644 --- a/ui/litellm-dashboard/src/components/team/TeamInfo.tsx +++ b/ui/litellm-dashboard/src/components/team/TeamInfo.tsx @@ -105,6 +105,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"; @@ -577,6 +579,7 @@ const TeamInfoView: React.FC = ({ premiumUser = false, onUpdate, }) => { + const searchToolCopy = searchToolPermissionCopy(useDefaultSearchListDeny()); const teamUpdateSchema = useMemo( () => teamUpdateFieldsSchema.superRefine((values, ctx) => { @@ -2086,17 +2089,14 @@ const TeamInfoView: React.FC = ({ {({ value, onChange }) => ( )} diff --git a/ui/litellm-dashboard/src/lib/http/schema.d.ts b/ui/litellm-dashboard/src/lib/http/schema.d.ts index 93f4718a586..3427e698552 100644 --- a/ui/litellm-dashboard/src/lib/http/schema.d.ts +++ b/ui/litellm-dashboard/src/lib/http/schema.d.ts @@ -28156,6 +28156,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.