From f0c10739185c7de551cd95ba74c3c81a759f2c03 Mon Sep 17 00:00:00 2001 From: Andrey Epifancev Date: Tue, 13 Oct 2026 12:07:20 +0400 Subject: [PATCH] =?UTF-8?q?NATS:=20subject'=D1=8B=20=D0=B8=20JWT=20=D0=B8?= =?UTF-8?q?=D0=B7=20OPENBAO=5F=5FNATS=5F=5F*,=20SecretStr,=20=D1=81=D0=BA?= =?UTF-8?q?=D1=80=D1=8B=D1=82=D0=B8=D0=B5=20=D0=B2=D1=85=D0=BE=D0=B4=D0=B0?= =?UTF-8?q?=20=D0=B2=20=D0=BE=D1=88=D0=B8=D0=B1=D0=BA=D0=B0=D1=85=20(#106)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NATSSettings: NATS_TABULAR_REQUEST_SUBJECT / NATS_IMAGE_REQUEST_SUBJECT / NATS_RESULT_SUBJECT с умолчанием имён контракта v1.0; проверка формата (без пробелов, '*', '>', пустых токенов) и попарного различия. - request_subjects() и request_subject(pipeline, subjects) — карта subject'ов окружения. - NATS_SEED / NATS_JWT — SecretStr; build_nats_auth читает значения через get_secret_value(). - hide_input_in_errors=True во всех классах настроек: ошибка «нет поля» не печатает JWT/seed. - Схемы контракта api/contracts/ — копии docs/contracts/ (побайтно). Co-Authored-By: Claude Opus 5.5 --- api/contracts/recognition-request.schema.json | 2 +- .../recognition-result-message.schema.json | 2 +- api/src/contract/subjects.py | 8 +- api/src/core/config.py | 61 +++++++++++- api/src/core/nats.py | 4 +- api/tests/test_config.py | 99 ++++++++++++++++--- api/tests/test_contract.py | 6 ++ 7 files changed, 159 insertions(+), 23 deletions(-) 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 243b11d..19a4bb7 100644 --- a/api/src/contract/subjects.py +++ b/api/src/contract/subjects.py @@ -22,10 +22,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 f9583d0..63feae4 100644 --- a/api/src/core/config.py +++ b/api/src/core/config.py @@ -3,19 +3,27 @@ import os import re from typing import Annotated, Any -from pydantic import NonNegativeInt, PositiveInt, field_validator, model_validator +from pydantic import NonNegativeInt, PositiveInt, SecretStr, ValidationInfo, field_validator, model_validator from pydantic_settings import BaseSettings, NoDecode, PydanticBaseSettingsSource, SettingsConfigDict +from src.contract.subjects import IMAGE_REQUEST_SUBJECT, RESULT_SUBJECT, TABULAR_REQUEST_SUBJECT from src.core.settings_sources import OpenBaoAppSettingsSource DB_SCHEMA_RE = re.compile(r"[a-z_][a-z0-9_]{0,62}") +# 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 DBSettings(BaseSettings): + model_config = SettingsConfigDict(hide_input_in_errors=True) + DATABASE_URL: str class MinioSettings(BaseSettings): + model_config = SettingsConfigDict(hide_input_in_errors=True) + MINIO_URL: str MINIO_ADMIN_LOGIN: str MINIO_ADMIN_PASSWORD: str @@ -24,15 +32,57 @@ 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 без nkeys (локальная разработка, тесты) + 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 SMBSettings(BaseSettings): """SMB (горячие папки ИС Сканирование, шаблон). Необязательны: ядру в Срезе 0 SMB не нужен; пустой `SMB_HOST` — сессия SMB не регистрируется.""" + model_config = SettingsConfigDict(hide_input_in_errors=True) + SMB_HOST: str = "" SMB_PATH: str = "" SMB_LOGIN: str = "" @@ -40,6 +90,8 @@ class SMBSettings(BaseSettings): class OpenBAOSettings(BaseSettings): + model_config = SettingsConfigDict(hide_input_in_errors=True) + DATABASE: DBSettings MINIO: MinioSettings NATS: NATSSettings @@ -57,6 +109,8 @@ class OpenBAOSettings(BaseSettings): class ServerSettings(BaseSettings): + model_config = SettingsConfigDict(hide_input_in_errors=True) + HOST: str = "0.0.0.0" PORT: int = 8000 @@ -162,6 +216,7 @@ class Settings(BaseSettings): env_file=os.environ.get("ENV_FILE", ".env"), env_nested_delimiter="__", nested_model_default_partial_update=False, + hide_input_in_errors=True, ) diff --git a/api/src/core/nats.py b/api/src/core/nats.py index 536f278..292bca6 100644 --- a/api/src/core/nats.py +++ b/api/src/core/nats.py @@ -66,8 +66,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/test_config.py b/api/tests/test_config.py index c570c8a..36a30ce 100644 --- a/api/tests/test_config.py +++ b/api/tests/test_config.py @@ -36,15 +36,7 @@ def test_env_test_is_read(clean_env: pytest.MonkeyPatch) -> None: def test_defaults(clean_env: pytest.MonkeyPatch) -> None: """Значения по умолчанию классов настроек: env-файла нет, заданы только обязательные OPENBAO__* (SMB не нужен).""" - required = { - "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", - } - for key, value in required.items(): + for key, value in REQUIRED_ENV.items(): clean_env.setenv(key, value) s = Settings(_env_file=None) # type: ignore[call-arg] assert s.DEBUG is False @@ -58,8 +50,8 @@ def test_defaults(clean_env: pytest.MonkeyPatch) -> None: assert s.NATS_ENABLED is False assert (s.OUTBOX_POLL_SECONDS, s.ACK_TIMEOUT_SECONDS, s.OUTBOX_MAX_REPUBLISH) == (5, 30, 3) assert (s.UPLOAD_MAX_BYTES, s.MAPPING_POLL_SECONDS, s.MAPPING_MAX_ATTEMPTS) == (50 * 1024 * 1024, 5, 3) - assert s.OPENBAO.NATS.NATS_SEED == "" - assert s.OPENBAO.NATS.NATS_JWT == "" + assert s.OPENBAO.NATS.NATS_SEED.get_secret_value() == "" + assert s.OPENBAO.NATS.NATS_JWT.get_secret_value() == "" assert s.OPENBAO.MINIO.MINIO_SECURE is True assert (s.server.HOST, s.server.PORT) == ("0.0.0.0", 8000) # noqa: S104 - по умолчанию, как в контейнере smb = s.OPENBAO.SMB @@ -69,8 +61,8 @@ def test_defaults(clean_env: pytest.MonkeyPatch) -> None: def test_empty_nats_seed_and_jwt_allowed(clean_env: pytest.MonkeyPatch) -> None: """Пустые NATS_SEED/NATS_JWT допустимы (подключение без nkeys).""" s = Settings(_env_file=API_DIR / ".env.example") # type: ignore[call-arg] - assert s.OPENBAO.NATS.NATS_SEED == "" - assert s.OPENBAO.NATS.NATS_JWT == "" + assert s.OPENBAO.NATS.NATS_SEED.get_secret_value() == "" + assert s.OPENBAO.NATS.NATS_JWT.get_secret_value() == "" assert s.NATS_ENABLED is True assert s.OPENBAO.SMB.SMB_HOST == "" assert (s.server.HOST, s.server.PORT) == ("127.0.0.1", 8000) # dev-запуск — только loopback (M-11) @@ -155,3 +147,84 @@ def test_cors_origins_from_env(clean_env: pytest.MonkeyPatch, raw: str, expected clean_env.setenv("CORS_ORIGINS", raw) s = Settings(_env_file=API_DIR / ".env.test") # type: ignore[call-arg] assert s.CORS_ORIGINS == expected + + +@pytest.fixture +def settings_from_env(clean_env: pytest.MonkeyPatch): + def make(**env: str | None) -> Settings: + for key, value in {**REQUIRED_ENV, **env}.items(): + if value is None: + clean_env.delenv(key, raising=False) + else: + clean_env.setenv(key, value) + return Settings(_env_file=None) # type: ignore[call-arg] + + return make + + +JWT = "eyJ0eXAiOiJKV1QiLCJhbGciOiJlZDI1NTE5LW5rZXkifQ.secret-payload.sig" +SEED = "SUACSSL3UAHUDXKFSNVUZRF5UHPMWZ6BFDTJ7M6USDXIEDNPPQYYYCU3VY" + + +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 обрезает `input_value` в тексте ошибки (около 50 символов по середине), и полный + JWT там не виден даже без скрытия — проверка на полном значении прошла бы вхолостую. + """ + jwt_mark, seed_mark = "JWTMARK", "SEEDMARK" + with pytest.raises(ValidationError) as exc_info: + settings_from_env( + OPENBAO__NATS__NATS_JWT=jwt_mark, OPENBAO__NATS__NATS_SEED=seed_mark, **dict.fromkeys(missing) + ) + assert jwt_mark not in str(exc_info.value) + assert seed_mark not in str(exc_info.value) diff --git a/api/tests/test_contract.py b/api/tests/test_contract.py index cb732c0..44a8041 100644 --- a/api/tests/test_contract.py +++ b/api/tests/test_contract.py @@ -626,6 +626,12 @@ def test_request_subject_by_pipeline() -> None: assert NATS_MSG_ID_HEADER == "Nats-Msg-Id" +def test_request_subject_uses_environment_map() -> None: + 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" + + def test_request_subject_rejects_unknown_pipeline() -> None: with pytest.raises(ValueError, match="pipeline"): request_subject("ocr")