diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index e959867091..bfcd13d909 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -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 diff --git a/tests/proxy_unit_tests/test_auth_checks.py b/tests/proxy_unit_tests/test_auth_checks.py index 6a404712c1..026abf99d8 100644 --- a/tests/proxy_unit_tests/test_auth_checks.py +++ b/tests/proxy_unit_tests/test_auth_checks.py @@ -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 + )