A previous `git pull --rebase origin main` dropped 177 local commits,
losing 3400+ files across admin-v2, backend, studio-v2, website,
klausur-service, and many other services. The partial restore attempt
(660295e2) only recovered some files.
This commit restores all missing files from pre-rebase ref 98933f5e
while preserving post-rebase additions (night-scheduler, night-mode UI,
NightModeWidget dashboard integration).
Restored features include:
- AI Module Sidebar (FAB), OCR Labeling, OCR Compare
- GPU Dashboard, RAG Pipeline, Magic Help
- Klausur-Korrektur (8 files), Abitur-Archiv (5+ files)
- Companion, Zeugnisse-Crawler, Screen Flow
- Full backend, studio-v2, website, klausur-service
- All compliance SDKs, agent-core, voice-service
- CI/CD configs, documentation, scripts
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
516 lines
16 KiB
Python
516 lines
16 KiB
Python
"""
|
|
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)
|