[Feature] Search Tools: Add access control via object permissions#24075
Conversation
- 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 <[email protected]>
- 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 <[email protected]>
- 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 <[email protected]>
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
|
- 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 <[email protected]>
…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) <[email protected]>
|
@greptileai review |
Greptile SummaryThis PR adds access control for search tool endpoints ( Key observations:
Confidence Score: 3/5
|
| Filename | Overview |
|---|---|
| litellm/proxy/schema.prisma | Adds search_tools String[]? — correct nullable field with no default — but no migration SQL file is present in the PR to apply this to existing databases. |
| litellm-proxy-extras/litellm_proxy_extras/schema.prisma | Same search_tools String[]? column addition as litellm/proxy/schema.prisma; shares the same missing-migration concern. |
| schema.prisma | Root schema also adds search_tools String[]?; no migration included here either. |
| litellm/proxy/_types.py | Adds search_tools to both LiteLLM_ObjectPermissionBase (input, None default) and LiteLLM_ObjectPermissionTable (DB output, None default = all access). Adds three ProxyErrorTypes enum members and a get_search_tool_access_error_type_for_object classmethod with a defensive raise ValueError fallback. All existing field defaults are unchanged. |
| litellm/proxy/auth/auth_checks.py | Adds _can_object_call_search_tools (correct least-privilege semantics, -> bool: annotated) and search_tool_access_check (correctly uses cached get_object_permission helper for both key and team levels). Missing return type annotation on search_tool_access_check. |
| litellm/proxy/search_endpoints/endpoints.py | Adds _get_allowed_search_tool_names (uses cached get_object_permission, computes intersection of key and team allowed lists), enforces 400 when search_tool_name is absent, and filters GET /v1/search/tools results by permission. Logic is consistent with search_tool_access_check. |
| tests/test_litellm/proxy/auth/test_search_tool_access.py | New test file covering all access-control branches (None, empty, specific, denied) for both key and team levels, error type resolution, and a vector store regression suite. All tests use mocks only — no real network calls. |
Flowchart
%%{init: {'theme': 'neutral'}}%%
flowchart TD
A["POST /v1/search/{search_tool_name}"] --> B{resolved_search_tool_name?}
B -- No --> C["HTTP 400: search_tool_name required"]
B -- Yes --> D["search_tool_access_check()"]
D --> E{prisma_client?}
E -- None --> F["return True (allow)"]
E -- Present --> G{object_permission_id on token?}
G -- Yes --> H["get_object_permission (cached)"]
H --> I["_can_object_call_search_tools(key)"]
I --> J{search_tools?}
J -- None --> K["allow"]
J -- empty list --> L["raise ProxyException (401)"]
J -- list --> M{tool in list?}
M -- Yes --> K
M -- No --> L
G -- No / after key check --> N{team_object_permission_id?}
N -- Yes --> O["get_object_permission (cached)"]
O --> P["_can_object_call_search_tools(team)"]
P --> J
N -- No --> Q["return True (allow)"]
R["GET /v1/search/tools"] --> S["_get_allowed_search_tool_names()"]
S --> T{key + team perms}
T -- both None --> U["return None (no filter)"]
T -- one set --> V["return that set"]
T -- both set --> W["return intersection"]
U --> X["show all tools"]
V --> Y["filter tool list"]
W --> Y
Last reviewed commit: "chore: remove migrat..."
| 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 | ||
|
|
There was a problem hiding this comment.
Incorrect test assertion — this test will fail
The test comment says "Vector stores: empty list = access to ALL (existing behavior)" and asserts result is True, but the actual implementation of _can_object_call_vector_stores (lines 3546-3555 of auth_checks.py) explicitly DENIES when vector_stores is an empty list:
# Empty list = no access to any vector stores
if len(object_permissions.vector_stores) == 0:
raise ProxyException(...)With perm.vector_stores = [], len([]) == 0 is True, so the function raises ProxyException. The assertion assert result is True will never be reached. The test should instead use pytest.raises(ProxyException) — matching the actual semantics that both vector stores and search tools share: None → allow, [] → DENY, non-empty list → check against list.
The comment and test are also inconsistent with the PR description, which claims "unlike vector stores where empty means all access." The existing code already denies on empty vector store lists.
| 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_deny_when_vector_stores_is_empty(self): | |
| """Vector stores: empty list = DENY (no access configured).""" | |
| perm = MagicMock() | |
| perm.vector_stores = [] | |
| with pytest.raises(ProxyException) as exc_info: | |
| _can_object_call_vector_stores( | |
| object_type="key", | |
| vector_store_ids_to_run=["store-1"], | |
| object_permissions=perm, | |
| ) | |
| assert exc_info.value.type == ProxyErrorTypes.key_vector_store_access_denied |
| 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)) |
There was a problem hiding this comment.
Direct DB queries in critical request path — use
get_object_permission with caching
_get_allowed_search_tool_names makes direct prisma_client.db.litellm_objectpermissiontable.find_unique calls (up to 2 per GET /v1/search/tools request) without going through the cached get_object_permission helper in auth_checks.py. This violates the project rule against direct DB queries in the critical path and adds unnecessary database load.
Additionally, the permission logic is duplicated from search_tool_access_check in auth_checks.py. For the list_search_tools endpoint, the same permissions are looked up via a completely separate code path. Consider reusing get_object_permission (from litellm.proxy.auth.auth_checks) and the get_permitted_search_tool_names helper that already exists in auth_checks.py to avoid the duplication.
Rule Used: What: In critical path of request, there should be... (source)
| """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 | ||
|
|
||
| Enforces allowed tools for a specific key/team/organization | ||
| { | ||
| "1234567890": ["tool_name_1", "tool_name_2"] | ||
| } | ||
| """ | ||
|
|
||
| vector_stores: 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): |
There was a problem hiding this comment.
Backwards-incompatible default change for
LiteLLM_ObjectPermissionTable fields
This PR changes the defaults of five pre-existing fields:
# Before
mcp_servers: Optional[List[str]] = []
mcp_access_groups: Optional[List[str]] = []
vector_stores: Optional[List[str]] = []
agents: Optional[List[str]] = []
agent_access_groups: Optional[List[str]] = []
# After
mcp_servers: Optional[List[str]] = None
mcp_access_groups: Optional[List[str]] = None
vector_stores: Optional[List[str]] = None
agents: Optional[List[str]] = None
agent_access_groups: Optional[List[str]] = NoneAny code that constructs a LiteLLM_ObjectPermissionTable without these fields will now receive None instead of []. Callers that previously iterated these fields safely (e.g., for s in obj.mcp_servers:) without a None check will now raise TypeError: 'NoneType' object is not iterable. This is particularly risky for the mcp_servers and mcp_access_groups fields, which have access-control checks that behave differently for None vs [].
If this default change is intentional to unify the semantics, all downstream code should be audited for None-safety before merging.
Rule Used: What: avoid backwards-incompatible changes without... (source)
| 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 |
There was a problem hiding this comment.
Implicit
None return when object_type is unexpected
get_search_tool_access_error_type_for_object has no else/fallback branch. If object_type is anything other than "key", "team", or "org", it returns None implicitly. This None is then passed as type=None to ProxyException.__init__, which would likely cause a downstream TypeError or silently drop the error type.
Although the parameter is typed as Literal["key", "team", "org"], a defensive fallback matches the pattern of the sibling get_vector_store_access_error_type_for_object method and prevents silent failures:
| 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 | |
| @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 | |
| else: | |
| return cls.org_search_tool_access_denied |
| 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, | ||
| ) |
There was a problem hiding this comment.
Direct DB queries bypass caching on every search request
search_tool_access_check makes raw prisma_client.db.litellm_objectpermissiontable.find_unique calls (up to 2 per request) instead of going through the existing get_object_permission helper (line 2235), which is backed by DualCache and caches results under "object_permission_id:{id}". This means every /v1/search/* call unconditionally hits the database twice for permission lookups, bypassing the cache.
The get_object_permission helper should be used here instead — consistent with how agent_permission_handler.py and user_api_key_auth_mcp.py resolve object permissions. The helper requires a user_api_key_cache argument, which can be imported from litellm.proxy.proxy_server.
Note: the identical issue exists in the pre-existing vector_store_access_check function (lines 3503–3521), but that's outside this PR's scope.
Rule Used: What: Avoid creating new database requests or Rout... (source)
| @@ -0,0 +1,2 @@ | |||
| -- AlterTable | |||
| ALTER TABLE "LiteLLM_ObjectPermissionTable" ADD COLUMN "search_tools" TEXT[] DEFAULT ARRAY[]::TEXT[]; | |||
There was a problem hiding this comment.
Migration default backfills
[] — silently denies search tools for all existing records
The migration adds the column with DEFAULT ARRAY[]::TEXT[]. In PostgreSQL, ADD COLUMN ... DEFAULT backfills all existing rows with the supplied default, so every current LiteLLM_ObjectPermissionTable record will immediately have search_tools = [] after this migration runs.
The Python access check in _can_object_call_search_tools treats [] as "deny all" (principle of least privilege). This means any team or API key that already has an ObjectPermissionTable record (e.g. for MCP or vector-store access control) will be silently denied access to every search tool the moment they upgrade, even though they never configured search_tools at all. This contradicts the PR description's backward-compatibility promise: "None (field not set / no object_permission) → No restriction".
The safe fix is to default to NULL, so the column is absent from existing rows and the Python None-means-allow path is taken:
| ALTER TABLE "LiteLLM_ObjectPermissionTable" ADD COLUMN "search_tools" TEXT[] DEFAULT ARRAY[]::TEXT[]; | |
| -- AlterTable | |
| ALTER TABLE "LiteLLM_ObjectPermissionTable" ADD COLUMN "search_tools" TEXT[]; |
The matching Prisma schema lines in all three schema.prisma files should also drop the @default([]) annotation for this field so that new rows also get NULL unless explicitly set.
| # 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, | ||
| ) |
There was a problem hiding this comment.
Access check skipped when
search_tool_name is absent — unrestricted search possible
The access check at line 222 is guarded by if resolved_search_tool_name:, which means requests that do not specify a search_tool_name (or pass an empty string) bypass the permission check entirely and proceed to the underlying LLM routing. If the router can still route the request without a tool name (e.g. via a default model), the caller effectively gets unrestricted search access regardless of their object_permission.
Consider either (a) enforcing that search_tool_name is always required, or (b) performing a broader "has any search tool access" check when no specific tool is named.
| # 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 |
There was a problem hiding this comment.
Direct DB queries on every search request — violates no-raw-DB-calls-in-critical-path rule
search_tool_access_check makes up to two raw prisma_client.db.litellm_objectpermissiontable.find_unique calls per /v1/search/* request instead of going through the existing get_object_permission helper (backed by DualCache). This adds two uncached DB round-trips to every search request.
The project rule requires that all permission lookups in the critical path use the cached helper functions (get_team, get_user, get_key, get_object_permission) rather than raw Prisma calls. The identical raw-call pattern exists in the pre-existing vector_store_access_check (lines 3503–3521), but this PR introduces the same anti-pattern again.
Use get_object_permission (importable from litellm.proxy.auth.auth_checks) and pass user_api_key_cache from litellm.proxy.proxy_server as the cache argument, consistent with how agent_permission_handler.py and user_api_key_auth_mcp.py resolve object permissions.
This same issue exists in _get_allowed_search_tool_names in litellm/proxy/search_endpoints/endpoints.py (lines 36–54), which also makes two raw DB calls per GET /v1/search/tools request.
Rule Used: What: In critical path of request, there should be... (source)
| 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 |
There was a problem hiding this comment.
Incorrect test assertion —
_can_object_call_vector_stores with [] raises, not returns True
The test comment reads "Vector stores: empty list = access to ALL (existing behavior)" and asserts result is True, but the actual implementation of _can_object_call_vector_stores (lines 3546–3555 of auth_checks.py) explicitly raises when vector_stores is an empty list:
if len(object_permissions.vector_stores) == 0:
raise ProxyException(...)So this test will fail at runtime: the call raises ProxyException before the assert is reached. The test should use pytest.raises:
| 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_all_when_vector_stores_is_empty(self): | |
| """Vector stores: empty list = DENY ALL (matches search_tools semantics).""" | |
| perm = MagicMock() | |
| perm.vector_stores = [] | |
| with pytest.raises(ProxyException) as exc_info: | |
| _can_object_call_vector_stores( | |
| object_type="key", | |
| vector_store_ids_to_run=["store-1"], | |
| object_permissions=perm, | |
| ) | |
| assert exc_info.value.type == ProxyErrorTypes.key_vector_store_access_denied |
The PR description's claim that vector stores differ from search tools ("unlike vector stores where empty means all access") is also incorrect — both features deny when the list is empty.
- 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) <[email protected]>
| 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, |
There was a problem hiding this comment.
check_member_permission, add_mcp_server_to_team, and remove_mcp_server_from_team are undefined — ImportError at startup
These three names are imported here but do not exist anywhere in the codebase:
check_member_permissionis imported fromlitellm.proxy.management_endpoints.common_utils(line 135), but a search of the entire repository finds no definition of this function.add_mcp_server_to_team(line 139) andremove_mcp_server_from_team(line 140) are imported fromlitellm.proxy.management_helpers.object_permission_utils, but only_get_team_allowed_mcp_serversexists in that file — the other two names are absent.
Because these imports are inside the if MCP_AVAILABLE: block that runs at module load time, any environment where MCP is available will raise an ImportError as soon as mcp_management_endpoints is imported, breaking all MCP endpoints (not just the new team-scoped paths). The three functions are called at lines 1237, 1301, 1542, 1578, and 1898.
These functions must be implemented and exported from their respective modules before this PR can be merged.
- 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) <[email protected]>
| 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. |
There was a problem hiding this comment.
search_tools must be nullable (String[]?) in the Prisma schema
The field is defined as String[] (non-nullable), but the intent is for NULL to mean "no restriction". In Prisma, a non-nullable String[] field causes the generated client to type this as string[] (not string[] | null). When prisma-client-py reads a DB row where this column is NULL (all rows after the migration, since no DEFAULT was specified), the client may coerce NULL → [] to satisfy the non-nullable contract.
If that coercion happens, object_permissions.search_tools will be [] instead of None, causing _can_object_call_search_tools to deny access for every existing permission record — the exact opposite of the backward-compatibility promise.
Compare with mcp_tool_permissions Json? which uses ? to explicitly allow null. Change to:
| 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. |
The same fix is needed in litellm-proxy-extras/litellm_proxy_extras/schema.prisma and the root schema.prisma.
Rule Used: What: avoid backwards-incompatible changes without... (source)
|
|
||
| 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 |
There was a problem hiding this comment.
get_permitted_search_tool_names is dead code
This helper is defined and exported but is never called from any production code path. Both search_tool_access_check (line 3587) and _get_allowed_search_tool_names in endpoints.py (line 48/61) access object_permissions.search_tools directly rather than going through this function. It is only exercised by the unit tests in test_search_tool_access.py.
Either wire the helper into _get_allowed_search_tool_names (replacing the direct .search_tools access) to justify its existence, or remove it to avoid confusion about the intended API surface.
| return True | ||
|
|
||
|
|
||
| def _can_object_call_search_tools( |
There was a problem hiding this comment.
Missing return type annotation
_can_object_call_search_tools has no -> bool: return type annotation, unlike the sibling _can_object_call_vector_stores which (implicitly) returns bool. Adding a type annotation keeps this consistent and makes the contract explicit for callers.
| def _can_object_call_search_tools( | |
| def _can_object_call_search_tools( | |
| object_type: Literal["key", "team", "org"], | |
| search_tool_name: str, | |
| object_permissions: Optional[LiteLLM_ObjectPermissionTable], | |
| ) -> bool: |
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
- 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) <[email protected]>
Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
185db19
into
litellm_yj_march_20_2026
| 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. |
There was a problem hiding this comment.
Missing database migration file
Three schema.prisma files (the root schema.prisma, litellm/proxy/schema.prisma, and litellm-proxy-extras/litellm_proxy_extras/schema.prisma) all add the search_tools String[]? column, but no corresponding SQL migration file is included in the PR.
Without a migration, existing production databases will not have this column. When the deployed code calls get_object_permission and the Prisma client tries to hydrate a LiteLLM_ObjectPermissionTable from a DB row missing the search_tools column, it will either error or silently return None depending on the Prisma-Python driver version — which could produce unexpected behavior.
Other columns in this project track explicit migrations (e.g. under litellm-proxy-extras/litellm_proxy_extras/migrations/). A migration needs to be added here, with DEFAULT NULL (not DEFAULT ARRAY[]::TEXT[]) so that existing rows are backward-compatible (NULL → no restriction, per the PR's own semantics). The same issue applies to the identical change in the root schema.prisma and litellm-proxy-extras/litellm_proxy_extras/schema.prisma.
| async def search_tool_access_check( | ||
| search_tool_name: str, | ||
| valid_token: Optional[UserAPIKeyAuth], | ||
| ): |
There was a problem hiding this comment.
Missing return type annotation on
search_tool_access_check
search_tool_access_check is missing a return type annotation, unlike its sibling _can_object_call_search_tools which declares -> bool:. Adding -> bool: to the function signature keeps the contract explicit for callers and is consistent with the rest of the codebase.
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!





Relevant issues
Implements access control for search tools in LiteLLM, mirroring the existing MCP server permission pattern.
Summary
Problem
Search tool endpoints (
/v1/search/{search_tool_name},/v1/search/tools) had no access control — any authenticated user could use any search tool.Fix
Adds a
search_toolsfield toLiteLLM_ObjectPermissionTablewith least-privilege semantics:search_toolsvalueNULL(no object_permission record or field not set)[](empty list)["tool-a", "tool-b"]This differs from vector stores (where
[]= all access) to follow the principle of least privilege for new fields.Key changes:
search_toolscolumn (String[]?) onLiteLLM_ObjectPermissionTable— no default, so existing rows get NULL (all access)_can_object_call_search_tools()enforces key-level and team-level permissionssearch_tool_access_check()uses the cachedget_object_permissionhelper (not raw DB queries)/v1/search/toolslisting filters results based on permissionssearch_tool_namenow returns 400 instead of silently bypassing access controlget_search_tool_access_error_type_for_object()includes araise ValueErrorfallback for unknown object typesNo pre-existing fields or behaviors are modified —
mcp_servers,vector_stores,agents, etc. keep their existing[]= all access semantics.Testing
_can_object_call_search_tools()covering all branches (None, empty, specific, denied)ProxyErrorTypessearch tool error resolution[]= allow all)Type
🆕 New Feature
✅ Test