diff --git a/api/contracts/recognition-request.schema.json b/api/contracts/recognition-request.schema.json index 9d59f2c..afd5077 100644 --- a/api/contracts/recognition-request.schema.json +++ b/api/contracts/recognition-request.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "arm-ka/recognition-request/v1", "title": "Запрос распознавания файла (arm-core → tabular-parser / scan-gateway), контракт v1.0", - "description": "Тело сообщения на subject files.tabular_uploaded или files.img_uploaded; subject должен соответствовать полю pipeline (tabular → files.tabular_uploaded, image → files.img_uploaded). Заголовок Nats-Msg-Id = : (не поле тела). Один файл (stored_file) → один job.", + "description": "Тело сообщения на subject запроса; subject — настройка окружения и должен соответствовать полю pipeline (tabular → NATS_TABULAR_REQUEST_SUBJECT, по умолчанию files.tabular_uploaded; image → NATS_IMAGE_REQUEST_SUBJECT, по умолчанию files.img_uploaded). Заголовок Nats-Msg-Id = : (не поле тела). Один файл (stored_file) → один job.", "$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (1.1) — необязательные поля и новые значения enum; MAJOR (2.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает тот же MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать 1.1), потому что закрытые enum отвергают незнакомые значения.", "type": "object", "required": ["schema_version", "job_id", "file_id", "pipeline", "file_type", "original_name", "s3", "reply_deadline", "attempt"], diff --git a/api/contracts/recognition-result-message.schema.json b/api/contracts/recognition-result-message.schema.json index 7c1cfb5..4d9f401 100644 --- a/api/contracts/recognition-result-message.schema.json +++ b/api/contracts/recognition-result-message.schema.json @@ -2,7 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "arm-ka/recognition-result-message/v1", "title": "Сообщение результата распознавания (tabular-parser / scan-gateway → arm-core), контракт v1.0", - "description": "Тело сообщения на subject files.tabular_parsed. Заголовок Nats-Msg-Id (не поле тела) на этом subject имеет две формы: ack:: — для status = processing; result:: — для done, needs_check и error. Форма запроса : описана в схеме recognition-request. status = processing — ack «взял в работу», публикуется до чтения файла из S3. group_schema в конверт не входит: источник — файл результата (recognition-result).", + "description": "Тело сообщения на subject ответа (настройка окружения NATS_RESULT_SUBJECT, по умолчанию files.tabular_parsed). Заголовок Nats-Msg-Id (не поле тела) на этом subject имеет две формы: ack:: — для status = processing; result:: — для done, needs_check и error. Форма запроса : описана в схеме recognition-request. status = processing — ack «взял в работу», публикуется до чтения файла из S3. group_schema в конверт не входит: источник — файл результата (recognition-result).", "$comment": "Версионирование: schema_version = MAJOR.MINOR. MINOR (1.1) — необязательные поля и новые значения enum; MAJOR (2.0) — удаление/переименование полей, смена типов или семантики. Потребитель принимает тот же MAJOR и любой MINOR. Новый MINOR раскатывается сначала на потребителей (ядро и сервисы обновляют копии схем раньше, чем производитель начнёт слать 1.1), потому что закрытые enum отвергают незнакомые значения.", "type": "object", "required": ["schema_version", "job_id", "file_id", "status", "service"], diff --git a/api/src/contract/subjects.py b/api/src/contract/subjects.py index ee5bb55..6ce4c6d 100644 --- a/api/src/contract/subjects.py +++ b/api/src/contract/subjects.py @@ -26,10 +26,12 @@ REQUEST_SUBJECTS: Mapping[str, str] = { } -def request_subject(pipeline: str) -> str: - """Subject запроса по pipeline: `tabular` → `files.tabular_uploaded`, `image` → `files.img_uploaded`.""" +def request_subject(pipeline: str, subjects: Mapping[str, str] = REQUEST_SUBJECTS) -> str: + """Subject запроса по pipeline. `subjects` — карта `pipeline → subject` окружения + (`NATSSettings.request_subjects()`); по умолчанию — имена контракта: `tabular` → `files.tabular_uploaded`, + `image` → `files.img_uploaded`.""" try: - return REQUEST_SUBJECTS[pipeline] + return subjects[pipeline] except KeyError: raise ValueError(f"неизвестный pipeline {pipeline!r}: допустимы {', '.join(get_args(Pipeline))}") from None diff --git a/api/src/core/config.py b/api/src/core/config.py index 326608f..3baf97c 100644 --- a/api/src/core/config.py +++ b/api/src/core/config.py @@ -1,13 +1,21 @@ import os +import re from typing import Any, Literal -from pydantic import NonNegativeInt, PositiveInt, model_validator +from pydantic import NonNegativeInt, PositiveInt, SecretStr, ValidationInfo, field_validator, model_validator from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict +from src.contract.subjects import IMAGE_REQUEST_SUBJECT, RESULT_SUBJECT, TABULAR_REQUEST_SUBJECT from src.core.settings_sources import OpenBaoAppSettingsSource +# Subject NATS без шаблонов: непустые токены через точку, без пробельных символов, `*` и `>` (docs nats-env-config) +NATS_SUBJECT_RE = re.compile(r"[^\s.*>]+(?:\.[^\s.*>]+)*") +NATS_SUBJECT_FIELDS = ("NATS_TABULAR_REQUEST_SUBJECT", "NATS_IMAGE_REQUEST_SUBJECT", "NATS_RESULT_SUBJECT") + class MinioSettings(BaseSettings): + model_config = SettingsConfigDict(hide_input_in_errors=True) + MINIO_URL: str MINIO_ADMIN_LOGIN: str MINIO_ADMIN_PASSWORD: str @@ -16,12 +24,54 @@ class MinioSettings(BaseSettings): class NATSSettings(BaseSettings): + """Подключение и subject'ы NATS — секреты окружения `OPENBAO__NATS__*` (прод и тест — свои наборы). + + Пустые `NATS_SEED` и `NATS_JWT` — подключение без nkeys (локальная разработка, тесты). Subject'ы по умолчанию — + имена контракта v1.0; у ядра и сервисов одного окружения они обязаны совпадать (docs, change nats-env-config). + """ + + model_config = SettingsConfigDict(hide_input_in_errors=True) + NATS_URL: str - NATS_SEED: str = "" # пусто — подключение к NATS без nkeys (локальная разработка, тесты) - NATS_JWT: str = "" + NATS_SEED: SecretStr = SecretStr("") + NATS_JWT: SecretStr = SecretStr("") + NATS_TABULAR_REQUEST_SUBJECT: str = TABULAR_REQUEST_SUBJECT + NATS_IMAGE_REQUEST_SUBJECT: str = IMAGE_REQUEST_SUBJECT + NATS_RESULT_SUBJECT: str = RESULT_SUBJECT + + @field_validator(*NATS_SUBJECT_FIELDS) + @classmethod + def _check_subject(cls, value: str, info: ValidationInfo) -> str: + """Конкретный subject без шаблонов: иначе подписка молча слушала бы чужой трафик или ничего.""" + if not NATS_SUBJECT_RE.fullmatch(value): + raise ValueError( + f"{info.field_name}={value!r}: нужен subject NATS из непустых токенов через точку, " + "без пробелов, '*' и '>'" + ) + return value + + @model_validator(mode="after") + def _check_distinct_subjects(self) -> "NATSSettings": + """Три subject'а попарно различны: совпадение запроса и ответа — сервис получает свои ответы, двух запросов — + каждый запрос получают оба сервиса.""" + seen: dict[str, str] = {} + for field in NATS_SUBJECT_FIELDS: + subject = getattr(self, field) + if subject in seen: + raise ValueError( + f"{seen[subject]} и {field} совпадают ({subject!r}): subject'ы NATS должны различаться" + ) + seen[subject] = field + return self + + def request_subjects(self) -> dict[str, str]: + """Subject запроса по `pipeline` (`tabular`, `image`) — аргумент `request_subject` контракта.""" + return {"tabular": self.NATS_TABULAR_REQUEST_SUBJECT, "image": self.NATS_IMAGE_REQUEST_SUBJECT} class OpenBAOSettings(BaseSettings): + model_config = SettingsConfigDict(hide_input_in_errors=True) + MINIO: MinioSettings NATS: NATSSettings @@ -37,6 +87,8 @@ class OpenBAOSettings(BaseSettings): class ServerSettings(BaseSettings): + model_config = SettingsConfigDict(hide_input_in_errors=True) + HOST: str = "0.0.0.0" PORT: int = 8000 @@ -84,6 +136,7 @@ class Settings(BaseSettings): ) model_config = SettingsConfigDict( + hide_input_in_errors=True, extra="ignore", env_file=os.environ.get("ENV_FILE", ".env"), env_nested_delimiter="__", diff --git a/api/src/core/nats.py b/api/src/core/nats.py index 37353d4..cd7c4c7 100644 --- a/api/src/core/nats.py +++ b/api/src/core/nats.py @@ -88,8 +88,8 @@ def build_nats_auth(settings: NATSSettings) -> dict[str, Any]: Пустые `NATS_SEED` и `NATS_JWT` — без учётных данных (docker compose, тесты): `{}`. Заданы оба — колбэки nkeys/JWT, как в шаблоне. Задан только один или seed не читается — `NatsConfigError`. """ - seed = settings.NATS_SEED - jwt = settings.NATS_JWT + seed = settings.NATS_SEED.get_secret_value() + jwt = settings.NATS_JWT.get_secret_value() if not seed and not jwt: return {} if not seed or not jwt: diff --git a/api/tests/unit/test_config.py b/api/tests/unit/test_config.py index 0abd8b5..ce5277b 100644 --- a/api/tests/unit/test_config.py +++ b/api/tests/unit/test_config.py @@ -8,6 +8,42 @@ from pydantic import ValidationError from src.core.config import Settings SettingsFactory = Callable[..., Settings] +SettingsFromEnvFactory = Callable[..., Settings] + +# Минимальный набор обязательных переменных (как MINIMAL_ENV в conftest): окружение процесса, без .env +MINIMAL_ENV_VARS = { + "DEBUG": "true", + "OPENBAO__MINIO__MINIO_URL": "localhost:9000", + "OPENBAO__MINIO__MINIO_ADMIN_LOGIN": "minioadmin", + "OPENBAO__MINIO__MINIO_ADMIN_PASSWORD": "minioadmin", + "OPENBAO__MINIO__MINIO_BUCKET": "arm-ka", + "OPENBAO__NATS__NATS_URL": "nats://localhost:4222", +} + + +@pytest.fixture +def settings_from_env(minimal_settings: SettingsFactory, monkeypatch: pytest.MonkeyPatch) -> SettingsFromEnvFactory: + """Фабрика `settings_from_env(**env)`: Settings из переменных окружения поверх минимального набора. + + Окружение процесса очищено фикстурой `minimal_settings` (она же убирает `OPENBAO__*`); значение `None` — удалить + переменную. `.env` не читается (`_env_file=None`). + """ + + def build(**env: str | None) -> Settings: + for name, value in MINIMAL_ENV_VARS.items(): + monkeypatch.setenv(name, value) + for name, value in env.items(): + if value is None: + monkeypatch.delenv(name, raising=False) + else: + monkeypatch.setenv(name, value) + return Settings(_env_file=None) # type: ignore[call-arg] + + return build + + +JWT = "eyJ0eXAiOiJKV1QiLCJhbGciOiJlZDI1NTE5LW5rZXkifQ.secret-payload.sig" +SEED = "SUACSSL3UAHUDXKFSNVUZRF5UHPMWZ6BFDTJ7M6USDXIEDNPPQYYYCU3VY" def test_minimal_environment_gives_defaults(minimal_settings: SettingsFactory) -> None: @@ -16,8 +52,8 @@ def test_minimal_environment_gives_defaults(minimal_settings: SettingsFactory) - assert settings.DEBUG is True assert settings.OPENBAO.MINIO.MINIO_BUCKET == "arm-ka" assert settings.OPENBAO.NATS.NATS_URL == "nats://localhost:4222" - assert settings.OPENBAO.NATS.NATS_SEED == "" - assert settings.OPENBAO.NATS.NATS_JWT == "" + assert settings.OPENBAO.NATS.NATS_SEED.get_secret_value() == "" + assert settings.OPENBAO.NATS.NATS_JWT.get_secret_value() == "" assert settings.NATS_ENABLED is True assert settings.SERVICE_NAME == "tabular-parser" assert settings.WORKER_CONCURRENCY == 1 @@ -48,3 +84,66 @@ def test_service_name_other_than_contract_value_is_rejected(minimal_settings: Se """SERVICE_NAME — `service.name` конвертов контракта: другое значение — ошибка конфигурации при старте.""" with pytest.raises(ValidationError, match="SERVICE_NAME"): minimal_settings("SERVICE_NAME=scan-gateway\n") + + +def test_nats_subjects_default_to_contract_names(settings_from_env) -> None: + nats = settings_from_env().OPENBAO.NATS + assert nats.NATS_TABULAR_REQUEST_SUBJECT == "files.tabular_uploaded" + assert nats.NATS_IMAGE_REQUEST_SUBJECT == "files.img_uploaded" + assert nats.NATS_RESULT_SUBJECT == "files.tabular_parsed" + assert nats.request_subjects() == {"tabular": "files.tabular_uploaded", "image": "files.img_uploaded"} + + +def test_nats_subjects_from_openbao(settings_from_env) -> None: + nats = settings_from_env( + OPENBAO__NATS__NATS_TABULAR_REQUEST_SUBJECT="test.files.tabular_uploaded", + OPENBAO__NATS__NATS_IMAGE_REQUEST_SUBJECT="test.files.img_uploaded", + OPENBAO__NATS__NATS_RESULT_SUBJECT="test.files.tabular_parsed", + ).OPENBAO.NATS + assert nats.request_subjects() == {"tabular": "test.files.tabular_uploaded", "image": "test.files.img_uploaded"} + assert nats.NATS_RESULT_SUBJECT == "test.files.tabular_parsed" + + +@pytest.mark.parametrize( + "value", + ["", "files.*", "files.>", ">", "files..tabular", ".files", "files.", "files tabular", "files.tabular_uploaded "], +) +def test_invalid_subject_rejected(settings_from_env, value: str) -> None: + with pytest.raises(ValidationError, match="NATS_RESULT_SUBJECT"): + settings_from_env(OPENBAO__NATS__NATS_RESULT_SUBJECT=value) + + +def test_equal_subjects_rejected(settings_from_env) -> None: + with pytest.raises(ValidationError, match="NATS_TABULAR_REQUEST_SUBJECT и NATS_RESULT_SUBJECT совпадают"): + settings_from_env(OPENBAO__NATS__NATS_RESULT_SUBJECT="files.tabular_uploaded") + + +def test_nats_secrets_hidden_in_repr(settings_from_env) -> None: + settings = settings_from_env(OPENBAO__NATS__NATS_JWT=JWT, OPENBAO__NATS__NATS_SEED=SEED) + assert settings.OPENBAO.NATS.NATS_JWT.get_secret_value() == JWT + assert JWT not in repr(settings) and SEED not in repr(settings) + assert JWT not in str(settings) and SEED not in str(settings) + + +MINIO_KEYS = ("MINIO_URL", "MINIO_ADMIN_LOGIN", "MINIO_ADMIN_PASSWORD", "MINIO_BUCKET", "MINIO_SECURE") + + +@pytest.mark.parametrize( + "missing", + [ + ("OPENBAO__NATS__NATS_URL",), # нет поля в группе NATS: в ошибке — вход группы NATS (с JWT) + tuple(f"OPENBAO__MINIO__{key}" for key in MINIO_KEYS), # нет группы MINIO: в ошибке — вход всего OPENBAO + ], +) +def test_validation_error_hides_nats_secrets(settings_from_env, missing: tuple[str, ...]) -> None: + """Ошибка «нет поля» печатает вход всей группы — JWT и seed туда попадать не должны (Review Focus 1). + + pydantic в тексте ошибки усекает значения (`'SUACSSL3UA...PQYYYCU3VY'`), поэтому полный секрет там не появляется + даже без скрытия; проверяем края секретов — они и попадают в текст при утечке. + """ + with pytest.raises(ValidationError) as exc_info: + settings_from_env(OPENBAO__NATS__NATS_JWT=JWT, OPENBAO__NATS__NATS_SEED=SEED, **dict.fromkeys(missing)) + text = str(exc_info.value) + for secret in (JWT, SEED): + for fragment in (secret[:10], secret[-10:]): + assert fragment not in text diff --git a/api/tests/unit/test_contract.py b/api/tests/unit/test_contract.py index 074d3c7..9a70731 100644 --- a/api/tests/unit/test_contract.py +++ b/api/tests/unit/test_contract.py @@ -650,6 +650,15 @@ def test_request_subject_rejects_unknown_pipeline() -> None: request_subject("ocr") +def test_request_subject_uses_environment_map() -> None: + """Карта `subjects` окружения (NATSSettings.request_subjects()) подменяет имена контракта по умолчанию.""" + subjects = {"tabular": "test.files.tabular_uploaded", "image": "test.files.img_uploaded"} + assert request_subject("tabular", subjects) == "test.files.tabular_uploaded" + assert request_subject("image", subjects) == "test.files.img_uploaded" + with pytest.raises(ValueError, match="неизвестный pipeline"): + request_subject("ocr", subjects) + + def test_request_subject_matches_example_pipeline() -> None: for name in ("request-tabular.json", "request-image.json"): request = RecognitionRequest.model_validate(_example(name))