mirror of
https://github.com/BerriAI/litellm.git
synced 2026-11-13 08:52:02 +00:00
feat(mcp): store credential headers encrypted at rest
This commit is contained in:
parent
40be7fa1a8
commit
a92fd56a67
5 changed files with 142 additions and 2 deletions
|
|
@ -37348,6 +37348,20 @@
|
|||
],
|
||||
"title": "Dcr Server Url"
|
||||
},
|
||||
"headers": {
|
||||
"anyOf": [
|
||||
{
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Headers"
|
||||
},
|
||||
"id_jag_resource": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
|
@ -40176,6 +40190,20 @@
|
|||
],
|
||||
"title": "Dcr Server Url"
|
||||
},
|
||||
"headers": {
|
||||
"anyOf": [
|
||||
{
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Headers"
|
||||
},
|
||||
"id_jag_resource": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
|
@ -43592,6 +43620,20 @@
|
|||
],
|
||||
"title": "Dcr Server Url"
|
||||
},
|
||||
"headers": {
|
||||
"anyOf": [
|
||||
{
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Headers"
|
||||
},
|
||||
"id_jag_resource": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
|
@ -47683,6 +47725,20 @@
|
|||
],
|
||||
"title": "Dcr Server Url"
|
||||
},
|
||||
"headers": {
|
||||
"anyOf": [
|
||||
{
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"title": "Headers"
|
||||
},
|
||||
"id_jag_resource": {
|
||||
"anyOf": [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -564,7 +564,16 @@ def encrypt_credentials(credentials: MCPCredentials, encryption_key: str | None)
|
|||
new_encryption_key=encryption_key,
|
||||
)
|
||||
# aws_region_name and aws_service_name are NOT secrets — stored as-is
|
||||
return credentials
|
||||
headers: Final = credentials.get("headers")
|
||||
if headers is None:
|
||||
return credentials
|
||||
return {
|
||||
**credentials,
|
||||
"headers": {
|
||||
name: encrypt_value_helper(value=value, new_encryption_key=encryption_key)
|
||||
for name, value in headers.items()
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _credentials_blob_to_mutable_dict(blob: str | Mapping[str, object]) -> dict[str, object]:
|
||||
|
|
@ -844,7 +853,22 @@ def decrypt_credentials(
|
|||
exception_type="debug",
|
||||
return_original_value=True,
|
||||
)
|
||||
return credentials
|
||||
headers: Final = credentials.get("headers")
|
||||
if not isinstance(headers, dict):
|
||||
return credentials
|
||||
return {
|
||||
**credentials,
|
||||
"headers": {
|
||||
name: decrypted
|
||||
for name, value in headers.items()
|
||||
if (
|
||||
decrypted := decrypt_value_helper(
|
||||
value=value, key="headers", exception_type="debug", return_original_value=True
|
||||
)
|
||||
)
|
||||
is not None
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _readable_mcp_servers(
|
||||
|
|
|
|||
|
|
@ -172,6 +172,11 @@ class MCPCredentials(TypedDict, total=False):
|
|||
Authentication value
|
||||
"""
|
||||
|
||||
headers: ReadOnly[Mapping[str, str] | None]
|
||||
"""
|
||||
Named secret headers sent upstream. Values are encrypted at rest.
|
||||
"""
|
||||
|
||||
client_id: str | None
|
||||
"""
|
||||
OAuth 2.0 client identifier used when auth_type is oauth2
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ keeps a plain-base64 fallback on read so existing rows continue to work.
|
|||
|
||||
import base64
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from types import SimpleNamespace
|
||||
from typing import Final
|
||||
|
|
@ -26,6 +27,7 @@ from litellm.proxy.common_utils.encrypt_decrypt_utils import (
|
|||
encrypt_secret_map,
|
||||
encrypt_value_helper,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.mcp_management_endpoints import _redact_mcp_credentials
|
||||
from litellm.proxy.mcp_server.db import (
|
||||
_decode_user_credential,
|
||||
_prepare_mcp_server_data,
|
||||
|
|
@ -478,6 +480,51 @@ def test_client_private_key_encrypted_at_rest():
|
|||
assert decrypted["id_jag_resource_client_secret"] == resource_client_secret
|
||||
|
||||
|
||||
_DATADOG_HEADERS: Final = {"DD-API-KEY": "dd-api-secret", "DD-APPLICATION-KEY": "dd-app-secret"}
|
||||
|
||||
|
||||
def test_credential_header_values_are_encrypted_and_round_trip() -> None:
|
||||
encrypted: Final = encrypt_credentials({"headers": dict(_DATADOG_HEADERS)}, encryption_key=None)
|
||||
assert encrypted["headers"].keys() == _DATADOG_HEADERS.keys()
|
||||
assert all(secret not in json.dumps(encrypted) for secret in _DATADOG_HEADERS.values())
|
||||
assert decrypt_credentials(dict(encrypted))["headers"] == _DATADOG_HEADERS
|
||||
|
||||
|
||||
def test_admin_read_drops_credential_headers_and_keeps_scopes() -> None:
|
||||
server: Final = LiteLLM_MCPServerTable(
|
||||
server_id="srv",
|
||||
transport=MCPTransport.http,
|
||||
credentials={"headers": dict(_DATADOG_HEADERS), "scopes": ["read"]},
|
||||
)
|
||||
assert _redact_mcp_credentials(server).credentials == {"scopes": ["read"]}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_credential_headers_are_stored_encrypted_kept_on_unrelated_update_and_replaced_on_new_headers() -> None:
|
||||
table: Final = _MapTable()
|
||||
prisma: Final = SimpleNamespace(
|
||||
db=SimpleNamespace(litellm_mcpservertable=table, litellm_config=SimpleNamespace(find_unique=AsyncMock(return_value=None)))
|
||||
)
|
||||
create: Final = NewMCPServerRequest.model_validate(
|
||||
{"server_id": "srv-dd", "transport": "http", "url": "https://up.example.com/mcp", "credentials": {"headers": dict(_DATADOG_HEADERS)}}
|
||||
)
|
||||
await create_mcp_server(prisma, create, touched_by="test")
|
||||
stored: Final = table.rows["srv-dd"]["credentials"]
|
||||
assert all(secret not in stored for secret in _DATADOG_HEADERS.values())
|
||||
assert decrypt_credentials(json.loads(stored))["headers"] == _DATADOG_HEADERS
|
||||
|
||||
await update_mcp_server(prisma, UpdateMCPServerRequest(server_id="srv-dd", description="renamed"), touched_by="test")
|
||||
assert table.rows["srv-dd"]["credentials"] == stored
|
||||
|
||||
replacement: Final = {"DD-API-KEY": "rotated-secret"}
|
||||
await update_mcp_server(
|
||||
prisma,
|
||||
UpdateMCPServerRequest.model_validate({"server_id": "srv-dd", "credentials": {"headers": replacement}}),
|
||||
touched_by="test",
|
||||
)
|
||||
assert decrypt_credentials(json.loads(table.rows["srv-dd"]["credentials"]))["headers"] == replacement
|
||||
|
||||
|
||||
@pytest.fixture(params=["xsalsa20-poly1305", "aes-256-gcm"])
|
||||
def map_algorithm(request: pytest.FixtureRequest, monkeypatch: pytest.MonkeyPatch) -> str:
|
||||
monkeypatch.setattr("litellm.proxy.proxy_server.general_settings", {"encryption_algorithm": request.param})
|
||||
|
|
@ -506,6 +553,10 @@ class _MapTable:
|
|||
async def update(self, *, where: dict[str, str], data: dict[str, object]) -> PrismaMCPServer:
|
||||
return await self.create(data={**self.rows[where["server_id"]], **data})
|
||||
|
||||
async def find_unique(self, *, where: Mapping[str, str]) -> PrismaMCPServer | None:
|
||||
row: Final = self.rows.get(where["server_id"])
|
||||
return None if row is None else _prisma_map_row(row, self.quoted)
|
||||
|
||||
async def find_many(self, where: object = None) -> list[PrismaMCPServer]:
|
||||
return [_prisma_map_row(row, self.quoted) for row in self.rows.values()]
|
||||
|
||||
|
|
|
|||
4
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
4
ui/litellm-dashboard/src/lib/http/schema.d.ts
generated
vendored
|
|
@ -38809,6 +38809,10 @@ export interface components {
|
|||
dcr_issuer?: string | null;
|
||||
/** Dcr Server Url */
|
||||
dcr_server_url?: string | null;
|
||||
/** Headers */
|
||||
headers?: {
|
||||
[key: string]: string;
|
||||
} | null;
|
||||
/** Id Jag Resource */
|
||||
id_jag_resource?: string | null;
|
||||
/** Id Jag Resource Client Id */
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue