Правки от GPT
This commit is contained in:
@@ -95,7 +95,7 @@ flowchart LR
|
|||||||
|
|
||||||
Client -->|HTTPS REST + Realtime| Nginx
|
Client -->|HTTPS REST + Realtime| Nginx
|
||||||
Nginx -->|/auth| Keycloak
|
Nginx -->|/auth| Keycloak
|
||||||
Nginx -->|/api (REST + WS realtime)| API
|
Nginx -->|"/api REST + WS realtime"| API
|
||||||
Keycloak --> DB
|
Keycloak --> DB
|
||||||
API --> DB
|
API --> DB
|
||||||
API --> Redis
|
API --> Redis
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,286 @@
|
|||||||
|
# module-02. Проектная спецификация тестового frontend-сайта
|
||||||
|
|
||||||
|
> Статус: целевая спецификация реализации MVP; это проектирование, не код.
|
||||||
|
> Канонические источники: [`README.md`](README.md), [`arch-00-glossary.md`](arch-00-glossary.md), [`arch-01-system-architecture.md`](arch-01-system-architecture.md), [`arch-02-api-contracts.md`](arch-02-api-contracts.md), [`arch-03-docker-compose-blueprint.md`](arch-03-docker-compose-blueprint.md), [`arch-04-settings-and-content.md`](arch-04-settings-and-content.md), [`arch-05-agent-development-process.md`](arch-05-agent-development-process.md), [`module-01-api-backend.md`](module-01-api-backend.md).
|
||||||
|
|
||||||
|
## 1. Назначение и границы
|
||||||
|
|
||||||
|
Сайт нужен для ручной, интеграционной и E2E-проверки всех пользовательских сценариев HAN Chat через реальные публичные API. Он остаётся простым по визуальному дизайну, но функционально покрывает guest, OTP/PKCE, bootstrap, UX-сессию, чат, файлы, профиль, 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}.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 message/file intent | память | восстанавливает отправку после OTP |
|
||||||
|
| 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 согласий отображает актуальные URL/версии из config. `personal_data` и `user_agreement` обязательны, `marketing` необязателен. После подтверждения intent остаётся в памяти, начинается OIDC PKCE redirect.
|
||||||
|
|
||||||
|
OTP вводится на странице/теме Keycloak. В MVP Keycloak сверяет mock-код из env; frontend не хранит и не проверяет код. Для тестовой среды UI может показывать только текст «используется тестовый OTP», но не получать secret из API.
|
||||||
|
|
||||||
|
### 5.3. Диалоги и чат
|
||||||
|
|
||||||
|
- история: `GET /dialogs`, cursor pagination;
|
||||||
|
- карточка: статус, сообщения, composer, attachment;
|
||||||
|
- сообщения сортируются по `created_at asc`, дубли объединяются по `message_id`;
|
||||||
|
- `waiting_for_company`, `waiting_for_client`, `closed` отображаются русскими подписями;
|
||||||
|
- closed dialog readonly; создание нового — только через контракт backend;
|
||||||
|
- промежуточный safety `203` клиенту не показывается: send request остаётся в progress до финального ответа.
|
||||||
|
|
||||||
|
### 5.4. Профиль
|
||||||
|
|
||||||
|
Readonly блок «Личные данные» из `GET /me`; блок «Документы» из `/me/documents`, допускается пустой. Редактирование отсутствует. Для изменения данных — CTA в чат. Download URL запрашивается только после клика и не сохраняется.
|
||||||
|
|
||||||
|
### 5.5. 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-сессию, если её нет; затем продолжить pending intent.
|
||||||
|
|
||||||
|
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` показать безопасный текст без повтора; на `503/504` предложить retry с тем же key.
|
||||||
|
|
||||||
|
## 11. Файловый flow
|
||||||
|
|
||||||
|
MVP допускает ровно один файл, только allow-list extension+MIME, до 5 МБ или значений app-config.
|
||||||
|
|
||||||
|
1. Локальная prevalidation.
|
||||||
|
2. Создать/reuse dialog.
|
||||||
|
3. `POST .../attachments/init` с filename, MIME, size.
|
||||||
|
4. Выполнить прямой `PUT upload_url` с точно выданными `upload_headers`; API domain при этом не используется.
|
||||||
|
5. Вычислить SHA-256, вызвать `complete`.
|
||||||
|
6. Отправить file message с `attachment_id` и `sha256:<hex>`.
|
||||||
|
|
||||||
|
Presigned URL не сохраняется и редактируется из диагностик. Abort позволяет отменить PUT; orphan очищает backend. При expiry init выполняется повторно в рамках согласованного состояния. CORS S3 должен разрешать origin сайта, PUT и необходимые 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 |
|
||||||
|
| 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 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 |
|
||||||
|
|
||||||
|
Browsers: Chromium, Firefox, WebKit; viewport desktop/mobile. Реальный Keycloak mock realm и API stub/compose используются в CI.
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
## 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: продуктовые тексты всех error states по мнемоникам.
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
# module-03. Проектная спецификация корневого `nginx`
|
||||||
|
|
||||||
|
> Статус: целевая спецификация полностью рабочего edge-контура MVP.
|
||||||
|
> Источники: [`README.md`](README.md), [`arch-00-glossary.md`](arch-00-glossary.md), [`arch-01-system-architecture.md`](arch-01-system-architecture.md), [`arch-02-api-contracts.md`](arch-02-api-contracts.md), [`arch-03-docker-compose-blueprint.md`](arch-03-docker-compose-blueprint.md), [`arch-04-settings-and-content.md`](arch-04-settings-and-content.md), [`arch-05-agent-development-process.md`](arch-05-agent-development-process.md), [`module-01-api-backend.md`](module-01-api-backend.md).
|
||||||
|
|
||||||
|
## 1. Назначение и обязательная топология
|
||||||
|
|
||||||
|
В production-like контуре существует ровно один корневой nginx. Только он публикует host-порты `80/443`, завершает TLS, раздаёт SPA и проксирует публичные маршруты. Контейнеры API, Keycloak, Redis, Safety, Bitrix и OTEL используют только `expose`/Docker networks.
|
||||||
|
|
||||||
|
Если перед VM есть внешний WAF/LB, доверенные proxy CIDR задаются явно; nginx не доверяет произвольному `X-Forwarded-For`. Другой nginx на host не должен маршрутизировать сервисы по отдельности.
|
||||||
|
|
||||||
|
## 2. Routing matrix
|
||||||
|
|
||||||
|
Порядок location критичен: exact/longest public routes до общего `/bitrix/`.
|
||||||
|
|
||||||
|
| Внешний путь | Upstream | Режим |
|
||||||
|
|---|---|---|
|
||||||
|
| `/api/` | `api-backend:8000` | REST; `/api/v1/realtime` WS |
|
||||||
|
| `/auth/` | `keycloak:8080` | OIDC/OTP, prefix/hostname согласован с issuer |
|
||||||
|
| `/bitrix/sync/webhook/contact` | `bitrix-sync:8080` | public HTTPS POST, no cache; отсутствует, пока действует stub module-07 |
|
||||||
|
| `/bitrix/handler`, `/bitrix/install`, `/bitrix/placement` | `bitrix-local-app:8080` | public HTTPS |
|
||||||
|
| exact `/health/live`, `/health/ready` | `bitrix-local-app:8080` | по умолчанию не публикуются; только при явно выбранной ops/monitoring policy |
|
||||||
|
| `/` | static SPA либо Expo dev upstream | `try_files` fallback |
|
||||||
|
|
||||||
|
`/internal/`, `/_internal/`, Redis/OTLP/admin/status/config files запрещены exact prefix response `404` (допустим `403`, но единообразно выбран `404`). Никакого fallback internal path в SPA или общий proxy. `message-safety` не имеет публичного route.
|
||||||
|
|
||||||
|
## 3. Upstreams
|
||||||
|
|
||||||
|
Именованные upstream: `api_backend`, `keycloak`, `bitrix_local`, `bitrix_sync`, опционально `frontend_dev`. Для одной replica допустим `server service:port`; `keepalive` включён. Docker DNS resolver задаётся с коротким `valid` и `resolve` там, где поддерживает выбранная nginx edition; иначе контейнер перезапускается при смене IP upstream.
|
||||||
|
|
||||||
|
Upstream failures не перенаправляются на другой сервис и не попадают в SPA. API возвращает `502/504` с безопасным nginx body и `X-Request-ID`; custom JSON error допустим для `/api`, но не имитирует backend domain code.
|
||||||
|
|
||||||
|
## 4. HTTP/HTTPS и TLS
|
||||||
|
|
||||||
|
- единый web host `:80` обслуживает только ACME challenge и `308 https://$host$request_uri`;
|
||||||
|
- выделенный API host, если появится, не имеет listener `:80`;
|
||||||
|
- `:443 ssl http2`, TLS 1.2/1.3, современные cipher suites, session tickets по ops policy;
|
||||||
|
- сертификат доверенного CA, private key read-only и недоступен приложению;
|
||||||
|
- OCSP stapling при поддержке CA/DNS;
|
||||||
|
- HSTS включается только после успешной проверки HTTPS: `max-age` из env, затем по решению ops `includeSubDomains`; preload не включать автоматически;
|
||||||
|
- OIDC redirects, cookies и external URLs всегда HTTPS.
|
||||||
|
|
||||||
|
## 5. ACME lifecycle
|
||||||
|
|
||||||
|
Выбран webroot Certbot/ACME client с общими named volumes:
|
||||||
|
|
||||||
|
```text
|
||||||
|
nginx-certs -> /etc/letsencrypt (rw у certbot, ro у nginx)
|
||||||
|
nginx-acme-webroot -> /var/www/certbot
|
||||||
|
frontend-static -> /usr/share/nginx/html:ro
|
||||||
|
```
|
||||||
|
|
||||||
|
Bootstrap:
|
||||||
|
|
||||||
|
1. DNS указывает на VM; 80/443 разрешены.
|
||||||
|
2. Запустить временный HTTP config с `/.well-known/acme-challenge/`.
|
||||||
|
3. Выпустить certificate без остановки nginx.
|
||||||
|
4. Проверить `nginx -t`, атомарно активировать TLS config, reload.
|
||||||
|
|
||||||
|
Renew container/host timer выполняет `certbot renew` минимум дважды в сутки; после фактического renewal — `nginx -s reload`. Reload допускается только после `nginx -t`; при ошибке остаётся старый worker/config/cert и срабатывает alert. Контролируются expiry days и последняя успешная попытка. Staging CA используется в rehearsal, чтобы не исчерпать лимиты.
|
||||||
|
|
||||||
|
## 6. Request ID и forwarded headers
|
||||||
|
|
||||||
|
На edge формируется trusted request id. Базовый nginx не генерирует UUID штатной переменной, поэтому используется njs/Lua либо модуль request-id, включённый в закреплённый image. Входящий `X-Request-ID` принимается только если соответствует UUID/ULID и длине; иначе генерируется новый.
|
||||||
|
|
||||||
|
Upstream получает:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Host: original host
|
||||||
|
X-Real-IP: trusted real client IP
|
||||||
|
X-Forwarded-For: normalized proxy chain
|
||||||
|
X-Forwarded-Proto: https
|
||||||
|
X-Forwarded-Host: original host
|
||||||
|
X-Forwarded-Port: 443
|
||||||
|
X-Request-ID: edge request id
|
||||||
|
traceparent: входной валидный либо новый согласно OTEL integration
|
||||||
|
```
|
||||||
|
|
||||||
|
Ответ всегда содержит `X-Request-ID`. Клиентские `X-Forwarded-*` от недоверенного адреса перезаписываются. `Authorization`, `Cookie`, query string и body не попадают в access log.
|
||||||
|
|
||||||
|
## 7. 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.
|
||||||
|
|
||||||
|
## 8. Timeouts и body limits
|
||||||
|
|
||||||
|
Общие ориентиры:
|
||||||
|
|
||||||
|
| Группа | connect/send/read |
|
||||||
|
|---|---|
|
||||||
|
| обычный API | 3s / 30s / 30s |
|
||||||
|
| auth | 3s / 30s / 60s |
|
||||||
|
| Bitrix callback | 3s / 30s / 60s |
|
||||||
|
| WS | 3s / 30s / 75s+ |
|
||||||
|
| message POST | 3s / 30s / `MESSAGE_SAFETY_TASK_POLL_MAX_SEC + 30s` минимум |
|
||||||
|
|
||||||
|
При default safety max 300 сек message read timeout не меньше 330 сек. Значение генерируется из env template до startup; nginx не выполняет арифметику env runtime.
|
||||||
|
|
||||||
|
`client_max_body_size` global 8m по arch-04, но JSON API locations получают более строгие limits, где возможно. Байты вложения не проходят через nginx/API: клиент PUT напрямую в S3. Buffering request допустим для малого JSON; для callback устанавливается bounded temp storage. Header count/size ограничены.
|
||||||
|
|
||||||
|
## 9. Edge rate limits
|
||||||
|
|
||||||
|
`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;
|
||||||
|
- `bitrix_callbacks`: мягкий burst для повторов;
|
||||||
|
- `ws_connect`: handshake;
|
||||||
|
- `connections`: `limit_conn`.
|
||||||
|
|
||||||
|
Ответ превышения — `429`, `Retry-After` (статический/вычисляемый для зоны) и request id. Edge не реализует user-level бизнес-лимит; это делает API/Redis. OPTIONS не должен расходовать auth budget чрезмерно. Bitrix webhook retries имеют отдельный достаточный burst и всё равно проверяют application token в сервисе.
|
||||||
|
|
||||||
|
## 10. Static SPA и dev mode
|
||||||
|
|
||||||
|
Production:
|
||||||
|
|
||||||
|
- root `${FRONTEND_STATIC_PATH}`;
|
||||||
|
- существующие 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.
|
||||||
|
|
||||||
|
## 11. 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 опционален.
|
||||||
|
|
||||||
|
## 12. 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'`;
|
||||||
|
- script-src без `unsafe-eval` production; nonce/hash при необходимости;
|
||||||
|
- style-src policy согласовать с Expo build, постепенно исключить unsafe-inline.
|
||||||
|
|
||||||
|
Также: `X-Content-Type-Options: nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, `Permissions-Policy`, frame protection через CSP, корректный COOP/CORP без поломки Keycloak redirect/S3. `Server` tokens скрыты; upstream `X-Powered-By` удаляется.
|
||||||
|
|
||||||
|
Bitrix placement может требовать embedding: для exact `/bitrix/placement` CSP `frame-ancestors` задаётся отдельным allow-list Bitrix24, а не ослабляет SPA.
|
||||||
|
|
||||||
|
## 13. Health
|
||||||
|
|
||||||
|
- внутренний `GET /nginx-health/live` возвращает static 200 и доступен Docker healthcheck;
|
||||||
|
- внешний health публикуется только если нужен мониторингу, с allow-list;
|
||||||
|
- nginx health не утверждает готовность upstream;
|
||||||
|
- внешняя synthetic проверка отдельно проверяет TLS, redirect, public API, auth discovery и callback route;
|
||||||
|
- upstream `/health/ready` не агрегируется публично без решения ops.
|
||||||
|
|
||||||
|
## 14. Логи и OTEL correlation
|
||||||
|
|
||||||
|
JSON access log: timestamp, request_id, trace_id (если извлечён), remote IP/hashed policy, host, method, route class, normalized URI без sensitive query, status, bytes, duration, upstream addr/status/time, cache status, TLS protocol/cipher, user agent при принятой retention.
|
||||||
|
|
||||||
|
Не логируются Authorization, cookies, request/response body, OTP, tokens, query token, presigned query, PII. Error log структурирован настолько, насколько позволяет nginx; debug выключен production.
|
||||||
|
|
||||||
|
Nginx передаёт W3C trace context; native OTEL module допустим при закреплённой версии. Если edge создаёт span, request id остаётся отдельным correlation key. Логи идут stdout/stderr; Docker/collector отвечает за доставку и rotation.
|
||||||
|
|
||||||
|
## 15. Layout конфигурации
|
||||||
|
|
||||||
|
```text
|
||||||
|
nginx/
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
|
nginx.conf
|
||||||
|
templates/
|
||||||
|
00-maps.conf.template
|
||||||
|
10-upstreams.conf.template
|
||||||
|
20-http-redirect.conf.template
|
||||||
|
30-https-site.conf.template
|
||||||
|
snippets/
|
||||||
|
proxy-common.conf
|
||||||
|
security-headers.conf
|
||||||
|
tls.conf
|
||||||
|
rate-limits.conf
|
||||||
|
websocket.conf
|
||||||
|
njs/request_id.js
|
||||||
|
scripts/{render,validate,reload-after-renew}.sh
|
||||||
|
tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
Image и modules pin по digest/version. Render использует allow-list env и fail-fast для пустых host/cert/upstream/timeouts. Секреты в rendered config не требуются.
|
||||||
|
|
||||||
|
## 16. Docker Compose
|
||||||
|
|
||||||
|
`nginx` подключён к `public` и `backend`, публикует `${NGINX_HTTP_PORT}:80`, `${NGINX_HTTPS_PORT}:443`; filesystem read-only, tmpfs для cache/run/temp, non-root где позволяет bind ports/capabilities. Cert/static volumes read-only. ACME client имеет только необходимые volumes/network.
|
||||||
|
|
||||||
|
`depends_on` health не заменяет retry: nginx может стартовать при временно недоступном upstream и отдавать 502, затем восстановиться без reload. Resource/FD limits учитывают WS.
|
||||||
|
|
||||||
|
## 17. Failure behavior
|
||||||
|
|
||||||
|
- 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.
|
||||||
|
- cert renewal failed: текущий cert продолжает работу, alert до expiry.
|
||||||
|
- invalid new config: reload отменяется, старые workers остаются.
|
||||||
|
- disk/cache full: public cache bypass/evict, requests продолжаются где безопасно.
|
||||||
|
- DNS upstream changed: resolver/restart policy восстанавливает адрес.
|
||||||
|
- overload: 429/503 на edge, bounded queues; не накапливать неограниченные connections.
|
||||||
|
|
||||||
|
## 18. Валидация и тесты
|
||||||
|
|
||||||
|
Команды acceptance:
|
||||||
|
|
||||||
|
```text
|
||||||
|
docker compose config
|
||||||
|
docker compose exec nginx nginx -t
|
||||||
|
curl -I http://tohin.ru/
|
||||||
|
curl -vk https://tohin.ru/api/v1/public/app-config
|
||||||
|
openssl s_client -connect tohin.ru:443 -servername tohin.ru
|
||||||
|
curl -i https://tohin.ru/internal/safety/v1/messages/check
|
||||||
|
```
|
||||||
|
|
||||||
|
Автоматические тесты:
|
||||||
|
|
||||||
|
- 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;
|
||||||
|
- public cache HIT/MISS/bypass/no private cache;
|
||||||
|
- CSP/CORS preflight и Bitrix placement exception;
|
||||||
|
- upstream down/timeout, failed reload, renewal rehearsal;
|
||||||
|
- logs не содержат secrets/query tokens.
|
||||||
|
|
||||||
|
## 19. Definition of Done
|
||||||
|
|
||||||
|
- единственный root nginx публикует только 80/443;
|
||||||
|
- TLS/ACME bootstrap, renewal и safe reload испытаны;
|
||||||
|
- routing matrix и route precedence покрыты;
|
||||||
|
- internal endpoints/ports извне недоступны;
|
||||||
|
- WS работает на `/api/v1/realtime`;
|
||||||
|
- message timeout равен safety max + минимум 30 секунд;
|
||||||
|
- request id и trusted forwarded headers корректны;
|
||||||
|
- limits, public cache, CSP/CORS/security headers проверены;
|
||||||
|
- static production и dev proxy guard работают;
|
||||||
|
- JSON logs коррелируют request/trace и не содержат секретов;
|
||||||
|
- health/synthetic checks и failure tests проходят;
|
||||||
|
- image non-root/read-only насколько возможно, versions pinned;
|
||||||
|
- runbooks для cert, reload, upstream outage и rollback готовы.
|
||||||
|
|
||||||
|
## 20. Решения, допущения и TBD
|
||||||
|
|
||||||
|
**Решения:** один nginx; njs/module для UUID; internal → 404; webroot ACME; отдельная CSP для Bitrix placement; public cache только allow-listed endpoints.
|
||||||
|
|
||||||
|
**Допущения:** MVP использует единый host `tohin.ru`; upstream service names стабильны в Compose; S3 CORS настраивается отдельно.
|
||||||
|
|
||||||
|
**TBD:** N1 доверенные WAF CIDR; N2 production cipher suite/OCSP; N3 нужен ли публичный health; N4 точный CSP Expo build; N5 Bitrix frame ancestor domains; N6 финальные burst/connection limits; N7 certbot vs другой ACME client после ops review.
|
||||||
@@ -0,0 +1,284 @@
|
|||||||
|
# module-04. Проектная спецификация Redis
|
||||||
|
|
||||||
|
> Статус: целевая спецификация Redis в едином Docker Compose MVP.
|
||||||
|
> Источники: [`README.md`](README.md), [`arch-00-glossary.md`](arch-00-glossary.md), [`arch-01-system-architecture.md`](arch-01-system-architecture.md), [`arch-02-api-contracts.md`](arch-02-api-contracts.md), [`arch-03-docker-compose-blueprint.md`](arch-03-docker-compose-blueprint.md), [`arch-04-settings-and-content.md`](arch-04-settings-and-content.md), [`arch-05-agent-development-process.md`](arch-05-agent-development-process.md), [`module-01-api-backend.md`](module-01-api-backend.md).
|
||||||
|
|
||||||
|
## 1. Назначение и инварианты
|
||||||
|
|
||||||
|
Один Redis-контейнер предоставляет быстрые ephemeral функции трём логическим DB:
|
||||||
|
|
||||||
|
- DB0 — `api-backend`: idempotency fast layer и API rate limits;
|
||||||
|
- DB1 — realtime и coordination;
|
||||||
|
- DB2 — `message-safety` stub tasks/cache.
|
||||||
|
|
||||||
|
Redis не является бизнес-очередью, source of truth сообщений, sync tasks, audit, профилей или delivery checkpoint. Надёжные состояния остаются в managed PostgreSQL/S3. Потеря Redis может ухудшить сервис, но не должна создавать потерю подтверждённых сообщений либо дубль side effect: durable idempotency/outbox/checkpoint api-backend описаны в module-01.
|
||||||
|
|
||||||
|
OTP counters api-backend в Redis не хранит; они принадлежат Keycloak/SPI.
|
||||||
|
|
||||||
|
## 2. Версия и topology
|
||||||
|
|
||||||
|
Redis 7.x, image закреплён по digest. Одна primary instance на VM без replica/Sentinel в MVP. Клиенты используют connection pool, bounded timeouts и не выполняют опасные команды.
|
||||||
|
|
||||||
|
Logical DB — изоляция имён, не security boundary и не независимый memory quota. При росте или разных eviction/SLA DB2 и DB0 выносятся в отдельные instances.
|
||||||
|
|
||||||
|
## 3. Общие правила ключей
|
||||||
|
|
||||||
|
Формат: `han:{domain}:{purpose}:{hashed-or-public-id}:{version}`. Только ASCII lowercase separators. Public UUID допустим; IP, phone, email, token, text и filename — только HMAC/SHA-256 с server-side pepper там, где нужна защита dictionary attack.
|
||||||
|
|
||||||
|
- key length желательно ≤ 200 bytes;
|
||||||
|
- значения versioned (`v=1`);
|
||||||
|
- timestamps — Unix ms/seconds или RFC3339, формат фиксирован для каждого key;
|
||||||
|
- wildcard `KEYS` production запрещён; только `SCAN` для ops;
|
||||||
|
- каждый non-channel key имеет TTL, кроме явно обоснованных bounded structures;
|
||||||
|
- large payload/presigned URL/token/message text запрещены.
|
||||||
|
|
||||||
|
## 4. 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 |
|
||||||
|
|
||||||
|
Алгоритм — atomic Lua/function: удалить старые entries, посчитать, добавить текущий request, установить expiry, вернуть `allowed`, `remaining`, `retry_after_ms`, `reset_at`. Для fixed window `INCR` и первый `EXPIRE` выполняются в одном script, чтобы не оставить бессрочный key.
|
||||||
|
|
||||||
|
Clock используется Redis `TIME` внутри script, а не client wall clock. Script загружается при startup, SHA кэшируется; после `NOSCRIPT` выполняется контролируемый reload. Route labels — bounded allow-list/hash, исключающий cardinality attack.
|
||||||
|
|
||||||
|
## 5. 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.
|
||||||
|
|
||||||
|
## 6. 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.
|
||||||
|
|
||||||
|
## 7. 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 |
|
||||||
|
|
||||||
|
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 остаются финальной защитой.
|
||||||
|
|
||||||
|
## 8. DB2: Message Safety stub
|
||||||
|
|
||||||
|
| Key | Тип/value | TTL |
|
||||||
|
|---|---|---|
|
||||||
|
| `han:safety:task:{task_id}` | HASH/JSON v1: created, polls, optional seed/context | `MESSAGE_SAFETY_TASK_TTL_SEC` |
|
||||||
|
| `han:safety:tasklock:{task_id}` | owner token | 5–30s |
|
||||||
|
| `han:safety:rl:service:{caller}:{window}` | counter | window+jitter |
|
||||||
|
| `han:safety:verdict:{content_hash}:{rules_version}` | optional cache | bounded technical TTL |
|
||||||
|
|
||||||
|
Для требуемой заглушки task — ephemeral contract state. Истечение task возвращает безопасный `404 task_not_found/expired` по internal error semantics. В production safety authoritative audit/cache может находиться в PostgreSQL `message_safety`; Redis DB2 не заменяет его.
|
||||||
|
|
||||||
|
Random verdict каждого GET по заданию независим; Redis хранит существование/TTL и счётчик polls для observability, но не предопределяет финал. В deterministic tests seed/RNG injected на уровне сервиса.
|
||||||
|
|
||||||
|
## 9. Serialization и limits
|
||||||
|
|
||||||
|
- простые counters — integer;
|
||||||
|
- locks — opaque random 128-bit token;
|
||||||
|
- metadata — Redis HASH либо компактный JSON с `schema_version`;
|
||||||
|
- max value target 32 KiB, hard application guard 128 KiB;
|
||||||
|
- response cache хранит только allow-listed sanitized JSON;
|
||||||
|
- decode error считается cache miss, key удаляется/карантинируется и поднимается metric.
|
||||||
|
|
||||||
|
## 10. TTL policy
|
||||||
|
|
||||||
|
| Категория | TTL |
|
||||||
|
|---|---|
|
||||||
|
| idempotency completed | 24h по arch-02 |
|
||||||
|
| idempotency in-progress lock | 30s, heartbeat bounded |
|
||||||
|
| rate limit | window + 10–30% deterministic jitter |
|
||||||
|
| realtime connection | 90s; set membership 120s |
|
||||||
|
| coordination lock | 30s |
|
||||||
|
| safety task | default 15m, обязательно > API poll max 300s + recovery margin |
|
||||||
|
| safety cache | default 5–60m по rules version |
|
||||||
|
|
||||||
|
Новый key без TTL запрещён contract test, кроме Pub/Sub channel (не key) и ops metadata с явным обоснованием.
|
||||||
|
|
||||||
|
## 11. Atomicity и Lua governance
|
||||||
|
|
||||||
|
Scripts/functions хранятся в репозитории рядом с клиентом, versioned и тестируются на real Redis. Запрещены unbounded loops/SCAN внутри Lua. Входные массивы ограничены. Script timeout отслеживается; `SCRIPT KILL` runbook применяется только если нет writes либо после оценки.
|
||||||
|
|
||||||
|
Обязательные scripts:
|
||||||
|
|
||||||
|
- rate-limit evaluate;
|
||||||
|
- idempotency reserve/complete/conflict;
|
||||||
|
- lock release/extend;
|
||||||
|
- realtime heartbeat/cleanup membership;
|
||||||
|
- safety task get+increment poll при необходимости.
|
||||||
|
|
||||||
|
Redis transaction не координирует PostgreSQL/S3/HTTP. Cross-system consistency обеспечивается DB checkpoint/outbox и idempotent finalize.
|
||||||
|
|
||||||
|
## 12. Persistence
|
||||||
|
|
||||||
|
Решение MVP: AOF `appendonly yes`, `appendfsync everysec` плюс RDB snapshots (`save 900 1`, `300 100`, `60 10000` либо tuned). Это ускоряет восстановление ephemeral state, но не превращает Redis в authoritative store.
|
||||||
|
|
||||||
|
`aof-use-rdb-preamble yes`, automatic rewrite с порогами; volume `redis-data`. При corruption используется `redis-check-aof`/restore clean instance, а сервисы восстанавливают authoritative state из PostgreSQL.
|
||||||
|
|
||||||
|
RPO Redis до ~1 секунды приемлем, потому что бизнес-RPO задаётся PostgreSQL/S3. Backup Redis не обязателен для бизнес-восстановления, но периодическая копия RDB/AOF полезна для ops forensic без secrets.
|
||||||
|
|
||||||
|
## 13. Memory и eviction
|
||||||
|
|
||||||
|
`maxmemory` задаётся относительно container limit (ориентир 70–75%, оставляя overhead/fork). Начальная оценка для одной VM — 512 MiB, уточняется load test.
|
||||||
|
|
||||||
|
Eviction MVP: `volatile-lru`/`volatile-ttl`, так как все application keys имеют TTL. `allkeys-lru` опасен для idempotency при memory pressure; `noeviction` может полностью закрыть writes. Окончательный выбор после нагрузки: предпочтительно `volatile-lru` + alerts, а при разделении instances DB0 idempotency получает отдельную noeviction policy.
|
||||||
|
|
||||||
|
Контролируются `used_memory`, RSS, fragmentation, evicted_keys, expired_keys, key count/avg TTL по DB. OOM/eviction idempotency не создаёт дубль благодаря PostgreSQL fallback.
|
||||||
|
|
||||||
|
## 14. Sizing
|
||||||
|
|
||||||
|
Расчёт до production:
|
||||||
|
|
||||||
|
```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
|
||||||
|
DB2 = safety tasks within TTL × avg task metadata
|
||||||
|
total × 1.5 allocator/fragmentation × 1.3 growth reserve
|
||||||
|
```
|
||||||
|
|
||||||
|
Pub/Sub output buffers и slow consumers имеют hard/soft limits. Load test фиксирует peak RPS, WS connections, idempotency response size и AOF rewrite headroom.
|
||||||
|
|
||||||
|
## 15. Auth, ACL и network boundary
|
||||||
|
|
||||||
|
Redis не публикует `6379` на host, подключён только к Docker `backend`. `protected-mode yes`, bind container interface, default user отключён. ACL users:
|
||||||
|
|
||||||
|
- `api_backend`: DB0/DB1 key prefixes, нужные command categories;
|
||||||
|
- `message_safety`: только DB2 prefixes;
|
||||||
|
- `ops_health`: `PING`, ограниченный `INFO`;
|
||||||
|
|
||||||
|
Важно: Redis ACL не ограничивает logical DB напрямую надёжно; key-prefix patterns и разные credentials обязательны. `SELECT` запрещается, клиент URL сразу задаёт DB, но ACL prefix остаётся основной защитой.
|
||||||
|
|
||||||
|
Dangerous/admin commands (`FLUSHALL`, `FLUSHDB`, `CONFIG`, `MODULE`, broad KEYS`, replication changes) запрещены application users; rename-command не считается основной защитой.
|
||||||
|
|
||||||
|
Пароли сильные, только env/secret mount, rotation current/new через rolling deploy. Внутри одной VM TLS Redis опционален при закрытой Docker network; при выносе за host/VPC TLS обязателен (`rediss://`) и plaintext отключается.
|
||||||
|
|
||||||
|
## 16. Docker/runtime
|
||||||
|
|
||||||
|
```text
|
||||||
|
redis/
|
||||||
|
docker-compose.yml
|
||||||
|
redis.conf
|
||||||
|
users.acl.template
|
||||||
|
scripts/
|
||||||
|
tests/
|
||||||
|
```
|
||||||
|
|
||||||
|
Compose: pinned Redis image, `expose: 6379`, без `ports`, `backend` network, `redis-data:/data`, config/ACL read-only, non-root UID, no-new-privileges, dropped capabilities, resource/memory/ulimit settings.
|
||||||
|
|
||||||
|
Startup валидирует config и ACL, permissions volume, затем Redis. Healthcheck использует ACL health user и `redis-cli --no-auth-warning PING`, secret не печатается. Graceful stop timeout позволяет AOF flush.
|
||||||
|
|
||||||
|
URL:
|
||||||
|
|
||||||
|
```text
|
||||||
|
REDIS_URL=redis://api_backend:<secret>@redis:6379/0
|
||||||
|
REDIS_REALTIME_URL=redis://api_backend:<secret>@redis:6379/1
|
||||||
|
MESSAGE_SAFETY_REDIS_URL=redis://message_safety:<secret>@redis:6379/2
|
||||||
|
```
|
||||||
|
|
||||||
|
Добавление credential env требует обновления arch-04 `.env.example`; до этого имена credential variables — TBD, URL может содержать injected secret.
|
||||||
|
|
||||||
|
## 17. Health и degraded behavior
|
||||||
|
|
||||||
|
`PING` проверяет liveness Redis; readiness приложений проверяет auth, correct DB и выполнение малого read/write/expire script без оставления key.
|
||||||
|
|
||||||
|
При 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;
|
||||||
|
- safety stub для digit task не может гарантировать GET task state — check возвращает `503`, а существующие task GET — `503`; синхронные text allow/deny могут работать только если policy явно разрешает Redis-independent path;
|
||||||
|
- internal inbox не теряется из-за Redis, так как durable receipt в PostgreSQL.
|
||||||
|
|
||||||
|
При latency выше threshold clients используют short timeout/circuit, не создают бесконечные retry storms. Reconnect — exponential backoff+jitter.
|
||||||
|
|
||||||
|
## 18. Backup и restore
|
||||||
|
|
||||||
|
Redis backup не используется для бизнес restore. Runbook:
|
||||||
|
|
||||||
|
1. остановить/изолировать corrupted instance;
|
||||||
|
2. при целостном AOF/RDB восстановить на отдельном instance и проверить;
|
||||||
|
3. иначе поднять пустой Redis;
|
||||||
|
4. api-backend прогревает idempotency по durable records, realtime восстанавливается reconnect/polling;
|
||||||
|
5. незавершённые safety tasks обрабатываются по service semantics/expire; api-backend durable `safety_tasks` сообщает dependency error/recovery.
|
||||||
|
|
||||||
|
Не копировать Redis dump в небезопасное место: keys содержат UUID и hashed identifiers.
|
||||||
|
|
||||||
|
## 19. Metrics и alerts
|
||||||
|
|
||||||
|
- availability, commands/sec, latency percentiles;
|
||||||
|
- connected/blocked clients, rejected connections;
|
||||||
|
- memory/RSS/fragmentation, maxmemory ratio;
|
||||||
|
- evictions/expirations/keyspace hits/misses;
|
||||||
|
- AOF fsync latency/rewrite status/last save;
|
||||||
|
- replication metrics зарезервированы;
|
||||||
|
- key count/avg TTL по DB без key values;
|
||||||
|
- script errors/NOSCRIPT/slowlog;
|
||||||
|
- rate limit decisions, idempotency hit/conflict/fallback;
|
||||||
|
- Pub/Sub subscribers/output buffer/slow disconnect;
|
||||||
|
- safety task create/get/expire.
|
||||||
|
|
||||||
|
Alerts: unavailable, p99 latency, >80/90% memory, any sustained evictions, AOF error, no recent persistence, client buffer pressure, unexpected keys without TTL.
|
||||||
|
|
||||||
|
## 20. Тесты
|
||||||
|
|
||||||
|
- ACL: каждый service видит только свой prefix/commands;
|
||||||
|
- порт 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;
|
||||||
|
- safety task TTL, concurrent polls и missing task;
|
||||||
|
- `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.
|
||||||
|
|
||||||
|
## 21. Definition of Done
|
||||||
|
|
||||||
|
- DB0/DB1/DB2 roles и prefixes реализованы;
|
||||||
|
- Lua scripts atomic, bounded, versioned и покрыты real Redis tests;
|
||||||
|
- idempotency 24h и durable fallback доказаны;
|
||||||
|
- realtime loss восстанавливается REST;
|
||||||
|
- Safety DB2 task TTL превышает poll/recovery budget;
|
||||||
|
- AOF/RDB, volume, restart и clean-instance recovery проверены;
|
||||||
|
- maxmemory/eviction/resource limits основаны на load test;
|
||||||
|
- ACL users и network isolation работают, порт не published;
|
||||||
|
- health/degraded policies реализованы в clients;
|
||||||
|
- dashboards/alerts/runbook готовы;
|
||||||
|
- Redis не используется как `sync_queue`, delivery queue, message/audit source of truth или OTP store.
|
||||||
|
|
||||||
|
## 22. Решения, допущения и TBD
|
||||||
|
|
||||||
|
**Решения:** один instance/три DB MVP; AOF everysec + RDB; Pub/Sub best effort; PostgreSQL durable fallback; prefix ACL; все application keys с TTL.
|
||||||
|
|
||||||
|
**Допущения:** одна VM и одна replica API на старте; Redis loss допустим без потери business truth.
|
||||||
|
|
||||||
|
**TBD:** R1 точный maxmemory после load profile; R2 eviction policy после измерений; R3 credential env names в arch-04; R4 Safety task TTL/recovery margin; R5 TLS при изменении network topology; R6 момент разделения DB на instances; R7 RPO/RTO ops target.
|
||||||
@@ -0,0 +1,458 @@
|
|||||||
|
# module-05. Проектная спецификация заглушки `message-safety`
|
||||||
|
|
||||||
|
> Статус: целевая спецификация тестовой заглушки MVP, строго реализующей правила данного задания.
|
||||||
|
> Источники: [`README.md`](README.md), [`arch-00-glossary.md`](arch-00-glossary.md), [`arch-01-system-architecture.md`](arch-01-system-architecture.md), [`arch-02-api-contracts.md`](arch-02-api-contracts.md), [`arch-03-docker-compose-blueprint.md`](arch-03-docker-compose-blueprint.md), [`arch-04-settings-and-content.md`](arch-04-settings-and-content.md), [`arch-05-agent-development-process.md`](arch-05-agent-development-process.md), [`module-01-api-backend.md`](module-01-api-backend.md), [`module-04-redis.md`](module-04-redis.md).
|
||||||
|
|
||||||
|
## 1. Назначение и ограничение
|
||||||
|
|
||||||
|
Сервис — internal stub для проверки orchestration `api-backend`, а не реальный moderation/antivirus engine. Он доступен только в Docker network и реализует канонические пути arch-02:
|
||||||
|
|
||||||
|
- `POST /internal/safety/v1/messages/check`;
|
||||||
|
- `GET /internal/safety/v1/messages/tasks/{task_id}`;
|
||||||
|
- `GET /health/live`;
|
||||||
|
- `GET /health/ready`.
|
||||||
|
|
||||||
|
Сервис не публикуется через nginx, не получает JWT пользователя, не перемещает S3 objects, не отправляет сообщения в Bitrix и не хранит бизнес-историю.
|
||||||
|
|
||||||
|
## 2. Главное отличие тестовой заглушки
|
||||||
|
|
||||||
|
По базовой архитектуре final deny у Message Safety обычно `403`. Для этой заглушки пользователь явно задал особый task-контракт: `GET task` независимо возвращает примерно с равной вероятностью `203`, `200` или **`400`**.
|
||||||
|
|
||||||
|
Здесь `400` на валидном `GET task` — **финальный отрицательный verdict/error заглушки**, а не malformed HTTP request. `api-backend` обязан трактовать его как terminal safety rejection и отображать публично как `422 message_blocked`, выставляя `safety_status=blocked`, `delivery_status=rejected`, без вызова Bitrix. Клиенту raw internal `400` не проксируется.
|
||||||
|
|
||||||
|
Это намеренное test-only расширение текущей таблицы arch-02 (`200/203/403`). Перед использованием не как заглушки arch-02 и contract tests должны быть обновлены либо `400` должен быть заменён на канонический `403`. Существующие arch-файлы в рамках этой задачи не изменяются.
|
||||||
|
|
||||||
|
## 3. Технологический профиль
|
||||||
|
|
||||||
|
- Python 3.12+, FastAPI, Pydantic v2, Uvicorn.
|
||||||
|
- Redis asyncio client, DB2.
|
||||||
|
- OpenTelemetry, JSON logging.
|
||||||
|
- pytest/anyio, HTTPX ASGI client, real Redis integration tests.
|
||||||
|
- Без PostgreSQL и S3 для этой stub-реализации; их будущая интеграция находится вне scope.
|
||||||
|
|
||||||
|
## 4. Приоритет правил
|
||||||
|
|
||||||
|
Перед классификацией текст нормализуется. Правила применяются строго в порядке:
|
||||||
|
|
||||||
|
1. validation/auth: invalid DTO или service token обрабатываются до бизнес-правил;
|
||||||
|
2. нормализация;
|
||||||
|
3. если первый Unicode code point нормализованного текста — кириллическая `ф` или `Ф`, вернуть `403 deny`;
|
||||||
|
4. иначе если первый code point — десятичная цифра, создать task и вернуть `203 pending`;
|
||||||
|
5. любой иной текст, включая пустой после допустимой нормализации, вернуть `200 allow`.
|
||||||
|
|
||||||
|
Таким образом, после нормализации строка не может одновременно начинаться и с `ф/Ф`, и с цифры. Rule `ф/Ф` записан раньше для явности. Для file-only request без текста default — `200 allow`; заглушка не сканирует файл.
|
||||||
|
|
||||||
|
## 5. Нормализация
|
||||||
|
|
||||||
|
Детерминированный pipeline:
|
||||||
|
|
||||||
|
1. требовать JSON UTF-8;
|
||||||
|
2. заменить `CRLF/CR` на `LF`;
|
||||||
|
3. Unicode normalization `NFKC`;
|
||||||
|
4. удалить leading Unicode whitespace (`lstrip`);
|
||||||
|
5. не менять регистр всей строки и не удалять punctuation;
|
||||||
|
6. ограничить текст max length до значения internal DTO (ориентир 10 000 code points).
|
||||||
|
|
||||||
|
Примеры:
|
||||||
|
|
||||||
|
| Вход | После нормализации | Результат |
|
||||||
|
|---|---|---|
|
||||||
|
| `"Файл"` | `"Файл"` | 403 |
|
||||||
|
| `" фраза"` | `"фраза"` | 403 |
|
||||||
|
| `"\u00a07 дней"` | `"7 дней"` | 203 + task |
|
||||||
|
| `"+7..."` | `"+7..."` | 200 |
|
||||||
|
| `"документ"` | `"документ"` | 200 |
|
||||||
|
| `"abc"` | `"abc"` | 200 |
|
||||||
|
| `""`/whitespace | `""` | 200 |
|
||||||
|
|
||||||
|
«Цифра» означает Unicode category `Nd` после NFKC, не только ASCII `[0-9]`.
|
||||||
|
|
||||||
|
## 6. Authentication и common headers
|
||||||
|
|
||||||
|
Каждый `/internal/safety/v1/*` требует:
|
||||||
|
|
||||||
|
```text
|
||||||
|
X-Service-Token: ${MESSAGE_SAFETY_SERVICE_TOKEN}
|
||||||
|
X-Request-ID: UUID/ULID (если нет — сервис создаёт)
|
||||||
|
traceparent: optional W3C
|
||||||
|
```
|
||||||
|
|
||||||
|
Token сравнивается constant-time. Missing/invalid token → `401` или `403` internal auth error; выбран единый `401 service_unauthorized`, без подсказки о значении. Health не требует token внутри network либо использует отдельную ops policy.
|
||||||
|
|
||||||
|
## 7. DTO `POST .../check`
|
||||||
|
|
||||||
|
Stub принимает минимальный versioned DTO, совместимый с потребностями api-backend:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message_id": "uuid",
|
||||||
|
"content_kind": "text",
|
||||||
|
"text": "Фраза",
|
||||||
|
"attachment": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Для file:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"message_id": "uuid",
|
||||||
|
"content_kind": "file",
|
||||||
|
"text": "",
|
||||||
|
"attachment": {
|
||||||
|
"attachment_id": "uuid",
|
||||||
|
"quarantine_object_key": "opaque",
|
||||||
|
"mime_type": "application/pdf",
|
||||||
|
"size_bytes": 12345,
|
||||||
|
"checksum": "sha256:..."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Неизвестные поля запрещены. `content_kind=text` требует text field (пустой разрешён именно stub default); `file` допускает attachment metadata, но не читает S3. `message_id` нужен для correlation/idempotency, не для выбора verdict.
|
||||||
|
|
||||||
|
## 8. Ответы `POST .../check`
|
||||||
|
|
||||||
|
### `200 allow`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"verdict": "allow",
|
||||||
|
"rule_id": "stub.default_allow",
|
||||||
|
"rules_version": "2026-01-01"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `403 deny` для `ф/Ф`
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"verdict": "deny",
|
||||||
|
"rule_id": "stub.starts_with_cyrillic_ef",
|
||||||
|
"reason_code": "stub_blocked",
|
||||||
|
"rules_version": "2026-01-01"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### `203 pending` для цифры
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"verdict": "pending",
|
||||||
|
"task_id": "uuid",
|
||||||
|
"poll_after_ms": 2000,
|
||||||
|
"expires_at": "2026-07-10T12:15:00Z",
|
||||||
|
"rules_version": "2026-01-01"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Все три — нормальные domain outcomes. `403` не участвует в circuit breaker failure count.
|
||||||
|
|
||||||
|
## 9. Task storage Redis DB2
|
||||||
|
|
||||||
|
Ключ:
|
||||||
|
|
||||||
|
```text
|
||||||
|
han:safety:task:{task_id}
|
||||||
|
```
|
||||||
|
|
||||||
|
HASH/JSON v1:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"message_id": "uuid",
|
||||||
|
"created_at_ms": 0,
|
||||||
|
"poll_count": 0,
|
||||||
|
"rng_context": "optional-test-only",
|
||||||
|
"rules_version": "2026-01-01"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
TTL `MESSAGE_SAFETY_TASK_TTL_SEC`, default 900 seconds, должен быть больше `MESSAGE_SAFETY_TASK_POLL_MAX_SEC` (300) плюс network/recovery margin. Текст, attachment key и checksum в Redis не нужны. Создание task и TTL атомарны. Коллизия UUID повторяется bounded.
|
||||||
|
|
||||||
|
`message_id → task_id` dedup key допустим для идемпотентного повторного POST:
|
||||||
|
|
||||||
|
```text
|
||||||
|
han:safety:task-by-message:{message_id} -> task_id
|
||||||
|
```
|
||||||
|
|
||||||
|
с тем же TTL; reserve обоих keys выполняется Lua. Повтор одинакового check возвращает тот же active task. Если fingerprint изменился для того же message id — `409 safety_request_conflict`.
|
||||||
|
|
||||||
|
## 10. `GET .../tasks/{task_id}`
|
||||||
|
|
||||||
|
Сначала проверяются token, UUID и существование task. Затем **на каждый GET независимо** выбирается один из трёх outcomes с вероятностью примерно 1/3:
|
||||||
|
|
||||||
|
- `203 pending`;
|
||||||
|
- `200 allow`;
|
||||||
|
- `400 stub_final_error` (terminal deny/error).
|
||||||
|
|
||||||
|
Предыдущий `200` или `400` не фиксируется как sticky verdict в Redis по буквальному требованию «дальнейший GET случайно и независимо». Следовательно, повторный GET того же task после terminal ответа теоретически может вернуть другой outcome. `api-backend` обязан прекратить polling на первом terminal `200/400`, поэтому противоречие снаружи не возникает.
|
||||||
|
|
||||||
|
Это поведение специально тестовое и не годится для production moderation. Для безопасной recovery production service должен сохранять sticky final verdict; переход потребует изменения режима/контракта.
|
||||||
|
|
||||||
|
### Ответы
|
||||||
|
|
||||||
|
`203`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"verdict":"pending","task_id":"uuid","poll_after_ms":2000}
|
||||||
|
```
|
||||||
|
|
||||||
|
`200`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"verdict":"allow","task_id":"uuid","rule_id":"stub.random_allow"}
|
||||||
|
```
|
||||||
|
|
||||||
|
`400` terminal:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"verdict":"deny",
|
||||||
|
"task_id":"uuid",
|
||||||
|
"error":{
|
||||||
|
"code":"stub_final_error",
|
||||||
|
"message":"Stub task returned a final negative verdict",
|
||||||
|
"request_id":"uuid",
|
||||||
|
"details":{"terminal":true}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Для malformed `task_id` используется `400 validation_error`, но его envelope имеет `verdict` отсутствующий и `details.terminal` отсутствует/false. Для неизвестного/expired task — `404 task_not_found`. Api-backend различает terminal stub `400` строго по schema/code, а не по одному HTTP status.
|
||||||
|
|
||||||
|
## 11. Worker/poll model
|
||||||
|
|
||||||
|
Реальный worker не требуется. Task создаётся сразу, а GET эмулирует состояние worker случайным outcome. Контракт остаётся таким же, как для async orchestration: check создаёт `task_id`, api-backend poll-ит GET внутри исходного user POST.
|
||||||
|
|
||||||
|
Опциональный `SAFETY_STUB_WORKER_MODE=emulated_on_poll` — единственный режим MVP. Будущий worker mode не должен менять endpoint/DTO, но final verdict тогда становится sticky.
|
||||||
|
|
||||||
|
Api-backend:
|
||||||
|
|
||||||
|
```text
|
||||||
|
POST check
|
||||||
|
200 -> allow
|
||||||
|
403 -> deny -> public 422 message_blocked
|
||||||
|
203 -> poll GET
|
||||||
|
GET 203 -> continue
|
||||||
|
GET 200 -> allow
|
||||||
|
GET 400 + code=stub_final_error + terminal=true
|
||||||
|
-> deny -> public 422 message_blocked
|
||||||
|
other 400 -> dependency contract error, not message verdict
|
||||||
|
timeout/5xx/redis unavailable -> public 503/504
|
||||||
|
```
|
||||||
|
|
||||||
|
## 12. Randomness и deterministic testing
|
||||||
|
|
||||||
|
Production-like stub default использует криптографически достаточный process RNG либо `random.Random` с entropy seed; распределение не является security decision.
|
||||||
|
|
||||||
|
RNG внедряется через интерфейс `VerdictRng.choice()`. Test implementations:
|
||||||
|
|
||||||
|
- sequence RNG: `pending, allow, final_error`;
|
||||||
|
- seeded RNG через `SAFETY_STUB_RNG_SEED` только при `APP_ENV=test`;
|
||||||
|
- forced outcome через dependency override, не public header.
|
||||||
|
|
||||||
|
В production-like env seed/forced mode вызывает startup failure, чтобы внешний caller не управлял verdict. Статистический test на большой выборке проверяет каждую долю в допустимом диапазоне (например, 0.30–0.36), но основные tests используют sequence RNG и не flaky.
|
||||||
|
|
||||||
|
«Независимо» означает новый RNG draw на каждый валидный GET; poll count/предыдущий outcome не влияют на draw.
|
||||||
|
|
||||||
|
## 13. Error semantics
|
||||||
|
|
||||||
|
Internal envelope:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"error": {
|
||||||
|
"code": "validation_error",
|
||||||
|
"message": "Request is invalid",
|
||||||
|
"request_id": "uuid",
|
||||||
|
"details": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
| HTTP | Code | Retry/смысл |
|
||||||
|
|---|---|---|
|
||||||
|
| 400 | `validation_error` | malformed, не terminal verdict |
|
||||||
|
| 400 | `stub_final_error` + verdict deny | terminal task verdict, не malformed |
|
||||||
|
| 401 | `service_unauthorized` | не retry без исправления secret |
|
||||||
|
| 403 | domain `deny` POST | terminal safety verdict |
|
||||||
|
| 404 | `task_not_found` | expired/unknown, dependency contract failure |
|
||||||
|
| 409 | `safety_request_conflict` | message id с другим fingerprint |
|
||||||
|
| 429 | `rate_limit_exceeded` | retry по `Retry-After` |
|
||||||
|
| 500 | `internal_error` | retry/circuit |
|
||||||
|
| 503 | `redis_unavailable` | retry/circuit |
|
||||||
|
|
||||||
|
Domain `403` и terminal stub `400` не считаются infrastructure failure circuit breaker.
|
||||||
|
|
||||||
|
## 14. Idempotency и concurrency
|
||||||
|
|
||||||
|
POST fingerprint = SHA-256 canonical normalized DTO без request-id/token. Lua reserve обеспечивает один task на `(message_id,fingerprint)` в TTL. Concurrent duplicate получает тот же task id.
|
||||||
|
|
||||||
|
GET атомарно проверяет существование и увеличивает `poll_count`; RNG draw выполняется независимо. Удалять task после terminal нельзя, иначе повтор получил бы 404 и нарушил независимый test behavior. TTL выполняет cleanup.
|
||||||
|
|
||||||
|
## 15. Health
|
||||||
|
|
||||||
|
`GET /health/live`: только process/event loop, всегда без Redis call.
|
||||||
|
|
||||||
|
`GET /health/ready` проверяет:
|
||||||
|
|
||||||
|
- env/token/rules version валидны;
|
||||||
|
- Redis DB2 auth, PING и короткий SET/GET/DEL с TTL;
|
||||||
|
- RNG provider доступен;
|
||||||
|
- OpenAPI schema загружена.
|
||||||
|
|
||||||
|
Redis down → `503 {"status":"not_ready","components":{"redis":"down"}}`. Текстовые sync rules технически вычислимы, но service целиком not-ready, а digit check возвращает 503, чтобы не выдавать task без storage.
|
||||||
|
|
||||||
|
## 16. Observability
|
||||||
|
|
||||||
|
JSON fields: timestamp, level, `service.name=message-safety`, module, event, request_id, trace_id/span_id, route, status, duration, rule_id, verdict, task_age_bucket, poll_count bucket, error_code.
|
||||||
|
|
||||||
|
Не логируются service token, message text, attachment key/name, checksum, DTO body или PII. Разрешены message/task UUID при принятой retention либо их hash.
|
||||||
|
|
||||||
|
Metrics:
|
||||||
|
|
||||||
|
- requests/latency/errors по route/status;
|
||||||
|
- check outcomes allow/deny/pending;
|
||||||
|
- task GET outcomes pending/allow/final_error;
|
||||||
|
- observed distribution;
|
||||||
|
- task create/dedup/conflict/not-found/expired;
|
||||||
|
- Redis latency/error/pool;
|
||||||
|
- auth rejects, rate limit;
|
||||||
|
- RNG mode как low-cardinality info;
|
||||||
|
- readiness.
|
||||||
|
|
||||||
|
Trace связывается с api-backend через `traceparent`, `X-Request-ID` возвращается.
|
||||||
|
|
||||||
|
## 17. Security
|
||||||
|
|
||||||
|
- только Docker backend network, без nginx/public route и host port;
|
||||||
|
- constant-time token compare, secret только env/secret mount;
|
||||||
|
- strict JSON schema/max body/max text;
|
||||||
|
- no dynamic code/rules from request;
|
||||||
|
- Redis ACL только DB2 prefixes;
|
||||||
|
- non-root, read-only root fs, tmpfs `/tmp`, dropped capabilities;
|
||||||
|
- OpenAPI docs UI production отключён, committed YAML остаётся;
|
||||||
|
- CORS не нужен internal service;
|
||||||
|
- rate limit по service identity/network защищает от accidental loops;
|
||||||
|
- error response не раскрывает internal host/stack/secret.
|
||||||
|
|
||||||
|
## 18. Docker и env
|
||||||
|
|
||||||
|
```text
|
||||||
|
message-safety/
|
||||||
|
app/
|
||||||
|
main.py
|
||||||
|
api/{routes,schemas,errors,auth}.py
|
||||||
|
application/{classifier,tasks}.py
|
||||||
|
infrastructure/{redis,rng,observability}.py
|
||||||
|
settings.py
|
||||||
|
tests/{unit,integration,contract}/
|
||||||
|
openapi.yaml
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
Compose: `expose: 8080`, networks `backend`,`observability`, без `ports`, depends_on Redis health, собственный retry startup.
|
||||||
|
|
||||||
|
Env:
|
||||||
|
|
||||||
|
```text
|
||||||
|
APP_ENV=production-like
|
||||||
|
MESSAGE_SAFETY_PORT=8080
|
||||||
|
MESSAGE_SAFETY_REDIS_URL=redis://message_safety:<secret>@redis:6379/2
|
||||||
|
MESSAGE_SAFETY_SERVICE_TOKEN=<secret>
|
||||||
|
MESSAGE_SAFETY_RULES_VERSION=2026-01-01
|
||||||
|
MESSAGE_SAFETY_TASK_TTL_SEC=900
|
||||||
|
MESSAGE_SAFETY_POLL_AFTER_MS=2000
|
||||||
|
SAFETY_STUB_WORKER_MODE=emulated_on_poll
|
||||||
|
SAFETY_STUB_RNG_SEED=
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
|
||||||
|
```
|
||||||
|
|
||||||
|
Новые env (`TASK_TTL`, `POLL_AFTER`, stub mode/seed) требуют внесения в arch-04 перед реализацией production config; здесь они зафиксированы как предложение.
|
||||||
|
|
||||||
|
## 19. OpenAPI
|
||||||
|
|
||||||
|
`message-safety/openapi.yaml` OpenAPI 3.1 обязателен и включает:
|
||||||
|
|
||||||
|
- security scheme `X-Service-Token`;
|
||||||
|
- check request union text/file;
|
||||||
|
- exact 200/203/403 responses POST;
|
||||||
|
- exact 200/203/400/404 responses GET;
|
||||||
|
- discriminator между malformed 400 и terminal stub 400;
|
||||||
|
- common request/trace headers;
|
||||||
|
- examples, max lengths, UUID/checksum formats;
|
||||||
|
- health endpoints.
|
||||||
|
|
||||||
|
Generated/runtime schema сравнивается с committed artifact. Contract test api-backend отдельно закрепляет mapping terminal `400 stub_final_error → 422 message_blocked`.
|
||||||
|
|
||||||
|
## 20. Тестовая матрица
|
||||||
|
|
||||||
|
### Unit
|
||||||
|
|
||||||
|
- NFKC/whitespace/Unicode `Nd`;
|
||||||
|
- `ф`, `Ф`, fullwidth variants, punctuation/default;
|
||||||
|
- exact rule priority;
|
||||||
|
- DTO union/limits;
|
||||||
|
- injected sequence and seeded RNG;
|
||||||
|
- error discrimination and log redaction.
|
||||||
|
|
||||||
|
### Integration
|
||||||
|
|
||||||
|
- Redis DB2 task/dedup/TTL/atomic concurrency;
|
||||||
|
- same message same/different fingerprint;
|
||||||
|
- task expiration;
|
||||||
|
- Redis outage/reconnect;
|
||||||
|
- ACL rejection outside prefix;
|
||||||
|
- poll count concurrency.
|
||||||
|
|
||||||
|
### Contract
|
||||||
|
|
||||||
|
- POST `документ`/default 200, `ф/Ф` 403, digit 203;
|
||||||
|
- GET independent 203/200/400;
|
||||||
|
- terminal 400 schema versus malformed 400;
|
||||||
|
- auth missing/wrong/correct;
|
||||||
|
- request id/trace propagation;
|
||||||
|
- api-backend mapping to public 422 and no Bitrix call;
|
||||||
|
- OpenAPI runtime parity.
|
||||||
|
|
||||||
|
### Statistical/failure
|
||||||
|
|
||||||
|
- 30k+ GET draws approximately 1/3 each with non-flaky tolerance;
|
||||||
|
- prior outcome does not influence next seeded sequence;
|
||||||
|
- API sync wait terminates on first 200/400;
|
||||||
|
- repeated 203 reaches timeout behavior;
|
||||||
|
- Redis restart loses ephemeral task safely and API returns dependency error;
|
||||||
|
- no text/token/object key in logs.
|
||||||
|
|
||||||
|
## 21. Definition of Done
|
||||||
|
|
||||||
|
- канонические endpoint paths arch-02 реализованы;
|
||||||
|
- правило normalized `ф/Ф → 403`, digit → `203 task`, others → `200` покрыто;
|
||||||
|
- каждый valid task GET независимо даёт 203/200/terminal 400 примерно 1/3;
|
||||||
|
- distinction terminal vs malformed 400 формально задано;
|
||||||
|
- api-backend contract mapping terminal 400 → public 422 проверен;
|
||||||
|
- Redis DB2 atomic task/dedup/TTL и degraded behavior готовы;
|
||||||
|
- RNG injected, deterministic tests не flaky, prod seed запрещён;
|
||||||
|
- service token/network/ACL/container hardening проверены;
|
||||||
|
- health, JSON logs, metrics/traces без PII/secrets;
|
||||||
|
- OpenAPI 3.1 committed и contract tests зелёные;
|
||||||
|
- контейнер запускается в root Compose без published port;
|
||||||
|
- intentional divergence с arch-02 либо принята как stub exception, либо arch-02 обновлён до production implementation.
|
||||||
|
|
||||||
|
## 22. Решения, допущения и TBD
|
||||||
|
|
||||||
|
**Решения:** normalizer NFKC+lstrip; Unicode `Nd`; default allow; emulation on GET без worker; independent non-sticky outcomes; `400 stub_final_error` terminal и преобразуется API в 422.
|
||||||
|
|
||||||
|
**Допущения:** пустой/file-only text попадает в default 200; `message_id` передаётся internal DTO; Redis task TTL 900 секунд достаточен для MVP tests.
|
||||||
|
|
||||||
|
**TBD:**
|
||||||
|
|
||||||
|
- S1 формально обновить arch-02 для test-only terminal 400 или вернуть production 403;
|
||||||
|
- S2 окончательный internal DTO/fingerprint в OpenAPI;
|
||||||
|
- S3 добавить новые env в arch-04;
|
||||||
|
- S4 точный Redis task TTL относительно extended recovery module-01;
|
||||||
|
- S5 sticky final verdict при переходе от stub к реальному Safety;
|
||||||
|
- S6 реальные file/link checks, PostgreSQL schema и S3 read-only — вне scope заглушки.
|
||||||
@@ -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`](arch-00-glossary.md), [`arch-01-system-architecture.md`](arch-01-system-architecture.md), [`arch-02-api-contracts.md`](arch-02-api-contracts.md), [`arch-03-docker-compose-blueprint.md`](arch-03-docker-compose-blueprint.md), [`arch-04-settings-and-content.md`](arch-04-settings-and-content.md), [`arch-05-agent-development-process.md`](arch-05-agent-development-process.md), [`module-01-api-backend.md`](module-01-api-backend.md), прототип [`../../HAN_chat/bitrix-local-app/README.md`](../../HAN_chat/bitrix-local-app/README.md) и [`../../HAN_chat/deploy/init-managed-postgres.py`](../../HAN_chat/deploy/init-managed-postgres.py).
|
||||||
|
|
||||||
|
## 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`;
|
||||||
|
- изменение `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.
|
||||||
|
- B-TBD7: antivirus policy operator files остаётся у `api-backend`.
|
||||||
@@ -0,0 +1,479 @@
|
|||||||
|
# module-07. Проектная спецификация заглушки `bitrix-sync`
|
||||||
|
|
||||||
|
> Статус: целевая спецификация инфраструктурной заглушки MVP. CRM-синхронизация не реализуется.
|
||||||
|
> Источники: [`README.md`](README.md), [`arch-00-glossary.md`](arch-00-glossary.md), [`arch-01-system-architecture.md`](arch-01-system-architecture.md), [`arch-02-api-contracts.md`](arch-02-api-contracts.md), [`arch-03-docker-compose-blueprint.md`](arch-03-docker-compose-blueprint.md), [`arch-04-settings-and-content.md`](arch-04-settings-and-content.md), [`arch-05-agent-development-process.md`](arch-05-agent-development-process.md), [`module-01-api-backend.md`](module-01-api-backend.md), [`../../HAN_chat/deploy/init-managed-postgres.py`](../../HAN_chat/deploy/init-managed-postgres.py).
|
||||||
|
|
||||||
|
## 1. Назначение и жёсткая граница
|
||||||
|
|
||||||
|
На текущем этапе сервис доказывает только:
|
||||||
|
|
||||||
|
1. контейнер и FastAPI process стабильно запускаются в общем Compose;
|
||||||
|
2. сервис подключается к managed PostgreSQL по private network/TLS;
|
||||||
|
3. при старте и затем примерно раз в 60 секунд выполняется `SELECT 1`;
|
||||||
|
4. состояние доступно через health и защищённый status endpoint;
|
||||||
|
5. shutdown корректно останавливает loop и закрывает pool.
|
||||||
|
|
||||||
|
В этой версии **нет**:
|
||||||
|
|
||||||
|
- чтения/обработки `han_app.sync_queue`;
|
||||||
|
- CRM Contact map/create/update;
|
||||||
|
- вызовов Bitrix24 REST;
|
||||||
|
- CRM webhook `/bitrix/sync/webhook/contact`;
|
||||||
|
- доступа к OAuth `bitrix-local-app`;
|
||||||
|
- write-back в `han_app`, GUC `han.sync_suppress`;
|
||||||
|
- DLQ бизнес-задач и field mapping.
|
||||||
|
|
||||||
|
Упоминания полноценного sync в arch-01/02/03 описывают будущую целевую границу, а не функциональность этого stub. Расширение требует новой версии спецификации, migrations/GRANT, OpenAPI и contract tests.
|
||||||
|
|
||||||
|
## 2. Технологический профиль
|
||||||
|
|
||||||
|
- Python 3.12+, FastAPI, Pydantic v2, Uvicorn.
|
||||||
|
- SQLAlchemy 2 async/`asyncpg` либо прямой `asyncpg` pool; выбран SQLAlchemy async для единообразия с backend.
|
||||||
|
- Managed PostgreSQL; схема/role `bitrix_sync`.
|
||||||
|
- Один in-process periodic loop на replica.
|
||||||
|
- OpenTelemetry, JSON logging, pytest/anyio.
|
||||||
|
|
||||||
|
```text
|
||||||
|
bitrix-sync/
|
||||||
|
app/
|
||||||
|
main.py
|
||||||
|
settings.py
|
||||||
|
api/{health,status,auth,errors,schemas}.py
|
||||||
|
application/{db_probe,periodic_loop,state}.py
|
||||||
|
infrastructure/{database,observability}.py
|
||||||
|
tests/{unit,integration,contract}/
|
||||||
|
openapi.yaml
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Runtime model
|
||||||
|
|
||||||
|
FastAPI lifespan:
|
||||||
|
|
||||||
|
```text
|
||||||
|
validate env
|
||||||
|
configure logs/OTEL
|
||||||
|
create bounded DB engine/pool
|
||||||
|
run initial probe with startup timeout
|
||||||
|
publish initial state
|
||||||
|
start exactly one periodic task
|
||||||
|
serve HTTP
|
||||||
|
on shutdown: signal stop → await/cancel sleep → finish bounded probe
|
||||||
|
→ close pool/OTEL → exit
|
||||||
|
```
|
||||||
|
|
||||||
|
HTTP process и loop разделяют thread-safe/async-safe immutable state snapshot. Router не выполняет probe для каждого status request.
|
||||||
|
|
||||||
|
## 4. Periodic loop
|
||||||
|
|
||||||
|
### 4.1. Период
|
||||||
|
|
||||||
|
Целевой интервал — примерно 60 секунд:
|
||||||
|
|
||||||
|
```text
|
||||||
|
BITRIX_SYNC_DB_CHECK_INTERVAL_SEC=60
|
||||||
|
```
|
||||||
|
|
||||||
|
Следующий запуск планируется от завершения предыдущего (`fixed-delay`), а не запускается параллельно. Добавляется jitter, например ±10%, чтобы несколько replicas не синхронизировались.
|
||||||
|
|
||||||
|
### 4.2. Initial check
|
||||||
|
|
||||||
|
Первый `SELECT 1` выполняется при startup до перехода в ready. Ошибка initial check не обязана завершать process: сервис остаётся live/not-ready и продолжает reconnect loop. Это позволяет восстановиться после временной недоступности managed PG без restart storm.
|
||||||
|
|
||||||
|
Невалидный env/DSN/TLS policy, напротив, является configuration error: process fail-fast.
|
||||||
|
|
||||||
|
### 4.3. Probe
|
||||||
|
|
||||||
|
Каждая проверка:
|
||||||
|
|
||||||
|
1. получает connection из pool с bounded acquire timeout;
|
||||||
|
2. выполняет параметризованный/constant `SELECT 1`;
|
||||||
|
3. проверяет результат `1`;
|
||||||
|
4. фиксирует monotonic duration и wall-clock UTC completion;
|
||||||
|
5. возвращает connection;
|
||||||
|
6. атомарно обновляет state.
|
||||||
|
|
||||||
|
Никаких table scans, DDL, schema writes и создания business rows.
|
||||||
|
|
||||||
|
### 4.4. Timeout
|
||||||
|
|
||||||
|
Общий probe timeout включает pool acquire + query:
|
||||||
|
|
||||||
|
```text
|
||||||
|
BITRIX_SYNC_DB_CHECK_TIMEOUT_SEC=5
|
||||||
|
```
|
||||||
|
|
||||||
|
На PostgreSQL задаются `connect_timeout`, `command_timeout`/`statement_timeout`. Timeout помечает check failed, отменяет query и гарантированно освобождает/инвалидирует connection.
|
||||||
|
|
||||||
|
### 4.5. Backoff
|
||||||
|
|
||||||
|
При успехе — обычный interval+jitter. При последовательных ошибках:
|
||||||
|
|
||||||
|
```text
|
||||||
|
delay = min(base * 2^(failures-1), max_backoff) + full_jitter
|
||||||
|
```
|
||||||
|
|
||||||
|
Ориентиры: base 5 с, max 60 с. Успех сбрасывает failure counter. Backoff не создаёт tight loop и не превышает readiness stale policy без явного статуса.
|
||||||
|
|
||||||
|
### 4.6. Prevention overlap
|
||||||
|
|
||||||
|
Одна task выполняет `await probe(); await sleep()`, поэтому overlap конструктивно невозможен. Дополнительно `asyncio.Lock`/single-flight защищает ручной internal trigger, если он когда-либо появится. В MVP trigger endpoint отсутствует.
|
||||||
|
|
||||||
|
При нескольких replicas каждая проверяет БД независимо; distributed lock не нужен, потому что `SELECT 1` безопасен и не является worker job.
|
||||||
|
|
||||||
|
## 5. Connection pool
|
||||||
|
|
||||||
|
Начальная конфигурация минимальна:
|
||||||
|
|
||||||
|
- pool size 1–2;
|
||||||
|
- max overflow 0;
|
||||||
|
- `pool_pre_ping=true` допустим, но не заменяет explicit probe;
|
||||||
|
- pool recycle меньше сетевого idle timeout провайдера;
|
||||||
|
- short acquire/connect/query timeout;
|
||||||
|
- TLS verify (`sslmode=verify-full` или эквивалент) с CA;
|
||||||
|
- `application_name=han-bitrix-sync`;
|
||||||
|
- search path только `bitrix_sync`.
|
||||||
|
|
||||||
|
Pool создаётся один раз и закрывается shutdown. Connection после network/protocol error invalidated. Пароль/DSN не логируются.
|
||||||
|
|
||||||
|
## 6. Enabled/disabled semantics
|
||||||
|
|
||||||
|
Сохраняется канонический `BITRIX_SYNC_ENABLED`.
|
||||||
|
|
||||||
|
### `true`
|
||||||
|
|
||||||
|
Для stub это означает: process запускает DB connectivity loop. Это **не** означает включённую CRM-синхронизацию. Status явно возвращает `mode=db_connectivity_stub`.
|
||||||
|
|
||||||
|
### `false`
|
||||||
|
|
||||||
|
- process и HTTP endpoint запускаются;
|
||||||
|
- DB pool можно не создавать, periodic loop не запускается;
|
||||||
|
- `/health/live` → `200`;
|
||||||
|
- `/health/ready` → `503` с `reason=sync_disabled`, как зафиксировано arch-04;
|
||||||
|
- internal status → `200`, `enabled=false`, `state=disabled`;
|
||||||
|
- CRM-функций всё равно нет.
|
||||||
|
|
||||||
|
Таким образом, disabled — явный no-op, а не скрытый success readiness.
|
||||||
|
|
||||||
|
## 7. HTTP API
|
||||||
|
|
||||||
|
### 7.1. `GET /health/live`
|
||||||
|
|
||||||
|
Без auth внутри Docker network. Не обращается к БД.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"status":"live"}
|
||||||
|
```
|
||||||
|
|
||||||
|
`200`, пока process/event loop обслуживает запросы.
|
||||||
|
|
||||||
|
### 7.2. `GET /health/ready`
|
||||||
|
|
||||||
|
Не выполняет новый DB query; читает snapshot.
|
||||||
|
|
||||||
|
`200`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "ready",
|
||||||
|
"mode": "db_connectivity_stub",
|
||||||
|
"database": {
|
||||||
|
"status": "ok",
|
||||||
|
"last_success_at": "2026-07-10T09:00:00Z",
|
||||||
|
"age_seconds": 12
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`503`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"status": "not_ready",
|
||||||
|
"reason": "database_unavailable",
|
||||||
|
"database": {
|
||||||
|
"status": "down",
|
||||||
|
"last_success_at": null,
|
||||||
|
"consecutive_failures": 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Ready только если enabled, initial success был и последний success не старше:
|
||||||
|
|
||||||
|
```text
|
||||||
|
max(2 * interval + jitter budget, BITRIX_SYNC_READY_MAX_STALENESS_SEC)
|
||||||
|
```
|
||||||
|
|
||||||
|
Рекомендуемый default staleness 150 с. Error detail не содержит host/DSN.
|
||||||
|
|
||||||
|
### 7.3. `GET /internal/sync/v1/status`
|
||||||
|
|
||||||
|
Защита:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Authorization: Bearer ${BITRIX_SYNC_SERVICE_TOKEN}
|
||||||
|
```
|
||||||
|
|
||||||
|
`X-Service-Token` можно поддержать только как migration compatibility; канонический вариант этого модуля — Bearer. Endpoint internal-only, edge не публикует.
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"service": "bitrix-sync",
|
||||||
|
"enabled": true,
|
||||||
|
"mode": "db_connectivity_stub",
|
||||||
|
"crm_sync_implemented": false,
|
||||||
|
"state": "healthy",
|
||||||
|
"started_at": "2026-07-10T08:00:00Z",
|
||||||
|
"last_check": {
|
||||||
|
"started_at": "2026-07-10T09:00:00Z",
|
||||||
|
"finished_at": "2026-07-10T09:00:00Z",
|
||||||
|
"success": true,
|
||||||
|
"duration_ms": 7,
|
||||||
|
"error_code": null
|
||||||
|
},
|
||||||
|
"last_success_at": "2026-07-10T09:00:00Z",
|
||||||
|
"consecutive_failures": 0,
|
||||||
|
"next_check_in_seconds": 48
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Не возвращаются queue depth/dead letters, поскольку сервис их не читает. Поля, обещающие CRM run, не симулируются.
|
||||||
|
|
||||||
|
Invalid token → `401 service_unauthorized`, constant-time compare. Status endpoint не запускает probe.
|
||||||
|
|
||||||
|
## 8. State machine
|
||||||
|
|
||||||
|
```text
|
||||||
|
starting
|
||||||
|
├─ disabled → disabled
|
||||||
|
├─ initial success → healthy
|
||||||
|
└─ initial failure → degraded
|
||||||
|
healthy
|
||||||
|
├─ one/more failures → degraded
|
||||||
|
└─ shutdown → stopping
|
||||||
|
degraded
|
||||||
|
├─ success → healthy
|
||||||
|
└─ shutdown → stopping
|
||||||
|
```
|
||||||
|
|
||||||
|
Snapshot содержит start/check timestamps, last success/failure, consecutive failures, duration и safe error code: `db_connect_timeout`, `db_query_timeout`, `db_auth_failed`, `db_tls_failed`, `db_unavailable`, `unexpected_result`.
|
||||||
|
|
||||||
|
Auth/TLS/config ошибки могут быть classified non-transient и alertятся немедленно, но loop продолжает с max backoff, если env был syntactically valid.
|
||||||
|
|
||||||
|
## 9. PostgreSQL и права
|
||||||
|
|
||||||
|
Managed init уже создаёт:
|
||||||
|
|
||||||
|
- schema `bitrix_sync`;
|
||||||
|
- role `bitrix_sync_user`;
|
||||||
|
- search path `bitrix_sync`;
|
||||||
|
- отсутствие доступа к чужим схемам.
|
||||||
|
|
||||||
|
Для stub достаточно `CONNECT` к database и возможности `SELECT 1`; `USAGE` на `bitrix_sync` допустим для будущих migration/version checks. Таблицы не нужны. Alembic может иметь пустую baseline revision, чтобы зафиксировать ownership/version, но runtime не выполняет DDL.
|
||||||
|
|
||||||
|
К `han_app` **не выдаются GRANT** до реализации полноценной CRM sync. Это сознательно строже общего будущего требования. Когда появится sync:
|
||||||
|
|
||||||
|
- GRANT выдаётся точечно на `sync_queue`, mapping и необходимые columns;
|
||||||
|
- запрещён broad schema write;
|
||||||
|
- GUC/write-back и trigger contract проходят integration tests;
|
||||||
|
- обновляются deploy scripts и module spec.
|
||||||
|
|
||||||
|
`BITRIX_SYNC_APP_DATABASE_URL` из arch-04 в stub не требуется. Канонический runtime DSN stub — `BITRIX_SYNC_DATABASE_URL` с search path `bitrix_sync`.
|
||||||
|
|
||||||
|
## 10. Env
|
||||||
|
|
||||||
|
Уже канонические:
|
||||||
|
|
||||||
|
```text
|
||||||
|
APP_ENV=production-like
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
BITRIX_SYNC_ENABLED=true
|
||||||
|
BITRIX_SYNC_DATABASE_URL=postgresql://bitrix_sync_user:.../han_chat?options=-csearch_path%3Dbitrix_sync
|
||||||
|
BITRIX_SYNC_SERVICE_TOKEN=<secret>
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
|
||||||
|
```
|
||||||
|
|
||||||
|
Предлагаемые technical env, которые нужно синхронизировать с arch-04 до реализации:
|
||||||
|
|
||||||
|
```text
|
||||||
|
BITRIX_SYNC_DB_CHECK_INTERVAL_SEC=60
|
||||||
|
BITRIX_SYNC_DB_CHECK_TIMEOUT_SEC=5
|
||||||
|
BITRIX_SYNC_DB_CHECK_JITTER_RATIO=0.10
|
||||||
|
BITRIX_SYNC_DB_RETRY_BASE_SEC=5
|
||||||
|
BITRIX_SYNC_DB_RETRY_MAX_SEC=60
|
||||||
|
BITRIX_SYNC_READY_MAX_STALENESS_SEC=150
|
||||||
|
BITRIX_SYNC_DB_POOL_SIZE=2
|
||||||
|
BITRIX_SYNC_DB_POOL_RECYCLE_SEC=300
|
||||||
|
```
|
||||||
|
|
||||||
|
Старые `BITRIX_SYNC_CONTACT_*`, CRM URL/webhook/concurrency в stub не читаются и не должны создавать иллюзию sync. Их можно оставить в root env зарезервированными, но status явно сообщает `crm_sync_implemented=false`.
|
||||||
|
|
||||||
|
## 11. Observability
|
||||||
|
|
||||||
|
### Logs
|
||||||
|
|
||||||
|
JSON fields:
|
||||||
|
|
||||||
|
- timestamp, level, `service.name=bitrix-sync`;
|
||||||
|
- module, event, request_id, trace/span id;
|
||||||
|
- enabled/mode/state;
|
||||||
|
- check sequence, success, duration_ms, consecutive failures;
|
||||||
|
- error_code; shutdown reason.
|
||||||
|
|
||||||
|
Не логируются DSN, DB password, service token, SQL exception с credentials, host при принятой security policy. Сам `SELECT 1` можно не логировать каждый раз на INFO: success — DEBUG/metric, state transition — INFO, failure — WARN/ERROR с throttling.
|
||||||
|
|
||||||
|
### Metrics
|
||||||
|
|
||||||
|
- `bitrix_sync_db_probe_total{outcome}`;
|
||||||
|
- duration histogram;
|
||||||
|
- consecutive failures gauge;
|
||||||
|
- seconds since last success;
|
||||||
|
- state info/enabled;
|
||||||
|
- pool checked-out/wait duration/errors;
|
||||||
|
- HTTP requests/latency/status;
|
||||||
|
- loop lag;
|
||||||
|
- readiness.
|
||||||
|
|
||||||
|
Labels low-cardinality; DB host/error text не labels.
|
||||||
|
|
||||||
|
### Traces
|
||||||
|
|
||||||
|
Initial/periodic probe создаёт span `bitrix_sync.db_probe`; SQL statement sanitised/semantic convention. OTEL outage не влияет на readiness.
|
||||||
|
|
||||||
|
## 12. Security
|
||||||
|
|
||||||
|
- сервис только в `backend`/`observability` networks, без published port;
|
||||||
|
- `/internal/sync/v1/status` не edge-routed;
|
||||||
|
- Bearer token constant-time, secret только env/secret mount;
|
||||||
|
- managed PG private network + TLS verify;
|
||||||
|
- runtime DB role least privilege; никаких `han_app` grants;
|
||||||
|
- strict env validation; OpenAPI docs off production;
|
||||||
|
- non-root/read-only rootfs/tmpfs/drop capabilities;
|
||||||
|
- pinned dependencies/image, vulnerability scan;
|
||||||
|
- responses/logs не раскрывают DSN/credentials/internal stack.
|
||||||
|
|
||||||
|
## 13. Docker и healthcheck
|
||||||
|
|
||||||
|
Service:
|
||||||
|
|
||||||
|
- `expose: 8080`, без `ports`;
|
||||||
|
- networks `backend`,`observability`;
|
||||||
|
- env из root `.env`;
|
||||||
|
- managed PostgreSQL вне Compose;
|
||||||
|
- restart policy `unless-stopped`/platform policy;
|
||||||
|
- init/signal forwarding;
|
||||||
|
- graceful stop timeout больше probe timeout.
|
||||||
|
|
||||||
|
Container healthcheck использует `/health/live`, чтобы временная DB outage не создавала restart storm. Orchestrator/monitoring отдельно проверяет `/health/ready`.
|
||||||
|
|
||||||
|
Startup dependency не задаётся через fake PostgreSQL container. Application самостоятельно reconnect с backoff.
|
||||||
|
|
||||||
|
## 14. Graceful shutdown
|
||||||
|
|
||||||
|
На SIGTERM:
|
||||||
|
|
||||||
|
1. FastAPI перестаёт принимать новые запросы по server grace;
|
||||||
|
2. выставляется stop event;
|
||||||
|
3. interruptible sleep завершается немедленно;
|
||||||
|
4. новый probe не стартует;
|
||||||
|
5. текущий probe ждётся максимум shutdown budget, затем отменяется;
|
||||||
|
6. connection корректно возвращается/invalidate;
|
||||||
|
7. pool и telemetry flush закрываются;
|
||||||
|
8. task awaited — никаких `Task was destroyed`.
|
||||||
|
|
||||||
|
Shutdown не пишет бизнес-данные и не требует БД.
|
||||||
|
|
||||||
|
## 15. Ошибки и degraded behavior
|
||||||
|
|
||||||
|
| Ситуация | Process | Live | Ready | Loop |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| disabled | работает | 200 | 503 `sync_disabled` | не запущен |
|
||||||
|
| PG startup down | работает | 200 | 503 | retry/backoff |
|
||||||
|
| PG кратко down после success | работает | 200 | 503 после policy/stale | retry |
|
||||||
|
| wrong password | работает или fail-fast по policy | 200 если работает | 503 | max backoff + alert |
|
||||||
|
| malformed DSN/env | fail-fast | — | — | — |
|
||||||
|
| OTEL down | работает | 200 | по DB | продолжает |
|
||||||
|
| loop task unexpectedly died | работает кратко | 200 | 503 `worker_not_running` | supervisor/exit |
|
||||||
|
|
||||||
|
Необработанное исключение loop не должно молча оставить stale ready. Lifespan supervisor помечает not-ready и завершает process либо перезапускает task bounded; предпочтительно fail process после alert, чтобы orchestrator восстановил clean state.
|
||||||
|
|
||||||
|
## 16. Тестовая матрица
|
||||||
|
|
||||||
|
### Unit
|
||||||
|
|
||||||
|
- interval+jitter boundaries;
|
||||||
|
- exponential backoff/reset;
|
||||||
|
- state transitions/staleness;
|
||||||
|
- no-overlap single-flight;
|
||||||
|
- enabled/disabled;
|
||||||
|
- safe error classification/redaction;
|
||||||
|
- shutdown during sleep/probe.
|
||||||
|
|
||||||
|
### Integration
|
||||||
|
|
||||||
|
- initial and periodic `SELECT 1` на PostgreSQL;
|
||||||
|
- pool size/acquire timeout/recycle;
|
||||||
|
- DB unavailable then recovery without restart;
|
||||||
|
- query timeout/cancel and connection return;
|
||||||
|
- wrong credentials/TLS;
|
||||||
|
- no tables/writes and no `han_app` access;
|
||||||
|
- exact approximate 60-second scheduling with fake clock.
|
||||||
|
|
||||||
|
### Contract
|
||||||
|
|
||||||
|
- OpenAPI 3.1 parity;
|
||||||
|
- health/status schemas and HTTP codes;
|
||||||
|
- missing/wrong/correct `BITRIX_SYNC_SERVICE_TOKEN`;
|
||||||
|
- request-id/trace;
|
||||||
|
- internal endpoint absent through nginx.
|
||||||
|
|
||||||
|
### Runtime/failure
|
||||||
|
|
||||||
|
- SIGTERM at each loop phase;
|
||||||
|
- loop crash detection;
|
||||||
|
- long DB outage without log/reconnect storm;
|
||||||
|
- multiple replicas independently probe without overlap within replica;
|
||||||
|
- no secret/DSN in logs;
|
||||||
|
- Compose health does not restart solely on PG outage.
|
||||||
|
|
||||||
|
## 17. Definition of Done
|
||||||
|
|
||||||
|
- FastAPI process и один periodic loop реализованы;
|
||||||
|
- initial check и `SELECT 1` примерно каждые 60 с работают;
|
||||||
|
- timeout, jitter, backoff, overlap prevention и recovery проверены;
|
||||||
|
- pool bounded и graceful shutdown доказан;
|
||||||
|
- live/ready/status соответствуют contract и service token;
|
||||||
|
- enabled/disabled semantics явны;
|
||||||
|
- status всегда сообщает `mode=db_connectivity_stub`, `crm_sync_implemented=false`;
|
||||||
|
- runtime не читает `han_app`, queue или Bitrix CRM;
|
||||||
|
- least-privilege DB/network/container security соблюдены;
|
||||||
|
- structured logs/metrics/traces без secrets;
|
||||||
|
- OpenAPI, Docker healthcheck и tests готовы;
|
||||||
|
- future CRM boundary документирована и не реализована скрыто.
|
||||||
|
|
||||||
|
## 18. Решения, допущения и TBD
|
||||||
|
|
||||||
|
**Решения:**
|
||||||
|
|
||||||
|
- S1: stub выполняет только DB connectivity probe.
|
||||||
|
- S2: fixed-delay loop + jitter; overlap невозможен.
|
||||||
|
- S3: PG outage даёт live/not-ready, а не restart storm.
|
||||||
|
- S4: disabled даёт live 200, ready 503 `sync_disabled`.
|
||||||
|
- S5: `han_app` GRANT отсутствует до реальной sync.
|
||||||
|
- S6: status internal защищён Bearer `BITRIX_SYNC_SERVICE_TOKEN`.
|
||||||
|
|
||||||
|
**Допущения:**
|
||||||
|
|
||||||
|
- A1: одна replica MVP; несколько replicas безопасны, поскольку probe read-only.
|
||||||
|
- A2: interval 60 с и timeout 5 с достаточны для connectivity smoke.
|
||||||
|
- A3: managed PG CA/TLS параметры предоставляет ops.
|
||||||
|
|
||||||
|
**TBD:**
|
||||||
|
|
||||||
|
- S-TBD1: добавить proposed DB probe env в arch-04.
|
||||||
|
- S-TBD2: ready staleness threshold и alert thresholds после ops review.
|
||||||
|
- S-TBD3: fail-fast или persistent degraded при non-transient auth/TLS error.
|
||||||
|
- S-TBD4: baseline Alembic revision без таблиц — решение владельца deploy.
|
||||||
|
- S-TBD5: полноценная CRM sync, webhook, queue, grants, retries и mapping — отдельная будущая спецификация.
|
||||||
@@ -0,0 +1,703 @@
|
|||||||
|
# module-08. Проектная спецификация `keycloak`
|
||||||
|
|
||||||
|
> Статус: целевая production-спецификация MVP; реальный SMS provider не входит в scope.
|
||||||
|
> Источники: [`README.md`](README.md), [`arch-00-glossary.md`](arch-00-glossary.md), [`arch-01-system-architecture.md`](arch-01-system-architecture.md), [`arch-02-api-contracts.md`](arch-02-api-contracts.md), [`arch-03-docker-compose-blueprint.md`](arch-03-docker-compose-blueprint.md), [`arch-04-settings-and-content.md`](arch-04-settings-and-content.md), [`arch-05-agent-development-process.md`](arch-05-agent-development-process.md), [`module-01-api-backend.md`](module-01-api-backend.md), [`module-02-frontend-test-site.md`](module-02-frontend-test-site.md), [`module-03-nginx.md`](module-03-nginx.md), [`../../HAN_chat/deploy/init-managed-postgres.py`](../../HAN_chat/deploy/init-managed-postgres.py).
|
||||||
|
|
||||||
|
## 1. Назначение и границы
|
||||||
|
|
||||||
|
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, mock verification и продуктовые limits;
|
||||||
|
- 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-сессию;
|
||||||
|
- реальную отправку SMS в MVP.
|
||||||
|
|
||||||
|
Реальный SMS provider — строго extension point/TBD. Mock code является секретом окружения, не контентом UI и не логируется.
|
||||||
|
|
||||||
|
## 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` инициирует mock/provider send.
|
||||||
|
7. Показывается форма OTP.
|
||||||
|
8. Проверяются TTL/attempt limits/constant-time hash or mock compare.
|
||||||
|
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 OTP
|
||||||
|
|
||||||
|
Env:
|
||||||
|
|
||||||
|
```text
|
||||||
|
KEYCLOAK_OTP_MOCK_ENABLED=true
|
||||||
|
KEYCLOAK_OTP_MOCK_CODE=<secret>
|
||||||
|
```
|
||||||
|
|
||||||
|
Правила:
|
||||||
|
|
||||||
|
- mock разрешён MVP production-like только как явно принятый риск;
|
||||||
|
- пустой/default `1234` запрещён startup policy для production-like, если не согласован secret;
|
||||||
|
- code не входит в realm import, frontend config, HTML hint, API response, logs, metrics, traces или audit;
|
||||||
|
- сравнение constant-time;
|
||||||
|
- challenge всё равно имеет TTL, max verify attempts и counters, чтобы flow был близок production;
|
||||||
|
- code не сохраняется per-user в открытом виде;
|
||||||
|
- UI сообщает только «тестовый режим», без кода;
|
||||||
|
- `KEYCLOAK_OTP_MOCK_ENABLED=false` при отсутствии configured provider делает OTP flow fail-closed/not-ready, а не пропускает проверку.
|
||||||
|
|
||||||
|
Реальный provider interface:
|
||||||
|
|
||||||
|
```java
|
||||||
|
interface OtpDeliveryProvider {
|
||||||
|
DeliveryResult send(E164Phone phone, String otp, Duration ttl, Correlation ctx);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Будущий provider обязан вернуть `provider_message_id`; raw OTP не логируется. Выбор provider, template, sender, delivery status webhook и vendor credentials — TBD.
|
||||||
|
|
||||||
|
## 8. OTP challenge и counters
|
||||||
|
|
||||||
|
Даже в mock:
|
||||||
|
|
||||||
|
- challenge id random ≥128 bit;
|
||||||
|
- OTP не хранится raw; production-generated code — keyed hash/HMAC с challenge salt/pepper;
|
||||||
|
- TTL (предлагается 5 минут) — technical security parameter;
|
||||||
|
- one-time use; success atomically consumes challenge;
|
||||||
|
- max verification attempts per challenge;
|
||||||
|
- resend invalidates либо version-binds предыдущий challenge;
|
||||||
|
- replay/parallel verify безопасны;
|
||||||
|
- destination stored masked/hash where possible.
|
||||||
|
|
||||||
|
Audit fields по arch-05: provider message id (для mock — synthetic non-secret), sent_at, destination_masked, otp_hash/reference, attempts, outcome. Никогда raw code.
|
||||||
|
|
||||||
|
### 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,
|
||||||
|
"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, otp_hash/mock marker, created/expires/consumed, verify attempts, settings version, provider id/status;
|
||||||
|
- `han_otp_send_counter`: phone_hmac, window_start, count, last_sent_at;
|
||||||
|
- `han_otp_security_event`: append-only minimal outcome/retention.
|
||||||
|
|
||||||
|
Indexes: unique active challenge policy, `(phone_hmac,window_start)`, `(expires_at)`. Cleanup bounded job. Доступ только `keycloak_user`.
|
||||||
|
|
||||||
|
## 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. CAPTCHA/risk engine — future extension.
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
## 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>
|
||||||
|
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
|
||||||
|
```
|
||||||
|
|
||||||
|
Дополнительные Keycloak-standard env version-specific (`KC_DB`, hostname/proxy/health/metrics/pool) фиксируются в `.env.example` после выбора image. Секреты только root `.env`/secret mounts с минимальными permissions.
|
||||||
|
|
||||||
|
Product limits `otp.phone.*` не дублируются env. OTP TTL/max verify attempts — security technical config provider-а; их имена нужно добавить в arch-04 до реализации, например:
|
||||||
|
|
||||||
|
```text
|
||||||
|
KEYCLOAK_OTP_TTL_SEC=300
|
||||||
|
KEYCLOAK_OTP_MAX_VERIFY_ATTEMPTS=5
|
||||||
|
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 либо реальный provider configured.
|
||||||
|
|
||||||
|
Стандартный 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;
|
||||||
|
- provider mode info (`mock`, later vendor), без 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`;
|
||||||
|
- без 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 |
|
||||||
|
| SMS mode без provider | not-ready `otp_provider_unconfigured` |
|
||||||
|
| 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.
|
||||||
|
|
||||||
|
### 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;
|
||||||
|
- 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 durable в Keycloak schema;
|
||||||
|
- 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 зелёная;
|
||||||
|
- реальный SMS явно остаётся extension point, не скрытой заглушкой.
|
||||||
|
|
||||||
|
## 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 SMS provider — extension point/TBD.
|
||||||
|
|
||||||
|
**Допущения:**
|
||||||
|
|
||||||
|
- A1: единый public host `tohin.ru` и relative path `/auth`.
|
||||||
|
- A2: Keycloak version поддерживает нужные hostname/proxy/health options; точные names pin после выбора image.
|
||||||
|
- A3: product допускает mock OTP в первой production-like среде как временный риск.
|
||||||
|
- 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: SMS vendor, credentials, templates, sender, delivery receipts and failover.
|
||||||
|
- K-TBD9: CAPTCHA/risk scoring после mock.
|
||||||
|
- K-TBD10: добавить proposed OTP technical env в arch-04 до реализации.
|
||||||
@@ -0,0 +1,572 @@
|
|||||||
|
# module-09. Наблюдаемость production-like контура
|
||||||
|
|
||||||
|
> Статус: целевая спецификация наблюдаемости MVP на одной VM.
|
||||||
|
> Источники: [`README.md`](README.md), [`arch-00-glossary.md`](arch-00-glossary.md), [`arch-01-system-architecture.md`](arch-01-system-architecture.md), [`arch-02-api-contracts.md`](arch-02-api-contracts.md), [`arch-03-docker-compose-blueprint.md`](arch-03-docker-compose-blueprint.md), [`arch-04-settings-and-content.md`](arch-04-settings-and-content.md), [`arch-05-agent-development-process.md`](arch-05-agent-development-process.md), [`module-01-api-backend.md`](module-01-api-backend.md)–[`module-08-keycloak.md`](module-08-keycloak.md).
|
||||||
|
|
||||||
|
## 1. Цели и границы
|
||||||
|
|
||||||
|
Наблюдаемость должна позволять:
|
||||||
|
|
||||||
|
- найти пользовательский запрос по `request_id`, `trace_id` или `ux_session_id`;
|
||||||
|
- восстановить путь «frontend → nginx → API → Safety → S3/Open Lines»;
|
||||||
|
- измерять доступность, задержку, ошибки и насыщение каждого сервиса;
|
||||||
|
- обнаруживать backlog, DLQ, circuit open, потерю telemetry и истечение TLS;
|
||||||
|
- расследовать security/audit события без записи PII и секретов;
|
||||||
|
- проверять SLO по данным, независимым от бизнес-логов.
|
||||||
|
|
||||||
|
Telemetry не является источником бизнес-истины и не влияет на auth, safety verdict или доставку сообщений. Недоступность Collector не должна блокировать запросы. Audit в `han_app` — отдельный durable контур.
|
||||||
|
|
||||||
|
## 2. Production-like решение MVP
|
||||||
|
|
||||||
|
### 2.1. Обязательный минимум в основном Compose
|
||||||
|
|
||||||
|
Архитектура явно требует только `otel-collector`. Поэтому **основной production-like Compose обязан содержать Collector, но не обязан размещать Prometheus/Grafana/Loki/Tempo на той же VM**.
|
||||||
|
|
||||||
|
Предпочтительный operable-вариант после выбора backend:
|
||||||
|
|
||||||
|
1. приложения экспортируют OTLP gRPC в `otel-collector:4317`;
|
||||||
|
2. Collector отправляет telemetry в выбранный удалённый управляемый OTLP backend провайдера;
|
||||||
|
3. JSON stdout остаётся аварийным локальным журналом Docker с rotation;
|
||||||
|
4. пока удалённый backend не выбран, допустим архитектурный минимум из arch-03: bounded JSON stdout/platform logs и Collector `debug` exporter с sampling в acceptance; такой режим не считается полноценным production-хранением и не закрывает alerting/SLO.
|
||||||
|
|
||||||
|
Требуемые возможности удалённого backend: OTLP ingest, поиск traces, PromQL-совместимые или эквивалентные metrics, поиск структурированных logs, alerting, RBAC, retention и TLS.
|
||||||
|
|
||||||
|
### 2.2. Самостоятельно размещаемая опция
|
||||||
|
|
||||||
|
Опциональный Compose profile `observability-local` может включать:
|
||||||
|
|
||||||
|
- Prometheus — scrape метрик Collector/Redis/Keycloak/nginx exporters;
|
||||||
|
- Grafana — dashboards и alerts;
|
||||||
|
- Loki — логи;
|
||||||
|
- Tempo — traces.
|
||||||
|
|
||||||
|
Он **не включается по умолчанию на малой VM**: стек требует дополнительной RAM/диска и сам становится объектом backup/monitoring. Для operable local-варианта нужны отдельный volume каждому backend, retention limits, compaction, auth через ops/VPN и отсутствие host ports. Grafana доступна только через отдельный защищённый ops route/VPN, не через публичный `/`.
|
||||||
|
|
||||||
|
Рекомендуемый минимум VM при local profile: дополнительно 4 vCPU, 8 ГБ RAM и 100+ ГБ SSD сверх приложения; точный размер — после измерения ingest.
|
||||||
|
|
||||||
|
## 3. Архитектура Collector
|
||||||
|
|
||||||
|
### 3.1. Компоненты
|
||||||
|
|
||||||
|
```text
|
||||||
|
backend services ─OTLP gRPC/HTTP─┐
|
||||||
|
nginx/Redis/Keycloak exporters ──┼─> otel-collector
|
||||||
|
Docker JSON stdout ─filelog───────┘ ├─ OTLP/TLS remote backend
|
||||||
|
├─ Prometheus endpoint (optional)
|
||||||
|
└─ debug exporter (acceptance only)
|
||||||
|
```
|
||||||
|
|
||||||
|
Collector запускается одним сервисом MVP. При росте разделяется на agent/gateway: локальный agent принимает и буферизует, remote gateway выполняет policy/export.
|
||||||
|
|
||||||
|
### 3.2. Receivers
|
||||||
|
|
||||||
|
- `otlp` gRPC `0.0.0.0:4317` — основной internal receiver;
|
||||||
|
- `otlp` HTTP `0.0.0.0:4318` — совместимость SDK;
|
||||||
|
- `prometheus` — scrape самого Collector, Keycloak metrics, Redis exporter, nginx exporter и сервисных `/metrics`, если они не идут OTLP;
|
||||||
|
- `filelog` — только если Docker logging driver предоставляет read-only каталог/volume; парсит JSON stdout без чтения secret-файлов;
|
||||||
|
- `hostmetrics` — CPU, memory, filesystem, network VM/container host, если Collector получает только необходимые read-only mounts.
|
||||||
|
|
||||||
|
Порты `4317`, `4318`, `8888`, `8889` используют `expose`, не `ports`. Receiver доступен только в сети `observability`.
|
||||||
|
|
||||||
|
### 3.3. Processors и порядок
|
||||||
|
|
||||||
|
Во всех pipelines первым стоит защита памяти, последним — batch:
|
||||||
|
|
||||||
|
1. `memory_limiter`: check interval 1s, soft/hard limit относительно container memory;
|
||||||
|
2. `resource`: нормализует `service.namespace=han-chat`, `deployment.environment`, `service.version`;
|
||||||
|
3. `attributes`: удаляет/маскирует sensitive attributes;
|
||||||
|
4. `transform`: нормализует route/status/error semantic conventions;
|
||||||
|
5. `filter`: исключает health noise, debug events и запрещённые поля;
|
||||||
|
6. `probabilistic_sampler` или tail sampling для traces;
|
||||||
|
7. `batch`: bounded batch/timeout;
|
||||||
|
8. при remote export — `queued_retry`/sending queue и `file_storage` extension.
|
||||||
|
|
||||||
|
`memory_limiter` не заменяется Docker OOM limit. При pressure Collector отбрасывает telemetry контролируемо и увеличивает `otelcol_processor_refused_*`.
|
||||||
|
|
||||||
|
### 3.4. Exporters
|
||||||
|
|
||||||
|
- `otlp/remote`: TLS verify, endpoint и auth header из secret env/mount;
|
||||||
|
- `prometheus`: optional pull endpoint только internal;
|
||||||
|
- `debug`: только `APP_ENV=test|acceptance`, verbosity normal; production debug exporter по умолчанию выключен;
|
||||||
|
- `loki`/`otlphttp` — только если выбран backend и его контракт закреплён.
|
||||||
|
|
||||||
|
Секрет exporter-а не должен появляться в rendered config, логах или `/debug/configz`. Config монтируется read-only; secret подставляется env.
|
||||||
|
|
||||||
|
### 3.5. Extensions
|
||||||
|
|
||||||
|
- `health_check` — internal endpoint, используется Compose;
|
||||||
|
- `pprof`/`zpages` — только при явном ops profile, internal network;
|
||||||
|
- `file_storage` — persistent sending queue на volume `otel-queue`;
|
||||||
|
- `basicauth`/`oauth2client` — если требует remote backend.
|
||||||
|
|
||||||
|
### 3.6. Принципиальная конфигурация
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
receivers:
|
||||||
|
otlp:
|
||||||
|
protocols:
|
||||||
|
grpc: {endpoint: 0.0.0.0:4317}
|
||||||
|
http: {endpoint: 0.0.0.0:4318}
|
||||||
|
prometheus:
|
||||||
|
config:
|
||||||
|
scrape_configs:
|
||||||
|
- job_name: otel-collector
|
||||||
|
static_configs: [{targets: ["127.0.0.1:8888"]}]
|
||||||
|
|
||||||
|
processors:
|
||||||
|
memory_limiter:
|
||||||
|
check_interval: 1s
|
||||||
|
limit_mib: 384
|
||||||
|
spike_limit_mib: 96
|
||||||
|
resource/common:
|
||||||
|
attributes:
|
||||||
|
- {key: service.namespace, value: han-chat, action: upsert}
|
||||||
|
- {key: deployment.environment, value: "${env:APP_ENV}", action: upsert}
|
||||||
|
attributes/redact:
|
||||||
|
actions:
|
||||||
|
- {key: http.request.header.authorization, action: delete}
|
||||||
|
- {key: http.request.header.cookie, action: delete}
|
||||||
|
- {key: url.query, action: delete}
|
||||||
|
- {key: db.statement, action: delete}
|
||||||
|
filter/noise:
|
||||||
|
error_mode: ignore
|
||||||
|
traces:
|
||||||
|
span:
|
||||||
|
- 'attributes["http.route"] == "/health/live"'
|
||||||
|
batch:
|
||||||
|
send_batch_size: 1024
|
||||||
|
timeout: 5s
|
||||||
|
|
||||||
|
exporters:
|
||||||
|
otlp/remote:
|
||||||
|
endpoint: "${env:OTEL_REMOTE_ENDPOINT}"
|
||||||
|
tls: {insecure: false}
|
||||||
|
headers: {authorization: "${env:OTEL_REMOTE_AUTH_HEADER}"}
|
||||||
|
|
||||||
|
extensions:
|
||||||
|
health_check: {endpoint: 0.0.0.0:13133}
|
||||||
|
file_storage: {directory: /var/lib/otelcol/queue}
|
||||||
|
|
||||||
|
service:
|
||||||
|
extensions: [health_check, file_storage]
|
||||||
|
pipelines:
|
||||||
|
traces:
|
||||||
|
receivers: [otlp]
|
||||||
|
processors: [memory_limiter, resource/common, attributes/redact, filter/noise, batch]
|
||||||
|
exporters: [otlp/remote]
|
||||||
|
metrics:
|
||||||
|
receivers: [otlp, prometheus]
|
||||||
|
processors: [memory_limiter, resource/common, attributes/redact, batch]
|
||||||
|
exporters: [otlp/remote]
|
||||||
|
logs:
|
||||||
|
receivers: [otlp]
|
||||||
|
processors: [memory_limiter, resource/common, attributes/redact, filter/noise, batch]
|
||||||
|
exporters: [otlp/remote]
|
||||||
|
telemetry:
|
||||||
|
metrics: {address: 0.0.0.0:8888}
|
||||||
|
```
|
||||||
|
|
||||||
|
Конкретная версия schema проверяется командой Collector `validate`; image закрепляется по digest. Значения memory/batch/queue — стартовые, не SLO.
|
||||||
|
|
||||||
|
## 4. Resource attributes и корреляция
|
||||||
|
|
||||||
|
Обязательные resource attributes:
|
||||||
|
|
||||||
|
- `service.name`: `nginx`, `api-backend`, `message-safety`, `bitrix-local-app`, `bitrix-sync`, `keycloak`, `redis`, `otel-collector`;
|
||||||
|
- `service.namespace=han-chat`;
|
||||||
|
- `service.version=<release-or-git-sha>`;
|
||||||
|
- `deployment.environment=production-like|production`;
|
||||||
|
- `host.name`/`service.instance.id` без публичного IP.
|
||||||
|
|
||||||
|
Обязательные поля request-события:
|
||||||
|
|
||||||
|
- `request_id`;
|
||||||
|
- `trace_id`, `span_id`;
|
||||||
|
- `ux_session_id` — nullable, только когда передан;
|
||||||
|
- `service.name`;
|
||||||
|
- `environment` либо canonical `deployment.environment`.
|
||||||
|
|
||||||
|
`request_id` формирует/валидирует nginx; сервис возвращает его клиенту и передаёт downstream. `trace_id` берётся из active span. `ux_session_id` не является auth и не должен использоваться как metric label.
|
||||||
|
|
||||||
|
## 5. W3C propagation
|
||||||
|
|
||||||
|
- принимаются только валидные `traceparent` и опциональный `tracestate`;
|
||||||
|
- nginx передаёт context в API; при edge instrumentation создаёт server span;
|
||||||
|
- API создаёт child spans для PostgreSQL, Redis, S3, Safety, Open Lines и JWKS;
|
||||||
|
- internal calls передают `traceparent`, `tracestate`, `X-Request-ID`;
|
||||||
|
- `baggage` по умолчанию не принимается от внешнего клиента; если включён, allow-list исключает PII;
|
||||||
|
- Bitrix24/S3 могут не вернуть context: внешний client span всё равно закрывается результатом;
|
||||||
|
- async outbox/inbox связывается span link с исходным trace; новый worker trace не притворяется продолжением спустя долгий срок.
|
||||||
|
|
||||||
|
Frontend может отправлять валидный `traceparent`, но backend не доверяет его sampling/security атрибутам.
|
||||||
|
|
||||||
|
## 6. JSON stdout contract
|
||||||
|
|
||||||
|
Одна JSON-запись на строку UTF-8:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"timestamp": "2026-07-10T09:00:00.123Z",
|
||||||
|
"level": "INFO",
|
||||||
|
"service.name": "api-backend",
|
||||||
|
"service.version": "git-abcdef0",
|
||||||
|
"environment": "production-like",
|
||||||
|
"module": "message_service",
|
||||||
|
"event": "message.delivery.completed",
|
||||||
|
"message": "Message delivery completed",
|
||||||
|
"request_id": "01J...",
|
||||||
|
"trace_id": "32hex",
|
||||||
|
"span_id": "16hex",
|
||||||
|
"ux_session_id": "uuid-or-null",
|
||||||
|
"route": "/api/v1/dialogs/{dialog_id}/messages",
|
||||||
|
"method": "POST",
|
||||||
|
"status_code": 201,
|
||||||
|
"duration_ms": 742,
|
||||||
|
"dependency": "bitrix-local-app",
|
||||||
|
"outcome": "success",
|
||||||
|
"error_code": null
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Правила:
|
||||||
|
|
||||||
|
- `event` — стабильная mnemonic, `message` — безопасное описание;
|
||||||
|
- route — template, никогда raw URI с id/query;
|
||||||
|
- stack trace допускается только в internal error log после redaction;
|
||||||
|
- message text, callback body, SQL values и file content запрещены;
|
||||||
|
- Docker driver ограничен `50m × 5`, но это buffer, не retention backend;
|
||||||
|
- multiline stack trace сериализуется полем JSON, не отдельными строками.
|
||||||
|
|
||||||
|
## 7. Redaction и data minimization
|
||||||
|
|
||||||
|
Удаляются или маскируются:
|
||||||
|
|
||||||
|
- `Authorization`, Cookie, Set-Cookie, JWT, OAuth/code/refresh/access tokens;
|
||||||
|
- raw OTP/mock code, Keycloak admin/client password;
|
||||||
|
- phone/email/full name, device id, IP по policy (допустим HMAC/truncated);
|
||||||
|
- message text, filenames с PII, document/file contents;
|
||||||
|
- DSN/password, Redis URL, S3 keys;
|
||||||
|
- presigned URL и любая query string;
|
||||||
|
- Bitrix raw payload/download URL/application token;
|
||||||
|
- `db.statement` с literals; предпочтительно operation/table, не SQL.
|
||||||
|
|
||||||
|
Redaction выполняется в SDK/logger **до stdout**, затем повторяется Collector processor. Collector не может считаться единственной защитой. Автотесты отправляют canary secrets/PII и требуют отсутствие во всех трёх сигналах.
|
||||||
|
|
||||||
|
## 8. Instrumentation по компонентам
|
||||||
|
|
||||||
|
### 8.1. FastAPI-сервисы
|
||||||
|
|
||||||
|
- OpenTelemetry ASGI/FastAPI server spans с route template;
|
||||||
|
- HTTPX client spans с sanitized host/method/status;
|
||||||
|
- SQLAlchemy/asyncpg spans без параметров и raw statement;
|
||||||
|
- Redis instrumentation с command name и DB index, без key/value;
|
||||||
|
- boto/S3 spans: operation/bucket logical name, без object key/query;
|
||||||
|
- background workers: span на claim/process/finalize, links на origin;
|
||||||
|
- исключить `/health/live` из traces; readiness оставить в metrics и sampled logs.
|
||||||
|
|
||||||
|
### 8.2. PostgreSQL
|
||||||
|
|
||||||
|
Собираются pool wait/checked-out, transaction duration, error class, migrations revision, managed PG provider metrics (CPU, storage, connections, locks, replication/PITR state). `user_id`, SQL text и row data не labels.
|
||||||
|
|
||||||
|
### 8.3. Redis
|
||||||
|
|
||||||
|
`redis_exporter` подключается отдельным ACL user только на `INFO`, `PING`, безопасные latency/keyspace metrics. Нужны memory ratio, evictions, expirations, blocked/rejected clients, command latency, AOF status/rewrite, Pub/Sub buffers, key count/TTL агрегаты. Keys/values не экспортируются.
|
||||||
|
|
||||||
|
### 8.4. 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 проверяется отдельно.
|
||||||
|
|
||||||
|
### 8.5. nginx
|
||||||
|
|
||||||
|
JSON access log содержит `request_id`, извлечённый `trace_id`, route class, method, normalized path, status, bytes, request/upstream duration/status, TLS version, cache status. `$request` с query не используется.
|
||||||
|
|
||||||
|
Collector `filelog` parser:
|
||||||
|
|
||||||
|
- разбирает JSON, timestamp и severity;
|
||||||
|
- переносит `service.name=nginx`;
|
||||||
|
- превращает пустые/`-` в null;
|
||||||
|
- route class нормализует в bounded set;
|
||||||
|
- отбрасывает ACME/health success noise;
|
||||||
|
- не парсит raw URI в labels.
|
||||||
|
|
||||||
|
Native nginx OTEL module предпочтителен, если image/version закреплены. Без него nginx только передаёт W3C context и коррелирует access log; первый server span создаёт API.
|
||||||
|
|
||||||
|
### 8.6. Host/Docker
|
||||||
|
|
||||||
|
CPU, load, memory/swap, disk usage/inodes/IO, network, container restarts/OOM, Docker daemon health и clock sync. Container name/version — bounded labels; container id не хранится как долгосрочный high-cardinality label.
|
||||||
|
|
||||||
|
## 9. Метрики бизнес-потоков
|
||||||
|
|
||||||
|
### 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}`.
|
||||||
|
|
||||||
|
### Message Safety
|
||||||
|
|
||||||
|
- checks/verdicts по `allow|deny|pending|error`;
|
||||||
|
- poll duration/count buckets, timeout и recovery backlog age;
|
||||||
|
- stub mode info и terminal `400` отдельно, пока действует test-only контракт;
|
||||||
|
- cache hit, Redis latency, task expired/not-found.
|
||||||
|
|
||||||
|
### Open Lines/Bitrix
|
||||||
|
|
||||||
|
- 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
|
||||||
|
|
||||||
|
- init/complete/promote/delete;
|
||||||
|
- quarantine object age/orphans;
|
||||||
|
- checksum/MIME/size reject;
|
||||||
|
- presigned download issued;
|
||||||
|
- S3 dependency latency/error by operation and logical bucket.
|
||||||
|
|
||||||
|
### Frontend synthetic
|
||||||
|
|
||||||
|
- public config/content;
|
||||||
|
- OIDC discovery/authorization page;
|
||||||
|
- WS handshake;
|
||||||
|
- test-user end-to-end flow в отдельной тестовой identity без реального PII.
|
||||||
|
|
||||||
|
Никакие UUID/user/session/dialog/task/message id не labels. Они допустимы только в sampled logs/traces при принятой retention.
|
||||||
|
|
||||||
|
## 10. Dashboards
|
||||||
|
|
||||||
|
1. **Executive/SLO**: availability, error budget burn, p50/p95/p99, message delivery, auth, active incidents.
|
||||||
|
2. **nginx edge**: RPS, 4xx/5xx, upstream latency/status, 429, WS, TLS, cache.
|
||||||
|
3. **api-backend**: routes, DB/Redis pools, JWKS, circuits, outbox/safety backlog, S3.
|
||||||
|
4. **message-safety**: verdicts, pending/poll, task TTL, Redis, distribution stub outcomes.
|
||||||
|
5. **bitrix-local-app**: install/OAuth, connector, outbound/inbox/DLQ, API/Bitrix latency.
|
||||||
|
6. **bitrix-sync**: mode, DB probe, last success/staleness; нельзя показывать CRM sync как рабочий в stub.
|
||||||
|
7. **Keycloak**: login/OTP/lockout, sessions/tokens, provider settings cache, JVM/DB.
|
||||||
|
8. **Redis**: memory/evictions/AOF/latency/clients/keyspace.
|
||||||
|
9. **PostgreSQL/S3**: provider metrics, storage, connections, backup/PITR, object errors.
|
||||||
|
10. **Business flow**: guest config → OTP → bootstrap → session → dialog → safety → Open Lines → operator reply.
|
||||||
|
11. **Collector health**: accepted/sent/refused/dropped, queue, retry, exporter errors, memory/CPU.
|
||||||
|
|
||||||
|
Каждая панель содержит release annotation, environment filter и links trace→logs по `trace_id`.
|
||||||
|
|
||||||
|
## 11. SLI, SLO и alerts
|
||||||
|
|
||||||
|
Значения — начальная production-like политика до load/product review:
|
||||||
|
|
||||||
|
| SLI | Initial SLO, 30 дней |
|
||||||
|
|---|---|
|
||||||
|
| HTTPS edge availability | 99.9% |
|
||||||
|
| public config/content successful requests | 99.9% |
|
||||||
|
| protected read API successful requests | 99.5% |
|
||||||
|
| Keycloak login flow availability | 99.5% |
|
||||||
|
| text message accepted и доставлен в Open Lines | 99.0% |
|
||||||
|
| operator inbox applied без permanent loss | 99.5% |
|
||||||
|
| p95 protected read API | < 750 ms |
|
||||||
|
| p95 text send без внешнего rate limit | < 5 s |
|
||||||
|
| telemetry Collector ingest availability | 99.0%, не входит в business availability |
|
||||||
|
|
||||||
|
Файловый send измеряется отдельно: p95 не должен превышать configured safety poll budget; user-cancel, safety deny, 4xx validation и edge abuse 429 не считаются server failure. 503/504 и unexpected 5xx считаются.
|
||||||
|
|
||||||
|
### Paging alerts
|
||||||
|
|
||||||
|
- multi-window burn: 14.4× за 5m/1h или 6× за 30m/6h;
|
||||||
|
- edge/API 5xx >5% 5 минут;
|
||||||
|
- text delivery failure >5% 10 минут;
|
||||||
|
- oldest outbox/inbox/safety task >5 минут либо DLQ >0;
|
||||||
|
- Keycloak login failures infrastructure class >10% 5 минут;
|
||||||
|
- PostgreSQL unavailable/connection saturation >90%;
|
||||||
|
- Redis unavailable, AOF error или sustained evictions;
|
||||||
|
- Collector exporter queue >80%, dropped/refused telemetry >0 sustained;
|
||||||
|
- TLS expiry <14 дней warning, <7 дней page;
|
||||||
|
- disk >85% warning, >92% page; OOM/restart loop;
|
||||||
|
- managed PG backup/PITR failure.
|
||||||
|
|
||||||
|
### Ticket/warning alerts
|
||||||
|
|
||||||
|
- p95 regression 20% release-over-release;
|
||||||
|
- settings/JWKS cache stale;
|
||||||
|
- bitrix-sync stub probe stale >150s;
|
||||||
|
- OAuth expires <24h без успешного refresh;
|
||||||
|
- quarantine orphan growth;
|
||||||
|
- cardinality/ingest growth >2× baseline.
|
||||||
|
|
||||||
|
Alert содержит service, environment, symptom, dashboard, runbook, release и безопасный query; не содержит PII.
|
||||||
|
|
||||||
|
## 12. Sampling, cardinality и retention
|
||||||
|
|
||||||
|
### Traces
|
||||||
|
|
||||||
|
- errors/5xx, circuit, timeout, DLQ, safety final deny и slow requests — 100%;
|
||||||
|
- обычные успешные requests — 5–10%;
|
||||||
|
- health/ACME success — 0%;
|
||||||
|
- tail sampling предпочтителен в Collector, но head sample SDK должен оставлять достаточно данных;
|
||||||
|
- sampling decision передаётся W3C.
|
||||||
|
|
||||||
|
### Metrics
|
||||||
|
|
||||||
|
Allow-list labels; route template вместо raw path; status class/known code; dependency enum. Cardinality budget: целевой <10 000 active series на MVP environment. CI проверяет запрещённые labels.
|
||||||
|
|
||||||
|
### Retention initial
|
||||||
|
|
||||||
|
- metrics: 30 дней high resolution, 13 месяцев downsampled при доступности backend;
|
||||||
|
- traces: 7 дней, errors 14 дней;
|
||||||
|
- technical logs: 14 дней, security/auth logs 30 дней;
|
||||||
|
- audit `han_app`: 365 дней **только как временное допущение до legal policy**;
|
||||||
|
- raw Bitrix callback не хранится в telemetry;
|
||||||
|
- local Docker logs: не более 250 МБ/container и 5 файлов.
|
||||||
|
|
||||||
|
Legal retention/erasure имеет приоритет; изменение требует обновления policy и backup lifecycle.
|
||||||
|
|
||||||
|
## 13. Collector health и отказоустойчивость
|
||||||
|
|
||||||
|
Контролируются:
|
||||||
|
|
||||||
|
- `/health` extension;
|
||||||
|
- process CPU/RSS/restarts;
|
||||||
|
- accepted/refused/sent/failed spans, points, records;
|
||||||
|
- batch send size/latency;
|
||||||
|
- exporter queue capacity/size, enqueue failures, retry age;
|
||||||
|
- file storage usage/corruption;
|
||||||
|
- scrape failures;
|
||||||
|
- config reload/validation.
|
||||||
|
|
||||||
|
При remote outage queue хранится на `otel-queue` с bounded size/age. При заполнении отбрасываются сначала low-priority success traces/logs; приложение продолжает работу. Нельзя позволять queue заполнить системный диск.
|
||||||
|
|
||||||
|
## 14. Docker Compose
|
||||||
|
|
||||||
|
`otel-collector`:
|
||||||
|
|
||||||
|
- pinned contrib image;
|
||||||
|
- networks: только `observability`, а для scrape internal targets — минимально необходимая `backend`;
|
||||||
|
- `expose`: 4317, 4318, 13133, 8888/8889;
|
||||||
|
- без host ports;
|
||||||
|
- config read-only, `otel-queue` volume rw;
|
||||||
|
- non-root, read-only rootfs, tmpfs `/tmp`, drop capabilities, no-new-privileges;
|
||||||
|
- initial limit: 0.5 CPU/512 MiB, queue disk 5–10 ГБ; уточнить load test;
|
||||||
|
- healthcheck extension;
|
||||||
|
- restart policy с backoff;
|
||||||
|
- приложения имеют bounded non-blocking OTLP exporter queue.
|
||||||
|
|
||||||
|
Доступ к Docker socket запрещён. Для container metrics используется безопасный exporter/hostmetrics, а не unrestricted socket mount.
|
||||||
|
|
||||||
|
## 15. Security
|
||||||
|
|
||||||
|
- OTLP receiver internal-only; при переходе между hosts — mTLS;
|
||||||
|
- remote exporter только TLS verify, credentials least privilege;
|
||||||
|
- Grafana/Prometheus/Loki/Tempo не публичны;
|
||||||
|
- RBAC: viewer/operator/admin; audit доступа к logs/traces;
|
||||||
|
- dashboards не показывают PII;
|
||||||
|
- config/secret permissions 0400/0600;
|
||||||
|
- dependency/image scan и SBOM;
|
||||||
|
- защита от log injection: JSON encoding, control chars, bounded field lengths;
|
||||||
|
- telemetry input не исполняет expressions из пользовательских значений;
|
||||||
|
- регулярная secret-canary проверка и incident deletion procedure.
|
||||||
|
|
||||||
|
## 16. Runbooks
|
||||||
|
|
||||||
|
### Collector not-ready
|
||||||
|
|
||||||
|
1. `docker compose ps otel-collector` и bounded logs.
|
||||||
|
2. Проверить config validation, memory/OOM, queue volume.
|
||||||
|
3. Проверить DNS/TLS/auth remote exporter.
|
||||||
|
4. Не рестартовать бесконечно при полной queue; сначала освободить/расширить безопасно.
|
||||||
|
5. Бизнес-сервисы оставить работающими; подтвердить local JSON logs.
|
||||||
|
6. После восстановления проверить drain и gap.
|
||||||
|
|
||||||
|
### Telemetry отсутствует у одного сервиса
|
||||||
|
|
||||||
|
1. Проверить `service.name`, endpoint/protocol и сеть `observability`.
|
||||||
|
2. Проверить SDK queue/drop counters и clock.
|
||||||
|
3. Отправить synthetic request с `X-Request-ID`.
|
||||||
|
4. Найти его в stdout, Collector accepted и backend.
|
||||||
|
5. Проверить sampling/filter/redaction rules.
|
||||||
|
|
||||||
|
### Remote backend outage
|
||||||
|
|
||||||
|
1. Подтвердить exporter errors, а не application outage.
|
||||||
|
2. Оценить queue fill rate/time-to-full.
|
||||||
|
3. Ограничить debug exporter; не включать verbose.
|
||||||
|
4. При длительном outage увеличить sampling только через reviewed config.
|
||||||
|
5. После восстановления подтвердить drain и создать incident note о потере данных.
|
||||||
|
|
||||||
|
### Cardinality/ingest spike
|
||||||
|
|
||||||
|
1. Найти новое metric/log attribute по release annotation.
|
||||||
|
2. Отключить offending instrument/filter в Collector.
|
||||||
|
3. Проверить raw path/id/user/session labels.
|
||||||
|
4. Rollback instrumentation при риске стоимости/доступности.
|
||||||
|
5. Добавить CI regression test.
|
||||||
|
|
||||||
|
### Высокая latency сообщения
|
||||||
|
|
||||||
|
1. Открыть trace по request id.
|
||||||
|
2. Разделить API, Safety poll, S3, local app, Bitrix.
|
||||||
|
3. Проверить circuit, queue age, DB pool и Redis.
|
||||||
|
4. Не повторять ambiguous message без исходного idempotency key.
|
||||||
|
5. Следовать runbook зависимого модуля.
|
||||||
|
|
||||||
|
### Логи содержат секрет/PII
|
||||||
|
|
||||||
|
1. Ограничить доступ и остановить offending export.
|
||||||
|
2. Сохранить только incident metadata, не копировать значение.
|
||||||
|
3. Ротировать скомпрометированный secret.
|
||||||
|
4. Удалить данные по процедуре backend/provider.
|
||||||
|
5. Исправить source redaction + Collector defense; добавить canary test.
|
||||||
|
|
||||||
|
## 17. Проверки и Definition of Done
|
||||||
|
|
||||||
|
- Collector config проходит validate и запускается в едином Compose;
|
||||||
|
- OTLP gRPC и HTTP принимают три сигнала;
|
||||||
|
- все сервисы имеют правильные resource attributes;
|
||||||
|
- request проходит nginx/API/Safety/Open Lines с одним `request_id` и связанным trace;
|
||||||
|
- `ux_session_id` есть только где передан и не является label;
|
||||||
|
- FastAPI/HTTPX/PG/Redis/S3 workers instrumented;
|
||||||
|
- nginx JSON parsing и trace correlation проверены;
|
||||||
|
- Redis/Keycloak/host/Collector metrics доступны;
|
||||||
|
- dashboards и alerts provisioned из versioned files для выбранного telemetry backend; до его выбора это остаётся acceptance/TBD, а не выполненный production DoD;
|
||||||
|
- remote outage, queue full, Collector restart и backend recovery rehearsed;
|
||||||
|
- local profile, если включён, имеет volumes/retention/auth и не публикует порты;
|
||||||
|
- secret/PII canary отсутствует в logs/traces/metrics;
|
||||||
|
- cardinality и sampling tests проходят;
|
||||||
|
- SLO queries воспроизводимы и исключения документированы;
|
||||||
|
- runbooks связаны с alerts.
|
||||||
|
|
||||||
|
## 18. Допущения, TBD и конфликты
|
||||||
|
|
||||||
|
### Решения
|
||||||
|
|
||||||
|
- O1: обязательный архитектурный минимум — Collector; удалённый управляемый OTLP backend является предпочтительным operable-вариантом и остаётся TBD до выбора провайдера.
|
||||||
|
- O2: Prometheus/Grafana/Loki/Tempo — отдельный operable profile, не скрытая обязательная нагрузка основной VM.
|
||||||
|
- O3: JSON stdout — аварийный локальный buffer; audit App DB — durable.
|
||||||
|
- O4: telemetry fail-open для business path, но потеря telemetry alertится.
|
||||||
|
- O5: ID/PII не labels; source redaction обязательна до Collector.
|
||||||
|
|
||||||
|
### TBD
|
||||||
|
|
||||||
|
- O-TBD1: выбрать remote backend/provider, endpoint/auth и стоимость.
|
||||||
|
- O-TBD2: утвердить SLO/RPS/error-budget с product owner.
|
||||||
|
- O-TBD3: legal retention/erasure и допустимость IP/user-agent.
|
||||||
|
- O-TBD4: точные sampling и resource limits после load test.
|
||||||
|
- O-TBD5: поддерживаемый nginx OTEL module и Keycloak native tracing по pinned versions.
|
||||||
|
- O-TBD6: нужен ли local observability profile в первой VM.
|
||||||
|
|
||||||
|
### Обнаруженные архитектурные конфликты
|
||||||
|
|
||||||
|
1. `arch-03` разрешает stdout/platform exporter как минимум, но production-like расследования и alerts без backend ограничены. Здесь remote OTLP backend рекомендован, но не объявлен выбранным: провайдер остаётся TBD.
|
||||||
|
2. `module-05` использует test-only terminal `400` и non-sticky verdict вместо canonical `403`/sticky production verdict. Dashboards обязаны маркировать сервис `stub`; production SLO Safety на нём недостоверен.
|
||||||
|
3. `module-07` — только DB connectivity stub, тогда как arch-01/02 описывают полноценную CRM sync. Dashboard не должен показывать queue/CRM SLI, которых нет.
|
||||||
|
4. Retention, RPO/RTO и production SLO открыты в module-01/04/06/08; значения этого документа являются initial ops policy, не закрывают legal/product TBD.
|
||||||
|
5. Новые observability env (`OTEL_REMOTE_*`, sampling/queue limits) отсутствуют в arch-04; перед реализацией production `.env.example` их нужно добавить туда.
|
||||||
|
|
||||||
|
## 19. Ссылки на прототип и исходные документы
|
||||||
|
|
||||||
|
- VM/Docker logging и firewall: [`../../HAN_chat/deploy/setup-vm-han-chat.sh`](../../HAN_chat/deploy/setup-vm-han-chat.sh).
|
||||||
|
- Прототипный nginx stdout/access log: [`../../HAN_chat/bitrix-local-app/deploy/nginx/nginx.conf`](../../HAN_chat/bitrix-local-app/deploy/nginx/nginx.conf).
|
||||||
|
- Архитектурный observability contract: [`arch-02-api-contracts.md`](arch-02-api-contracts.md).
|
||||||
|
- Compose topology: [`arch-03-docker-compose-blueprint.md`](arch-03-docker-compose-blueprint.md).
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user