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>
This commit is contained in:
yassin 2026-09-17 02:23:50 +00:00
parent 4694bd0c63
commit 8691a1e190
2 changed files with 31 additions and 17 deletions

View file

@ -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",

View file

@ -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: