From a6b0993405feac07fb4049dbac99f5206a930a68 Mon Sep 17 00:00:00 2001 From: Ishaan Jaff Date: Fri, 7 Nov 2025 14:39:33 -0800 Subject: [PATCH] [Feat] Secret Manager - Hashicorp, add auth via approle (#16374) * add _verify_required_credentials_exist and _auth_via_approle * test_hashicorp_secret_manager_approle_auth * docs hcorp auth --- .../docs/secret_managers/hashicorp_vault.md | 89 +++++++++++++- .../hashicorp_secret_manager.py | 112 +++++++++++++++++- tests/litellm_utils_tests/test_hashicorp.py | 43 +++++++ 3 files changed, 235 insertions(+), 9 deletions(-) diff --git a/docs/my-website/docs/secret_managers/hashicorp_vault.md b/docs/my-website/docs/secret_managers/hashicorp_vault.md index 4d0ef05a326..9e536270988 100644 --- a/docs/my-website/docs/secret_managers/hashicorp_vault.md +++ b/docs/my-website/docs/secret_managers/hashicorp_vault.md @@ -16,21 +16,28 @@ import Image from '@theme/IdealImage'; |---------|----------|-------------| | Reading Secrets | ✅ | Read secrets e.g `OPENAI_API_KEY` | | Writing Secrets | ✅ | Store secrets e.g `Virtual Keys` | +| Authentication Methods to Hashicorp Vault | ✅ | AppRole, TLS Certificate, Token | Read secrets from [Hashicorp Vault](https://developer.hashicorp.com/vault/docs/secrets/kv/kv-v2) **Step 1.** Add Hashicorp Vault details in your environment -LiteLLM supports two methods of authentication: +LiteLLM supports three methods of authentication: -1. TLS cert authentication - `HCP_VAULT_CLIENT_CERT` and `HCP_VAULT_CLIENT_KEY` -2. Token authentication - `HCP_VAULT_TOKEN` +1. AppRole authentication (recommended) - `HCP_VAULT_APPROLE_ROLE_ID` and `HCP_VAULT_APPROLE_SECRET_ID` +2. TLS cert authentication - `HCP_VAULT_CLIENT_CERT` and `HCP_VAULT_CLIENT_KEY` +3. Token authentication - `HCP_VAULT_TOKEN` ```bash HCP_VAULT_ADDR="https://test-cluster-public-vault-0f98180c.e98296b2.z1.hashicorp.cloud:8200" HCP_VAULT_NAMESPACE="admin" -# Authentication via TLS cert +# Authentication via AppRole (recommended) +HCP_VAULT_APPROLE_ROLE_ID="your-role-id" +HCP_VAULT_APPROLE_SECRET_ID="your-secret-id" +HCP_VAULT_APPROLE_MOUNT_PATH="approle" # OPTIONAL. defaults to "approle" + +# OR - Authentication via TLS cert HCP_VAULT_CLIENT_CERT="path/to/client.pem" HCP_VAULT_CLIENT_KEY="path/to/client.key" @@ -64,6 +71,80 @@ $ litellm --config /path/to/config.yaml [Quick Test Proxy](../proxy/user_keys) +## Authentication Methods + +LiteLLM supports three authentication methods for Hashicorp Vault, with the following priority: + +1. **AppRole** - Recommended for production applications +2. **TLS Certificate** - For certificate-based authentication +3. **Token** - Direct token authentication + +### 1. AppRole Authentication + +To set up AppRole authentication: + +1. Enable AppRole auth in Vault: +```bash +vault auth enable approle +``` + +2. Create a policy and role for LiteLLM: +```bash +# Create a policy file (litellm-policy.hcl) +path "secret/data/*" { + capabilities = ["create", "read", "update", "delete", "list"] +} + +# Apply the policy +vault policy write litellm-policy litellm-policy.hcl + +# Create an AppRole +vault write auth/approle/role/litellm \ + token_policies="litellm-policy" \ + token_ttl=32d \ + token_max_ttl=32d +``` + +3. Get your Role ID and Secret ID: +```bash +# Get Role ID +vault read auth/approle/role/litellm/role-id + +# Generate Secret ID +vault write -f auth/approle/role/litellm/secret-id +``` + +4. Set the environment variables: +```bash +export HCP_VAULT_APPROLE_ROLE_ID="your-role-id" +export HCP_VAULT_APPROLE_SECRET_ID="your-secret-id" +``` + +### 2. TLS Certificate Authentication + +TLS Certificate authentication uses client certificates for mutual TLS authentication with Vault. + +**Environment Variables:** +```bash +export HCP_VAULT_CLIENT_CERT="path/to/client.pem" +export HCP_VAULT_CLIENT_KEY="path/to/client.key" +export HCP_VAULT_CERT_ROLE="your-cert-role" # Optional +``` + +**How it works:** +- LiteLLM uses the client certificate and key for mutual TLS authentication +- Vault validates the certificate and issues a temporary token +- The token is cached for the duration of its lease + +### 3. Token Authentication + +Direct token authentication uses a static Vault token. + +**Environment Variables:** +```bash +export HCP_VAULT_TOKEN="hvs.CAESIG52gL6ljBSdmq*****" +``` + ## How it works **Reading Secrets** diff --git a/litellm/secret_managers/hashicorp_secret_manager.py b/litellm/secret_managers/hashicorp_secret_manager.py index 90705de02cb..fe26f5c332c 100644 --- a/litellm/secret_managers/hashicorp_secret_manager.py +++ b/litellm/secret_managers/hashicorp_secret_manager.py @@ -37,11 +37,12 @@ class HashicorpSecretManager(BaseSecretManager): self.tls_key_path = os.getenv("HCP_VAULT_CLIENT_KEY", "") self.vault_cert_role = os.getenv("HCP_VAULT_CERT_ROLE", None) - # Validate environment - if not self.vault_token: - raise ValueError( - "Missing Vault token. Please set HCP_VAULT_TOKEN in your environment." - ) + # Optional config for AppRole auth + self.approle_role_id = os.getenv("HCP_VAULT_APPROLE_ROLE_ID", "") + self.approle_secret_id = os.getenv("HCP_VAULT_APPROLE_SECRET_ID", "") + self.approle_mount_path = os.getenv("HCP_VAULT_APPROLE_MOUNT_PATH", "approle") + + self._verify_required_credentials_exist() litellm.secret_manager_client = self litellm._key_management_system = KeyManagementSystem.HASHICORP_VAULT @@ -62,6 +63,92 @@ class HashicorpSecretManager(BaseSecretManager): f"Hashicorp secret manager is only available for premium users. {CommonProxyErrors.not_premium_user.value}" ) + def _verify_required_credentials_exist(self) -> None: + """ + Validate that at least one authentication method is configured. + + Raises: + ValueError: If no valid authentication credentials are provided + """ + if not self.vault_token and not (self.approle_role_id and self.approle_secret_id): + raise ValueError( + "Missing Vault authentication credentials. Please set either:\n" + " - HCP_VAULT_TOKEN for token-based auth, or\n" + " - HCP_VAULT_APPROLE_ROLE_ID and HCP_VAULT_APPROLE_SECRET_ID for AppRole auth" + ) + + def _auth_via_approle(self) -> str: + """ + Authenticate to Vault using AppRole auth method. + + Ref: https://developer.hashicorp.com/vault/api-docs/auth/approle + + Request: + ``` + curl \ + --request POST \ + --header "X-Vault-Namespace: mynamespace/" \ + --data '{"role_id": "...", "secret_id": "..."}' \ + http://127.0.0.1:8200/v1/auth/approle/login + ``` + + Response: + ``` + { + "auth": { + "client_token": "hvs.CAESI...", + "accessor": "hmac-sha256...", + "policies": ["default", "dev-policy"], + "token_policies": ["default", "dev-policy"], + "lease_duration": 2764800, + "renewable": true + } + } + ``` + """ + verbose_logger.debug("Using AppRole auth for Hashicorp Vault") + + # Check cache first + cached_token = self.cache.get_cache(key="hcp_vault_approle_token") + if cached_token: + verbose_logger.debug("Using cached Vault token from AppRole auth") + return cached_token + + # Vault endpoint for AppRole login + login_url = f"{self.vault_addr}/v1/auth/{self.approle_mount_path}/login" + + headers = {} + if hasattr(self, "vault_namespace") and self.vault_namespace: + headers["X-Vault-Namespace"] = self.vault_namespace + + try: + client = _get_httpx_client() + resp = client.post( + url=login_url, + headers=headers, + json={ + "role_id": self.approle_role_id, + "secret_id": self.approle_secret_id, + }, + ) + resp.raise_for_status() + + auth_data = resp.json()["auth"] + token = auth_data["client_token"] + _lease_duration = auth_data["lease_duration"] + + verbose_logger.debug( + f"Successfully obtained Vault token via AppRole auth. Lease duration: {_lease_duration}s" + ) + + # Cache the token with its lease duration + self.cache.set_cache( + key="hcp_vault_approle_token", value=token, ttl=_lease_duration + ) + return token + except Exception as e: + raise RuntimeError(f"Could not authenticate to Vault via AppRole: {e}") + def _auth_via_tls_cert(self) -> str: """ Ref: https://developer.hashicorp.com/vault/api-docs/auth/cert @@ -144,8 +231,23 @@ class HashicorpSecretManager(BaseSecretManager): return _url def _get_request_headers(self) -> dict: + """ + Get the headers for Vault API requests. + + Authentication priority: + 1. AppRole (if role_id and secret_id are configured) + 2. TLS Certificate (if cert paths are configured) + 3. Direct token (if HCP_VAULT_TOKEN is set) + """ + # Priority 1: AppRole auth + if self.approle_role_id and self.approle_secret_id: + return {"X-Vault-Token": self._auth_via_approle()} + + # Priority 2: TLS cert auth if self.tls_cert_path and self.tls_key_path: return {"X-Vault-Token": self._auth_via_tls_cert()} + + # Priority 3: Direct token return {"X-Vault-Token": self.vault_token} async def async_read_secret( diff --git a/tests/litellm_utils_tests/test_hashicorp.py b/tests/litellm_utils_tests/test_hashicorp.py index ef8aa889ff1..4757c72262b 100644 --- a/tests/litellm_utils_tests/test_hashicorp.py +++ b/tests/litellm_utils_tests/test_hashicorp.py @@ -217,6 +217,49 @@ def test_hashicorp_secret_manager_tls_cert_auth(monkeypatch): assert test_manager.cache.get_cache("hcp_vault_token") == "test-client-token-12345" +def test_hashicorp_secret_manager_approle_auth(monkeypatch): + """ + Test AppRole authentication makes the expected POST request to the correct URL. + """ + monkeypatch.setenv("HCP_VAULT_TOKEN", "test-token-12345") + + with patch("litellm.llms.custom_httpx.http_handler.HTTPHandler.post") as mock_post: + mock_response = MagicMock() + mock_response.json.return_value = { + "auth": { + "client_token": "hvs.approle-token-67890", + "lease_duration": 2764800, + "renewable": True, + } + } + mock_response.raise_for_status.return_value = None + mock_post.return_value = mock_response + + test_manager = HashicorpSecretManager() + test_manager.approle_role_id = "test-role-id-123" + test_manager.approle_secret_id = "test-secret-id-456" + test_manager.approle_mount_path = "approle" + + token = test_manager._auth_via_approle() + + assert token == "hvs.approle-token-67890" + + expected_headers = {} + if test_manager.vault_namespace: + expected_headers["X-Vault-Namespace"] = test_manager.vault_namespace + + mock_post.assert_called_once_with( + url=f"{test_manager.vault_addr}/v1/auth/approle/login", + headers=expected_headers, + json={ + "role_id": "test-role-id-123", + "secret_id": "test-secret-id-456", + }, + ) + + assert test_manager.cache.get_cache("hcp_vault_approle_token") == "hvs.approle-token-67890" + + def test_hashicorp_custom_mount_and_prefix(): """Test URL construction with custom mount name and path prefix using get_url method.""" # Save original values