From 8691a1e1908650ab7991bde7644a95791e655727 Mon Sep 17 00:00:00 2001 From: yassin Date: Thu, 17 Sep 2026 02:23:50 +0000 Subject: [PATCH] fix(vault): cache the secret body per url so mutations evict every field Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../hashicorp_secret_manager.py | 30 ++++++++----------- .../test_hashicorp_secret_manager.py | 18 +++++++++++ 2 files changed, 31 insertions(+), 17 deletions(-) diff --git a/litellm/secret_managers/hashicorp_secret_manager.py b/litellm/secret_managers/hashicorp_secret_manager.py index 4e360b99c65..fd7267e03dd 100644 --- a/litellm/secret_managers/hashicorp_secret_manager.py +++ b/litellm/secret_managers/hashicorp_secret_manager.py @@ -39,7 +39,6 @@ class _VaultSecretTarget(TypedDict): url: ReadOnly[str] data_key: ReadOnly[str] secret_name: ReadOnly[str] - cache_key: ReadOnly[str] class _VaultSecretDataBlock(TypedDict, total=False): @@ -369,7 +368,6 @@ class HashicorpSecretManager(BaseSecretManager): "url": url, "data_key": data_key, "secret_name": secret_name, - "cache_key": f"{url}#{data_key}", } def _get_request_headers(self) -> dict: @@ -408,17 +406,16 @@ class HashicorpSecretManager(BaseSecretManager): ) try: target: Final = self._build_secret_target(secret_name, optional_params) - cached_value: Final = self.cache.get_cache(target["cache_key"]) - if cached_value is not None: - return cached_value + cached_body: Final = self.cache.get_cache(target["url"]) + if cached_body is not None: + return self._get_secret_value_from_json_response(cached_body, target["data_key"]) response: Final = await async_client.get(target["url"], headers=self._get_request_headers()) response.raise_for_status() json_resp: Final = _json_object_body(response) - _value: Final = self._get_secret_value_from_json_response(json_resp, target["data_key"]) - self.cache.set_cache(target["cache_key"], _value) - return _value + self.cache.set_cache(target["url"], json_resp) + return self._get_secret_value_from_json_response(json_resp, target["data_key"]) except Exception as e: verbose_logger.exception("Error reading secret from Hashicorp Vault: %s", e) @@ -438,17 +435,16 @@ class HashicorpSecretManager(BaseSecretManager): sync_client: Final = _get_httpx_client() try: target: Final = self._build_secret_target(secret_name, optional_params) - cached_value: Final = self.cache.get_cache(target["cache_key"]) - if cached_value is not None: - return cached_value + cached_body: Final = self.cache.get_cache(target["url"]) + if cached_body is not None: + return self._get_secret_value_from_json_response(cached_body, target["data_key"]) response: Final = sync_client.get(target["url"], headers=self._get_request_headers()) response.raise_for_status() json_resp: Final = _json_object_body(response) - _value: Final = self._get_secret_value_from_json_response(json_resp, target["data_key"]) - self.cache.set_cache(target["cache_key"], _value) - return _value + self.cache.set_cache(target["url"], json_resp) + return self._get_secret_value_from_json_response(json_resp, target["data_key"]) except Exception as e: verbose_logger.exception("Error reading secret from Hashicorp Vault: %s", e) @@ -637,10 +633,10 @@ class HashicorpSecretManager(BaseSecretManager): ) else: # Clear cache for the old secret only if deletion was successful - self.cache.delete_cache(current_target["cache_key"]) + self.cache.delete_cache(current_target["url"]) # Clear cache for the new secret (or updated secret if names are the same) - self.cache.delete_cache(new_target["cache_key"]) + self.cache.delete_cache(new_target["url"]) return create_response @@ -681,7 +677,7 @@ class HashicorpSecretManager(BaseSecretManager): response: Final = await async_client.delete(url=target["url"], headers=self._get_request_headers()) response.raise_for_status() - self.cache.delete_cache(target["cache_key"]) + self.cache.delete_cache(target["url"]) return { "status": "success", diff --git a/tests/test_litellm/secret_managers/test_hashicorp_secret_manager.py b/tests/test_litellm/secret_managers/test_hashicorp_secret_manager.py index b47037bbca9..1676540e4ec 100644 --- a/tests/test_litellm/secret_managers/test_hashicorp_secret_manager.py +++ b/tests/test_litellm/secret_managers/test_hashicorp_secret_manager.py @@ -132,6 +132,24 @@ def test_sync_read_caches_per_data_key_for_the_same_secret_path(monkeypatch: pyt assert manager.sync_read_secret("DB_CREDS") == "sk-from-vault" +@pytest.mark.asyncio +@respx.mock +async def test_async_delete_evicts_every_cached_field_of_the_secret_path(monkeypatch: pytest.MonkeyPatch) -> None: + monkeypatch.setattr(litellm, "disable_aiohttp_transport", True) + manager: Final = _build_manager(monkeypatch, {"HCP_VAULT_SECRET_NAMESPACE": "teams/team-a"}) + respx.post(f"{VAULT_ADDR}/v1/auth/approle/login").respond(json=LOGIN_RESPONSE) + secret_url: Final = f"{VAULT_ADDR}/v1/teams/team-a/secret/data/DB_CREDS" + read_route: Final = respx.get(secret_url).respond(json=SECRET_RESPONSE) + respx.delete(secret_url).respond(status_code=204) + password_params: Final = {"secret_manager_settings": {"data": "password"}} + + assert await manager.async_read_secret("DB_CREDS", optional_params=password_params) == "pw-from-vault" + assert await manager.async_delete_secret("DB_CREDS") + assert await manager.async_read_secret("DB_CREDS", optional_params=password_params) == "pw-from-vault" + + assert read_route.call_count == 2 + + @pytest.mark.asyncio @respx.mock async def test_async_read_uses_secret_namespace_and_login_namespace(monkeypatch: pytest.MonkeyPatch) -> None: