feat(mcp/auth): additive key access-group grants + opt-in member assignment (#29313)

* fix(mcp): make key.access_group_ids grants additive over team ceiling

A key whose unified access_group_ids grant a private MCP server was
having that grant intersected against its team's MCP ceiling, so a key
in a team scoped to other servers (or with no own scope) lost the
granted server entirely. Resolve access_group_ids once as ungated
additive grants and union them on top of the key/team ceiling instead
of folding them into the key scope that gets intersected.

* test(mcp): align key access-group tests with additive-grant model

The previous commit moved key.access_group_ids resolution out of the
intersected key ceiling (_get_allowed_mcp_servers_for_key) and into the
ungated additive grant path (_get_key_access_group_mcp_server_extras),
unioned on top of the team ceiling. Five tests from #28890/#29195 still
asserted the old gated / in-key-scope contract and failed:

- _get_allowed_mcp_servers_for_key now returns the object_permission
  ceiling only and never resolves access_group_ids; two tests now assert
  the group resolver is not called from that path (with and without an
  object_permission present).
- The extras path is ungated, so a group whose assigned_team_ids /
  assigned_key_ids exclude the caller still contributes its servers.
- The end-to-end test asserts the grant surfaces via the extras path
  rather than the base key path.
- Dropped test_key_access_group_ids_empty_returns_no_extras; the empty
  case is already covered by the extras family's no-groups test.

* feat(auth): gate member access-group assignment on keys behind opt-in

Non-admin team members could attach access_group_ids to keys they create
or update, letting them self-grant resources (MCP servers/models) the team
admin never intended. Add an opt-in KEY_ACCESS_GROUP_ASSIGNMENT team-member
permission (default-deny) enforced at /key/generate and /key/update; proxy
and team admins bypass. Surfaces automatically as a checkbox in the team
Member Permissions UI.

* fix(auth): gate access-group assignment on /key/regenerate too

RegenerateKeyRequest inherits access_group_ids and prepare_key_update_data
persists it, so a non-admin key owner could self-grant access groups by
regenerating. Apply the same opt-in member gate using the existing key's
team.

* test(auth): cover member access-group gate and additive MCP grants

Add unit tests for enforce_member_can_assign_access_groups (deny without
opt-in, allow with opt-in, and proxy-admin / team-admin / non-team-key
bypasses) and for _get_key_access_group_mcp_server_extras (no-auth and
no-resolved-servers return empty, resolved ids are expanded, errors
degrade to no grants).
This commit is contained in:
ryan-crabbe-berri 2026-05-30 17:35:31 -07:00 committed by GitHub
parent dc4f5b12ef
commit 90b5104475
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 412 additions and 141 deletions

View File

@ -566,7 +566,7 @@ class MCPRequestHandler:
)
)
key_access_group_extras = (
key_access_group_grants = (
await MCPRequestHandler._get_key_access_group_mcp_server_extras(
user_api_key_auth
)
@ -577,11 +577,11 @@ class MCPRequestHandler:
#########################################################
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:
@ -589,9 +589,10 @@ class MCPRequestHandler:
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,52 +891,12 @@ class MCPRequestHandler:
) -> 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.
"""
if user_api_key_auth is None:
return []
try:
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
from litellm.proxy.auth.auth_checks import (
get_authorized_resources_from_key_access_groups,
)
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",
)
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)}"
)
return []
@staticmethod
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).
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.
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 []
@ -945,7 +906,6 @@ class MCPRequestHandler:
)
from litellm.proxy.auth.auth_checks import (
_get_mcp_server_ids_from_access_groups,
get_object_permission,
)
from litellm.proxy.proxy_server import (
prisma_client,
@ -953,17 +913,48 @@ class MCPRequestHandler:
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,
)
raw_server_ids = 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,
)
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 grants: {str(e)}"
)
return []
@staticmethod
async def _get_allowed_mcp_servers_for_key(
user_api_key_auth: Optional[UserAPIKeyAuth] = None,
) -> List[str]:
"""
Get the key's own MCP ceiling from its object_permission
(mcp_servers, tag-style mcp_access_groups, mcp_tool_permissions).
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 []
try:
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
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,
)
# Get key object permission (already loaded in main auth flow, or fetch from DB)
@ -983,7 +974,7 @@ class MCPRequestHandler:
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 @@ class MCPRequestHandler:
)
# 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(

