mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-11 08:38:24 +00:00
fix: enforce LiteAdmin MCP route permissions (#48080)
* fix(admin-mcp): expose and enforce credential route permissions * fix: restore beta header and telemetry test parity * fix(admin-mcp): align permissions with registered HTTP routes * test(admin-mcp): verify encoded paths through HTTP authorization * test(admin-mcp): simplify HTTP permission regression fixtures
This commit is contained in:
parent
e0778bc9dd
commit
fd594ab0cf
11 changed files with 344 additions and 29 deletions
|
|
@ -238,7 +238,8 @@ impl AnthropicBeta {
|
|||
Self::ComputerUse20250124
|
||||
| Self::ComputerUse20251124
|
||||
| Self::Context1m20250807
|
||||
| Self::ThinkingBindingControls20260801 => &[],
|
||||
| Self::ThinkingBindingControls20260801
|
||||
| Self::ThinkingDisplayUpdates20260818 => &[],
|
||||
Self::DangerousToolUse20260903 => &[BetaProvider::BedrockConverse],
|
||||
Self::Effort20251124 => &[BetaProvider::VertexAi],
|
||||
Self::FastMode20260201 | Self::Oauth20250420 => &[
|
||||
|
|
@ -275,7 +276,6 @@ impl AnthropicBeta {
|
|||
BetaProvider::Databricks,
|
||||
],
|
||||
Self::StructuredOutputs20251113 => &[BetaProvider::Bedrock, BetaProvider::VertexAi],
|
||||
Self::ThinkingDisplayUpdates20260818 => &[BetaProvider::Databricks],
|
||||
Self::ToolSearchTool20251019 => &[
|
||||
BetaProvider::Anthropic,
|
||||
BetaProvider::AzureAi,
|
||||
|
|
|
|||
|
|
@ -3177,11 +3177,18 @@ from litellm.types.proxy.auth.user_api_key_auth import (
|
|||
)
|
||||
|
||||
|
||||
class AdminMCPPermissions(LiteLLMPydanticObjectBase):
|
||||
model_config = ConfigDict(frozen=True)
|
||||
tools: tuple[str, ...]
|
||||
action_allowed: bool | None = None
|
||||
|
||||
|
||||
class UserInfoResponse(LiteLLMPydanticObjectBase):
|
||||
user_id: str | None
|
||||
user_info: dict | BaseModel | None
|
||||
keys: list
|
||||
teams: list
|
||||
admin_mcp_permissions: AdminMCPPermissions | None = None
|
||||
|
||||
|
||||
class UserInfoV2Response(LiteLLMPydanticObjectBase):
|
||||
|
|
|
|||
|
|
@ -1,20 +1,21 @@
|
|||
import os
|
||||
import re
|
||||
from collections.abc import AsyncGenerator, Mapping
|
||||
from collections.abc import AsyncGenerator, Iterator, Mapping
|
||||
from contextlib import asynccontextmanager
|
||||
from contextvars import ContextVar
|
||||
from typing import Final
|
||||
from urllib.parse import urlsplit
|
||||
from urllib.parse import unquote, urlsplit
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.routing import APIRoute
|
||||
from pydantic import TypeAdapter
|
||||
from starlette.datastructures import Headers
|
||||
from starlette.requests import Request
|
||||
from starlette.routing import Mount
|
||||
from starlette.types import ASGIApp, Receive, Scope, Send
|
||||
|
||||
from litellm.constants import STANDARD_CUSTOMER_ID_HEADERS
|
||||
from litellm.proxy._types import SpecialHeaders
|
||||
from litellm.proxy._types import AdminMCPPermissions, SpecialHeaders, UserAPIKeyAuth
|
||||
from litellm.proxy.auth.route_checks import RouteChecks
|
||||
from litellm.proxy.middleware.admission_control_middleware import ADMISSION_LEASE_SCOPE_KEY
|
||||
|
||||
_REQUEST_HEADERS: Final = frozenset(
|
||||
|
|
@ -58,6 +59,91 @@ _RESERVED_KEY_HEADERS: Final = (
|
|||
_SETTINGS: Final = TypeAdapter(Mapping[str, object])
|
||||
_IDENTITY_MAPPINGS: Final = TypeAdapter(tuple[dict[str, object], ...] | dict[str, object] | None)
|
||||
_OAUTH_MAPPINGS: Final = TypeAdapter(dict[str, str])
|
||||
_ROUTE_GRANTS: Final = TypeAdapter(tuple[str, ...])
|
||||
|
||||
|
||||
def _admin_route_allowed(auth: UserAPIKeyAuth, request: Request, method: str, path: str) -> bool:
|
||||
target: Final = Request({**request.scope, "method": method, "path": path, "raw_path": path.encode()})
|
||||
try:
|
||||
RouteChecks.should_call_route(route=path, valid_token=auth, request=target)
|
||||
except HTTPException:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def _admin_routes(request: Request) -> Iterator[tuple[tuple[str, str], APIRoute]]:
|
||||
app: Final = request.app
|
||||
if not isinstance(app, FastAPI):
|
||||
return
|
||||
for route in app.routes:
|
||||
if not isinstance(route, APIRoute):
|
||||
continue
|
||||
for method in route.methods:
|
||||
yield (method, route.path_format), route
|
||||
|
||||
|
||||
def _admin_grant_paths(route: APIRoute, grant: str) -> Iterator[str]:
|
||||
if route.path_regex.fullmatch(grant) is not None:
|
||||
yield grant
|
||||
prefix: Final = grant[:-1] if grant.endswith("*") else grant + "/"
|
||||
representative: Final = re.sub(r"\{[^}]+\}", "_", route.path_format)
|
||||
# A matching suffix supplies the rest of a concrete route for prefix grants,
|
||||
# including grants ending inside a static segment or a path parameter.
|
||||
for candidate in (prefix + representative[offset:] for offset in range(len(representative) + 1)):
|
||||
if route.path_regex.fullmatch(candidate) is not None:
|
||||
yield candidate
|
||||
|
||||
|
||||
def _admin_grant_allowed(auth: UserAPIKeyAuth, request: Request, method: str, route: APIRoute, grant: str) -> bool:
|
||||
return any(_admin_route_allowed(auth, request, method, path) for path in _admin_grant_paths(route, grant))
|
||||
|
||||
|
||||
def _admin_tool_visible(auth: UserAPIKeyAuth, request: Request, method: str, route: APIRoute) -> bool:
|
||||
try:
|
||||
RouteChecks.check_route_enabled(route.path)
|
||||
except HTTPException:
|
||||
return False
|
||||
if _admin_route_allowed(auth, request, method, route.path):
|
||||
return True
|
||||
grants: Final = _ROUTE_GRANTS.validate_python(getattr(auth, "allowed_routes", None) or ())
|
||||
return bool(route.param_convertors) and any(
|
||||
_admin_grant_allowed(auth, request, method, route, grant) for grant in grants if grant.startswith("/")
|
||||
)
|
||||
|
||||
|
||||
def get_admin_mcp_permissions(
|
||||
auth: UserAPIKeyAuth, request: Request, tool: str | None = None, path: str | None = None
|
||||
) -> AdminMCPPermissions:
|
||||
try:
|
||||
from litellm_admin_mcp.catalog import ( # pyright: ignore[reportMissingTypeStubs] # connector has no py.typed marker
|
||||
BY_NAME,
|
||||
OPERATIONS,
|
||||
)
|
||||
except ImportError as exc:
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Admin MCP permissions require the admin-mcp dependency group (Python 3.12+)"
|
||||
) from exc
|
||||
|
||||
routes: Final = dict(_admin_routes(request))
|
||||
allowed: Final = tuple(
|
||||
operation.name
|
||||
for operation in OPERATIONS
|
||||
if (route := routes.get((operation.method, operation.path))) is not None
|
||||
and _admin_tool_visible(auth, request, operation.method, route)
|
||||
)
|
||||
if tool is None:
|
||||
return AdminMCPPermissions(tools=allowed)
|
||||
operation: Final = BY_NAME.get(tool)
|
||||
if operation is None or tool not in allowed:
|
||||
return AdminMCPPermissions(tools=allowed, action_allowed=False)
|
||||
if path is None:
|
||||
return AdminMCPPermissions(tools=allowed, action_allowed=True)
|
||||
registered: Final = routes[(operation.method, operation.path)]
|
||||
decoded_path: Final = unquote(path)
|
||||
action_allowed: Final = registered.path_regex.fullmatch(decoded_path) is not None and _admin_route_allowed(
|
||||
auth, request, operation.method, decoded_path
|
||||
)
|
||||
return AdminMCPPermissions(tools=allowed, action_allowed=action_allowed)
|
||||
|
||||
|
||||
def _configured_key_header() -> bytes | None:
|
||||
|
|
|
|||
|
|
@ -111,14 +111,7 @@ _TOKEN_COUNTING_ACTION: Final = "countTokens"
|
|||
|
||||
class RouteChecks:
|
||||
@staticmethod
|
||||
def should_call_route(
|
||||
route: str,
|
||||
valid_token: UserAPIKeyAuth,
|
||||
request: Request | None = None,
|
||||
):
|
||||
"""
|
||||
Check if management route is disabled and raise exception
|
||||
"""
|
||||
def check_route_enabled(route: str) -> None:
|
||||
try:
|
||||
from litellm_enterprise.proxy.auth.route_checks import EnterpriseRouteChecks
|
||||
|
||||
|
|
@ -128,6 +121,16 @@ class RouteChecks:
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def should_call_route(
|
||||
route: str,
|
||||
valid_token: UserAPIKeyAuth,
|
||||
request: Request | None = None,
|
||||
):
|
||||
"""
|
||||
Check if management route is disabled and raise exception
|
||||
"""
|
||||
RouteChecks.check_route_enabled(route)
|
||||
# Check if Virtual Key is allowed to call the route - Applies to all Roles
|
||||
RouteChecks.is_virtual_key_allowed_to_call_route(route=route, valid_token=valid_token, request=request)
|
||||
return True
|
||||
|
|
|
|||
|
|
@ -945,6 +945,14 @@ async def user_info(
|
|||
request: Request,
|
||||
user_id: str | None = fastapi.Query(default=None, description="User ID in the request parameters"),
|
||||
user_api_key_dict: UserAPIKeyAuth = Depends(user_api_key_auth),
|
||||
admin_mcp: bool = fastapi.Query(
|
||||
default=False, description="Include the caller's current admin MCP route permissions"
|
||||
),
|
||||
mcp_tool: str | None = fastapi.Query(default=None, description="Admin MCP tool to authorize for this caller"),
|
||||
mcp_path: str | None = fastapi.Query(
|
||||
default=None,
|
||||
description="URL-encoded concrete management path used by the MCP tool, before query-string encoding",
|
||||
),
|
||||
):
|
||||
"""
|
||||
[10/07/2024]
|
||||
|
|
@ -965,12 +973,23 @@ async def user_info(
|
|||
user_id = _normalize_user_info_user_id(request=request, user_id=user_id)
|
||||
_enforce_user_info_access(user_id=user_id, user_api_key_dict=user_api_key_dict)
|
||||
|
||||
from litellm.proxy.admin_mcp import get_admin_mcp_permissions
|
||||
|
||||
mcp_permissions: Final = (
|
||||
get_admin_mcp_permissions(user_api_key_dict, request, mcp_tool, mcp_path) if admin_mcp is True else None
|
||||
)
|
||||
|
||||
if prisma_client is None:
|
||||
raise Exception(
|
||||
"Database not connected. Connect a database to your proxy - https://docs.litellm.ai/docs/simple_proxy#managing-auth---virtual-keys"
|
||||
)
|
||||
if user_id is None and user_api_key_has_admin_view(user_api_key_dict):
|
||||
return await _get_user_info_for_proxy_admin(user_api_key_dict=user_api_key_dict)
|
||||
admin_response: Final = await _get_user_info_for_proxy_admin(user_api_key_dict=user_api_key_dict)
|
||||
return (
|
||||
admin_response.model_copy(update={"admin_mcp_permissions": mcp_permissions})
|
||||
if mcp_permissions is not None
|
||||
else admin_response
|
||||
)
|
||||
elif user_id is None:
|
||||
user_id = user_api_key_dict.user_id
|
||||
## GET USER ROW ##
|
||||
|
|
@ -1009,7 +1028,11 @@ async def user_info(
|
|||
),
|
||||
)
|
||||
|
||||
return response_data
|
||||
return (
|
||||
response_data.model_copy(update={"admin_mcp_permissions": mcp_permissions})
|
||||
if mcp_permissions is not None
|
||||
else response_data
|
||||
)
|
||||
except Exception as e:
|
||||
verbose_proxy_logger.exception("litellm.proxy.proxy_server.user_info(): Exception occured - %s", e)
|
||||
raise handle_exception_on_proxy(e)
|
||||
|
|
|
|||
|
|
@ -251,7 +251,7 @@ e2e-dev = [
|
|||
"mcp>=2.3.0,<3",
|
||||
]
|
||||
admin-mcp = [
|
||||
"litellm-admin-mcp @ https://github.com/BerriAI/liteadmin-mcp/archive/d35ec9c19c117d4c50cc1eccf6ce1296aac25a1a.tar.gz#sha256=86b930f6706fb2da10d53d1798ee7fd14e44fb0afdef0da122cf0e6bb88fa2c3 ; python_version >= '3.12'",
|
||||
"litellm-admin-mcp @ https://github.com/BerriAI/liteadmin-mcp/archive/31fb096904ae05684c0779110a93b135b5601182.tar.gz#sha256=5803a20a00a18177f4591706956f31be311879543049b8b38143ad90bb1cb0a6 ; python_version >= '3.12'",
|
||||
]
|
||||
proxy-dev = [
|
||||
{ include-group = "admin-mcp" },
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import asyncio
|
||||
import json
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from typing import Final
|
||||
|
||||
import httpx2
|
||||
|
|
@ -12,11 +13,11 @@ from starlette.testclient import TestClient
|
|||
|
||||
from litellm.constants import STANDARD_CUSTOMER_ID_HEADERS
|
||||
from litellm.proxy import proxy_server
|
||||
from litellm.proxy._types import LiteLLM_UserTable, ProxyException, SpecialHeaders
|
||||
from litellm.proxy.admin_mcp import admin_mcp_lifespan
|
||||
from litellm.proxy.auth.user_api_key_auth import get_api_key, get_api_key_from_custom_header
|
||||
from litellm.proxy._types import LiteLLM_UserTable, LitellmUserRoles, ProxyException, SpecialHeaders, UserAPIKeyAuth
|
||||
from litellm.proxy.admin_mcp import admin_mcp_lifespan, get_admin_mcp_permissions
|
||||
from litellm.proxy.auth.user_api_key_auth import get_api_key, get_api_key_from_custom_header, user_api_key_auth
|
||||
from litellm.proxy.common_utils.user_api_key_cache import UserApiKeyCache
|
||||
from litellm.proxy.management_endpoints import internal_user_endpoints
|
||||
from litellm.proxy.management_endpoints import internal_user_endpoints, team_callback_endpoints
|
||||
from litellm.proxy.middleware.admission_control_middleware import (
|
||||
AdmissionControlMiddleware,
|
||||
AdmissionControlSettings,
|
||||
|
|
@ -66,12 +67,22 @@ def management_app(monkeypatch: pytest.MonkeyPatch) -> FastAPI:
|
|||
if credential not in {"admin-a", "admin-b", "admin-b-limited", "member", "viewer"}:
|
||||
raise HTTPException(status_code=401)
|
||||
user_id: Final = "admin-b" if credential == "admin-b-limited" else credential
|
||||
role: Final = {"member": LitellmUserRoles.INTERNAL_USER, "viewer": LitellmUserRoles.PROXY_ADMIN_VIEW_ONLY}.get(
|
||||
user_id, LitellmUserRoles.PROXY_ADMIN
|
||||
)
|
||||
auth: Final = UserAPIKeyAuth(
|
||||
user_id=user_id, user_role=role,
|
||||
allowed_routes=["/user/info"] if credential == "admin-b-limited" else None,
|
||||
)
|
||||
return {
|
||||
"user_id": user_id,
|
||||
"user_info": {
|
||||
"user_id": user_id,
|
||||
"user_role": {"member": "internal_user", "viewer": "proxy_admin_viewer"}.get(user_id, "proxy_admin"),
|
||||
"user_role": role.value,
|
||||
},
|
||||
"admin_mcp_permissions": get_admin_mcp_permissions(
|
||||
auth, request, request.query_params.get("mcp_tool"), request.query_params.get("mcp_path")
|
||||
).model_dump(mode="json"),
|
||||
}
|
||||
|
||||
@app.get("/key/list", operation_id="list_keys_key_list_get")
|
||||
|
|
@ -565,3 +576,168 @@ async def test_oauth2_proxy_auth_preserves_native_identity_and_peer_trust(
|
|||
assert response.status_code == status, response.text
|
||||
if status == 200:
|
||||
assert "list_keys" in {tool["name"] for tool in response.json()["result"]["tools"]}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def user_info_http_app(monkeypatch: pytest.MonkeyPatch, grant: str) -> FastAPI:
|
||||
class EmptyDatabase:
|
||||
async def query_raw(self, _: str) -> tuple[dict[str, None], ...]:
|
||||
return ({"teams": None, "keys": None},)
|
||||
|
||||
monkeypatch.setattr(proxy_server, "prisma_client", SimpleNamespace(db=EmptyDatabase()))
|
||||
auth: Final = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, allowed_routes=["/user/info", grant])
|
||||
app: Final = FastAPI()
|
||||
app.include_router(internal_user_endpoints.router)
|
||||
app.include_router(team_callback_endpoints.router)
|
||||
app.dependency_overrides[user_api_key_auth] = lambda: auth
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def permissions_request() -> Request:
|
||||
return Request({"type": "http", "method": "GET", "path": "/user/info", "headers": [], "app": proxy_server.app})
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.version_info < (3, 12), reason="Admin MCP requires Python 3.12+")
|
||||
@pytest.mark.parametrize(
|
||||
"routes,session,can_write",
|
||||
[
|
||||
(None, False, True),
|
||||
([], False, True),
|
||||
(["management_routes"], False, True),
|
||||
(["info_routes"], False, False),
|
||||
(["llm_api_routes"], False, False),
|
||||
(None, True, True),
|
||||
(["info_routes"], True, False),
|
||||
],
|
||||
)
|
||||
def test_admin_mcp_uses_credential_routes_without_removing_full_access(
|
||||
routes: list[str] | None, session: bool, can_write: bool, permissions_request: Request
|
||||
) -> None:
|
||||
auth: Final = UserAPIKeyAuth(
|
||||
user_id="admin",
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN,
|
||||
allowed_routes=routes,
|
||||
is_session_token=session,
|
||||
)
|
||||
permissions: Final = get_admin_mcp_permissions(auth, permissions_request, "create_key", "/key/generate")
|
||||
assert permissions.action_allowed is can_write
|
||||
assert ("create_key" in permissions.tools) is can_write
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.version_info < (3, 12), reason="Admin MCP requires Python 3.12+")
|
||||
@pytest.mark.parametrize(
|
||||
"team_id,grant",
|
||||
[
|
||||
("team-a", "/team/team-a/callback"),
|
||||
("team-a", "/team/team-a/*"),
|
||||
("org/team-a", "/team/org/team-a/callback"),
|
||||
("org/team-a", "/team/org/team-a/*"),
|
||||
("org/team-a", "/team/org/team-a/call*"),
|
||||
],
|
||||
)
|
||||
def test_admin_mcp_checks_the_concrete_resource_even_when_the_tool_is_visible(
|
||||
team_id: str, grant: str, permissions_request: Request
|
||||
) -> None:
|
||||
auth: Final = UserAPIKeyAuth(
|
||||
user_id="admin",
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN,
|
||||
allowed_routes=["/user/info", grant],
|
||||
metadata={"is_session_token": True, "user_role": "proxy_admin"},
|
||||
)
|
||||
own: Final = get_admin_mcp_permissions(auth, permissions_request, "get_team_callbacks", f"/team/{team_id}/callback")
|
||||
other: Final = get_admin_mcp_permissions(auth, permissions_request, "get_team_callbacks", "/team/other/callback")
|
||||
wrong_route: Final = get_admin_mcp_permissions(auth, permissions_request, "get_team_callbacks", "/key/generate")
|
||||
assert "get_team_callbacks" in own.tools
|
||||
assert own.action_allowed is True
|
||||
assert other.action_allowed is False
|
||||
assert wrong_route.action_allowed is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.skipif(sys.version_info < (3, 12), reason="Admin MCP requires Python 3.12+")
|
||||
@pytest.mark.parametrize(
|
||||
"grant,path,allowed",
|
||||
[
|
||||
("/team/team a/callback", "/team/team%20a/callback", True),
|
||||
("/team/org/team-a/callback", "/team/org%2Fteam-a/callback", True),
|
||||
("/team/team%2Fa/callback", "/team/team%252Fa/callback", True),
|
||||
("/team/team+a/callback", "/team/team+a/callback", True),
|
||||
("/team/team-é/callback", "/team/team-%C3%A9/callback", True),
|
||||
("/team/team/a/callback", "/team/team%252Fa/callback", False),
|
||||
("/team/team a/callback", "/team/team+a/callback", False),
|
||||
("/team/team%41/callback", "/team/team%2541/callback", True),
|
||||
],
|
||||
)
|
||||
async def test_user_info_decodes_mcp_path_after_query_parsing(
|
||||
user_info_http_app: FastAPI,
|
||||
path: str,
|
||||
allowed: bool,
|
||||
) -> None:
|
||||
async with httpx2.AsyncClient(
|
||||
transport=httpx2.ASGITransport(app=user_info_http_app),
|
||||
base_url="http://testserver",
|
||||
params={"admin_mcp": "true", "mcp_tool": "get_team_callbacks"},
|
||||
) as client:
|
||||
own: Final = await client.get("/user/info", params={"mcp_path": path})
|
||||
sibling: Final = await client.get("/user/info", params={"mcp_path": "/team/other/callback"})
|
||||
assert own.status_code == 200, own.text
|
||||
assert sibling.status_code == 200, sibling.text
|
||||
assert "get_team_callbacks" in own.json()["admin_mcp_permissions"]["tools"]
|
||||
assert own.json()["admin_mcp_permissions"]["action_allowed"] is allowed
|
||||
assert sibling.json()["admin_mcp_permissions"]["action_allowed"] is False
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.version_info < (3, 12), reason="Admin MCP requires Python 3.12+")
|
||||
@pytest.mark.parametrize("grant", ["/te?m/*", "/[t]eam/*", "/te*m/*"])
|
||||
def test_admin_mcp_does_not_invent_glob_permissions(grant: str, permissions_request: Request) -> None:
|
||||
auth: Final = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, allowed_routes=["/user/info", grant])
|
||||
permissions: Final = get_admin_mcp_permissions(
|
||||
auth, permissions_request, "get_team_callbacks", "/team/team-a/callback"
|
||||
)
|
||||
assert "get_team_callbacks" not in permissions.tools
|
||||
assert permissions.action_allowed is False
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.version_info < (3, 12), reason="Admin MCP requires Python 3.12+")
|
||||
def test_admin_mcp_discovery_respects_disabled_management_routes(
|
||||
permissions_request: Request, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(proxy_server, "premium_user", True)
|
||||
monkeypatch.setenv("DISABLE_ADMIN_ENDPOINTS", "true")
|
||||
auth: Final = UserAPIKeyAuth(
|
||||
user_role=LitellmUserRoles.PROXY_ADMIN, allowed_routes=["/user/info", "/key/key-a/reset_spend"]
|
||||
)
|
||||
permissions: Final = get_admin_mcp_permissions(
|
||||
auth, permissions_request, "reset_key_spend", "/key/key-a/reset_spend"
|
||||
)
|
||||
assert permissions.tools == ()
|
||||
assert permissions.action_allowed is False
|
||||
|
||||
|
||||
@pytest.mark.skipif(sys.version_info < (3, 12), reason="Admin MCP requires Python 3.12+")
|
||||
def test_admin_mcp_preserves_http_prefix_grants(permissions_request: Request) -> None:
|
||||
auth: Final = UserAPIKeyAuth(user_role=LitellmUserRoles.PROXY_ADMIN, allowed_routes=["/user/info", "/key/list"])
|
||||
own: Final = get_admin_mcp_permissions(auth, permissions_request, "reset_key_spend", "/key/list/reset_spend")
|
||||
other: Final = get_admin_mcp_permissions(auth, permissions_request, "reset_key_spend", "/key/other/reset_spend")
|
||||
assert "reset_key_spend" in own.tools
|
||||
assert own.action_allowed is True
|
||||
assert other.action_allowed is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_user_info_explains_missing_admin_mcp_dependency(
|
||||
permissions_request: Request, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setitem(sys.modules, "litellm_admin_mcp.catalog", None)
|
||||
auth: Final = UserAPIKeyAuth(user_id="admin", user_role=LitellmUserRoles.PROXY_ADMIN)
|
||||
with pytest.raises(ProxyException, match="admin-mcp dependency group") as exc:
|
||||
await internal_user_endpoints.user_info(
|
||||
request=permissions_request,
|
||||
user_id=None,
|
||||
user_api_key_dict=auth,
|
||||
admin_mcp=True,
|
||||
mcp_tool=None,
|
||||
mcp_path=None,
|
||||
)
|
||||
assert exc.value.code == "503"
|
||||
|
|
|
|||
|
|
@ -468,7 +468,10 @@ def test_admin_mcp_survives_only_management_component_lifespans(
|
|||
|
||||
@application.get("/user/info")
|
||||
async def user_info() -> dict[str, object]:
|
||||
return {"user_id": "admin", "user_info": {"user_id": "admin", "user_role": "proxy_admin"}}
|
||||
return {
|
||||
"user_id": "admin", "user_info": {"user_id": "admin", "user_role": "proxy_admin"},
|
||||
"admin_mcp_permissions": {"tools": ["list_teams"], "action_allowed": True},
|
||||
}
|
||||
|
||||
if component_lifespan is not None:
|
||||
application.router.lifespan_context = partial(component_lifespan, lifespan=application.router.lifespan_context)
|
||||
|
|
|
|||
|
|
@ -147,7 +147,10 @@ async def test_proxy_shutdown_drains_active_admin_tool_before_closing_connector(
|
|||
|
||||
@admin_mcp_proxy.get("/user/info")
|
||||
async def user_info() -> dict[str, object]:
|
||||
return {"user_id": "admin", "user_info": {"user_id": "admin", "user_role": "proxy_admin"}}
|
||||
return {
|
||||
"user_id": "admin", "user_info": {"user_id": "admin", "user_role": "proxy_admin"},
|
||||
"admin_mcp_permissions": {"tools": ["list_teams"], "action_allowed": True},
|
||||
}
|
||||
|
||||
@admin_mcp_proxy.get("/team/list", operation_id="list_team_team_list_get")
|
||||
async def list_teams() -> dict[str, object]:
|
||||
|
|
|
|||
14
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
14
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -25710,6 +25710,13 @@ export interface components {
|
|||
*/
|
||||
source: "admin";
|
||||
};
|
||||
/** AdminMCPPermissions */
|
||||
AdminMCPPermissions: {
|
||||
/** Action Allowed */
|
||||
action_allowed?: boolean | null;
|
||||
/** Tools */
|
||||
tools: string[];
|
||||
};
|
||||
/**
|
||||
* AgentCapabilities
|
||||
* @description Defines optional capabilities supported by an agent.
|
||||
|
|
@ -49920,6 +49927,7 @@ export interface components {
|
|||
};
|
||||
/** UserInfoResponse */
|
||||
UserInfoResponse: {
|
||||
admin_mcp_permissions?: components["schemas"]["AdminMCPPermissions"] | null;
|
||||
/** Keys */
|
||||
keys: unknown[];
|
||||
/** Teams */
|
||||
|
|
@ -76732,6 +76740,12 @@ export interface operations {
|
|||
query?: {
|
||||
/** @description User ID in the request parameters */
|
||||
user_id?: string | null;
|
||||
/** @description Include the caller's current admin MCP route permissions */
|
||||
admin_mcp?: boolean;
|
||||
/** @description Admin MCP tool to authorize for this caller */
|
||||
mcp_tool?: string | null;
|
||||
/** @description URL-encoded concrete management path used by the MCP tool, before query-string encoding */
|
||||
mcp_path?: string | null;
|
||||
};
|
||||
header?: never;
|
||||
path?: never;
|
||||
|
|
|
|||
8
uv.lock
generated
8
uv.lock
generated
|
|
@ -5334,7 +5334,7 @@ requires-dist = [
|
|||
provides-extras = ["proxy", "cli", "extra-proxy", "utils", "mcp", "saml", "semantic-router", "mlflow", "grpc", "stt-vertex-chirp", "stt-nvidia-riva", "google", "bedrock-realtime", "proxy-runtime"]
|
||||
|
||||
[package.metadata.requires-dev]
|
||||
admin-mcp = [{ name = "litellm-admin-mcp", marker = "python_full_version >= '3.12'", url = "https://github.com/BerriAI/liteadmin-mcp/archive/d35ec9c19c117d4c50cc1eccf6ce1296aac25a1a.tar.gz" }]
|
||||
admin-mcp = [{ name = "litellm-admin-mcp", marker = "python_full_version >= '3.12'", url = "https://github.com/BerriAI/liteadmin-mcp/archive/31fb096904ae05684c0779110a93b135b5601182.tar.gz" }]
|
||||
benchmarks = [
|
||||
{ name = "a2a-sdk", specifier = "==1.1.0" },
|
||||
{ name = "mcp", specifier = ">=2.3.0,<3" },
|
||||
|
|
@ -5431,7 +5431,7 @@ proxy-dev = [
|
|||
{ name = "azure-identity", specifier = "==1.25.2" },
|
||||
{ name = "backports-zstd", marker = "python_full_version < '3.14'", specifier = "==1.8.0" },
|
||||
{ name = "hypercorn", specifier = "==0.17.3" },
|
||||
{ name = "litellm-admin-mcp", marker = "python_full_version >= '3.12'", url = "https://github.com/BerriAI/liteadmin-mcp/archive/d35ec9c19c117d4c50cc1eccf6ce1296aac25a1a.tar.gz" },
|
||||
{ name = "litellm-admin-mcp", marker = "python_full_version >= '3.12'", url = "https://github.com/BerriAI/liteadmin-mcp/archive/31fb096904ae05684c0779110a93b135b5601182.tar.gz" },
|
||||
{ name = "opentelemetry-api", specifier = "==1.45.1" },
|
||||
{ name = "opentelemetry-exporter-otlp", specifier = "==1.45.1" },
|
||||
{ name = "opentelemetry-instrumentation-fastapi", specifier = "==0.66b1" },
|
||||
|
|
@ -5444,14 +5444,14 @@ proxy-dev = [
|
|||
[[package]]
|
||||
name = "litellm-admin-mcp"
|
||||
version = "0.1.0"
|
||||
source = { url = "https://github.com/BerriAI/liteadmin-mcp/archive/d35ec9c19c117d4c50cc1eccf6ce1296aac25a1a.tar.gz" }
|
||||
source = { url = "https://github.com/BerriAI/liteadmin-mcp/archive/31fb096904ae05684c0779110a93b135b5601182.tar.gz" }
|
||||
dependencies = [
|
||||
{ name = "httpx2" },
|
||||
{ name = "jsonschema" },
|
||||
{ name = "mcp" },
|
||||
{ name = "uvicorn" },
|
||||
]
|
||||
sdist = { hash = "sha256:86b930f6706fb2da10d53d1798ee7fd14e44fb0afdef0da122cf0e6bb88fa2c3" }
|
||||
sdist = { hash = "sha256:5803a20a00a18177f4591706956f31be311879543049b8b38143ad90bb1cb0a6" }
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue