This commit is contained in:
devin-ai-integration[bot] 2026-10-01 00:51:22 +00:00 • committed by GitHub
commit 8f3a473834
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 209 additions and 1 deletions

View file

@ -5263,6 +5263,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",
)

View file

@ -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
@ -16,6 +17,9 @@ from models import (
LiteLLMParamsBody,
ModelInfoBody,
ModelNewBody,
ProjectCreateBody,
ProjectDeleteBody,
ProjectIdentity,
TeamDeleteBody,
TeamInfoParams,
TeamInfoResponse,
@ -26,7 +30,10 @@ 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"
ALL_PROXY_MODELS = "all-proxy-models"
class ApiErrorDetail(BaseModel):
@ -105,6 +112,29 @@ 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:
result = self.proxy.transport.delete(
"/project/delete",
headers=self.proxy.transport.master,
json=ProjectDeleteBody(project_ids=[project_id]),
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))
def delete_team(self, team_id: str) -> None:
_ = self.proxy.transport.post(
"/team/delete",

View file

@ -0,0 +1,105 @@
"""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_PROXY_MODELS,
ALL_TEAM_MODELS,
PROJECT_MODEL_ACCESS_DENIED_MARKER,
TEAM_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 = "gemini-2.5-flash"
OUTSIDE_MODEL = "gpt-5.5"
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.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]
) -> 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)
@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:
"""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])
_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, (
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]}"
)
@pytest.mark.covers("other.auth.project.explicit_model_list_enforced")
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]}"
)

View file

@ -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"}

View file

@ -1502,6 +1502,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

View file

@ -10085,6 +10085,61 @@ 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
@pytest.mark.asyncio
@pytest.mark.parametrize("allowed", [True, False])
async def test_authoritative_access_group_reads_writer_despite_stale_allow_cache(allowed: bool) -> None: