Реализована интеграция с СМС провайдером
This commit is contained in:
@@ -76,7 +76,9 @@ Auth state machine: `guest → authorizing → bootstrapping → authenticated`;
|
||||
|
||||
Modal согласий отображает актуальные URL/версии из config. `personal_data` и `user_agreement` обязательны, `marketing` необязателен. После подтверждения intent остаётся в памяти, начинается OIDC PKCE redirect.
|
||||
|
||||
OTP вводится на странице/теме Keycloak. В MVP Keycloak сверяет mock-код из env; frontend не хранит и не проверяет код. Для тестовой среды UI может показывать только текст «используется тестовый OTP», но не получать secret из API.
|
||||
OTP вводится на странице/теме Keycloak. В mock mode Keycloak сверяет secret-код; в real mode Keycloak генерирует и локально проверяет OTP, а доставку заказывает в `sms-service` по module-11. Frontend не вызывает `sms-service`/Direct, не получает provider status, service URL/token или mock secret.
|
||||
|
||||
Resend запускает новое Keycloak action, блокирует double click на время запроса и сообщает, что предыдущий код недействителен (`superseded`). Countdown строится из snapshot challenge (`expires_at`/`otp_ttl_sec`), без hardcoded `6` digits или `0:59`.
|
||||
|
||||
### 5.3. Чат
|
||||
|
||||
@@ -185,6 +187,7 @@ Presigned URL не сохраняется и редактируется из д
|
||||
| 422 blocked | нейтральное сообщение, контент не отправлен |
|
||||
| 429 | countdown по `Retry-After` |
|
||||
| 503/504 | зависимость недоступна; retry с тем же key |
|
||||
| OTP invalid/expired/superseded/limited | показать соответствующий безопасный Keycloak UX; generic order unavailable не раскрывает provider |
|
||||
| S3 PUT error | оставить attachment intent, предложить повтор |
|
||||
| WS failure | polling badge, чат остаётся usable |
|
||||
|
||||
@@ -245,7 +248,7 @@ Production: статический export монтируется в корнев
|
||||
| Сценарий | Варианты |
|
||||
|---|---|
|
||||
| guest | просмотр public content; write закрыт |
|
||||
| first send | manual/popular → consents → mock OTP → delivered |
|
||||
| first send | manual/popular → consents → mock и real OTP → delivered |
|
||||
| return | valid refresh без OTP; expired refresh с OTP |
|
||||
| text safety | allow, block, pending-to-final, timeout |
|
||||
| file | PDF/image allow, deny, wrong MIME/size/checksum, expired URL |
|
||||
@@ -253,8 +256,9 @@ Production: статический export монтируется в корнев
|
||||
| concurrency | два send click, несколько 401, две вкладки |
|
||||
| profile | filled/null fields, empty documents, download failure |
|
||||
| security | XSS text, token absence in logs/storage diagnostics |
|
||||
| OTP resend | double click; старый код `superseded`; новый код; snapshot countdown; order unavailable |
|
||||
|
||||
Browsers: Chromium, Firefox, WebKit; viewport desktop/mobile. Реальный Keycloak mock realm и API stub/compose используются в CI.
|
||||
Browsers: Chromium, Firefox, WebKit; viewport desktop/mobile. В CI используются Keycloak mock mode и локальный mock/WireMock Direct. Отдельный sandbox Direct не предполагается; provider smoke выполняется только ops на контролируемом номере.
|
||||
|
||||
## 17. Definition of Done
|
||||
|
||||
@@ -271,6 +275,7 @@ Browsers: Chromium, Firefox, WebKit; viewport desktop/mobile. Реальный K
|
||||
- accessibility checks и keyboard сценарии проходят;
|
||||
- unit/component/contract/E2E matrix зелёная;
|
||||
- production static и dev proxy режимы проверены через единственный nginx.
|
||||
- frontend bundle/config/analytics не содержит raw OTP, `sms-service`/Direct credentials или provider status; resend/expiry/limits проверены для real-mode контракта.
|
||||
|
||||
## 18. Решения, допущения и TBD
|
||||
|
||||
|
||||
@@ -17,16 +17,17 @@
|
||||
|---|---|---|
|
||||
| `/api/` | `api-backend:8000` | REST; `/api/v1/realtime` WS |
|
||||
| `/auth/` | `keycloak:8080` | OIDC/OTP, prefix/hostname согласован с issuer |
|
||||
| exact `/callbacks/idgtl/sms` | `sms-service:8080` | public HTTPS POST Direct; IP allowlist + Basic auth в upstream |
|
||||
| `/bitrix/sync/webhook/contact` | `bitrix-sync:8080` | public HTTPS POST, no cache; отсутствует, пока действует stub module-07 |
|
||||
| `/bitrix/handler`, `/bitrix/install`, `/bitrix/placement` | `bitrix-local-app:8080` | public HTTPS |
|
||||
| exact `/health/live`, `/health/ready` | `bitrix-local-app:8080` | по умолчанию не публикуются; только при явно выбранной ops/monitoring policy |
|
||||
| `/` | static SPA либо Expo dev upstream | `try_files` fallback |
|
||||
|
||||
`/internal/`, `/_internal/`, Redis/OTLP/admin/status/config files запрещены exact prefix response `404` (допустим `403`, но единообразно выбран `404`). Никакого fallback internal path в SPA или общий proxy. `message-safety` не имеет публичного route.
|
||||
`/internal/`, `/_internal/`, Redis/OTLP/admin/status/config files запрещены exact prefix response `404` (допустим `403`, но единообразно выбран `404`). Никакого fallback internal path в SPA или общий proxy. `message-safety` и `/internal/sms/*` не имеют публичного route.
|
||||
|
||||
## 3. Upstreams
|
||||
|
||||
Именованные upstream: `api_backend`, `keycloak`, `bitrix_local`, `bitrix_sync`, опционально `frontend_dev`. Для одной replica допустим `server service:port`; `keepalive` включён. Docker DNS resolver задаётся с коротким `valid` и `resolve` там, где поддерживает выбранная nginx edition; иначе контейнер перезапускается при смене IP upstream.
|
||||
Именованные upstream: `api_backend`, `keycloak`, `sms_service`, `bitrix_local`, `bitrix_sync`, опционально `frontend_dev`. Для одной replica допустим `server service:port`; `keepalive` включён. Docker DNS resolver задаётся с коротким `valid` и `resolve` там, где поддерживает выбранная nginx edition; иначе контейнер перезапускается при смене IP upstream.
|
||||
|
||||
Upstream failures не перенаправляются на другой сервис и не попадают в SPA. API возвращает `502/504` с безопасным nginx body и `X-Request-ID`; custom JSON error допустим для `/api`, но не имитирует backend domain code.
|
||||
|
||||
@@ -100,6 +101,7 @@ traceparent: входной валидный либо новый согласн
|
||||
| обычный API | 3s / 30s / 30s |
|
||||
| auth | 3s / 30s / 60s |
|
||||
| Bitrix callback | 3s / 30s / 60s |
|
||||
| Direct SMS callback | 3s / 30s / 60s |
|
||||
| WS | 3s / 30s / 75s+ |
|
||||
| message POST | 3s / 30s / `MESSAGE_SAFETY_TASK_POLL_MAX_SEC + 30s` минимум |
|
||||
|
||||
@@ -117,6 +119,7 @@ traceparent: входной валидный либо новый согласн
|
||||
- `polling`: GET messages fallback;
|
||||
- `downloads`: issuance URL;
|
||||
- `bitrix_callbacks`: мягкий burst для повторов;
|
||||
- `idgtl_callbacks`: отдельный bounded burst, учитывающий повтор каждые 5 минут в течение суток;
|
||||
- `ws_connect`: handshake;
|
||||
- `connections`: `limit_conn`.
|
||||
|
||||
@@ -162,6 +165,14 @@ CORS — exact allow-list из согласованного deploy config; appli
|
||||
|
||||
Bitrix placement может требовать embedding: для exact `/bitrix/placement` CSP `frame-ancestors` задаётся отдельным allow-list Bitrix24, а не ослабляет SPA.
|
||||
|
||||
### Callback i-Digital Direct
|
||||
|
||||
- Только exact `location = /callbacks/idgtl/sms`; разрешён только `POST`, остальные методы отклоняются.
|
||||
- Source IP allowlist — `185.203.96.7`, но значение обязательно повторно сверяется с актуальной документацией Direct перед production. При WAF/LB используется только нормализованный trusted client IP.
|
||||
- TLS обязателен; cache выключен; body size ограничен под массив callback items.
|
||||
- Basic `Authorization` передаётся `sms-service`, но никогда не записывается в access/error logs. URL с credentials также редактируется.
|
||||
- Nginx не проверяет provider payload и не преобразует статусы; это делает `sms-service`. Ошибку upstream/DB нельзя маскировать `2xx`, иначе Direct не повторит callback.
|
||||
|
||||
## 13. Health
|
||||
|
||||
- внутренний `GET /nginx-health/live` возвращает static 200 и доступен Docker healthcheck;
|
||||
@@ -248,6 +259,7 @@ curl -i https://tohin.ru/internal/safety/v1/messages/check
|
||||
- CSP/CORS preflight и Bitrix placement exception;
|
||||
- upstream down/timeout, failed reload, renewal rehearsal;
|
||||
- logs не содержат secrets/query tokens.
|
||||
- allowed Direct callback проходит; wrong IP/method и любой `/internal/sms/*` отклоняются; Authorization отсутствует в логах.
|
||||
|
||||
## 19. Definition of Done
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# module-08. Проектная спецификация `keycloak`
|
||||
|
||||
> Статус: целевая production-спецификация MVP; реальный SMS provider не входит в scope.
|
||||
> Статус: целевая production-спецификация OTP; mock действует до controlled rollout, real mode интегрируется только через `sms-service` по [`module-11-idgtl-sms.md`](module-11-idgtl-sms.md).
|
||||
> Источники: [`README.md`](README.md), [`arch-00-glossary.md`](arch-00-glossary.md), [`arch-01-system-architecture.md`](arch-01-system-architecture.md), [`arch-02-api-contracts.md`](arch-02-api-contracts.md), [`arch-03-docker-compose-blueprint.md`](arch-03-docker-compose-blueprint.md), [`arch-04-settings-and-content.md`](arch-04-settings-and-content.md), [`arch-05-agent-development-process.md`](arch-05-agent-development-process.md), [`module-01-api-backend.md`](module-01-api-backend.md), [`module-02-frontend-test-site.md`](module-02-frontend-test-site.md), [`module-03-nginx.md`](module-03-nginx.md), [`../../HAN_chat/deploy/init-managed-postgres.py`](../../HAN_chat/deploy/init-managed-postgres.py).
|
||||
|
||||
## 1. Назначение и границы
|
||||
@@ -12,7 +12,7 @@ Keycloak отвечает за:
|
||||
- realm, users, credentials, auth sessions и token lifecycle;
|
||||
- Authorization Code Flow with PKCE для Expo web/iOS/Android;
|
||||
- нормализацию/уникальность телефона и claims;
|
||||
- OTP authenticator/SPI, mock verification и продуктовые limits;
|
||||
- OTP authenticator/SPI, генерацию и локальную проверку OTP, challenge lifecycle, продуктовые limits и verify audit;
|
||||
- brute-force, sessions, logout/revocation;
|
||||
- keys/JWKS rotation и health/metrics.
|
||||
|
||||
@@ -22,9 +22,10 @@ Keycloak отвечает за:
|
||||
- App DB/profile/chat и CRM sync;
|
||||
- API service-to-service tokens;
|
||||
- пользовательскую UX-сессию;
|
||||
- реальную отправку SMS в MVP.
|
||||
- шаблоны, отправку и provider delivery journal (это `sms-service`);
|
||||
- прямой вызов i-Digital Direct и обработку delivery callback.
|
||||
|
||||
Реальный SMS provider — строго extension point/TBD. Mock code является секретом окружения, не контентом UI и не логируется.
|
||||
Mock code является секретом окружения, не контентом UI и не логируется. В real mode Keycloak вызывает только закрытый durable-order API `sms-service`; API Direct Verifier не используется.
|
||||
|
||||
## 2. Топология и публичный URL
|
||||
|
||||
@@ -130,9 +131,9 @@ Internal API по-прежнему используют service tokens из arch
|
||||
3. `Phone Identity Authenticator` нормализует номер.
|
||||
4. Проверяются realm brute-force и product send limits.
|
||||
5. Создаётся/находится user по canonical phone identity.
|
||||
6. `Phone OTP Challenge` инициирует mock/provider send.
|
||||
6. `Phone OTP Challenge` создаёт `ordering`: mock активирует его локально, real mode заказывает SMS через `sms-service`.
|
||||
7. Показывается форма OTP.
|
||||
8. Проверяются TTL/attempt limits/constant-time hash or mock compare.
|
||||
8. Проверяются только локальные status/TTL/attempt limits и constant-time HMAC/mock compare; provider status не читается.
|
||||
9. При успехе user enabled/phone verified, flow завершается code.
|
||||
10. Frontend меняет code+verifier на tokens.
|
||||
|
||||
@@ -182,7 +183,7 @@ Required actions не должны предлагать пароль/email. По
|
||||
|
||||
Изменение телефона требует re-auth + OTP нового номера и invalidation sessions/tokens по policy. `api-backend` обновляет cached phone при следующем bootstrap/login claim; прямого вызова Keycloak DB нет.
|
||||
|
||||
## 7. Mock OTP
|
||||
## 7. Mock и real delivery mode
|
||||
|
||||
Env:
|
||||
|
||||
@@ -193,24 +194,24 @@ KEYCLOAK_OTP_MOCK_CODE=<secret>
|
||||
|
||||
Правила:
|
||||
|
||||
- mock разрешён MVP production-like только как явно принятый риск;
|
||||
- mock временно разрешён до controlled SMS rollout только как явно принятый риск;
|
||||
- пустой/default `1234` запрещён startup policy для production-like, если не согласован secret;
|
||||
- code не входит в realm import, frontend config, HTML hint, API response, logs, metrics, traces или audit;
|
||||
- сравнение constant-time;
|
||||
- challenge всё равно имеет TTL, max verify attempts и counters, чтобы flow был близок production;
|
||||
- code не сохраняется per-user в открытом виде;
|
||||
- UI сообщает только «тестовый режим», без кода;
|
||||
- `KEYCLOAK_OTP_MOCK_ENABLED=false` при отсутствии configured provider делает OTP flow fail-closed/not-ready, а не пропускает проверку.
|
||||
- `KEYCLOAK_OTP_MOCK_ENABLED=false` при недоступном/неконфигурированном `sms-service` завершает новый order generic unavailable; уже active challenges продолжают локальный verify до TTL.
|
||||
|
||||
Реальный provider interface:
|
||||
Реальный delivery interface:
|
||||
|
||||
```java
|
||||
interface OtpDeliveryProvider {
|
||||
DeliveryResult send(E164Phone phone, String otp, Duration ttl, Correlation ctx);
|
||||
SmsOrderResult order(E164Phone phone, String otp, Duration ttl, String challengeId);
|
||||
}
|
||||
```
|
||||
|
||||
Будущий provider обязан вернуть `provider_message_id`; raw OTP не логируется. Выбор provider, template, sender, delivery status webhook и vendor credentials — TBD.
|
||||
Реализация real mode — `SmsOrderClient` к `POST /internal/sms/v1/send`. Успех — только `200/202` с `sms_message_id`; один HTTP retry использует тот же challenge и `idempotency_key=keycloak:challenge:{challenge_id}`. Keycloak не получает `provider_message_id`, template/sender/status/callback и не хранит vendor credentials.
|
||||
|
||||
## 8. OTP challenge и counters
|
||||
|
||||
@@ -218,14 +219,14 @@ interface OtpDeliveryProvider {
|
||||
|
||||
- challenge id random ≥128 bit;
|
||||
- OTP не хранится raw; production-generated code — keyed hash/HMAC с challenge salt/pepper;
|
||||
- TTL (предлагается 5 минут) — technical security parameter;
|
||||
- TTL — snapshot `app_settings["otp.phone.ttl_seconds"]`, диапазон `60..900`, кратен 60; real mode считается от `ordered_at`;
|
||||
- one-time use; success atomically consumes challenge;
|
||||
- max verification attempts per challenge;
|
||||
- resend invalidates либо version-binds предыдущий challenge;
|
||||
- resend всегда переводит предыдущий `active`/`ordering` challenge в `superseded`;
|
||||
- replay/parallel verify безопасны;
|
||||
- destination stored masked/hash where possible.
|
||||
|
||||
Audit fields по arch-05: provider message id (для mock — synthetic non-secret), sent_at, destination_masked, otp_hash/reference, attempts, outcome. Никогда raw code.
|
||||
Audit хранит `sms_message_id` (nullable для mock/order_failed), `ordered_at`, destination masked/HMAC, attempts, outcome и device context. Provider send/delivery status и полный SMS journal в schema `keycloak` запрещены.
|
||||
|
||||
### 8.1. Product send limits bridge
|
||||
|
||||
@@ -242,6 +243,10 @@ Authorization: Bearer ${KEYCLOAK_SETTINGS_BRIDGE_TOKEN}
|
||||
{
|
||||
"max_send_attempts_per_24h": 3,
|
||||
"min_seconds_between_attempts": 30,
|
||||
"max_verify_attempts": 5,
|
||||
"code_length": 6,
|
||||
"ttl_seconds": 60,
|
||||
"sms_order_timeout_ms": 3000,
|
||||
"version": "2026-07-10T08:00:00Z",
|
||||
"cache_ttl_seconds": 60
|
||||
}
|
||||
@@ -270,11 +275,27 @@ Token name точно `KEYCLOAK_SETTINGS_BRIDGE_TOKEN`, endpoint точно `/in
|
||||
|
||||
Минимальные records:
|
||||
|
||||
- `han_otp_challenge`: id, phone_hmac, otp_hash/mock marker, created/expires/consumed, verify attempts, settings version, provider id/status;
|
||||
- `han_otp_challenge`: id, phone_hmac, destination_masked, otp_hash, `sms_message_id`, `delivery_mode`, `challenge_status`, `ordered_at`, `expires_at`, `otp_ttl_sec`, `otp_code_length`, verify attempts, settings version;
|
||||
- `han_otp_send_counter`: phone_hmac, window_start, count, last_sent_at;
|
||||
- `han_otp_security_event`: append-only minimal outcome/retention.
|
||||
- `han_otp_security_event`: append-only событие на каждую send/verify попытку, `sms_message_id`, outcome/details и device context.
|
||||
|
||||
Indexes: unique active challenge policy, `(phone_hmac,window_start)`, `(expires_at)`. Cleanup bounded job. Доступ только `keycloak_user`.
|
||||
Indexes: unique active challenge policy, `(phone_hmac,window_start)`, `(challenge_status,expires_at)`, partial `sms_message_id` и event `sms_message_id`. Periodic expiry переводит active в `expired`; автоматическое удаление SMS journal выполняться здесь не может. Доступ только `keycloak_user`.
|
||||
|
||||
### 8.3. Lifecycle и границы транзакций
|
||||
|
||||
1. После limits/counter reservation прежние `active`/`ordering` становятся `superseded`; создаётся новый `ordering` с crypto-random numeric OTP и immutable settings snapshot.
|
||||
2. В real mode HTTP order выполняется вне transaction с DB locks. Потерянный ответ повторяется с тем же challenge/idempotency key, без нового OTP/counter.
|
||||
3. `200/202` + `sms_message_id` → короткая transaction устанавливает `ordered_at`, `expires_at=ordered_at+otp_ttl_sec`, status `active` и event `otp_send/ordered`.
|
||||
4. Невозможность durable order → `order_failed`; прежний challenge не восстанавливается. В mock mode challenge сразу `active`, `sms_message_id=null`.
|
||||
5. Verify разрешён только для `active`: success → `consumed`, неверный код увеличивает attempts/event, лимит → `limited`, TTL → `expired`. Никакой переход не зависит от Direct `send_status`/`delivery_status`.
|
||||
|
||||
Миграция существующих mock rows: дождаться прежнего max TTL либо истечь незавершённые challenges; установить `delivery_mode=mock`, `sms_message_id=null`, `ordered_at=created_at`, consumed rows → `consumed`, остальные → `expired`, backfill TTL/length текущими seed. Прежние `provider_id`/`provider_status` сначала nullable/неиспользуемые и удаляются только отдельной backward-incompatible migration после стабилизации.
|
||||
|
||||
### 8.4. Device context и verify events
|
||||
|
||||
`han_otp_security_event` содержит `client_ip`, `user_agent`, `device_id`, `fingerprint`, `os_name`, `os_version`, `platform`, `app_version`; `sms_message_id` копируется для корреляции. Событие `otp_verify` пишется на каждую попытку с outcome `success|failure|limited|expired|already_used`.
|
||||
|
||||
Frontend передаёт необязательные `han_device_id`, `han_fingerprint`, `han_platform`, `han_os_name`, `han_os_version`, `han_app_version` в OIDC request/hidden fields. Значения недоверенные audit metadata: id/fingerprint ≤256, OS/app ≤64, platform только `web|ios|android`, control characters запрещены. IP берётся только из trusted nginx chain, UA — из текущего запроса. Query/form/OTP/device identifiers редактируются в access logs.
|
||||
|
||||
## 9. Brute-force и abuse
|
||||
|
||||
@@ -472,15 +493,24 @@ KC_DB_URL_PROPERTIES=currentSchema=keycloak
|
||||
KEYCLOAK_OTP_MOCK_ENABLED=true
|
||||
KEYCLOAK_OTP_MOCK_CODE=<secret>
|
||||
KEYCLOAK_SETTINGS_BRIDGE_TOKEN=<secret>
|
||||
KEYCLOAK_SMS_SERVICE_URL=http://sms-service:8080
|
||||
KEYCLOAK_SMS_SERVICE_TOKEN=<secret>
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
|
||||
```
|
||||
|
||||
Дополнительные Keycloak-standard env version-specific (`KC_DB`, hostname/proxy/health/metrics/pool) фиксируются в `.env.example` после выбора image. Секреты только root `.env`/secret mounts с минимальными permissions.
|
||||
|
||||
Product limits `otp.phone.*`, включая `otp.phone.max_verify_attempts`, не дублируются env и поступают через settings bridge. OTP TTL остаётся security technical config provider-а:
|
||||
Все изменяемые OTP-параметры, включая limits, длину кода, TTL и timeout durable SMS order, не дублируются в env и поступают через settings bridge:
|
||||
|
||||
```text
|
||||
otp.phone.code_length
|
||||
otp.phone.ttl_seconds
|
||||
otp.phone.sms_order_timeout_ms
|
||||
```
|
||||
|
||||
Challenge сохраняет snapshot этих значений и `settings_version`; изменение настроек влияет только на новые challenges. В env остаются только secret/bootstrap-параметры:
|
||||
|
||||
```text
|
||||
KEYCLOAK_OTP_TTL_SEC=300
|
||||
KEYCLOAK_OTP_SETTINGS_MAX_STALE_SEC=300
|
||||
KEYCLOAK_OTP_HMAC_KEY=<secret>
|
||||
```
|
||||
@@ -494,7 +524,7 @@ Keycloak management health endpoints включены. Compose проверяе
|
||||
- realm/client/auth flow/provider loaded;
|
||||
- active signing key;
|
||||
- settings bridge last-known-good для OTP send;
|
||||
- mock enabled с valid secret либо реальный provider configured.
|
||||
- mock enabled с valid secret либо real-mode `sms-service` URL/token configured. Общая readiness Keycloak не зависит от Direct/provider status; недоступность `sms-service` отражается отдельным degraded dependency indicator и блокирует только новый real order.
|
||||
|
||||
Стандартный Keycloak health сам не знает business provider state; custom provider readiness check/sidecar/synthetic internal check дополняет его. Public synthetic проверяет discovery/JWKS и authorization endpoint без отправки OTP.
|
||||
|
||||
@@ -527,7 +557,7 @@ Keycloak access log должен редактировать sensitive query. TRA
|
||||
- active sessions/token refresh/error;
|
||||
- DB pool/JVM/GC/HTTP;
|
||||
- JWKS/key age;
|
||||
- provider mode info (`mock`, later vendor), без phone labels.
|
||||
- delivery mode info (`mock`, `sms`) и provider dependency `idgtl`, без phone labels.
|
||||
|
||||
### Tracing
|
||||
|
||||
@@ -582,7 +612,9 @@ TLS client→nginx; Keycloak→managed PG TLS. Internal nginx→Keycloak HTTP д
|
||||
| settings bridge down, cache valid | send limits по last-known-good |
|
||||
| settings bridge down, cache empty/stale | new OTP send fail-closed |
|
||||
| mock secret missing/invalid | startup/not-ready; OTP не bypass |
|
||||
| SMS mode без provider | not-ready `otp_provider_unconfigured` |
|
||||
| real mode без URL/token `sms-service` | новый OTP order fail-closed `otp_provider_unconfigured`; startup/config gate не пройден |
|
||||
| `sms-service` timeout/5xx | один retry с тем же idempotency key; затем `order_failed`, generic unavailable |
|
||||
| Direct reject/timeout после durable order | active challenge не меняется; Keycloak provider status не читает |
|
||||
| wrong OTP | generic error, increment counter |
|
||||
| too many sends/verifies | temporary reject/lockout, safe UX |
|
||||
| token signing key rotation | old keys passive в JWKS grace |
|
||||
@@ -604,6 +636,9 @@ TLS client→nginx; Keycloak→managed PG TLS. Internal nginx→Keycloak HTTP д
|
||||
- settings cache/ETag/stale/fail-closed;
|
||||
- phone HMAC/counter cleanup;
|
||||
- provider SPI error mapping.
|
||||
- durable order `200/202`, idempotent retry, `409` reuse и `order_failed`;
|
||||
- lifecycle `ordering/active/superseded/expired/limited/consumed`, periodic/lazy expiry;
|
||||
- device metadata validation и append-only event на каждую verify.
|
||||
|
||||
### Realm/config contract
|
||||
|
||||
@@ -629,6 +664,8 @@ TLS client→nginx; Keycloak→managed PG TLS. Internal nginx→Keycloak HTTP д
|
||||
### E2E
|
||||
|
||||
- new phone → mock OTP → PKCE tokens → API bootstrap;
|
||||
- real mode: durable order открывает OTP form до ответа Direct; `sms_message_id` совпадает в обеих БД;
|
||||
- resend отклоняет старый код; provider reject/timeout не меняет active challenge;
|
||||
- existing user login; valid refresh without OTP;
|
||||
- expired/revoked/rotated refresh → re-auth;
|
||||
- wrong/expired/replayed code;
|
||||
@@ -657,7 +694,7 @@ TLS client→nginx; Keycloak→managed PG TLS. Internal nginx→Keycloak HTTP д
|
||||
- phone canonical E.164 и storage-level unique;
|
||||
- claims соответствуют module-01 (`sub`, `phone_number`, audience);
|
||||
- mock secret only env, не логируется/не отдаётся;
|
||||
- OTP challenges/counters durable в Keycloak schema;
|
||||
- OTP challenges/counters/verify events durable в Keycloak schema; SMS journal/template/provider statuses там отсутствуют;
|
||||
- product limits читаются только через canonical settings bridge/token;
|
||||
- brute-force, TTL, verify attempts и enumeration protection работают;
|
||||
- refresh rotation/reuse detection/logout/revocation покрыты;
|
||||
@@ -666,7 +703,7 @@ TLS client→nginx; Keycloak→managed PG TLS. Internal nginx→Keycloak HTTP д
|
||||
- health/metrics/logging/tracing не раскрывают secrets/PII;
|
||||
- container hardening/root Compose без published port;
|
||||
- test matrix зелёная;
|
||||
- реальный SMS явно остаётся extension point, не скрытой заглушкой.
|
||||
- mock и real mutually exclusive; real mode вызывает только durable-order API `sms-service`, Direct/Verifier/status polling отсутствуют.
|
||||
|
||||
## 27. Решения, допущения и TBD
|
||||
|
||||
@@ -679,13 +716,13 @@ TLS client→nginx; Keycloak→managed PG TLS. Internal nginx→Keycloak HTTP д
|
||||
- K5: counters/challenges в provider-owned PostgreSQL schema `keycloak`, не API Redis.
|
||||
- K6: product limits только `/internal/settings/v1/otp` + `KEYCLOAK_SETTINGS_BRIDGE_TOKEN`.
|
||||
- K7: refresh rotation/revoke-on-use; frontend single-flight.
|
||||
- K8: real SMS provider — extension point/TBD.
|
||||
- K8: real delivery — `Keycloak → sms-service → i-Digital Direct`; verify остаётся локальным.
|
||||
|
||||
**Допущения:**
|
||||
|
||||
- A1: единый public host `tohin.ru` и relative path `/auth`.
|
||||
- A2: Keycloak version поддерживает нужные hostname/proxy/health options; точные names pin после выбора image.
|
||||
- A3: product допускает mock OTP в первой production-like среде как временный риск.
|
||||
- A3: product допускает mock OTP до прохождения controlled real-SMS rollout как временный риск.
|
||||
- A4: телефон в access token необходим API bootstrap и защищён TLS/short token TTL.
|
||||
|
||||
**TBD:**
|
||||
@@ -697,6 +734,5 @@ TLS client→nginx; Keycloak→managed PG TLS. Internal nginx→Keycloak HTTP д
|
||||
- K-TBD5: admin MFA/ops access topology и отдельный admin hostname.
|
||||
- K-TBD6: signing-key rotation interval/HSM и emergency revocation.
|
||||
- K-TBD7: RPO/RTO/event retention/legal deletion.
|
||||
- K-TBD8: SMS vendor, credentials, templates, sender, delivery receipts and failover.
|
||||
- K-TBD8 закрыт module-11 для v1: vendor i-Digital Direct, credentials/template/sender/callback принадлежат `sms-service`; failover вне v1.
|
||||
- K-TBD9: CAPTCHA/risk scoring после mock.
|
||||
- K-TBD10: добавить proposed OTP technical env в arch-04 до реализации.
|
||||
|
||||
@@ -39,6 +39,9 @@ Placeholders:
|
||||
<RELEASE> immutable tag/git SHA
|
||||
<ACME_EMAIL> адрес ops, не placeholder в реальном запуске
|
||||
<BITRIX_PORTAL> разрешённый портал
|
||||
<IDGTL_SENDER_NAME> согласованное в Direct имя отправителя
|
||||
<IDGTL_STATIC_EGRESS_IP> фактический статический egress IP `sms-worker`
|
||||
<IDGTL_TEST_PHONE> контролируемый номер для provider smoke
|
||||
```
|
||||
|
||||
## 3. Stage 0 — решения до provisioning
|
||||
@@ -72,7 +75,7 @@ Placeholders:
|
||||
- [ ] RPO/RTO приняты хотя бы временно: ориентир RPO PG ≤15 минут/PITR, RTO ≤4 часа.
|
||||
- [ ] Решено: images pull из registry или build на VM.
|
||||
- [ ] Remote telemetry backend выбран либо явно принят ограниченный debug-only режим.
|
||||
- [ ] Риск mock OTP и Safety stub письменно принят.
|
||||
- [ ] Риск mock OTP до SMS cutover и Safety stub письменно принят; real SMS не включается без gates module-11.
|
||||
|
||||
**Ожидаемый результат:** есть release checklist с конкретными values; не создано ни одной публичной БД/Redis.
|
||||
|
||||
@@ -90,7 +93,7 @@ Security groups:
|
||||
| internet | VM | TCP 80 | allow для redirect/ACME |
|
||||
| internet | VM | TCP 443 | allow |
|
||||
| VM private IP/SG | managed PG | `<PG_PORT>` | allow |
|
||||
| VM | internet | 443 | allow egress: registry, Bitrix, S3, OTLP, ACME |
|
||||
| VM | internet | 443 | allow egress: registry, Bitrix, S3, OTLP, ACME, i-Digital Direct |
|
||||
| internet | managed PG | any | deny |
|
||||
| internet | VM | 6379, 4317, 4318, 8000, 8080, 9000 | deny |
|
||||
|
||||
@@ -197,7 +200,7 @@ CA managed PostgreSQL скачать из панели или документа
|
||||
|
||||
Прототип `init-managed-postgres.py` выдаёт runtime roles `CREATE` на schema и печатает DSN. Это допустимо только для bootstrap/dev, но **слишком широко для production runtime**. Перед production адаптировать:
|
||||
|
||||
1. создать пять schemas: `han_app`, `bitrix_local`, `bitrix_sync`, `keycloak`, `message_safety`;
|
||||
1. создать шесть schemas: `han_app`, `bitrix_local`, `bitrix_sync`, `keycloak`, `message_safety`, `sms`;
|
||||
2. создать runtime roles;
|
||||
3. создать migration roles либо controlled admin job;
|
||||
4. schema owner = migration role;
|
||||
@@ -239,14 +242,15 @@ psql "host=<PG_PRIVATE_HOST> port=<PG_PORT> dbname=<PG_DATABASE> user=<RUNTIME_U
|
||||
2. `bitrix-local-app` Alembic владеет `bitrix_local`;
|
||||
3. `message-safety` stub не создаёт PG tables до production implementation;
|
||||
4. `bitrix-sync` stub — optional empty baseline;
|
||||
5. Keycloak мигрирует standard tables сам; custom provider имеет собственные versioned migrations.
|
||||
5. Keycloak мигрирует standard tables сам; custom provider имеет собственные versioned migrations;
|
||||
6. `sms-service` владеет versioned migrations/seed schema `sms`; runtime `sms_user` не имеет доступа к `han_app`/`keycloak`.
|
||||
|
||||
Только expand/migrate/contract. Destructive migration — отдельный backup, approval и release. Downgrade data migrations не обещается; rollback приложения требует backward-compatible schema.
|
||||
|
||||
### Gate 3
|
||||
|
||||
- [ ] Backups/PITR/TLS/deletion protection включены.
|
||||
- [ ] Пять schemas/roles созданы.
|
||||
- [ ] Шесть schemas/roles созданы, включая `sms`/`sms_user`.
|
||||
- [ ] Runtime roles не имеют DDL/чужого доступа.
|
||||
- [ ] Migration credentials отделены от runtime.
|
||||
- [ ] Empty/previous-version migration test успешен.
|
||||
@@ -366,6 +370,7 @@ openssl rand -hex 32
|
||||
- Redis ACL credentials/URLs DB0/1/2;
|
||||
- public web/API/auth URLs;
|
||||
- Keycloak realm/audience/hostname/bootstrap/provider technical secrets;
|
||||
- SMS DB URL, парные Keycloak↔SMS tokens, Direct `TOKEN_1`, callback URL и отдельные callback credentials;
|
||||
- paired service tokens из arch-02;
|
||||
- Bitrix client/application/webhook/encryption secrets;
|
||||
- S3 endpoint/buckets/API and read-only Safety credentials;
|
||||
@@ -378,6 +383,7 @@ openssl rand -hex 32
|
||||
```text
|
||||
BITRIX_LOCAL_APP_INTERNAL_TOKEN == BITRIX_INTERNAL_API_TOKEN
|
||||
BITRIX_API_FORWARD_TOKEN == BITRIX_API_INBOX_TOKEN
|
||||
KEYCLOAK_SMS_SERVICE_TOKEN == SMS_SERVICE_TOKEN
|
||||
```
|
||||
|
||||
Service token и webhook token — разные secrets.
|
||||
@@ -398,6 +404,7 @@ Service token и webhook token — разные secrets.
|
||||
- Safety timeout согласован с nginx;
|
||||
- secrets minimum length;
|
||||
- mock OTP risk flag explicitly accepted.
|
||||
- placeholders `change-me`/`<...>` запрещены; real mode требует sender/template/API key/callback credentials и recorded static egress IP;
|
||||
|
||||
```bash
|
||||
cd <BACKEND_ROOT>
|
||||
@@ -466,12 +473,13 @@ cd <BACKEND_ROOT>
|
||||
docker compose config --services
|
||||
```
|
||||
|
||||
Ожидаются: `nginx`, `api-backend`, `message-safety`, `keycloak`, `bitrix-sync`, `bitrix-local-app`, `redis`, `otel-collector` и one-shot jobs/profile components.
|
||||
В целевом real-SMS release ожидаются: `nginx`, `api-backend`, `message-safety`, `keycloak`, `sms-service`, `sms-worker` (либо документированный worker process), `bitrix-sync`, `bitrix-local-app`, `redis`, `otel-collector` и one-shot jobs/profile components.
|
||||
|
||||
Networks:
|
||||
|
||||
- `public`: nginx и минимально Keycloak/frontend path;
|
||||
- `backend`: internal services/Redis;
|
||||
- `egress`: только утверждённые outbound workers; Keycloak в неё не входит, `sms-worker` входит;
|
||||
- `observability`: services + Collector.
|
||||
|
||||
Volumes:
|
||||
@@ -661,6 +669,25 @@ docker compose run --rm api-backend python -m app.cli.validate_settings
|
||||
- [ ] Mandatory settings valid; secrets отсутствуют в `app_settings`.
|
||||
- [ ] Backward compatibility с текущими images подтверждена.
|
||||
|
||||
### 13.4. Controlled rollout real SMS
|
||||
|
||||
До переключения Keycloak:
|
||||
|
||||
1. применить App DB seed `otp.phone.code_length`, `otp.phone.ttl_seconds`, `otp.phone.sms_order_timeout_ms`;
|
||||
2. создать schema/role `sms`, применить migrations и idempotent seed `sms_setting`/active approved `auth_otp`;
|
||||
3. в test environment развернуть `sms-service`/worker с локальным mock Direct и выполнить contract/E2E;
|
||||
4. получить production Direct `TOKEN_1`, согласованные sender и template, отдельные callback credentials;
|
||||
5. определить egress IP фактическим запросом из `sms-worker`, подтвердить его статичность/NAT, записать в inventory и передать Direct для allowlist;
|
||||
6. развернуть production `sms-service`/worker и callback route, оставив `KEYCLOAK_OTP_MOCK_ENABLED=true`;
|
||||
7. применить Keycloak expand migration/SPI, мигрировать старые challenges по module-11;
|
||||
8. выполнить provider smoke отдельной ops-командой на `<IDGTL_TEST_PHONE>`; проверить journal, callback, redaction и отсутствие duplicate;
|
||||
9. только после подписанных evidence переключить `KEYCLOAK_OTP_MOCK_ENABLED=false`;
|
||||
10. проверить durable order до Direct response, resend/superseded, expiry snapshot, limits и verify при provider reject/timeout.
|
||||
|
||||
Production cutover запрещён при любом placeholder, несогласованном sender/template, отсутствующем API key/callback credentials, неподтверждённом callback IP или нестатическом egress IP. Direct API key — готовый `TOKEN_1` для Basic, повторно Base64 не кодируется.
|
||||
|
||||
Rollback SMS: немедленно вернуть Keycloak в mock mode; не удалять schema/journal и не откатывать migrations без доказанной backward compatibility. Остановить новые real orders, дать worker завершить либо зафиксировать in-flight/`uncertain`; предпочтителен forward-fix.
|
||||
|
||||
## 14. Stage 11 — Keycloak bootstrap
|
||||
|
||||
### 14.1. Первый старт
|
||||
@@ -713,22 +740,25 @@ Custom OTP tables мигрируются versioned mechanism до включен
|
||||
Архитектурный порядок:
|
||||
|
||||
1. Redis;
|
||||
2. Keycloak;
|
||||
3. OTEL Collector;
|
||||
4. Message Safety;
|
||||
5. API backend;
|
||||
6. Bitrix local app;
|
||||
7. Bitrix sync;
|
||||
8. nginx.
|
||||
2. OTEL Collector;
|
||||
3. API backend/settings;
|
||||
4. SMS service/worker после migrations (при SMS release; Keycloak пока mock);
|
||||
5. Keycloak;
|
||||
6. Message Safety;
|
||||
7. Bitrix local app;
|
||||
8. Bitrix sync;
|
||||
9. nginx.
|
||||
|
||||
Команды:
|
||||
|
||||
```bash
|
||||
cd <BACKEND_ROOT>
|
||||
docker compose up -d redis
|
||||
docker compose up -d keycloak otel-collector
|
||||
docker compose up -d message-safety
|
||||
docker compose up -d otel-collector
|
||||
docker compose up -d api-backend
|
||||
docker compose up -d sms-service sms-worker
|
||||
docker compose up -d keycloak
|
||||
docker compose up -d message-safety
|
||||
docker compose up -d bitrix-local-app bitrix-sync
|
||||
docker compose up -d nginx
|
||||
docker compose ps
|
||||
@@ -827,6 +857,7 @@ Expected: 308; public 200 strict DTO; discovery 200; internal 404; valid cert.
|
||||
- silent refresh работает без OTP;
|
||||
- logout очищает tokens;
|
||||
- wrong/replayed OTP не выдаёт tokens.
|
||||
- real mode: durable order возвращает `sms_message_id` до Direct response; callback обновляет только SMS journal; resend делает старый challenge `superseded`.
|
||||
|
||||
### 17.3. Message Safety правила stub
|
||||
|
||||
@@ -955,13 +986,14 @@ DB backup включает realm/users/signing keys/provider data. Secret-free r
|
||||
### Application-only
|
||||
|
||||
1. объявить incident/maintenance;
|
||||
2. сохранить diagnostics и current state;
|
||||
3. остановить новые claims/send при возможности;
|
||||
4. переключить image tags на previous digests;
|
||||
5. не выполнять Alembic downgrade;
|
||||
6. `docker compose up -d`;
|
||||
7. health/smoke/idempotency;
|
||||
8. проверить outbox/inbox/recovery.
|
||||
2. при SMS incident вернуть `KEYCLOAK_OTP_MOCK_ENABLED=true`, прекратить новые real orders и сохранить journal/in-flight state;
|
||||
3. сохранить diagnostics и current state;
|
||||
4. остановить новые claims/send при возможности;
|
||||
5. переключить image tags на previous digests;
|
||||
6. не выполнять Alembic downgrade;
|
||||
7. `docker compose up -d`;
|
||||
8. health/smoke/idempotency;
|
||||
9. проверить outbox/inbox/SMS pending/uncertain/recovery.
|
||||
|
||||
### После backward-incompatible migration
|
||||
|
||||
@@ -1162,7 +1194,7 @@ certbot delete active cert
|
||||
- D-A2: обязательный минимум — `otel-collector`; доступность remote backend не предполагается до закрытия D-TBD11, local Grafana stack не обязателен.
|
||||
- D-A3: managed provider даёт private network, TLS, backups/PITR.
|
||||
- D-A4: Bitrix portal/connector/line остаются разрешёнными значениями architecture.
|
||||
- D-A5: mock OTP временно разрешён как documented risk.
|
||||
- D-A5: mock OTP временно разрешён до controlled SMS cutover как documented risk.
|
||||
|
||||
### TBD до production
|
||||
|
||||
@@ -1186,8 +1218,9 @@ certbot delete active cert
|
||||
4. Прототипные TLS scripts используют отдельный service Compose/standalone downtime, тогда как целевая архитектура требует root Compose и two-phase webroot.
|
||||
5. Prototype публиковал `/bitrix-internal/*` и использовал `/internal/v1/*`; целевой контур это запрещает и использует `/internal/openlines/v1/*`.
|
||||
6. `arch-04` не содержит ряд proposed env из module-04–09; production `.env.example` должен быть синхронизирован до реализации.
|
||||
7. Точные RPO/RTO, retention, SLO, Keycloak version/TTL и Bitrix retry semantics не утверждены; начальные значения runbook не закрывают product/security decision.
|
||||
7. Точные RPO/RTO, SLO, Keycloak version и Bitrix retry semantics не утверждены; OTP TTL задаётся `app_settings`, SMS journal по module-11 хранится бессрочно.
|
||||
8. `init-managed-postgres.py` по умолчанию не задаёт TLS parameters при bootstrap connection и печатает credential-bearing DSN; его production-hardening обязателен.
|
||||
9. Текущие Compose/env/config artifacts могут ещё не содержать `sms-service`; документация не разрешает real mode до реализации и прохождения rollout gates.
|
||||
|
||||
## 29. Ссылки на прототип
|
||||
|
||||
|
||||
@@ -0,0 +1,889 @@
|
||||
# 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-00`…`arch-04`, `module-08`, Compose и `.env.example` настоящий документ имеет приоритет только как спецификация нового модуля, но не изменяет действующий mock-only контур.
|
||||
|
||||
## 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 остаётся без `egress`: он видит только `sms-service` по сети `backend`;
|
||||
- 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`, `egress`, `observability`, `expose: 8080`, без `ports`;
|
||||
- отдельный `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`
|
||||
|
||||
- добавить точный 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 из §9 для `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:
|
||||
|
||||
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, а не параметр приложения или открытое архитектурное решение.
|
||||
Reference in New Issue
Block a user