Files
han-app/VM1_app/documentation/module-11-idgtl-sms.md
T

892 lines
67 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# module-11. Сервис доставки SMS (i-Digital Direct)
> Статус: целевая проектная спецификация post-MVP (закрывает K-TBD8 / бэклог «интеграция с SMS-провайдером»).
> Реализация отсутствует. Документ задаёт обязательные контракты для разработки `sms-service` и доработки Keycloak.
> Источники провайдера: [Отправка SMS](https://api.docs.direct.i-dgtl.ru/messages/sms-sending/), [Авторизация](https://api.docs.direct.i-dgtl.ru/authorization/), [Callback](https://api.docs.direct.i-dgtl.ru/messages/callback/).
> Смежные: [`module-08-keycloak.md`](module-08-keycloak.md), [`arch-01`](../../architectory/arch-01-system-architecture.md), [`arch-02`](../../architectory/arch-02-api-contracts.md), [`arch-04`](../../architectory/arch-04-settings-and-content.md), [`arch-06`](../../architectory/arch-06-service-hosting-security.md).
**Критерий применимости:** документ описывает target real-SMS rollout, который остаётся post-MVP backlog до реализации и закрытия gates. Текущее as-is состояние до cutover — `KEYCLOAK_OTP_MOCK_ENABLED=true`. При конфликте приоритет всегда у architectory/README; настоящий модуль не переопределяет действующий mock-only runtime сам по себе.
## 1. Разделение ответственности
| Зона | Модуль | Что хранит / делает |
|---|---|---|
| Доставка сообщений | **module-11 (sms-service)** | Шаблоны, журнал отправок (кому/что/когда/статусы), вызов провайдера, callback доставки |
| Auth OTP | **module-08 (Keycloak)** | Генерация и локальная проверка кода, challenge, лимиты, **результат verify**, **контекст устройства**, ссылка на `sms_message_id` |
**Жёсткие правила:**
1. Keycloak **не** вызывает i-Digital напрямую и **не** хранит полный журнал SMS (текст, delivery status провайдера, шаблоны).
2. sms-service **не** генерирует OTP, **не** проверяет код и **не** знает, верно ли пользователь ввёл код.
3. Связка: Keycloak получает от sms-service `sms_message_id` и сохраняет его в своём challenge/событиях.
4. [API верификации телефона](https://api.docs.direct.i-dgtl.ru/verifier/api/) (`/verifier/send`, `/verifier/check`) **не используется**.
```text
User → nginx → Keycloak
│ 1. generate OTP, create challenge (+ device context)
│ 2. POST /internal/sms/v1/send → sms-service
│ ├─ render template
│ ├─ INSERT sms_outbound_message
│ └─ return sms_message_id
│ 3. сохранить sms_message_id в challenge
│ 4. user enters code → local verify
│ 5. записать verify outcome (+ device) в Keycloak DB
└─ OIDC code
sms-service worker → POST Direct /api/v1/message → update send_status
Direct callback → sms-service only → update delivery_status
```
Текущий заказчик: `keycloak`. Процесс: `auth_otp`. Канал: `SMS`. Провайдер: `idgtl` (резервный канал — будущее расширение той же модели).
---
## 2. Границы module-11
### В scope
- отдельный сервис `sms-service` (Compose-модуль);
- схема БД: шаблоны + журнал исходящих сообщений;
- internal API для заказчиков (сейчас Keycloak);
- адаптер провайдера `idgtl` (`POST /api/v1/message`, `TOKEN_1`);
- асинхронная отправка worker-ом и обновление статусов отправки/доставки;
- секреты провайдера, health/metrics.
- OpenAPI 3.1 для internal send/read API и JSON Schema callback;
- бессрочный журнал отправок и reconciliation зависших `pending`/`uncertain`.
### Вне scope
- генерация/проверка OTP;
- product limits `otp.phone.*` (остаются в Keycloak);
- каскады VK/WhatsApp, FLASHCALL, рассылки;
- публичный API для frontend;
- решение «пользователь авторизован» / выдача токенов.
---
## 3. Модель данных module-11
Схема: отдельная managed PostgreSQL schema, например `sms` (роль `sms_user`). App DB `han_app` и schema `keycloak` **не** используются для журнала SMS.
### 3.1. `sms_template` — шаблоны
Шаблон **не** хранится в env. Env только credentials/timeouts провайдера.
| Поле | Тип | Описание |
|---|---|---|
| `id` | UUID PK | Идентификатор версии шаблона |
| `code` | varchar | Стабильный код, напр. `auth_otp` |
| `channel` | enum | `SMS` (расширяемо) |
| `locale` | varchar | напр. `ru` |
| `version` | int | Монотонная версия внутри `code`+`channel`+`locale` |
| `body_template` | text | Текст с плейсхолдерами, напр. `Код входа в HAN Chat: {code}. Действителен {ttl_min} мин.` |
| `placeholders` | jsonb | Описание обязательных ключей: `["code","ttl_min"]` |
| `sender_name` | varchar | Имя отправителя для этого шаблона (или null → default провайдера) |
| `max_parts` | int | Максимально допустимое число SMS-частей; для `auth_otp``1` |
| `is_active` | bool | Активная версия для `code` (ровно одна active на code+channel+locale) |
| `approved_at` | timestamptz | Согласование с оператором/провайдером |
| `created_at` / `updated_at` | timestamptz | Аудит |
| `created_by` | varchar | ops/system |
Seed первой версии: `code=auth_otp`, `channel=SMS`, `locale=ru`.
### 3.2. Настройки SMS и OTP
Параметры, изменение которых не требует изменения Compose, секретов или сетевой топологии, в `.env` не хранятся.
**OTP-настройки в `han_app.app_settings`** (владелец продукта, потребитель — Keycloak через settings bridge):
| Ключ | Тип | Seed | Назначение |
|---|---|---:|---|
| `otp.phone.code_length` | integer | `6` | Длина numeric OTP |
| `otp.phone.ttl_seconds` | integer | `60` | Срок жизни OTP от `ordered_at`; диапазон `60..900`, значение кратно 60 |
| `otp.phone.sms_order_timeout_ms` | integer | `3000` | Timeout Keycloak → sms-service только на durable order |
Эти ключи возвращаются существующим `GET /internal/settings/v1/otp` вместе с лимитами и `version`. Keycloak сохраняет snapshot `otp_ttl_sec`, `otp_code_length` и `settings_version` в challenge. Изменение settings действует только на новые challenges.
**Технические настройки в `sms.sms_setting`** (владелец — `sms-service`):
| Ключ | Тип | Seed | Назначение |
|---|---|---:|---|
| `provider.idgtl.default_sender_name` | string | согласованное имя | Default, если sender отсутствует в шаблоне |
| `provider.idgtl.connect_timeout_ms` | integer | `3000` | Connect timeout worker → Direct |
| `provider.idgtl.request_timeout_ms` | integer | `70000` | Total/read timeout worker → Direct |
| `provider.idgtl.callback_enabled` | boolean | `true` | Включение callback в production |
| `worker.poll_interval_ms` | integer | `500` | Интервал поиска pending-заказов |
| `worker.lease_seconds` | integer | `90` | Lease записи на время внешнего вызова |
Минимальные поля `sms_setting`: `setting_key` PK, `setting_value`, `value_type`, `description`, `updated_at`. Seed выполняется versioned migration. `sms-service` валидирует обязательные ключи при startup, кэширует их и периодически перечитывает по `updated_at`; некорректное значение не применяется и вызывает alert.
### 3.3. `sms_outbound_message` — журнал отправок
Каждый заказ Keycloak на новую SMS — одна строка. Повторные HTTP-попытки worker по тому же заказу увеличивают `attempt_count`, но не создают новую строку. Resend создаёт новый challenge и новую строку. Это **источник истины** «когда, кому и какой текст заказали, что произошло при отправке и доставке».
| Поле | Тип | Обязательность | Описание |
|---|---|---|---|
| `id` | UUID PK | да | **`sms_message_id`** — то, на что ссылается Keycloak |
| `created_at` | timestamptz | да | Создание записи (до/в момент вызова провайдера) |
| `requested_at` | timestamptz | да | Время запроса от заказчика |
| `accepted_at` | timestamptz | нет | Провайдер принял сообщение |
| `sent_at` | timestamptz | нет | Статус sent от провайдера/callback |
| `delivered_at` | timestamptz | нет | delivered |
| `updated_at` | timestamptz | да | Последнее изменение статусов |
| `requester_service` | varchar | да | Заказчик: сейчас `keycloak`; позже др. сервисы |
| `process` | varchar | да | Бизнес-процесс: сейчас `auth_otp` |
| `channel` | varchar | да | `SMS` |
| `provider` | varchar | да | Сервис доставки: сейчас `idgtl`; резерв — новый код |
| `phone_e164` | varchar | да | Кому: E.164 (`+79001234567`) |
| `phone_digits` | varchar | да | Как у провайдера: `79001234567` |
| `phone_masked` | varchar | да | Для UI/ops без полного номера |
| `template_id` | UUID FK | да | Ссылка на `sms_template.id` |
| `template_code` | varchar | да | Денормализация `auth_otp` |
| `body_rendered` | text | да | Итоговый текст, ушедший провайдеру |
| `substitutions` | jsonb | да | Подстановки (`code`, `ttl_min`, …) |
| `send_status` | enum | да | Статус **отправки** (наш/accept) |
| `delivery_status` | enum | да | Статус **доставки** (провайдер) |
| `provider_message_id` | varchar | нет | `messageUuid` Direct |
| `provider_external_id` | varchar | нет | `externalMessageId`, отправленный в Direct |
| `customer_ref` | varchar | нет | Корреляция заказчика (напр. Keycloak `challenge_id`) |
| `idempotency_key` | varchar | да | Уникальный ключ от заказчика; защита от дублей |
| `request_fingerprint` | varchar | да | SHA-256 канонического значимого payload для обнаружения повторного ключа с другим запросом |
| `request_id` | varchar | нет | `X-Request-ID` / trace |
| `provider_http_status` | int | нет | HTTP ответа Direct |
| `provider_error_code` | varchar | нет | Код ошибки провайдера |
| `provider_error_message` | varchar | нет | Краткий класс/текст ошибки (без секретов) |
| `sender_name` | varchar | да | Фактически использованное имя |
| `message_ttl_sec` | int | нет | TTL у провайдера |
| `attempt_count` | int | да | Число HTTP-попыток к провайдеру |
| `last_attempt_at` | timestamptz | нет | Время последней попытки worker |
| `next_attempt_at` | timestamptz | нет | Когда разрешена следующая однозначно безопасная попытка |
| `worker_locked_until` | timestamptz | нет | Lease фонового worker для защиты от параллельной обработки |
| `parts` / `price` / `currency` | — | нет | Из price-callback, если включён |
| `callback_last_at` | timestamptz | нет | Последний callback |
#### Enum `send_status` (отправка)
| Значение | Смысл |
|---|---|
| `pending` | Запись создана, вызов провайдера ещё не завершён |
| `accepted` | Провайдер принял (`errors=false`, success item code) |
| `rejected` | Провайдер отклонил (4xx бизнес) |
| `failed` | Однозначный технический сбой до передачи запроса провайдеру |
| `uncertain` | Результат внешнего вызова неизвестен: запрос мог быть принят, но подтверждение не получено |
| `skipped` | Не вызывали провайдера (напр. dry-run/dev) |
#### Enum `delivery_status` (доставка)
| Значение | Смысл |
|---|---|
| `unknown` | Ещё нет данных о доставке |
| `sent` | Отправлено оператору |
| `delivered` | Доставлено |
| `undelivered` | Не доставлено за TTL |
| `unsent` | Не отправлено |
`send_status` и `delivery_status`**разные** оси и относятся только к журналу `sms-service`. Keycloak не читает их, не ждёт и не использует при проверке OTP. Безопасность обеспечивается тем, что корректный код известен только Keycloak и получателю SMS.
### 3.4. Дополнительные поля (рекомендации)
Имеет смысл заложить сразу:
| Поле | Зачем |
|---|---|
| `idempotency_key` UNIQUE | Повтор Keycloak при timeout не создаёт вторую SMS |
| `customer_ref` | Связь с challenge без join через другие БД |
| `phone_masked` | Ops-выборки без полного MSISDN |
| `attempt_count` + timestamps | Диагностика retry |
| `provider` как код | Переключение/failover без смены схемы |
| `template_id` + `template_code` | Аудит «какой текст был согласован» |
| `request_id` | Сквозная трассировка |
| архивирование/партиционирование | Журнал хранится бессрочно; при росте объёма используются месячные partition и перенос старых partition в архивный storage без удаления данных |
**Хранение журнала:**
- application-level encryption текста и substitutions не применяется: после истечения OTP они не дают возможности авторизоваться, а отдельный контур ключей несоразмерно усложняет реализацию;
- используется штатное encryption at rest managed PostgreSQL и backups;
- OTP действует `challenge.otp_ttl_sec` от `ordered_at`; snapshot берётся из `app_settings["otp.phone.ttl_seconds"]`, после истечения код не принимается независимо от состояния SMS;
- автоматическое удаление, очистка или обезличивание строк журнала запрещены;
- текст, substitutions, телефон, provider IDs, статусы и timestamps сохраняются бессрочно для будущего аудита и аналитики;
- при росте объёма допускаются PostgreSQL partitioning, сжатие backup и перенос старых partition в архивное хранилище при сохранении возможности восстановления/выборки;
- удаление возможно только отдельной утверждённой процедурой по юридическому требованию или запросу субъекта данных, с audit события;
- hash итогового текста/OTP отдельно не хранится;
- полный телефон доступен только роли `sms_user`; ops/read API по умолчанию возвращает mask;
- доступ к raw `body_rendered`/`substitutions` разрешён только `sms_user`; internal read API их не возвращает.
В логах/метриках текст, OTP, полный телефон, callback credentials и Authorization **запрещены**.
### 3.5. Индексы
- UNIQUE(`requester_service`, `idempotency_key`);
- UNIQUE(`provider`, `provider_message_id`) where not null;
- (`phone_e164`, `created_at DESC`);
- (`requester_service`, `process`, `created_at DESC`);
- (`customer_ref`);
- (`send_status`, `created_at`);
- (`delivery_status`, `updated_at`).
- UNIQUE(`code`, `channel`, `locale`, `version`) для шаблонов;
- UNIQUE partial (`code`, `channel`, `locale`) where `is_active=true`.
Все enum/check constraints и индексы создаются versioned-миграциями. DDL-on-start запрещён.
---
## 4. Internal API module-11 (для заказчиков)
Только закрытая Docker-сеть `backend`. Auth: `Authorization: Bearer <token>`.
- `KEYCLOAK_SMS_SERVICE_TOKEN` передаёт Keycloak; значение равно `SMS_SERVICE_TOKEN`, который проверяет `sms-service`;
- токен — random secret не менее 32 bytes, constant-time compare, без вывода в логи;
- в v1 разрешён только caller `keycloak` и только process/template `auth_otp`;
- `requester_service`, `process`, `channel` и `provider` не считаются доверенными данными запроса: сервис сверяет их с allowlist токена либо подставляет серверные значения;
- `X-Request-ID` и `traceparent` передаются сквозным образом;
- rate limit по caller + destination HMAC обязателен как дополнительная защита при компрометации service token.
### 4.1. `POST /internal/sms/v1/send`
Запрос:
```text
{
"idempotency_key": "keycloak:challenge:01JABCDEF",
"template_code": "auth_otp",
"locale": "ru",
"phone_e164": "+79001234567",
"substitutions": {
"code": "482193",
"ttl_min": "<challenge.otp_ttl_sec / 60>"
},
"customer_ref": "01JABCDEF",
"message_ttl_sec": <challenge.otp_ttl_sec>
}
```
`message_ttl_sec` равен snapshot `app_settings["otp.phone.ttl_seconds"]` для challenge. `ttl_min` вычисляется из того же snapshot; настройка обязана быть кратна 60.
`requester_service=keycloak`, `process=auth_otp`, `channel=SMS`, `provider=idgtl` определяются сервером по service token/route. `request_id` передаётся только заголовком `X-Request-ID` и не входит в idempotency fingerprint.
Поведение:
1. Проверить service token и allowlist caller/process/template/provider.
2. Нормализовать и повторно проверить E.164; `phone_digits` должен однозначно соответствовать `phone_e164`.
3. Проверить `message_ttl_sec` в диапазоне Direct `60..86400`, длины полей и строгий набор substitutions; неизвестные/пропущенные placeholder → `422`.
4. Рассчитать `request_fingerprint` по каноническому значимому payload.
5. Если `(requester_service,idempotency_key)` уже есть:
- fingerprint совпадает → вернуть сохранённый результат без нового внешнего вызова;
- fingerprint отличается → `409 idempotency_key_reused`.
Конкурентная вставка разрешается UNIQUE constraint: проигравшая transaction перечитывает существующую запись и применяет те же правила fingerprint.
6. Найти единственный active `sms_template` по `template_code`+`channel`+`locale`; locale fallback в v1 отсутствует.
7. Срендерить `body_rendered`; проверить лимит длины, UTF-8 без BOM и ожидаемое число SMS-частей.
8. В одной DB transaction вставить `sms_outbound_message` (`send_status=pending`, `delivery_status=unknown`, `next_attempt_at=now`).
9. Commit гарантирует, что заказ на отправку сохранён.
10. Немедленно вернуть `sms_message_id`; внешний API Direct в обработчике этого запроса не вызывается.
11. Фоновый worker выбирает готовые `pending` через lease/`FOR UPDATE SKIP LOCKED`, вызывает адаптер `idgtl` и обновляет journal row.
Ответ `202 Accepted` для нового заказа:
```json
{
"sms_message_id": "9f3c…",
"ordered_at": "2026-07-22T13:00:00Z"
}
```
Ошибки используют envelope из `arch-02`: `401 unauthorized`, `409 idempotency_key_reused`, `422 sms_request_invalid`, `429 rate_limit_exceeded`, `503 sms_service_unavailable`.
Правило ответа:
- `202` означает только «заказ надёжно записан в БД sms-service», но не подтверждает отправку или доставку;
- идемпотентный повтор с тем же fingerprint возвращает `200` и тот же `sms_message_id` независимо от текущего provider status;
- ошибки до commit journal row возвращаются соответствующим 4xx/5xx;
- Keycloak считает задачу «заказать SMS» выполненной при `200`/`202` и наличии `sms_message_id`;
- Keycloak не анализирует и не запрашивает `send_status`, `delivery_status` или `provider_message_id`.
### 4.2. `GET /internal/sms/v1/messages/{sms_message_id}`
Для диагностики заказчика. Доступ Keycloak разрешён только к сообщениям `requester_service=keycloak`. Endpoint никогда не отдаёт OTP, substitutions или полный итоговый текст, в том числе через privileged flag. Телефон всегда masked.
### 4.3. Callback от Direct
Публичный endpoint: `POST /callbacks/idgtl/sms` через root nginx. Префикс `/internal/*` для callback запрещён.
Защита:
- только HTTPS;
- nginx allowlist source IP `185.203.96.7`; изменение IP требует сверки с актуальной документацией Direct;
- Basic auth callback (`IDGTL_SMS_CALLBACK_USERNAME` / `IDGTL_SMS_CALLBACK_PASSWORD`), который Direct поддерживает через credentials в `callbackUrl`;
- URL с credentials и Authorization редактируются во всех логах/traces;
- service дополнительно проверяет `channel_type=SMS`, известный `message_uuid` и соответствие `external_message_id`.
Обработка:
- callback body — массив; каждый item валидируется и обрабатывается независимо;
- дедупликация по `(message_uuid, callback_event, status, status_time)`;
- повторы ожидаемы: при отсутствии 2xx Direct повторяет callback каждые 5 минут в течение суток;
- `status_time` провайдера сохраняется как время статуса; `callback_last_at` — время получения;
- переходы монотонны: поздний `sent` не понижает `delivered`/`undelivered`/`unsent`;
- неизвестный/противоречивый item пишется в security log без PII и не изменяет запись;
- 2xx возвращается только после успешной фиксации всех валидных items; transient DB failure → 5xx для повтора.
Callback обновляет только `delivery_status`, timestamps, error code и price. **Не** уведомляет Keycloak и **не** влияет на verify.
---
## 5. Адаптер провайдера `idgtl`
### 5.1. Вызов
```http
POST https://direct.i-dgtl.ru/api/v1/message
Authorization: Basic {TOKEN_1}
Content-Type: application/json
```
```text
[
{
"channelType": "SMS",
"senderName": "<from template or default>",
"destination": "79001234567",
"content": "<body_rendered>",
"externalMessageId": "<sms_message_id>",
"ttl": <message_ttl_sec>,
"callbackUrl": "https://<basic-credentials>@tohin.ru/callbacks/idgtl/sms",
"callbackEvents": ["delivered", "sent"]
}
]
```
Успех: только HTTP 200, `errors=false`, ровно один response item, `item.code=201`, валидный `messageUuid` и совпадающий `externalMessageId``send_status=accepted`.
Маппинг остальных результатов:
- HTTP `401`/`402`/`403`/`422``rejected`, без retry; сохранить provider error code и безопасный класс ошибки;
- HTTP 200 с `errors=true`, отсутствующим item, `item.code!=201`, неверным `externalMessageId` или невалидным `messageUuid``rejected` и alert о нарушении provider contract;
- connect failure до установления соединения → `failed`; допускается ограниченный retry с jitter;
- полученный явный `503` до такого подтверждения → `uncertain`; retry разрешается только после письменного подтверждения Direct, что сообщение не создано;
- read timeout, connection reset после отправки body, `502`/`504` и любой ответ, при котором неизвестно, создал ли Direct сообщение, → `uncertain`, **без автоматического retry**.
`externalMessageId` всегда равен `sms_message_id` и не использует `customer_ref`.
### 5.2. Таймауты и защита от дублей
Direct рекомендует ожидание ответа до 70 секунд. Фактические значения берутся из settings:
- connect timeout worker → Direct — `sms_setting["provider.idgtl.connect_timeout_ms"]`;
- total/read timeout worker → Direct — `sms_setting["provider.idgtl.request_timeout_ms"]`;
- timeout Keycloak → sms-service для записи заказа — snapshot `app_settings["otp.phone.sms_order_timeout_ms"]`;
- ожидание Direct происходит только в background worker и не удерживает Keycloak auth request;
- при превышении provider request timeout результат считается `uncertain`; новый вызов Direct с тем же или другим `externalMessageId` автоматически не выполняется.
Local idempotency защищает только от повторного запроса Keycloak к `sms-service`. Она **не доказывает** идемпотентность Direct. До письменного подтверждения провайдера `externalMessageId` считается корреляцией, а не idempotency key.
### 5.3. Env (только infra, не шаблоны)
```text
KEYCLOAK_SMS_SERVICE_URL=http://sms-service:8080
SMS_SERVICE_TOKEN=<secret checked by sms-service>
KEYCLOAK_SMS_SERVICE_TOKEN=<same secret used by Keycloak>
SMS_DATABASE_URL=postgresql://sms_user:...@<managed-pg>/<db>?...
IDGTL_SMS_BASE_URL=https://direct.i-dgtl.ru
IDGTL_SMS_API_KEY=<TOKEN_1>
IDGTL_SMS_CALLBACK_PUBLIC_URL=https://tohin.ru/callbacks/idgtl/sms
IDGTL_SMS_CALLBACK_USERNAME=<random>
IDGTL_SMS_CALLBACK_PASSWORD=<random>
```
Здесь намеренно отсутствуют OTP TTL/length/order timeout, sender default, provider timeouts, callback flag и worker intervals: они хранятся в `app_settings` или `sms.sms_setting` согласно §3.2.
`KEYCLOAK_OTP_MOCK_ENABLED=true` — Keycloak **не** вызывает sms-service (текущий MVP).
`false` + sms-service down/unconfigured — новый заказ SMS завершается generic unavailable; уже созданные active challenges продолжают локальную проверку до TTL.
`IDGTL_SMS_API_KEY` содержит выданный Direct готовый API key для Basic (`TOKEN_1`); повторно Base64-кодировать его запрещено. При возможности у Direct включается outbound IP allowlist на egress IP VM.
`senderName` обязателен у Direct. Если он отсутствует и в active template, и в `sms_setting["provider.idgtl.default_sender_name"]`, readiness=false и отправка запрещена.
### 5.4. Запрещено
| Метод | Почему |
|---|---|
| `/api/v1/verifier/send` | код генерирует провайдер |
| `/api/v1/verifier/check` | проверка у провайдера |
| вызов Direct из Keycloak | нарушает границу module-11 |
---
## 6. Что хранит Keycloak (module-08) — отдельно
Keycloak остаётся владельцем auth-факта. Расширить provider-owned таблицы в schema `keycloak` (не копировать журнал SMS).
Текущая реализация mock-only должна быть изменена: `Config` больше не запрещает startup при `KEYCLOAK_OTP_MOCK_ENABLED=false`, а `OtpStore.reserve()` не должен хешировать постоянный `KEYCLOAK_OTP_MOCK_CODE` в real mode.
### 6.1. Challenge + ссылка на SMS
`han_otp_challenge` (расширение):
| Поле | Описание |
|---|---|
| существующие | `id`, `phone_hmac`, `destination_masked`, `otp_hash`, TTL, `verify_attempts`, `consumed_at`, … |
| `sms_message_id` | UUID из module-11; **логическая** ссылка (FK между БД нет) |
| `delivery_mode` | `mock` / `sms` — snapshot режима challenge |
| `challenge_status` | `ordering` / `active` / `consumed` / `superseded` / `expired` / `limited` / `order_failed` |
| `ordered_at` | Когда sms-service надёжно принял заказ; с этого момента challenge `active` |
| `otp_ttl_sec` | Snapshot `app_settings["otp.phone.ttl_seconds"]` |
| `otp_code_length` | Snapshot `app_settings["otp.phone.code_length"]` |
| `settings_version` | Версия набора OTP settings из bridge |
Raw OTP и полный текст SMS в Keycloak **не** хранятся (только `otp_hash`).
Keycloak не хранит provider send/delivery status. В real mode `expires_at = ordered_at + otp_ttl_sec`. `sms_message_id` обязателен для `active` real-mode challenge и nullable для mock/`order_failed`.
Переходы:
- `ordering → active` после HTTP `200`/`202` от sms-service;
- `ordering → order_failed` при невозможности надёжно записать заказ;
- `active → consumed` после верного кода;
- `active → superseded` при запросе новой SMS;
- `active → expired` после `expires_at`;
- `active → limited` после исчерпания verify attempts.
Никакой переход не зависит от `send_status` или `delivery_status` в sms-service.
### 6.2. Результат ввода кода пользователем
Источник истины verify — Keycloak.
**A. Агрегат на challenge** (текущее + уточнение):
- `challenge_status`, `verify_attempts`, `consumed_at`, `expires_at`;
- итоговый outcome определяется только состоянием challenge и результатом локального сравнения OTP.
**B. Append-only события** `han_otp_security_event` (обязательно на **каждую** попытку ввода):
| Поле | Описание |
|---|---|
| `id` | UUID события |
| `occurred_at` | Когда пользователь отправил код |
| `event_type` | `otp_verify` |
| `challenge_id` | Ссылка на challenge |
| `sms_message_id` | Копия ссылки на отправленное SMS (денормализация для выборок) |
| `phone_hmac` | Без raw phone |
| `outcome` | `success` / `failure` / `limited` / `expired` / `already_used` |
| `details` | `invalid` / `attempt_limit` / … |
| device-поля | см. §6.3 |
Так отвечаем на вопрос «верно/неверно ввёл»: **только** в Keycloak (`han_otp_security_event` + состояние challenge), со ссылкой на `sms_message_id`.
Событие `otp_send` при успехе заказа SMS тоже пишет `sms_message_id`.
### 6.3. Контекст устройства (на send и на каждую verify-попытку)
Фиксировать в событии (и/или snapshot на challenge при send):
| Поле | Источник | Описание |
|---|---|---|
| `client_ip` | trusted proxy (`X-Forwarded-For` от nginx) | IP |
| `user_agent` | заголовок | UA строка |
| `device_id` | клиент (theme/form/auth note) | Стабильный id устройства приложения |
| `fingerprint` | клиент | Browser/device fingerprint (не секрет auth) |
| `os_name` / `os_version` | клиент | ОС |
| `platform` | клиент | `web` / `ios` / `android` |
| `app_version` | клиент | Версия приложения (если есть) |
Правила:
- device metadata **не** заменяет phone OTP;
- IP только из trusted hop nginx;
- в логах fingerprint/device_id допустимы; не логировать OTP.
Механизм передачи зафиксирован:
1. Frontend добавляет в OIDC authorization request необязательные параметры `han_device_id`, `han_fingerprint`, `han_platform`, `han_os_name`, `han_os_version`, `han_app_version`.
2. `PhoneIdentityAuthenticator.authenticate()` читает их только на первом шаге, валидирует и сохраняет в auth session notes. Это недоверенные audit metadata, а не auth-фактор.
3. Ограничения: `device_id`/`fingerprint` ≤ 256 символов; OS/app version ≤ 64; `platform` только `web`/`ios`/`android`; control characters запрещены.
4. Для web при отсутствии `han_device_id` theme создаёт random UUID, хранит его в `localStorage` и отправляет hidden field формы телефона; native-клиент передаёт свой stable installation id.
5. `client_ip` берётся сервером из trusted proxy chain, `user_agent` — из текущего HTTP-запроса на каждой send/verify попытке; клиент их не задаёт.
6. Snapshot device fields копируется в `otp_send` и каждое `otp_verify` event. Новые значения hidden fields могут обновить snapshot перед verify.
7. Nginx/Keycloak access logs для `/auth` используют path без query string либо редактируют `han_*`, чтобы device identifiers не размножались в технических логах.
8. `phone.ftl` и `otp.ftl` получают hidden fields/атрибуты через SPI; `otp.ftl` строит число digit inputs из `challenge.otp_code_length`, countdown — из `expires_at`, без hardcoded `6`/`0:59`.
После успешного OTP те же device metadata по-прежнему уходят в `POST /auth/bootstrap` (arch-02) для App DB — это **другой** контур (продуктовая сессия), не замена Keycloak OTP audit.
### 6.4. Чего Keycloak не делает
- не пишет `body_rendered` / delivery callback;
- не держит шаблоны;
- не вызывает Direct.
---
## 7. Поток end-to-end
1. Пользователь вводит телефон (+ device context попадает в Keycloak session).
2. Keycloak применяет уже реализованные send limits/cooldown/counters.
3. В короткой transaction Keycloak:
- помечает прежний `active`/`ordering` challenge этого телефона как `superseded`;
- генерирует новый криптографически случайный numeric OTP длиной `settings_snapshot.otp_code_length`;
- сохраняет только HMAC;
- создаёт новый challenge со статусом `ordering`;
- резервирует одну send attempt по действующим правилам counters.
4. Keycloak формирует `idempotency_key=keycloak:challenge:{challenge_id}` и вызывает `POST /internal/sms/v1/send` вне DB transaction.
5. sms-service валидирует запрос, сохраняет journal row и сразу возвращает `sms_message_id` (`202`; при идемпотентном повторе — `200`). Direct ещё может не быть вызван.
6. Keycloak сохраняет `sms_message_id`, `ordered_at=now`, `expires_at=ordered_at+challenge.otp_ttl_sec`, переводит challenge в `active`, пишет событие `otp_send/ordered` и показывает форму кода.
7. Background worker sms-service отправляет SMS в Direct и обновляет журнал. Результаты отправки/доставки не передаются в Keycloak и не меняют challenge.
8. Пользователь вводит код (+ тот же/обновлённый device context).
9. Keycloak проверяет только `challenge_status=active`, TTL, verify limits и локальный HMAC:
- верный код → `consumed`, событие success, завершение OIDC flow;
- неверный → increment verify attempts и failure event;
- attempts exhausted → `limited`;
- `now >= expires_at``expired`.
10. Если пользователь запрашивает новую SMS, поток повторяется с шага 2; прежний challenge становится `superseded`, поэтому его код больше не принимается.
11. Periodic expiry job помечает оставшиеся `active` challenges как `expired` после `expires_at`; verify также выполняет этот переход лениво, если job ещё не успел. Изменение текущего `otp.phone.ttl_seconds` не пересчитывает `expires_at` существующих challenges.
12. Direct callback обновляет только журнал sms-service.
Если sms-service не подтвердил durable order (`200`/`202`), новый challenge становится `order_failed`; прежний уже остаётся `superseded`. Frontend получает generic unavailable и может начать новый resend с учётом counters.
Mock-режим: внешний заказ не создаётся; challenge сразу получает `active`, `sms_message_id=null`, а остальные TTL/verify/resend/counter rules идентичны real mode.
**Граница транзакций Keycloak:** HTTP-вызов sms-service не выполняется внутри transaction с блокировкой counters/challenge. Создание `ordering` и перевод в `active`/`order_failed` — отдельные короткие transaction. Повтор после потерянного HTTP-ответа использует тот же challenge/idempotency key и не создаёт вторую SMS.
---
## 8. Безопасность
- Direct credentials только в sms-service.
- Internal SMS API недоступен из публичной сети.
- OTP в `substitutions`/`body_rendered` хранится как часть закрытого журнала, но никогда не попадает в logs/traces/read API; после `challenge.expires_at` Keycloak его не принимает.
- Keycloak хранит только hash OTP и `sms_message_id`.
- Enumeration: ошибки send/verify наружу generic + request id.
- Service token Keycloak→sms-service и callback credentials различны; ротация через secret store.
- TLS certificate Direct проверяется стандартным trust store; `verify=false` запрещён.
- Шаблоны редактируются только controlled migration/ops-процедурой; active version требует `approved_at`.
- API key Direct ограничивается типом TOKEN_1 и, если поддержано, egress IP.
---
## 9. Наблюдаемость
**sms-service:** `sms_send_total{provider,send_status}`, provider latency, `sms_uncertain_total`, callback counters/lag, pending age, journal size/partition age; логи: `sms_message_id`, `provider_message_id`, `requester_service`, `process` — без phone plaintext/OTP/body.
**Keycloak:** существующие OTP metrics + verify outcomes; в audit events — `sms_message_id`, device fields.
Alerting: 401/402 у Direct, contract violation, любой `uncertain`, рост `failed`, callback lag, зависшие pending, аномальный рост журнала, sms-service not-ready.
`/health/live` проверяет процесс. `/health/ready` проверяет DB/schema, active approved template, sender/API key configuration; кратковременная недоступность Direct отражается отдельным dependency status и метрикой, но не вызывает restart loop.
---
## 10. Совместимость документов
| Документ | Изменение при внедрении |
|---|---|
| module-08 | `OtpDeliveryProvider` вызывает **sms-service**, не Direct; challenge + events + device (§6) |
| arch-01/02 | Новый internal сервис; направление Keycloak → sms-service → Direct |
| arch-03 | Compose-сервис `sms-service`, schema `sms`, сеть backend |
| arch-04 | `SMS_SERVICE_*`, `IDGTL_SMS_*`; шаблоны — в БД, не env |
| arch-00 | Термины `sms_message_id`, `sms_outbound_message`, `sms_template` |
### 10.1. Compose и сети
Добавить `sms-service` в `backend/infra/compose/application.yml`:
- networks: `backend`, `egress`, `observability`;
- `expose: 8080`, без host `ports`;
- managed PostgreSQL schema `sms`, роль только `sms_user`;
- Keycloak видит `sms-service` по сети `backend`; при включённой Yandex SmartCaptcha получает отдельный ограниченный egress только к SmartCaptcha API, а при выключенной CAPTCHA остаётся без egress;
- root nginx маршрутизирует только точный публичный `POST /callbacks/idgtl/sms` в `sms-service`; `/internal/sms/*` наружу блокируется;
- callback location: HTTPS, IP allowlist, request body limit, без access-log Authorization;
- зависимости запуска не должны образовывать цикл: Keycloak может стартовать при недоступном `sms-service`; недоступность блокирует только создание нового real-mode заказа, но не verify уже активного challenge.
### 10.2. Артефакты реализации
```text
backend/sms-service/
app/
migrations/
tests/
openapi.yaml
Dockerfile
pyproject.toml
```
Отдельный `docker-compose.yml` не обязателен: действующий репозиторий использует агрегированный `infra/compose/application.yml`.
### 10.3. ТЗ на доработку смежных модулей
Ниже перечислены обязательные изменения вне `sms-service`, без которых end-to-end использование нового сервиса не считается реализованным.
#### 10.3.1. Общие интеграционные правила
1. Единственный заказчик SMS в v1 — Keycloak SPI.
2. Frontend, `api-backend` и другие сервисы не вызывают `sms-service` и Direct для OTP.
3. Keycloak ждёт только durable order (`200`/`202` + `sms_message_id`) и не ждёт вызова Direct.
4. `send_status`, `delivery_status`, callback и provider errors используются только журналом/ops и никогда не меняют результат verify.
5. OTP генерируется и проверяется только Keycloak; raw OTP передаётся только в закрытом HTTP-запросе Keycloak → sms-service и не логируется.
6. Во всех вызовах передаются `X-Request-ID` и `traceparent`; `idempotency_key=keycloak:challenge:{challenge_id}`.
#### 10.3.2. `module-08-keycloak`
**Settings bridge**
- расширить DTO `GET /internal/settings/v1/otp`: `code_length`, `ttl_seconds`, `sms_order_timeout_ms`;
- валидировать диапазоны и сохранять единый immutable settings snapshot на новый challenge;
- убрать чтение `KEYCLOAK_OTP_TTL_SEC` и других перенесённых runtime-параметров из env;
- last-known-good/cache semantics оставить как для существующих OTP limits.
**Миграция provider-owned таблиц**
Добавить в `han_otp_challenge`:
- `sms_message_id` UUID nullable;
- `delivery_mode varchar(16)` с CHECK `mock|sms`;
- `challenge_status varchar(16)` с CHECK `ordering|active|consumed|superseded|expired|limited|order_failed`;
- `ordered_at timestamptz` nullable;
- `otp_ttl_sec integer` с CHECK `60..900` и кратностью 60;
- `otp_code_length smallint` с CHECK `4..10`;
- существующий `settings_version varchar(128)` переиспользовать, новую колонку не создавать.
Миграция существующих mock-записей:
- `delivery_mode=mock`, `sms_message_id=null`;
- перед migration дождаться прежнего max OTP TTL либо в maintenance transaction пометить все неиспользованные challenges как `expired`;
- `ordered_at=created_at`;
- `challenge_status=consumed`, если `consumed_at` заполнен; иначе `expired`;
- `otp_ttl_sec` и `otp_code_length` backfill текущими seed из `app_settings`; исторические challenges уже не проверяются;
- старые `provider_id`/`provider_status` сначала сделать nullable и перестать использовать; удалить отдельной backward-incompatible migration после стабилизации.
Расширить `han_otp_security_event`:
- `sms_message_id uuid` nullable;
- `client_ip inet`, `user_agent text`;
- `device_id varchar(256)`, `fingerprint varchar(256)`;
- `os_name varchar(64)`, `os_version varchar(64)`;
- `platform varchar(16)`, `app_version varchar(64)`.
Добавить индексы `han_otp_challenge(challenge_status, expires_at)`, `han_otp_challenge(sms_message_id)` where not null и `han_otp_security_event(sms_message_id)` where not null. Обновить JPA entities и Liquibase changelog; migration должна быть повторяемо проверена на копии production schema.
**Клиент sms-service**
- реализовать `SmsOrderClient`, который вызывает `POST /internal/sms/v1/send`;
- URL и service token — env; timeout — settings snapshot;
- успех заказа: только HTTP `200`/`202`, валидный `sms_message_id`;
- HTTP timeout/5xx: повторить один раз с тем же challenge/idempotency key; новый challenge и новый OTP не создавать;
- не реализовывать GET/poll provider status в auth flow.
**Challenge lifecycle**
- перед новым заказом после успешной проверки limits перевести прежний `active`/`ordering` challenge в `superseded`;
- создать новый `ordering`, сгенерировать numeric OTP по snapshot length, сохранить только HMAC;
- после durable order перевести в `active`, установить `ordered_at`/`expires_at`, записать `otp_send/ordered`;
- при невозможности durable order перевести в `order_failed`;
- verify допускается только для `active` и зависит только от HMAC, TTL и verify counters;
- верный код → `consumed`; resend → `superseded`; TTL → `expired`; attempts → `limited`;
- periodic expiry job и lazy expiry на verify обязательны;
- повтор одного auth action использует тот же challenge и idempotency key.
**Counters и mock**
- существующие send/verify limits, cooldown, phone HMAC и locking сохраняются;
- один новый challenge резервирует одну send attempt; HTTP retry того же заказа повторно counter не увеличивает;
- mock mode не вызывает sms-service, но использует те же statuses, TTL, resend и verify rules;
- недоступность Direct не влияет на Keycloak; недоступность sms-service блокирует только создание нового real-mode заказа;
- общая readiness Keycloak не должна зависеть от Direct или provider status. Допускается отдельный degraded dependency indicator для sms-service.
**Тесты Keycloak**
- migration/backfill существующих challenges;
- durable order → форма OTP до ответа Direct;
- resend отклоняет старый код;
- expiry и attempts transitions;
- provider rejected/timeout не меняет active challenge;
- идемпотентный повтор не создаёт второй challenge и не увеличивает counter;
- отсутствие OTP/phone/service token в logs/traces.
#### 10.3.3. `module-01-api-backend` и App DB settings
- добавить migration/seed `app_settings`:
- `otp.phone.code_length`;
- `otp.phone.ttl_seconds`;
- `otp.phone.sms_order_timeout_ms`;
- расширить строгий DTO `/internal/settings/v1/otp` согласно `arch-02`;
- возвращать все OTP settings одной версией, чтобы Keycloak не смешивал значения разных revisions;
- добавить валидацию: code length в разрешённом диапазоне; TTL `60..900` и кратен 60; timeout положительный и bounded;
- не добавлять отправку/проверку OTP в `api-backend`;
- покрыть endpoint contract tests, cache/ETag и отсутствие новых ключей в public config, если они явно не разрешены.
#### 10.3.4. Managed PostgreSQL и deployment jobs
- в init-managed-postgres создать schema `sms` и роль `sms_user`;
- выдать `sms_user` права только на schema `sms`; доступа к `han_app` и `keycloak` нет;
- `sms-service` применяет собственные versioned migrations для `sms_template`, `sms_setting`, `sms_outbound_message`;
- добавить idempotent seed active template `auth_otp` и `sms_setting`;
- добавить pre-deploy migration job и проверку schema version;
- backup/PITR должны включать schema `sms`; автоматическое удаление журнала запрещено;
- restore test обязан подтверждать сохранность journal rows, templates, settings и provider IDs.
#### 10.3.5. Root Compose и конфигурация
Добавить в `backend/infra/compose/application.yml`:
- `sms-service` — internal HTTP API/callback receiver;
- `sms-worker` — background sender из того же image либо обязательный worker process внутри `sms-service`;
- `sms-service`: networks `backend`, `observability`, `expose: 8080`, без `ports`; `egress` добавляется только в совмещённом callback+worker process;
- отдельный `sms-worker`: networks `egress`, `observability`, без published/exposed port;
- оба процесса используют `SMS_DATABASE_URL`; только worker получает `IDGTL_SMS_API_KEY`;
- callback credentials получают `sms-service` для проверки и `sms-worker` для формирования callback URL в запросе Direct; Keycloak получает только `KEYCLOAK_SMS_SERVICE_TOKEN`;
- healthchecks, graceful shutdown, lease recovery, read-only rootfs, non-root и resource limits;
- startup не строится на `depends_on` Direct; provider outage не вызывает restart loop.
Обновить:
- root `.env.example` только URL/DB/secrets;
- `scripts/validate-env` и config tests;
- image/build/release manifests;
- secret generation и rotation runbook.
#### 10.3.6. `module-03-nginx`
Требования относятся к [`module-03-nginx-vm1.md`](module-03-nginx-vm1.md) и общему контракту [`arch-08-nginx.md`](../../architectory/arch-08-nginx.md):
- добавить точный public route `POST /callbacks/idgtl/sms``sms-service:8080`;
- остальные методы на callback path отклонять;
- source IP allowlist Direct, учитывая только trusted proxy chain;
- передавать Basic Authorization в sms-service, но не писать его в access/error logs;
- ограничить размер body, отключить cache, задать отдельный callback rate limit без блокировки легитимных повторов;
- `/internal/sms/*` и порт sms-service наружу не публиковать;
- добавить config/route tests: allowed callback, wrong IP, wrong method, internal path denied.
#### 10.3.7. `module-02-frontend-test-site` и Keycloak theme
- frontend не вызывает sms-service;
- resend запускает новый Keycloak action; двойной click блокируется на время запроса;
- после resend UI явно сообщает, что предыдущий код недействителен;
- countdown берётся из challenge/settings snapshot, а не из hardcoded значения;
- корректно отображать `invalid`, `expired`, `superseded`, `limited` и generic order unavailable;
- raw OTP, service URLs/tokens и provider status не попадают в frontend config/analytics.
#### 10.3.8. `module-09-observability`
- добавить metrics/alerts в [`module-09-observability-vm1.md`](module-09-observability-vm1.md) и имя сервиса в реестр [`arch-07-observability.md`](../../architectory/arch-07-observability.md) §4 для `sms-service` и `sms-worker`;
- dashboard: pending age, send outcomes, provider latency, callback lag, uncertain, journal growth;
- traces: Keycloak order span → sms-service DB commit; worker → Direct отдельным trace/span с correlation через `sms_message_id`;
- настроить redaction OTP, body, phone, Authorization, API key и callback credentials;
- alert routing/runbook для Direct 401/402, `uncertain`, stuck pending и callback failures.
#### 10.3.9. `module-10-deployment-runbook` и `deploy-steps.md`
Зафиксировать rollout в [`module-10-deployment-vm1.md`](module-10-deployment-vm1.md) и контракте [`arch-10-deployment.md`](../../architectory/arch-10-deployment.md):
1. применить App DB seed новых OTP settings;
2. создать schema/role `sms`, применить migrations и seed;
3. в test environment deploy `sms-service`/worker с `IDGTL_SMS_BASE_URL` локального mock Direct и выполнить contract/E2E;
4. выпустить/установить production Direct TOKEN_1, sender и callback credentials;
5. deploy production `sms-service`/worker, проверить health/migrations, оставив Keycloak в mock mode;
6. применить Keycloak migration и deploy SPI с `KEYCLOAK_OTP_MOCK_ENABLED=true`;
7. выполнить provider smoke отдельной ops-командой на контролируемом номере;
8. проверить реальный callback, журнал и redaction;
9. переключить Keycloak в real mode;
10. проверить resend/expiry/limits и сохранить release evidence.
Rollback:
- вернуть Keycloak в mock mode без удаления schema/journal;
- остановить создание новых real orders, дать worker завершить/зафиксировать in-flight;
- migrations откатывать только при доказанной backward compatibility; иначе forward-fix.
#### 10.3.10. Архитектурные документы
До merge реализации синхронизировать:
- `arch-00`: сервис/сущности/ID/settings/env, `send_status`, `delivery_status`, `challenge_status`;
- `arch-01`: компонент `sms-service`, schema `sms`, поток Keycloak → durable order → worker → Direct, отсутствие зависимости verify от provider status;
- `arch-02`: полный `POST/GET /internal/sms/v1/*`, callback, service-token pair, HTTP-коды и OpenAPI registry;
- `arch-03`: `sms-service`/worker, networks, schema/role, nginx callback route, startup/health;
- `arch-04`: разделение env / `app_settings` / `sms.sms_setting`;
- `architectory/README.md`: убрать формулировку о неоформленной интеграции после начала реализации и добавить ссылки на новый контракт;
- `module-01`, `module-02`, `module-03`, `module-08`, `module-09`, `module-10` — добавить перечисленные требования в профильные DoD/test matrix;
- `backlog.md`: переводить интеграцию из backlog только после выполнения общего DoD;
- `deploy-steps.md`: добавить rollout/rollback и smoke-команды.
`module-04-redis`, `module-05-message-safety`, `module-06-bitrix-local-app`, `module-07-bitrix-sync` изменений для SMS не требуют.
### 10.4. Общие критерии приёмки смежных изменений
- новый OTP-заказ возвращается до начала/завершения внешнего HTTP-вызова Direct;
- Keycloak не содержит кода чтения provider send/delivery status;
- provider failure после durable order не деактивирует challenge;
- resend делает старый challenge и код `superseded`;
- challenge становится `expired` по сохранённому settings snapshot;
- повтор с тем же idempotency key не создаёт вторую SMS и не увеличивает counters;
- internal SMS API недоступен извне; callback доступен только по установленным правилам;
- журнал содержит заказ, provider result и callback и сохраняется бессрочно;
- OTP, body, телефон и секреты отсутствуют в logs/traces/metrics;
- все изменённые OpenAPI/DTO/migrations/docs проходят contract, migration и E2E tests;
- поиск по документации не находит старого прямого потока Keycloak → Direct или зависимости verify от provider status.
---
## 11. Тест-план (будущая реализация)
- unit: strict template render, E.164/TTL, request fingerprint, idempotency conflict, status transitions;
- contract: локальный mock/WireMock Direct + callback fixtures; существование отдельного sandbox Direct не предполагается;
- provider smoke: выделенный test account/sender `sms_promo` только по отдельному ops-runbook, чтобы тест не отправлял SMS случайным адресатам;
- integration: Keycloak → durable order в sms-service → background worker → mock Direct;
- E2E: форма OTP открывается после durable order и до ответа Direct; provider reject/timeout не меняет Keycloak challenge;
- E2E: wrong code → success verify; `sms_message_id` совпадает в обеих БД;
- E2E: resend переводит прежний challenge в `superseded`, старый код отклоняется, новый принимается;
- E2E: active challenge без ввода кода становится `expired` через snapshot `otp.phone.ttl_seconds`;
- E2E: изменение `otp.phone.ttl_seconds`/`code_length` влияет только на новые challenges;
- E2E: counters/cooldown применяются до создания нового заказа; идемпотентный HTTP-повтор не увеличивает counters повторно;
- resilience: connect failure, 401/402/403/422, `errors=true`, malformed 200, 503, read timeout → `uncertain`, crash после INSERT и после provider accept;
- callback: массив, duplicate, out-of-order sent after delivered, unknown UUID, Basic auth/IP reject, retry после DB failure;
- security: нет OTP/phone/token/callback credentials в logs/traces; internal API без token → 401; provider TLS verification;
- migration: upgrade существующих Keycloak tables и rollback compatibility;
- persistence: записи и полный состав журнала сохраняются после архивирования/ротации partition и восстановления backup.
---
## 12. Definition of Done
- Журнал SMS целиком в module-11 (`sms_template` + `sms_outbound_message`);
- Verify outcomes + device — в Keycloak с `sms_message_id`;
- Keycloak не ходит в Direct; Direct не проверяет код;
- mock XOR real; отсутствие durable order блокирует только новый challenge;
- Keycloak не читает и не проверяет provider send/delivery statuses;
- sms-service возвращает durable order до фонового вызова Direct;
- ambiguous provider result → `uncertain` без автоматической повторной SMS;
- callback защищён HTTPS + IP allowlist + Basic auth и обрабатывается идемпотентно;
- TTL OTP задаётся `app_settings["otp.phone.ttl_seconds"]` и считается от `ordered_at`; resend делает прежний challenge `superseded`, expiry job — `expired`;
- журнал SMS хранится бессрочно без автоматической очистки;
- OpenAPI, migrations, Compose, env validation, health/metrics и runbook готовы;
- arch-* и module-08 синхронизированы.
---
## 13. Решения, допущения и внешние предпосылки
**Решения:**
- S1: module-11 — единственный владелец отправки SMS и журнала.
- S2: шаблоны в БД (`sms_template`), не в env.
- S3: OTP generate/verify — Keycloak; связь через `sms_message_id`.
- S4: первый provider `idgtl`, канал `SMS`, process `auth_otp`, requester `keycloak`.
- S5: delivery callback только в sms-service.
- S6: устройство (IP, UA, device_id, fingerprint, OS) — в Keycloak verify/send events.
- S7: Keycloak зависит только от durable order (`sms_message_id`) и не зависит от provider send/delivery status.
- S8: отправка в Direct выполняется background worker-ом после ответа Keycloak.
- S9: resend всегда делает прежний challenge `superseded`; неиспользованный challenge после TTL становится `expired`.
- S10: `externalMessageId` в v1 считается только корреляцией, не idempotency key; ambiguous provider call не повторяется независимо от будущего ответа Direct.
- S11: failover-провайдер не входит в v1; поле `provider` остаётся для аудита и будущего расширения.
- S12: device metadata передаётся через custom OIDC `han_*` параметры/auth notes и hidden fields theme по §6.3.
- S13: точная миграция Keycloak фиксируется §10.3.2; все прежние незавершённые challenges истекают при rollout.
**Допущения:**
- A1: отдельная schema `sms` на том же managed PostgreSQL допустима.
- A2: sender/template согласуются с i-Digital до prod.
- A3: Direct отправляет callback с IP `185.203.96.7`; адрес повторно подтверждается перед production.
- A4: Direct поддерживает Basic auth callback через credentials в callback URL согласно опубликованной документации.
**Внешняя production-предпосылка:**
- Перед production rollout ops определяет фактический статический egress IP из контейнера `sms-worker`, фиксирует его в deployment inventory и передаёт Direct для API-key allowlist. Если egress IP не статичен, production-включение real mode запрещено до настройки NAT/static IP. Это deployment value, а не параметр приложения или открытое архитектурное решение.