Coverage for src/lumen/config.py: 100%
82 statements
« prev ^ index » next coverage.py v7.16.0, created at 2026-08-31 18:46 +0000
« prev ^ index » next coverage.py v7.16.0, created at 2026-08-31 18:46 +0000
1"""
2Using pydantic BaseSettings to have custom settings for the app.
3BaseSettings has the ability to read from environment variables and .env files.
4"""
6import os
7import sys
8from pathlib import Path
9from uuid import UUID
11from pydantic import AliasChoices, Field
12from pydantic_settings import BaseSettings, SettingsConfigDict
14SECRETS_DIR = "/run/secrets" if Path("/run/secrets").exists() else None
16# Do not load .env file when running tests to ensure isolation and reproducibility
17IS_TESTING = "pytest" in sys.modules or "PYTEST_CURRENT_TEST" in os.environ
18ENV_FILE = None if IS_TESTING else ".env"
21class DatabaseSettings(BaseSettings):
22 """
23 Main database settings
24 """
26 model_config = SettingsConfigDict(
27 env_prefix="LUMEN_DB_",
28 env_file=ENV_FILE,
29 env_file_encoding="utf-8",
30 # support docker secrets (currently not used)
31 secrets_dir=SECRETS_DIR,
32 populate_by_name=True,
33 extra="ignore",
34 )
35 url: str = Field(
36 default="postgresql://lumen:lumen@localhost:5432/lumen",
37 validation_alias=AliasChoices("lumen_db_url", "LUMEN_DB_URL"),
38 )
39 min_connections: int = Field(default=2, ge=1)
40 max_connections: int = Field(default=10, ge=1)
43class SmartSettings(BaseSettings):
44 """SMART-on-FHIR resource-server configuration (Phase 12)."""
46 model_config = SettingsConfigDict(
47 env_prefix="LUMEN_SMART_",
48 env_file=ENV_FILE,
49 env_file_encoding="utf-8",
50 extra="ignore",
51 )
53 enabled: bool = False
54 audience: str = "lumen"
55 jwks_cache_ttl: int = Field(default=300, ge=10)
57 # Maps OIDC issuer URL → tenant UUID.
58 # Example (env): LUMEN_SMART_TENANT_MAP='{"https://kc.example.com/realms/a":"<uuid>"}'
59 tenant_map: dict[str, UUID] = Field(default_factory=dict)
61 # Maps external issuer URL → internal base URL for JWKS/OIDC discovery fetching.
62 # Use when Lumen runs in Docker and cannot reach the external issuer URL directly.
63 # Example: LUMEN_SMART_INTERNAL_ISSUER_MAP='{"http://localhost:8180/realms/r":"http://keycloak:8080/realms/r"}'
64 internal_issuer_map: dict[str, str] = Field(default_factory=dict)
67class ObservabilitySettings(BaseSettings):
68 """OpenTelemetry / OTLP exporter configuration (Phase 13b). Disabled by default."""
70 model_config = SettingsConfigDict(
71 env_prefix="LUMEN_OTEL_",
72 env_file=ENV_FILE,
73 env_file_encoding="utf-8",
74 extra="ignore",
75 )
77 enabled: bool = False
78 # OTLP/HTTP endpoint base (e.g. http://otel-lgtm:4318). No trailing slash.
79 exporter_endpoint: str = "http://localhost:4318"
80 service_name: str = "lumen"
81 traces_sample_rate: float = Field(default=1.0, ge=0.0, le=1.0)
82 # Optional OTLP log export (Weg A keeps structlog-JSON on stdout by default).
83 log_export: bool = False
86class TenantEditableSettings(BaseSettings):
87 """Settings that can be overridden at the tenant level and modified via the API."""
89 model_config = SettingsConfigDict(
90 env_prefix="LUMEN_",
91 env_file=ENV_FILE,
92 env_file_encoding="utf-8",
93 extra="ignore",
94 )
96 create_on_update: bool = True
97 # Recommended baseline (docs: knowledge/VALIDIERUNG.md):
98 # keep the two cheap, "pure FHIR conformance" checks strict (structure via
99 # JSON Schema, and base R4B code enums — both free, no DB I/O), while the
100 # profile-/reference-specific checks stay opt-in for performance.
101 reference_integrity: str = "disabled" # "strict" | "lenient" | "disabled"
102 validation_strictness: str = "strict" # "strict" | "lenient" — JSON Schema structure
103 terminology_strictness: str = "strict" # "strict" | "lenient" — base R4B code bindings
104 timezone_strictness: str = "disabled" # "strict" | "lenient" | "disabled"
105 profile_validation_mode: str = "tolerant" # "tolerant" | "declared" | "enforced"
106 enforced_profiles: dict[str, list[str]] = Field(default_factory=dict)
107 ignore_unknown_search_parameters: bool = False
108 # Stage 3b (Terminology Bindings, docs/architecture/profile-validation.md): opt-in
109 # per tenant, disabled by default — Lumen stays 100% offline unless a tenant
110 # explicitly allows outbound $expand calls to a public terminology server for
111 # ValueSets the local engine can't resolve.
112 terminology_pre_expansion_enabled: bool = False
115class TerminologySettings(BaseSettings):
116 """External terminology server config for Stage 3b Pre-Expansion. Not
117 tenant-editable (a deployment-wide server choice, unlike the per-tenant
118 opt-in flag `TenantEditableSettings.terminology_pre_expansion_enabled`)."""
120 model_config = SettingsConfigDict(
121 env_prefix="LUMEN_TERMINOLOGY_",
122 env_file=ENV_FILE,
123 env_file_encoding="utf-8",
124 extra="ignore",
125 )
127 server_url: str = "https://tx.fhir.org/r4"
128 timeout_seconds: float = Field(default=10.0, gt=0.0)
131class RateLimitSettings(BaseSettings):
132 """In-memory, per-process rate limiting + request-size limiting (Phase 16
133 hardening). Not tenant-editable — a deployment-wide operational safeguard.
134 Enabled by default with a generous limit; per-process only (no Redis) —
135 each instance in a horizontally-scaled deployment counts independently.
136 """
138 model_config = SettingsConfigDict(
139 env_prefix="LUMEN_RATELIMIT_",
140 env_file=ENV_FILE,
141 env_file_encoding="utf-8",
142 extra="ignore",
143 )
145 enabled: bool = True
146 requests_per_minute: int = Field(default=300, ge=1)
147 max_body_bytes: int = Field(default=50 * 1024 * 1024, ge=1) # 50 MB
150class SecurityHeadersSettings(BaseSettings):
151 """Baseline browser security headers (Phase 16 hardening). Not tenant-editable."""
153 model_config = SettingsConfigDict(
154 env_prefix="LUMEN_SECURITY_",
155 env_file=ENV_FILE,
156 env_file_encoding="utf-8",
157 extra="ignore",
158 )
160 enabled: bool = True
161 hsts_max_age_seconds: int = Field(default=63072000, ge=0) # 2 years
164class CortexSettings(BaseSettings):
165 """In-process plugin host (Phase 18). Disabled by default — a server with no
166 plugins configured behaves exactly as it did before Cortex existed."""
168 model_config = SettingsConfigDict(
169 env_prefix="LUMEN_CORTEX_",
170 env_file=ENV_FILE,
171 env_file_encoding="utf-8",
172 extra="ignore",
173 )
175 enabled: bool = False
176 # Comma-separated "module:ClassName" (or bare "module") entries, e.g.
177 # LUMEN_CORTEX_PLUGINS='lumen_plugins.sepsis_guard:SepsisGuard'
178 plugins: str = ""
179 # Also pick up plugins published under the "lumen.cortex.plugins" entry-point group.
180 load_entry_points: bool = True
181 # How deep hook-triggered writes may cascade. 1 means a plugin's own writes
182 # do not fire hooks again — the safe default against accidental recursion.
183 max_hook_depth: int = Field(default=1, ge=0, le=5)
184 # Allow plugins to be registered at runtime (notebook DX). Development only:
185 # it lets anything holding a reference to the process add server-side code.
186 allow_runtime_registration: bool = False
189class Settings(BaseSettings):
190 """
191 Main app settings
192 """
194 model_config = SettingsConfigDict(
195 env_prefix="LUMEN_",
196 env_file=ENV_FILE,
197 env_file_encoding="utf-8",
198 # support docker secrets (currently not used)
199 secrets_dir=SECRETS_DIR,
200 extra="ignore",
201 )
203 server_address: str = "http://localhost:8000"
204 fhir_version: str = "4.3.0"
205 log_level: str = "info"
206 log_format: str = "console" # "console" | "json"
207 cors_origins: list[str] = Field(default=["*"])
208 seed_packages_dir: str | None = None
209 seed_registry_packages: str | None = None
211 # Tenant used when no auth token supplies one (anonymous / single-tenant mode).
212 default_tenant: UUID = UUID("00000000-0000-0000-0000-000000000001")
214 # tenant-specific settings (both API-editable and env-configurable)
215 tenant: TenantEditableSettings = Field(default_factory=TenantEditableSettings)
217 # integrate settings for the database and SMART-on-FHIR resource-server
218 db: DatabaseSettings = Field(default_factory=DatabaseSettings)
219 smart: SmartSettings = Field(default_factory=SmartSettings)
220 otel: ObservabilitySettings = Field(default_factory=ObservabilitySettings)
221 terminology: TerminologySettings = Field(default_factory=TerminologySettings)
222 ratelimit: RateLimitSettings = Field(default_factory=RateLimitSettings)
223 security: SecurityHeadersSettings = Field(default_factory=SecurityHeadersSettings)
224 cortex: CortexSettings = Field(default_factory=CortexSettings)
227_settings: Settings | None = None
230def get_settings() -> Settings:
231 """
232 Get the settings for the app.
233 If the settings are not set, set them.
234 """
235 global _settings
236 if _settings is None:
237 _settings = Settings()
238 return _settings