feat: add production startup checks
This commit is contained in:
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user