mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-06 02:48:13 +00:00
Merge d74ad34a0f into 8a1f3568ba
This commit is contained in:
commit
8f3a473834
6 changed files with 209 additions and 1 deletions
|
|
@ -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",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
105
tests/e2e/access_control/test_project_all_team_models_e2e.py
Normal file
105
tests/e2e/access_control/test_project_all_team_models_e2e.py
Normal 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]}"
|
||||
)
|
||||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue