Разработана первая версия приложений

This commit is contained in:
mi
2026-07-10 18:06:14 +03:00
parent aa8761d1b3
commit 8c7b4074c4
162 changed files with 12178 additions and 16 deletions
File diff suppressed because it is too large Load Diff
+286
View File
@@ -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 по мнемоникам.
+274
View File
@@ -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.
+284
View File
@@ -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 | 530s |
| `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 + 1030% deterministic jitter |
| realtime connection | 90s; set membership 120s |
| coordination lock | 30s |
| safety task | default 15m, обязательно > API poll max 300s + recovery margin |
| safety cache | default 560m по 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 (ориентир 7075%, оставляя 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.
+458
View File
@@ -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 |
| `"\u00a0 дней"` | `"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 заглушки.
+643
View File
@@ -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 1015 с, 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`.
+479
View File
@@ -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 12;
- 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 — отдельная будущая спецификация.
+703
View File
@@ -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?...&currentSchema=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 до реализации.
+572
View File
@@ -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 — 510%;
- 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 510 ГБ; уточнить 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