""" HashiCorp Vault Client for BreakPilot Provides secure secrets management with: - Vault KV v2 secrets engine support - Automatic token renewal - Fallback to environment variables in development - Docker secrets support for containerized environments - Caching to reduce Vault API calls Usage: from secret_store import get_secret # Get a secret (tries Vault first, then env, then Docker secrets) api_key = get_secret("anthropic_api_key") # Get with default value debug = get_secret("debug_mode", default="false") License: Apache-2.0 (HashiCorp Vault compatible) """ import os import logging import functools from dataclasses import dataclass, field from typing import Optional, Dict, Any, List from pathlib import Path import json import time logger = logging.getLogger(__name__) # ============================================= # Exceptions # ============================================= class SecretNotFoundError(Exception): """Raised when a secret cannot be found in any source.""" pass # ============================================= # Environment Variable to Vault Path Mapping # ============================================= # Maps common environment variable names to Vault paths # Format: env_var_name -> vault_path (relative to secrets_path) ENV_TO_VAULT_MAPPING = { # API Keys "ANTHROPIC_API_KEY": "api_keys/anthropic", "VAST_API_KEY": "api_keys/vast", "TAVILY_API_KEY": "api_keys/tavily", "STRIPE_SECRET_KEY": "api_keys/stripe", "STRIPE_WEBHOOK_SECRET": "api_keys/stripe_webhook", "OPENAI_API_KEY": "api_keys/openai", # Database "DATABASE_URL": "database/postgres", "POSTGRES_PASSWORD": "database/postgres", "SYNAPSE_DB_PASSWORD": "database/synapse", # Auth "JWT_SECRET": "auth/jwt", "JWT_REFRESH_SECRET": "auth/jwt", "KEYCLOAK_CLIENT_SECRET": "auth/keycloak", # Communication "MATRIX_ACCESS_TOKEN": "communication/matrix", "JITSI_APP_SECRET": "communication/jitsi", # Storage "MINIO_ACCESS_KEY": "storage/minio", "MINIO_SECRET_KEY": "storage/minio", # Infrastructure "CONTROL_API_KEY": "infra/vast", "VAST_INSTANCE_ID": "infra/vast", } class VaultConnectionError(Exception): """Raised when Vault is configured but unreachable.""" pass class VaultAuthenticationError(Exception): """Raised when Vault authentication fails.""" pass # ============================================= # Configuration # ============================================= @dataclass class VaultConfig: """Configuration for HashiCorp Vault connection.""" # Vault server URL url: str = "" # Authentication method: "token", "approle", "kubernetes" auth_method: str = "token" # Token for token auth token: Optional[str] = None # AppRole credentials role_id: Optional[str] = None secret_id: Optional[str] = None # Kubernetes auth kubernetes_role: Optional[str] = None kubernetes_jwt_path: str = "/var/run/secrets/kubernetes.io/serviceaccount/token" # KV secrets engine configuration secrets_mount: str = "secret" # KV v2 mount path secrets_path: str = "breakpilot" # Base path for secrets # TLS configuration verify_ssl: bool = True ca_cert: Optional[str] = None # Caching cache_ttl_seconds: int = 300 # 5 minutes # Namespace (Vault Enterprise) namespace: Optional[str] = None # ============================================= # Secrets Manager # ============================================= class SecretsManager: """ Unified secrets management with multiple backends. Priority order: 1. HashiCorp Vault (if configured and available) 2. Environment variables 3. Docker secrets (/run/secrets/) 4. Default value (if provided) """ def __init__( self, vault_config: Optional[VaultConfig] = None, environment: str = "development" ): self.vault_config = vault_config self.environment = environment self._vault_client = None self._cache: Dict[str, tuple] = {} # key -> (value, expiry_time) self._vault_available = False # Initialize Vault client if configured if vault_config and vault_config.url: self._init_vault_client() def _init_vault_client(self): """Initialize the Vault client with hvac library.""" try: import hvac config = self.vault_config # Create client self._vault_client = hvac.Client( url=config.url, verify=config.verify_ssl if config.ca_cert is None else config.ca_cert, namespace=config.namespace, ) # Authenticate based on method if config.auth_method == "token": if config.token: self._vault_client.token = config.token elif config.auth_method == "approle": if config.role_id and config.secret_id: self._vault_client.auth.approle.login( role_id=config.role_id, secret_id=config.secret_id, ) elif config.auth_method == "kubernetes": jwt_path = Path(config.kubernetes_jwt_path) if jwt_path.exists(): jwt = jwt_path.read_text().strip() self._vault_client.auth.kubernetes.login( role=config.kubernetes_role, jwt=jwt, ) # Check if authenticated if self._vault_client.is_authenticated(): self._vault_available = True logger.info("Vault client initialized successfully") else: logger.warning("Vault client created but not authenticated") except ImportError: logger.warning("hvac library not installed - Vault integration disabled") except Exception as e: logger.warning(f"Failed to initialize Vault client: {e}") def _get_from_vault(self, key: str) -> Optional[str]: """Get a secret from Vault KV v2.""" if not self._vault_available or not self._vault_client: return None try: config = self.vault_config # Use mapping if available, otherwise use key as path vault_path = ENV_TO_VAULT_MAPPING.get(key, key) path = f"{config.secrets_path}/{vault_path}" # Read from KV v2 response = self._vault_client.secrets.kv.v2.read_secret_version( path=path, mount_point=config.secrets_mount, ) if response and "data" in response and "data" in response["data"]: data = response["data"]["data"] # For multi-field secrets like JWT, get specific field field_mapping = { "JWT_SECRET": "secret", "JWT_REFRESH_SECRET": "refresh_secret", "DATABASE_URL": "url", "POSTGRES_PASSWORD": "password", "MINIO_ACCESS_KEY": "access_key", "MINIO_SECRET_KEY": "secret_key", "VAST_INSTANCE_ID": "instance_id", "CONTROL_API_KEY": "control_key", } # Get specific field if mapped if key in field_mapping and field_mapping[key] in data: return data[field_mapping[key]] # Return the 'value' field or the first field if "value" in data: return data["value"] elif data: return list(data.values())[0] except Exception as e: logger.debug(f"Vault lookup failed for {key}: {e}") return None def _get_from_env(self, key: str) -> Optional[str]: """Get a secret from environment variables.""" # Try exact key value = os.environ.get(key) if value: return value # Try uppercase value = os.environ.get(key.upper()) if value: return value return None def _get_from_docker_secrets(self, key: str) -> Optional[str]: """Get a secret from Docker secrets mount.""" secrets_dir = Path("/run/secrets") if not secrets_dir.exists(): return None # Try exact filename secret_file = secrets_dir / key if secret_file.exists(): return secret_file.read_text().strip() # Try lowercase secret_file = secrets_dir / key.lower() if secret_file.exists(): return secret_file.read_text().strip() return None def _check_cache(self, key: str) -> Optional[str]: """Check if a secret is cached and not expired.""" if key in self._cache: value, expiry = self._cache[key] if time.time() < expiry: return value else: del self._cache[key] return None def _cache_secret(self, key: str, value: str): """Cache a secret with TTL.""" if self.vault_config: ttl = self.vault_config.cache_ttl_seconds else: ttl = 300 self._cache[key] = (value, time.time() + ttl) def get( self, key: str, default: Optional[str] = None, required: bool = False, cache: bool = True ) -> Optional[str]: """ Get a secret from the best available source. Args: key: The secret key to look up default: Default value if not found required: If True, raise SecretNotFoundError when not found cache: Whether to cache the result Returns: The secret value or default Raises: SecretNotFoundError: If required and not found """ # Check cache first if cache: cached = self._check_cache(key) if cached is not None: return cached # Try Vault (production) value = self._get_from_vault(key) # Fall back to environment variables if value is None: value = self._get_from_env(key) # Fall back to Docker secrets if value is None: value = self._get_from_docker_secrets(key) # Use default or raise error if value is None: if required: raise SecretNotFoundError( f"Secret '{key}' not found in Vault, environment, or Docker secrets" ) value = default # Cache the result if value is not None and cache: self._cache_secret(key, value) return value def get_all(self, prefix: str = "") -> Dict[str, str]: """ Get all secrets with a given prefix. Args: prefix: Filter secrets by prefix Returns: Dictionary of secret key -> value """ secrets = {} # Get from Vault if available if self._vault_available and self._vault_client: try: config = self.vault_config path = f"{config.secrets_path}/{prefix}" if prefix else config.secrets_path response = self._vault_client.secrets.kv.v2.list_secrets( path=path, mount_point=config.secrets_mount, ) if response and "data" in response and "keys" in response["data"]: for key in response["data"]["keys"]: if not key.endswith("/"): # Skip directories full_key = f"{prefix}/{key}" if prefix else key value = self.get(full_key, cache=False) if value: secrets[full_key] = value except Exception as e: logger.debug(f"Vault list failed: {e}") # Also get from environment (for development) for key, value in os.environ.items(): if prefix: if key.lower().startswith(prefix.lower()): secrets[key] = value else: # Only add secrets that look like secrets if any(s in key.lower() for s in ["key", "secret", "password", "token"]): secrets[key] = value return secrets def is_vault_available(self) -> bool: """Check if Vault is available and authenticated.""" return self._vault_available def clear_cache(self): """Clear the secrets cache.""" self._cache.clear() # ============================================= # Global Instance & Helper Functions # ============================================= _secrets_manager: Optional[SecretsManager] = None def get_secrets_manager() -> SecretsManager: """Get or create the global SecretsManager instance.""" global _secrets_manager if _secrets_manager is None: # Read Vault configuration from environment vault_url = os.environ.get("VAULT_ADDR", "") if vault_url: config = VaultConfig( url=vault_url, auth_method=os.environ.get("VAULT_AUTH_METHOD", "token"), token=os.environ.get("VAULT_TOKEN"), role_id=os.environ.get("VAULT_ROLE_ID"), secret_id=os.environ.get("VAULT_SECRET_ID"), kubernetes_role=os.environ.get("VAULT_K8S_ROLE"), secrets_mount=os.environ.get("VAULT_SECRETS_MOUNT", "secret"), secrets_path=os.environ.get("VAULT_SECRETS_PATH", "breakpilot"), verify_ssl=os.environ.get("VAULT_SKIP_VERIFY", "false").lower() != "true", namespace=os.environ.get("VAULT_NAMESPACE"), ) else: config = None environment = os.environ.get("ENVIRONMENT", "development") _secrets_manager = SecretsManager(vault_config=config, environment=environment) return _secrets_manager def get_secret( key: str, default: Optional[str] = None, required: bool = False ) -> Optional[str]: """ Convenience function to get a secret. Usage: api_key = get_secret("ANTHROPIC_API_KEY") db_url = get_secret("DATABASE_URL", required=True) """ manager = get_secrets_manager() return manager.get(key, default=default, required=required) # ============================================= # Secret Key Mappings # ============================================= # Mapping of standard secret names to their paths in Vault SECRET_MAPPINGS = { # API Keys "ANTHROPIC_API_KEY": "api_keys/anthropic", "VAST_API_KEY": "api_keys/vast", "TAVILY_API_KEY": "api_keys/tavily", "STRIPE_SECRET_KEY": "api_keys/stripe", "STRIPE_WEBHOOK_SECRET": "api_keys/stripe_webhook", # Database "DATABASE_URL": "database/url", "POSTGRES_PASSWORD": "database/postgres_password", # JWT "JWT_SECRET": "auth/jwt_secret", "JWT_REFRESH_SECRET": "auth/jwt_refresh_secret", # Keycloak "KEYCLOAK_CLIENT_SECRET": "auth/keycloak_client_secret", # Matrix "MATRIX_ACCESS_TOKEN": "communication/matrix_token", "SYNAPSE_DB_PASSWORD": "communication/synapse_db_password", # Jitsi "JITSI_APP_SECRET": "communication/jitsi_secret", "JITSI_JICOFO_AUTH_PASSWORD": "communication/jitsi_jicofo", "JITSI_JVB_AUTH_PASSWORD": "communication/jitsi_jvb", # MinIO "MINIO_SECRET_KEY": "storage/minio_secret", } def get_mapped_secret(key: str, default: Optional[str] = None) -> Optional[str]: """ Get a secret using the standard name mapping. This maps environment variable names to Vault paths for consistency. """ vault_path = SECRET_MAPPINGS.get(key, key) return get_secret(vault_path, default=default)