Files
the-other-dude/backend/app/config.py
Jason Staack b840047e19 feat: The Other Dude v9.0.1 — full-featured email system
ci: add GitHub Pages deployment workflow for docs site

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-08 19:30:44 -05:00

178 lines
6.3 KiB
Python

"""Application configuration using Pydantic Settings."""
import base64
import sys
from functools import lru_cache
from typing import Optional
from pydantic import field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
# Known insecure default values that MUST NOT be used in non-dev environments.
# If any of these are detected in production/staging, the app refuses to start.
KNOWN_INSECURE_DEFAULTS: dict[str, list[str]] = {
"JWT_SECRET_KEY": [
"change-this-in-production-use-a-long-random-string",
"dev-jwt-secret-change-in-production",
"CHANGE_ME_IN_PRODUCTION",
],
"CREDENTIAL_ENCRYPTION_KEY": [
"LLLjnfBZTSycvL2U07HDSxUeTtLxb9cZzryQl0R9E4w=",
"CHANGE_ME_IN_PRODUCTION",
],
"OPENBAO_TOKEN": [
"dev-openbao-token",
"CHANGE_ME_IN_PRODUCTION",
],
}
def validate_production_settings(settings: "Settings") -> None:
"""Reject known-insecure defaults in non-dev environments.
Called during app startup. Exits with code 1 and clear error message
if production is running with dev secrets.
"""
if settings.ENVIRONMENT == "dev":
return
for field, insecure_values in KNOWN_INSECURE_DEFAULTS.items():
actual = getattr(settings, field, None)
if actual in insecure_values:
print(
f"FATAL: {field} uses a known insecure default in '{settings.ENVIRONMENT}' environment.\n"
f"Generate a secure value and set it in your .env.prod file.\n"
f"For JWT_SECRET_KEY: python -c \"import secrets; print(secrets.token_urlsafe(64))\"\n"
f"For CREDENTIAL_ENCRYPTION_KEY: python -c \"import secrets, base64; print(base64.b64encode(secrets.token_bytes(32)).decode())\"",
file=sys.stderr,
)
sys.exit(1)
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
env_file_encoding="utf-8",
case_sensitive=False,
extra="ignore",
)
# Environment (dev | staging | production)
ENVIRONMENT: str = "dev"
# Database
DATABASE_URL: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/mikrotik"
# Sync URL used by Alembic only
SYNC_DATABASE_URL: str = "postgresql+psycopg2://postgres:postgres@localhost:5432/mikrotik"
# App user for RLS enforcement (cannot bypass RLS)
APP_USER_DATABASE_URL: str = "postgresql+asyncpg://app_user:app_password@localhost:5432/mikrotik"
# Database connection pool
DB_POOL_SIZE: int = 20
DB_MAX_OVERFLOW: int = 40
DB_ADMIN_POOL_SIZE: int = 10
DB_ADMIN_MAX_OVERFLOW: int = 20
# Redis
REDIS_URL: str = "redis://localhost:6379/0"
# NATS JetStream
NATS_URL: str = "nats://localhost:4222"
# JWT configuration
JWT_SECRET_KEY: str = "change-this-in-production-use-a-long-random-string"
JWT_ALGORITHM: str = "HS256"
JWT_ACCESS_TOKEN_EXPIRE_MINUTES: int = 15
JWT_REFRESH_TOKEN_EXPIRE_DAYS: int = 7
# Credential encryption key — must be 32 bytes, base64-encoded in env
# Generate with: python -c "import secrets, base64; print(base64.b64encode(secrets.token_bytes(32)).decode())"
CREDENTIAL_ENCRYPTION_KEY: str = "LLLjnfBZTSycvL2U07HDSxUeTtLxb9cZzryQl0R9E4w="
# OpenBao Transit (KMS for per-tenant credential encryption)
OPENBAO_ADDR: str = "http://localhost:8200"
OPENBAO_TOKEN: str = "dev-openbao-token"
# First admin bootstrap
FIRST_ADMIN_EMAIL: Optional[str] = None
FIRST_ADMIN_PASSWORD: Optional[str] = None
# CORS origins (comma-separated)
CORS_ORIGINS: str = "http://localhost:3000,http://localhost:5173,http://localhost:8080"
# Git store — PVC mount for bare git repos (one per tenant).
# In production: /data/git-store (Kubernetes PVC ReadWriteMany).
# In local dev: ./git-store (relative to cwd, created on first use).
GIT_STORE_PATH: str = "./git-store"
# WireGuard config path — shared volume with the WireGuard container
WIREGUARD_CONFIG_PATH: str = "/data/wireguard"
# Firmware cache
FIRMWARE_CACHE_DIR: str = "/data/firmware-cache" # PVC mount path
FIRMWARE_CHECK_INTERVAL_HOURS: int = 24 # How often to check for new versions
# SMTP settings for transactional email (password reset, etc.)
SMTP_HOST: str = "localhost"
SMTP_PORT: int = 587
SMTP_USER: Optional[str] = None
SMTP_PASSWORD: Optional[str] = None
SMTP_USE_TLS: bool = False
SMTP_FROM_ADDRESS: str = "noreply@mikrotik-portal.local"
# Password reset
PASSWORD_RESET_TOKEN_EXPIRE_MINUTES: int = 30
APP_BASE_URL: str = "http://localhost:5173"
# App settings
APP_NAME: str = "TOD - The Other Dude"
APP_VERSION: str = "0.1.0"
DEBUG: bool = False
@field_validator("CREDENTIAL_ENCRYPTION_KEY")
@classmethod
def validate_encryption_key(cls, v: str) -> str:
"""Ensure the key decodes to exactly 32 bytes.
Note: CHANGE_ME_IN_PRODUCTION is allowed through this validator
because it fails the base64 length check. The production safety
check in validate_production_settings() catches it separately.
"""
if v == "CHANGE_ME_IN_PRODUCTION":
# Allow the placeholder through field validation -- the production
# safety check will reject it in non-dev environments.
return v
try:
key_bytes = base64.b64decode(v)
if len(key_bytes) != 32:
raise ValueError(
f"CREDENTIAL_ENCRYPTION_KEY must decode to exactly 32 bytes, got {len(key_bytes)}"
)
except Exception as e:
raise ValueError(f"Invalid CREDENTIAL_ENCRYPTION_KEY: {e}") from e
return v
def get_encryption_key_bytes(self) -> bytes:
"""Return the encryption key as raw bytes."""
return base64.b64decode(self.CREDENTIAL_ENCRYPTION_KEY)
def get_cors_origins(self) -> list[str]:
"""Return CORS origins as a list."""
return [origin.strip() for origin in self.CORS_ORIGINS.split(",") if origin.strip()]
@lru_cache()
def get_settings() -> Settings:
"""Return cached settings instance.
Validates that production environments do not use insecure defaults.
This runs once (cached) at startup before the app accepts requests.
"""
s = Settings()
validate_production_settings(s)
return s
settings = get_settings()