View File

@ -242,6 +242,11 @@ class KeyManagementRoutes(str, enum.Enum):
TEAM_KEY_BULK_UPDATE = "/team/key/bulk_update"
KEY_RESET_SPEND = "/key/{key_id}/reset_spend"
# Field-level opt-in permission (not a real HTTP route). When present in a
# team's `team_member_permissions`, non-admin members of that team may set
# `access_group_ids` on keys they create/update. Default-deny.
KEY_ACCESS_GROUP_ASSIGNMENT = "/key/access_group_assignment"
# info and health routes
KEY_INFO = "/key/info"
KEY_HEALTH = "/key/health"
@ -552,6 +557,7 @@ class LiteLLMRoutes(enum.Enum):
KeyManagementRoutes.SPEND_LOGS_V2.value,
KeyManagementRoutes.KEY_RESET_SPEND.value,
KeyManagementRoutes.KEY_ALIASES.value,
KeyManagementRoutes.KEY_ACCESS_GROUP_ASSIGNMENT.value,
]
management_routes = (

View File

@ -325,6 +325,14 @@ def _team_key_generation_check(
_team_key_generation.get("required_params"),
)
# Field-level opt-in: non-admin members may only assign access groups when
# the team has enabled KEY_ACCESS_GROUP_ASSIGNMENT.
TeamMemberPermissionChecks.enforce_member_can_assign_access_groups(
user_api_key_dict=user_api_key_dict,
team_table=team_table,
access_group_ids=data.access_group_ids,
)
return True
@ -2267,6 +2275,14 @@ async def _validate_update_key_data(
detail=f"Team not found for team_id={data.team_id}. Non-admin users cannot set keys to non-existent teams.",
)
# Field-level opt-in: non-admin members may only assign access groups when
# the team has enabled KEY_ACCESS_GROUP_ASSIGNMENT.
TeamMemberPermissionChecks.enforce_member_can_assign_access_groups(
user_api_key_dict=user_api_key_dict,
team_table=team_obj,
access_group_ids=data.access_group_ids,
)
if team_obj is not None:
await _check_team_key_limits(
team_table=team_obj,
@ -4511,6 +4527,23 @@ async def regenerate_key_fn( # noqa: PLR0915
detail={"error": "You are not authorized to regenerate this key"},
)
# Gate access_group_ids on regenerate, same as /key/generate and
# /key/update. Use the existing key's team since the body may omit it.
if data is not None and data.access_group_ids:
regenerate_team_table: Optional[LiteLLM_TeamTableCachedObj] = None
if _key_in_db.team_id is not None:
regenerate_team_table = await get_team_object(
team_id=_key_in_db.team_id,
prisma_client=prisma_client,
user_api_key_cache=user_api_key_cache,
check_db_only=True,
)
TeamMemberPermissionChecks.enforce_member_can_assign_access_groups(
user_api_key_dict=user_api_key_dict,
team_table=regenerate_team_table,
access_group_ids=data.access_group_ids,
)
verbose_proxy_logger.info(
"Key regeneration requested: key_alias=%s",
getattr(_key_in_db, "key_alias", None),

View File

@ -154,6 +154,70 @@ class TeamMemberPermissionChecks:
return True
@staticmethod
def enforce_member_can_assign_access_groups(
user_api_key_dict: UserAPIKeyAuth,
team_table: Optional[LiteLLM_TeamTableCachedObj],
access_group_ids: Optional[List[str]],
) -> None:
"""
Field-level opt-in gate: a non-admin team member may only set
`access_group_ids` on a (team) key if their team has opted in by adding
`KEY_ACCESS_GROUP_ASSIGNMENT` to `team_member_permissions`.
Bypassed for proxy admins, team admins, and personal (non-team) keys.
Default-deny: members cannot self-assign access groups until enabled.
Raises HTTPException(403) when a gated member attempts the assignment.
"""
from fastapi import HTTPException
from litellm.proxy.management_endpoints.key_management_endpoints import (
_get_user_in_team,
)
# No-op when the request does not assign any access groups.
if not access_group_ids:
return
# Proxy admins always bypass.
if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value:
return
# Personal (non-team) keys are out of scope for team-member gating.
if team_table is None:
return
team_member_object = _get_user_in_team(
team_table=team_table, user_id=user_api_key_dict.user_id
)
# Team admins always bypass (consistent with other member-permission checks).
if team_member_object is not None and team_member_object.role == "admin":
return
permissions = (
TeamMemberPermissionChecks._get_list_of_route_enum_as_str(
TeamMemberPermissionChecks.get_permissions_for_team_member(
team_member_object=team_member_object,
team_table=team_table,
)
)
if team_member_object is not None
else []
)
if KeyManagementRoutes.KEY_ACCESS_GROUP_ASSIGNMENT.value not in permissions:
raise HTTPException(
status_code=403,
detail=(
"Team members cannot assign access groups to keys for team "
f"{team_table.team_id}. Ask a team or proxy admin to enable the "
f"'{KeyManagementRoutes.KEY_ACCESS_GROUP_ASSIGNMENT.value}' team "
"member permission to allow this."
),
)
@staticmethod
async def user_belongs_to_keys_team(
user_api_key_dict: UserAPIKeyAuth,

View File

@ -200,6 +200,117 @@ class TestMCPRequestHandler:
result = await MCPRequestHandler.get_allowed_mcp_servers(user_api_key_auth)
assert result == [] # Should handle exception gracefully
@pytest.mark.parametrize(
"key_servers,team_servers,grant_servers,expected,scenario",
[
# Key has no own scope, restrictive team ceiling {test}, server
# granted only via key.access_group_ids → caller sees team's server
# AND the grant (grant is added on top of the ceiling).
(
[],
["test"],
["context7"],
["context7", "test"],
"grant_over_team_ceiling",
),
# key {a} ∩ team {b} = {} ; the grant still surfaces, proving grants
# are unioned with the ceiling, not intersected against it.
(
["a"],
["b"],
["context7"],
["context7"],
"grant_survives_empty_intersection",
),
# No grant → ceiling behavior is unchanged (no additive leakage).
(["x", "y"], ["x"], [], ["x"], "no_grant_keeps_intersection"),
],
)
async def test_access_group_grants_are_additive_over_ceiling(
self, key_servers, team_servers, grant_servers, expected, scenario
):
"""Regression: key.access_group_ids grants are unioned on top of the
key/team MCP ceiling, so a grant reaches the caller even when the team
ceiling does not include it (and even when key team is empty)."""
mock_user_auth = UserAPIKeyAuth(
api_key="test-key",
user_id="test-user",
team_id="test-team",
access_group_ids=["grp-mcp"],
)
with (
patch.object(
MCPRequestHandler, "_get_allowed_mcp_servers_for_key"
) as mock_key,
patch.object(
MCPRequestHandler, "_get_allowed_mcp_servers_for_team"
) as mock_team,
patch.object(
MCPRequestHandler, "_get_key_access_group_mcp_server_extras"
) as mock_grants,
):
mock_key.return_value = key_servers
mock_team.return_value = team_servers
mock_grants.return_value = grant_servers
result = await MCPRequestHandler.get_allowed_mcp_servers(mock_user_auth)
assert sorted(result) == sorted(expected)
async def test_access_group_extras_returns_empty_when_no_auth(self):
"""No auth object → no additive grants."""
result = await MCPRequestHandler._get_key_access_group_mcp_server_extras(None)
assert result == []
async def test_access_group_extras_returns_empty_without_access_group_ids(self):
"""A key with no resolvable access groups yields no additive grants
(the `if not raw_server_ids: return []` branch)."""
auth = UserAPIKeyAuth(api_key="k", access_group_ids=[])
with (
patch(
"litellm.proxy.auth.auth_checks._get_mcp_server_ids_from_access_groups",
new=AsyncMock(return_value=[]),
),
patch(
"litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager"
) as mock_mgr,
):
result = await MCPRequestHandler._get_key_access_group_mcp_server_extras(
auth
)
assert result == []
# expand_permission_list must not be reached when there are no raw ids.
mock_mgr.expand_permission_list.assert_not_called()
async def test_access_group_extras_expands_resolved_server_ids(self):
"""Resolved access-group server ids/names are expanded to server ids."""
auth = UserAPIKeyAuth(api_key="k", access_group_ids=["grp-mcp"])
with (
patch(
"litellm.proxy.auth.auth_checks._get_mcp_server_ids_from_access_groups",
new=AsyncMock(return_value=["alias-a", "srv-b"]),
),
patch(
"litellm.proxy._experimental.mcp_server.mcp_server_manager.global_mcp_server_manager"
) as mock_mgr,
):
mock_mgr.expand_permission_list.return_value = ["srv-a", "srv-b"]
result = await MCPRequestHandler._get_key_access_group_mcp_server_extras(
auth
)
assert sorted(result) == ["srv-a", "srv-b"]
mock_mgr.expand_permission_list.assert_called_once_with(["alias-a", "srv-b"])
async def test_access_group_extras_swallows_errors(self):
"""Resolution failures degrade to no grants rather than raising."""
auth = UserAPIKeyAuth(api_key="k", access_group_ids=["grp-mcp"])
with patch(
"litellm.proxy.auth.auth_checks._get_mcp_server_ids_from_access_groups",
new=AsyncMock(side_effect=Exception("db down")),
):
result = await MCPRequestHandler._get_key_access_group_mcp_server_extras(
auth
)
assert result == []
@pytest.mark.parametrize(
"headers,expected_api_key,expected_mcp_auth_header,expected_server_auth_headers",
[
@ -3335,12 +3446,12 @@ async def test_mcp_key_access_group_extras_when_group_has_no_servers():
@pytest.mark.asyncio
async def test_mcp_key_access_group_extras_when_group_authorizes_neither():
"""
Escalation regression: team member attaches a foreign access group to their key.
Group grants servers BUT assigned_team_ids/assigned_key_ids exclude this caller.
No extras contributed.
"""
async def test_mcp_key_access_group_extras_granted_even_when_group_authorizes_neither():
"""Grants are ungated: attaching the group to the key is itself the grant, so its
servers are contributed even when assigned_team_ids/assigned_key_ids exclude this
caller. (A team member self-assigning a foreign group to reach past the team
ceiling is a known, accepted-for-now tradeoff; restricting who may set
key.access_group_ids is a separate concern.)"""
valid_token = UserAPIKeyAuth(
token="team-a-token",
access_group_ids=["team-b-mcp-group"],
@ -3365,7 +3476,7 @@ async def test_mcp_key_access_group_extras_when_group_authorizes_neither():
result = await MCPRequestHandler._get_key_access_group_mcp_server_extras(
valid_token
)
assert result == []
assert result == ["srv-finance-only"]
finally:
_stop_patches(patches)
@ -3650,11 +3761,12 @@ async def test_get_allowed_mcp_servers_includes_team_access_group_extras_end_to_
@pytest.mark.asyncio
async def test_key_access_group_ids_resolves_mcp_servers_ungated():
"""A teamless key whose unified access_group_ids grant an MCP server sees it
even though the group lists the key in NEITHER assigned_key_ids NOR
assigned_team_ids the group is attached to the key, so it grants the key's
own scope (ungated, mirroring can_key_call_model's fallback)."""
async def test_allowed_mcp_servers_for_key_excludes_access_group_ids():
"""The key's own ceiling (which is intersected against the team) must NOT resolve
access_group_ids those are additive grants handled separately, so folding them
in here is exactly the bug this fix removes. A key with only access_group_ids and
no object_permission yields an empty ceiling, and the group resolver is never
called from this path."""
auth = UserAPIKeyAuth(
token="test-token-hash",
api_key="sk-test",
@ -3671,15 +3783,16 @@ async def test_key_access_group_ids_resolves_mcp_servers_ungated():
):
result = await MCPRequestHandler._get_allowed_mcp_servers_for_key(auth)
assert result == ["srv-stripe"]
mock_resolver.assert_called_once()
assert mock_resolver.call_args.kwargs["access_group_ids"] == ["mcp-premium"]
assert result == []
mock_resolver.assert_not_called()
@pytest.mark.asyncio
async def test_key_access_group_ids_union_with_object_permission():
"""When both legacy key.object_permission and unified key.access_group_ids
grant MCP servers, the final list is their union."""
async def test_allowed_mcp_servers_for_key_uses_object_permission_not_access_groups():
"""The key's own ceiling is built from object_permission alone. Even when the key
also carries access_group_ids that would resolve to other servers, those grants do
NOT enter this (intersected) scope only the object_permission server comes back.
"""
from litellm.proxy._experimental.mcp_server.mcp_server_manager import (
global_mcp_server_manager,
)
@ -3714,82 +3827,40 @@ async def test_key_access_group_ids_union_with_object_permission():
"litellm.proxy.auth.auth_checks._get_mcp_server_ids_from_access_groups",
new_callable=AsyncMock,
return_value=["srv-stripe"],
),
) as mock_resolver,
):
result = await MCPRequestHandler._get_allowed_mcp_servers_for_key(auth)
assert set(result) == {"srv-direct", "srv-stripe"}
assert set(result) == {"srv-direct"}
mock_resolver.assert_not_called()
finally:
global_mcp_server_manager.registry.pop("srv-direct", None)
@pytest.mark.asyncio
async def test_key_access_group_ids_empty_returns_no_extras():
"""Empty key.access_group_ids and no object_permission → resolver called with
[], short-circuits without DB access, returns []."""
auth = UserAPIKeyAuth(
token="test-token-hash",
api_key="sk-test",
access_group_ids=[],
)
with (
patch("litellm.proxy.proxy_server.prisma_client", MagicMock()),
patch(
"litellm.proxy.auth.auth_checks._get_mcp_server_ids_from_access_groups",
new_callable=AsyncMock,
return_value=[],
) as mock_resolver,
):
result = await MCPRequestHandler._get_allowed_mcp_servers_for_key(auth)
assert result == []
mock_resolver.assert_called_once()
assert mock_resolver.call_args.kwargs["access_group_ids"] == []
@pytest.mark.asyncio
async def test_get_allowed_mcp_servers_key_access_group_base_end_to_end():
"""End-to-end bug repro: a teamless key has an MCP-granting access group on
its access_group_ids, but the group lists the key in NEITHER assigned_key_ids
NOR assigned_team_ids. The gated extras path returns [] (no override), yet the
ungated base key path grants the server the key sees it through
get_allowed_mcp_servers."""
async def test_get_allowed_mcp_servers_surfaces_ungated_key_access_group_grant_end_to_end():
"""End-to-end: a teamless key has an MCP-granting access group on its
access_group_ids. The grant is resolved ungated by the additive extras path and
surfaces through get_allowed_mcp_servers, even though the key's own ceiling
(object_permission) is empty."""
auth = UserAPIKeyAuth(
token="test-token",
api_key="sk-test",
access_group_ids=["mcp-group"],
)
# Group grants the server but admits neither this key nor its (absent) team.
fake_ag = _fake_mcp_access_group(
access_group_id="mcp-group",
access_mcp_server_ids=["srv-deepwiki"],
assigned_team_ids=[],
assigned_key_ids=[],
)
patches = _patch_proxy_server_globals_for_mcp() + [
# Ungated base resolver used by _get_allowed_mcp_servers_for_key.
patch(
"litellm.proxy.auth.auth_checks._get_mcp_server_ids_from_access_groups",
new_callable=AsyncMock,
return_value=["srv-deepwiki"],
),
# Gated path (_get_key_access_group_mcp_server_extras) resolves the group
# via get_access_object; empty assigned_* → it contributes nothing.
patch(
"litellm.proxy.auth.auth_checks.get_access_object",
new_callable=AsyncMock,
return_value=fake_ag,
),
]
_start_patches(patches)
try:
# Sanity: the gated extras path alone denies (the old behavior).
extras = await MCPRequestHandler._get_key_access_group_mcp_server_extras(auth)
assert extras == []
assert extras == ["srv-deepwiki"]
# But the key now sees the server via the ungated base path.
result = await MCPRequestHandler.get_allowed_mcp_servers(auth)
assert result == ["srv-deepwiki"]
finally:

View File

@ -265,3 +265,112 @@ class TestCanTeamMemberExecuteKeyManagementEndpoint:
user_api_key_cache=MagicMock(),
existing_key_row=existing_key_row,
)
class TestEnforceMemberCanAssignAccessGroups:
"""Opt-in gate controlling whether a non-admin team member may set
`access_group_ids` on a key (generate/update/regenerate)."""
AG_PERMISSION = KeyManagementRoutes.KEY_ACCESS_GROUP_ASSIGNMENT.value
def _user(self, role="internal_user", user_id="user-a"):
u = MagicMock()
u.user_role = role
u.user_id = user_id
return u
def _team(self, team_member_permissions, team_id="team-a"):
team = MagicMock()
team.team_id = team_id
team.team_member_permissions = team_member_permissions
return team
def test_no_access_group_ids_is_noop(self, monkeypatch):
"""When no access groups are requested the gate never raises, even
for a gated member with no opt-in permission."""
from litellm.proxy.management_endpoints import key_management_endpoints
monkeypatch.setattr(
key_management_endpoints,
"_get_user_in_team",
lambda **kwargs: Member(role="user", user_id="user-a"),
)
# Both None and empty list are no-ops.
for access_group_ids in (None, []):
TeamMemberPermissionChecks.enforce_member_can_assign_access_groups(
user_api_key_dict=self._user(),
team_table=self._team([]),
access_group_ids=access_group_ids,
)
def test_proxy_admin_bypasses(self, monkeypatch):
"""Proxy admins may assign access groups regardless of team opt-in."""
from litellm.proxy._types import LitellmUserRoles
TeamMemberPermissionChecks.enforce_member_can_assign_access_groups(
user_api_key_dict=self._user(role=LitellmUserRoles.PROXY_ADMIN.value),
team_table=self._team([]),
access_group_ids=["ag-1"],
)
def test_personal_key_out_of_scope(self):
"""Personal (non-team) keys are not gated by team-member permissions."""
TeamMemberPermissionChecks.enforce_member_can_assign_access_groups(
user_api_key_dict=self._user(),
team_table=None,
access_group_ids=["ag-1"],
)
def test_team_admin_bypasses(self, monkeypatch):
"""Team admins may assign access groups even without the opt-in perm."""
from litellm.proxy.management_endpoints import key_management_endpoints
monkeypatch.setattr(
key_management_endpoints,
"_get_user_in_team",
lambda **kwargs: Member(role="admin", user_id="user-a"),
)
TeamMemberPermissionChecks.enforce_member_can_assign_access_groups(
user_api_key_dict=self._user(),
team_table=self._team([]),
access_group_ids=["ag-1"],
)
def test_member_denied_without_opt_in(self, monkeypatch):
"""A non-admin member without the opt-in permission gets a 403."""
from fastapi import HTTPException
from litellm.proxy.management_endpoints import key_management_endpoints
monkeypatch.setattr(
key_management_endpoints,
"_get_user_in_team",
lambda **kwargs: Member(role="user", user_id="user-a"),
)
with pytest.raises(HTTPException) as exc:
TeamMemberPermissionChecks.enforce_member_can_assign_access_groups(
user_api_key_dict=self._user(),
team_table=self._team(["/key/generate", "/key/update"]),
access_group_ids=["ag-1"],
)
assert exc.value.status_code == 403
assert self.AG_PERMISSION in str(exc.value.detail)
def test_member_allowed_with_opt_in(self, monkeypatch):
"""A non-admin member is allowed once the team opts in via the perm."""
from litellm.proxy.management_endpoints import key_management_endpoints
monkeypatch.setattr(
key_management_endpoints,
"_get_user_in_team",
lambda **kwargs: Member(role="user", user_id="user-a"),
)
TeamMemberPermissionChecks.enforce_member_can_assign_access_groups(
user_api_key_dict=self._user(),
team_table=self._team(["/key/generate", self.AG_PERMISSION]),
access_group_ids=["ag-1"],
)

View File

@ -20,6 +20,8 @@ export const PERMISSION_DESCRIPTIONS: Record<string, string> = {
"/key/list": "Member can list virtual keys belonging to this team",
"/key/block": "Member can block a virtual key belonging to this team",
"/key/unblock": "Member can unblock a virtual key belonging to this team",
"/key/access_group_assignment":
"Member can assign access groups to virtual keys for this team",
"/team/daily/activity":
"Member can view all team usage data (not just their own)",
"/spend/logs":