From 98137f15671687bbe792c0da35d4fce0e3077eba Mon Sep 17 00:00:00 2001 From: mubashir1osmani Date: Thu, 14 May 2026 07:55:37 -0400 Subject: [PATCH 1/5] restrict team level access groups on keys --- litellm/proxy/auth/auth_checks.py | 29 +++++++++++++++++++++++-- litellm/proxy/auth/user_api_key_auth.py | 4 +++- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 0b30999aa21..224c936a1d5 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -2891,18 +2891,43 @@ async def can_key_call_model( Raises: - Exception: If token not allowed to call model """ + key_access_group_ids = valid_token.access_group_ids or [] + key_models = valid_token.models or [] + key_defers_to_team_models = ( + len(key_models) == 0 or SpecialModelNames.all_team_models.value in key_models + ) + + if key_access_group_ids and key_defers_to_team_models: + models_from_groups = await _get_models_from_access_groups( + access_group_ids=key_access_group_ids, + ) + if models_from_groups: + return _can_object_call_model( + model=model, + llm_router=llm_router, + models=models_from_groups, + team_model_aliases=valid_token.team_model_aliases, + team_id=valid_token.team_id, + object_type="key", + ) + raise ProxyException( + message=f"key not allowed to access model. This key has access_group_ids={key_access_group_ids}, but those groups do not grant any models. Tried to access {model}", + type=ProxyErrorTypes.key_model_access_denied, + param="model", + code=status.HTTP_403_FORBIDDEN, + ) + try: return _can_object_call_model( model=model, llm_router=llm_router, - models=valid_token.models, + models=key_models, team_model_aliases=valid_token.team_model_aliases, team_id=valid_token.team_id, object_type="key", ) except ProxyException: # Fallback: check key's access_group_ids - key_access_group_ids = valid_token.access_group_ids or [] if key_access_group_ids: models_from_groups = await _get_models_from_access_groups( access_group_ids=key_access_group_ids, diff --git a/litellm/proxy/auth/user_api_key_auth.py b/litellm/proxy/auth/user_api_key_auth.py index 03167c5a2dc..6e29bd9c78a 100644 --- a/litellm/proxy/auth/user_api_key_auth.py +++ b/litellm/proxy/auth/user_api_key_auth.py @@ -2339,7 +2339,9 @@ async def _enforce_key_and_fallback_model_access( new_model_list = model_list verbose_proxy_logger.debug(f"\n new llm router model list {new_model_list}") elif ( - isinstance(valid_token.models, list) and "all-team-models" in valid_token.models + isinstance(valid_token.models, list) + and "all-team-models" in valid_token.models + and not valid_token.access_group_ids ): pass else: From b5ea52c6cf91415912191d2104797c9b513e26a0 Mon Sep 17 00:00:00 2001 From: mubashir1osmani Date: Thu, 14 May 2026 08:01:38 -0400 Subject: [PATCH 2/5] add test --- tests/proxy_unit_tests/test_auth_checks.py | 109 +++++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/tests/proxy_unit_tests/test_auth_checks.py b/tests/proxy_unit_tests/test_auth_checks.py index d9f4a6e56b8..d16acf51420 100644 --- a/tests/proxy_unit_tests/test_auth_checks.py +++ b/tests/proxy_unit_tests/test_auth_checks.py @@ -1155,6 +1155,115 @@ async def test_can_key_call_model_via_access_group_ids(): ) +@pytest.mark.asyncio +async def test_can_key_call_model_restricts_empty_key_models_to_access_group_ids(): + """Keys with access_group_ids and no model list are restricted to those groups.""" + from unittest.mock import AsyncMock, patch + + from litellm.proxy._types import ProxyException + from litellm.proxy.auth.auth_checks import can_key_call_model + + user_api_key_object = UserAPIKeyAuth( + token="test-token", + models=[], + access_group_ids=["ag-with-gpt4"], + ) + router = litellm.Router( + model_list=[ + { + "model_name": "gpt-4", + "litellm_params": {"model": "openai/gpt-4", "api_key": "test"}, + }, + { + "model_name": "claude-3", + "litellm_params": {"model": "anthropic/claude-3", "api_key": "test"}, + }, + ] + ) + + with patch( + "litellm.proxy.auth.auth_checks._get_models_from_access_groups", + new_callable=AsyncMock, + return_value=["gpt-4"], + ): + with pytest.raises(ProxyException): + await can_key_call_model( + model="claude-3", + llm_model_list=[], + valid_token=user_api_key_object, + llm_router=router, + ) + + +@pytest.mark.asyncio +async def test_can_key_call_model_restricts_all_team_models_to_access_group_ids(): + """Keys with all-team-models and access_group_ids are restricted to those groups.""" + from unittest.mock import AsyncMock, patch + + from litellm.proxy._types import ProxyException + from litellm.proxy.auth.auth_checks import can_key_call_model + + user_api_key_object = UserAPIKeyAuth( + token="test-token", + models=["all-team-models"], + access_group_ids=["ag-with-gpt4"], + team_models=["gpt-4", "claude-3"], + ) + router = litellm.Router( + model_list=[ + { + "model_name": "gpt-4", + "litellm_params": {"model": "openai/gpt-4", "api_key": "test"}, + }, + { + "model_name": "claude-3", + "litellm_params": {"model": "anthropic/claude-3", "api_key": "test"}, + }, + ] + ) + + with patch( + "litellm.proxy.auth.auth_checks._get_models_from_access_groups", + new_callable=AsyncMock, + return_value=["gpt-4"], + ): + with pytest.raises(ProxyException): + await can_key_call_model( + model="claude-3", + llm_model_list=[], + valid_token=user_api_key_object, + llm_router=router, + ) + + +@pytest.mark.asyncio +async def test_can_key_call_model_denies_when_access_group_ids_resolve_no_models(): + """Keys with access_group_ids do not fall back to all models when groups are empty.""" + from unittest.mock import AsyncMock, patch + + from litellm.proxy._types import ProxyException + from litellm.proxy.auth.auth_checks import can_key_call_model + + user_api_key_object = UserAPIKeyAuth( + token="test-token", + models=[], + access_group_ids=["empty-group"], + ) + + with patch( + "litellm.proxy.auth.auth_checks._get_models_from_access_groups", + new_callable=AsyncMock, + return_value=[], + ): + with pytest.raises(ProxyException): + await can_key_call_model( + model="gpt-4", + llm_model_list=[], + valid_token=user_api_key_object, + llm_router=None, + ) + + # --------------------------------------------------------------------------- # _key_access_group_grants_model (key access group overriding team restriction) # --------------------------------------------------------------------------- From 1c889f0e44aea4a21d0a3b544168a161d0f6da7e Mon Sep 17 00:00:00 2001 From: mubashir1osmani Date: Thu, 14 May 2026 08:12:38 -0400 Subject: [PATCH 3/5] add warning, if no models in assigned access groups --- litellm/proxy/auth/auth_checks.py | 8 ++++++++ tests/proxy_unit_tests/test_auth_checks.py | 13 +++++++++---- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 224c936a1d5..b6078d1cc11 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -2910,6 +2910,14 @@ async def can_key_call_model( team_id=valid_token.team_id, object_type="key", ) + verbose_proxy_logger.warning( + "Key has access_group_ids=%s, but those access groups resolved to no model permissions. " + "Denying model=%s for key_alias=%s, team_id=%s.", + key_access_group_ids, + model, + valid_token.key_alias, + valid_token.team_id, + ) raise ProxyException( message=f"key not allowed to access model. This key has access_group_ids={key_access_group_ids}, but those groups do not grant any models. Tried to access {model}", type=ProxyErrorTypes.key_model_access_denied, diff --git a/tests/proxy_unit_tests/test_auth_checks.py b/tests/proxy_unit_tests/test_auth_checks.py index d16acf51420..9535fe49ebe 100644 --- a/tests/proxy_unit_tests/test_auth_checks.py +++ b/tests/proxy_unit_tests/test_auth_checks.py @@ -1250,10 +1250,13 @@ async def test_can_key_call_model_denies_when_access_group_ids_resolve_no_models access_group_ids=["empty-group"], ) - with patch( - "litellm.proxy.auth.auth_checks._get_models_from_access_groups", - new_callable=AsyncMock, - return_value=[], + with ( + patch( + "litellm.proxy.auth.auth_checks._get_models_from_access_groups", + new_callable=AsyncMock, + return_value=[], + ), + patch("litellm.proxy.auth.auth_checks.verbose_proxy_logger.warning") as warning, ): with pytest.raises(ProxyException): await can_key_call_model( @@ -1262,6 +1265,8 @@ async def test_can_key_call_model_denies_when_access_group_ids_resolve_no_models valid_token=user_api_key_object, llm_router=None, ) + warning.assert_called_once() + assert "resolved to no model permissions" in warning.call_args.args[0] # --------------------------------------------------------------------------- From aa311bc833750756c809559ced943af028493de3 Mon Sep 17 00:00:00 2001 From: mubashir1osmani Date: Thu, 14 May 2026 08:40:16 -0400 Subject: [PATCH 4/5] fix --- litellm/proxy/auth/auth_checks.py | 57 ++++++++++++++++++++-- tests/proxy_unit_tests/test_auth_checks.py | 56 +++++++++++++++++++++ 2 files changed, 108 insertions(+), 5 deletions(-) diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index b6078d1cc11..89a28bcbc4a 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -2873,6 +2873,40 @@ def _model_in_team_aliases( return False +def _model_list_allows_model(model: str, allowed_models: List[str]) -> bool: + if ( + "*" in allowed_models + or SpecialModelNames.all_proxy_models.value in allowed_models + ): + return True + if model in allowed_models: + return True + return _model_matches_any_wildcard_pattern_in_list( + model=model, allowed_model_list=allowed_models + ) + + +def _filter_team_model_aliases_by_allowed_models( + team_model_aliases: Optional[Dict[str, str]], + allowed_models: List[str], +) -> Optional[Dict[str, str]]: + """ + Return only aliases whose resolved target is allowed by `allowed_models`. + + This is used when the effective allowlist comes from access groups. Without + this filter, any team alias name would bypass the access-group model list. + """ + if not team_model_aliases: + return team_model_aliases + + filtered_aliases = { + alias: target + for alias, target in team_model_aliases.items() + if _model_list_allows_model(model=target, allowed_models=allowed_models) + } + return filtered_aliases + + async def can_key_call_model( model: Union[str, List[str]], llm_model_list: Optional[list], @@ -2906,7 +2940,10 @@ async def can_key_call_model( model=model, llm_router=llm_router, models=models_from_groups, - team_model_aliases=valid_token.team_model_aliases, + team_model_aliases=_filter_team_model_aliases_by_allowed_models( + team_model_aliases=valid_token.team_model_aliases, + allowed_models=models_from_groups, + ), team_id=valid_token.team_id, object_type="key", ) @@ -2945,7 +2982,10 @@ async def can_key_call_model( model=model, llm_router=llm_router, models=models_from_groups, - team_model_aliases=valid_token.team_model_aliases, + team_model_aliases=_filter_team_model_aliases_by_allowed_models( + team_model_aliases=valid_token.team_model_aliases, + allowed_models=models_from_groups, + ), team_id=valid_token.team_id, object_type="key", ) @@ -3006,7 +3046,10 @@ async def can_team_access_model( model=model, llm_router=llm_router, models=models_from_groups, - team_model_aliases=team_model_aliases, + team_model_aliases=_filter_team_model_aliases_by_allowed_models( + team_model_aliases=team_model_aliases, + allowed_models=models_from_groups, + ), team_id=team_object.team_id if team_object else None, object_type="team", ) @@ -3070,12 +3113,16 @@ async def _key_access_group_grants_model( if not authorized_models: return False + authorized_models = list(set(authorized_models)) try: _can_object_call_model( model=model, llm_router=llm_router, - models=list(set(authorized_models)), - team_model_aliases=valid_token.team_model_aliases, + models=authorized_models, + team_model_aliases=_filter_team_model_aliases_by_allowed_models( + team_model_aliases=valid_token.team_model_aliases, + allowed_models=authorized_models, + ), team_id=valid_token.team_id, object_type="key", ) diff --git a/tests/proxy_unit_tests/test_auth_checks.py b/tests/proxy_unit_tests/test_auth_checks.py index 9535fe49ebe..a09ea3f9ad0 100644 --- a/tests/proxy_unit_tests/test_auth_checks.py +++ b/tests/proxy_unit_tests/test_auth_checks.py @@ -1269,6 +1269,62 @@ async def test_can_key_call_model_denies_when_access_group_ids_resolve_no_models assert "resolved to no model permissions" in warning.call_args.args[0] +@pytest.mark.asyncio +async def test_can_key_call_model_denies_team_alias_outside_access_group_models(): + """Team aliases do not bypass a key's access-group model allowlist.""" + from unittest.mock import AsyncMock, patch + + from litellm.proxy._types import ProxyException + from litellm.proxy.auth.auth_checks import can_key_call_model + + user_api_key_object = UserAPIKeyAuth( + token="test-token", + models=[], + access_group_ids=["ag-with-gpt4"], + team_model_aliases={"premium-alias": "claude-3"}, + ) + + with patch( + "litellm.proxy.auth.auth_checks._get_models_from_access_groups", + new_callable=AsyncMock, + return_value=["gpt-4"], + ): + with pytest.raises(ProxyException): + await can_key_call_model( + model="premium-alias", + llm_model_list=[], + valid_token=user_api_key_object, + llm_router=None, + ) + + +@pytest.mark.asyncio +async def test_can_key_call_model_allows_team_alias_inside_access_group_models(): + """Team aliases remain usable when their target is in the key's access groups.""" + from unittest.mock import AsyncMock, patch + + from litellm.proxy.auth.auth_checks import can_key_call_model + + user_api_key_object = UserAPIKeyAuth( + token="test-token", + models=[], + access_group_ids=["ag-with-gpt4"], + team_model_aliases={"premium-alias": "gpt-4"}, + ) + + with patch( + "litellm.proxy.auth.auth_checks._get_models_from_access_groups", + new_callable=AsyncMock, + return_value=["gpt-4"], + ): + await can_key_call_model( + model="premium-alias", + llm_model_list=[], + valid_token=user_api_key_object, + llm_router=None, + ) + + # --------------------------------------------------------------------------- # _key_access_group_grants_model (key access group overriding team restriction) # --------------------------------------------------------------------------- From a8b828790c525b62fe724f21ed8b8c1e96a31567 Mon Sep 17 00:00:00 2001 From: mubashir1osmani Date: Thu, 14 May 2026 08:42:01 -0400 Subject: [PATCH 5/5] Revert "fix" This reverts commit aa311bc833750756c809559ced943af028493de3. --- litellm/proxy/auth/auth_checks.py | 57 ++-------------------- tests/proxy_unit_tests/test_auth_checks.py | 56 --------------------- 2 files changed, 5 insertions(+), 108 deletions(-) diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 89a28bcbc4a..b6078d1cc11 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -2873,40 +2873,6 @@ def _model_in_team_aliases( return False -def _model_list_allows_model(model: str, allowed_models: List[str]) -> bool: - if ( - "*" in allowed_models - or SpecialModelNames.all_proxy_models.value in allowed_models - ): - return True - if model in allowed_models: - return True - return _model_matches_any_wildcard_pattern_in_list( - model=model, allowed_model_list=allowed_models - ) - - -def _filter_team_model_aliases_by_allowed_models( - team_model_aliases: Optional[Dict[str, str]], - allowed_models: List[str], -) -> Optional[Dict[str, str]]: - """ - Return only aliases whose resolved target is allowed by `allowed_models`. - - This is used when the effective allowlist comes from access groups. Without - this filter, any team alias name would bypass the access-group model list. - """ - if not team_model_aliases: - return team_model_aliases - - filtered_aliases = { - alias: target - for alias, target in team_model_aliases.items() - if _model_list_allows_model(model=target, allowed_models=allowed_models) - } - return filtered_aliases - - async def can_key_call_model( model: Union[str, List[str]], llm_model_list: Optional[list], @@ -2940,10 +2906,7 @@ async def can_key_call_model( model=model, llm_router=llm_router, models=models_from_groups, - team_model_aliases=_filter_team_model_aliases_by_allowed_models( - team_model_aliases=valid_token.team_model_aliases, - allowed_models=models_from_groups, - ), + team_model_aliases=valid_token.team_model_aliases, team_id=valid_token.team_id, object_type="key", ) @@ -2982,10 +2945,7 @@ async def can_key_call_model( model=model, llm_router=llm_router, models=models_from_groups, - team_model_aliases=_filter_team_model_aliases_by_allowed_models( - team_model_aliases=valid_token.team_model_aliases, - allowed_models=models_from_groups, - ), + team_model_aliases=valid_token.team_model_aliases, team_id=valid_token.team_id, object_type="key", ) @@ -3046,10 +3006,7 @@ async def can_team_access_model( model=model, llm_router=llm_router, models=models_from_groups, - team_model_aliases=_filter_team_model_aliases_by_allowed_models( - team_model_aliases=team_model_aliases, - allowed_models=models_from_groups, - ), + team_model_aliases=team_model_aliases, team_id=team_object.team_id if team_object else None, object_type="team", ) @@ -3113,16 +3070,12 @@ async def _key_access_group_grants_model( if not authorized_models: return False - authorized_models = list(set(authorized_models)) try: _can_object_call_model( model=model, llm_router=llm_router, - models=authorized_models, - team_model_aliases=_filter_team_model_aliases_by_allowed_models( - team_model_aliases=valid_token.team_model_aliases, - allowed_models=authorized_models, - ), + models=list(set(authorized_models)), + team_model_aliases=valid_token.team_model_aliases, team_id=valid_token.team_id, object_type="key", ) diff --git a/tests/proxy_unit_tests/test_auth_checks.py b/tests/proxy_unit_tests/test_auth_checks.py index a09ea3f9ad0..9535fe49ebe 100644 --- a/tests/proxy_unit_tests/test_auth_checks.py +++ b/tests/proxy_unit_tests/test_auth_checks.py @@ -1269,62 +1269,6 @@ async def test_can_key_call_model_denies_when_access_group_ids_resolve_no_models assert "resolved to no model permissions" in warning.call_args.args[0] -@pytest.mark.asyncio -async def test_can_key_call_model_denies_team_alias_outside_access_group_models(): - """Team aliases do not bypass a key's access-group model allowlist.""" - from unittest.mock import AsyncMock, patch - - from litellm.proxy._types import ProxyException - from litellm.proxy.auth.auth_checks import can_key_call_model - - user_api_key_object = UserAPIKeyAuth( - token="test-token", - models=[], - access_group_ids=["ag-with-gpt4"], - team_model_aliases={"premium-alias": "claude-3"}, - ) - - with patch( - "litellm.proxy.auth.auth_checks._get_models_from_access_groups", - new_callable=AsyncMock, - return_value=["gpt-4"], - ): - with pytest.raises(ProxyException): - await can_key_call_model( - model="premium-alias", - llm_model_list=[], - valid_token=user_api_key_object, - llm_router=None, - ) - - -@pytest.mark.asyncio -async def test_can_key_call_model_allows_team_alias_inside_access_group_models(): - """Team aliases remain usable when their target is in the key's access groups.""" - from unittest.mock import AsyncMock, patch - - from litellm.proxy.auth.auth_checks import can_key_call_model - - user_api_key_object = UserAPIKeyAuth( - token="test-token", - models=[], - access_group_ids=["ag-with-gpt4"], - team_model_aliases={"premium-alias": "gpt-4"}, - ) - - with patch( - "litellm.proxy.auth.auth_checks._get_models_from_access_groups", - new_callable=AsyncMock, - return_value=["gpt-4"], - ): - await can_key_call_model( - model="premium-alias", - llm_model_list=[], - valid_token=user_api_key_object, - llm_router=None, - ) - - # --------------------------------------------------------------------------- # _key_access_group_grants_model (key access group overriding team restriction) # ---------------------------------------------------------------------------