diff --git a/litellm/litellm_core_utils/credential_accessor.py b/litellm/litellm_core_utils/credential_accessor.py index 95e36a64ef7..c61d4353c3a 100644 --- a/litellm/litellm_core_utils/credential_accessor.py +++ b/litellm/litellm_core_utils/credential_accessor.py @@ -1,10 +1,51 @@ """Utils for accessing credentials.""" +from collections.abc import Mapping from typing import Final import litellm +from litellm._logging import verbose_logger +from litellm.secret_managers.main import get_secret_str +from litellm.types.router import holds_secret_pointer from litellm.types.utils import CredentialItem +_ENV_REF_PREFIX: Final = "os.environ/" + + +def _resolved_credential_value(credential_name: str, key: str, value: object) -> object: + """The environment variable's value for an ``os.environ/`` reference, or the value itself. + + An unresolvable reference is kept and logged rather than dropped, so a provider with optional + params cannot silently fall back to another account's ambient credentials. ``*_ref`` federation + fields are pointers their identity source dereferences at use time, so they stay as written. + """ + if not isinstance(value, str) or not value.startswith(_ENV_REF_PREFIX): + return value + if holds_secret_pointer(key): + return value + resolved: Final = get_secret_str(value) + if resolved is not None: + return resolved + verbose_logger.warning( + "credential '%s' value '%s' references unset environment variable %s; keeping the reference", + credential_name, + key, + value, + ) + return value + + +def expand_credential_env_refs( + credential_name: str, values: Mapping[str, object] +) -> dict[str, object]: # mutable-ok: CredentialItem.credential_values is a mutable dict by contract + """Resolve ``os.environ/`` values against the environment. + + Mirrors the config loader: references resolve once, when the credential enters the process, so + runtime reads stay plain and a rotated env var needs the credential to be re-saved or the proxy + restarted. + """ + return {key: _resolved_credential_value(credential_name, key, value) for key, value in values.items()} + class CredentialAccessor: @staticmethod @@ -27,6 +68,11 @@ class CredentialAccessor: def upsert_credentials(credentials: list[CredentialItem]): """Add a credential to the list of credentials.""" + for credential in credentials: + credential.credential_values = expand_credential_env_refs( + credential.credential_name, credential.credential_values + ) + credential_names: Final = [cred.credential_name for cred in litellm.credential_list] for credential in credentials: diff --git a/litellm/proxy/common_utils/credential_hydration.py b/litellm/proxy/common_utils/credential_hydration.py index a4e858692e6..43651654d0b 100644 --- a/litellm/proxy/common_utils/credential_hydration.py +++ b/litellm/proxy/common_utils/credential_hydration.py @@ -12,6 +12,7 @@ from types import MappingProxyType from typing import Final, cast # noqa: TID251 # narrows untyped credential values at the decrypt boundary import litellm +from litellm.litellm_core_utils.credential_accessor import expand_credential_env_refs from litellm.proxy.common_utils.encrypt_decrypt_utils import decrypt_value_helper from litellm.proxy.utils import PrismaClient from litellm.repositories.credentials_repository import CredentialsRepository @@ -78,7 +79,7 @@ def _decrypted(db_credential: CredentialItem) -> CredentialItem: ) return CredentialItem( credential_name=db_credential.credential_name, - credential_values=decrypted_values, # pyright: ignore[reportArgumentType] # declared dict[str, str], and pydantic copies this mapping into one on validation; LIT002 rules out building that dict here + credential_values=expand_credential_env_refs(db_credential.credential_name, decrypted_values), # pyright: ignore[reportArgumentType] # declared dict[str, str], and pydantic copies this mapping into one on validation; LIT002 rules out building that dict here credential_info=db_credential.credential_info, ) diff --git a/tests/unit/litellm_core_utils/test_credential_accessor.py b/tests/unit/litellm_core_utils/test_credential_accessor.py new file mode 100644 index 00000000000..ef06b96b5e4 --- /dev/null +++ b/tests/unit/litellm_core_utils/test_credential_accessor.py @@ -0,0 +1,71 @@ +from typing import Final +from unittest.mock import patch + +import litellm +from litellm.litellm_core_utils.credential_accessor import ( + CredentialAccessor, + expand_credential_env_refs, +) +from litellm.models.credentials import CredentialItem + +CREDENTIAL_NAME: Final = "bedrock-account-b" + + +def _credential(values: dict[str, object]) -> CredentialItem: + return CredentialItem( + credential_name=CREDENTIAL_NAME, + credential_values=values, + credential_info={"custom_llm_provider": "bedrock"}, + ) + + +def test_upsert_resolves_env_refs_so_deployments_read_effective_values(monkeypatch): + monkeypatch.setenv("AWS_ACCESS_KEY_ID_CRED_TEST", "ak-account-b") + with patch.object(litellm, "credential_list", []): # test-quality-ok: isolate the global list + CredentialAccessor.upsert_credentials( + [ + _credential( + { + "aws_access_key_id": "os.environ/AWS_ACCESS_KEY_ID_CRED_TEST", + "aws_region_name": "us-east-1", + } + ) + ] + ) + + assert CredentialAccessor.get_credential_values(CREDENTIAL_NAME) == { + "aws_access_key_id": "ak-account-b", + "aws_region_name": "us-east-1", + } + + +def test_upsert_keeps_the_reference_when_the_variable_is_unset(monkeypatch): + monkeypatch.delenv("AWS_SECRET_ACCESS_KEY_CRED_TEST", raising=False) + with patch.object(litellm, "credential_list", []): # test-quality-ok: isolate the global list + CredentialAccessor.upsert_credentials( + [_credential({"aws_secret_access_key": "os.environ/AWS_SECRET_ACCESS_KEY_CRED_TEST"})] + ) + + assert CredentialAccessor.get_credential_values(CREDENTIAL_NAME) == { + "aws_secret_access_key": "os.environ/AWS_SECRET_ACCESS_KEY_CRED_TEST" + } + + +def test_expand_leaves_non_reference_values_alone(): + values: Final = { + "aws_region_name": "us-east-1", + "aws_session_tags": ["team", "prod"], + "max_retries": 4, + } + + assert expand_credential_env_refs(CREDENTIAL_NAME, values) == values + + +def test_expand_leaves_federation_pointer_fields_as_written(monkeypatch): + monkeypatch.setenv("JWKS_CRED_TEST_SIGNING_KEY", "pem-bytes") + values: Final = { + "anthropic_identity_source": "internal_issuer", + "anthropic_issuer_signing_key_ref": "os.environ/JWKS_CRED_TEST_SIGNING_KEY", + } + + assert expand_credential_env_refs(CREDENTIAL_NAME, values) == values diff --git a/tests/unit/proxy/common_utils/test_credential_hydration.py b/tests/unit/proxy/common_utils/test_credential_hydration.py index f40b0114d71..356cd39c1d3 100644 --- a/tests/unit/proxy/common_utils/test_credential_hydration.py +++ b/tests/unit/proxy/common_utils/test_credential_hydration.py @@ -7,6 +7,36 @@ from litellm.proxy.common_utils.credential_hydration import hydrate_named_creden from litellm.proxy.common_utils.encrypt_decrypt_utils import encrypt_value_helper +@pytest.mark.asyncio +async def test_authoritative_hydrate_resolves_env_refs_in_stored_values(monkeypatch): + monkeypatch.setenv("LITELLM_SALT_KEY", "sk-hydration-test-salt") + monkeypatch.setenv("BEDROCK_HYDRATION_TEST_KEY", "ak-account-b") + monkeypatch.delenv("BEDROCK_HYDRATION_TEST_MISSING", raising=False) + row = { + "credential_name": "bedrock-account-b", + "credential_values": { + "aws_access_key_id": encrypt_value_helper("os.environ/BEDROCK_HYDRATION_TEST_KEY"), + "aws_secret_access_key": encrypt_value_helper("literal-secret"), + "aws_session_token": encrypt_value_helper("os.environ/BEDROCK_HYDRATION_TEST_MISSING"), + "anthropic_issuer_signing_key_ref": encrypt_value_helper("os.environ/BEDROCK_HYDRATION_TEST_KEY"), + }, + "credential_info": {"custom_llm_provider": "bedrock"}, + } + prisma = MagicMock() + prisma.db.litellm_credentialstable.find_unique = AsyncMock(return_value=row) + + with patch.object(litellm, "credential_list", []): # test-quality-ok: the row under test must win over memory + resolved = await hydrate_named_credential_authoritative("bedrock-account-b", prisma) + + assert resolved is not None + assert resolved.credential_values == { + "aws_access_key_id": "ak-account-b", + "aws_secret_access_key": "literal-secret", + "aws_session_token": "os.environ/BEDROCK_HYDRATION_TEST_MISSING", + "anthropic_issuer_signing_key_ref": "os.environ/BEDROCK_HYDRATION_TEST_KEY", + } + + @pytest.mark.asyncio async def test_authoritative_hydrate_returns_an_encrypted_empty_value_as_empty(monkeypatch): monkeypatch.setenv("LITELLM_SALT_KEY", "sk-hydration-test-salt")