/
systemsstrategyy
/
Treker
Обзор
Документация
Войти
/
systemsstrategyy
/
Treker
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
0
CI/CD
Аналитика
master
api/core/settings.py
261 строка
12 KB
SystemsStrategy
Синхронизация с актуальной линией разработки (август 2026)
06 авг 2026, 12:52
06 авг 2026, 12:52
19af6aa
Код
Авторство
О чём код?
"""Конфигурация приложения через pydantic-settings v2. Coding_Principles_v1.1 §9 (ADR-0001): все настройки загружаются из env- переменных, поддерживается `.env` для локальной разработки, секции оформлены через nested-models. В production секреты инжектируются как env-переменные из Yandex Lockbox / Vault / sops-encrypted git — `.env` с реальными секретами никогда не коммитим (только `.env.example`). Имена env-переменных: `APP_<SECTION>__<FIELD>` через двойное подчёркивание как разделитель nested-уровней. Пример: APP_DATABASE__URL=postgresql+asyncpg://tracker:dev@localhost:5432/tracker APP_JWT__ACCESS_TTL_SEC=1800 Использование: from api.core.settings import get_settings settings = get_settings() # singleton через lru_cache db_url = settings.database.url """ from __future__ import annotations from functools import lru_cache from typing import Literal from pydantic import BaseModel, Field, SecretStr, model_validator from pydantic_settings import BaseSettings, SettingsConfigDict # Дефолтный dev-секрет — держим здесь, чтобы валидатор и default поля # secret_key не разъехались (production с этим значением запрещён). _DEFAULT_DEV_SECRET = "dev-secret-CHANGE-IN-PRODUCTION" # noqa: S105 — намеренная dev-заглушка # Хосты dev-почтовика (mailhog) — в production недопустимы. _MAILHOG_HOSTS = frozenset({"localhost", "127.0.0.1", "mailhog"}) class DatabaseSettings(BaseModel): """Подключение к PostgreSQL через async-драйвер (asyncpg).""" url: str = "postgresql+asyncpg://tracker:dev_password@localhost:5432/tracker_dev" pool_size: int = 10 max_overflow: int = 20 class RedisSettings(BaseModel): """Redis — JWT denylist, idempotency-кэш, кэш горячих выборок (§5).""" url: str = "redis://localhost:6379/0" class JWTSettings(BaseModel): """JWT RS256 (ADR-0003): access + refresh с rotation, denylist по jti в Redis. Приватный ключ только на auth-эндпоинте, публичный — на всех сервисах. Файлы PEM монтируются как volume в контейнер; в prod — через KMS/Vault.""" private_key_path: str = "var/keys/jwt_private.pem" public_key_path: str = "var/keys/jwt_public.pem" access_ttl_sec: int = 1800 # 30 минут refresh_ttl_sec: int = 2592000 # 30 дней algorithm: Literal["RS256"] = "RS256" class SMTPSettings(BaseModel): """SMTP для email-уведомлений. В dev — mailhog, в prod — Yandex/Mailgun.""" host: str = "localhost" port: int = 1025 user: str = "" password: SecretStr = SecretStr("") from_email: str = "noreply@tracker.local" use_tls: bool = False class FileStorageSettings(BaseModel): """Локальная файловая система через FileStorage-абстракцию (§FileStorage). S3/MinIO намеренно отсутствуют — миграция на S3 оформляется отдельным ADR при появлении соответствующих объёмов.""" dir: str = "var/files" max_upload_mb: int = 50 class RateLimitSettings(BaseModel): """Rate limiting через slowapi (§10).""" per_minute: int = 60 burst: int = 100 login_attempts: int = 5 login_window_minutes: int = 15 class YooKassaSettings(BaseModel): """Платёжный шлюз ЮKassa (Billing Agent заполнит на неделе 4).""" shop_id: str = "" secret_key: SecretStr = SecretStr("") return_url: str = "http://localhost:5173/billing/success" # ЮKassa не использует HMAC на webhook — IP allow-list + re-fetch (§10). trusted_ips: list[str] = Field( default_factory=lambda: [ "185.71.76.0/27", "185.71.77.0/27", "77.75.153.0/25", "77.75.156.11/32", "77.75.156.35/32", "77.75.154.128/25", "2a02:5180::/32", ] ) class TelegramSettings(BaseModel): """Telegram bot (Comms Agent заполнит если 6.2 модуль включён).""" bot_token: SecretStr = SecretStr("") class SentrySettings(BaseModel): """Sentry/Bugsink error-tracking. Без DSN — отключено (dev/тесты не шлют). send_default_pii НЕ включаем — ПД пользователей (email, IP, cookies) в Sentry не уходят (требование 152-ФЗ / аккредитации Минцифры).""" dsn: SecretStr = SecretStr("") # APM-трейсы выключены по умолчанию (квота/стоимость); включить на проде # маленькой долей (0.05–0.2) если нужен performance-мониторинг. traces_sample_rate: float = 0.0 class FeatureFlags(BaseModel): """Feature-флаги — постепенный rollout новых модулей.""" billing_enabled: bool = True telegram_enabled: bool = False realtime_sse: bool = False # SSE + LISTEN/NOTIFY, §5 class Settings(BaseSettings): """Главные настройки приложения. Singleton через get_settings().""" model_config = SettingsConfigDict( env_prefix="APP_", env_nested_delimiter="__", env_file=".env", env_file_encoding="utf-8", case_sensitive=False, extra="ignore", ) # ── Окружение ──────────────────────────────────────────────────────── # Поля без префикса `app_` — он применяется автоматически через # env_prefix="APP_". Так env-переменные читаются естественно: # APP_ENV, APP_DEBUG, APP_VERSION. env: Literal["development", "staging", "production"] = "development" debug: bool = True version: str = "0.1.0" secret_key: SecretStr = SecretStr(_DEFAULT_DEV_SECRET) base_url: str = "http://localhost:8000" # ── CORS ───────────────────────────────────────────────────────────── cors_origins: list[str] = Field( default_factory=lambda: ["http://localhost:3000", "http://localhost:5173"] ) # ── Nested секции ──────────────────────────────────────────────────── database: DatabaseSettings = Field(default_factory=DatabaseSettings) redis: RedisSettings = Field(default_factory=RedisSettings) jwt: JWTSettings = Field(default_factory=JWTSettings) smtp: SMTPSettings = Field(default_factory=SMTPSettings) file_storage: FileStorageSettings = Field(default_factory=FileStorageSettings) rate_limit: RateLimitSettings = Field(default_factory=RateLimitSettings) yookassa: YooKassaSettings = Field(default_factory=YooKassaSettings) telegram: TelegramSettings = Field(default_factory=TelegramSettings) sentry: SentrySettings = Field(default_factory=SentrySettings) features: FeatureFlags = Field(default_factory=FeatureFlags) # ── Computed properties (удобные алиасы) ───────────────────────────── @property def is_dev(self) -> bool: """True если окружение — development.""" return self.env == "development" @property def is_prod(self) -> bool: """True если окружение — production.""" return self.env == "production" @property def is_secure_cookies(self) -> bool: """Ставить ли флаг Secure на auth-cookies. True везде кроме development: staging-прод ходит по HTTPS, cookie без Secure — downgrade-риск (аудит #1). Привязка к is_prod давала Secure=False на staging-проде.""" return self.env != "development" @model_validator(mode="after") def _validate_production_safety(self) -> Settings: """Fail-fast: не даём стартовать вне development с dev-конфигурацией. Дешевле упасть на старте, чем работать с подделываемым JWT (дефолтный секрет), молча не слать письма (mailhog/.local) или ходить в dev-БД. Аудит #0/#25: security-инварианты проверяются во ВСЕХ не-development окружениях (включая staging — staging-прод смотрит наружу). Почта, CORS и billing-инварианты — только в production, т.к. на staging почта/ЮKassa могут быть намеренно не подключены. """ if self.env == "development": return self # в dev дефолты допустимы problems: list[str] = [] # Security-инварианты: staging + production. if self.secret_key.get_secret_value() == _DEFAULT_DEV_SECRET: problems.append("APP_SECRET_KEY не переопределён (дефолтный dev-секрет)") if self.debug: problems.append("APP_DEBUG=true вне development") if "dev_password" in self.database.url: problems.append("APP_DATABASE__URL содержит dev_password") # Почта + billing + CORS-инварианты: только production. if self.env == "production": # CORS wildcard '*' несовместим с credentials (браузер отвергает # ответ, а если бы принял — любой origin читал бы ответы с cookie). # Фронт ходит с credentials, поэтому '*' в prod — latent-мисконфиг. if "*" in self.cors_origins: problems.append( "APP_CORS_ORIGINS содержит '*' — wildcard запрещён в production " "(несовместим с allow_credentials); задайте явные origin'ы" ) if self.smtp.host.lower() in _MAILHOG_HOSTS or self.smtp.port == 1025: problems.append("APP_SMTP__HOST/PORT указывают на dev-mailhog (localhost:1025)") if self.smtp.from_email.strip().lower().endswith(".local"): problems.append("APP_SMTP__FROM_EMAIL — dev-заглушка (.local)") # stub-режим (billing_enabled=false) в production запрещён: приём # платежей должен быть либо боевым, либо явно выключенным окружением. if not self.features.billing_enabled: problems.append( "APP_FEATURES__BILLING_ENABLED=false (stub-режим) запрещён в production" ) elif not self.yookassa.shop_id or not self.yookassa.secret_key.get_secret_value(): problems.append( "ЮKassa-credentials (APP_YOOKASSA__SHOP_ID/SECRET_KEY) не заданы " "при включённом биллинге" ) if problems: raise ValueError(f"Небезопасная конфигурация ({self.env}): " + "; ".join(problems)) return self @lru_cache(maxsize=1) def get_settings() -> Settings: """Закешированный singleton Settings — читается один раз на процесс. Используется как FastAPI-зависимость: `Depends(get_settings)`. В тестах сбрасывается через `get_settings.cache_clear()`.""" return Settings() # Удобный модульный alias для прямого импорта (НЕ для production-кода — # через Depends() предпочтительнее, см. ADR-0001 §3 Dependency Injection). settings = get_settings()