From f155385e3e2d3c72f1498c8af6a57a497dd0dcb6 Mon Sep 17 00:00:00 2001 From: Josh Date: Mon, 13 Apr 2026 09:14:00 -0400 Subject: [PATCH 1/9] feat(security): add redact_sensitive_logging_metadata helper --- litellm/proxy/common_utils/callback_utils.py | 34 ++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/litellm/proxy/common_utils/callback_utils.py b/litellm/proxy/common_utils/callback_utils.py index 9ecae363ed7..a66a5e30e75 100644 --- a/litellm/proxy/common_utils/callback_utils.py +++ b/litellm/proxy/common_utils/callback_utils.py @@ -1,3 +1,4 @@ +import copy from typing import TYPE_CHECKING, Any, Dict, Iterable, List, Literal, Optional import litellm @@ -524,3 +525,36 @@ 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 redact_sensitive_logging_metadata(metadata: Optional[Dict]) -> Optional[Dict]: + """ + Return a copy of `metadata` with credential values inside + `metadata["logging"][*]["callback_vars"]` replaced by "***". + + Values that are just environment-variable references + (e.g. "os.environ/LANGFUSE_SECRET_KEY") are left as-is because they + don't expose the actual secret — they're just pointers. + """ + 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(): + # Keep env-var pointers; scrub anything that looks like a real secret + if isinstance(value, str) and value.startswith("os.environ/"): + continue + callback_vars[key] = "***" + + return metadata From e104f4296523f0b0fbd6f1912f29bbbb8fdf0070 Mon Sep 17 00:00:00 2001 From: Josh Date: Mon, 13 Apr 2026 10:03:00 -0400 Subject: [PATCH 2/9] fix(security): redact logging callback_vars in key info/list endpoints --- .../key_management_endpoints.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index f69d9d2f8d4..e988b2b3b69 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -49,6 +49,7 @@ 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 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 ( @@ -2708,6 +2709,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} @@ -2791,6 +2795,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) @@ -4934,6 +4944,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)) From 14f9c79cc42567a42545e09219b065440dd90999 Mon Sep 17 00:00:00 2001 From: Josh Date: Mon, 13 Apr 2026 10:47:00 -0400 Subject: [PATCH 3/9] fix(security): redact logging callback_vars in team info/list endpoints --- .../proxy/management_endpoints/team_endpoints.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 138469312e1..89cbd7b4877 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -69,6 +69,7 @@ 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 redact_sensitive_logging_metadata from litellm.proxy.management_endpoints.common_utils import ( _is_user_org_admin_for_team, _is_user_team_admin, @@ -3154,6 +3155,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( @@ -3183,6 +3185,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, @@ -3535,6 +3541,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: @@ -3959,9 +3968,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, ) From 251023b5e0de493a4a3d296cf2e23be9c70c4b2d Mon Sep 17 00:00:00 2001 From: Josh Date: Mon, 13 Apr 2026 11:22:00 -0400 Subject: [PATCH 4/9] test(security): add coverage for redact_sensitive_logging_metadata --- .../proxy/common_utils/test_callback_utils.py | 92 +++++++++++++++++++ 1 file changed, 92 insertions(+) diff --git a/tests/test_litellm/proxy/common_utils/test_callback_utils.py b/tests/test_litellm/proxy/common_utils/test_callback_utils.py index 985e8d20be7..50758b87150 100644 --- a/tests/test_litellm/proxy/common_utils/test_callback_utils.py +++ b/tests/test_litellm/proxy/common_utils/test_callback_utils.py @@ -8,6 +8,7 @@ sys.path.insert( from litellm.proxy.common_utils.callback_utils import ( get_remaining_tokens_and_requests_from_request_data, normalize_callback_names, + redact_sensitive_logging_metadata, ) from unittest.mock import patch @@ -81,3 +82,94 @@ def test_normalize_callback_names_none_returns_empty_list(): def test_normalize_callback_names_lowercases_strings(): assert normalize_callback_names(["SQS", "S3", "CUSTOM_CALLBACK"]) == ["sqs", "s3", "custom_callback"] + +# --------------------------------------------------------------------------- +# redact_sensitive_logging_metadata tests +# --------------------------------------------------------------------------- + + +def test_redact_scrubs_real_secret_values(): + """Real credential values must be replaced with '***'.""" + 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"] == "***" + assert vars_["langfuse_secret_key"] == "***" + assert vars_["langfuse_host"] == "***" + + +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"] == "***" + From 7add825f38d15074c48b3d570328491402ace3ee Mon Sep 17 00:00:00 2001 From: Josh Date: Wed, 15 Apr 2026 15:11:37 -0400 Subject: [PATCH 5/9] feat(security): encrypt logging callback_vars before DB write --- litellm/proxy/common_utils/callback_utils.py | 56 ++++++++++++--- litellm/proxy/litellm_pre_call_utils.py | 50 ++++++++----- .../key_management_endpoints.py | 45 ++++++------ .../management_endpoints/team_endpoints.py | 70 ++++++++++--------- 4 files changed, 140 insertions(+), 81 deletions(-) diff --git a/litellm/proxy/common_utils/callback_utils.py b/litellm/proxy/common_utils/callback_utils.py index a66a5e30e75..421e4defb59 100644 --- a/litellm/proxy/common_utils/callback_utils.py +++ b/litellm/proxy/common_utils/callback_utils.py @@ -363,17 +363,17 @@ def get_remaining_tokens_and_requests_from_request_data(data: Dict) -> Dict[str, remaining_requests_variable_name = f"litellm-key-remaining-requests-{model_group}" remaining_requests = _metadata.get(remaining_requests_variable_name, None) if remaining_requests: - headers[ - f"x-litellm-key-remaining-requests-{h11_model_group_name}" - ] = remaining_requests + headers[f"x-litellm-key-remaining-requests-{h11_model_group_name}"] = ( + remaining_requests + ) # Remaining Tokens remaining_tokens_variable_name = f"litellm-key-remaining-tokens-{model_group}" remaining_tokens = _metadata.get(remaining_tokens_variable_name, None) if remaining_tokens: - headers[ - f"x-litellm-key-remaining-tokens-{h11_model_group_name}" - ] = remaining_tokens + headers[f"x-litellm-key-remaining-tokens-{h11_model_group_name}"] = ( + remaining_tokens + ) return headers @@ -473,9 +473,9 @@ def add_guardrail_response_to_standard_logging_object( ): if litellm_logging_obj is None: return - standard_logging_object: Optional[ - StandardLoggingPayload - ] = litellm_logging_obj.model_call_details.get("standard_logging_object") + standard_logging_object: Optional[StandardLoggingPayload] = ( + litellm_logging_obj.model_call_details.get("standard_logging_object") + ) if standard_logging_object is None: return guardrail_information = standard_logging_object.get("guardrail_information", []) @@ -527,6 +527,44 @@ def normalize_callback_names(callbacks: Iterable[Any]) -> List[Any]: 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 + + from litellm.proxy.common_utils.encrypt_decrypt_utils import encrypt_value_helper + + 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 diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index 4ec31925ea2..9b7ad9fbaed 100644 --- a/litellm/proxy/litellm_pre_call_utils.py +++ b/litellm/proxy/litellm_pre_call_utils.py @@ -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 @@ -43,6 +44,8 @@ def _sanitize_for_log(value: Any) -> str: text = repr(value) # Strip CR/LF characters commonly used for log injection return text.replace("\r", "").replace("\n", "") + + from litellm.router import Router from litellm.secret_managers.main import get_secret_bool from litellm.types.llms.anthropic import ANTHROPIC_API_HEADERS @@ -185,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 @@ -220,12 +232,12 @@ def _get_dynamic_logging_metadata( user_api_key_dict: UserAPIKeyAuth, proxy_config: ProxyConfig ) -> Optional[TeamCallbackMetadata]: callback_settings_obj: Optional[TeamCallbackMetadata] = None - key_dynamic_logging_settings: Optional[ - dict - ] = KeyAndTeamLoggingSettings.get_key_dynamic_logging_settings(user_api_key_dict) - team_dynamic_logging_settings: Optional[ - dict - ] = KeyAndTeamLoggingSettings.get_team_dynamic_logging_settings(user_api_key_dict) + key_dynamic_logging_settings: Optional[dict] = ( + KeyAndTeamLoggingSettings.get_key_dynamic_logging_settings(user_api_key_dict) + ) + team_dynamic_logging_settings: Optional[dict] = ( + KeyAndTeamLoggingSettings.get_team_dynamic_logging_settings(user_api_key_dict) + ) ######################################################################################### # Key-based callbacks ######################################################################################### @@ -779,11 +791,11 @@ class LiteLLMProxyRequestSetup: ## KEY-LEVEL SPEND LOGS / TAGS if "tags" in key_metadata and key_metadata["tags"] is not None: - data[_metadata_variable_name][ - "tags" - ] = LiteLLMProxyRequestSetup._merge_tags( - request_tags=data[_metadata_variable_name].get("tags"), - tags_to_add=key_metadata["tags"], + data[_metadata_variable_name]["tags"] = ( + LiteLLMProxyRequestSetup._merge_tags( + request_tags=data[_metadata_variable_name].get("tags"), + tags_to_add=key_metadata["tags"], + ) ) if "disable_global_guardrails" in key_metadata and isinstance( key_metadata["disable_global_guardrails"], bool @@ -1079,9 +1091,9 @@ async def add_litellm_data_to_request( # noqa: PLR0915 data[_metadata_variable_name]["litellm_api_version"] = version if general_settings is not None: - data[_metadata_variable_name][ - "global_max_parallel_requests" - ] = general_settings.get("global_max_parallel_requests", None) + data[_metadata_variable_name]["global_max_parallel_requests"] = ( + general_settings.get("global_max_parallel_requests", None) + ) ### KEY-LEVEL Controls key_metadata = user_api_key_dict.metadata @@ -1881,7 +1893,9 @@ async def move_guardrails_to_metadata( ) # Only check policy engine if no local config (avoid import + registry lookup) - if not (has_key_config or has_team_config or has_project_config or has_request_config): + if not ( + has_key_config or has_team_config or has_project_config or has_request_config + ): from litellm.proxy.policy_engine.policy_registry import get_policy_registry if not get_policy_registry().is_initialized(): diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index e988b2b3b69..db402312d96 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -49,7 +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 redact_sensitive_logging_metadata +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 ( @@ -769,9 +772,9 @@ async def _common_key_generation_helper( # noqa: PLR0915 request_type="key", **data_json, table_name="key" ) - response[ - "soft_budget" - ] = data.soft_budget # include the user-input soft budget in the response + response["soft_budget"] = ( + data.soft_budget + ) # include the user-input soft budget in the response response = GenerateKeyResponse(**response) @@ -1541,6 +1544,7 @@ def prepare_metadata_fields( ) non_default_values["metadata"] = casted_metadata + encrypt_logging_callback_vars(non_default_values["metadata"]) return non_default_values @@ -2952,6 +2956,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) @@ -3287,10 +3292,10 @@ async def delete_verification_tokens( try: if prisma_client: tokens = [_hash_token_if_needed(token=key) for key in tokens] - _keys_being_deleted: List[ - LiteLLM_VerificationToken - ] = await prisma_client.db.litellm_verificationtoken.find_many( - where={"token": {"in": tokens}} + _keys_being_deleted: List[LiteLLM_VerificationToken] = ( + await prisma_client.db.litellm_verificationtoken.find_many( + where={"token": {"in": tokens}} + ) ) if len(_keys_being_deleted) == 0: @@ -3490,9 +3495,9 @@ async def _rotate_master_key( # noqa: PLR0915 from litellm.proxy.proxy_server import proxy_config try: - models: Optional[ - List - ] = await prisma_client.db.litellm_proxymodeltable.find_many() + models: Optional[List] = ( + await prisma_client.db.litellm_proxymodeltable.find_many() + ) except Exception: models = None # 2. process model table @@ -4132,11 +4137,11 @@ async def validate_key_list_check( param="user_id", code=status.HTTP_403_FORBIDDEN, ) - complete_user_info_db_obj: Optional[ - BaseModel - ] = await prisma_client.db.litellm_usertable.find_unique( - where={"user_id": user_api_key_dict.user_id}, - include={"organization_memberships": True}, + complete_user_info_db_obj: Optional[BaseModel] = ( + await prisma_client.db.litellm_usertable.find_unique( + where={"user_id": user_api_key_dict.user_id}, + include={"organization_memberships": True}, + ) ) if complete_user_info_db_obj is None: @@ -4219,10 +4224,10 @@ async def _fetch_user_team_objects( if complete_user_info is None or not complete_user_info.teams: return [] - teams: Optional[ - List[BaseModel] - ] = await prisma_client.db.litellm_teamtable.find_many( - where={"team_id": {"in": complete_user_info.teams}} + teams: Optional[List[BaseModel]] = ( + await prisma_client.db.litellm_teamtable.find_many( + where={"team_id": {"in": complete_user_info.teams}} + ) ) if teams is None: return [] diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 89cbd7b4877..93d78c25d6d 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -69,7 +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 redact_sensitive_logging_metadata +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, @@ -121,6 +124,7 @@ def _sanitize_for_log(value: Any) -> str: text = repr(value) return text.replace("\r", "").replace("\n", "") + async def _verify_team_access( team_obj: LiteLLM_TeamTable, user_api_key_dict: UserAPIKeyAuth, @@ -315,10 +319,8 @@ class TeamMemberBudgetHandler: return # Batch-fetch existing memberships for this team (avoids N+1 queries) - existing_memberships = ( - await prisma_client.db.litellm_teammembership.find_many( - where={"team_id": team_id} - ) + existing_memberships = await prisma_client.db.litellm_teammembership.find_many( + where={"team_id": team_id} ) existing_user_ids = {m.user_id for m in existing_memberships} @@ -1082,6 +1084,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) @@ -1639,6 +1642,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") @@ -1660,12 +1664,12 @@ async def update_team( # noqa: PLR0915 updated_kv["router_settings"] = safe_dumps(updated_kv["router_settings"]) updated_kv = prisma_client.jsonify_team_object(db_data=updated_kv) - team_row: Optional[ - LiteLLM_TeamTable - ] = await prisma_client.db.litellm_teamtable.update( - where={"team_id": data.team_id}, - data=updated_kv, - include={"litellm_model_table": True}, # type: ignore + team_row: Optional[LiteLLM_TeamTable] = ( + await prisma_client.db.litellm_teamtable.update( + where={"team_id": data.team_id}, + data=updated_kv, + include={"litellm_model_table": True}, # type: ignore + ) ) if team_row is None or team_row.team_id is None: @@ -2412,13 +2416,13 @@ async def team_member_delete( ) # Fetch keys before deletion to persist them - keys_to_delete: List[ - LiteLLM_VerificationToken - ] = await prisma_client.db.litellm_verificationtoken.find_many( - where={ - "user_id": {"in": list(user_ids_to_delete)}, - "team_id": data.team_id, - } + keys_to_delete: List[LiteLLM_VerificationToken] = ( + await prisma_client.db.litellm_verificationtoken.find_many( + where={ + "user_id": {"in": list(user_ids_to_delete)}, + "team_id": data.team_id, + } + ) ) if keys_to_delete: @@ -2802,10 +2806,10 @@ async def delete_team( team_rows: List[LiteLLM_TeamTable] = [] for team_id in data.team_ids: try: - team_row_base: Optional[ - BaseModel - ] = await prisma_client.db.litellm_teamtable.find_unique( - where={"team_id": team_id} + team_row_base: Optional[BaseModel] = ( + await prisma_client.db.litellm_teamtable.find_unique( + where={"team_id": team_id} + ) ) if team_row_base is None: raise Exception @@ -2871,10 +2875,10 @@ async def delete_team( _persist_deleted_verification_tokens, ) - keys_to_delete: List[ - LiteLLM_VerificationToken - ] = await prisma_client.db.litellm_verificationtoken.find_many( - where={"team_id": {"in": data.team_ids}} + keys_to_delete: List[LiteLLM_VerificationToken] = ( + await prisma_client.db.litellm_verificationtoken.find_many( + where={"team_id": {"in": data.team_ids}} + ) ) if keys_to_delete: @@ -3111,11 +3115,11 @@ async def team_info( ) try: - team_info: Optional[ - BaseModel - ] = await prisma_client.db.litellm_teamtable.find_unique( - where={"team_id": team_id}, - include={"object_permission": True}, + team_info: Optional[BaseModel] = ( + await prisma_client.db.litellm_teamtable.find_unique( + where={"team_id": team_id}, + include={"object_permission": True}, + ) ) if team_info is None: raise Exception @@ -3982,9 +3986,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 From 47d90255ba3bf470616e1262aa6493d4c6cd9616 Mon Sep 17 00:00:00 2001 From: Josh Date: Wed, 15 Apr 2026 16:23:09 -0400 Subject: [PATCH 6/9] fix(security): decrypt callback_vars on use, add roundtrip tests --- .../proxy/common_utils/test_callback_utils.py | 85 ++++++++++++++++++- 1 file changed, 83 insertions(+), 2 deletions(-) diff --git a/tests/test_litellm/proxy/common_utils/test_callback_utils.py b/tests/test_litellm/proxy/common_utils/test_callback_utils.py index 50758b87150..f2e9b61a8c5 100644 --- a/tests/test_litellm/proxy/common_utils/test_callback_utils.py +++ b/tests/test_litellm/proxy/common_utils/test_callback_utils.py @@ -6,6 +6,7 @@ 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, @@ -80,7 +81,11 @@ def test_normalize_callback_names_none_returns_empty_list(): def test_normalize_callback_names_lowercases_strings(): - assert normalize_callback_names(["SQS", "S3", "CUSTOM_CALLBACK"]) == ["sqs", "s3", "custom_callback"] + assert normalize_callback_names(["SQS", "S3", "CUSTOM_CALLBACK"]) == [ + "sqs", + "s3", + "custom_callback", + ] # --------------------------------------------------------------------------- @@ -141,7 +146,9 @@ def test_redact_does_not_mutate_original(): } 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 + assert ( + metadata["logging"][0]["callback_vars"]["langfuse_secret_key"] == original_value + ) def test_redact_returns_none_for_none_input(): @@ -173,3 +180,77 @@ def test_redact_mixed_env_and_real_values(): assert vars_["langfuse_public_key"] == "os.environ/LANGFUSE_PUBLIC_KEY" assert vars_["langfuse_secret_key"] == "***" + +# --------------------------------------------------------------------------- +# 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" + ) From 74a7b9c9a202b2754c692bcdbbdbf32716fdc358 Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 17 Apr 2026 15:10:03 -0400 Subject: [PATCH 7/9] fix(security): guard against double-encryption on key update --- litellm/proxy/common_utils/callback_utils.py | 38 ++++++--- .../key_management_endpoints.py | 4 +- .../proxy/common_utils/test_callback_utils.py | 10 +-- .../test_key_management_endpoints.py | 77 +++++++++++++++++++ 4 files changed, 114 insertions(+), 15 deletions(-) diff --git a/litellm/proxy/common_utils/callback_utils.py b/litellm/proxy/common_utils/callback_utils.py index 421e4defb59..02571298db0 100644 --- a/litellm/proxy/common_utils/callback_utils.py +++ b/litellm/proxy/common_utils/callback_utils.py @@ -1,6 +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 @@ -546,8 +551,6 @@ def encrypt_logging_callback_vars(metadata: Optional[Dict]) -> Optional[Dict]: if not isinstance(logging_configs, list): return metadata - from litellm.proxy.common_utils.encrypt_decrypt_utils import encrypt_value_helper - for entry in logging_configs: if not isinstance(entry, dict): continue @@ -568,11 +571,14 @@ def encrypt_logging_callback_vars(metadata: Optional[Dict]) -> Optional[Dict]: def redact_sensitive_logging_metadata(metadata: Optional[Dict]) -> Optional[Dict]: """ Return a copy of `metadata` with credential values inside - `metadata["logging"][*]["callback_vars"]` replaced by "***". + `metadata["logging"][*]["callback_vars"]` partially masked. - Values that are just environment-variable references - (e.g. "os.environ/LANGFUSE_SECRET_KEY") are left as-is because they - don't expose the actual secret — they're just pointers. + 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 @@ -590,9 +596,23 @@ def redact_sensitive_logging_metadata(metadata: Optional[Dict]) -> Optional[Dict if not isinstance(callback_vars, dict): continue for key, value in callback_vars.items(): - # Keep env-var pointers; scrub anything that looks like a real secret - if isinstance(value, str) and value.startswith("os.environ/"): + if not isinstance(value, str): + callback_vars[key] = "***" continue - callback_vars[key] = "***" + # 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 diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index db402312d96..b9c8e39a994 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -1516,6 +1516,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() @@ -1544,7 +1545,8 @@ def prepare_metadata_fields( ) non_default_values["metadata"] = casted_metadata - encrypt_logging_callback_vars(non_default_values["metadata"]) + if metadata_provided_in_update and "logging" in casted_metadata: + encrypt_logging_callback_vars(non_default_values["metadata"]) return non_default_values diff --git a/tests/test_litellm/proxy/common_utils/test_callback_utils.py b/tests/test_litellm/proxy/common_utils/test_callback_utils.py index f2e9b61a8c5..9ae41b84231 100644 --- a/tests/test_litellm/proxy/common_utils/test_callback_utils.py +++ b/tests/test_litellm/proxy/common_utils/test_callback_utils.py @@ -94,7 +94,7 @@ def test_normalize_callback_names_lowercases_strings(): def test_redact_scrubs_real_secret_values(): - """Real credential values must be replaced with '***'.""" + """Real credential values must be partially masked showing last 3 chars.""" metadata = { "logging": [ { @@ -110,9 +110,9 @@ def test_redact_scrubs_real_secret_values(): } result = redact_sensitive_logging_metadata(metadata) vars_ = result["logging"][0]["callback_vars"] - assert vars_["langfuse_public_key"] == "***" - assert vars_["langfuse_secret_key"] == "***" - assert vars_["langfuse_host"] == "***" + assert vars_["langfuse_public_key"] == "...123" + assert vars_["langfuse_secret_key"] == "...ret" + assert vars_["langfuse_host"] == "...com" def test_redact_keeps_env_var_references(): @@ -178,7 +178,7 @@ def test_redact_mixed_env_and_real_values(): 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"] == "***" + assert vars_["langfuse_secret_key"] == "...ret" # --------------------------------------------------------------------------- diff --git a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py index 479defbff5c..eed67ec10d5 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_key_management_endpoints.py @@ -8922,3 +8922,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" From 844c43bb635e6f6b791e4505f07d7852ef930ef3 Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 17 Apr 2026 17:26:16 -0400 Subject: [PATCH 8/9] fix(security): encrypt callback_vars on POST and redact on GET in team callback endpoint --- .../team_callback_endpoints.py | 16 ++- .../test_team_callback_endpoints.py | 130 ++++++++++++++++++ 2 files changed, 145 insertions(+), 1 deletion(-) create mode 100644 tests/test_litellm/proxy/management_endpoints/test_team_callback_endpoints.py diff --git a/litellm/proxy/management_endpoints/team_callback_endpoints.py b/litellm/proxy/management_endpoints/team_callback_endpoints.py index 4eec7c6b7c0..d36be2f5d8e 100644 --- a/litellm/proxy/management_endpoints/team_callback_endpoints.py +++ b/litellm/proxy/management_endpoints/team_callback_endpoints.py @@ -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() @@ -126,6 +130,7 @@ async def add_team_callbacks( team_callback_settings.append(data.model_dump()) team_metadata["logging"] = team_callback_settings + encrypt_logging_callback_vars(team_metadata) team_metadata_json = json.dumps(team_metadata) # update team_metadata new_team_row = await prisma_client.db.litellm_teamtable.update( @@ -312,13 +317,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, }, } diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_callback_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_callback_endpoints.py new file mode 100644 index 00000000000..cd515903452 --- /dev/null +++ b/tests/test_litellm/proxy/management_endpoints/test_team_callback_endpoints.py @@ -0,0 +1,130 @@ +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" + + +# --------------------------------------------------------------------------- +# 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" + ) From ea153d2c5fb1c6f2c142cd9f4b60df8ea67af2bc Mon Sep 17 00:00:00 2001 From: Josh Date: Fri, 17 Apr 2026 18:08:34 -0400 Subject: [PATCH 9/9] fix(security): encrypt only new callback entry, not existing encrypted entries --- .../team_callback_endpoints.py | 5 +- .../test_team_callback_endpoints.py | 56 +++++++++++++++++++ 2 files changed, 59 insertions(+), 2 deletions(-) diff --git a/litellm/proxy/management_endpoints/team_callback_endpoints.py b/litellm/proxy/management_endpoints/team_callback_endpoints.py index d36be2f5d8e..3b1e8995cb3 100644 --- a/litellm/proxy/management_endpoints/team_callback_endpoints.py +++ b/litellm/proxy/management_endpoints/team_callback_endpoints.py @@ -127,10 +127,11 @@ 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 - encrypt_logging_callback_vars(team_metadata) team_metadata_json = json.dumps(team_metadata) # update team_metadata new_team_row = await prisma_client.db.litellm_teamtable.update( diff --git a/tests/test_litellm/proxy/management_endpoints/test_team_callback_endpoints.py b/tests/test_litellm/proxy/management_endpoints/test_team_callback_endpoints.py index cd515903452..60613aa387a 100644 --- a/tests/test_litellm/proxy/management_endpoints/test_team_callback_endpoints.py +++ b/tests/test_litellm/proxy/management_endpoints/test_team_callback_endpoints.py @@ -71,6 +71,62 @@ async def test_add_team_callback_encrypts_callback_vars(mock_prisma, monkeypatch 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 # ---------------------------------------------------------------------------