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

1""" 

2Configuration management with Infisical secrets integration 

3""" 

4 

5from typing import Any 

6 

7from pydantic import Field, field_validator 

8from pydantic_settings import BaseSettings, SettingsConfigDict 

9 

10from mcp_server_langgraph.secrets.manager import get_secrets_manager 

11 

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 

17 

18 

19class Settings(BaseSettings): 

20 """Application settings with Infisical secrets support""" 

21 

22 # Service 

23 service_name: str = "mcp-server-langgraph" 

24 service_version: str = __version__ # Read from package version 

25 environment: str = "development" 

26 

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 

31 

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) 

37 

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 

43 

44 # HIPAA Compliance (only required if processing PHI) 

45 hipaa_integrity_secret: str | None = None 

46 

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 

52 

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 

57 

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 

66 

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) 

71 

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) 

78 

79 # Observability Backend Selection 

80 observability_backend: str = "both" # opentelemetry, langsmith, both 

81 

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) 

88 

89 # LLM Provider (litellm integration) 

90 llm_provider: str = "google" # google, anthropic, openai, ollama, azure, bedrock, vertex_ai 

91 

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 

95 

96 # OpenAI 

97 openai_api_key: str | None = None 

98 openai_organization: str | None = None 

99 

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" 

105 

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 

115 

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 

121 

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" 

126 

127 # Ollama (for local/open-source models) 

128 ollama_base_url: str = "http://localhost:11434" 

129 

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 

141 

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 

149 

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 

156 

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 ] 

166 

167 # Agent 

168 max_iterations: int = 10 

169 enable_checkpointing: bool = True 

170 

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 

177 

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" 

183 

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 

191 

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 ) 

200 

201 context_cache_size: int = 100 # LRU cache size for loaded contexts 

202 

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 

209 

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 

213 

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 

224 

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) 

250 

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 

254 

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) 

258 

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 

266 

267 # OpenFGA 

268 openfga_api_url: str = "http://localhost:8080" 

269 openfga_store_id: str | None = None 

270 openfga_model_id: str | None = None 

271 

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 

277 

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" 

282 

283 # Authentication Provider 

284 auth_provider: str = "inmemory" # "inmemory", "keycloak" 

285 auth_mode: str = "token" # "token" (JWT), "session" 

286 

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 

291 

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 

301 

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 

315 

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) 

322 

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" 

326 

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" 

332 

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) 

339 

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 

343 

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 

348 

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 

353 

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 

359 

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 ) 

366 

367 def model_post_init(self, __context: Any) -> None: 

368 """ 

369 Pydantic v2 hook called after model initialization. 

370 

371 Performs security validation to ensure production configuration is secure. 

372 """ 

373 # Validate production security configuration 

374 self.validate_production_config() 

375 

376 # Validate CORS configuration 

377 self.validate_cors_config() 

378 

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 

387 

388 def validate_production_config(self) -> None: 

389 """ 

390 Validate that production configuration is secure. 

391 

392 SECURITY: Prevents CWE-1188 (Initialization with Insecure Default) by 

393 enforcing secure configuration in production environments. 

394 

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 

401 

402 errors = [] 

403 

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 ) 

410 

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") 

414 

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 ) 

421 

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 ) 

427 

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 ) 

435 

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) 

443 

444 def get_mock_authorization_enabled(self) -> bool: 

445 """ 

446 Get the effective value of enable_mock_authorization based on environment. 

447 

448 SECURITY: Mock authorization is disabled by default in production. 

449 In development, it's enabled by default for better developer experience. 

450 

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 

457 

458 # Auto-determine based on environment 

459 # Enable in development, disable in production/staging 

460 return self.environment == "development" 

461 

462 def get_cors_origins(self) -> list[str]: 

463 """ 

464 Get the effective CORS allowed origins based on environment and configuration. 

465 

466 SECURITY: Returns empty list (no CORS) by default in production for security. 

467 In development, defaults to localhost URLs if not explicitly configured. 

468 

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 

475 

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 [] 

483 

484 def validate_cors_config(self) -> None: 

485 """ 

486 Validate CORS configuration for security issues. 

487 

488 SECURITY: Prevents wildcard CORS with credentials in production. 

489 This configuration is rejected by browsers and is a security risk. 

490 

491 Raises: 

492 ValueError: If insecure CORS configuration detected in production 

493 """ 

494 origins = self.get_cors_origins() 

495 

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) 

504 

505 # Warn about wildcard in non-production environments 

506 if self.environment != "production" and "*" in origins: 

507 import logging 

508 

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 ) 

515 

516 def _validate_fallback_credentials(self) -> None: 

517 """ 

518 Validate that fallback models have corresponding API keys configured. 

519 

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 

525 

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 } 

534 

535 missing_creds = [] 

536 

537 for model in self.fallback_models: 

538 model_lower = model.lower() 

539 

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 

547 

548 # Log warnings for missing credentials 

549 if missing_creds: 

550 import logging 

551 

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") 

559 

560 def load_secrets(self) -> None: # noqa: C901 

561 """ 

562 Load secrets from Infisical or environment variables. 

563 

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() 

568 

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) 

572 

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) 

577 

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) 

581 

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) 

595 

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") 

612 

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) 

627 

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) 

634 

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) 

638 

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) 

642 

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) 

649 

650 # Checkpoint configuration loaded on-demand (redis backend specific) 

651 

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 

663 

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 

671 

672 @property 

673 def redis_session_url(self) -> str: 

674 """Alias for redis_url (test compatibility)""" 

675 return self.redis_url 

676 

677 def get_secret(self, key: str, fallback: str | None = None) -> str | None: 

678 """ 

679 Get a secret value from Infisical 

680 

681 Args: 

682 key: Secret key 

683 fallback: Fallback value if not found 

684 

685 Returns: 

686 Secret value 

687 """ 

688 secrets_mgr = get_secrets_manager() 

689 return secrets_mgr.get_secret(key, fallback=fallback) 

690 

691 

692# Global settings instance 

693settings = Settings() 

694 

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 

704 

705 _logger = logging.getLogger(__name__) 

706 _logger.warning("Failed to load secrets from Infisical, using environment fallback") 

707 

708# Validate fallback model credentials 

709settings._validate_fallback_credentials()