fix: scope key access_group_ids override by team's assigned groups

A team member could set any access_group_ids on their key (e.g. a group
assigned only to a different team) and override the team's model
restriction. Intersect the key's access_group_ids with team_object.access_group_ids
in _key_access_group_grants_model so foreign groups are dropped before
model expansion. Adds a regression test that asserts expansion is never
called for foreign groups.
This commit is contained in:
Ryan Crabbe 2026-05-01 15:54:03 -07:00
parent f92594f2c6
commit f17d779666
No known key found for this signature in database
2 changed files with 102 additions and 4 deletions

View File

@ -512,6 +512,7 @@ async def common_checks( # noqa: PLR0915
if not await _key_access_group_grants_model(
model=_model,
valid_token=valid_token,
team_object=team_object,
llm_router=llm_router,
):
raise
@ -2870,20 +2871,28 @@ async def can_team_access_model(
async def _key_access_group_grants_model(
model: Union[str, List[str]],
valid_token: Optional[UserAPIKeyAuth],
team_object: Optional[LiteLLM_TeamTable],
llm_router: Optional[Router],
) -> bool:
"""
Returns True if the key's `access_group_ids` expand to models that grant
access to `model`. Used to let a key's access group override a team's
model restriction in `common_checks`.
A key's access group only counts if it is also assigned to the key's team
(i.e., present in `team_object.access_group_ids`). This preserves the
team-as-owner boundary: a team member cannot escalate by naming an access
group that belongs to a different team.
"""
if valid_token is None:
if valid_token is None or team_object is None:
return False
key_access_group_ids = valid_token.access_group_ids or []
if not key_access_group_ids:
key_access_group_ids = set(valid_token.access_group_ids or [])
team_access_group_ids = set(team_object.access_group_ids or [])
allowed_group_ids = key_access_group_ids & team_access_group_ids
if not allowed_group_ids:
return False
models_from_groups = await _get_models_from_access_groups(
access_group_ids=key_access_group_ids,
access_group_ids=list(allowed_group_ids),
)
if not models_from_groups:
return False

View File

@ -1164,6 +1164,12 @@ async def test_key_access_group_grants_model_when_group_covers_model():
token="test-token",
models=[],
access_group_ids=["ryan-access-group"],
team_id="team-a",
)
team_object = LiteLLM_TeamTable(
team_id="team-a",
models=["mock-success"],
access_group_ids=["ryan-access-group"],
)
with patch(
@ -1175,6 +1181,7 @@ async def test_key_access_group_grants_model_when_group_covers_model():
await _key_access_group_grants_model(
model="claude-haiku-4-5",
valid_token=valid_token,
team_object=team_object,
llm_router=None,
)
is True
@ -1190,11 +1197,18 @@ async def test_key_access_group_grants_model_when_key_has_no_groups():
token="test-token",
models=[],
access_group_ids=[],
team_id="team-a",
)
team_object = LiteLLM_TeamTable(
team_id="team-a",
models=["mock-success"],
access_group_ids=["ryan-access-group"],
)
assert (
await _key_access_group_grants_model(
model="claude-haiku-4-5",
valid_token=valid_token,
team_object=team_object,
llm_router=None,
)
is False
@ -1212,6 +1226,12 @@ async def test_key_access_group_grants_model_when_group_does_not_cover_model():
token="test-token",
models=[],
access_group_ids=["other-group"],
team_id="team-a",
)
team_object = LiteLLM_TeamTable(
team_id="team-a",
models=["mock-success"],
access_group_ids=["other-group"],
)
with patch(
@ -1223,7 +1243,76 @@ async def test_key_access_group_grants_model_when_group_does_not_cover_model():
await _key_access_group_grants_model(
model="claude-haiku-4-5",
valid_token=valid_token,
team_object=team_object,
llm_router=None,
)
is False
)
@pytest.mark.asyncio
async def test_key_access_group_grants_model_when_group_not_assigned_to_team():
"""
Regression test: a team member naming a foreign access group on their key
must NOT escalate to that group's models. The group expands to the requested
model, but it isn't assigned to the key's team so the override is denied.
"""
from unittest.mock import AsyncMock, patch
from litellm.proxy.auth.auth_checks import _key_access_group_grants_model
valid_token = UserAPIKeyAuth(
token="test-token",
models=[],
access_group_ids=["team-b-premium"],
team_id="team-a",
)
team_object = LiteLLM_TeamTable(
team_id="team-a",
models=["mock-success"],
access_group_ids=["team-a-basic"],
)
with patch(
"litellm.proxy.auth.auth_checks._get_models_from_access_groups",
new_callable=AsyncMock,
return_value=["claude-opus-4-5"],
) as mocked_expand:
assert (
await _key_access_group_grants_model(
model="claude-opus-4-5",
valid_token=valid_token,
team_object=team_object,
llm_router=None,
)
is False
)
# Foreign group must be filtered out before expansion ever runs.
mocked_expand.assert_not_called()
@pytest.mark.asyncio
async def test_key_access_group_grants_model_when_team_has_no_groups():
"""Team with no access_group_ids leaves the intersection empty → denied."""
from litellm.proxy.auth.auth_checks import _key_access_group_grants_model
valid_token = UserAPIKeyAuth(
token="test-token",
models=[],
access_group_ids=["ryan-access-group"],
team_id="team-a",
)
team_object = LiteLLM_TeamTable(
team_id="team-a",
models=["mock-success"],
access_group_ids=[],
)
assert (
await _key_access_group_grants_model(
model="claude-haiku-4-5",
valid_token=valid_token,
team_object=team_object,
llm_router=None,
)
is False
)