Coverage for src / mcp_server_langgraph / core / config.py: 78%
324 statements
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-03 00:43 +0000
« prev ^ index » next coverage.py v7.12.0, created at 2025-12-03 00:43 +0000
1"""
2Configuration management with Infisical secrets integration
3"""
5from typing import Any
7from pydantic import Field, field_validator
8from pydantic_settings import BaseSettings, SettingsConfigDict
10from mcp_server_langgraph.secrets.manager import get_secrets_manager
12# Import version from package __init__.py (single source of truth)
13try:
14 from mcp_server_langgraph import __version__
15except ImportError:
16 __version__ = "2.7.0" # Fallback
19class Settings(BaseSettings):
20 """Application settings with Infisical secrets support"""
22 # Service
23 service_name: str = "mcp-server-langgraph"
24 service_version: str = __version__ # Read from package version
25 environment: str = "development"
27 # CORS Configuration
28 # SECURITY: Empty list by default (no CORS) in production
29 # Override with CORS_ALLOWED_ORIGINS="http://localhost:3000,http://localhost:8000"
30 cors_allowed_origins: list[str] = [] # Empty = no CORS/restrictive by default
32 # Authentication
33 jwt_secret_key: str | None = None
34 jwt_algorithm: str = "HS256"
35 jwt_expiration_seconds: int = 3600
36 use_password_hashing: bool = True # Enable bcrypt password hashing for InMemoryUserProvider (default: secure)
38 # Authorization Fallback Control (OpenAI Codex Finding #1)
39 # SECURITY: Controls whether authorization can fall back to role-based checks when OpenFGA is unavailable
40 # Default: False (fail-closed, secure by default)
41 # Set to True only in development/testing environments to allow degraded authorization
42 allow_auth_fallback: bool = False
44 # HIPAA Compliance (only required if processing PHI)
45 hipaa_integrity_secret: str | None = None
47 # OpenTelemetry
48 otlp_endpoint: str = "http://localhost:4317"
49 enable_console_export: bool = True
50 enable_tracing: bool = True
51 enable_metrics: bool = True
53 # Prometheus (for SLA monitoring and compliance metrics)
54 prometheus_url: str = "http://prometheus:9090"
55 prometheus_timeout: int = 30 # Query timeout in seconds
56 prometheus_retry_attempts: int = 3 # Number of retry attempts
58 # Alerting Configuration (PagerDuty, Slack, OpsGenie, Email)
59 pagerduty_integration_key: str | None = None # PagerDuty Events API v2 integration key
60 slack_webhook_url: str | None = None # Slack incoming webhook URL
61 opsgenie_api_key: str | None = None # OpsGenie API key
62 email_smtp_host: str | None = None # SMTP server for email alerts
63 email_smtp_port: int = 587 # SMTP port
64 email_from_address: str | None = None # From email address
65 email_to_addresses: str | None = None # Comma-separated list of email addresses
67 # Web Search API Configuration (for search_tools.py)
68 tavily_api_key: str | None = None # Tavily API key (recommended for AI)
69 serper_api_key: str | None = None # Serper API key (Google search)
70 brave_api_key: str | None = None # Brave Search API key (privacy-focused)
72 # LangSmith Observability
73 langsmith_api_key: str | None = None
74 langsmith_project: str = "mcp-server-langgraph"
75 langsmith_endpoint: str = "https://api.smith.langchain.com"
76 langsmith_tracing: bool = False # Enable LangSmith tracing
77 langsmith_tracing_v2: bool = True # Use v2 tracing (recommended)
79 # Observability Backend Selection
80 observability_backend: str = "both" # opentelemetry, langsmith, both
82 # Logging
83 log_level: str = "INFO"
84 log_file: str | None = None
85 log_format: str = "json" # "json" or "text"
86 log_json_indent: int | None = None # None for compact, 2 for pretty-print
87 enable_file_logging: bool = False # Opt-in file-based log rotation (for persistent storage)
89 # LLM Provider (litellm integration)
90 llm_provider: str = "google" # google, anthropic, openai, ollama, azure, bedrock, vertex_ai
92 # Anthropic (Direct API)
93 # Latest models: claude-sonnet-4-5-20250929, claude-haiku-4-5-20251001, claude-opus-4-1-20250805
94 anthropic_api_key: str | None = None
96 # OpenAI
97 openai_api_key: str | None = None
98 openai_organization: str | None = None
100 # Google (Gemini via Google AI Studio)
101 # Latest models: gemini-3-pro-preview (Nov 2025), gemini-2.5-flash
102 google_api_key: str | None = None
103 google_project_id: str | None = None
104 google_location: str = "us-central1"
106 # Vertex AI (Google Cloud AI Platform)
107 # Supports both Anthropic Claude and Google Gemini models via Vertex AI
108 # Authentication:
109 # - On GKE: Use Workload Identity (automatic, no credentials needed)
110 # - Locally: Set GOOGLE_APPLICATION_CREDENTIALS=/path/to/service-account-key.json
111 # Anthropic via Vertex AI: vertex_ai/claude-sonnet-4-5@20250929
112 # Google via Vertex AI: vertex_ai/gemini-3-pro-preview
113 vertex_project: str | None = None # GCP project ID for Vertex AI (falls back to google_project_id)
114 vertex_location: str = "us-central1" # Vertex AI location/region
116 # Azure OpenAI
117 azure_api_key: str | None = None
118 azure_api_base: str | None = None
119 azure_api_version: str = "2024-02-15-preview"
120 azure_deployment_name: str | None = None
122 # AWS Bedrock
123 aws_access_key_id: str | None = None
124 aws_secret_access_key: str | None = None
125 aws_region: str = "us-east-1"
127 # Ollama (for local/open-source models)
128 ollama_base_url: str = "http://localhost:11434"
130 # Model Configuration (Primary Chat Model)
131 # Options:
132 # - gemini-3-pro-preview (Gemini 3.0 Pro - latest, Nov 2025, 1M context window)
133 # - gemini-2.5-flash (Gemini 2.5 Flash - fast, cost-effective)
134 # - claude-sonnet-4-5-20250929 (Claude Sonnet 4.5 via Anthropic API)
135 # - vertex_ai/claude-sonnet-4-5@20250929 (Claude Sonnet 4.5 via Vertex AI)
136 # - vertex_ai/gemini-3-pro-preview (Gemini 3.0 Pro via Vertex AI)
137 model_name: str = "gemini-2.5-flash" # Default: Gemini 2.5 Flash (balanced cost/performance)
138 model_temperature: float = 0.7
139 model_max_tokens: int = 8192
140 model_timeout: int = 60
142 # Dedicated Models for Cost/Performance Optimization
143 # Summarization Model (lighter/cheaper model for context compaction)
144 use_dedicated_summarization_model: bool = True
145 summarization_model_name: str | None = "gemini-2.5-flash" # Lighter/cheaper for summarization
146 summarization_model_provider: str | None = None # Defaults to llm_provider if None
147 summarization_model_temperature: float = 0.3 # Lower temperature for factual summaries
148 summarization_model_max_tokens: int = 2000 # Smaller output for summaries
150 # Verification Model (dedicated model for LLM-as-judge)
151 use_dedicated_verification_model: bool = True
152 verification_model_name: str | None = "gemini-2.5-flash" # Can use different model for verification
153 verification_model_provider: str | None = None # Defaults to llm_provider if None
154 verification_model_temperature: float = 0.0 # Deterministic for consistent verification
155 verification_model_max_tokens: int = 1000 # Smaller output for verification feedback
157 # Fallback Models (for resilience)
158 # Using latest production Claude 4.5 models as of October 2025
159 # Verified against https://docs.claude.com/en/docs/about-claude/models
160 enable_fallback: bool = True
161 fallback_models: list[str] = [
162 "claude-haiku-4-5-20251001", # Claude Haiku 4.5 (fast, cost-effective)
163 "claude-sonnet-4-5-20250929", # Claude Sonnet 4.5 (balanced performance)
164 "gpt-5.1", # OpenAI GPT-5.1 (cross-provider resilience)
165 ]
167 # Agent
168 max_iterations: int = 10
169 enable_checkpointing: bool = True
171 # Agentic Loop Configuration (Anthropic Best Practices)
172 # Context Management
173 enable_context_compaction: bool = True # Enable conversation compaction
174 compaction_threshold: int = 8000 # Token count that triggers compaction
175 target_after_compaction: int = 4000 # Target token count after compaction
176 recent_message_count: int = 5 # Number of recent messages to keep uncompacted
178 # Work Verification
179 enable_verification: bool = True # Enable LLM-as-judge verification
180 verification_quality_threshold: float = 0.7 # Minimum score to pass (0.0-1.0)
181 max_refinement_attempts: int = 3 # Maximum refinement iterations
182 verification_mode: str = "standard" # "standard", "strict", "lenient"
184 # Dynamic Context Loading (Just-in-Time) - Anthropic Best Practice
185 enable_dynamic_context_loading: bool = False # Enable semantic search-based context loading
186 qdrant_url: str = "localhost" # Qdrant server URL
187 qdrant_port: int = 6333 # Qdrant server port
188 qdrant_collection_name: str = "mcp_context" # Collection name for context storage
189 dynamic_context_max_tokens: int = 2000 # Max tokens to load from dynamic context
190 dynamic_context_top_k: int = 3 # Number of top results from semantic search
192 # Embedding Configuration
193 embedding_provider: str = "google" # "google" (Gemini API) or "local" (sentence-transformers)
194 embedding_model_name: str = "models/text-embedding-004" # Google: text-embedding-004, Local: all-MiniLM-L6-v2
195 embedding_dimensions: int = 768 # Google: 768 (128-3072 supported), Local: 384
196 embedding_task_type: str = "RETRIEVAL_DOCUMENT" # Google task type optimization
197 embedding_model: str = (
198 "all-MiniLM-L6-v2" # Deprecated: use embedding_model_name instead (kept for backwards compatibility)
199 )
201 context_cache_size: int = 100 # LRU cache size for loaded contexts
203 # Data Security & Compliance (for regulated workloads)
204 enable_context_encryption: bool = False # Enable encryption-at-rest for context data
205 context_encryption_key: str | None = None # Encryption key for context data (Fernet-compatible)
206 context_retention_days: int = 90 # Retention period for context data (days)
207 enable_auto_deletion: bool = True # Automatically delete expired context data
208 enable_multi_tenant_isolation: bool = False # Use separate collections per tenant
210 # Parallel Tool Execution - Anthropic Best Practice
211 enable_parallel_execution: bool = False # Enable parallel tool execution
212 max_parallel_tools: int = 5 # Maximum concurrent tool executions
214 # Enhanced Note-Taking - Anthropic Best Practice
215 enable_llm_extraction: bool = False # Use LLM for structured note extraction
216 extraction_categories: list[str] = [
217 "decisions",
218 "requirements",
219 "facts",
220 "action_items",
221 "issues",
222 "preferences",
223 ] # Categories for information extraction
225 # Code Execution Configuration (Anthropic Best Practice - Progressive Disclosure)
226 # SECURITY: Disabled by default - must be explicitly enabled
227 enable_code_execution: bool = False # Enable sandboxed code execution
228 code_execution_backend: str = "docker-engine" # Backend: docker-engine, kubernetes, process
229 code_execution_timeout: int = 30 # Execution timeout in seconds (1-600)
230 code_execution_memory_limit_mb: int = 512 # Memory limit in MB (64-8192)
231 code_execution_cpu_quota: float = 1.0 # CPU cores quota (0.1-8.0)
232 code_execution_disk_quota_mb: int = 100 # Disk quota in MB (1-10240)
233 code_execution_max_processes: int = 1 # Maximum processes (1-100)
234 code_execution_network_mode: str = "none" # Network mode: none, allowlist, unrestricted (SECURITY: defaults to 'none' for maximum isolation - users must explicitly opt-in to network access)
235 code_execution_allowed_domains: list[str] = [] # Allowed domains for allowlist mode
236 code_execution_allowed_imports: list[str] = [
237 # Safe standard library modules
238 "json",
239 "math",
240 "datetime",
241 "statistics",
242 "collections",
243 "itertools",
244 "functools",
245 "typing",
246 # Data processing libraries
247 "pandas",
248 "numpy",
249 ] # Allowed Python imports (whitelist)
251 # Docker-specific settings
252 code_execution_docker_image: str = "python:3.12-slim" # Docker image for execution
253 code_execution_docker_socket: str = "/var/run/docker.sock" # Docker socket path
255 # Kubernetes-specific settings
256 code_execution_k8s_namespace: str = "default" # Kubernetes namespace for jobs
257 code_execution_k8s_job_ttl: int = 300 # Kubernetes job TTL in seconds (cleanup)
259 # Conversation Checkpointing (for distributed state across replicas)
260 checkpoint_backend: str = "memory" # "memory", "redis"
261 checkpoint_redis_url: str = Field(
262 default="redis://localhost:6379/1", # Use db 1 (sessions use db 0)
263 validation_alias="redis_checkpoint_url", # Accept both names
264 )
265 checkpoint_redis_ttl: int = 604800 # 7 days TTL for conversation checkpoints
267 # OpenFGA
268 openfga_api_url: str = "http://localhost:8080"
269 openfga_store_id: str | None = None
270 openfga_model_id: str | None = None
272 # Infisical
273 infisical_site_url: str = "https://app.infisical.com"
274 infisical_client_id: str | None = None
275 infisical_client_secret: str | None = None
276 infisical_project_id: str | None = None
278 # LangGraph Platform
279 langgraph_api_key: str | None = None
280 langgraph_deployment_url: str | None = None
281 langgraph_api_url: str = "https://api.langchain.com"
283 # Authentication Provider
284 auth_provider: str = "inmemory" # "inmemory", "keycloak"
285 auth_mode: str = "token" # "token" (JWT), "session"
287 # Mock Data (Development)
288 # SECURITY: Mock authorization disabled by default in production
289 # Override with ENABLE_MOCK_AUTHORIZATION=true if needed for staging/testing
290 enable_mock_authorization: bool | None = None # None = auto-determine based on environment
292 # Keycloak Settings
293 keycloak_server_url: str = "http://localhost:8082"
294 keycloak_realm: str = "langgraph-agent"
295 keycloak_client_id: str = "langgraph-client"
296 keycloak_client_secret: str | None = None
297 keycloak_admin_username: str = "admin"
298 keycloak_admin_password: str | None = None
299 keycloak_verify_ssl: bool = True
300 keycloak_timeout: int = 30 # HTTP timeout in seconds
302 # Session Management
303 session_backend: str = "memory" # "memory", "redis"
304 redis_url: str = Field(
305 default="redis://localhost:6379/0",
306 validation_alias="redis_session_url", # Accept both names
307 )
308 redis_host: str = "localhost" # Redis host for rate limiting and cache
309 redis_port: int = 6379 # Redis port for rate limiting and cache
310 redis_password: str | None = None
311 redis_ssl: bool = False
312 session_ttl_seconds: int = 86400 # 24 hours
313 session_sliding_window: bool = True
314 session_max_concurrent: int = 5 # Max concurrent sessions per user
316 # API Key Cache Configuration (ADR-0034: Redis-backed API key lookup)
317 # Improves API key validation from O(users×keys) to O(1)
318 # Redis DB allocation: 0=sessions, 1=checkpoints, 2=api-key-cache, 3=rate-limiting
319 api_key_cache_enabled: bool = True # Enable Redis cache for API key validation
320 api_key_cache_db: int = 3 # Redis database number for API key cache (ISOLATED from L2 cache DB 2)
321 api_key_cache_ttl: int = 3600 # Cache TTL in seconds (1 hour)
323 # Storage Backend Configuration (for compliance data retention)
324 # Conversation Storage (uses checkpoint backend by default)
325 conversation_storage_backend: str = "checkpoint" # "checkpoint" (uses checkpoint_backend), "database"
327 # GDPR/HIPAA/SOC2 Compliance Storage (ADR-0041: Pure PostgreSQL)
328 # Storage for user profiles, preferences, consents, conversations, and audit logs
329 # CRITICAL: Must use "postgres" in production (in-memory is DEVELOPMENT ONLY)
330 gdpr_storage_backend: str = "memory" # "postgres" (production), "memory" (dev/test only)
331 gdpr_postgres_url: str = "postgresql://postgres:postgres@localhost:5432/gdpr"
333 # GDPR Storage Configuration
334 # - User profiles: Until deletion request (GDPR Article 17)
335 # - Preferences: Until deletion request
336 # - Consents: 7 years (GDPR Article 7, legal requirement)
337 # - Conversations: 90 days (GDPR Article 5(1)(e), configurable)
338 # - Audit logs: 7 years (HIPAA §164.316(b)(2)(i), SOC2 CC6.6)
340 # Audit Log Cold Storage (for long-term compliance archival)
341 audit_log_cold_storage_backend: str | None = None # None, "s3", "gcs", "azure", "local"
342 audit_log_cold_storage_path: str | None = None # Local path or bucket name
344 # S3 Configuration (for audit log archival)
345 aws_s3_bucket: str | None = None # S3 bucket for audit log archival
346 aws_s3_region: str | None = None # S3 region (defaults to aws_region if not set)
347 aws_s3_prefix: str = "audit-logs/" # S3 key prefix for audit logs
349 # GCS Configuration (for audit log archival)
350 gcp_storage_bucket: str | None = None # GCS bucket for audit log archival
351 gcp_storage_prefix: str = "audit-logs/" # GCS object prefix for audit logs
352 gcp_credentials_path: str | None = None # Path to GCP service account credentials JSON
354 # Azure Blob Storage Configuration (for audit log archival)
355 azure_storage_account: str | None = None # Azure storage account name
356 azure_storage_container: str | None = None # Azure blob container for audit logs
357 azure_storage_prefix: str = "audit-logs/" # Blob prefix for audit logs
358 azure_storage_connection_string: str | None = None # Azure storage connection string
360 model_config = SettingsConfigDict(
361 env_file=".env",
362 env_file_encoding="utf-8",
363 case_sensitive=False,
364 extra="ignore", # Ignore extra environment variables
365 )
367 def model_post_init(self, __context: Any) -> None:
368 """
369 Pydantic v2 hook called after model initialization.
371 Performs security validation to ensure production configuration is secure.
372 """
373 # Validate production security configuration
374 self.validate_production_config()
376 # Validate CORS configuration
377 self.validate_cors_config()
379 @field_validator("cors_allowed_origins", "code_execution_allowed_domains", "code_execution_allowed_imports", mode="before")
380 @classmethod
381 def parse_comma_separated_list(cls, v: Any) -> Any:
382 """Parse comma-separated strings from environment variables into lists"""
383 if isinstance(v, str): 383 ↛ 385line 383 didn't jump to line 385 because the condition on line 383 was never true
384 # Split by comma and strip whitespace
385 return [item.strip() for item in v.split(",") if item.strip()]
386 return v
388 def validate_production_config(self) -> None:
389 """
390 Validate that production configuration is secure.
392 SECURITY: Prevents CWE-1188 (Initialization with Insecure Default) by
393 enforcing secure configuration in production environments.
395 Raises:
396 ValueError: If production configuration is insecure
397 """
398 # Only validate in production/staging environments
399 if self.environment.lower() not in ("production", "staging", "prod", "stg"):
400 return
402 errors = []
404 # Check 1: Auth provider must not be inmemory in production
405 if self.auth_provider.lower() == "inmemory": 405 ↛ 406line 405 didn't jump to line 406 because the condition on line 405 was never true
406 errors.append(
407 "AUTH_PROVIDER=inmemory is not allowed in production. "
408 "Use AUTH_PROVIDER=keycloak or another production-grade provider."
409 )
411 # Check 2: Mock authorization must be explicitly disabled
412 if self.get_mock_authorization_enabled(): 412 ↛ 413line 412 didn't jump to line 413 because the condition on line 412 was never true
413 errors.append("Mock authorization must be disabled in production. Set ENABLE_MOCK_AUTHORIZATION=false")
415 # Check 3: JWT secret key must be set
416 if not self.jwt_secret_key or self.jwt_secret_key == "change-this-in-production": 416 ↛ 417line 416 didn't jump to line 417 because the condition on line 416 was never true
417 errors.append(
418 "JWT_SECRET_KEY must be set to a secure value in production. "
419 "Generate a strong secret key and set it via environment variable."
420 )
422 # Check 4: GDPR storage must use database in production
423 if self.gdpr_storage_backend == "memory": 423 ↛ 424line 423 didn't jump to line 424 because the condition on line 423 was never true
424 errors.append(
425 "GDPR_STORAGE_BACKEND=memory is not allowed in production. Use GDPR_STORAGE_BACKEND=postgres for compliance."
426 )
428 # Check 5: Code execution should be explicitly enabled if needed
429 # (Already disabled by default, but log warning if enabled without proper config)
430 if self.enable_code_execution and self.code_execution_backend not in ("kubernetes", "docker-engine"): 430 ↛ 431line 430 didn't jump to line 431 because the condition on line 430 was never true
431 errors.append(
432 "Code execution is enabled but backend is not set to kubernetes or docker-engine. "
433 f"Current: {self.code_execution_backend}"
434 )
436 if errors: 436 ↛ 437line 436 didn't jump to line 437 because the condition on line 436 was never true
437 error_msg = (
438 "PRODUCTION CONFIGURATION SECURITY ERRORS:\n\n"
439 + "\n\n".join(f" {i + 1}. {err}" for i, err in enumerate(errors))
440 + "\n\nProduction deployment blocked to prevent security vulnerabilities."
441 )
442 raise ValueError(error_msg)
444 def get_mock_authorization_enabled(self) -> bool:
445 """
446 Get the effective value of enable_mock_authorization based on environment.
448 SECURITY: Mock authorization is disabled by default in production.
449 In development, it's enabled by default for better developer experience.
451 Returns:
452 bool: True if mock authorization should be enabled, False otherwise
453 """
454 if self.enable_mock_authorization is not None:
455 # Explicit override from environment variable
456 return self.enable_mock_authorization
458 # Auto-determine based on environment
459 # Enable in development, disable in production/staging
460 return self.environment == "development"
462 def get_cors_origins(self) -> list[str]:
463 """
464 Get the effective CORS allowed origins based on environment and configuration.
466 SECURITY: Returns empty list (no CORS) by default in production for security.
467 In development, defaults to localhost URLs if not explicitly configured.
469 Returns:
470 List[str]: List of allowed CORS origins
471 """
472 if self.cors_allowed_origins:
473 # Explicit configuration from environment variable
474 return self.cors_allowed_origins
476 # Auto-determine based on environment
477 if self.environment == "development":
478 # Development: Allow common localhost URLs
479 return ["http://localhost:3000", "http://localhost:8000", "http://localhost:5173"]
480 else:
481 # Production/staging: No CORS by default (fail-closed)
482 return []
484 def validate_cors_config(self) -> None:
485 """
486 Validate CORS configuration for security issues.
488 SECURITY: Prevents wildcard CORS with credentials in production.
489 This configuration is rejected by browsers and is a security risk.
491 Raises:
492 ValueError: If insecure CORS configuration detected in production
493 """
494 origins = self.get_cors_origins()
496 # Check for wildcard CORS in production
497 if self.environment == "production" and "*" in origins:
498 msg = (
499 "CRITICAL: Wildcard CORS (allow_origins=['*']) is not allowed in production. "
500 "This is a security risk and browsers will reject it when allow_credentials=True. "
501 "Set CORS_ALLOWED_ORIGINS to specific domains or set ENVIRONMENT=development for local testing."
502 )
503 raise ValueError(msg)
505 # Warn about wildcard in non-production environments
506 if self.environment != "production" and "*" in origins:
507 import logging
509 logger = logging.getLogger(__name__)
510 logger.warning(
511 "SECURITY WARNING: Wildcard CORS detected in %s environment. "
512 "This should only be used for local development, never in production.",
513 self.environment,
514 )
516 def _validate_fallback_credentials(self) -> None:
517 """
518 Validate that fallback models have corresponding API keys configured.
520 Logs warnings for missing credentials but doesn't block startup.
521 This helps users catch configuration errors early.
522 """
523 if not self.enable_fallback or not self.fallback_models:
524 return
526 # Map model patterns to required credentials
527 provider_patterns = {
528 "anthropic": (["claude", "anthropic"], "anthropic_api_key"),
529 "openai": (["gpt-", "o1-", "davinci"], "openai_api_key"),
530 "google": (["gemini", "palm", "bison"], "google_api_key"),
531 "azure": (["azure/"], "azure_api_key"),
532 "bedrock": (["bedrock/"], "aws_access_key_id"),
533 }
535 missing_creds = []
537 for model in self.fallback_models:
538 model_lower = model.lower()
540 # Determine which provider this model belongs to
541 for provider, (patterns, cred_attr) in provider_patterns.items(): 541 ↛ 537line 541 didn't jump to line 537 because the loop on line 541 didn't complete
542 if any(pattern in model_lower for pattern in patterns):
543 # Check if the credential is configured
544 if not getattr(self, cred_attr, None):
545 missing_creds.append((model, provider, cred_attr))
546 break
548 # Log warnings for missing credentials
549 if missing_creds:
550 import logging
552 logger = logging.getLogger(__name__)
553 logger.warning(
554 "Fallback models configured without required credentials. "
555 "These fallbacks will fail at runtime if the primary provider fails."
556 )
557 for model, provider, cred in missing_creds:
558 logger.warning(f" - Model '{model}' (provider: {provider}) requires '{cred.upper()}' environment variable")
560 def load_secrets(self) -> None: # noqa: C901
561 """
562 Load secrets from Infisical or environment variables.
564 Implements fail-closed security pattern - critical secrets have no fallbacks.
565 Secrets are now loaded conditionally based on feature flags to reduce noise.
566 """
567 secrets_mgr = get_secrets_manager()
569 # Load JWT secret (no fallback - fail-closed pattern)
570 if not self.jwt_secret_key: 570 ↛ 571line 570 didn't jump to line 571 because the condition on line 570 was never true
571 self.jwt_secret_key = secrets_mgr.get_secret("JWT_SECRET_KEY", fallback=None)
573 # Load HIPAA integrity secret ONLY if HIPAA controls are being used
574 # Check for enable_hipaa flag or hipaa_integrity_secret env var
575 if not self.hipaa_integrity_secret and hasattr(self, "enable_hipaa") and self.enable_hipaa: 575 ↛ 576line 575 didn't jump to line 576 because the condition on line 575 was never true
576 self.hipaa_integrity_secret = secrets_mgr.get_secret("HIPAA_INTEGRITY_SECRET", fallback=None)
578 # Load context encryption key ONLY if context encryption is enabled
579 if not self.context_encryption_key and self.enable_context_encryption: 579 ↛ 580line 579 didn't jump to line 580 because the condition on line 579 was never true
580 self.context_encryption_key = secrets_mgr.get_secret("CONTEXT_ENCRYPTION_KEY", fallback=None)
582 # Load LLM API keys based on ACTIVE provider and fallbacks
583 # Primary provider
584 if self.llm_provider == "anthropic" and not self.anthropic_api_key: 584 ↛ 585line 584 didn't jump to line 585 because the condition on line 584 was never true
585 self.anthropic_api_key = secrets_mgr.get_secret("ANTHROPIC_API_KEY", fallback=None)
586 elif self.llm_provider == "openai" and not self.openai_api_key: 586 ↛ 587line 586 didn't jump to line 587 because the condition on line 586 was never true
587 self.openai_api_key = secrets_mgr.get_secret("OPENAI_API_KEY", fallback=None)
588 elif self.llm_provider == "google" and not self.google_api_key:
589 self.google_api_key = secrets_mgr.get_secret("GOOGLE_API_KEY", fallback=None)
590 elif self.llm_provider == "azure" and not self.azure_api_key: 590 ↛ 591line 590 didn't jump to line 591 because the condition on line 590 was never true
591 self.azure_api_key = secrets_mgr.get_secret("AZURE_API_KEY", fallback=None)
592 elif self.llm_provider == "bedrock" and not self.aws_access_key_id: 592 ↛ 593line 592 didn't jump to line 593 because the condition on line 592 was never true
593 self.aws_access_key_id = secrets_mgr.get_secret("AWS_ACCESS_KEY_ID", fallback=None)
594 self.aws_secret_access_key = secrets_mgr.get_secret("AWS_SECRET_ACCESS_KEY", fallback=None)
596 # Load fallback providers if fallback enabled
597 if self.enable_fallback and self.fallback_models: 597 ↛ 629line 597 didn't jump to line 629 because the condition on line 597 was always true
598 # Check which providers are needed for fallbacks
599 fallback_providers = set()
600 for model in self.fallback_models:
601 model_lower = model.lower()
602 if "claude" in model_lower or "anthropic" in model_lower:
603 fallback_providers.add("anthropic")
604 elif "gpt-" in model_lower or "o1-" in model_lower: 604 ↛ 606line 604 didn't jump to line 606 because the condition on line 604 was always true
605 fallback_providers.add("openai")
606 elif "gemini" in model_lower or "palm" in model_lower:
607 fallback_providers.add("google")
608 elif "azure/" in model_lower:
609 fallback_providers.add("azure")
610 elif "bedrock/" in model_lower:
611 fallback_providers.add("bedrock")
613 # Load secrets for fallback providers only
614 if "anthropic" in fallback_providers and not self.anthropic_api_key: 614 ↛ 616line 614 didn't jump to line 616 because the condition on line 614 was always true
615 self.anthropic_api_key = secrets_mgr.get_secret("ANTHROPIC_API_KEY", fallback=None)
616 if "openai" in fallback_providers and not self.openai_api_key: 616 ↛ 618line 616 didn't jump to line 618 because the condition on line 616 was always true
617 self.openai_api_key = secrets_mgr.get_secret("OPENAI_API_KEY", fallback=None)
618 if "google" in fallback_providers and not self.google_api_key: 618 ↛ 619line 618 didn't jump to line 619 because the condition on line 618 was never true
619 self.google_api_key = secrets_mgr.get_secret("GOOGLE_API_KEY", fallback=None)
620 if "azure" in fallback_providers and not self.azure_api_key: 620 ↛ 621line 620 didn't jump to line 621 because the condition on line 620 was never true
621 self.azure_api_key = secrets_mgr.get_secret("AZURE_API_KEY", fallback=None)
622 if "bedrock" in fallback_providers: 622 ↛ 623line 622 didn't jump to line 623 because the condition on line 622 was never true
623 if not self.aws_access_key_id:
624 self.aws_access_key_id = secrets_mgr.get_secret("AWS_ACCESS_KEY_ID", fallback=None)
625 if not self.aws_secret_access_key:
626 self.aws_secret_access_key = secrets_mgr.get_secret("AWS_SECRET_ACCESS_KEY", fallback=None)
628 # Load OpenFGA configuration (if any OpenFGA settings are configured)
629 if self.openfga_api_url and self.openfga_api_url != "http://localhost:8080": 629 ↛ 630line 629 didn't jump to line 630 because the condition on line 629 was never true
630 if not self.openfga_store_id:
631 self.openfga_store_id = secrets_mgr.get_secret("OPENFGA_STORE_ID", fallback=None)
632 if not self.openfga_model_id:
633 self.openfga_model_id = secrets_mgr.get_secret("OPENFGA_MODEL_ID", fallback=None)
635 # Load LangSmith configuration ONLY if enabled
636 if self.langsmith_tracing and not self.langsmith_api_key: 636 ↛ 637line 636 didn't jump to line 637 because the condition on line 636 was never true
637 self.langsmith_api_key = secrets_mgr.get_secret("LANGSMITH_API_KEY", fallback=None)
639 # Load LangGraph Platform configuration (if deployment URL configured)
640 if self.langgraph_deployment_url and not self.langgraph_api_key: 640 ↛ 641line 640 didn't jump to line 641 because the condition on line 640 was never true
641 self.langgraph_api_key = secrets_mgr.get_secret("LANGGRAPH_API_KEY", fallback=None)
643 # Load Keycloak configuration ONLY if using Keycloak auth provider
644 if self.auth_provider == "keycloak": 644 ↛ 645line 644 didn't jump to line 645 because the condition on line 644 was never true
645 if not self.keycloak_client_secret:
646 self.keycloak_client_secret = secrets_mgr.get_secret("KEYCLOAK_CLIENT_SECRET", fallback=None)
647 if not self.keycloak_admin_password:
648 self.keycloak_admin_password = secrets_mgr.get_secret("KEYCLOAK_ADMIN_PASSWORD", fallback=None)
650 # Checkpoint configuration loaded on-demand (redis backend specific)
652 # Load cloud storage credentials if cold storage is configured
653 if self.audit_log_cold_storage_backend == "s3": 653 ↛ 655line 653 didn't jump to line 655 because the condition on line 653 was never true
654 # S3 credentials already loaded via AWS Bedrock configuration if set
655 pass
656 elif self.audit_log_cold_storage_backend == "azure": 656 ↛ 657line 656 didn't jump to line 657 because the condition on line 656 was never true
657 if not self.azure_storage_connection_string:
658 self.azure_storage_connection_string = secrets_mgr.get_secret("AZURE_STORAGE_CONNECTION_STRING", fallback=None)
659 elif self.audit_log_cold_storage_backend == "gcs" and not self.gcp_credentials_path: 659 ↛ 662line 659 didn't jump to line 662 because the condition on line 659 was never true
660 # GCP credentials are typically loaded from a file path
661 # or via GOOGLE_APPLICATION_CREDENTIALS environment variable
662 pass
664 # ========================================================================
665 # REDIS URL ALIASES (for test compatibility)
666 # ========================================================================
667 @property
668 def redis_checkpoint_url(self) -> str:
669 """Alias for checkpoint_redis_url (test compatibility)"""
670 return self.checkpoint_redis_url
672 @property
673 def redis_session_url(self) -> str:
674 """Alias for redis_url (test compatibility)"""
675 return self.redis_url
677 def get_secret(self, key: str, fallback: str | None = None) -> str | None:
678 """
679 Get a secret value from Infisical
681 Args:
682 key: Secret key
683 fallback: Fallback value if not found
685 Returns:
686 Secret value
687 """
688 secrets_mgr = get_secrets_manager()
689 return secrets_mgr.get_secret(key, fallback=fallback)
692# Global settings instance
693settings = Settings()
695# Load secrets on initialization
696# SECURITY: Do not log exception details - may contain credentials (CWE-532)
697try:
698 settings.load_secrets()
699except Exception:
700 # Note: We intentionally don't log the exception message as it may contain
701 # Infisical credentials, connection strings, or other sensitive data.
702 # The fallback to environment variables is silent and graceful.
703 import logging
705 _logger = logging.getLogger(__name__)
706 _logger.warning("Failed to load secrets from Infisical, using environment fallback")
708# Validate fallback model credentials
709settings._validate_fallback_credentials()