Проект разделен на два репозитория
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
# Документация ВМ1 HAN Chat
|
||||
|
||||
Здесь находятся профильные спецификации контура ВМ1. Канонические границы, имена и межсервисные контракты задаёт [`architectory`](../../architectory/README.md); при конфликте действует порядок приоритетов из этого README.
|
||||
|
||||
## Состав ВМ1
|
||||
|
||||
- [`module-01-api-backend.md`](module-01-api-backend.md) — API, App DB, Message Safety caller, realtime.
|
||||
- [`module-02-frontend-test-site.md`](module-02-frontend-test-site.md) — web/mobile frontend contract.
|
||||
- [`module-03-nginx-vm1.md`](module-03-nginx-vm1.md) — public edge ВМ1.
|
||||
- [`module-04-redis-vm1.md`](module-04-redis-vm1.md) — Redis DB0/DB1.
|
||||
- [`module-06-bitrix-local-app.md`](module-06-bitrix-local-app.md) — Bitrix24 Open Lines local app.
|
||||
- [`module-08-keycloak.md`](module-08-keycloak.md) — OIDC/OTP/SmartCaptcha.
|
||||
- [`module-09-observability-vm1.md`](module-09-observability-vm1.md) — telemetry ВМ1.
|
||||
- [`module-10-deployment-vm1.md`](module-10-deployment-vm1.md) — runbook ВМ1.
|
||||
- [`module-11-idgtl-sms.md`](module-11-idgtl-sms.md) — целевой SMS-контур.
|
||||
|
||||
Message Safety, CRM sync, nginx/Redis/telemetry и runbook ВМ2 находятся в [`VM2_services/documentation`](../../VM2_services/documentation/README.md). После cutover ВМ1 не запускает `message-safety`, `bitrix-sync` или Redis DB2; вызов Safety идёт по private HTTPS `:8443`.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,299 @@
|
||||
# module-02. Проектная спецификация тестового frontend-сайта
|
||||
|
||||
> Статус: целевая спецификация реализации MVP; это проектирование, не код.
|
||||
> Канонические источники: [`README.md`](README.md), [`arch-00-glossary.md`](../../architectory/arch-00-glossary.md), [`arch-01-system-architecture.md`](../../architectory/arch-01-system-architecture.md), [`arch-02-api-contracts.md`](../../architectory/arch-02-api-contracts.md), [`arch-03-docker-compose-blueprint.md`](../../architectory/arch-03-docker-compose-blueprint.md), [`arch-04-settings-and-content.md`](../../architectory/arch-04-settings-and-content.md), [`arch-05-agent-development-process.md`](../../architectory/arch-05-agent-development-process.md), [`arch-06-service-hosting-security.md`](../../architectory/arch-06-service-hosting-security.md), [`module-01-api-backend.md`](module-01-api-backend.md).
|
||||
|
||||
## 1. Назначение и границы
|
||||
|
||||
Сайт нужен для ручной, интеграционной и E2E-проверки всех пользовательских сценариев HAN Chat через реальные публичные API. Он остаётся простым по визуальному дизайну, но функционально покрывает guest, OTP/PKCE, bootstrap, UX-сессию, чат, файлы, профиль, Notification Center, realtime и деградации.
|
||||
|
||||
Сайт не реализует бизнес-решения backend, не обращается к PostgreSQL, Redis, Bitrix24 или S3 постоянными credentials и не подменяет Message Safety. Публичный вопрос отправляется обычным текстовым сообщением.
|
||||
|
||||
## 2. Зафиксированный стек
|
||||
|
||||
- Expo SDK + React Native + TypeScript, web target через Expo Router.
|
||||
- React Query для server state; локальный reducer/state machine для auth и отложенной отправки.
|
||||
- `expo-auth-session`/OIDC Authorization Code Flow with PKCE; парольный flow запрещён.
|
||||
- SecureStore на native; для test web — защищённая browser storage adapter с явным предупреждением о риске XSS. Access token предпочтительно держать в памяти, refresh token — в доступном платформе secure storage.
|
||||
- React Hook Form + schema validation (Zod либо эквивалент).
|
||||
- WebSocket API браузера; REST polling как обязательный fallback.
|
||||
- Playwright для web E2E, Vitest/Jest + Testing Library для unit/component.
|
||||
- Никакого отдельного frontend nginx: production-статику отдаёт единственный корневой nginx.
|
||||
|
||||
## 3. Предлагаемая структура
|
||||
|
||||
```text
|
||||
frontend-test-site/
|
||||
app/
|
||||
_layout.tsx
|
||||
index.tsx
|
||||
auth/callback.tsx
|
||||
dialogs/index.tsx
|
||||
dialogs/[dialogId].tsx
|
||||
profile.tsx
|
||||
diagnostics.tsx
|
||||
src/
|
||||
api/{client,errors,public,auth,dialogs,attachments,profile,notifications}.ts
|
||||
auth/{oidc,pkce,token-store,refresh-single-flight}.ts
|
||||
session/{ux-session,activity}.ts
|
||||
realtime/{socket,polling,reconcile}.ts
|
||||
flows/{deferred-send,bootstrap}.ts
|
||||
components/
|
||||
config/
|
||||
accessibility/
|
||||
tests/{unit,component,contract,e2e}/
|
||||
app.config.ts
|
||||
package.json
|
||||
```
|
||||
|
||||
## 4. Runtime state
|
||||
|
||||
| Состояние | Хранение | Правило |
|
||||
|---|---|---|
|
||||
| access token | память | не логировать, не показывать полностью |
|
||||
| refresh token | secure adapter | очищать при logout/`invalid_grant` |
|
||||
| PKCE verifier/state/nonce | session storage, короткий TTL | одноразовые, проверяются callback |
|
||||
| `ux_session_id`, `last_activity_at` | только память | не localStorage |
|
||||
| `guest_session_id` | локально, опционально | не auth, не посылается как право доступа |
|
||||
| pending text intent | session storage, до терминального send outcome | восстанавливает отправку после OIDC redirect |
|
||||
| pending file intent | память | переносит выбранный `File` с главной в чат без сериализации |
|
||||
| REST cursors | память по dialog | opaque, не парсить |
|
||||
|
||||
Auth state machine: `guest → authorizing → bootstrapping → authenticated`; при refresh failure — обратно `guest`. UX-сессия независима от Keycloak-сессии.
|
||||
|
||||
## 5. Экраны
|
||||
|
||||
### 5.1. Главная
|
||||
|
||||
- загрузка `GET /api/v1/public/app-config` и `/content`;
|
||||
- приветствие, популярные вопросы, textarea, attach button, send;
|
||||
- индикаторы загрузки/ошибки и повтор;
|
||||
- ссылки на чат и профиль (при guest запускают auth только по явному действию);
|
||||
- диагностический badge режима: guest/authenticated, WS/polling, без раскрытия token.
|
||||
|
||||
Выбор популярного вопроса сразу запускает тот же send flow, что ручной текст.
|
||||
|
||||
### 5.2. Согласия и OTP
|
||||
|
||||
Modal согласий (макет AuthConsent) показывает три блока. В блоке `personal_data` — две ссылки: `document_url` (согласие на обработку ПД) и `privacy_policy_document_url` (политика ПД из `consent.privacy_policy.document_url`). В блоках `user_agreement` и `marketing` — по одной ссылке из `document_url` (`consent.marketing.document_url` для рекламы). Обязательность берётся из `consent.*.required` (`personal_data`/`user_agreement` обычно обязательны, `marketing` — нет). После подтверждения intent остаётся в памяти, начинается OIDC PKCE redirect.
|
||||
|
||||
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. Чат
|
||||
|
||||
- в MVP frontend показывает пользователю один чат с компанией без истории диалогов;
|
||||
- пункт навигации «Чат» вызывает `POST /dialogs`: backend возвращает текущий активный диалог или создаёт новый, после чего frontend открывает `/dialogs/{dialog_id}`;
|
||||
- backend-модель диалогов и `GET /dialogs` сохраняются для будущего возврата истории;
|
||||
- экран чата содержит статус, сообщения, composer и attachment;
|
||||
- сообщения сортируются по `created_at asc`, дубли объединяются по `message_id`;
|
||||
- `waiting_for_company`, `waiting_for_client`, `closed` отображаются русскими подписями;
|
||||
- завершённая беседа readonly; CTA «Продолжить общение» повторно открывает текущий чат через `POST /dialogs`;
|
||||
- internal safety `202` клиенту не показывается: send request остаётся in progress до финального ответа.
|
||||
|
||||
### 5.4. Профиль
|
||||
|
||||
Readonly блок «Личные данные» из `GET /me`; блок «Документы» из `/me/documents`, допускается пустой. Редактирование отсутствует. Для изменения данных — CTA в чат. Download URL запрашивается только после клика и не сохраняется.
|
||||
|
||||
### 5.5. Notification Center
|
||||
|
||||
Главная показывает bounded carousel активных G/P-уведомлений, центр — paginated список и состояния personal notifications. Публичные guest-кампании читаются без JWT; персональные read/action/hide/upload операции требуют JWT, ownership и idempotency по arch-02. CTA использует только allow-listed action types, external URL открывается безопасно, upload/download URL не сохраняются. WS-событие уведомления служит только сигналом обновить данные через REST; после reconnect выполняется reconciliation.
|
||||
|
||||
### 5.6. Diagnostics (только non-production)
|
||||
|
||||
Последние безопасные request id, HTTP status, WS state, cursor, время token expiry и UX-session id. Tokens, OTP, PII, тела сообщений и presigned URL не выводятся.
|
||||
|
||||
## 6. Startup, auth и bootstrap
|
||||
|
||||
1. Немедленно показать guest UI и параллельно загрузить public config/content.
|
||||
2. Проверить refresh token. При наличии — выполнить silent Refresh Token Grant через single-flight.
|
||||
3. При успехе определить новую UX-сессию (`cold_start` при новом page lifecycle), вызвать `session-start`, затем загрузить profile/dialogs.
|
||||
4. При отсутствии/истечении refresh token оставаться guest до protected action.
|
||||
5. После OTP callback проверить `state`/`nonce`, обменять code с PKCE, вызвать `POST /auth/bootstrap` с согласиями и device metadata.
|
||||
6. Создать UX-сессию, если её нет, завершить auth-экран и перейти в чат; pending intent отправляется уже экраном чата. Ошибка Message Safety/Bitrix не превращается в ошибку bootstrap.
|
||||
|
||||
Bootstrap повторяем безопасно после неопределённого сетевого результата. Телефон в body никогда не передаётся.
|
||||
|
||||
## 7. UX-сессия
|
||||
|
||||
- `ux_session_id` и activity timestamp живут только в памяти вкладки.
|
||||
- После JWT `session-start` вызывается с `first_launch`, `cold_start` либо `idle_timeout`.
|
||||
- Idle timeout берётся из app-config; default UI не подменяет server config.
|
||||
- Visibility/focus/user input обновляют activity; возврат после превышения timeout создаёт новую сессию.
|
||||
- Refresh token grant не создаёт новую UX-сессию.
|
||||
- `X-Ux-Session-Id` добавляется ко всем JWT REST-запросам, когда id уже получен.
|
||||
|
||||
## 8. HTTP client и заголовки
|
||||
|
||||
Каждый API-запрос получает `X-Request-ID` (UUID), `traceparent` при активной трассировке и `Accept: application/json`. Protected request получает Bearer token и `X-Ux-Session-Id`.
|
||||
|
||||
`Idempotency-Key` обязателен для `POST /dialogs` и `POST .../messages`; ключ создаётся один раз на пользовательское действие и сохраняется на retry этого действия. Новый intent получает новый key. Для attachment init/complete повтор соблюдает state/idempotency контракта backend.
|
||||
|
||||
Единый error envelope маппится по `error.code`, а `request_id` показывается в деталях поддержки. Тело/headers с credentials не логируются.
|
||||
|
||||
## 9. Token refresh single-flight
|
||||
|
||||
- планировать refresh за 60 секунд до `exp`;
|
||||
- один Promise/mutex на refresh; все параллельные запросы ждут его;
|
||||
- на первом `401 unauthorized/token_expired` — один refresh и один replay исходного запроса;
|
||||
- mutating replay использует исходный `Idempotency-Key`;
|
||||
- второй `401` не запускает цикл;
|
||||
- `invalid_grant` очищает tokens, закрывает WS, переводит в guest;
|
||||
- WS auth failure использует тот же single-flight, затем reconnect;
|
||||
- logout отзывает/завершает OIDC-сессию best effort и всегда очищает local secrets.
|
||||
|
||||
## 10. Отправка текста
|
||||
|
||||
1. Валидировать непустой нормализованный текст и клиентский max length из контракта.
|
||||
2. Если guest — сохранить intent, consent → OTP → bootstrap → session-start.
|
||||
3. `POST /dialogs` с idempotency key, сохранить `dialog_id` и перейти на экран чата до отправки.
|
||||
4. Экран чата выполняет `POST /dialogs/{id}/messages` с отдельным стабильным key.
|
||||
5. Блокировать повторный click только для того же intent; другие действия не замораживать.
|
||||
6. На `201` merge `MessageResponse`; на `422 message_blocked` не показывать internal reason/rule и не строить собственный текст: получить/merge backend `company`-реплику (`message.new`) с контентом мнемоники `safety.chat.blocked`; на `503/504` предложить retry с тем же key.
|
||||
7. Composer показывает счётчик `n/max`, где `max` приходит как `messages.max_text_length` из public app-config; сверх лимита отправка блокируется без обрезки ввода.
|
||||
|
||||
## 11. Файловый flow
|
||||
|
||||
MVP допускает ровно один файл, только allow-list extension+MIME, до 5 МБ или значений app-config.
|
||||
|
||||
1. Локальная prevalidation.
|
||||
2. Создать/reuse dialog.
|
||||
3. `POST .../attachments/init` с filename, MIME, size.
|
||||
4. Вычислить SHA-256 до upload и выполнить прямой `PUT upload_url` с **точно** выданными `upload_headers`, включая `If-None-Match: *` и checksum; заголовки являются частью подписи.
|
||||
5. Вызвать `complete` с тем же checksum; backend фиксирует authoritative S3 version/ETag/checksum.
|
||||
6. Отправить file message с `attachment_id` и `sha256:<hex>`.
|
||||
|
||||
Presigned URL не сохраняется и редактируется из диагностик. `412` означает, что immutable key уже записан: frontend не повторяет PUT в тот же key, а запрашивает новый init. Abort позволяет отменить PUT; orphan очищает backend через 48 ч. При expiry init выполняется повторно в рамках согласованного состояния. CORS S3 разрешает origin сайта, PUT и только необходимые signed headers.
|
||||
|
||||
## 12. Realtime и polling
|
||||
|
||||
Предпочтение — `WSS /api/v1/realtime`, token через согласованный subprotocol; query token допускается только для совместимости и не логируется.
|
||||
|
||||
- connected → subscribe актуальных dialog ids;
|
||||
- события `message.new`, `message.status`, `dialog.status` merge идемпотентно;
|
||||
- отвечать `pong` на `ping`;
|
||||
- reconnect 1, 2, 4…30 секунд с jitter;
|
||||
- после каждого reconnect делать REST gap reconciliation по последнему cursor;
|
||||
- если WS недоступен более 30 секунд — polling `GET .../messages?after=...`;
|
||||
- polling прекращается после устойчивого WS, но только после reconciliation;
|
||||
- hidden tab снижает polling frequency без нарушения восстановления;
|
||||
- неизвестные event types игнорируются с безопасной метрикой.
|
||||
|
||||
## 13. Ошибки и UX
|
||||
|
||||
| Ситуация | Поведение |
|
||||
|---|---|
|
||||
| offline/network | banner, сохранение intent в памяти, ручной retry |
|
||||
| 400 validation | подсветить поле; не retry автоматически |
|
||||
| 401 | single-flight refresh; при провале guest |
|
||||
| 403 consents | обновить config, повторить consent flow |
|
||||
| 404 | безопасное «ресурс недоступен», обновить список |
|
||||
| 409 idempotency | остановить retry, показать request id |
|
||||
| 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 |
|
||||
|
||||
Skeleton/empty/error states обязательны для каждого data screen. Никаких optimistic «delivered» до `201`.
|
||||
|
||||
## 14. Конфигурация и сборка
|
||||
|
||||
Public build-time env содержит только URL/realm/client id:
|
||||
|
||||
```text
|
||||
EXPO_PUBLIC_API_BASE_URL=https://tohin.ru
|
||||
EXPO_PUBLIC_AUTH_BASE_URL=https://tohin.ru/auth
|
||||
EXPO_PUBLIC_KEYCLOAK_REALM=han-chat
|
||||
EXPO_PUBLIC_KEYCLOAK_CLIENT_ID=han-chat-frontend
|
||||
EXPO_PUBLIC_APP_ENV=production-like
|
||||
```
|
||||
|
||||
Redirect URI и allowed origins фиксируются в Keycloak/nginx. Service tokens, S3 keys и mock OTP code во frontend env запрещены. Бизнес-конфиг приходит через `/public/app-config`, тексты — `/public/content`.
|
||||
|
||||
Production: статический export монтируется в корневой nginx, `try_files $uri /index.html`; hashed assets immutable, `index.html` no-cache/revalidate. Local dev: Expo dev server, опциональный proxy корневого nginx через `FRONTEND_DEV_PROXY_ENABLED=true`.
|
||||
|
||||
## 15. Доступность
|
||||
|
||||
- WCAG 2.1 AA как цель; полная keyboard navigation и видимый focus.
|
||||
- Семантические headings/landmarks, labels и error descriptions.
|
||||
- Modal: focus trap, возврат focus, Escape только если не нарушает обязательный flow.
|
||||
- Live region для новых сообщений и статусов без повторного озвучивания всей ленты.
|
||||
- Контраст, zoom 200%, reduced motion, touch targets не менее 44×44 CSS px.
|
||||
- Статусы не кодируются одним цветом; файлы имеют доступные имена и progress.
|
||||
- OTP поля поддерживают paste/autocomplete, но не логируют значение.
|
||||
|
||||
## 16. Тестовая матрица
|
||||
|
||||
### Unit/component
|
||||
|
||||
- auth state machine, PKCE callback state/nonce;
|
||||
- refresh scheduler/single-flight/concurrent 401;
|
||||
- UX idle boundary;
|
||||
- idempotency key reuse;
|
||||
- error mapping/redaction;
|
||||
- text/file union и file validation;
|
||||
- WS merge, duplicate, reconnect и poll switch;
|
||||
- accessibility scans ключевых экранов.
|
||||
|
||||
### Contract/integration
|
||||
|
||||
- DTO соответствует `api-backend/openapi.yaml`;
|
||||
- public cache/ETag;
|
||||
- bootstrap без phone body;
|
||||
- dialog 200/201;
|
||||
- message 201/422/429/503/504;
|
||||
- presigned PUT headers/checksum/expiry;
|
||||
- profile/documents readonly;
|
||||
- WS события и REST reconciliation.
|
||||
|
||||
### E2E
|
||||
|
||||
| Сценарий | Варианты |
|
||||
|---|---|
|
||||
| guest | просмотр public content; write закрыт |
|
||||
| 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 |
|
||||
| realtime | message, status, close, reconnect, polling fallback |
|
||||
| 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. В CI используются Keycloak mock mode и локальный mock/WireMock Direct. Отдельный sandbox Direct не предполагается; provider smoke выполняется только ops на контролируемом номере.
|
||||
|
||||
## 17. Definition of Done
|
||||
|
||||
- все экраны и flows выше реализованы на русском;
|
||||
- guest не вызывает protected write;
|
||||
- PKCE/OTP, silent refresh, bootstrap и pending intent проверены E2E;
|
||||
- UX-session создаётся и передаётся строго по правилам;
|
||||
- idempotency и refresh single-flight выдерживают concurrency;
|
||||
- text/file lifecycle работает через presigned PUT;
|
||||
- WS и polling не оставляют gap;
|
||||
- profile readonly и documents empty state реализованы;
|
||||
- CSP/CORS совместимы без unsafe token practices;
|
||||
- отсутствуют secrets, PII, message body и URLs в логах;
|
||||
- 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
|
||||
|
||||
**Решения:** Expo/TypeScript; server state через React Query; auth state machine; WS best effort + обязательная REST reconciliation; никаких optimistic delivered.
|
||||
|
||||
**Допущения:** test site использует те же API и Keycloak realm contracts, что мобильные клиенты; locale MVP — `ru`; browser secure storage не эквивалентен OS Keychain, поэтому CSP и отсутствие сторонних scripts обязательны.
|
||||
|
||||
**TBD:**
|
||||
|
||||
- F1: окончательный Keycloak browser adapter и token rotation policy;
|
||||
- F2: точные max lengths и WS limits после OpenAPI;
|
||||
- F3: web storage policy refresh token перед production security review;
|
||||
- F4: окончательный DTO app-config (G10);
|
||||
- F5: WS `event_id`/protocol version (TBD module-01/G11);
|
||||
- F6 для Safety закрыт: generic deny использует `safety.chat.blocked`; остальные error-state мнемоники остаются в frontend content backlog.
|
||||
@@ -0,0 +1,214 @@
|
||||
# module-03-vm1. Nginx ВМ1 HAN Chat
|
||||
|
||||
> Статус: целевая спецификация nginx на ВМ1.
|
||||
> Канонический контракт (TLS/ACME, request id, internal 404, logs, reload) — [`arch-08-nginx.md`](../../architectory/arch-08-nginx.md).
|
||||
> Обязательный host/container hardening baseline — [`arch-06-service-hosting-security.md`](../../architectory/arch-06-service-hosting-security.md).
|
||||
> Контур ВМ2 — [`module-03-nginx-vm2.md`](../../VM2_services/documentation/module-03-nginx-vm2.md). Публичный трафик ВМ2 не проходит через этот nginx.
|
||||
|
||||
## 1. Назначение и границы
|
||||
|
||||
Nginx ВМ1 — публичная точка входа приложения: frontend, API, auth, Open Lines, SMS callback. Message Safety и CRM webhook на этой машине не публикуются.
|
||||
|
||||
Канонический вызов Safety: `api-backend` напрямую → private `8443` nginx ВМ2. Public `/internal/` на ВМ1 всегда `404`; upstream `processing_gateway` и proxy route Safety в nginx ВМ1 запрещены.
|
||||
|
||||
## 2. Routing matrix
|
||||
|
||||
Порядок location критичен. Prefix `/` — последним.
|
||||
|
||||
| Внешний путь | Upstream | Режим |
|
||||
|---|---|---|
|
||||
| `/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/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 |
|
||||
|
||||
Notification paths внутри `/api/` имеют отдельные edge-зоны. `/internal/`, `/_internal/`, Redis/OTLP/admin/status/config files → `404` (arch-08). `/bitrix/sync/webhook/*` на ВМ1 не маршрутизируется.
|
||||
|
||||
## 3. Upstreams
|
||||
|
||||
Именованные upstream: `api_backend`, `keycloak`, `sms_service`, `bitrix_local`, опционально `frontend_dev`.
|
||||
|
||||
Upstream failures: `502/504` с безопасным body и `X-Request-ID`. Custom JSON error допустим для `/api`, но не имитирует backend domain code. Fallback в SPA запрещён.
|
||||
|
||||
## 4. Listeners и TLS
|
||||
|
||||
Public host ВМ1: `:80` только ACME + `308 https://$host$request_uri`; `:443 ssl http2` по arch-08. Собственный сертификат, не разделяется с ВМ2.
|
||||
|
||||
## 5. WebSocket
|
||||
|
||||
Только exact `location = /api/v1/realtime`:
|
||||
|
||||
- `proxy_http_version 1.1`;
|
||||
- `Upgrade $http_upgrade`, `Connection` через `map`;
|
||||
- buffering и response cache выключены;
|
||||
- read timeout больше ping interval (ориентир 75 с), send timeout bounded;
|
||||
- rate limit handshake и `limit_conn` на IP;
|
||||
- query `access_token` вырезается/редактируется из логов;
|
||||
- subprotocol передаётся;
|
||||
- при shutdown nginx позволяет grace reconnect, frontend восстанавливается polling.
|
||||
|
||||
## 6. Timeouts ВМ1
|
||||
|
||||
Ориентиры arch-08 §6. Обязательно:
|
||||
|
||||
- message POST read timeout ≥ `MESSAGE_SAFETY_TASK_POLL_MAX_SEC + 30s`; при default safety max 300 сек — не меньше 330 сек;
|
||||
- значение из env template до startup.
|
||||
|
||||
`client_max_body_size` global 8m; JSON API locations строже, где возможно. Байты вложения через nginx не идут.
|
||||
|
||||
## 7. Edge rate limits ВМ1
|
||||
|
||||
Зоны `limit_req_zone` по binary remote address:
|
||||
|
||||
- `auth`: `NGINX_RATE_LIMIT_AUTH`, малый burst, без большого nodelay;
|
||||
- `public`: config/content, 60/min/IP;
|
||||
- `api`: общий API;
|
||||
- `polling`: GET messages fallback;
|
||||
- `downloads`: issuance URL;
|
||||
- `notifications_read`: list/counter/detail;
|
||||
- `notifications_action`: read/hide/CTA/button;
|
||||
- `notification_upload`: универсальные upload drafts;
|
||||
- `notifications_public`: guest notifications и каталог видов;
|
||||
- `bitrix_callbacks`: мягкий burst для повторов local app;
|
||||
- `idgtl_callbacks`: отдельный bounded burst, учитывающий повтор каждые 5 минут в течение суток;
|
||||
- `ws_connect`: handshake;
|
||||
- `connections`: `limit_conn`.
|
||||
|
||||
Ответ превышения — `429`, `Retry-After`, request id. OPTIONS не должен расходовать auth budget чрезмерно. Resource/FD limits учитывают WS.
|
||||
|
||||
## 8. Static SPA и dev mode
|
||||
|
||||
Production:
|
||||
|
||||
- root `${FRONTEND_STATIC_PATH}`;
|
||||
- volume `frontend-static` → `/usr/share/nginx/html:ro`;
|
||||
- существующие hashed assets — `Cache-Control: public, max-age=31536000, immutable`;
|
||||
- `index.html`, manifest/service worker — `no-cache` либо короткая revalidation;
|
||||
- `try_files $uri $uri/ /index.html`;
|
||||
- dotfiles, source maps (если не предназначены), config/env artifacts запрещены;
|
||||
- API/Bitrix/auth/internal locations объявлены до SPA и никогда в неё не fallback.
|
||||
|
||||
Dev: при `FRONTEND_DEV_PROXY_ENABLED=true` `/` проксируется на allow-listed `EXPO_DEV_SERVER_URL`, с WS/HMR. Этот режим запрещён при `APP_ENV=production-like|production`; startup template validator fail-fast.
|
||||
|
||||
## 9. Public caching
|
||||
|
||||
`GET /api/v1/public/app-config` и `/content` кэшируются только для GET/HEAD, с key `scheme+host+uri+accept-encoding` (и locale query, если контракт его использует). Backend `Cache-Control`/ETag учитываются. Базовый TTL — `security.public_cache.max_age_seconds`/3600.
|
||||
|
||||
- `Set-Cookie` не кэшируется;
|
||||
- Authorization request bypass cache;
|
||||
- stale-if-error допускается ограниченно и маркируется `Warning`;
|
||||
- mutation, auth, Bitrix, profile, dialogs, downloads и errors не кэшируются;
|
||||
- cache status пишется в log, но наружу технологический header опционален.
|
||||
|
||||
## 10. CORS, CSP и security headers
|
||||
|
||||
CORS — exact allow-list из согласованного deploy config; application CORS остаётся последней инстанцией. Wildcard с credentials запрещён. Allowed headers: `Authorization`, `Content-Type`, `X-Request-ID`, `X-Ux-Session-Id`, `Idempotency-Key`, `traceparent`; методы соответствуют OpenAPI. Preflight получает bounded max-age.
|
||||
|
||||
Для SPA:
|
||||
|
||||
- CSP default-src `'self'`;
|
||||
- connect-src `'self'` `https:` к разрешённому S3 endpoint и `wss:` текущего host;
|
||||
- img-src `'self' data: blob:` и разрешённые signed HTTPS resources;
|
||||
- object-src `'none'`, base-uri `'self'`, frame-ancestors `'none'`;
|
||||
- frame-src `'none'`: инструкция `install_app` всегда открывается в новой вкладке, iframe/модалка не поддерживается;
|
||||
- script-src без `unsafe-eval` production; nonce/hash при необходимости;
|
||||
- style-src policy согласовать с Expo build, постепенно исключить unsafe-inline.
|
||||
|
||||
Для Keycloak login endpoints под `/auth/` применяется отдельный CSP, не SPA-policy. При `KEYCLOAK_YANDEX_CAPTCHA_ENABLED=true` он точечно разрешает `https://smartcaptcha.cloud.yandex.ru` и необходимые static resources `https://yastatic.net` только в соответствующих directives; wildcard и ослабление CSP остальных `/auth/*` запрещены. При выключенной CAPTCHA эти origins отсутствуют. Env validator обязан согласовать CAPTCHA flag, CSP allow-list и ограниченный egress Keycloak.
|
||||
|
||||
Также: `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, `Permissions-Policy`, frame protection через CSP, корректный COOP/CORP без поломки Keycloak redirect/S3.
|
||||
|
||||
Bitrix placement может требовать embedding: для exact `/bitrix/placement` CSP `frame-ancestors` задаётся отдельным allow-list Bitrix24, а не ослабляет SPA.
|
||||
|
||||
## 11. 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.
|
||||
- `/internal/sms/*` и порт sms-service наружу не публиковать.
|
||||
|
||||
## 12. Health и synthetic ВМ1
|
||||
|
||||
Внутренний `/nginx-health/live` — arch-08 §8. Внешняя synthetic проверка отдельно проверяет TLS, redirect, public API, auth discovery и SMS callback route. Upstream `/health/ready` local app не публикуется без решения ops.
|
||||
|
||||
## 13. Layout и Compose ВМ1
|
||||
|
||||
Каркас arch-08 §10 плюс snippets `websocket.conf`. Volume `frontend-static` только на ВМ1.
|
||||
|
||||
`nginx` публикует `${NGINX_HTTP_PORT}:80`, `${NGINX_HTTPS_PORT}:443`. Private `8443` на ВМ1 нет.
|
||||
|
||||
## 14. Failure behavior ВМ1
|
||||
|
||||
Дополнительно к arch-08 §12:
|
||||
|
||||
- API/Keycloak upstream down: bounded 502/504, без SPA fallback.
|
||||
- Safety slow: nginx ждёт message budget ≥ max+30, затем 504; backend checkpoint продолжает recovery.
|
||||
- Redis down не влияет на запуск nginx; app решает degraded policy.
|
||||
|
||||
Остановленный nginx ВМ1 не должен ломать приём CRM webhook на ВМ2.
|
||||
|
||||
## 15. Валидация и тесты ВМ1
|
||||
|
||||
Команды acceptance (подставить `<PUBLIC_HOST>` ВМ1):
|
||||
|
||||
```text
|
||||
docker compose config
|
||||
docker compose exec nginx nginx -t
|
||||
curl -I http://<PUBLIC_HOST>/
|
||||
curl -vk https://<PUBLIC_HOST>/api/v1/public/app-config
|
||||
openssl s_client -connect <PUBLIC_HOST>:443 -servername <PUBLIC_HOST>
|
||||
curl -i https://<PUBLIC_HOST>/internal/safety/v2/messages/check
|
||||
```
|
||||
|
||||
Ожидания: HTTP → 308; public config 200; internal Safety снаружи `404`; valid cert.
|
||||
|
||||
Автоматические тесты:
|
||||
|
||||
- Test::Nginx/containers для route precedence, methods, 404 internal;
|
||||
- TLS scan: только 1.2/1.3, chain/hostname/expiry;
|
||||
- redirect и ACME challenge;
|
||||
- request-id valid/invalid/generation/propagation;
|
||||
- forwarded spoof rejection;
|
||||
- WS handshake, ping idle и reconnect;
|
||||
- message request длительнее safety max не обрывается до budget;
|
||||
- body/header limits;
|
||||
- rate zones/429/Retry-After, включая notification read/action/upload/public;
|
||||
- public cache HIT/MISS/bypass/no private cache;
|
||||
- CSP/CORS preflight и Bitrix placement exception;
|
||||
- CSP содержит `frame-src 'none'`; инструкция проверяется как новая вкладка без embedded content;
|
||||
- `/internal/notifications/*` снаружи всегда `404`;
|
||||
- allowed Direct callback проходит; wrong IP/method и любой `/internal/sms/*` отклоняются; Authorization отсутствует в логах;
|
||||
- CRM webhook paths на ВМ1 не проксируются в `bitrix-sync`;
|
||||
- upstream down/timeout, failed reload, renewal rehearsal;
|
||||
- logs не содержат secrets/query tokens.
|
||||
|
||||
## 16. Definition of Done ВМ1
|
||||
|
||||
Дополнительно к arch-08 §13:
|
||||
|
||||
- routing matrix §2 и route precedence покрыты;
|
||||
- WS работает на `/api/v1/realtime`;
|
||||
- message timeout равен safety max + минимум 30 секунд;
|
||||
- limits, public cache, CSP/CORS/security headers проверены;
|
||||
- static production и dev proxy guard работают;
|
||||
- SMS callback allow-list/method/redaction проверены;
|
||||
- internal Safety/sync снаружи `404`.
|
||||
|
||||
## 17. TBD ВМ1
|
||||
|
||||
- N1: доверенные WAF CIDR перед ВМ1.
|
||||
- N3: нужен ли публичный health.
|
||||
- N4: точный CSP Expo build.
|
||||
- N5: Bitrix frame ancestor domains.
|
||||
- N6: финальные burst/connection limits зон ВМ1.
|
||||
|
||||
## 18. Ссылки
|
||||
|
||||
- Контракт: [`arch-08-nginx.md`](../../architectory/arch-08-nginx.md).
|
||||
- ВМ2: [`module-03-nginx-vm2.md`](../../VM2_services/documentation/module-03-nginx-vm2.md).
|
||||
- Указатель: [`module-03-nginx.md`](module-03-nginx.md).
|
||||
- API / local app / SMS / Keycloak: [`module-01-api-backend.md`](module-01-api-backend.md), [`module-06-bitrix-local-app.md`](module-06-bitrix-local-app.md), [`module-11-idgtl-sms.md`](module-11-idgtl-sms.md), [`module-08-keycloak.md`](module-08-keycloak.md).
|
||||
@@ -0,0 +1,164 @@
|
||||
# module-04-vm1. Redis ВМ1 HAN Chat
|
||||
|
||||
> Статус: целевая спецификация Redis на ВМ1.
|
||||
> Канонический контракт (ключи, TTL, Lua, AOF, ACL, eviction) — [`arch-09-redis.md`](../../architectory/arch-09-redis.md).
|
||||
> Обязательный host/container hardening baseline — [`arch-06-service-hosting-security.md`](../../architectory/arch-06-service-hosting-security.md).
|
||||
> Redis Safety ВМ2 — [`module-04-redis-vm2.md`](../../VM2_services/documentation/module-04-redis-vm2.md). Hostname Redis ВМ2 не используется.
|
||||
|
||||
## 1. Назначение и границы
|
||||
|
||||
Redis ВМ1 обслуживает `api-backend`: DB0 (rate/idempotency) и DB1 (realtime/coordination). OTP counters здесь нет. Message Safety cache/rate/wakeup — на ВМ2.
|
||||
|
||||
Legacy DB2 на ВМ1 существует только для test stub v1 до cutover и после него удаляется. Production v2 не хранит Safety task state в этом Redis.
|
||||
|
||||
Durable idempotency/outbox/checkpoint — [`module-01-api-backend.md`](module-01-api-backend.md).
|
||||
|
||||
## 2. URL и ACL
|
||||
|
||||
```text
|
||||
REDIS_URL=redis://api_backend:<secret>@redis:6379/0
|
||||
REDIS_REALTIME_URL=redis://api_backend:<secret>@redis:6379/1
|
||||
```
|
||||
|
||||
Только на ВМ1. `api_backend` ACL: prefixes `han:api:*`, `han:rt:*`, `han:coord:*`, нужные command categories. `SELECT` запрещён. `MESSAGE_SAFETY_REDIS_URL` на ВМ1 после cutover отсутствует.
|
||||
|
||||
## 3. DB0: API rate limiting
|
||||
|
||||
| Key | Тип/value | TTL |
|
||||
|---|---|---|
|
||||
| `han:api:rl:user:{user_id}:{route_hash}:{window}` | ZSET timestamps либо counter | window + jitter |
|
||||
| `han:api:rl:ip:{ip_hmac}:{route_hash}:{window}` | ZSET/counter | window + jitter |
|
||||
| `han:api:rl:dialog:{dialog_id}:message:{window}` | ZSET/counter | window + jitter |
|
||||
| `han:api:rl:service:{service}:{route_hash}:{window}` | counter/token bucket | window + jitter |
|
||||
| `han:api:jwks:negative:{kid_hash}` | marker отрицательного lookup | 30s |
|
||||
|
||||
Алгоритм — atomic Lua/function: удалить старые entries, посчитать, добавить текущий request, установить expiry, вернуть `allowed`, `remaining`, `retry_after_ms`, `reset_at`. Для fixed window `INCR` и первый `EXPIRE` выполняются в одном script, чтобы не оставить бессрочный key.
|
||||
|
||||
Clock — Redis `TIME`. Route labels — bounded allow-list/hash, исключающий cardinality attack.
|
||||
|
||||
## 4. DB0: idempotency
|
||||
|
||||
| Key | Значение | TTL |
|
||||
|---|---|---|
|
||||
| `han:api:idem:{scope}:{user_id}:{key_hmac}` | HASH/MessagePack: state, fingerprint, status, sanitized response, resource id, version | 24h |
|
||||
| `han:api:idemlock:{scope}:{user_id}:{key_hmac}` | random owner token | 30s + heartbeat |
|
||||
|
||||
State transitions `absent → in_progress → completed`; fingerprint mismatch возвращает conflict. Создание/сравнение/lock выполняется Lua. Unlock/extend разрешены только если owner token совпадает (`compare-and-delete/expire` script).
|
||||
|
||||
Response не содержит tokens, cookies, presigned URL или PII. Transient 503/504 не фиксируется как окончательный completed. PostgreSQL `idempotency_records` — durable fallback; Redis — ускоритель. При cache loss API читает durable row и прогревает key.
|
||||
|
||||
## 5. DB1: realtime
|
||||
|
||||
| Key/channel | Формат | TTL |
|
||||
|---|---|---|
|
||||
| `han:rt:conn:{connection_id}` | HASH: user_id, instance, last_seen, subscriptions_count | 90s |
|
||||
| `han:rt:user:{user_id}:connections` | ZSET connection_id → heartbeat | 120s |
|
||||
| `han:rt:dialog:{dialog_id}` | Pub/Sub channel | нет хранения |
|
||||
| `han:rt:user:{user_id}` | Pub/Sub channel | нет хранения |
|
||||
|
||||
Heartbeat атомарно обновляет connection и membership; cleanup удаляет stale ZSET entries bounded batches. Pub/Sub — at-most-once notification. Payload содержит только event id/type/entity UUID и DTO, допустимый realtime контрактом; DB остаётся source of truth. После reconnect frontend всегда делает REST reconciliation.
|
||||
|
||||
Redis Streams не используются как бизнес queue. Если позже понадобится durable realtime replay, сначала меняется архитектура и выбирается PostgreSQL outbox/event broker.
|
||||
|
||||
## 6. DB1: coordination locks
|
||||
|
||||
| Key | TTL |
|
||||
|---|---|
|
||||
| `han:coord:lock:safety-recovery:{task_id}` | 30s |
|
||||
| `han:coord:lock:delivery:{message_id}` | 30s |
|
||||
| `han:coord:lock:settings-refresh:{instance}` | 30s |
|
||||
| `han:settings:snapshot:{version}` | 5m |
|
||||
|
||||
Acquire: `SET key owner NX PX ttl`; extend/release — Lua compare owner. Worker обязан опираться также на PostgreSQL row lease/`FOR UPDATE SKIP LOCKED`; Redis lock — оптимизация, не единственная защита. Fencing token рекомендуется для внешнего side effect, а уникальные DB constraints/idempotency остаются финальной защитой.
|
||||
|
||||
Lock `safety-recovery` на ВМ1 относится к recovery caller/`api-backend`, не к Redis Safety ВМ2.
|
||||
|
||||
## 7. Legacy DB2 stub
|
||||
|
||||
До cutover test stub v1 может временно использовать DB2 ВМ1 для random task state. Этот namespace не используется production v2 и удаляется вместе со stub.
|
||||
|
||||
Если stub ещё жив: Safety task TTL в DB2 должен превышать poll/recovery budget; Lua `safety task get+increment poll` допустим только здесь. После cutover keys, ACL и DB2 удаляются.
|
||||
|
||||
## 8. Lua scripts ВМ1
|
||||
|
||||
Обязательные:
|
||||
|
||||
- rate-limit evaluate;
|
||||
- idempotency reserve/complete/conflict;
|
||||
- lock release/extend;
|
||||
- realtime heartbeat/cleanup membership.
|
||||
|
||||
Правила хранения/тестов — arch-09 §6.
|
||||
|
||||
## 9. Sizing ВМ1
|
||||
|
||||
```text
|
||||
DB0 rate = peak identities × routes × active windows × bytes/key
|
||||
DB0 idem = mutating requests/24h × avg sanitized record
|
||||
DB1 = peak connections × connection metadata + Pub/Sub buffers
|
||||
total × 1.5 allocator/fragmentation × 1.3 growth reserve
|
||||
```
|
||||
|
||||
При memory pressure eviction idempotency не создаёт дубль благодаря PostgreSQL fallback. Если instances разделят (R6), DB0 idempotency может получить `noeviction`.
|
||||
|
||||
## 10. Degraded behavior ВМ1
|
||||
|
||||
При Redis недоступен:
|
||||
|
||||
- message send, attachment init и download URL `api-backend` fail-closed `503`, если нельзя безопасно применить лимит/idempotency;
|
||||
- completed idempotency восстанавливается из PostgreSQL;
|
||||
- profile/history GET могут работать под edge limits;
|
||||
- public GET использует bounded local conservative limiter/cache;
|
||||
- realtime cross-instance publish/coordination деградирует; REST/polling остаётся source of truth;
|
||||
- internal inbox не теряется из-за Redis, так как durable receipt в PostgreSQL;
|
||||
- legacy stub v1 может стать недоступным при потере своей DB2 до cutover.
|
||||
|
||||
Production Safety на ВМ2 при этом продолжает PostgreSQL claim; это не runbook Redis ВМ1.
|
||||
|
||||
Restore: `api-backend` прогревает idempotency по durable records, realtime восстанавливается reconnect/polling.
|
||||
|
||||
## 11. Metrics ВМ1
|
||||
|
||||
Общие — arch-09 §14 и [`module-09-observability-vm1.md`](module-09-observability-vm1.md). Дополнительно: rate limit decisions, idempotency hit/conflict/fallback, Pub/Sub subscribers/output buffer.
|
||||
|
||||
## 12. Тесты ВМ1
|
||||
|
||||
- ACL: `api_backend` видит только свои prefix/commands; Safety prefixes отсутствуют;
|
||||
- порт 6379 недоступен с host/public network;
|
||||
- rate Lua concurrency и exact Retry-After;
|
||||
- idempotency same/different fingerprint, lock ownership, expiry, Redis loss + PostgreSQL fallback;
|
||||
- realtime heartbeat cleanup, duplicate disconnect, Pub/Sub loss + REST recovery;
|
||||
- locks expiry/late owner/fencing;
|
||||
- `NOSCRIPT` reload;
|
||||
- all application keys имеют TTL;
|
||||
- max value/invalid serialization;
|
||||
- restart with AOF/RDB, corrupted AOF rehearsal, empty restore;
|
||||
- memory pressure/eviction и no duplicate business side effect;
|
||||
- network partition, latency, reconnect backoff;
|
||||
- logs/metrics не содержат secret/value/PII;
|
||||
- после cutover DB2/stub keys отсутствуют.
|
||||
|
||||
## 13. Definition of Done ВМ1
|
||||
|
||||
Дополнительно к arch-09 §15:
|
||||
|
||||
- DB0/DB1 roles и prefixes реализованы;
|
||||
- idempotency 24h и durable fallback доказаны;
|
||||
- realtime loss восстанавливается REST;
|
||||
- health/degraded policies §10 реализованы в `api-backend`;
|
||||
- dashboards/alerts Redis ВМ1 готовы;
|
||||
- до cutover: Safety DB2 task TTL превышает poll/recovery budget, если stub ещё включён;
|
||||
- после cutover: DB2 и stub namespace удалены.
|
||||
|
||||
## 14. TBD ВМ1
|
||||
|
||||
- R1/R2: maxmemory и eviction после load profile ВМ1.
|
||||
- R6: момент разделения DB0/DB1 на instances.
|
||||
- R3: имена credential env — arch-04.
|
||||
|
||||
## 15. Ссылки
|
||||
|
||||
- Контракт: [`arch-09-redis.md`](../../architectory/arch-09-redis.md).
|
||||
- ВМ2: [`module-04-redis-vm2.md`](../../VM2_services/documentation/module-04-redis-vm2.md).
|
||||
- Указатель: [`module-04-redis.md`](module-04-redis.md).
|
||||
- API: [`module-01-api-backend.md`](module-01-api-backend.md).
|
||||
@@ -0,0 +1,643 @@
|
||||
# module-06. Проектная спецификация `bitrix-local-app`
|
||||
|
||||
> Статус: целевая production-спецификация MVP.
|
||||
> Портал: `han0107.bitrix24.ru`; connector: `han_mobile_app`; Open Line: `8`.
|
||||
> Источники: [`README.md`](README.md), [`arch-00-glossary.md`](../../architectory/arch-00-glossary.md), [`arch-01-system-architecture.md`](../../architectory/arch-01-system-architecture.md), [`arch-02-api-contracts.md`](../../architectory/arch-02-api-contracts.md), [`arch-03-docker-compose-blueprint.md`](../../architectory/arch-03-docker-compose-blueprint.md), [`arch-04-settings-and-content.md`](../../architectory/arch-04-settings-and-content.md), [`arch-05-agent-development-process.md`](../../architectory/arch-05-agent-development-process.md), [`arch-06-service-hosting-security.md`](../../architectory/arch-06-service-hosting-security.md), [`module-01-api-backend.md`](module-01-api-backend.md).
|
||||
|
||||
## 1. Назначение и приоритет
|
||||
|
||||
Сервис является локальным серверным приложением Bitrix24 и адаптером Open Lines. Он изолирует OAuth и протокол `imconnector` от `api-backend`, надёжно доставляет разрешённые сообщения клиента оператору и события оператора обратно в HAN.
|
||||
|
||||
При конфликте действуют приоритеты `README.md`. Настоящий документ детализирует существующие контракты, но не меняет их. Любой новый внешний/internal endpoint сначала фиксируется в `arch-02`.
|
||||
|
||||
Канонический URL канала:
|
||||
|
||||
```text
|
||||
https://han0107.bitrix24.ru/contact_center/connector/?ID=han_mobile_app&LINE=8
|
||||
```
|
||||
|
||||
## 2. Ответственность и границы
|
||||
|
||||
Сервис отвечает за:
|
||||
|
||||
- install/lifecycle локального приложения и OAuth Bitrix24;
|
||||
- шифрованное хранение и безопасное обновление portal tokens;
|
||||
- `imconnector.register`, `imconnector.activate`, `event.bind`, status/retry setup;
|
||||
- публичный приём `ONAPP*` и `ONIMCONNECTOR*`;
|
||||
- tolerant parsing JSON/form/multipart и PHP-style массивов;
|
||||
- проверку callback, нормализацию, durable inbox, retry и DLQ;
|
||||
- `dialog_sessions`: `external_chat_id` (= `dialog_id`) ↔ `bitrix_chat_id` ↔ `session_id`;
|
||||
- идемпотентный outbound `api-backend` → `imconnector.send.messages`;
|
||||
- forward входящих сообщений/файлов и `dialog.closed` в `api-backend`;
|
||||
- `imconnector.send.status.delivery` только после durable ack API;
|
||||
- health, telemetry, audit технических переходов.
|
||||
|
||||
Сервис не отвечает за:
|
||||
|
||||
- JWT/пользовательскую авторизацию, Message Safety и App DB;
|
||||
- хранение истории HAN, realtime и S3;
|
||||
- CRM Contact/profile sync — зона `bitrix-sync` на ВМ2, не этого модуля;
|
||||
- изменение `Dialog.status` в `han_app`;
|
||||
- публикацию internal API на edge.
|
||||
|
||||
## 3. Технологический профиль и структура
|
||||
|
||||
- Python 3.12+, FastAPI, Pydantic v2, Uvicorn.
|
||||
- SQLAlchemy 2 async + `asyncpg`; Alembic.
|
||||
- Один долгоживущий `httpx.AsyncClient` с bounded pool.
|
||||
- PostgreSQL managed, только схема `bitrix_local`.
|
||||
- OpenTelemetry и JSON logging.
|
||||
|
||||
```text
|
||||
bitrix-local-app/
|
||||
app/
|
||||
main.py
|
||||
settings.py
|
||||
api/{public_bitrix,internal_openlines,health,schemas,errors,auth}.py
|
||||
application/{install,setup,outbound,inbound,forward,delivery_ack}.py
|
||||
domain/{entities,enums,policies}.py
|
||||
infrastructure/
|
||||
bitrix/{client,oauth,connector,parser,normalizer}.py
|
||||
db/{models,repositories,uow}.py
|
||||
crypto/{token_cipher,keyring}.py
|
||||
resilience/{retry,circuit,rate_limit}.py
|
||||
observability/{logging,metrics,tracing}.py
|
||||
workers/{inbox_forward,outbox_delivery,setup_reconcile}.py
|
||||
alembic/
|
||||
tests/{unit,integration,contract,e2e}/
|
||||
openapi.yaml
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
```
|
||||
|
||||
Router только валидирует/аутентифицирует; use case задаёт транзакцию; Bitrix adapter скрывает внешний payload.
|
||||
|
||||
## 4. Публичные endpoint
|
||||
|
||||
Сервис предоставляет следующие endpoint. Корневой nginx публикует первые три; health остаются internal по умолчанию и открываются exact-route только при явно выбранной ops/monitoring policy:
|
||||
|
||||
| Method | Path | Назначение |
|
||||
|---|---|---|
|
||||
| GET/POST | `/bitrix/handler` | probe и callbacks `ONAPP*`/`ONIMCONNECTOR*` |
|
||||
| GET/POST | `/bitrix/install` | install callback/probe |
|
||||
| GET | `/bitrix/placement` | минимальный HTML placement |
|
||||
| GET | `/health/live` | liveness; internal по умолчанию |
|
||||
| GET | `/health/ready` | readiness; internal по умолчанию |
|
||||
|
||||
`GET handler/install` возвращает безопасный `200`, не раскрывая OAuth/setup. POST принимает только bounded body и разрешённые content types. Placement имеет отдельный CSP `frame-ancestors` с точным allow-list Bitrix24.
|
||||
|
||||
Internal `/internal/openlines/v1/*` доступны только по Docker/VPC network и **не маршрутизируются nginx наружу**.
|
||||
|
||||
## 5. Internal Open Lines API
|
||||
|
||||
Все вызовы требуют:
|
||||
|
||||
```text
|
||||
Authorization: Bearer ${BITRIX_INTERNAL_API_TOKEN}
|
||||
X-Request-ID: UUID/ULID
|
||||
traceparent: optional W3C
|
||||
```
|
||||
|
||||
Caller `api-backend` передаёт `BITRIX_LOCAL_APP_INTERNAL_TOKEN`, значение которого равно `BITRIX_INTERNAL_API_TOKEN`. Сравнение constant-time.
|
||||
|
||||
### 5.1. `POST /internal/openlines/v1/messages`
|
||||
|
||||
Одна операция — одно сообщение MVP. `Idempotency-Key` обязателен и равен `message_id`.
|
||||
|
||||
```json
|
||||
{
|
||||
"message_id": "uuid",
|
||||
"external_chat_id": "uuid",
|
||||
"occurred_at": "2026-07-10T09:00:00Z",
|
||||
"user": {
|
||||
"id": "uuid",
|
||||
"display_name": "Клиент HAN"
|
||||
},
|
||||
"message": {
|
||||
"content_kind": "text",
|
||||
"text": "Здравствуйте",
|
||||
"files": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Файловый вариант:
|
||||
|
||||
```json
|
||||
{
|
||||
"message_id": "uuid",
|
||||
"external_chat_id": "uuid",
|
||||
"occurred_at": "2026-07-10T09:00:00Z",
|
||||
"user": {"id": "uuid", "display_name": "Клиент HAN"},
|
||||
"message": {
|
||||
"content_kind": "file",
|
||||
"text": "",
|
||||
"files": [{
|
||||
"attachment_id": "uuid",
|
||||
"name": "document.pdf",
|
||||
"mime_type": "application/pdf",
|
||||
"size_bytes": 12345,
|
||||
"download_url": "https://short-lived-signed-url"
|
||||
}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Правила:
|
||||
|
||||
- `external_chat_id` строго UUID и равен App `dialog_id`;
|
||||
- `text` xor один file; unknown fields запрещены;
|
||||
- signed URL не сохраняется в обычные логи и редактируется в durable payload по истечении необходимости;
|
||||
- PII профиля не требуется; телефон/email не передаются;
|
||||
- fingerprint строится по стабильным полям без signed query;
|
||||
- тот же key/fingerprint возвращает прежний результат;
|
||||
- тот же key с иным fingerprint → `409 idempotency_key_reused`.
|
||||
|
||||
Успех `200/201`:
|
||||
|
||||
```json
|
||||
{
|
||||
"status": "delivered",
|
||||
"message_id": "uuid",
|
||||
"external_chat_id": "uuid",
|
||||
"bitrix_message_id": "string-or-null",
|
||||
"dialog_session": {
|
||||
"bitrix_chat_id": 1807,
|
||||
"session_id": "sess-42"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`api-backend` выставляет `delivery_status=delivered` только после этого ответа/duplicate result. `202` не считается финальной доставкой в основном синхронном flow.
|
||||
|
||||
### 5.2. `GET /internal/openlines/v1/dialogs/{external_chat_id}`
|
||||
|
||||
Возвращает active mapping:
|
||||
|
||||
```json
|
||||
{
|
||||
"external_chat_id": "uuid",
|
||||
"bitrix_chat_id": 1807,
|
||||
"session_id": "sess-42",
|
||||
"status": "open",
|
||||
"updated_at": "2026-07-10T09:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
`404` — mapping отсутствует/soft-deleted. Пользовательская PII не возвращается.
|
||||
|
||||
### 5.3. `GET /internal/openlines/v1/status`
|
||||
|
||||
Возвращает безопасный статус portal OAuth, connector registration/activation, event bindings, worker backlog и circuit state; токены и raw Bitrix response исключены. `200` может иметь `status=degraded`; `503` — нет usable OAuth/БД.
|
||||
|
||||
### 5.4. `POST /internal/openlines/v1/setup/retry`
|
||||
|
||||
Идемпотентно запускает reconcile `register → activate line 8 → event.bind`. Одновременно разрешён один run по portal advisory lock/DB lease. Ответ содержит per-step status. Endpoint ops-only с тем же Bearer и дополнительным service rate limit.
|
||||
|
||||
## 6. Outbound: HAN → Open Lines
|
||||
|
||||
1. Аутентифицировать caller и зарезервировать `outbound_messages` по `message_id`.
|
||||
2. При completed вернуть сохранённый sanitized result.
|
||||
3. Собрать `MESSAGES` Bitrix: `user.id`, `message.id/date/text/files`, `chat.id`.
|
||||
4. Вызвать `imconnector.send.messages` с `CONNECTOR=han_mobile_app`, `LINE=8`.
|
||||
5. Извлечь `CHAT_ID`, session `ID`, Bitrix message id из допускаемых вариантов ответа.
|
||||
6. В одной транзакции upsert `dialog_sessions`, записать result, status `delivered`.
|
||||
7. Вернуть ack API.
|
||||
|
||||
Ambiguous timeout не разрешает слепой повтор без idempotency/reconciliation. Worker сверяет локальный state/session и повторяет только если метод/Bitrix semantics не создадут дубль; иначе `manual_review`/DLQ. Automatic retry допустим для connect failure до отправки, explicit rate-limit и известных transient ошибок.
|
||||
|
||||
## 7. Install, OAuth и setup
|
||||
|
||||
### 7.1. Install
|
||||
|
||||
POST install/handler с `ONAPPINSTALL`:
|
||||
|
||||
1. parse и strict validate `auth`;
|
||||
2. проверить expected portal domain `han0107.bitrix24.ru`, HTTPS `client_endpoint`, `member_id`;
|
||||
3. сохранить tokens до внешнего setup;
|
||||
4. создать `install_runs`;
|
||||
5. выполнить setup идемпотентно;
|
||||
6. вернуть `installed` либо `installed_with_errors`; partial setup не теряет OAuth.
|
||||
|
||||
`ONAPPUNINSTALL` помечает portal installation `uninstalled`, запрещает outbound и планирует revocation/retention. В отличие от прототипа, usable tokens не остаются active.
|
||||
|
||||
### 7.2. Token storage и encryption
|
||||
|
||||
- `access_token`, `refresh_token`, `application_token` шифруются application-level envelope encryption (AES-256-GCM или эквивалент AEAD).
|
||||
- Master key только secret env/mount: `BITRIX_TOKEN_ENCRYPTION_KEY`; в БД — `ciphertext`, `nonce`, `key_version`.
|
||||
- AAD связывает ciphertext с `member_id`, portal domain и token type.
|
||||
- Поддерживается keyring current+previous для rolling rotation и re-encryption job.
|
||||
- Токены никогда не логируются, не экспортируются в metrics/traces и не возвращаются API.
|
||||
- DB/TLS и backup encryption остаются дополнительными слоями.
|
||||
|
||||
### 7.3. Refresh
|
||||
|
||||
- refresh заранее, когда `expires_at - now <= skew` (ориентир 60 с);
|
||||
- single-flight на portal через DB advisory lock/lease;
|
||||
- POST только на allow-listed `https://oauth.bitrix.info/oauth/token/`;
|
||||
- refresh token rotation сохраняется атомарно;
|
||||
- при `expired_token` — максимум один refresh+replay;
|
||||
- `invalid_grant` переводит installation в `reauth_required`, readiness degraded, outbound fail-closed;
|
||||
- timeout/retry bounded; secret/client credentials не попадают в exception text.
|
||||
|
||||
### 7.4. Connector setup
|
||||
|
||||
Используемые методы:
|
||||
|
||||
- `imconnector.register`: `ID=han_mobile_app`, name/icon, `{BITRIX_PUBLIC_BASE_URL}/placement`;
|
||||
- `imconnector.activate`: connector, `LINE=8`, `ACTIVE=1`;
|
||||
- `event.bind`: `OnImConnectorMessageAdd`, `OnImConnectorDialogStart`, `OnImConnectorDialogFinish`;
|
||||
- `imconnector.status` для reconcile/readiness;
|
||||
- `imconnector.send.messages`;
|
||||
- `imconnector.send.status.delivery`.
|
||||
|
||||
Каждый setup step хранит desired/observed state, attempts и safe error. Повтор не создаёт duplicate binding; если API Bitrix не гарантирует это, сначала проверяется status/list binding.
|
||||
|
||||
## 8. Webhook parsing и безопасность
|
||||
|
||||
Поддерживаются JSON, form-urlencoded, multipart и PHP-style keys/числовые dict. Parser:
|
||||
|
||||
- ограничивает body/header/field count, nesting, array/message count и строковые длины;
|
||||
- NFKC не применяется к opaque ids/tokens;
|
||||
- не сохраняет неизвестный raw body без redaction;
|
||||
- принимает только известные events; прочие безопасно `ignored` с metric;
|
||||
- проверяет connector `han_mobile_app`, line `8`, expected member/domain;
|
||||
- проверяет `auth.application_token` constant-time против расшифрованного portal token и/или `BITRIX_APPLICATION_TOKEN`;
|
||||
- не доверяет IP как единственной аутентификации, но nginx edge limit/allow policy дополняет token;
|
||||
- всегда отвечает достаточно быстро после durable insert, чтобы Bitrix retry не создал storm.
|
||||
|
||||
Невалидный security token не маскируется как успешная обработка в telemetry: внешний ответ может быть нейтральным, но audit/metric фиксируют reject. Callback secret и payload не логируются.
|
||||
|
||||
## 9. Нормализация и inbox-контракт API
|
||||
|
||||
Owned receiver находится в `api-backend`:
|
||||
|
||||
```text
|
||||
POST http://api-backend:8000/internal/openlines/v1/inbox
|
||||
Authorization: Bearer ${BITRIX_API_FORWARD_TOKEN}
|
||||
```
|
||||
|
||||
Значение равно `BITRIX_API_INBOX_TOKEN` на API.
|
||||
|
||||
`message.new`:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "stable-opaque",
|
||||
"event_type": "message.new",
|
||||
"external_chat_id": "uuid",
|
||||
"bitrix_message_id": "string",
|
||||
"occurred_at": "2026-07-10T09:00:00Z",
|
||||
"message": {
|
||||
"text": "Ответ оператора или пустая строка",
|
||||
"files": [{
|
||||
"name": "scan.pdf",
|
||||
"mime_type": "application/pdf",
|
||||
"size_bytes": 12345,
|
||||
"download_url": "https://..."
|
||||
}]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
`dialog.closed`:
|
||||
|
||||
```json
|
||||
{
|
||||
"event_id": "stable-opaque",
|
||||
"event_type": "dialog.closed",
|
||||
"external_chat_id": "uuid",
|
||||
"bitrix_message_id": null,
|
||||
"occurred_at": "2026-07-10T09:00:00Z",
|
||||
"message": null
|
||||
}
|
||||
```
|
||||
|
||||
`event_id` обязателен согласно допущению module-01 A2; предпочтительно используется Bitrix event/message/session id, иначе versioned SHA-256 стабильных полей. `message.new` дополнительно unique по `(external_chat_id, bitrix_message_id)`.
|
||||
|
||||
Пустые text+files отклоняются. URL файла передаётся только API; API защищается от SSRF, скачивает с лимитами и сохраняет в S3-data. Local app не скачивает/не хранит файл.
|
||||
|
||||
## 10. Delivery ack входящего события
|
||||
|
||||
Критический инвариант:
|
||||
|
||||
```text
|
||||
Bitrix webhook → durable inbox → API 201/duplicate 200/204
|
||||
→ только затем imconnector.send.status.delivery
|
||||
```
|
||||
|
||||
Ack запрещён при timeout/5xx/неприменённом `404` API. Если API commit успешен, но HTTP response потерян, повтор forward получает duplicate ack, после чего delivery status безопасно отправляется. Ack имеет собственный outbox/retry. Ошибка ack не повторяет application события в API.
|
||||
|
||||
## 11. Inbox, outbox, DLQ и backoff
|
||||
|
||||
### Inbox
|
||||
|
||||
Webhook transaction сохраняет event, normalized payload/fingerprint и initial status. Worker использует `FOR UPDATE SKIP LOCKED`, lease и heartbeat.
|
||||
|
||||
States:
|
||||
|
||||
```text
|
||||
received → forwarding → api_acked → ack_pending → completed
|
||||
↘ retry
|
||||
received/forwarding/retry → dead_letter
|
||||
```
|
||||
|
||||
### Outbound messages
|
||||
|
||||
States: `received | sending | delivered | retry | ambiguous | dead_letter`. Unique `message_id`; payload versioned; signed URLs не должны переживать TTL — при retry API обязан дать актуальный URL по согласованному recovery контракту либо операция уходит в reconciliation.
|
||||
|
||||
### Backoff
|
||||
|
||||
- exponential full jitter, ориентир 1, 2, 4, 8… max 300 с;
|
||||
- учитывать `Retry-After` Bitrix/API;
|
||||
- max attempts и max age — infra env;
|
||||
- permanent 4xx/schema/auth не повторяются автоматически;
|
||||
- DLQ содержит safe error code, не token/raw PII;
|
||||
- replay — ops runbook/CLI с audit, не публичный endpoint MVP.
|
||||
|
||||
## 12. PostgreSQL `bitrix_local`
|
||||
|
||||
Общие правила: UUID/timestamptz, schema-qualified DDL, soft delete для прикладных records, технические queue rows архивируются/удаляются по retention. Runtime role `bitrix_local_app`; отдельная migration role. Прямого доступа к `han_app` нет.
|
||||
|
||||
### 12.1. `portal_installations`
|
||||
|
||||
`id`, `member_id` unique, `domain`, `client_endpoint`, encrypted token columns, `expires_at`, `scope`, `key_version`, `install_status`, `setup_status`, `last_refresh_at`, `last_error_code`, common fields.
|
||||
|
||||
Indexes: unique active `member_id`; unique active normalized domain. MVP разрешает только один active expected portal.
|
||||
|
||||
### 12.2. `connector_setup`
|
||||
|
||||
`id`, `portal_id`, `connector_id`, `line_id`, `registered`, `activated`, `bindings_json`, `desired_version`, `observed_at`, `next_retry_at`, `attempt_count`, lease/error fields. Unique `(portal_id, connector_id, line_id)`.
|
||||
|
||||
### 12.3. `dialog_sessions`
|
||||
|
||||
`id`, `external_chat_id uuid`, `bitrix_chat_id bigint NULL`, `session_id varchar NULL`, `portal_id`, `status open|closed`, common fields.
|
||||
|
||||
Indexes:
|
||||
|
||||
- unique active `external_chat_id`;
|
||||
- index `(bitrix_chat_id) WHERE record_status='A'`;
|
||||
- index `(session_id)`;
|
||||
- `(status, updated_at)`.
|
||||
|
||||
Связь с user_id не нужна: идентичность принадлежит App DB.
|
||||
|
||||
### 12.4. `inbox_events`
|
||||
|
||||
`id`, `event_id`, `event_type`, `external_chat_id`, `bitrix_message_id`, `payload_fingerprint`, `normalized_json`, `status`, attempts/next/lease, `api_ack_status`, `delivery_ack_status`, safe error, timestamps.
|
||||
|
||||
Unique `event_id`; unique partial `(external_chat_id, bitrix_message_id)`; worker index `(status,next_attempt_at)`.
|
||||
|
||||
Raw payload хранится только если необходим для forensic, зашифрован/редактирован и с коротким retention; preferred — минимальный normalized payload.
|
||||
|
||||
### 12.5. `outbound_messages`
|
||||
|
||||
`id`, `message_id uuid unique`, `external_chat_id uuid`, `request_fingerprint`, `payload_json`, `status`, `bitrix_message_id`, `response_json`, attempts/lease/error/timestamps. Index worker `(status,next_attempt_at)`.
|
||||
|
||||
### 12.6. `delivery_ack_outbox`
|
||||
|
||||
Unique inbox event; status/attempt/next/lease, minimal Bitrix delivery DTO. Не содержит API token.
|
||||
|
||||
### 12.7. `install_runs` и `audit_events`
|
||||
|
||||
Append-only setup step/results и security/ops actions без tokens/raw payload. BRIN/date indexes при росте.
|
||||
|
||||
## 13. Alembic и транзакции
|
||||
|
||||
- Никакого `CREATE TABLE IF NOT EXISTS` при startup.
|
||||
- `alembic upgrade head` — отдельный deploy step.
|
||||
- Expand/migrate/contract, forward-fix; destructive migration только после backup/согласования.
|
||||
- Smoke upgrade пустой и предыдущей версии.
|
||||
- Внешний HTTP не выполняется внутри DB transaction.
|
||||
- Claim → commit lease → external call → finalize under row lock.
|
||||
- Setup/refresh используют portal-scoped lock.
|
||||
|
||||
## 14. Bitrix rate limits и resilience
|
||||
|
||||
- Ограничить concurrency (начально 2 на portal) и локальный token bucket.
|
||||
- Разделить quotas setup, outbound, ack/status.
|
||||
- На Bitrix rate-limit учитывать headers/body code и `Retry-After`.
|
||||
- Circuit breakers отдельно: OAuth endpoint, portal REST, API forward.
|
||||
- Timeout: connect 3 с, обычный REST/read 10–15 с, OAuth 10 с; значения infra env.
|
||||
- 4xx domain/schema не открывает circuit; 429/transient/timeout учитываются по policy.
|
||||
- Half-open имеет один probe; retry storms предотвращаются jitter/queue concurrency.
|
||||
- Один `httpx` pool; TLS verify обязателен; redirects для token/REST запрещены либо allow-listed.
|
||||
|
||||
## 15. Health
|
||||
|
||||
`GET /health/live`: только процесс/event loop, `200`.
|
||||
|
||||
`GET /health/ready` с коротким timeout проверяет:
|
||||
|
||||
- PostgreSQL `SELECT 1`, expected Alembic revision;
|
||||
- usable active portal OAuth либо сообщает `portal_not_installed`;
|
||||
- connector desired state register+line 8+bindings;
|
||||
- workers heartbeat/lease;
|
||||
- backlog age/DLQ thresholds;
|
||||
- forward URL/token configured;
|
||||
- circuit state.
|
||||
|
||||
DB/schema failure → `503`. До install сервис может быть `200 degraded` или `503 portal_not_installed` согласно ops policy; для production traffic выбран `503`, liveness остаётся 200. Ответ не делает внешних Bitrix calls на каждый probe — использует свежий cached observed state.
|
||||
|
||||
## 16. Observability
|
||||
|
||||
JSON fields: timestamp, level, `service.name=bitrix-local-app`, module, event, request_id, trace/span id, route, event_type, portal hash/member hash, message/event id hash, attempt, queue age, dependency, status/error code, duration.
|
||||
|
||||
Не логируются OAuth/application/service tokens, Authorization, raw callback, message text, phone/email/name, filenames с PII, file/download URL, response body Bitrix.
|
||||
|
||||
Metrics:
|
||||
|
||||
- HTTP latency/status;
|
||||
- callback accepted/rejected/duplicate;
|
||||
- parser variants/errors;
|
||||
- OAuth refresh success/failure/time-to-expiry;
|
||||
- connector setup desired/observed;
|
||||
- outbound success/retry/ambiguous/DLQ;
|
||||
- inbox depth/oldest age/retry/DLQ;
|
||||
- API forward and delivery ack;
|
||||
- Bitrix REST latency/rate-limit/circuit;
|
||||
- DB pool/lease/readiness.
|
||||
|
||||
IDs не metric labels. Trace context передаётся в API; внешний Bitrix call — child span без token/query.
|
||||
|
||||
## 17. Security
|
||||
|
||||
- TLS boundary — root nginx; internal HTTP только backend network.
|
||||
- Internal endpoints не edge-routed, Bearer token обязателен.
|
||||
- Exact host/domain/connector/line allow-list.
|
||||
- `client_endpoint` из callback валидируется против portal allow-list для защиты SSRF.
|
||||
- Strict DTO/body limits; parameterized SQL.
|
||||
- OAuth encryption+key rotation; secrets только env/secret mount.
|
||||
- Non-root, read-only root fs, tmpfs, dropped capabilities.
|
||||
- OpenAPI UI off production; committed OpenAPI 3.1 обязателен.
|
||||
- CORS не нужен; placement не получает secrets.
|
||||
- Error envelope не раскрывает host/stack/raw dependency response.
|
||||
- Dependency/image scanning и pinned lock/image.
|
||||
|
||||
## 18. Env
|
||||
|
||||
Канонические из arch-04:
|
||||
|
||||
```text
|
||||
BITRIX_DATABASE_URL
|
||||
BITRIX_CLIENT_ID
|
||||
BITRIX_CLIENT_SECRET
|
||||
BITRIX_CONNECTOR_ID=han_mobile_app
|
||||
BITRIX_CONNECTOR_NAME=HAN Mobile App
|
||||
BITRIX_OPEN_LINE_ID=8
|
||||
BITRIX_PUBLIC_BASE_URL=https://tohin.ru/bitrix
|
||||
BITRIX_APPLICATION_TOKEN
|
||||
BITRIX_INTERNAL_API_TOKEN
|
||||
BITRIX_API_FORWARD_URL=http://api-backend:8000/internal/openlines/v1/inbox
|
||||
BITRIX_API_FORWARD_TOKEN
|
||||
OTEL_EXPORTER_OTLP_ENDPOINT
|
||||
APP_ENV
|
||||
LOG_LEVEL
|
||||
```
|
||||
|
||||
Предлагаемые infra env, которые до реализации нужно добавить в arch-04:
|
||||
|
||||
```text
|
||||
BITRIX_TOKEN_ENCRYPTION_KEY
|
||||
BITRIX_TOKEN_ENCRYPTION_KEY_VERSION
|
||||
BITRIX_HTTP_TIMEOUT_SEC=15
|
||||
BITRIX_HTTP_MAX_CONCURRENCY=2
|
||||
BITRIX_RETRY_MAX_ATTEMPTS=10
|
||||
BITRIX_RETRY_MAX_DELAY_SEC=300
|
||||
BITRIX_INBOX_RETENTION_DAYS
|
||||
BITRIX_DLQ_ALERT_AGE_SEC
|
||||
```
|
||||
|
||||
Business settings здесь не хранятся. Legacy `BITRIX_SYNC_FORWARD_*` удаляются после migration window и не являются каноническими.
|
||||
|
||||
## 19. Docker и deployment
|
||||
|
||||
- service `bitrix-local-app`, `expose: 8080`, без `ports`;
|
||||
- networks `backend`,`observability`; root nginx отдельно;
|
||||
- managed PostgreSQL вне compose, TLS обязательно;
|
||||
- нет SQLite volume production;
|
||||
- healthcheck `/health/live`; readiness — orchestration/monitoring;
|
||||
- migration one-shot job до rollout;
|
||||
- graceful shutdown: stop claims, finish in-flight до grace, release leases, close pools;
|
||||
- stateless filesystem.
|
||||
|
||||
Прототипные `deploy/nginx/*`, certbot/SSL scripts и отдельный compose-stack не переносятся: сертификат и routing принадлежат корневому nginx/compose.
|
||||
|
||||
## 20. Что переиспользуется из прототипа
|
||||
|
||||
Концептуально переиспользуются и покрываются новыми тестами:
|
||||
|
||||
- tolerant parser JSON/form/multipart и `auth[...]`;
|
||||
- преобразование PHP-style `MESSAGES` list/dict;
|
||||
- разделение client/connector/handler/normalizer/session store;
|
||||
- setup `register → activate → bind`;
|
||||
- refresh до expiry и один replay `expired_token`;
|
||||
- extraction session `CHAT_ID`/`ID`;
|
||||
- `application_token` и Bearer constant-time compare;
|
||||
- deterministic idempotency event key как основа fingerprint;
|
||||
- `dialog_sessions` и enrichment;
|
||||
- отключение docs production;
|
||||
- различение Open Lines и CRM sync.
|
||||
|
||||
Обязательно меняется:
|
||||
|
||||
- `/internal/v1/*` → только `/internal/openlines/v1/*`;
|
||||
- forward envelope → канонический `POST /internal/openlines/v1/inbox`;
|
||||
- immediate delivery ack до API запрещён;
|
||||
- single-attempt forward → durable worker/backoff/DLQ;
|
||||
- plaintext tokens → AEAD encryption/key rotation;
|
||||
- sync psycopg2/thread lock → async pool/transactions/leases;
|
||||
- SQLite и DDL-on-start не используются production;
|
||||
- отдельный nginx/certbot/compose удаляются из production topology;
|
||||
- `/bitrix-internal/` edge alias не нужен: internal API не публикуется;
|
||||
- raw payload/error storage/logging минимизируется;
|
||||
- uninstall деактивирует installation;
|
||||
- Alembic и OpenAPI 3.1 обязательны.
|
||||
|
||||
## 21. Тестовая матрица
|
||||
|
||||
### Unit
|
||||
|
||||
- parser variants/nesting/limits;
|
||||
- normalizer message/file/start/finish;
|
||||
- token encryption/decryption/AAD/rotation;
|
||||
- fingerprint/idempotency;
|
||||
- session extraction variants;
|
||||
- retry classification/backoff/jitter;
|
||||
- URL/portal validation and redaction.
|
||||
|
||||
### Integration
|
||||
|
||||
- Alembic empty/upgrade;
|
||||
- concurrent duplicate webhook;
|
||||
- outbound same/different fingerprint;
|
||||
- `SKIP LOCKED`, lease expiry, crash recovery;
|
||||
- refresh single-flight;
|
||||
- setup reconcile;
|
||||
- DB constraints/soft delete;
|
||||
- no DDL at startup.
|
||||
|
||||
### Contract
|
||||
|
||||
- all public/internal schemas in committed OpenAPI;
|
||||
- tokens and paired names with module-01;
|
||||
- `message.new`/`dialog.closed` inbox;
|
||||
- API 201/duplicate before delivery ack;
|
||||
- request-id/trace propagation;
|
||||
- Bitrix fixture payloads and response variants.
|
||||
|
||||
### E2E/failure
|
||||
|
||||
- install portal → connector visible on line 8;
|
||||
- text/file send and mapping;
|
||||
- operator text/file → API → ack;
|
||||
- duplicate/reordered callbacks;
|
||||
- API outage, Bitrix 429/5xx/timeout, OAuth expiry/invalid_grant;
|
||||
- crash at every checkpoint;
|
||||
- DLQ/replay;
|
||||
- circuit half-open;
|
||||
- logs contain no secrets/PII/URLs.
|
||||
|
||||
## 22. Definition of Done
|
||||
|
||||
- portal/connector/line fixed and validated;
|
||||
- public and internal paths exactly match arch-02;
|
||||
- internal API is unreachable from public edge;
|
||||
- install/OAuth encryption/refresh/setup reconciliation complete;
|
||||
- outbound idempotency survives crash/ambiguous response;
|
||||
- inbox retry/DLQ and ack-after-API invariant proven;
|
||||
- operator text/files and `dialog.closed` contract-tested;
|
||||
- `bitrix_local` schema, indexes and Alembic migrations tested;
|
||||
- rate-limit/circuit/timeout/graceful shutdown implemented;
|
||||
- health/metrics/traces/JSON logs secure;
|
||||
- OpenAPI 3.1 committed and parity checked;
|
||||
- root Compose starts non-root container without published port;
|
||||
- runbooks: reinstall, key rotation, OAuth failure, setup retry, backlog/DLQ, migration/rollback;
|
||||
- no SQLite, startup DDL or separate production nginx.
|
||||
|
||||
## 23. Решения, допущения и TBD
|
||||
|
||||
**Решения:**
|
||||
|
||||
- B1: canonical internal prefix только `/internal/openlines/v1`.
|
||||
- B2: delivery ack только после API commit/duplicate ack.
|
||||
- B3: OAuth tokens шифруются application-level AEAD.
|
||||
- B4: durable PostgreSQL inbox/outbox/DLQ; Redis не требуется.
|
||||
- B5: `external_chat_id=dialog_id`; local app не хранит user profile.
|
||||
- B6: production только managed PostgreSQL + Alembic.
|
||||
|
||||
**Допущения:**
|
||||
|
||||
- A1: один active portal `han0107.bitrix24.ru` в MVP.
|
||||
- A2: Bitrix fixtures позволят стабильно извлечь event/message/session ids; иначе versioned fingerprint.
|
||||
- A3: API может повторно выдать актуальный signed file URL при delayed outbound recovery; exact handshake требуется в contract test.
|
||||
|
||||
**TBD:**
|
||||
|
||||
- B-TBD1: точные Bitrix REST quotas/headers и safe retry матрица по официальной документации/portal tests.
|
||||
- B-TBD2: окончательный outbound DTO user display name и file fields в OpenAPI.
|
||||
- B-TBD3: exact stable `event_id` для dialog events (согласовать с module-01 TBD-3).
|
||||
- B-TBD4: retention/RPO/RTO и DLQ replay authorization.
|
||||
- B-TBD5: encryption key source/rotation runbook до production.
|
||||
- B-TBD6: точный CSP `frame-ancestors` placement.
|
||||
- B7: operator files считаются trusted-channel данными MVP; api-backend применяет MIME/size/audit без Message Safety/AV, residual malware risk принят.
|
||||
@@ -0,0 +1,740 @@
|
||||
# module-08. Проектная спецификация `keycloak`
|
||||
|
||||
> Статус: целевая 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`](../../architectory/arch-00-glossary.md), [`arch-01-system-architecture.md`](../../architectory/arch-01-system-architecture.md), [`arch-02-api-contracts.md`](../../architectory/arch-02-api-contracts.md), [`arch-03-docker-compose-blueprint.md`](../../architectory/arch-03-docker-compose-blueprint.md), [`arch-04-settings-and-content.md`](../../architectory/arch-04-settings-and-content.md), [`arch-05-agent-development-process.md`](../../architectory/arch-05-agent-development-process.md), [`arch-06-service-hosting-security.md`](../../architectory/arch-06-service-hosting-security.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-vm1.md`](module-03-nginx-vm1.md).
|
||||
|
||||
## 1. Назначение и границы
|
||||
|
||||
Keycloak — единственный IdP HAN Chat. MVP предоставляет регистрацию/вход только по подтверждённому номеру телефона и OTP, OIDC tokens, refresh/logout, discovery/JWKS и защиту auth flow.
|
||||
|
||||
Keycloak отвечает за:
|
||||
|
||||
- realm, users, credentials, auth sessions и token lifecycle;
|
||||
- Authorization Code Flow with PKCE для Expo web/iOS/Android;
|
||||
- нормализацию/уникальность телефона и claims;
|
||||
- OTP authenticator/SPI, генерацию и локальную проверку OTP, challenge lifecycle, продуктовые limits и verify audit;
|
||||
- brute-force, sessions, logout/revocation;
|
||||
- keys/JWKS rotation и health/metrics.
|
||||
|
||||
Не отвечает за:
|
||||
|
||||
- `api-backend` bootstrap/consents/UserIdentity;
|
||||
- App DB/profile/chat и CRM sync;
|
||||
- API service-to-service tokens;
|
||||
- пользовательскую UX-сессию;
|
||||
- шаблоны, отправку и provider delivery journal (это `sms-service`);
|
||||
- прямой вызов i-Digital Direct и обработку delivery callback.
|
||||
|
||||
Mock code является секретом окружения, не контентом UI и не логируется. В real mode Keycloak вызывает только закрытый durable-order API `sms-service`; API Direct Verifier не используется.
|
||||
|
||||
## 2. Топология и публичный URL
|
||||
|
||||
Keycloak работает за единственным root nginx:
|
||||
|
||||
```text
|
||||
Client HTTPS https://tohin.ru/auth/*
|
||||
→ nginx TLS termination
|
||||
→ HTTP keycloak:8080 в закрытой Docker network
|
||||
→ managed PostgreSQL schema keycloak по TLS
|
||||
```
|
||||
|
||||
Публичный issuer обязан быть стабильным:
|
||||
|
||||
```text
|
||||
https://tohin.ru/auth/realms/han-chat
|
||||
```
|
||||
|
||||
OIDC discovery:
|
||||
|
||||
```text
|
||||
https://tohin.ru/auth/realms/han-chat/.well-known/openid-configuration
|
||||
```
|
||||
|
||||
JWKS — URI из discovery. `api-backend` проверяет `iss`, audience, signature, `exp/nbf` и `sub`, не вызывает Admin API в hot path.
|
||||
|
||||
## 3. Версия, image и providers
|
||||
|
||||
- Keycloak Quarkus distribution, поддерживаемая LTS/stable версия, закреплённая image digest.
|
||||
- PostgreSQL JDBC driver из image.
|
||||
- Custom Java provider JAR для phone OTP authenticator/settings bridge/counters.
|
||||
- Сборка provider reproducible, зависимости pinned, SBOM/signature/security scan.
|
||||
- Build-stage выполняет `kc.sh build`; runtime image immutable/non-root.
|
||||
- Перед upgrade читаются Keycloak migration notes и SPI compatibility.
|
||||
|
||||
Версия Keycloak фиксируется в deployment manifest; `latest` запрещён.
|
||||
|
||||
## 4. Realm и clients
|
||||
|
||||
Realm: `han-chat`. Master realm не используется приложением.
|
||||
|
||||
### 4.1. Public frontend client
|
||||
|
||||
Канонический client id:
|
||||
|
||||
```text
|
||||
han-chat-frontend
|
||||
```
|
||||
|
||||
Настройки:
|
||||
|
||||
- public client; client authentication off;
|
||||
- standard flow on;
|
||||
- Authorization Code + PKCE `S256` обязательно;
|
||||
- implicit flow off;
|
||||
- direct access grants/password grant off;
|
||||
- service accounts off;
|
||||
- device flow off, если не нужен;
|
||||
- consent screen Keycloak не заменяет продуктовые согласия API;
|
||||
- exact redirect URIs и web origins;
|
||||
- full scope allowed off; только назначенные scopes/mappers.
|
||||
|
||||
Примеры redirect URI должны перечисляться отдельно:
|
||||
|
||||
```text
|
||||
https://tohin.ru/auth/callback
|
||||
han-chat://auth/callback
|
||||
<Expo native scheme/callback, exact value после сборки>
|
||||
```
|
||||
|
||||
Production redirect URI задаются exact; wildcard не используется до отдельного security review. Development localhost origins/redirects находятся в отдельном dev realm/client либо profile и запрещены production.
|
||||
|
||||
### 4.2. API audience
|
||||
|
||||
Audience:
|
||||
|
||||
```text
|
||||
han-chat-api
|
||||
```
|
||||
|
||||
Client scope/audience mapper добавляет `aud=han-chat-api` в access token frontend. `api-backend` не принимает token только по `azp` без audience.
|
||||
|
||||
### 4.3. Optional confidential client
|
||||
|
||||
`han-chat-backend` можно импортировать disabled/optional для будущих admin/ops S2S:
|
||||
|
||||
- client authentication on, service account only при явном включении;
|
||||
- secret не хранится в realm export;
|
||||
- минимальные roles;
|
||||
- не используется между текущими сервисами и не требуется для JWT validation;
|
||||
- не участвует в пользовательском hot path.
|
||||
|
||||
Internal API по-прежнему используют service tokens из arch-02.
|
||||
|
||||
## 5. OTP-only phone flow
|
||||
|
||||
### 5.1. Browser flow
|
||||
|
||||
Отдельный flow `han-phone-otp-browser`:
|
||||
|
||||
1. Cookie/SSO authenticator проверяет действующую Keycloak session.
|
||||
2. При отсутствии session показывается форма телефона.
|
||||
3. `Phone Identity Authenticator` нормализует номер.
|
||||
4. Проверяются realm brute-force и product send limits.
|
||||
5. Создаётся/находится user по canonical phone identity.
|
||||
6. `Phone OTP Challenge` создаёт `ordering`: mock активирует его локально, real mode заказывает SMS через `sms-service`.
|
||||
7. Показывается форма OTP.
|
||||
8. Проверяются только локальные status/TTL/attempt limits и constant-time HMAC/mock compare; provider status не читается.
|
||||
9. При успехе user enabled/phone verified, flow завершается code.
|
||||
10. Frontend меняет code+verifier на tokens.
|
||||
|
||||
Password form, registration password, reset password, email OTP, social login и magic link отсутствуют.
|
||||
|
||||
### 5.2. Регистрация/find-or-create
|
||||
|
||||
До выдачи OTP новый user может существовать как short-lived pending identity либо создаваться после успешной проверки. Предпочтительное решение:
|
||||
|
||||
- normalized phone reservation/counter создаётся в SPI store;
|
||||
- permanent Keycloak user создаётся/активируется только после успешного OTP;
|
||||
- concurrent flow защищён unique phone index/transaction;
|
||||
- abandoned pending challenges очищаются TTL.
|
||||
|
||||
Если Keycloak storage не позволяет безопасный custom unique index в managed schema, user создаётся disabled с deterministic username и очищается job; точная реализация покрывается concurrency tests.
|
||||
|
||||
### 5.3. Required actions
|
||||
|
||||
Используются только при реальной необходимости:
|
||||
|
||||
- `VERIFY_PHONE` — если user импортирован/номер изменён вне текущего verified flow;
|
||||
- `UPDATE_PHONE` — будущий controlled flow с повторной OTP;
|
||||
- terms/product consents не required action: версии и факт согласия хранит `api-backend`.
|
||||
|
||||
Required actions не должны предлагать пароль/email. После phone OTP обычный вход завершается без лишнего profile screen.
|
||||
|
||||
## 6. Нормализация и уникальная identity
|
||||
|
||||
Телефон парсится libphonenumber:
|
||||
|
||||
- Unicode digits/NFKC input normalization;
|
||||
- default region `RU` допустим только для национального ввода; международные номера поддерживаются по product policy;
|
||||
- canonical storage/claim — E.164, например `+79001234567`;
|
||||
- invalid/impossible number отклоняется до send;
|
||||
- отображение только masked;
|
||||
- canonical phone comparison exact.
|
||||
|
||||
Рекомендуемая модель:
|
||||
|
||||
- `username` = canonical E.164 либо irreversible deterministic identifier;
|
||||
- user attribute `phone_number` = E.164;
|
||||
- `phone_number_verified=true`;
|
||||
- unique phone enforced storage-level, не только pre-check;
|
||||
- email nullable/не используется.
|
||||
|
||||
Утечка существования номера запрещена: initiate/challenge возвращают одинаковый внешний текст/timing class для нового/существующего пользователя. Один verified phone соответствует одному active `sub`. Merge/reassignment — отдельная administrative policy, не автоматический side effect login.
|
||||
|
||||
Изменение телефона требует re-auth + OTP нового номера и invalidation sessions/tokens по policy. `api-backend` обновляет cached phone при следующем bootstrap/login claim; прямого вызова Keycloak DB нет.
|
||||
|
||||
## 7. Mock и real delivery mode
|
||||
|
||||
Env:
|
||||
|
||||
```text
|
||||
KEYCLOAK_OTP_MOCK_ENABLED=true
|
||||
KEYCLOAK_OTP_MOCK_CODE=<secret>
|
||||
```
|
||||
|
||||
Правила:
|
||||
|
||||
- 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` при недоступном/неконфигурированном `sms-service` завершает новый order generic unavailable; уже active challenges продолжают локальный verify до TTL.
|
||||
|
||||
Реальный delivery interface:
|
||||
|
||||
```java
|
||||
interface OtpDeliveryProvider {
|
||||
SmsOrderResult order(E164Phone phone, String otp, Duration ttl, String challengeId);
|
||||
}
|
||||
```
|
||||
|
||||
Реализация 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
|
||||
|
||||
Даже в mock:
|
||||
|
||||
- challenge id random ≥128 bit;
|
||||
- OTP не хранится raw; production-generated code — keyed hash/HMAC с challenge salt/pepper;
|
||||
- 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 всегда переводит предыдущий `active`/`ordering` challenge в `superseded`;
|
||||
- replay/parallel verify безопасны;
|
||||
- destination stored masked/hash where possible.
|
||||
|
||||
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
|
||||
|
||||
SPI вызывает:
|
||||
|
||||
```text
|
||||
GET http://api-backend:8000/internal/settings/v1/otp
|
||||
Authorization: Bearer ${KEYCLOAK_SETTINGS_BRIDGE_TOKEN}
|
||||
```
|
||||
|
||||
Ответ:
|
||||
|
||||
```json
|
||||
{
|
||||
"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
|
||||
}
|
||||
```
|
||||
|
||||
Это единственный путь к `otp.phone.*`; Keycloak не получает GRANT на `han_app`. SPI поддерживает ETag/cache, single-flight refresh. Bridge down:
|
||||
|
||||
- использовать last-known-good до bounded max stale;
|
||||
- если cache пуст/слишком стар — fail-closed для send;
|
||||
- verify уже выданного challenge может продолжаться по snapshot, с которым challenge создан.
|
||||
|
||||
Token name точно `KEYCLOAK_SETTINGS_BRIDGE_TOKEN`, endpoint точно `/internal/settings/v1/otp`.
|
||||
|
||||
### 8.2. Где хранятся counters
|
||||
|
||||
Решение MVP: counters/challenges хранятся в Keycloak-owned PostgreSQL tables/provider storage в схеме `keycloak`, а не в Redis API и не в `han_app`.
|
||||
|
||||
Причины:
|
||||
|
||||
- durable across restart;
|
||||
- одна transaction для reserve/send-attempt/consume;
|
||||
- не добавляет Keycloak credentials к общему Redis;
|
||||
- соответствует границе «счётчики в зоне Keycloak/SPI».
|
||||
|
||||
Используются phone HMAC, не E.164 в key/index для rate data. Tables provider-owned создаются versioned migration provider-а, не ручным DDL-on-start.
|
||||
|
||||
Минимальные records:
|
||||
|
||||
- `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 событие на каждую send/verify попытку, `sms_message_id`, outcome/details и device context.
|
||||
|
||||
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
|
||||
|
||||
Слои:
|
||||
|
||||
1. nginx `/auth` IP rate limit (`NGINX_RATE_LIMIT_AUTH`);
|
||||
2. Keycloak realm brute-force detection;
|
||||
3. SPI product send limits per phone HMAC;
|
||||
4. verify-attempt limit per challenge/phone/IP hash;
|
||||
5. cooldown after repeated failures;
|
||||
6. невидимая Yandex SmartCaptcha перед каждым первичным и повторным заказом OTP SMS.
|
||||
|
||||
Realm включает brute-force protection с temporary lockout и bounded wait. Permanent lockout для consumer phone login без recovery runbook нежелателен. Error messages не различают unknown phone/wrong code/locked account сверх безопасной UX причины. `Retry-After`/remaining time выдаётся только если не помогает enumeration.
|
||||
|
||||
IP берётся только из trusted proxy chain; Keycloak настроен доверять forwarded headers от root nginx.
|
||||
|
||||
SmartCaptcha включается только через `KEYCLOAK_YANDEX_CAPTCHA_ENABLED`; client/server keys обязательны при `true`. Одноразовый token проверяется server-side до `OtpFlow.start()`/counter reservation. `status=failed`, отсутствующий token и non-temporary HTTP 4xx блокируют SMS; timeout, I/O, HTTP 408/429/5xx и malformed response работают fail-open с безопасным логом. Сложность и traffic rules принадлежат одной CAPTCHA в Yandex Cloud. CSP с доменами SmartCaptcha задаётся точечно в nginx только для login endpoints; custom realm CSP запрещён из-за риска поломки Admin Console/`3p-cookies`.
|
||||
|
||||
## 10. Claims и token contract
|
||||
|
||||
Access token минимум:
|
||||
|
||||
| Claim | Значение |
|
||||
|---|---|
|
||||
| `iss` | `https://tohin.ru/auth/realms/han-chat` |
|
||||
| `sub` | immutable Keycloak user id |
|
||||
| `aud` | включает `han-chat-api` |
|
||||
| `azp` | `han-chat-frontend` |
|
||||
| `exp`, `iat`, `nbf` | стандартные |
|
||||
| `sid` | session id, если поддерживается |
|
||||
| `auth_time` | время auth |
|
||||
| `acr`/`amr` | отражает phone OTP |
|
||||
| `phone_number` | canonical E.164 |
|
||||
| `phone_number_verified` | `true` |
|
||||
| `scope` | only allowed scopes |
|
||||
|
||||
`preferred_username` может совпадать с phone для compatibility, но канонический claim API — `phone_number`; module-01 допускает fallback только если E.164.
|
||||
|
||||
ID token предназначен client login state; API принимает access token, не ID token. Refresh token непрозрачен для приложения и хранится frontend secure storage.
|
||||
|
||||
PII minimization: full phone нужен API bootstrap по зафиксированному контракту, но не добавляется в service tokens/metrics/logs. Roles/groups выдаются только если используются authorization policy.
|
||||
|
||||
## 11. Signing keys, JWKS и rotation
|
||||
|
||||
- asymmetric signing, RS256 MVP; `none`/HS algorithms запрещены;
|
||||
- active signing key + passive previous keys до истечения всех выпущенных tokens/grace;
|
||||
- keys генерируются/хранятся Keycloak, private material не в realm export/repo;
|
||||
- JWKS публичен через issuer;
|
||||
- rotation rehearsed; `kid` меняется, API controlled-refresh cache;
|
||||
- emergency compromise: disable key, revoke sessions, force re-login, alert/runbook;
|
||||
- backup/restore учитывает realm keys.
|
||||
|
||||
Rotation interval и HSM/keystore — ops TBD. Изменение algorithm требует совместного rollout API verifier.
|
||||
|
||||
## 12. Token и session lifecycle
|
||||
|
||||
Предлагаемые MVP значения, окончательно принять security/product review:
|
||||
|
||||
- access token lifespan: 5 минут;
|
||||
- SSO session idle: 30 дней;
|
||||
- SSO session max: 90 дней;
|
||||
- refresh token следует session limits;
|
||||
- authorization code: 1 минута;
|
||||
- login action: 5 минут;
|
||||
- client session idle/max согласованы с SSO;
|
||||
- clock skew минимальный.
|
||||
|
||||
Refresh:
|
||||
|
||||
- revoke refresh token on use / refresh token rotation включены;
|
||||
- max reuse `0` или минимально поддерживаемое значение;
|
||||
- frontend применяет single-flight, поэтому parallel refresh не требуется;
|
||||
- reuse старого refresh token → `invalid_grant`, возможная session revocation/security event;
|
||||
- offline tokens не выдаются.
|
||||
|
||||
Access token не хранится server-side и живёт до exp; критическая блокировка пользователя сопровождается logout/revocation/not-before policy.
|
||||
|
||||
## 13. Logout, revocation и browser cookies
|
||||
|
||||
Frontend вызывает OIDC end-session/logout с valid post-logout redirect, затем всегда очищает local tokens. Back-channel logout можно включить для clients, которые его поддержат; API JWT hot path не хранит browser session.
|
||||
|
||||
Cookies Keycloak:
|
||||
|
||||
- `Secure`, `HttpOnly`;
|
||||
- SameSite согласно redirect/iframe requirements, по умолчанию `Lax`;
|
||||
- domain/path минимальны (`/auth`/host);
|
||||
- third-party cookie dependency не закладывается;
|
||||
- session fixation предотвращается Keycloak;
|
||||
- admin console cookies не расширяются на frontend origins.
|
||||
|
||||
Front-channel iframe checks не должны заставлять ослабить CSP всего сайта. Native logout использует system browser и app-link/custom scheme validation.
|
||||
|
||||
## 14. CORS, origins и redirects
|
||||
|
||||
- exact `Web Origins`: `https://tohin.ru`;
|
||||
- no wildcard `*` with credentials;
|
||||
- native apps не получают произвольные web origins;
|
||||
- valid redirects exact/safely scoped;
|
||||
- redirect URI comparison не допускает open redirect;
|
||||
- post-logout redirects отдельно allow-listed;
|
||||
- nginx и Keycloak CORS не должны дублировать противоречащие headers;
|
||||
- token endpoint используется PKCE client без client secret;
|
||||
- admin endpoints не CORS-доступны приложению.
|
||||
|
||||
Любой новый environment имеет отдельный host/client config, а не production wildcard.
|
||||
|
||||
## 15. Reverse proxy и hostname
|
||||
|
||||
Ключевые настройки (точные CLI names проверяются по закреплённой версии):
|
||||
|
||||
```text
|
||||
KC_HTTP_ENABLED=true
|
||||
KC_HTTP_PORT=8080
|
||||
KC_PROXY_HEADERS=xforwarded
|
||||
KC_HOSTNAME=https://tohin.ru/auth
|
||||
KC_HTTP_RELATIVE_PATH=/auth
|
||||
KC_HOSTNAME_STRICT=true
|
||||
KC_HOSTNAME_STRICT_HTTPS=true
|
||||
```
|
||||
|
||||
Если выбран другой поддержанный pattern (`hostname` без path + relative path), итоговые issuer/endpoints обязаны совпасть с `KEYCLOAK_PUBLIC_URL`.
|
||||
|
||||
Nginx передаёт trusted `Host`, `X-Forwarded-Proto=https`, `X-Forwarded-Host`, `X-Forwarded-Port=443`, real IP. Keycloak не доступен напрямую с host/public network, поэтому spoofed forwarded headers не принимаются извне.
|
||||
|
||||
Admin hostname/path рекомендуется ограничить ops network/VPN; публично нужны только realm/OIDC/login assets. Если разделить admin hostname невозможно MVP, admin console защищается network allow-list и сильным admin auth.
|
||||
|
||||
## 16. PostgreSQL `keycloak`
|
||||
|
||||
Используется:
|
||||
|
||||
```text
|
||||
KEYCLOAK_DB_URL=jdbc:postgresql://.../han_chat?...¤tSchema=keycloak
|
||||
KC_DB_URL_PROPERTIES=currentSchema=keycloak
|
||||
```
|
||||
|
||||
Role `keycloak_user` имеет доступ только к schema `keycloak`; нет доступа `han_app`, `bitrix_*`, `message_safety`. Connection только private VPC + TLS verify.
|
||||
|
||||
Pool:
|
||||
|
||||
- bounded initial/min/max;
|
||||
- acquisition/query/connect timeout;
|
||||
- leak detection/metrics;
|
||||
- pool max определяется load test и managed PG limit;
|
||||
- `application_name=keycloak`.
|
||||
|
||||
Keycloak управляет своей стандартной schema migration. Custom provider tables имеют отдельную versioned migration strategy, совместимую с startup/rolling upgrade; DDL не выполняется бесконтрольно каждым replica.
|
||||
|
||||
Нельзя редактировать стандартные Keycloak tables вручную или Alembic-миграциями Python-сервисов.
|
||||
|
||||
## 17. Admin bootstrap и realm import
|
||||
|
||||
### 17.1. Bootstrap admin
|
||||
|
||||
- `KC_BOOTSTRAP_ADMIN_USERNAME`/password или актуальный bootstrap mechanism только на первом запуске;
|
||||
- password генерируется strong secret, не коммитится и после bootstrap ротируется/удаляется из runtime env;
|
||||
- admin user не используется приложением;
|
||||
- отдельные named admin accounts/least privilege для ops;
|
||||
- MFA для admin обязательно до production, независимо от consumer phone flow;
|
||||
- admin events audit включён.
|
||||
|
||||
### 17.2. Realm import
|
||||
|
||||
Репозиторий:
|
||||
|
||||
```text
|
||||
keycloak/
|
||||
realm/han-chat-realm.json.template
|
||||
providers/han-phone-otp-provider.jar
|
||||
themes/han-phone/
|
||||
migrations/
|
||||
scripts/{render-realm,validate-realm,export-realm}.sh
|
||||
tests/
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
```
|
||||
|
||||
Export/template содержит realm/client/flow/scopes/policies, но не:
|
||||
|
||||
- client/admin/provider secrets;
|
||||
- mock code;
|
||||
- private signing keys;
|
||||
- environment-specific production credentials.
|
||||
|
||||
Import автоматически допустим для clean local/test. Production changes применяются controlled declarative job/Admin API procedure с diff/backup, не `--import-realm` поверх живого realm без проверки. Drift detection сравнивает безопасный desired subset.
|
||||
|
||||
## 18. Settings и secrets
|
||||
|
||||
Канонические:
|
||||
|
||||
```text
|
||||
KEYCLOAK_PUBLIC_URL=https://tohin.ru/auth
|
||||
KEYCLOAK_INTERNAL_URL=http://keycloak:8080
|
||||
KEYCLOAK_REALM=han-chat
|
||||
KEYCLOAK_AUDIENCE=han-chat-api
|
||||
KEYCLOAK_DB_URL=jdbc:postgresql://...
|
||||
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.
|
||||
|
||||
Все изменяемые 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_SETTINGS_MAX_STALE_SEC=300
|
||||
KEYCLOAK_OTP_HMAC_KEY=<secret>
|
||||
```
|
||||
|
||||
## 19. Health, readiness и startup
|
||||
|
||||
Keycloak management health endpoints включены. Compose проверяет liveness/startup; readiness требует:
|
||||
|
||||
- server started;
|
||||
- DB reachable/schema migration complete;
|
||||
- realm/client/auth flow/provider loaded;
|
||||
- active signing key;
|
||||
- settings bridge last-known-good для OTP send;
|
||||
- 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.
|
||||
|
||||
DB/settings failure не должен приводить к выдаче tokens без OTP. OTEL/metrics outage не блокирует login.
|
||||
|
||||
## 20. Logging, metrics, tracing и audit
|
||||
|
||||
### Logs
|
||||
|
||||
JSON/stdout:
|
||||
|
||||
- service/version/environment, event/category;
|
||||
- request/trace id, realm/client, safe flow step;
|
||||
- result/error code, duration;
|
||||
- phone только HMAC/masked при необходимости.
|
||||
|
||||
Запрещены raw OTP/mock code, phone, access/refresh/code, cookies, Authorization, client/admin secret, password, form body, redirect query с `code`, DB URL.
|
||||
|
||||
Keycloak access log должен редактировать sensitive query. TRACE/DEBUG production выключены.
|
||||
|
||||
### Events/audit
|
||||
|
||||
Включаются login/login_error, logout, refresh/revoke, user create/disable, phone verify/change, brute-force/OTP limit, admin config changes. Retention/consumer определяется ops/legal; event payload минимален.
|
||||
|
||||
### Metrics
|
||||
|
||||
- login/OTP send/verify success/failure/latency;
|
||||
- limit/lockout rejects;
|
||||
- settings cache age/refresh failures;
|
||||
- active sessions/token refresh/error;
|
||||
- DB pool/JVM/GC/HTTP;
|
||||
- JWKS/key age;
|
||||
- delivery mode info (`mock`, `sms`) и provider dependency `idgtl`, без phone labels.
|
||||
|
||||
### Tracing
|
||||
|
||||
OTEL support зависит от версии; HTTP/provider/settings bridge spans добавляются instrumentation без secrets. Если native tracing недостаточно, сохраняются request/trace correlation headers. Наблюдаемость не меняет auth outcome.
|
||||
|
||||
## 21. Backup, restore и disaster recovery
|
||||
|
||||
- managed PostgreSQL daily backup + PITR;
|
||||
- realm config export хранится versioned и secret-free;
|
||||
- signing key/private realm state входит в protected DB backup;
|
||||
- provider JAR/theme/image reproducible из repo/artifacts;
|
||||
- restore rehearsal в isolated environment;
|
||||
- после restore проверяются issuer, keys/JWKS, clients/flows, user/session consistency, provider tables;
|
||||
- RPO/RTO фиксируются ops до production.
|
||||
|
||||
При восстановлении в другой host нельзя случайно выдать production tokens с неверным issuer. DNS/TLS/hostname проверяются до открытия traffic. Backup encrypted/access-controlled; OTP expired rows очищаются по TTL.
|
||||
|
||||
## 22. Миграции и upgrades
|
||||
|
||||
Порядок:
|
||||
|
||||
1. прочитать release notes и supported DB upgrade path;
|
||||
2. backup/PITR checkpoint;
|
||||
3. проверить provider SPI/API compatibility и пересобрать JAR;
|
||||
4. прогнать upgrade clone БД;
|
||||
5. contract/E2E login+refresh+logout;
|
||||
6. staged maintenance/rolling rollout только если версия поддерживает cluster compatibility;
|
||||
7. проверить schema migration, realm drift, JWKS;
|
||||
8. rollback приложения возможен только если DB schema backward-compatible; иначе restore/forward-fix runbook.
|
||||
|
||||
Нельзя пропускать major versions произвольно. Realm changes versioned отдельно. Custom provider migration имеет собственный version table/compatibility matrix.
|
||||
|
||||
## 23. Docker/runtime hardening
|
||||
|
||||
- service `keycloak`, `expose: 8080` и management port только internal;
|
||||
- networks `public` (только nginx access при необходимости), `backend`, `observability`; при `KEYCLOAK_YANDEX_CAPTCHA_ENABLED=true` дополнительно ограниченная `egress` только к `smartcaptcha.cloud.yandex.ru:443` для server-side `/validate`, при `false` egress у Keycloak отсутствует;
|
||||
- без host `ports`;
|
||||
- non-root, read-only rootfs где совместимо, tmpfs для temp;
|
||||
- no-new-privileges/drop capabilities;
|
||||
- memory/CPU/JVM heap limits, graceful termination;
|
||||
- startup/readiness probes с достаточным initial period;
|
||||
- immutable provider/theme mounts/image;
|
||||
- no local persistent DB volume.
|
||||
|
||||
TLS client→nginx; Keycloak→managed PG TLS. Internal nginx→Keycloak HTTP допустим только на закрытой сети одной VM.
|
||||
|
||||
## 24. Failure semantics
|
||||
|
||||
| Сбой | Поведение |
|
||||
|---|---|
|
||||
| DB down | not-ready; login/refresh fail; existing access tokens проверяются API до exp по cached JWKS |
|
||||
| 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 |
|
||||
| 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 |
|
||||
| OTEL down | auth работает, telemetry drop metric/local log |
|
||||
| provider exception | flow fail-closed, generic error/request id |
|
||||
|
||||
Не должно быть fallback на password или «успешный OTP» при инфраструктурной ошибке.
|
||||
|
||||
## 25. Тестовая матрица
|
||||
|
||||
### Unit provider
|
||||
|
||||
- E.164 normalization across RU/international/Unicode;
|
||||
- invalid/impossible phone;
|
||||
- unique concurrent reservation;
|
||||
- mock constant-time compare/redaction;
|
||||
- challenge TTL/one-time/replay/concurrent verify;
|
||||
- send/verify limits and window boundaries;
|
||||
- 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
|
||||
|
||||
- only standard code+PKCE S256;
|
||||
- password/direct/implicit/social disabled;
|
||||
- exact origins/redirect/logout URIs;
|
||||
- audience/claims/issuer;
|
||||
- token/session TTL and refresh rotation;
|
||||
- browser flow executions/required actions;
|
||||
- no secrets/private keys in realm export.
|
||||
|
||||
### Integration
|
||||
|
||||
- managed/test PostgreSQL schema/currentSchema/TLS;
|
||||
- restart preserves counters/challenges;
|
||||
- Keycloak upgrade/provider migration;
|
||||
- settings bridge token/path with module-01;
|
||||
- JWKS rotation and API validation;
|
||||
- disabled user/revocation/not-before;
|
||||
- brute-force lockout/recovery;
|
||||
- proxy hostname/path builds correct external URLs.
|
||||
|
||||
### 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;
|
||||
- max sends/min interval/max verifies;
|
||||
- concurrent tabs/refresh single-flight assumptions;
|
||||
- logout web/native;
|
||||
- DB/settings outage;
|
||||
- no phone/OTP/token in logs, URLs or metrics;
|
||||
- admin endpoint inaccessible publicly.
|
||||
|
||||
### Security
|
||||
|
||||
- redirect/open redirect, PKCE downgrade, state/nonce;
|
||||
- user enumeration/timing;
|
||||
- cookie flags/CSRF on auth forms;
|
||||
- forwarded header spoofing;
|
||||
- brute-force/IP/phone distributed attempts;
|
||||
- JWT alg/aud/iss/kid attacks;
|
||||
- secret scanning/image/SBOM/provider dependency review.
|
||||
|
||||
## 26. Definition of Done
|
||||
|
||||
- Keycloak доступен за `/auth`, issuer/discovery/JWKS стабильны;
|
||||
- realm/client topology и PKCE S256 зафиксированы declaratively;
|
||||
- только phone OTP; password/implicit/direct/social отключены;
|
||||
- phone canonical E.164 и storage-level unique;
|
||||
- claims соответствуют module-01 (`sub`, `phone_number`, audience);
|
||||
- mock secret only env, не логируется/не отдаётся;
|
||||
- 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 покрыты;
|
||||
- proxy/redirect/origin/CORS/cookies/TLS boundaries проверены;
|
||||
- DB role/schema/backup/restore/upgrade runbooks готовы;
|
||||
- health/metrics/logging/tracing не раскрывают secrets/PII;
|
||||
- container hardening/root Compose без published port;
|
||||
- test matrix зелёная;
|
||||
- mock и real mutually exclusive; real mode вызывает только durable-order API `sms-service`, Direct/Verifier/status polling отсутствуют.
|
||||
|
||||
## 27. Решения, допущения и TBD
|
||||
|
||||
**Решения:**
|
||||
|
||||
- K1: realm `han-chat`, public client `han-chat-frontend`, audience `han-chat-api`.
|
||||
- K2: Authorization Code + PKCE S256; остальные user grants выключены.
|
||||
- K3: canonical identity/claim — E.164 `phone_number`; `sub` immutable.
|
||||
- K4: OTP authenticator/provider SPI; mock code только secret env.
|
||||
- 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 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 до прохождения controlled real-SMS rollout как временный риск.
|
||||
- A4: телефон в access token необходим API bootstrap и защищён TLS/short token TTL.
|
||||
|
||||
**TBD:**
|
||||
|
||||
- K-TBD1: выбрать/pin Keycloak version и проверить custom SPI compatibility.
|
||||
- K-TBD2: окончательные redirect URI для Expo iOS/Android и universal/app links.
|
||||
- K-TBD3: финальные token/session TTL и brute-force thresholds после security review.
|
||||
- K-TBD4: exact schema/migration mechanism provider tables без вмешательства в standard schema.
|
||||
- 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 закрыт module-11 для v1: vendor i-Digital Direct, credentials/template/sender/callback принадлежат `sms-service`; failover вне v1.
|
||||
- K-TBD9 закрыт для v1: одна невидимая Yandex SmartCaptcha защищает initial send и resend; динамический risk scoring остаётся вне scope.
|
||||
@@ -0,0 +1,196 @@
|
||||
# module-09-vm1. Наблюдаемость ВМ1 HAN Chat
|
||||
|
||||
> Статус: целевая спецификация реализации наблюдаемости на ВМ1.
|
||||
> Канонический контракт (JSON-лог, redaction, sampling, Collector pipeline, SigNoz) — [`arch-07-observability.md`](../../architectory/arch-07-observability.md). Его поля, labels и `service.namespace` здесь не переопределяются.
|
||||
> Контур ВМ2 в этот документ не входит: [`module-09-observability-vm2.md`](../../VM2_services/documentation/module-09-observability-vm2.md). Корреляция сквозного запроса — по `request_id` / `trace_id`.
|
||||
|
||||
## 1. Назначение и границы
|
||||
|
||||
Документ задаёт, **что агент ВМ1 реализует в Compose, коде, тестах и алертах этой машины**.
|
||||
|
||||
ВМ1 владеет публичным edge, `api-backend`, Keycloak, `bitrix-local-app`, SMS-контуром, Redis DB0/DB1 и локальным Collector. Message Safety, `bitrix-sync`, ClamAV и Redis Safety живут на ВМ2; ВМ1 только вызывает Safety по private HTTPS и продолжает trace.
|
||||
|
||||
Агент ВМ1 не добавляет scrape, дашборды и алерты сервисов ВМ2.
|
||||
|
||||
## 2. Сервисы и `service.name`
|
||||
|
||||
| Компонент | `service.name` |
|
||||
|---|---|
|
||||
| edge nginx | `nginx` |
|
||||
| `api-backend` | `api-backend` |
|
||||
| `bitrix-local-app` | `bitrix-local-app` |
|
||||
| Keycloak | `keycloak` |
|
||||
| `sms-service` | `sms-service` |
|
||||
| `sms-worker` | `sms-worker` |
|
||||
| Redis DB0/DB1 | `redis` |
|
||||
| local Collector | `otel-collector` |
|
||||
|
||||
SMS-метрики и алерты детализированы в [`module-11-idgtl-sms.md`](module-11-idgtl-sms.md); имена зарегистрированы в arch-07 §4.
|
||||
|
||||
Различать экземпляр от ВМ2 через `host.name` / `service.instance.id`, не через другое `service.namespace`.
|
||||
|
||||
## 3. Collector на ВМ1
|
||||
|
||||
- отдельный экземпляр в root Compose ВМ1, собственный volume `otel-queue`;
|
||||
- приложения ВМ1 экспортируют OTLP только в `otel-collector:4317` этой машины;
|
||||
- export в SigNoz `192.168.0.5:4317`; hostname collector ВМ2 не используется;
|
||||
- pipeline, processors, limits, `otel-queue-init` и fail-open — arch-07 §3, §13, §14.
|
||||
|
||||
Scrape targets ВМ1 (кроме самого Collector): Keycloak management metrics, Redis exporter приложения, nginx exporter, сервисные `/metrics` `api-backend` и `bitrix-local-app`, если они не идут OTLP.
|
||||
|
||||
## 4. Instrumentation
|
||||
|
||||
Правила FastAPI/HTTPX/PG/Redis/S3/workers, JSON access log nginx и hostmetrics — arch-07 §8. Ниже только покрытие ВМ1.
|
||||
|
||||
### 4.1. `api-backend`
|
||||
|
||||
- server spans с route template;
|
||||
- child spans: PostgreSQL App DB, Redis DB0/DB1, S3, Message Safety, Open Lines / `bitrix-local-app`, JWKS;
|
||||
- internal calls передают `traceparent`, `tracestate`, `X-Request-ID`;
|
||||
- `ux_session_id` в JSON-логах только если передан `X-Ux-Session-Id`;
|
||||
- исключить `/health/live` из traces.
|
||||
|
||||
### 4.2. `bitrix-local-app`
|
||||
|
||||
- server spans handler/install/placement;
|
||||
- outbound Open Lines / Bitrix client spans закрываются результатом, даже если Bitrix не вернул context;
|
||||
- async outbox/inbox — span link на origin, не подмена долгого worker trace.
|
||||
|
||||
### 4.3. Keycloak
|
||||
|
||||
Включаются management metrics/JVM/HTTP/DB pool. Custom OTP provider публикует counters send/verify/limit/settings-cache без phone labels. Login events идут в JSON/audit с masked/HMAC destination. Public OIDC synthetic проверяется отдельно. Native tracing — arch-07 O-TBD5.
|
||||
|
||||
### 4.4. nginx ВМ1
|
||||
|
||||
Edge access log по arch-07 §8.4. Route class — bounded set публичных маршрутов ВМ1 (API, auth, WS, Bitrix local app, SMS callback, static). Без native OTEL module первый server span создаёт `api-backend` или соответствующий upstream.
|
||||
|
||||
### 4.5. Redis приложения и PostgreSQL
|
||||
|
||||
Exporter и ACL — arch-07 §8.2–8.3. Клиентские pool metrics публикует `api-backend` (и SMS, когда появится). Managed PG provider metrics — общий сигнал, не дублируется как метрика ВМ2.
|
||||
|
||||
### 4.6. Host/Docker ВМ1
|
||||
|
||||
CPU, memory, disk, network, restarts/OOM, Docker daemon, clock sync — arch-07 §8.5.
|
||||
|
||||
## 5. Метрики бизнес-потоков ВМ1
|
||||
|
||||
### API и auth
|
||||
|
||||
- `han_http_requests_total{service,route,method,status_class}`;
|
||||
- `han_http_request_duration_seconds`;
|
||||
- `han_auth_bootstrap_total{outcome}`;
|
||||
- `han_ux_session_start_total{reason}`;
|
||||
- `han_jwks_refresh_total{outcome}`;
|
||||
- `han_rate_limit_decisions_total{scope,outcome}`.
|
||||
|
||||
### Open Lines / `bitrix-local-app`
|
||||
|
||||
- message submitted → delivered end-to-end latency;
|
||||
- local app outbound result/retry/ambiguous/DLQ;
|
||||
- inbox depth/oldest age/forward retries/duplicate;
|
||||
- OAuth time-to-expiry/refresh result;
|
||||
- connector desired/observed state;
|
||||
- Bitrix 429, circuit state, setup failure.
|
||||
|
||||
### Files / S3 со стороны API
|
||||
|
||||
- init/complete/promote/delete;
|
||||
- presigned download issued;
|
||||
- S3 dependency latency/error by operation and logical bucket.
|
||||
|
||||
Quarantine object age, checksum/MIME/size reject и Safety file pipeline — спецификация ВМ2.
|
||||
|
||||
### Frontend synthetic
|
||||
|
||||
- public config/content;
|
||||
- OIDC discovery/authorization page;
|
||||
- WS handshake;
|
||||
- test-user end-to-end flow в отдельной тестовой identity без реального PII.
|
||||
|
||||
SMS metrics/alerts — [`module-11-idgtl-sms.md`](module-11-idgtl-sms.md) §10.3.8, после добавления в arch-07.
|
||||
|
||||
UUID/user/session/dialog/task/message id не labels.
|
||||
|
||||
## 6. Dashboards ВМ1
|
||||
|
||||
В SigNoz, с filter `host.name` / environment ВМ1:
|
||||
|
||||
1. **nginx ingress ВМ1**: RPS, 4xx/5xx, upstream latency/status, 429, WS, TLS, cache. Без CRM webhook — это ВМ2.
|
||||
2. **api-backend**: routes, DB/Redis pools, JWKS, circuits, outbox/safety backlog со стороны caller, S3.
|
||||
3. **bitrix-local-app**: install/OAuth, connector, outbound/inbox/DLQ, API/Bitrix latency.
|
||||
4. **Keycloak**: login/OTP/lockout, sessions/tokens, provider settings cache, JVM/DB.
|
||||
5. **Redis приложения**: memory/evictions/AOF/latency/clients/keyspace.
|
||||
6. **PostgreSQL/S3** в части App DB и бакетов, которыми пользуется ВМ1.
|
||||
|
||||
Сквозные Executive/SLO, Business flow и Collector health — arch-07 §10; ВМ1 поставляет свои сигналы, не владеет панелями ВМ2.
|
||||
|
||||
## 7. Alerts ВМ1
|
||||
|
||||
Политика SLO — arch-07 §11. Ниже alerts, которые закрывает on-call ВМ1.
|
||||
|
||||
### Paging
|
||||
|
||||
- edge/API 5xx >5% 5 минут;
|
||||
- text delivery failure >5% 10 минут (сигнал ВМ1: accept/submit/Open Lines; Safety hop подтверждается с ВМ2);
|
||||
- oldest outbox/inbox >5 минут либо DLQ >0 у `bitrix-local-app`;
|
||||
- Keycloak login failures infrastructure class >10% 5 минут;
|
||||
- Redis приложения unavailable, AOF error или sustained evictions;
|
||||
- Collector ВМ1 exporter queue >80%, dropped/refused telemetry >0 sustained;
|
||||
- TLS expiry публичного host ВМ1 <14 дней warning, <7 дней page;
|
||||
- disk/OOM/restart loop ВМ1.
|
||||
|
||||
### Ticket/warning
|
||||
|
||||
- p95 regression 20% release-over-release на protected read / text send;
|
||||
- settings/JWKS cache stale;
|
||||
- OAuth local app expires <24h без успешного refresh;
|
||||
- cardinality/ingest growth >2× baseline на сериях ВМ1.
|
||||
|
||||
Safety mock, Safety config stale, `bitrix-sync` DLQ/credentials — не алерты репозитория ВМ1.
|
||||
|
||||
## 8. Docker Compose ВМ1
|
||||
|
||||
Root Compose включает `otel-collector` по arch-07 §14. Сети приложений ВМ1: `backend` + `observability` (SMS worker — как в arch-03).
|
||||
|
||||
Optional profile `observability-local` на ВМ1 по умолчанию выключен; включение — O-TBD6.
|
||||
|
||||
## 9. Runbooks ВМ1
|
||||
|
||||
Общие (Collector not-ready, missing telemetry, remote outage, cardinality, PII) — arch-07 §16.
|
||||
|
||||
### Высокая latency сообщения (hop ВМ1)
|
||||
|
||||
1. Открыть trace по `request_id`.
|
||||
2. Разделить nginx, API, Safety client span, S3, local app, Bitrix.
|
||||
3. Если delay внутри Safety poll/scan — передать инцидент владельцу ВМ2, не менять collector ВМ1.
|
||||
4. Проверить circuit, outbox age, DB pool и Redis приложения.
|
||||
5. Не повторять ambiguous message без исходного idempotency key.
|
||||
6. Следовать runbook [`module-01-api-backend.md`](module-01-api-backend.md) / [`module-06-bitrix-local-app.md`](module-06-bitrix-local-app.md).
|
||||
|
||||
## 10. Definition of Done ВМ1
|
||||
|
||||
Дополнительно к arch-07 §17:
|
||||
|
||||
- Collector ВМ1 validate + up в root Compose;
|
||||
- инструментированы FastAPI/HTTPX/PG/Redis/S3 workers `api-backend` и `bitrix-local-app`;
|
||||
- Keycloak metrics доступны;
|
||||
- nginx JSON parsing и trace correlation edge ВМ1 проверены;
|
||||
- Redis приложения и host/Collector metrics доступны;
|
||||
- дашборды и alerts §6–§7 provisioned либо явно TBD до SigNoz packaging;
|
||||
- canary secret/PII отсутствует в сигналах ВМ1;
|
||||
- synthetic: public config, OIDC, WS handshake;
|
||||
- request с `X-Request-ID` находится в nginx ВМ1, API и client span Safety; продолжение на ВМ2 не блокирует DoD ВМ1, но сквозной gate — [`arch-10-deployment.md`](../../architectory/arch-10-deployment.md) §11 / [`module-10-deployment-vm1.md`](module-10-deployment-vm1.md).
|
||||
|
||||
## 11. TBD и конфликты, принадлежащие ВМ1
|
||||
|
||||
- O-TBD6: нужен ли local observability profile на ВМ1.
|
||||
- O-TBD5 в части Keycloak native tracing и nginx OTEL module pinned image ВМ1.
|
||||
- Production SLO Keycloak/API остаются initial ops policy, пока product owner не утвердил O-TBD2.
|
||||
|
||||
## 12. Ссылки
|
||||
|
||||
- Контракт: [`arch-07-observability.md`](../../architectory/arch-07-observability.md).
|
||||
- ВМ2: [`module-09-observability-vm2.md`](../../VM2_services/documentation/module-09-observability-vm2.md).
|
||||
- Указатель: [`module-09-observability.md`](module-09-observability.md).
|
||||
- Edge nginx: [`module-03-nginx-vm1.md`](module-03-nginx-vm1.md).
|
||||
- Деплой: [`module-10-deployment-vm1.md`](module-10-deployment-vm1.md).
|
||||
@@ -0,0 +1,213 @@
|
||||
# module-10-vm1. Runbook развёртывания ВМ1 HAN Chat
|
||||
|
||||
> Статус: целевой runbook репозитория ВМ1.
|
||||
> Общий контракт (VPC/SG, PG, S3, роли `deploy`, TLS процедура, порядок cutover) — [`arch-10-deployment.md`](../../architectory/arch-10-deployment.md).
|
||||
> ВМ2 — [`module-10-deployment-vm2.md`](../../VM2_services/documentation/module-10-deployment-vm2.md). Не переносить команды ВМ2 и не шарить Compose/secrets.
|
||||
|
||||
## 1. Границы
|
||||
|
||||
ВМ1 владеет edge nginx `80/443`, `api-backend`, Keycloak, SMS, `bitrix-local-app`, Redis DB0/DB1, Collector. После Safety cutover local Safety/Redis DB2 отсутствуют; `MESSAGE_SAFETY_URL` — private HTTPS ВМ2.
|
||||
|
||||
`<BACKEND_ROOT>` / `<BACKEND_REPO_URL>` — репозиторий ВМ1. Host ACME — `<PUBLIC_HOST>`.
|
||||
|
||||
## 2. Sizing ВМ1
|
||||
|
||||
Final sizing — D-TBD2. Disk после pull/build ≥30% free. `PUBLIC_DOCKER_PORTS=80,443`.
|
||||
|
||||
## 3. Hardening
|
||||
|
||||
По arch-10 §5 / arch-06. Пример:
|
||||
|
||||
```bash
|
||||
sudo DEPLOY_USER=deploy \
|
||||
DEPLOY_DIR=/opt/han-chat \
|
||||
SSH_PORT=<SSH_PORT> \
|
||||
SWAP_SIZE_GB=4 \
|
||||
PUBLIC_DOCKER_PORTS=80,443 \
|
||||
./deploy/setup-vm-han-chat.sh
|
||||
```
|
||||
|
||||
Gate 2 — arch-10. Break-glass вне VM.
|
||||
|
||||
## 4. Release layout и `.env` ВМ1
|
||||
|
||||
Checkout exact SHA в `/opt/han-chat/backend`. Структура: root Compose, `nginx`, `keycloak`, `redis`, `observability`, frontend artifact.
|
||||
|
||||
Обязательные группы секретов/config ВМ1:
|
||||
|
||||
- `APP_ENV`, release, log level;
|
||||
- private PG host/port/database и TLS CA; runtime DSN в secret backend;
|
||||
- Redis ACL URLs DB0/DB1 только в secret backend; после cutover DB2 нет;
|
||||
- public web/API/auth URLs;
|
||||
- Keycloak realm/audience/hostname/bootstrap/provider secrets;
|
||||
- SMS DB URL, парные Keycloak↔SMS tokens, Direct `TOKEN_1`, callback credentials;
|
||||
- paired service tokens arch-02;
|
||||
- Bitrix local app client/application/encryption secrets (не CRM sync webhook ВМ2);
|
||||
- S3 API credentials (не Safety read-only key ВМ2);
|
||||
- OTEL exporter secrets;
|
||||
- nginx/TLS/rate limits;
|
||||
- frontend public build values;
|
||||
- после cutover: `MESSAGE_SAFETY_URL=https://<private-vm2-name>:8443` и `MESSAGE_SAFETY_CA_HOST_PATH`.
|
||||
|
||||
Пары: `BITRIX_LOCAL_APP_INTERNAL_TOKEN == BITRIX_INTERNAL_API_TOKEN`, `BITRIX_API_FORWARD_TOKEN == BITRIX_API_INBOX_TOKEN`, `KEYCLOAK_SMS_SERVICE_TOKEN == SMS_SERVICE_TOKEN`.
|
||||
|
||||
`FRONTEND_DEV_PROXY_ENABLED=false`. Safety timeout согласован с nginx ВМ1. Validation — arch-10 §8; `validate-env` в этом репозитории.
|
||||
|
||||
## 5. Images и frontend
|
||||
|
||||
Pull или build без production secrets. Frontend:
|
||||
|
||||
```bash
|
||||
cd <FRONTEND_PROJECT_PATH>
|
||||
npm ci
|
||||
npm run test
|
||||
npx expo export --platform web
|
||||
```
|
||||
|
||||
Artifact в versioned `frontend-static`. Build env — только public URL/realm/client id. Secret scanner: нет service tokens/mock OTP/S3 keys.
|
||||
|
||||
Image/frontend gate: images по digest; static без secrets; nginx image с request-id/TLS; disk >30% free.
|
||||
|
||||
## 6. Root Compose ВМ1
|
||||
|
||||
Сервисы: edge `nginx`, `api-backend`, `keycloak`, `sms-service`, `sms-worker`, `bitrix-local-app`, Redis DB0/DB1, local `otel-collector`. После cutover — без `message-safety` и Redis DB2.
|
||||
|
||||
Networks: `public`, `backend`, `egress` (`sms-worker`; Keycloak входит только при `KEYCLOAK_YANDEX_CAPTCHA_ENABLED=true` и только с destination allow-list SmartCaptcha API), `observability`. `sms-service` без egress при отдельном worker.
|
||||
|
||||
Volumes: `redis-data`, ACME, `frontend-static`, `otel-queue` + `otel-queue-init`. Единственные published mappings — nginx 80/443.
|
||||
|
||||
Compose gate — arch-10 применительно к этому Compose.
|
||||
|
||||
## 7. TLS ВМ1
|
||||
|
||||
Arch-10 §9 / arch-08, `-d <PUBLIC_HOST> --cert-name <PUBLIC_HOST>`. Private `8443` на ВМ1 нет.
|
||||
|
||||
## 8. Миграции и seed ВМ1
|
||||
|
||||
Preflight/upgrade:
|
||||
|
||||
```bash
|
||||
cd <BACKEND_ROOT>
|
||||
docker compose run --rm api-backend alembic current
|
||||
docker compose run --rm bitrix-local-app alembic current
|
||||
# PITR marker, затем:
|
||||
docker compose run --rm api-backend alembic upgrade head
|
||||
docker compose run --rm bitrix-local-app alembic upgrade head
|
||||
```
|
||||
|
||||
Shared `han_app.sync_queue` мигрирует api-backend **до** sync cutover на ВМ2, но grants `bitrix_sync_user` — после обеих migrations (см. runbook ВМ2). Seed `app_settings` idempotent из этого репозитория.
|
||||
|
||||
SMS schema/migrations выполняет `sms-service` на ВМ1.
|
||||
|
||||
### Controlled rollout real SMS
|
||||
|
||||
1. seed `otp.phone.*`;
|
||||
2. schema/role `sms`, migrations, seed `sms_setting`/active `auth_otp`;
|
||||
3. test env с mock Direct;
|
||||
4. production Direct `TOKEN_1`, sender, template, callback credentials;
|
||||
5. записать `<IDGTL_STATIC_EGRESS_IP>` из `sms-worker`;
|
||||
6. deploy `sms-service`/worker и callback route nginx ВМ1, `KEYCLOAK_OTP_MOCK_ENABLED=true`;
|
||||
7. Keycloak expand migration/SPI;
|
||||
8. provider smoke на `<IDGTL_TEST_PHONE>`;
|
||||
9. только после evidence — `KEYCLOAK_OTP_MOCK_ENABLED=false`;
|
||||
10. проверить durable order, resend, limits.
|
||||
|
||||
Rollback SMS: вернуть Keycloak в mock; не удалять schema/journal. Production cutover запрещён при placeholder или нестабильном egress IP.
|
||||
|
||||
## 9. Keycloak bootstrap
|
||||
|
||||
```bash
|
||||
cd <BACKEND_ROOT>
|
||||
docker compose up -d keycloak
|
||||
```
|
||||
|
||||
Bootstrap admin только на первый запуск, затем MFA named admin и удаление bootstrap. Realm: public client PKCE S256, issuer `https://<PUBLIC_HOST>/auth/realms/han-chat`, без `--import-realm` на живой production без diff. Keycloak gate — discovery/JWKS HTTPS, OTP fail-closed, settings bridge.
|
||||
|
||||
## 10. Ordered startup ВМ1
|
||||
|
||||
После готовности ВМ2 (arch-10 §10 шаги 1–3):
|
||||
|
||||
1. Redis ВМ1, `otel-queue-init`, Collector;
|
||||
2. API, SMS, Keycloak, local app;
|
||||
3. edge nginx последним; после readiness — `nginx -t -c /tmp/nginx.conf` и HUP.
|
||||
4. `MESSAGE_SAFETY_URL` переключается на ВМ2 **только** после cutover gates runbook ВМ2 и legacy gate §14.
|
||||
|
||||
Не использовать host ports для health curl. Expected: Redis `PONG`; Keycloak ready; Collector health; API core DB/Redis/JWKS/settings ready. S3/Safety/Open Lines могут быть `degraded` без снятия read API из readiness, но send path при недоступном Safety остаётся fail-closed. Local app до install может быть `portal_not_installed`.
|
||||
|
||||
## 11. Bitrix24 local app и Open Lines
|
||||
|
||||
Install/handler/placement URL на `https://<PUBLIC_HOST>/bitrix/...`. Canonical internal path `/internal/openlines/v1/*`, не prototype `/bitrix-internal/*`. Open Lines gate: connector line 8, outbound once, operator reply, duplicate callback безопасен.
|
||||
|
||||
CRM webhook robots **не** настраиваются на ВМ1.
|
||||
|
||||
## 12. Public smoke ВМ1
|
||||
|
||||
```bash
|
||||
curl -I http://<PUBLIC_HOST>/
|
||||
curl -fsS https://<PUBLIC_HOST>/api/v1/public/app-config
|
||||
curl -fsS https://<PUBLIC_HOST>/api/v1/public/content
|
||||
curl -fsS https://<PUBLIC_HOST>/auth/realms/han-chat/.well-known/openid-configuration
|
||||
curl -i https://<PUBLIC_HOST>/internal/safety/v2/messages/check
|
||||
openssl s_client -connect <PUBLIC_HOST>:443 -servername <PUBLIC_HOST>
|
||||
```
|
||||
|
||||
Expected: 308; public 200; discovery 200; internal 404; valid cert.
|
||||
|
||||
Далее: guest content; 401 без JWT; consent → OTP → PKCE; bootstrap без phone body; `ux_session_id`; silent refresh; logout; wrong OTP. Real SMS mode — по §8.
|
||||
|
||||
Safety E2E **со стороны caller** (правила stub до v2 cutover): `ф` → `422 message_blocked`; allow path; timeout 503/504 без duplicate. Статус Safety `stub` не заменяет production AV.
|
||||
|
||||
Files: presigned PUT quarantine, promote/deny, owner-only download, нет URL в logs. Realtime/ownership/idempotency/429 — module-01.
|
||||
|
||||
Сквозной first-send до Open Lines требует готовую ВМ2; system end-to-end gate закрывается по arch-10 после обоих runbook.
|
||||
|
||||
## 13. Observability ВМ1
|
||||
|
||||
[`module-09-observability-vm1.md`](module-09-observability-vm1.md) + arch-07. Сквозной `X-Request-ID` до Safety span — совместно с ВМ2.
|
||||
|
||||
## 14. Legacy gate перед cutover Safety
|
||||
|
||||
Перед `MESSAGE_SAFETY_URL` на ВМ2:
|
||||
|
||||
1. validator принимает только `https://<private-vm2-name>:8443`, требует CA path, запрещает Docker hostname и plaintext;
|
||||
2. internal CA root-owned; read-test UID `api-backend`, negative посторонний UID;
|
||||
3. local `message-safety`, Redis DB2 и local rules-version env удалены из Compose/validator;
|
||||
4. root-owned stack unit; `deploy` не в `docker`;
|
||||
5. `DOCKER-USER` counters через `conntrack --ctorigdstport` после Docker restart и reboot;
|
||||
6. images digest; rollback по compatible digests;
|
||||
7. ordered startup этого runbook, не legacy `docker compose up -d`;
|
||||
8. `han-secrets` и firewall oneshot явно перезапущены; TLS renew success — пустой stderr.
|
||||
|
||||
Ни старый single-VM guide, ни успешный stub Compose не являются evidence. Rollback caller — предыдущий immutable release ВМ1. Уже созданные v2 tasks не down-migrate.
|
||||
|
||||
## 15. Rollback, ops, incidents ВМ1
|
||||
|
||||
Rollback application-only: previous digests, без Alembic downgrade; при SMS incident — mock OTP, сохранить journal. Redis restore — clean instance, прогрев idempotency из PG ([`module-04-redis-vm1.md`](module-04-redis-vm1.md)). Keycloak restore — проверить issuer/JWKS/PKCE/OTP.
|
||||
|
||||
Routine: health, PG/TLS/disk/OTEL/Redis, Keycloak signing, Bitrix connector desired/observed.
|
||||
|
||||
Incident triage — arch-10 команды в `<BACKEND_ROOT>` ВМ1. Типовое: API 503 (DB/Redis/JWKS/Safety circuit); send timeout — не новый idempotency key; Redis loss — polling.
|
||||
|
||||
Потеря ВМ1: provision в той же VPC, restore secrets из vault, existing PG/S3, TLS `<PUBLIC_HOST>`, Bitrix local app/callbacks verify. Не пересоздавать ВМ2.
|
||||
|
||||
## 16. Definition of Done ВМ1
|
||||
|
||||
Дополнительно к arch-10 §12:
|
||||
|
||||
- Compose/nginx/Redis/Collector ВМ1 прошли профильные compose/nginx gates;
|
||||
- Keycloak realm/provider/PKCE/OTP готов;
|
||||
- SMS либо mock с accepted risk, либо real mode после §8;
|
||||
- Bitrix connector line 8 проверен;
|
||||
- auth/text/file/realtime E2E caller-side зелёный;
|
||||
- observability ВМ1 + redaction;
|
||||
- после cutover — legacy gate §14 закрыт.
|
||||
|
||||
## 17. TBD ВМ1
|
||||
|
||||
D-TBD1, D-TBD2 (VM1 sizing/SLO), D-TBD8 CLI, D-TBD9 Keycloak admin VPN.
|
||||
|
||||
## 18. Ссылки
|
||||
|
||||
- Контракт: [`arch-10-deployment.md`](../../architectory/arch-10-deployment.md).
|
||||
- ВМ2: [`module-10-deployment-vm2.md`](../../VM2_services/documentation/module-10-deployment-vm2.md).
|
||||
- Указатель: [`module-10-deployment-runbook.md`](module-10-deployment-runbook.md).
|
||||
@@ -0,0 +1,891 @@
|
||||
# module-11. Сервис доставки SMS (i-Digital Direct)
|
||||
|
||||
> Статус: целевая проектная спецификация post-MVP (закрывает K-TBD8 / бэклог «интеграция с SMS-провайдером»).
|
||||
> Реализация отсутствует. Документ задаёт обязательные контракты для разработки `sms-service` и доработки Keycloak.
|
||||
> Источники провайдера: [Отправка SMS](https://api.docs.direct.i-dgtl.ru/messages/sms-sending/), [Авторизация](https://api.docs.direct.i-dgtl.ru/authorization/), [Callback](https://api.docs.direct.i-dgtl.ru/messages/callback/).
|
||||
> Смежные: [`module-08-keycloak.md`](module-08-keycloak.md), [`arch-01`](../../architectory/arch-01-system-architecture.md), [`arch-02`](../../architectory/arch-02-api-contracts.md), [`arch-04`](../../architectory/arch-04-settings-and-content.md), [`arch-06`](../../architectory/arch-06-service-hosting-security.md).
|
||||
|
||||
**Критерий применимости:** документ описывает target real-SMS rollout, который остаётся post-MVP backlog до реализации и закрытия gates. Текущее as-is состояние до cutover — `KEYCLOAK_OTP_MOCK_ENABLED=true`. При конфликте приоритет всегда у architectory/README; настоящий модуль не переопределяет действующий mock-only runtime сам по себе.
|
||||
|
||||
## 1. Разделение ответственности
|
||||
|
||||
| Зона | Модуль | Что хранит / делает |
|
||||
|---|---|---|
|
||||
| Доставка сообщений | **module-11 (sms-service)** | Шаблоны, журнал отправок (кому/что/когда/статусы), вызов провайдера, callback доставки |
|
||||
| Auth OTP | **module-08 (Keycloak)** | Генерация и локальная проверка кода, challenge, лимиты, **результат verify**, **контекст устройства**, ссылка на `sms_message_id` |
|
||||
|
||||
**Жёсткие правила:**
|
||||
|
||||
1. Keycloak **не** вызывает i-Digital напрямую и **не** хранит полный журнал SMS (текст, delivery status провайдера, шаблоны).
|
||||
2. sms-service **не** генерирует OTP, **не** проверяет код и **не** знает, верно ли пользователь ввёл код.
|
||||
3. Связка: Keycloak получает от sms-service `sms_message_id` и сохраняет его в своём challenge/событиях.
|
||||
4. [API верификации телефона](https://api.docs.direct.i-dgtl.ru/verifier/api/) (`/verifier/send`, `/verifier/check`) **не используется**.
|
||||
|
||||
```text
|
||||
User → nginx → Keycloak
|
||||
│ 1. generate OTP, create challenge (+ device context)
|
||||
│ 2. POST /internal/sms/v1/send → sms-service
|
||||
│ ├─ render template
|
||||
│ ├─ INSERT sms_outbound_message
|
||||
│ └─ return sms_message_id
|
||||
│ 3. сохранить sms_message_id в challenge
|
||||
│ 4. user enters code → local verify
|
||||
│ 5. записать verify outcome (+ device) в Keycloak DB
|
||||
└─ OIDC code
|
||||
|
||||
sms-service worker → POST Direct /api/v1/message → update send_status
|
||||
Direct callback → sms-service only → update delivery_status
|
||||
```
|
||||
|
||||
Текущий заказчик: `keycloak`. Процесс: `auth_otp`. Канал: `SMS`. Провайдер: `idgtl` (резервный канал — будущее расширение той же модели).
|
||||
|
||||
---
|
||||
|
||||
## 2. Границы module-11
|
||||
|
||||
### В scope
|
||||
|
||||
- отдельный сервис `sms-service` (Compose-модуль);
|
||||
- схема БД: шаблоны + журнал исходящих сообщений;
|
||||
- internal API для заказчиков (сейчас Keycloak);
|
||||
- адаптер провайдера `idgtl` (`POST /api/v1/message`, `TOKEN_1`);
|
||||
- асинхронная отправка worker-ом и обновление статусов отправки/доставки;
|
||||
- секреты провайдера, health/metrics.
|
||||
- OpenAPI 3.1 для internal send/read API и JSON Schema callback;
|
||||
- бессрочный журнал отправок и reconciliation зависших `pending`/`uncertain`.
|
||||
|
||||
### Вне scope
|
||||
|
||||
- генерация/проверка OTP;
|
||||
- product limits `otp.phone.*` (остаются в Keycloak);
|
||||
- каскады VK/WhatsApp, FLASHCALL, рассылки;
|
||||
- публичный API для frontend;
|
||||
- решение «пользователь авторизован» / выдача токенов.
|
||||
|
||||
---
|
||||
|
||||
## 3. Модель данных module-11
|
||||
|
||||
Схема: отдельная managed PostgreSQL schema, например `sms` (роль `sms_user`). App DB `han_app` и schema `keycloak` **не** используются для журнала SMS.
|
||||
|
||||
### 3.1. `sms_template` — шаблоны
|
||||
|
||||
Шаблон **не** хранится в env. Env только credentials/timeouts провайдера.
|
||||
|
||||
| Поле | Тип | Описание |
|
||||
|---|---|---|
|
||||
| `id` | UUID PK | Идентификатор версии шаблона |
|
||||
| `code` | varchar | Стабильный код, напр. `auth_otp` |
|
||||
| `channel` | enum | `SMS` (расширяемо) |
|
||||
| `locale` | varchar | напр. `ru` |
|
||||
| `version` | int | Монотонная версия внутри `code`+`channel`+`locale` |
|
||||
| `body_template` | text | Текст с плейсхолдерами, напр. `Код входа в HAN Chat: {code}. Действителен {ttl_min} мин.` |
|
||||
| `placeholders` | jsonb | Описание обязательных ключей: `["code","ttl_min"]` |
|
||||
| `sender_name` | varchar | Имя отправителя для этого шаблона (или null → default провайдера) |
|
||||
| `max_parts` | int | Максимально допустимое число SMS-частей; для `auth_otp` — `1` |
|
||||
| `is_active` | bool | Активная версия для `code` (ровно одна active на code+channel+locale) |
|
||||
| `approved_at` | timestamptz | Согласование с оператором/провайдером |
|
||||
| `created_at` / `updated_at` | timestamptz | Аудит |
|
||||
| `created_by` | varchar | ops/system |
|
||||
|
||||
Seed первой версии: `code=auth_otp`, `channel=SMS`, `locale=ru`.
|
||||
|
||||
### 3.2. Настройки SMS и OTP
|
||||
|
||||
Параметры, изменение которых не требует изменения Compose, секретов или сетевой топологии, в `.env` не хранятся.
|
||||
|
||||
**OTP-настройки в `han_app.app_settings`** (владелец продукта, потребитель — Keycloak через settings bridge):
|
||||
|
||||
| Ключ | Тип | Seed | Назначение |
|
||||
|---|---|---:|---|
|
||||
| `otp.phone.code_length` | integer | `6` | Длина numeric OTP |
|
||||
| `otp.phone.ttl_seconds` | integer | `60` | Срок жизни OTP от `ordered_at`; диапазон `60..900`, значение кратно 60 |
|
||||
| `otp.phone.sms_order_timeout_ms` | integer | `3000` | Timeout Keycloak → sms-service только на durable order |
|
||||
|
||||
Эти ключи возвращаются существующим `GET /internal/settings/v1/otp` вместе с лимитами и `version`. Keycloak сохраняет snapshot `otp_ttl_sec`, `otp_code_length` и `settings_version` в challenge. Изменение settings действует только на новые challenges.
|
||||
|
||||
**Технические настройки в `sms.sms_setting`** (владелец — `sms-service`):
|
||||
|
||||
| Ключ | Тип | Seed | Назначение |
|
||||
|---|---|---:|---|
|
||||
| `provider.idgtl.default_sender_name` | string | согласованное имя | Default, если sender отсутствует в шаблоне |
|
||||
| `provider.idgtl.connect_timeout_ms` | integer | `3000` | Connect timeout worker → Direct |
|
||||
| `provider.idgtl.request_timeout_ms` | integer | `70000` | Total/read timeout worker → Direct |
|
||||
| `provider.idgtl.callback_enabled` | boolean | `true` | Включение callback в production |
|
||||
| `worker.poll_interval_ms` | integer | `500` | Интервал поиска pending-заказов |
|
||||
| `worker.lease_seconds` | integer | `90` | Lease записи на время внешнего вызова |
|
||||
|
||||
Минимальные поля `sms_setting`: `setting_key` PK, `setting_value`, `value_type`, `description`, `updated_at`. Seed выполняется versioned migration. `sms-service` валидирует обязательные ключи при startup, кэширует их и периодически перечитывает по `updated_at`; некорректное значение не применяется и вызывает alert.
|
||||
|
||||
### 3.3. `sms_outbound_message` — журнал отправок
|
||||
|
||||
Каждый заказ Keycloak на новую SMS — одна строка. Повторные HTTP-попытки worker по тому же заказу увеличивают `attempt_count`, но не создают новую строку. Resend создаёт новый challenge и новую строку. Это **источник истины** «когда, кому и какой текст заказали, что произошло при отправке и доставке».
|
||||
|
||||
| Поле | Тип | Обязательность | Описание |
|
||||
|---|---|---|---|
|
||||
| `id` | UUID PK | да | **`sms_message_id`** — то, на что ссылается Keycloak |
|
||||
| `created_at` | timestamptz | да | Создание записи (до/в момент вызова провайдера) |
|
||||
| `requested_at` | timestamptz | да | Время запроса от заказчика |
|
||||
| `accepted_at` | timestamptz | нет | Провайдер принял сообщение |
|
||||
| `sent_at` | timestamptz | нет | Статус sent от провайдера/callback |
|
||||
| `delivered_at` | timestamptz | нет | delivered |
|
||||
| `updated_at` | timestamptz | да | Последнее изменение статусов |
|
||||
| `requester_service` | varchar | да | Заказчик: сейчас `keycloak`; позже др. сервисы |
|
||||
| `process` | varchar | да | Бизнес-процесс: сейчас `auth_otp` |
|
||||
| `channel` | varchar | да | `SMS` |
|
||||
| `provider` | varchar | да | Сервис доставки: сейчас `idgtl`; резерв — новый код |
|
||||
| `phone_e164` | varchar | да | Кому: E.164 (`+79001234567`) |
|
||||
| `phone_digits` | varchar | да | Как у провайдера: `79001234567` |
|
||||
| `phone_masked` | varchar | да | Для UI/ops без полного номера |
|
||||
| `template_id` | UUID FK | да | Ссылка на `sms_template.id` |
|
||||
| `template_code` | varchar | да | Денормализация `auth_otp` |
|
||||
| `body_rendered` | text | да | Итоговый текст, ушедший провайдеру |
|
||||
| `substitutions` | jsonb | да | Подстановки (`code`, `ttl_min`, …) |
|
||||
| `send_status` | enum | да | Статус **отправки** (наш/accept) |
|
||||
| `delivery_status` | enum | да | Статус **доставки** (провайдер) |
|
||||
| `provider_message_id` | varchar | нет | `messageUuid` Direct |
|
||||
| `provider_external_id` | varchar | нет | `externalMessageId`, отправленный в Direct |
|
||||
| `customer_ref` | varchar | нет | Корреляция заказчика (напр. Keycloak `challenge_id`) |
|
||||
| `idempotency_key` | varchar | да | Уникальный ключ от заказчика; защита от дублей |
|
||||
| `request_fingerprint` | varchar | да | SHA-256 канонического значимого payload для обнаружения повторного ключа с другим запросом |
|
||||
| `request_id` | varchar | нет | `X-Request-ID` / trace |
|
||||
| `provider_http_status` | int | нет | HTTP ответа Direct |
|
||||
| `provider_error_code` | varchar | нет | Код ошибки провайдера |
|
||||
| `provider_error_message` | varchar | нет | Краткий класс/текст ошибки (без секретов) |
|
||||
| `sender_name` | varchar | да | Фактически использованное имя |
|
||||
| `message_ttl_sec` | int | нет | TTL у провайдера |
|
||||
| `attempt_count` | int | да | Число HTTP-попыток к провайдеру |
|
||||
| `last_attempt_at` | timestamptz | нет | Время последней попытки worker |
|
||||
| `next_attempt_at` | timestamptz | нет | Когда разрешена следующая однозначно безопасная попытка |
|
||||
| `worker_locked_until` | timestamptz | нет | Lease фонового worker для защиты от параллельной обработки |
|
||||
| `parts` / `price` / `currency` | — | нет | Из price-callback, если включён |
|
||||
| `callback_last_at` | timestamptz | нет | Последний callback |
|
||||
|
||||
#### Enum `send_status` (отправка)
|
||||
|
||||
| Значение | Смысл |
|
||||
|---|---|
|
||||
| `pending` | Запись создана, вызов провайдера ещё не завершён |
|
||||
| `accepted` | Провайдер принял (`errors=false`, success item code) |
|
||||
| `rejected` | Провайдер отклонил (4xx бизнес) |
|
||||
| `failed` | Однозначный технический сбой до передачи запроса провайдеру |
|
||||
| `uncertain` | Результат внешнего вызова неизвестен: запрос мог быть принят, но подтверждение не получено |
|
||||
| `skipped` | Не вызывали провайдера (напр. dry-run/dev) |
|
||||
|
||||
#### Enum `delivery_status` (доставка)
|
||||
|
||||
| Значение | Смысл |
|
||||
|---|---|
|
||||
| `unknown` | Ещё нет данных о доставке |
|
||||
| `sent` | Отправлено оператору |
|
||||
| `delivered` | Доставлено |
|
||||
| `undelivered` | Не доставлено за TTL |
|
||||
| `unsent` | Не отправлено |
|
||||
|
||||
`send_status` и `delivery_status` — **разные** оси и относятся только к журналу `sms-service`. Keycloak не читает их, не ждёт и не использует при проверке OTP. Безопасность обеспечивается тем, что корректный код известен только Keycloak и получателю SMS.
|
||||
|
||||
### 3.4. Дополнительные поля (рекомендации)
|
||||
|
||||
Имеет смысл заложить сразу:
|
||||
|
||||
| Поле | Зачем |
|
||||
|---|---|
|
||||
| `idempotency_key` UNIQUE | Повтор Keycloak при timeout не создаёт вторую SMS |
|
||||
| `customer_ref` | Связь с challenge без join через другие БД |
|
||||
| `phone_masked` | Ops-выборки без полного MSISDN |
|
||||
| `attempt_count` + timestamps | Диагностика retry |
|
||||
| `provider` как код | Переключение/failover без смены схемы |
|
||||
| `template_id` + `template_code` | Аудит «какой текст был согласован» |
|
||||
| `request_id` | Сквозная трассировка |
|
||||
| архивирование/партиционирование | Журнал хранится бессрочно; при росте объёма используются месячные partition и перенос старых partition в архивный storage без удаления данных |
|
||||
|
||||
**Хранение журнала:**
|
||||
|
||||
- application-level encryption текста и substitutions не применяется: после истечения OTP они не дают возможности авторизоваться, а отдельный контур ключей несоразмерно усложняет реализацию;
|
||||
- используется штатное encryption at rest managed PostgreSQL и backups;
|
||||
- OTP действует `challenge.otp_ttl_sec` от `ordered_at`; snapshot берётся из `app_settings["otp.phone.ttl_seconds"]`, после истечения код не принимается независимо от состояния SMS;
|
||||
- автоматическое удаление, очистка или обезличивание строк журнала запрещены;
|
||||
- текст, substitutions, телефон, provider IDs, статусы и timestamps сохраняются бессрочно для будущего аудита и аналитики;
|
||||
- при росте объёма допускаются PostgreSQL partitioning, сжатие backup и перенос старых partition в архивное хранилище при сохранении возможности восстановления/выборки;
|
||||
- удаление возможно только отдельной утверждённой процедурой по юридическому требованию или запросу субъекта данных, с audit события;
|
||||
- hash итогового текста/OTP отдельно не хранится;
|
||||
- полный телефон доступен только роли `sms_user`; ops/read API по умолчанию возвращает mask;
|
||||
- доступ к raw `body_rendered`/`substitutions` разрешён только `sms_user`; internal read API их не возвращает.
|
||||
|
||||
В логах/метриках текст, OTP, полный телефон, callback credentials и Authorization **запрещены**.
|
||||
|
||||
### 3.5. Индексы
|
||||
|
||||
- UNIQUE(`requester_service`, `idempotency_key`);
|
||||
- UNIQUE(`provider`, `provider_message_id`) where not null;
|
||||
- (`phone_e164`, `created_at DESC`);
|
||||
- (`requester_service`, `process`, `created_at DESC`);
|
||||
- (`customer_ref`);
|
||||
- (`send_status`, `created_at`);
|
||||
- (`delivery_status`, `updated_at`).
|
||||
- UNIQUE(`code`, `channel`, `locale`, `version`) для шаблонов;
|
||||
- UNIQUE partial (`code`, `channel`, `locale`) where `is_active=true`.
|
||||
|
||||
Все enum/check constraints и индексы создаются versioned-миграциями. DDL-on-start запрещён.
|
||||
|
||||
---
|
||||
|
||||
## 4. Internal API module-11 (для заказчиков)
|
||||
|
||||
Только закрытая Docker-сеть `backend`. Auth: `Authorization: Bearer <token>`.
|
||||
|
||||
- `KEYCLOAK_SMS_SERVICE_TOKEN` передаёт Keycloak; значение равно `SMS_SERVICE_TOKEN`, который проверяет `sms-service`;
|
||||
- токен — random secret не менее 32 bytes, constant-time compare, без вывода в логи;
|
||||
- в v1 разрешён только caller `keycloak` и только process/template `auth_otp`;
|
||||
- `requester_service`, `process`, `channel` и `provider` не считаются доверенными данными запроса: сервис сверяет их с allowlist токена либо подставляет серверные значения;
|
||||
- `X-Request-ID` и `traceparent` передаются сквозным образом;
|
||||
- rate limit по caller + destination HMAC обязателен как дополнительная защита при компрометации service token.
|
||||
|
||||
### 4.1. `POST /internal/sms/v1/send`
|
||||
|
||||
Запрос:
|
||||
|
||||
```text
|
||||
{
|
||||
"idempotency_key": "keycloak:challenge:01JABCDEF",
|
||||
"template_code": "auth_otp",
|
||||
"locale": "ru",
|
||||
"phone_e164": "+79001234567",
|
||||
"substitutions": {
|
||||
"code": "482193",
|
||||
"ttl_min": "<challenge.otp_ttl_sec / 60>"
|
||||
},
|
||||
"customer_ref": "01JABCDEF",
|
||||
"message_ttl_sec": <challenge.otp_ttl_sec>
|
||||
}
|
||||
```
|
||||
|
||||
`message_ttl_sec` равен snapshot `app_settings["otp.phone.ttl_seconds"]` для challenge. `ttl_min` вычисляется из того же snapshot; настройка обязана быть кратна 60.
|
||||
|
||||
`requester_service=keycloak`, `process=auth_otp`, `channel=SMS`, `provider=idgtl` определяются сервером по service token/route. `request_id` передаётся только заголовком `X-Request-ID` и не входит в idempotency fingerprint.
|
||||
|
||||
Поведение:
|
||||
|
||||
1. Проверить service token и allowlist caller/process/template/provider.
|
||||
2. Нормализовать и повторно проверить E.164; `phone_digits` должен однозначно соответствовать `phone_e164`.
|
||||
3. Проверить `message_ttl_sec` в диапазоне Direct `60..86400`, длины полей и строгий набор substitutions; неизвестные/пропущенные placeholder → `422`.
|
||||
4. Рассчитать `request_fingerprint` по каноническому значимому payload.
|
||||
5. Если `(requester_service,idempotency_key)` уже есть:
|
||||
- fingerprint совпадает → вернуть сохранённый результат без нового внешнего вызова;
|
||||
- fingerprint отличается → `409 idempotency_key_reused`.
|
||||
Конкурентная вставка разрешается UNIQUE constraint: проигравшая transaction перечитывает существующую запись и применяет те же правила fingerprint.
|
||||
6. Найти единственный active `sms_template` по `template_code`+`channel`+`locale`; locale fallback в v1 отсутствует.
|
||||
7. Срендерить `body_rendered`; проверить лимит длины, UTF-8 без BOM и ожидаемое число SMS-частей.
|
||||
8. В одной DB transaction вставить `sms_outbound_message` (`send_status=pending`, `delivery_status=unknown`, `next_attempt_at=now`).
|
||||
9. Commit гарантирует, что заказ на отправку сохранён.
|
||||
10. Немедленно вернуть `sms_message_id`; внешний API Direct в обработчике этого запроса не вызывается.
|
||||
11. Фоновый worker выбирает готовые `pending` через lease/`FOR UPDATE SKIP LOCKED`, вызывает адаптер `idgtl` и обновляет journal row.
|
||||
|
||||
Ответ `202 Accepted` для нового заказа:
|
||||
|
||||
```json
|
||||
{
|
||||
"sms_message_id": "9f3c…",
|
||||
"ordered_at": "2026-07-22T13:00:00Z"
|
||||
}
|
||||
```
|
||||
|
||||
Ошибки используют envelope из `arch-02`: `401 unauthorized`, `409 idempotency_key_reused`, `422 sms_request_invalid`, `429 rate_limit_exceeded`, `503 sms_service_unavailable`.
|
||||
|
||||
Правило ответа:
|
||||
|
||||
- `202` означает только «заказ надёжно записан в БД sms-service», но не подтверждает отправку или доставку;
|
||||
- идемпотентный повтор с тем же fingerprint возвращает `200` и тот же `sms_message_id` независимо от текущего provider status;
|
||||
- ошибки до commit journal row возвращаются соответствующим 4xx/5xx;
|
||||
- Keycloak считает задачу «заказать SMS» выполненной при `200`/`202` и наличии `sms_message_id`;
|
||||
- Keycloak не анализирует и не запрашивает `send_status`, `delivery_status` или `provider_message_id`.
|
||||
|
||||
### 4.2. `GET /internal/sms/v1/messages/{sms_message_id}`
|
||||
|
||||
Для диагностики заказчика. Доступ Keycloak разрешён только к сообщениям `requester_service=keycloak`. Endpoint никогда не отдаёт OTP, substitutions или полный итоговый текст, в том числе через privileged flag. Телефон всегда masked.
|
||||
|
||||
### 4.3. Callback от Direct
|
||||
|
||||
Публичный endpoint: `POST /callbacks/idgtl/sms` через root nginx. Префикс `/internal/*` для callback запрещён.
|
||||
|
||||
Защита:
|
||||
|
||||
- только HTTPS;
|
||||
- nginx allowlist source IP `185.203.96.7`; изменение IP требует сверки с актуальной документацией Direct;
|
||||
- Basic auth callback (`IDGTL_SMS_CALLBACK_USERNAME` / `IDGTL_SMS_CALLBACK_PASSWORD`), который Direct поддерживает через credentials в `callbackUrl`;
|
||||
- URL с credentials и Authorization редактируются во всех логах/traces;
|
||||
- service дополнительно проверяет `channel_type=SMS`, известный `message_uuid` и соответствие `external_message_id`.
|
||||
|
||||
Обработка:
|
||||
|
||||
- callback body — массив; каждый item валидируется и обрабатывается независимо;
|
||||
- дедупликация по `(message_uuid, callback_event, status, status_time)`;
|
||||
- повторы ожидаемы: при отсутствии 2xx Direct повторяет callback каждые 5 минут в течение суток;
|
||||
- `status_time` провайдера сохраняется как время статуса; `callback_last_at` — время получения;
|
||||
- переходы монотонны: поздний `sent` не понижает `delivered`/`undelivered`/`unsent`;
|
||||
- неизвестный/противоречивый item пишется в security log без PII и не изменяет запись;
|
||||
- 2xx возвращается только после успешной фиксации всех валидных items; transient DB failure → 5xx для повтора.
|
||||
|
||||
Callback обновляет только `delivery_status`, timestamps, error code и price. **Не** уведомляет Keycloak и **не** влияет на verify.
|
||||
|
||||
---
|
||||
|
||||
## 5. Адаптер провайдера `idgtl`
|
||||
|
||||
### 5.1. Вызов
|
||||
|
||||
```http
|
||||
POST https://direct.i-dgtl.ru/api/v1/message
|
||||
Authorization: Basic {TOKEN_1}
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
```text
|
||||
[
|
||||
{
|
||||
"channelType": "SMS",
|
||||
"senderName": "<from template or default>",
|
||||
"destination": "79001234567",
|
||||
"content": "<body_rendered>",
|
||||
"externalMessageId": "<sms_message_id>",
|
||||
"ttl": <message_ttl_sec>,
|
||||
"callbackUrl": "https://<basic-credentials>@tohin.ru/callbacks/idgtl/sms",
|
||||
"callbackEvents": ["delivered", "sent"]
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
Успех: только HTTP 200, `errors=false`, ровно один response item, `item.code=201`, валидный `messageUuid` и совпадающий `externalMessageId` → `send_status=accepted`.
|
||||
|
||||
Маппинг остальных результатов:
|
||||
|
||||
- HTTP `401`/`402`/`403`/`422` → `rejected`, без retry; сохранить provider error code и безопасный класс ошибки;
|
||||
- HTTP 200 с `errors=true`, отсутствующим item, `item.code!=201`, неверным `externalMessageId` или невалидным `messageUuid` → `rejected` и alert о нарушении provider contract;
|
||||
- connect failure до установления соединения → `failed`; допускается ограниченный retry с jitter;
|
||||
- полученный явный `503` до такого подтверждения → `uncertain`; retry разрешается только после письменного подтверждения Direct, что сообщение не создано;
|
||||
- read timeout, connection reset после отправки body, `502`/`504` и любой ответ, при котором неизвестно, создал ли Direct сообщение, → `uncertain`, **без автоматического retry**.
|
||||
|
||||
`externalMessageId` всегда равен `sms_message_id` и не использует `customer_ref`.
|
||||
|
||||
### 5.2. Таймауты и защита от дублей
|
||||
|
||||
Direct рекомендует ожидание ответа до 70 секунд. Фактические значения берутся из settings:
|
||||
|
||||
- connect timeout worker → Direct — `sms_setting["provider.idgtl.connect_timeout_ms"]`;
|
||||
- total/read timeout worker → Direct — `sms_setting["provider.idgtl.request_timeout_ms"]`;
|
||||
- timeout Keycloak → sms-service для записи заказа — snapshot `app_settings["otp.phone.sms_order_timeout_ms"]`;
|
||||
- ожидание Direct происходит только в background worker и не удерживает Keycloak auth request;
|
||||
- при превышении provider request timeout результат считается `uncertain`; новый вызов Direct с тем же или другим `externalMessageId` автоматически не выполняется.
|
||||
|
||||
Local idempotency защищает только от повторного запроса Keycloak к `sms-service`. Она **не доказывает** идемпотентность Direct. До письменного подтверждения провайдера `externalMessageId` считается корреляцией, а не idempotency key.
|
||||
|
||||
### 5.3. Env (только infra, не шаблоны)
|
||||
|
||||
```text
|
||||
KEYCLOAK_SMS_SERVICE_URL=http://sms-service:8080
|
||||
SMS_SERVICE_TOKEN=<secret checked by sms-service>
|
||||
KEYCLOAK_SMS_SERVICE_TOKEN=<same secret used by Keycloak>
|
||||
SMS_DATABASE_URL=postgresql://sms_user:...@<managed-pg>/<db>?...
|
||||
IDGTL_SMS_BASE_URL=https://direct.i-dgtl.ru
|
||||
IDGTL_SMS_API_KEY=<TOKEN_1>
|
||||
IDGTL_SMS_CALLBACK_PUBLIC_URL=https://tohin.ru/callbacks/idgtl/sms
|
||||
IDGTL_SMS_CALLBACK_USERNAME=<random>
|
||||
IDGTL_SMS_CALLBACK_PASSWORD=<random>
|
||||
```
|
||||
|
||||
Здесь намеренно отсутствуют OTP TTL/length/order timeout, sender default, provider timeouts, callback flag и worker intervals: они хранятся в `app_settings` или `sms.sms_setting` согласно §3.2.
|
||||
|
||||
`KEYCLOAK_OTP_MOCK_ENABLED=true` — Keycloak **не** вызывает sms-service (текущий MVP).
|
||||
`false` + sms-service down/unconfigured — новый заказ SMS завершается generic unavailable; уже созданные active challenges продолжают локальную проверку до TTL.
|
||||
|
||||
`IDGTL_SMS_API_KEY` содержит выданный Direct готовый API key для Basic (`TOKEN_1`); повторно Base64-кодировать его запрещено. При возможности у Direct включается outbound IP allowlist на egress IP VM.
|
||||
|
||||
`senderName` обязателен у Direct. Если он отсутствует и в active template, и в `sms_setting["provider.idgtl.default_sender_name"]`, readiness=false и отправка запрещена.
|
||||
|
||||
### 5.4. Запрещено
|
||||
|
||||
| Метод | Почему |
|
||||
|---|---|
|
||||
| `/api/v1/verifier/send` | код генерирует провайдер |
|
||||
| `/api/v1/verifier/check` | проверка у провайдера |
|
||||
| вызов Direct из Keycloak | нарушает границу module-11 |
|
||||
|
||||
---
|
||||
|
||||
## 6. Что хранит Keycloak (module-08) — отдельно
|
||||
|
||||
Keycloak остаётся владельцем auth-факта. Расширить provider-owned таблицы в schema `keycloak` (не копировать журнал SMS).
|
||||
|
||||
Текущая реализация mock-only должна быть изменена: `Config` больше не запрещает startup при `KEYCLOAK_OTP_MOCK_ENABLED=false`, а `OtpStore.reserve()` не должен хешировать постоянный `KEYCLOAK_OTP_MOCK_CODE` в real mode.
|
||||
|
||||
### 6.1. Challenge + ссылка на SMS
|
||||
|
||||
`han_otp_challenge` (расширение):
|
||||
|
||||
| Поле | Описание |
|
||||
|---|---|
|
||||
| существующие | `id`, `phone_hmac`, `destination_masked`, `otp_hash`, TTL, `verify_attempts`, `consumed_at`, … |
|
||||
| `sms_message_id` | UUID из module-11; **логическая** ссылка (FK между БД нет) |
|
||||
| `delivery_mode` | `mock` / `sms` — snapshot режима challenge |
|
||||
| `challenge_status` | `ordering` / `active` / `consumed` / `superseded` / `expired` / `limited` / `order_failed` |
|
||||
| `ordered_at` | Когда sms-service надёжно принял заказ; с этого момента challenge `active` |
|
||||
| `otp_ttl_sec` | Snapshot `app_settings["otp.phone.ttl_seconds"]` |
|
||||
| `otp_code_length` | Snapshot `app_settings["otp.phone.code_length"]` |
|
||||
| `settings_version` | Версия набора OTP settings из bridge |
|
||||
|
||||
Raw OTP и полный текст SMS в Keycloak **не** хранятся (только `otp_hash`).
|
||||
|
||||
Keycloak не хранит provider send/delivery status. В real mode `expires_at = ordered_at + otp_ttl_sec`. `sms_message_id` обязателен для `active` real-mode challenge и nullable для mock/`order_failed`.
|
||||
|
||||
Переходы:
|
||||
|
||||
- `ordering → active` после HTTP `200`/`202` от sms-service;
|
||||
- `ordering → order_failed` при невозможности надёжно записать заказ;
|
||||
- `active → consumed` после верного кода;
|
||||
- `active → superseded` при запросе новой SMS;
|
||||
- `active → expired` после `expires_at`;
|
||||
- `active → limited` после исчерпания verify attempts.
|
||||
|
||||
Никакой переход не зависит от `send_status` или `delivery_status` в sms-service.
|
||||
|
||||
### 6.2. Результат ввода кода пользователем
|
||||
|
||||
Источник истины verify — Keycloak.
|
||||
|
||||
**A. Агрегат на challenge** (текущее + уточнение):
|
||||
|
||||
- `challenge_status`, `verify_attempts`, `consumed_at`, `expires_at`;
|
||||
- итоговый outcome определяется только состоянием challenge и результатом локального сравнения OTP.
|
||||
|
||||
**B. Append-only события** `han_otp_security_event` (обязательно на **каждую** попытку ввода):
|
||||
|
||||
| Поле | Описание |
|
||||
|---|---|
|
||||
| `id` | UUID события |
|
||||
| `occurred_at` | Когда пользователь отправил код |
|
||||
| `event_type` | `otp_verify` |
|
||||
| `challenge_id` | Ссылка на challenge |
|
||||
| `sms_message_id` | Копия ссылки на отправленное SMS (денормализация для выборок) |
|
||||
| `phone_hmac` | Без raw phone |
|
||||
| `outcome` | `success` / `failure` / `limited` / `expired` / `already_used` |
|
||||
| `details` | `invalid` / `attempt_limit` / … |
|
||||
| device-поля | см. §6.3 |
|
||||
|
||||
Так отвечаем на вопрос «верно/неверно ввёл»: **только** в Keycloak (`han_otp_security_event` + состояние challenge), со ссылкой на `sms_message_id`.
|
||||
|
||||
Событие `otp_send` при успехе заказа SMS тоже пишет `sms_message_id`.
|
||||
|
||||
### 6.3. Контекст устройства (на send и на каждую verify-попытку)
|
||||
|
||||
Фиксировать в событии (и/или snapshot на challenge при send):
|
||||
|
||||
| Поле | Источник | Описание |
|
||||
|---|---|---|
|
||||
| `client_ip` | trusted proxy (`X-Forwarded-For` от nginx) | IP |
|
||||
| `user_agent` | заголовок | UA строка |
|
||||
| `device_id` | клиент (theme/form/auth note) | Стабильный id устройства приложения |
|
||||
| `fingerprint` | клиент | Browser/device fingerprint (не секрет auth) |
|
||||
| `os_name` / `os_version` | клиент | ОС |
|
||||
| `platform` | клиент | `web` / `ios` / `android` |
|
||||
| `app_version` | клиент | Версия приложения (если есть) |
|
||||
|
||||
Правила:
|
||||
|
||||
- device metadata **не** заменяет phone OTP;
|
||||
- IP только из trusted hop nginx;
|
||||
- в логах fingerprint/device_id допустимы; не логировать OTP.
|
||||
|
||||
Механизм передачи зафиксирован:
|
||||
|
||||
1. Frontend добавляет в OIDC authorization request необязательные параметры `han_device_id`, `han_fingerprint`, `han_platform`, `han_os_name`, `han_os_version`, `han_app_version`.
|
||||
2. `PhoneIdentityAuthenticator.authenticate()` читает их только на первом шаге, валидирует и сохраняет в auth session notes. Это недоверенные audit metadata, а не auth-фактор.
|
||||
3. Ограничения: `device_id`/`fingerprint` ≤ 256 символов; OS/app version ≤ 64; `platform` только `web`/`ios`/`android`; control characters запрещены.
|
||||
4. Для web при отсутствии `han_device_id` theme создаёт random UUID, хранит его в `localStorage` и отправляет hidden field формы телефона; native-клиент передаёт свой stable installation id.
|
||||
5. `client_ip` берётся сервером из trusted proxy chain, `user_agent` — из текущего HTTP-запроса на каждой send/verify попытке; клиент их не задаёт.
|
||||
6. Snapshot device fields копируется в `otp_send` и каждое `otp_verify` event. Новые значения hidden fields могут обновить snapshot перед verify.
|
||||
7. Nginx/Keycloak access logs для `/auth` используют path без query string либо редактируют `han_*`, чтобы device identifiers не размножались в технических логах.
|
||||
8. `phone.ftl` и `otp.ftl` получают hidden fields/атрибуты через SPI; `otp.ftl` строит число digit inputs из `challenge.otp_code_length`, countdown — из `expires_at`, без hardcoded `6`/`0:59`.
|
||||
|
||||
После успешного OTP те же device metadata по-прежнему уходят в `POST /auth/bootstrap` (arch-02) для App DB — это **другой** контур (продуктовая сессия), не замена Keycloak OTP audit.
|
||||
|
||||
### 6.4. Чего Keycloak не делает
|
||||
|
||||
- не пишет `body_rendered` / delivery callback;
|
||||
- не держит шаблоны;
|
||||
- не вызывает Direct.
|
||||
|
||||
---
|
||||
|
||||
## 7. Поток end-to-end
|
||||
|
||||
1. Пользователь вводит телефон (+ device context попадает в Keycloak session).
|
||||
2. Keycloak применяет уже реализованные send limits/cooldown/counters.
|
||||
3. В короткой transaction Keycloak:
|
||||
- помечает прежний `active`/`ordering` challenge этого телефона как `superseded`;
|
||||
- генерирует новый криптографически случайный numeric OTP длиной `settings_snapshot.otp_code_length`;
|
||||
- сохраняет только HMAC;
|
||||
- создаёт новый challenge со статусом `ordering`;
|
||||
- резервирует одну send attempt по действующим правилам counters.
|
||||
4. Keycloak формирует `idempotency_key=keycloak:challenge:{challenge_id}` и вызывает `POST /internal/sms/v1/send` вне DB transaction.
|
||||
5. sms-service валидирует запрос, сохраняет journal row и сразу возвращает `sms_message_id` (`202`; при идемпотентном повторе — `200`). Direct ещё может не быть вызван.
|
||||
6. Keycloak сохраняет `sms_message_id`, `ordered_at=now`, `expires_at=ordered_at+challenge.otp_ttl_sec`, переводит challenge в `active`, пишет событие `otp_send/ordered` и показывает форму кода.
|
||||
7. Background worker sms-service отправляет SMS в Direct и обновляет журнал. Результаты отправки/доставки не передаются в Keycloak и не меняют challenge.
|
||||
8. Пользователь вводит код (+ тот же/обновлённый device context).
|
||||
9. Keycloak проверяет только `challenge_status=active`, TTL, verify limits и локальный HMAC:
|
||||
- верный код → `consumed`, событие success, завершение OIDC flow;
|
||||
- неверный → increment verify attempts и failure event;
|
||||
- attempts exhausted → `limited`;
|
||||
- `now >= expires_at` → `expired`.
|
||||
10. Если пользователь запрашивает новую SMS, поток повторяется с шага 2; прежний challenge становится `superseded`, поэтому его код больше не принимается.
|
||||
11. Periodic expiry job помечает оставшиеся `active` challenges как `expired` после `expires_at`; verify также выполняет этот переход лениво, если job ещё не успел. Изменение текущего `otp.phone.ttl_seconds` не пересчитывает `expires_at` существующих challenges.
|
||||
12. Direct callback обновляет только журнал sms-service.
|
||||
|
||||
Если sms-service не подтвердил durable order (`200`/`202`), новый challenge становится `order_failed`; прежний уже остаётся `superseded`. Frontend получает generic unavailable и может начать новый resend с учётом counters.
|
||||
|
||||
Mock-режим: внешний заказ не создаётся; challenge сразу получает `active`, `sms_message_id=null`, а остальные TTL/verify/resend/counter rules идентичны real mode.
|
||||
|
||||
**Граница транзакций Keycloak:** HTTP-вызов sms-service не выполняется внутри transaction с блокировкой counters/challenge. Создание `ordering` и перевод в `active`/`order_failed` — отдельные короткие transaction. Повтор после потерянного HTTP-ответа использует тот же challenge/idempotency key и не создаёт вторую SMS.
|
||||
|
||||
---
|
||||
|
||||
## 8. Безопасность
|
||||
|
||||
- Direct credentials только в sms-service.
|
||||
- Internal SMS API недоступен из публичной сети.
|
||||
- OTP в `substitutions`/`body_rendered` хранится как часть закрытого журнала, но никогда не попадает в logs/traces/read API; после `challenge.expires_at` Keycloak его не принимает.
|
||||
- Keycloak хранит только hash OTP и `sms_message_id`.
|
||||
- Enumeration: ошибки send/verify наружу generic + request id.
|
||||
- Service token Keycloak→sms-service и callback credentials различны; ротация через secret store.
|
||||
- TLS certificate Direct проверяется стандартным trust store; `verify=false` запрещён.
|
||||
- Шаблоны редактируются только controlled migration/ops-процедурой; active version требует `approved_at`.
|
||||
- API key Direct ограничивается типом TOKEN_1 и, если поддержано, egress IP.
|
||||
|
||||
---
|
||||
|
||||
## 9. Наблюдаемость
|
||||
|
||||
**sms-service:** `sms_send_total{provider,send_status}`, provider latency, `sms_uncertain_total`, callback counters/lag, pending age, journal size/partition age; логи: `sms_message_id`, `provider_message_id`, `requester_service`, `process` — без phone plaintext/OTP/body.
|
||||
|
||||
**Keycloak:** существующие OTP metrics + verify outcomes; в audit events — `sms_message_id`, device fields.
|
||||
|
||||
Alerting: 401/402 у Direct, contract violation, любой `uncertain`, рост `failed`, callback lag, зависшие pending, аномальный рост журнала, sms-service not-ready.
|
||||
|
||||
`/health/live` проверяет процесс. `/health/ready` проверяет DB/schema, active approved template, sender/API key configuration; кратковременная недоступность Direct отражается отдельным dependency status и метрикой, но не вызывает restart loop.
|
||||
|
||||
---
|
||||
|
||||
## 10. Совместимость документов
|
||||
|
||||
| Документ | Изменение при внедрении |
|
||||
|---|---|
|
||||
| module-08 | `OtpDeliveryProvider` вызывает **sms-service**, не Direct; challenge + events + device (§6) |
|
||||
| arch-01/02 | Новый internal сервис; направление Keycloak → sms-service → Direct |
|
||||
| arch-03 | Compose-сервис `sms-service`, schema `sms`, сеть backend |
|
||||
| arch-04 | `SMS_SERVICE_*`, `IDGTL_SMS_*`; шаблоны — в БД, не env |
|
||||
| arch-00 | Термины `sms_message_id`, `sms_outbound_message`, `sms_template` |
|
||||
|
||||
### 10.1. Compose и сети
|
||||
|
||||
Добавить `sms-service` в `backend/infra/compose/application.yml`:
|
||||
|
||||
- networks: `backend`, `egress`, `observability`;
|
||||
- `expose: 8080`, без host `ports`;
|
||||
- managed PostgreSQL schema `sms`, роль только `sms_user`;
|
||||
- Keycloak видит `sms-service` по сети `backend`; при включённой Yandex SmartCaptcha получает отдельный ограниченный egress только к SmartCaptcha API, а при выключенной CAPTCHA остаётся без egress;
|
||||
- root nginx маршрутизирует только точный публичный `POST /callbacks/idgtl/sms` в `sms-service`; `/internal/sms/*` наружу блокируется;
|
||||
- callback location: HTTPS, IP allowlist, request body limit, без access-log Authorization;
|
||||
- зависимости запуска не должны образовывать цикл: Keycloak может стартовать при недоступном `sms-service`; недоступность блокирует только создание нового real-mode заказа, но не verify уже активного challenge.
|
||||
|
||||
### 10.2. Артефакты реализации
|
||||
|
||||
```text
|
||||
backend/sms-service/
|
||||
app/
|
||||
migrations/
|
||||
tests/
|
||||
openapi.yaml
|
||||
Dockerfile
|
||||
pyproject.toml
|
||||
```
|
||||
|
||||
Отдельный `docker-compose.yml` не обязателен: действующий репозиторий использует агрегированный `infra/compose/application.yml`.
|
||||
|
||||
### 10.3. ТЗ на доработку смежных модулей
|
||||
|
||||
Ниже перечислены обязательные изменения вне `sms-service`, без которых end-to-end использование нового сервиса не считается реализованным.
|
||||
|
||||
#### 10.3.1. Общие интеграционные правила
|
||||
|
||||
1. Единственный заказчик SMS в v1 — Keycloak SPI.
|
||||
2. Frontend, `api-backend` и другие сервисы не вызывают `sms-service` и Direct для OTP.
|
||||
3. Keycloak ждёт только durable order (`200`/`202` + `sms_message_id`) и не ждёт вызова Direct.
|
||||
4. `send_status`, `delivery_status`, callback и provider errors используются только журналом/ops и никогда не меняют результат verify.
|
||||
5. OTP генерируется и проверяется только Keycloak; raw OTP передаётся только в закрытом HTTP-запросе Keycloak → sms-service и не логируется.
|
||||
6. Во всех вызовах передаются `X-Request-ID` и `traceparent`; `idempotency_key=keycloak:challenge:{challenge_id}`.
|
||||
|
||||
#### 10.3.2. `module-08-keycloak`
|
||||
|
||||
**Settings bridge**
|
||||
|
||||
- расширить DTO `GET /internal/settings/v1/otp`: `code_length`, `ttl_seconds`, `sms_order_timeout_ms`;
|
||||
- валидировать диапазоны и сохранять единый immutable settings snapshot на новый challenge;
|
||||
- убрать чтение `KEYCLOAK_OTP_TTL_SEC` и других перенесённых runtime-параметров из env;
|
||||
- last-known-good/cache semantics оставить как для существующих OTP limits.
|
||||
|
||||
**Миграция provider-owned таблиц**
|
||||
|
||||
Добавить в `han_otp_challenge`:
|
||||
|
||||
- `sms_message_id` UUID nullable;
|
||||
- `delivery_mode varchar(16)` с CHECK `mock|sms`;
|
||||
- `challenge_status varchar(16)` с CHECK `ordering|active|consumed|superseded|expired|limited|order_failed`;
|
||||
- `ordered_at timestamptz` nullable;
|
||||
- `otp_ttl_sec integer` с CHECK `60..900` и кратностью 60;
|
||||
- `otp_code_length smallint` с CHECK `4..10`;
|
||||
- существующий `settings_version varchar(128)` переиспользовать, новую колонку не создавать.
|
||||
|
||||
Миграция существующих mock-записей:
|
||||
|
||||
- `delivery_mode=mock`, `sms_message_id=null`;
|
||||
- перед migration дождаться прежнего max OTP TTL либо в maintenance transaction пометить все неиспользованные challenges как `expired`;
|
||||
- `ordered_at=created_at`;
|
||||
- `challenge_status=consumed`, если `consumed_at` заполнен; иначе `expired`;
|
||||
- `otp_ttl_sec` и `otp_code_length` backfill текущими seed из `app_settings`; исторические challenges уже не проверяются;
|
||||
- старые `provider_id`/`provider_status` сначала сделать nullable и перестать использовать; удалить отдельной backward-incompatible migration после стабилизации.
|
||||
|
||||
Расширить `han_otp_security_event`:
|
||||
|
||||
- `sms_message_id uuid` nullable;
|
||||
- `client_ip inet`, `user_agent text`;
|
||||
- `device_id varchar(256)`, `fingerprint varchar(256)`;
|
||||
- `os_name varchar(64)`, `os_version varchar(64)`;
|
||||
- `platform varchar(16)`, `app_version varchar(64)`.
|
||||
|
||||
Добавить индексы `han_otp_challenge(challenge_status, expires_at)`, `han_otp_challenge(sms_message_id)` where not null и `han_otp_security_event(sms_message_id)` where not null. Обновить JPA entities и Liquibase changelog; migration должна быть повторяемо проверена на копии production schema.
|
||||
|
||||
**Клиент sms-service**
|
||||
|
||||
- реализовать `SmsOrderClient`, который вызывает `POST /internal/sms/v1/send`;
|
||||
- URL и service token — env; timeout — settings snapshot;
|
||||
- успех заказа: только HTTP `200`/`202`, валидный `sms_message_id`;
|
||||
- HTTP timeout/5xx: повторить один раз с тем же challenge/idempotency key; новый challenge и новый OTP не создавать;
|
||||
- не реализовывать GET/poll provider status в auth flow.
|
||||
|
||||
**Challenge lifecycle**
|
||||
|
||||
- перед новым заказом после успешной проверки limits перевести прежний `active`/`ordering` challenge в `superseded`;
|
||||
- создать новый `ordering`, сгенерировать numeric OTP по snapshot length, сохранить только HMAC;
|
||||
- после durable order перевести в `active`, установить `ordered_at`/`expires_at`, записать `otp_send/ordered`;
|
||||
- при невозможности durable order перевести в `order_failed`;
|
||||
- verify допускается только для `active` и зависит только от HMAC, TTL и verify counters;
|
||||
- верный код → `consumed`; resend → `superseded`; TTL → `expired`; attempts → `limited`;
|
||||
- periodic expiry job и lazy expiry на verify обязательны;
|
||||
- повтор одного auth action использует тот же challenge и idempotency key.
|
||||
|
||||
**Counters и mock**
|
||||
|
||||
- существующие send/verify limits, cooldown, phone HMAC и locking сохраняются;
|
||||
- один новый challenge резервирует одну send attempt; HTTP retry того же заказа повторно counter не увеличивает;
|
||||
- mock mode не вызывает sms-service, но использует те же statuses, TTL, resend и verify rules;
|
||||
- недоступность Direct не влияет на Keycloak; недоступность sms-service блокирует только создание нового real-mode заказа;
|
||||
- общая readiness Keycloak не должна зависеть от Direct или provider status. Допускается отдельный degraded dependency indicator для sms-service.
|
||||
|
||||
**Тесты Keycloak**
|
||||
|
||||
- migration/backfill существующих challenges;
|
||||
- durable order → форма OTP до ответа Direct;
|
||||
- resend отклоняет старый код;
|
||||
- expiry и attempts transitions;
|
||||
- provider rejected/timeout не меняет active challenge;
|
||||
- идемпотентный повтор не создаёт второй challenge и не увеличивает counter;
|
||||
- отсутствие OTP/phone/service token в logs/traces.
|
||||
|
||||
#### 10.3.3. `module-01-api-backend` и App DB settings
|
||||
|
||||
- добавить migration/seed `app_settings`:
|
||||
- `otp.phone.code_length`;
|
||||
- `otp.phone.ttl_seconds`;
|
||||
- `otp.phone.sms_order_timeout_ms`;
|
||||
- расширить строгий DTO `/internal/settings/v1/otp` согласно `arch-02`;
|
||||
- возвращать все OTP settings одной версией, чтобы Keycloak не смешивал значения разных revisions;
|
||||
- добавить валидацию: code length в разрешённом диапазоне; TTL `60..900` и кратен 60; timeout положительный и bounded;
|
||||
- не добавлять отправку/проверку OTP в `api-backend`;
|
||||
- покрыть endpoint contract tests, cache/ETag и отсутствие новых ключей в public config, если они явно не разрешены.
|
||||
|
||||
#### 10.3.4. Managed PostgreSQL и deployment jobs
|
||||
|
||||
- в init-managed-postgres создать schema `sms` и роль `sms_user`;
|
||||
- выдать `sms_user` права только на schema `sms`; доступа к `han_app` и `keycloak` нет;
|
||||
- `sms-service` применяет собственные versioned migrations для `sms_template`, `sms_setting`, `sms_outbound_message`;
|
||||
- добавить idempotent seed active template `auth_otp` и `sms_setting`;
|
||||
- добавить pre-deploy migration job и проверку schema version;
|
||||
- backup/PITR должны включать schema `sms`; автоматическое удаление журнала запрещено;
|
||||
- restore test обязан подтверждать сохранность journal rows, templates, settings и provider IDs.
|
||||
|
||||
#### 10.3.5. Root Compose и конфигурация
|
||||
|
||||
Добавить в `backend/infra/compose/application.yml`:
|
||||
|
||||
- `sms-service` — internal HTTP API/callback receiver;
|
||||
- `sms-worker` — background sender из того же image либо обязательный worker process внутри `sms-service`;
|
||||
- `sms-service`: networks `backend`, `observability`, `expose: 8080`, без `ports`; `egress` добавляется только в совмещённом callback+worker process;
|
||||
- отдельный `sms-worker`: networks `egress`, `observability`, без published/exposed port;
|
||||
- оба процесса используют `SMS_DATABASE_URL`; только worker получает `IDGTL_SMS_API_KEY`;
|
||||
- callback credentials получают `sms-service` для проверки и `sms-worker` для формирования callback URL в запросе Direct; Keycloak получает только `KEYCLOAK_SMS_SERVICE_TOKEN`;
|
||||
- healthchecks, graceful shutdown, lease recovery, read-only rootfs, non-root и resource limits;
|
||||
- startup не строится на `depends_on` Direct; provider outage не вызывает restart loop.
|
||||
|
||||
Обновить:
|
||||
|
||||
- root `.env.example` только URL/DB/secrets;
|
||||
- `scripts/validate-env` и config tests;
|
||||
- image/build/release manifests;
|
||||
- secret generation и rotation runbook.
|
||||
|
||||
#### 10.3.6. `module-03-nginx`
|
||||
|
||||
Требования относятся к [`module-03-nginx-vm1.md`](module-03-nginx-vm1.md) и общему контракту [`arch-08-nginx.md`](../../architectory/arch-08-nginx.md):
|
||||
|
||||
- добавить точный public route `POST /callbacks/idgtl/sms` → `sms-service:8080`;
|
||||
- остальные методы на callback path отклонять;
|
||||
- source IP allowlist Direct, учитывая только trusted proxy chain;
|
||||
- передавать Basic Authorization в sms-service, но не писать его в access/error logs;
|
||||
- ограничить размер body, отключить cache, задать отдельный callback rate limit без блокировки легитимных повторов;
|
||||
- `/internal/sms/*` и порт sms-service наружу не публиковать;
|
||||
- добавить config/route tests: allowed callback, wrong IP, wrong method, internal path denied.
|
||||
|
||||
#### 10.3.7. `module-02-frontend-test-site` и Keycloak theme
|
||||
|
||||
- frontend не вызывает sms-service;
|
||||
- resend запускает новый Keycloak action; двойной click блокируется на время запроса;
|
||||
- после resend UI явно сообщает, что предыдущий код недействителен;
|
||||
- countdown берётся из challenge/settings snapshot, а не из hardcoded значения;
|
||||
- корректно отображать `invalid`, `expired`, `superseded`, `limited` и generic order unavailable;
|
||||
- raw OTP, service URLs/tokens и provider status не попадают в frontend config/analytics.
|
||||
|
||||
#### 10.3.8. `module-09-observability`
|
||||
|
||||
- добавить metrics/alerts в [`module-09-observability-vm1.md`](module-09-observability-vm1.md) и имя сервиса в реестр [`arch-07-observability.md`](../../architectory/arch-07-observability.md) §4 для `sms-service` и `sms-worker`;
|
||||
- dashboard: pending age, send outcomes, provider latency, callback lag, uncertain, journal growth;
|
||||
- traces: Keycloak order span → sms-service DB commit; worker → Direct отдельным trace/span с correlation через `sms_message_id`;
|
||||
- настроить redaction OTP, body, phone, Authorization, API key и callback credentials;
|
||||
- alert routing/runbook для Direct 401/402, `uncertain`, stuck pending и callback failures.
|
||||
|
||||
#### 10.3.9. `module-10-deployment-runbook` и `deploy-steps.md`
|
||||
|
||||
Зафиксировать rollout в [`module-10-deployment-vm1.md`](module-10-deployment-vm1.md) и контракте [`arch-10-deployment.md`](../../architectory/arch-10-deployment.md):
|
||||
|
||||
1. применить App DB seed новых OTP settings;
|
||||
2. создать schema/role `sms`, применить migrations и seed;
|
||||
3. в test environment deploy `sms-service`/worker с `IDGTL_SMS_BASE_URL` локального mock Direct и выполнить contract/E2E;
|
||||
4. выпустить/установить production Direct TOKEN_1, sender и callback credentials;
|
||||
5. deploy production `sms-service`/worker, проверить health/migrations, оставив Keycloak в mock mode;
|
||||
6. применить Keycloak migration и deploy SPI с `KEYCLOAK_OTP_MOCK_ENABLED=true`;
|
||||
7. выполнить provider smoke отдельной ops-командой на контролируемом номере;
|
||||
8. проверить реальный callback, журнал и redaction;
|
||||
9. переключить Keycloak в real mode;
|
||||
10. проверить resend/expiry/limits и сохранить release evidence.
|
||||
|
||||
Rollback:
|
||||
|
||||
- вернуть Keycloak в mock mode без удаления schema/journal;
|
||||
- остановить создание новых real orders, дать worker завершить/зафиксировать in-flight;
|
||||
- migrations откатывать только при доказанной backward compatibility; иначе forward-fix.
|
||||
|
||||
#### 10.3.10. Архитектурные документы
|
||||
|
||||
До merge реализации синхронизировать:
|
||||
|
||||
- `arch-00`: сервис/сущности/ID/settings/env, `send_status`, `delivery_status`, `challenge_status`;
|
||||
- `arch-01`: компонент `sms-service`, schema `sms`, поток Keycloak → durable order → worker → Direct, отсутствие зависимости verify от provider status;
|
||||
- `arch-02`: полный `POST/GET /internal/sms/v1/*`, callback, service-token pair, HTTP-коды и OpenAPI registry;
|
||||
- `arch-03`: `sms-service`/worker, networks, schema/role, nginx callback route, startup/health;
|
||||
- `arch-04`: разделение env / `app_settings` / `sms.sms_setting`;
|
||||
- `architectory/README.md`: убрать формулировку о неоформленной интеграции после начала реализации и добавить ссылки на новый контракт;
|
||||
- `module-01`, `module-02`, `module-03`, `module-08`, `module-09`, `module-10` — добавить перечисленные требования в профильные DoD/test matrix;
|
||||
- `backlog.md`: переводить интеграцию из backlog только после выполнения общего DoD;
|
||||
- `deploy-steps.md`: добавить rollout/rollback и smoke-команды.
|
||||
|
||||
`module-04-redis`, `module-05-message-safety`, `module-06-bitrix-local-app`, `module-07-bitrix-sync` изменений для SMS не требуют.
|
||||
|
||||
### 10.4. Общие критерии приёмки смежных изменений
|
||||
|
||||
- новый OTP-заказ возвращается до начала/завершения внешнего HTTP-вызова Direct;
|
||||
- Keycloak не содержит кода чтения provider send/delivery status;
|
||||
- provider failure после durable order не деактивирует challenge;
|
||||
- resend делает старый challenge и код `superseded`;
|
||||
- challenge становится `expired` по сохранённому settings snapshot;
|
||||
- повтор с тем же idempotency key не создаёт вторую SMS и не увеличивает counters;
|
||||
- internal SMS API недоступен извне; callback доступен только по установленным правилам;
|
||||
- журнал содержит заказ, provider result и callback и сохраняется бессрочно;
|
||||
- OTP, body, телефон и секреты отсутствуют в logs/traces/metrics;
|
||||
- все изменённые OpenAPI/DTO/migrations/docs проходят contract, migration и E2E tests;
|
||||
- поиск по документации не находит старого прямого потока Keycloak → Direct или зависимости verify от provider status.
|
||||
|
||||
---
|
||||
|
||||
## 11. Тест-план (будущая реализация)
|
||||
|
||||
- unit: strict template render, E.164/TTL, request fingerprint, idempotency conflict, status transitions;
|
||||
- contract: локальный mock/WireMock Direct + callback fixtures; существование отдельного sandbox Direct не предполагается;
|
||||
- provider smoke: выделенный test account/sender `sms_promo` только по отдельному ops-runbook, чтобы тест не отправлял SMS случайным адресатам;
|
||||
- integration: Keycloak → durable order в sms-service → background worker → mock Direct;
|
||||
- E2E: форма OTP открывается после durable order и до ответа Direct; provider reject/timeout не меняет Keycloak challenge;
|
||||
- E2E: wrong code → success verify; `sms_message_id` совпадает в обеих БД;
|
||||
- E2E: resend переводит прежний challenge в `superseded`, старый код отклоняется, новый принимается;
|
||||
- E2E: active challenge без ввода кода становится `expired` через snapshot `otp.phone.ttl_seconds`;
|
||||
- E2E: изменение `otp.phone.ttl_seconds`/`code_length` влияет только на новые challenges;
|
||||
- E2E: counters/cooldown применяются до создания нового заказа; идемпотентный HTTP-повтор не увеличивает counters повторно;
|
||||
- resilience: connect failure, 401/402/403/422, `errors=true`, malformed 200, 503, read timeout → `uncertain`, crash после INSERT и после provider accept;
|
||||
- callback: массив, duplicate, out-of-order sent after delivered, unknown UUID, Basic auth/IP reject, retry после DB failure;
|
||||
- security: нет OTP/phone/token/callback credentials в logs/traces; internal API без token → 401; provider TLS verification;
|
||||
- migration: upgrade существующих Keycloak tables и rollback compatibility;
|
||||
- persistence: записи и полный состав журнала сохраняются после архивирования/ротации partition и восстановления backup.
|
||||
|
||||
---
|
||||
|
||||
## 12. Definition of Done
|
||||
|
||||
- Журнал SMS целиком в module-11 (`sms_template` + `sms_outbound_message`);
|
||||
- Verify outcomes + device — в Keycloak с `sms_message_id`;
|
||||
- Keycloak не ходит в Direct; Direct не проверяет код;
|
||||
- mock XOR real; отсутствие durable order блокирует только новый challenge;
|
||||
- Keycloak не читает и не проверяет provider send/delivery statuses;
|
||||
- sms-service возвращает durable order до фонового вызова Direct;
|
||||
- ambiguous provider result → `uncertain` без автоматической повторной SMS;
|
||||
- callback защищён HTTPS + IP allowlist + Basic auth и обрабатывается идемпотентно;
|
||||
- TTL OTP задаётся `app_settings["otp.phone.ttl_seconds"]` и считается от `ordered_at`; resend делает прежний challenge `superseded`, expiry job — `expired`;
|
||||
- журнал SMS хранится бессрочно без автоматической очистки;
|
||||
- OpenAPI, migrations, Compose, env validation, health/metrics и runbook готовы;
|
||||
- arch-* и module-08 синхронизированы.
|
||||
|
||||
---
|
||||
|
||||
## 13. Решения, допущения и внешние предпосылки
|
||||
|
||||
**Решения:**
|
||||
|
||||
- S1: module-11 — единственный владелец отправки SMS и журнала.
|
||||
- S2: шаблоны в БД (`sms_template`), не в env.
|
||||
- S3: OTP generate/verify — Keycloak; связь через `sms_message_id`.
|
||||
- S4: первый provider `idgtl`, канал `SMS`, process `auth_otp`, requester `keycloak`.
|
||||
- S5: delivery callback только в sms-service.
|
||||
- S6: устройство (IP, UA, device_id, fingerprint, OS) — в Keycloak verify/send events.
|
||||
- S7: Keycloak зависит только от durable order (`sms_message_id`) и не зависит от provider send/delivery status.
|
||||
- S8: отправка в Direct выполняется background worker-ом после ответа Keycloak.
|
||||
- S9: resend всегда делает прежний challenge `superseded`; неиспользованный challenge после TTL становится `expired`.
|
||||
- S10: `externalMessageId` в v1 считается только корреляцией, не idempotency key; ambiguous provider call не повторяется независимо от будущего ответа Direct.
|
||||
- S11: failover-провайдер не входит в v1; поле `provider` остаётся для аудита и будущего расширения.
|
||||
- S12: device metadata передаётся через custom OIDC `han_*` параметры/auth notes и hidden fields theme по §6.3.
|
||||
- S13: точная миграция Keycloak фиксируется §10.3.2; все прежние незавершённые challenges истекают при rollout.
|
||||
|
||||
**Допущения:**
|
||||
|
||||
- A1: отдельная schema `sms` на том же managed PostgreSQL допустима.
|
||||
- A2: sender/template согласуются с i-Digital до prod.
|
||||
- A3: Direct отправляет callback с IP `185.203.96.7`; адрес повторно подтверждается перед production.
|
||||
- A4: Direct поддерживает Basic auth callback через credentials в callback URL согласно опубликованной документации.
|
||||
|
||||
**Внешняя production-предпосылка:**
|
||||
|
||||
- Перед production rollout ops определяет фактический статический egress IP из контейнера `sms-worker`, фиксирует его в deployment inventory и передаёт Direct для API-key allowlist. Если egress IP не статичен, production-включение real mode запрещено до настройки NAT/static IP. Это deployment value, а не параметр приложения или открытое архитектурное решение.
|
||||
Reference in New Issue
Block a user