mirror of
https://github.com/BerriAI/litellm.git
synced 2026-09-13 23:11:40 +00:00
Merge pull request #25791 from J-Byron/fix/langfuse-credential-leak
Langfuse credential leak
This commit is contained in:
commit
237b18ad85
8 changed files with 595 additions and 9 deletions
|
|
@ -1,5 +1,11 @@
|
|||
import copy
|
||||
from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Literal, Optional
|
||||
|
||||
from litellm.proxy.common_utils.encrypt_decrypt_utils import (
|
||||
decrypt_value_helper,
|
||||
encrypt_value_helper,
|
||||
)
|
||||
|
||||
import litellm
|
||||
from litellm import get_secret
|
||||
from litellm._logging import verbose_proxy_logger
|
||||
|
|
@ -524,3 +530,89 @@ def normalize_callback_names(callbacks: Iterable[Any]) -> List[Any]:
|
|||
if callbacks is None:
|
||||
return []
|
||||
return [c.lower() if isinstance(c, str) else c for c in callbacks]
|
||||
|
||||
|
||||
def encrypt_logging_callback_vars(metadata: Optional[Dict]) -> Optional[Dict]:
|
||||
"""
|
||||
Encrypt credential values inside ``metadata["logging"][*]["callback_vars"]``
|
||||
before persisting to the database.
|
||||
|
||||
Values that are environment-variable references (``os.environ/…``) are
|
||||
left untouched — they are pointers, not secrets, and do not need
|
||||
to be stored encrypted.
|
||||
|
||||
Returns the *same* dict (mutated in-place) so the caller can assign the
|
||||
result back to ``metadata`` if convenient.
|
||||
"""
|
||||
if not metadata:
|
||||
return metadata
|
||||
|
||||
logging_configs = metadata.get("logging")
|
||||
if not isinstance(logging_configs, list):
|
||||
return metadata
|
||||
|
||||
for entry in logging_configs:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
callback_vars = entry.get("callback_vars")
|
||||
if not isinstance(callback_vars, dict):
|
||||
continue
|
||||
for key, value in callback_vars.items():
|
||||
if not isinstance(value, str):
|
||||
continue
|
||||
# Leave env-var pointers as-is; encrypt everything else
|
||||
if value.startswith("os.environ/"):
|
||||
continue
|
||||
callback_vars[key] = encrypt_value_helper(value)
|
||||
|
||||
return metadata
|
||||
|
||||
|
||||
def redact_sensitive_logging_metadata(metadata: Optional[Dict]) -> Optional[Dict]:
|
||||
"""
|
||||
Return a copy of `metadata` with credential values inside
|
||||
`metadata["logging"][*]["callback_vars"]` partially masked.
|
||||
|
||||
Each value is replaced with "...XYZ" where XYZ is the last 3 characters
|
||||
of the plaintext (decrypting first if the value is encrypted), giving
|
||||
admins a visual hint without exposing the full secret.
|
||||
|
||||
Values that are environment-variable references
|
||||
(e.g. "os.environ/LANGFUSE_SECRET_KEY") are left as-is.
|
||||
"""
|
||||
if not metadata:
|
||||
return metadata
|
||||
|
||||
metadata = copy.deepcopy(metadata)
|
||||
|
||||
logging_configs = metadata.get("logging")
|
||||
if not isinstance(logging_configs, list):
|
||||
return metadata
|
||||
|
||||
for entry in logging_configs:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
callback_vars = entry.get("callback_vars")
|
||||
if not isinstance(callback_vars, dict):
|
||||
continue
|
||||
for key, value in callback_vars.items():
|
||||
if not isinstance(value, str):
|
||||
callback_vars[key] = "***"
|
||||
continue
|
||||
# Keep env-var pointers as-is
|
||||
if value.startswith("os.environ/"):
|
||||
continue
|
||||
# Decrypt to get plaintext (no-op for already-plaintext rows)
|
||||
plaintext = str(
|
||||
decrypt_value_helper(
|
||||
value=value,
|
||||
key=key,
|
||||
exception_type="debug",
|
||||
return_original_value=True,
|
||||
)
|
||||
or value
|
||||
)
|
||||
suffix = plaintext[-3:] if len(plaintext) >= 3 else plaintext
|
||||
callback_vars[key] = f"...{suffix}"
|
||||
|
||||
return metadata
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ from litellm.proxy._types import (
|
|||
TeamCallbackMetadata,
|
||||
UserAPIKeyAuth,
|
||||
)
|
||||
from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value_helper
|
||||
from litellm.proxy.common_utils.http_parsing_utils import _safe_get_request_headers
|
||||
|
||||
# Cache special headers as a frozenset for O(1) lookup performance
|
||||
|
|
@ -187,9 +188,18 @@ def convert_key_logging_metadata_to_callback(
|
|||
for var, value in data.callback_vars.items():
|
||||
if team_callback_settings_obj.callback_vars is None:
|
||||
team_callback_settings_obj.callback_vars = {}
|
||||
team_callback_settings_obj.callback_vars[var] = str(
|
||||
litellm.utils.get_secret(value, default_value=value) or value
|
||||
)
|
||||
resolved = str(litellm.utils.get_secret(value, default_value=value) or value)
|
||||
# If not an env-var pointer the stored value may be encrypted — decrypt it
|
||||
if not value.startswith("os.environ/"):
|
||||
resolved = str(
|
||||
decrypt_value_helper(
|
||||
value=resolved,
|
||||
key=var,
|
||||
exception_type="debug",
|
||||
return_original_value=True,
|
||||
)
|
||||
)
|
||||
team_callback_settings_obj.callback_vars[var] = resolved
|
||||
|
||||
return team_callback_settings_obj
|
||||
|
||||
|
|
|
|||
|
|
@ -49,6 +49,10 @@ from litellm.proxy.auth.auth_checks import (
|
|||
)
|
||||
from litellm.proxy.auth.auth_utils import abbreviate_api_key
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.common_utils.callback_utils import (
|
||||
encrypt_logging_callback_vars,
|
||||
redact_sensitive_logging_metadata,
|
||||
)
|
||||
from litellm.proxy.common_utils.timezone_utils import get_budget_reset_time
|
||||
from litellm.proxy.hooks.key_management_event_hooks import KeyManagementEventHooks
|
||||
from litellm.proxy.management_endpoints.common_utils import (
|
||||
|
|
@ -1513,6 +1517,7 @@ def prepare_metadata_fields(
|
|||
"""
|
||||
Check LiteLLM_ManagementEndpoint_MetadataFields (proxy/_types.py) for fields that are allowed to be updated
|
||||
"""
|
||||
metadata_provided_in_update = "metadata" in non_default_values
|
||||
if "metadata" not in non_default_values: # allow user to set metadata to none
|
||||
non_default_values["metadata"] = existing_metadata.copy()
|
||||
|
||||
|
|
@ -1541,6 +1546,8 @@ def prepare_metadata_fields(
|
|||
)
|
||||
|
||||
non_default_values["metadata"] = casted_metadata
|
||||
if metadata_provided_in_update and "logging" in casted_metadata:
|
||||
encrypt_logging_callback_vars(non_default_values["metadata"])
|
||||
return non_default_values
|
||||
|
||||
|
||||
|
|
@ -2723,6 +2730,9 @@ async def info_key_fn_v2(
|
|||
except Exception:
|
||||
k_dict = k.dict()
|
||||
k_dict.pop("token", None)
|
||||
k_dict["metadata"] = redact_sensitive_logging_metadata(
|
||||
k_dict.get("metadata")
|
||||
)
|
||||
filtered_key_info.append(k_dict)
|
||||
return {"key": data.keys, "info": filtered_key_info}
|
||||
|
||||
|
|
@ -2806,6 +2816,12 @@ async def info_key_fn(
|
|||
key_info = key_info.dict()
|
||||
key_info.pop("token")
|
||||
|
||||
# Scrub credentials stored in metadata.logging[].callback_vars so
|
||||
# Langfuse / other integration secrets are never returned in plaintext.
|
||||
key_info["metadata"] = redact_sensitive_logging_metadata(
|
||||
key_info.get("metadata")
|
||||
)
|
||||
|
||||
# Attach object_permission if object_permission_id is set
|
||||
key_info = await attach_object_permission_to_dict(key_info, prisma_client)
|
||||
|
||||
|
|
@ -2970,6 +2986,7 @@ async def generate_key_helper_fn( # noqa: PLR0915
|
|||
metadata = metadata or {}
|
||||
metadata["prompts"] = prompts
|
||||
|
||||
encrypt_logging_callback_vars(metadata)
|
||||
metadata_json = json.dumps(metadata)
|
||||
validate_model_max_budget(model_max_budget)
|
||||
model_max_budget_json = json.dumps(model_max_budget)
|
||||
|
|
@ -4964,6 +4981,9 @@ async def _list_key_helper(
|
|||
}
|
||||
|
||||
if return_full_object is True or (expand and "user" in expand):
|
||||
key_dict["metadata"] = redact_sensitive_logging_metadata(
|
||||
key_dict.get("metadata")
|
||||
)
|
||||
if use_deleted_table:
|
||||
# Use deleted key type to preserve deleted_at, deleted_by, etc.
|
||||
key_list.append(LiteLLM_DeletedVerificationToken(**key_dict))
|
||||
|
|
|
|||
|
|
@ -19,6 +19,10 @@ from litellm.proxy._types import (
|
|||
UserAPIKeyAuth,
|
||||
)
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.common_utils.callback_utils import (
|
||||
encrypt_logging_callback_vars,
|
||||
redact_sensitive_logging_metadata,
|
||||
)
|
||||
from litellm.proxy.management_helpers.utils import management_endpoint_wrapper
|
||||
|
||||
router = APIRouter()
|
||||
|
|
@ -123,7 +127,9 @@ async def add_team_callbacks(
|
|||
param="callback_name",
|
||||
)
|
||||
|
||||
team_callback_settings.append(data.model_dump())
|
||||
new_entry = data.model_dump()
|
||||
encrypt_logging_callback_vars({"logging": [new_entry]})
|
||||
team_callback_settings.append(new_entry)
|
||||
|
||||
team_metadata["logging"] = team_callback_settings
|
||||
team_metadata_json = json.dumps(team_metadata) # update team_metadata
|
||||
|
|
@ -312,13 +318,22 @@ async def get_team_callbacks(
|
|||
# Convert to TeamCallbackMetadata object for consistent structure
|
||||
team_callback_settings_obj = TeamCallbackMetadata(**team_callback_settings)
|
||||
|
||||
redacted_metadata = redact_sensitive_logging_metadata(
|
||||
{"logging": [{"callback_vars": team_callback_settings_obj.callback_vars}]}
|
||||
)
|
||||
redacted_vars = (
|
||||
redacted_metadata["logging"][0]["callback_vars"]
|
||||
if redacted_metadata
|
||||
else team_callback_settings_obj.callback_vars
|
||||
)
|
||||
|
||||
return {
|
||||
"status": "success",
|
||||
"data": {
|
||||
"team_id": team_id,
|
||||
"success_callbacks": team_callback_settings_obj.success_callback,
|
||||
"failure_callbacks": team_callback_settings_obj.failure_callback,
|
||||
"callback_vars": team_callback_settings_obj.callback_vars,
|
||||
"callback_vars": redacted_vars,
|
||||
},
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -69,6 +69,10 @@ from litellm.proxy.auth.auth_checks import (
|
|||
get_user_object,
|
||||
)
|
||||
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
|
||||
from litellm.proxy.common_utils.callback_utils import (
|
||||
encrypt_logging_callback_vars,
|
||||
redact_sensitive_logging_metadata,
|
||||
)
|
||||
from litellm.proxy.management_endpoints.common_utils import (
|
||||
_is_user_org_admin_for_team,
|
||||
_is_user_team_admin,
|
||||
|
|
@ -1095,6 +1099,7 @@ async def new_team( # noqa: PLR0915
|
|||
complete_team_data.members_with_roles = []
|
||||
|
||||
complete_team_data_dict = complete_team_data.model_dump(exclude_none=True)
|
||||
encrypt_logging_callback_vars(complete_team_data_dict.get("metadata"))
|
||||
|
||||
# Serialize router_settings to JSON (matching key creation pattern)
|
||||
router_settings_value = getattr(data, "router_settings", None)
|
||||
|
|
@ -1654,6 +1659,7 @@ async def update_team( # noqa: PLR0915
|
|||
|
||||
# update team metadata fields
|
||||
_update_metadata_fields(updated_kv=updated_kv)
|
||||
encrypt_logging_callback_vars(updated_kv.get("metadata"))
|
||||
|
||||
if "model_aliases" in updated_kv:
|
||||
updated_kv.pop("model_aliases")
|
||||
|
|
@ -3197,6 +3203,7 @@ async def team_info(
|
|||
# if using pydantic v1
|
||||
key = key.dict()
|
||||
key.pop("token", None)
|
||||
key["metadata"] = redact_sensitive_logging_metadata(key.get("metadata"))
|
||||
|
||||
## GET ALL MEMBERSHIPS ##
|
||||
returned_tm = await get_all_team_memberships(
|
||||
|
|
@ -3226,6 +3233,10 @@ async def team_info(
|
|||
# Resolve resources inherited from access groups
|
||||
await _resolve_team_access_group_resources(_team_info)
|
||||
|
||||
# Scrub credentials from team-level logging config before returning
|
||||
if _team_info.metadata is not None:
|
||||
_team_info.metadata = redact_sensitive_logging_metadata(_team_info.metadata)
|
||||
|
||||
response_object = TeamInfoResponseObject(
|
||||
team_id=team_id,
|
||||
team_info=_team_info,
|
||||
|
|
@ -3578,6 +3589,9 @@ def _convert_teams_to_response_models(
|
|||
except Exception:
|
||||
team_dict = team.dict()
|
||||
|
||||
team_dict["metadata"] = redact_sensitive_logging_metadata(
|
||||
team_dict.get("metadata")
|
||||
)
|
||||
if use_deleted_table:
|
||||
team_list.append(LiteLLM_DeletedTeamTable(**team_dict))
|
||||
else:
|
||||
|
|
@ -4002,9 +4016,13 @@ async def list_team(
|
|||
)
|
||||
|
||||
try:
|
||||
_team_dict = team.model_dump()
|
||||
_team_dict["metadata"] = redact_sensitive_logging_metadata(
|
||||
_team_dict.get("metadata")
|
||||
)
|
||||
returned_responses.append(
|
||||
TeamListResponseObject(
|
||||
**team.model_dump(),
|
||||
**_team_dict,
|
||||
team_memberships=_team_memberships,
|
||||
keys=keys,
|
||||
)
|
||||
|
|
@ -4012,9 +4030,7 @@ async def list_team(
|
|||
except Exception as e:
|
||||
team_exception = """Invalid team object for team_id: {}. team_object={}.
|
||||
Error: {}
|
||||
""".format(
|
||||
team.team_id, team.model_dump(), str(e)
|
||||
)
|
||||
""".format(team.team_id, team.model_dump(), str(e))
|
||||
verbose_proxy_logger.exception(team_exception)
|
||||
continue
|
||||
# Sort the responses by team_alias
|
||||
|
|
|
|||
|
|
@ -6,8 +6,10 @@ sys.path.insert(
|
|||
) # Adds the parent directory to the system path
|
||||
|
||||
from litellm.proxy.common_utils.callback_utils import (
|
||||
encrypt_logging_callback_vars,
|
||||
get_remaining_tokens_and_requests_from_request_data,
|
||||
normalize_callback_names,
|
||||
redact_sensitive_logging_metadata,
|
||||
)
|
||||
|
||||
from unittest.mock import patch
|
||||
|
|
@ -84,3 +86,171 @@ def test_normalize_callback_names_lowercases_strings():
|
|||
"s3",
|
||||
"custom_callback",
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# redact_sensitive_logging_metadata tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_redact_scrubs_real_secret_values():
|
||||
"""Real credential values must be partially masked showing last 3 chars."""
|
||||
metadata = {
|
||||
"logging": [
|
||||
{
|
||||
"callback_name": "langfuse",
|
||||
"callback_type": "success_and_failure",
|
||||
"callback_vars": {
|
||||
"langfuse_public_key": "pk-lf-abc123",
|
||||
"langfuse_secret_key": "sk-lf-supersecret",
|
||||
"langfuse_host": "https://us.cloud.langfuse.com",
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
result = redact_sensitive_logging_metadata(metadata)
|
||||
vars_ = result["logging"][0]["callback_vars"]
|
||||
assert vars_["langfuse_public_key"] == "...123"
|
||||
assert vars_["langfuse_secret_key"] == "...ret"
|
||||
assert vars_["langfuse_host"] == "...com"
|
||||
|
||||
|
||||
def test_redact_keeps_env_var_references():
|
||||
"""os.environ/ pointers are not secrets — they must be preserved."""
|
||||
metadata = {
|
||||
"logging": [
|
||||
{
|
||||
"callback_name": "langfuse",
|
||||
"callback_vars": {
|
||||
"langfuse_public_key": "os.environ/LANGFUSE_PUBLIC_KEY",
|
||||
"langfuse_secret_key": "os.environ/LANGFUSE_SECRET_KEY",
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
result = redact_sensitive_logging_metadata(metadata)
|
||||
vars_ = result["logging"][0]["callback_vars"]
|
||||
assert vars_["langfuse_public_key"] == "os.environ/LANGFUSE_PUBLIC_KEY"
|
||||
assert vars_["langfuse_secret_key"] == "os.environ/LANGFUSE_SECRET_KEY"
|
||||
|
||||
|
||||
def test_redact_does_not_mutate_original():
|
||||
"""The original metadata dict must not be modified."""
|
||||
metadata = {
|
||||
"logging": [
|
||||
{
|
||||
"callback_name": "langfuse",
|
||||
"callback_vars": {"langfuse_secret_key": "sk-real-secret"},
|
||||
}
|
||||
]
|
||||
}
|
||||
original_value = metadata["logging"][0]["callback_vars"]["langfuse_secret_key"]
|
||||
redact_sensitive_logging_metadata(metadata)
|
||||
assert (
|
||||
metadata["logging"][0]["callback_vars"]["langfuse_secret_key"] == original_value
|
||||
)
|
||||
|
||||
|
||||
def test_redact_returns_none_for_none_input():
|
||||
assert redact_sensitive_logging_metadata(None) is None
|
||||
|
||||
|
||||
def test_redact_returns_unchanged_when_no_logging_key():
|
||||
"""Metadata without a 'logging' key passes through untouched."""
|
||||
metadata = {"some_other_key": "value"}
|
||||
result = redact_sensitive_logging_metadata(metadata)
|
||||
assert result == {"some_other_key": "value"}
|
||||
|
||||
|
||||
def test_redact_mixed_env_and_real_values():
|
||||
"""Only real values are scrubbed; env-var pointers in the same dict survive."""
|
||||
metadata = {
|
||||
"logging": [
|
||||
{
|
||||
"callback_name": "langfuse",
|
||||
"callback_vars": {
|
||||
"langfuse_public_key": "os.environ/LANGFUSE_PUBLIC_KEY",
|
||||
"langfuse_secret_key": "sk-real-secret",
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
result = redact_sensitive_logging_metadata(metadata)
|
||||
vars_ = result["logging"][0]["callback_vars"]
|
||||
assert vars_["langfuse_public_key"] == "os.environ/LANGFUSE_PUBLIC_KEY"
|
||||
assert vars_["langfuse_secret_key"] == "...ret"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# encrypt_logging_callback_vars tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_metadata(callback_vars: dict) -> dict:
|
||||
return {
|
||||
"logging": [
|
||||
{
|
||||
"callback_name": "langfuse",
|
||||
"callback_type": "success_and_failure",
|
||||
"callback_vars": callback_vars,
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
def test_encrypt_returns_none_for_none():
|
||||
assert encrypt_logging_callback_vars(None) is None
|
||||
|
||||
|
||||
def test_encrypt_returns_unchanged_when_no_logging_key():
|
||||
metadata = {"other_key": "value"}
|
||||
result = encrypt_logging_callback_vars(metadata)
|
||||
assert result == {"other_key": "value"}
|
||||
|
||||
|
||||
def test_encrypt_leaves_env_var_pointers_unchanged(monkeypatch):
|
||||
monkeypatch.setenv("LITELLM_SALT_KEY", "test-salt-key-1234567890123456")
|
||||
metadata = _make_metadata({"langfuse_secret_key": "os.environ/LANGFUSE_SECRET_KEY"})
|
||||
result = encrypt_logging_callback_vars(metadata)
|
||||
assert (
|
||||
result["logging"][0]["callback_vars"]["langfuse_secret_key"]
|
||||
== "os.environ/LANGFUSE_SECRET_KEY"
|
||||
)
|
||||
|
||||
|
||||
def test_encrypt_real_values_are_changed(monkeypatch):
|
||||
monkeypatch.setenv("LITELLM_SALT_KEY", "test-salt-key-1234567890123456")
|
||||
plaintext = "sk-lf-supersecret"
|
||||
metadata = _make_metadata({"langfuse_secret_key": plaintext})
|
||||
result = encrypt_logging_callback_vars(metadata)
|
||||
encrypted = result["logging"][0]["callback_vars"]["langfuse_secret_key"]
|
||||
assert encrypted != plaintext
|
||||
assert isinstance(encrypted, str)
|
||||
|
||||
|
||||
def test_encrypt_then_decrypt_roundtrip(monkeypatch):
|
||||
"""Encrypt a value and confirm decrypt_value_helper returns the original."""
|
||||
monkeypatch.setenv("LITELLM_SALT_KEY", "test-salt-key-1234567890123456")
|
||||
from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value_helper
|
||||
|
||||
plaintext = "pk-lf-abc123"
|
||||
metadata = _make_metadata({"langfuse_public_key": plaintext})
|
||||
encrypt_logging_callback_vars(metadata)
|
||||
encrypted = metadata["logging"][0]["callback_vars"]["langfuse_public_key"]
|
||||
assert encrypted != plaintext
|
||||
|
||||
recovered = decrypt_value_helper(
|
||||
value=encrypted, key="langfuse_public_key", return_original_value=True
|
||||
)
|
||||
assert recovered == plaintext
|
||||
|
||||
|
||||
def test_encrypt_mutates_in_place(monkeypatch):
|
||||
"""encrypt_logging_callback_vars modifies the dict in-place."""
|
||||
monkeypatch.setenv("LITELLM_SALT_KEY", "test-salt-key-1234567890123456")
|
||||
metadata = _make_metadata({"langfuse_secret_key": "sk-lf-real"})
|
||||
returned = encrypt_logging_callback_vars(metadata)
|
||||
assert returned is metadata
|
||||
assert (
|
||||
metadata["logging"][0]["callback_vars"]["langfuse_secret_key"] != "sk-lf-real"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -9007,3 +9007,80 @@ async def test_execute_virtual_key_regeneration_cache_invalidation_with_token_ha
|
|||
call_kwargs = mock_delete_cache.call_args.kwargs
|
||||
# The token hash should be passed as-is, NOT double-hashed
|
||||
assert call_kwargs["hashed_token"] == token_hash
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# prepare_metadata_fields double-encryption guard tests
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_prepare_metadata_fields_no_double_encrypt_when_metadata_not_in_update(
|
||||
monkeypatch,
|
||||
):
|
||||
"""
|
||||
When metadata is NOT in the update request, existing_metadata (which may
|
||||
already have encrypted callback_vars) must be copied as-is.
|
||||
encrypt_logging_callback_vars must NOT be called.
|
||||
"""
|
||||
monkeypatch.setenv("LITELLM_SALT_KEY", "test-salt-key-1234567890123456")
|
||||
from litellm.proxy.management_endpoints.key_management_endpoints import (
|
||||
prepare_metadata_fields,
|
||||
)
|
||||
|
||||
already_encrypted = "someencryptedblob=="
|
||||
existing_metadata = {
|
||||
"logging": [
|
||||
{
|
||||
"callback_name": "langfuse",
|
||||
"callback_vars": {"langfuse_secret_key": already_encrypted},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
data = UpdateKeyRequest(key="sk-test")
|
||||
non_default_values: dict = {} # metadata NOT provided in update
|
||||
|
||||
result = prepare_metadata_fields(
|
||||
data=data,
|
||||
non_default_values=non_default_values,
|
||||
existing_metadata=existing_metadata,
|
||||
)
|
||||
|
||||
# Value must be unchanged — no second encryption pass
|
||||
assert (
|
||||
result["metadata"]["logging"][0]["callback_vars"]["langfuse_secret_key"]
|
||||
== already_encrypted
|
||||
)
|
||||
|
||||
|
||||
def test_prepare_metadata_fields_encrypts_when_metadata_in_update(monkeypatch):
|
||||
"""
|
||||
When metadata IS in the update request with fresh callback_vars, they must
|
||||
be encrypted.
|
||||
"""
|
||||
monkeypatch.setenv("LITELLM_SALT_KEY", "test-salt-key-1234567890123456")
|
||||
from litellm.proxy.management_endpoints.key_management_endpoints import (
|
||||
prepare_metadata_fields,
|
||||
)
|
||||
|
||||
plaintext = "sk-lf-supersecret"
|
||||
fresh_metadata = {
|
||||
"logging": [
|
||||
{
|
||||
"callback_name": "langfuse",
|
||||
"callback_vars": {"langfuse_secret_key": plaintext},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
data = UpdateKeyRequest(key="sk-test", metadata=fresh_metadata)
|
||||
non_default_values: dict = {"metadata": fresh_metadata} # metadata WAS provided
|
||||
|
||||
result = prepare_metadata_fields(
|
||||
data=data,
|
||||
non_default_values=non_default_values,
|
||||
existing_metadata={},
|
||||
)
|
||||
|
||||
encrypted = result["metadata"]["logging"][0]["callback_vars"]["langfuse_secret_key"]
|
||||
assert encrypted != plaintext, "plaintext credential must be encrypted on write"
|
||||
|
|
|
|||
|
|
@ -0,0 +1,186 @@
|
|||
import json
|
||||
import os
|
||||
import sys
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, os.path.abspath("../../../.."))
|
||||
|
||||
from litellm.proxy.management_endpoints.team_callback_endpoints import (
|
||||
add_team_callbacks,
|
||||
get_team_callbacks,
|
||||
)
|
||||
from litellm.proxy._types import AddTeamCallback, UserAPIKeyAuth
|
||||
|
||||
|
||||
def _make_team(metadata: dict) -> MagicMock:
|
||||
team = MagicMock()
|
||||
team.metadata = metadata
|
||||
return team
|
||||
|
||||
|
||||
def _make_request() -> MagicMock:
|
||||
req = MagicMock()
|
||||
req.headers = {}
|
||||
return req
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /team/{team_id}/callback — encrypt on write
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("litellm.proxy.proxy_server.prisma_client")
|
||||
async def test_add_team_callback_encrypts_callback_vars(mock_prisma, monkeypatch):
|
||||
"""callback_vars must be encrypted before writing to DB."""
|
||||
monkeypatch.setenv("LITELLM_SALT_KEY", "test-salt-key-1234567890123456")
|
||||
|
||||
plaintext_secret = "sk-lf-supersecret99"
|
||||
existing_team = _make_team({"logging": []})
|
||||
mock_prisma.get_data = AsyncMock(return_value=existing_team)
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_update(where, data):
|
||||
captured["data"] = data
|
||||
row = MagicMock()
|
||||
row.metadata = data.get("metadata", "{}")
|
||||
return row
|
||||
|
||||
mock_prisma.db = MagicMock()
|
||||
mock_prisma.db.litellm_teamtable = MagicMock()
|
||||
mock_prisma.db.litellm_teamtable.update = fake_update
|
||||
|
||||
data = AddTeamCallback(
|
||||
callback_name="langfuse",
|
||||
callback_type="success",
|
||||
callback_vars={"langfuse_secret_key": plaintext_secret},
|
||||
)
|
||||
|
||||
await add_team_callbacks(
|
||||
data=data,
|
||||
http_request=_make_request(),
|
||||
team_id="team-123",
|
||||
user_api_key_dict=UserAPIKeyAuth(),
|
||||
)
|
||||
|
||||
written_metadata = json.loads(captured["data"]["metadata"])
|
||||
stored_secret = written_metadata["logging"][0]["callback_vars"]["langfuse_secret_key"]
|
||||
assert stored_secret != plaintext_secret, "plaintext secret must be encrypted before DB write"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("litellm.proxy.proxy_server.prisma_client")
|
||||
async def test_add_second_callback_does_not_double_encrypt_first(mock_prisma, monkeypatch):
|
||||
"""Adding a second callback must not re-encrypt already-encrypted entries."""
|
||||
monkeypatch.setenv("LITELLM_SALT_KEY", "test-salt-key-1234567890123456")
|
||||
from litellm.proxy.common_utils.callback_utils import encrypt_logging_callback_vars
|
||||
from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value_helper
|
||||
|
||||
# Simulate a team whose first callback was already written (and encrypted)
|
||||
first_entry = {
|
||||
"callback_name": "langfuse",
|
||||
"callback_type": "success",
|
||||
"callback_vars": {"langfuse_secret_key": "sk-lf-firstsecret"},
|
||||
}
|
||||
encrypt_logging_callback_vars({"logging": [first_entry]})
|
||||
encrypted_first = first_entry["callback_vars"]["langfuse_secret_key"]
|
||||
|
||||
existing_team = _make_team({"logging": [first_entry]})
|
||||
mock_prisma.get_data = AsyncMock(return_value=existing_team)
|
||||
|
||||
captured = {}
|
||||
|
||||
async def fake_update(where, data):
|
||||
captured["data"] = data
|
||||
row = MagicMock()
|
||||
row.metadata = data.get("metadata", "{}")
|
||||
return row
|
||||
|
||||
mock_prisma.db = MagicMock()
|
||||
mock_prisma.db.litellm_teamtable = MagicMock()
|
||||
mock_prisma.db.litellm_teamtable.update = fake_update
|
||||
|
||||
data = AddTeamCallback(
|
||||
callback_name="langsmith",
|
||||
callback_type="success",
|
||||
callback_vars={"langsmith_api_key": "ls-secondsecret"},
|
||||
)
|
||||
|
||||
await add_team_callbacks(
|
||||
data=data,
|
||||
http_request=_make_request(),
|
||||
team_id="team-123",
|
||||
user_api_key_dict=UserAPIKeyAuth(),
|
||||
)
|
||||
|
||||
written_metadata = json.loads(captured["data"]["metadata"])
|
||||
# First entry must be unchanged (not re-encrypted)
|
||||
assert written_metadata["logging"][0]["callback_vars"]["langfuse_secret_key"] == encrypted_first
|
||||
# First entry must still decrypt correctly
|
||||
recovered = decrypt_value_helper(value=encrypted_first, key="langfuse_secret_key", return_original_value=True)
|
||||
assert recovered == "sk-lf-firstsecret"
|
||||
# Second entry must be encrypted
|
||||
stored_second = written_metadata["logging"][1]["callback_vars"]["langsmith_api_key"]
|
||||
assert stored_second != "ls-secondsecret"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GET /team/{team_id}/callback — redact on read
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("litellm.proxy.proxy_server.prisma_client")
|
||||
async def test_get_team_callbacks_redacts_callback_vars(mock_prisma, monkeypatch):
|
||||
"""callback_vars must be redacted (last-3-chars) in the GET response."""
|
||||
monkeypatch.setenv("LITELLM_SALT_KEY", "test-salt-key-1234567890123456")
|
||||
|
||||
existing_team = _make_team(
|
||||
{
|
||||
"callback_settings": {
|
||||
"success_callback": ["langfuse"],
|
||||
"callback_vars": {"langfuse_secret_key": "sk-lf-supersecret99"},
|
||||
}
|
||||
}
|
||||
)
|
||||
mock_prisma.get_data = AsyncMock(return_value=existing_team)
|
||||
|
||||
result = await get_team_callbacks(
|
||||
http_request=_make_request(),
|
||||
team_id="team-123",
|
||||
user_api_key_dict=UserAPIKeyAuth(),
|
||||
)
|
||||
|
||||
secret = result["data"]["callback_vars"]["langfuse_secret_key"]
|
||||
assert secret != "sk-lf-supersecret99", "plaintext secret must not be returned"
|
||||
assert secret.startswith("..."), "redacted value must use ...XYZ format"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@patch("litellm.proxy.proxy_server.prisma_client")
|
||||
async def test_get_team_callbacks_keeps_env_var_pointers(mock_prisma):
|
||||
"""os.environ/ references must pass through unredacted."""
|
||||
existing_team = _make_team(
|
||||
{
|
||||
"callback_settings": {
|
||||
"callback_vars": {
|
||||
"langfuse_secret_key": "os.environ/LANGFUSE_SECRET_KEY"
|
||||
},
|
||||
}
|
||||
}
|
||||
)
|
||||
mock_prisma.get_data = AsyncMock(return_value=existing_team)
|
||||
|
||||
result = await get_team_callbacks(
|
||||
http_request=_make_request(),
|
||||
team_id="team-123",
|
||||
user_api_key_dict=UserAPIKeyAuth(),
|
||||
)
|
||||
|
||||
assert (
|
||||
result["data"]["callback_vars"]["langfuse_secret_key"]
|
||||
== "os.environ/LANGFUSE_SECRET_KEY"
|
||||
)
|
||||
Loading…
Add table
Reference in a new issue