From 56b1fcabd83782eea06d9caf6804206ddb02fc84 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 15:59:12 +0000 Subject: [PATCH 1/4] fix(proxy/auth): expand all-team-models sentinel for project model access checks Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- litellm/proxy/auth/auth_checks.py | 1 + .../access_control/access_control_client.py | 26 ++++++ .../test_project_all_team_models_e2e.py | 93 +++++++++++++++++++ tests/e2e/models.py | 14 +++ .../proxy/auth/test_auth_checks.py | 55 +++++++++++ 5 files changed, 189 insertions(+) create mode 100644 tests/e2e/access_control/test_project_all_team_models_e2e.py diff --git a/litellm/proxy/auth/auth_checks.py b/litellm/proxy/auth/auth_checks.py index 8fbeaf18460..5b2c6b5b1ab 100644 --- a/litellm/proxy/auth/auth_checks.py +++ b/litellm/proxy/auth/auth_checks.py @@ -5169,6 +5169,7 @@ def can_project_access_model( model=model, llm_router=llm_router, models=project_object.models if project_object else [], + team_id=project_object.team_id if project_object else None, key_model_aliases=key_model_aliases, object_type="project", ) diff --git a/tests/e2e/access_control/access_control_client.py b/tests/e2e/access_control/access_control_client.py index 5f459c09767..c87feb68711 100644 --- a/tests/e2e/access_control/access_control_client.py +++ b/tests/e2e/access_control/access_control_client.py @@ -16,6 +16,9 @@ from models import ( LiteLLMParamsBody, ModelInfoBody, ModelNewBody, + ProjectCreateBody, + ProjectDeleteBody, + ProjectIdentity, TeamDeleteBody, TeamInfoParams, TeamInfoResponse, @@ -26,7 +29,9 @@ from models import ( MODEL_ACCESS_DENIED_MARKER = "key_model_access_denied" TEAM_MODEL_ACCESS_DENIED_MARKER = "team_model_access_denied" +PROJECT_MODEL_ACCESS_DENIED_MARKER = "project_model_access_denied" ROUTE_NOT_ALLOWED_MARKER = "not allowed to call this route" +ALL_TEAM_MODELS = "all-team-models" class ApiErrorDetail(BaseModel): @@ -105,6 +110,27 @@ class AccessControlClient: ) ) + def create_project(self, team_id: str, project_alias: str, models: list[str]) -> str: + return unwrap( + self.proxy.transport.post( + "/project/new", + headers=self.proxy.transport.master, + json=ProjectCreateBody(team_id=team_id, project_alias=project_alias, models=models), + response_type=ProjectIdentity, + ) + ).project_id + + def delete_project(self, project_id: str) -> None: + _ = self.proxy.transport.delete( + "/project/delete", + headers=self.proxy.transport.master, + json=ProjectDeleteBody(project_ids=[project_id]), + response_type=NoBody, + ) + + def project_key(self, team_id: str, project_id: str, models: list[str]) -> str: + return self.proxy.generate_key(KeyGenerateBody(team_id=team_id, project_id=project_id, models=models)) + def delete_team(self, team_id: str) -> None: _ = self.proxy.transport.post( "/team/delete", diff --git a/tests/e2e/access_control/test_project_all_team_models_e2e.py b/tests/e2e/access_control/test_project_all_team_models_e2e.py new file mode 100644 index 00000000000..e33e9efc613 --- /dev/null +++ b/tests/e2e/access_control/test_project_all_team_models_e2e.py @@ -0,0 +1,93 @@ +"""Live e2e: a project allow-listed to "all-team-models" inherits the team's models. + +The Admin UI writes ``all-team-models`` into a project's model list when a user picks +"All Team Models". The contract is that the project then allows exactly what the +parent team allows, so a project-scoped virtual key can call any model the team can +call and is still denied a model outside the team's list. Regression coverage for a +project-scoped key being denied with ``project_model_access_denied`` while naming a +model the team could reach. +""" + +from __future__ import annotations + +import pytest +from access_control_client import ( + ALL_TEAM_MODELS, + PROJECT_MODEL_ACCESS_DENIED_MARKER, + AccessControlClient, +) +from e2e_config import unique_marker +from lifecycle import ResourceManager +from models import ChatResponse + +pytestmark = pytest.mark.e2e + +TEAM_MODEL = "gpt-5.6-sol" +OUTSIDE_MODEL = "gpt-5.6-sol-eu" +ALL_PROXY_MODELS = "all-proxy-models" + + +def _chat_assert_completion(client: AccessControlClient, key: str, model: str) -> None: + result = client.chat_status(key, model, f"capital of France? {unique_marker()}") + assert result.status_code == 200, ( + f"project key must be able to call {model!r}, got {result.status_code}: {result.body[:300]}" + ) + assert ChatResponse.model_validate_json(result.body).choices, ( + f"200 must carry a real completion, not an error envelope: {result.body[:300]}" + ) + + +class TestProjectAllTeamModels: + @pytest.mark.parametrize("team_models", [[], [ALL_PROXY_MODELS]]) + def test_all_team_models_project_calls_team_allowed_model( + self, client: AccessControlClient, resources: ResourceManager, team_models: list[str] + ) -> None: + """A project set to all-team-models on an unrestricted team calls a served model.""" + marker = unique_marker() + team_id = client.create_team(f"e2e-proj-team-{marker}", models=team_models) + resources.defer(lambda: client.delete_team(team_id)) + project_id = client.create_project(team_id, f"e2e-proj-{marker}", models=[ALL_TEAM_MODELS]) + resources.defer(lambda: client.delete_project(project_id)) + key = client.project_key(team_id, project_id, models=[ALL_TEAM_MODELS]) + resources.defer(lambda: client.delete_key(key)) + + _chat_assert_completion(client, key, TEAM_MODEL) + + def test_all_team_models_project_denied_outside_team_list( + self, client: AccessControlClient, resources: ResourceManager + ) -> None: + """The inherited allowlist is the team's, not the proxy's.""" + marker = unique_marker() + team_id = client.create_team(f"e2e-proj-team-{marker}", models=[]) + resources.defer(lambda: client.delete_team(team_id)) + project_id = client.create_project(team_id, f"e2e-proj-{marker}", models=[ALL_TEAM_MODELS]) + resources.defer(lambda: client.delete_project(project_id)) + key = client.project_key(team_id, project_id, models=[ALL_TEAM_MODELS]) + resources.defer(lambda: client.delete_key(key)) + + client.set_team_models(team_id, f"e2e-proj-team-{marker}", [TEAM_MODEL]) + + denied = client.chat_status(key, OUTSIDE_MODEL, f"capital of France? {unique_marker()}") + assert denied.status_code == 403 and "_model_access_denied" in denied.body, ( + f"model outside the team's list must be denied 403, got {denied.status_code}: {denied.body[:300]}" + ) + + def test_project_explicit_model_list_calls_model( + self, client: AccessControlClient, resources: ResourceManager + ) -> None: + """Control: an explicit project model list on the same topology succeeds.""" + marker = unique_marker() + team_id = client.create_team(f"e2e-proj-team-{marker}", models=[]) + resources.defer(lambda: client.delete_team(team_id)) + project_id = client.create_project(team_id, f"e2e-proj-{marker}", models=[TEAM_MODEL]) + resources.defer(lambda: client.delete_project(project_id)) + key = client.project_key(team_id, project_id, models=[]) + resources.defer(lambda: client.delete_key(key)) + + _chat_assert_completion(client, key, TEAM_MODEL) + + denied = client.chat_status(key, OUTSIDE_MODEL, f"capital of France? {unique_marker()}") + assert denied.status_code == 403 and PROJECT_MODEL_ACCESS_DENIED_MARKER in denied.body, ( + f"a model outside the explicit project list must still be denied, got " + f"{denied.status_code}: {denied.body[:300]}" + ) diff --git a/tests/e2e/models.py b/tests/e2e/models.py index 6e66529ec8f..214a71bd062 100644 --- a/tests/e2e/models.py +++ b/tests/e2e/models.py @@ -1486,6 +1486,20 @@ class TeamDeleteBody(BaseModel): team_ids: list[str] +class ProjectCreateBody(BaseModel): + team_id: str + project_alias: str + models: list[str] + + +class ProjectIdentity(BaseModel): + project_id: str + + +class ProjectDeleteBody(BaseModel): + project_ids: list[str] + + class TeamListEntry(BaseModel): team_id: str diff --git a/tests/test_litellm/proxy/auth/test_auth_checks.py b/tests/test_litellm/proxy/auth/test_auth_checks.py index dabd97cff0b..481c300ff73 100644 --- a/tests/test_litellm/proxy/auth/test_auth_checks.py +++ b/tests/test_litellm/proxy/auth/test_auth_checks.py @@ -10003,3 +10003,58 @@ def test_can_object_call_model_allows_listed_model_for_key(): ) assert result is True + + +def _router_serving(model_names: list[str]) -> "Router": + from litellm import Router + + return Router( + model_list=[ + {"model_name": name, "litellm_params": {"model": f"openai/{name}", "api_key": "sk-test"}} + for name in model_names + ] + ) + + +def test_can_project_access_model_expands_all_team_models_sentinel(): + from litellm.proxy._types import LiteLLM_ProjectTableCachedObj + from litellm.proxy.auth.auth_checks import can_project_access_model + + project: Final = LiteLLM_ProjectTableCachedObj(project_id="p-1", team_id="t-1", models=["all-team-models"]) + result: Final = can_project_access_model( + model="gpt-5.6-sol", + project_object=project, + llm_router=_router_serving(["gpt-5.6-sol"]), + ) + + assert result is True + + +def test_can_project_access_model_denies_model_outside_router_names(): + from litellm.proxy._types import LiteLLM_ProjectTableCachedObj + from litellm.proxy.auth.auth_checks import can_project_access_model + + project: Final = LiteLLM_ProjectTableCachedObj(project_id="p-1", team_id="t-1", models=["all-team-models"]) + with pytest.raises(ProxyException) as exc_info: + can_project_access_model( + model="gpt-5.6-sol-eu", + project_object=project, + llm_router=_router_serving(["gpt-5.6-sol"]), + ) + + assert exc_info.value.type == ProxyErrorTypes.project_model_access_denied + + +def test_can_project_access_model_keeps_sentinel_denied_without_team_id(): + from litellm.proxy._types import LiteLLM_ProjectTableCachedObj + from litellm.proxy.auth.auth_checks import can_project_access_model + + project: Final = LiteLLM_ProjectTableCachedObj(project_id="p-1", team_id=None, models=["all-team-models"]) + with pytest.raises(ProxyException) as exc_info: + can_project_access_model( + model="gpt-5.6-sol", + project_object=project, + llm_router=_router_serving(["gpt-5.6-sol"]), + ) + + assert exc_info.value.type == ProxyErrorTypes.project_model_access_denied From b76730b77f3821b035f1efbc766b30132e6caf92 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 16:55:45 +0000 Subject: [PATCH 2/4] test(e2e): use CI-served models and tighten project all-team-models denial check Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../e2e/access_control/access_control_client.py | 10 +++++++--- .../test_project_all_team_models_e2e.py | 17 +++++++++++++---- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/tests/e2e/access_control/access_control_client.py b/tests/e2e/access_control/access_control_client.py index c87feb68711..e881e933ef4 100644 --- a/tests/e2e/access_control/access_control_client.py +++ b/tests/e2e/access_control/access_control_client.py @@ -3,9 +3,10 @@ from __future__ import annotations import time +import warnings from dataclasses import dataclass -from pydantic import BaseModel, ValidationError +from pydantic import BaseModel, RootModel, ValidationError from proxy_client import ProxyClient from e2e_http import NoBody, StreamingResponse, is_ok, unwrap @@ -32,6 +33,7 @@ TEAM_MODEL_ACCESS_DENIED_MARKER = "team_model_access_denied" PROJECT_MODEL_ACCESS_DENIED_MARKER = "project_model_access_denied" ROUTE_NOT_ALLOWED_MARKER = "not allowed to call this route" ALL_TEAM_MODELS = "all-team-models" +ALL_PROXY_MODELS = "all-proxy-models" class ApiErrorDetail(BaseModel): @@ -121,12 +123,14 @@ class AccessControlClient: ).project_id def delete_project(self, project_id: str) -> None: - _ = self.proxy.transport.delete( + result = self.proxy.transport.delete( "/project/delete", headers=self.proxy.transport.master, json=ProjectDeleteBody(project_ids=[project_id]), - response_type=NoBody, + response_type=RootModel[list[ProjectIdentity]], ) + if not is_ok(result): + warnings.warn(f"delete_project({project_id!r}) failed: {result}", stacklevel=2) def project_key(self, team_id: str, project_id: str, models: list[str]) -> str: return self.proxy.generate_key(KeyGenerateBody(team_id=team_id, project_id=project_id, models=models)) diff --git a/tests/e2e/access_control/test_project_all_team_models_e2e.py b/tests/e2e/access_control/test_project_all_team_models_e2e.py index e33e9efc613..36820916d40 100644 --- a/tests/e2e/access_control/test_project_all_team_models_e2e.py +++ b/tests/e2e/access_control/test_project_all_team_models_e2e.py @@ -12,8 +12,10 @@ from __future__ import annotations import pytest from access_control_client import ( + ALL_PROXY_MODELS, ALL_TEAM_MODELS, PROJECT_MODEL_ACCESS_DENIED_MARKER, + TEAM_MODEL_ACCESS_DENIED_MARKER, AccessControlClient, ) from e2e_config import unique_marker @@ -22,9 +24,8 @@ from models import ChatResponse pytestmark = pytest.mark.e2e -TEAM_MODEL = "gpt-5.6-sol" -OUTSIDE_MODEL = "gpt-5.6-sol-eu" -ALL_PROXY_MODELS = "all-proxy-models" +TEAM_MODEL = "gemini-2.5-flash" +OUTSIDE_MODEL = "gpt-5.5" def _chat_assert_completion(client: AccessControlClient, key: str, model: str) -> None: @@ -67,10 +68,18 @@ class TestProjectAllTeamModels: client.set_team_models(team_id, f"e2e-proj-team-{marker}", [TEAM_MODEL]) + _chat_assert_completion(client, key, TEAM_MODEL) + denied = client.chat_status(key, OUTSIDE_MODEL, f"capital of France? {unique_marker()}") - assert denied.status_code == 403 and "_model_access_denied" in denied.body, ( + assert denied.status_code == 403, ( f"model outside the team's list must be denied 403, got {denied.status_code}: {denied.body[:300]}" ) + assert PROJECT_MODEL_ACCESS_DENIED_MARKER not in denied.body, ( + f"the denial must come from the key or team check, not the project check: {denied.body[:300]}" + ) + assert TEAM_MODEL_ACCESS_DENIED_MARKER in denied.body, ( + f"403 body must be a team model-access denial, got: {denied.body[:300]}" + ) def test_project_explicit_model_list_calls_model( self, client: AccessControlClient, resources: ResourceManager From fd876cb3449f097133d0b4849cb26a1faa1f3944 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:52:15 +0000 Subject: [PATCH 3/4] test(e2e): declare coverage registry cells for project all-team-models tests Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- tests/e2e/access_control/test_project_all_team_models_e2e.py | 3 +++ tests/e2e/coverage_registry/other.yaml | 3 +++ 2 files changed, 6 insertions(+) diff --git a/tests/e2e/access_control/test_project_all_team_models_e2e.py b/tests/e2e/access_control/test_project_all_team_models_e2e.py index 36820916d40..b58f97e6562 100644 --- a/tests/e2e/access_control/test_project_all_team_models_e2e.py +++ b/tests/e2e/access_control/test_project_all_team_models_e2e.py @@ -39,6 +39,7 @@ def _chat_assert_completion(client: AccessControlClient, key: str, model: str) - class TestProjectAllTeamModels: + @pytest.mark.covers("other.auth.project.all_team_models_inherits_team_allowlist") @pytest.mark.parametrize("team_models", [[], [ALL_PROXY_MODELS]]) def test_all_team_models_project_calls_team_allowed_model( self, client: AccessControlClient, resources: ResourceManager, team_models: list[str] @@ -54,6 +55,7 @@ class TestProjectAllTeamModels: _chat_assert_completion(client, key, TEAM_MODEL) + @pytest.mark.covers("other.auth.project.all_team_models_denied_outside_team") def test_all_team_models_project_denied_outside_team_list( self, client: AccessControlClient, resources: ResourceManager ) -> None: @@ -81,6 +83,7 @@ class TestProjectAllTeamModels: f"403 body must be a team model-access denial, got: {denied.body[:300]}" ) + @pytest.mark.covers("other.auth.project.explicit_model_list_enforced") def test_project_explicit_model_list_calls_model( self, client: AccessControlClient, resources: ResourceManager ) -> None: diff --git a/tests/e2e/coverage_registry/other.yaml b/tests/e2e/coverage_registry/other.yaml index 3bd98ff5b0b..f21624e3501 100644 --- a/tests/e2e/coverage_registry/other.yaml +++ b/tests/e2e/coverage_registry/other.yaml @@ -23,6 +23,9 @@ - {id: other.auth.model_access_group.non_member_denied, module: other, tier: P0, area: auth, assertions: [non_member_denied], source: "auth_checks.py:3232", rationale: "That same grant reaches nothing outside the group, including provider models the group's wildcard does not cover"} - {id: other.auth.model_access_group.team_wildcard_bare_name_allowed, module: other, tier: P1, area: auth, assertions: [team_wildcard_bare_name_allowed], source: "auth_checks.py:3232 / LIT-5813", fail_before_fix: proven, rationale: "The same bare-name grant holds when the wildcard deployment is team-scoped and the team's allow-list is the group"} - {id: other.auth.model_access_group.team_non_member_denied, module: other, tier: P1, area: auth, assertions: [team_non_member_denied], source: "auth_checks.py:3232", rationale: "A team-level group grant reaches nothing outside the group"} +- {id: other.auth.project.all_team_models_inherits_team_allowlist, module: other, tier: P1, area: auth, assertions: [all_team_models_inherits_team_allowlist], source: "auth_checks.py can_project_access_model / LIT-8967", fail_before_fix: proven, rationale: "A project whose models list is all-team-models can call any model its parent team can call, when the team allows all proxy models via [] or all-proxy-models"} +- {id: other.auth.project.all_team_models_denied_outside_team, module: other, tier: P1, area: auth, assertions: [all_team_models_denied_outside_team], source: "auth_checks.py can_project_access_model / LIT-8967", fail_before_fix: proven, rationale: "An all-team-models project inherits a restricted team's list, so a team model still completes and a model outside it is denied 403 by the team check, not the project check"} +- {id: other.auth.project.explicit_model_list_enforced, module: other, tier: P1, area: auth, assertions: [explicit_model_list_enforced], source: "auth_checks.py can_project_access_model", rationale: "A project with an explicit models list can call models on the list and is denied 403 project_model_access_denied for models off it"} - {id: other.auth.virtual_key.route_permission_enforced, module: other, tier: P0, area: auth, assertions: [route_permission_enforced], source: "route_checks.py:89-151", rationale: "allowed_routes whitelist denies disallowed routes"} - {id: other.auth.virtual_key.route_group_allowed, module: other, tier: P1, area: auth, assertions: [route_group_allowed], source: "route_checks.py:106-128", rationale: "allowed_routes=[llm_api_routes] grants all LLM endpoints"} - {id: other.auth.passthrough.model_allowlist_enforced, module: other, tier: P1, area: auth, assertions: [model_allowlist_enforced], source: "route_checks.py:135-151", rationale: "Passthrough enforces per-key model allow-lists"} From 14c012f187ffafb6fbf10904ee11d430d0ed7377 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 00:43:58 +0000 Subject: [PATCH 4/4] ci: retrigger e2e after ephemeral stack deploy timeout Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>