feat: add production startup checks
This commit is contained in:
@@ -21,6 +21,7 @@ class Settings(BaseSettings):
|
|||||||
# Base
|
# Base
|
||||||
PROJECT_NAME: str = "DMARQ"
|
PROJECT_NAME: str = "DMARQ"
|
||||||
API_V1_STR: str = "/api/v1"
|
API_V1_STR: str = "/api/v1"
|
||||||
|
ENVIRONMENT: str = "development"
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
# Default to a sub-directory so the SQLite file lives in a location that
|
# 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).
|
# by an external auth proxy (e.g. Authelia, OAuth2 Proxy, Traefik Forward Auth).
|
||||||
# Never expose an AUTH_DISABLED instance directly to the internet.
|
# Never expose an AUTH_DISABLED instance directly to the internet.
|
||||||
AUTH_DISABLED: bool = False
|
AUTH_DISABLED: bool = False
|
||||||
|
ALLOW_AUTH_DISABLED_IN_PRODUCTION: bool = False
|
||||||
|
|
||||||
# ── Logto OIDC ────────────────────────────────────────────────────────────
|
# ── Logto OIDC ────────────────────────────────────────────────────────────
|
||||||
# Set these to enable Logto-based authentication.
|
# Set these to enable Logto-based authentication.
|
||||||
@@ -79,12 +81,18 @@ class Settings(BaseSettings):
|
|||||||
LOGTO_APP_SECRET: Optional[str] = None
|
LOGTO_APP_SECRET: Optional[str] = None
|
||||||
LOGTO_REDIRECT_URI: Optional[str] = None
|
LOGTO_REDIRECT_URI: Optional[str] = None
|
||||||
LOGTO_SKIP_SSL_VERIFY: bool = False
|
LOGTO_SKIP_SSL_VERIFY: bool = False
|
||||||
|
ALLOW_LOGTO_SKIP_SSL_VERIFY_IN_PRODUCTION: bool = False
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def logto_configured(self) -> bool:
|
def logto_configured(self) -> bool:
|
||||||
"""Return True when the minimum Logto settings are present."""
|
"""Return True when the minimum Logto settings are present."""
|
||||||
return bool(self.LOGTO_ENDPOINT and self.LOGTO_APP_ID and self.LOGTO_APP_SECRET)
|
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)
|
@validator("ADMIN_API_KEY", pre=True, always=True)
|
||||||
@classmethod
|
@classmethod
|
||||||
def validate_admin_api_key(
|
def validate_admin_api_key(
|
||||||
@@ -101,12 +109,20 @@ class Settings(BaseSettings):
|
|||||||
return v or None
|
return v or None
|
||||||
|
|
||||||
@validator("SECRET_KEY", pre=True, always=True)
|
@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."""
|
"""Validate and generate SECRET_KEY if not provided."""
|
||||||
# Default insecure key that should never be used
|
# Default insecure key that should never be used
|
||||||
DEFAULT_INSECURE_KEY = "CHANGE_THIS_TO_A_RANDOM_SECRET_IN_PRODUCTION"
|
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 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
|
# Generate a secure random key
|
||||||
generated_key = secrets.token_hex(32)
|
generated_key = secrets.token_hex(32)
|
||||||
logger.warning(
|
logger.warning(
|
||||||
@@ -119,6 +135,10 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
# Check if key is too short
|
# Check if key is too short
|
||||||
if len(v) < 32:
|
if len(v) < 32:
|
||||||
|
if is_production:
|
||||||
|
raise ValueError(
|
||||||
|
"SECRET_KEY must be at least 32 characters when ENVIRONMENT=production."
|
||||||
|
)
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"SECRET_KEY is too short (%s characters). "
|
"SECRET_KEY is too short (%s characters). "
|
||||||
"Recommended minimum is 32 characters for security.",
|
"Recommended minimum is 32 characters for security.",
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -18,6 +18,7 @@ from app.api.api_v1.api import api_router
|
|||||||
from app.core.config import get_settings
|
from app.core.config import get_settings
|
||||||
from app.core.database import Base, SessionLocal, engine
|
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.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.auth import AuthRedirectMiddleware
|
||||||
from app.middleware.security import SecurityHeadersMiddleware
|
from app.middleware.security import SecurityHeadersMiddleware
|
||||||
from app.models.mail_source import MailSource # noqa: F401 – ensure table is registered
|
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"""
|
"""Initialize background tasks and security on application startup"""
|
||||||
global background_task # pylint: disable=global-statement
|
global background_task # pylint: disable=global-statement
|
||||||
|
|
||||||
|
run_startup_checks(settings)
|
||||||
|
|
||||||
# Ensure all tables exist (no-op if already present)
|
# Ensure all tables exist (no-op if already present)
|
||||||
Base.metadata.create_all(bind=engine)
|
Base.metadata.create_all(bind=engine)
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
could run (see: pydantic_settings sources/providers/env.py decode_complex_value).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from app.core.config import Settings
|
from app.core.config import Settings
|
||||||
from app.core.database import _ensure_sqlite_dir, _make_sync_db_url
|
from app.core.database import _ensure_sqlite_dir, _make_sync_db_url
|
||||||
|
|
||||||
@@ -184,3 +186,125 @@ class TestLogtoSettings:
|
|||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|
||||||
assert settings.LOGTO_SKIP_SSL_VERIFY is True
|
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)
|
||||||
|
|||||||
@@ -69,13 +69,13 @@ Quality bar:
|
|||||||
Objective: make self-hosted deployments safer.
|
Objective: make self-hosted deployments safer.
|
||||||
|
|
||||||
Priority tasks:
|
Priority tasks:
|
||||||
- Add startup validation for production settings.
|
|
||||||
- Add backup and restore documentation.
|
- Add backup and restore documentation.
|
||||||
- Add a release checklist covering migrations, tests, and smoke checks.
|
- Add a release checklist covering migrations, tests, and smoke checks.
|
||||||
|
|
||||||
Delivered:
|
Delivered:
|
||||||
- Documented a 1Password secret-injection deployment flow for local, Docker Compose, and systemd deployments.
|
- 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.
|
- 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
|
## Later Milestones
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -98,9 +98,9 @@ Delivered:
|
|||||||
- 1Password-based secret injection flow for local, Docker Compose, and systemd deployments.
|
- 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.
|
- 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.
|
- 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:
|
Planned:
|
||||||
- Add startup checks for production-critical configuration.
|
|
||||||
- Add backup/restore guidance for database deployments.
|
- Add backup/restore guidance for database deployments.
|
||||||
- Add release checklist covering migrations, tests, and smoke checks.
|
- Add release checklist covering migrations, tests, and smoke checks.
|
||||||
|
|
||||||
|
|||||||
@@ -157,6 +157,7 @@ This file tracks the specific implementation tasks for each milestone of the DMA
|
|||||||
## Future Milestones
|
## Future Milestones
|
||||||
- [x] Production secret handling guide using 1Password injection
|
- [x] Production secret handling guide using 1Password injection
|
||||||
- [x] Redact mailbox/OAuth secrets from diagnostics, logs, import history, and auth contexts
|
- [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
|
- [ ] Apprise notifications and alert rules
|
||||||
- [ ] DNS health guidance and Cloudflare read-only inspection
|
- [ ] DNS health guidance and Cloudflare read-only inspection
|
||||||
- [ ] Guided setup and operator health pages
|
- [ ] Guided setup and operator health pages
|
||||||
|
|||||||
Reference in New Issue
Block a user