From afc457f59314e7cec902675eb37bff18f2a1b41d Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Wed, 7 Oct 2026 18:27:06 +0400 Subject: [PATCH] =?UTF-8?q?feat(config):=20=D0=BD=D0=B5=D1=81=D0=B5=D0=BA?= =?UTF-8?q?=D1=80=D0=B5=D1=82=D0=BD=D1=8B=D0=B5=20=D0=BD=D0=B0=D1=81=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=B9=D0=BA=D0=B8=20=D0=B8=D0=B7=20OpenBao=20?= =?UTF-8?q?=D1=87=D0=B5=D1=80=D0=B5=D0=B7=20OPENBAO=5F=5FAPP=5F=5F<=D0=98?= =?UTF-8?q?=D0=9C=D0=AF>=20(#44)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Контур платформы передаёт в под только OPENBAO__*. Свой источник настроек (settings_customise_sources): приоритет обычный env -> OPENBAO__APP__* -> .env -> умолчание; неизвестное имя — ошибка при старте; ключ APP не ломает OpenBAOSettings. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EAKRDNzXtRrwmWz9z6PZu3 --- README.md | 1 + api/.env.example | 9 ++ api/src/core/config.py | 34 ++++++- api/src/core/settings_sources.py | 50 ++++++++++ api/tests/test_config_openbao_app.py | 132 +++++++++++++++++++++++++++ 5 files changed, 224 insertions(+), 2 deletions(-) create mode 100644 api/src/core/settings_sources.py create mode 100644 api/tests/test_config_openbao_app.py diff --git a/README.md b/README.md index 4410a4a..6e294b5 100644 --- a/README.md +++ b/README.md @@ -871,6 +871,7 @@ email без учёта регистра проверяет только сер | Переменная | По умолчанию | Назначение | |---|---|---| +| `OPENBAO__APP__<ИМЯ>` | — | **контур платформы:** туда передаются только секреты OpenBao (`OPENBAO__*`), обычные переменные окружения задать нельзя. Любая настройка верхнего уровня из этой таблицы (`NATS_ENABLED`, `DEBUG`, `AUTH_DEV_ISSUER`, `FORWARDED_ALLOW_IPS`, `CORS_ORIGINS`, …) может прийти как `OPENBAO__APP__<ИМЯ>`, например `OPENBAO__APP__NATS_ENABLED=true`. Значения разбираются как из обычного env (bool, числа, списки). Приоритет: обычный env → `OPENBAO__APP__*` → `.env` → значение по умолчанию. Неизвестное имя — ошибка конфигурации при старте (опечатка не игнорируется); `OPENBAO__APP__OPENBAO__…` не допускается. Читается только из окружения процесса (в `.env` не действует). Реализация — `api/src/core/settings_sources.py` | | `ENV_FILE` | `.env` | файл окружения (относительно `api/`) | | `DEBUG` | `false` | режим разработки. `true` необходим dev-эмитенту токенов и `make seed-dev`, включает документацию API (`/docs`, `/redoc`, `/openapi.json`) и CORS `*`; при `false` документации нет (404), CORS — из `CORS_ORIGINS`, пакеты РАИСА подгружаются условно | | `FORWARDED_ALLOW_IPS` | `127.0.0.1` | доверенные прокси для `X-Forwarded-For`/`X-Forwarded-Proto` (`ProxyHeadersMiddleware`): адреса через запятую или `*`. Адрес клиента (`audit_event.ip_address`) берётся из заголовка только от них, от остальных заголовок игнорируется. На платформе — адреса ingress/балансировщика (OQ-65) | diff --git a/api/.env.example b/api/.env.example index edcb32c..1942f13 100644 --- a/api/.env.example +++ b/api/.env.example @@ -1,6 +1,15 @@ # Пример локального окружения. Скопируйте в api/.env: cp .env.example .env # Порты — стандартные для docs/dev/docker-compose.yml. +# Контур платформы: обычные переменные окружения там задать нельзя, передаются только секреты OpenBao (OPENBAO__*). +# Любая настройка верхнего уровня ниже (не вложенные OPENBAO__<ГРУППА>__*) может прийти как OPENBAO__APP__<ИМЯ>, например: +# OPENBAO__APP__NATS_ENABLED=true +# OPENBAO__APP__DEBUG=true +# OPENBAO__APP__AUTH_DEV_ISSUER=true +# OPENBAO__APP__FORWARDED_ALLOW_IPS=10.0.0.1,10.0.0.2 +# Приоритет: обычный env -> OPENBAO__APP__* -> .env -> значение по умолчанию. Неизвестное имя — ошибка при старте. +# В .env-файле OPENBAO__APP__* не читается (только процесс окружения). + DEBUG=true # Dev-запуск слушает только loopback: при DEBUG и AUTH_DEV_ISSUER эндпоинт dev-токенов выдаёт токен любому, кто знает diff --git a/api/src/core/config.py b/api/src/core/config.py index 5f15f84..0136606 100644 --- a/api/src/core/config.py +++ b/api/src/core/config.py @@ -2,8 +2,10 @@ import json import os from typing import Annotated, Any -from pydantic import NonNegativeInt, PositiveInt, field_validator -from pydantic_settings import BaseSettings, NoDecode, SettingsConfigDict +from pydantic import NonNegativeInt, PositiveInt, field_validator, model_validator +from pydantic_settings import BaseSettings, NoDecode, PydanticBaseSettingsSource, SettingsConfigDict + +from src.core.settings_sources import OpenBaoAppSettingsSource class DBSettings(BaseSettings): @@ -40,6 +42,16 @@ class OpenBAOSettings(BaseSettings): NATS: NATSSettings SMB: SMBSettings = SMBSettings() # все поля необязательны (см. SMBSettings) + @model_validator(mode="before") + @classmethod + def _drop_app(cls, data: Any) -> Any: + """`OPENBAO__APP__<ИМЯ>` — настройки верхнего уровня `Settings` (читает `OpenBaoAppSettingsSource`), не группа + секретов. Вложенный разбор `__` кладёт их сюда ключом `APP`, а `extra=forbid` отверг бы его; убирается только + этот ключ — опечатка в имени группы (`OPENBAO__DATABSE__...`) по-прежнему ошибка.""" + if isinstance(data, dict): + return {key: value for key, value in data.items() if str(key).upper() != "APP"} + return data + class ServerSettings(BaseSettings): HOST: str = "0.0.0.0" @@ -94,6 +106,24 @@ class Settings(BaseSettings): MAPPING_POLL_SECONDS: PositiveInt = 5 MAPPING_MAX_ATTEMPTS: PositiveInt = 3 + @classmethod + def settings_customise_sources( + cls, + settings_cls: type[BaseSettings], + init_settings: PydanticBaseSettingsSource, + env_settings: PydanticBaseSettingsSource, + dotenv_settings: PydanticBaseSettingsSource, + file_secret_settings: PydanticBaseSettingsSource, + ) -> tuple[PydanticBaseSettingsSource, ...]: + """Обычный env → `OPENBAO__APP__*` → `.env` → значение по умолчанию.""" + return ( + init_settings, + env_settings, + OpenBaoAppSettingsSource(settings_cls), + dotenv_settings, + file_secret_settings, + ) + @field_validator("CORS_ORIGINS", mode="before") @classmethod def _split_origins(cls, value: Any) -> Any: diff --git a/api/src/core/settings_sources.py b/api/src/core/settings_sources.py new file mode 100644 index 0000000..ce57bb1 --- /dev/null +++ b/api/src/core/settings_sources.py @@ -0,0 +1,50 @@ +"""Источник настроек `OPENBAO__APP__<ИМЯ>`: несекретные настройки верхнего уровня из OpenBao. + +Платформа передаёт в под только переменные `OPENBAO__*`, обычные переменные окружения задать нельзя. Поэтому любая +настройка верхнего уровня `Settings` может прийти как `OPENBAO__APP__<ИМЯ>` +(например `OPENBAO__APP__NATS_ENABLED=true`). +Приоритет: обычный env → `OPENBAO__APP__*` → `.env` → значение по умолчанию. + +Файл одинаковый в arm-core, arm-scan-gateway, arm-tabular-parser (менять во всех трёх). +""" + +from typing import Any + +from pydantic_settings import BaseSettings, EnvSettingsSource + +APP_PREFIX = "OPENBAO__APP__" +# Группа секретов не настраивается через APP: секреты — только `OPENBAO__<ГРУППА>__<ИМЯ>` +RESERVED_FIELD = "OPENBAO" + + +class OpenBaoAppSettingsSource(EnvSettingsSource): + """Читает `OPENBAO__APP__<ИМЯ>` как обычный env: те же разбор типов, `NoDecode`, вложенность `__`, регистр. + + Только процесс окружения (не `.env`): так платформа и передаёт значения. Неизвестное имя — `ValueError`. + """ + + def __init__(self, settings_cls: type[BaseSettings]) -> None: + super().__init__( + settings_cls, + env_prefix=APP_PREFIX, + env_nested_delimiter=settings_cls.model_config.get("env_nested_delimiter"), + ) + + def __call__(self) -> dict[str, Any]: + self._check_names() + return super().__call__() + + def _check_names(self) -> None: + known = {name.lower() for name in self.settings_cls.model_fields} - {RESERVED_FIELD.lower()} + delimiter = self.env_nested_delimiter or "__" + prefix = self.env_prefix.lower() + unknown = sorted( + env_name + for env_name in self.env_vars + if env_name.startswith(prefix) and env_name[len(prefix) :].split(delimiter, 1)[0] not in known + ) + if unknown: + raise ValueError( + f"Неизвестная настройка в {APP_PREFIX}*: {', '.join(name.upper() for name in unknown)}. " + "Допустимы имена полей верхнего уровня Settings (кроме OPENBAO)." + ) diff --git a/api/tests/test_config_openbao_app.py b/api/tests/test_config_openbao_app.py new file mode 100644 index 0000000..aa1832c --- /dev/null +++ b/api/tests/test_config_openbao_app.py @@ -0,0 +1,132 @@ +"""Несекретные настройки из OpenBao: `OPENBAO__APP__<ИМЯ>` (issue #44; без БД и сети). + +Приоритет: обычный env → `OPENBAO__APP__*` → `.env` → значение по умолчанию. +""" + +import os +from pathlib import Path + +import pytest +from pydantic import ValidationError + +from src.core.config import Settings + +REQUIRED_ENV = { + "OPENBAO__DATABASE__DATABASE_URL": "postgresql+asyncpg://u:p@localhost:5432/d", + "OPENBAO__MINIO__MINIO_URL": "localhost:9000", + "OPENBAO__MINIO__MINIO_ADMIN_LOGIN": "login", + "OPENBAO__MINIO__MINIO_ADMIN_PASSWORD": "password", + "OPENBAO__MINIO__MINIO_BUCKET": "bucket", + "OPENBAO__NATS__NATS_URL": "nats://localhost:4222", +} +# Плоские настройки, которые тесты ниже задают/проверяют +POLL = 7 +PORT = 9000 +FLAT_KEYS = {"DEBUG", "NATS_ENABLED", "AUTH_DEV_ISSUER", "FORWARDED_ALLOW_IPS", "CORS_ORIGINS", "OUTBOX_POLL_SECONDS"} + + +@pytest.fixture +def env(monkeypatch: pytest.MonkeyPatch) -> pytest.MonkeyPatch: + """Чистое окружение с обязательными OPENBAO__*; остальное из процесса убрано.""" + for key in list(os.environ): + if key.startswith(("OPENBAO__", "SERVER__")) or key in FLAT_KEYS: + monkeypatch.delenv(key) + for key, value in REQUIRED_ENV.items(): + monkeypatch.setenv(key, value) + return monkeypatch + + +def _settings(env_file: Path | None = None) -> Settings: + return Settings(_env_file=env_file) # type: ignore[call-arg] + + +def test_app_value_applied(env: pytest.MonkeyPatch) -> None: + env.setenv("OPENBAO__APP__NATS_ENABLED", "true") + env.setenv("OPENBAO__APP__DEBUG", "true") + s = _settings() + assert s.NATS_ENABLED is True + assert s.DEBUG is True + # Секреты OpenBao по-прежнему читаются во вложенные группы + assert s.OPENBAO.NATS.NATS_URL == "nats://localhost:4222" + + +def test_default_without_app(env: pytest.MonkeyPatch) -> None: + assert _settings().NATS_ENABLED is False + + +def test_plain_env_overrides_app(env: pytest.MonkeyPatch) -> None: + env.setenv("OPENBAO__APP__NATS_ENABLED", "true") + env.setenv("NATS_ENABLED", "false") + assert _settings().NATS_ENABLED is False + + +def test_app_overrides_dotenv(env: pytest.MonkeyPatch, tmp_path: Path) -> None: + dotenv = tmp_path / ".env" + dotenv.write_text("NATS_ENABLED=false\nFORWARDED_ALLOW_IPS=1.1.1.1\n", encoding="utf-8") + env.setenv("OPENBAO__APP__NATS_ENABLED", "true") + s = _settings(dotenv) + assert s.NATS_ENABLED is True # OPENBAO__APP__ сильнее .env + assert s.FORWARDED_ALLOW_IPS == "1.1.1.1" # то, чего нет в OPENBAO__APP__, остаётся из .env + + +def test_types_parsed_like_plain_env(env: pytest.MonkeyPatch) -> None: + env.setenv("OPENBAO__APP__OUTBOX_POLL_SECONDS", str(POLL)) + env.setenv("OPENBAO__APP__AUTH_DEV_ISSUER", "1") + env.setenv("OPENBAO__APP__FORWARDED_ALLOW_IPS", "10.0.0.1,10.0.0.2") + s = _settings() + assert s.OUTBOX_POLL_SECONDS == POLL + assert s.AUTH_DEV_ISSUER is True + assert s.FORWARDED_ALLOW_IPS == "10.0.0.1,10.0.0.2" + + +@pytest.mark.parametrize( + ("raw", "expected"), + [ + ("https://a.example.ru,https://b.example.ru", ["https://a.example.ru", "https://b.example.ru"]), + ('["https://a.example.ru"]', ["https://a.example.ru"]), + ("", []), + ], + ids=["comma", "json", "empty"], +) +def test_cors_origins_list_parsed_like_plain_env(env: pytest.MonkeyPatch, raw: str, expected: list[str]) -> None: + """NoDecode-список разбирается валидатором так же, как из обычного env.""" + env.setenv("OPENBAO__APP__CORS_ORIGINS", raw) + assert _settings().CORS_ORIGINS == expected + + +def test_invalid_value_rejected(env: pytest.MonkeyPatch) -> None: + env.setenv("OPENBAO__APP__OUTBOX_POLL_SECONDS", "0") + with pytest.raises(ValidationError, match="OUTBOX_POLL_SECONDS"): + _settings() + + +def test_unknown_name_is_config_error(env: pytest.MonkeyPatch) -> None: + """Опечатка не игнорируется молча: ошибка при создании Settings (то есть при старте).""" + env.setenv("OPENBAO__APP__NATS_ENABLD", "true") + with pytest.raises(ValueError, match="NATS_ENABLD"): + _settings() + + +def test_openbao_group_not_settable_via_app(env: pytest.MonkeyPatch) -> None: + """Секреты — только `OPENBAO__<ГРУППА>__*`; `OPENBAO__APP__OPENBAO__...` — не имя настройки.""" + env.setenv("OPENBAO__APP__OPENBAO__NATS__NATS_URL", "nats://x:4222") + with pytest.raises(ValueError, match="OPENBAO"): + _settings() + + +def test_app_key_does_not_break_openbao_validation(env: pytest.MonkeyPatch, tmp_path: Path) -> None: + """Ключ APP не попадает в OpenBAOSettings (там extra=forbid) — ни из env, ни из env-файла.""" + dotenv = tmp_path / ".env" + dotenv.write_text("OPENBAO__APP__NATS_ENABLED=true\n", encoding="utf-8") + env.setenv("OPENBAO__APP__DEBUG", "true") + s = _settings(dotenv) + assert s.DEBUG is True + assert not hasattr(s.OPENBAO, "APP") + + +def test_nested_group_still_works(env: pytest.MonkeyPatch) -> None: + env.setenv("OPENBAO__MINIO__MINIO_SECURE", "false") + env.setenv("OPENBAO__APP__SERVER__PORT", str(PORT)) + s = _settings() + assert s.OPENBAO.MINIO.MINIO_SECURE is False + assert s.server.PORT == PORT -- 2.47.2