From a8be4d3d686e983468c7a8dc938017bb79dad3ab Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 19 Mar 2026 01:35:49 +0000 Subject: [PATCH 1/9] feat: add search_tools field to ObjectPermission schema and types - Add search_tools String[] column to LiteLLM_ObjectPermissionTable in all 3 Prisma schema files - Add Prisma migration for the new column - Add search_tools field to LiteLLM_ObjectPermissionBase and LiteLLM_ObjectPermissionTable Python types - Add ProxyErrorTypes for search tool access denied (key, team, org) - Add get_search_tool_access_error_type_for_object() classmethod to ProxyErrorTypes Co-authored-by: yuneng-jiang --- .../migration.sql | 2 ++ .../litellm_proxy_extras/schema.prisma | 1 + litellm/proxy/_types.py | 31 +++++++++++++++++++ litellm/proxy/schema.prisma | 1 + schema.prisma | 1 + 5 files changed, 36 insertions(+) create mode 100644 litellm-proxy-extras/litellm_proxy_extras/migrations/20260319000000_add_search_tools_to_object_permissions/migration.sql diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260319000000_add_search_tools_to_object_permissions/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260319000000_add_search_tools_to_object_permissions/migration.sql new file mode 100644 index 00000000000..1b33edc3a8b --- /dev/null +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260319000000_add_search_tools_to_object_permissions/migration.sql @@ -0,0 +1,2 @@ +-- AlterTable +ALTER TABLE "LiteLLM_ObjectPermissionTable" ADD COLUMN "search_tools" TEXT[] DEFAULT ARRAY[]::TEXT[]; diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma index a2c83295403..084b1bdce6d 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma +++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma @@ -269,6 +269,7 @@ model LiteLLM_ObjectPermissionTable { mcp_access_groups String[] @default([]) mcp_tool_permissions Json? // Tool-level permissions for MCP servers. Format: {"server_id": ["tool_name_1", "tool_name_2"]} vector_stores String[] @default([]) + search_tools String[] @default([]) // Search tool names allowed for this key/team/org. Empty = no access (least privilege). agents String[] @default([]) agent_access_groups String[] @default([]) models String[] @default([]) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 9e86680e355..18e1ad89c72 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -856,6 +856,7 @@ class LiteLLM_ObjectPermissionBase(LiteLLMPydanticObjectBase): mcp_access_groups: Optional[List[str]] = None mcp_tool_permissions: Optional[Dict[str, List[str]]] = None vector_stores: Optional[List[str]] = None + search_tools: Optional[List[str]] = None agents: Optional[List[str]] = None agent_access_groups: Optional[List[str]] = None models: Optional[List[str]] = None @@ -1843,6 +1844,7 @@ class LiteLLM_ObjectPermissionTable(LiteLLMPydanticObjectBase): """ vector_stores: Optional[List[str]] = [] + search_tools: Optional[List[str]] = [] agents: Optional[List[str]] = [] agent_access_groups: Optional[List[str]] = [] @@ -3527,6 +3529,21 @@ class ProxyErrorTypes(str, enum.Enum): Organization does not have access to the vector store """ + key_search_tool_access_denied = "key_search_tool_access_denied" + """ + Key does not have access to the search tool + """ + + team_search_tool_access_denied = "team_search_tool_access_denied" + """ + Team does not have access to the search tool + """ + + org_search_tool_access_denied = "org_search_tool_access_denied" + """ + Organization does not have access to the search tool + """ + team_member_already_in_team = "team_member_already_in_team" """ Team member is already in team @@ -3569,6 +3586,20 @@ class ProxyErrorTypes(str, enum.Enum): elif object_type == "org": return cls.org_vector_store_access_denied + @classmethod + def get_search_tool_access_error_type_for_object( + cls, object_type: Literal["key", "team", "org"] + ) -> "ProxyErrorTypes": + """ + Get the search tool access error type for object_type + """ + if object_type == "key": + return cls.key_search_tool_access_denied + elif object_type == "team": + return cls.team_search_tool_access_denied + elif object_type == "org": + return cls.org_search_tool_access_denied + DB_CONNECTION_ERROR_TYPES = ( httpx.ConnectError, diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index 46be6b31e1f..e56de5d6feb 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -269,6 +269,7 @@ model LiteLLM_ObjectPermissionTable { mcp_access_groups String[] @default([]) mcp_tool_permissions Json? // Tool-level permissions for MCP servers. Format: {"server_id": ["tool_name_1", "tool_name_2"]} vector_stores String[] @default([]) + search_tools String[] @default([]) // Search tool names allowed for this key/team/org. Empty = no access (least privilege). agents String[] @default([]) agent_access_groups String[] @default([]) models String[] @default([]) diff --git a/schema.prisma b/schema.prisma index fde9a466a28..d5abf7ef382 100644 --- a/schema.prisma +++ b/schema.prisma @@ -269,6 +269,7 @@ model LiteLLM_ObjectPermissionTable { mcp_access_groups String[] @default([]) mcp_tool_permissions Json? // Tool-level permissions for MCP servers. Format: {"server_id": ["tool_name_1", "tool_name_2"]} vector_stores String[] @default([]) + search_tools String[] @default([]) // Search tool names allowed for this key/team/org. Empty = no access (least privilege). agents String[] @default([]) agent_access_groups String[] @default([]) models String[] @default([]) From c219302bfe391eea0cdb54d6f82439d72378a013 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 19 Mar 2026 01:36:45 +0000 Subject: [PATCH 2/9] feat: add search tool access control logic in auth_checks.py - Add _can_object_call_search_tools() with least-privilege semantics (empty list = no access) - Add search_tool_access_check() that checks both key-level and team-level permissions - Add get_permitted_search_tool_names() helper for filtering search tool listings Co-authored-by: yuneng-jiang --- litellm/proxy/auth/auth_checks.py | 108 ++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index d31a13e8bc6..1b720c18660 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -3559,3 +3559,111 @@ def _can_object_call_vector_stores( ) return True + + +def _can_object_call_search_tools( + object_type: Literal["key", "team", "org"], + search_tool_name: str, + object_permissions: Optional[LiteLLM_ObjectPermissionTable], +): + """ + Raises ProxyException if the object (key, team, org) cannot access the specific search tool. + + Key difference from vector stores: follows principle of least privilege. + - object_permissions is None → allow (no permission record) + - search_tools is None → allow (field not configured, no restriction) + - search_tools == [] → DENY ALL (empty list = no access granted) + - search_tool_name in search_tools → allow + - search_tool_name not in search_tools → deny + """ + if object_permissions is None: + return True + + if object_permissions.search_tools is None: + return True + + # Empty list = no access (principle of least privilege) + # Non-empty list = only listed tools are accessible + if search_tool_name not in object_permissions.search_tools: + raise ProxyException( + message=f"User not allowed to access search tool '{search_tool_name}'. Allowed search tools: {object_permissions.search_tools}", + type=ProxyErrorTypes.get_search_tool_access_error_type_for_object( + object_type + ), + param="search_tool", + code=status.HTTP_401_UNAUTHORIZED, + ) + + return True + + +async def search_tool_access_check( + search_tool_name: str, + valid_token: Optional[UserAPIKeyAuth], +): + """ + Checks if the key/team has access to a specific search tool. + + Uses valid_token.object_permission_id (key level) and + valid_token.team_object_permission_id (team level) to look up permissions. + + Raises ProxyException if access is denied. + """ + from litellm.proxy.proxy_server import prisma_client + + if prisma_client is None: + verbose_proxy_logger.debug( + "Prisma client not found, skipping search tool access check" + ) + return True + + # Check key-level permissions + if valid_token is not None and valid_token.object_permission_id is not None: + key_object_permission = ( + await prisma_client.db.litellm_objectpermissiontable.find_unique( + where={"object_permission_id": valid_token.object_permission_id}, + ) + ) + if key_object_permission is not None: + _can_object_call_search_tools( + object_type="key", + search_tool_name=search_tool_name, + object_permissions=key_object_permission, + ) + + # Check team-level permissions + team_object_permission_id = ( + getattr(valid_token, "team_object_permission_id", None) + if valid_token + else None + ) + if team_object_permission_id is not None: + team_object_permission = ( + await prisma_client.db.litellm_objectpermissiontable.find_unique( + where={"object_permission_id": team_object_permission_id}, + ) + ) + if team_object_permission is not None: + _can_object_call_search_tools( + object_type="team", + search_tool_name=search_tool_name, + object_permissions=team_object_permission, + ) + + return True + + +def get_permitted_search_tool_names( + object_permissions: Optional[LiteLLM_ObjectPermissionTable], +) -> Optional[List[str]]: + """ + Returns the list of permitted search tool names for the given object permissions. + + Returns: + None → no restriction (all tools accessible) + [] → no tools accessible + ["a", "b"] → only those tools accessible + """ + if object_permissions is None: + return None + return object_permissions.search_tools From fd975053ba24da673ce9d9775a8ed474ee2fb4f6 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 19 Mar 2026 01:37:51 +0000 Subject: [PATCH 3/9] feat: integrate search tool access control into search endpoints - Add access check in search() endpoint after resolving search_tool_name - Add _get_allowed_search_tool_names() helper for computing allowed tools - Filter list_search_tools() results based on key/team permissions - Least privilege: empty search_tools list means no access Co-authored-by: yuneng-jiang --- litellm/proxy/search_endpoints/endpoints.py | 73 +++++++++++++++++++++ 1 file changed, 73 insertions(+) diff --git a/litellm/proxy/search_endpoints/endpoints.py b/litellm/proxy/search_endpoints/endpoints.py index 8bed5b54075..22f777fc929 100644 --- a/litellm/proxy/search_endpoints/endpoints.py +++ b/litellm/proxy/search_endpoints/endpoints.py @@ -12,6 +12,60 @@ from litellm.proxy.common_request_processing import ProxyBaseLLMRequestProcessin router = APIRouter() +async def _get_allowed_search_tool_names( + user_api_key_dict: UserAPIKeyAuth, +) -> Optional[List[str]]: + """ + Compute the intersection of key-level and team-level search tool permissions. + + Returns: + None → no restriction (all tools accessible) + list → only those tool names are accessible (may be empty = none) + """ + from litellm.proxy.proxy_server import prisma_client + + if prisma_client is None: + return None + + key_allowed: Optional[List[str]] = None + team_allowed: Optional[List[str]] = None + + # Key-level permissions + if user_api_key_dict.object_permission_id is not None: + key_perm = ( + await prisma_client.db.litellm_objectpermissiontable.find_unique( + where={ + "object_permission_id": user_api_key_dict.object_permission_id + }, + ) + ) + if key_perm is not None: + key_allowed = key_perm.search_tools # None means no restriction + + # Team-level permissions + team_perm_id = getattr(user_api_key_dict, "team_object_permission_id", None) + if team_perm_id is not None: + team_perm = ( + await prisma_client.db.litellm_objectpermissiontable.find_unique( + where={"object_permission_id": team_perm_id}, + ) + ) + if team_perm is not None: + team_allowed = team_perm.search_tools # None means no restriction + + # Combine: both None → None (no restriction) + # One set → use that set + # Both set → intersection + if key_allowed is None and team_allowed is None: + return None + if key_allowed is None: + return team_allowed + if team_allowed is None: + return key_allowed + # Both are set - return the intersection + return list(set(key_allowed) & set(team_allowed)) + + @router.post( "/v1/search/{search_tool_name}", dependencies=[Depends(user_api_key_auth)], @@ -163,6 +217,16 @@ async def search( data["metadata"] = {} data["metadata"]["model_group"] = search_tool_name_value + # Access control check for search tools + resolved_search_tool_name = data.get("search_tool_name") + if resolved_search_tool_name: + from litellm.proxy.auth.auth_checks import search_tool_access_check + + await search_tool_access_check( + search_tool_name=resolved_search_tool_name, + valid_token=user_api_key_dict, + ) + # Process request using ProxyBaseLLMRequestProcessing processor = ProxyBaseLLMRequestProcessing(data=data) try: @@ -258,6 +322,15 @@ async def list_search_tools( search_tools_list.append(tool_info) + # Filter search tools based on user's permissions + allowed_names = await _get_allowed_search_tool_names(user_api_key_dict) + if allowed_names is not None: + search_tools_list = [ + tool + for tool in search_tools_list + if tool.get("search_tool_name") in allowed_names + ] + return {"object": "list", "data": search_tools_list} except Exception as e: from litellm._logging import verbose_proxy_logger From 049d951e71b9191b271929aedfa493ab8149fe0c Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 19 Mar 2026 01:56:59 +0000 Subject: [PATCH 4/9] test: add unit tests for search tool access control - Test _can_object_call_search_tools() with least-privilege semantics - Test search_tool_access_check() for key/team level permissions - Test get_permitted_search_tool_names() helper - Test ProxyErrorTypes for search tool access - Regression tests ensuring vector store semantics unchanged Co-authored-by: yuneng-jiang --- .../proxy/auth/test_search_tool_access.py | 413 ++++++++++++++++++ 1 file changed, 413 insertions(+) create mode 100644 tests/test_litellm/proxy/auth/test_search_tool_access.py diff --git a/tests/test_litellm/proxy/auth/test_search_tool_access.py b/tests/test_litellm/proxy/auth/test_search_tool_access.py new file mode 100644 index 00000000000..1559672dcb8 --- /dev/null +++ b/tests/test_litellm/proxy/auth/test_search_tool_access.py @@ -0,0 +1,413 @@ +""" +Tests for search tool access control. + +Covers: +- _can_object_call_search_tools() with least-privilege semantics +- search_tool_access_check() for key and team level permissions +- get_permitted_search_tool_names() helper +- ProxyErrorTypes for search tool access denied +- Ensures vector store access check semantics are unchanged +""" + +from typing import List, Optional +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from litellm.proxy._types import ( + LiteLLM_ObjectPermissionTable, + ProxyErrorTypes, + ProxyException, + UserAPIKeyAuth, +) +from litellm.proxy.auth.auth_checks import ( + _can_object_call_search_tools, + _can_object_call_vector_stores, + get_permitted_search_tool_names, + search_tool_access_check, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_object_permission( + search_tools: Optional[List[str]] = None, + vector_stores: Optional[List[str]] = None, +) -> LiteLLM_ObjectPermissionTable: + """Create a minimal object permission for testing.""" + return LiteLLM_ObjectPermissionTable( + object_permission_id="test-perm-id", + search_tools=search_tools, + vector_stores=vector_stores if vector_stores is not None else [], + ) + + +# =========================================================================== +# _can_object_call_search_tools — least-privilege semantics +# =========================================================================== + + +class TestCanObjectCallSearchTools: + """should enforce least-privilege semantics for search tools.""" + + def test_should_allow_when_permissions_are_none(self): + """None object_permissions → allow (no permission record).""" + result = _can_object_call_search_tools( + object_type="key", + search_tool_name="any-tool", + object_permissions=None, + ) + assert result is True + + def test_should_allow_when_search_tools_field_is_none(self): + """search_tools=None → allow (field not configured).""" + perm = _make_object_permission(search_tools=None) + result = _can_object_call_search_tools( + object_type="key", + search_tool_name="any-tool", + object_permissions=perm, + ) + assert result is True + + def test_should_deny_when_search_tools_is_empty_list(self): + """search_tools=[] → DENY (principle of least privilege).""" + perm = _make_object_permission(search_tools=[]) + with pytest.raises(ProxyException) as exc_info: + _can_object_call_search_tools( + object_type="key", + search_tool_name="any-tool", + object_permissions=perm, + ) + assert exc_info.value.type == ProxyErrorTypes.key_search_tool_access_denied + + def test_should_allow_tool_in_allowed_list(self): + """Requesting a tool that is in the allowed list → allow.""" + perm = _make_object_permission(search_tools=["tool-a", "tool-b"]) + result = _can_object_call_search_tools( + object_type="key", + search_tool_name="tool-a", + object_permissions=perm, + ) + assert result is True + + def test_should_deny_tool_not_in_allowed_list(self): + """Requesting a tool NOT in the allowed list → deny.""" + perm = _make_object_permission(search_tools=["tool-a"]) + with pytest.raises(ProxyException) as exc_info: + _can_object_call_search_tools( + object_type="key", + search_tool_name="tool-b", + object_permissions=perm, + ) + assert exc_info.value.type == ProxyErrorTypes.key_search_tool_access_denied + + def test_should_use_team_error_type_for_team_object(self): + """Team denial uses team-specific error type.""" + perm = _make_object_permission(search_tools=[]) + with pytest.raises(ProxyException) as exc_info: + _can_object_call_search_tools( + object_type="team", + search_tool_name="any-tool", + object_permissions=perm, + ) + assert exc_info.value.type == ProxyErrorTypes.team_search_tool_access_denied + + def test_should_use_org_error_type_for_org_object(self): + """Org denial uses org-specific error type.""" + perm = _make_object_permission(search_tools=["other"]) + with pytest.raises(ProxyException) as exc_info: + _can_object_call_search_tools( + object_type="org", + search_tool_name="not-other", + object_permissions=perm, + ) + assert exc_info.value.type == ProxyErrorTypes.org_search_tool_access_denied + + def test_should_allow_all_tools_in_allowed_list(self): + """Multiple tools in allowed list all pass.""" + perm = _make_object_permission(search_tools=["a", "b", "c"]) + for name in ["a", "b", "c"]: + result = _can_object_call_search_tools( + object_type="key", + search_tool_name=name, + object_permissions=perm, + ) + assert result is True + with pytest.raises(ProxyException): + _can_object_call_search_tools( + object_type="key", + search_tool_name="d", + object_permissions=perm, + ) + + +# =========================================================================== +# search_tool_access_check — async DB lookups +# =========================================================================== + + +@pytest.mark.asyncio +async def test_should_allow_when_no_prisma_client(): + """No prisma client → allow.""" + with patch("litellm.proxy.proxy_server.prisma_client", None): + result = await search_tool_access_check( + search_tool_name="any-tool", + valid_token=None, + ) + assert result is True + + +@pytest.mark.asyncio +async def test_should_allow_when_no_token(): + """No valid_token → allow.""" + mock_prisma = MagicMock() + with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma): + result = await search_tool_access_check( + search_tool_name="any-tool", + valid_token=None, + ) + assert result is True + + +@pytest.mark.asyncio +async def test_should_allow_when_no_permission_ids(): + """Token with no object_permission_id or team_object_permission_id → allow.""" + mock_prisma = MagicMock() + token = UserAPIKeyAuth( + object_permission_id=None, + team_object_permission_id=None, + ) + with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma): + result = await search_tool_access_check( + search_tool_name="any-tool", + valid_token=token, + ) + assert result is True + + +@pytest.mark.asyncio +async def test_should_allow_key_with_matching_permission(): + """Key with object_permission that includes the tool → allow.""" + mock_prisma = MagicMock() + mock_perm = MagicMock() + mock_perm.search_tools = ["my-tool"] + mock_prisma.db.litellm_objectpermissiontable.find_unique = AsyncMock( + return_value=mock_perm + ) + + token = UserAPIKeyAuth( + object_permission_id="key-perm-id", + team_object_permission_id=None, + ) + with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma): + result = await search_tool_access_check( + search_tool_name="my-tool", + valid_token=token, + ) + assert result is True + + +@pytest.mark.asyncio +async def test_should_deny_key_with_empty_search_tools(): + """Key with empty search_tools → deny.""" + mock_prisma = MagicMock() + mock_perm = MagicMock() + mock_perm.search_tools = [] + mock_prisma.db.litellm_objectpermissiontable.find_unique = AsyncMock( + return_value=mock_perm + ) + + token = UserAPIKeyAuth( + object_permission_id="key-perm-id", + team_object_permission_id=None, + ) + with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma): + with pytest.raises(ProxyException) as exc_info: + await search_tool_access_check( + search_tool_name="any-tool", + valid_token=token, + ) + assert exc_info.value.type == ProxyErrorTypes.key_search_tool_access_denied + + +@pytest.mark.asyncio +async def test_should_deny_team_with_empty_search_tools(): + """Team with empty search_tools → deny.""" + mock_prisma = MagicMock() + mock_team_perm = MagicMock() + mock_team_perm.search_tools = [] + mock_prisma.db.litellm_objectpermissiontable.find_unique = AsyncMock( + return_value=mock_team_perm + ) + + token = UserAPIKeyAuth( + object_permission_id=None, + team_object_permission_id="team-perm-id", + ) + with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma): + with pytest.raises(ProxyException) as exc_info: + await search_tool_access_check( + search_tool_name="any-tool", + valid_token=token, + ) + assert exc_info.value.type == ProxyErrorTypes.team_search_tool_access_denied + + +@pytest.mark.asyncio +async def test_should_deny_when_key_allows_but_team_denies(): + """Key allows, team denies → deny (team check second).""" + mock_prisma = MagicMock() + + key_perm = MagicMock() + key_perm.search_tools = ["the-tool"] + + team_perm = MagicMock() + team_perm.search_tools = [] + + async def mock_find(where): + if where["object_permission_id"] == "key-perm-id": + return key_perm + elif where["object_permission_id"] == "team-perm-id": + return team_perm + return None + + mock_prisma.db.litellm_objectpermissiontable.find_unique = AsyncMock( + side_effect=mock_find + ) + + token = UserAPIKeyAuth( + object_permission_id="key-perm-id", + team_object_permission_id="team-perm-id", + ) + with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma): + with pytest.raises(ProxyException) as exc_info: + await search_tool_access_check( + search_tool_name="the-tool", + valid_token=token, + ) + assert exc_info.value.type == ProxyErrorTypes.team_search_tool_access_denied + + +@pytest.mark.asyncio +async def test_should_allow_when_both_key_and_team_allow(): + """Both key and team allow → allow.""" + mock_prisma = MagicMock() + + key_perm = MagicMock() + key_perm.search_tools = ["tool-x"] + + team_perm = MagicMock() + team_perm.search_tools = ["tool-x", "tool-y"] + + async def mock_find(where): + if where["object_permission_id"] == "key-perm-id": + return key_perm + elif where["object_permission_id"] == "team-perm-id": + return team_perm + return None + + mock_prisma.db.litellm_objectpermissiontable.find_unique = AsyncMock( + side_effect=mock_find + ) + + token = UserAPIKeyAuth( + object_permission_id="key-perm-id", + team_object_permission_id="team-perm-id", + ) + with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma): + result = await search_tool_access_check( + search_tool_name="tool-x", + valid_token=token, + ) + assert result is True + + +# =========================================================================== +# get_permitted_search_tool_names +# =========================================================================== + + +class TestGetPermittedSearchToolNames: + def test_should_return_none_for_none_permissions(self): + assert get_permitted_search_tool_names(None) is None + + def test_should_return_none_when_search_tools_is_none(self): + perm = _make_object_permission(search_tools=None) + assert get_permitted_search_tool_names(perm) is None + + def test_should_return_empty_list_for_empty_search_tools(self): + perm = _make_object_permission(search_tools=[]) + assert get_permitted_search_tool_names(perm) == [] + + def test_should_return_specific_tools(self): + perm = _make_object_permission(search_tools=["a", "b"]) + assert get_permitted_search_tool_names(perm) == ["a", "b"] + + +# =========================================================================== +# ProxyErrorTypes classmethod +# =========================================================================== + + +class TestSearchToolErrorTypes: + def test_should_return_key_error_type(self): + assert ( + ProxyErrorTypes.get_search_tool_access_error_type_for_object("key") + == ProxyErrorTypes.key_search_tool_access_denied + ) + + def test_should_return_team_error_type(self): + assert ( + ProxyErrorTypes.get_search_tool_access_error_type_for_object("team") + == ProxyErrorTypes.team_search_tool_access_denied + ) + + def test_should_return_org_error_type(self): + assert ( + ProxyErrorTypes.get_search_tool_access_error_type_for_object("org") + == ProxyErrorTypes.org_search_tool_access_denied + ) + + +# =========================================================================== +# Regression: vector store semantics unchanged (empty = allow all) +# =========================================================================== + + +class TestVectorStoreAccessNotBroken: + """should preserve existing vector store semantics: empty list = allow ALL.""" + + def test_should_allow_all_when_vector_stores_is_empty(self): + """Vector stores: empty list = access to ALL (existing behavior).""" + perm = MagicMock() + perm.vector_stores = [] + result = _can_object_call_vector_stores( + object_type="key", + vector_store_ids_to_run=["store-1"], + object_permissions=perm, + ) + assert result is True + + def test_should_allow_when_vector_stores_is_none(self): + perm = MagicMock() + perm.vector_stores = None + result = _can_object_call_vector_stores( + object_type="key", + vector_store_ids_to_run=["store-1"], + object_permissions=perm, + ) + assert result is True + + def test_should_deny_unlisted_vector_store(self): + perm = MagicMock() + perm.vector_stores = ["store-1"] + with pytest.raises(ProxyException) as exc_info: + _can_object_call_vector_stores( + object_type="key", + vector_store_ids_to_run=["store-99"], + object_permissions=perm, + ) + assert exc_info.value.type == ProxyErrorTypes.key_vector_store_access_denied From bca07dca61f6b10aca53219e0088e21095a805b2 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Sat, 21 Mar 2026 00:37:20 -0700 Subject: [PATCH 5/9] fix: change ObjectPermissionTable defaults from [] to None, add team-scoped MCP RBAC - Change all ObjectPermissionTable array field defaults from [] to None (None = all access / no restriction, [] = no access) - Update vector store access check: [] now denies access instead of allowing all - Add team-scoped MCP server management (create/update/delete) with granular permissions (mcp:create, mcp:update, mcp:delete) - Auto-assign created servers to team's ObjectPermissionTable - Auto-remove deleted servers from team's ObjectPermissionTable - Fix unreachable special MCP server name guard in add_mcp_server - Fix server_id validation ordering in edit_mcp_server - Fix description typo on PUT /server endpoint - Move inline imports to module level in common_utils.py (CLAUDE.md) Co-Authored-By: Claude Opus 4.6 (1M context) --- litellm/proxy/_types.py | 12 +- litellm/proxy/auth/auth_checks.py | 11 +- .../management_endpoints/common_utils.py | 12 +- .../mcp_management_endpoints.py | 189 ++++++++++++++---- 4 files changed, 169 insertions(+), 55 deletions(-) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 18e1ad89c72..07d4859220c 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -1831,8 +1831,8 @@ class LiteLLM_ObjectPermissionTable(LiteLLMPydanticObjectBase): """Represents a LiteLLM_ObjectPermissionTable record""" object_permission_id: str - mcp_servers: Optional[List[str]] = [] - mcp_access_groups: Optional[List[str]] = [] + mcp_servers: Optional[List[str]] = None + mcp_access_groups: Optional[List[str]] = None mcp_tool_permissions: Optional[Dict[str, List[str]]] = None """ Mapping - server_id -> list of tools @@ -1843,10 +1843,10 @@ class LiteLLM_ObjectPermissionTable(LiteLLMPydanticObjectBase): } """ - vector_stores: Optional[List[str]] = [] - search_tools: Optional[List[str]] = [] - agents: Optional[List[str]] = [] - agent_access_groups: Optional[List[str]] = [] + vector_stores: Optional[List[str]] = None + search_tools: Optional[List[str]] = None + agents: Optional[List[str]] = None + agent_access_groups: Optional[List[str]] = None class LiteLLM_TeamTable(TeamBase): diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 1b720c18660..95f22bb4f3b 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -3543,9 +3543,16 @@ def _can_object_call_vector_stores( if object_permissions.vector_stores is None: return True - # If length is 0, then the object has access to all vector stores. + # Empty list = no access to any vector stores if len(object_permissions.vector_stores) == 0: - return True + raise ProxyException( + message="User not allowed to access any vector stores. No vector stores are configured in object permissions.", + type=ProxyErrorTypes.get_vector_store_access_error_type_for_object( + object_type + ), + param="vector_store", + code=status.HTTP_401_UNAUTHORIZED, + ) for vector_store_id in vector_store_ids_to_run: if vector_store_id not in object_permissions.vector_stores: diff --git a/litellm/proxy/management_endpoints/common_utils.py b/litellm/proxy/management_endpoints/common_utils.py index efc42d3355c..06115993245 100644 --- a/litellm/proxy/management_endpoints/common_utils.py +++ b/litellm/proxy/management_endpoints/common_utils.py @@ -15,6 +15,7 @@ from litellm.proxy._types import ( UpdateProjectRequest, UserAPIKeyAuth, ) +from litellm.proxy.auth.auth_checks import get_user_object from litellm.proxy.utils import _premium_user_check if TYPE_CHECKING: @@ -54,7 +55,6 @@ async def _is_user_org_admin_for_team( if not team_obj.organization_id or not user_api_key_dict.user_id: return False - from litellm.proxy.auth.auth_checks import get_user_object from litellm.proxy.proxy_server import ( prisma_client, proxy_logging_obj, @@ -124,14 +124,11 @@ async def _user_has_admin_privileges( return False # Get user object to check team and org admin status - from litellm.caching import DualCache as DualCacheImport - from litellm.proxy.auth.auth_checks import get_user_object - try: user_obj = await get_user_object( user_id=user_api_key_dict.user_id, prisma_client=prisma_client, - user_api_key_cache=user_api_key_cache or DualCacheImport(), + user_api_key_cache=user_api_key_cache or DualCache(), user_id_upsert=False, proxy_logging_obj=proxy_logging_obj, ) @@ -273,11 +270,8 @@ async def admin_can_invite_user( if prisma_client is None or user_api_key_dict.user_id is None: return False - from litellm.caching import DualCache as DualCacheImport - from litellm.proxy.auth.auth_checks import get_user_object - try: - cache = user_api_key_cache or DualCacheImport() + cache = user_api_key_cache or DualCache() admin_user_obj = await get_user_object( user_id=user_api_key_dict.user_id, prisma_client=prisma_client, diff --git a/litellm/proxy/management_endpoints/mcp_management_endpoints.py b/litellm/proxy/management_endpoints/mcp_management_endpoints.py index 3e5b729cea6..0080e5b7887 100644 --- a/litellm/proxy/management_endpoints/mcp_management_endpoints.py +++ b/litellm/proxy/management_endpoints/mcp_management_endpoints.py @@ -130,7 +130,15 @@ if MCP_AVAILABLE: ) from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.common_utils.http_parsing_utils import _read_request_body - from litellm.proxy.management_endpoints.common_utils import _user_has_admin_view + from litellm.proxy.management_endpoints.common_utils import ( + _user_has_admin_view, + check_member_permission, + ) + from litellm.proxy.management_helpers.object_permission_utils import ( + _get_team_allowed_mcp_servers, + add_mcp_server_to_team, + remove_mcp_server_from_team, + ) from litellm.proxy.management_helpers.utils import management_endpoint_wrapper from litellm.types.mcp import MCPCredentials from litellm.types.mcp_server.mcp_server_manager import MCPServer @@ -1206,25 +1214,39 @@ if MCP_AVAILABLE: # Validate and normalize payload fields validate_and_normalize_mcp_server_payload(payload) - # AuthZ - restrict only proxy admins to create mcp servers + # AuthZ - proxy admins, team admins, or members with mcp:create permission + from litellm.proxy.auth.auth_checks import get_team_object + from litellm.proxy.proxy_server import user_api_key_cache + + team_obj = None + team_id = payload.team_id or user_api_key_dict.team_id + if LitellmUserRoles.PROXY_ADMIN != user_api_key_dict.user_role: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail={ - "error": "User does not have permission to create mcp servers. You can only create mcp servers if you are a PROXY_ADMIN." - }, - ) - elif payload.server_id is not None: - # fail if the mcp server with id already exists - mcp_server = await get_mcp_server(prisma_client, payload.server_id) - if mcp_server is not None: + if not team_id: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail={ - "error": f"MCP Server with id {payload.server_id} already exists. Cannot create another." + "error": "team_id is required for non-proxy-admin users to create MCP servers." }, ) - elif ( + team_obj = await get_team_object( + team_id=team_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + ) + if not check_member_permission( + user_api_key_dict, team_obj, "mcp:create" + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={ + "error": "User does not have permission to create MCP servers for this team. " + "Requires team admin role or 'mcp:create' permission." + }, + ) + + # Reject special reserved server names + if ( SpecialMCPServerName.all_team_servers == payload.server_id or SpecialMCPServerName.all_proxy_servers == payload.server_id ): @@ -1235,6 +1257,17 @@ if MCP_AVAILABLE: }, ) + if payload.server_id is not None: + # fail if the mcp server with id already exists + mcp_server = await get_mcp_server(prisma_client, payload.server_id) + if mcp_server is not None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "error": f"MCP Server with id {payload.server_id} already exists. Cannot create another." + }, + ) + # TODO: audit log for create # Admin-created servers are always active — clear any submission lifecycle @@ -1261,6 +1294,20 @@ if MCP_AVAILABLE: status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail={"error": f"Error creating mcp server: {str(e)}"}, ) + + # Auto-assign server to team's ObjectPermissionTable if team-scoped + if team_id and new_mcp_server.server_id: + try: + await add_mcp_server_to_team( + prisma_client, team_id, new_mcp_server.server_id + ) + except ValueError as e: + # Team not found — surface as 400 so caller knows + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={"error": str(e)}, + ) + return _redact_mcp_credentials(new_mcp_server) @router.post( @@ -1445,7 +1492,7 @@ if MCP_AVAILABLE: @router.delete( "/server/{server_id}", - description="Allows deleting mcp serves in the db", + description="Allows deleting mcp servers in the db", dependencies=[Depends(user_api_key_auth)], response_class=JSONResponse, status_code=status.HTTP_202_ACCEPTED, @@ -1473,16 +1520,44 @@ if MCP_AVAILABLE: "Database not connected. Connect a database to your proxy - https://docs.litellm.ai/docs/simple_proxy#managing-auth---virtual-keys" ) - # Authz - restrict only admins to delete mcp servers + # AuthZ - proxy admins, team admins, or members with mcp:delete permission + from litellm.proxy.auth.auth_checks import get_team_object + from litellm.proxy.proxy_server import user_api_key_cache + + team_id = user_api_key_dict.team_id + if LitellmUserRoles.PROXY_ADMIN != user_api_key_dict.user_role: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail={ - "error": "Call not allowed to delete MCP server. User is not a proxy admin. route={}".format( - "DELETE /v1/mcp/server" - ) - }, + if not team_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "error": "team_id is required for non-proxy-admin users to delete MCP servers." + }, + ) + team_obj = await get_team_object( + team_id=team_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, ) + if not check_member_permission( + user_api_key_dict, team_obj, "mcp:delete" + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={ + "error": "User does not have permission to delete MCP servers for this team. " + "Requires team admin role or 'mcp:delete' permission." + }, + ) + # Verify server belongs to this team + team_servers = await _get_team_allowed_mcp_servers(team_obj) + if server_id not in team_servers: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={ + "error": f"MCP Server {server_id} does not belong to your team." + }, + ) # try to delete the mcp server mcp_server_record_deleted = await delete_mcp_server(prisma_client, server_id) @@ -1497,16 +1572,20 @@ if MCP_AVAILABLE: # Ensure registry is up to date by reloading from database await global_mcp_server_manager.reload_servers_from_database() + # Remove server from team's ObjectPermissionTable + if team_id: + try: + await remove_mcp_server_from_team(prisma_client, team_id, server_id) + except Exception as e: + verbose_proxy_logger.warning( + f"Failed to remove server {server_id} from team {team_id} permissions: {e}. " + "Server was deleted but team's ObjectPermissionTable may contain a stale entry." + ) + # TODO: Enterprise: Finish audit log trail if litellm.store_audit_logs: pass - # TODO: Delete from virtual keys - - # TODO: Delete from teams - - # Update from global mcp store - return Response(status_code=status.HTTP_202_ACCEPTED) @router.post( @@ -1767,7 +1846,7 @@ if MCP_AVAILABLE: @router.put( "/server", - description="Allows deleting mcp serves in the db", + description="Allows updating mcp servers in the db", dependencies=[Depends(user_api_key_auth)], response_model=LiteLLM_MCPServerTable, status_code=status.HTTP_202_ACCEPTED, @@ -1798,16 +1877,50 @@ if MCP_AVAILABLE: # Validate and normalize payload fields validate_and_normalize_mcp_server_payload(payload) - # Authz - restrict only admins to delete mcp servers + # AuthZ - proxy admins, team admins, or members with mcp:update permission + from litellm.proxy.auth.auth_checks import get_team_object + from litellm.proxy.proxy_server import user_api_key_cache + if LitellmUserRoles.PROXY_ADMIN != user_api_key_dict.user_role: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail={ - "error": "Call not allowed to update MCP server. User is not a proxy admin. route={}".format( - "PUT /v1/mcp/server" - ) - }, + team_id = user_api_key_dict.team_id + if not team_id: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "error": "team_id is required for non-proxy-admin users to update MCP servers." + }, + ) + team_obj = await get_team_object( + team_id=team_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, ) + if not check_member_permission( + user_api_key_dict, team_obj, "mcp:update" + ): + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={ + "error": "User does not have permission to update MCP servers for this team. " + "Requires team admin role or 'mcp:update' permission." + }, + ) + # Verify server_id is provided and belongs to this team + if payload.server_id is None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "error": "server_id is required to update an MCP server." + }, + ) + team_servers = await _get_team_allowed_mcp_servers(team_obj) + if payload.server_id not in team_servers: + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={ + "error": f"MCP Server {payload.server_id} does not belong to your team." + }, + ) # try to update the mcp server mcp_server_record_updated = await update_mcp_server( From d1a0b94919c6cfeb9899769536d82eb80dde3028 Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Sat, 21 Mar 2026 09:28:08 -0700 Subject: [PATCH 6/9] fix: address greptile feedback on search_tools RBAC - Migration: remove DEFAULT ARRAY[]::TEXT[] so existing rows get NULL (NULL = all access) instead of [] (no access) - Schema: remove @default([]) for search_tools in all 3 schema.prisma files - Use cached get_object_permission instead of raw DB queries in search_tool_access_check and _get_allowed_search_tool_names - Reject requests with missing search_tool_name (400) instead of silently bypassing access control - Fix vector store test: [] now correctly denies access (not allows) - Update search_tool_access_check tests to mock get_object_permission Co-Authored-By: Claude Opus 4.6 (1M context) --- .../migration.sql | 2 +- .../litellm_proxy_extras/schema.prisma | 2 +- litellm/proxy/auth/auth_checks.py | 29 ++++-- litellm/proxy/schema.prisma | 2 +- litellm/proxy/search_endpoints/endpoints.py | 41 +++++--- schema.prisma | 2 +- .../proxy/auth/test_search_tool_access.py | 96 +++++++++---------- 7 files changed, 98 insertions(+), 76 deletions(-) diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260319000000_add_search_tools_to_object_permissions/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260319000000_add_search_tools_to_object_permissions/migration.sql index 1b33edc3a8b..b01d8c106c1 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260319000000_add_search_tools_to_object_permissions/migration.sql +++ b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260319000000_add_search_tools_to_object_permissions/migration.sql @@ -1,2 +1,2 @@ -- AlterTable -ALTER TABLE "LiteLLM_ObjectPermissionTable" ADD COLUMN "search_tools" TEXT[] DEFAULT ARRAY[]::TEXT[]; +ALTER TABLE "LiteLLM_ObjectPermissionTable" ADD COLUMN "search_tools" TEXT[]; diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma index 084b1bdce6d..4fbbca07793 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma +++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma @@ -269,7 +269,7 @@ model LiteLLM_ObjectPermissionTable { mcp_access_groups String[] @default([]) mcp_tool_permissions Json? // Tool-level permissions for MCP servers. Format: {"server_id": ["tool_name_1", "tool_name_2"]} vector_stores String[] @default([]) - search_tools String[] @default([]) // Search tool names allowed for this key/team/org. Empty = no access (least privilege). + search_tools String[] // Search tool names allowed for this key/team/org. NULL = all access, empty = no access. agents String[] @default([]) agent_access_groups String[] @default([]) models String[] @default([]) diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 95f22bb4f3b..06c6ddd714b 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -3612,11 +3612,16 @@ async def search_tool_access_check( Checks if the key/team has access to a specific search tool. Uses valid_token.object_permission_id (key level) and - valid_token.team_object_permission_id (team level) to look up permissions. + valid_token.team_object_permission_id (team level) to look up permissions + via the cached get_object_permission helper. Raises ProxyException if access is denied. """ - from litellm.proxy.proxy_server import prisma_client + from litellm.proxy.proxy_server import ( + prisma_client, + proxy_logging_obj, + user_api_key_cache, + ) if prisma_client is None: verbose_proxy_logger.debug( @@ -3626,10 +3631,12 @@ async def search_tool_access_check( # Check key-level permissions if valid_token is not None and valid_token.object_permission_id is not None: - key_object_permission = ( - await prisma_client.db.litellm_objectpermissiontable.find_unique( - where={"object_permission_id": valid_token.object_permission_id}, - ) + key_object_permission = await get_object_permission( + object_permission_id=valid_token.object_permission_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + parent_otel_span=getattr(valid_token, "parent_otel_span", None), + proxy_logging_obj=proxy_logging_obj, ) if key_object_permission is not None: _can_object_call_search_tools( @@ -3645,10 +3652,12 @@ async def search_tool_access_check( else None ) if team_object_permission_id is not None: - team_object_permission = ( - await prisma_client.db.litellm_objectpermissiontable.find_unique( - where={"object_permission_id": team_object_permission_id}, - ) + team_object_permission = await get_object_permission( + object_permission_id=team_object_permission_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + parent_otel_span=getattr(valid_token, "parent_otel_span", None), + proxy_logging_obj=proxy_logging_obj, ) if team_object_permission is not None: _can_object_call_search_tools( diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index e56de5d6feb..414ae23dad5 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -269,7 +269,7 @@ model LiteLLM_ObjectPermissionTable { mcp_access_groups String[] @default([]) mcp_tool_permissions Json? // Tool-level permissions for MCP servers. Format: {"server_id": ["tool_name_1", "tool_name_2"]} vector_stores String[] @default([]) - search_tools String[] @default([]) // Search tool names allowed for this key/team/org. Empty = no access (least privilege). + search_tools String[] // Search tool names allowed for this key/team/org. NULL = all access, empty = no access. agents String[] @default([]) agent_access_groups String[] @default([]) models String[] @default([]) diff --git a/litellm/proxy/search_endpoints/endpoints.py b/litellm/proxy/search_endpoints/endpoints.py index 22f777fc929..b67c9fe2443 100644 --- a/litellm/proxy/search_endpoints/endpoints.py +++ b/litellm/proxy/search_endpoints/endpoints.py @@ -22,7 +22,12 @@ async def _get_allowed_search_tool_names( None → no restriction (all tools accessible) list → only those tool names are accessible (may be empty = none) """ - from litellm.proxy.proxy_server import prisma_client + from litellm.proxy.auth.auth_checks import get_object_permission + from litellm.proxy.proxy_server import ( + prisma_client, + proxy_logging_obj, + user_api_key_cache, + ) if prisma_client is None: return None @@ -30,25 +35,27 @@ async def _get_allowed_search_tool_names( key_allowed: Optional[List[str]] = None team_allowed: Optional[List[str]] = None - # Key-level permissions + # Key-level permissions (via cached helper) if user_api_key_dict.object_permission_id is not None: - key_perm = ( - await prisma_client.db.litellm_objectpermissiontable.find_unique( - where={ - "object_permission_id": user_api_key_dict.object_permission_id - }, - ) + key_perm = await get_object_permission( + object_permission_id=user_api_key_dict.object_permission_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + parent_otel_span=getattr(user_api_key_dict, "parent_otel_span", None), + proxy_logging_obj=proxy_logging_obj, ) if key_perm is not None: key_allowed = key_perm.search_tools # None means no restriction - # Team-level permissions + # Team-level permissions (via cached helper) team_perm_id = getattr(user_api_key_dict, "team_object_permission_id", None) if team_perm_id is not None: - team_perm = ( - await prisma_client.db.litellm_objectpermissiontable.find_unique( - where={"object_permission_id": team_perm_id}, - ) + team_perm = await get_object_permission( + object_permission_id=team_perm_id, + prisma_client=prisma_client, + user_api_key_cache=user_api_key_cache, + parent_otel_span=getattr(user_api_key_dict, "parent_otel_span", None), + proxy_logging_obj=proxy_logging_obj, ) if team_perm is not None: team_allowed = team_perm.search_tools # None means no restriction @@ -226,6 +233,14 @@ async def search( search_tool_name=resolved_search_tool_name, valid_token=user_api_key_dict, ) + else: + raise HTTPException( + status_code=400, + detail={ + "error": "search_tool_name is required. Provide it in the URL path " + "(/v1/search/{search_tool_name}) or in the request body." + }, + ) # Process request using ProxyBaseLLMRequestProcessing processor = ProxyBaseLLMRequestProcessing(data=data) diff --git a/schema.prisma b/schema.prisma index d5abf7ef382..7b782ada24b 100644 --- a/schema.prisma +++ b/schema.prisma @@ -269,7 +269,7 @@ model LiteLLM_ObjectPermissionTable { mcp_access_groups String[] @default([]) mcp_tool_permissions Json? // Tool-level permissions for MCP servers. Format: {"server_id": ["tool_name_1", "tool_name_2"]} vector_stores String[] @default([]) - search_tools String[] @default([]) // Search tool names allowed for this key/team/org. Empty = no access (least privilege). + search_tools String[] // Search tool names allowed for this key/team/org. NULL = all access, empty = no access. agents String[] @default([]) agent_access_groups String[] @default([]) models String[] @default([]) diff --git a/tests/test_litellm/proxy/auth/test_search_tool_access.py b/tests/test_litellm/proxy/auth/test_search_tool_access.py index 1559672dcb8..06797760c8d 100644 --- a/tests/test_litellm/proxy/auth/test_search_tool_access.py +++ b/tests/test_litellm/proxy/auth/test_search_tool_access.py @@ -13,6 +13,7 @@ from typing import List, Optional from unittest.mock import AsyncMock, MagicMock, patch import pytest +from starlette import status from litellm.proxy._types import ( LiteLLM_ObjectPermissionTable, @@ -149,10 +150,14 @@ class TestCanObjectCallSearchTools: # =========================================================================== +_PROXY_SERVER = "litellm.proxy.proxy_server" +_GET_OBJ_PERM = "litellm.proxy.auth.auth_checks.get_object_permission" + + @pytest.mark.asyncio async def test_should_allow_when_no_prisma_client(): """No prisma client → allow.""" - with patch("litellm.proxy.proxy_server.prisma_client", None): + with patch(f"{_PROXY_SERVER}.prisma_client", None): result = await search_tool_access_check( search_tool_name="any-tool", valid_token=None, @@ -163,8 +168,9 @@ async def test_should_allow_when_no_prisma_client(): @pytest.mark.asyncio async def test_should_allow_when_no_token(): """No valid_token → allow.""" - mock_prisma = MagicMock() - with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma): + with patch(f"{_PROXY_SERVER}.prisma_client", MagicMock()), \ + patch(f"{_PROXY_SERVER}.proxy_logging_obj", MagicMock()), \ + patch(f"{_PROXY_SERVER}.user_api_key_cache", MagicMock()): result = await search_tool_access_check( search_tool_name="any-tool", valid_token=None, @@ -175,12 +181,13 @@ async def test_should_allow_when_no_token(): @pytest.mark.asyncio async def test_should_allow_when_no_permission_ids(): """Token with no object_permission_id or team_object_permission_id → allow.""" - mock_prisma = MagicMock() token = UserAPIKeyAuth( object_permission_id=None, team_object_permission_id=None, ) - with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma): + with patch(f"{_PROXY_SERVER}.prisma_client", MagicMock()), \ + patch(f"{_PROXY_SERVER}.proxy_logging_obj", MagicMock()), \ + patch(f"{_PROXY_SERVER}.user_api_key_cache", MagicMock()): result = await search_tool_access_check( search_tool_name="any-tool", valid_token=token, @@ -191,18 +198,17 @@ async def test_should_allow_when_no_permission_ids(): @pytest.mark.asyncio async def test_should_allow_key_with_matching_permission(): """Key with object_permission that includes the tool → allow.""" - mock_prisma = MagicMock() mock_perm = MagicMock() mock_perm.search_tools = ["my-tool"] - mock_prisma.db.litellm_objectpermissiontable.find_unique = AsyncMock( - return_value=mock_perm - ) token = UserAPIKeyAuth( object_permission_id="key-perm-id", team_object_permission_id=None, ) - with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma): + with patch(f"{_PROXY_SERVER}.prisma_client", MagicMock()), \ + patch(f"{_PROXY_SERVER}.proxy_logging_obj", MagicMock()), \ + patch(f"{_PROXY_SERVER}.user_api_key_cache", MagicMock()), \ + patch(_GET_OBJ_PERM, AsyncMock(return_value=mock_perm)): result = await search_tool_access_check( search_tool_name="my-tool", valid_token=token, @@ -213,18 +219,17 @@ async def test_should_allow_key_with_matching_permission(): @pytest.mark.asyncio async def test_should_deny_key_with_empty_search_tools(): """Key with empty search_tools → deny.""" - mock_prisma = MagicMock() mock_perm = MagicMock() mock_perm.search_tools = [] - mock_prisma.db.litellm_objectpermissiontable.find_unique = AsyncMock( - return_value=mock_perm - ) token = UserAPIKeyAuth( object_permission_id="key-perm-id", team_object_permission_id=None, ) - with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma): + with patch(f"{_PROXY_SERVER}.prisma_client", MagicMock()), \ + patch(f"{_PROXY_SERVER}.proxy_logging_obj", MagicMock()), \ + patch(f"{_PROXY_SERVER}.user_api_key_cache", MagicMock()), \ + patch(_GET_OBJ_PERM, AsyncMock(return_value=mock_perm)): with pytest.raises(ProxyException) as exc_info: await search_tool_access_check( search_tool_name="any-tool", @@ -236,18 +241,17 @@ async def test_should_deny_key_with_empty_search_tools(): @pytest.mark.asyncio async def test_should_deny_team_with_empty_search_tools(): """Team with empty search_tools → deny.""" - mock_prisma = MagicMock() mock_team_perm = MagicMock() mock_team_perm.search_tools = [] - mock_prisma.db.litellm_objectpermissiontable.find_unique = AsyncMock( - return_value=mock_team_perm - ) token = UserAPIKeyAuth( object_permission_id=None, team_object_permission_id="team-perm-id", ) - with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma): + with patch(f"{_PROXY_SERVER}.prisma_client", MagicMock()), \ + patch(f"{_PROXY_SERVER}.proxy_logging_obj", MagicMock()), \ + patch(f"{_PROXY_SERVER}.user_api_key_cache", MagicMock()), \ + patch(_GET_OBJ_PERM, AsyncMock(return_value=mock_team_perm)): with pytest.raises(ProxyException) as exc_info: await search_tool_access_check( search_tool_name="any-tool", @@ -259,30 +263,27 @@ async def test_should_deny_team_with_empty_search_tools(): @pytest.mark.asyncio async def test_should_deny_when_key_allows_but_team_denies(): """Key allows, team denies → deny (team check second).""" - mock_prisma = MagicMock() - key_perm = MagicMock() key_perm.search_tools = ["the-tool"] team_perm = MagicMock() team_perm.search_tools = [] - async def mock_find(where): - if where["object_permission_id"] == "key-perm-id": + async def mock_get_perm(object_permission_id, **kwargs): + if object_permission_id == "key-perm-id": return key_perm - elif where["object_permission_id"] == "team-perm-id": + elif object_permission_id == "team-perm-id": return team_perm return None - mock_prisma.db.litellm_objectpermissiontable.find_unique = AsyncMock( - side_effect=mock_find - ) - token = UserAPIKeyAuth( object_permission_id="key-perm-id", team_object_permission_id="team-perm-id", ) - with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma): + with patch(f"{_PROXY_SERVER}.prisma_client", MagicMock()), \ + patch(f"{_PROXY_SERVER}.proxy_logging_obj", MagicMock()), \ + patch(f"{_PROXY_SERVER}.user_api_key_cache", MagicMock()), \ + patch(_GET_OBJ_PERM, AsyncMock(side_effect=mock_get_perm)): with pytest.raises(ProxyException) as exc_info: await search_tool_access_check( search_tool_name="the-tool", @@ -294,30 +295,27 @@ async def test_should_deny_when_key_allows_but_team_denies(): @pytest.mark.asyncio async def test_should_allow_when_both_key_and_team_allow(): """Both key and team allow → allow.""" - mock_prisma = MagicMock() - key_perm = MagicMock() key_perm.search_tools = ["tool-x"] team_perm = MagicMock() team_perm.search_tools = ["tool-x", "tool-y"] - async def mock_find(where): - if where["object_permission_id"] == "key-perm-id": + async def mock_get_perm(object_permission_id, **kwargs): + if object_permission_id == "key-perm-id": return key_perm - elif where["object_permission_id"] == "team-perm-id": + elif object_permission_id == "team-perm-id": return team_perm return None - mock_prisma.db.litellm_objectpermissiontable.find_unique = AsyncMock( - side_effect=mock_find - ) - token = UserAPIKeyAuth( object_permission_id="key-perm-id", team_object_permission_id="team-perm-id", ) - with patch("litellm.proxy.proxy_server.prisma_client", mock_prisma): + with patch(f"{_PROXY_SERVER}.prisma_client", MagicMock()), \ + patch(f"{_PROXY_SERVER}.proxy_logging_obj", MagicMock()), \ + patch(f"{_PROXY_SERVER}.user_api_key_cache", MagicMock()), \ + patch(_GET_OBJ_PERM, AsyncMock(side_effect=mock_get_perm)): result = await search_tool_access_check( search_tool_name="tool-x", valid_token=token, @@ -378,18 +376,18 @@ class TestSearchToolErrorTypes: class TestVectorStoreAccessNotBroken: - """should preserve existing vector store semantics: empty list = allow ALL.""" + """Vector store access follows same semantics: None = all access, [] = no access.""" - def test_should_allow_all_when_vector_stores_is_empty(self): - """Vector stores: empty list = access to ALL (existing behavior).""" + def test_should_deny_all_when_vector_stores_is_empty(self): + """Vector stores: empty list = no access (matches search_tools semantics).""" perm = MagicMock() perm.vector_stores = [] - result = _can_object_call_vector_stores( - object_type="key", - vector_store_ids_to_run=["store-1"], - object_permissions=perm, - ) - assert result is True + with pytest.raises(ProxyException): + _can_object_call_vector_stores( + object_type="key", + vector_store_ids_to_run=["store-1"], + object_permissions=perm, + ) def test_should_allow_when_vector_stores_is_none(self): perm = MagicMock() From 9019207111c0c8b68d764d7206425ff5d24cb18a Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Sat, 21 Mar 2026 09:42:49 -0700 Subject: [PATCH 7/9] fix: revert out-of-scope changes, fix remaining review feedback - Revert MCP management endpoint changes: team-scoped CRUD depends on check_member_permission, add_mcp_server_to_team, remove_mcp_server_from_team which exist only in the base branch, not main. Importing them crashes all MCP endpoints on main. - Revert ObjectPermissionTable default changes for pre-existing fields (mcp_servers, mcp_access_groups, vector_stores, agents, agent_access_groups) back to [] to avoid backwards-incompatible behavior change. Only search_tools (new field) uses None default. - Restore vector store access check: [] = allow all (existing behavior) - Restore vector store test assertion - Revert common_utils.py inline import changes (infrastructure not in scope) - Add ValueError fallback to get_search_tool_access_error_type_for_object for unrecognized object_type values Co-Authored-By: Claude Opus 4.6 (1M context) --- litellm/proxy/_types.py | 15 +- litellm/proxy/auth/auth_checks.py | 11 +- .../management_endpoints/common_utils.py | 12 +- .../mcp_management_endpoints.py | 191 ++++-------------- .../proxy/auth/test_search_tool_access.py | 19 +- 5 files changed, 68 insertions(+), 180 deletions(-) diff --git a/litellm/proxy/_types.py b/litellm/proxy/_types.py index 07d4859220c..9a7457b829b 100644 --- a/litellm/proxy/_types.py +++ b/litellm/proxy/_types.py @@ -1831,8 +1831,8 @@ class LiteLLM_ObjectPermissionTable(LiteLLMPydanticObjectBase): """Represents a LiteLLM_ObjectPermissionTable record""" object_permission_id: str - mcp_servers: Optional[List[str]] = None - mcp_access_groups: Optional[List[str]] = None + mcp_servers: Optional[List[str]] = [] + mcp_access_groups: Optional[List[str]] = [] mcp_tool_permissions: Optional[Dict[str, List[str]]] = None """ Mapping - server_id -> list of tools @@ -1843,10 +1843,10 @@ class LiteLLM_ObjectPermissionTable(LiteLLMPydanticObjectBase): } """ - vector_stores: Optional[List[str]] = None - search_tools: Optional[List[str]] = None - agents: Optional[List[str]] = None - agent_access_groups: Optional[List[str]] = None + vector_stores: Optional[List[str]] = [] + search_tools: Optional[List[str]] = None # NULL = all access, [] = no access + agents: Optional[List[str]] = [] + agent_access_groups: Optional[List[str]] = [] class LiteLLM_TeamTable(TeamBase): @@ -3599,6 +3599,9 @@ class ProxyErrorTypes(str, enum.Enum): return cls.team_search_tool_access_denied elif object_type == "org": return cls.org_search_tool_access_denied + raise ValueError( + f"Unknown object_type '{object_type}' for search tool access error" + ) DB_CONNECTION_ERROR_TYPES = ( diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 06c6ddd714b..1b7bd645a14 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -3543,16 +3543,9 @@ def _can_object_call_vector_stores( if object_permissions.vector_stores is None: return True - # Empty list = no access to any vector stores + # If length is 0, then the object has access to all vector stores. if len(object_permissions.vector_stores) == 0: - raise ProxyException( - message="User not allowed to access any vector stores. No vector stores are configured in object permissions.", - type=ProxyErrorTypes.get_vector_store_access_error_type_for_object( - object_type - ), - param="vector_store", - code=status.HTTP_401_UNAUTHORIZED, - ) + return True for vector_store_id in vector_store_ids_to_run: if vector_store_id not in object_permissions.vector_stores: diff --git a/litellm/proxy/management_endpoints/common_utils.py b/litellm/proxy/management_endpoints/common_utils.py index 06115993245..efc42d3355c 100644 --- a/litellm/proxy/management_endpoints/common_utils.py +++ b/litellm/proxy/management_endpoints/common_utils.py @@ -15,7 +15,6 @@ from litellm.proxy._types import ( UpdateProjectRequest, UserAPIKeyAuth, ) -from litellm.proxy.auth.auth_checks import get_user_object from litellm.proxy.utils import _premium_user_check if TYPE_CHECKING: @@ -55,6 +54,7 @@ async def _is_user_org_admin_for_team( if not team_obj.organization_id or not user_api_key_dict.user_id: return False + from litellm.proxy.auth.auth_checks import get_user_object from litellm.proxy.proxy_server import ( prisma_client, proxy_logging_obj, @@ -124,11 +124,14 @@ async def _user_has_admin_privileges( return False # Get user object to check team and org admin status + from litellm.caching import DualCache as DualCacheImport + from litellm.proxy.auth.auth_checks import get_user_object + try: user_obj = await get_user_object( user_id=user_api_key_dict.user_id, prisma_client=prisma_client, - user_api_key_cache=user_api_key_cache or DualCache(), + user_api_key_cache=user_api_key_cache or DualCacheImport(), user_id_upsert=False, proxy_logging_obj=proxy_logging_obj, ) @@ -270,8 +273,11 @@ async def admin_can_invite_user( if prisma_client is None or user_api_key_dict.user_id is None: return False + from litellm.caching import DualCache as DualCacheImport + from litellm.proxy.auth.auth_checks import get_user_object + try: - cache = user_api_key_cache or DualCache() + cache = user_api_key_cache or DualCacheImport() admin_user_obj = await get_user_object( user_id=user_api_key_dict.user_id, prisma_client=prisma_client, diff --git a/litellm/proxy/management_endpoints/mcp_management_endpoints.py b/litellm/proxy/management_endpoints/mcp_management_endpoints.py index 0080e5b7887..3e5b729cea6 100644 --- a/litellm/proxy/management_endpoints/mcp_management_endpoints.py +++ b/litellm/proxy/management_endpoints/mcp_management_endpoints.py @@ -130,15 +130,7 @@ if MCP_AVAILABLE: ) from litellm.proxy.auth.user_api_key_auth import user_api_key_auth from litellm.proxy.common_utils.http_parsing_utils import _read_request_body - from litellm.proxy.management_endpoints.common_utils import ( - _user_has_admin_view, - check_member_permission, - ) - from litellm.proxy.management_helpers.object_permission_utils import ( - _get_team_allowed_mcp_servers, - add_mcp_server_to_team, - remove_mcp_server_from_team, - ) + from litellm.proxy.management_endpoints.common_utils import _user_has_admin_view from litellm.proxy.management_helpers.utils import management_endpoint_wrapper from litellm.types.mcp import MCPCredentials from litellm.types.mcp_server.mcp_server_manager import MCPServer @@ -1214,50 +1206,15 @@ if MCP_AVAILABLE: # Validate and normalize payload fields validate_and_normalize_mcp_server_payload(payload) - # AuthZ - proxy admins, team admins, or members with mcp:create permission - from litellm.proxy.auth.auth_checks import get_team_object - from litellm.proxy.proxy_server import user_api_key_cache - - team_obj = None - team_id = payload.team_id or user_api_key_dict.team_id - + # AuthZ - restrict only proxy admins to create mcp servers if LitellmUserRoles.PROXY_ADMIN != user_api_key_dict.user_role: - if not team_id: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail={ - "error": "team_id is required for non-proxy-admin users to create MCP servers." - }, - ) - team_obj = await get_team_object( - team_id=team_id, - prisma_client=prisma_client, - user_api_key_cache=user_api_key_cache, - ) - if not check_member_permission( - user_api_key_dict, team_obj, "mcp:create" - ): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail={ - "error": "User does not have permission to create MCP servers for this team. " - "Requires team admin role or 'mcp:create' permission." - }, - ) - - # Reject special reserved server names - if ( - SpecialMCPServerName.all_team_servers == payload.server_id - or SpecialMCPServerName.all_proxy_servers == payload.server_id - ): raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, + status_code=status.HTTP_403_FORBIDDEN, detail={ - "error": f"MCP Server with id {payload.server_id} is special and cannot be used." + "error": "User does not have permission to create mcp servers. You can only create mcp servers if you are a PROXY_ADMIN." }, ) - - if payload.server_id is not None: + elif payload.server_id is not None: # fail if the mcp server with id already exists mcp_server = await get_mcp_server(prisma_client, payload.server_id) if mcp_server is not None: @@ -1267,6 +1224,16 @@ if MCP_AVAILABLE: "error": f"MCP Server with id {payload.server_id} already exists. Cannot create another." }, ) + elif ( + SpecialMCPServerName.all_team_servers == payload.server_id + or SpecialMCPServerName.all_proxy_servers == payload.server_id + ): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail={ + "error": f"MCP Server with id {payload.server_id} is special and cannot be used." + }, + ) # TODO: audit log for create @@ -1294,20 +1261,6 @@ if MCP_AVAILABLE: status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail={"error": f"Error creating mcp server: {str(e)}"}, ) - - # Auto-assign server to team's ObjectPermissionTable if team-scoped - if team_id and new_mcp_server.server_id: - try: - await add_mcp_server_to_team( - prisma_client, team_id, new_mcp_server.server_id - ) - except ValueError as e: - # Team not found — surface as 400 so caller knows - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail={"error": str(e)}, - ) - return _redact_mcp_credentials(new_mcp_server) @router.post( @@ -1492,7 +1445,7 @@ if MCP_AVAILABLE: @router.delete( "/server/{server_id}", - description="Allows deleting mcp servers in the db", + description="Allows deleting mcp serves in the db", dependencies=[Depends(user_api_key_auth)], response_class=JSONResponse, status_code=status.HTTP_202_ACCEPTED, @@ -1520,44 +1473,16 @@ if MCP_AVAILABLE: "Database not connected. Connect a database to your proxy - https://docs.litellm.ai/docs/simple_proxy#managing-auth---virtual-keys" ) - # AuthZ - proxy admins, team admins, or members with mcp:delete permission - from litellm.proxy.auth.auth_checks import get_team_object - from litellm.proxy.proxy_server import user_api_key_cache - - team_id = user_api_key_dict.team_id - + # Authz - restrict only admins to delete mcp servers if LitellmUserRoles.PROXY_ADMIN != user_api_key_dict.user_role: - if not team_id: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail={ - "error": "team_id is required for non-proxy-admin users to delete MCP servers." - }, - ) - team_obj = await get_team_object( - team_id=team_id, - prisma_client=prisma_client, - user_api_key_cache=user_api_key_cache, + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={ + "error": "Call not allowed to delete MCP server. User is not a proxy admin. route={}".format( + "DELETE /v1/mcp/server" + ) + }, ) - if not check_member_permission( - user_api_key_dict, team_obj, "mcp:delete" - ): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail={ - "error": "User does not have permission to delete MCP servers for this team. " - "Requires team admin role or 'mcp:delete' permission." - }, - ) - # Verify server belongs to this team - team_servers = await _get_team_allowed_mcp_servers(team_obj) - if server_id not in team_servers: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail={ - "error": f"MCP Server {server_id} does not belong to your team." - }, - ) # try to delete the mcp server mcp_server_record_deleted = await delete_mcp_server(prisma_client, server_id) @@ -1572,20 +1497,16 @@ if MCP_AVAILABLE: # Ensure registry is up to date by reloading from database await global_mcp_server_manager.reload_servers_from_database() - # Remove server from team's ObjectPermissionTable - if team_id: - try: - await remove_mcp_server_from_team(prisma_client, team_id, server_id) - except Exception as e: - verbose_proxy_logger.warning( - f"Failed to remove server {server_id} from team {team_id} permissions: {e}. " - "Server was deleted but team's ObjectPermissionTable may contain a stale entry." - ) - # TODO: Enterprise: Finish audit log trail if litellm.store_audit_logs: pass + # TODO: Delete from virtual keys + + # TODO: Delete from teams + + # Update from global mcp store + return Response(status_code=status.HTTP_202_ACCEPTED) @router.post( @@ -1846,7 +1767,7 @@ if MCP_AVAILABLE: @router.put( "/server", - description="Allows updating mcp servers in the db", + description="Allows deleting mcp serves in the db", dependencies=[Depends(user_api_key_auth)], response_model=LiteLLM_MCPServerTable, status_code=status.HTTP_202_ACCEPTED, @@ -1877,50 +1798,16 @@ if MCP_AVAILABLE: # Validate and normalize payload fields validate_and_normalize_mcp_server_payload(payload) - # AuthZ - proxy admins, team admins, or members with mcp:update permission - from litellm.proxy.auth.auth_checks import get_team_object - from litellm.proxy.proxy_server import user_api_key_cache - + # Authz - restrict only admins to delete mcp servers if LitellmUserRoles.PROXY_ADMIN != user_api_key_dict.user_role: - team_id = user_api_key_dict.team_id - if not team_id: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail={ - "error": "team_id is required for non-proxy-admin users to update MCP servers." - }, - ) - team_obj = await get_team_object( - team_id=team_id, - prisma_client=prisma_client, - user_api_key_cache=user_api_key_cache, + raise HTTPException( + status_code=status.HTTP_403_FORBIDDEN, + detail={ + "error": "Call not allowed to update MCP server. User is not a proxy admin. route={}".format( + "PUT /v1/mcp/server" + ) + }, ) - if not check_member_permission( - user_api_key_dict, team_obj, "mcp:update" - ): - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail={ - "error": "User does not have permission to update MCP servers for this team. " - "Requires team admin role or 'mcp:update' permission." - }, - ) - # Verify server_id is provided and belongs to this team - if payload.server_id is None: - raise HTTPException( - status_code=status.HTTP_400_BAD_REQUEST, - detail={ - "error": "server_id is required to update an MCP server." - }, - ) - team_servers = await _get_team_allowed_mcp_servers(team_obj) - if payload.server_id not in team_servers: - raise HTTPException( - status_code=status.HTTP_403_FORBIDDEN, - detail={ - "error": f"MCP Server {payload.server_id} does not belong to your team." - }, - ) # try to update the mcp server mcp_server_record_updated = await update_mcp_server( diff --git a/tests/test_litellm/proxy/auth/test_search_tool_access.py b/tests/test_litellm/proxy/auth/test_search_tool_access.py index 06797760c8d..e915a9b3ba0 100644 --- a/tests/test_litellm/proxy/auth/test_search_tool_access.py +++ b/tests/test_litellm/proxy/auth/test_search_tool_access.py @@ -13,7 +13,6 @@ from typing import List, Optional from unittest.mock import AsyncMock, MagicMock, patch import pytest -from starlette import status from litellm.proxy._types import ( LiteLLM_ObjectPermissionTable, @@ -376,18 +375,18 @@ class TestSearchToolErrorTypes: class TestVectorStoreAccessNotBroken: - """Vector store access follows same semantics: None = all access, [] = no access.""" + """Preserves existing vector store semantics: empty list = allow ALL.""" - def test_should_deny_all_when_vector_stores_is_empty(self): - """Vector stores: empty list = no access (matches search_tools semantics).""" + def test_should_allow_all_when_vector_stores_is_empty(self): + """Vector stores: empty list = access to ALL (existing behavior).""" perm = MagicMock() perm.vector_stores = [] - with pytest.raises(ProxyException): - _can_object_call_vector_stores( - object_type="key", - vector_store_ids_to_run=["store-1"], - object_permissions=perm, - ) + result = _can_object_call_vector_stores( + object_type="key", + vector_store_ids_to_run=["store-1"], + object_permissions=perm, + ) + assert result is True def test_should_allow_when_vector_stores_is_none(self): perm = MagicMock() From 2c4be6f5c769523661173da51ef8ba8228a95d9e Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Sat, 21 Mar 2026 10:02:47 -0700 Subject: [PATCH 8/9] fix: nullable schema, remove dead code, add return type annotation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Make search_tools String[]? (nullable) in all 3 schema.prisma files so Prisma preserves NULL from DB instead of coercing to [] - Remove get_permitted_search_tool_names (dead code — never called from any production path) and its tests - Add -> bool return type annotation to _can_object_call_search_tools Co-Authored-By: Claude Opus 4.6 (1M context) --- .../litellm_proxy_extras/schema.prisma | 2 +- litellm/proxy/auth/auth_checks.py | 18 +------------- litellm/proxy/schema.prisma | 2 +- schema.prisma | 2 +- .../proxy/auth/test_search_tool_access.py | 24 ------------------- 5 files changed, 4 insertions(+), 44 deletions(-) diff --git a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma index 4fbbca07793..fae9ce96ce6 100644 --- a/litellm-proxy-extras/litellm_proxy_extras/schema.prisma +++ b/litellm-proxy-extras/litellm_proxy_extras/schema.prisma @@ -269,7 +269,7 @@ model LiteLLM_ObjectPermissionTable { mcp_access_groups String[] @default([]) mcp_tool_permissions Json? // Tool-level permissions for MCP servers. Format: {"server_id": ["tool_name_1", "tool_name_2"]} vector_stores String[] @default([]) - search_tools String[] // Search tool names allowed for this key/team/org. NULL = all access, empty = no access. + search_tools String[]? // Search tool names allowed for this key/team/org. NULL = all access, empty = no access. agents String[] @default([]) agent_access_groups String[] @default([]) models String[] @default([]) diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 1b7bd645a14..6e40e5fca1d 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -3565,7 +3565,7 @@ def _can_object_call_search_tools( object_type: Literal["key", "team", "org"], search_tool_name: str, object_permissions: Optional[LiteLLM_ObjectPermissionTable], -): +) -> bool: """ Raises ProxyException if the object (key, team, org) cannot access the specific search tool. @@ -3660,19 +3660,3 @@ async def search_tool_access_check( ) return True - - -def get_permitted_search_tool_names( - object_permissions: Optional[LiteLLM_ObjectPermissionTable], -) -> Optional[List[str]]: - """ - Returns the list of permitted search tool names for the given object permissions. - - Returns: - None → no restriction (all tools accessible) - [] → no tools accessible - ["a", "b"] → only those tools accessible - """ - if object_permissions is None: - return None - return object_permissions.search_tools diff --git a/litellm/proxy/schema.prisma b/litellm/proxy/schema.prisma index 414ae23dad5..26e429fdbf7 100644 --- a/litellm/proxy/schema.prisma +++ b/litellm/proxy/schema.prisma @@ -269,7 +269,7 @@ model LiteLLM_ObjectPermissionTable { mcp_access_groups String[] @default([]) mcp_tool_permissions Json? // Tool-level permissions for MCP servers. Format: {"server_id": ["tool_name_1", "tool_name_2"]} vector_stores String[] @default([]) - search_tools String[] // Search tool names allowed for this key/team/org. NULL = all access, empty = no access. + search_tools String[]? // Search tool names allowed for this key/team/org. NULL = all access, empty = no access. agents String[] @default([]) agent_access_groups String[] @default([]) models String[] @default([]) diff --git a/schema.prisma b/schema.prisma index 7b782ada24b..dcbae8a9cff 100644 --- a/schema.prisma +++ b/schema.prisma @@ -269,7 +269,7 @@ model LiteLLM_ObjectPermissionTable { mcp_access_groups String[] @default([]) mcp_tool_permissions Json? // Tool-level permissions for MCP servers. Format: {"server_id": ["tool_name_1", "tool_name_2"]} vector_stores String[] @default([]) - search_tools String[] // Search tool names allowed for this key/team/org. NULL = all access, empty = no access. + search_tools String[]? // Search tool names allowed for this key/team/org. NULL = all access, empty = no access. agents String[] @default([]) agent_access_groups String[] @default([]) models String[] @default([]) diff --git a/tests/test_litellm/proxy/auth/test_search_tool_access.py b/tests/test_litellm/proxy/auth/test_search_tool_access.py index e915a9b3ba0..0ce5f6fd559 100644 --- a/tests/test_litellm/proxy/auth/test_search_tool_access.py +++ b/tests/test_litellm/proxy/auth/test_search_tool_access.py @@ -4,7 +4,6 @@ Tests for search tool access control. Covers: - _can_object_call_search_tools() with least-privilege semantics - search_tool_access_check() for key and team level permissions -- get_permitted_search_tool_names() helper - ProxyErrorTypes for search tool access denied - Ensures vector store access check semantics are unchanged """ @@ -23,7 +22,6 @@ from litellm.proxy._types import ( from litellm.proxy.auth.auth_checks import ( _can_object_call_search_tools, _can_object_call_vector_stores, - get_permitted_search_tool_names, search_tool_access_check, ) @@ -322,28 +320,6 @@ async def test_should_allow_when_both_key_and_team_allow(): assert result is True -# =========================================================================== -# get_permitted_search_tool_names -# =========================================================================== - - -class TestGetPermittedSearchToolNames: - def test_should_return_none_for_none_permissions(self): - assert get_permitted_search_tool_names(None) is None - - def test_should_return_none_when_search_tools_is_none(self): - perm = _make_object_permission(search_tools=None) - assert get_permitted_search_tool_names(perm) is None - - def test_should_return_empty_list_for_empty_search_tools(self): - perm = _make_object_permission(search_tools=[]) - assert get_permitted_search_tool_names(perm) == [] - - def test_should_return_specific_tools(self): - perm = _make_object_permission(search_tools=["a", "b"]) - assert get_permitted_search_tool_names(perm) == ["a", "b"] - - # =========================================================================== # ProxyErrorTypes classmethod # =========================================================================== From 9051bf1e63068fa3ea4afee3726ccd9c0c72fd8b Mon Sep 17 00:00:00 2001 From: yuneng-jiang Date: Sat, 21 Mar 2026 10:07:15 -0700 Subject: [PATCH 9/9] chore: remove migration file (will be created via standard process) Co-Authored-By: Claude Opus 4.6 (1M context) --- .../migration.sql | 2 -- 1 file changed, 2 deletions(-) delete mode 100644 litellm-proxy-extras/litellm_proxy_extras/migrations/20260319000000_add_search_tools_to_object_permissions/migration.sql diff --git a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260319000000_add_search_tools_to_object_permissions/migration.sql b/litellm-proxy-extras/litellm_proxy_extras/migrations/20260319000000_add_search_tools_to_object_permissions/migration.sql deleted file mode 100644 index b01d8c106c1..00000000000 --- a/litellm-proxy-extras/litellm_proxy_extras/migrations/20260319000000_add_search_tools_to_object_permissions/migration.sql +++ /dev/null @@ -1,2 +0,0 @@ --- AlterTable -ALTER TABLE "LiteLLM_ObjectPermissionTable" ADD COLUMN "search_tools" TEXT[];