-
-
Notifications
You must be signed in to change notification settings - Fork 11.7k
feat(mcp/auth): additive key access-group grants + opt-in member assignment #29313
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
Changes from all commits
91ba45c
c38dbba
4d504f6
8e5696a
e86f2d8
e1f8932
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 |
|---|---|---|
|
|
@@ -566,7 +566,7 @@ async def get_allowed_mcp_servers( | |
| ) | ||
| ) | ||
|
|
||
| key_access_group_extras = ( | ||
| key_access_group_grants = ( | ||
| await MCPRequestHandler._get_key_access_group_mcp_server_extras( | ||
| user_api_key_auth | ||
| ) | ||
|
|
@@ -577,21 +577,22 @@ async def get_allowed_mcp_servers( | |
| ######################################################### | ||
| key_set = set(allowed_mcp_servers_for_key) | ||
| team_set = set(allowed_mcp_servers_for_team) | ||
| extras_set = set(key_access_group_extras) | ||
| grants_set = set(key_access_group_grants) | ||
|
|
||
| has_lower_level_mcp_restrictions = bool(key_set or team_set or extras_set) | ||
| has_lower_level_mcp_restrictions = bool(key_set or team_set or grants_set) | ||
|
|
||
| # 1. Team-gated base scope. | ||
| # 1. Key/team ceiling. An empty set means "this level does not restrict". | ||
| if not team_set: | ||
| base = key_set # no team restriction | ||
| elif not key_set: | ||
| base = team_set # key has no own perms → inherits team | ||
| else: | ||
| base = key_set & team_set # both restrict → intersect | ||
|
|
||
| # 2. Extend with access-group extras (LIT-3189 — bypasses team | ||
| # ceiling, gated by group's assigned_team_ids / assigned_key_ids). | ||
| allowed_mcp_servers: List[str] = list(base | extras_set) | ||
| # 2. Add the key's access-group grants on top. These are additive: | ||
| # attaching a group to the key grants its servers regardless of the | ||
| # team ceiling. | ||
| allowed_mcp_servers: List[str] = list(base | grants_set) | ||
|
|
||
| ######################################################### | ||
| # Check end_user permissions if end_user_id is set | ||
|
|
@@ -890,11 +891,12 @@ async def _get_key_access_group_mcp_server_extras( | |
| ) -> List[str]: | ||
| """ | ||
| Resolve the key's unified `access_group_ids` (LiteLLM_AccessGroupTable) to | ||
| MCP server IDs, gated by the access group's `assigned_team_ids` / | ||
| `assigned_key_ids`. These servers extend the team's MCP scope rather | ||
| than being capped by it. Tag-style `mcp_access_groups` (per-server tags) | ||
| are intentionally not handled here — they have no assignment fields and | ||
| remain subject to the team ceiling. | ||
| MCP server IDs as additive grants: a group attached to the key extends the | ||
| key's allowed servers on top of the key/team ceiling rather than being | ||
| capped by the team. Attaching the group to the key is itself the grant — | ||
| no `assigned_key_ids` / `assigned_team_ids` re-check. Tag-style | ||
| `mcp_access_groups` (per-server tags) live in the key's object_permission | ||
| scope, not here. | ||
| """ | ||
| if user_api_key_auth is None: | ||
| return [] | ||
|
|
@@ -903,21 +905,27 @@ async def _get_key_access_group_mcp_server_extras( | |
| global_mcp_server_manager, | ||
| ) | ||
| from litellm.proxy.auth.auth_checks import ( | ||
| get_authorized_resources_from_key_access_groups, | ||
| _get_mcp_server_ids_from_access_groups, | ||
| ) | ||
| from litellm.proxy.proxy_server import ( | ||
| prisma_client, | ||
| proxy_logging_obj, | ||
| user_api_key_cache, | ||
| ) | ||
|
|
||
| raw_server_ids = await get_authorized_resources_from_key_access_groups( | ||
| valid_token=user_api_key_auth, | ||
| team_object=None, | ||
| resource_field="access_mcp_server_ids", | ||
| raw_server_ids = await _get_mcp_server_ids_from_access_groups( | ||
|
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: MCP access-group authorization bypass
Contributor
Author
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. this is the point, we are unioning the keys ag's mcp's with the team instead of keeping the team as the gate. this behavior falls in line with the current design of access groups being purely additive. it will be opt in to be able to allow internal users to attach access groups to the key, this will be a a team member permission you can set |
||
| access_group_ids=user_api_key_auth.access_group_ids or [], | ||
| prisma_client=prisma_client, | ||
| user_api_key_cache=user_api_key_cache, | ||
| proxy_logging_obj=proxy_logging_obj, | ||
| ) | ||
|
ryan-crabbe-berri marked this conversation as resolved.
|
||
| if not raw_server_ids: | ||
| return [] | ||
| # Permission entries may be server_ids OR names/aliases — expand to ids. | ||
| return global_mcp_server_manager.expand_permission_list(raw_server_ids) | ||
| except Exception as e: | ||
| verbose_logger.warning( | ||
| f"Failed to get key access group MCP server extras: {str(e)}" | ||
| f"Failed to get key access group MCP server grants: {str(e)}" | ||
| ) | ||
| return [] | ||
|
|
||
|
|
@@ -926,16 +934,13 @@ async def _get_allowed_mcp_servers_for_key( | |
| user_api_key_auth: Optional[UserAPIKeyAuth] = None, | ||
| ) -> List[str]: | ||
| """ | ||
| Get allowed MCP servers for a key (the key's own scope). | ||
| Get the key's own MCP ceiling from its object_permission | ||
| (mcp_servers, tag-style mcp_access_groups, mcp_tool_permissions). | ||
|
|
||
| Unions two sources: | ||
| - Legacy key.object_permission (mcp_servers, mcp_access_groups, | ||
| mcp_tool_permissions). | ||
| - Unified key.access_group_ids → access_group.access_mcp_server_ids. | ||
| Mirrors the ungated fallback in can_key_call_model — the group is | ||
| attached to the key itself, so it grants the key's own scope (no | ||
| assigned_key_ids re-check). The gated, team-ceiling-busting override | ||
| lives in _get_key_access_group_mcp_server_extras. | ||
| Unified key.access_group_ids are NOT resolved here — they are additive | ||
| grants handled by _get_key_access_group_mcp_server_extras and unioned on | ||
| top of the key/team ceiling, so they must not enter this scope (which is | ||
| intersected against the team). | ||
| """ | ||
| if user_api_key_auth is None: | ||
| return [] | ||
|
|
@@ -944,7 +949,6 @@ async def _get_allowed_mcp_servers_for_key( | |
| global_mcp_server_manager, | ||
| ) | ||
| from litellm.proxy.auth.auth_checks import ( | ||
| _get_mcp_server_ids_from_access_groups, | ||
| get_object_permission, | ||
| ) | ||
| from litellm.proxy.proxy_server import ( | ||
|
|
@@ -953,19 +957,6 @@ async def _get_allowed_mcp_servers_for_key( | |
| user_api_key_cache, | ||
| ) | ||
|
|
||
| # Unified key.access_group_ids → MCP servers (ungated: the group is | ||
| # attached to the key, so it grants the key's own scope). Entries in | ||
| # access_mcp_server_ids may be server_ids OR names/aliases, so expand | ||
| # to ids here — matching the legacy object_permission path below. | ||
| key_access_group_servers = global_mcp_server_manager.expand_permission_list( | ||
| await _get_mcp_server_ids_from_access_groups( | ||
| access_group_ids=user_api_key_auth.access_group_ids or [], | ||
| prisma_client=prisma_client, | ||
| user_api_key_cache=user_api_key_cache, | ||
| proxy_logging_obj=proxy_logging_obj, | ||
| ) | ||
| ) | ||
|
|
||
| # Get key object permission (already loaded in main auth flow, or fetch from DB) | ||
| key_object_permission = MCPRequestHandler._get_key_object_permission( | ||
| user_api_key_auth | ||
|
|
@@ -983,7 +974,7 @@ async def _get_allowed_mcp_servers_for_key( | |
| proxy_logging_obj=proxy_logging_obj, | ||
| ) | ||
| if key_object_permission is None: | ||
| return list(set(key_access_group_servers)) | ||
| return [] | ||
|
|
||
| # Permission entries may be server_ids OR names/aliases — expand to ids. | ||
| direct_mcp_servers = global_mcp_server_manager.expand_permission_list( | ||
|
|
@@ -1005,12 +996,7 @@ async def _get_allowed_mcp_servers_for_key( | |
| ) | ||
|
|
||
| # Combine all lists | ||
| all_servers = ( | ||
| direct_mcp_servers | ||
| + access_group_servers | ||
| + tool_perm_servers | ||
| + key_access_group_servers | ||
| ) | ||
| all_servers = direct_mcp_servers + access_group_servers + tool_perm_servers | ||
| return list(set(all_servers)) | ||
| except Exception as e: | ||
| verbose_logger.warning( | ||
|
|
||
Uh oh!
There was an error while loading. Please reload this page.