diff --git a/backend/app/core/config.py b/backend/app/core/config.py index 93ee9a9..5c70a42 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -21,6 +21,7 @@ class Settings(BaseSettings): # Base PROJECT_NAME: str = "DMARQ" API_V1_STR: str = "/api/v1" + ENVIRONMENT: str = "development" # Database # Default to a sub-directory so the SQLite file lives in a location that @@ -62,6 +63,7 @@ class Settings(BaseSettings): # by an external auth proxy (e.g. Authelia, OAuth2 Proxy, Traefik Forward Auth). # Never expose an AUTH_DISABLED instance directly to the internet. AUTH_DISABLED: bool = False + ALLOW_AUTH_DISABLED_IN_PRODUCTION: bool = False # ── Logto OIDC ──────────────────────────────────────────────────────────── # Set these to enable Logto-based authentication. @@ -79,12 +81,18 @@ class Settings(BaseSettings): LOGTO_APP_SECRET: Optional[str] = None LOGTO_REDIRECT_URI: Optional[str] = None LOGTO_SKIP_SSL_VERIFY: bool = False + ALLOW_LOGTO_SKIP_SSL_VERIFY_IN_PRODUCTION: bool = False @property def logto_configured(self) -> bool: """Return True when the minimum Logto settings are present.""" return bool(self.LOGTO_ENDPOINT and self.LOGTO_APP_ID and self.LOGTO_APP_SECRET) + @property + def is_production(self) -> bool: + """Return True when the app is explicitly running in production mode.""" + return self.ENVIRONMENT.strip().lower() in {"prod", "production"} + @validator("ADMIN_API_KEY", pre=True, always=True) @classmethod def validate_admin_api_key( @@ -101,12 +109,20 @@ class Settings(BaseSettings): return v or None @validator("SECRET_KEY", pre=True, always=True) - def validate_secret_key(cls, v: Optional[str]) -> str: # pylint: disable=no-self-argument + def validate_secret_key( # pylint: disable=no-self-argument + cls, v: Optional[str], values + ) -> str: """Validate and generate SECRET_KEY if not provided.""" # Default insecure key that should never be used DEFAULT_INSECURE_KEY = "CHANGE_THIS_TO_A_RANDOM_SECRET_IN_PRODUCTION" + environment = str(values.get("ENVIRONMENT", "development")).strip().lower() + is_production = environment in {"prod", "production"} if v is None or v == "" or v == DEFAULT_INSECURE_KEY: + if is_production: + raise ValueError( + "SECRET_KEY must be set to a stable random value when ENVIRONMENT=production." + ) # Generate a secure random key generated_key = secrets.token_hex(32) logger.warning( @@ -119,6 +135,10 @@ class Settings(BaseSettings): # Check if key is too short if len(v) < 32: + if is_production: + raise ValueError( + "SECRET_KEY must be at least 32 characters when ENVIRONMENT=production." + ) logger.warning( "SECRET_KEY is too short (%s characters). " "Recommended minimum is 32 characters for security.", diff --git a/backend/app/core/startup_checks.py b/backend/app/core/startup_checks.py new file mode 100644 index 0000000..be65e9a --- /dev/null +++ b/backend/app/core/startup_checks.py @@ -0,0 +1,95 @@ +""" +Startup configuration checks for production deployments. +""" + +import logging +from dataclasses import dataclass + +from sqlalchemy.engine import make_url + +from app.core.config import Settings + +logger = logging.getLogger(__name__) + + +class StartupConfigurationError(RuntimeError): + """Raised when production configuration is unsafe enough to block startup.""" + + +@dataclass(frozen=True) +class StartupCheckResult: + """Result of validating startup configuration.""" + + errors: tuple[str, ...] + warnings: tuple[str, ...] + + @property + def ok(self) -> bool: + return not self.errors + + +def _uses_sqlite(database_url: str) -> bool: + try: + return make_url(database_url).drivername.startswith("sqlite") + except Exception: # pylint: disable=broad-exception-caught + return False + + +def validate_startup_configuration(settings: Settings) -> StartupCheckResult: + """Return production startup errors and warnings for the provided settings.""" + errors: list[str] = [] + warnings: list[str] = [] + + if not settings.is_production: + return StartupCheckResult(errors=(), warnings=()) + + if settings.AUTH_DISABLED and not settings.ALLOW_AUTH_DISABLED_IN_PRODUCTION: + errors.append( + "AUTH_DISABLED=true is not allowed in production unless " + "ALLOW_AUTH_DISABLED_IN_PRODUCTION=true is also set." + ) + + if ( + settings.LOGTO_SKIP_SSL_VERIFY + and not settings.ALLOW_LOGTO_SKIP_SSL_VERIFY_IN_PRODUCTION + ): + errors.append( + "LOGTO_SKIP_SSL_VERIFY=true is not allowed in production unless " + "ALLOW_LOGTO_SKIP_SSL_VERIFY_IN_PRODUCTION=true is also set." + ) + + if not settings.AUTH_DISABLED and not settings.ADMIN_API_KEY and not settings.logto_configured: + errors.append( + "Production startup requires Logto settings or ADMIN_API_KEY. " + "Set LOGTO_ENDPOINT, LOGTO_APP_ID, and LOGTO_APP_SECRET, or set ADMIN_API_KEY." + ) + + if settings.ADMIN_API_KEY and len(settings.ADMIN_API_KEY) < 32: + errors.append("ADMIN_API_KEY must be at least 32 characters in production.") + + if settings.SECRET_KEY is None or len(settings.SECRET_KEY) < 32: + errors.append("SECRET_KEY must be at least 32 characters in production.") + + if _uses_sqlite(settings.DATABASE_URL): + warnings.append( + "DATABASE_URL uses SQLite in production. This is supported for small " + "single-node deployments, but PostgreSQL is recommended for durable production use." + ) + + return StartupCheckResult(errors=tuple(errors), warnings=tuple(warnings)) + + +def run_startup_checks(settings: Settings) -> StartupCheckResult: + """Log startup validation results and raise when production config is unsafe.""" + result = validate_startup_configuration(settings) + for warning in result.warnings: + logger.warning("Startup configuration warning: %s", warning) + + if result.errors: + for error in result.errors: + logger.error("Startup configuration error: %s", error) + raise StartupConfigurationError( + "Unsafe production configuration: " + " ".join(result.errors) + ) + + return result diff --git a/backend/app/main.py b/backend/app/main.py index a7f8424..7a8b8e3 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -18,6 +18,7 @@ from app.api.api_v1.api import api_router from app.core.config import get_settings from app.core.database import Base, SessionLocal, engine from app.core.security import add_api_key, generate_api_key, require_admin_auth +from app.core.startup_checks import run_startup_checks from app.middleware.auth import AuthRedirectMiddleware from app.middleware.security import SecurityHeadersMiddleware from app.models.mail_source import MailSource # noqa: F401 – ensure table is registered @@ -316,6 +317,8 @@ def create_app() -> FastAPI: """Initialize background tasks and security on application startup""" global background_task # pylint: disable=global-statement + run_startup_checks(settings) + # Ensure all tables exist (no-op if already present) Base.metadata.create_all(bind=engine) diff --git a/backend/app/tests/test_config.py b/backend/app/tests/test_config.py index 41fbf56..66aa8b7 100644 --- a/backend/app/tests/test_config.py +++ b/backend/app/tests/test_config.py @@ -6,6 +6,8 @@ previously caused a JSONDecodeError in pydantic_settings v2 before validators could run (see: pydantic_settings sources/providers/env.py decode_complex_value). """ +import pytest + from app.core.config import Settings from app.core.database import _ensure_sqlite_dir, _make_sync_db_url @@ -184,3 +186,125 @@ class TestLogtoSettings: settings = Settings() assert settings.LOGTO_SKIP_SSL_VERIFY is True + + +class TestProductionStartupSettings: + """Tests for production-critical settings and startup validation.""" + + def test_environment_defaults_to_development(self): + settings = Settings() + + assert settings.ENVIRONMENT == "development" + assert settings.is_production is False + + def test_production_requires_stable_secret_key(self): + with pytest.raises(Exception) as excinfo: + Settings(ENVIRONMENT="production") + + assert "SECRET_KEY" in str(excinfo.value) + + def test_production_rejects_short_secret_key(self): + with pytest.raises(Exception) as excinfo: + Settings(ENVIRONMENT="production", SECRET_KEY="short") + + assert "SECRET_KEY" in str(excinfo.value) + + def test_production_startup_passes_with_admin_key(self): + from app.core.startup_checks import validate_startup_configuration + + settings = Settings( + ENVIRONMENT="production", + SECRET_KEY="s" * 32, + ADMIN_API_KEY="a" * 64, + DATABASE_URL="postgresql://dmarq:password@db/dmarq", + ) + + result = validate_startup_configuration(settings) + + assert result.ok is True + assert result.errors == () + + def test_production_startup_requires_auth_path(self): + from app.core.startup_checks import validate_startup_configuration + + settings = Settings( + ENVIRONMENT="production", + SECRET_KEY="s" * 32, + DATABASE_URL="postgresql://dmarq:password@db/dmarq", + ) + + result = validate_startup_configuration(settings) + + assert result.ok is False + assert any("Logto settings or ADMIN_API_KEY" in error for error in result.errors) + + def test_production_startup_rejects_auth_disabled_without_override(self): + from app.core.startup_checks import validate_startup_configuration + + settings = Settings( + ENVIRONMENT="production", + SECRET_KEY="s" * 32, + AUTH_DISABLED=True, + DATABASE_URL="postgresql://dmarq:password@db/dmarq", + ) + + result = validate_startup_configuration(settings) + + assert any("AUTH_DISABLED=true" in error for error in result.errors) + + def test_production_startup_allows_auth_disabled_with_explicit_override(self): + from app.core.startup_checks import validate_startup_configuration + + settings = Settings( + ENVIRONMENT="production", + SECRET_KEY="s" * 32, + AUTH_DISABLED=True, + ALLOW_AUTH_DISABLED_IN_PRODUCTION=True, + DATABASE_URL="postgresql://dmarq:password@db/dmarq", + ) + + result = validate_startup_configuration(settings) + + assert result.ok is True + + def test_production_startup_rejects_logto_ssl_skip_without_override(self): + from app.core.startup_checks import validate_startup_configuration + + settings = Settings( + ENVIRONMENT="production", + SECRET_KEY="s" * 32, + ADMIN_API_KEY="a" * 64, + LOGTO_SKIP_SSL_VERIFY=True, + DATABASE_URL="postgresql://dmarq:password@db/dmarq", + ) + + result = validate_startup_configuration(settings) + + assert any("LOGTO_SKIP_SSL_VERIFY=true" in error for error in result.errors) + + def test_production_startup_warns_for_sqlite(self): + from app.core.startup_checks import validate_startup_configuration + + settings = Settings( + ENVIRONMENT="production", + SECRET_KEY="s" * 32, + ADMIN_API_KEY="a" * 64, + DATABASE_URL="sqlite:///./data/dmarq.db", + ) + + result = validate_startup_configuration(settings) + + assert result.ok is True + assert any("SQLite" in warning for warning in result.warnings) + + def test_run_startup_checks_raises_for_unsafe_production(self): + from app.core.startup_checks import StartupConfigurationError, run_startup_checks + + settings = Settings( + ENVIRONMENT="production", + SECRET_KEY="s" * 32, + DATABASE_URL="postgresql://dmarq:password@db/dmarq", + ) + + with pytest.raises(StartupConfigurationError): + run_startup_checks(settings) diff --git a/docs/development/roadmap.md b/docs/development/roadmap.md index 0eb2bc5..6591178 100644 --- a/docs/development/roadmap.md +++ b/docs/development/roadmap.md @@ -69,13 +69,13 @@ Quality bar: Objective: make self-hosted deployments safer. Priority tasks: -- Add startup validation for production settings. - Add backup and restore documentation. - Add a release checklist covering migrations, tests, and smoke checks. Delivered: - Documented a 1Password secret-injection deployment flow for local, Docker Compose, and systemd deployments. - Redacted secret-like values from mail-source diagnostics, stored import history, OAuth error logs, and validated admin auth contexts. +- Added production startup validation for stable secrets, configured auth, auth-disabled mode, and Logto TLS verification. ## Later Milestones diff --git a/docs/milestones.md b/docs/milestones.md index ce52628..44a2a6d 100644 --- a/docs/milestones.md +++ b/docs/milestones.md @@ -98,9 +98,9 @@ Delivered: - 1Password-based secret injection flow for local, Docker Compose, and systemd deployments. - Raw mailbox/OAuth secrets are redacted from mail-source diagnostics, import history, and OAuth error logs. - Admin authentication contexts no longer carry raw API keys after validation. +- Production startup checks now fail early for missing stable secrets, missing auth configuration, auth-disabled mode, or disabled Logto TLS verification. Planned: -- Add startup checks for production-critical configuration. - Add backup/restore guidance for database deployments. - Add release checklist covering migrations, tests, and smoke checks. diff --git a/docs/todo.md b/docs/todo.md index 101e720..cfded22 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -157,6 +157,7 @@ This file tracks the specific implementation tasks for each milestone of the DMA ## Future Milestones - [x] Production secret handling guide using 1Password injection - [x] Redact mailbox/OAuth secrets from diagnostics, logs, import history, and auth contexts +- [x] Add startup checks for production-critical configuration - [ ] Apprise notifications and alert rules - [ ] DNS health guidance and Cloudflare read-only inspection - [ ] Guided setup and operator health pages