From 76ae0a8e4b772f8a33f1f3326fa8be123e725940 Mon Sep 17 00:00:00 2001 From: Kent Date: Sat, 1 Aug 2026 21:31:39 +0800 Subject: [PATCH] fix(router): apply the team guard to exact deployment-id lookups A caller who knew another team's deployment id could resolve its credentials and model through the exact-id branch of _resolve_unblocked_deployment, which skipped the team filter that the name and wildcard branches already apply. The exact-id hit now goes through _deployment_usable_by_team as well, so no lookup path resolves a deployment owned by a different team The router's internal _acancel_batch re-resolves credentials by the deployment id that async_get_available_deployment already picked and team-authorized, so it re-resolves with that deployment's own team id to stay compatible with the guard --- litellm/router.py | 30 ++++++++++++------- .../test_vector_store_endpoints.py | 2 +- .../test_vector_store_tenant_guard.py | 4 +-- tests/test_litellm/test_router.py | 9 ++++++ 4 files changed, 32 insertions(+), 13 deletions(-) diff --git a/litellm/router.py b/litellm/router.py index d95c9a9f298..51335691e18 100644 --- a/litellm/router.py +++ b/litellm/router.py @@ -5378,10 +5378,15 @@ class Router: request_kwargs=kwargs, ) - selected_deployment_id = (deployment.get("model_info") or {}).get("id") + selected_model_info = deployment.get("model_info") or {} + selected_deployment_id = selected_model_info.get("id") data = deployment["litellm_params"].copy() + # async_get_available_deployment already team-authorized this + # deployment; re-resolve with its owner team so the resolver's + # team guard doesn't reject the deployment it was handed. resolved_credentials = self.get_deployment_credentials_with_provider( - model_id=selected_deployment_id or model + model_id=selected_deployment_id or model, + team_id=selected_model_info.get("team_id"), ) if resolved_credentials is not None: data.update(resolved_credentials) @@ -8647,12 +8652,16 @@ class Router: Both the credential resolver and the alias-to-model resolver delegate here so a mixed model group can never hand one caller the credentials - of one deployment and the model of another. Name and wildcard lookups - skip deployments owned by a team other than ``team_id``; passing - ``team_id`` also unlocks that team's own deployments (exact team public - model name and team wildcard patterns). + of one deployment and the model of another. Every lookup path - + exact deployment id, model-group name, and wildcard - skips + deployments owned by a team other than ``team_id``, so a caller who + knows another team's deployment id cannot resolve its credentials or + model; passing ``team_id`` also unlocks that team's own deployments + (exact team public model name and team wildcard patterns). """ deployment = self.get_deployment(model_id=model_id) + if deployment is not None and not self._deployment_usable_by_team(deployment, team_id): + deployment = None if deployment is None: deployment = self._get_model_group_deployment_usable_by_team(model_group_name=model_id, team_id=team_id) @@ -8775,10 +8784,11 @@ class Router: model_id: Model ID or model name from model_list (e.g., "gpt-4o-litellm") team_id: Optional team id of the caller. When set, team-scoped deployments (indexed by team public model name, including team - wildcard models like "openai/*") are also considered. Name and - wildcard lookups never resolve a deployment owned by a - different team, so shared model names can't leak another - team's credentials. + wildcard models like "openai/*") are also considered. No lookup + path - exact deployment id, model-group name, or wildcard - + ever resolves a deployment owned by a different team, so + neither shared model names nor known deployment ids can leak + another team's credentials. Returns: Dictionary containing api_key, api_base, custom_llm_provider, etc. diff --git a/tests/test_litellm/proxy/vector_store_endpoints/test_vector_store_endpoints.py b/tests/test_litellm/proxy/vector_store_endpoints/test_vector_store_endpoints.py index e7de8b54e4e..9fa510cd4cb 100644 --- a/tests/test_litellm/proxy/vector_store_endpoints/test_vector_store_endpoints.py +++ b/tests/test_litellm/proxy/vector_store_endpoints/test_vector_store_endpoints.py @@ -177,7 +177,7 @@ async def test_vector_store_file_list_resolves_credentials_from_model_query_para assert result["model"] == "openai/gpt-4o-mini" assert "custom_llm_provider" not in result llm_router.get_deployment_credentials_with_provider.assert_called_once_with( - model_id="team-openai" + model_id="team-openai", team_id=None ) diff --git a/tests/test_litellm/proxy/vector_store_endpoints/test_vector_store_tenant_guard.py b/tests/test_litellm/proxy/vector_store_endpoints/test_vector_store_tenant_guard.py index b1bd7ccbf0f..098678a176a 100644 --- a/tests/test_litellm/proxy/vector_store_endpoints/test_vector_store_tenant_guard.py +++ b/tests/test_litellm/proxy/vector_store_endpoints/test_vector_store_tenant_guard.py @@ -138,7 +138,7 @@ async def test_vector_store_file_list_resolves_managed_vector_store_before_team_ llm_router = MagicMock() - def get_credentials(model_id): + def get_credentials(model_id, team_id=None): return { "api_key": f"sk-{model_id}", "api_base": "https://api.openai.com/v1", @@ -171,7 +171,7 @@ async def test_vector_store_file_list_resolves_managed_vector_store_before_team_ assert captured_data["api_key"] == "sk-managed-deployment" assert captured_data["model"] == "openai/managed-deployment" llm_router.get_deployment_credentials_with_provider.assert_called_once_with( - model_id="managed-deployment" + model_id="managed-deployment", team_id=None ) diff --git a/tests/test_litellm/test_router.py b/tests/test_litellm/test_router.py index 3cfa9fe16c4..905b0fc1cd0 100644 --- a/tests/test_litellm/test_router.py +++ b/tests/test_litellm/test_router.py @@ -5892,6 +5892,15 @@ def test_get_deployment_model_for_alias_matches_credential_deployment_per_team() f"team_id={team_id}: credentials came from a different deployment than the model" ) + # A caller who knows another team's exact deployment id must not resolve + # its model or credentials through it either. + for outsider_team_id in [None, "team-b"]: + assert router.get_deployment_model_for_alias(model_id="team-a-dep", team_id=outsider_team_id) is None + assert router.get_deployment_credentials_with_provider(model_id="team-a-dep", team_id=outsider_team_id) is None + assert router.get_deployment_model_for_alias(model_id="team-a-dep", team_id="team-a") == ( + "bedrock/team-a-private-model" + ) + def test_resolve_unblocked_deployment_resolves_alias_id_and_wildcard(): """