mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-01 07:03:02 +00:00
fix(proxy)!: bind admin-owned team keys to their team's MCP servers
A virtual key on a real team now resolves MCP servers and toolsets through its team even when its
owner is a proxy_admin or proxy_admin_viewer, the way team model access already binds it. The
unscoped admin view stays for the master key, admin keys with no team, the admin dashboard
session, and admitted user subjects. The toolset grant checks on /toolset/{name}/mcp and on the
Responses litellm_proxy MCP tool use the same rule.
BREAKING CHANGE: a team key owned by a proxy admin or admin viewer no longer reaches MCP servers or
toolsets its team does not grant. Grant them to the team or use a key with no team.
This commit is contained in:
parent
e17cd3e6fd
commit
e9fef302d9
3 changed files with 31 additions and 17 deletions
|
|
@ -300,6 +300,18 @@ def is_mcp_admitted_user_subject(user_api_key_auth: UserAPIKeyAuth | None) -> bo
|
|||
_is_mcp_admitted_user_subject: Final = is_mcp_admitted_user_subject
|
||||
|
||||
|
||||
def has_team_unbound_mcp_admin_view(user_api_key_auth: UserAPIKeyAuth | None) -> bool:
|
||||
return (
|
||||
user_api_key_auth is not None
|
||||
and user_api_key_has_admin_view(user_api_key_auth)
|
||||
and (
|
||||
is_mcp_admitted_user_subject(user_api_key_auth)
|
||||
or not user_api_key_auth.team_id
|
||||
or user_api_key_auth.team_id == UI_TEAM_ID
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _gateway_dcr_challenge_target(
|
||||
route: str,
|
||||
mcp_servers: list[str] | None,
|
||||
|
|
@ -4080,19 +4092,19 @@ class MCPRequestHandler:
|
|||
"""Whether this principal's admin-view role grants the unscoped MCP resolution, whatever
|
||||
credential carries it (admin key, dashboard session, or OAuth-admitted session subject).
|
||||
|
||||
Two bounds disqualify, one per ownership of the row. A CREDENTIAL's explicit
|
||||
``object_permission.mcp_servers`` scope wins even for admins, including the empty list. An
|
||||
admitted subject's object_permission is the user's own row, whose ``mcp_servers`` column is
|
||||
[] by DB default, so for that shape the row binds through the entitlement ceiling instead
|
||||
(any non-empty entitlement, or an unresolved one, disqualifies), exactly as
|
||||
``operator_open_server_ids`` reads the same row. The one owner of this predicate: the
|
||||
server-axis registry resolution in ``get_allowed_mcp_servers`` and the tools-axis open
|
||||
channel in ``resolve_admitted_subject_tools`` both consult it, so the two axes cannot
|
||||
disagree."""
|
||||
A virtual key on a real team never qualifies: its team bounds its MCP access whoever owns it, as it
|
||||
bounds the key's model access. Two more bounds disqualify, one per ownership of the row. A CREDENTIAL's
|
||||
explicit ``object_permission.mcp_servers`` scope wins even for admins, including the empty list. An
|
||||
admitted subject's object_permission is the user's own row, whose ``mcp_servers`` column is [] by DB
|
||||
default, so for that shape the row binds through the entitlement ceiling instead (any non-empty
|
||||
entitlement, or an unresolved one, disqualifies), exactly as ``operator_open_server_ids`` reads the same
|
||||
row. The one owner of this predicate: the server-axis registry resolution in ``get_allowed_mcp_servers``
|
||||
and the tools-axis open channel in ``resolve_admitted_subject_tools`` both consult it, so the two axes
|
||||
cannot disagree."""
|
||||
if (
|
||||
user_api_key_auth is None
|
||||
or user_api_key_auth.mcp_explicit_grants_only
|
||||
or not user_api_key_has_admin_view(user_api_key_auth)
|
||||
or not has_team_unbound_mcp_admin_view(user_api_key_auth)
|
||||
):
|
||||
return False
|
||||
object_permission: Final = user_api_key_auth.object_permission
|
||||
|
|
|
|||
|
|
@ -1760,10 +1760,10 @@ if MCP_AVAILABLE:
|
|||
row capping it.
|
||||
|
||||
Raises HTTPException(403) unless the principal holds toolset_id through one
|
||||
of its grant sources. Admins always pass.
|
||||
of its grant sources. Admins pass unless the key is bound to a real team.
|
||||
"""
|
||||
from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import has_team_unbound_mcp_admin_view
|
||||
from litellm.proxy._types import LiteLLM_ObjectPermissionTable
|
||||
from litellm.proxy.management_endpoints.common_utils import user_api_key_has_admin_view
|
||||
|
||||
# A key scoped to no MCP servers opts out of every MCP path. Enforce it
|
||||
# here too, since toolset scoping replaces mcp_servers and would otherwise
|
||||
|
|
@ -1777,7 +1777,7 @@ if MCP_AVAILABLE:
|
|||
)
|
||||
|
||||
acting: Final = await acting_user(user_api_key_auth)
|
||||
is_admin: Final = user_api_key_has_admin_view(acting)
|
||||
is_admin: Final = has_team_unbound_mcp_admin_view(acting)
|
||||
if not is_admin and toolset_id not in await granted(acting):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
|
|
|
|||
|
|
@ -282,14 +282,16 @@ class LiteLLM_Proxy_MCP_Handler:
|
|||
toolset = await global_mcp_server_manager.get_toolset_by_name_cached(prisma_client, name)
|
||||
if toolset is not None:
|
||||
if user_api_key_auth is not None:
|
||||
from litellm.proxy._experimental.mcp_server.auth.user_api_key_auth_mcp import (
|
||||
has_team_unbound_mcp_admin_view,
|
||||
)
|
||||
from litellm.proxy._experimental.mcp_server.ui_session_utils import (
|
||||
granted_toolset_ids,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.common_utils import (
|
||||
user_api_key_has_admin_view,
|
||||
)
|
||||
|
||||
if not user_api_key_has_admin_view(user_api_key_auth) and toolset.toolset_id not in (
|
||||
if not has_team_unbound_mcp_admin_view(
|
||||
user_api_key_auth
|
||||
) and toolset.toolset_id not in (
|
||||
await (granted_toolsets or granted_toolset_ids)(user_api_key_auth)
|
||||
):
|
||||
verbose_logger.debug("Key does not have access to toolset '%s', skipping.", name)
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue