mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-06 02:48:13 +00:00
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>
This commit is contained in:
parent
684a1edd44
commit
56b1fcabd8
5 changed files with 189 additions and 0 deletions
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
93
tests/e2e/access_control/test_project_all_team_models_e2e.py
Normal file
93
tests/e2e/access_control/test_project_all_team_models_e2e.py
Normal file
|
|
@ -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]}"
|
||||
)
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue