mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-15 23:31:29 +00:00
The MCP gateway resolved a caller's allowed servers and per-server tool allowlists from the key, the team, the end user and the agent, but never from the internal user row, so an admin had no way to bound what a person may call across every key they hold. Anything the key allowed went through The internal user now carries the same object_permission an admin already attaches to a key or a team, and the resolver applies it as a ceiling: the caller ends up with the intersection of what the key allows and what the user allows, so adding a user entitlement can only narrow, never widen. A level that names no server and no tool places no ceiling, which keeps every existing deployment on its current behavior /user/new and /user/update accept object_permission and reuse the same create-or-update helper the team endpoints use, so the row is written once and the three cached views of it (the user row, the object-permission link and the permission itself) are invalidated on write. Clearing it with an empty object now really unlinks the permission instead of being swallowed as an empty value A row that cannot be read at all places no ceiling, but a row that names a permission the database cannot return denies the call rather than falling through to the wider set, so a partial outage cannot hand out access the admin withheld The users page grows the MCP servers, access groups, toolsets and per-server tool pickers the key and team pages already have. A save keeps a tool allowlist whenever an access group or toolset the admin retained could still supply that server, since an allowlist is what narrows a grant and an absent one reads as no restriction; it drops the allowlist once nothing indirect survives to supply the server, so removing a grant really removes it
186 lines
7.3 KiB
Python
186 lines
7.3 KiB
Python
from __future__ import annotations
|
|
|
|
from typing import Any, Optional, Type, TypeVar, Union, cast, overload
|
|
|
|
from pydantic import BaseModel
|
|
|
|
from litellm._logging import verbose_proxy_logger
|
|
from litellm.caching.dual_cache import DualCache
|
|
from litellm.constants import DEFAULT_MANAGEMENT_OBJECT_IN_MEMORY_CACHE_TTL
|
|
from litellm.proxy.common_utils.cache_pydantic_utils import CacheCodec
|
|
|
|
T = TypeVar("T", bound=BaseModel)
|
|
|
|
|
|
class UserApiKeyCache(DualCache):
|
|
"""
|
|
DualCache wrapper for UserAPIKeyAuth-like payloads.
|
|
|
|
Stores a Redis-safe JSON payload in BOTH in-memory and Redis to avoid
|
|
"memory returns BaseModel, Redis returns dict" format drift.
|
|
|
|
When ``model_type`` is provided:
|
|
- writes are serialized via ``CacheCodec.serialize(..., model_type=...)``
|
|
- reads are deserialized via ``CacheCodec.deserialize(..., model_type)``
|
|
and return ``Optional[T]``: the model on success, ``None`` on cache miss
|
|
**or** if the cached payload fails validation (schema drift). On
|
|
validation failure after a cache hit, an error line is emitted via
|
|
``verbose_proxy_logger``.
|
|
|
|
When ``model_type`` is omitted, the interface behaves like ``DualCache``:
|
|
raw cached payload is returned (dict/str/etc.).
|
|
|
|
``async_set_cache_pipeline`` applies the same untyped Codec pass as omitting
|
|
``model_type`` on ``async_set_cache`` (so ``BaseModel`` rows are dumped before Redis).
|
|
|
|
``get_cache`` / ``async_get_cache`` overloads and implementations must be contiguous
|
|
(no other methods in between) so mypy resolves ``@overload`` + implementation correctly.
|
|
"""
|
|
|
|
@overload
|
|
def get_cache(
|
|
self,
|
|
key: Any,
|
|
parent_otel_span: Any = None,
|
|
local_only: bool = False,
|
|
*,
|
|
model_type: Type[T],
|
|
**kwargs: Any,
|
|
) -> Optional[T]: ...
|
|
|
|
@overload
|
|
def get_cache(
|
|
self,
|
|
key: Any,
|
|
parent_otel_span: Any = None,
|
|
local_only: bool = False,
|
|
**kwargs: Any,
|
|
) -> Any: ...
|
|
|
|
def get_cache( # type: ignore[override]
|
|
self,
|
|
key,
|
|
parent_otel_span=None,
|
|
local_only: bool = False,
|
|
model_type: Optional[Type[BaseModel]] = None,
|
|
**kwargs,
|
|
) -> Union[Any, Optional[BaseModel]]:
|
|
if model_type is None and "model_type" in kwargs:
|
|
model_type = cast(Optional[Type[BaseModel]], kwargs.pop("model_type", None))
|
|
cached = super().get_cache(key=key, parent_otel_span=parent_otel_span, local_only=local_only, **kwargs)
|
|
if model_type is None:
|
|
return cached
|
|
if cached is None:
|
|
return None
|
|
decoded = CacheCodec.deserialize(cached, model_type=model_type)
|
|
if decoded is None:
|
|
verbose_proxy_logger.error(
|
|
"UserApiKeyCache.get_cache failed to deserialize cached value for key=%r model_type=%s",
|
|
key,
|
|
getattr(model_type, "__name__", str(model_type)),
|
|
)
|
|
return None
|
|
return decoded
|
|
|
|
@overload
|
|
async def async_get_cache(
|
|
self,
|
|
key: Any,
|
|
parent_otel_span: Any = None,
|
|
local_only: bool = False,
|
|
*,
|
|
model_type: Type[T],
|
|
**kwargs: Any,
|
|
) -> Optional[T]: ...
|
|
|
|
@overload
|
|
async def async_get_cache(
|
|
self,
|
|
key: Any,
|
|
parent_otel_span: Any = None,
|
|
local_only: bool = False,
|
|
**kwargs: Any,
|
|
) -> Any: ...
|
|
|
|
async def async_get_cache( # type: ignore[override]
|
|
self,
|
|
key,
|
|
parent_otel_span=None,
|
|
local_only: bool = False,
|
|
model_type: Optional[Type[BaseModel]] = None,
|
|
**kwargs,
|
|
) -> Union[Any, Optional[BaseModel]]:
|
|
if model_type is None and "model_type" in kwargs:
|
|
model_type = cast(Optional[Type[BaseModel]], kwargs.pop("model_type", None))
|
|
cached = await super().async_get_cache(
|
|
key=key, parent_otel_span=parent_otel_span, local_only=local_only, **kwargs
|
|
)
|
|
if model_type is None:
|
|
return cached
|
|
if cached is None:
|
|
return None
|
|
decoded = CacheCodec.deserialize(cached, model_type=model_type)
|
|
if decoded is None:
|
|
verbose_proxy_logger.error(
|
|
"UserApiKeyCache.async_get_cache failed to deserialize cached value for key=%r model_type=%s",
|
|
key,
|
|
getattr(model_type, "__name__", str(model_type)),
|
|
)
|
|
return None
|
|
return decoded
|
|
|
|
def set_cache(self, key, value, local_only: bool = False, **kwargs): # type: ignore[override]
|
|
model_type = cast(Optional[Type[BaseModel]], kwargs.pop("model_type", None))
|
|
payload = CacheCodec.serialize(value, model_type=model_type)
|
|
return super().set_cache(key=key, value=payload, local_only=local_only, **kwargs)
|
|
|
|
async def async_set_cache(self, key, value, local_only: bool = False, **kwargs): # type: ignore[override]
|
|
model_type = cast(Optional[Type[BaseModel]], kwargs.pop("model_type", None))
|
|
payload = CacheCodec.serialize(value, model_type=model_type)
|
|
return await super().async_set_cache(key=key, value=payload, local_only=local_only, **kwargs)
|
|
|
|
async def async_set_cache_pipeline( # type: ignore[override]
|
|
self, cache_list: list, local_only: bool = False, **kwargs
|
|
) -> None:
|
|
"""
|
|
Batch writes with the same Codec boundary as ``async_set_cache`` without
|
|
``model_type``: ``BaseModel`` values become JSON-safe dicts; dicts/scalars unchanged.
|
|
"""
|
|
normalized = [(key, CacheCodec.serialize(value, model_type=None)) for key, value in cache_list]
|
|
return await super().async_set_cache_pipeline(cache_list=normalized, local_only=local_only, **kwargs)
|
|
|
|
|
|
#: Value cached under ``user_object_permission_id_cache_key`` when the user links no permission row,
|
|
#: so a human without an entitlement costs no DB read per request. Lives beside the key builder
|
|
#: because it is part of the same cache protocol: a reader that knows the key must know this value.
|
|
USER_NO_MCP_PERMISSION_SENTINEL = "__user_no_mcp_permission__"
|
|
|
|
|
|
def user_object_permission_id_cache_key(user_id: str) -> str:
|
|
"""Cache key for the ``user_id -> object_permission_id`` link.
|
|
|
|
Lives here rather than next to either user because two modules own the two halves: the MCP auth
|
|
resolver writes it on read, and ``/user/update`` deletes it after changing the link. A key format
|
|
duplicated across those two drifts silently, and the failure is an entitlement change that never
|
|
takes effect.
|
|
"""
|
|
return f"user_object_permission_id:{user_id}"
|
|
|
|
|
|
def object_permission_cache_key(object_permission_id: str) -> str:
|
|
"""Cache key ``get_object_permission`` stores a permission row under."""
|
|
return f"object_permission_id:{object_permission_id}"
|
|
|
|
|
|
def get_management_object_ttl(cache: DualCache) -> float:
|
|
"""
|
|
In-memory TTL for management-object cache writes (keys, teams, users, budgets, ...).
|
|
|
|
Honors ``general_settings.user_api_key_cache_ttl``, which ``proxy_server``
|
|
propagates onto ``default_in_memory_ttl`` at startup, and falls back to
|
|
``DEFAULT_MANAGEMENT_OBJECT_IN_MEMORY_CACHE_TTL`` when no default is configured.
|
|
"""
|
|
configured: Optional[float] = getattr(cache, "default_in_memory_ttl", None)
|
|
if configured is not None:
|
|
return configured
|
|
return DEFAULT_MANAGEMENT_OBJECT_IN_MEMORY_CACHE_TTL
|