mirror of
https://github.com/BerriAI/litellm.git
synced 2026-08-28 05:25:59 +00:00
3435 lines
114 KiB
Python
3435 lines
114 KiB
Python
import os
|
|
import sys
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
sys.path.insert(
|
|
0, os.path.abspath("../../..")
|
|
) # Adds the parent directory to the system path
|
|
|
|
import pytest
|
|
from fastapi import HTTPException, Request
|
|
|
|
from litellm.proxy._types import LiteLLM_UserTable, LitellmUserRoles, UserAPIKeyAuth
|
|
from litellm.proxy.auth.route_checks import RouteChecks
|
|
|
|
|
|
def test_non_admin_config_update_route_rejected():
|
|
"""Test that non-admin users are rejected when trying to call /config/update"""
|
|
|
|
# Create a non-admin user object
|
|
user_obj = LiteLLM_UserTable(
|
|
user_id="test_user",
|
|
user_email="test@example.com",
|
|
user_role=LitellmUserRoles.INTERNAL_USER.value, # Non-admin role
|
|
)
|
|
|
|
# Create a non-admin user API key auth
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="test_user",
|
|
user_role=LitellmUserRoles.INTERNAL_USER.value, # Non-admin role
|
|
)
|
|
|
|
# Create a mock request
|
|
request = MagicMock(spec=Request)
|
|
request.query_params = {}
|
|
|
|
# Test that calling /config/update route raises HTTPException with 403 status
|
|
with pytest.raises(Exception) as exc_info:
|
|
RouteChecks.non_proxy_admin_allowed_routes_check(
|
|
user_obj=user_obj,
|
|
_user_role=LitellmUserRoles.INTERNAL_USER.value,
|
|
route="/config/update",
|
|
request=request,
|
|
valid_token=valid_token,
|
|
request_data={},
|
|
)
|
|
|
|
# Verify the exception is raised with the correct message
|
|
assert (
|
|
"Only proxy admin can be used to generate, delete, update info for new keys/users/teams"
|
|
in str(exc_info.value)
|
|
)
|
|
assert "Route=/config/update" in str(exc_info.value)
|
|
assert "Your role=internal_user" in str(exc_info.value)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"role",
|
|
[
|
|
LitellmUserRoles.INTERNAL_USER.value,
|
|
LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value,
|
|
],
|
|
)
|
|
@pytest.mark.parametrize(
|
|
"route",
|
|
["/compliance/eu-ai-act", "/compliance/gdpr"],
|
|
)
|
|
def test_compliance_routes_open_to_non_admin_roles(role, route):
|
|
"""Compliance routes are stateless validators on caller-supplied log data
|
|
— both non-admin internal_user roles can call them."""
|
|
user_obj = LiteLLM_UserTable(
|
|
user_id="test_user",
|
|
user_email="test@example.com",
|
|
user_role=role,
|
|
)
|
|
valid_token = UserAPIKeyAuth(user_id="test_user", user_role=role)
|
|
request = MagicMock(spec=Request)
|
|
request.query_params = {}
|
|
|
|
RouteChecks.non_proxy_admin_allowed_routes_check(
|
|
user_obj=user_obj,
|
|
_user_role=role,
|
|
route=route,
|
|
request=request,
|
|
valid_token=valid_token,
|
|
request_data={},
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"role",
|
|
[
|
|
LitellmUserRoles.INTERNAL_USER.value,
|
|
LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value,
|
|
],
|
|
)
|
|
def test_user_banner_read_open_to_non_admin_roles(role):
|
|
"""The dashboard banner renders for every authenticated user, so the read
|
|
route must be reachable by non-admin roles."""
|
|
user_obj = LiteLLM_UserTable(
|
|
user_id="test_user",
|
|
user_email="test@example.com",
|
|
user_role=role,
|
|
)
|
|
valid_token = UserAPIKeyAuth(user_id="test_user", user_role=role)
|
|
request = MagicMock(spec=Request)
|
|
request.query_params = {}
|
|
|
|
RouteChecks.non_proxy_admin_allowed_routes_check(
|
|
user_obj=user_obj,
|
|
_user_role=role,
|
|
route="/get/user_banner",
|
|
request=request,
|
|
valid_token=valid_token,
|
|
request_data={},
|
|
)
|
|
|
|
|
|
def test_user_banner_update_rejected_for_non_admin():
|
|
"""Publishing the banner stays admin-only at the route layer."""
|
|
user_obj = LiteLLM_UserTable(
|
|
user_id="test_user",
|
|
user_email="test@example.com",
|
|
user_role=LitellmUserRoles.INTERNAL_USER.value,
|
|
)
|
|
valid_token = UserAPIKeyAuth(user_id="test_user", user_role=LitellmUserRoles.INTERNAL_USER.value)
|
|
request = MagicMock(spec=Request)
|
|
request.query_params = {}
|
|
|
|
with pytest.raises(Exception) as exc_info:
|
|
RouteChecks.non_proxy_admin_allowed_routes_check(
|
|
user_obj=user_obj,
|
|
_user_role=LitellmUserRoles.INTERNAL_USER.value,
|
|
route="/update/user_banner",
|
|
request=request,
|
|
valid_token=valid_token,
|
|
request_data={},
|
|
)
|
|
|
|
assert "Route=/update/user_banner" in str(exc_info.value)
|
|
|
|
|
|
def test_proxy_admin_viewer_config_update_route_rejected():
|
|
"""Test that proxy admin viewer users are rejected when trying to call /config/update"""
|
|
|
|
# Create a proxy admin viewer user object (read-only admin)
|
|
user_obj = LiteLLM_UserTable(
|
|
user_id="viewer_user",
|
|
user_email="viewer@example.com",
|
|
user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
|
|
)
|
|
|
|
# Create a proxy admin viewer user API key auth
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="viewer_user",
|
|
user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
|
|
)
|
|
|
|
# Create a mock request
|
|
request = MagicMock(spec=Request)
|
|
request.query_params = {}
|
|
|
|
# Test that calling /config/update route raises HTTPException with 403 status
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
RouteChecks.non_proxy_admin_allowed_routes_check(
|
|
user_obj=user_obj,
|
|
_user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
|
|
route="/config/update",
|
|
request=request,
|
|
valid_token=valid_token,
|
|
request_data={},
|
|
)
|
|
|
|
# Verify the exception is HTTPException with 403 status
|
|
assert exc_info.value.status_code == 403
|
|
assert "user not allowed to access this route" in str(exc_info.value.detail)
|
|
assert "role= proxy_admin_viewer" in str(exc_info.value.detail)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"blocked_route",
|
|
[
|
|
# team write routes that previously fell through the blocklist
|
|
"/team/block",
|
|
"/team/unblock",
|
|
"/team/permissions_update",
|
|
"/team/permissions_bulk_update",
|
|
# JWT key mapping write routes
|
|
"/jwt/key/mapping/new",
|
|
"/jwt/key/mapping/update",
|
|
"/jwt/key/mapping/delete",
|
|
# key write routes
|
|
"/key/bulk_update",
|
|
# path-parameterized key write routes (suffix match)
|
|
"/key/abc123/regenerate",
|
|
"/key/abc123/reset_spend",
|
|
# baseline coverage of routes that were already blocked
|
|
"/team/new",
|
|
"/team/delete",
|
|
"/key/generate",
|
|
"/key/delete",
|
|
"/model/new",
|
|
"/model/delete",
|
|
],
|
|
)
|
|
def test_proxy_admin_viewer_blocked_management_writes(blocked_route):
|
|
"""View-only admins must be denied on every management write route — the
|
|
fall-through path previously allowed /team/block, /team/unblock,
|
|
/key/bulk_update, /key/{id}/reset_spend, and the JWT key-mapping routes."""
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
RouteChecks._check_proxy_admin_viewer_access(
|
|
route=blocked_route,
|
|
_user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
|
|
request_data={},
|
|
)
|
|
assert exc_info.value.status_code == 403
|
|
assert blocked_route in str(exc_info.value.detail)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"allowed_read_route",
|
|
[
|
|
"/team/info",
|
|
"/team/list",
|
|
"/v2/team/list",
|
|
"/team/permissions_list",
|
|
"/team/daily/activity",
|
|
"/user/info",
|
|
"/user/list",
|
|
"/key/info",
|
|
"/key/list",
|
|
"/model/info",
|
|
"/jwt/key/mapping/list",
|
|
"/jwt/key/mapping/info",
|
|
],
|
|
)
|
|
def test_proxy_admin_viewer_allowed_management_reads(allowed_read_route):
|
|
"""View-only admins must still be allowed to read management routes."""
|
|
# Should not raise
|
|
RouteChecks._check_proxy_admin_viewer_access(
|
|
route=allowed_read_route,
|
|
_user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
|
|
request_data={},
|
|
)
|
|
|
|
|
|
def test_virtual_key_allowed_routes_with_litellm_routes_member_name_allowed():
|
|
"""Test that virtual key is allowed to call routes when allowed_routes contains LiteLLMRoutes member name"""
|
|
|
|
# Create a UserAPIKeyAuth with allowed_routes containing a LiteLLMRoutes member name
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="test_user",
|
|
allowed_routes=["openai_routes"], # This is a member name in LiteLLMRoutes enum
|
|
)
|
|
|
|
# Test that a route from the openai_routes group is allowed
|
|
result = RouteChecks.is_virtual_key_allowed_to_call_route(
|
|
route="/chat/completions", # This is in LiteLLMRoutes.openai_routes.value
|
|
valid_token=valid_token,
|
|
)
|
|
|
|
assert result is True
|
|
|
|
|
|
def test_virtual_key_mcp_routes_allows_v1_mcp_server():
|
|
"""Regression test for #20325: allow virtual keys to list MCP servers."""
|
|
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="test_user",
|
|
allowed_routes=["mcp_routes"],
|
|
)
|
|
|
|
result = RouteChecks.is_virtual_key_allowed_to_call_route(
|
|
route="/v1/mcp/server",
|
|
valid_token=valid_token,
|
|
)
|
|
|
|
assert result is True
|
|
|
|
|
|
def test_auth_enforced_passthrough_check_does_not_apply_to_info_routes():
|
|
"""Auth-enforced passthrough gating only applies to OpenAI/LLM route groups."""
|
|
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="test_user",
|
|
allowed_routes=["info_routes"],
|
|
)
|
|
|
|
with patch.object(
|
|
RouteChecks,
|
|
"is_auth_enforced_pass_through_route",
|
|
return_value=True,
|
|
) as mock_is_auth_enforced_pass_through_route:
|
|
result = RouteChecks.is_virtual_key_allowed_to_call_route(
|
|
route="/team/info",
|
|
valid_token=valid_token,
|
|
)
|
|
|
|
assert result is True
|
|
mock_is_auth_enforced_pass_through_route.assert_not_called()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"route",
|
|
[
|
|
"/v1/mcp/server/register",
|
|
"/v1/mcp/server/health",
|
|
"/v1/mcp/server/submissions",
|
|
"/v1/mcp/server/abc123",
|
|
"/v1/mcp/server/abc123/approve",
|
|
"/v1/mcp/server/oauth/session",
|
|
"/v1/mcp/server/oauth/abc123/authorize",
|
|
],
|
|
)
|
|
def test_virtual_key_mcp_routes_allows_v1_mcp_server_subpaths(route):
|
|
"""Regression test: mcp_routes must allow /v1/mcp/server sub-paths (register, health, oauth, etc.)."""
|
|
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="test_user",
|
|
allowed_routes=["mcp_routes"],
|
|
)
|
|
|
|
result = RouteChecks.is_virtual_key_allowed_to_call_route(
|
|
route=route,
|
|
valid_token=valid_token,
|
|
)
|
|
|
|
assert result is True
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"route",
|
|
[
|
|
"/v1/mcp/server",
|
|
"/v1/mcp/server/abc-123",
|
|
"/v1/mcp/server/abc-123/approve",
|
|
],
|
|
)
|
|
def test_mcp_management_routes_classified_as_management_not_llm_api(route):
|
|
"""MCP server CRUD must be management routes, not llm_api routes, so
|
|
DISABLE_LLM_API_ENDPOINTS on admin nodes does not block the Admin UI.
|
|
|
|
Note: virtual keys with allowed_routes=["llm_api_routes"] can still call
|
|
*GET* `/v1/mcp/server` and *GET* `/v1/mcp/server/{server_id}` — that
|
|
carve-out is enforced method-aware inside
|
|
`is_virtual_key_allowed_to_call_route`, not by adding the paths to
|
|
`llm_api_routes`. So `is_llm_api_route()` still returns False here and
|
|
`DISABLE_LLM_API_ENDPOINTS` still does not block these paths.
|
|
"""
|
|
|
|
assert RouteChecks.is_llm_api_route(route=route) is False
|
|
assert RouteChecks.is_management_route(route=route) is True
|
|
|
|
|
|
def _mock_request(method: str) -> Request:
|
|
request = MagicMock(spec=Request)
|
|
request.method = method
|
|
return request
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"route",
|
|
[
|
|
"/v1/mcp/server",
|
|
"/v1/mcp/server/abc-123",
|
|
],
|
|
)
|
|
def test_virtual_key_llm_api_routes_allows_get_mcp_server_discovery(route):
|
|
"""
|
|
Regression test: virtual keys with allowed_routes=["llm_api_routes"] must
|
|
be able to list/inspect MCP servers via GET /v1/mcp/server[/{server_id}].
|
|
|
|
The handlers strip credential-bearing fields via
|
|
`_sanitize_mcp_server_list_for_virtual_key` when the caller is a
|
|
restricted virtual key, so GET is safe to expose. The carve-out is
|
|
method-aware (see below) — non-GET requests to the same paths are
|
|
rejected at this layer, so admin-only writes remain gated.
|
|
"""
|
|
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="test_user",
|
|
allowed_routes=["llm_api_routes"],
|
|
)
|
|
|
|
result = RouteChecks.is_virtual_key_allowed_to_call_route(
|
|
route=route,
|
|
valid_token=valid_token,
|
|
request=_mock_request("GET"),
|
|
)
|
|
|
|
assert result is True
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"route",
|
|
[
|
|
"/v1/mcp/server",
|
|
"/v1/mcp/server/abc-123",
|
|
],
|
|
)
|
|
@pytest.mark.parametrize("method", ["POST", "PUT", "PATCH", "DELETE"])
|
|
def test_virtual_key_llm_api_routes_rejects_non_get_mcp_server_discovery(route, method):
|
|
"""Method-aware: the MCP server discovery carve-out is GET-only.
|
|
|
|
POST/PUT/PATCH/DELETE on `/v1/mcp/server[/{server_id}]` are admin-only
|
|
management writes and must not be reachable via llm_api_routes.
|
|
"""
|
|
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="test_user",
|
|
allowed_routes=["llm_api_routes"],
|
|
)
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
RouteChecks.is_virtual_key_allowed_to_call_route(
|
|
route=route,
|
|
valid_token=valid_token,
|
|
request=_mock_request(method),
|
|
)
|
|
|
|
assert exc_info.value.status_code == 403
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"route",
|
|
[
|
|
# Multi-segment admin-only sub-paths must NOT be reachable via
|
|
# llm_api_routes, even on GET.
|
|
"/v1/mcp/server/abc-123/approve",
|
|
"/v1/mcp/server/abc-123/reject",
|
|
"/v1/mcp/server/oauth/session",
|
|
"/v1/mcp/server/abc-123/user-credential",
|
|
],
|
|
)
|
|
def test_virtual_key_llm_api_routes_rejects_mcp_multi_segment_admin_subpaths(
|
|
route,
|
|
):
|
|
"""Multi-segment admin-only MCP sub-paths are not reachable via llm_api_routes.
|
|
|
|
The discovery carve-out only matches `/v1/mcp/server` and
|
|
`/v1/mcp/server/{server_id}` (single segment after `/server/`), so any
|
|
path with additional segments is rejected even when the request is GET.
|
|
"""
|
|
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="test_user",
|
|
allowed_routes=["llm_api_routes"],
|
|
)
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
RouteChecks.is_virtual_key_allowed_to_call_route(
|
|
route=route,
|
|
valid_token=valid_token,
|
|
request=_mock_request("GET"),
|
|
)
|
|
|
|
assert exc_info.value.status_code == 403
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"route, method",
|
|
[
|
|
("/mcp", "POST"),
|
|
("/mcp/", "POST"),
|
|
("/mcp/my-server", "POST"), # matches the /mcp/{subpath} pattern
|
|
("/mcp/tools", "GET"),
|
|
("/mcp/tools/list", "POST"),
|
|
("/mcp/tools/call", "POST"),
|
|
("/mcp-rest/tools/list", "GET"),
|
|
("/mcp-rest/tools/call", "POST"),
|
|
("/v1/mcp/tools", "GET"),
|
|
],
|
|
)
|
|
def test_virtual_key_llm_api_routes_allows_mcp_inference_endpoints(route, method):
|
|
"""Every MCP inference/discovery endpoint must be reachable by virtual keys
|
|
scoped to allowed_routes=["llm_api_routes"], the default the Create Key UI
|
|
applies.
|
|
|
|
/v1/mcp/tools is the most recent addition: before it joined this group a key
|
|
could list tools via /mcp/tools/list and /mcp-rest/tools/list but got a 403
|
|
on the equivalent /v1/mcp/tools. Unlike /v1/mcp/server, none of these paths
|
|
have a management write counterpart, so they live directly in
|
|
`mcp_inference_routes` rather than behind a method-aware carve-out.
|
|
"""
|
|
|
|
assert RouteChecks.is_llm_api_route(route=route) is True
|
|
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="test_user",
|
|
allowed_routes=["llm_api_routes"],
|
|
)
|
|
|
|
result = RouteChecks.is_virtual_key_allowed_to_call_route(
|
|
route=route,
|
|
valid_token=valid_token,
|
|
request=_mock_request(method),
|
|
)
|
|
|
|
assert result is True
|
|
|
|
|
|
@pytest.mark.parametrize("route", ["/model/info", "/v1/model/info"])
|
|
def test_virtual_key_llm_api_routes_allows_model_info(route):
|
|
"""AI API virtual keys must be able to read model metadata (pricing, mode,
|
|
max_tokens) for the deployments they can already route to. Both the
|
|
unversioned and /v1 paths are the same handler, so both must be reachable.
|
|
"""
|
|
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="test_user",
|
|
allowed_routes=["llm_api_routes"],
|
|
)
|
|
|
|
result = RouteChecks.is_virtual_key_allowed_to_call_route(
|
|
route=route,
|
|
valid_token=valid_token,
|
|
request=_mock_request("GET"),
|
|
)
|
|
|
|
assert result is True
|
|
|
|
|
|
@pytest.mark.parametrize("route", ["/model/info", "/v1/model/info"])
|
|
def test_model_info_not_classified_as_llm_api(route):
|
|
"""Membership in `llm_api_routes` must not promote /model/info to an
|
|
`is_llm_api_route()`. That predicate gates DISABLE_LLM_API_ENDPOINTS,
|
|
global/virtual-key budget enforcement, enforce_user_param and the JWT
|
|
x-litellm-team-id attachment; model metadata is a free read and must stay
|
|
outside all of them.
|
|
"""
|
|
|
|
assert RouteChecks.is_llm_api_route(route=route) is False
|
|
|
|
|
|
@pytest.mark.parametrize("route", ["/v2/model/info", "/model_group/info"])
|
|
def test_virtual_key_llm_api_routes_denies_other_model_info_routes(route):
|
|
"""The grant is scoped to the two /model/info paths. The paginated Admin UI
|
|
listing and the model-group endpoint stay outside it.
|
|
"""
|
|
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="test_user",
|
|
allowed_routes=["llm_api_routes"],
|
|
)
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
RouteChecks.is_virtual_key_allowed_to_call_route(
|
|
route=route,
|
|
valid_token=valid_token,
|
|
request=_mock_request("GET"),
|
|
)
|
|
|
|
assert exc_info.value.status_code == 403
|
|
|
|
|
|
def test_spend_logs_v2_classified_as_management_not_llm_api():
|
|
"""Paginated spend logs are a management/spend read route, not an LLM API."""
|
|
|
|
assert RouteChecks.is_llm_api_route(route="/spend/logs/v2") is False
|
|
assert RouteChecks.is_management_route(route="/spend/logs/v2") is True
|
|
|
|
|
|
def test_virtual_key_management_routes_allows_spend_logs_v2():
|
|
"""Management virtual keys should be allowed to call the v2 spend logs endpoint."""
|
|
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="test_user",
|
|
allowed_routes=["management_routes"],
|
|
)
|
|
|
|
result = RouteChecks.is_virtual_key_allowed_to_call_route(
|
|
route="/spend/logs/v2",
|
|
valid_token=valid_token,
|
|
)
|
|
|
|
assert result is True
|
|
|
|
|
|
def test_virtual_key_llm_api_routes_denies_spend_logs_v2():
|
|
"""AI API virtual keys should not gain spend-log access."""
|
|
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="test_user",
|
|
allowed_routes=["llm_api_routes"],
|
|
)
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
RouteChecks.is_virtual_key_allowed_to_call_route(
|
|
route="/spend/logs/v2",
|
|
valid_token=valid_token,
|
|
)
|
|
|
|
assert exc_info.value.status_code == 403
|
|
assert "Virtual key is not allowed to call this route" in str(exc_info.value.detail)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"route",
|
|
[
|
|
"/mcp/tools/call",
|
|
"/mcp-rest/tools/call",
|
|
"/mcp/tools/list",
|
|
],
|
|
)
|
|
def test_mcp_inference_routes_classified_as_llm_api(route):
|
|
"""MCP tool-call / passthrough routes must remain llm_api routes so they
|
|
continue to be blocked by DISABLE_LLM_API_ENDPOINTS on admin nodes."""
|
|
|
|
assert RouteChecks.is_llm_api_route(route=route) is True
|
|
assert RouteChecks.is_management_route(route=route) is False
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"route",
|
|
[
|
|
"/realtime/client_secrets",
|
|
"/v1/realtime/client_secrets",
|
|
"/openai/v1/realtime/client_secrets",
|
|
"/realtime/calls",
|
|
"/v1/realtime/calls",
|
|
"/openai/v1/realtime/calls",
|
|
"/realtime/transcription_sessions",
|
|
"/v1/realtime/transcription_sessions",
|
|
"/openai/v1/realtime/transcription_sessions",
|
|
],
|
|
)
|
|
def test_realtime_webrtc_http_routes_classified_as_llm_api(route):
|
|
"""GA Realtime WebRTC HTTP routes must be classified as LLM API routes so
|
|
non-admin virtual keys can call them instead of hitting the admin-only
|
|
401 branch in non_proxy_admin_allowed_routes_check.
|
|
|
|
Regression test for https://github.com/BerriAI/litellm/issues/29923
|
|
"""
|
|
|
|
assert RouteChecks.is_llm_api_route(route=route) is True
|
|
assert RouteChecks.is_management_route(route=route) is False
|
|
|
|
|
|
def test_virtual_key_allowed_routes_with_litellm_routes_member_name_denied():
|
|
"""Test that virtual key is denied when route is not in the allowed LiteLLMRoutes group"""
|
|
|
|
# Create a UserAPIKeyAuth with allowed_routes containing a LiteLLMRoutes member name
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="test_user",
|
|
allowed_routes=["info_routes"], # This is a member name in LiteLLMRoutes enum
|
|
)
|
|
|
|
# Test that a route NOT in the info_routes group raises an HTTPException
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
RouteChecks.is_virtual_key_allowed_to_call_route(
|
|
route="/chat/completions", # This is NOT in LiteLLMRoutes.info_routes.value
|
|
valid_token=valid_token,
|
|
)
|
|
|
|
# Verify the exception has correct status and message
|
|
assert exc_info.value.status_code == 403
|
|
assert "Virtual key is not allowed to call this route" in str(exc_info.value.detail)
|
|
assert "Only allowed to call routes: ['info_routes']" in str(exc_info.value.detail)
|
|
assert "Tried to call route: /chat/completions" in str(exc_info.value.detail)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"route",
|
|
[
|
|
"/anthropic/v1/messages",
|
|
"/anthropic/v1/count_tokens",
|
|
"/gemini/v1/models",
|
|
"/gemini/countTokens",
|
|
],
|
|
)
|
|
def test_virtual_key_llm_api_route_includes_passthrough_prefix(route):
|
|
"""
|
|
Virtual key with llm_api_routes should allow passthrough routes like /anthropic/v1/messages
|
|
|
|
Relevant issue: https://github.com/BerriAI/litellm/issues/14017
|
|
"""
|
|
|
|
valid_token = UserAPIKeyAuth(user_id="test_user", allowed_routes=["llm_api_routes"])
|
|
|
|
result = RouteChecks.is_virtual_key_allowed_to_call_route(
|
|
route=route, valid_token=valid_token
|
|
)
|
|
|
|
assert result is True
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"route",
|
|
[
|
|
"/v1beta/models/gemini-2.5-flash:countTokens",
|
|
"/v1beta/models/gemini-2.0-flash:generateContent",
|
|
"/v1beta/models/bedrock/claude-sonnet-3.7:generateContent",
|
|
"/v1beta/models/gemini-1.5-pro:streamGenerateContent",
|
|
"/models/gemini-2.5-flash:countTokens",
|
|
"/models/gemini-2.0-flash:generateContent",
|
|
"/models/bedrock/claude-sonnet-3.7:generateContent",
|
|
"/models/gemini-1.5-pro:streamGenerateContent",
|
|
],
|
|
)
|
|
def test_virtual_key_llm_api_routes_allows_google_routes(route):
|
|
"""
|
|
Test that virtual keys with llm_api_routes permission can access Google AI Studio routes.
|
|
"""
|
|
|
|
valid_token = UserAPIKeyAuth(user_id="test_user", allowed_routes=["llm_api_routes"])
|
|
|
|
result = RouteChecks.is_virtual_key_allowed_to_call_route(
|
|
route=route, valid_token=valid_token
|
|
)
|
|
|
|
assert result is True
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"route",
|
|
[
|
|
"/v1beta/models/google-gemini-2-5-pro-code-reviewer-k8s:generateContent",
|
|
"/v1beta/models/gemini-2.5-flash-exp:countTokens",
|
|
"/v1beta/models/custom-model-name-123:streamGenerateContent",
|
|
"/v1beta/models/bedrock/claude-sonnet-3.7:generateContent",
|
|
"/models/google-gemini-2-5-pro-code-reviewer-k8s:generateContent",
|
|
"/models/gemini-2.5-flash-exp:countTokens",
|
|
"/models/custom-model-name-123:streamGenerateContent",
|
|
"/models/bedrock/claude-sonnet-3.7:generateContent",
|
|
],
|
|
)
|
|
def test_google_routes_with_dynamic_model_names_recognized_as_llm_api_route(route):
|
|
"""
|
|
Test that Google routes with dynamic model names (including custom names) are recognized as LLM API routes.
|
|
|
|
This test verifies the fix for the issue where routes like:
|
|
/v1beta/models/google-gemini-2-5-pro-code-reviewer-k8s:generateContent
|
|
were incorrectly classified as "custom admin only route" instead of LLM API routes.
|
|
|
|
The fix adds pattern matching for Google routes with placeholders like {model_name}.
|
|
"""
|
|
|
|
# Test that the route is recognized as an LLM API route
|
|
assert RouteChecks.is_llm_api_route(route) is True
|
|
|
|
|
|
def test_google_routes_with_dynamic_model_names_accessible_to_internal_users():
|
|
"""
|
|
Test that internal users can access Google routes with dynamic model names.
|
|
|
|
This ensures that routes like /v1beta/models/{model_name}:generateContent
|
|
are properly accessible to internal users and not blocked as admin-only routes.
|
|
"""
|
|
|
|
# Create an internal user object
|
|
user_obj = LiteLLM_UserTable(
|
|
user_id="test_user",
|
|
user_email="test@example.com",
|
|
user_role=LitellmUserRoles.INTERNAL_USER.value,
|
|
)
|
|
|
|
# Create an internal user API key auth
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="test_user",
|
|
user_role=LitellmUserRoles.INTERNAL_USER.value,
|
|
)
|
|
|
|
# Create a mock request
|
|
request = MagicMock(spec=Request)
|
|
request.query_params = {}
|
|
|
|
# Test that calling Google route with dynamic model name does NOT raise an exception
|
|
try:
|
|
RouteChecks.non_proxy_admin_allowed_routes_check(
|
|
user_obj=user_obj,
|
|
_user_role=LitellmUserRoles.INTERNAL_USER.value,
|
|
route="/v1beta/models/google-gemini-2-5-pro-code-reviewer-k8s:generateContent",
|
|
request=request,
|
|
valid_token=valid_token,
|
|
request_data={"contents": [{"parts": [{"text": "test"}]}]},
|
|
)
|
|
# If no exception is raised, the test passes
|
|
except Exception as e:
|
|
pytest.fail(
|
|
f"Internal user should be able to access Google generateContent route. Got error: {str(e)}"
|
|
)
|
|
|
|
|
|
def test_virtual_key_allowed_routes_with_multiple_litellm_routes_member_names():
|
|
"""Test that virtual key works with multiple LiteLLMRoutes member names in allowed_routes"""
|
|
|
|
# Create a UserAPIKeyAuth with multiple LiteLLMRoutes member names
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="test_user", allowed_routes=["openai_routes", "info_routes"]
|
|
)
|
|
|
|
# Test that routes from both groups are allowed
|
|
result1 = RouteChecks.is_virtual_key_allowed_to_call_route(
|
|
route="/chat/completions",
|
|
valid_token=valid_token, # This is in openai_routes
|
|
)
|
|
|
|
result2 = RouteChecks.is_virtual_key_allowed_to_call_route(
|
|
route="/user/info",
|
|
valid_token=valid_token, # This is in info_routes
|
|
)
|
|
|
|
assert result1 is True
|
|
assert result2 is True
|
|
|
|
|
|
def test_virtual_key_allowed_routes_with_mixed_member_names_and_explicit_routes():
|
|
"""Test that virtual key works with both LiteLLMRoutes member names and explicit routes"""
|
|
|
|
# Create a UserAPIKeyAuth with both member names and explicit routes
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="test_user",
|
|
allowed_routes=[
|
|
"info_routes",
|
|
"/custom/route",
|
|
], # Mix of member name and explicit route
|
|
)
|
|
|
|
# Test that both info routes and explicit custom route are allowed
|
|
result1 = RouteChecks.is_virtual_key_allowed_to_call_route(
|
|
route="/user/info",
|
|
valid_token=valid_token, # This is in info_routes
|
|
)
|
|
|
|
result2 = RouteChecks.is_virtual_key_allowed_to_call_route(
|
|
route="/custom/route",
|
|
valid_token=valid_token, # This is explicitly listed
|
|
)
|
|
|
|
assert result1 is True
|
|
assert result2 is True
|
|
|
|
|
|
def test_virtual_key_allowed_routes_with_no_member_names_only_explicit():
|
|
"""Test that virtual key works when allowed_routes contains only explicit routes (no member names)"""
|
|
|
|
# Create a UserAPIKeyAuth with only explicit routes (no LiteLLMRoutes member names)
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="test_user",
|
|
allowed_routes=["/chat/completions", "/custom/route"], # Only explicit routes
|
|
)
|
|
|
|
# Test that explicit routes are allowed
|
|
result1 = RouteChecks.is_virtual_key_allowed_to_call_route(
|
|
route="/chat/completions", valid_token=valid_token
|
|
)
|
|
|
|
result2 = RouteChecks.is_virtual_key_allowed_to_call_route(
|
|
route="/custom/route", valid_token=valid_token
|
|
)
|
|
|
|
assert result1 is True
|
|
assert result2 is True
|
|
|
|
# Test that non-allowed route raises HTTPException
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
RouteChecks.is_virtual_key_allowed_to_call_route(
|
|
route="/user/info",
|
|
valid_token=valid_token, # Not in allowed routes
|
|
)
|
|
|
|
assert exc_info.value.status_code == 403
|
|
assert "Virtual key is not allowed to call this route" in str(exc_info.value.detail)
|
|
|
|
|
|
def test_anthropic_count_tokens_route_is_llm_api_route():
|
|
"""Test that /v1/messages/count_tokens is recognized as an LLM API route for Anthropic"""
|
|
|
|
# Test the core anthropic routes
|
|
assert RouteChecks.is_llm_api_route("/v1/messages") is True
|
|
assert RouteChecks.is_llm_api_route("/v1/messages/count_tokens") is True
|
|
|
|
|
|
def test_anthropic_count_tokens_route_accessible_to_internal_users():
|
|
"""Test that internal users can access the Anthropic count_tokens route"""
|
|
|
|
# Test that the route is recognized as an LLM API route (which means it's accessible to internal users)
|
|
# This is the core check that was failing in the original issue
|
|
assert RouteChecks.is_llm_api_route("/v1/messages/count_tokens") is True
|
|
|
|
# Also test that the regular messages route still works
|
|
assert RouteChecks.is_llm_api_route("/v1/messages") is True
|
|
|
|
|
|
def test_virtual_key_llm_api_routes_allows_registered_pass_through_endpoints():
|
|
"""
|
|
Virtual keys with llm_api_routes can access auth=true pass-through endpoints only when
|
|
allowed_passthrough_routes is configured on the key or team.
|
|
"""
|
|
|
|
mock_registered_routes = {
|
|
"test-uuid-1:exact:/azure-assistant:DELETE,GET,PATCH,POST,PUT": {
|
|
"endpoint_id": "test-uuid-1",
|
|
"path": "/azure-assistant",
|
|
"type": "exact",
|
|
"auth": True,
|
|
},
|
|
"test-uuid-2:subpath:/custom-endpoint:DELETE,GET,PATCH,POST,PUT": {
|
|
"endpoint_id": "test-uuid-2",
|
|
"path": "/custom-endpoint",
|
|
"type": "subpath",
|
|
"auth": True,
|
|
},
|
|
}
|
|
|
|
with (
|
|
patch(
|
|
"litellm.proxy.pass_through_endpoints.pass_through_endpoints._registered_pass_through_routes",
|
|
mock_registered_routes,
|
|
),
|
|
patch(
|
|
"litellm.proxy.utils.get_server_root_path",
|
|
return_value="/",
|
|
),
|
|
):
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="test_user",
|
|
allowed_routes=["llm_api_routes"],
|
|
metadata={
|
|
"allowed_passthrough_routes": [
|
|
"/azure-assistant",
|
|
"/custom-endpoint",
|
|
]
|
|
},
|
|
)
|
|
|
|
assert (
|
|
RouteChecks.is_virtual_key_allowed_to_call_route(
|
|
route="/azure-assistant",
|
|
valid_token=valid_token,
|
|
)
|
|
is True
|
|
)
|
|
assert (
|
|
RouteChecks.is_virtual_key_allowed_to_call_route(
|
|
route="/custom-endpoint/openai/assistants",
|
|
valid_token=valid_token,
|
|
)
|
|
is True
|
|
)
|
|
assert (
|
|
RouteChecks.is_virtual_key_allowed_to_call_route(
|
|
route="/custom-endpoint",
|
|
valid_token=valid_token,
|
|
)
|
|
is True
|
|
)
|
|
|
|
|
|
def test_virtual_key_llm_api_routes_allows_non_auth_enforced_pass_through_endpoints():
|
|
"""
|
|
Virtual keys with llm_api_routes can access registered pass-through endpoints that
|
|
are NOT auth-enforced (auth=false) without configuring allowed_passthrough_routes.
|
|
This is the original behaviour and must not regress.
|
|
"""
|
|
|
|
mock_registered_routes = {
|
|
"test-uuid-1:exact:/azure-assistant:DELETE,GET,PATCH,POST,PUT": {
|
|
"endpoint_id": "test-uuid-1",
|
|
"path": "/azure-assistant",
|
|
"type": "exact",
|
|
"auth": False,
|
|
},
|
|
"test-uuid-2:subpath:/custom-endpoint:DELETE,GET,PATCH,POST,PUT": {
|
|
"endpoint_id": "test-uuid-2",
|
|
"path": "/custom-endpoint",
|
|
"type": "subpath",
|
|
"auth": False,
|
|
},
|
|
}
|
|
|
|
with (
|
|
patch(
|
|
"litellm.proxy.pass_through_endpoints.pass_through_endpoints._registered_pass_through_routes",
|
|
mock_registered_routes,
|
|
),
|
|
patch(
|
|
"litellm.proxy.utils.get_server_root_path",
|
|
return_value="/",
|
|
),
|
|
):
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="test_user",
|
|
allowed_routes=["llm_api_routes"],
|
|
)
|
|
|
|
assert (
|
|
RouteChecks.is_virtual_key_allowed_to_call_route(
|
|
route="/azure-assistant",
|
|
valid_token=valid_token,
|
|
)
|
|
is True
|
|
)
|
|
assert (
|
|
RouteChecks.is_virtual_key_allowed_to_call_route(
|
|
route="/custom-endpoint/openai/assistants",
|
|
valid_token=valid_token,
|
|
)
|
|
is True
|
|
)
|
|
assert (
|
|
RouteChecks.is_virtual_key_allowed_to_call_route(
|
|
route="/custom-endpoint",
|
|
valid_token=valid_token,
|
|
)
|
|
is True
|
|
)
|
|
|
|
|
|
def test_virtual_key_llm_api_routes_denies_auth_pass_through_without_allowlist():
|
|
"""auth=true pass-through must not be reachable via llm_api_routes alone."""
|
|
|
|
mock_registered_routes = {
|
|
"test-uuid-1:exact:/azure-assistant:GET,POST": {
|
|
"endpoint_id": "test-uuid-1",
|
|
"path": "/azure-assistant",
|
|
"type": "exact",
|
|
"auth": True,
|
|
},
|
|
}
|
|
|
|
with (
|
|
patch(
|
|
"litellm.proxy.pass_through_endpoints.pass_through_endpoints._registered_pass_through_routes",
|
|
mock_registered_routes,
|
|
),
|
|
patch(
|
|
"litellm.proxy.utils.get_server_root_path",
|
|
return_value="/",
|
|
),
|
|
):
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="test_user",
|
|
allowed_routes=["llm_api_routes"],
|
|
)
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
RouteChecks.is_virtual_key_allowed_to_call_route(
|
|
route="/azure-assistant",
|
|
valid_token=valid_token,
|
|
)
|
|
assert exc_info.value.status_code == 403
|
|
assert "allowed_passthrough_routes" in exc_info.value.detail
|
|
|
|
|
|
def test_virtual_key_llm_api_routes_uses_method_specific_auth_setting():
|
|
"""Same-path pass-through routes must be checked against the request method."""
|
|
|
|
mock_registered_routes = {
|
|
"test-uuid-1:exact:/custom:GET": {
|
|
"endpoint_id": "test-uuid-1",
|
|
"path": "/custom",
|
|
"type": "exact",
|
|
"methods": ["GET"],
|
|
"auth": False,
|
|
},
|
|
"test-uuid-2:exact:/custom:POST": {
|
|
"endpoint_id": "test-uuid-2",
|
|
"path": "/custom",
|
|
"type": "exact",
|
|
"methods": ["POST"],
|
|
"auth": True,
|
|
},
|
|
}
|
|
|
|
with (
|
|
patch(
|
|
"litellm.proxy.pass_through_endpoints.pass_through_endpoints._registered_pass_through_routes",
|
|
mock_registered_routes,
|
|
),
|
|
patch(
|
|
"litellm.proxy.utils.get_server_root_path",
|
|
return_value="/",
|
|
),
|
|
):
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="test_user",
|
|
allowed_routes=["llm_api_routes"],
|
|
)
|
|
|
|
get_request = MagicMock(spec=Request)
|
|
get_request.method = "GET"
|
|
assert (
|
|
RouteChecks.is_virtual_key_allowed_to_call_route(
|
|
route="/custom",
|
|
valid_token=valid_token,
|
|
request=get_request,
|
|
)
|
|
is True
|
|
)
|
|
|
|
post_request = MagicMock(spec=Request)
|
|
post_request.method = "POST"
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
RouteChecks.is_virtual_key_allowed_to_call_route(
|
|
route="/custom",
|
|
valid_token=valid_token,
|
|
request=post_request,
|
|
)
|
|
|
|
assert exc_info.value.status_code == 403
|
|
|
|
|
|
def test_non_proxy_admin_denies_auth_pass_through_without_allowlist():
|
|
"""Internal users must not bypass allowed_passthrough_routes via openai_routes."""
|
|
|
|
mock_registered_routes = {
|
|
"test-uuid-1:exact:/my-pass-through:GET,POST": {
|
|
"endpoint_id": "test-uuid-1",
|
|
"path": "/my-pass-through",
|
|
"type": "exact",
|
|
"auth": True,
|
|
},
|
|
}
|
|
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="test_user",
|
|
user_role=LitellmUserRoles.INTERNAL_USER.value,
|
|
)
|
|
|
|
with (
|
|
patch(
|
|
"litellm.proxy.pass_through_endpoints.pass_through_endpoints._registered_pass_through_routes",
|
|
mock_registered_routes,
|
|
),
|
|
patch(
|
|
"litellm.proxy.utils.get_server_root_path",
|
|
return_value="/",
|
|
),
|
|
):
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
RouteChecks.non_proxy_admin_allowed_routes_check(
|
|
user_obj=None,
|
|
_user_role=LitellmUserRoles.INTERNAL_USER.value,
|
|
route="/my-pass-through",
|
|
request=MagicMock(spec=Request),
|
|
valid_token=valid_token,
|
|
request_data={},
|
|
)
|
|
assert exc_info.value.status_code == 403
|
|
assert "allowed_passthrough_routes" in exc_info.value.detail
|
|
|
|
|
|
def test_non_proxy_admin_allows_auth_pass_through_with_team_allowlist():
|
|
mock_registered_routes = {
|
|
"test-uuid-1:exact:/my-pass-through:GET,POST": {
|
|
"endpoint_id": "test-uuid-1",
|
|
"path": "/my-pass-through",
|
|
"type": "exact",
|
|
"auth": True,
|
|
},
|
|
}
|
|
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="test_user",
|
|
user_role=LitellmUserRoles.INTERNAL_USER.value,
|
|
team_metadata={"allowed_passthrough_routes": ["/my-pass-through"]},
|
|
)
|
|
|
|
with (
|
|
patch(
|
|
"litellm.proxy.pass_through_endpoints.pass_through_endpoints._registered_pass_through_routes",
|
|
mock_registered_routes,
|
|
),
|
|
patch(
|
|
"litellm.proxy.utils.get_server_root_path",
|
|
return_value="/",
|
|
),
|
|
):
|
|
RouteChecks.non_proxy_admin_allowed_routes_check(
|
|
user_obj=None,
|
|
_user_role=LitellmUserRoles.INTERNAL_USER.value,
|
|
route="/my-pass-through",
|
|
request=MagicMock(spec=Request),
|
|
valid_token=valid_token,
|
|
request_data={},
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"route,registered_type",
|
|
[
|
|
("/myprefix", "exact"),
|
|
("/myprefix/models", "subpath"),
|
|
],
|
|
)
|
|
def test_non_proxy_admin_allows_non_auth_pass_through_with_llm_api_routes(
|
|
route, registered_type
|
|
):
|
|
mock_registered_routes = {
|
|
f"test-uuid-1:{registered_type}:/myprefix:GET": {
|
|
"endpoint_id": "test-uuid-1",
|
|
"path": "/myprefix",
|
|
"type": registered_type,
|
|
"methods": ["GET"],
|
|
"auth": False,
|
|
},
|
|
}
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="test_user",
|
|
user_role=LitellmUserRoles.INTERNAL_USER.value,
|
|
allowed_routes=["llm_api_routes"],
|
|
)
|
|
from litellm.types.passthrough_endpoints.pass_through_endpoints import (
|
|
LITELLM_PASS_THROUGH_ENDPOINT_MARKER,
|
|
)
|
|
|
|
def pass_through_endpoint(): ...
|
|
|
|
setattr(pass_through_endpoint, LITELLM_PASS_THROUGH_ENDPOINT_MARKER, True)
|
|
request = Request(
|
|
scope={
|
|
"type": "http",
|
|
"headers": [],
|
|
"method": "GET",
|
|
"query_string": b"",
|
|
"endpoint": pass_through_endpoint,
|
|
}
|
|
)
|
|
|
|
with (
|
|
patch(
|
|
"litellm.proxy.pass_through_endpoints.pass_through_endpoints._registered_pass_through_routes",
|
|
mock_registered_routes,
|
|
),
|
|
patch(
|
|
"litellm.proxy.utils.get_server_root_path",
|
|
return_value="/",
|
|
),
|
|
):
|
|
assert (
|
|
RouteChecks.is_virtual_key_allowed_to_call_route(
|
|
route=route,
|
|
valid_token=valid_token,
|
|
request=request,
|
|
)
|
|
is True
|
|
)
|
|
RouteChecks.non_proxy_admin_allowed_routes_check(
|
|
user_obj=None,
|
|
_user_role=LitellmUserRoles.INTERNAL_USER.value,
|
|
route=route,
|
|
request=request,
|
|
valid_token=valid_token,
|
|
request_data={},
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"registered_path,registered_type",
|
|
[
|
|
("/config/update", "exact"),
|
|
("/config", "subpath"),
|
|
],
|
|
)
|
|
def test_public_pass_through_registration_does_not_bypass_builtin_route_auth(
|
|
registered_path, registered_type
|
|
):
|
|
mock_registered_routes = {
|
|
f"test-uuid-1:{registered_type}:{registered_path}:GET": {
|
|
"endpoint_id": "test-uuid-1",
|
|
"path": registered_path,
|
|
"type": registered_type,
|
|
"methods": ["GET"],
|
|
"auth": False,
|
|
},
|
|
}
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="test_user",
|
|
user_role=LitellmUserRoles.INTERNAL_USER.value,
|
|
allowed_routes=["llm_api_routes"],
|
|
)
|
|
|
|
def builtin_config_update(): ...
|
|
|
|
request = Request(
|
|
scope={
|
|
"type": "http",
|
|
"headers": [],
|
|
"method": "GET",
|
|
"query_string": b"",
|
|
"endpoint": builtin_config_update,
|
|
}
|
|
)
|
|
|
|
with (
|
|
patch(
|
|
"litellm.proxy.pass_through_endpoints.pass_through_endpoints._registered_pass_through_routes",
|
|
mock_registered_routes,
|
|
),
|
|
patch(
|
|
"litellm.proxy.utils.get_server_root_path",
|
|
return_value="/",
|
|
),
|
|
):
|
|
assert (
|
|
RouteChecks.is_virtual_key_allowed_to_call_route(
|
|
route="/config/update",
|
|
valid_token=valid_token,
|
|
request=request,
|
|
)
|
|
is True
|
|
)
|
|
with pytest.raises(Exception, match="Only proxy admin"):
|
|
RouteChecks.non_proxy_admin_allowed_routes_check(
|
|
user_obj=None,
|
|
_user_role=LitellmUserRoles.INTERNAL_USER.value,
|
|
route="/config/update",
|
|
request=request,
|
|
valid_token=valid_token,
|
|
request_data={},
|
|
)
|
|
|
|
|
|
def test_virtual_key_without_llm_api_routes_cannot_access_pass_through():
|
|
"""
|
|
Test that virtual keys without llm_api_routes permission cannot access registered pass-through endpoints.
|
|
"""
|
|
|
|
# Mock the registered pass-through routes
|
|
mock_registered_routes = {
|
|
"test-uuid-1:exact:/azure-assistant": {
|
|
"endpoint_id": "test-uuid-1",
|
|
"path": "/azure-assistant",
|
|
"type": "exact",
|
|
},
|
|
}
|
|
|
|
with (
|
|
patch(
|
|
"litellm.proxy.pass_through_endpoints.pass_through_endpoints._registered_pass_through_routes",
|
|
mock_registered_routes,
|
|
),
|
|
patch(
|
|
"litellm.proxy.utils.get_server_root_path",
|
|
return_value="/",
|
|
),
|
|
):
|
|
# Create a virtual key without llm_api_routes permission
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="test_user",
|
|
allowed_routes=["info_routes"],
|
|
)
|
|
|
|
# Test that access is denied
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
RouteChecks.is_virtual_key_allowed_to_call_route(
|
|
route="/azure-assistant",
|
|
valid_token=valid_token,
|
|
)
|
|
|
|
assert exc_info.value.status_code == 403
|
|
assert "Virtual key is not allowed to call this route" in str(
|
|
exc_info.value.detail
|
|
)
|
|
|
|
|
|
def test_check_passthrough_route_access_key_metadata_exact_match():
|
|
"""Test that key metadata allowed_passthrough_routes allows exact match"""
|
|
|
|
# Create a UserAPIKeyAuth with allowed_passthrough_routes in metadata
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="test_user",
|
|
metadata={"allowed_passthrough_routes": ["/custom-endpoint"]},
|
|
)
|
|
|
|
# Test exact match
|
|
result = RouteChecks.check_passthrough_route_access(
|
|
route="/custom-endpoint",
|
|
user_api_key_dict=valid_token,
|
|
)
|
|
|
|
assert result is True
|
|
|
|
|
|
def test_check_passthrough_route_access_key_metadata_prefix_match():
|
|
"""Test that key metadata allowed_passthrough_routes allows prefix match"""
|
|
|
|
# Create a UserAPIKeyAuth with allowed_passthrough_routes in metadata
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="test_user",
|
|
metadata={"allowed_passthrough_routes": ["/custom-endpoint"]},
|
|
)
|
|
|
|
# Test prefix match
|
|
result = RouteChecks.check_passthrough_route_access(
|
|
route="/custom-endpoint/v1/chat/completions",
|
|
user_api_key_dict=valid_token,
|
|
)
|
|
|
|
assert result is True
|
|
|
|
|
|
def test_check_passthrough_route_access_key_metadata_no_match():
|
|
"""Test that key metadata allowed_passthrough_routes denies non-matching routes"""
|
|
|
|
# Create a UserAPIKeyAuth with allowed_passthrough_routes in metadata
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="test_user",
|
|
metadata={"allowed_passthrough_routes": ["/custom-endpoint"]},
|
|
)
|
|
|
|
# Test non-matching route
|
|
result = RouteChecks.check_passthrough_route_access(
|
|
route="/other-endpoint",
|
|
user_api_key_dict=valid_token,
|
|
)
|
|
|
|
assert result is False
|
|
|
|
|
|
def test_check_passthrough_route_access_team_metadata_exact_match():
|
|
"""Test that team metadata allowed_passthrough_routes allows exact match"""
|
|
|
|
# Create a UserAPIKeyAuth with allowed_passthrough_routes in team_metadata
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="test_user",
|
|
metadata={},
|
|
team_metadata={"allowed_passthrough_routes": ["/team-endpoint"]},
|
|
)
|
|
|
|
# Test exact match
|
|
result = RouteChecks.check_passthrough_route_access(
|
|
route="/team-endpoint",
|
|
user_api_key_dict=valid_token,
|
|
)
|
|
|
|
assert result is True
|
|
|
|
|
|
def test_check_passthrough_route_access_team_metadata_prefix_match():
|
|
"""Test that team metadata allowed_passthrough_routes allows prefix match"""
|
|
|
|
# Create a UserAPIKeyAuth with allowed_passthrough_routes in team_metadata
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="test_user",
|
|
metadata={},
|
|
team_metadata={"allowed_passthrough_routes": ["/team-endpoint"]},
|
|
)
|
|
|
|
# Test prefix match
|
|
result = RouteChecks.check_passthrough_route_access(
|
|
route="/team-endpoint/v1/messages",
|
|
user_api_key_dict=valid_token,
|
|
)
|
|
|
|
assert result is True
|
|
|
|
|
|
def test_check_passthrough_route_access_team_metadata_no_match():
|
|
"""Test that team metadata allowed_passthrough_routes denies non-matching routes"""
|
|
|
|
# Create a UserAPIKeyAuth with allowed_passthrough_routes in team_metadata
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="test_user",
|
|
metadata={},
|
|
team_metadata={"allowed_passthrough_routes": ["/team-endpoint"]},
|
|
)
|
|
|
|
# Test non-matching route
|
|
result = RouteChecks.check_passthrough_route_access(
|
|
route="/other-endpoint",
|
|
user_api_key_dict=valid_token,
|
|
)
|
|
|
|
assert result is False
|
|
|
|
|
|
def test_check_passthrough_route_access_key_metadata_takes_precedence():
|
|
"""Test that key metadata takes precedence over team metadata"""
|
|
|
|
# Create a UserAPIKeyAuth with different allowed_passthrough_routes in both metadata
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="test_user",
|
|
metadata={"allowed_passthrough_routes": ["/key-endpoint"]},
|
|
team_metadata={"allowed_passthrough_routes": ["/team-endpoint"]},
|
|
)
|
|
|
|
# Test that key endpoint is allowed
|
|
result1 = RouteChecks.check_passthrough_route_access(
|
|
route="/key-endpoint",
|
|
user_api_key_dict=valid_token,
|
|
)
|
|
|
|
# Test that team endpoint is NOT allowed (key metadata takes precedence)
|
|
result2 = RouteChecks.check_passthrough_route_access(
|
|
route="/team-endpoint",
|
|
user_api_key_dict=valid_token,
|
|
)
|
|
|
|
assert result1 is True
|
|
assert result2 is False
|
|
|
|
|
|
def test_check_passthrough_route_access_no_metadata():
|
|
"""Test that route is denied when metadata and team_metadata don't have allowed_passthrough_routes"""
|
|
|
|
# Create a UserAPIKeyAuth without allowed_passthrough_routes
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="test_user",
|
|
)
|
|
|
|
# Test that route is denied
|
|
result = RouteChecks.check_passthrough_route_access(
|
|
route="/any-endpoint",
|
|
user_api_key_dict=valid_token,
|
|
)
|
|
|
|
assert result is False
|
|
|
|
|
|
def test_check_passthrough_route_access_no_allowed_passthrough_routes_key():
|
|
"""Test that route is denied when allowed_passthrough_routes is not in metadata"""
|
|
|
|
# Create a UserAPIKeyAuth with metadata but no allowed_passthrough_routes
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="test_user",
|
|
metadata={"other_field": "value"},
|
|
team_metadata={},
|
|
)
|
|
|
|
# Test that route is denied
|
|
result = RouteChecks.check_passthrough_route_access(
|
|
route="/any-endpoint",
|
|
user_api_key_dict=valid_token,
|
|
)
|
|
|
|
assert result is False
|
|
|
|
|
|
def test_check_passthrough_route_access_allowed_passthrough_routes_is_none():
|
|
"""Test that route is denied when allowed_passthrough_routes is None"""
|
|
|
|
# Create a UserAPIKeyAuth with allowed_passthrough_routes set to None
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="test_user",
|
|
metadata={"allowed_passthrough_routes": None},
|
|
team_metadata={"allowed_passthrough_routes": None},
|
|
)
|
|
|
|
# Test that route is denied
|
|
result = RouteChecks.check_passthrough_route_access(
|
|
route="/any-endpoint",
|
|
user_api_key_dict=valid_token,
|
|
)
|
|
|
|
assert result is False
|
|
|
|
|
|
def test_check_passthrough_route_access_multiple_routes():
|
|
"""Test that multiple allowed_passthrough_routes work correctly"""
|
|
|
|
# Create a UserAPIKeyAuth with multiple allowed_passthrough_routes
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="test_user",
|
|
metadata={
|
|
"allowed_passthrough_routes": [
|
|
"/endpoint-1",
|
|
"/endpoint-2",
|
|
"/endpoint-3",
|
|
]
|
|
},
|
|
)
|
|
|
|
# Test that all allowed routes work
|
|
result1 = RouteChecks.check_passthrough_route_access(
|
|
route="/endpoint-1/v1/chat",
|
|
user_api_key_dict=valid_token,
|
|
)
|
|
result2 = RouteChecks.check_passthrough_route_access(
|
|
route="/endpoint-2",
|
|
user_api_key_dict=valid_token,
|
|
)
|
|
result3 = RouteChecks.check_passthrough_route_access(
|
|
route="/endpoint-3/completions",
|
|
user_api_key_dict=valid_token,
|
|
)
|
|
|
|
# Test that non-allowed route fails
|
|
result4 = RouteChecks.check_passthrough_route_access(
|
|
route="/endpoint-4",
|
|
user_api_key_dict=valid_token,
|
|
)
|
|
|
|
assert result1 is True
|
|
assert result2 is True
|
|
assert result3 is True
|
|
assert result4 is False
|
|
|
|
|
|
def test_check_passthrough_route_access_prevents_false_prefix_match():
|
|
"""Test that prefix matching doesn't allow false matches like /endpoint vs /endpoint-2"""
|
|
|
|
# Create a UserAPIKeyAuth with allowed_passthrough_routes
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="test_user",
|
|
metadata={"allowed_passthrough_routes": ["/endpoint"]},
|
|
)
|
|
|
|
# Test that /endpoint-2 is NOT allowed (not a valid prefix match)
|
|
result = RouteChecks.check_passthrough_route_access(
|
|
route="/endpoint-2",
|
|
user_api_key_dict=valid_token,
|
|
)
|
|
|
|
assert result is False
|
|
|
|
# Test that /endpoint/something IS allowed (valid prefix match)
|
|
result2 = RouteChecks.check_passthrough_route_access(
|
|
route="/endpoint/something",
|
|
user_api_key_dict=valid_token,
|
|
)
|
|
|
|
assert result2 is True
|
|
|
|
|
|
def test_check_passthrough_route_access_empty_list():
|
|
"""Test that empty allowed_passthrough_routes list denies all routes"""
|
|
|
|
# Create a UserAPIKeyAuth with empty allowed_passthrough_routes
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="test_user",
|
|
metadata={"allowed_passthrough_routes": []},
|
|
)
|
|
|
|
# Test that route is denied
|
|
result = RouteChecks.check_passthrough_route_access(
|
|
route="/any-endpoint",
|
|
user_api_key_dict=valid_token,
|
|
)
|
|
|
|
assert result is False
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"route",
|
|
[
|
|
"/videos",
|
|
"/v1/videos",
|
|
"/videos/video_123",
|
|
"/v1/videos/video_123",
|
|
"/videos/video_123/content",
|
|
"/v1/videos/video_123/content",
|
|
"/videos/video_123/remix",
|
|
"/v1/videos/video_123/remix",
|
|
],
|
|
)
|
|
def test_videos_route_is_llm_api_route(route):
|
|
"""Test that video routes are recognized as LLM API routes"""
|
|
|
|
# Test that all video routes are recognized as LLM API routes
|
|
assert RouteChecks.is_llm_api_route(route) is True
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"route",
|
|
[
|
|
"/containers",
|
|
"/v1/containers",
|
|
"/containers/container_123",
|
|
"/v1/containers/container_123",
|
|
"/containers/container_123/files",
|
|
"/v1/containers/container_123/files",
|
|
"/containers/container_123/files/file_456",
|
|
"/v1/containers/container_123/files/file_456",
|
|
],
|
|
)
|
|
def test_containers_routes_are_llm_api_routes(route):
|
|
"""Test that container routes are recognized as LLM API routes"""
|
|
|
|
assert RouteChecks.is_llm_api_route(route) is True
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"route",
|
|
[
|
|
"/rag/ingest",
|
|
"/v1/rag/ingest",
|
|
"/rag/query",
|
|
"/v1/rag/query",
|
|
],
|
|
)
|
|
def test_rag_routes_are_llm_api_routes(route):
|
|
"""Test that RAG routes are recognized as LLM API routes (internal_user_viewer can access)"""
|
|
|
|
assert RouteChecks.is_llm_api_route(route) is True
|
|
|
|
|
|
def test_rag_routes_accessible_to_internal_user_viewer():
|
|
"""
|
|
Test that internal_user_viewer can access RAG routes (/rag/ingest, /rag/query).
|
|
|
|
internal_user_viewer should be able to call RAG endpoints like chat/completions
|
|
since they are LLM API routes. For /rag/ingest, they can only add to existing
|
|
vector stores (enforced in the endpoint).
|
|
"""
|
|
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="test_user",
|
|
user_role=LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value,
|
|
)
|
|
|
|
for route in ["/rag/ingest", "/v1/rag/ingest", "/rag/query", "/v1/rag/query"]:
|
|
RouteChecks.non_proxy_admin_allowed_routes_check(
|
|
user_obj=None,
|
|
_user_role=LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value,
|
|
route=route,
|
|
request=MagicMock(spec=Request),
|
|
valid_token=valid_token,
|
|
request_data={},
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"route",
|
|
[
|
|
"/vector_stores/vs_123",
|
|
"/v1/vector_stores/vs_123",
|
|
"/vector_stores/vs_123/search",
|
|
"/v1/vector_stores/vs_123/search",
|
|
"/vector_stores/vs_123/files",
|
|
"/v1/vector_stores/vs_123/files",
|
|
],
|
|
)
|
|
def test_vector_store_routes_are_llm_api_routes(route):
|
|
"""Retrieve/update/delete on a single vector store must classify as LLM API routes.
|
|
|
|
Regression for the missing bare `/v1/vector_stores/{vector_store_id}` entry in
|
|
`openai_routes` that left retrieve/update/delete blocked for internal roles
|
|
while `/search` and `/files` sub-routes worked.
|
|
"""
|
|
|
|
assert RouteChecks.is_llm_api_route(route) is True
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"user_role",
|
|
[
|
|
LitellmUserRoles.INTERNAL_USER.value,
|
|
LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value,
|
|
],
|
|
)
|
|
@pytest.mark.parametrize(
|
|
"method, route",
|
|
[
|
|
("GET", "/v1/vector_stores/vs_123"),
|
|
("POST", "/v1/vector_stores/vs_123"),
|
|
("DELETE", "/v1/vector_stores/vs_123"),
|
|
],
|
|
)
|
|
def test_vector_store_crud_accessible_to_internal_roles(user_role, method, route):
|
|
"""Internal user and internal viewer must reach vector store retrieve/update/delete.
|
|
|
|
Object-level access is still gated by `assert_user_can_access_vector_store`;
|
|
this only verifies the route gate no longer 403s these roles.
|
|
"""
|
|
|
|
valid_token = UserAPIKeyAuth(user_id="test_user", user_role=user_role)
|
|
request = MagicMock(spec=Request)
|
|
request.method = method
|
|
request.query_params = {}
|
|
|
|
RouteChecks.non_proxy_admin_allowed_routes_check(
|
|
user_obj=LiteLLM_UserTable(user_id="test_user", user_role=user_role),
|
|
_user_role=user_role,
|
|
route=route,
|
|
request=request,
|
|
valid_token=valid_token,
|
|
request_data={},
|
|
)
|
|
|
|
|
|
def test_videos_route_accessible_to_internal_users():
|
|
"""
|
|
Test that internal users can access the videos routes.
|
|
|
|
This test verifies the fix for issue #16470:
|
|
https://github.com/BerriAI/litellm/issues/16470
|
|
|
|
Videos routes should be accessible to internal_user role since video generation
|
|
is a legitimate user feature, not a management/admin-only feature.
|
|
"""
|
|
|
|
# Create an internal user object
|
|
user_obj = LiteLLM_UserTable(
|
|
user_id="test_user",
|
|
user_email="test@example.com",
|
|
user_role=LitellmUserRoles.INTERNAL_USER.value,
|
|
)
|
|
|
|
# Create an internal user API key auth
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="test_user",
|
|
user_role=LitellmUserRoles.INTERNAL_USER.value,
|
|
)
|
|
|
|
# Create a mock request
|
|
request = MagicMock(spec=Request)
|
|
request.query_params = {}
|
|
|
|
# Test that calling /v1/videos route does NOT raise an exception
|
|
# Since videos is now in openai_routes, it should be accessible to internal users
|
|
try:
|
|
RouteChecks.non_proxy_admin_allowed_routes_check(
|
|
user_obj=user_obj,
|
|
_user_role=LitellmUserRoles.INTERNAL_USER.value,
|
|
route="/v1/videos",
|
|
request=request,
|
|
valid_token=valid_token,
|
|
request_data={"model": "sora-2", "prompt": "test video"},
|
|
)
|
|
# If no exception is raised, the test passes
|
|
except Exception as e:
|
|
pytest.fail(
|
|
f"Internal user should be able to access /v1/videos route. Got error: {str(e)}"
|
|
)
|
|
|
|
|
|
def test_videos_route_with_virtual_key_llm_api_routes():
|
|
"""Test that virtual keys with llm_api_routes permission can access videos endpoints"""
|
|
|
|
# Create a virtual key with llm_api_routes permission
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="test_user",
|
|
allowed_routes=["llm_api_routes"],
|
|
)
|
|
|
|
# Test that all video routes are accessible
|
|
test_routes = [
|
|
"/v1/videos",
|
|
"/videos",
|
|
"/v1/videos/video_123",
|
|
"/videos/video_123/content",
|
|
"/v1/videos/video_123/remix",
|
|
]
|
|
|
|
for route in test_routes:
|
|
result = RouteChecks.is_virtual_key_allowed_to_call_route(
|
|
route=route, valid_token=valid_token
|
|
)
|
|
assert (
|
|
result is True
|
|
), f"Virtual key with llm_api_routes should be able to access {route}"
|
|
|
|
|
|
def test_non_proxy_admin_wildcard_allowed_routes():
|
|
"""Test that nonproxy admin users can still use wildcard routes"""
|
|
|
|
user_obj = LiteLLM_UserTable(
|
|
user_id="test_user",
|
|
user_email="test@example.com",
|
|
user_role=LitellmUserRoles.INTERNAL_USER.value,
|
|
)
|
|
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="test_user",
|
|
user_role=LitellmUserRoles.INTERNAL_USER.value,
|
|
allowed_routes=["/scim/*"],
|
|
)
|
|
|
|
request = MagicMock(spec=Request)
|
|
request.query_params = {}
|
|
|
|
RouteChecks.non_proxy_admin_allowed_routes_check(
|
|
user_obj=user_obj,
|
|
_user_role=LitellmUserRoles.INTERNAL_USER.value,
|
|
route="/scim/v2/Users",
|
|
request=request,
|
|
valid_token=valid_token,
|
|
request_data={},
|
|
)
|
|
|
|
|
|
def test_proxy_admin_viewer_can_access_global_spend_tags():
|
|
"""
|
|
Test that proxy_admin_viewer can access /global/spend/tags endpoint.
|
|
|
|
This test verifies the fix for the issue where proxy_admin_viewer was getting
|
|
403 errors when trying to access /global/spend/tags endpoint.
|
|
|
|
Related: Slack thread from 10/9/2025 - Erik Kristensen reported this issue.
|
|
proxy_admin_viewer role should have access to "view all spend" endpoints.
|
|
"""
|
|
|
|
# Create a proxy admin viewer user object
|
|
user_obj = LiteLLM_UserTable(
|
|
user_id="viewer_user",
|
|
user_email="viewer@example.com",
|
|
user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
|
|
)
|
|
|
|
# Create a proxy admin viewer user API key auth
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="viewer_user",
|
|
user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
|
|
)
|
|
|
|
# Create a mock request
|
|
request = MagicMock(spec=Request)
|
|
request.query_params = {"start_date": "2025-05-12", "end_date": "2025-10-09"}
|
|
|
|
# Test that calling /global/spend/tags route does NOT raise an exception
|
|
try:
|
|
RouteChecks.non_proxy_admin_allowed_routes_check(
|
|
user_obj=user_obj,
|
|
_user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
|
|
route="/global/spend/tags",
|
|
request=request,
|
|
valid_token=valid_token,
|
|
request_data={},
|
|
)
|
|
# If no exception is raised, the test passes
|
|
except Exception as e:
|
|
pytest.fail(
|
|
f"proxy_admin_viewer should be able to access /global/spend/tags route. Got error: {str(e)}"
|
|
)
|
|
|
|
|
|
# Routes returning proxy-wide spend across every team / customer / api_key.
|
|
# Sourced from `LiteLLMRoutes.global_spend_tracking_routes` so any future
|
|
# additions to that list are exercised by these tests automatically.
|
|
from litellm.proxy._types import LiteLLMRoutes
|
|
|
|
GLOBAL_SPEND_ROUTES = LiteLLMRoutes.global_spend_tracking_routes.value
|
|
|
|
|
|
@pytest.mark.parametrize("route", GLOBAL_SPEND_ROUTES)
|
|
def test_internal_user_blocked_from_global_spend_routes(route):
|
|
"""
|
|
Non-admin INTERNAL_USER role must NOT be able to read proxy-wide spend.
|
|
These routes return spend across every team, customer, and api_key.
|
|
"""
|
|
user_obj = LiteLLM_UserTable(
|
|
user_id="internal_user",
|
|
user_email="user@example.com",
|
|
user_role=LitellmUserRoles.INTERNAL_USER.value,
|
|
)
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="internal_user",
|
|
user_role=LitellmUserRoles.INTERNAL_USER.value,
|
|
)
|
|
request = MagicMock(spec=Request)
|
|
request.query_params = {}
|
|
|
|
with pytest.raises(Exception) as exc_info:
|
|
RouteChecks.non_proxy_admin_allowed_routes_check(
|
|
user_obj=user_obj,
|
|
_user_role=LitellmUserRoles.INTERNAL_USER.value,
|
|
route=route,
|
|
request=request,
|
|
valid_token=valid_token,
|
|
request_data={},
|
|
)
|
|
assert "Only proxy admin" in str(exc_info.value)
|
|
|
|
|
|
@pytest.mark.parametrize("route", GLOBAL_SPEND_ROUTES)
|
|
def test_internal_user_view_only_blocked_from_global_spend_routes(route):
|
|
"""
|
|
INTERNAL_USER_VIEW_ONLY must also be blocked from proxy-wide spend routes.
|
|
"""
|
|
user_obj = LiteLLM_UserTable(
|
|
user_id="viewer_user",
|
|
user_email="viewer@example.com",
|
|
user_role=LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value,
|
|
)
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="viewer_user",
|
|
user_role=LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value,
|
|
)
|
|
request = MagicMock(spec=Request)
|
|
request.query_params = {}
|
|
|
|
with pytest.raises(Exception) as exc_info:
|
|
RouteChecks.non_proxy_admin_allowed_routes_check(
|
|
user_obj=user_obj,
|
|
_user_role=LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value,
|
|
route=route,
|
|
request=request,
|
|
valid_token=valid_token,
|
|
request_data={},
|
|
)
|
|
assert "Only proxy admin" in str(exc_info.value)
|
|
|
|
|
|
@pytest.mark.parametrize("route", GLOBAL_SPEND_ROUTES)
|
|
def test_proxy_admin_viewer_can_access_all_global_spend_routes(route):
|
|
"""
|
|
PROXY_ADMIN_VIEW_ONLY ("view all keys, view all spend") must retain access
|
|
to every route in `global_spend_tracking_routes`.
|
|
"""
|
|
user_obj = LiteLLM_UserTable(
|
|
user_id="admin_viewer",
|
|
user_email="admin_viewer@example.com",
|
|
user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
|
|
)
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="admin_viewer",
|
|
user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
|
|
)
|
|
request = MagicMock(spec=Request)
|
|
request.query_params = {}
|
|
|
|
RouteChecks.non_proxy_admin_allowed_routes_check(
|
|
user_obj=user_obj,
|
|
_user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
|
|
route=route,
|
|
request=request,
|
|
valid_token=valid_token,
|
|
request_data={},
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("route", GLOBAL_SPEND_ROUTES)
|
|
def test_get_spend_routes_permission_keeps_access_for_internal_user(route):
|
|
"""
|
|
A key minted with the `get_spend_routes` permission is an explicit
|
|
admin opt-in and must continue to grant access even though the caller's
|
|
role would otherwise be blocked.
|
|
"""
|
|
user_obj = LiteLLM_UserTable(
|
|
user_id="internal_user_with_permission",
|
|
user_email="user@example.com",
|
|
user_role=LitellmUserRoles.INTERNAL_USER.value,
|
|
)
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="internal_user_with_permission",
|
|
user_role=LitellmUserRoles.INTERNAL_USER.value,
|
|
permissions={"get_spend_routes": True},
|
|
)
|
|
request = MagicMock(spec=Request)
|
|
request.query_params = {}
|
|
|
|
RouteChecks.non_proxy_admin_allowed_routes_check(
|
|
user_obj=user_obj,
|
|
_user_role=LitellmUserRoles.INTERNAL_USER.value,
|
|
route=route,
|
|
request=request,
|
|
valid_token=valid_token,
|
|
request_data={},
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("route", ["/audit", "/audit/some-log-id"])
|
|
def test_proxy_admin_viewer_can_access_audit_logs(route):
|
|
"""
|
|
Test that proxy_admin_viewer can access /audit endpoints.
|
|
|
|
Admin viewers should be able to view audit logs since these are read-only.
|
|
"""
|
|
|
|
user_obj = LiteLLM_UserTable(
|
|
user_id="viewer_user",
|
|
user_email="viewer@example.com",
|
|
user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
|
|
)
|
|
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="viewer_user",
|
|
user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
|
|
)
|
|
|
|
request = MagicMock(spec=Request)
|
|
request.query_params = {}
|
|
|
|
try:
|
|
RouteChecks.non_proxy_admin_allowed_routes_check(
|
|
user_obj=user_obj,
|
|
_user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
|
|
route=route,
|
|
request=request,
|
|
valid_token=valid_token,
|
|
request_data={},
|
|
)
|
|
except Exception as e:
|
|
pytest.fail(
|
|
f"proxy_admin_viewer should be able to access {route} route. Got error: {str(e)}"
|
|
)
|
|
|
|
|
|
# ── Admin Viewer parity: Logs page endpoints ──────────────────────────────────
|
|
#
|
|
# The Admin Viewer (PROXY_ADMIN_VIEW_ONLY) role is documented as
|
|
# "view all keys, view all spend" and follows a read-parity-with-Proxy-Admin
|
|
# rule. The UI Logs page is the most user-visible failure mode: filtering and
|
|
# log details break entirely when these routes are blocked at the route_checks
|
|
# layer, even though the underlying handlers already gate on PROXY_ADMIN_VIEW_ONLY.
|
|
#
|
|
# Each route below corresponds to a network call made by the Logs page
|
|
# (ui/litellm-dashboard/src/components/view_logs/) — see the comment on each.
|
|
ADMIN_VIEWER_LOGS_PAGE_ROUTES = [
|
|
# Main paginated log list — uiSpendLogsCall in log_filter_logic.tsx & index.tsx
|
|
"/spend/logs/ui",
|
|
# Single-log detail drawer — fetched on row click in LogDetailsDrawer
|
|
"/spend/logs/ui/abc-request-id",
|
|
# Multi-call session drawer — sessionSpendLogsCall in LogDetailsDrawer
|
|
"/spend/logs/session/ui",
|
|
# End User filter dropdown — allEndUsersCall in index.tsx
|
|
"/customer/list",
|
|
"/customer/info",
|
|
# Cost estimation — used by some log views
|
|
"/cost/estimate",
|
|
# Public spend logs / spend tracking routes that admin viewer should read
|
|
"/spend/logs",
|
|
"/spend/logs/v2",
|
|
"/spend/keys",
|
|
"/spend/users",
|
|
"/spend/tags",
|
|
"/spend/calculate",
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("route", ADMIN_VIEWER_LOGS_PAGE_ROUTES)
|
|
def test_proxy_admin_viewer_can_access_logs_page_endpoints(route):
|
|
"""
|
|
PROXY_ADMIN_VIEW_ONLY must pass route_checks for every endpoint the UI
|
|
Logs page depends on. Without these, the page renders empty / errors.
|
|
"""
|
|
user_obj = LiteLLM_UserTable(
|
|
user_id="viewer_user",
|
|
user_email="viewer@example.com",
|
|
user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
|
|
)
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="viewer_user",
|
|
user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
|
|
)
|
|
request = MagicMock(spec=Request)
|
|
request.query_params = {}
|
|
|
|
try:
|
|
RouteChecks.non_proxy_admin_allowed_routes_check(
|
|
user_obj=user_obj,
|
|
_user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
|
|
route=route,
|
|
request=request,
|
|
valid_token=valid_token,
|
|
request_data={},
|
|
)
|
|
except Exception as e:
|
|
pytest.fail(
|
|
f"proxy_admin_viewer should be able to access {route}. Got error: {str(e)}"
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize("route", ADMIN_VIEWER_LOGS_PAGE_ROUTES)
|
|
def test_internal_user_blocked_from_admin_viewer_logs_routes(route):
|
|
"""
|
|
The Logs-page route opening above must NOT also widen access for
|
|
INTERNAL_USER. Plain internal users still see only their own logs and
|
|
must be blocked from proxy-wide spend tracking + customer routes.
|
|
"""
|
|
user_obj = LiteLLM_UserTable(
|
|
user_id="internal_user",
|
|
user_email="user@example.com",
|
|
user_role=LitellmUserRoles.INTERNAL_USER.value,
|
|
)
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="internal_user",
|
|
user_role=LitellmUserRoles.INTERNAL_USER.value,
|
|
)
|
|
request = MagicMock(spec=Request)
|
|
request.query_params = {}
|
|
|
|
# Routes already in `spend_tracking_routes` (which is part of
|
|
# `internal_user_routes`) are intentionally accessible to internal users
|
|
# for their own scoped spend — those handlers enforce per-user filtering.
|
|
# /cost/estimate is similarly per-user. The /customer/* routes are
|
|
# admin-only.
|
|
INTERNAL_USER_BLOCKED_SUBSET = {
|
|
"/customer/list",
|
|
"/customer/info",
|
|
}
|
|
if route not in INTERNAL_USER_BLOCKED_SUBSET:
|
|
return
|
|
|
|
with pytest.raises(Exception) as exc_info:
|
|
RouteChecks.non_proxy_admin_allowed_routes_check(
|
|
user_obj=user_obj,
|
|
_user_role=LitellmUserRoles.INTERNAL_USER.value,
|
|
route=route,
|
|
request=request,
|
|
valid_token=valid_token,
|
|
request_data={},
|
|
)
|
|
assert "Only proxy admin" in str(exc_info.value)
|
|
|
|
|
|
# ── Admin Viewer parity: Settings/observability read endpoints ────────────────
|
|
#
|
|
# These are GET endpoints accessible to PROXY_ADMIN that the UI exposes to
|
|
# admin viewers via sidebar items gated by `all_admin_roles` (which includes
|
|
# proxy_admin_viewer). Without these, the Logging & Alerts, Caching, Budgets,
|
|
# and Admin Settings pages break for admin viewers.
|
|
ADMIN_VIEWER_SETTINGS_ROUTES = [
|
|
# Logging & Alerts page
|
|
"/callbacks/list",
|
|
"/callbacks/configs",
|
|
"/get/config/callbacks",
|
|
"/alerting/settings",
|
|
# Admin Settings / Router Settings pages
|
|
"/config/list",
|
|
"/config/field/info",
|
|
# Budgets page
|
|
"/budget/list",
|
|
"/management/v1/budgets",
|
|
"/budget/settings",
|
|
# Invitation viewing (admin viewer cannot create/delete; can read)
|
|
"/invitation/info",
|
|
# Guardrails / Policies pages (read-only views)
|
|
"/guardrails/list",
|
|
"/v2/guardrails/list",
|
|
"/guardrails/submissions",
|
|
"/guardrails/submissions/some-guardrail-id",
|
|
"/guardrails/usage/overview",
|
|
"/policies/attachments/list",
|
|
# MCP semantic filter settings (read)
|
|
"/get/mcp_semantic_filter_settings",
|
|
# Model cost map (read-only status / source)
|
|
"/schedule/model_cost_map_reload/status",
|
|
"/model/cost_map/source",
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("route", ADMIN_VIEWER_SETTINGS_ROUTES)
|
|
def test_proxy_admin_viewer_can_access_settings_read_endpoints(route):
|
|
"""
|
|
PROXY_ADMIN_VIEW_ONLY must pass route_checks for the read-only
|
|
settings/observability endpoints exposed in admin-only sidebar groups.
|
|
"""
|
|
user_obj = LiteLLM_UserTable(
|
|
user_id="viewer_user",
|
|
user_email="viewer@example.com",
|
|
user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
|
|
)
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="viewer_user",
|
|
user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
|
|
)
|
|
request = MagicMock(spec=Request)
|
|
request.query_params = {}
|
|
|
|
try:
|
|
RouteChecks.non_proxy_admin_allowed_routes_check(
|
|
user_obj=user_obj,
|
|
_user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
|
|
route=route,
|
|
request=request,
|
|
valid_token=valid_token,
|
|
request_data={},
|
|
)
|
|
except Exception as e:
|
|
pytest.fail(
|
|
f"proxy_admin_viewer should be able to access {route}. Got error: {str(e)}"
|
|
)
|
|
|
|
|
|
# ── Admin Viewer parity: default-allow GET semantics ─────────────────────────
|
|
#
|
|
# The route-check layer is structured to default-allow safe HTTP methods
|
|
# (GET / HEAD / OPTIONS) for PROXY_ADMIN_VIEW_ONLY. This eliminates the
|
|
# whack-a-mole where every newly-added GET endpoint silently 403'd until
|
|
# someone remembered to add it to admin_viewer_routes.
|
|
#
|
|
# These tests pin the new contract:
|
|
# - Any GET endpoint not on the LLM/inference path is readable.
|
|
# - Any unsafe method (POST/PUT/PATCH/DELETE) outside the explicit allow
|
|
# sets is still 403.
|
|
|
|
# Routes the user reported as broken in production — they're in disparate
|
|
# corners of the codebase and represent the long tail of GETs we'd otherwise
|
|
# need to enumerate manually. Default-allow makes them all work.
|
|
ADMIN_VIEWER_REPORTED_GET_ROUTES = [
|
|
"/health/latest",
|
|
"/credentials",
|
|
"/v1/mcp/network/client-ip",
|
|
"/claude-code/plugins",
|
|
"/policy/templates",
|
|
# Routes we already had to enumerate manually (regression coverage).
|
|
"/spend/logs/ui",
|
|
"/customer/list",
|
|
"/guardrails/list",
|
|
"/policies/attachments/list",
|
|
# Hypothetical future GETs — must not require an allowlist entry.
|
|
"/some/future/read/endpoint",
|
|
"/another/admin-tool/status",
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("route", ADMIN_VIEWER_REPORTED_GET_ROUTES)
|
|
def test_proxy_admin_viewer_default_allows_any_get(route):
|
|
"""
|
|
PROXY_ADMIN_VIEW_ONLY must be able to GET any non-inference endpoint.
|
|
|
|
This is a structural guarantee: the route-check defaults to allow for
|
|
safe HTTP methods so we don't have to maintain an explicit allowlist.
|
|
"""
|
|
user_obj = LiteLLM_UserTable(
|
|
user_id="viewer_user",
|
|
user_email="viewer@example.com",
|
|
user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
|
|
)
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="viewer_user",
|
|
user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
|
|
)
|
|
request = MagicMock(spec=Request)
|
|
request.method = "GET"
|
|
request.query_params = {}
|
|
request.url = MagicMock()
|
|
request.url.path = route
|
|
|
|
try:
|
|
RouteChecks.non_proxy_admin_allowed_routes_check(
|
|
user_obj=user_obj,
|
|
_user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
|
|
route=route,
|
|
request=request,
|
|
valid_token=valid_token,
|
|
request_data={},
|
|
)
|
|
except Exception as e:
|
|
pytest.fail(f"proxy_admin_viewer GET should default-allow {route!r}. Got: {e}")
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"route",
|
|
[
|
|
# Random path that isn't in any allowlist — POST must still 403.
|
|
"/some/future/write/endpoint",
|
|
# Hard-blocked write routes.
|
|
"/user/new",
|
|
"/team/new",
|
|
"/key/generate",
|
|
"/model/new",
|
|
],
|
|
)
|
|
def test_proxy_admin_viewer_post_blocked_outside_allowlists(route):
|
|
"""
|
|
Default-allow only applies to safe HTTP methods. POST/PUT/PATCH/DELETE
|
|
on a route not in any allow set must still 403.
|
|
"""
|
|
user_obj = LiteLLM_UserTable(
|
|
user_id="viewer_user",
|
|
user_email="viewer@example.com",
|
|
user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
|
|
)
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="viewer_user",
|
|
user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
|
|
)
|
|
request = MagicMock(spec=Request)
|
|
request.method = "POST"
|
|
request.query_params = {}
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
RouteChecks.non_proxy_admin_allowed_routes_check(
|
|
user_obj=user_obj,
|
|
_user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
|
|
route=route,
|
|
request=request,
|
|
valid_token=valid_token,
|
|
request_data={},
|
|
)
|
|
assert exc_info.value.status_code == 403
|
|
|
|
|
|
# ── Admin Viewer: management_routes write endpoints stay blocked ─────────────
|
|
#
|
|
# `management_routes` is a mix of reads (info/list, handled via the safe-method
|
|
# branch — GET) and writes. The route_checks layer must NOT blanket-allow the
|
|
# whole set on POST — that would let Admin Viewer mutate teams, JWT mappings,
|
|
# and bulk-update keys, violating the "no writes, ever" rule.
|
|
#
|
|
# These cases pin the gap closed (Greptile P1 review, 2026-04-30).
|
|
ADMIN_VIEWER_MANAGEMENT_ROUTE_WRITES = [
|
|
# Team writes
|
|
"/team/block",
|
|
"/team/unblock",
|
|
"/team/permissions_update",
|
|
# JWT key mapping writes
|
|
"/jwt/key/mapping/new",
|
|
"/jwt/key/mapping/update",
|
|
"/jwt/key/mapping/delete",
|
|
# Key writes (existing _ADMIN_VIEWER_BLOCKED_WRITE_ROUTES doesn't list bulk
|
|
# update or per-key reset-spend, so the management_routes fallback was the
|
|
# only thing keeping them out — and it was permissive, not restrictive).
|
|
"/key/bulk_update",
|
|
"/key/some-key-id/reset_spend",
|
|
]
|
|
|
|
|
|
@pytest.mark.parametrize("route", ADMIN_VIEWER_MANAGEMENT_ROUTE_WRITES)
|
|
def test_proxy_admin_viewer_post_blocked_for_management_route_writes(route):
|
|
"""
|
|
Admin Viewer must be blocked on POST to write endpoints in
|
|
`management_routes`, even when the specific route is not in
|
|
`_ADMIN_VIEWER_BLOCKED_WRITE_ROUTES`.
|
|
"""
|
|
user_obj = LiteLLM_UserTable(
|
|
user_id="viewer_user",
|
|
user_email="viewer@example.com",
|
|
user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
|
|
)
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="viewer_user",
|
|
user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
|
|
)
|
|
request = MagicMock(spec=Request)
|
|
request.method = "POST"
|
|
request.query_params = {}
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
RouteChecks.non_proxy_admin_allowed_routes_check(
|
|
user_obj=user_obj,
|
|
_user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
|
|
route=route,
|
|
request=request,
|
|
valid_token=valid_token,
|
|
request_data={},
|
|
)
|
|
assert exc_info.value.status_code == 403
|
|
|
|
|
|
class TestModelsRouteExemptFromDisableLLMEndpoints:
|
|
"""
|
|
Test that /models and /v1/models are exempt from DISABLE_LLM_API_ENDPOINTS.
|
|
|
|
When DISABLE_LLM_API_ENDPOINTS is set, inference routes like /v1/chat/completions
|
|
should be blocked, but /models and /v1/models should remain accessible because
|
|
they are read-only model listing routes needed by the Admin UI.
|
|
|
|
Relevant issue: https://github.com/BerriAI/litellm/issues/new (UI breaks with DISABLE_LLM_ENDPOINTS)
|
|
"""
|
|
|
|
def _get_enterprise_route_checks(self):
|
|
"""Import EnterpriseRouteChecks from the local enterprise source file."""
|
|
import importlib.util
|
|
|
|
local_file = os.path.join(
|
|
os.path.dirname(__file__),
|
|
"..",
|
|
"..",
|
|
"..",
|
|
"..",
|
|
"enterprise",
|
|
"litellm_enterprise",
|
|
"proxy",
|
|
"auth",
|
|
"route_checks.py",
|
|
)
|
|
local_file = os.path.abspath(local_file)
|
|
|
|
spec = importlib.util.spec_from_file_location(
|
|
"local_enterprise_route_checks", local_file
|
|
)
|
|
mod = importlib.util.module_from_spec(spec)
|
|
spec.loader.exec_module(mod)
|
|
return mod.EnterpriseRouteChecks
|
|
|
|
@patch("litellm.proxy.proxy_server.premium_user", True)
|
|
def test_should_models_route_allowed_when_llm_api_disabled(self):
|
|
"""Test that /models is allowed even when LLM API routes are disabled"""
|
|
EnterpriseRouteChecks = self._get_enterprise_route_checks()
|
|
|
|
with (
|
|
patch.object(
|
|
EnterpriseRouteChecks, "is_llm_api_route_disabled", return_value=True
|
|
),
|
|
patch.object(
|
|
EnterpriseRouteChecks,
|
|
"is_management_routes_disabled",
|
|
return_value=False,
|
|
),
|
|
):
|
|
# /models should NOT raise - it's exempt
|
|
EnterpriseRouteChecks.should_call_route("/models")
|
|
|
|
@patch("litellm.proxy.proxy_server.premium_user", True)
|
|
def test_should_v1_models_route_allowed_when_llm_api_disabled(self):
|
|
"""Test that /v1/models is allowed even when LLM API routes are disabled"""
|
|
EnterpriseRouteChecks = self._get_enterprise_route_checks()
|
|
|
|
with (
|
|
patch.object(
|
|
EnterpriseRouteChecks, "is_llm_api_route_disabled", return_value=True
|
|
),
|
|
patch.object(
|
|
EnterpriseRouteChecks,
|
|
"is_management_routes_disabled",
|
|
return_value=False,
|
|
),
|
|
):
|
|
# /v1/models should NOT raise - it's exempt
|
|
EnterpriseRouteChecks.should_call_route("/v1/models")
|
|
|
|
@patch("litellm.proxy.proxy_server.premium_user", True)
|
|
def test_should_chat_completions_still_blocked_when_llm_api_disabled(self):
|
|
"""Test that non-exempt LLM routes like /v1/chat/completions are still blocked"""
|
|
EnterpriseRouteChecks = self._get_enterprise_route_checks()
|
|
|
|
with (
|
|
patch.object(
|
|
EnterpriseRouteChecks, "is_llm_api_route_disabled", return_value=True
|
|
),
|
|
patch.object(
|
|
EnterpriseRouteChecks,
|
|
"is_management_routes_disabled",
|
|
return_value=False,
|
|
),
|
|
):
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
EnterpriseRouteChecks.should_call_route("/v1/chat/completions")
|
|
|
|
assert exc_info.value.status_code == 403
|
|
assert "LLM API routes are disabled for this instance." in str(
|
|
exc_info.value.detail
|
|
)
|
|
|
|
@patch("litellm.proxy.proxy_server.premium_user", True)
|
|
def test_should_embeddings_still_blocked_when_llm_api_disabled(self):
|
|
"""Test that /v1/embeddings is still blocked when LLM API routes are disabled"""
|
|
EnterpriseRouteChecks = self._get_enterprise_route_checks()
|
|
|
|
with (
|
|
patch.object(
|
|
EnterpriseRouteChecks, "is_llm_api_route_disabled", return_value=True
|
|
),
|
|
patch.object(
|
|
EnterpriseRouteChecks,
|
|
"is_management_routes_disabled",
|
|
return_value=False,
|
|
),
|
|
):
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
EnterpriseRouteChecks.should_call_route("/v1/embeddings")
|
|
|
|
assert exc_info.value.status_code == 403
|
|
|
|
@patch("litellm.proxy.proxy_server.premium_user", True)
|
|
def test_should_models_route_allowed_when_llm_api_not_disabled(self):
|
|
"""Test that /models works normally when LLM API routes are not disabled"""
|
|
EnterpriseRouteChecks = self._get_enterprise_route_checks()
|
|
|
|
with (
|
|
patch.object(
|
|
EnterpriseRouteChecks, "is_llm_api_route_disabled", return_value=False
|
|
),
|
|
patch.object(
|
|
EnterpriseRouteChecks,
|
|
"is_management_routes_disabled",
|
|
return_value=False,
|
|
),
|
|
):
|
|
# Should not raise
|
|
EnterpriseRouteChecks.should_call_route("/models")
|
|
EnterpriseRouteChecks.should_call_route("/v1/models")
|
|
|
|
|
|
def test_route_in_additional_public_routes_wildcard_match():
|
|
"""
|
|
Test that route_in_additonal_public_routes supports wildcard patterns.
|
|
"""
|
|
from litellm.proxy.auth.auth_utils import route_in_additonal_public_routes
|
|
|
|
with (
|
|
patch(
|
|
"litellm.proxy.proxy_server.general_settings", {"public_routes": ["/api/*"]}
|
|
),
|
|
patch("litellm.proxy.proxy_server.premium_user", True),
|
|
):
|
|
# Wildcard should match subpaths
|
|
assert route_in_additonal_public_routes("/api/users") is True
|
|
assert route_in_additonal_public_routes("/api/users/123") is True
|
|
# Should not match different prefix
|
|
assert route_in_additonal_public_routes("/other/path") is False
|
|
|
|
|
|
def test_route_in_additional_public_routes_exact_match():
|
|
"""
|
|
Test that route_in_additonal_public_routes supports exact matches.
|
|
"""
|
|
from litellm.proxy.auth.auth_utils import route_in_additonal_public_routes
|
|
|
|
with (
|
|
patch(
|
|
"litellm.proxy.proxy_server.general_settings",
|
|
{"public_routes": ["/health", "/status"]},
|
|
),
|
|
patch("litellm.proxy.proxy_server.premium_user", True),
|
|
):
|
|
# Exact matches should work
|
|
assert route_in_additonal_public_routes("/health") is True
|
|
assert route_in_additonal_public_routes("/status") is True
|
|
# Non-matching routes should fail
|
|
assert route_in_additonal_public_routes("/other") is False
|
|
|
|
|
|
def test_internal_user_can_access_key_reset_spend_route():
|
|
"""
|
|
Regression test: team admins (role=internal_user) should pass the route-level
|
|
check for /key/{hash}/reset_spend. The endpoint itself enforces team admin status.
|
|
"""
|
|
user_obj = LiteLLM_UserTable(
|
|
user_id="team-admin-user",
|
|
user_email="teamadmin@example.com",
|
|
user_role=LitellmUserRoles.INTERNAL_USER.value,
|
|
)
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="team-admin-user",
|
|
user_role=LitellmUserRoles.INTERNAL_USER.value,
|
|
)
|
|
request = MagicMock(spec=Request)
|
|
request.query_params = {}
|
|
|
|
key_hash = "baec26d2901589fe9fec76610e6e2be4895cdd8e19b3ada9a4fa2eb85e1901ae"
|
|
route = f"/key/{key_hash}/reset_spend"
|
|
|
|
# Should not raise — the route-level check must pass for team admins
|
|
RouteChecks.non_proxy_admin_allowed_routes_check(
|
|
user_obj=user_obj,
|
|
_user_role=LitellmUserRoles.INTERNAL_USER.value,
|
|
route=route,
|
|
request=request,
|
|
valid_token=valid_token,
|
|
request_data={},
|
|
)
|
|
|
|
|
|
def test_non_admin_non_team_admin_cannot_access_config_update_but_can_attempt_reset_spend():
|
|
"""
|
|
An internal_user passes the route check for /key/{hash}/reset_spend
|
|
(authorization is deferred to the endpoint), but is still blocked from
|
|
admin-only routes like /config/update.
|
|
"""
|
|
user_obj = LiteLLM_UserTable(
|
|
user_id="regular-user",
|
|
user_email="user@example.com",
|
|
user_role=LitellmUserRoles.INTERNAL_USER.value,
|
|
)
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="regular-user",
|
|
user_role=LitellmUserRoles.INTERNAL_USER.value,
|
|
)
|
|
request = MagicMock(spec=Request)
|
|
request.query_params = {}
|
|
|
|
key_hash = "baec26d2901589fe9fec76610e6e2be4895cdd8e19b3ada9a4fa2eb85e1901ae"
|
|
|
|
# /key/{hash}/reset_spend passes the route check for internal_user
|
|
RouteChecks.non_proxy_admin_allowed_routes_check(
|
|
user_obj=user_obj,
|
|
_user_role=LitellmUserRoles.INTERNAL_USER.value,
|
|
route=f"/key/{key_hash}/reset_spend",
|
|
request=request,
|
|
valid_token=valid_token,
|
|
request_data={},
|
|
)
|
|
|
|
# /config/update is still blocked
|
|
with pytest.raises(Exception) as exc_info:
|
|
RouteChecks.non_proxy_admin_allowed_routes_check(
|
|
user_obj=user_obj,
|
|
_user_role=LitellmUserRoles.INTERNAL_USER.value,
|
|
route="/config/update",
|
|
request=request,
|
|
valid_token=valid_token,
|
|
request_data={},
|
|
)
|
|
assert "Only proxy admin can be used to generate" in str(exc_info.value)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"user_role",
|
|
[
|
|
LitellmUserRoles.INTERNAL_USER.value,
|
|
LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value,
|
|
],
|
|
)
|
|
@pytest.mark.parametrize("route", ["/tag/list", "/tag/daily/activity"])
|
|
def test_internal_users_can_access_scoped_tag_usage_routes(user_role, route):
|
|
"""
|
|
Internal users can read tag usage endpoints because the endpoint handlers
|
|
scope results to the caller's own keys.
|
|
"""
|
|
user_obj = LiteLLM_UserTable(
|
|
user_id="test_user",
|
|
user_email="test@example.com",
|
|
user_role=user_role,
|
|
)
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="test_user",
|
|
user_role=user_role,
|
|
)
|
|
request = MagicMock(spec=Request)
|
|
request.query_params = {}
|
|
|
|
RouteChecks.non_proxy_admin_allowed_routes_check(
|
|
user_obj=user_obj,
|
|
_user_role=user_role,
|
|
route=route,
|
|
request=request,
|
|
valid_token=valid_token,
|
|
request_data={},
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"user_role",
|
|
[
|
|
LitellmUserRoles.INTERNAL_USER.value,
|
|
LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value,
|
|
LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
|
|
],
|
|
)
|
|
def test_available_roles_accessible_to_non_admin_users(user_role):
|
|
"""
|
|
/user/available_roles is read-only role metadata that any authenticated user
|
|
(including org admins and team admins) needs when inviting users. It should
|
|
pass the route check for all non-proxy-admin roles without requiring an
|
|
organization_id in the request body.
|
|
"""
|
|
user_obj = LiteLLM_UserTable(
|
|
user_id="test_user",
|
|
user_email="test@example.com",
|
|
user_role=user_role,
|
|
)
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="test_user",
|
|
user_role=user_role,
|
|
)
|
|
request = MagicMock(spec=Request)
|
|
request.query_params = {}
|
|
|
|
# Should not raise — /user/available_roles is in self_managed_routes
|
|
RouteChecks.non_proxy_admin_allowed_routes_check(
|
|
user_obj=user_obj,
|
|
_user_role=user_role,
|
|
route="/user/available_roles",
|
|
request=request,
|
|
valid_token=valid_token,
|
|
request_data={},
|
|
)
|
|
|
|
|
|
# ── _user_is_org_admin tests ──────────────────────────────────────────────────
|
|
|
|
from datetime import datetime
|
|
|
|
from litellm.proxy._types import LiteLLM_OrganizationMembershipTable
|
|
from litellm.proxy.auth.auth_checks_organization import _user_is_org_admin
|
|
|
|
|
|
def _make_org_admin_user(org_id: str) -> LiteLLM_UserTable:
|
|
membership = LiteLLM_OrganizationMembershipTable(
|
|
user_id="org-admin-user",
|
|
organization_id=org_id,
|
|
user_role=LitellmUserRoles.ORG_ADMIN.value,
|
|
created_at=datetime(2024, 1, 1),
|
|
updated_at=datetime(2024, 1, 1),
|
|
)
|
|
return LiteLLM_UserTable(
|
|
user_id="org-admin-user",
|
|
user_role=LitellmUserRoles.INTERNAL_USER.value,
|
|
organization_memberships=[membership],
|
|
)
|
|
|
|
|
|
def test_user_is_org_admin_with_organizations_list():
|
|
"""Org admin can be identified via the `organizations` list field (used by /user/new)."""
|
|
user_obj = _make_org_admin_user("org-1")
|
|
assert _user_is_org_admin({"organizations": ["org-1"]}, user_obj) is True
|
|
|
|
|
|
def test_user_is_org_admin_with_singular_organization_id():
|
|
"""Backward-compat: org admin can still be identified via singular `organization_id`."""
|
|
user_obj = _make_org_admin_user("org-1")
|
|
assert _user_is_org_admin({"organization_id": "org-1"}, user_obj) is True
|
|
|
|
|
|
def test_user_is_org_admin_organizations_list_wrong_org():
|
|
"""Non-member of the requested org is not considered an org admin for it."""
|
|
user_obj = _make_org_admin_user("org-2")
|
|
assert _user_is_org_admin({"organizations": ["org-1"]}, user_obj) is False
|
|
|
|
|
|
def test_user_is_org_admin_no_org_fields():
|
|
"""Returns False when neither `organization_id` nor `organizations` is in the request."""
|
|
user_obj = _make_org_admin_user("org-1")
|
|
assert _user_is_org_admin({}, user_obj) is False
|
|
|
|
|
|
def test_non_org_admin_with_organizations_list():
|
|
"""A regular internal user is not an org admin even if they are a member of the org."""
|
|
membership = LiteLLM_OrganizationMembershipTable(
|
|
user_id="regular-user",
|
|
organization_id="org-1",
|
|
user_role=LitellmUserRoles.INTERNAL_USER.value,
|
|
created_at=datetime(2024, 1, 1),
|
|
updated_at=datetime(2024, 1, 1),
|
|
)
|
|
user_obj = LiteLLM_UserTable(
|
|
user_id="regular-user",
|
|
user_role=LitellmUserRoles.INTERNAL_USER.value,
|
|
organization_memberships=[membership],
|
|
)
|
|
assert _user_is_org_admin({"organizations": ["org-1"]}, user_obj) is False
|
|
|
|
|
|
def test_org_admin_cannot_escalate_to_other_org():
|
|
"""Regression: admin of org-A requesting [org-A, org-B] must be rejected."""
|
|
user_obj = _make_org_admin_user("org-A")
|
|
assert _user_is_org_admin({"organizations": ["org-A", "org-B"]}, user_obj) is False
|
|
|
|
|
|
def test_org_admin_of_multiple_orgs_can_operate_on_both():
|
|
"""Admin of both org-A and org-B can operate on both."""
|
|
memberships = [
|
|
LiteLLM_OrganizationMembershipTable(
|
|
user_id="multi-admin",
|
|
organization_id="org-A",
|
|
user_role=LitellmUserRoles.ORG_ADMIN.value,
|
|
created_at=datetime(2024, 1, 1),
|
|
updated_at=datetime(2024, 1, 1),
|
|
),
|
|
LiteLLM_OrganizationMembershipTable(
|
|
user_id="multi-admin",
|
|
organization_id="org-B",
|
|
user_role=LitellmUserRoles.ORG_ADMIN.value,
|
|
created_at=datetime(2024, 1, 1),
|
|
updated_at=datetime(2024, 1, 1),
|
|
),
|
|
]
|
|
user_obj = LiteLLM_UserTable(
|
|
user_id="multi-admin",
|
|
user_role=LitellmUserRoles.INTERNAL_USER.value,
|
|
organization_memberships=memberships,
|
|
)
|
|
assert _user_is_org_admin({"organizations": ["org-A", "org-B"]}, user_obj) is True
|
|
|
|
|
|
# ── LIT-4221: /team/update org-context resolution from team_id ────────────────
|
|
from litellm.proxy.auth.auth_checks_organization import (
|
|
add_team_org_context_to_request_body,
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_team_org_context_resolves_org_from_team():
|
|
"""For /team/update with only team_id, the target team's org is resolved and
|
|
injected so the org-admin route gate can see it. This is what lets an org
|
|
admin update a team budget from the Hub UI, which sends team_id, not
|
|
organization_id (LIT-4221)."""
|
|
|
|
async def fetch(team_id: str):
|
|
assert team_id == "team-1"
|
|
return "org-1"
|
|
|
|
out = await add_team_org_context_to_request_body(
|
|
route="/team/update",
|
|
request_body={"team_id": "team-1", "max_budget": 42},
|
|
fetch_team_org_id=fetch,
|
|
)
|
|
assert out == {"team_id": "team-1", "max_budget": 42, "organization_id": "org-1"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_team_org_context_noop_when_org_id_already_present():
|
|
"""If the caller already passed organization_id, no lookup happens and the
|
|
body is returned unchanged."""
|
|
|
|
async def fetch(team_id: str):
|
|
raise AssertionError("must not resolve when organization_id is present")
|
|
|
|
body = {"team_id": "team-1", "organization_id": "org-explicit"}
|
|
out = await add_team_org_context_to_request_body(
|
|
route="/team/update", request_body=body, fetch_team_org_id=fetch
|
|
)
|
|
assert out == body
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_team_org_context_noop_for_other_routes():
|
|
"""Only /team/update opts into org resolution; other routes are untouched."""
|
|
|
|
async def fetch(team_id: str):
|
|
raise AssertionError("must not resolve for a non-opted-in route")
|
|
|
|
body = {"team_id": "team-1"}
|
|
out = await add_team_org_context_to_request_body(
|
|
route="/team/delete", request_body=body, fetch_team_org_id=fetch
|
|
)
|
|
assert out == body
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_team_org_context_noop_when_team_has_no_org():
|
|
"""A standalone team (no org) resolves to None, so nothing is injected and
|
|
the org-admin branch stays unreachable (no blanket access)."""
|
|
|
|
async def fetch(team_id: str):
|
|
return None
|
|
|
|
body = {"team_id": "team-1"}
|
|
out = await add_team_org_context_to_request_body(
|
|
route="/team/update", request_body=body, fetch_team_org_id=fetch
|
|
)
|
|
assert out == body
|
|
|
|
|
|
def test_team_update_gate_allows_org_admin_with_resolved_org():
|
|
"""Post-resolution (organization_id present), an org admin of that org clears
|
|
the gate for /team/update."""
|
|
user_obj = _make_org_admin_user("org-1")
|
|
valid_token = UserAPIKeyAuth(user_id="org-admin-user", user_role=LitellmUserRoles.INTERNAL_USER.value)
|
|
request = MagicMock(spec=Request)
|
|
request.method = "POST"
|
|
request.query_params = {}
|
|
|
|
RouteChecks.non_proxy_admin_allowed_routes_check(
|
|
user_obj=user_obj,
|
|
_user_role=LitellmUserRoles.INTERNAL_USER.value,
|
|
route="/team/update",
|
|
request=request,
|
|
valid_token=valid_token,
|
|
request_data={"team_id": "team-1", "organization_id": "org-1"},
|
|
)
|
|
|
|
|
|
def test_team_update_gate_rejects_without_org_context():
|
|
"""Without organization_id (i.e. resolution found no org, or a non-org-admin),
|
|
the gate still rejects /team/update — the fix adds no blanket allow. Guards
|
|
against re-widening the route (e.g. dropping it into self_managed_routes)."""
|
|
user_obj = _make_org_admin_user("org-1")
|
|
valid_token = UserAPIKeyAuth(user_id="org-admin-user", user_role=LitellmUserRoles.INTERNAL_USER.value)
|
|
request = MagicMock(spec=Request)
|
|
request.method = "POST"
|
|
request.query_params = {}
|
|
|
|
with pytest.raises(Exception):
|
|
RouteChecks.non_proxy_admin_allowed_routes_check(
|
|
user_obj=user_obj,
|
|
_user_role=LitellmUserRoles.INTERNAL_USER.value,
|
|
route="/team/update",
|
|
request=request,
|
|
valid_token=valid_token,
|
|
request_data={"team_id": "team-1", "max_budget": 42},
|
|
)
|
|
|
|
|
|
def test_team_update_gate_rejects_cross_org_admin_with_resolved_org():
|
|
"""Even after the target team's org is resolved, an org admin of a DIFFERENT
|
|
org is rejected at the gate (no cross-org escalation)."""
|
|
user_obj = _make_org_admin_user("org-1")
|
|
valid_token = UserAPIKeyAuth(user_id="org-admin-user", user_role=LitellmUserRoles.INTERNAL_USER.value)
|
|
request = MagicMock(spec=Request)
|
|
request.method = "POST"
|
|
request.query_params = {}
|
|
|
|
with pytest.raises(Exception):
|
|
RouteChecks.non_proxy_admin_allowed_routes_check(
|
|
user_obj=user_obj,
|
|
_user_role=LitellmUserRoles.INTERNAL_USER.value,
|
|
route="/team/update",
|
|
request=request,
|
|
valid_token=valid_token,
|
|
request_data={"team_id": "team-1", "organization_id": "org-2"},
|
|
)
|
|
|
|
|
|
# ── PATCH /team/{team_id}: same org-context + role reach as POST /team/update ──
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_team_org_context_resolves_org_from_path_for_patch_route():
|
|
"""PATCH /team/{team_id} carries team_id in the PATH, not the body. The target
|
|
team's org is resolved from the last path segment (identified by the route
|
|
template) and injected, so an org admin of that team's org clears the same gate
|
|
they clear for POST /team/update."""
|
|
|
|
async def fetch(team_id: str):
|
|
assert team_id == "team-1"
|
|
return "org-1"
|
|
|
|
out = await add_team_org_context_to_request_body(
|
|
route="/team/team-1",
|
|
request_body={"metadata": {"cost_center": "x"}},
|
|
fetch_team_org_id=fetch,
|
|
route_template="/team/{team_id}",
|
|
)
|
|
assert out == {"metadata": {"cost_center": "x"}, "organization_id": "org-1"}
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_team_org_context_path_noop_for_team_subresource():
|
|
"""A sub-resource like /team/{team_id}/members/me has a different route template,
|
|
so it is not mistaken for the bare team route and no org is injected."""
|
|
|
|
async def fetch(team_id: str):
|
|
raise AssertionError("must not resolve for a team sub-resource route")
|
|
|
|
body = {"foo": "bar"}
|
|
out = await add_team_org_context_to_request_body(
|
|
route="/team/team-1/members/me",
|
|
request_body=body,
|
|
fetch_team_org_id=fetch,
|
|
route_template="/team/{team_id}/members/me",
|
|
)
|
|
assert out == body
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_add_team_org_context_noop_for_static_team_route():
|
|
"""A static sibling route (e.g. POST /team/new) whose resolved path also has the
|
|
single-segment shape has its own template, not /team/{team_id}, so no team lookup
|
|
is attempted — the guard against a spurious DB hit on every /team/<verb> call."""
|
|
|
|
async def fetch(team_id: str):
|
|
raise AssertionError("must not resolve for a static /team/<verb> route")
|
|
|
|
body = {"team_alias": "new team"}
|
|
out = await add_team_org_context_to_request_body(
|
|
route="/team/new",
|
|
request_body=body,
|
|
fetch_team_org_id=fetch,
|
|
route_template="/team/new",
|
|
)
|
|
assert out == body
|
|
|
|
|
|
def test_patch_team_route_has_same_reach_as_team_update():
|
|
"""/team/{team_id} is reachable by org admins (in org_admin_allowed_routes) but
|
|
NOT by regular internal users or the role-agnostic self_managed_routes — the
|
|
latter would open /team/new (the collision footgun) to any authenticated user."""
|
|
from litellm.proxy._types import LiteLLMRoutes
|
|
|
|
assert RouteChecks.check_route_access(
|
|
route="/team/abc-123", allowed_routes=LiteLLMRoutes.org_admin_allowed_routes.value
|
|
)
|
|
assert not RouteChecks.check_route_access(
|
|
route="/team/abc-123", allowed_routes=LiteLLMRoutes.internal_user_routes.value
|
|
)
|
|
assert not RouteChecks.check_route_access(
|
|
route="/team/abc-123", allowed_routes=LiteLLMRoutes.self_managed_routes.value
|
|
)
|
|
|
|
|
|
def _patch_team_request() -> MagicMock:
|
|
request = MagicMock(spec=Request)
|
|
request.method = "PATCH"
|
|
request.query_params = {}
|
|
return request
|
|
|
|
|
|
def test_patch_team_gate_allows_org_admin_with_resolved_org():
|
|
"""Post-resolution, an org admin of the team's org clears the coarse gate for
|
|
PATCH /team/{team_id} — parity with /team/update."""
|
|
user_obj = _make_org_admin_user("org-1")
|
|
valid_token = UserAPIKeyAuth(user_id="org-admin-user", user_role=LitellmUserRoles.INTERNAL_USER.value)
|
|
|
|
RouteChecks.non_proxy_admin_allowed_routes_check(
|
|
user_obj=user_obj,
|
|
_user_role=LitellmUserRoles.INTERNAL_USER.value,
|
|
route="/team/team-1",
|
|
request=_patch_team_request(),
|
|
valid_token=valid_token,
|
|
request_data={"organization_id": "org-1"},
|
|
)
|
|
|
|
|
|
def test_patch_team_gate_rejects_regular_internal_user():
|
|
"""A plain internal user (not an org admin) is rejected at the coarse gate for
|
|
PATCH /team/{team_id}, even with the team's org resolved — injection alone is
|
|
not access. Same outcome as /team/update."""
|
|
user_obj = LiteLLM_UserTable(
|
|
user_id="regular-user",
|
|
user_role=LitellmUserRoles.INTERNAL_USER.value,
|
|
organization_memberships=None,
|
|
)
|
|
valid_token = UserAPIKeyAuth(user_id="regular-user", user_role=LitellmUserRoles.INTERNAL_USER.value)
|
|
|
|
with pytest.raises(Exception):
|
|
RouteChecks.non_proxy_admin_allowed_routes_check(
|
|
user_obj=user_obj,
|
|
_user_role=LitellmUserRoles.INTERNAL_USER.value,
|
|
route="/team/team-1",
|
|
request=_patch_team_request(),
|
|
valid_token=valid_token,
|
|
request_data={"organization_id": "org-1"},
|
|
)
|
|
|
|
|
|
def test_patch_team_gate_rejects_cross_org_admin():
|
|
"""An org admin of a DIFFERENT org is rejected even after org resolution."""
|
|
user_obj = _make_org_admin_user("org-1")
|
|
valid_token = UserAPIKeyAuth(user_id="org-admin-user", user_role=LitellmUserRoles.INTERNAL_USER.value)
|
|
|
|
with pytest.raises(Exception):
|
|
RouteChecks.non_proxy_admin_allowed_routes_check(
|
|
user_obj=user_obj,
|
|
_user_role=LitellmUserRoles.INTERNAL_USER.value,
|
|
route="/team/team-1",
|
|
request=_patch_team_request(),
|
|
valid_token=valid_token,
|
|
request_data={"organization_id": "org-2"},
|
|
)
|
|
|
|
|
|
def test_patch_team_gate_rejects_view_only_admin():
|
|
"""A view-only proxy admin cannot PATCH a team (unsafe method), parity with the
|
|
/team/update view-only block."""
|
|
user_obj = LiteLLM_UserTable(
|
|
user_id="viewer",
|
|
user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
|
|
)
|
|
valid_token = UserAPIKeyAuth(user_id="viewer", user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value)
|
|
|
|
with pytest.raises(Exception):
|
|
RouteChecks.non_proxy_admin_allowed_routes_check(
|
|
user_obj=user_obj,
|
|
_user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
|
|
route="/team/team-1",
|
|
request=_patch_team_request(),
|
|
valid_token=valid_token,
|
|
request_data={"organization_id": "org-1"},
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_initialize_pass_through_registers_wildcard_for_auth_subpath():
|
|
"""
|
|
Test that initialize_pass_through_endpoints registers both base path and
|
|
wildcard path in openai_routes when auth=true and include_subpath=true,
|
|
and that subpath requests pass is_llm_api_route.
|
|
|
|
Also verifies:
|
|
- Dedup: calling init twice does not duplicate entries
|
|
- Cleanup: removing the endpoint cleans up openai_routes
|
|
"""
|
|
from litellm.proxy._types import LiteLLMRoutes
|
|
from litellm.proxy.pass_through_endpoints.pass_through_endpoints import (
|
|
InitPassThroughEndpointHelpers,
|
|
initialize_pass_through_endpoints,
|
|
)
|
|
|
|
base_path = "/v1/ocr/nvidia/community/nemoretriever-ocr-v1"
|
|
wildcard_path = base_path + "/*"
|
|
|
|
endpoint_config = {
|
|
"path": base_path,
|
|
"target": "https://httpbin.org/post",
|
|
"include_subpath": True,
|
|
"auth": True,
|
|
"headers": {"content-type": "application/json"},
|
|
}
|
|
|
|
original_routes = LiteLLMRoutes.openai_routes.value[:]
|
|
try:
|
|
with (
|
|
patch(
|
|
"litellm.proxy.proxy_server.app",
|
|
MagicMock(),
|
|
),
|
|
patch(
|
|
"litellm.proxy.proxy_server.premium_user",
|
|
True,
|
|
),
|
|
patch(
|
|
"litellm.proxy.proxy_server.config_passthrough_endpoints",
|
|
None,
|
|
),
|
|
):
|
|
await initialize_pass_through_endpoints([endpoint_config])
|
|
|
|
# Both base and wildcard paths should be registered
|
|
assert base_path in LiteLLMRoutes.openai_routes.value
|
|
assert wildcard_path in LiteLLMRoutes.openai_routes.value
|
|
|
|
# Subpath requests should pass the auth route check
|
|
assert RouteChecks.is_llm_api_route(base_path) is True
|
|
assert RouteChecks.is_llm_api_route(base_path + "/v1/infer") is True
|
|
|
|
# Calling init again should not duplicate entries
|
|
await initialize_pass_through_endpoints([endpoint_config])
|
|
assert LiteLLMRoutes.openai_routes.value.count(base_path) == 1
|
|
assert LiteLLMRoutes.openai_routes.value.count(wildcard_path) == 1
|
|
|
|
# Removing the endpoint should clean up openai_routes
|
|
# remove_endpoint_routes takes endpoint_id (UUID portion of
|
|
# the route key "{id}:exact:{path}:{methods}")
|
|
registered = (
|
|
InitPassThroughEndpointHelpers.get_all_registered_pass_through_routes()
|
|
)
|
|
endpoint_ids = {k.split(":")[0] for k in registered}
|
|
for eid in endpoint_ids:
|
|
InitPassThroughEndpointHelpers.remove_endpoint_routes(eid)
|
|
assert base_path not in LiteLLMRoutes.openai_routes.value
|
|
assert wildcard_path not in LiteLLMRoutes.openai_routes.value
|
|
finally:
|
|
LiteLLMRoutes.openai_routes.value[:] = original_routes
|
|
# Clean up any routes registered during this test to avoid
|
|
# polluting the module-level _registered_pass_through_routes
|
|
registered = (
|
|
InitPassThroughEndpointHelpers.get_all_registered_pass_through_routes()
|
|
)
|
|
for k in registered:
|
|
InitPassThroughEndpointHelpers.remove_endpoint_routes(k.split(":")[0])
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"route",
|
|
[
|
|
"/credentials/by_name/openai",
|
|
"/credentials/openai",
|
|
"/credentials/azure",
|
|
"/credentials/by_name/anthropic",
|
|
"/model/delete/openai",
|
|
"/model/delete/anthropic-prod",
|
|
"/budget/update/bedrock",
|
|
"/user/delete/gemini-user",
|
|
],
|
|
)
|
|
def test_provider_name_substring_not_classified_as_llm_route(route):
|
|
"""
|
|
Regression: mapped_pass_through_routes used a substring check
|
|
(`_llm_passthrough_route in route`) so any admin-only path whose URL
|
|
happened to contain a provider name (openai, anthropic, azure, …) was
|
|
misclassified as an LLM API route and bypassed the admin gate.
|
|
|
|
The fix uses an exact/prefix match so only routes that actually *start*
|
|
with a passthrough prefix are allowed through.
|
|
"""
|
|
from litellm.proxy.auth.route_checks import RouteChecks
|
|
|
|
assert RouteChecks.is_llm_api_route(route=route) is False, (
|
|
f"{route!r} should NOT be classified as an LLM API route — "
|
|
"provider-name substring match bypass"
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"route",
|
|
[
|
|
"/openai/v1/chat/completions",
|
|
"/openai",
|
|
"/anthropic/v1/messages",
|
|
"/anthropic",
|
|
"/bedrock/invoke",
|
|
"/azure/openai/deployments/gpt-4/chat/completions",
|
|
"/gemini/v1/models",
|
|
"/vertex-ai/predict",
|
|
"/vertex_ai/predict",
|
|
],
|
|
)
|
|
def test_legitimate_passthrough_routes_still_classified_as_llm_route(route):
|
|
"""Legitimate passthrough routes must still pass is_llm_api_route."""
|
|
from litellm.proxy.auth.route_checks import RouteChecks
|
|
|
|
assert (
|
|
RouteChecks.is_llm_api_route(route=route) is True
|
|
), f"{route!r} should be classified as an LLM API route"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"route",
|
|
[
|
|
"/search_tools/list",
|
|
"/search_tools/ui/available_providers",
|
|
],
|
|
)
|
|
def test_internal_user_can_read_search_tools(route):
|
|
"""Regression for LIT-3150: internal users must be able to view search tools,
|
|
the same way they can view vector stores."""
|
|
user_obj = LiteLLM_UserTable(
|
|
user_id="test_user",
|
|
user_email="user@example.com",
|
|
user_role=LitellmUserRoles.INTERNAL_USER.value,
|
|
)
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="test_user",
|
|
user_role=LitellmUserRoles.INTERNAL_USER.value,
|
|
)
|
|
request = MagicMock(spec=Request)
|
|
request.query_params = {}
|
|
|
|
RouteChecks.non_proxy_admin_allowed_routes_check(
|
|
user_obj=user_obj,
|
|
_user_role=LitellmUserRoles.INTERNAL_USER.value,
|
|
route=route,
|
|
request=request,
|
|
valid_token=valid_token,
|
|
request_data={},
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"route",
|
|
[
|
|
"/search_tools", # create
|
|
"/search_tools/abc123", # update / delete / get-by-id
|
|
"/search_tools/test_connection",
|
|
],
|
|
)
|
|
def test_internal_user_blocked_from_search_tool_writes(route):
|
|
"""Read access must not leak the search-tool management write routes to
|
|
internal users; only proxy admins create/update/delete/test them."""
|
|
user_obj = LiteLLM_UserTable(
|
|
user_id="test_user",
|
|
user_email="user@example.com",
|
|
user_role=LitellmUserRoles.INTERNAL_USER.value,
|
|
)
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="test_user",
|
|
user_role=LitellmUserRoles.INTERNAL_USER.value,
|
|
)
|
|
request = MagicMock(spec=Request)
|
|
request.query_params = {}
|
|
|
|
with pytest.raises(Exception) as exc_info:
|
|
RouteChecks.non_proxy_admin_allowed_routes_check(
|
|
user_obj=user_obj,
|
|
_user_role=LitellmUserRoles.INTERNAL_USER.value,
|
|
route=route,
|
|
request=request,
|
|
valid_token=valid_token,
|
|
request_data={},
|
|
)
|
|
assert "Only proxy admin" in str(exc_info.value)
|
|
assert f"Route={route}" in str(exc_info.value)
|
|
assert "Your role=internal_user" in str(exc_info.value)
|
|
|
|
|
|
def test_proxy_admin_viewer_can_read_another_users_info():
|
|
"""Admin Viewer has read parity with Proxy Admin, so the /user/info
|
|
key-ownership gate must not apply to it — the Users page reads every row."""
|
|
user_obj = LiteLLM_UserTable(
|
|
user_id="viewer_user",
|
|
user_email="viewer@example.com",
|
|
user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
|
|
)
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="viewer_user",
|
|
user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
|
|
)
|
|
request = MagicMock(spec=Request)
|
|
request.query_params = {"user_id": "some_other_user"}
|
|
|
|
RouteChecks.non_proxy_admin_allowed_routes_check(
|
|
user_obj=user_obj,
|
|
_user_role=LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY.value,
|
|
route="/user/info",
|
|
request=request,
|
|
valid_token=valid_token,
|
|
request_data={},
|
|
)
|
|
|
|
|
|
def test_internal_user_still_blocked_from_another_users_info():
|
|
"""The Admin Viewer carve-out above must stay scoped to that role; internal
|
|
users keep hitting the ownership 403."""
|
|
user_obj = LiteLLM_UserTable(
|
|
user_id="internal_user",
|
|
user_email="user@example.com",
|
|
user_role=LitellmUserRoles.INTERNAL_USER.value,
|
|
)
|
|
valid_token = UserAPIKeyAuth(
|
|
user_id="internal_user",
|
|
user_role=LitellmUserRoles.INTERNAL_USER.value,
|
|
)
|
|
request = MagicMock(spec=Request)
|
|
request.query_params = {"user_id": "some_other_user"}
|
|
|
|
with pytest.raises(HTTPException) as exc_info:
|
|
RouteChecks.non_proxy_admin_allowed_routes_check(
|
|
user_obj=user_obj,
|
|
_user_role=LitellmUserRoles.INTERNAL_USER.value,
|
|
route="/user/info",
|
|
request=request,
|
|
valid_token=valid_token,
|
|
request_data={},
|
|
)
|
|
|
|
assert exc_info.value.status_code == 403
|
|
assert "key not allowed to access this user's info" in str(exc_info.value.detail)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"route",
|
|
[
|
|
"/user/daily/activity",
|
|
"/user/daily/activity/aggregated",
|
|
],
|
|
)
|
|
@pytest.mark.parametrize(
|
|
"user_role",
|
|
[
|
|
LitellmUserRoles.INTERNAL_USER.value,
|
|
LitellmUserRoles.INTERNAL_USER_VIEW_ONLY.value,
|
|
],
|
|
)
|
|
def test_user_daily_activity_routes_reachable_by_non_admin(route, user_role):
|
|
"""Both /user/daily/activity and its /aggregated sibling power the default
|
|
"Your Usage" dashboard view, and both handlers self-scope to the caller
|
|
(_user_has_admin_view -> require_caller_user_id_for_non_admin -> 403 on a
|
|
user_id mismatch). self_managed_routes is the ONLY list that grants either
|
|
route to a non-admin, so dropping one from it 401s every internal user's
|
|
main Usage page before the handler ever runs.
|
|
"""
|
|
user_obj = LiteLLM_UserTable(
|
|
user_id="test_user",
|
|
user_email="test@example.com",
|
|
user_role=user_role,
|
|
)
|
|
valid_token = UserAPIKeyAuth(user_id="test_user", user_role=user_role)
|
|
request = MagicMock(spec=Request)
|
|
request.query_params = {}
|
|
|
|
RouteChecks.non_proxy_admin_allowed_routes_check(
|
|
user_obj=user_obj,
|
|
_user_role=user_role,
|
|
route=route,
|
|
request=request,
|
|
valid_token=valid_token,
|
|
request_data={},
|
|
)
|
|
|
|
|
|
def test_user_daily_activity_aggregated_not_covered_by_prefix_match():
|
|
"""check_route_access is exact-match plus explicit wildcards, so listing the
|
|
parent /user/daily/activity does not implicitly cover the /aggregated
|
|
sub-path. Pins the reason the sibling needs its own entry.
|
|
"""
|
|
assert not RouteChecks.check_route_access(
|
|
route="/user/daily/activity/aggregated",
|
|
allowed_routes=["/user/daily/activity"],
|
|
)
|