mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
feat(proxy): add denied_passthrough_routes deny list for custom pass-through endpoints (#44924)
* feat(proxy): add denied_passthrough_routes deny list for custom pass-through endpoints Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(proxy): harden denied_passthrough_routes against non-admin clears and dot-segment paths Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * chore(ui): regenerate schema.d.ts for typed denied_passthrough_routes Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(proxy): match trailing-slash deny entries, block null metadata from dropping denies Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(proxy): close bulk update, encoded ?/# and ordering gaps in passthrough deny list Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(proxy): check denied pass-through routes against the path the forwarder sends upstream Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> * fix(proxy): type matching_denied_passthrough_route metadata mappings * fix(proxy): treat a `/` deny entry as denying every pass-through route Also types the new deny-list tests and drops their get_server_root_path mock in favour of unsetting SERVER_ROOT_PATH. * test(proxy): type the deny-list tests and drop unrelated test reformatting --------- Co-authored-by: mrinal <mrinal@berri.ai> Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Co-authored-by: Yucheng He <yucheng@berri.ai>
This commit is contained in:
parent
9e20264607
commit
225fd3bcdf
14 changed files with 1093 additions and 78 deletions
|
|
@ -1365,6 +1365,7 @@ class KeyRequestBase(GenerateRequestBase):
|
|||
enforced_params: list[str] | None = None
|
||||
allowed_routes: list | None = []
|
||||
allowed_passthrough_routes: list | None = None
|
||||
denied_passthrough_routes: list[str] | None = None
|
||||
allowed_vector_store_indexes: list[AllowedVectorStoreIndexItem] | None = None
|
||||
rpm_limit_type: Literal["guaranteed_throughput", "best_effort_throughput", "dynamic"] | None = (
|
||||
None # raise an error if 'guaranteed_throughput' is set and we're overallocating rpm
|
||||
|
|
@ -2212,6 +2213,7 @@ class NewTeamRequest(TeamBase):
|
|||
prompts: list[str] | None = None
|
||||
object_permission: LiteLLM_ObjectPermissionBase | None = None
|
||||
allowed_passthrough_routes: list | None = None
|
||||
denied_passthrough_routes: list[str] | None = None
|
||||
disable_global_guardrails: bool | None = None
|
||||
secret_manager_settings: dict | None = None
|
||||
model_rpm_limit: dict[str, int] | None = None
|
||||
|
|
@ -2293,6 +2295,7 @@ class UpdateTeamRequest(LiteLLMPydanticObjectBase):
|
|||
team_member_tpm_limit: int | None = None
|
||||
team_member_key_duration: str | None = None
|
||||
allowed_passthrough_routes: list | None = None
|
||||
denied_passthrough_routes: list[str] | None = None
|
||||
secret_manager_settings: dict | None = None
|
||||
prompts: list[str] | None = None
|
||||
model_rpm_limit: dict[str, int] | None = None
|
||||
|
|
@ -5104,6 +5107,7 @@ LiteLLM_ManagementEndpoint_MetadataFields_Premium: Final = [
|
|||
"logging",
|
||||
"secret_manager_settings",
|
||||
"allowed_passthrough_routes",
|
||||
"denied_passthrough_routes",
|
||||
]
|
||||
|
||||
# Metadata keys that are immutable once set: preserved when an update omits them,
|
||||
|
|
|
|||
|
|
@ -1648,6 +1648,10 @@ class JWTAuthManager:
|
|||
):
|
||||
return True
|
||||
|
||||
team_metadata: Final = (team_object.metadata or {}) if team_object else {}
|
||||
if RouteChecks.matching_denied_passthrough_route(route=route, metadata_sources=(team_metadata,)) is not None:
|
||||
return False
|
||||
|
||||
if RouteChecks.jwt_team_routes_grant_pass_through(route=route, team_allowed_routes=team_allowed_routes):
|
||||
return True
|
||||
|
||||
|
|
@ -1655,11 +1659,16 @@ class JWTAuthManager:
|
|||
# so beyond the JWT config grant above, only the selected team's metadata grants access.
|
||||
return RouteChecks.check_passthrough_route_access(
|
||||
route=route,
|
||||
user_api_key_dict=UserAPIKeyAuth(team_metadata=(team_object.metadata or {}) if team_object else {}),
|
||||
user_api_key_dict=UserAPIKeyAuth(team_metadata=team_metadata),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _raise_team_passthrough_route_denial(route: str) -> None:
|
||||
def _raise_team_passthrough_route_denial(route: str, team_object: LiteLLM_TeamTable | None) -> None:
|
||||
denied_route: Final = RouteChecks.matching_denied_passthrough_route(
|
||||
route=route, metadata_sources=((team_object.metadata if team_object else None),)
|
||||
)
|
||||
if denied_route is not None:
|
||||
raise RouteChecks.passthrough_route_denied_exception(route=route, denied_route=denied_route)
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=(
|
||||
|
|
@ -1683,7 +1692,7 @@ class JWTAuthManager:
|
|||
"""Find first team with access to the requested model"""
|
||||
from litellm.proxy.proxy_server import llm_router
|
||||
|
||||
denied_auth_enforced_pass_through_route = False
|
||||
denied_pass_through_team: LiteLLM_TeamTable | None = None
|
||||
|
||||
if not team_ids:
|
||||
if (
|
||||
|
|
@ -1733,7 +1742,7 @@ class JWTAuthManager:
|
|||
team_allowed_routes=jwt_handler.litellm_jwtauth.team_allowed_routes,
|
||||
):
|
||||
is_allowed = False
|
||||
denied_auth_enforced_pass_through_route = True
|
||||
denied_pass_through_team = team_object
|
||||
verbose_proxy_logger.debug(
|
||||
"JWT team route check: team_id=%s, route=%s, is_allowed=%s", team_id, route, is_allowed
|
||||
)
|
||||
|
|
@ -1742,8 +1751,8 @@ class JWTAuthManager:
|
|||
except Exception:
|
||||
continue
|
||||
|
||||
if denied_auth_enforced_pass_through_route:
|
||||
JWTAuthManager._raise_team_passthrough_route_denial(route=route)
|
||||
if denied_pass_through_team is not None:
|
||||
JWTAuthManager._raise_team_passthrough_route_denial(route=route, team_object=denied_pass_through_team)
|
||||
|
||||
if requested_model and (any_claim_team_resolved or not jwt_handler.litellm_jwtauth.team_claim_fallback):
|
||||
# Claim resolved but no model access, or fallback disabled — deny.
|
||||
|
|
@ -2788,7 +2797,7 @@ class JWTAuthManager:
|
|||
request_method=request_method,
|
||||
team_allowed_routes=handler.litellm_jwtauth.team_allowed_routes,
|
||||
):
|
||||
JWTAuthManager._raise_team_passthrough_route_denial(route=route)
|
||||
JWTAuthManager._raise_team_passthrough_route_denial(route=route, team_object=selected_team_object)
|
||||
|
||||
# Extract alias fields for resolution (if configured)
|
||||
org_alias: Final = handler.get_org_alias(token=jwt_valid_token, default_value=None)
|
||||
|
|
@ -2858,7 +2867,7 @@ class JWTAuthManager:
|
|||
request_method=request_method,
|
||||
team_allowed_routes=handler.litellm_jwtauth.team_allowed_routes,
|
||||
):
|
||||
JWTAuthManager._raise_team_passthrough_route_denial(route=route)
|
||||
JWTAuthManager._raise_team_passthrough_route_denial(route=route, team_object=team_object)
|
||||
elif selected_team_id is None:
|
||||
(
|
||||
team_id,
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import itertools
|
||||
import re
|
||||
from collections.abc import Collection
|
||||
from typing import Final
|
||||
from collections.abc import Collection, Iterable, Mapping
|
||||
from typing import Final, cast
|
||||
|
||||
from fastapi import HTTPException, Request, status
|
||||
|
||||
|
|
@ -693,6 +694,70 @@ class RouteChecks:
|
|||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _route_matches_denied_route(route: str, denied_route: str) -> bool:
|
||||
"""A `/` entry denies every route, since every route sits under the root."""
|
||||
normalized_denied_route: Final = denied_route.rstrip("/") or "/"
|
||||
return (
|
||||
normalized_denied_route == "/"
|
||||
or RouteChecks._route_matches_allowed_route(route=route, allowed_route=normalized_denied_route)
|
||||
or RouteChecks.route_matches_wildcard_pattern(route=route, pattern=denied_route)
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def matching_denied_passthrough_route(
|
||||
route: str, metadata_sources: Iterable[Mapping[str, object] | None]
|
||||
) -> str | None:
|
||||
"""
|
||||
First ``denied_passthrough_routes`` entry across ``metadata_sources`` that matches ``route``.
|
||||
Unlike the allowlist (key list, else team list), every source's deny list applies.
|
||||
"""
|
||||
denied_routes: Final = tuple(
|
||||
itertools.chain.from_iterable(
|
||||
cast( # cast-ok: management endpoints validate this metadata key as a list of route strings on write
|
||||
"list[str]", (metadata or {}).get("denied_passthrough_routes") or []
|
||||
)
|
||||
for metadata in metadata_sources
|
||||
)
|
||||
)
|
||||
if not denied_routes:
|
||||
return None
|
||||
from litellm.proxy.pass_through_endpoints.pass_through_endpoints import (
|
||||
InitPassThroughEndpointHelpers,
|
||||
)
|
||||
|
||||
forwarded_routes: Final = InitPassThroughEndpointHelpers.forwarded_routes(route)
|
||||
return next(
|
||||
(
|
||||
denied_route
|
||||
for denied_route in denied_routes
|
||||
if any(
|
||||
RouteChecks._route_matches_denied_route(route=candidate, denied_route=denied_route)
|
||||
for candidate in forwarded_routes
|
||||
)
|
||||
),
|
||||
None,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def passthrough_route_denied_exception(route: str, denied_route: str) -> HTTPException:
|
||||
return HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail=(
|
||||
f"Key/team denied access to passthrough route {route}. "
|
||||
f"Matched `{denied_route}` in `denied_passthrough_routes`."
|
||||
),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _raise_if_passthrough_route_denied(route: str, valid_token: UserAPIKeyAuth) -> None:
|
||||
denied_route: Final = RouteChecks.matching_denied_passthrough_route(
|
||||
route=route,
|
||||
metadata_sources=(valid_token.metadata, valid_token.team_metadata),
|
||||
)
|
||||
if denied_route is not None:
|
||||
raise RouteChecks.passthrough_route_denied_exception(route=route, denied_route=denied_route)
|
||||
|
||||
@staticmethod
|
||||
def jwt_team_routes_grant_pass_through(route: str, team_allowed_routes: Collection[str]) -> bool:
|
||||
"""
|
||||
|
|
@ -724,8 +789,10 @@ class RouteChecks:
|
|||
) -> None:
|
||||
"""
|
||||
Require an explicit grant for auth=true pass-through: ``allowed_passthrough_routes`` on the
|
||||
key or team, or an explicit JWT ``team_allowed_routes`` entry.
|
||||
key or team, or an explicit JWT ``team_allowed_routes`` entry. A key or team
|
||||
``denied_passthrough_routes`` match blocks the route even when one of those grants it.
|
||||
"""
|
||||
RouteChecks._raise_if_passthrough_route_denied(route=route, valid_token=valid_token)
|
||||
if RouteChecks.check_passthrough_route_access(route=route, user_api_key_dict=valid_token):
|
||||
return
|
||||
if RouteChecks.jwt_team_routes_grant_pass_through(route=route, team_allowed_routes=jwt_team_allowed_routes):
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ from types import MappingProxyType
|
|||
from typing import TYPE_CHECKING, Any, Final, Optional, Union
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
from pydantic import BaseModel, TypeAdapter, ValidationError
|
||||
|
||||
|
||||
# Defined above the `litellm.proxy.*` imports so the name is bound even when
|
||||
|
|
@ -150,31 +150,89 @@ def require_caller_user_id_for_non_admin(
|
|||
return user_api_key_dict.user_id
|
||||
|
||||
|
||||
_ROUTE_LIST: Final = TypeAdapter(list[str] | None)
|
||||
|
||||
|
||||
def _passthrough_routes_permission_error(field: str, entity: str) -> HTTPException:
|
||||
return HTTPException(
|
||||
status_code=403,
|
||||
detail={"error": f"Only proxy admins can set `{field}` on a {entity}."},
|
||||
)
|
||||
|
||||
|
||||
def _check_passthrough_routes_caller_permission(
|
||||
data: BaseModel,
|
||||
data: BaseModel | None,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
*,
|
||||
entity: str = "key",
|
||||
existing_metadata: Mapping[str, object] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
Only proxy admins may set `allowed_passthrough_routes` or `denied_passthrough_routes`
|
||||
(top-level or under `metadata`), since the runtime route checker reads both from key and
|
||||
team metadata.
|
||||
"""
|
||||
check_allowed_passthrough_routes_caller_permission(data, user_api_key_dict, entity=entity)
|
||||
check_denied_passthrough_routes_caller_permission(
|
||||
data, user_api_key_dict, entity=entity, existing_metadata=existing_metadata
|
||||
)
|
||||
|
||||
|
||||
def check_allowed_passthrough_routes_caller_permission(
|
||||
data: BaseModel | None,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
*,
|
||||
entity: str = "key",
|
||||
) -> None:
|
||||
"""
|
||||
Only proxy admins may set `allowed_passthrough_routes` (top-level or under
|
||||
`metadata`) — it short-circuits the role-based route gate, so keys and teams
|
||||
must be gated identically.
|
||||
"""
|
||||
if data is None:
|
||||
return
|
||||
metadata: Final = getattr(data, "metadata", None)
|
||||
if isinstance(metadata, dict):
|
||||
try:
|
||||
_ROUTE_LIST.validate_python(metadata.get("denied_passthrough_routes"))
|
||||
except ValidationError as e:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail={"error": "`metadata.denied_passthrough_routes` must be a list of route strings."},
|
||||
) from e
|
||||
# view-only admins excluded by design; blocked upstream from writes anyway
|
||||
if user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value:
|
||||
return
|
||||
if getattr(data, "allowed_passthrough_routes", None):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail={"error": f"Only proxy admins can set `allowed_passthrough_routes` on a {entity}."},
|
||||
)
|
||||
metadata: Final = getattr(data, "metadata", None)
|
||||
raise _passthrough_routes_permission_error("allowed_passthrough_routes", entity)
|
||||
if isinstance(metadata, dict) and metadata.get("allowed_passthrough_routes"):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail={"error": f"Only proxy admins can set `metadata.allowed_passthrough_routes` on a {entity}."},
|
||||
)
|
||||
raise _passthrough_routes_permission_error("metadata.allowed_passthrough_routes", entity)
|
||||
|
||||
|
||||
def check_denied_passthrough_routes_caller_permission(
|
||||
data: BaseModel | None,
|
||||
user_api_key_dict: UserAPIKeyAuth,
|
||||
*,
|
||||
entity: str = "key",
|
||||
existing_metadata: Mapping[str, object] | None = None,
|
||||
) -> None:
|
||||
"""
|
||||
A non-admin request must leave an existing deny list as it is: clearing it, or replacing
|
||||
`metadata` without it, would widen access. The outcome depends on the stored deny list, so
|
||||
run this only after the caller is known to be allowed to edit the object.
|
||||
"""
|
||||
if data is None or user_api_key_dict.user_role == LitellmUserRoles.PROXY_ADMIN.value:
|
||||
return
|
||||
metadata: Final = getattr(data, "metadata", None)
|
||||
existing_denied: Final = (existing_metadata or {}).get("denied_passthrough_routes") or None
|
||||
if (
|
||||
"denied_passthrough_routes" in data.model_fields_set
|
||||
and (getattr(data, "denied_passthrough_routes", None) or None) != existing_denied
|
||||
):
|
||||
raise _passthrough_routes_permission_error("denied_passthrough_routes", entity)
|
||||
if _metadata_changes_denied_routes(data, metadata, existing_denied):
|
||||
raise _passthrough_routes_permission_error("metadata.denied_passthrough_routes", entity)
|
||||
|
||||
|
||||
def _metadata_changes_denied_routes(data: BaseModel, metadata: object, existing_denied: object) -> bool:
|
||||
if isinstance(metadata, dict):
|
||||
return (metadata.get("denied_passthrough_routes") or None) != existing_denied
|
||||
return metadata is None and "metadata" in data.model_fields_set and existing_denied is not None
|
||||
|
||||
|
||||
def _check_disable_global_guardrails_caller_permission(
|
||||
|
|
|
|||
|
|
@ -94,6 +94,8 @@ from litellm.proxy.management_endpoints.common_utils import (
|
|||
_set_object_metadata_field,
|
||||
_team_member_has_permission,
|
||||
_user_has_admin_view,
|
||||
check_allowed_passthrough_routes_caller_permission,
|
||||
check_denied_passthrough_routes_caller_permission,
|
||||
validate_budget_duration,
|
||||
validate_finite_spend,
|
||||
)
|
||||
|
|
@ -2012,6 +2014,7 @@ async def generate_key_fn(
|
|||
- prompts: Optional[List[str]] - List of prompts that the key is allowed to use.
|
||||
- allowed_routes: Optional[list] - List of allowed routes for the key. Store the actual route or store a wildcard pattern for a set of routes. Example - ["/chat/completions", "/embeddings", "/keys/*"]
|
||||
- allowed_passthrough_routes: Optional[list] - List of allowed pass through endpoints for the key. Store the actual endpoint or store a wildcard pattern for a set of endpoints. Example - ["/my-custom-endpoint"]. Use this instead of allowed_routes, if you just want to specify which pass through endpoints the key can access, without specifying the routes. If allowed_routes is specified, allowed_pass_through_endpoints is ignored.
|
||||
- denied_passthrough_routes: Optional[list] - List of pass through routes the key may not call, even if allowed by `allowed_passthrough_routes` or `allowed_routes`. Matches exact paths, path prefixes, and trailing `*` wildcards. Applies together with the team's `denied_passthrough_routes`. Example - ["/my-custom-endpoint/admin"].
|
||||
- object_permission: Optional[LiteLLM_ObjectPermissionBase] - key-specific object permission. Example - {"vector_stores": ["vector_store_1", "vector_store_2"], "agents": ["agent_1", "agent_2"], "agent_access_groups": ["dev_group"]}. IF null or {} then no object permission.
|
||||
- key_type: Optional[str] - Type of key that determines default allowed routes. Options: "llm_api" (can call LLM API routes), "management" (can call management routes), "read_only" (can only call info/read routes), "default" (uses default allowed routes). Defaults to "default".
|
||||
- prompts: Optional[List[str]] - List of allowed prompts for the key. If specified, the key will only be able to use these specific prompts.
|
||||
|
|
@ -2774,6 +2777,11 @@ async def _process_single_key_update(
|
|||
existing_key_row=existing_key_row,
|
||||
user_api_key_cache=user_api_key_cache,
|
||||
)
|
||||
check_denied_passthrough_routes_caller_permission(
|
||||
update_key_request,
|
||||
user_api_key_dict,
|
||||
existing_metadata=existing_key_row.metadata, # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # LiteLLM_VerificationToken.metadata is a bare dict
|
||||
)
|
||||
|
||||
# Custom key update hook
|
||||
if user_custom_key_update is not None:
|
||||
|
|
@ -3091,10 +3099,7 @@ async def _validate_update_key_data(
|
|||
existing_key_row=existing_key_row,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
)
|
||||
_check_passthrough_routes_caller_permission(
|
||||
data=data,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
)
|
||||
check_allowed_passthrough_routes_caller_permission(data, user_api_key_dict)
|
||||
_check_permissions_caller_permission(
|
||||
data=data,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
|
|
@ -3240,6 +3245,11 @@ async def _validate_update_key_data(
|
|||
user_api_key_cache=user_api_key_cache,
|
||||
route=("/key/update (max_budget/spend)" if _is_budget_change else "/key/update"),
|
||||
)
|
||||
check_denied_passthrough_routes_caller_permission(
|
||||
data,
|
||||
user_api_key_dict,
|
||||
existing_metadata=existing_key_row.metadata, # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # LiteLLM_VerificationToken.metadata is a bare dict
|
||||
)
|
||||
|
||||
# Check team limits if key has a team_id (from request or existing key)
|
||||
team_obj: LiteLLM_TeamTableCachedObj | None = None
|
||||
|
|
@ -3428,6 +3438,7 @@ async def update_key_fn(
|
|||
- temp_budget_expiry: Optional[str] - Expiry time for the temporary budget increase (Enterprise only).
|
||||
- allowed_routes: Optional[list] - List of allowed routes for the key. Store the actual route or store a wildcard pattern for a set of routes. Example - ["/chat/completions", "/embeddings", "/keys/*"]
|
||||
- allowed_passthrough_routes: Optional[list] - List of allowed pass through routes for the key. Store the actual route or store a wildcard pattern for a set of routes. Example - ["/my-custom-endpoint"]. Use this instead of allowed_routes, if you just want to specify which pass through routes the key can access, without specifying the routes. If allowed_routes is specified, allowed_passthrough_routes is ignored.
|
||||
- denied_passthrough_routes: Optional[list] - List of pass through routes the key may not call, even if allowed by `allowed_passthrough_routes` or `allowed_routes`. Matches exact paths, path prefixes, and trailing `*` wildcards. Applies together with the team's `denied_passthrough_routes`. Example - ["/my-custom-endpoint/admin"].
|
||||
- prompts: Optional[List[str]] - List of allowed prompts for the key. If specified, the key will only be able to use these specific prompts.
|
||||
- object_permission: Optional[LiteLLM_ObjectPermissionBase] - key-specific object permission. Example - {"vector_stores": ["vector_store_1", "vector_store_2"], "agents": ["agent_1", "agent_2"], "agent_access_groups": ["dev_group"]}. IF null or {} then no object permission.
|
||||
- auto_rotate: Optional[bool] - Whether this key should be automatically rotated
|
||||
|
|
@ -3944,7 +3955,7 @@ async def bulk_update_team_keys(
|
|||
|
||||
# Block metadata.allowed_passthrough_routes for non-admins — the runtime
|
||||
# route checker reads it from key/team metadata to grant passthrough.
|
||||
_check_passthrough_routes_caller_permission(data=data.update_fields, user_api_key_dict=user_api_key_dict)
|
||||
check_allowed_passthrough_routes_caller_permission(data.update_fields, user_api_key_dict)
|
||||
|
||||
if not requested_tokens:
|
||||
raise HTTPException(
|
||||
|
|
@ -5824,10 +5835,7 @@ async def regenerate_key_fn(
|
|||
user_api_key_dict=user_api_key_dict,
|
||||
allowed_routes_was_provided="allowed_routes" in data.model_fields_set,
|
||||
)
|
||||
_check_passthrough_routes_caller_permission(
|
||||
data=data,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
)
|
||||
check_allowed_passthrough_routes_caller_permission(data, user_api_key_dict)
|
||||
_check_permissions_caller_permission(
|
||||
data=data,
|
||||
user_api_key_dict=user_api_key_dict,
|
||||
|
|
@ -5945,6 +5953,11 @@ async def regenerate_key_fn(
|
|||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail={"error": "You are not authorized to regenerate this key"},
|
||||
)
|
||||
check_denied_passthrough_routes_caller_permission(
|
||||
data,
|
||||
user_api_key_dict,
|
||||
existing_metadata=_key_in_db.metadata, # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # LiteLLM_VerificationToken.metadata is a bare dict
|
||||
)
|
||||
|
||||
if data is not None and (data.access_group_ids or data.object_permission is not None):
|
||||
regenerate_team_table: LiteLLM_TeamTableCachedObj | None = None
|
||||
|
|
|
|||
|
|
@ -1389,6 +1389,7 @@ async def new_team(
|
|||
- team_member_tpm_limit: Optional[int] - The TPM (Tokens Per Minute) limit for individual team members.
|
||||
- team_member_key_duration: Optional[str] - The duration for a team member's key. e.g. "1d", "1w", "1mo"
|
||||
- allowed_passthrough_routes: Optional[List[str]] - List of allowed pass through routes for the team.
|
||||
- denied_passthrough_routes: Optional[List[str]] - List of pass through routes the team's keys may not call, even if allowed. Applies together with each key's `denied_passthrough_routes`.
|
||||
- allowed_vector_store_indexes: Optional[List[dict]] - List of allowed vector store indexes for the key. Example - [{"index_name": "my-index", "index_permissions": ["write", "read"]}]. If specified, the key will only be able to use these specific vector store indexes. Create index, using `/v1/indexes` endpoint.
|
||||
- secret_manager_settings: Optional[dict] - Secret manager settings for the team. [Docs](https://docs.litellm.ai/docs/secret_managers/overview)
|
||||
- router_settings: Optional[UpdateRouterConfig] - team-specific router settings. Example - {"model_group_retry_policy": {"gpt-4": {"RateLimitErrorRetries": 5}}}. IF null or {} then no router settings.
|
||||
|
|
@ -2151,6 +2152,7 @@ async def update_team(
|
|||
- team_member_tpm_limit: Optional[int] - The TPM (Tokens Per Minute) limit for individual team members.
|
||||
- team_member_key_duration: Optional[str] - The duration for a team member's key. e.g. "1d", "1w", "1mo"
|
||||
- allowed_passthrough_routes: Optional[List[str]] - List of allowed pass through routes for the team.
|
||||
- denied_passthrough_routes: Optional[List[str]] - List of pass through routes the team's keys may not call, even if allowed. Applies together with each key's `denied_passthrough_routes`.
|
||||
- model_rpm_limit: Optional[Dict[str, int]] - The RPM (Requests Per Minute) limit per model for this team. Example: {"gpt-4": 100, "gpt-3.5-turbo": 200}
|
||||
- model_tpm_limit: Optional[Dict[str, int]] - The TPM (Tokens Per Minute) limit per model for this team. Example: {"gpt-4": 10000, "gpt-3.5-turbo": 20000}
|
||||
- default_estimated_output_tokens: Optional[int] - Expected output tokens reserved for TPM limiting when a request omits max_tokens, for keys on this team that do not set their own. Positive integer.
|
||||
|
|
@ -2283,7 +2285,12 @@ async def update_team(
|
|||
entity="team",
|
||||
)
|
||||
|
||||
_check_passthrough_routes_caller_permission(data, user_api_key_dict, entity="team")
|
||||
_check_passthrough_routes_caller_permission(
|
||||
data,
|
||||
user_api_key_dict,
|
||||
entity="team",
|
||||
existing_metadata=_existing_team_metadata if isinstance(_existing_team_metadata, dict) else None,
|
||||
)
|
||||
_check_disable_global_guardrails_caller_permission(
|
||||
data.disable_global_guardrails,
|
||||
data.metadata, # pyright: ignore[reportUnknownMemberType, reportUnknownArgumentType] # request models declare `metadata` as bare dict
|
||||
|
|
|
|||
|
|
@ -716,23 +716,29 @@ class HttpPassThroughEndpointHelpers(BasePassthroughUtils):
|
|||
if not subpath:
|
||||
return base_target
|
||||
|
||||
# Ensure base_target ends with / and subpath doesn't start with /
|
||||
if not base_target.endswith("/"):
|
||||
base_target = base_target + "/"
|
||||
subpath = subpath.removeprefix("/")
|
||||
target_root: Final = base_target if base_target.endswith("/") else base_target + "/"
|
||||
return target_root + HttpPassThroughEndpointHelpers.resolve_subpath(subpath)
|
||||
|
||||
# Resolve any '..' segments in the subpath so it cannot climb above
|
||||
# the base_target prefix that the operator configured. Preserve a
|
||||
# trailing slash on the original subpath since some upstreams treat
|
||||
# `/foo` and `/foo/` as different resources.
|
||||
trailing_slash: Final = subpath.endswith("/")
|
||||
safe_subpath = posixpath.normpath("/" + subpath).lstrip("/")
|
||||
if safe_subpath == ".":
|
||||
safe_subpath = ""
|
||||
if trailing_slash and safe_subpath and not safe_subpath.endswith("/"):
|
||||
safe_subpath += "/"
|
||||
@staticmethod
|
||||
def resolve_subpath(subpath: str) -> str:
|
||||
"""
|
||||
``subpath`` with ``.``, ``..`` and empty segments resolved, so it cannot climb above the target the
|
||||
operator configured. A trailing slash is kept since some upstreams treat `/foo` and `/foo/` differently.
|
||||
"""
|
||||
resolved: Final = posixpath.normpath("/" + subpath.removeprefix("/")).lstrip("/")
|
||||
return resolved + "/" if resolved and subpath.endswith("/") else resolved
|
||||
|
||||
return base_target + safe_subpath
|
||||
@staticmethod
|
||||
def forwarded_route(endpoint_path: str, subpath: str) -> str:
|
||||
"""
|
||||
The proxy route as the upstream sees it: the subpath resolved like the forwarder resolves it, then
|
||||
parsed by ``httpx`` like the forwarded URL is, so a decoded ``?`` or ``#`` ends the path there too.
|
||||
"""
|
||||
route: Final = f"{endpoint_path.rstrip('/')}/{HttpPassThroughEndpointHelpers.resolve_subpath(subpath)}"
|
||||
try:
|
||||
return httpx.URL(route).path
|
||||
except httpx.InvalidURL:
|
||||
return route
|
||||
|
||||
@staticmethod
|
||||
def join_base_and_endpoint_path(base_url: httpx.URL, endpoint_path: str) -> str:
|
||||
|
|
@ -3272,6 +3278,31 @@ class InitPassThroughEndpointHelpers:
|
|||
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def forwarded_routes(route: str) -> tuple[str, ...]:
|
||||
"""
|
||||
``route`` as each registered endpoint it falls under would forward it. An exact endpoint, or no
|
||||
endpoint at all, sees ``route`` itself.
|
||||
"""
|
||||
comparison_route: Final = InitPassThroughEndpointHelpers._route_for_registry_lookup(route)
|
||||
registered: Final = tuple(
|
||||
(parts[1], parts[2])
|
||||
for parts in (key.split(":", 3) for key in _registered_pass_through_routes)
|
||||
if len(parts) >= 3
|
||||
)
|
||||
subpath_endpoint_paths: Final = tuple(
|
||||
path
|
||||
for route_type, path in registered
|
||||
if route_type == "subpath" and (comparison_route == path or comparison_route.startswith(path + "/"))
|
||||
)
|
||||
forwarded: Final = tuple(
|
||||
HttpPassThroughEndpointHelpers.forwarded_route(endpoint_path=path, subpath=comparison_route[len(path) :])
|
||||
for path in subpath_endpoint_paths
|
||||
)
|
||||
if subpath_endpoint_paths and ("exact", comparison_route) not in registered:
|
||||
return forwarded
|
||||
return (comparison_route, *forwarded)
|
||||
|
||||
@staticmethod
|
||||
def get_registered_pass_through_route(route: str, method: str | None = None) -> dict[str, Any] | None:
|
||||
"""Get passthrough params for a given route and optionally filter by HTTP method"""
|
||||
|
|
@ -3576,7 +3607,8 @@ async def _filter_endpoints_by_team_allowed_routes(
|
|||
prisma_client,
|
||||
) -> list[PassThroughGenericEndpoint]:
|
||||
"""
|
||||
Filter pass-through endpoints based on team's allowed_passthrough_routes metadata.
|
||||
Filter pass-through endpoints based on team's allowed_passthrough_routes and
|
||||
denied_passthrough_routes metadata.
|
||||
|
||||
Args:
|
||||
team_id: The team ID to check permissions for
|
||||
|
|
@ -3603,18 +3635,23 @@ async def _filter_endpoints_by_team_allowed_routes(
|
|||
team_metadata: Final = cast( # cast-ok: prisma types the Json column as str; reads hand back the decoded value
|
||||
"Mapping[str, object] | None", team.metadata
|
||||
)
|
||||
if team_metadata is not None and team_metadata.get("allowed_passthrough_routes") is not None:
|
||||
## FILTER pass_through_endpoints by allowed_passthrough_routes
|
||||
pass_through_endpoints = [
|
||||
endpoint
|
||||
for endpoint in pass_through_endpoints
|
||||
if endpoint.path
|
||||
in cast( # cast-ok: guarded above; team metadata stores this key as a list of route paths
|
||||
Sequence[str], team_metadata.get("allowed_passthrough_routes")
|
||||
)
|
||||
]
|
||||
if team_metadata is None:
|
||||
return pass_through_endpoints
|
||||
|
||||
return pass_through_endpoints
|
||||
from litellm.proxy.auth.route_checks import RouteChecks
|
||||
|
||||
allowed_routes: Final = cast( # cast-ok: team metadata stores this key as a list of route paths
|
||||
"Sequence[str] | None", team_metadata.get("allowed_passthrough_routes")
|
||||
)
|
||||
return [
|
||||
endpoint
|
||||
for endpoint in pass_through_endpoints
|
||||
if (allowed_routes is None or endpoint.path in allowed_routes)
|
||||
and not (
|
||||
endpoint.auth
|
||||
and RouteChecks.matching_denied_passthrough_route(route=endpoint.path, metadata_sources=(team_metadata,))
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@router.get(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,333 @@
|
|||
import json
|
||||
import uuid
|
||||
from typing import Final, Literal
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
from integration._support.client import Gateway, JsonValue, Scenario, eventually, object_value, string_value
|
||||
from integration._support.wire import Reply, Request, Wire, wire_server
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
ENDPOINTS: Final = TypeAdapter(list[JsonValue])
|
||||
JSON_OBJECT: Final = TypeAdapter(dict[str, JsonValue])
|
||||
Owner = Literal["key", "team"]
|
||||
|
||||
|
||||
def _echo(request: Request) -> Reply:
|
||||
return Reply(body=json.dumps({"target": request.target}).encode())
|
||||
|
||||
|
||||
def _registered_endpoint(gateway: Gateway, scenario: Scenario, wire: Wire, *, auth: bool = True) -> str:
|
||||
path: Final = f"/integration-deny-{uuid.uuid4().hex}"
|
||||
created: Final = gateway.post(
|
||||
"/config/pass_through_endpoint",
|
||||
{"path": path, "target": f"{wire.url}/upstream", "auth": auth, "include_subpath": True},
|
||||
)
|
||||
endpoint_id: Final = object_value(ENDPOINTS.validate_python(created["endpoints"])[0])["id"]
|
||||
scenario.cleanups.callback(
|
||||
lambda: gateway.request("DELETE", "/config/pass_through_endpoint", params={"endpoint_id": str(endpoint_id)})
|
||||
)
|
||||
return path
|
||||
|
||||
|
||||
def _call(gateway: Gateway, route: str, key: str) -> httpx.Response:
|
||||
return gateway.request("POST", route, {"probe": "denylist"}, key=key)
|
||||
|
||||
|
||||
def _upstream_targets(wire: Wire) -> tuple[str, ...]:
|
||||
return tuple(request.target for request in wire.drain())
|
||||
|
||||
|
||||
def _assert_denied(response: httpx.Response, denied_entry: str) -> None:
|
||||
assert response.status_code == 403, response.text
|
||||
assert f"Matched `{denied_entry}` in `denied_passthrough_routes`" in response.text, response.text
|
||||
|
||||
|
||||
def _key_with_routes(
|
||||
scenario: Scenario, allow_on: Owner, deny_on: Owner, allowed: list[JsonValue], denied: list[JsonValue]
|
||||
) -> str:
|
||||
team_fields: Final[dict[str, JsonValue]] = {
|
||||
**({"allowed_passthrough_routes": allowed} if allow_on == "team" else {}),
|
||||
**({"denied_passthrough_routes": denied} if deny_on == "team" else {}),
|
||||
}
|
||||
key_fields: Final[dict[str, JsonValue]] = {
|
||||
**({"allowed_passthrough_routes": allowed} if allow_on == "key" else {}),
|
||||
**({"denied_passthrough_routes": denied} if deny_on == "key" else {}),
|
||||
}
|
||||
return scenario.key(team_id=scenario.team(**team_fields), **key_fields)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("allow_on", "deny_on"),
|
||||
[("key", "key"), ("team", "key"), ("key", "team")],
|
||||
)
|
||||
def test_denied_subpath_is_blocked_even_when_allowed_while_its_sibling_still_reaches_upstream(
|
||||
gateway: Gateway, allow_on: Owner, deny_on: Owner
|
||||
) -> None:
|
||||
with wire_server(_echo) as wire, gateway.scenario() as scenario:
|
||||
path: Final = _registered_endpoint(gateway, scenario, wire)
|
||||
key: Final = _key_with_routes(scenario, allow_on, deny_on, [path], [f"{path}/admin"])
|
||||
|
||||
_assert_denied(_call(gateway, f"{path}/admin/users", key), f"{path}/admin")
|
||||
sibling: Final = _call(gateway, f"{path}/public", key)
|
||||
|
||||
assert sibling.status_code == 200, sibling.text
|
||||
assert _upstream_targets(wire) == ("/upstream/public",)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"subpath",
|
||||
[
|
||||
"public/%2e%2e/admin/users",
|
||||
"/admin/users",
|
||||
"admin%3F",
|
||||
"admin%3F/users",
|
||||
"admin%23",
|
||||
"admin%23/users",
|
||||
"public%3Fx/%2e%2e/admin%3F",
|
||||
"public%23x/%2e%2e/admin%23",
|
||||
],
|
||||
ids=[
|
||||
"encoded_dot_dot_segment",
|
||||
"empty_segment",
|
||||
"encoded_query_mark",
|
||||
"encoded_query_mark_then_subpath",
|
||||
"encoded_fragment_mark",
|
||||
"encoded_fragment_mark_then_subpath",
|
||||
"encoded_query_mark_then_dot_dot",
|
||||
"encoded_fragment_mark_then_dot_dot",
|
||||
],
|
||||
)
|
||||
def test_dot_and_empty_segments_cannot_reach_a_denied_subpath(gateway: Gateway, subpath: str) -> None:
|
||||
with wire_server(_echo) as wire, gateway.scenario() as scenario:
|
||||
path: Final = _registered_endpoint(gateway, scenario, wire)
|
||||
key: Final = scenario.key(allowed_passthrough_routes=[path], denied_passthrough_routes=[f"{path}/admin"])
|
||||
|
||||
response: Final = _call(gateway, f"{path}/{subpath}", key)
|
||||
|
||||
_assert_denied(response, f"{path}/admin")
|
||||
assert _upstream_targets(wire) == ()
|
||||
|
||||
|
||||
def test_trailing_slash_deny_entry_blocks_the_route_and_everything_under_it(gateway: Gateway) -> None:
|
||||
with wire_server(_echo) as wire, gateway.scenario() as scenario:
|
||||
path: Final = _registered_endpoint(gateway, scenario, wire)
|
||||
key: Final = scenario.key(allowed_passthrough_routes=[path], denied_passthrough_routes=[f"{path}/admin/"])
|
||||
|
||||
_assert_denied(_call(gateway, f"{path}/admin", key), f"{path}/admin/")
|
||||
_assert_denied(_call(gateway, f"{path}/admin/", key), f"{path}/admin/")
|
||||
_assert_denied(_call(gateway, f"{path}/admin/users", key), f"{path}/admin/")
|
||||
sibling: Final = _call(gateway, f"{path}/public", key)
|
||||
|
||||
assert sibling.status_code == 200, sibling.text
|
||||
assert _upstream_targets(wire) == ("/upstream/public",)
|
||||
|
||||
|
||||
def test_trailing_wildcard_deny_blocks_every_route_with_that_prefix(gateway: Gateway) -> None:
|
||||
with wire_server(_echo) as wire, gateway.scenario() as scenario:
|
||||
path: Final = _registered_endpoint(gateway, scenario, wire)
|
||||
key: Final = scenario.key(allowed_passthrough_routes=[path], denied_passthrough_routes=[f"{path}/adm*"])
|
||||
|
||||
_assert_denied(_call(gateway, f"{path}/admin", key), f"{path}/adm*")
|
||||
_assert_denied(_call(gateway, f"{path}/adm-console/x", key), f"{path}/adm*")
|
||||
sibling: Final = _call(gateway, f"{path}/public", key)
|
||||
|
||||
assert sibling.status_code == 200, sibling.text
|
||||
assert _upstream_targets(wire) == ("/upstream/public",)
|
||||
|
||||
|
||||
def test_deny_entry_does_not_match_a_longer_segment_that_shares_its_prefix(gateway: Gateway) -> None:
|
||||
with wire_server(_echo) as wire, gateway.scenario() as scenario:
|
||||
path: Final = _registered_endpoint(gateway, scenario, wire)
|
||||
key: Final = scenario.key(allowed_passthrough_routes=[path], denied_passthrough_routes=[f"{path}/admin"])
|
||||
|
||||
response: Final = _call(gateway, f"{path}/administrator", key)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert _upstream_targets(wire) == ("/upstream/administrator",)
|
||||
|
||||
|
||||
def test_proxy_admin_key_reaches_a_route_its_key_and_team_both_deny(gateway: Gateway) -> None:
|
||||
with wire_server(_echo) as wire, gateway.scenario() as scenario:
|
||||
path: Final = _registered_endpoint(gateway, scenario, wire)
|
||||
admin: Final = scenario.user(user_role="proxy_admin")
|
||||
team: Final = scenario.team(denied_passthrough_routes=[path])
|
||||
gateway.post("/team/member_add", {"team_id": team, "member": {"role": "user", "user_id": admin}})
|
||||
key: Final = scenario.key(user_id=admin, team_id=team, denied_passthrough_routes=[path])
|
||||
|
||||
response: Final = _call(gateway, f"{path}/ops", key)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
assert _upstream_targets(wire) == ("/upstream/ops",)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("deny_on", ["key", "team"])
|
||||
def test_deny_added_and_cleared_through_update_takes_effect_on_the_next_request(
|
||||
gateway: Gateway, deny_on: Owner
|
||||
) -> None:
|
||||
with wire_server(_echo) as wire, gateway.scenario() as scenario:
|
||||
path: Final = _registered_endpoint(gateway, scenario, wire)
|
||||
team: Final = scenario.team()
|
||||
key: Final = scenario.key(team_id=team, allowed_passthrough_routes=[path])
|
||||
|
||||
def set_denied(routes: list[JsonValue]) -> None:
|
||||
if deny_on == "key":
|
||||
gateway.post("/key/update", {"key": key, "denied_passthrough_routes": routes})
|
||||
else:
|
||||
gateway.post("/team/update", {"team_id": team, "denied_passthrough_routes": routes})
|
||||
|
||||
def probe() -> httpx.Response:
|
||||
return _call(gateway, f"{path}/admin", key)
|
||||
|
||||
before: Final = probe()
|
||||
assert before.status_code == 200, before.text
|
||||
set_denied([path])
|
||||
_assert_denied(eventually(probe, lambda response: response.status_code == 403, seconds=10), path)
|
||||
set_denied([])
|
||||
restored: Final = eventually(probe, lambda response: response.status_code == 200, seconds=10)
|
||||
|
||||
assert restored.status_code == 200, restored.text
|
||||
targets: Final = _upstream_targets(wire)
|
||||
assert len(targets) >= 2 and set(targets) == {"/upstream/admin"}, targets
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body",
|
||||
[{"denied_passthrough_routes": ["/integration-deny-probe"]}, {"metadata": {"denied_passthrough_routes": ["/x"]}}],
|
||||
ids=["top_level", "metadata"],
|
||||
)
|
||||
def test_internal_user_cannot_set_denied_routes_while_proxy_admin_can(
|
||||
gateway: Gateway, body: dict[str, JsonValue]
|
||||
) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
user: Final = scenario.user(user_role="internal_user")
|
||||
user_key: Final = scenario.key(user_id=user)
|
||||
|
||||
refused: Final = gateway.request("POST", "/key/generate", {"user_id": user, **body}, key=user_key)
|
||||
if refused.status_code == 200:
|
||||
scenario.cleanups.callback(
|
||||
scenario.delete_key, string_value(JSON_OBJECT.validate_json(refused.content)["key"])
|
||||
)
|
||||
|
||||
assert refused.status_code == 403, refused.text
|
||||
assert "denied_passthrough_routes" in refused.text, refused.text
|
||||
admin_key: Final = scenario.key(denied_passthrough_routes=["/integration-deny-probe"])
|
||||
info: Final = object_value(gateway.get("/key/info", {"key": admin_key})["info"])
|
||||
assert object_value(info["metadata"])["denied_passthrough_routes"] == ["/integration-deny-probe"], info
|
||||
|
||||
|
||||
def test_deny_entries_leave_open_passthroughs_and_llm_routes_untouched(gateway: Gateway) -> None:
|
||||
with wire_server(_echo) as wire, gateway.scenario() as scenario:
|
||||
open_path: Final = _registered_endpoint(gateway, scenario, wire, auth=False)
|
||||
model: Final = scenario.model()
|
||||
key: Final = scenario.key(denied_passthrough_routes=[open_path, "/v1/chat/completions", "/chat/completions"])
|
||||
|
||||
opened: Final = _call(gateway, open_path, key)
|
||||
chat: Final = gateway.request(
|
||||
"POST", "/v1/chat/completions", {"model": model, "messages": [{"role": "user", "content": "x"}]}, key=key
|
||||
)
|
||||
|
||||
assert opened.status_code == 200, opened.text
|
||||
assert _upstream_targets(wire) == ("/upstream",)
|
||||
assert chat.status_code == 200, chat.text
|
||||
|
||||
|
||||
def test_team_endpoint_listing_hides_routes_the_team_denies(gateway: Gateway) -> None:
|
||||
with wire_server(_echo) as wire, gateway.scenario() as scenario:
|
||||
denied: Final = _registered_endpoint(gateway, scenario, wire)
|
||||
visible: Final = _registered_endpoint(gateway, scenario, wire)
|
||||
team: Final = scenario.team(denied_passthrough_routes=[denied])
|
||||
|
||||
listed: Final = gateway.get("/config/pass_through_endpoint", {"team_id": team})["endpoints"]
|
||||
|
||||
paths: Final = {string_value(object_value(endpoint)["path"]) for endpoint in ENDPOINTS.validate_python(listed)}
|
||||
assert visible in paths, paths
|
||||
assert denied not in paths, paths
|
||||
|
||||
|
||||
def test_team_admin_cannot_clear_or_drop_a_deny_a_proxy_admin_set_on_a_team_key(gateway: Gateway) -> None:
|
||||
with wire_server(_echo) as wire, gateway.scenario() as scenario:
|
||||
path: Final = _registered_endpoint(gateway, scenario, wire)
|
||||
team_admin: Final = scenario.user(user_role="internal_user")
|
||||
team: Final = scenario.team(members_with_roles=[{"role": "admin", "user_id": team_admin}])
|
||||
team_admin_key: Final = scenario.key(user_id=team_admin)
|
||||
denied: Final[list[JsonValue]] = [f"{path}/admin"]
|
||||
key: Final = scenario.key(team_id=team, allowed_passthrough_routes=[path], denied_passthrough_routes=denied)
|
||||
|
||||
def update(body: dict[str, JsonValue]) -> httpx.Response:
|
||||
return gateway.request("POST", "/key/update", {"key": key, **body}, key=team_admin_key)
|
||||
|
||||
cleared: Final = update({"denied_passthrough_routes": []})
|
||||
dropped: Final = update({"metadata": {}})
|
||||
unchanged: Final = update({"denied_passthrough_routes": denied})
|
||||
|
||||
assert cleared.status_code == 403 and "denied_passthrough_routes" in cleared.text, cleared.text
|
||||
assert dropped.status_code == 403 and "metadata.denied_passthrough_routes" in dropped.text, dropped.text
|
||||
assert unchanged.status_code == 200, unchanged.text
|
||||
_assert_denied(_call(gateway, f"{path}/admin/users", key), f"{path}/admin")
|
||||
assert _upstream_targets(wire) == ()
|
||||
|
||||
|
||||
def test_team_admin_bulk_update_cannot_drop_a_deny_a_proxy_admin_set_on_a_team_key(gateway: Gateway) -> None:
|
||||
with wire_server(_echo) as wire, gateway.scenario() as scenario:
|
||||
path: Final = _registered_endpoint(gateway, scenario, wire)
|
||||
team_admin: Final = scenario.user(user_role="internal_user")
|
||||
team: Final = scenario.team(members_with_roles=[{"role": "admin", "user_id": team_admin}])
|
||||
team_admin_key: Final = scenario.key(user_id=team_admin)
|
||||
guarded: Final = scenario.key(
|
||||
team_id=team, allowed_passthrough_routes=[path], denied_passthrough_routes=[f"{path}/admin"]
|
||||
)
|
||||
plain: Final = scenario.key(team_id=team, allowed_passthrough_routes=[path])
|
||||
|
||||
response: Final = gateway.request(
|
||||
"POST",
|
||||
"/team/key/bulk_update",
|
||||
{"team_id": team, "key_ids": [guarded, plain], "update_fields": {"metadata": {}}},
|
||||
key=team_admin_key,
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.text
|
||||
body: Final = JSON_OBJECT.validate_python(response.json())
|
||||
failed: Final = tuple(object_value(item) for item in ENDPOINTS.validate_python(body["failed_updates"]))
|
||||
succeeded: Final = tuple(object_value(item) for item in ENDPOINTS.validate_python(body["successful_updates"]))
|
||||
assert [string_value(item["key"]) for item in failed] == [guarded], response.text
|
||||
assert "metadata.denied_passthrough_routes" in string_value(failed[0]["failed_reason"]), response.text
|
||||
assert [string_value(item["key"]) for item in succeeded] == [plain], response.text
|
||||
_assert_denied(_call(gateway, f"{path}/admin/users", guarded), f"{path}/admin")
|
||||
assert _upstream_targets(wire) == ()
|
||||
|
||||
|
||||
@pytest.mark.parametrize("route", ["/key/update", "/key/regenerate"])
|
||||
def test_non_owner_gets_the_same_refusal_whether_or_not_another_users_key_has_a_deny(
|
||||
gateway: Gateway, route: str
|
||||
) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
owner: Final = scenario.user(user_role="internal_user")
|
||||
guarded: Final = scenario.key(user_id=owner, denied_passthrough_routes=["/integration-deny-probe"])
|
||||
plain: Final = scenario.key(user_id=owner)
|
||||
outsider_key: Final = scenario.key(user_id=scenario.user(user_role="internal_user"))
|
||||
|
||||
def probe(key: str) -> httpx.Response:
|
||||
return gateway.request("POST", route, {"key": key, "denied_passthrough_routes": []}, key=outsider_key)
|
||||
|
||||
on_guarded: Final = probe(guarded)
|
||||
on_plain: Final = probe(plain)
|
||||
|
||||
assert on_guarded.status_code == on_plain.status_code != 200, (on_guarded.text, on_plain.text)
|
||||
assert "denied_passthrough_routes" not in on_guarded.text, on_guarded.text
|
||||
assert on_guarded.text.replace(guarded, "KEY") == on_plain.text.replace(plain, "KEY")
|
||||
|
||||
|
||||
def test_non_admin_setting_allowed_routes_on_regenerate_is_refused_before_the_key_lookup(gateway: Gateway) -> None:
|
||||
with gateway.scenario() as scenario:
|
||||
user_key: Final = scenario.key(user_id=scenario.user(user_role="internal_user"))
|
||||
|
||||
response: Final = gateway.request(
|
||||
"POST",
|
||||
"/key/regenerate",
|
||||
{"key": f"sk-missing-{uuid.uuid4().hex}", "allowed_passthrough_routes": ["/integration-deny-probe"]},
|
||||
key=user_key,
|
||||
)
|
||||
|
||||
assert response.status_code == 403, response.text
|
||||
assert "allowed_passthrough_routes" in response.text, response.text
|
||||
|
|
@ -8252,3 +8252,54 @@ async def test_check_admin_access_names_the_route_and_the_expanded_allow_list_wh
|
|||
"Admin not allowed to access this route. Route=/key/generate, "
|
||||
f"Allowed Routes={[*LiteLLMRoutes.info_routes.value, '/custom/admin/route']}"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"team_allowed_routes, team_metadata",
|
||||
[
|
||||
((), {"allowed_passthrough_routes": ["/model-host"], "denied_passthrough_routes": ["/model-host/v1"]}),
|
||||
(("/model-host/*",), {"denied_passthrough_routes": ["/model-host/v1/*"]}),
|
||||
],
|
||||
ids=["team-metadata-allow", "jwt-team-allowed-routes-grant"],
|
||||
)
|
||||
def test_team_has_passthrough_route_access_denied_route_wins(
|
||||
team_allowed_routes: tuple[str, ...],
|
||||
team_metadata: dict[str, list[str]],
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.delenv("SERVER_ROOT_PATH", raising=False)
|
||||
team: Final = LiteLLM_TeamTable(team_id="team-a", metadata=team_metadata)
|
||||
|
||||
with patch(
|
||||
"litellm.proxy.pass_through_endpoints.pass_through_endpoints._registered_pass_through_routes",
|
||||
_AUTH_ENFORCED_MODEL_HOST_ROUTES,
|
||||
):
|
||||
assert not JWTAuthManager._team_has_passthrough_route_access(
|
||||
team_object=team,
|
||||
route="/model-host/v1/extractor/predict",
|
||||
request_method="POST",
|
||||
team_allowed_routes=team_allowed_routes,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"team_metadata, expected_detail",
|
||||
[
|
||||
(
|
||||
{"allowed_passthrough_routes": ["/model-host"], "denied_passthrough_routes": ["/model-host/v1"]},
|
||||
"Matched `/model-host/v1` in `denied_passthrough_routes`",
|
||||
),
|
||||
({}, "Team not allowed to access passthrough route"),
|
||||
],
|
||||
ids=["team-deny-names-the-entry", "no-grant-keeps-generic-message"],
|
||||
)
|
||||
def test_team_passthrough_route_denial_names_the_matched_deny_entry(
|
||||
team_metadata: dict[str, list[str]], expected_detail: str
|
||||
) -> None:
|
||||
team: Final = LiteLLM_TeamTable(team_id="team-a", metadata=team_metadata)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
JWTAuthManager._raise_team_passthrough_route_denial(route="/model-host/v1/predict", team_object=team)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert expected_detail in str(exc_info.value.detail)
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ from litellm.proxy._types import (
|
|||
LitellmUserRoles,
|
||||
UserAPIKeyAuth,
|
||||
)
|
||||
from litellm.proxy.auth.auth_checks import _is_api_route_allowed
|
||||
from litellm.proxy.auth.auth_checks_organization import _user_is_org_admin
|
||||
from litellm.proxy.auth.route_checks import RouteChecks
|
||||
from litellm.proxy.pass_through_endpoints.llm_passthrough_endpoints import router as llm_passthrough_router
|
||||
|
|
@ -4464,6 +4465,210 @@ def test_non_admin_trace_reads_reach_endpoint_visibility_checks(route: str) -> N
|
|||
)
|
||||
|
||||
|
||||
_DENY_TEST_REGISTERED_ROUTES: Final = {
|
||||
"test-uuid-1:subpath:/svc:GET,POST": {
|
||||
"endpoint_id": "test-uuid-1",
|
||||
"path": "/svc",
|
||||
"type": "subpath",
|
||||
"auth": True,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _check_route_with_registered_routes(
|
||||
route: str, valid_token: UserAPIKeyAuth, user_role: LitellmUserRoles = LitellmUserRoles.INTERNAL_USER
|
||||
) -> None:
|
||||
request: Final = MagicMock(spec=Request)
|
||||
request.method = "POST"
|
||||
with (
|
||||
pytest.MonkeyPatch.context() as env,
|
||||
patch(
|
||||
"litellm.proxy.pass_through_endpoints.pass_through_endpoints._registered_pass_through_routes",
|
||||
_DENY_TEST_REGISTERED_ROUTES,
|
||||
),
|
||||
):
|
||||
env.delenv("SERVER_ROOT_PATH", raising=False)
|
||||
_is_api_route_allowed(
|
||||
route=route,
|
||||
request=request,
|
||||
request_data={},
|
||||
valid_token=valid_token,
|
||||
user_obj=LiteLLM_UserTable(user_id="test_user", user_role=user_role.value),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"metadata, team_metadata, denied_route",
|
||||
[
|
||||
({"allowed_passthrough_routes": ["/svc"], "denied_passthrough_routes": ["/svc/admin"]}, {}, "/svc/admin"),
|
||||
({"denied_passthrough_routes": ["/svc/admin"]}, {"allowed_passthrough_routes": ["/svc"]}, "/svc/admin"),
|
||||
({"allowed_passthrough_routes": ["/svc"]}, {"denied_passthrough_routes": ["/svc/admin"]}, "/svc/admin"),
|
||||
({"allowed_passthrough_routes": ["/svc"], "denied_passthrough_routes": ["/svc/adm*"]}, {}, "/svc/adm*"),
|
||||
],
|
||||
ids=["key-deny-beats-key-allow", "key-deny-beats-team-allow", "team-deny-beats-key-allow", "wildcard-deny"],
|
||||
)
|
||||
def test_denied_passthrough_routes_win_over_allow(
|
||||
metadata: dict[str, list[str]], team_metadata: dict[str, list[str]], denied_route: str
|
||||
) -> None:
|
||||
valid_token: Final = UserAPIKeyAuth(
|
||||
user_id="test_user",
|
||||
user_role=LitellmUserRoles.INTERNAL_USER.value,
|
||||
metadata=metadata,
|
||||
team_metadata=team_metadata,
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
_check_route_with_registered_routes(route="/svc/admin/users", valid_token=valid_token)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert f"Matched `{denied_route}` in `denied_passthrough_routes`" in exc_info.value.detail
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"route",
|
||||
["/svc/public", "/svc/administrator", "/anthropic/v1/messages", "/chat/completions"],
|
||||
ids=["allowed-sibling", "no-false-prefix-match", "built-in-provider-route", "llm-api-route"],
|
||||
)
|
||||
def test_denied_passthrough_routes_leave_other_routes_untouched(route: str) -> None:
|
||||
valid_token: Final = UserAPIKeyAuth(
|
||||
user_id="test_user",
|
||||
user_role=LitellmUserRoles.INTERNAL_USER.value,
|
||||
metadata={
|
||||
"allowed_passthrough_routes": ["/svc"],
|
||||
"denied_passthrough_routes": ["/svc/admin", "/anthropic", "/chat/completions"],
|
||||
},
|
||||
)
|
||||
|
||||
_check_route_with_registered_routes(route=route, valid_token=valid_token)
|
||||
|
||||
|
||||
def test_denied_passthrough_routes_do_not_restrict_proxy_admins() -> None:
|
||||
valid_token: Final = UserAPIKeyAuth(
|
||||
user_id="test_user",
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN.value,
|
||||
metadata={"denied_passthrough_routes": ["/svc"]},
|
||||
team_metadata={"denied_passthrough_routes": ["/svc"]},
|
||||
)
|
||||
|
||||
_check_route_with_registered_routes(
|
||||
route="/svc/admin/users", valid_token=valid_token, user_role=LitellmUserRoles.PROXY_ADMIN
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"route",
|
||||
[
|
||||
"/svc/public/../admin/users",
|
||||
"/svc/public/../../admin/users",
|
||||
"/svc//admin/users",
|
||||
"/svc/./admin",
|
||||
"/svc/admin?",
|
||||
"/svc/admin?/users",
|
||||
"/svc/admin#",
|
||||
"/svc/admin#/users",
|
||||
"/svc/public/../admin?x",
|
||||
"/svc/public?x/../admin?",
|
||||
"/svc/public#x/../admin#",
|
||||
],
|
||||
ids=[
|
||||
"dot-dot-segment",
|
||||
"dot-dot-past-endpoint-root",
|
||||
"empty-segment",
|
||||
"dot-segment",
|
||||
"query-mark",
|
||||
"query-mark-then-subpath",
|
||||
"fragment-mark",
|
||||
"fragment-mark-then-subpath",
|
||||
"dot-dot-then-query-mark",
|
||||
"query-mark-then-dot-dot",
|
||||
"fragment-mark-then-dot-dot",
|
||||
],
|
||||
)
|
||||
def test_dot_and_empty_segments_cannot_reach_a_denied_route(route: str) -> None:
|
||||
valid_token: Final = UserAPIKeyAuth(
|
||||
user_id="test_user",
|
||||
user_role=LitellmUserRoles.INTERNAL_USER.value,
|
||||
metadata={"allowed_passthrough_routes": ["/svc"], "denied_passthrough_routes": ["/svc/admin"]},
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
_check_route_with_registered_routes(route=route, valid_token=valid_token)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert "Matched `/svc/admin` in `denied_passthrough_routes`" in exc_info.value.detail
|
||||
|
||||
|
||||
def test_dot_dot_out_of_a_denied_route_is_checked_as_the_route_it_forwards_to() -> None:
|
||||
valid_token: Final = UserAPIKeyAuth(
|
||||
user_id="test_user",
|
||||
user_role=LitellmUserRoles.INTERNAL_USER.value,
|
||||
metadata={"allowed_passthrough_routes": ["/svc"], "denied_passthrough_routes": ["/svc/admin"]},
|
||||
)
|
||||
|
||||
_check_route_with_registered_routes(route="/svc/admin/../public", valid_token=valid_token)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("denied_route", ["/", "//"])
|
||||
@pytest.mark.parametrize("route", ["/svc", "/svc/public", "/svc/admin/users"])
|
||||
def test_root_deny_entry_blocks_every_route(route: str, denied_route: str) -> None:
|
||||
valid_token: Final = UserAPIKeyAuth(
|
||||
user_id="test_user",
|
||||
user_role=LitellmUserRoles.INTERNAL_USER.value,
|
||||
metadata={"allowed_passthrough_routes": ["/svc"], "denied_passthrough_routes": [denied_route]},
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
_check_route_with_registered_routes(route=route, valid_token=valid_token)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert f"Matched `{denied_route}` in `denied_passthrough_routes`" in exc_info.value.detail
|
||||
|
||||
|
||||
@pytest.mark.parametrize("route", ["/svc/admin", "/svc/admin/", "/svc/admin/users"])
|
||||
def test_trailing_slash_deny_entry_blocks_the_route_and_everything_under_it(route: str) -> None:
|
||||
valid_token: Final = UserAPIKeyAuth(
|
||||
user_id="test_user",
|
||||
user_role=LitellmUserRoles.INTERNAL_USER.value,
|
||||
metadata={"allowed_passthrough_routes": ["/svc"], "denied_passthrough_routes": ["/svc/admin/"]},
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
_check_route_with_registered_routes(route=route, valid_token=valid_token)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert "Matched `/svc/admin/` in `denied_passthrough_routes`" in exc_info.value.detail
|
||||
|
||||
|
||||
def test_trailing_slash_deny_entry_does_not_match_a_longer_segment() -> None:
|
||||
valid_token: Final = UserAPIKeyAuth(
|
||||
user_id="test_user",
|
||||
user_role=LitellmUserRoles.INTERNAL_USER.value,
|
||||
metadata={"allowed_passthrough_routes": ["/svc"], "denied_passthrough_routes": ["/svc/admin/"]},
|
||||
)
|
||||
|
||||
_check_route_with_registered_routes(route="/svc/administrator", valid_token=valid_token)
|
||||
|
||||
|
||||
def test_dot_segments_resolving_outside_a_denied_route_still_pass() -> None:
|
||||
valid_token: Final = UserAPIKeyAuth(
|
||||
user_id="test_user",
|
||||
user_role=LitellmUserRoles.INTERNAL_USER.value,
|
||||
metadata={"allowed_passthrough_routes": ["/svc"], "denied_passthrough_routes": ["/svc/admin"]},
|
||||
)
|
||||
|
||||
_check_route_with_registered_routes(route="/svc/public/./docs", valid_token=valid_token)
|
||||
|
||||
|
||||
def test_query_text_naming_a_denied_route_still_passes() -> None:
|
||||
valid_token: Final = UserAPIKeyAuth(
|
||||
user_id="test_user",
|
||||
user_role=LitellmUserRoles.INTERNAL_USER.value,
|
||||
metadata={"allowed_passthrough_routes": ["/svc"], "denied_passthrough_routes": ["/svc/admin"]},
|
||||
)
|
||||
|
||||
_check_route_with_registered_routes(route="/svc/public?next=/svc/admin", valid_token=valid_token)
|
||||
|
||||
|
||||
def test_is_llm_api_route():
|
||||
assert RouteChecks.is_llm_api_route("/v1/chat/completions") is True
|
||||
assert RouteChecks.is_llm_api_route("/v1/completions") is True
|
||||
|
|
|
|||
|
|
@ -9,22 +9,24 @@ users can intentionally clear previously-set fields.
|
|||
|
||||
from datetime import datetime, timezone
|
||||
from types import SimpleNamespace
|
||||
|
||||
from fastapi import HTTPException
|
||||
from litellm import Router
|
||||
from typing import Final
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from litellm import Router
|
||||
from litellm.proxy._types import (
|
||||
Member,
|
||||
LiteLLM_OrganizationMembershipTable,
|
||||
LiteLLM_TeamTable,
|
||||
LiteLLM_UserTable,
|
||||
LitellmUserRoles,
|
||||
Member,
|
||||
UserAPIKeyAuth,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.common_utils import (
|
||||
_has_non_empty_value,
|
||||
_org_admin_can_invite_user,
|
||||
_set_object_metadata_field,
|
||||
_team_admin_can_invite_user,
|
||||
|
|
@ -33,7 +35,6 @@ from litellm.proxy.management_endpoints.common_utils import (
|
|||
_user_has_admin_view,
|
||||
admin_can_invite_user,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.common_utils import _has_non_empty_value
|
||||
from litellm.types.utils import BudgetConfig
|
||||
|
||||
|
||||
|
|
@ -726,11 +727,109 @@ class TestCheckPassthroughRoutesCallerPermission:
|
|||
class _Bare(BaseModel):
|
||||
unrelated: str = "x"
|
||||
|
||||
assert (
|
||||
_check_passthrough_routes_caller_permission(_Bare(), self._non_admin())
|
||||
is None
|
||||
assert _check_passthrough_routes_caller_permission(_Bare(), self._non_admin()) is None
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kwargs, field",
|
||||
[
|
||||
({"denied_passthrough_routes": ["/v1/foo"]}, "denied_passthrough_routes"),
|
||||
({"metadata": {"denied_passthrough_routes": ["/v1/foo"]}}, "metadata.denied_passthrough_routes"),
|
||||
],
|
||||
)
|
||||
def test_denied_routes_rejected_for_non_admin(self, kwargs: dict[str, object], field: str) -> None:
|
||||
from fastapi import HTTPException
|
||||
from pydantic import BaseModel
|
||||
|
||||
from litellm.proxy.management_endpoints.common_utils import (
|
||||
_check_passthrough_routes_caller_permission,
|
||||
)
|
||||
|
||||
class _RouteData(BaseModel):
|
||||
denied_passthrough_routes: list[str] | None = None
|
||||
metadata: dict[str, object] | None = None
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
_check_passthrough_routes_caller_permission(
|
||||
_RouteData.model_validate(kwargs), self._non_admin(), entity="team"
|
||||
)
|
||||
|
||||
assert exc_info.value.detail == {"error": f"Only proxy admins can set `{field}` on a team."}
|
||||
|
||||
|
||||
class _DenyRouteData(BaseModel):
|
||||
denied_passthrough_routes: list[str] | None = None
|
||||
metadata: dict[str, object] | None = None
|
||||
max_budget: float | None = None
|
||||
|
||||
|
||||
_EXISTING_DENY: Final = {"denied_passthrough_routes": ["/v1/foo"]}
|
||||
|
||||
|
||||
class TestDeniedPassthroughRoutesCallerPermission:
|
||||
@pytest.mark.parametrize(
|
||||
"kwargs, field",
|
||||
[
|
||||
({"denied_passthrough_routes": []}, "denied_passthrough_routes"),
|
||||
({"denied_passthrough_routes": ["/v1/other"]}, "denied_passthrough_routes"),
|
||||
({"metadata": {"team": "core"}}, "metadata.denied_passthrough_routes"),
|
||||
({"metadata": None}, "metadata.denied_passthrough_routes"),
|
||||
],
|
||||
ids=["cleared", "replaced", "dropped-by-metadata-replace", "dropped-by-null-metadata"],
|
||||
)
|
||||
def test_non_admin_cannot_change_an_existing_deny_list(self, kwargs: dict[str, object], field: str) -> None:
|
||||
from litellm.proxy.management_endpoints.common_utils import _check_passthrough_routes_caller_permission
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
_check_passthrough_routes_caller_permission(
|
||||
_DenyRouteData.model_validate(kwargs),
|
||||
UserAPIKeyAuth(user_id="u1", user_role=LitellmUserRoles.INTERNAL_USER),
|
||||
existing_metadata=_EXISTING_DENY,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert exc_info.value.detail == {"error": f"Only proxy admins can set `{field}` on a key."}
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"kwargs",
|
||||
[
|
||||
{"denied_passthrough_routes": ["/v1/foo"]},
|
||||
{"metadata": {"team": "core", "denied_passthrough_routes": ["/v1/foo"]}},
|
||||
{"max_budget": 10.0},
|
||||
],
|
||||
ids=["resent-top-level", "resent-in-metadata", "unrelated-field"],
|
||||
)
|
||||
def test_non_admin_may_leave_an_existing_deny_list_unchanged(self, kwargs: dict[str, object]) -> None:
|
||||
from litellm.proxy.management_endpoints.common_utils import _check_passthrough_routes_caller_permission
|
||||
|
||||
_check_passthrough_routes_caller_permission(
|
||||
_DenyRouteData.model_validate(kwargs),
|
||||
UserAPIKeyAuth(user_id="u1", user_role=LitellmUserRoles.INTERNAL_USER),
|
||||
existing_metadata=_EXISTING_DENY,
|
||||
)
|
||||
|
||||
def test_non_admin_may_send_null_metadata_when_no_deny_list_exists(self) -> None:
|
||||
from litellm.proxy.management_endpoints.common_utils import _check_passthrough_routes_caller_permission
|
||||
|
||||
_check_passthrough_routes_caller_permission(
|
||||
_DenyRouteData(metadata=None),
|
||||
UserAPIKeyAuth(user_id="u1", user_role=LitellmUserRoles.INTERNAL_USER),
|
||||
existing_metadata={"team": "core"},
|
||||
)
|
||||
|
||||
def test_malformed_metadata_deny_entries_are_rejected_even_for_proxy_admins(self) -> None:
|
||||
from litellm.proxy.management_endpoints.common_utils import _check_passthrough_routes_caller_permission
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
_check_passthrough_routes_caller_permission(
|
||||
_DenyRouteData(metadata={"denied_passthrough_routes": [123, None]}),
|
||||
UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN),
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert exc_info.value.detail == {
|
||||
"error": "`metadata.denied_passthrough_routes` must be a list of route strings."
|
||||
}
|
||||
|
||||
|
||||
class TestCheckDisableGlobalGuardrailsCallerPermission:
|
||||
"""Only proxy admins may set disable_global_guardrails (top-level or under
|
||||
|
|
|
|||
|
|
@ -14781,6 +14781,37 @@ async def test_process_single_key_update_non_admin_permissions_explicit_empty_re
|
|||
assert "permissions" in str(exc_info.value.detail)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"fields",
|
||||
[{"metadata": {}}, {"metadata": None}, {"denied_passthrough_routes": []}],
|
||||
ids=["metadata_replaced", "metadata_null", "denies_cleared"],
|
||||
)
|
||||
async def test_process_single_key_update_non_admin_cannot_drop_stored_denied_passthrough_routes(
|
||||
fields: dict[str, object],
|
||||
) -> None:
|
||||
stored_key: Final = LiteLLM_VerificationToken(
|
||||
token="hashed-key", user_id="key-owner", metadata={"denied_passthrough_routes": ["/svc/admin"]}
|
||||
)
|
||||
prisma_client: Final = AsyncMock()
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await _process_single_key_update(
|
||||
update_key_request=UpdateKeyRequest.model_validate({"key": "sk-owned-key", **fields}),
|
||||
user_api_key_dict=UserAPIKeyAuth(user_role=LitellmUserRoles.INTERNAL_USER, user_id="team-admin"),
|
||||
litellm_changed_by=None,
|
||||
prisma_client=prisma_client,
|
||||
user_api_key_cache=MagicMock(),
|
||||
proxy_logging_obj=MagicMock(),
|
||||
llm_router=MagicMock(),
|
||||
existing_key_row=stored_key,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
assert "denied_passthrough_routes" in str(exc_info.value.detail)
|
||||
prisma_client.update_data.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_virtual_key_regeneration_cache_invalidation_with_token_hash():
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -56,9 +56,9 @@ from litellm.proxy.pass_through_endpoints.success_handler import (
|
|||
from litellm.proxy.route_llm_request import ProxyModelNotFoundError
|
||||
from litellm.types import utils as types_utils
|
||||
from litellm.types.passthrough_endpoints.pass_through_endpoints import (
|
||||
EndpointType,
|
||||
LITELLM_PASS_THROUGH_DEPLOYMENT_MODEL_INFO_STATE_KEY,
|
||||
LITELLM_PASS_THROUGH_RAW_BODY_STATE_KEY,
|
||||
EndpointType,
|
||||
)
|
||||
from tests._master_key import MASTER_KEY
|
||||
from tests._vcr_conftest_common import install_live_call_probe, record_vcr_outcome
|
||||
|
|
@ -713,6 +713,28 @@ def test_construct_target_url_with_subpath():
|
|||
)
|
||||
assert result == "http://example.com/api/v1"
|
||||
|
||||
result = HttpPassThroughEndpointHelpers.construct_target_url_with_subpath(
|
||||
base_target="http://example.com", subpath="api/../v1/", include_subpath=True
|
||||
)
|
||||
assert result == "http://example.com/v1/"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"subpath",
|
||||
["admin/users", "public/../admin", "../../admin", "/admin/", "./admin", "admin?", "public?x/../admin#"],
|
||||
)
|
||||
def test_forwarded_route_is_the_path_the_forwarder_sends_upstream(subpath: str) -> None:
|
||||
from litellm.proxy.pass_through_endpoints.pass_through_endpoints import (
|
||||
HttpPassThroughEndpointHelpers,
|
||||
)
|
||||
|
||||
target: Final = HttpPassThroughEndpointHelpers.construct_target_url_with_subpath(
|
||||
base_target="http://upstream.test/base", subpath=subpath, include_subpath=True
|
||||
)
|
||||
forwarded: Final = HttpPassThroughEndpointHelpers.forwarded_route(endpoint_path="/svc", subpath=subpath)
|
||||
|
||||
assert "/svc" + httpx.URL(target).path.removeprefix("/base") == forwarded
|
||||
|
||||
|
||||
def test_add_exact_path_route():
|
||||
"""
|
||||
|
|
@ -7019,12 +7041,21 @@ async def test_user_defined_passthrough_is_neither_tracked_nor_enforced(metadata
|
|||
api_key="custom-key", token="custom-key", team_id="shared-team", team_model_max_budget=budget,
|
||||
)
|
||||
endpoint: Final = create_pass_through_route(
|
||||
endpoint="/custom-budget-test", target="https://upstream.test/echo", custom_headers={}, cost_per_request=0.25,
|
||||
endpoint="/custom-budget-test",
|
||||
target="https://upstream.test/echo",
|
||||
custom_headers={},
|
||||
cost_per_request=0.25,
|
||||
)
|
||||
request: Final = Request(
|
||||
{
|
||||
"type": "http",
|
||||
"method": "POST",
|
||||
"path": "/custom-budget-test",
|
||||
"headers": [],
|
||||
"query_string": b"",
|
||||
"endpoint": endpoint,
|
||||
}
|
||||
)
|
||||
request: Final = Request({
|
||||
"type": "http", "method": "POST", "path": "/custom-budget-test", "headers": [],
|
||||
"query_string": b"", "endpoint": endpoint,
|
||||
})
|
||||
body: Final = {
|
||||
"model": "upstream-only-model", metadata_slot: {
|
||||
"model_group": "managed-model", "customer_label": "retained",
|
||||
|
|
@ -8368,6 +8399,56 @@ def test_a_pass_through_added_after_a_lazy_feature_loaded_takes_over_its_path(mo
|
|||
assert client.post("/v1/decider").json() == {"served_by": "pass-through"}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filter_endpoints_by_team_allowed_routes_drops_denied() -> None:
|
||||
from litellm.proxy._types import PassThroughGenericEndpoint
|
||||
from litellm.proxy.pass_through_endpoints.pass_through_endpoints import (
|
||||
_filter_endpoints_by_team_allowed_routes,
|
||||
)
|
||||
|
||||
endpoints: Final = [
|
||||
PassThroughGenericEndpoint(id="endpoint-1", path="/api/public", target="http://example.com/api1"),
|
||||
PassThroughGenericEndpoint(id="endpoint-2", path="/api/admin", target="http://example.com/api2"),
|
||||
]
|
||||
mock_prisma_client: Final = MagicMock()
|
||||
mock_team: Final = MagicMock()
|
||||
mock_team.metadata = {"denied_passthrough_routes": ["/api/admin"]}
|
||||
mock_prisma_client.db.litellm_teamtable.find_unique = AsyncMock(return_value=mock_team)
|
||||
|
||||
result: Final = await _filter_endpoints_by_team_allowed_routes(
|
||||
team_id="test-team-123",
|
||||
pass_through_endpoints=endpoints,
|
||||
prisma_client=mock_prisma_client,
|
||||
)
|
||||
|
||||
assert [endpoint.path for endpoint in result] == ["/api/public"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filter_endpoints_by_team_allowed_routes_keeps_public_endpoints_the_team_denies() -> None:
|
||||
from litellm.proxy._types import PassThroughGenericEndpoint
|
||||
from litellm.proxy.pass_through_endpoints.pass_through_endpoints import (
|
||||
_filter_endpoints_by_team_allowed_routes,
|
||||
)
|
||||
|
||||
endpoints: Final = [
|
||||
PassThroughGenericEndpoint(id="endpoint-1", path="/api/webhook", target="http://example.com/a", auth=False),
|
||||
PassThroughGenericEndpoint(id="endpoint-2", path="/api/admin", target="http://example.com/b"),
|
||||
]
|
||||
mock_prisma_client: Final = MagicMock()
|
||||
mock_team: Final = MagicMock()
|
||||
mock_team.metadata = {"denied_passthrough_routes": ["/api"]}
|
||||
mock_prisma_client.db.litellm_teamtable.find_unique = AsyncMock(return_value=mock_team)
|
||||
|
||||
result: Final = await _filter_endpoints_by_team_allowed_routes(
|
||||
team_id="test-team-123",
|
||||
pass_through_endpoints=endpoints,
|
||||
prisma_client=mock_prisma_client,
|
||||
)
|
||||
|
||||
assert [endpoint.path for endpoint in result] == ["/api/webhook"]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
async def _drain_logging_worker():
|
||||
"""
|
||||
|
|
|
|||
20
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
20
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -8275,6 +8275,7 @@ export interface paths {
|
|||
* - prompts: Optional[List[str]] - List of prompts that the key is allowed to use.
|
||||
* - allowed_routes: Optional[list] - List of allowed routes for the key. Store the actual route or store a wildcard pattern for a set of routes. Example - ["/chat/completions", "/embeddings", "/keys/*"]
|
||||
* - allowed_passthrough_routes: Optional[list] - List of allowed pass through endpoints for the key. Store the actual endpoint or store a wildcard pattern for a set of endpoints. Example - ["/my-custom-endpoint"]. Use this instead of allowed_routes, if you just want to specify which pass through endpoints the key can access, without specifying the routes. If allowed_routes is specified, allowed_pass_through_endpoints is ignored.
|
||||
* - denied_passthrough_routes: Optional[list] - List of pass through routes the key may not call, even if allowed by `allowed_passthrough_routes` or `allowed_routes`. Matches exact paths, path prefixes, and trailing `*` wildcards. Applies together with the team's `denied_passthrough_routes`. Example - ["/my-custom-endpoint/admin"].
|
||||
* - object_permission: Optional[LiteLLM_ObjectPermissionBase] - key-specific object permission. Example - {"vector_stores": ["vector_store_1", "vector_store_2"], "agents": ["agent_1", "agent_2"], "agent_access_groups": ["dev_group"]}. IF null or {} then no object permission.
|
||||
* - key_type: Optional[str] - Type of key that determines default allowed routes. Options: "llm_api" (can call LLM API routes), "management" (can call management routes), "read_only" (can only call info/read routes), "default" (uses default allowed routes). Defaults to "default".
|
||||
* - prompts: Optional[List[str]] - List of allowed prompts for the key. If specified, the key will only be able to use these specific prompts.
|
||||
|
|
@ -8741,6 +8742,7 @@ export interface paths {
|
|||
* - temp_budget_expiry: Optional[str] - Expiry time for the temporary budget increase (Enterprise only).
|
||||
* - allowed_routes: Optional[list] - List of allowed routes for the key. Store the actual route or store a wildcard pattern for a set of routes. Example - ["/chat/completions", "/embeddings", "/keys/*"]
|
||||
* - allowed_passthrough_routes: Optional[list] - List of allowed pass through routes for the key. Store the actual route or store a wildcard pattern for a set of routes. Example - ["/my-custom-endpoint"]. Use this instead of allowed_routes, if you just want to specify which pass through routes the key can access, without specifying the routes. If allowed_routes is specified, allowed_passthrough_routes is ignored.
|
||||
* - denied_passthrough_routes: Optional[list] - List of pass through routes the key may not call, even if allowed by `allowed_passthrough_routes` or `allowed_routes`. Matches exact paths, path prefixes, and trailing `*` wildcards. Applies together with the team's `denied_passthrough_routes`. Example - ["/my-custom-endpoint/admin"].
|
||||
* - prompts: Optional[List[str]] - List of allowed prompts for the key. If specified, the key will only be able to use these specific prompts.
|
||||
* - object_permission: Optional[LiteLLM_ObjectPermissionBase] - key-specific object permission. Example - {"vector_stores": ["vector_store_1", "vector_store_2"], "agents": ["agent_1", "agent_2"], "agent_access_groups": ["dev_group"]}. IF null or {} then no object permission.
|
||||
* - auto_rotate: Optional[bool] - Whether this key should be automatically rotated
|
||||
|
|
@ -17220,6 +17222,7 @@ export interface paths {
|
|||
* - team_member_tpm_limit: Optional[int] - The TPM (Tokens Per Minute) limit for individual team members.
|
||||
* - team_member_key_duration: Optional[str] - The duration for a team member's key. e.g. "1d", "1w", "1mo"
|
||||
* - allowed_passthrough_routes: Optional[List[str]] - List of allowed pass through routes for the team.
|
||||
* - denied_passthrough_routes: Optional[List[str]] - List of pass through routes the team's keys may not call, even if allowed. Applies together with each key's `denied_passthrough_routes`.
|
||||
* - allowed_vector_store_indexes: Optional[List[dict]] - List of allowed vector store indexes for the key. Example - [{"index_name": "my-index", "index_permissions": ["write", "read"]}]. If specified, the key will only be able to use these specific vector store indexes. Create index, using `/v1/indexes` endpoint.
|
||||
* - secret_manager_settings: Optional[dict] - Secret manager settings for the team. [Docs](https://docs.litellm.ai/docs/secret_managers/overview)
|
||||
* - router_settings: Optional[UpdateRouterConfig] - team-specific router settings. Example - {"model_group_retry_policy": {"gpt-4": {"RateLimitErrorRetries": 5}}}. IF null or {} then no router settings.
|
||||
|
|
@ -17447,6 +17450,7 @@ export interface paths {
|
|||
* - team_member_tpm_limit: Optional[int] - The TPM (Tokens Per Minute) limit for individual team members.
|
||||
* - team_member_key_duration: Optional[str] - The duration for a team member's key. e.g. "1d", "1w", "1mo"
|
||||
* - allowed_passthrough_routes: Optional[List[str]] - List of allowed pass through routes for the team.
|
||||
* - denied_passthrough_routes: Optional[List[str]] - List of pass through routes the team's keys may not call, even if allowed. Applies together with each key's `denied_passthrough_routes`.
|
||||
* - model_rpm_limit: Optional[Dict[str, int]] - The RPM (Requests Per Minute) limit per model for this team. Example: {"gpt-4": 100, "gpt-3.5-turbo": 200}
|
||||
* - model_tpm_limit: Optional[Dict[str, int]] - The TPM (Tokens Per Minute) limit per model for this team. Example: {"gpt-4": 10000, "gpt-3.5-turbo": 20000}
|
||||
* - default_estimated_output_tokens: Optional[int] - Expected output tokens reserved for TPM limiting when a request omits max_tokens, for keys on this team that do not set their own. Positive integer.
|
||||
|
|
@ -32879,6 +32883,8 @@ export interface components {
|
|||
default_estimated_output_tokens_per_model?: {
|
||||
[key: string]: number;
|
||||
} | null;
|
||||
/** Denied Passthrough Routes */
|
||||
denied_passthrough_routes?: string[] | null;
|
||||
/** Disable Global Guardrails */
|
||||
disable_global_guardrails?: boolean | null;
|
||||
/** Duration */
|
||||
|
|
@ -33045,6 +33051,8 @@ export interface components {
|
|||
default_estimated_output_tokens_per_model?: {
|
||||
[key: string]: number;
|
||||
} | null;
|
||||
/** Denied Passthrough Routes */
|
||||
denied_passthrough_routes?: string[] | null;
|
||||
/** Disable Global Guardrails */
|
||||
disable_global_guardrails?: boolean | null;
|
||||
/** Duration */
|
||||
|
|
@ -39478,6 +39486,8 @@ export interface components {
|
|||
} | null;
|
||||
/** Default Team Member Models */
|
||||
default_team_member_models?: string[] | null;
|
||||
/** Denied Passthrough Routes */
|
||||
denied_passthrough_routes?: string[] | null;
|
||||
/** Disable Global Guardrails */
|
||||
disable_global_guardrails?: boolean | null;
|
||||
/** Enforced Batch Output Expires After */
|
||||
|
|
@ -39772,6 +39782,8 @@ export interface components {
|
|||
default_estimated_output_tokens_per_model?: {
|
||||
[key: string]: number;
|
||||
} | null;
|
||||
/** Denied Passthrough Routes */
|
||||
denied_passthrough_routes?: string[] | null;
|
||||
/** Disable Global Guardrails */
|
||||
disable_global_guardrails?: boolean | null;
|
||||
/** Duration */
|
||||
|
|
@ -40590,6 +40602,8 @@ export interface components {
|
|||
} | null;
|
||||
/** Default Team Member Models */
|
||||
default_team_member_models?: string[] | null;
|
||||
/** Denied Passthrough Routes */
|
||||
denied_passthrough_routes?: string[] | null;
|
||||
/** Disable Global Guardrails */
|
||||
disable_global_guardrails?: boolean | null;
|
||||
/** Enforced Batch Output Expires After */
|
||||
|
|
@ -42171,6 +42185,8 @@ export interface components {
|
|||
default_estimated_output_tokens_per_model?: {
|
||||
[key: string]: number;
|
||||
} | null;
|
||||
/** Denied Passthrough Routes */
|
||||
denied_passthrough_routes?: string[] | null;
|
||||
/** Disable Global Guardrails */
|
||||
disable_global_guardrails?: boolean | null;
|
||||
/** Duration */
|
||||
|
|
@ -48341,6 +48357,8 @@ export interface components {
|
|||
default_estimated_output_tokens_per_model?: {
|
||||
[key: string]: number;
|
||||
} | null;
|
||||
/** Denied Passthrough Routes */
|
||||
denied_passthrough_routes?: string[] | null;
|
||||
/** Disable Global Guardrails */
|
||||
disable_global_guardrails?: boolean | null;
|
||||
/** Duration */
|
||||
|
|
@ -48835,6 +48853,8 @@ export interface components {
|
|||
} | null;
|
||||
/** Default Team Member Models */
|
||||
default_team_member_models?: string[] | null;
|
||||
/** Denied Passthrough Routes */
|
||||
denied_passthrough_routes?: string[] | null;
|
||||
/** Disable Global Guardrails */
|
||||
disable_global_guardrails?: boolean | null;
|
||||
/** Enforced Batch Output Expires After */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue