This commit is contained in:
devin-ai-integration[bot] 2026-09-30 05:50:26 +00:00 • committed by GitHub
commit 9e35d5f4da
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 1046 additions and 92 deletions

View file

@ -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(

View file

@ -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=(

View file

@ -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

View file

@ -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",
}

View file

@ -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

View file

@ -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(

View file

@ -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(

View file

@ -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()

View file

@ -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."""

View file

@ -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."""

View file

@ -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.

View file

@ -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

View file

@ -0,0 +1,113 @@
from collections.abc import Iterator
from typing import Final
import pytest
import respx
from fastapi import FastAPI
from fastapi.testclient import TestClient
import litellm
from litellm import Router
from litellm.proxy import proxy_server
from litellm.proxy._types import (
LiteLLM_ObjectPermissionTable,
LiteLLM_TeamTableCachedObj,
LiteLLM_UserTable,
LitellmUserRoles,
ProxyException,
UserAPIKeyAuth,
)
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
from litellm.proxy.search_endpoints.endpoints import router
TAVILY_SEARCH_URL: Final = "https://api.tavily.com/search"
TAVILY_RESULT: Final = {"title": "LiteLLM", "url": "https://docs.litellm.ai", "content": "LLM gateway"}
def _search_router() -> Router:
return Router(
model_list=[],
search_tools=[
{"search_tool_name": "search-a", "litellm_params": {"search_provider": "tavily", "api_key": "fake"}},
],
num_retries=0,
)
def _client(caller: UserAPIKeyAuth) -> TestClient:
app = FastAPI()
app.include_router(router)
app.add_exception_handler(ProxyException, proxy_server.openai_exception_handler)
app.dependency_overrides[user_api_key_auth] = lambda: caller
return TestClient(app, raise_server_exceptions=False)
@pytest.fixture
def tavily(monkeypatch: pytest.MonkeyPatch) -> Iterator[respx.Route]:
monkeypatch.setattr( # test-quality-ok: respx needs HTTPX enabled to fake the provider HTTP boundary.
litellm,
"disable_aiohttp_transport",
True,
)
litellm.in_memory_llm_clients_cache.flush_cache()
with respx.mock(assert_all_called=False) as mock:
yield mock.post(TAVILY_SEARCH_URL).respond(200, json={"results": [TAVILY_RESULT]})
litellm.in_memory_llm_clients_cache.flush_cache()
@pytest.fixture
def cache(monkeypatch: pytest.MonkeyPatch) -> UserApiKeyCache:
user_api_key_cache = UserApiKeyCache()
user_api_key_cache.set_cache(key="user-1", value=LiteLLM_UserTable(user_id="user-1"))
monkeypatch.setattr(proxy_server, "user_api_key_cache", user_api_key_cache)
monkeypatch.setattr(proxy_server, "prisma_client", object())
monkeypatch.setattr(proxy_server, "llm_router", _search_router())
return user_api_key_cache
def _cache_team(cache: UserApiKeyCache, search_tools: list[str]) -> None:
team = LiteLLM_TeamTableCachedObj(
team_id="team-1",
object_permission=LiteLLM_ObjectPermissionTable(object_permission_id="op-team", search_tools=search_tools),
)
cache.set_cache(key="team_id:team-1", value=team)
@pytest.mark.parametrize(
"general_settings, team_search_tools, expected_status",
[
({}, [], 200),
({"default_search_list_deny": False}, [], 200),
({"default_search_list_deny": True}, [], 403),
({"default_search_list_deny": True}, ["search-b"], 403),
({"default_search_list_deny": True}, ["search-a"], 200),
],
)
@pytest.mark.parametrize("path", ["/v1/search/search-a", "/search/search-a"])
def test_direct_search_team_key_follows_default_search_list_deny(
monkeypatch, cache, tavily, path, general_settings, team_search_tools, expected_status
):
monkeypatch.setattr(proxy_server, "general_settings", general_settings)
_cache_team(cache, team_search_tools)
caller = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id="user-1", team_id="team-1")
response = _client(caller).post(path, json={"query": "what is litellm"})
assert response.status_code == expected_status, response.text
if expected_status == 200:
assert response.json()["results"][0]["url"] == TAVILY_RESULT["url"]
assert tavily.call_count == 1
else:
assert "search-a" in response.text
assert tavily.call_count == 0
def test_direct_search_body_tool_name_is_denied_under_default_search_list_deny(monkeypatch, cache, tavily):
monkeypatch.setattr(proxy_server, "general_settings", {"default_search_list_deny": True})
caller = UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER)
response = _client(caller).post("/v1/search", json={"search_tool_name": "search-a", "query": "what is litellm"})
assert response.status_code == 403, response.text
assert tavily.call_count == 0

View file

@ -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;

View file

@ -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.

View file

@ -1,4 +1,11 @@
import { fireEvent, renderWithProviders, screen, within } from "../../../../../tests/test-utils";
import {
fireEvent,
renderWithProviders,
screen,
testQueryClient,
waitFor,
within,
} from "../../../../../tests/test-utils";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import GeneralSettings from "./general_settings";
@ -253,3 +260,27 @@ it("should delete only the Default setting and retain explicit false and zero",
["token", "synthetic_count", 0],
]);
});
it("refetches UI settings after a general setting is updated so dependent screens see the new value", async () => {
vi.mocked(getGeneralSettingsCall).mockResolvedValue([
{
field_name: "default_search_list_deny",
field_type: "Boolean",
field_value: true,
field_description: "search deny",
stored_in_db: null,
},
]);
testQueryClient.setQueryData(["uiSettings", "list", { params: {} }], { values: { default_search_list_deny: false } });
const user = userEvent.setup();
renderWithProviders(<GeneralSettings accessToken="token" userRole="Admin" userID="user" />);
await user.click(screen.getByRole("tab", { name: "General" }));
await user.click(
within(await screen.findByRole("row", { name: /default_search_list_deny/ })).getByRole("button", {
name: "Update",
}),
);
await waitFor(() =>
expect(testQueryClient.getQueryState(["uiSettings", "list", { params: {} }])?.isInvalidated).toBe(true),
);
});

View file

@ -9,6 +9,8 @@ import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { getGeneralSettingsCall, updateConfigFieldSetting, deleteConfigFieldSetting } from "@/components/networking";
import { Trash2 } from "lucide-react";
import { useQueryClient } from "@tanstack/react-query";
import { uiSettingsKeys } from "@/app/(dashboard)/hooks/uiSettings/useUISettings";
import { StatusBadge } from "@/components/shared/table_cells";
import RouterSettings from "@/components/router_settings";
@ -221,6 +223,8 @@ export const PromptCachingPanel: React.FC<{
const GeneralSettings: React.FC<GeneralSettingsPageProps> = ({ accessToken, userRole, userID }) => {
const [generalSettings, setGeneralSettings] = useState<generalSettingsItem[]>([]);
const queryClient = useQueryClient();
const refreshUISettings = () => queryClient.invalidateQueries({ queryKey: uiSettingsKeys.all });
useEffect(() => {
if (!accessToken) {
@ -253,7 +257,7 @@ const GeneralSettings: React.FC<GeneralSettingsPageProps> = ({ accessToken, user
return;
}
try {
updateConfigFieldSetting(accessToken, fieldName, fieldValue);
updateConfigFieldSetting(accessToken, fieldName, fieldValue).then(refreshUISettings);
// update value in state
const updatedSettings = generalSettings.map((setting) =>
@ -271,7 +275,7 @@ const GeneralSettings: React.FC<GeneralSettingsPageProps> = ({ accessToken, user
}
try {
deleteConfigFieldSetting(accessToken, fieldName);
deleteConfigFieldSetting(accessToken, fieldName).then(refreshUISettings);
// update value in state
const updatedSettings = generalSettings.map((setting) =>

View file

@ -51,6 +51,8 @@ import NumericalInput from "./shared/numerical_input";
import { ModelMaxBudget, ModelMaxBudgetField } from "./key_team_helpers/ModelMaxBudgetEditor";
import VectorStoreSelector from "./vector_store_management/VectorStoreSelector";
import SearchToolSelector from "./search_tools/SearchToolSelector";
import { searchToolPermissionCopy } from "./search_tools/searchToolPermissionCopy";
import { useDefaultSearchListDeny } from "@/app/(dashboard)/hooks/uiSettings/useDefaultSearchListDeny";
import SkillSelector from "./skills/SkillSelector";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
@ -210,6 +212,7 @@ const getAdminOrganizations = (
// @deprecated
const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser = false }) => {
const { data: organizationsData } = useOrganizations();
const searchToolCopy = searchToolPermissionCopy(useDefaultSearchListDeny());
const organizations = organizationsData ?? null;
const { data: teamMetadataSchemaFields = [], isLoading: isTeamMetadataSchemaLoading } = useTeamMetadataSchema();
const queryClient = useQueryClient();
@ -1190,10 +1193,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
control={form.control}
name="object_permission_search_tools"
className="mt-4"
label={labelWithHint(
"Allowed Search Tools",
"Select which search tools this team can access. Leave empty to allow all search tools.",
)}
label={labelWithHint("Allowed Search Tools", searchToolCopy.hint)}
description="Restrict which configured search tools keys on this team may call."
>
{({ value, onChange }) => (
@ -1201,7 +1201,7 @@ const Teams: React.FC<TeamProps> = ({ accessToken, userID, userRole, premiumUser
onChange={onChange}
value={value}
accessToken={accessToken || ""}
placeholder="Select search tools (optional, empty = all allowed)"
placeholder={searchToolCopy.placeholder}
/>
)}
</FormField>

View file

@ -4,6 +4,8 @@ import MCPServerPermissions from "./permissions/MCPServerPermissions";
import AgentPermissions from "./permissions/AgentPermissions";
import type { ObjectPermission } from "./object_permission_types";
import type { InheritedGrant } from "./permissions/inheritedGrants";
import { searchToolPermissionCopy, type SearchToolPermissionScope } from "./search_tools/searchToolPermissionCopy";
import { useDefaultSearchListDeny } from "@/app/(dashboard)/hooks/uiSettings/useDefaultSearchListDeny";
interface ObjectPermissionsViewProps {
objectPermission?: ObjectPermission | null;
@ -12,6 +14,7 @@ interface ObjectPermissionsViewProps {
variant?: "card" | "inline";
className?: string;
accessToken?: string | null;
searchToolScope?: SearchToolPermissionScope;
}
export function ObjectPermissionsView({
@ -21,6 +24,7 @@ export function ObjectPermissionsView({
variant = "card",
className = "",
accessToken,
searchToolScope = "team",
}: ObjectPermissionsViewProps) {
const vectorStores = objectPermission?.vector_stores || [];
const mcpServers = objectPermission?.mcp_servers || [];
@ -31,6 +35,7 @@ export function ObjectPermissionsView({
const agentAccessGroups = objectPermission?.agent_access_groups || [];
const searchTools = objectPermission?.search_tools || [];
const skills = objectPermission?.skills || [];
const searchToolCopy = searchToolPermissionCopy(useDefaultSearchListDeny(), searchToolScope);
const content = (
<div className={variant === "card" ? "grid grid-cols-1 @xl:grid-cols-2 @4xl:grid-cols-3 gap-6" : "space-y-4"}>
@ -52,9 +57,7 @@ export function ObjectPermissionsView({
<div className="min-w-0 rounded-md border border-border p-4">
<p className="text-sm font-medium text-foreground">Search tools</p>
{searchTools.length === 0 ? (
<p className="mt-1 block text-xs text-muted-foreground">
No restriction — all configured search tools are allowed for this team.
</p>
<p className="mt-1 block text-xs text-muted-foreground">{searchToolCopy.emptyState}</p>
) : (
<p className="mt-1 block text-xs break-words text-foreground">{searchTools.join(", ")}</p>
)}

View file

@ -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);
});
});

View file

@ -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;
};

View file

@ -106,6 +106,8 @@ import ObjectPermissionsView from "../object_permissions_view";
import NumericalInput from "../shared/numerical_input";
import VectorStoreSelector from "../vector_store_management/VectorStoreSelector";
import SearchToolSelector from "../search_tools/SearchToolSelector";
import { searchToolPermissionCopy } from "../search_tools/searchToolPermissionCopy";
import { useDefaultSearchListDeny } from "@/app/(dashboard)/hooks/uiSettings/useDefaultSearchListDeny";
import SkillSelector from "../skills/SkillSelector";
import EditLoggingSettings from "./EditLoggingSettings";
import RouterSettingsAccordion, { RouterSettingsAccordionRef } from "../common_components/RouterSettingsAccordion";
@ -578,6 +580,7 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
premiumUser = false,
onUpdate,
}) => {
const searchToolCopy = searchToolPermissionCopy(useDefaultSearchListDeny());
const teamUpdateSchema = useMemo(
() =>
teamUpdateFieldsSchema.superRefine((values, ctx) => {
@ -2088,17 +2091,14 @@ const TeamInfoView: React.FC<TeamInfoProps> = ({
<FormField
control={form.control}
name="object_permission_search_tools"
label={labelWithHint(
"Allowed Search Tools",
"Select which search tools this team can access. Leave empty to allow all search tools.",
)}
label={labelWithHint("Allowed Search Tools", searchToolCopy.hint)}
>
{({ value, onChange }) => (
<SearchToolSelector
onChange={onChange}
value={value}
accessToken={accessToken || ""}
placeholder="Select search tools (optional, empty = all allowed)"
placeholder={searchToolCopy.placeholder}
/>
)}
</FormField>

View file

@ -765,6 +765,7 @@ export default function KeyInfoView({
<Card className="block p-6">
<ObjectPermissionsView
objectPermission={currentKeyData.object_permission}
searchToolScope="key"
variant="inline"
accessToken={accessToken}
/>
@ -1171,6 +1172,7 @@ export default function KeyInfoView({
<ObjectPermissionsView
objectPermission={currentKeyData.object_permission}
searchToolScope="key"
variant="inline"
className="pt-4 border-t border-border"
accessToken={accessToken}

View file

@ -28227,6 +28227,11 @@ export interface components {
* @description connect to a postgres db - needed for generating temporary keys + tracking spend / key
*/
database_url?: string | null;
/**
* Default Search List Deny
* @description If True, a key, team or user with no search tool grants is denied every search tool instead of being allowed all of them. A team key uses its team's grants, a personal key uses its user's grants, and any other non-empty key or user allowlist only narrows that. Proxy admins are exempt. Defaults to False, where an empty search tool list allows every search tool.
*/
default_search_list_deny?: boolean | null;
/**
* Disable Auto Add Proxy Admin To Teams
* @description By default, the user calling /team/new is automatically added to the new team as a team admin. If True, proxy admins are no longer auto-added; members explicitly listed in members_with_roles are unaffected. Default is False.