diff --git a/litellm/proxy/common_utils/callback_utils.py b/litellm/proxy/common_utils/callback_utils.py index a206be87a11..02571298db0 100644 --- a/litellm/proxy/common_utils/callback_utils.py +++ b/litellm/proxy/common_utils/callback_utils.py @@ -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 diff --git a/litellm/proxy/litellm_pre_call_utils.py b/litellm/proxy/litellm_pre_call_utils.py index b0adf7aa6ee..ec335618a48 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 @@ -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 diff --git a/litellm/proxy/management_endpoints/key_management_endpoints.py b/litellm/proxy/management_endpoints/key_management_endpoints.py index 028b765e021..5c0a3e014a9 100644 --- a/litellm/proxy/management_endpoints/key_management_endpoints.py +++ b/litellm/proxy/management_endpoints/key_management_endpoints.py @@ -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)) diff --git a/litellm/proxy/management_endpoints/team_callback_endpoints.py b/litellm/proxy/management_endpoints/team_callback_endpoints.py index 4eec7c6b7c0..3b1e8995cb3 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() @@ -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, }, } diff --git a/litellm/proxy/management_endpoints/team_endpoints.py b/litellm/proxy/management_endpoints/team_endpoints.py index 377adb216c2..94893bc9ddb 100644 --- a/litellm/proxy/management_endpoints/team_endpoints.py +++ b/litellm/proxy/management_endpoints/team_endpoints.py @@ -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 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 27528fbd20b..9ae41b84231 100644 --- a/tests/test_litellm/proxy/common_utils/test_callback_utils.py +++ b/tests/test_litellm/proxy/common_utils/test_callback_utils.py @@ -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" + ) 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 6f56b5ca18b..eedeaf656b9 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 @@ -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" 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..60613aa387a --- /dev/null +++ b/tests/test_litellm/proxy/management_endpoints/test_team_callback_endpoints.py @@ -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" + )