Law/app/core/config.py
2026-03-02 17:18:10 +03:00

251 lines
11 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from pydantic_settings import BaseSettings, SettingsConfigDict
from typing import List
class Settings(BaseSettings):
model_config = SettingsConfigDict(
env_file=".env",
case_sensitive=True,
extra="ignore",
)
APP_ENV: str = "local"
APP_NAME: str = "legal-case-tracker"
PUBLIC_JWT_TTL_DAYS: int = 7
ADMIN_JWT_TTL_MINUTES: int = 240
ADMIN_JWT_SECRET: str = "change_me_admin"
ADMIN_AUTH_MODE: str = "password_totp_optional" # password | password_totp_optional | password_totp_required
TOTP_ISSUER: str = "Правовой Трекер"
PUBLIC_JWT_SECRET: str = "change_me_public"
PUBLIC_COOKIE_NAME: str = "public_jwt"
PUBLIC_COOKIE_SECURE: bool = False
PUBLIC_COOKIE_SAMESITE: str = "lax"
PUBLIC_STRICT_ORIGIN_CHECK: bool = True
PUBLIC_ALLOWED_WEB_ORIGINS: str = (
"http://localhost:8080,http://localhost:8081,"
"https://ruakb.ru,https://www.ruakb.ru,"
"https://ruakb.online,https://www.ruakb.online"
)
PRODUCTION_ENFORCE_SECURE_SETTINGS: bool = True
CORS_ORIGINS: str = "http://localhost:3000,http://localhost:8081"
CORS_ALLOW_METHODS: str = "GET,POST,PUT,PATCH,DELETE,OPTIONS"
CORS_ALLOW_HEADERS: str = "Authorization,Content-Type,X-Requested-With,X-Request-ID"
CORS_ALLOW_CREDENTIALS: bool = True
DATABASE_URL: str
REDIS_URL: str
S3_ENDPOINT: str
S3_ACCESS_KEY: str
S3_SECRET_KEY: str
S3_BUCKET: str
S3_REGION: str = "us-east-1"
S3_USE_SSL: bool = False
S3_VERIFY_SSL: bool = True
S3_CA_CERT_PATH: str = ""
MAX_FILE_MB: int = 25
MAX_CASE_MB: int = 250
ATTACHMENT_SCAN_ENABLED: bool = False
ATTACHMENT_SCAN_ENFORCE: bool = False
ATTACHMENT_ALLOWED_MIME_TYPES: str = (
"application/pdf,image/jpeg,image/png,video/mp4,text/plain"
)
CLAMAV_ENABLED: bool = False
CLAMAV_HOST: str = "clamav"
CLAMAV_PORT: int = 3310
CLAMAV_TIMEOUT_SECONDS: int = 20
TELEGRAM_BOT_TOKEN: str = "change_me"
TELEGRAM_CHAT_ID: str = "0"
SMS_PROVIDER: str = "dummy"
SMSAERO_EMAIL: str = ""
SMSAERO_API_KEY: str = ""
OTP_SMS_TEMPLATE: str = "Your verification code: {code}"
OTP_AUTOTEST_FORCE_MOCK_SMS: bool = True
PUBLIC_AUTH_MODE: str = "sms" # sms | email | sms_or_email | totp
EMAIL_PROVIDER: str = "dummy" # dummy | smtp
EMAIL_SERVICE_ENABLED: bool = True
EMAIL_SERVICE_URL: str = "http://email-service:8010"
INTERNAL_SERVICE_TOKEN: str = "change_me_internal_service_token"
SMTP_HOST: str = ""
SMTP_PORT: int = 587
SMTP_USER: str = ""
SMTP_PASSWORD: str = ""
SMTP_FROM: str = ""
SMTP_USE_TLS: bool = True
SMTP_USE_SSL: bool = False
OTP_EMAIL_SUBJECT_TEMPLATE: str = "Код подтверждения: {code}"
OTP_EMAIL_TEMPLATE: str = "Ваш код подтверждения: {code}"
OTP_EMAIL_FALLBACK_ENABLED: bool = True
OTP_SMS_MIN_BALANCE: float = 20.0
DATA_ENCRYPTION_ACTIVE_KID: str = "legacy"
DATA_ENCRYPTION_KEYS: str = ""
CHAT_ENCRYPTION_ACTIVE_KID: str = ""
CHAT_ENCRYPTION_KEYS: str = ""
DATA_ENCRYPTION_SECRET: str = "change_me_data_encryption"
CHAT_ENCRYPTION_SECRET: str = ""
OTP_RATE_LIMIT_WINDOW_SECONDS: int = 300
OTP_SEND_RATE_LIMIT: int = 8
OTP_VERIFY_RATE_LIMIT: int = 20
OTP_DEV_MODE: bool = False
ADMIN_BOOTSTRAP_ENABLED: bool = True
ADMIN_BOOTSTRAP_EMAIL: str = "admin@example.com"
ADMIN_BOOTSTRAP_PASSWORD: str = "admin123"
ADMIN_BOOTSTRAP_NAME: str = "Администратор системы"
# Compose/infra vars that may exist in shared .env
POSTGRES_USER: str = "postgres"
POSTGRES_PASSWORD: str = "postgres"
POSTGRES_DB: str = "legal"
MINIO_ROOT_USER: str = "minio_local_admin"
MINIO_ROOT_PASSWORD: str = "minio_local_password_change_me"
MINIO_TLS_ENABLED: bool = False
@property
def cors_origins_list(self) -> List[str]:
return [o.strip() for o in self.CORS_ORIGINS.split(",") if o.strip()]
@property
def cors_allow_methods_list(self) -> List[str]:
values = [v.strip().upper() for v in str(self.CORS_ALLOW_METHODS or "").split(",") if v.strip()]
return values or ["GET", "POST", "OPTIONS"]
@property
def cors_allow_headers_list(self) -> List[str]:
values = [v.strip() for v in str(self.CORS_ALLOW_HEADERS or "").split(",") if v.strip()]
return values or ["Authorization", "Content-Type"]
@property
def public_allowed_web_origins_list(self) -> List[str]:
values: list[str] = []
for item in str(self.PUBLIC_ALLOWED_WEB_ORIGINS or "").split(","):
value = item.strip().rstrip("/").lower()
if value:
values.append(value)
return values
@property
def app_env_is_production(self) -> bool:
return str(self.APP_ENV or "").strip().lower() in {"prod", "production"}
@property
def public_cookie_secure_effective(self) -> bool:
if self.app_env_is_production:
return True
return bool(self.PUBLIC_COOKIE_SECURE)
@property
def public_cookie_samesite_effective(self) -> str:
raw = str(self.PUBLIC_COOKIE_SAMESITE or "lax").strip().lower()
if raw in {"lax", "strict", "none"}:
return raw
return "lax"
settings = Settings()
def _looks_insecure_secret(value: str, *, min_len: int = 16) -> bool:
raw = str(value or "").strip()
lowered = raw.lower()
if len(raw) < min_len:
return True
markers = (
"change_me",
"example",
"admin123",
"password",
"test",
"local",
)
return any(marker in lowered for marker in markers)
def validate_production_security_or_raise(component: str = "app") -> None:
if not settings.app_env_is_production:
return
if not bool(getattr(settings, "PRODUCTION_ENFORCE_SECURE_SETTINGS", True)):
return
issues: list[str] = []
if bool(settings.OTP_DEV_MODE):
issues.append("OTP_DEV_MODE=true запрещен в production")
if bool(settings.ADMIN_BOOTSTRAP_ENABLED):
issues.append("ADMIN_BOOTSTRAP_ENABLED=true запрещен в production")
if not settings.public_cookie_secure_effective:
issues.append("PUBLIC cookie должен быть secure в production")
if settings.public_cookie_samesite_effective == "none" and not settings.public_cookie_secure_effective:
issues.append("PUBLIC_COOKIE_SAMESITE=none требует secure cookie")
if _looks_insecure_secret(settings.ADMIN_JWT_SECRET):
issues.append("ADMIN_JWT_SECRET выглядит небезопасным")
if _looks_insecure_secret(settings.PUBLIC_JWT_SECRET):
issues.append("PUBLIC_JWT_SECRET выглядит небезопасным")
if _looks_insecure_secret(settings.DATA_ENCRYPTION_SECRET):
issues.append("DATA_ENCRYPTION_SECRET выглядит небезопасным")
if _looks_insecure_secret(settings.INTERNAL_SERVICE_TOKEN):
issues.append("INTERNAL_SERVICE_TOKEN выглядит небезопасным")
if not str(settings.CHAT_ENCRYPTION_SECRET or "").strip():
# Backward-compatible: keyring-based CHAT_ENCRYPTION_KEYS is allowed.
if not str(getattr(settings, "CHAT_ENCRYPTION_KEYS", "") or "").strip():
issues.append("CHAT_ENCRYPTION_SECRET или CHAT_ENCRYPTION_KEYS обязателен в production")
if not str(getattr(settings, "DATA_ENCRYPTION_ACTIVE_KID", "") or "").strip():
issues.append("DATA_ENCRYPTION_ACTIVE_KID должен быть задан в production")
minio_user = str(settings.MINIO_ROOT_USER or "").strip().lower()
minio_password = str(settings.MINIO_ROOT_PASSWORD or "").strip()
if minio_user in {"", "minioadmin", "minio_local_admin"}:
issues.append("MINIO_ROOT_USER должен быть переопределен для production")
if _looks_insecure_secret(minio_password):
issues.append("MINIO_ROOT_PASSWORD выглядит небезопасным")
if not bool(settings.S3_USE_SSL):
issues.append("S3_USE_SSL должен быть включен в production")
s3_endpoint = str(settings.S3_ENDPOINT or "").strip().lower()
if not s3_endpoint.startswith("https://"):
issues.append("S3_ENDPOINT должен начинаться с https:// в production")
if not bool(settings.S3_VERIFY_SSL):
issues.append("S3_VERIFY_SSL должен быть включен в production")
if not str(settings.S3_CA_CERT_PATH or "").strip():
issues.append("S3_CA_CERT_PATH должен быть задан для trusted TLS в production")
if not bool(settings.MINIO_TLS_ENABLED):
issues.append("MINIO_TLS_ENABLED должен быть включен в production")
if bool(getattr(settings, "PUBLIC_STRICT_ORIGIN_CHECK", True)):
allowed_public_origins = settings.public_allowed_web_origins_list
if not allowed_public_origins:
issues.append("PUBLIC_ALLOWED_WEB_ORIGINS должен быть задан в production")
for origin in allowed_public_origins:
if "localhost" in origin or "127.0.0.1" in origin:
issues.append("PUBLIC_ALLOWED_WEB_ORIGINS не должен содержать localhost в production")
break
cors_origins = [item.strip().lower().rstrip("/") for item in settings.cors_origins_list]
if not cors_origins:
issues.append("CORS_ORIGINS должен быть задан в production")
for origin in cors_origins:
if origin == "*" or "*" in origin:
issues.append("CORS_ORIGINS не должен содержать wildcard (*) в production")
break
if "localhost" in origin or "127.0.0.1" in origin:
issues.append("CORS_ORIGINS не должен содержать localhost в production")
break
if not origin.startswith("https://"):
issues.append("CORS_ORIGINS должен содержать только https origins в production")
break
cors_methods = [item.strip().upper() for item in settings.cors_allow_methods_list]
if "*" in cors_methods:
issues.append("CORS_ALLOW_METHODS не должен содержать wildcard (*) в production")
cors_headers_lower = [item.strip().lower() for item in settings.cors_allow_headers_list]
if "*" in cors_headers_lower:
issues.append("CORS_ALLOW_HEADERS не должен содержать wildcard (*) в production")
if issues:
formatted = "\n".join(f"- {item}" for item in issues)
raise RuntimeError(f"[{component}] insecure production configuration:\n{formatted}")