fix(config): rifiuta i segreti di default fuori da dev/test (#33, parte A2)

Nuova impostazione app_env (env APP_ENV, default "dev"). All'istanza di
Settings, se app_env non è "dev"/"test" e SERVER_SECRET_KEY o
DB_PASSWORD sono ancora il valore committato, RuntimeError che nomina
la variabile. Dev e test continuano a partire senza .env: il default
"dev" non fa mai scattare la guardia.

Le parti frontend (flask_app/config.py) e deploy (docker-compose.yml)
di questa issue sono degli altri lotti (B2, C2).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-21 22:41:28 +02:00
parent 0ec5988581
commit a2f357b3d3
2 changed files with 89 additions and 2 deletions
+39 -2
View File
@@ -1,22 +1,37 @@
"""TieMeasureFlow Server Configuration.""" """TieMeasureFlow Server Configuration."""
from pathlib import Path from pathlib import Path
from pydantic import model_validator
from pydantic_settings import BaseSettings from pydantic_settings import BaseSettings
# Committed defaults. Fine for dev/test (SQLite in-memory, nothing exposed); a
# real deployment that still has these is running on secrets anyone can read
# in this file's git history.
_DEFAULT_SECRET_KEY = "change-this-to-a-random-secret-key"
_DEFAULT_DB_PASSWORD = "change_me_in_production"
class Settings(BaseSettings): class Settings(BaseSettings):
"""Application settings loaded from environment variables.""" """Application settings loaded from environment variables."""
# Deployment environment. "dev" and "test" skip the production-only guard
# below; anything else (e.g. "production") is treated as a real
# deployment. Local dev and the test suite must keep starting with no
# .env at all, which is why this defaults to "dev" rather than requiring
# the variable.
app_env: str = "dev"
# Database # Database
db_host: str = "localhost" db_host: str = "localhost"
db_port: int = 3306 db_port: int = 3306
db_name: str = "tiemeasureflow" db_name: str = "tiemeasureflow"
db_user: str = "tmflow" db_user: str = "tmflow"
db_password: str = "change_me_in_production" db_password: str = _DEFAULT_DB_PASSWORD
# Server # Server
server_host: str = "0.0.0.0" server_host: str = "0.0.0.0"
server_port: int = 8000 server_port: int = 8000
server_secret_key: str = "change-this-to-a-random-secret-key" server_secret_key: str = _DEFAULT_SECRET_KEY
server_cors_origins: str = "http://localhost:5000" server_cors_origins: str = "http://localhost:5000"
# Worker processes (e.g. `uvicorn --workers N`). Each rate-limit bucket lives # Worker processes (e.g. `uvicorn --workers N`). Each rate-limit bucket lives
# in process memory, so this is how RateLimitMiddleware divides the # in process memory, so this is how RateLimitMiddleware divides the
@@ -86,5 +101,27 @@ class Settings(BaseSettings):
"extra": "ignore", "extra": "ignore",
} }
@model_validator(mode="after")
def _refuse_default_secrets_outside_dev(self) -> "Settings":
"""Refuse to boot with a committed default secret in a real deployment.
Only fires when APP_ENV names an actual deployment (not "dev"/"test"),
so local development and the test suite keep starting with no .env at
all.
"""
if self.app_env in ("dev", "test"):
return self
if self.server_secret_key == _DEFAULT_SECRET_KEY:
raise RuntimeError(
"SERVER_SECRET_KEY is still the committed default value; "
"set it before running with APP_ENV=" + self.app_env
)
if self.db_password == _DEFAULT_DB_PASSWORD:
raise RuntimeError(
"DB_PASSWORD is still the committed default value; "
"set it before running with APP_ENV=" + self.app_env
)
return self
settings = Settings() settings = Settings()
+50
View File
@@ -0,0 +1,50 @@
"""Tests for Settings' production-secret guard (issue #33, backend part)."""
import pytest
from src.backend.config import Settings, _DEFAULT_DB_PASSWORD, _DEFAULT_SECRET_KEY
_REAL_SECRET_KEY = "a-real-generated-secret-key-not-the-default"
_REAL_DB_PASSWORD = "a-real-generated-db-password-not-the-default"
def _settings(**overrides) -> Settings:
"""Build a Settings instance without reading the real .env file or
inheriting stray SERVER_SECRET_KEY/DB_PASSWORD from the shell env."""
overrides.setdefault("server_secret_key", _DEFAULT_SECRET_KEY)
overrides.setdefault("db_password", _DEFAULT_DB_PASSWORD)
return Settings(_env_file=None, **overrides)
class TestProductionSecretGuard:
def test_dev_env_allows_default_secrets(self):
s = _settings(app_env="dev")
assert s.server_secret_key == _DEFAULT_SECRET_KEY
assert s.db_password == _DEFAULT_DB_PASSWORD
def test_test_env_allows_default_secrets(self):
s = _settings(app_env="test")
assert s.server_secret_key == _DEFAULT_SECRET_KEY
def test_production_env_rejects_default_secret_key(self):
with pytest.raises(RuntimeError, match="SERVER_SECRET_KEY"):
_settings(app_env="production", db_password=_REAL_DB_PASSWORD)
def test_production_env_rejects_default_db_password(self):
with pytest.raises(RuntimeError, match="DB_PASSWORD"):
_settings(app_env="production", server_secret_key=_REAL_SECRET_KEY)
def test_production_env_accepts_real_secrets(self):
s = _settings(
app_env="production",
server_secret_key=_REAL_SECRET_KEY,
db_password=_REAL_DB_PASSWORD,
)
assert s.app_env == "production"
def test_module_level_settings_instance_starts_without_env(self):
"""The already-instantiated `settings` (imported at process start)
must not have raised - this is what "tests/dev start without .env"
requires: APP_ENV defaults to "dev", so the guard never fires here."""
from src.backend.config import settings
assert settings.app_env == "dev"