-
-
Notifications
You must be signed in to change notification settings - Fork 11.6k
feat(mcp): union team-inherited MCP grants across all of a user's teams for keyless admission #33191
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat(mcp): union team-inherited MCP grants across all of a user's teams for keyless admission #33191
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,3 +1,4 @@ | ||||||||||||||
| import asyncio | ||||||||||||||
| import re | ||||||||||||||
| from datetime import datetime, timezone | ||||||||||||||
| from typing import Dict, List, Optional, Set, Tuple, cast | ||||||||||||||
|
|
@@ -126,6 +127,21 @@ def _has_client_supplied_mcp_auth( | |||||||||||||
| return bool(mcp_auth_header) or bool(mcp_server_auth_headers) | ||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
| MCP_ADMITTED_USER_SUBJECT_METADATA = "mcp_admitted_user_subject" | ||||||||||||||
| """Marker key stamped into ``UserAPIKeyAuth.metadata`` by ``_reload_admitted_user`` for a | ||||||||||||||
| subject admitted keyless through the gateway session / bridge user path. It is what lets | ||||||||||||||
| ``_team_ids_for_mcp_grant`` union the user's teams for exactly those admissions without also | ||||||||||||||
| broadening JWT auth, which produces a structurally identical keyless auth.""" | ||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
| def _is_mcp_admitted_user_subject(user_api_key_auth: UserAPIKeyAuth) -> bool: | ||||||||||||||
| """True when this auth is a keyless subject admitted by the gateway session / bridge user | ||||||||||||||
| path (stamped at admission), as opposed to a JWT or other keyless auth that merely lacks a | ||||||||||||||
| ``team_id``.""" | ||||||||||||||
| metadata = user_api_key_auth.metadata | ||||||||||||||
| return isinstance(metadata, dict) and metadata.get(MCP_ADMITTED_USER_SUBJECT_METADATA) is True | ||||||||||||||
|
|
||||||||||||||
|
|
||||||||||||||
| def _is_aggregate_mcp_scope(route: str, mcp_servers: list[str] | None) -> bool: | ||||||||||||||
| """True when a request targets the aggregate ``/mcp`` endpoint rather than any named | ||||||||||||||
| server. Named targets arrive either through ``x-mcp-servers`` (``mcp_servers``) or a | ||||||||||||||
|
|
@@ -807,6 +823,7 @@ async def _reload_admitted_user(user_id: str) -> UserAPIKeyAuth: | |||||||||||||
| org_id=user_object.organization_id, | ||||||||||||||
| object_permission=object_permission, | ||||||||||||||
| object_permission_id=user_object.object_permission_id, | ||||||||||||||
| metadata={MCP_ADMITTED_USER_SUBJECT_METADATA: True}, | ||||||||||||||
| ) | ||||||||||||||
|
|
||||||||||||||
| @staticmethod | ||||||||||||||
|
|
@@ -1590,10 +1607,91 @@ async def _get_allowed_mcp_servers_for_key( | |||||||||||||
|
|
||||||||||||||
| @staticmethod | ||||||||||||||
| async def _get_allowed_mcp_servers_for_team( | ||||||||||||||
| user_api_key_auth: Optional[UserAPIKeyAuth] = None, | ||||||||||||||
| ) -> List[str]: | ||||||||||||||
| """ | ||||||||||||||
| Get allowed MCP servers for a team. | ||||||||||||||
| user_api_key_auth: UserAPIKeyAuth | None = None, | ||||||||||||||
| ) -> list[str]: | ||||||||||||||
| """ | ||||||||||||||
| Get allowed MCP servers a caller inherits from team membership. | ||||||||||||||
|
|
||||||||||||||
| For a caller with a ``team_id`` (every key-based caller) the result is that one team's | ||||||||||||||
| grants, byte-identical to before this method learned about multiple teams. For a | ||||||||||||||
| subject admitted keyless through the gateway DCR session or bridge user path, a | ||||||||||||||
| ``UserAPIKeyAuth`` can only pin one team while the user may belong to many, so the | ||||||||||||||
| inherited grant is the UNION across every team the user belongs to. Without this a | ||||||||||||||
| signed-in user would see only servers granted to them directly and none granted | ||||||||||||||
| through their teams, which is how servers are meant to be shared (assign teams, not | ||||||||||||||
| individuals). The union is gated on the admission marker | ||||||||||||||
| ``_team_ids_for_mcp_grant`` checks, NOT on ``api_key is None``, so JWT auth (also | ||||||||||||||
| keyless, also possibly team-less) keeps its prior behavior and is not silently | ||||||||||||||
| broadened. | ||||||||||||||
| """ | ||||||||||||||
| team_ids = await MCPRequestHandler._team_ids_for_mcp_grant(user_api_key_auth) | ||||||||||||||
| if not team_ids: | ||||||||||||||
| return [] | ||||||||||||||
| per_team = await asyncio.gather( | ||||||||||||||
| *( | ||||||||||||||
| MCPRequestHandler._allowed_mcp_servers_for_single_team(team_id, user_api_key_auth) | ||||||||||||||
| for team_id in team_ids | ||||||||||||||
| ) | ||||||||||||||
| ) | ||||||||||||||
|
Comment on lines
+1630
to
+1635
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||||||||||||||
| return list({server for servers in per_team for server in servers}) | ||||||||||||||
|
|
||||||||||||||
| @staticmethod | ||||||||||||||
| async def _team_ids_for_mcp_grant(user_api_key_auth: UserAPIKeyAuth | None) -> list[str]: | ||||||||||||||
| """The team ids whose MCP grants a caller inherits. | ||||||||||||||
|
|
||||||||||||||
| A caller with an explicit ``team_id`` (every key-based caller, and any auth that pins | ||||||||||||||
| a team) uses that single team, so key auth is byte-identical. The fan-out to the | ||||||||||||||
| user's full team list happens ONLY for a subject admitted keyless through the gateway | ||||||||||||||
| session or bridge user path, which ``_reload_admitted_user`` stamps with | ||||||||||||||
| ``MCP_ADMITTED_USER_SUBJECT_METADATA``. Gating on that positive marker rather than on | ||||||||||||||
| ``api_key is None`` is deliberate: JWT auth also produces a keyless ``user_id`` auth | ||||||||||||||
| with no ``team_id``, and it must keep its prior behavior (no team-inherited grants) | ||||||||||||||
| rather than silently gaining the union across every team the user belongs to. The | ||||||||||||||
| ``UI_TEAM_ID`` sentinel resolves to no teams exactly as before.""" | ||||||||||||||
| if user_api_key_auth is None: | ||||||||||||||
| return [] | ||||||||||||||
| if user_api_key_auth.team_id: | ||||||||||||||
| return [] if user_api_key_auth.team_id == UI_TEAM_ID else [user_api_key_auth.team_id] | ||||||||||||||
| if not user_api_key_auth.user_id or not _is_mcp_admitted_user_subject(user_api_key_auth): | ||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. High: Key metadata can forge MCP session admission
Suggested change
|
||||||||||||||
| return [] | ||||||||||||||
| return await MCPRequestHandler._resolve_user_team_ids(user_api_key_auth.user_id, user_api_key_auth) | ||||||||||||||
|
|
||||||||||||||
| @staticmethod | ||||||||||||||
| async def _resolve_user_team_ids(user_id: str, user_api_key_auth: UserAPIKeyAuth) -> list[str]: | ||||||||||||||
| """The distinct team ids a user belongs to, from the live user record. Returns [] on | ||||||||||||||
| no DB, a missing user, or any resolution failure so a lookup blip narrows access | ||||||||||||||
| rather than raising; the caller's direct grants still apply.""" | ||||||||||||||
| from litellm.proxy.auth.auth_checks import get_user_object | ||||||||||||||
| from litellm.proxy.proxy_server import ( | ||||||||||||||
| prisma_client, | ||||||||||||||
| proxy_logging_obj, | ||||||||||||||
| user_api_key_cache, | ||||||||||||||
| ) | ||||||||||||||
|
|
||||||||||||||
| if prisma_client is None: | ||||||||||||||
| return [] | ||||||||||||||
| try: | ||||||||||||||
| user_object = await get_user_object( | ||||||||||||||
| user_id=user_id, | ||||||||||||||
| prisma_client=prisma_client, | ||||||||||||||
| user_api_key_cache=user_api_key_cache, | ||||||||||||||
| user_id_upsert=False, | ||||||||||||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Medium: Team removal does not immediately revoke inherited access
Suggested change
|
||||||||||||||
| parent_otel_span=user_api_key_auth.parent_otel_span, | ||||||||||||||
| proxy_logging_obj=proxy_logging_obj, | ||||||||||||||
| ) | ||||||||||||||
| except Exception as e: # noqa: BLE001 # a team-resolution blip narrows access, never raises | ||||||||||||||
| verbose_logger.warning(f"Failed to resolve user teams for MCP grant: {str(e)}") | ||||||||||||||
| return [] | ||||||||||||||
| if user_object is None or not user_object.teams: | ||||||||||||||
| return [] | ||||||||||||||
| return list(dict.fromkeys(t for t in user_object.teams if t and t != UI_TEAM_ID)) | ||||||||||||||
|
Comment on lines
+1660
to
+1687
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For a keyless user-subject caller this path fires Rule Used: What: Avoid creating new database requests or Rout... (source) 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!
Comment on lines
+1673
to
+1687
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
The 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! |
||||||||||||||
|
|
||||||||||||||
| @staticmethod | ||||||||||||||
| async def _allowed_mcp_servers_for_single_team( | ||||||||||||||
| team_id: str, | ||||||||||||||
| user_api_key_auth: UserAPIKeyAuth | None, | ||||||||||||||
| ) -> list[str]: | ||||||||||||||
| """Allowed MCP servers granted by ONE team. | ||||||||||||||
|
|
||||||||||||||
| Unions two sources: | ||||||||||||||
| - Legacy team.object_permission (mcp_servers, mcp_access_groups, | ||||||||||||||
|
|
@@ -1617,17 +1715,15 @@ async def _get_allowed_mcp_servers_for_team( | |||||||||||||
| user_api_key_cache, | ||||||||||||||
| ) | ||||||||||||||
|
|
||||||||||||||
| if user_api_key_auth is None or not user_api_key_auth.team_id or prisma_client is None: | ||||||||||||||
| if not team_id or team_id == UI_TEAM_ID or prisma_client is None: | ||||||||||||||
| return [] | ||||||||||||||
|
|
||||||||||||||
| if user_api_key_auth.team_id == UI_TEAM_ID: | ||||||||||||||
| return [] | ||||||||||||||
|
|
||||||||||||||
| team_obj: Optional[LiteLLM_TeamTable] = await get_team_object( | ||||||||||||||
| team_id=user_api_key_auth.team_id, | ||||||||||||||
| parent_otel_span = user_api_key_auth.parent_otel_span if user_api_key_auth is not None else None | ||||||||||||||
| team_obj: LiteLLM_TeamTable | None = await get_team_object( | ||||||||||||||
| team_id=team_id, | ||||||||||||||
| prisma_client=prisma_client, | ||||||||||||||
| user_api_key_cache=user_api_key_cache, | ||||||||||||||
| parent_otel_span=user_api_key_auth.parent_otel_span, | ||||||||||||||
| parent_otel_span=parent_otel_span, | ||||||||||||||
| proxy_logging_obj=proxy_logging_obj, | ||||||||||||||
| ) | ||||||||||||||
| if team_obj is None: | ||||||||||||||
|
|
||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
High: Team tool restrictions are not preserved
This grants servers from every team, but
get_allowed_tools_for_server()still obtains team permissions through_get_team_object_permission(), which returns no team permission when this admitted auth has noteam_id. A user can consequently call any tool on an inherited server even when the granting team limitsmcp_tool_permissions; aggregate the corresponding per-team tool permissions alongside these server grants and enforce their union during tool listing and execution.