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
This commit is contained in:
Kent 2026-08-01 21:31:39 +08:00
parent adbb3e1bce
commit 76ae0a8e4b
4 changed files with 32 additions and 13 deletions

View file

@ -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.

View file

@ -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
)

View file

@ -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
)

View file

@ -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():
"""