feat(config): несекретные настройки из OpenBao через OPENBAO__APP__<ИМЯ> (#44) #49

Merged
aep merged 1 commits from feat/44-openbao-app-settings into main 2026-10-07 17:42:13 +03:00
5 changed files with 224 additions and 2 deletions
Showing only changes of commit afc457f593 - Show all commits

View File

@ -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) |

View File

@ -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-токенов выдаёт токен любому, кто знает

View File

@ -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:

View File

@ -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)."
)

View File

@ -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