From aa8761d1b3972eac5a5f70f5982dbddd7fc50cb4 Mon Sep 17 00:00:00 2001 From: mi Date: Fri, 10 Jul 2026 12:23:18 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D1=80=D0=B0=D0=B2=D0=BA=D0=B8=20=D0=BE?= =?UTF-8?q?=D1=82=20GPT?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- architectory/arch-01-system-architecture.md | 2 +- architectory/module-01-api-backend.md | 1353 ++++++++++++++++++ architectory/module-02-frontend-test-site.md | 286 ++++ architectory/module-03-nginx.md | 274 ++++ architectory/module-04-redis.md | 284 ++++ architectory/module-05-message-safety.md | 458 ++++++ architectory/module-06-bitrix-local-app.md | 643 +++++++++ architectory/module-07-bitrix-sync.md | 479 +++++++ architectory/module-08-keycloak.md | 703 +++++++++ architectory/module-09-observability.md | 572 ++++++++ architectory/module-10-deployment-runbook.md | 1135 +++++++++++++++ 11 files changed, 6188 insertions(+), 1 deletion(-) create mode 100644 architectory/module-01-api-backend.md create mode 100644 architectory/module-02-frontend-test-site.md create mode 100644 architectory/module-03-nginx.md create mode 100644 architectory/module-04-redis.md create mode 100644 architectory/module-05-message-safety.md create mode 100644 architectory/module-06-bitrix-local-app.md create mode 100644 architectory/module-07-bitrix-sync.md create mode 100644 architectory/module-08-keycloak.md create mode 100644 architectory/module-09-observability.md create mode 100644 architectory/module-10-deployment-runbook.md diff --git a/architectory/arch-01-system-architecture.md b/architectory/arch-01-system-architecture.md index 40d432c..bff1368 100644 --- a/architectory/arch-01-system-architecture.md +++ b/architectory/arch-01-system-architecture.md @@ -95,7 +95,7 @@ flowchart LR Client -->|HTTPS REST + Realtime| Nginx Nginx -->|/auth| Keycloak - Nginx -->|/api (REST + WS realtime)| API + Nginx -->|"/api REST + WS realtime"| API Keycloak --> DB API --> DB API --> Redis diff --git a/architectory/module-01-api-backend.md b/architectory/module-01-api-backend.md new file mode 100644 index 0000000..de46c6a --- /dev/null +++ b/architectory/module-01-api-backend.md @@ -0,0 +1,1353 @@ +# module-01. Проектная спецификация `api-backend` + +> Статус: целевая спецификация реализации MVP. +> Язык реализации: Python, FastAPI. +> Канонические источники: [`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). + +## 1. Назначение и приоритет + +Документ определяет внутреннее устройство, модель данных, алгоритмы, эксплуатационные требования и Definition of Done сервиса `api-backend`. Он детализирует, но не изменяет зафиксированные архитектурные контракты. + +При конфликте применяются приоритеты из `README.md`: канонические имена и семантика — `arch-00`, границы и сценарии — `arch-01`, HTTP-контракты — `arch-02`, инфраструктура — `arch-03`, настройки — `arch-04`, процесс — `arch-05`. Любое необходимое изменение внешнего контракта сначала вносится в `arch-02`, а не скрыто реализуется в модуле. + +Все публичные пользовательские endpoint имеют префикс **`/api/v1`**. Все Open Lines internal endpoint, которыми владеет или которые вызывает `api-backend`, имеют префикс **`/internal/openlines/v1`**. Internal API не публикуется через `nginx`. + +## 2. Ответственность и границы + +### 2.1. Сервис отвечает за + +- публичные настройки и контент frontend; +- проверку access token Keycloak по OIDC discovery/JWKS; +- локальный `find-or-create` пользователя после OTP, согласия и минимальный профиль; +- аналитические `UxSession`; +- чтение профиля и документов; +- создание и чтение диалогов и сообщений; +- авторизацию доступа к каждой пользовательской сущности через текущий `user_id`; +- upload lifecycle вложений: init, presigned PUT в S3-quarantine, complete, проверка metadata, promote/delete; +- orchestration Message Safety: check, sync-poll `task_id`, checkpoint и recovery; +- надежную и идемпотентную доставку разрешённых сообщений в `bitrix-local-app`; +- приём нормализованного inbox Open Lines и сохранение сообщений оператора; +- WebSocket realtime и REST polling fallback; +- API-level rate limiting; +- аудит, метрики, трассировку, health и readiness; +- чтение `app_settings`, `text_resources`, `popular_questions`; +- internal settings bridge для Keycloak SPI. + +### 2.2. Сервис не отвечает за + +- ввод, отправку и проверку OTP, refresh token grant и хранение IdP-сессий; +- Keycloak Admin API в hot path; +- выбор sync/async способа проверки Message Safety и сам анализ содержимого; +- OAuth Bitrix24, connector setup, `ONIMCONNECTOR*`, `dialog_sessions`; +- CRM Contact mapping и двустороннюю CRM-синхронизацию; +- ручное создание задач CRM sync: их создают только PostgreSQL-триггеры; +- хранение надежных бизнес-событий только в Redis; +- проксирование байтов upload/download через API; +- доставку документов компании из Bitrix24 в MVP; +- редактирование профиля через публичный API. + +### 2.3. Зависимости + +| Зависимость | Использование | Допустимая деградация | +|---|---|---| +| Managed PostgreSQL, схема `han_app` | источник прикладных данных и checkpoint | без БД сервис not-ready | +| Redis DB0 | rate limit, idempotency cache | write API fail-closed; read API ограниченно деградирует | +| Redis DB1 | realtime coordination | REST работает, WS/publish деградирует | +| Keycloak discovery/JWKS | JWT validation | cached JWKS разрешён до истечения cache; без валидного ключа protected API fail-closed | +| `message-safety` | проверка сообщений клиента | отправка сообщений недоступна, чтение работает | +| `bitrix-local-app` | Open Lines delivery/status | сообщение фиксируется как `failed`, recovery по outbox | +| Selectel S3 | вложения и документы | файловые операции недоступны, текстовый чат продолжает работать | +| OTEL Collector | telemetry export | не блокирует бизнес-запросы | + +## 3. Технологический профиль + +- Python 3.12+. +- FastAPI + Pydantic v2. +- ASGI server: Uvicorn; production — один контейнер с настраиваемым числом workers. +- SQLAlchemy 2 async + `asyncpg`. +- Alembic для миграций. +- `httpx.AsyncClient` для internal HTTP. +- S3-compatible async client или вызовы boto3 через ограниченный thread pool. +- Redis asyncio client. +- OpenTelemetry instrumentation для ASGI, HTTP client, SQLAlchemy и Redis. +- `structlog` либо стандартный logging с JSON formatter. +- Тесты: pytest, pytest-asyncio/anyio, HTTPX ASGI client, Testcontainers либо выделенная тестовая managed PostgreSQL-схема. + +**Решение M1:** доменная логика не размещается в router-функциях и ORM-моделях. Router выполняет parsing/auth/dependency injection; use case задаёт транзакционную границу; repository работает с хранилищем; integration adapter инкапсулирует внешний протокол. + +## 4. Структура FastAPI-компонентов + +```text +api-backend/ + app/ + main.py + bootstrap.py + api/ + dependencies.py + errors.py + middleware.py + routers/ + public.py + auth.py + analytics.py + consents.py + profile.py + dialogs.py + attachments.py + realtime.py + internal_openlines.py + internal_settings.py + health.py + schemas/ + common.py + auth.py + chat.py + profile.py + public.py + internal.py + realtime.py + application/ + auth_bootstrap.py + consent_service.py + session_service.py + dialog_service.py + message_service.py + attachment_service.py + inbound_openlines.py + delivery_recovery.py + safety_recovery.py + settings_service.py + realtime_service.py + domain/ + entities.py + enums.py + policies.py + errors.py + infrastructure/ + db/ + models.py + repositories/ + unit_of_work.py + auth/ + jwks.py + principal.py + clients/ + message_safety.py + openlines.py + redis/ + idempotency.py + rate_limit.py + realtime.py + s3/ + client.py + keys.py + observability/ + logging.py + metrics.py + tracing.py + workers/ + delivery_outbox.py + safety_recovery.py + quarantine_cleanup.py + inbound_attachment.py + settings.py + alembic/ + tests/ + unit/ + integration/ + contract/ + e2e/ + openapi.yaml + Dockerfile + docker-compose.yml + pyproject.toml +``` + +### 4.1. Middleware, порядок + +1. trusted proxy middleware — принимает forwarded headers только от `nginx`; +2. request-id — валидирует/принимает `X-Request-ID` либо генерирует UUID/ULID; +3. W3C trace context; +4. structured access logging; +5. CORS из `security.cors.allowed_origins`; +6. error mapper в единый envelope; +7. metrics; +8. route dependencies: JWT, ownership, rate limit, idempotency. + +Middleware не читает request body повторно и не логирует body, Authorization, query token WS или presigned URL. + +## 5. API conventions + +### 5.1. Заголовки + +| Заголовок | Правило | +|---|---| +| `Authorization: Bearer ` | обязателен для protected REST | +| `X-Request-ID` | опционален от клиента; всегда присутствует в ответе | +| `X-Ux-Session-Id` | UUID, рекомендуется во всех JWT-запросах; не является auth | +| `Idempotency-Key` | обязателен для создания диалога и сообщения | +| `traceparent` | принимается и передаётся downstream | +| `Retry-After` | возвращается при retryable `429`, иногда `503` | + +Все даты — RFC 3339 UTC. Публичные id — UUID. Неизвестные поля request DTO запрещаются (`extra="forbid"`). Размер строк, массивов и query limit ограничивается схемой. + +### 5.2. Error envelope + +```json +{ + "error": { + "code": "not_found", + "message": "Resource was not found", + "request_id": "01J00000000000000000000000", + "details": {} + } +} +``` + +Обязательный каталог: + +| HTTP | `code` | +|---|---| +| 400 | `validation_error`, `phone_claim_missing`, `mixed_content_not_allowed`, `empty_message`, `too_many_attachments`, `attachment_not_completed`, `attachment_checksum_mismatch` | +| 401 | `unauthorized`, `token_expired` | +| 403 | `consents_required`, `forbidden` | +| 404 | `not_found` | +| 409 | `idempotency_key_reused`, `resource_state_conflict` | +| 422 | `message_blocked` | +| 429 | `rate_limit_exceeded` | +| 503 | `dependency_unavailable` | +| 504 | `dependency_timeout` | + +Pydantic `422` преобразуется в `400 validation_error`, чтобы соответствовать каноническому каталогу. `details` содержит только безопасные имена полей/ограничения. Для чужой сущности возвращается `404`, а не `403`. + +### 5.3. Pagination + +- dialogs: keyset cursor по `(updated_at DESC, id DESC)`; +- messages: keyset cursor по `(created_at ASC, id ASC)`; +- cursor — base64url от versioned JSON + HMAC, frontend его не разбирает; +- default `limit=50`, max `100`; +- `after` в polling означает строго позже позиции cursor. + +## 6. Public и protected REST endpoints + +### 6.1. Публичные read-only + +#### `GET /api/v1/public/app-config` + +Ответ — строгий DTO, не dump `app_settings`: + +```json +{ + "auth": {"phone_enabled": true, "password_enabled": false}, + "operator": {"call_phone": "+74999591007"}, + "consents": { + "personal_data": {"required": true, "document_url": "https://...", "version": "2026-06-10"}, + "user_agreement": {"required": true, "document_url": "https://...", "version": "2026-06-10"}, + "marketing": {"required": false, "document_url": null, "version": "2026-06-10"} + }, + "attachments": { + "allowed_extensions": ["jpg", "jpeg", "png", "webp", "heic", "heif", "pdf"], + "allowed_mime_types": ["image/jpeg", "image/png", "image/webp", "image/heic", "image/heif", "application/pdf"], + "max_size_mb": 5 + }, + "ux": {"idle_timeout_minutes": 30} +} +``` + +`Cache-Control: public, max-age=`, ETag по версии cache. Rate limit per IP. + +#### `GET /api/v1/public/content` + +Возвращает только active `text_resources` и active `popular_questions`, отсортированные по `sort_order`. + +```json +{ + "locale": "ru", + "texts": {"home.welcome.title": "string"}, + "popular_questions": [{"id": "uuid", "mnemonic": "string", "text": "string"}], + "version": "opaque" +} +``` + +**Допущение A1:** MVP принимает необязательный `locale`, но поддерживает только `ru`; иной locale нормализуется к `ru`. Это сохраняет будущую расширяемость без заявления о мультиязычности. + +### 6.2. Auth bootstrap и согласия + +#### `POST /api/v1/auth/bootstrap` + +JWT обязателен. Request: + +```json +{ + "consents": { + "personal_data": {"accepted": true, "version": "2026-06-10"}, + "user_agreement": {"accepted": true, "version": "2026-06-10"}, + "marketing": {"accepted": false, "version": "2026-06-10"} + }, + "device": {"platform": "ios", "app_version": "1.0.0", "device_id": "opaque"} +} +``` + +Response `200`: `{"user_id":"uuid","profile_ready":true}`. + +Алгоритм в одной SERIALIZABLE-повторяемой либо READ COMMITTED + unique/upsert транзакции: + +```text +principal = validate_jwt() +phone = canonical_phone_claim(principal) +if phone absent/invalid E.164: phone_claim_missing +validate consent versions against active app_settings +require required consents accepted + +BEGIN + INSERT UserIdentity(keycloak_sub, phone_number, last_login_at) + ON CONFLICT(keycloak_sub) DO UPDATE phone_number, last_login_at + INSERT ClientProfile(user_id) ON CONFLICT(user_id) DO NOTHING + INSERT immutable UserConsent rows for submitted versions + INSERT audit auth.bootstrap +COMMIT +return stable user_id +``` + +Повторный bootstrap безопасен: уникальный ключ согласия не создаёт дубль; `last_login_at` обновляется. Триггеры на `UserIdentity`/`ClientProfile` создают `contact.map_or_create`/`contact.update` в `sync_queue`; application code задач не вставляет. + +#### `POST /api/v1/consents` + +JWT, существующий пользователь. Сохраняет новые immutable записи согласий. Обязательные актуальные версии должны быть приняты. Response `201` с `recorded_at` и версиями. + +### 6.3. UX session + +#### `POST /api/v1/analytics/session-start` + +Request: + +```json +{ + "start_reason": "first_launch", + "device": {"platform": "web", "app_version": "1.0.0", "device_id": "opaque"} +} +``` + +`start_reason`: `first_launch | cold_start | idle_timeout`. Response `201`: + +```json +{"ux_session_id":"uuid","started_at":"2026-07-08T12:00:00Z"} +``` + +Создаёт `UxSession` и audit `session_start` атомарно. Не влияет на auth. `device_id` хешируется перед сохранением; raw значение в лог не попадает. + +### 6.4. Profile и documents + +- `GET /api/v1/me` → блочный readonly профиль. +- `GET /api/v1/me/documents` → cursor list; в MVP обычно пустой. +- `GET /api/v1/documents/{document_id}` → metadata, только owner. +- `GET /api/v1/documents/{document_id}/download-url` → короткий presigned GET + audit. + +`GET /api/v1/me`: + +```json +{ + "user_id": "uuid", + "profile": { + "personal_data": { + "full_name": null, + "citizenship": null, + "russian_phone": null, + "foreign_phone": null, + "email": null + }, + "documents": {"count": 0} + } +} +``` + +PATCH/PUT профиля в MVP отсутствует. + +### 6.5. Dialogs + +- `POST /api/v1/dialogs` — JWT + `Idempotency-Key`; `201` новый либо `200` существующий active. +- `GET /api/v1/dialogs` — история. +- `GET /api/v1/dialogs/{dialog_id}` — карточка. +- `GET /api/v1/dialogs/{dialog_id}/messages?after=&limit=` — история/polling. + +`DialogSummary`: + +```json +{ + "dialog_id": "uuid", + "status": "waiting_for_company", + "last_message_preview": "string-or-null", + "unread_count": 0, + "created_at": "2026-07-09T12:00:00Z", + "updated_at": "2026-07-09T12:01:00Z" +} +``` + +Один active dialog на пользователя обеспечивается partial unique index. Создание: + +```text +BEGIN + SELECT active dialog WHERE user_id=:current_user FOR UPDATE + if found: return 200 + INSERT Dialog(id=uuid, user_id, status='open') +COMMIT +return 201 +``` + +Конфликт concurrent INSERT перехватывается, после rollback читается победившая active запись и возвращается `200`. + +### 6.6. Send message + +#### `POST /api/v1/dialogs/{dialog_id}/messages` + +JWT + ownership + required `Idempotency-Key`. + +Text request: `{"content_kind":"text","text":"Здравствуйте"}`. +File request: `{"content_kind":"file","attachment_id":"uuid","checksum":"sha256:"}`. + +Success `201` возвращает финальный `MessageResponse`: + +```json +{ + "message_id": "uuid", + "dialog_id": "uuid", + "sender_type": "client", + "content_kind": "text", + "text": "Здравствуйте", + "attachments": [], + "safety_status": "allowed", + "delivery_status": "delivered", + "created_at": "2026-07-09T12:00:00Z" +} +``` + +На safety deny — `422 message_blocked`; blocked message допустимо сохранять для аудита, но его текст должен храниться по политике минимизации данных (см. решение M8). На dependency failure — `503/504`; если Message уже создан, его `delivery_status=failed`. + +### 6.7. Attachments + +- `POST /api/v1/dialogs/{dialog_id}/attachments/init`; +- `POST /api/v1/dialogs/{dialog_id}/attachments/{attachment_id}/complete`; +- `GET /api/v1/dialogs/{dialog_id}/attachments/{attachment_id}/download-url`. + +Init request: + +```json +{"file_name":"scan.pdf","mime_type":"application/pdf","size_bytes":12345} +``` + +Init response `201`: + +```json +{ + "attachment_id":"uuid", + "upload_url":"https://presigned...", + "upload_headers":{"Content-Type":"application/pdf"}, + "expires_at":"2026-07-09T12:10:00Z" +} +``` + +Complete request: `{"checksum":"sha256:<64-lowercase-hex>"}`. Response `200` возвращает metadata и `scan_status=pending`. + +Init и complete должны быть idempotent по состоянию attachment; повтор complete с тем же checksum возвращает прежний результат, с другим — `409 resource_state_conflict`. + +## 7. Internal endpoints + +### 7.1. Inbox Open Lines + +#### `POST /internal/openlines/v1/inbox` + +Владелец — `api-backend`; caller — `bitrix-local-app`. Защита: private network + `Authorization: Bearer `, constant-time compare. + +```json +{ + "event_id":"stable-opaque", + "event_type":"message.new", + "external_chat_id":"uuid", + "bitrix_message_id":"string", + "occurred_at":"2026-07-09T12:00:00Z", + "message":{ + "text":"Ответ", + "files":[{ + "name":"scan.pdf", + "mime_type":"application/pdf", + "size_bytes":12345, + "download_url":"https://..." + }] + } +} +``` + +Поддерживаемые MVP события: + +- `message.new`; +- `dialog.closed`. + +Результаты: + +- `201` — событие впервые применено; +- `200` или `204` — duplicate-ack; +- `400` — invalid normalized payload; +- `404` — неизвестный `external_chat_id`, retry допустим ограниченно; +- `503/504` — временная зависимость; local app повторяет. + +Idempotency `message.new`: unique `(external_chat_id, bitrix_message_id)`. Для `dialog.closed`, где message id отсутствует, используется `event_id`; **допущение A2:** OpenAPI internal inbox должен сделать `event_id` обязательным для всех событий, сохранив `bitrix_message_id` обязательным для `message.new`. До уточнения upstream допустимый fallback fingerprint — SHA-256 от стабильных полей события. + +### 7.2. Settings bridge + +#### `GET /internal/settings/v1/otp` + +Private network + 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 +} +``` + +Не содержит PII или secrets. ETag/`If-None-Match` поддерживаются. Если обязательные настройки отсутствуют, endpoint возвращает `503`, а readiness — false. + +### 7.3. Исходящие Open Lines calls + +`api-backend` вызывает только: + +- `POST /internal/openlines/v1/messages`; +- `GET /internal/openlines/v1/dialogs/{external_chat_id}`; +- опционально для readiness — `GET /internal/openlines/v1/status`. + +Все вызовы: private network, `Authorization: Bearer `, `X-Request-ID`, `traceparent`, idempotency по `message_id`. + +## 8. JWT, JWKS и phone claims + +### 8.1. Validation + +Для каждого protected REST/WS: + +1. извлечь Bearer token; +2. декодировать header, разрешить только настроенные asymmetric algorithms (`RS256` по умолчанию), запретить `none` и symmetric algorithms; +3. выбрать ключ по `kid` из JWKS cache; +4. при неизвестном `kid` выполнить один controlled refresh JWKS (single-flight); +5. проверить подпись, `iss == KEYCLOAK_PUBLIC_URL/realms/`, audience `KEYCLOAK_AUDIENCE`, `exp`, `nbf` с малым clock skew; +6. требовать непустой `sub`; +7. сформировать immutable `Principal`. + +JWKS cache имеет positive TTL и short negative TTL для неизвестного `kid`; stale cached keys разрешены только в ограниченном grace window при временной недоступности Keycloak. Token и claims целиком не логируются. + +### 8.2. Phone claim + +Канонический claim задаётся realm mapping. Порядок MVP: + +1. `phone_number`; +2. fallback `preferred_username` только если значение валидно как E.164. + +Телефон нормализуется библиотекой libphonenumber и сохраняется в E.164. Body никогда не является источником auth-телефона. Отсутствие/невалидность при bootstrap → `400 phone_claim_missing`. + +**Решение M2:** список phone claim names не вводится как новая бизнес-настройка. Это realm/infra contract; реализация фиксирует приоритет выше и покрывает его contract test. Если realm изменится, сначала обновляются архитектура/OpenAPI и realm config. + +### 8.3. User resolution + +После bootstrap `sub` разрешается в active `UserIdentity`. Protected endpoint, кроме bootstrap, при отсутствии локального пользователя возвращает `409 resource_state_conflict` с безопасным сообщением «bootstrap required». **TBD-1:** arch-02 допускает `404/409` только для consents; перед публикацией OpenAPI выбрать единый код. До решения используется `409`. + +## 9. PostgreSQL: схема `han_app` + +### 9.1. Общие правила + +- UUID: PostgreSQL `uuid`, генерируется приложением UUIDv7 либо `gen_random_uuid()`. +- timestamps: `timestamptz`, UTC. +- все основные прикладные сущности: `id`, `record_status`, `status_changed_at`, `status_change_reason`, `created_at`, `updated_at`, `updater_user_id`; +- soft delete: `A`/`D`; физическое удаление прикладных строк запрещено; +- технические queue/checkpoint таблицы удаляются/архивируются по retention, что является разрешённым исключением; +- FK по умолчанию `ON DELETE RESTRICT`; +- строковые enum — PostgreSQL `varchar` + CHECK, чтобы миграция enum не блокировала rollout; +- все SQL параметризованы; +- DB role `han_app` имеет least privilege только на схему `han_app`; +- RLS в MVP не является единственным механизмом доступа; ownership всегда проверяет repository query. + +### 9.2. `user_identities` + +| Поле | Тип/ограничение | +|---|---| +| `id` | uuid PK | +| `keycloak_sub` | varchar(255) NOT NULL UNIQUE | +| `phone_number` | varchar(32) NOT NULL | +| `last_login_at` | timestamptz NOT NULL | +| common fields | обязательны | + +Индексы: unique `keycloak_sub`; index на normalized `phone_number` для CRM trigger/map. Телефон не объявляется unique: миграция/merge IdP может временно дать конфликт, а identity master — Keycloak. + +### 9.3. `ux_sessions` + +`id` = `ux_session_id`; `user_id` FK; `start_reason` CHECK; `platform`, `app_version`, `device_id_hash`; `started_at`; common fields. + +Индексы: `(user_id, started_at DESC)`, `(started_at)` для retention. + +### 9.4. `user_consents` + +`id`, `user_id`, `ux_session_id NULL`, `consent_type`, `document_version`, `accepted`, `accepted_at`, `client_ip` (inet), `user_agent_hash`, common fields. + +CHECK consent type: `personal_data | user_agreement | marketing`. Unique `(user_id, consent_type, document_version)`. Запись immutable; исправление — новая версия или audit-backed administrative action. Обязательные согласия определяются текущим `app_settings`. + +### 9.5. `client_profiles` + +`id`, `user_id` UNIQUE FK, `bitrix_contact_id NULL`, `full_name`, `citizenship`, `russian_phone`, `foreign_phone`, `email`, `source_updated_at`, common fields. + +Индексы: unique active `user_id`; partial unique `bitrix_contact_id WHERE bitrix_contact_id IS NOT NULL AND record_status='A'`; `updated_at`. PII поля не включаются в логи и generic audit payload. + +### 9.6. `dialogs` + +`id` = `dialog_id` = `external_chat_id`; `user_id`; `status`; `last_message_at`; `closed_at`; common fields. + +CHECK status: `open | waiting_for_company | waiting_for_client | closed`. + +Индексы: + +```sql +CREATE UNIQUE INDEX uq_dialog_one_active_per_user +ON han_app.dialogs(user_id) +WHERE record_status='A' AND status IN ('open','waiting_for_company','waiting_for_client'); + +CREATE INDEX ix_dialogs_user_updated +ON han_app.dialogs(user_id, updated_at DESC, id DESC) +WHERE record_status='A'; +``` + +Переходы: + +- new → `open`; +- delivered client message → `waiting_for_company`; +- saved company message → `waiting_for_client`; +- `dialog.closed` → `closed`; +- из `closed` обратный переход запрещён. + +### 9.7. `messages` + +Поля: `id`, `dialog_id`, `sender_type`, `content_kind`, `text`, `safety_status`, `delivery_status`, `external_message_id NULL`, `client_idempotency_key NULL`, `occurred_at`, common fields. + +CHECK: + +- sender: `client | company`; +- content: `text | file`; +- safety: `pending | allowed | blocked` (`needs_review` зарезервирован, не создаётся); +- delivery: `accepted | processing | delivered | rejected | failed`; +- text message: `text <> ''`; +- file message: `text = ''`; +- company message: `safety_status='allowed'`; +- rejected → blocked; delivered → allowed. + +Индексы: `(dialog_id, created_at, id) WHERE record_status='A'`; `(delivery_status, updated_at)` для recovery; unique `(dialog_id, external_message_id)` where external id not null; unique `(dialog_id, client_idempotency_key)` where not null. + +**Решение M3:** исходящее сообщение создаётся до safety со статусами `pending/accepted`, чтобы `safety_tasks` всегда имел FK и crash checkpoint. При начале poll delivery может стать `processing`; клиенту этот промежуточный ответ не отдаётся. + +### 9.8. `message_attachments` + +Поля: `id`, `dialog_id`, `message_id NULL`, `owner_user_id`, `direction` (`client_upload | company_inbound`), `original_file_name`, `safe_file_name`, `mime_type`, `size_bytes`, `checksum_sha256`, `scan_status`, `storage_bucket`, `object_key`, `quarantine_object_key NULL`, `upload_expires_at`, `completed_at`, common fields. + +Ограничения: + +- `size_bytes > 0`; +- SHA-256 — 64 lowercase hex; +- scan: `pending | clean | infected | failed`; +- до allow client file находится только в quarantine; +- attachment связывается максимум с одним message; +- для MVP у message максимум одно active attachment: unique partial `message_id`. + +Индексы: `(owner_user_id, id)`, `(dialog_id, created_at)`, `(scan_status, updated_at)`, unique active object keys. + +### 9.9. `documents` + +Reserved MVP table: `id`, `user_id`, `name`, `mime_type`, `size_bytes`, `checksum_sha256`, `storage_bucket`, `object_key`, `sent_at`, common fields. Индекс `(user_id, sent_at DESC)`; unique `(storage_bucket, object_key)`. Заполнение внешней доставкой — post-MVP. + +### 9.10. `safety_tasks` + +Технический durable checkpoint: + +- `id`, `task_id` UNIQUE; +- `message_id` UNIQUE; +- `attachment_id NULL`; +- `quarantine_object_key NULL`; +- `status`: `polling | finalizing | completed | failed`; +- `deadline_at`, `next_poll_at`, `attempt_count`, `last_error_code`; +- `locked_at`, `locked_by`; +- timestamps. + +Индексы: `(status, next_poll_at)`, `(deadline_at)`. Worker забирает `FOR UPDATE SKIP LOCKED`. Это не очередь анализа и не заменяет `message-safety`. + +### 9.11. `delivery_outbox` + +Durable намерение App → Open Lines: + +- `id`, `message_id` UNIQUE; +- `external_chat_id`; +- `payload_json` с versioned internal DTO, без service token/presigned permanent secrets; +- `status`: `pending | processing | delivered | retry | dead_letter`; +- `attempt_count`, `next_attempt_at`, `last_error_code`; +- `locked_at`, `locked_by`, timestamps. + +Индексы: `(status, next_attempt_at)`, `(locked_at)`. Message + outbox создаются/финализируются в одной транзакции после safety allow. + +### 9.12. `openlines_inbox_receipts` + +Durable idempotency приёма local app: + +- `id`, `event_id`, `external_chat_id`, `bitrix_message_id NULL`; +- `event_type`, `payload_fingerprint`; +- `status`: `received | processing | applied | failed`; +- `message_id NULL`, `last_error_code`, timestamps. + +Unique `event_id`; unique `(external_chat_id, bitrix_message_id)` where not null. Сам retry inbox принадлежит схеме `bitrix_local`; эта таблица — receipt/application checkpoint `api-backend`, а не дублирующая очередь local app. + +### 9.13. `idempotency_records` + +PostgreSQL durable fallback для завершённых mutating operations: + +- `scope`, `user_id`, `idempotency_key`; +- `request_fingerprint`; +- `status`: `in_progress | completed | failed`; +- `response_status`, `response_body_json`; +- `resource_type`, `resource_id`; +- `expires_at`, timestamps; +- PK/unique `(scope, user_id, idempotency_key)`. + +Redis остаётся быстрым слоем по arch-02. Durable row нужна, чтобы потеря Redis не повторила side effect. **Решение M4:** это уточнение надежности, не изменение внешнего контракта. + +### 9.14. `audit_events` + +Append-only: `id`, `event_type`, `actor_type`, `user_id NULL`, `ux_session_id NULL`, `request_id`, `trace_id`, `resource_type`, `resource_id`, `ip`, `user_agent_hash`, `outcome`, `metadata_json`, `created_at`. + +Индексы: `(user_id, created_at DESC)`, `(resource_type, resource_id)`, `(event_type, created_at DESC)`, BRIN `created_at` при росте. Запрещены raw OTP, token, PII, file contents и presigned URL. + +### 9.15. Настройки и контент + +- `app_settings` — поля и правила из arch-04; +- `text_resources`: `id`, `mnemonic`, `locale`, `text_value`, `sort_order`, common fields; unique active `(mnemonic, locale)`; +- `popular_questions`: `id`, `mnemonic`, `locale`, `question_text`, `sort_order`, common fields; unique active `(mnemonic, locale)`; +- `sync_queue`, `entity_external_mapping` — shared contract с `bitrix-sync`. + +## 10. Alembic и транзакции + +### 10.1. Migration policy + +- schema-qualified DDL; +- отдельная migration DB role с DDL, runtime role без DDL; +- `alembic upgrade head` — отдельный deploy step до старта новой версии; +- миграции backward-compatible по expand/migrate/contract; +- destructive migration только после отдельного согласования и backup; +- seed `app_settings` idempotent и versioned; +- PostgreSQL triggers CRM sync создаются миграциями владельца App DB; +- migration smoke test с пустой БД и upgrade от предыдущей release; +- downgrade не обещается для data migration; вместо него forward-fix. + +### 10.2. Транзакционные границы + +Одна DB-транзакция не держится во время HTTP/S3 вызовов. Используется prepare → external call → finalize: + +1. короткая транзакция создаёт message/checkpoint; +2. external safety poll выполняется без DB lock; +3. короткая транзакция блокирует message и применяет verdict идемпотентно; +4. S3 promote выполняется идемпотентно между checkpoint states; +5. message + delivery outbox фиксируются атомарно; +6. worker выполняет Open Lines call без открытой DB-транзакции; +7. результат фиксируется под row lock. + +Isolation default READ COMMITTED. Для конкурентных state transitions — `SELECT ... FOR UPDATE`; для очередей — `SKIP LOCKED`; deadlock/serialization failure повторяется ограниченно с jitter. + +## 11. Redis DB0/DB1 + +### 11.1. DB0: rate limits и idempotency + +Ключи не содержат телефон, token или raw PII. + +| Key | Value | TTL | +|---|---|---| +| `han:api:rl:user:{user_id}:{route}:{window}` | counter/token bucket | длина окна + jitter | +| `han:api:rl:ip:{ip_hash}:{route}:{window}` | counter | длина окна + jitter | +| `han:api:rl:dialog:{dialog_id}:message:{window}` | counter | длина окна + jitter | +| `han:api:rl:service:{service}:{route}:{window}` | counter | длина окна + jitter | +| `han:api:idem:{scope}:{user_id}:{key_hash}` | state, fingerprint, response | 24 часа | +| `han:api:idemlock:{scope}:{user_id}:{key_hash}` | owner token | 30 секунд, продлевается heartbeat | +| `han:api:jwks:negative:{kid_hash}` | marker | 30 секунд | + +Rate limit выполняется atomic Lua script. При превышении возвращаются `429`, `Retry-After` и audit только для значимых/повторных abuse случаев. + +### 11.2. DB1: realtime/coordination + +| Key/channel | Назначение | TTL | +|---|---|---| +| `han:rt:user:{user_id}:connections` | set connection ids | heartbeat 90 сек | +| `han:rt:conn:{connection_id}` | user, subscriptions, server instance | 90 сек | +| `han:rt:dialog:{dialog_id}` | Pub/Sub channel | сообщения не сохраняются | +| `han:rt:user:{user_id}` | Pub/Sub channel | сообщения не сохраняются | +| `han:coord:lock:safety-recovery:{task_id}` | distributed lock | 30 сек | +| `han:coord:lock:delivery:{message_id}` | distributed lock | 30 сек | +| `han:settings:snapshot:{version}` | optional serialized public snapshot | 5 минут | + +Redis Pub/Sub — ускоритель, не durable event bus. После reconnect клиент обязательно выполняет REST polling. Потеря DB1 не теряет сообщения. + +## 12. Idempotency + +Fingerprint = SHA-256 от canonical method + route template + normalized path params + canonical JSON body + authenticated user id. Authorization, request-id и UX-session не входят. + +Алгоритм: + +```text +validate key syntax/length +lookup Redis record +if completed and fingerprint equal: replay stored response +if fingerprint differs: 409 idempotency_key_reused +acquire short lock +lookup/insert durable idempotency_records +if durable completed: warm Redis and replay +if in_progress: wait briefly or return safe 409/503 Retry-After +execute use case +commit resource + durable response atomically where possible +store sanitized response in Redis for 24h +release lock +``` + +Не кэшируются transient `500/503/504` как окончательный результат, но уже созданный resource связывается с key, чтобы retry продолжил recovery вместо создания дубля. Ответ хранится без presigned URL; для init повтор генерирует новый URL к тому же attachment. + +## 13. Message Safety: sync, poll, recovery + +### 13.1. Основной алгоритм + +```text +authorize dialog + current user +validate current consents and content union +enforce rate limits and idempotency +for file: lock attachment, require completed/pending and checksum equality +create Message(pending, accepted) +call POST /internal/safety/v1/messages/check + +if 200 allow: + finalize_allow() +elif 403 deny: + finalize_deny() +elif 203 pending: + persist safety_tasks(task_id, deadline) + while monotonic_now < request_deadline: + sleep(backoff_with_jitter) + poll GET /internal/safety/v1/messages/tasks/{task_id} + if 200 allow: finalize_allow() and return + if 403 deny: finalize_deny() and return + if 400 and code=stub_final_error and verdict=deny and details.terminal=true: + finalize_deny() and return + if other 4xx/5xx: return mapped dependency error + mark failed, retain checkpoint/quarantine + return 504 +else: + mark failed + return mapped dependency error +``` + +Polling interval начинается с `MESSAGE_SAFETY_TASK_POLL_INTERVAL_SEC`, допускает capped exponential backoff и jitter, но не превышает общий `MESSAGE_SAFETY_TASK_POLL_MAX_SEC`. Клиенту не возвращается `203`. Terminal `400 stub_final_error` — намеренное test-only расширение `module-05`; оно преобразуется в публичный `422 message_blocked`, не считается infrastructure failure и не смешивается с malformed `400`. + +### 13.2. Final allow + +Для text: `safety_status=allowed`. Для file: + +1. проверить checkpoint; +2. copy quarantine object в attachments bucket с conditional/idempotent key; +3. HeadObject destination, сверить checksum/size; +4. в транзакции изменить attachment на `clean`, storage location на S3-data; message на `allowed/accepted`; создать delivery outbox; +5. удалить quarantine object best-effort; при сбое cleanup повторит; +6. попытаться синхронно доставить outbox, чтобы исходный POST вернул финальный `delivered`. + +### 13.3. Final deny + +В транзакции: `blocked/rejected`, attachment `infected`; затем delete quarantine best-effort. В Open Lines ничего не отправляется. Realtime `message.status` публикуется, если message уже мог быть виден этому клиенту. + +### 13.4. Timeout/crash recovery + +Фоновый worker: + +```text +SELECT due safety_tasks FOR UPDATE SKIP LOCKED +claim with lease +poll safety by task_id +if pending before recovery deadline: schedule next_poll_at +if allow: idempotent promote + delivery checkpoint +if deny (canonical 403 or test-only terminal 400): idempotent delete + reject +if budget exhausted: mark task failed, message failed, preserve audit +``` + +HTTP disconnect не отменяет durable recovery. Клиентский retry с тем же idempotency key получает восстановленный результат либо текущую dependency error. Recovery не принимает решение о типе анализа. + +**Решение M5:** после client-facing timeout recovery budget продолжается ещё 15 минут как техническая константа модуля; до production-load test значение должно быть вынесено в infra env и добавлено в arch-04. Пока это **TBD-2**, код обязан иметь безопасный default и метрику. + +## 14. S3 attachment lifecycle + +Object keys не содержат original filename или PII: + +```text +quarantine/users/{user_uuid}/dialogs/{dialog_uuid}/{attachment_uuid} +attachments/dialogs/{dialog_uuid}/{attachment_uuid} +documents/users/{user_uuid}/{document_uuid} +``` + +Lifecycle: + +1. `init`: allow-list extension + declared MIME + size; create metadata; presign exact key, MIME, max size, TTL; +2. direct PUT client → S3-quarantine; +3. `complete`: HeadObject, size/MIME/checksum metadata; checksum при отсутствии trustworthy S3 checksum вычисляется safety service при scan; +4. message send: attachment ownership/state/checksum; +5. allow: copy + verify + DB finalize + quarantine delete; +6. deny: quarantine delete + infected metadata; +7. abandoned/failed: cleanup only if expired and no active safety task; +8. download: owner check → audit commit → short presigned GET. + +Extension и MIME оба должны быть разрешены; server normalizes filename and sets safe `Content-Disposition`. S3 credentials never reach frontend. + +**Допущение A3:** Selectel S3 может не предоставлять SHA-256 в `HeadObject`; `complete` сверяет клиентский checksum с signed metadata, а authoritative checksum подтверждает Message Safety. Если storage поддерживает checksum header, он обязателен. + +Inbound operator file: + +- validate count/size/MIME and URL scheme/host policy; +- protect against SSRF: no redirects to private/link-local ranges, DNS rebinding checks, max bytes streaming; +- download with timeout to temporary stream, never local persistent disk; +- optional antivirus policy; Message Safety outbound pipeline не вызывается; +- upload directly to S3-data attachments; +- only then atomically save attachment/message and ack inbox. + +## 15. Open Lines outbox/inbox и delivery + +### 15.1. App → local app + +Outbox worker и synchronous first attempt используют один dispatcher: + +1. claim `delivery_outbox` lease; +2. build `/internal/openlines/v1/messages` DTO; +3. `Idempotency-Key = message_id`; +4. file message: generate short S3-data signed URL immediately before call; URL не сохранять в outbox/log; +5. call with circuit/timeout; +6. success/duplicate ack → outbox delivered, Message delivered, Dialog `waiting_for_company`; +7. transient failure → Message failed, outbox retry with exponential backoff; +8. permanent invalid payload → dead_letter + audit/alert. + +Повторная доставка безопасна только при подтверждённой idempotency local app по `message_id`. До contract test этого свойства автоматический retry после ambiguous timeout ограничивается; выполняется reconciliation через `GET /internal/openlines/v1/dialogs/{external_chat_id}` и статус local app. + +### 15.2. Local app → App + +`bitrix-local-app` владеет durable inbox/retry/DLQ в `bitrix_local`. `api-backend`: + +1. аутентифицирует service token; +2. резервирует receipt по event id/fingerprint; +3. duplicate applied → 200/204; +4. проверяет dialog; +5. для message.new сохраняет text/files, Message `company/allowed/delivered`, Dialog `waiting_for_client`; +6. для dialog.closed переводит Dialog в `closed`; +7. commit receipt applied; +8. после commit публикует realtime; +9. local app только после ack вызывает `imconnector.send.status.delivery`. + +Публикация realtime после commit; сбой publish не откатывает сообщение, polling восстановит состояние. + +## 16. CRM sync — только триггеры + +`api-backend` не вызывает `bitrix-sync` по HTTP в пользовательском flow и не пишет `sync_queue` вручную. + +Миграции создают triggers: + +- insert active `UserIdentity`/`ClientProfile` без mapping → `contact.map_or_create`; +- изменение tracked profile/auth-phone fields → `contact.update`; +- trigger строит deterministic dedup key; +- при `current_setting('han.sync_suppress', true)='true'` задача не создаётся; +- trigger и business update находятся в одной транзакции. + +`bitrix-sync` получает ограниченные GRANT. Ошибка CRM не откатывает bootstrap и chat. Open Lines не зависит от CRM mapping. + +## 17. Realtime + +### 17.1. WebSocket contract + +`WS /api/v1/realtime`, только WSS через nginx. **Решение M6:** JWT передаётся через WebSocket subprotocol, а query `access_token` поддерживается временно для совместимости, но удаляется из access logs. Предпочтительный формат: `Sec-WebSocket-Protocol: han.jwt.`. + +После auth: + +```json +{"type":"connected","server_time":"2026-07-09T12:00:00Z"} +{"type":"subscribe","dialog_ids":["uuid"]} +{"type":"subscribed","dialog_ids":["uuid"]} +``` + +Events: + +- `message.new` с `MessageResponse`; +- `message.status`; +- `dialog.status`; +- `ping`; client отвечает `pong`. + +Каждый subscribe проверяет ownership всех dialogs; чужие id не раскрываются. Лимиты: max connections/user, max subscriptions/connection, max frame bytes, subscribe rate. Slow consumer: bounded queue; при переполнении connection закрывается с retryable code, клиент восстанавливается polling. + +### 17.2. Delivery semantics + +WS — at-most-once best effort. DB — source of truth. Событие содержит `event_id` и `occurred_at` как расширение DTO реализации; **TBD-3:** добавить эти поля в OpenAPI без изменения event types. Client после reconnect: + +1. refresh token при auth error; +2. reconnect с backoff 1,2,4…30s; +3. resubscribe; +4. запросить messages после последнего REST cursor; +5. если WS недоступен >30s — polling. + +На одной replica возможен in-memory hub; DB1 Pub/Sub обязателен при более одной replica. G12 остаётся post-MVP для полной backpressure/sticky-session стратегии. + +## 18. Settings + +### 18.1. Startup + +Pydantic Settings читает только infra env. `SettingsService` загружает обязательные `app_settings`, type-checks и строит immutable snapshot. Отсутствующий/невалидный обязательный ключ делает readiness false. + +Runtime refresh: poll `MAX(updated_at)` каждые 30 секунд; новый snapshot заменяется атомарно. Ошибка refresh сохраняет last-known-good и поднимает metric. Public config ETag меняется с snapshot version. + +### 18.2. Business settings + +Используются все ключи seed arch-04: auth, OTP bridge, operator, consent, attachments, rate limits, UX, CORS/cache. Hardcode запрещён, кроме технических безопасных defaults, явно отмеченных TBD. + +### 18.3. Infra env `api-backend` + +Обязательные: + +- `APP_ENV`, `API_PORT`, `LOG_LEVEL`; +- `DATABASE_URL`; +- `REDIS_URL`, `REDIS_REALTIME_URL`; +- `KEYCLOAK_PUBLIC_URL`, `KEYCLOAK_INTERNAL_URL`, `KEYCLOAK_REALM`, `KEYCLOAK_AUDIENCE`; +- `MESSAGE_SAFETY_URL`, `MESSAGE_SAFETY_SERVICE_TOKEN`; +- `MESSAGE_SAFETY_POST_TIMEOUT_SEC`, `MESSAGE_SAFETY_TASK_POLL_INTERVAL_SEC`, `MESSAGE_SAFETY_TASK_POLL_MAX_SEC`; +- `MESSAGE_SAFETY_CIRCUIT_FAILURE_THRESHOLD`, `MESSAGE_SAFETY_CIRCUIT_OPEN_SEC`; +- `BITRIX_LOCAL_APP_BASE_URL`, `BITRIX_LOCAL_APP_INTERNAL_TOKEN`, `BITRIX_API_INBOX_TOKEN`; +- `BITRIX_LOCAL_APP_HTTP_TIMEOUT_SEC`, `BITRIX_LOCAL_APP_CIRCUIT_FAILURE_THRESHOLD`, `BITRIX_LOCAL_APP_CIRCUIT_OPEN_SEC`; +- `KEYCLOAK_SETTINGS_BRIDGE_TOKEN`; +- `SELECTEL_S3_ENDPOINT_URL`, три bucket names, write access key/secret; +- `OTEL_EXPORTER_OTLP_ENDPOINT`. + +`SELECTEL_S3_QUARANTINE_READ_*` принадлежит `message-safety`, не должен передаваться контейнеру API. Новые env сначала документируются в arch-04. + +## 19. Rate limiting + +Два слоя обязательны: nginx edge и API Redis. + +| Endpoint/group | Identity | +|---|---| +| public config/content | IP hash | +| bootstrap/consents/session | user + IP | +| create dialog/message | user + dialog + IP | +| attachment init/complete | user + dialog | +| download URL | user + resource group | +| WS connect/subscribe | user + IP | +| internal inbox/settings | service identity + source network | + +Значения user-facing лимитов — `app_settings`. Алгоритм — sliding window/token bucket через Lua. При Redis unavailable: + +- message send, attachment init, download URL — fail-closed `503`; +- public GET — локальный conservative limiter с bounded memory; +- profile/history GET — допускается fail-open с метрикой и edge protection; +- internal inbox не отклоняется только из-за Redis: PostgreSQL idempotency остаётся. + +OTP counters API не ведёт. + +## 20. Resilience и circuit breakers + +Для `message-safety` и `bitrix-local-app` отдельные breakers: closed/open/half-open, настройки arch-04. Считаются timeout, connect failure и 5xx; 4xx domain result не считается infrastructure failure. + +Timeout budget: + +- safety POST: `MESSAGE_SAFETY_POST_TIMEOUT_SEC`; +- safety poll GET: 2s на попытку, не больше общего poll budget; +- Open Lines: `BITRIX_LOCAL_APP_HTTP_TIMEOUT_SEC`; +- S3 operation: bounded connect/read timeout; +- inbound URL download: отдельный bounded timeout и max bytes. + +Retry: + +- GET/idempotent internal call — exponential backoff + full jitter; +- POST Open Lines — только с idempotency key; +- S3 copy/delete — по deterministic object key; +- не повторять safety check POST после ambiguous response без stable request/message id; сначала recovery/reconciliation. + +Graceful shutdown прекращает принимать новые requests, закрывает WS, перестаёт claim worker rows, завершает текущие операции в grace period и освобождает leases. + +## 21. Observability и audit + +### 21.1. JSON logs + +Обязательные поля: `timestamp`, `level`, `service.name=api-backend`, `module`, `event`, `request_id`, `trace_id`, `span_id`, `ux_session_id` (если передан), `route`, `method`, `status_code`, `duration_ms`, `dependency`, `error_code`. + +Не логировать: + +- Authorization/cookies/tokens/raw JWT; +- raw OTP; +- полный phone/email/name; +- request/response body чата; +- filenames при наличии PII; +- S3 credentials, object signed query, presigned URLs; +- Bitrix download URL; +- stack trace в публичном ответе. + +### 21.2. Metrics + +- request count/latency/error by route template; +- JWT/JWKS cache hit/refresh/failure; +- DB pool saturation/transaction latency; +- rate limit rejects; +- idempotency replay/conflict/in-progress; +- safety verdict/poll duration/timeout/recovery backlog; +- delivery outbox depth/age/retries/dead letter; +- inbox duplicate/apply/failure; +- S3 init/complete/promote/delete/cleanup failure; +- WS active/reconnect/publish/drop/slow consumer; +- settings snapshot age/refresh failure; +- circuit state/transitions; +- readiness dependency state. + +Нельзя использовать user_id/dialog_id как metric labels. + +### 21.3. Audit events + +Минимум: + +- `auth.bootstrap`; +- `consent.recorded`; +- `session_start`; +- `dialog.created`; +- `message.submitted`, `message.blocked`, `message.delivered`, `message.failed`; +- `attachment.upload_initialized/completed/promoted/rejected`; +- `attachment.download_url_issued`; +- `document.download_url_issued`; +- `openlines.inbox_applied`; +- повторные severe rate limit violations. + +Audit записывается в той же транзакции с критическим изменением либо через durable outbox. Download URL выдаётся только после успешной audit записи. + +## 22. Health + +### `GET /health/live` + +Только состояние event loop/process; не обращается к зависимостям. `200 {"status":"live"}`. + +### `GET /health/ready` + +Проверяет с малым timeout: + +- PostgreSQL schema/revision и `SELECT 1`; +- Redis DB0 и DB1; +- наличие валидного JWKS cache/discovery; +- обязательный settings snapshot; +- S3 permissions для presign/Head/copy/delete через безопасную capability check без создания orphan; +- Message Safety readiness; +- worker heartbeat/backlog thresholds. + +Open Lines недоступность отображается как component `degraded`, но не обязательно делает весь API not-ready, чтобы чтение продолжало работать. **Решение M7:** readiness HTTP `200` при доступных DB/auth/settings и `status=degraded` для Bitrix/S3 partial failure; `503` — когда сервис не способен безопасно обслуживать большинство protected API. Compose healthcheck оценивает HTTP code, мониторинг — component details. + +Ответ не содержит secrets/internal credentials: + +```json +{"status":"ready","components":{"postgres":"ok","redis":"ok","jwks":"ok","safety":"ok","openlines":"degraded","s3":"ok"}} +``` + +## 23. Security + +- TLS только через nginx, internal HTTP только Docker backend network. +- Доверять proxy headers только от известных proxy CIDR. +- JWT validation fail-closed; алгоритм pinning; JWKS SSRF невозможен — URL строится из configured issuer/discovery. +- Service tokens сравниваются constant-time; rotation поддерживает current + previous token в короткое окно только после документирования env. +- Ownership predicate включён непосредственно в SQL (`id=:id AND user_id=:current_user AND record_status='A'`). +- CORS exact allow-list; wildcard с credentials запрещён. +- CSRF не требуется для Bearer API без cookie auth; если web перейдёт на cookies — обязателен CSRF token. +- Pydantic strict validation, max lengths, Unicode normalization для текста. +- SQL injection предотвращается ORM/parameterized SQL; dynamic sort только allow-list. +- SSRF защита inbound files; URL никогда не вызывается без проверки. +- S3 presign least privilege, short TTL, exact object key/content constraints. +- Content-Disposition безопасный; MIME sniffing protection. +- Secrets только env, не в repository/App DB/log. +- Dependency versions pin/lock, image scanning, non-root container, read-only filesystem, tmpfs `/tmp`. +- `docs`/OpenAPI UI в production либо отключены, либо доступны только ops; статический `openapi.yaml` остаётся артефактом. +- Error messages не раскрывают existence чужих ресурсов, SQL, hostnames и stack traces. +- Data retention и право удаления требуют отдельной legal policy; soft delete не заменяет обязательное уничтожение PII. + +**Решение M8:** blocked message text не нужен продукту после deny. В `messages.text` хранится пустая строка/безопасный redacted marker, а audit хранит только rule/verdict id и hash содержимого. Если регуляторно требуется исходный текст, это отдельное согласованное изменение retention/security. + +## 24. Docker/runtime + +Service compose: + +- `build: ./api-backend`, `expose: 8000`, без `ports`; +- networks: `backend`, `observability`; +- env только через `${VAR}` из root `.env`; +- healthcheck `/health/live` для процесса; root orchestration учитывает readiness; +- depends_on health для Redis/Keycloak/message-safety, но приложение само retry startup dependencies; +- managed PostgreSQL вне compose, TLS обязателен; +- stateless container, без persistent volume; +- init process для signal forwarding; +- non-root UID, dropped Linux capabilities, `no-new-privileges`; +- resource limits и ulimits задаются ops-профилем. + +Startup: + +1. parse/validate infra env; +2. configure structured logging/OTEL; +3. connect DB and verify Alembic revision; +4. connect Redis DB0/DB1; +5. load settings/content snapshot; +6. warm discovery/JWKS; +7. validate S3 bucket access; +8. start workers; +9. mark ready. + +Nginx маршрутизирует `/api/*`, включая WS `/api/v1/realtime`. Для message POST `proxy_read_timeout >= MESSAGE_SAFETY_TASK_POLL_MAX_SEC + 30s`. Internal paths наружу не маршрутизируются. + +## 25. Ключевые user flows + +### 25.1. Первый вопрос неавторизованного пользователя + +1. public config/content; +2. пользователь выбирает вопрос/вводит текст; +3. frontend показывает согласия; +4. Keycloak OTP + PKCE; +5. bootstrap с consent body, identity из JWT; +6. session-start; +7. create dialog с idempotency; +8. send message с idempotency; +9. safety → Open Lines; +10. финальный MessageResponse. + +Популярный вопрос не имеет отдельного backend flow: его text отправляется как обычное сообщение. + +### 25.2. Возврат пользователя + +Frontend выполняет refresh token grant. API не обновляет token. При новой UX-сессии — session-start; затем profile/history/chat. Успешный token refresh сам по себе новую UX session не создаёт. + +### 25.3. Файловое сообщение + +create/reuse dialog → init → direct PUT quarantine → complete → send file message → safety `203` poll → allow promote → delivery outbox → Open Lines → delivered. При deny quarantine удаляется, Bitrix не вызывается. + +### 25.4. Ответ оператора + +Bitrix event → local app durable inbox → `POST /internal/openlines/v1/inbox` → API receipt + DB message → commit → realtime → local app delivery ack. При WS failure frontend polling получает сообщение. + +## 26. Тестовая стратегия + +### 26.1. Unit + +- JWT claims/phone normalization; +- Pydantic discriminated union text/file; +- status transition policies; +- canonical fingerprint/idempotency conflict; +- cursor encode/decode/tamper; +- rate-limit keying; +- circuit state; +- S3 object key generation; +- settings parsing/fail-fast; +- safe error/log redaction. + +### 26.2. Integration + +- Alembic empty upgrade and previous-version upgrade; +- unique active dialog under concurrency; +- bootstrap upsert + immutable consents + trigger-generated sync task; +- `han.sync_suppress` prevents echo; +- ownership queries and soft delete; +- outbox atomicity with message; +- inbox duplicate race; +- safety task `SKIP LOCKED` leases; +- Redis Lua limits and 24h idempotency; +- S3 init/complete/promote/delete with S3-compatible test endpoint; +- JWKS rotation/unknown kid/outage cache. + +### 26.3. Contract + +- generated FastAPI OpenAPI matches committed `api-backend/openapi.yaml`; +- Message Safety POST `200/203/403`, task poll `203/200/403` и test-only terminal `400 stub_final_error`; проверены различение malformed `400` и mapping terminal `400` → public `422`; +- Open Lines message idempotency and inbox schemas; +- settings bridge DTO/token; +- common request-id/trace propagation; +- error envelope for every 4xx/5xx. + +### 26.4. E2E/failure + +- first login → popular question delivered; +- silent refresh flow assumptions from frontend contract; +- text and file happy paths; +- file deny and cleanup; +- safety pending then allow/deny; +- API crash during poll and recovery; +- client disconnect during poll; +- Redis loss without duplicate message; +- local app timeout before/after accepting message; +- inbound event duplicate and file SSRF rejection; +- WS disconnect/reconnect/poll gap recovery; +- concurrent sends/idempotency; +- foreign user ids always 404; +- rate limits and Retry-After; +- dependency circuit open/half-open; +- no PII/secrets/presigned URLs in logs. + +### 26.5. Performance targets + +**TBD-4:** финальные SLO/RPS определяются load profile до production. Минимальные acceptance checks: + +- public/profile/history p95 без внешних dependencies измеряется отдельно; +- text send p95 включает safety + Open Lines; +- file send допускает до poll max budget; +- 1 slow file poll не блокирует другие requests; +- DB pool и worker concurrency не исчерпываются при long polls; +- WS slow consumer не увеличивает память без границ. + +## 27. Definition of Done + +Модуль готов, когда: + +- реализованы все перечисленные `/api/v1` и owned internal endpoints; +- committed `api-backend/openapi.yaml` соответствует runtime OpenAPI 3.1; +- пути Open Lines в коде/тестах используют только `/internal/openlines/v1`; +- schema `han_app`, constraints, indexes, triggers и seed созданы Alembic; +- migration upgrade проверен на пустой и предыдущей схеме; +- JWT/JWKS, phone claim, ownership и consent checks покрыты; +- idempotency переживает потерю Redis без повторного side effect; +- Message Safety sync/poll/recovery и S3 lifecycle покрыты failure tests; +- Open Lines outbox/inbox duplicate delivery покрыты contract/E2E tests; +- CRM задачи создают только DB triggers; +- realtime и polling не имеют gap при reconnect; +- business settings не hardcoded и не продублированы в env; +- rate limits работают на nginx и API уровнях; +- circuit breakers, timeout, graceful shutdown реализованы; +- JSON logs/metrics/traces/audit соответствуют требованиям и не содержат PII/secrets; +- `/health/live` и `/health/ready` проверены; +- контейнер non-root запускается в едином root Docker Compose без published port; +- `ruff check`, format check, mypy/pyright, unit/integration/contract/E2E tests успешны; +- dependency/security scan не имеет unresolved critical/high; +- подготовлены runbooks: migration, outbox/safety backlog, DLQ, quarantine cleanup, JWKS outage, token rotation; +- все допущения/TBD ниже закрыты либо явно приняты владельцем продукта/архитектуры. + +## 28. Явные решения, допущения и TBD + +### Зафиксированные решения модуля + +- **M1:** layered FastAPI, транзакции в use cases, внешние адаптеры отдельно. +- **M2:** phone claim `phone_number`, fallback `preferred_username` только E.164. +- **M3:** Message создаётся до safety для durable crash checkpoint. +- **M4:** Redis idempotency дополнен durable PostgreSQL record. +- **M5:** recovery продолжается после client timeout; точный extended budget — TBD. +- **M6:** WS subprotocol предпочтительнее query token. +- **M7:** readiness различает critical not-ready и partial degraded. +- **M8:** denied text редактируется/не хранится в открытом виде. + +### Допущения + +- **A1:** locale API зарезервирован, MVP фактически `ru`. +- **A2:** inbox получит стабильный `event_id`; временно возможен deterministic fingerprint. +- **A3:** authoritative SHA-256 может подтверждаться Message Safety, если S3 HeadObject его не отдаёт. + +### Требуют согласования + +- **TBD-1:** единый код для protected endpoint до bootstrap (`409` предложен). +- **TBD-2:** extended recovery budget после `MESSAGE_SAFETY_TASK_POLL_MAX_SEC`. +- **TBD-3:** добавить `event_id`/`occurred_at` в WS events и `event_id` в inbox OpenAPI. +- **TBD-4:** production SLO, RPS, concurrency, RPO/RTO и retention. +- **TBD-5:** antivirus policy для файлов оператора, которые не проходят outbound Message Safety. +- **TBD-6:** legal retention/erasure для PII, audit, blocked messages и S3-data. +- **TBD-7:** точный max WS connections/subscriptions/frame и queue size. +- **TBD-8:** G10 — окончательный DTO/mapping public app-config при оформлении OpenAPI. +- **TBD-9:** G11 — API/WS deprecation policy до публичного релиза. + +Ни один TBD не разрешает менять канонические endpoint, auth, enum или service boundaries без обновления соответствующего `arch-*`. diff --git a/architectory/module-02-frontend-test-site.md b/architectory/module-02-frontend-test-site.md new file mode 100644 index 0000000..14b640b --- /dev/null +++ b/architectory/module-02-frontend-test-site.md @@ -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:`. + +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 по мнемоникам. diff --git a/architectory/module-03-nginx.md b/architectory/module-03-nginx.md new file mode 100644 index 0000000..485fff8 --- /dev/null +++ b/architectory/module-03-nginx.md @@ -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. diff --git a/architectory/module-04-redis.md b/architectory/module-04-redis.md new file mode 100644 index 0000000..ea1cfa2 --- /dev/null +++ b/architectory/module-04-redis.md @@ -0,0 +1,284 @@ +# module-04. Проектная спецификация Redis + +> Статус: целевая спецификация Redis в едином Docker Compose MVP. +> Источники: [`README.md`](README.md), [`arch-00-glossary.md`](arch-00-glossary.md), [`arch-01-system-architecture.md`](arch-01-system-architecture.md), [`arch-02-api-contracts.md`](arch-02-api-contracts.md), [`arch-03-docker-compose-blueprint.md`](arch-03-docker-compose-blueprint.md), [`arch-04-settings-and-content.md`](arch-04-settings-and-content.md), [`arch-05-agent-development-process.md`](arch-05-agent-development-process.md), [`module-01-api-backend.md`](module-01-api-backend.md). + +## 1. Назначение и инварианты + +Один Redis-контейнер предоставляет быстрые ephemeral функции трём логическим DB: + +- DB0 — `api-backend`: idempotency fast layer и API rate limits; +- DB1 — realtime и coordination; +- DB2 — `message-safety` stub tasks/cache. + +Redis не является бизнес-очередью, source of truth сообщений, sync tasks, audit, профилей или delivery checkpoint. Надёжные состояния остаются в managed PostgreSQL/S3. Потеря Redis может ухудшить сервис, но не должна создавать потерю подтверждённых сообщений либо дубль side effect: durable idempotency/outbox/checkpoint api-backend описаны в module-01. + +OTP counters api-backend в Redis не хранит; они принадлежат Keycloak/SPI. + +## 2. Версия и topology + +Redis 7.x, image закреплён по digest. Одна primary instance на VM без replica/Sentinel в MVP. Клиенты используют connection pool, bounded timeouts и не выполняют опасные команды. + +Logical DB — изоляция имён, не security boundary и не независимый memory quota. При росте или разных eviction/SLA DB2 и DB0 выносятся в отдельные instances. + +## 3. Общие правила ключей + +Формат: `han:{domain}:{purpose}:{hashed-or-public-id}:{version}`. Только ASCII lowercase separators. Public UUID допустим; IP, phone, email, token, text и filename — только HMAC/SHA-256 с server-side pepper там, где нужна защита dictionary attack. + +- key length желательно ≤ 200 bytes; +- значения versioned (`v=1`); +- timestamps — Unix ms/seconds или RFC3339, формат фиксирован для каждого key; +- wildcard `KEYS` production запрещён; только `SCAN` для ops; +- каждый non-channel key имеет TTL, кроме явно обоснованных bounded structures; +- large payload/presigned URL/token/message text запрещены. + +## 4. DB0: API rate limiting + +Примеры: + +| Key | Тип/value | TTL | +|---|---|---| +| `han:api:rl:user:{user_id}:{route_hash}:{window}` | ZSET timestamps либо counter | window + jitter | +| `han:api:rl:ip:{ip_hmac}:{route_hash}:{window}` | ZSET/counter | window + jitter | +| `han:api:rl:dialog:{dialog_id}:message:{window}` | ZSET/counter | window + jitter | +| `han:api:rl:service:{service}:{route_hash}:{window}` | counter/token bucket | window + jitter | + +Алгоритм — atomic Lua/function: удалить старые entries, посчитать, добавить текущий request, установить expiry, вернуть `allowed`, `remaining`, `retry_after_ms`, `reset_at`. Для fixed window `INCR` и первый `EXPIRE` выполняются в одном script, чтобы не оставить бессрочный key. + +Clock используется Redis `TIME` внутри script, а не client wall clock. Script загружается при startup, SHA кэшируется; после `NOSCRIPT` выполняется контролируемый reload. Route labels — bounded allow-list/hash, исключающий cardinality attack. + +## 5. DB0: idempotency + +| Key | Значение | TTL | +|---|---|---| +| `han:api:idem:{scope}:{user_id}:{key_hmac}` | HASH/MessagePack: state, fingerprint, status, sanitized response, resource id, version | 24h | +| `han:api:idemlock:{scope}:{user_id}:{key_hmac}` | random owner token | 30s + heartbeat | + +State transitions `absent → in_progress → completed`; fingerprint mismatch возвращает conflict. Создание/сравнение/lock выполняется Lua. Unlock/extend разрешены только если owner token совпадает (`compare-and-delete/expire` script). + +Response не содержит tokens, cookies, presigned URL или PII. Transient 503/504 не фиксируется как окончательный completed. PostgreSQL `idempotency_records` — durable fallback; Redis — ускоритель. При cache loss API читает durable row и прогревает key. + +## 6. DB1: realtime + +| Key/channel | Формат | TTL | +|---|---|---| +| `han:rt:conn:{connection_id}` | HASH: user_id, instance, last_seen, subscriptions_count | 90s | +| `han:rt:user:{user_id}:connections` | ZSET connection_id → heartbeat | 120s | +| `han:rt:dialog:{dialog_id}` | Pub/Sub channel | нет хранения | +| `han:rt:user:{user_id}` | Pub/Sub channel | нет хранения | + +Heartbeat атомарно обновляет connection и membership; cleanup удаляет stale ZSET entries bounded batches. Pub/Sub — at-most-once notification. Payload содержит только event id/type/entity UUID и DTO, допустимый realtime контрактом; DB остаётся source of truth. После reconnect frontend всегда делает REST reconciliation. + +Redis Streams не используются как бизнес queue. Если позже понадобится durable realtime replay, сначала меняется архитектура и выбирается PostgreSQL outbox/event broker. + +## 7. DB1: coordination locks + +| Key | TTL | +|---|---| +| `han:coord:lock:safety-recovery:{task_id}` | 30s | +| `han:coord:lock:delivery:{message_id}` | 30s | +| `han:coord:lock:settings-refresh:{instance}` | 30s | + +Acquire: `SET key owner NX PX ttl`; extend/release — Lua compare owner. Worker обязан опираться также на PostgreSQL row lease/`FOR UPDATE SKIP LOCKED`; Redis lock — оптимизация, не единственная защита. Fencing token рекомендуется для внешнего side effect, а уникальные DB constraints/idempotency остаются финальной защитой. + +## 8. DB2: Message Safety stub + +| Key | Тип/value | TTL | +|---|---|---| +| `han:safety:task:{task_id}` | HASH/JSON v1: created, polls, optional seed/context | `MESSAGE_SAFETY_TASK_TTL_SEC` | +| `han:safety:tasklock:{task_id}` | owner token | 5–30s | +| `han:safety:rl:service:{caller}:{window}` | counter | window+jitter | +| `han:safety:verdict:{content_hash}:{rules_version}` | optional cache | bounded technical TTL | + +Для требуемой заглушки task — ephemeral contract state. Истечение task возвращает безопасный `404 task_not_found/expired` по internal error semantics. В production safety authoritative audit/cache может находиться в PostgreSQL `message_safety`; Redis DB2 не заменяет его. + +Random verdict каждого GET по заданию независим; Redis хранит существование/TTL и счётчик polls для observability, но не предопределяет финал. В deterministic tests seed/RNG injected на уровне сервиса. + +## 9. Serialization и limits + +- простые counters — integer; +- locks — opaque random 128-bit token; +- metadata — Redis HASH либо компактный JSON с `schema_version`; +- max value target 32 KiB, hard application guard 128 KiB; +- response cache хранит только allow-listed sanitized JSON; +- decode error считается cache miss, key удаляется/карантинируется и поднимается metric. + +## 10. TTL policy + +| Категория | TTL | +|---|---| +| idempotency completed | 24h по arch-02 | +| idempotency in-progress lock | 30s, heartbeat bounded | +| rate limit | window + 10–30% deterministic jitter | +| realtime connection | 90s; set membership 120s | +| coordination lock | 30s | +| safety task | default 15m, обязательно > API poll max 300s + recovery margin | +| safety cache | default 5–60m по rules version | + +Новый key без TTL запрещён contract test, кроме Pub/Sub channel (не key) и ops metadata с явным обоснованием. + +## 11. Atomicity и Lua governance + +Scripts/functions хранятся в репозитории рядом с клиентом, versioned и тестируются на real Redis. Запрещены unbounded loops/SCAN внутри Lua. Входные массивы ограничены. Script timeout отслеживается; `SCRIPT KILL` runbook применяется только если нет writes либо после оценки. + +Обязательные scripts: + +- rate-limit evaluate; +- idempotency reserve/complete/conflict; +- lock release/extend; +- realtime heartbeat/cleanup membership; +- safety task get+increment poll при необходимости. + +Redis transaction не координирует PostgreSQL/S3/HTTP. Cross-system consistency обеспечивается DB checkpoint/outbox и idempotent finalize. + +## 12. Persistence + +Решение MVP: AOF `appendonly yes`, `appendfsync everysec` плюс RDB snapshots (`save 900 1`, `300 100`, `60 10000` либо tuned). Это ускоряет восстановление ephemeral state, но не превращает Redis в authoritative store. + +`aof-use-rdb-preamble yes`, automatic rewrite с порогами; volume `redis-data`. При corruption используется `redis-check-aof`/restore clean instance, а сервисы восстанавливают authoritative state из PostgreSQL. + +RPO Redis до ~1 секунды приемлем, потому что бизнес-RPO задаётся PostgreSQL/S3. Backup Redis не обязателен для бизнес-восстановления, но периодическая копия RDB/AOF полезна для ops forensic без secrets. + +## 13. Memory и eviction + +`maxmemory` задаётся относительно container limit (ориентир 70–75%, оставляя overhead/fork). Начальная оценка для одной VM — 512 MiB, уточняется load test. + +Eviction MVP: `volatile-lru`/`volatile-ttl`, так как все application keys имеют TTL. `allkeys-lru` опасен для idempotency при memory pressure; `noeviction` может полностью закрыть writes. Окончательный выбор после нагрузки: предпочтительно `volatile-lru` + alerts, а при разделении instances DB0 idempotency получает отдельную noeviction policy. + +Контролируются `used_memory`, RSS, fragmentation, evicted_keys, expired_keys, key count/avg TTL по DB. OOM/eviction idempotency не создаёт дубль благодаря PostgreSQL fallback. + +## 14. Sizing + +Расчёт до production: + +```text +DB0 rate = peak identities × routes × active windows × bytes/key +DB0 idem = mutating requests/24h × avg sanitized record +DB1 = peak connections × connection metadata + Pub/Sub buffers +DB2 = safety tasks within TTL × avg task metadata +total × 1.5 allocator/fragmentation × 1.3 growth reserve +``` + +Pub/Sub output buffers и slow consumers имеют hard/soft limits. Load test фиксирует peak RPS, WS connections, idempotency response size и AOF rewrite headroom. + +## 15. Auth, ACL и network boundary + +Redis не публикует `6379` на host, подключён только к Docker `backend`. `protected-mode yes`, bind container interface, default user отключён. ACL users: + +- `api_backend`: DB0/DB1 key prefixes, нужные command categories; +- `message_safety`: только DB2 prefixes; +- `ops_health`: `PING`, ограниченный `INFO`; + +Важно: Redis ACL не ограничивает logical DB напрямую надёжно; key-prefix patterns и разные credentials обязательны. `SELECT` запрещается, клиент URL сразу задаёт DB, но ACL prefix остаётся основной защитой. + +Dangerous/admin commands (`FLUSHALL`, `FLUSHDB`, `CONFIG`, `MODULE`, broad KEYS`, replication changes) запрещены application users; rename-command не считается основной защитой. + +Пароли сильные, только env/secret mount, rotation current/new через rolling deploy. Внутри одной VM TLS Redis опционален при закрытой Docker network; при выносе за host/VPC TLS обязателен (`rediss://`) и plaintext отключается. + +## 16. Docker/runtime + +```text +redis/ + docker-compose.yml + redis.conf + users.acl.template + scripts/ + tests/ +``` + +Compose: pinned Redis image, `expose: 6379`, без `ports`, `backend` network, `redis-data:/data`, config/ACL read-only, non-root UID, no-new-privileges, dropped capabilities, resource/memory/ulimit settings. + +Startup валидирует config и ACL, permissions volume, затем Redis. Healthcheck использует ACL health user и `redis-cli --no-auth-warning PING`, secret не печатается. Graceful stop timeout позволяет AOF flush. + +URL: + +```text +REDIS_URL=redis://api_backend:@redis:6379/0 +REDIS_REALTIME_URL=redis://api_backend:@redis:6379/1 +MESSAGE_SAFETY_REDIS_URL=redis://message_safety:@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. diff --git a/architectory/module-05-message-safety.md b/architectory/module-05-message-safety.md new file mode 100644 index 0000000..383bd84 --- /dev/null +++ b/architectory/module-05-message-safety.md @@ -0,0 +1,458 @@ +# module-05. Проектная спецификация заглушки `message-safety` + +> Статус: целевая спецификация тестовой заглушки MVP, строго реализующей правила данного задания. +> Источники: [`README.md`](README.md), [`arch-00-glossary.md`](arch-00-glossary.md), [`arch-01-system-architecture.md`](arch-01-system-architecture.md), [`arch-02-api-contracts.md`](arch-02-api-contracts.md), [`arch-03-docker-compose-blueprint.md`](arch-03-docker-compose-blueprint.md), [`arch-04-settings-and-content.md`](arch-04-settings-and-content.md), [`arch-05-agent-development-process.md`](arch-05-agent-development-process.md), [`module-01-api-backend.md`](module-01-api-backend.md), [`module-04-redis.md`](module-04-redis.md). + +## 1. Назначение и ограничение + +Сервис — internal stub для проверки orchestration `api-backend`, а не реальный moderation/antivirus engine. Он доступен только в Docker network и реализует канонические пути arch-02: + +- `POST /internal/safety/v1/messages/check`; +- `GET /internal/safety/v1/messages/tasks/{task_id}`; +- `GET /health/live`; +- `GET /health/ready`. + +Сервис не публикуется через nginx, не получает JWT пользователя, не перемещает S3 objects, не отправляет сообщения в Bitrix и не хранит бизнес-историю. + +## 2. Главное отличие тестовой заглушки + +По базовой архитектуре final deny у Message Safety обычно `403`. Для этой заглушки пользователь явно задал особый task-контракт: `GET task` независимо возвращает примерно с равной вероятностью `203`, `200` или **`400`**. + +Здесь `400` на валидном `GET task` — **финальный отрицательный verdict/error заглушки**, а не malformed HTTP request. `api-backend` обязан трактовать его как terminal safety rejection и отображать публично как `422 message_blocked`, выставляя `safety_status=blocked`, `delivery_status=rejected`, без вызова Bitrix. Клиенту raw internal `400` не проксируется. + +Это намеренное test-only расширение текущей таблицы arch-02 (`200/203/403`). Перед использованием не как заглушки arch-02 и contract tests должны быть обновлены либо `400` должен быть заменён на канонический `403`. Существующие arch-файлы в рамках этой задачи не изменяются. + +## 3. Технологический профиль + +- Python 3.12+, FastAPI, Pydantic v2, Uvicorn. +- Redis asyncio client, DB2. +- OpenTelemetry, JSON logging. +- pytest/anyio, HTTPX ASGI client, real Redis integration tests. +- Без PostgreSQL и S3 для этой stub-реализации; их будущая интеграция находится вне scope. + +## 4. Приоритет правил + +Перед классификацией текст нормализуется. Правила применяются строго в порядке: + +1. validation/auth: invalid DTO или service token обрабатываются до бизнес-правил; +2. нормализация; +3. если первый Unicode code point нормализованного текста — кириллическая `ф` или `Ф`, вернуть `403 deny`; +4. иначе если первый code point — десятичная цифра, создать task и вернуть `203 pending`; +5. любой иной текст, включая пустой после допустимой нормализации, вернуть `200 allow`. + +Таким образом, после нормализации строка не может одновременно начинаться и с `ф/Ф`, и с цифры. Rule `ф/Ф` записан раньше для явности. Для file-only request без текста default — `200 allow`; заглушка не сканирует файл. + +## 5. Нормализация + +Детерминированный pipeline: + +1. требовать JSON UTF-8; +2. заменить `CRLF/CR` на `LF`; +3. Unicode normalization `NFKC`; +4. удалить leading Unicode whitespace (`lstrip`); +5. не менять регистр всей строки и не удалять punctuation; +6. ограничить текст max length до значения internal DTO (ориентир 10 000 code points). + +Примеры: + +| Вход | После нормализации | Результат | +|---|---|---| +| `"Файл"` | `"Файл"` | 403 | +| `" фраза"` | `"фраза"` | 403 | +| `"\u00a07 дней"` | `"7 дней"` | 203 + task | +| `"+7..."` | `"+7..."` | 200 | +| `"документ"` | `"документ"` | 200 | +| `"abc"` | `"abc"` | 200 | +| `""`/whitespace | `""` | 200 | + +«Цифра» означает Unicode category `Nd` после NFKC, не только ASCII `[0-9]`. + +## 6. Authentication и common headers + +Каждый `/internal/safety/v1/*` требует: + +```text +X-Service-Token: ${MESSAGE_SAFETY_SERVICE_TOKEN} +X-Request-ID: UUID/ULID (если нет — сервис создаёт) +traceparent: optional W3C +``` + +Token сравнивается constant-time. Missing/invalid token → `401` или `403` internal auth error; выбран единый `401 service_unauthorized`, без подсказки о значении. Health не требует token внутри network либо использует отдельную ops policy. + +## 7. DTO `POST .../check` + +Stub принимает минимальный versioned DTO, совместимый с потребностями api-backend: + +```json +{ + "message_id": "uuid", + "content_kind": "text", + "text": "Фраза", + "attachment": null +} +``` + +Для file: + +```json +{ + "message_id": "uuid", + "content_kind": "file", + "text": "", + "attachment": { + "attachment_id": "uuid", + "quarantine_object_key": "opaque", + "mime_type": "application/pdf", + "size_bytes": 12345, + "checksum": "sha256:..." + } +} +``` + +Неизвестные поля запрещены. `content_kind=text` требует text field (пустой разрешён именно stub default); `file` допускает attachment metadata, но не читает S3. `message_id` нужен для correlation/idempotency, не для выбора verdict. + +## 8. Ответы `POST .../check` + +### `200 allow` + +```json +{ + "verdict": "allow", + "rule_id": "stub.default_allow", + "rules_version": "2026-01-01" +} +``` + +### `403 deny` для `ф/Ф` + +```json +{ + "verdict": "deny", + "rule_id": "stub.starts_with_cyrillic_ef", + "reason_code": "stub_blocked", + "rules_version": "2026-01-01" +} +``` + +### `203 pending` для цифры + +```json +{ + "verdict": "pending", + "task_id": "uuid", + "poll_after_ms": 2000, + "expires_at": "2026-07-10T12:15:00Z", + "rules_version": "2026-01-01" +} +``` + +Все три — нормальные domain outcomes. `403` не участвует в circuit breaker failure count. + +## 9. Task storage Redis DB2 + +Ключ: + +```text +han:safety:task:{task_id} +``` + +HASH/JSON v1: + +```json +{ + "schema_version": 1, + "message_id": "uuid", + "created_at_ms": 0, + "poll_count": 0, + "rng_context": "optional-test-only", + "rules_version": "2026-01-01" +} +``` + +TTL `MESSAGE_SAFETY_TASK_TTL_SEC`, default 900 seconds, должен быть больше `MESSAGE_SAFETY_TASK_POLL_MAX_SEC` (300) плюс network/recovery margin. Текст, attachment key и checksum в Redis не нужны. Создание task и TTL атомарны. Коллизия UUID повторяется bounded. + +`message_id → task_id` dedup key допустим для идемпотентного повторного POST: + +```text +han:safety:task-by-message:{message_id} -> task_id +``` + +с тем же TTL; reserve обоих keys выполняется Lua. Повтор одинакового check возвращает тот же active task. Если fingerprint изменился для того же message id — `409 safety_request_conflict`. + +## 10. `GET .../tasks/{task_id}` + +Сначала проверяются token, UUID и существование task. Затем **на каждый GET независимо** выбирается один из трёх outcomes с вероятностью примерно 1/3: + +- `203 pending`; +- `200 allow`; +- `400 stub_final_error` (terminal deny/error). + +Предыдущий `200` или `400` не фиксируется как sticky verdict в Redis по буквальному требованию «дальнейший GET случайно и независимо». Следовательно, повторный GET того же task после terminal ответа теоретически может вернуть другой outcome. `api-backend` обязан прекратить polling на первом terminal `200/400`, поэтому противоречие снаружи не возникает. + +Это поведение специально тестовое и не годится для production moderation. Для безопасной recovery production service должен сохранять sticky final verdict; переход потребует изменения режима/контракта. + +### Ответы + +`203`: + +```json +{"verdict":"pending","task_id":"uuid","poll_after_ms":2000} +``` + +`200`: + +```json +{"verdict":"allow","task_id":"uuid","rule_id":"stub.random_allow"} +``` + +`400` terminal: + +```json +{ + "verdict":"deny", + "task_id":"uuid", + "error":{ + "code":"stub_final_error", + "message":"Stub task returned a final negative verdict", + "request_id":"uuid", + "details":{"terminal":true} + } +} +``` + +Для malformed `task_id` используется `400 validation_error`, но его envelope имеет `verdict` отсутствующий и `details.terminal` отсутствует/false. Для неизвестного/expired task — `404 task_not_found`. Api-backend различает terminal stub `400` строго по schema/code, а не по одному HTTP status. + +## 11. Worker/poll model + +Реальный worker не требуется. Task создаётся сразу, а GET эмулирует состояние worker случайным outcome. Контракт остаётся таким же, как для async orchestration: check создаёт `task_id`, api-backend poll-ит GET внутри исходного user POST. + +Опциональный `SAFETY_STUB_WORKER_MODE=emulated_on_poll` — единственный режим MVP. Будущий worker mode не должен менять endpoint/DTO, но final verdict тогда становится sticky. + +Api-backend: + +```text +POST check +200 -> allow +403 -> deny -> public 422 message_blocked +203 -> poll GET +GET 203 -> continue +GET 200 -> allow +GET 400 + code=stub_final_error + terminal=true + -> deny -> public 422 message_blocked +other 400 -> dependency contract error, not message verdict +timeout/5xx/redis unavailable -> public 503/504 +``` + +## 12. Randomness и deterministic testing + +Production-like stub default использует криптографически достаточный process RNG либо `random.Random` с entropy seed; распределение не является security decision. + +RNG внедряется через интерфейс `VerdictRng.choice()`. Test implementations: + +- sequence RNG: `pending, allow, final_error`; +- seeded RNG через `SAFETY_STUB_RNG_SEED` только при `APP_ENV=test`; +- forced outcome через dependency override, не public header. + +В production-like env seed/forced mode вызывает startup failure, чтобы внешний caller не управлял verdict. Статистический test на большой выборке проверяет каждую долю в допустимом диапазоне (например, 0.30–0.36), но основные tests используют sequence RNG и не flaky. + +«Независимо» означает новый RNG draw на каждый валидный GET; poll count/предыдущий outcome не влияют на draw. + +## 13. Error semantics + +Internal envelope: + +```json +{ + "error": { + "code": "validation_error", + "message": "Request is invalid", + "request_id": "uuid", + "details": {} + } +} +``` + +| HTTP | Code | Retry/смысл | +|---|---|---| +| 400 | `validation_error` | malformed, не terminal verdict | +| 400 | `stub_final_error` + verdict deny | terminal task verdict, не malformed | +| 401 | `service_unauthorized` | не retry без исправления secret | +| 403 | domain `deny` POST | terminal safety verdict | +| 404 | `task_not_found` | expired/unknown, dependency contract failure | +| 409 | `safety_request_conflict` | message id с другим fingerprint | +| 429 | `rate_limit_exceeded` | retry по `Retry-After` | +| 500 | `internal_error` | retry/circuit | +| 503 | `redis_unavailable` | retry/circuit | + +Domain `403` и terminal stub `400` не считаются infrastructure failure circuit breaker. + +## 14. Idempotency и concurrency + +POST fingerprint = SHA-256 canonical normalized DTO без request-id/token. Lua reserve обеспечивает один task на `(message_id,fingerprint)` в TTL. Concurrent duplicate получает тот же task id. + +GET атомарно проверяет существование и увеличивает `poll_count`; RNG draw выполняется независимо. Удалять task после terminal нельзя, иначе повтор получил бы 404 и нарушил независимый test behavior. TTL выполняет cleanup. + +## 15. Health + +`GET /health/live`: только process/event loop, всегда без Redis call. + +`GET /health/ready` проверяет: + +- env/token/rules version валидны; +- Redis DB2 auth, PING и короткий SET/GET/DEL с TTL; +- RNG provider доступен; +- OpenAPI schema загружена. + +Redis down → `503 {"status":"not_ready","components":{"redis":"down"}}`. Текстовые sync rules технически вычислимы, но service целиком not-ready, а digit check возвращает 503, чтобы не выдавать task без storage. + +## 16. Observability + +JSON fields: timestamp, level, `service.name=message-safety`, module, event, request_id, trace_id/span_id, route, status, duration, rule_id, verdict, task_age_bucket, poll_count bucket, error_code. + +Не логируются service token, message text, attachment key/name, checksum, DTO body или PII. Разрешены message/task UUID при принятой retention либо их hash. + +Metrics: + +- requests/latency/errors по route/status; +- check outcomes allow/deny/pending; +- task GET outcomes pending/allow/final_error; +- observed distribution; +- task create/dedup/conflict/not-found/expired; +- Redis latency/error/pool; +- auth rejects, rate limit; +- RNG mode как low-cardinality info; +- readiness. + +Trace связывается с api-backend через `traceparent`, `X-Request-ID` возвращается. + +## 17. Security + +- только Docker backend network, без nginx/public route и host port; +- constant-time token compare, secret только env/secret mount; +- strict JSON schema/max body/max text; +- no dynamic code/rules from request; +- Redis ACL только DB2 prefixes; +- non-root, read-only root fs, tmpfs `/tmp`, dropped capabilities; +- OpenAPI docs UI production отключён, committed YAML остаётся; +- CORS не нужен internal service; +- rate limit по service identity/network защищает от accidental loops; +- error response не раскрывает internal host/stack/secret. + +## 18. Docker и env + +```text +message-safety/ + app/ + main.py + api/{routes,schemas,errors,auth}.py + application/{classifier,tasks}.py + infrastructure/{redis,rng,observability}.py + settings.py + tests/{unit,integration,contract}/ + openapi.yaml + Dockerfile + docker-compose.yml +``` + +Compose: `expose: 8080`, networks `backend`,`observability`, без `ports`, depends_on Redis health, собственный retry startup. + +Env: + +```text +APP_ENV=production-like +MESSAGE_SAFETY_PORT=8080 +MESSAGE_SAFETY_REDIS_URL=redis://message_safety:@redis:6379/2 +MESSAGE_SAFETY_SERVICE_TOKEN= +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 заглушки. diff --git a/architectory/module-06-bitrix-local-app.md b/architectory/module-06-bitrix-local-app.md new file mode 100644 index 0000000..e5f4f38 --- /dev/null +++ b/architectory/module-06-bitrix-local-app.md @@ -0,0 +1,643 @@ +# module-06. Проектная спецификация `bitrix-local-app` + +> Статус: целевая production-спецификация MVP. +> Портал: `han0107.bitrix24.ru`; connector: `han_mobile_app`; Open Line: `8`. +> Источники: [`README.md`](README.md), [`arch-00-glossary.md`](arch-00-glossary.md), [`arch-01-system-architecture.md`](arch-01-system-architecture.md), [`arch-02-api-contracts.md`](arch-02-api-contracts.md), [`arch-03-docker-compose-blueprint.md`](arch-03-docker-compose-blueprint.md), [`arch-04-settings-and-content.md`](arch-04-settings-and-content.md), [`arch-05-agent-development-process.md`](arch-05-agent-development-process.md), [`module-01-api-backend.md`](module-01-api-backend.md), прототип [`../../HAN_chat/bitrix-local-app/README.md`](../../HAN_chat/bitrix-local-app/README.md) и [`../../HAN_chat/deploy/init-managed-postgres.py`](../../HAN_chat/deploy/init-managed-postgres.py). + +## 1. Назначение и приоритет + +Сервис является локальным серверным приложением Bitrix24 и адаптером Open Lines. Он изолирует OAuth и протокол `imconnector` от `api-backend`, надёжно доставляет разрешённые сообщения клиента оператору и события оператора обратно в HAN. + +При конфликте действуют приоритеты `README.md`. Настоящий документ детализирует существующие контракты, но не меняет их. Любой новый внешний/internal endpoint сначала фиксируется в `arch-02`. + +Канонический URL канала: + +```text +https://han0107.bitrix24.ru/contact_center/connector/?ID=han_mobile_app&LINE=8 +``` + +## 2. Ответственность и границы + +Сервис отвечает за: + +- install/lifecycle локального приложения и OAuth Bitrix24; +- шифрованное хранение и безопасное обновление portal tokens; +- `imconnector.register`, `imconnector.activate`, `event.bind`, status/retry setup; +- публичный приём `ONAPP*` и `ONIMCONNECTOR*`; +- tolerant parsing JSON/form/multipart и PHP-style массивов; +- проверку callback, нормализацию, durable inbox, retry и DLQ; +- `dialog_sessions`: `external_chat_id` (= `dialog_id`) ↔ `bitrix_chat_id` ↔ `session_id`; +- идемпотентный outbound `api-backend` → `imconnector.send.messages`; +- forward входящих сообщений/файлов и `dialog.closed` в `api-backend`; +- `imconnector.send.status.delivery` только после durable ack API; +- health, telemetry, audit технических переходов. + +Сервис не отвечает за: + +- JWT/пользовательскую авторизацию, Message Safety и App DB; +- хранение истории HAN, realtime и S3; +- CRM Contact/profile sync — это будущая зона `bitrix-sync`; +- изменение `Dialog.status` в `han_app`; +- публикацию internal API на edge. + +## 3. Технологический профиль и структура + +- Python 3.12+, FastAPI, Pydantic v2, Uvicorn. +- SQLAlchemy 2 async + `asyncpg`; Alembic. +- Один долгоживущий `httpx.AsyncClient` с bounded pool. +- PostgreSQL managed, только схема `bitrix_local`. +- OpenTelemetry и JSON logging. + +```text +bitrix-local-app/ + app/ + main.py + settings.py + api/{public_bitrix,internal_openlines,health,schemas,errors,auth}.py + application/{install,setup,outbound,inbound,forward,delivery_ack}.py + domain/{entities,enums,policies}.py + infrastructure/ + bitrix/{client,oauth,connector,parser,normalizer}.py + db/{models,repositories,uow}.py + crypto/{token_cipher,keyring}.py + resilience/{retry,circuit,rate_limit}.py + observability/{logging,metrics,tracing}.py + workers/{inbox_forward,outbox_delivery,setup_reconcile}.py + alembic/ + tests/{unit,integration,contract,e2e}/ + openapi.yaml + Dockerfile + docker-compose.yml +``` + +Router только валидирует/аутентифицирует; use case задаёт транзакцию; Bitrix adapter скрывает внешний payload. + +## 4. Публичные endpoint + +Сервис предоставляет следующие endpoint. Корневой nginx публикует первые три; health остаются internal по умолчанию и открываются exact-route только при явно выбранной ops/monitoring policy: + +| Method | Path | Назначение | +|---|---|---| +| GET/POST | `/bitrix/handler` | probe и callbacks `ONAPP*`/`ONIMCONNECTOR*` | +| GET/POST | `/bitrix/install` | install callback/probe | +| GET | `/bitrix/placement` | минимальный HTML placement | +| GET | `/health/live` | liveness; internal по умолчанию | +| GET | `/health/ready` | readiness; internal по умолчанию | + +`GET handler/install` возвращает безопасный `200`, не раскрывая OAuth/setup. POST принимает только bounded body и разрешённые content types. Placement имеет отдельный CSP `frame-ancestors` с точным allow-list Bitrix24. + +Internal `/internal/openlines/v1/*` доступны только по Docker/VPC network и **не маршрутизируются nginx наружу**. + +## 5. Internal Open Lines API + +Все вызовы требуют: + +```text +Authorization: Bearer ${BITRIX_INTERNAL_API_TOKEN} +X-Request-ID: UUID/ULID +traceparent: optional W3C +``` + +Caller `api-backend` передаёт `BITRIX_LOCAL_APP_INTERNAL_TOKEN`, значение которого равно `BITRIX_INTERNAL_API_TOKEN`. Сравнение constant-time. + +### 5.1. `POST /internal/openlines/v1/messages` + +Одна операция — одно сообщение MVP. `Idempotency-Key` обязателен и равен `message_id`. + +```json +{ + "message_id": "uuid", + "external_chat_id": "uuid", + "occurred_at": "2026-07-10T09:00:00Z", + "user": { + "id": "uuid", + "display_name": "Клиент HAN" + }, + "message": { + "content_kind": "text", + "text": "Здравствуйте", + "files": [] + } +} +``` + +Файловый вариант: + +```json +{ + "message_id": "uuid", + "external_chat_id": "uuid", + "occurred_at": "2026-07-10T09:00:00Z", + "user": {"id": "uuid", "display_name": "Клиент HAN"}, + "message": { + "content_kind": "file", + "text": "", + "files": [{ + "attachment_id": "uuid", + "name": "document.pdf", + "mime_type": "application/pdf", + "size_bytes": 12345, + "download_url": "https://short-lived-signed-url" + }] + } +} +``` + +Правила: + +- `external_chat_id` строго UUID и равен App `dialog_id`; +- `text` xor один file; unknown fields запрещены; +- signed URL не сохраняется в обычные логи и редактируется в durable payload по истечении необходимости; +- PII профиля не требуется; телефон/email не передаются; +- fingerprint строится по стабильным полям без signed query; +- тот же key/fingerprint возвращает прежний результат; +- тот же key с иным fingerprint → `409 idempotency_key_reused`. + +Успех `200/201`: + +```json +{ + "status": "delivered", + "message_id": "uuid", + "external_chat_id": "uuid", + "bitrix_message_id": "string-or-null", + "dialog_session": { + "bitrix_chat_id": 1807, + "session_id": "sess-42" + } +} +``` + +`api-backend` выставляет `delivery_status=delivered` только после этого ответа/duplicate result. `202` не считается финальной доставкой в основном синхронном flow. + +### 5.2. `GET /internal/openlines/v1/dialogs/{external_chat_id}` + +Возвращает active mapping: + +```json +{ + "external_chat_id": "uuid", + "bitrix_chat_id": 1807, + "session_id": "sess-42", + "status": "open", + "updated_at": "2026-07-10T09:00:00Z" +} +``` + +`404` — mapping отсутствует/soft-deleted. Пользовательская PII не возвращается. + +### 5.3. `GET /internal/openlines/v1/status` + +Возвращает безопасный статус portal OAuth, connector registration/activation, event bindings, worker backlog и circuit state; токены и raw Bitrix response исключены. `200` может иметь `status=degraded`; `503` — нет usable OAuth/БД. + +### 5.4. `POST /internal/openlines/v1/setup/retry` + +Идемпотентно запускает reconcile `register → activate line 8 → event.bind`. Одновременно разрешён один run по portal advisory lock/DB lease. Ответ содержит per-step status. Endpoint ops-only с тем же Bearer и дополнительным service rate limit. + +## 6. Outbound: HAN → Open Lines + +1. Аутентифицировать caller и зарезервировать `outbound_messages` по `message_id`. +2. При completed вернуть сохранённый sanitized result. +3. Собрать `MESSAGES` Bitrix: `user.id`, `message.id/date/text/files`, `chat.id`. +4. Вызвать `imconnector.send.messages` с `CONNECTOR=han_mobile_app`, `LINE=8`. +5. Извлечь `CHAT_ID`, session `ID`, Bitrix message id из допускаемых вариантов ответа. +6. В одной транзакции upsert `dialog_sessions`, записать result, status `delivered`. +7. Вернуть ack API. + +Ambiguous timeout не разрешает слепой повтор без idempotency/reconciliation. Worker сверяет локальный state/session и повторяет только если метод/Bitrix semantics не создадут дубль; иначе `manual_review`/DLQ. Automatic retry допустим для connect failure до отправки, explicit rate-limit и известных transient ошибок. + +## 7. Install, OAuth и setup + +### 7.1. Install + +POST install/handler с `ONAPPINSTALL`: + +1. parse и strict validate `auth`; +2. проверить expected portal domain `han0107.bitrix24.ru`, HTTPS `client_endpoint`, `member_id`; +3. сохранить tokens до внешнего setup; +4. создать `install_runs`; +5. выполнить setup идемпотентно; +6. вернуть `installed` либо `installed_with_errors`; partial setup не теряет OAuth. + +`ONAPPUNINSTALL` помечает portal installation `uninstalled`, запрещает outbound и планирует revocation/retention. В отличие от прототипа, usable tokens не остаются active. + +### 7.2. Token storage и encryption + +- `access_token`, `refresh_token`, `application_token` шифруются application-level envelope encryption (AES-256-GCM или эквивалент AEAD). +- Master key только secret env/mount: `BITRIX_TOKEN_ENCRYPTION_KEY`; в БД — `ciphertext`, `nonce`, `key_version`. +- AAD связывает ciphertext с `member_id`, portal domain и token type. +- Поддерживается keyring current+previous для rolling rotation и re-encryption job. +- Токены никогда не логируются, не экспортируются в metrics/traces и не возвращаются API. +- DB/TLS и backup encryption остаются дополнительными слоями. + +### 7.3. Refresh + +- refresh заранее, когда `expires_at - now <= skew` (ориентир 60 с); +- single-flight на portal через DB advisory lock/lease; +- POST только на allow-listed `https://oauth.bitrix.info/oauth/token/`; +- refresh token rotation сохраняется атомарно; +- при `expired_token` — максимум один refresh+replay; +- `invalid_grant` переводит installation в `reauth_required`, readiness degraded, outbound fail-closed; +- timeout/retry bounded; secret/client credentials не попадают в exception text. + +### 7.4. Connector setup + +Используемые методы: + +- `imconnector.register`: `ID=han_mobile_app`, name/icon, `{BITRIX_PUBLIC_BASE_URL}/placement`; +- `imconnector.activate`: connector, `LINE=8`, `ACTIVE=1`; +- `event.bind`: `OnImConnectorMessageAdd`, `OnImConnectorDialogStart`, `OnImConnectorDialogFinish`; +- `imconnector.status` для reconcile/readiness; +- `imconnector.send.messages`; +- `imconnector.send.status.delivery`. + +Каждый setup step хранит desired/observed state, attempts и safe error. Повтор не создаёт duplicate binding; если API Bitrix не гарантирует это, сначала проверяется status/list binding. + +## 8. Webhook parsing и безопасность + +Поддерживаются JSON, form-urlencoded, multipart и PHP-style keys/числовые dict. Parser: + +- ограничивает body/header/field count, nesting, array/message count и строковые длины; +- NFKC не применяется к opaque ids/tokens; +- не сохраняет неизвестный raw body без redaction; +- принимает только известные events; прочие безопасно `ignored` с metric; +- проверяет connector `han_mobile_app`, line `8`, expected member/domain; +- проверяет `auth.application_token` constant-time против расшифрованного portal token и/или `BITRIX_APPLICATION_TOKEN`; +- не доверяет IP как единственной аутентификации, но nginx edge limit/allow policy дополняет token; +- всегда отвечает достаточно быстро после durable insert, чтобы Bitrix retry не создал storm. + +Невалидный security token не маскируется как успешная обработка в telemetry: внешний ответ может быть нейтральным, но audit/metric фиксируют reject. Callback secret и payload не логируются. + +## 9. Нормализация и inbox-контракт API + +Owned receiver находится в `api-backend`: + +```text +POST http://api-backend:8000/internal/openlines/v1/inbox +Authorization: Bearer ${BITRIX_API_FORWARD_TOKEN} +``` + +Значение равно `BITRIX_API_INBOX_TOKEN` на API. + +`message.new`: + +```json +{ + "event_id": "stable-opaque", + "event_type": "message.new", + "external_chat_id": "uuid", + "bitrix_message_id": "string", + "occurred_at": "2026-07-10T09:00:00Z", + "message": { + "text": "Ответ оператора или пустая строка", + "files": [{ + "name": "scan.pdf", + "mime_type": "application/pdf", + "size_bytes": 12345, + "download_url": "https://..." + }] + } +} +``` + +`dialog.closed`: + +```json +{ + "event_id": "stable-opaque", + "event_type": "dialog.closed", + "external_chat_id": "uuid", + "bitrix_message_id": null, + "occurred_at": "2026-07-10T09:00:00Z", + "message": null +} +``` + +`event_id` обязателен согласно допущению module-01 A2; предпочтительно используется Bitrix event/message/session id, иначе versioned SHA-256 стабильных полей. `message.new` дополнительно unique по `(external_chat_id, bitrix_message_id)`. + +Пустые text+files отклоняются. URL файла передаётся только API; API защищается от SSRF, скачивает с лимитами и сохраняет в S3-data. Local app не скачивает/не хранит файл. + +## 10. Delivery ack входящего события + +Критический инвариант: + +```text +Bitrix webhook → durable inbox → API 201/duplicate 200/204 +→ только затем imconnector.send.status.delivery +``` + +Ack запрещён при timeout/5xx/неприменённом `404` API. Если API commit успешен, но HTTP response потерян, повтор forward получает duplicate ack, после чего delivery status безопасно отправляется. Ack имеет собственный outbox/retry. Ошибка ack не повторяет application события в API. + +## 11. Inbox, outbox, DLQ и backoff + +### Inbox + +Webhook transaction сохраняет event, normalized payload/fingerprint и initial status. Worker использует `FOR UPDATE SKIP LOCKED`, lease и heartbeat. + +States: + +```text +received → forwarding → api_acked → ack_pending → completed + ↘ retry +received/forwarding/retry → dead_letter +``` + +### Outbound messages + +States: `received | sending | delivered | retry | ambiguous | dead_letter`. Unique `message_id`; payload versioned; signed URLs не должны переживать TTL — при retry API обязан дать актуальный URL по согласованному recovery контракту либо операция уходит в reconciliation. + +### Backoff + +- exponential full jitter, ориентир 1, 2, 4, 8… max 300 с; +- учитывать `Retry-After` Bitrix/API; +- max attempts и max age — infra env; +- permanent 4xx/schema/auth не повторяются автоматически; +- DLQ содержит safe error code, не token/raw PII; +- replay — ops runbook/CLI с audit, не публичный endpoint MVP. + +## 12. PostgreSQL `bitrix_local` + +Общие правила: UUID/timestamptz, schema-qualified DDL, soft delete для прикладных records, технические queue rows архивируются/удаляются по retention. Runtime role `bitrix_local_app`; отдельная migration role. Прямого доступа к `han_app` нет. + +### 12.1. `portal_installations` + +`id`, `member_id` unique, `domain`, `client_endpoint`, encrypted token columns, `expires_at`, `scope`, `key_version`, `install_status`, `setup_status`, `last_refresh_at`, `last_error_code`, common fields. + +Indexes: unique active `member_id`; unique active normalized domain. MVP разрешает только один active expected portal. + +### 12.2. `connector_setup` + +`id`, `portal_id`, `connector_id`, `line_id`, `registered`, `activated`, `bindings_json`, `desired_version`, `observed_at`, `next_retry_at`, `attempt_count`, lease/error fields. Unique `(portal_id, connector_id, line_id)`. + +### 12.3. `dialog_sessions` + +`id`, `external_chat_id uuid`, `bitrix_chat_id bigint NULL`, `session_id varchar NULL`, `portal_id`, `status open|closed`, common fields. + +Indexes: + +- unique active `external_chat_id`; +- index `(bitrix_chat_id) WHERE record_status='A'`; +- index `(session_id)`; +- `(status, updated_at)`. + +Связь с user_id не нужна: идентичность принадлежит App DB. + +### 12.4. `inbox_events` + +`id`, `event_id`, `event_type`, `external_chat_id`, `bitrix_message_id`, `payload_fingerprint`, `normalized_json`, `status`, attempts/next/lease, `api_ack_status`, `delivery_ack_status`, safe error, timestamps. + +Unique `event_id`; unique partial `(external_chat_id, bitrix_message_id)`; worker index `(status,next_attempt_at)`. + +Raw payload хранится только если необходим для forensic, зашифрован/редактирован и с коротким retention; preferred — минимальный normalized payload. + +### 12.5. `outbound_messages` + +`id`, `message_id uuid unique`, `external_chat_id uuid`, `request_fingerprint`, `payload_json`, `status`, `bitrix_message_id`, `response_json`, attempts/lease/error/timestamps. Index worker `(status,next_attempt_at)`. + +### 12.6. `delivery_ack_outbox` + +Unique inbox event; status/attempt/next/lease, minimal Bitrix delivery DTO. Не содержит API token. + +### 12.7. `install_runs` и `audit_events` + +Append-only setup step/results и security/ops actions без tokens/raw payload. BRIN/date indexes при росте. + +## 13. Alembic и транзакции + +- Никакого `CREATE TABLE IF NOT EXISTS` при startup. +- `alembic upgrade head` — отдельный deploy step. +- Expand/migrate/contract, forward-fix; destructive migration только после backup/согласования. +- Smoke upgrade пустой и предыдущей версии. +- Внешний HTTP не выполняется внутри DB transaction. +- Claim → commit lease → external call → finalize under row lock. +- Setup/refresh используют portal-scoped lock. + +## 14. Bitrix rate limits и resilience + +- Ограничить concurrency (начально 2 на portal) и локальный token bucket. +- Разделить quotas setup, outbound, ack/status. +- На Bitrix rate-limit учитывать headers/body code и `Retry-After`. +- Circuit breakers отдельно: OAuth endpoint, portal REST, API forward. +- Timeout: connect 3 с, обычный REST/read 10–15 с, OAuth 10 с; значения infra env. +- 4xx domain/schema не открывает circuit; 429/transient/timeout учитываются по policy. +- Half-open имеет один probe; retry storms предотвращаются jitter/queue concurrency. +- Один `httpx` pool; TLS verify обязателен; redirects для token/REST запрещены либо allow-listed. + +## 15. Health + +`GET /health/live`: только процесс/event loop, `200`. + +`GET /health/ready` с коротким timeout проверяет: + +- PostgreSQL `SELECT 1`, expected Alembic revision; +- usable active portal OAuth либо сообщает `portal_not_installed`; +- connector desired state register+line 8+bindings; +- workers heartbeat/lease; +- backlog age/DLQ thresholds; +- forward URL/token configured; +- circuit state. + +DB/schema failure → `503`. До install сервис может быть `200 degraded` или `503 portal_not_installed` согласно ops policy; для production traffic выбран `503`, liveness остаётся 200. Ответ не делает внешних Bitrix calls на каждый probe — использует свежий cached observed state. + +## 16. Observability + +JSON fields: timestamp, level, `service.name=bitrix-local-app`, module, event, request_id, trace/span id, route, event_type, portal hash/member hash, message/event id hash, attempt, queue age, dependency, status/error code, duration. + +Не логируются OAuth/application/service tokens, Authorization, raw callback, message text, phone/email/name, filenames с PII, file/download URL, response body Bitrix. + +Metrics: + +- HTTP latency/status; +- callback accepted/rejected/duplicate; +- parser variants/errors; +- OAuth refresh success/failure/time-to-expiry; +- connector setup desired/observed; +- outbound success/retry/ambiguous/DLQ; +- inbox depth/oldest age/retry/DLQ; +- API forward and delivery ack; +- Bitrix REST latency/rate-limit/circuit; +- DB pool/lease/readiness. + +IDs не metric labels. Trace context передаётся в API; внешний Bitrix call — child span без token/query. + +## 17. Security + +- TLS boundary — root nginx; internal HTTP только backend network. +- Internal endpoints не edge-routed, Bearer token обязателен. +- Exact host/domain/connector/line allow-list. +- `client_endpoint` из callback валидируется против portal allow-list для защиты SSRF. +- Strict DTO/body limits; parameterized SQL. +- OAuth encryption+key rotation; secrets только env/secret mount. +- Non-root, read-only root fs, tmpfs, dropped capabilities. +- OpenAPI UI off production; committed OpenAPI 3.1 обязателен. +- CORS не нужен; placement не получает secrets. +- Error envelope не раскрывает host/stack/raw dependency response. +- Dependency/image scanning и pinned lock/image. + +## 18. Env + +Канонические из arch-04: + +```text +BITRIX_DATABASE_URL +BITRIX_CLIENT_ID +BITRIX_CLIENT_SECRET +BITRIX_CONNECTOR_ID=han_mobile_app +BITRIX_CONNECTOR_NAME=HAN Mobile App +BITRIX_OPEN_LINE_ID=8 +BITRIX_PUBLIC_BASE_URL=https://tohin.ru/bitrix +BITRIX_APPLICATION_TOKEN +BITRIX_INTERNAL_API_TOKEN +BITRIX_API_FORWARD_URL=http://api-backend:8000/internal/openlines/v1/inbox +BITRIX_API_FORWARD_TOKEN +OTEL_EXPORTER_OTLP_ENDPOINT +APP_ENV +LOG_LEVEL +``` + +Предлагаемые infra env, которые до реализации нужно добавить в arch-04: + +```text +BITRIX_TOKEN_ENCRYPTION_KEY +BITRIX_TOKEN_ENCRYPTION_KEY_VERSION +BITRIX_HTTP_TIMEOUT_SEC=15 +BITRIX_HTTP_MAX_CONCURRENCY=2 +BITRIX_RETRY_MAX_ATTEMPTS=10 +BITRIX_RETRY_MAX_DELAY_SEC=300 +BITRIX_INBOX_RETENTION_DAYS +BITRIX_DLQ_ALERT_AGE_SEC +``` + +Business settings здесь не хранятся. Legacy `BITRIX_SYNC_FORWARD_*` удаляются после migration window и не являются каноническими. + +## 19. Docker и deployment + +- service `bitrix-local-app`, `expose: 8080`, без `ports`; +- networks `backend`,`observability`; root nginx отдельно; +- managed PostgreSQL вне compose, TLS обязательно; +- нет SQLite volume production; +- healthcheck `/health/live`; readiness — orchestration/monitoring; +- migration one-shot job до rollout; +- graceful shutdown: stop claims, finish in-flight до grace, release leases, close pools; +- stateless filesystem. + +Прототипные `deploy/nginx/*`, certbot/SSL scripts и отдельный compose-stack не переносятся: сертификат и routing принадлежат корневому nginx/compose. + +## 20. Что переиспользуется из прототипа + +Концептуально переиспользуются и покрываются новыми тестами: + +- tolerant parser JSON/form/multipart и `auth[...]`; +- преобразование PHP-style `MESSAGES` list/dict; +- разделение client/connector/handler/normalizer/session store; +- setup `register → activate → bind`; +- refresh до expiry и один replay `expired_token`; +- extraction session `CHAT_ID`/`ID`; +- `application_token` и Bearer constant-time compare; +- deterministic idempotency event key как основа fingerprint; +- `dialog_sessions` и enrichment; +- отключение docs production; +- различение Open Lines и CRM sync. + +Обязательно меняется: + +- `/internal/v1/*` → только `/internal/openlines/v1/*`; +- forward envelope → канонический `POST /internal/openlines/v1/inbox`; +- immediate delivery ack до API запрещён; +- single-attempt forward → durable worker/backoff/DLQ; +- plaintext tokens → AEAD encryption/key rotation; +- sync psycopg2/thread lock → async pool/transactions/leases; +- SQLite и DDL-on-start не используются production; +- отдельный nginx/certbot/compose удаляются из production topology; +- `/bitrix-internal/` edge alias не нужен: internal API не публикуется; +- raw payload/error storage/logging минимизируется; +- uninstall деактивирует installation; +- Alembic и OpenAPI 3.1 обязательны. + +## 21. Тестовая матрица + +### Unit + +- parser variants/nesting/limits; +- normalizer message/file/start/finish; +- token encryption/decryption/AAD/rotation; +- fingerprint/idempotency; +- session extraction variants; +- retry classification/backoff/jitter; +- URL/portal validation and redaction. + +### Integration + +- Alembic empty/upgrade; +- concurrent duplicate webhook; +- outbound same/different fingerprint; +- `SKIP LOCKED`, lease expiry, crash recovery; +- refresh single-flight; +- setup reconcile; +- DB constraints/soft delete; +- no DDL at startup. + +### Contract + +- all public/internal schemas in committed OpenAPI; +- tokens and paired names with module-01; +- `message.new`/`dialog.closed` inbox; +- API 201/duplicate before delivery ack; +- request-id/trace propagation; +- Bitrix fixture payloads and response variants. + +### E2E/failure + +- install portal → connector visible on line 8; +- text/file send and mapping; +- operator text/file → API → ack; +- duplicate/reordered callbacks; +- API outage, Bitrix 429/5xx/timeout, OAuth expiry/invalid_grant; +- crash at every checkpoint; +- DLQ/replay; +- circuit half-open; +- logs contain no secrets/PII/URLs. + +## 22. Definition of Done + +- portal/connector/line fixed and validated; +- public and internal paths exactly match arch-02; +- internal API is unreachable from public edge; +- install/OAuth encryption/refresh/setup reconciliation complete; +- outbound idempotency survives crash/ambiguous response; +- inbox retry/DLQ and ack-after-API invariant proven; +- operator text/files and `dialog.closed` contract-tested; +- `bitrix_local` schema, indexes and Alembic migrations tested; +- rate-limit/circuit/timeout/graceful shutdown implemented; +- health/metrics/traces/JSON logs secure; +- OpenAPI 3.1 committed and parity checked; +- root Compose starts non-root container without published port; +- runbooks: reinstall, key rotation, OAuth failure, setup retry, backlog/DLQ, migration/rollback; +- no SQLite, startup DDL or separate production nginx. + +## 23. Решения, допущения и TBD + +**Решения:** + +- B1: canonical internal prefix только `/internal/openlines/v1`. +- B2: delivery ack только после API commit/duplicate ack. +- B3: OAuth tokens шифруются application-level AEAD. +- B4: durable PostgreSQL inbox/outbox/DLQ; Redis не требуется. +- B5: `external_chat_id=dialog_id`; local app не хранит user profile. +- B6: production только managed PostgreSQL + Alembic. + +**Допущения:** + +- A1: один active portal `han0107.bitrix24.ru` в MVP. +- A2: Bitrix fixtures позволят стабильно извлечь event/message/session ids; иначе versioned fingerprint. +- A3: API может повторно выдать актуальный signed file URL при delayed outbound recovery; exact handshake требуется в contract test. + +**TBD:** + +- B-TBD1: точные Bitrix REST quotas/headers и safe retry матрица по официальной документации/portal tests. +- B-TBD2: окончательный outbound DTO user display name и file fields в OpenAPI. +- B-TBD3: exact stable `event_id` для dialog events (согласовать с module-01 TBD-3). +- B-TBD4: retention/RPO/RTO и DLQ replay authorization. +- B-TBD5: encryption key source/rotation runbook до production. +- B-TBD6: точный CSP `frame-ancestors` placement. +- B-TBD7: antivirus policy operator files остаётся у `api-backend`. diff --git a/architectory/module-07-bitrix-sync.md b/architectory/module-07-bitrix-sync.md new file mode 100644 index 0000000..9407e63 --- /dev/null +++ b/architectory/module-07-bitrix-sync.md @@ -0,0 +1,479 @@ +# module-07. Проектная спецификация заглушки `bitrix-sync` + +> Статус: целевая спецификация инфраструктурной заглушки MVP. CRM-синхронизация не реализуется. +> Источники: [`README.md`](README.md), [`arch-00-glossary.md`](arch-00-glossary.md), [`arch-01-system-architecture.md`](arch-01-system-architecture.md), [`arch-02-api-contracts.md`](arch-02-api-contracts.md), [`arch-03-docker-compose-blueprint.md`](arch-03-docker-compose-blueprint.md), [`arch-04-settings-and-content.md`](arch-04-settings-and-content.md), [`arch-05-agent-development-process.md`](arch-05-agent-development-process.md), [`module-01-api-backend.md`](module-01-api-backend.md), [`../../HAN_chat/deploy/init-managed-postgres.py`](../../HAN_chat/deploy/init-managed-postgres.py). + +## 1. Назначение и жёсткая граница + +На текущем этапе сервис доказывает только: + +1. контейнер и FastAPI process стабильно запускаются в общем Compose; +2. сервис подключается к managed PostgreSQL по private network/TLS; +3. при старте и затем примерно раз в 60 секунд выполняется `SELECT 1`; +4. состояние доступно через health и защищённый status endpoint; +5. shutdown корректно останавливает loop и закрывает pool. + +В этой версии **нет**: + +- чтения/обработки `han_app.sync_queue`; +- CRM Contact map/create/update; +- вызовов Bitrix24 REST; +- CRM webhook `/bitrix/sync/webhook/contact`; +- доступа к OAuth `bitrix-local-app`; +- write-back в `han_app`, GUC `han.sync_suppress`; +- DLQ бизнес-задач и field mapping. + +Упоминания полноценного sync в arch-01/02/03 описывают будущую целевую границу, а не функциональность этого stub. Расширение требует новой версии спецификации, migrations/GRANT, OpenAPI и contract tests. + +## 2. Технологический профиль + +- Python 3.12+, FastAPI, Pydantic v2, Uvicorn. +- SQLAlchemy 2 async/`asyncpg` либо прямой `asyncpg` pool; выбран SQLAlchemy async для единообразия с backend. +- Managed PostgreSQL; схема/role `bitrix_sync`. +- Один in-process periodic loop на replica. +- OpenTelemetry, JSON logging, pytest/anyio. + +```text +bitrix-sync/ + app/ + main.py + settings.py + api/{health,status,auth,errors,schemas}.py + application/{db_probe,periodic_loop,state}.py + infrastructure/{database,observability}.py + tests/{unit,integration,contract}/ + openapi.yaml + Dockerfile + docker-compose.yml +``` + +## 3. Runtime model + +FastAPI lifespan: + +```text +validate env +configure logs/OTEL +create bounded DB engine/pool +run initial probe with startup timeout +publish initial state +start exactly one periodic task +serve HTTP +on shutdown: signal stop → await/cancel sleep → finish bounded probe + → close pool/OTEL → exit +``` + +HTTP process и loop разделяют thread-safe/async-safe immutable state snapshot. Router не выполняет probe для каждого status request. + +## 4. Periodic loop + +### 4.1. Период + +Целевой интервал — примерно 60 секунд: + +```text +BITRIX_SYNC_DB_CHECK_INTERVAL_SEC=60 +``` + +Следующий запуск планируется от завершения предыдущего (`fixed-delay`), а не запускается параллельно. Добавляется jitter, например ±10%, чтобы несколько replicas не синхронизировались. + +### 4.2. Initial check + +Первый `SELECT 1` выполняется при startup до перехода в ready. Ошибка initial check не обязана завершать process: сервис остаётся live/not-ready и продолжает reconnect loop. Это позволяет восстановиться после временной недоступности managed PG без restart storm. + +Невалидный env/DSN/TLS policy, напротив, является configuration error: process fail-fast. + +### 4.3. Probe + +Каждая проверка: + +1. получает connection из pool с bounded acquire timeout; +2. выполняет параметризованный/constant `SELECT 1`; +3. проверяет результат `1`; +4. фиксирует monotonic duration и wall-clock UTC completion; +5. возвращает connection; +6. атомарно обновляет state. + +Никаких table scans, DDL, schema writes и создания business rows. + +### 4.4. Timeout + +Общий probe timeout включает pool acquire + query: + +```text +BITRIX_SYNC_DB_CHECK_TIMEOUT_SEC=5 +``` + +На PostgreSQL задаются `connect_timeout`, `command_timeout`/`statement_timeout`. Timeout помечает check failed, отменяет query и гарантированно освобождает/инвалидирует connection. + +### 4.5. Backoff + +При успехе — обычный interval+jitter. При последовательных ошибках: + +```text +delay = min(base * 2^(failures-1), max_backoff) + full_jitter +``` + +Ориентиры: base 5 с, max 60 с. Успех сбрасывает failure counter. Backoff не создаёт tight loop и не превышает readiness stale policy без явного статуса. + +### 4.6. Prevention overlap + +Одна task выполняет `await probe(); await sleep()`, поэтому overlap конструктивно невозможен. Дополнительно `asyncio.Lock`/single-flight защищает ручной internal trigger, если он когда-либо появится. В MVP trigger endpoint отсутствует. + +При нескольких replicas каждая проверяет БД независимо; distributed lock не нужен, потому что `SELECT 1` безопасен и не является worker job. + +## 5. Connection pool + +Начальная конфигурация минимальна: + +- pool size 1–2; +- max overflow 0; +- `pool_pre_ping=true` допустим, но не заменяет explicit probe; +- pool recycle меньше сетевого idle timeout провайдера; +- short acquire/connect/query timeout; +- TLS verify (`sslmode=verify-full` или эквивалент) с CA; +- `application_name=han-bitrix-sync`; +- search path только `bitrix_sync`. + +Pool создаётся один раз и закрывается shutdown. Connection после network/protocol error invalidated. Пароль/DSN не логируются. + +## 6. Enabled/disabled semantics + +Сохраняется канонический `BITRIX_SYNC_ENABLED`. + +### `true` + +Для stub это означает: process запускает DB connectivity loop. Это **не** означает включённую CRM-синхронизацию. Status явно возвращает `mode=db_connectivity_stub`. + +### `false` + +- process и HTTP endpoint запускаются; +- DB pool можно не создавать, periodic loop не запускается; +- `/health/live` → `200`; +- `/health/ready` → `503` с `reason=sync_disabled`, как зафиксировано arch-04; +- internal status → `200`, `enabled=false`, `state=disabled`; +- CRM-функций всё равно нет. + +Таким образом, disabled — явный no-op, а не скрытый success readiness. + +## 7. HTTP API + +### 7.1. `GET /health/live` + +Без auth внутри Docker network. Не обращается к БД. + +```json +{"status":"live"} +``` + +`200`, пока process/event loop обслуживает запросы. + +### 7.2. `GET /health/ready` + +Не выполняет новый DB query; читает snapshot. + +`200`: + +```json +{ + "status": "ready", + "mode": "db_connectivity_stub", + "database": { + "status": "ok", + "last_success_at": "2026-07-10T09:00:00Z", + "age_seconds": 12 + } +} +``` + +`503`: + +```json +{ + "status": "not_ready", + "reason": "database_unavailable", + "database": { + "status": "down", + "last_success_at": null, + "consecutive_failures": 3 + } +} +``` + +Ready только если enabled, initial success был и последний success не старше: + +```text +max(2 * interval + jitter budget, BITRIX_SYNC_READY_MAX_STALENESS_SEC) +``` + +Рекомендуемый default staleness 150 с. Error detail не содержит host/DSN. + +### 7.3. `GET /internal/sync/v1/status` + +Защита: + +```text +Authorization: Bearer ${BITRIX_SYNC_SERVICE_TOKEN} +``` + +`X-Service-Token` можно поддержать только как migration compatibility; канонический вариант этого модуля — Bearer. Endpoint internal-only, edge не публикует. + +```json +{ + "service": "bitrix-sync", + "enabled": true, + "mode": "db_connectivity_stub", + "crm_sync_implemented": false, + "state": "healthy", + "started_at": "2026-07-10T08:00:00Z", + "last_check": { + "started_at": "2026-07-10T09:00:00Z", + "finished_at": "2026-07-10T09:00:00Z", + "success": true, + "duration_ms": 7, + "error_code": null + }, + "last_success_at": "2026-07-10T09:00:00Z", + "consecutive_failures": 0, + "next_check_in_seconds": 48 +} +``` + +Не возвращаются queue depth/dead letters, поскольку сервис их не читает. Поля, обещающие CRM run, не симулируются. + +Invalid token → `401 service_unauthorized`, constant-time compare. Status endpoint не запускает probe. + +## 8. State machine + +```text +starting + ├─ disabled → disabled + ├─ initial success → healthy + └─ initial failure → degraded +healthy + ├─ one/more failures → degraded + └─ shutdown → stopping +degraded + ├─ success → healthy + └─ shutdown → stopping +``` + +Snapshot содержит start/check timestamps, last success/failure, consecutive failures, duration и safe error code: `db_connect_timeout`, `db_query_timeout`, `db_auth_failed`, `db_tls_failed`, `db_unavailable`, `unexpected_result`. + +Auth/TLS/config ошибки могут быть classified non-transient и alertятся немедленно, но loop продолжает с max backoff, если env был syntactically valid. + +## 9. PostgreSQL и права + +Managed init уже создаёт: + +- schema `bitrix_sync`; +- role `bitrix_sync_user`; +- search path `bitrix_sync`; +- отсутствие доступа к чужим схемам. + +Для stub достаточно `CONNECT` к database и возможности `SELECT 1`; `USAGE` на `bitrix_sync` допустим для будущих migration/version checks. Таблицы не нужны. Alembic может иметь пустую baseline revision, чтобы зафиксировать ownership/version, но runtime не выполняет DDL. + +К `han_app` **не выдаются GRANT** до реализации полноценной CRM sync. Это сознательно строже общего будущего требования. Когда появится sync: + +- GRANT выдаётся точечно на `sync_queue`, mapping и необходимые columns; +- запрещён broad schema write; +- GUC/write-back и trigger contract проходят integration tests; +- обновляются deploy scripts и module spec. + +`BITRIX_SYNC_APP_DATABASE_URL` из arch-04 в stub не требуется. Канонический runtime DSN stub — `BITRIX_SYNC_DATABASE_URL` с search path `bitrix_sync`. + +## 10. Env + +Уже канонические: + +```text +APP_ENV=production-like +LOG_LEVEL=INFO +BITRIX_SYNC_ENABLED=true +BITRIX_SYNC_DATABASE_URL=postgresql://bitrix_sync_user:.../han_chat?options=-csearch_path%3Dbitrix_sync +BITRIX_SYNC_SERVICE_TOKEN= +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 — отдельная будущая спецификация. diff --git a/architectory/module-08-keycloak.md b/architectory/module-08-keycloak.md new file mode 100644 index 0000000..af0de74 --- /dev/null +++ b/architectory/module-08-keycloak.md @@ -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 + +``` + +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= +``` + +Правила: + +- mock разрешён MVP production-like только как явно принятый риск; +- пустой/default `1234` запрещён startup policy для production-like, если не согласован secret; +- code не входит в realm import, frontend config, HTML hint, API response, logs, metrics, traces или audit; +- сравнение constant-time; +- challenge всё равно имеет TTL, max verify attempts и counters, чтобы flow был близок production; +- code не сохраняется per-user в открытом виде; +- UI сообщает только «тестовый режим», без кода; +- `KEYCLOAK_OTP_MOCK_ENABLED=false` при отсутствии configured provider делает OTP flow fail-closed/not-ready, а не пропускает проверку. + +Реальный provider interface: + +```java +interface OtpDeliveryProvider { + DeliveryResult send(E164Phone phone, String otp, Duration ttl, Correlation ctx); +} +``` + +Будущий provider обязан вернуть `provider_message_id`; raw OTP не логируется. Выбор provider, template, sender, delivery status webhook и vendor credentials — TBD. + +## 8. OTP challenge и counters + +Даже в mock: + +- challenge id random ≥128 bit; +- OTP не хранится raw; production-generated code — keyed hash/HMAC с challenge salt/pepper; +- TTL (предлагается 5 минут) — technical security parameter; +- one-time use; success atomically consumes challenge; +- max verification attempts per challenge; +- resend invalidates либо version-binds предыдущий challenge; +- replay/parallel verify безопасны; +- destination stored masked/hash where possible. + +Audit fields по arch-05: provider message id (для mock — synthetic non-secret), sent_at, destination_masked, otp_hash/reference, attempts, outcome. Никогда raw code. + +### 8.1. Product send limits bridge + +SPI вызывает: + +```text +GET http://api-backend:8000/internal/settings/v1/otp +Authorization: Bearer ${KEYCLOAK_SETTINGS_BRIDGE_TOKEN} +``` + +Ответ: + +```json +{ + "max_send_attempts_per_24h": 3, + "min_seconds_between_attempts": 30, + "version": "2026-07-10T08:00:00Z", + "cache_ttl_seconds": 60 +} +``` + +Это единственный путь к `otp.phone.*`; Keycloak не получает GRANT на `han_app`. SPI поддерживает ETag/cache, single-flight refresh. Bridge down: + +- использовать last-known-good до bounded max stale; +- если cache пуст/слишком стар — fail-closed для send; +- verify уже выданного challenge может продолжаться по snapshot, с которым challenge создан. + +Token name точно `KEYCLOAK_SETTINGS_BRIDGE_TOKEN`, endpoint точно `/internal/settings/v1/otp`. + +### 8.2. Где хранятся counters + +Решение MVP: counters/challenges хранятся в Keycloak-owned PostgreSQL tables/provider storage в схеме `keycloak`, а не в Redis API и не в `han_app`. + +Причины: + +- durable across restart; +- одна transaction для reserve/send-attempt/consume; +- не добавляет Keycloak credentials к общему Redis; +- соответствует границе «счётчики в зоне Keycloak/SPI». + +Используются phone HMAC, не E.164 в key/index для rate data. Tables provider-owned создаются versioned migration provider-а, не ручным DDL-on-start. + +Минимальные records: + +- `han_otp_challenge`: id, phone_hmac, otp_hash/mock marker, created/expires/consumed, verify attempts, settings version, provider id/status; +- `han_otp_send_counter`: phone_hmac, window_start, count, last_sent_at; +- `han_otp_security_event`: append-only minimal outcome/retention. + +Indexes: unique active challenge policy, `(phone_hmac,window_start)`, `(expires_at)`. Cleanup bounded job. Доступ только `keycloak_user`. + +## 9. Brute-force и abuse + +Слои: + +1. nginx `/auth` IP rate limit (`NGINX_RATE_LIMIT_AUTH`); +2. Keycloak realm brute-force detection; +3. SPI product send limits per phone HMAC; +4. verify-attempt limit per challenge/phone/IP hash; +5. cooldown after repeated failures; +6. CAPTCHA/risk engine — future extension. + +Realm включает brute-force protection с temporary lockout и bounded wait. Permanent lockout для consumer phone login без recovery runbook нежелателен. Error messages не различают unknown phone/wrong code/locked account сверх безопасной UX причины. `Retry-After`/remaining time выдаётся только если не помогает enumeration. + +IP берётся только из trusted proxy chain; Keycloak настроен доверять forwarded headers от root nginx. + +## 10. Claims и token contract + +Access token минимум: + +| Claim | Значение | +|---|---| +| `iss` | `https://tohin.ru/auth/realms/han-chat` | +| `sub` | immutable Keycloak user id | +| `aud` | включает `han-chat-api` | +| `azp` | `han-chat-frontend` | +| `exp`, `iat`, `nbf` | стандартные | +| `sid` | session id, если поддерживается | +| `auth_time` | время auth | +| `acr`/`amr` | отражает phone OTP | +| `phone_number` | canonical E.164 | +| `phone_number_verified` | `true` | +| `scope` | only allowed scopes | + +`preferred_username` может совпадать с phone для compatibility, но канонический claim API — `phone_number`; module-01 допускает fallback только если E.164. + +ID token предназначен client login state; API принимает access token, не ID token. Refresh token непрозрачен для приложения и хранится frontend secure storage. + +PII minimization: full phone нужен API bootstrap по зафиксированному контракту, но не добавляется в service tokens/metrics/logs. Roles/groups выдаются только если используются authorization policy. + +## 11. Signing keys, JWKS и rotation + +- asymmetric signing, RS256 MVP; `none`/HS algorithms запрещены; +- active signing key + passive previous keys до истечения всех выпущенных tokens/grace; +- keys генерируются/хранятся Keycloak, private material не в realm export/repo; +- JWKS публичен через issuer; +- rotation rehearsed; `kid` меняется, API controlled-refresh cache; +- emergency compromise: disable key, revoke sessions, force re-login, alert/runbook; +- backup/restore учитывает realm keys. + +Rotation interval и HSM/keystore — ops TBD. Изменение algorithm требует совместного rollout API verifier. + +## 12. Token и session lifecycle + +Предлагаемые MVP значения, окончательно принять security/product review: + +- access token lifespan: 5 минут; +- SSO session idle: 30 дней; +- SSO session max: 90 дней; +- refresh token следует session limits; +- authorization code: 1 минута; +- login action: 5 минут; +- client session idle/max согласованы с SSO; +- clock skew минимальный. + +Refresh: + +- revoke refresh token on use / refresh token rotation включены; +- max reuse `0` или минимально поддерживаемое значение; +- frontend применяет single-flight, поэтому parallel refresh не требуется; +- reuse старого refresh token → `invalid_grant`, возможная session revocation/security event; +- offline tokens не выдаются. + +Access token не хранится server-side и живёт до exp; критическая блокировка пользователя сопровождается logout/revocation/not-before policy. + +## 13. Logout, revocation и browser cookies + +Frontend вызывает OIDC end-session/logout с valid post-logout redirect, затем всегда очищает local tokens. Back-channel logout можно включить для clients, которые его поддержат; API JWT hot path не хранит browser session. + +Cookies Keycloak: + +- `Secure`, `HttpOnly`; +- SameSite согласно redirect/iframe requirements, по умолчанию `Lax`; +- domain/path минимальны (`/auth`/host); +- third-party cookie dependency не закладывается; +- session fixation предотвращается Keycloak; +- admin console cookies не расширяются на frontend origins. + +Front-channel iframe checks не должны заставлять ослабить CSP всего сайта. Native logout использует system browser и app-link/custom scheme validation. + +## 14. CORS, origins и redirects + +- exact `Web Origins`: `https://tohin.ru`; +- no wildcard `*` with credentials; +- native apps не получают произвольные web origins; +- valid redirects exact/safely scoped; +- redirect URI comparison не допускает open redirect; +- post-logout redirects отдельно allow-listed; +- nginx и Keycloak CORS не должны дублировать противоречащие headers; +- token endpoint используется PKCE client без client secret; +- admin endpoints не CORS-доступны приложению. + +Любой новый environment имеет отдельный host/client config, а не production wildcard. + +## 15. Reverse proxy и hostname + +Ключевые настройки (точные CLI names проверяются по закреплённой версии): + +```text +KC_HTTP_ENABLED=true +KC_HTTP_PORT=8080 +KC_PROXY_HEADERS=xforwarded +KC_HOSTNAME=https://tohin.ru/auth +KC_HTTP_RELATIVE_PATH=/auth +KC_HOSTNAME_STRICT=true +KC_HOSTNAME_STRICT_HTTPS=true +``` + +Если выбран другой поддержанный pattern (`hostname` без path + relative path), итоговые issuer/endpoints обязаны совпасть с `KEYCLOAK_PUBLIC_URL`. + +Nginx передаёт trusted `Host`, `X-Forwarded-Proto=https`, `X-Forwarded-Host`, `X-Forwarded-Port=443`, real IP. Keycloak не доступен напрямую с host/public network, поэтому spoofed forwarded headers не принимаются извне. + +Admin hostname/path рекомендуется ограничить ops network/VPN; публично нужны только realm/OIDC/login assets. Если разделить admin hostname невозможно MVP, admin console защищается network allow-list и сильным admin auth. + +## 16. PostgreSQL `keycloak` + +Используется: + +```text +KEYCLOAK_DB_URL=jdbc:postgresql://.../han_chat?...¤tSchema=keycloak +KC_DB_URL_PROPERTIES=currentSchema=keycloak +``` + +Role `keycloak_user` имеет доступ только к schema `keycloak`; нет доступа `han_app`, `bitrix_*`, `message_safety`. Connection только private VPC + TLS verify. + +Pool: + +- bounded initial/min/max; +- acquisition/query/connect timeout; +- leak detection/metrics; +- pool max определяется load test и managed PG limit; +- `application_name=keycloak`. + +Keycloak управляет своей стандартной schema migration. Custom provider tables имеют отдельную versioned migration strategy, совместимую с startup/rolling upgrade; DDL не выполняется бесконтрольно каждым replica. + +Нельзя редактировать стандартные Keycloak tables вручную или Alembic-миграциями Python-сервисов. + +## 17. Admin bootstrap и realm import + +### 17.1. Bootstrap admin + +- `KC_BOOTSTRAP_ADMIN_USERNAME`/password или актуальный bootstrap mechanism только на первом запуске; +- password генерируется strong secret, не коммитится и после bootstrap ротируется/удаляется из runtime env; +- admin user не используется приложением; +- отдельные named admin accounts/least privilege для ops; +- MFA для admin обязательно до production, независимо от consumer phone flow; +- admin events audit включён. + +### 17.2. Realm import + +Репозиторий: + +```text +keycloak/ + realm/han-chat-realm.json.template + providers/han-phone-otp-provider.jar + themes/han-phone/ + migrations/ + scripts/{render-realm,validate-realm,export-realm}.sh + tests/ + Dockerfile + docker-compose.yml +``` + +Export/template содержит realm/client/flow/scopes/policies, но не: + +- client/admin/provider secrets; +- mock code; +- private signing keys; +- environment-specific production credentials. + +Import автоматически допустим для clean local/test. Production changes применяются controlled declarative job/Admin API procedure с diff/backup, не `--import-realm` поверх живого realm без проверки. Drift detection сравнивает безопасный desired subset. + +## 18. Settings и secrets + +Канонические: + +```text +KEYCLOAK_PUBLIC_URL=https://tohin.ru/auth +KEYCLOAK_INTERNAL_URL=http://keycloak:8080 +KEYCLOAK_REALM=han-chat +KEYCLOAK_AUDIENCE=han-chat-api +KEYCLOAK_DB_URL=jdbc:postgresql://... +KC_DB_URL_PROPERTIES=currentSchema=keycloak +KEYCLOAK_OTP_MOCK_ENABLED=true +KEYCLOAK_OTP_MOCK_CODE= +KEYCLOAK_SETTINGS_BRIDGE_TOKEN= +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= +``` + +## 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 до реализации. diff --git a/architectory/module-09-observability.md b/architectory/module-09-observability.md new file mode 100644 index 0000000..821a4ed --- /dev/null +++ b/architectory/module-09-observability.md @@ -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=`; +- `deployment.environment=production-like|production`; +- `host.name`/`service.instance.id` без публичного IP. + +Обязательные поля request-события: + +- `request_id`; +- `trace_id`, `span_id`; +- `ux_session_id` — nullable, только когда передан; +- `service.name`; +- `environment` либо canonical `deployment.environment`. + +`request_id` формирует/валидирует nginx; сервис возвращает его клиенту и передаёт downstream. `trace_id` берётся из active span. `ux_session_id` не является auth и не должен использоваться как metric label. + +## 5. W3C propagation + +- принимаются только валидные `traceparent` и опциональный `tracestate`; +- nginx передаёт context в API; при edge instrumentation создаёт server span; +- API создаёт child spans для PostgreSQL, Redis, S3, Safety, Open Lines и JWKS; +- internal calls передают `traceparent`, `tracestate`, `X-Request-ID`; +- `baggage` по умолчанию не принимается от внешнего клиента; если включён, allow-list исключает PII; +- Bitrix24/S3 могут не вернуть context: внешний client span всё равно закрывается результатом; +- async outbox/inbox связывается span link с исходным trace; новый worker trace не притворяется продолжением спустя долгий срок. + +Frontend может отправлять валидный `traceparent`, но backend не доверяет его sampling/security атрибутам. + +## 6. JSON stdout contract + +Одна JSON-запись на строку UTF-8: + +```json +{ + "timestamp": "2026-07-10T09:00:00.123Z", + "level": "INFO", + "service.name": "api-backend", + "service.version": "git-abcdef0", + "environment": "production-like", + "module": "message_service", + "event": "message.delivery.completed", + "message": "Message delivery completed", + "request_id": "01J...", + "trace_id": "32hex", + "span_id": "16hex", + "ux_session_id": "uuid-or-null", + "route": "/api/v1/dialogs/{dialog_id}/messages", + "method": "POST", + "status_code": 201, + "duration_ms": 742, + "dependency": "bitrix-local-app", + "outcome": "success", + "error_code": null +} +``` + +Правила: + +- `event` — стабильная mnemonic, `message` — безопасное описание; +- route — template, никогда raw URI с id/query; +- stack trace допускается только в internal error log после redaction; +- message text, callback body, SQL values и file content запрещены; +- Docker driver ограничен `50m × 5`, но это buffer, не retention backend; +- multiline stack trace сериализуется полем JSON, не отдельными строками. + +## 7. Redaction и data minimization + +Удаляются или маскируются: + +- `Authorization`, Cookie, Set-Cookie, JWT, OAuth/code/refresh/access tokens; +- raw OTP/mock code, Keycloak admin/client password; +- phone/email/full name, device id, IP по policy (допустим HMAC/truncated); +- message text, filenames с PII, document/file contents; +- DSN/password, Redis URL, S3 keys; +- presigned URL и любая query string; +- Bitrix raw payload/download URL/application token; +- `db.statement` с literals; предпочтительно operation/table, не SQL. + +Redaction выполняется в SDK/logger **до stdout**, затем повторяется Collector processor. Collector не может считаться единственной защитой. Автотесты отправляют canary secrets/PII и требуют отсутствие во всех трёх сигналах. + +## 8. Instrumentation по компонентам + +### 8.1. FastAPI-сервисы + +- OpenTelemetry ASGI/FastAPI server spans с route template; +- HTTPX client spans с sanitized host/method/status; +- SQLAlchemy/asyncpg spans без параметров и raw statement; +- Redis instrumentation с command name и DB index, без key/value; +- boto/S3 spans: operation/bucket logical name, без object key/query; +- background workers: span на claim/process/finalize, links на origin; +- исключить `/health/live` из traces; readiness оставить в metrics и sampled logs. + +### 8.2. PostgreSQL + +Собираются pool wait/checked-out, transaction duration, error class, migrations revision, managed PG provider metrics (CPU, storage, connections, locks, replication/PITR state). `user_id`, SQL text и row data не labels. + +### 8.3. Redis + +`redis_exporter` подключается отдельным ACL user только на `INFO`, `PING`, безопасные latency/keyspace metrics. Нужны memory ratio, evictions, expirations, blocked/rejected clients, command latency, AOF status/rewrite, Pub/Sub buffers, key count/TTL агрегаты. Keys/values не экспортируются. + +### 8.4. Keycloak + +Включаются management metrics/JVM/HTTP/DB pool. Custom OTP provider публикует counters send/verify/limit/settings-cache без phone labels. Login events идут в JSON/audit с masked/HMAC destination. Public OIDC synthetic проверяется отдельно. + +### 8.5. nginx + +JSON access log содержит `request_id`, извлечённый `trace_id`, route class, method, normalized path, status, bytes, request/upstream duration/status, TLS version, cache status. `$request` с query не используется. + +Collector `filelog` parser: + +- разбирает JSON, timestamp и severity; +- переносит `service.name=nginx`; +- превращает пустые/`-` в null; +- route class нормализует в bounded set; +- отбрасывает ACME/health success noise; +- не парсит raw URI в labels. + +Native nginx OTEL module предпочтителен, если image/version закреплены. Без него nginx только передаёт W3C context и коррелирует access log; первый server span создаёт API. + +### 8.6. Host/Docker + +CPU, load, memory/swap, disk usage/inodes/IO, network, container restarts/OOM, Docker daemon health и clock sync. Container name/version — bounded labels; container id не хранится как долгосрочный high-cardinality label. + +## 9. Метрики бизнес-потоков + +### API и auth + +- `han_http_requests_total{service,route,method,status_class}`; +- `han_http_request_duration_seconds`; +- `han_auth_bootstrap_total{outcome}`; +- `han_ux_session_start_total{reason}`; +- `han_jwks_refresh_total{outcome}`; +- `han_rate_limit_decisions_total{scope,outcome}`. + +### Message Safety + +- checks/verdicts по `allow|deny|pending|error`; +- poll duration/count buckets, timeout и recovery backlog age; +- stub mode info и terminal `400` отдельно, пока действует test-only контракт; +- cache hit, Redis latency, task expired/not-found. + +### Open Lines/Bitrix + +- message submitted → delivered end-to-end latency; +- local app outbound result/retry/ambiguous/DLQ; +- inbox depth/oldest age/forward retries/duplicate; +- OAuth time-to-expiry/refresh result; +- connector desired/observed state; +- Bitrix 429, circuit state, setup failure. + +### Files/S3 + +- init/complete/promote/delete; +- quarantine object age/orphans; +- checksum/MIME/size reject; +- presigned download issued; +- S3 dependency latency/error by operation and logical bucket. + +### Frontend synthetic + +- public config/content; +- OIDC discovery/authorization page; +- WS handshake; +- test-user end-to-end flow в отдельной тестовой identity без реального PII. + +Никакие UUID/user/session/dialog/task/message id не labels. Они допустимы только в sampled logs/traces при принятой retention. + +## 10. Dashboards + +1. **Executive/SLO**: availability, error budget burn, p50/p95/p99, message delivery, auth, active incidents. +2. **nginx edge**: RPS, 4xx/5xx, upstream latency/status, 429, WS, TLS, cache. +3. **api-backend**: routes, DB/Redis pools, JWKS, circuits, outbox/safety backlog, S3. +4. **message-safety**: verdicts, pending/poll, task TTL, Redis, distribution stub outcomes. +5. **bitrix-local-app**: install/OAuth, connector, outbound/inbox/DLQ, API/Bitrix latency. +6. **bitrix-sync**: mode, DB probe, last success/staleness; нельзя показывать CRM sync как рабочий в stub. +7. **Keycloak**: login/OTP/lockout, sessions/tokens, provider settings cache, JVM/DB. +8. **Redis**: memory/evictions/AOF/latency/clients/keyspace. +9. **PostgreSQL/S3**: provider metrics, storage, connections, backup/PITR, object errors. +10. **Business flow**: guest config → OTP → bootstrap → session → dialog → safety → Open Lines → operator reply. +11. **Collector health**: accepted/sent/refused/dropped, queue, retry, exporter errors, memory/CPU. + +Каждая панель содержит release annotation, environment filter и links trace→logs по `trace_id`. + +## 11. SLI, SLO и alerts + +Значения — начальная production-like политика до load/product review: + +| SLI | Initial SLO, 30 дней | +|---|---| +| HTTPS edge availability | 99.9% | +| public config/content successful requests | 99.9% | +| protected read API successful requests | 99.5% | +| Keycloak login flow availability | 99.5% | +| text message accepted и доставлен в Open Lines | 99.0% | +| operator inbox applied без permanent loss | 99.5% | +| p95 protected read API | < 750 ms | +| p95 text send без внешнего rate limit | < 5 s | +| telemetry Collector ingest availability | 99.0%, не входит в business availability | + +Файловый send измеряется отдельно: p95 не должен превышать configured safety poll budget; user-cancel, safety deny, 4xx validation и edge abuse 429 не считаются server failure. 503/504 и unexpected 5xx считаются. + +### Paging alerts + +- multi-window burn: 14.4× за 5m/1h или 6× за 30m/6h; +- edge/API 5xx >5% 5 минут; +- text delivery failure >5% 10 минут; +- oldest outbox/inbox/safety task >5 минут либо DLQ >0; +- Keycloak login failures infrastructure class >10% 5 минут; +- PostgreSQL unavailable/connection saturation >90%; +- Redis unavailable, AOF error или sustained evictions; +- Collector exporter queue >80%, dropped/refused telemetry >0 sustained; +- TLS expiry <14 дней warning, <7 дней page; +- disk >85% warning, >92% page; OOM/restart loop; +- managed PG backup/PITR failure. + +### Ticket/warning alerts + +- p95 regression 20% release-over-release; +- settings/JWKS cache stale; +- bitrix-sync stub probe stale >150s; +- OAuth expires <24h без успешного refresh; +- quarantine orphan growth; +- cardinality/ingest growth >2× baseline. + +Alert содержит service, environment, symptom, dashboard, runbook, release и безопасный query; не содержит PII. + +## 12. Sampling, cardinality и retention + +### Traces + +- errors/5xx, circuit, timeout, DLQ, safety final deny и slow requests — 100%; +- обычные успешные requests — 5–10%; +- health/ACME success — 0%; +- tail sampling предпочтителен в Collector, но head sample SDK должен оставлять достаточно данных; +- sampling decision передаётся W3C. + +### Metrics + +Allow-list labels; route template вместо raw path; status class/known code; dependency enum. Cardinality budget: целевой <10 000 active series на MVP environment. CI проверяет запрещённые labels. + +### Retention initial + +- metrics: 30 дней high resolution, 13 месяцев downsampled при доступности backend; +- traces: 7 дней, errors 14 дней; +- technical logs: 14 дней, security/auth logs 30 дней; +- audit `han_app`: 365 дней **только как временное допущение до legal policy**; +- raw Bitrix callback не хранится в telemetry; +- local Docker logs: не более 250 МБ/container и 5 файлов. + +Legal retention/erasure имеет приоритет; изменение требует обновления policy и backup lifecycle. + +## 13. Collector health и отказоустойчивость + +Контролируются: + +- `/health` extension; +- process CPU/RSS/restarts; +- accepted/refused/sent/failed spans, points, records; +- batch send size/latency; +- exporter queue capacity/size, enqueue failures, retry age; +- file storage usage/corruption; +- scrape failures; +- config reload/validation. + +При remote outage queue хранится на `otel-queue` с bounded size/age. При заполнении отбрасываются сначала low-priority success traces/logs; приложение продолжает работу. Нельзя позволять queue заполнить системный диск. + +## 14. Docker Compose + +`otel-collector`: + +- pinned contrib image; +- networks: только `observability`, а для scrape internal targets — минимально необходимая `backend`; +- `expose`: 4317, 4318, 13133, 8888/8889; +- без host ports; +- config read-only, `otel-queue` volume rw; +- non-root, read-only rootfs, tmpfs `/tmp`, drop capabilities, no-new-privileges; +- initial limit: 0.5 CPU/512 MiB, queue disk 5–10 ГБ; уточнить load test; +- healthcheck extension; +- restart policy с backoff; +- приложения имеют bounded non-blocking OTLP exporter queue. + +Доступ к Docker socket запрещён. Для container metrics используется безопасный exporter/hostmetrics, а не unrestricted socket mount. + +## 15. Security + +- OTLP receiver internal-only; при переходе между hosts — mTLS; +- remote exporter только TLS verify, credentials least privilege; +- Grafana/Prometheus/Loki/Tempo не публичны; +- RBAC: viewer/operator/admin; audit доступа к logs/traces; +- dashboards не показывают PII; +- config/secret permissions 0400/0600; +- dependency/image scan и SBOM; +- защита от log injection: JSON encoding, control chars, bounded field lengths; +- telemetry input не исполняет expressions из пользовательских значений; +- регулярная secret-canary проверка и incident deletion procedure. + +## 16. Runbooks + +### Collector not-ready + +1. `docker compose ps otel-collector` и bounded logs. +2. Проверить config validation, memory/OOM, queue volume. +3. Проверить DNS/TLS/auth remote exporter. +4. Не рестартовать бесконечно при полной queue; сначала освободить/расширить безопасно. +5. Бизнес-сервисы оставить работающими; подтвердить local JSON logs. +6. После восстановления проверить drain и gap. + +### Telemetry отсутствует у одного сервиса + +1. Проверить `service.name`, endpoint/protocol и сеть `observability`. +2. Проверить SDK queue/drop counters и clock. +3. Отправить synthetic request с `X-Request-ID`. +4. Найти его в stdout, Collector accepted и backend. +5. Проверить sampling/filter/redaction rules. + +### Remote backend outage + +1. Подтвердить exporter errors, а не application outage. +2. Оценить queue fill rate/time-to-full. +3. Ограничить debug exporter; не включать verbose. +4. При длительном outage увеличить sampling только через reviewed config. +5. После восстановления подтвердить drain и создать incident note о потере данных. + +### Cardinality/ingest spike + +1. Найти новое metric/log attribute по release annotation. +2. Отключить offending instrument/filter в Collector. +3. Проверить raw path/id/user/session labels. +4. Rollback instrumentation при риске стоимости/доступности. +5. Добавить CI regression test. + +### Высокая latency сообщения + +1. Открыть trace по request id. +2. Разделить API, Safety poll, S3, local app, Bitrix. +3. Проверить circuit, queue age, DB pool и Redis. +4. Не повторять ambiguous message без исходного idempotency key. +5. Следовать runbook зависимого модуля. + +### Логи содержат секрет/PII + +1. Ограничить доступ и остановить offending export. +2. Сохранить только incident metadata, не копировать значение. +3. Ротировать скомпрометированный secret. +4. Удалить данные по процедуре backend/provider. +5. Исправить source redaction + Collector defense; добавить canary test. + +## 17. Проверки и Definition of Done + +- Collector config проходит validate и запускается в едином Compose; +- OTLP gRPC и HTTP принимают три сигнала; +- все сервисы имеют правильные resource attributes; +- request проходит nginx/API/Safety/Open Lines с одним `request_id` и связанным trace; +- `ux_session_id` есть только где передан и не является label; +- FastAPI/HTTPX/PG/Redis/S3 workers instrumented; +- nginx JSON parsing и trace correlation проверены; +- Redis/Keycloak/host/Collector metrics доступны; +- dashboards и alerts provisioned из versioned files для выбранного telemetry backend; до его выбора это остаётся acceptance/TBD, а не выполненный production DoD; +- remote outage, queue full, Collector restart и backend recovery rehearsed; +- local profile, если включён, имеет volumes/retention/auth и не публикует порты; +- secret/PII canary отсутствует в logs/traces/metrics; +- cardinality и sampling tests проходят; +- SLO queries воспроизводимы и исключения документированы; +- runbooks связаны с alerts. + +## 18. Допущения, TBD и конфликты + +### Решения + +- O1: обязательный архитектурный минимум — Collector; удалённый управляемый OTLP backend является предпочтительным operable-вариантом и остаётся TBD до выбора провайдера. +- O2: Prometheus/Grafana/Loki/Tempo — отдельный operable profile, не скрытая обязательная нагрузка основной VM. +- O3: JSON stdout — аварийный локальный buffer; audit App DB — durable. +- O4: telemetry fail-open для business path, но потеря telemetry alertится. +- O5: ID/PII не labels; source redaction обязательна до Collector. + +### TBD + +- O-TBD1: выбрать remote backend/provider, endpoint/auth и стоимость. +- O-TBD2: утвердить SLO/RPS/error-budget с product owner. +- O-TBD3: legal retention/erasure и допустимость IP/user-agent. +- O-TBD4: точные sampling и resource limits после load test. +- O-TBD5: поддерживаемый nginx OTEL module и Keycloak native tracing по pinned versions. +- O-TBD6: нужен ли local observability profile в первой VM. + +### Обнаруженные архитектурные конфликты + +1. `arch-03` разрешает stdout/platform exporter как минимум, но production-like расследования и alerts без backend ограничены. Здесь remote OTLP backend рекомендован, но не объявлен выбранным: провайдер остаётся TBD. +2. `module-05` использует test-only terminal `400` и non-sticky verdict вместо canonical `403`/sticky production verdict. Dashboards обязаны маркировать сервис `stub`; production SLO Safety на нём недостоверен. +3. `module-07` — только DB connectivity stub, тогда как arch-01/02 описывают полноценную CRM sync. Dashboard не должен показывать queue/CRM SLI, которых нет. +4. Retention, RPO/RTO и production SLO открыты в module-01/04/06/08; значения этого документа являются initial ops policy, не закрывают legal/product TBD. +5. Новые observability env (`OTEL_REMOTE_*`, sampling/queue limits) отсутствуют в arch-04; перед реализацией production `.env.example` их нужно добавить туда. + +## 19. Ссылки на прототип и исходные документы + +- VM/Docker logging и firewall: [`../../HAN_chat/deploy/setup-vm-han-chat.sh`](../../HAN_chat/deploy/setup-vm-han-chat.sh). +- Прототипный nginx stdout/access log: [`../../HAN_chat/bitrix-local-app/deploy/nginx/nginx.conf`](../../HAN_chat/bitrix-local-app/deploy/nginx/nginx.conf). +- Архитектурный observability contract: [`arch-02-api-contracts.md`](arch-02-api-contracts.md). +- Compose topology: [`arch-03-docker-compose-blueprint.md`](arch-03-docker-compose-blueprint.md). diff --git a/architectory/module-10-deployment-runbook.md b/architectory/module-10-deployment-runbook.md new file mode 100644 index 0000000..ca9d00f --- /dev/null +++ b/architectory/module-10-deployment-runbook.md @@ -0,0 +1,1135 @@ +# module-10. Runbook развёртывания HAN Chat + +> Статус: последовательная инструкция первого production-like деплоя и эксплуатации на одной Ubuntu VM. +> Все значения в `<УГЛОВЫХ_СКОБКАХ>` — placeholders. Команды с `cd ` требуют подстановки реального пути корня backend-репозитория на 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-09-observability.md`](module-09-observability.md). + +## 1. Неподвижные правила + +1. Один root `docker compose` запускается из ``. +2. Ровно один edge nginx публикует `80/443`. +3. API, Keycloak, Redis, OTEL, Safety и Bitrix-сервисы не имеют host `ports`. +4. `/internal/*` не маршрутизируется публично. +5. Managed PostgreSQL находится вне Compose, в той же VPC, без public IP. +6. S3 — внешний Selectel-compatible storage; клиент получает только presigned URL. +7. Секреты не коммитятся, не вставляются в команды shell history и не выводятся в отчёты. +8. Миграции выполняются отдельными one-shot steps до новой версии приложения. +9. Message Safety запускается как documented stub до замены; это не production antivirus/moderation. +10. `bitrix-sync` запускается как DB-connectivity stub; полноценной CRM sync нет. + +## 2. Роли и обозначения + +- **Cloud admin**: VPC, VM, PG, S3, DNS/security groups. +- **Deploy operator**: VM, Compose, migrations, release/rollback. +- **Bitrix admin**: local app, connector, Open Line 8, callbacks. +- **Security owner**: secrets, Keycloak admin MFA, firewall, retention. + +Placeholders: + +```text + например chat.example.ru + публичный IPv4 VM + приватный IPv4 VM + например 10.20.0.0/24 + private FQDN/IP managed PG + 5432 или 6432 + han_chat + URL репозитория + /opt/han-chat/backend + immutable tag/git SHA + адрес ops, не placeholder в реальном запуске + разрешённый портал +``` + +## 3. Stage 0 — решения до provisioning + +### 3.1. Зафиксировать параметры + +- region/availability zone и VPC; +- hostnames и TTL DNS; +- VM image Ubuntu 24.04 LTS; +- sizing; +- PG plan/storage/backups/PITR; +- S3 region/endpoint/bucket names; +- container registry и immutable image tags/digests; +- remote observability backend; +- RPO/RTO и maintenance window; +- ответственных за alerts/Bitrix/Keycloak. + +Начальный sizing без local Grafana stack: + +- VM: 4 vCPU, 8 ГБ RAM, 80 ГБ SSD, 4 ГБ swap; +- managed PG: минимум 2 vCPU, 4 ГБ RAM, 50 ГБ, HA по возможности; +- Redis limit: 512 МиБ; +- OTEL Collector: 512 МиБ + 5–10 ГБ queue; +- свободный диск VM после pull/build: не менее 30%. + +Это baseline, не гарантия. До real traffic обязателен load test с long Safety poll и WS. + +### Gate 0 + +- [ ] Владельцы и maintenance window назначены. +- [ ] RPO/RTO приняты хотя бы временно: ориентир RPO PG ≤15 минут/PITR, RTO ≤4 часа. +- [ ] Решено: images pull из registry или build на VM. +- [ ] Remote telemetry backend выбран либо явно принят ограниченный debug-only режим. +- [ ] Риск mock OTP и Safety stub письменно принят. + +**Ожидаемый результат:** есть release checklist с конкретными values; не создано ни одной публичной БД/Redis. + +## 4. Stage 1 — VPC, VM, DNS и security groups + +### 4.1. Сеть + +Создать одну private subnet для VM и managed PG. PG получает только private address. VM имеет public IP только для nginx/SSH. + +Security groups: + +| Source | Destination | Port | Rule | +|---|---|---:|---| +| trusted ops CIDR/VPN | VM | SSH `` | allow | +| internet | VM | TCP 80 | allow для redirect/ACME | +| internet | VM | TCP 443 | allow | +| VM private IP/SG | managed PG | `` | allow | +| VM | internet | 443 | allow egress: registry, Bitrix, S3, OTLP, ACME | +| internet | managed PG | any | deny | +| internet | VM | 6379, 4317, 4318, 8000, 8080, 9000 | deny | + +Если cloud SG не поддерживает egress allow-list, оставить egress open и контролировать destinations приложением/TLS; не ломать S3/Bitrix/OIDC. + +### 4.2. DNS + +Создать `A `. Не добавлять `www`, если он не нужен и не включён в certificate. Для отдельного API host действуют правила arch-03; MVP предпочтительно использует один host с paths. + +Проверка с рабочей станции: + +```bash +dig +short +``` + +Ответ должен совпасть с ``. + +### Gate 1 + +- [ ] PG не имеет public endpoint. +- [ ] SSH доступен только trusted source. +- [ ] Снаружи открыты только 80/443/ограниченный SSH. +- [ ] DNS стабильно разрешается с нескольких resolver. +- [ ] VM достигает private PG и внешних HTTPS endpoints. + +**Ожидаемый результат:** `nc -vz ` с VM успешен; с внешней машины PG недоступен. + +## 5. Stage 2 — hardening Ubuntu и deploy user + +### 5.1. Первичный вход + +Войти cloud user, добавить отдельный deploy key. Не отключать пароль/root до проверки второго SSH-сеанса. + +Прототипный скрипт можно адаптировать: + +```bash +sudo DEPLOY_USER=deploy \ + DEPLOY_DIR=/opt/han-chat \ + SSH_PORT= \ + SWAP_SIZE_GB=4 \ + PUBLIC_DOCKER_PORTS=80,443 \ + ./deploy/setup-vm-han-chat.sh +``` + +Скрипт из [`../../HAN_chat/deploy/setup-vm-han-chat.sh`](../../HAN_chat/deploy/setup-vm-han-chat.sh) полезен для UFW, fail2ban, Docker и `DOCKER-USER`, но перед production: + +- проверить его версию/review; +- не передавать реальные IP/ключи в git; +- проверить auto reboot unattended upgrades относительно maintenance; +- решить, действительно ли deploy user нужен в группе `docker` (это root-equivalent); +- не применять `AllowTcpForwarding no`, если утверждённый break-glass DB tunnel необходим; предпочтителен VPN/bastion. + +### 5.2. Проверки + +```bash +sudo sshd -t +sudo ufw status verbose +sudo fail2ban-client status sshd +docker version +docker compose version +sudo iptables -L HAN-CHAT-DOCKER -n -v +timedatectl status +df -h +free -h +``` + +Открыть второй SSH session как `deploy`, затем отключить root/password login. `.env` позже имеет mode `0600`. + +### Gate 2 + +- [ ] SSH key login `deploy` проверен во втором сеансе. +- [ ] Root/password auth выключены. +- [ ] UFW и DOCKER-USER активны после restart Docker. +- [ ] Docker Engine/Compose plugin закреплены поддерживаемой версией. +- [ ] NTP active; disk/swap соответствуют sizing. +- [ ] Break-glass процедура сохранена вне VM. + +**Ожидаемый результат:** reboot VM не теряет SSH, firewall и Docker service. + +## 6. Stage 3 — managed PostgreSQL + +### 6.1. Backups и TLS + +До схем включить: + +- daily backup; +- PITR; +- encryption at rest; +- TLS certificate/CA; +- alerts storage/connections/backup failure; +- deletion protection. + +Скачать CA в защищённый путь VM, например `/opt/han-chat/secrets/pg/ca.pem`, mode 0444/0400 по policy. Все DSN используют `verify-full`/эквивалент. + +### 6.2. Роли + +Целевая модель разделяет: + +- admin/bootstrap role; +- migration role каждого schema с DDL; +- runtime role без DDL. + +Прототип `init-managed-postgres.py` выдаёт runtime roles `CREATE` на schema и печатает DSN. Это допустимо только для bootstrap/dev, но **слишком широко для production runtime**. Перед production адаптировать: + +1. создать пять schemas: `han_app`, `bitrix_local`, `bitrix_sync`, `keycloak`, `message_safety`; +2. создать runtime roles; +3. создать migration roles либо controlled admin job; +4. schema owner = migration role; +5. runtime: `USAGE`, DML и sequence grants только на свои objects; +6. `ALTER DEFAULT PRIVILEGES` от migration owner; +7. запретить чужие schemas и public schema create; +8. `bitrix_sync_user` не получает `han_app` grants, пока module-07 остаётся stub. + +Команда bootstrap требует ``: + +```bash +cd +cp deploy/pg-init.env.example deploy/pg-init.env +chmod 600 deploy/pg-init.env +# заполнить private host/database/admin и generated passwords +set -a; source deploy/pg-init.env; set +a +python3 deploy/init-managed-postgres.py +unset HAN_PG_ADMIN_PASSWORD +``` + +Не сохранять stdout с DSN в shared logs. Исторический `init-managed-postgres.sql` содержит placeholder passwords и database `postgres`; для целевой БД применять только после review и замены database name. + +### 6.3. Проверка least privilege + +Для каждого runtime user: + +```bash +psql "host= port= dbname= user= sslmode=verify-full sslrootcert=" \ + -c "select current_user, current_setting('search_path');" +``` + +Негативно проверить `CREATE TABLE` и доступ к чужой schema — они должны завершиться permission denied. + +### 6.4. Migration policy + +Порядок ownership: + +1. `api-backend` Alembic владеет `han_app`, triggers, seed; +2. `bitrix-local-app` Alembic владеет `bitrix_local`; +3. `message-safety` stub не создаёт PG tables до production implementation; +4. `bitrix-sync` stub — optional empty baseline; +5. Keycloak мигрирует standard tables сам; custom provider имеет собственные versioned migrations. + +Только expand/migrate/contract. Destructive migration — отдельный backup, approval и release. Downgrade data migrations не обещается; rollback приложения требует backward-compatible schema. + +### Gate 3 + +- [ ] Backups/PITR/TLS/deletion protection включены. +- [ ] Пять schemas/roles созданы. +- [ ] Runtime roles не имеют DDL/чужого доступа. +- [ ] Migration credentials отделены от runtime. +- [ ] Empty/previous-version migration test успешен. +- [ ] PITR restore point создан перед первым release. + +**Ожидаемый результат:** runtime `SELECT 1` успешен, unauthorized schema read/create запрещены. + +## 7. Stage 4 — S3 buckets, IAM, CORS и lifecycle + +Создать три приватных bucket: + +- `-quarantine`; +- `-attachments`; +- `-documents`. + +Public ACL/listing выключены. Versioning включить для data buckets по policy; server-side encryption включить. + +IAM: + +- API role/key: exact prefixes, presign PUT quarantine, Head/copy/delete quarantine, write/read data; +- Safety role/key: **read-only quarantine**; +- backup/ops role: отдельно; +- frontend: никаких permanent credentials. + +CORS quarantine: + +```json +[ + { + "AllowedOrigins": ["https://"], + "AllowedMethods": ["PUT"], + "AllowedHeaders": ["Content-Type", "x-amz-*"], + "ExposeHeaders": ["ETag", "x-amz-checksum-sha256"], + "MaxAgeSeconds": 600 + } +] +``` + +Уточнить фактические required signed headers. Не разрешать `*` origin с credentials. + +Lifecycle: + +- quarantine: expire orphan objects только после периода, превышающего Safety poll + recovery; initial 2 дня, согласовать; +- incomplete multipart upload: abort через 1 день; +- attachments/documents: без auto-delete до legal retention; +- noncurrent versions: policy после legal review. + +### Gate 4 + +- [ ] Все buckets private. +- [ ] API key не может list/write вне exact scope. +- [ ] Safety key не может write/delete. +- [ ] Browser test origin выполняет presigned PUT. +- [ ] Quarantine lifecycle не удалит active `safety_tasks`. +- [ ] Data lifecycle соответствует retention. + +**Ожидаемый результат:** anonymous GET/PUT получает deny; API capability test проходит. + +## 8. Stage 5 — repository и release layout + +На VM: + +```text +/opt/han-chat/ + backend/ # checkout текущего release + releases// # optional immutable release dirs + secrets/ # не в git + backups/ # только metadata/short-lived encrypted artifacts +``` + +Рекомендуемый rollout — immutable images из registry. Build на VM допустим для MVP, но требует reproducible Dockerfiles и достаточно диска. + +```bash +sudo install -d -m 0755 -o deploy -g deploy /opt/han-chat +git clone +cd +git fetch --tags +git checkout --detach +git status --short +``` + +Ожидается clean tree. Запретить deploy из mutable branch без recorded SHA. + +Проверить структуру: root `docker-compose.yml`, service directories, `nginx`, `keycloak`, `redis`, `observability`, frontend artifact. + +### Gate 5 + +- [ ] Checkout exact SHA/tag. +- [ ] Working tree clean. +- [ ] Images/Dockerfiles pinned, `latest` отсутствует. +- [ ] SBOM/vulnerability scan без unresolved critical/high. +- [ ] Root Compose — единственный production entrypoint. + +## 9. Stage 6 — `.env` и secrets + +### 9.1. Создание + +```bash +cd +umask 077 +cp .env.example .env +chmod 600 .env +``` + +Генерировать минимум 256-bit: + +```bash +openssl rand -hex 32 +``` + +Не выполнять `export SECRET=...` в shared shell history. Использовать editor с restricted permissions или secret manager/secret files. + +### 9.2. Обязательные группы + +- `APP_ENV`, release/version, log level; +- private PG host/port/database, TLS CA и runtime DSN; +- Redis ACL credentials/URLs DB0/1/2; +- public web/API/auth URLs; +- Keycloak realm/audience/hostname/bootstrap/provider technical secrets; +- paired service tokens из arch-02; +- Bitrix client/application/webhook/encryption secrets; +- S3 endpoint/buckets/API and read-only Safety credentials; +- OTEL endpoint/remote exporter secrets; +- nginx/TLS/rate limits; +- frontend public build values. + +Пары должны совпасть: + +```text +BITRIX_LOCAL_APP_INTERNAL_TOKEN == BITRIX_INTERNAL_API_TOKEN +BITRIX_API_FORWARD_TOKEN == BITRIX_API_INBOX_TOKEN +``` + +Service token и webhook token — разные secrets. + +### 9.3. Validation + +Добавить/запустить `scripts/validate-env`: + +- mandatory not empty; +- нет `change-me`, example IP/domain, default OTP; +- URLs have correct schemes; +- public URLs HTTPS, internal URLs service DNS; +- PG TLS enabled; +- paired tokens equal; +- CORS/origins exact; +- no duplicate keys; +- `FRONTEND_DEV_PROXY_ENABLED=false`; +- Safety timeout согласован с nginx; +- secrets minimum length; +- mock OTP risk flag explicitly accepted. + +```bash +cd +./scripts/validate-env .env +docker compose config --quiet +``` + +`docker compose config` может раскрыть resolved secrets; не сохранять/публиковать его stdout. + +### Gate 6 + +- [ ] `.env` mode 0600, отсутствует в git. +- [ ] Все placeholder/default secrets отклонены. +- [ ] Paired tokens совпадают. +- [ ] DSN private/TLS; URLs/issuer согласованы. +- [ ] Validation и Compose interpolation успешны. +- [ ] Secret recovery/rotation owner назначен. + +## 10. Stage 7 — images и frontend static + +### Pull-вариант + +```bash +cd +docker login +docker compose pull +docker image ls --digests +``` + +Registry token read-only и короткоживущий. + +### Build-вариант + +```bash +cd +DOCKER_BUILDKIT=1 docker compose build --pull +``` + +Build не получает production secrets. Записать image digests. + +Frontend: + +```bash +cd +npm ci +npm run test +npx expo export --platform web +``` + +Скопировать artifact в versioned `frontend-static` volume/image. `index.html` revalidate, hashed assets immutable. Build env содержит только public URL/realm/client id. Проверить отсутствие service tokens/mock OTP/S3 keys командой secret scanner. + +### Gate 7 + +- [ ] Все images доступны по digest. +- [ ] Frontend build/tests успешны. +- [ ] Static artifact не содержит secrets/source maps по policy. +- [ ] nginx image/config содержит request-id module и TLS features. +- [ ] Disk после pull/build >30% free. + +## 11. Stage 8 — root Compose, networks и volumes + +До запуска: + +```bash +cd +docker compose config --services +``` + +Ожидаются: `nginx`, `api-backend`, `message-safety`, `keycloak`, `bitrix-sync`, `bitrix-local-app`, `redis`, `otel-collector` и one-shot jobs/profile components. + +Networks: + +- `public`: nginx и минимально Keycloak/frontend path; +- `backend`: internal services/Redis; +- `observability`: services + Collector. + +Volumes: + +- `redis-data`; +- ACME certs/webroot; +- frontend static; +- `otel-queue`; +- никаких PG data volumes. + +Проверить: + +```bash +docker compose config | rg 'ports:|expose:|networks:|volumes:' +``` + +Если `rg` на VM нет, использовать reviewed script, не ручной визуальный просмотр. Единственные published mappings — nginx 80/443. + +Redis: ACL, AOF everysec, RDB, maxmemory, volume, no host port. OTEL: config read-only, queue bounded, no public OTLP. + +### Gate 8 + +- [ ] Только nginx публикует ports. +- [ ] Internal services не подключены к public без причины. +- [ ] Named volumes созданы и permissions проверены. +- [ ] Container resource limits/healthchecks заданы. +- [ ] `docker compose config --quiet` success. + +## 12. Stage 9 — TLS bootstrap, фаза 1 + +Прототип `ssl-issue.sh` останавливает весь Compose и использует standalone Certbot. Для full stack предпочтителен **webroot two-phase**, чтобы не делать `compose down`. + +### Phase A: HTTP bootstrap + +1. DNS уже указывает на VM. +2. Запустить nginx с bootstrap config: только `/.well-known/acme-challenge/` и redirect; TLS block не требует отсутствующий cert. +3. Запустить Certbot profile: + +```bash +cd +docker compose --profile tls-bootstrap up -d nginx +docker compose --profile certbot run --rm certbot certonly \ + --webroot -w /var/www/certbot \ + -d \ + --email \ + --agree-tos --no-eff-email --non-interactive +``` + +Сначала rehearsal с Let's Encrypt staging CA. + +### Phase B: TLS activation + +```bash +cd +docker compose exec -T nginx nginx -t +# активировать rendered TLS config атомарно +docker compose exec -T nginx nginx -s reload +``` + +HSTS пока не включать. Проверить chain/hostname/redirect, затем включить HSTS без preload. + +Renewal: systemd timer предпочтительнее weekly cron. Запуск минимум дважды в сутки: + +1. `certbot renew --webroot`; +2. если cert изменился — `nginx -t`; +3. reload; +4. emit metric/log; +5. alert expiry. + +Прототипные [`ssl-common.sh`](../../HAN_chat/bitrix-local-app/deploy/ssl-common.sh), [`ssl-renew.sh`](../../HAN_chat/bitrix-local-app/deploy/ssl-renew.sh) полезны концептуально, но должны работать с **root Compose**, не service compose. + +### Gate 9 + +- [ ] Staging issuance rehearsal успешен. +- [ ] Production cert chain/hostname valid. +- [ ] HTTP только ACME + 308. +- [ ] TLS 1.0/1.1 rejected; 1.2/1.3 accepted. +- [ ] Renewal dry-run и safe reload успешны. +- [ ] Alert expiry настроен. + +## 13. Stage 10 — миграции и seed + +Остановить public traffic либо использовать maintenance page до gate. + +### 13.1. Preflight + +```bash +cd +docker compose run --rm api-backend alembic current +docker compose run --rm bitrix-local-app alembic current +``` + +Создать PITR marker. Выполнить dry-run/SQL review в clone/staging. + +### 13.2. Upgrade + +```bash +cd +docker compose run --rm api-backend alembic upgrade head +docker compose run --rm bitrix-local-app alembic upgrade head +``` + +Для message-safety stub PG migration отсутствует. Для bitrix-sync stub — baseline только если реализован. Keycloak стандартную schema мигрирует выбранная pinned версия при controlled startup; provider migration выполняется отдельным approved job. + +### 13.3. Seed `app_settings` + +Seed обязан быть idempotent/versioned и содержать все ключи arch-04. Выполнить migration или: + +```bash +cd +docker compose run --rm api-backend python -m app.cli.seed_settings --file /deploy/app-settings.production-like.yaml +docker compose run --rm api-backend python -m app.cli.validate_settings +``` + +Команды являются целевым интерфейсом; если CLI ещё не реализован, gate не проходить ручными ad-hoc INSERT без reviewed SQL. + +Проверить public keys/DTO, consent versions/URLs, CORS host, file MIME/size, UX idle timeout. Не копировать phone/URLs из prototype без product approval. + +### Gate 10 + +- [ ] PITR marker до migrations. +- [ ] Expected Alembic revisions active. +- [ ] Runtime users не выполняли DDL. +- [ ] Seed idempotency проверена повторным запуском. +- [ ] Mandatory settings valid; secrets отсутствуют в `app_settings`. +- [ ] Backward compatibility с текущими images подтверждена. + +## 14. Stage 11 — Keycloak bootstrap + +### 14.1. Первый старт + +Запустить PostgreSQL-ready Keycloak отдельно: + +```bash +cd +docker compose up -d keycloak +docker compose ps keycloak +docker compose logs --since=10m keycloak +``` + +Bootstrap admin secret существует только на первый запуск. После создания named admin с MFA удалить/ротировать bootstrap credential из runtime env. + +### 14.2. Realm + +Clean environment может импортировать secret-free `han-chat` realm template. Живой production realm нельзя перетирать `--import-realm` без diff. + +Проверить: + +- client `han-chat-frontend`, public, PKCE S256; +- direct/implicit/password/social disabled; +- audience `han-chat-api`; +- exact redirect/web origins; +- issuer `https:///auth/realms/han-chat`; +- claims `sub`, `phone_number`, verified, audience; +- custom phone OTP provider; +- settings bridge token/path; +- mock code non-default и не виден UI/log; +- brute-force, token/session TTL, refresh rotation; +- admin console limited by VPN/allow-list. + +### 14.3. Provider migration + +Custom OTP tables мигрируются versioned mechanism до включения flow. Не редактировать standard Keycloak tables вручную. + +### Gate 11 + +- [ ] Discovery/JWKS public через HTTPS. +- [ ] Issuer exact, no internal hostname. +- [ ] Realm drift check clean. +- [ ] Только Authorization Code + PKCE S256. +- [ ] OTP wrong/replay/limit tests fail safely. +- [ ] Settings bridge cache/fail-closed tested. +- [ ] Bootstrap admin removed; named admin MFA enabled. + +## 15. Stage 12 — ordered startup и health gates + +Архитектурный порядок: + +1. Redis; +2. Keycloak; +3. OTEL Collector; +4. Message Safety; +5. API backend; +6. Bitrix local app; +7. Bitrix sync; +8. nginx. + +Команды: + +```bash +cd +docker compose up -d redis +docker compose up -d keycloak otel-collector +docker compose up -d message-safety +docker compose up -d api-backend +docker compose up -d bitrix-local-app bitrix-sync +docker compose up -d nginx +docker compose ps +``` + +После каждого шага ждать health, но проверять readiness отдельно из internal network: + +```bash +docker compose exec -T api-backend python -c "" +``` + +Не использовать host ports для curl. Допустим dedicated toolbox container в `backend` network. + +Expected: + +- Redis `PONG`; +- Keycloak DB/realm/provider ready; +- Collector health + exporter queue; +- Safety ready и Redis DB2; +- API DB/Redis/JWKS/settings/S3/Safety ready; +- local app до Bitrix install может быть `portal_not_installed`; +- bitrix-sync возвращает `mode=db_connectivity_stub`, не CRM-ready; +- nginx config test success. + +### Gate 12 + +- [ ] Все containers live, нет restart loop/OOM. +- [ ] Critical readiness green. +- [ ] Expected degraded statuses только Bitrix not-installed/sync stub. +- [ ] `docker compose ps` не публикует internal ports. +- [ ] Internal `/internal/*` снаружи 404. +- [ ] OTEL принимает telemetry. + +## 16. Stage 13 — Bitrix24 local app и Open Lines + +Bitrix admin создаёт local application: + +- install URL `https:///bitrix/install`; +- handler URL `https:///bitrix/handler`; +- placement URL `https:///bitrix/placement`; +- required scopes по module-06; +- client id/secret загружены в secret store до install; +- portal/domain allow-list exact. + +Выполнить install в Bitrix24. Local app должен сохранить encrypted OAuth, затем: + +1. `imconnector.register` connector `han_mobile_app`; +2. `imconnector.activate` line `8`; +3. `event.bind`; +4. status/reconciliation. + +Не использовать prototype `/bitrix-internal/internal/v1/*`: canonical path только internal Docker `/internal/openlines/v1/*`, наружу он отсутствует. + +Проверить status из toolbox/internal network с Bearer token, не печатая token: + +```bash +cd +docker compose run --rm --no-deps \ + +``` + +Создать test dialog/message через public API, не прямым legacy payload с телефоном. Проверить mapping и ответ оператора. + +### Gate 13 + +- [ ] OAuth stored encrypted; token не в logs. +- [ ] Connector configured/active on line 8. +- [ ] Events bound exactly once. +- [ ] Outbound text reaches Open Lines once. +- [ ] Operator reply reaches API, затем delivery ack. +- [ ] Duplicate callback не создаёт duplicate message. +- [ ] Internal status с internet недоступен. + +## 17. Stage 14 — public smoke и E2E + +### 17.1. Edge + +```bash +curl -I http:/// +curl -fsS https:///api/v1/public/app-config +curl -fsS https:///api/v1/public/content +curl -fsS https:///auth/realms/han-chat/.well-known/openid-configuration +curl -i https:///internal/safety/v1/messages/check +openssl s_client -connect :443 -servername +``` + +Expected: 308; public 200 strict DTO; discovery 200; internal 404; valid cert. + +### 17.2. Auth/frontend + +- guest открывает content без write; +- protected write без JWT → 401; +- consent → mock OTP → PKCE tokens; +- bootstrap не передаёт phone body; +- session-start создаёт `ux_session_id`; +- silent refresh работает без OTP; +- logout очищает tokens; +- wrong/replayed OTP не выдаёт tokens. + +### 17.3. Message Safety правила stub + +Обязательные E2E: + +- text, начинающийся после normalization с `ф/Ф` → public `422 message_blocked`, Bitrix не вызван; +- text с цифры → Safety `203`, API poll до `200` или test terminal `400`; клиент никогда не получает `203`; +- прочий text → allow; +- terminal stub `400` преобразуется в `422`, не в generic validation; +- timeout → `503/504`, checkpoint/recovery, без duplicate; +- один slow poll не блокирует другие requests. + +Статус `message-safety` должен быть явно `stub`; для real production он не заменяет antivirus/file scan. + +### 17.4. Files + +- allow image/PDF ≤ configured size; +- wrong extension+MIME/oversize/checksum reject; +- direct presigned PUT quarantine; +- allow promote attachments; +- deny остаётся вне data и quarantine cleanup; +- Safety read-only credential не может write; +- download URL owner-only + audit; +- presigned URL отсутствует в logs. + +### 17.5. Realtime и ownership + +- WS connects/subscribes; +- operator reply arrives; +- reconnect + REST gap reconciliation; +- polling fallback; +- чужие dialog/message/attachment/document id → 404; +- idempotency same body replay, changed body 409; +- rate limits 429 + `Retry-After`. + +### Gate 14 + +- [ ] Полный first-send flow успешен. +- [ ] Safety allow/deny/pending/timeout проверены. +- [ ] Text/file/WS/polling работают. +- [ ] Ownership и no-public-internal tests зелёные. +- [ ] Нет secret/PII/message body/presigned URL в logs. +- [ ] Audit events созданы. +- [ ] Bitrix получает только allowed message. + +## 18. Stage 15 — observability validation + +Следовать [`module-09-observability.md`](module-09-observability.md): + +1. послать request с известным `X-Request-ID`; +2. найти nginx log, API trace и downstream spans; +3. проверить `service.name`, environment, trace/request/UX ids; +4. при выбранном telemetry backend проверить dashboards всех services; до выбора — проверить bounded stdout/debug acceptance и явно зафиксировать ограничение; +5. trigger safe synthetic 4xx/5xx и проверить alert route, если backend с alerting уже выбран; +6. при настроенном remote OTLP временно блокировать его, проверить bounded queue и business continuity; +7. проверить Collector health/drop/refused; +8. выполнить PII/secret canary test. + +### Gate 15 + +- [ ] Три сигнала доступны. +- [ ] Trace cross-service связан. +- [ ] Для выбранного backend alerts доставляются on-call и SLO queries возвращают данные; иначе limitation/TBD явно принят и traffic не называется production-ready. +- [ ] Collector outage не ломает business path. +- [ ] Redaction test пройден. + +## 19. Stage 16 — opening traffic + +До открытия: + +- удалить maintenance response; +- включить HSTS после финального TLS test; +- сохранить release SHA/image digests/schema revisions/realm desired version; +- создать restore point; +- подтвердить on-call; +- не удалять previous images; +- observation window 60 минут. + +В первые 60 минут: 5xx, auth, message delivery, DB/Redis, memory, restart, Collector queue, Bitrix OAuth/DLQ. + +### Gate 16 + +- [ ] Все Gate 0–15 подписаны. +- [ ] Rollback release доступен. +- [ ] Backup/restore evidence свежий. +- [ ] Нет active page alert. +- [ ] Product owner принял stub limitations. + +## 20. Backup и restore + +### PostgreSQL + +- provider daily + PITR; +- перед migrations/Keycloak upgrade — manual restore point; +- ежеквартальный restore clone; +- проверить все schemas, Alembic/Keycloak revisions, keys, grants. + +### S3 + +- versioning/lifecycle data buckets; +- inventory/checksum при поддержке; +- restore не делает objects public; +- quarantine не является backup. + +### Redis + +AOF/RDB ускоряют restart, но не business backup. При corruption поднять clean Redis; API восстанавливает durable state из PG. Никогда не считать Redis dump достаточным для messages/audit. + +### Keycloak + +DB backup включает realm/users/signing keys/provider data. Secret-free realm export — config backup, не полный data backup. После restore проверить issuer/JWKS/PKCE/OTP/refresh. + +### Restore rehearsal + +1. isolated VPC/hostnames; +2. restore PG clone и S3 copies; +3. deploy same image digests; +4. не направлять production DNS/Bitrix callbacks; +5. run migrations only if required release; +6. smoke auth/chat without real operator impact; +7. record measured RPO/RTO; +8. destroy isolated secrets/resources controlled. + +## 21. Rollback + +### Application-only + +1. объявить incident/maintenance; +2. сохранить diagnostics и current state; +3. остановить новые claims/send при возможности; +4. переключить image tags на previous digests; +5. не выполнять Alembic downgrade; +6. `docker compose up -d`; +7. health/smoke/idempotency; +8. проверить outbox/inbox/recovery. + +### После backward-incompatible migration + +Обычный rollback запрещён. Выбор: + +- forward-fix; +- restore PG PITR + coordinated S3/Bitrix reconciliation; +- maintenance до решения. + +Нельзя rollback DB отдельно от Keycloak signing/session state без анализа. + +### TLS/nginx rollback + +`nginx -t`; оставить старый config/workers при failure. Не удалять working cert. При ошибке renewal current cert остаётся, но alert. + +### Gate rollback + +- [ ] Previous images available. +- [ ] Schema совместима. +- [ ] No duplicate outbound after restart. +- [ ] Data reconciliation completed. +- [ ] Incident timeline содержит release/request ids без PII. + +## 22. Routine operations + +Ежедневно автоматически: + +- health/synthetics/SLO/alerts; +- PG backup/PITR status; +- TLS expiry/renew; +- disk/inodes/OTEL queue; +- Redis AOF/memory/evictions; +- DLQ/backlog/quarantine age; +- Keycloak signing/OAuth/settings cache. + +Еженедельно: + +- vulnerability/image updates review; +- failed login/rate-limit trend; +- Bitrix connector desired/observed; +- S3 lifecycle/inventory; +- restore/rollback artifacts availability. + +Ежемесячно: + +- patch OS/images in maintenance; +- secret/access review; +- capacity/cardinality/cost; +- stale users/admins; +- runbook sample drill. + +Команды: + +```bash +cd +docker compose ps +docker compose logs --since=15m +docker stats --no-stream +docker system df +docker compose exec -T nginx nginx -t +``` + +Не использовать unbounded `logs`, `docker system prune -a`, Redis `KEYS/FLUSH*` или ad-hoc DB DELETE. + +## 23. Incident commands + +Безопасный triage: + +```bash +cd +date -Is +docker compose ps +docker stats --no-stream +docker compose logs --since=10m --tail=500 +df -h +free -h +sudo ss -lntp +sudo iptables -L HAN-CHAT-DOCKER -n -v +``` + +PG: + +```bash +psql "" -c "select now(), count(*) from pg_stat_activity;" +``` + +Redis — только ops ACL: + +```bash +docker compose exec -T redis redis-cli --user --pass '' PING +``` + +Никогда не вставлять secret literal в ticket/chat. Предпочесть stdin/secret file. Не выполнять ручной replay message/DLQ до проверки idempotency и ambiguous Bitrix outcome. + +Типовые сценарии: + +- API 503: DB/Redis/JWKS/settings/Safety readiness и circuits; +- send timeout: safety checkpoint/outbox, не повторять с новым key; +- Bitrix down: OAuth/circuit/backlog/DLQ, reads оставить; +- Redis loss: clean restart, durable fallback, polling; +- PG outage: не restart storm; provider incident; +- disk full: остановить ingest growth, очистить только known cache/old image после inventory; +- cert near expiry: webroot/DNS/rate limit, staging rehearsal; +- secret leak: revoke/rotate, telemetry deletion, redeploy. + +## 24. Upgrades + +Общий порядок: + +1. release notes/security advisories; +2. compatibility matrix; +3. backup/PITR; +4. staging clone; +5. image/build/test/scan; +6. expand migration; +7. one service at a time по dependency order; +8. health/E2E/observation; +9. contract migration later; +10. record digests/revisions. + +Keycloak: не пропускать unsupported majors; проверить SPI/provider migration и JWKS. Redis: AOF compatibility/rewrite. Collector: config validate against exact version. nginx: `nginx -t` и TLS scan. PostgreSQL major upgrade сначала rehearsal clone. + +## 25. Disaster recovery + +### Потеря VM + +1. provision new Ubuntu VM в VPC; +2. применить reviewed hardening; +3. attach public IP/update DNS с low TTL; +4. restore secrets из vault, не со старого disk без проверки; +5. pull exact images; +6. mount/create volumes; Redis можно clean; +7. connect existing/restored PG/S3; +8. TLS issue/restore safely; +9. ordered startup/gates; +10. Bitrix callback/connectors verify; +11. public smoke, then traffic. + +### Потеря PG + +Restore PITR в new managed instance, private SG/TLS, update DSN, validate schemas/grants/revisions. Остановить writes до chosen restore point/reconciliation. S3 objects после restore point могут стать orphan; выполнить audit-backed reconcile. + +### Потеря S3 + +Без data backup/versioning полное восстановление невозможно. Временно отключить file operations, оставить text flow, restore objects/inventory, reconcile DB metadata, не генерировать URLs отсутствующих objects. + +### Compromise + +Isolate VM, preserve forensic snapshot, rotate all service/DB/S3/Bitrix/Keycloak secrets, revoke sessions/signing keys по масштабу, deploy clean VM/images, restore trusted data, notify по incident/legal process. + +## 26. Teardown cautions + +`docker compose down` не удаляет managed PG/S3, но может остановить callbacks. `down -v` удалит Redis/ACME/OTEL queue volumes и запрещён без approval. Запрещены: + +```text +docker compose down -v +docker system prune -a --volumes +DROP DATABASE / DROP SCHEMA +S3 recursive delete +cloud project/VPC delete +certbot delete active cert +``` + +Перед teardown: + +- export inventory/digests/config without secrets; +- revoke Bitrix app/callbacks; +- revoke/rotate credentials; +- backup/retention/legal hold; +- DNS drain; +- deletion protection removal — отдельное approval; +- проверить shared VPC/PG/S3; +- зафиксировать evidence уничтожения. + +## 27. Definition of Done + +- VM/VPC/DNS/SG/hardening соответствуют Gate 1–2; +- managed PG private/TLS/backups/least privilege/migrations работают; +- S3 private/IAM/CORS/lifecycle проверены; +- exact release/images/frontend deployed; +- `.env` validated, secrets protected; +- один root Compose, один nginx, только 80/443; +- Redis/Collector volumes/resources/security работают; +- Keycloak realm/provider/PKCE/OTP готов; +- ordered startup/readiness пройден; +- Bitrix connector line 8 и callback flow проверены; +- full auth/text/file/realtime/safety E2E зелёный; +- observability/redaction проверены; SLO/alerts проверены для выбранного backend либо явно остаются принятым production-blocking TBD; +- backup restore и rollback rehearsed; +- ops/incident/upgrade/DR owners назначены; +- все assumptions/TBD приняты до открытия traffic. + +## 28. Допущения, TBD и архитектурные конфликты + +### Допущения + +- D-A1: одна VM и один public host на MVP. +- D-A2: обязательный минимум — `otel-collector`; доступность remote backend не предполагается до закрытия D-TBD11, local Grafana stack не обязателен. +- D-A3: managed provider даёт private network, TLS, backups/PITR. +- D-A4: Bitrix portal/connector/line остаются разрешёнными значениями architecture. +- D-A5: mock OTP временно разрешён как documented risk. + +### TBD до production + +- D-TBD1: реальные domains, Expo native redirect URI и Bitrix placement frame ancestors. +- D-TBD2: final VM/PG sizing, RPS/WS, SLO/RPO/RTO. +- D-TBD3: legal retention/erasure для PG/S3/audit/telemetry. +- D-TBD4: secret manager и rotation windows. +- D-TBD5: production Safety вместо stub и antivirus inbound operator files. +- D-TBD6: полноценный bitrix-sync/GRANT или явное исключение CRM sync из release. +- D-TBD7: pinned Keycloak/nginx/Collector versions и SPI compatibility. +- D-TBD8: exact migration/seed/toolbox CLI commands после реализации repo. +- D-TBD9: Keycloak admin VPN/MFA topology. +- D-TBD10: final CSP/CORS/S3 headers и cloud-specific IAM. +- D-TBD11: выбрать observability backend/provider, endpoint/auth/retention и alert route либо явно ограничить среду acceptance-режимом без production-ready SLO. + +### Обнаруженные конфликты + +1. `arch-01/02/03` описывают полноценный `bitrix-sync`, но module-07 реализует только `SELECT 1`. Первый release не поддерживает обещанную CRM profile sync. +2. `arch-01/02` ожидают production-like Safety и S3 scan, но module-05 — Redis-only random stub, file-only default allow и test-only terminal `400`. Это блокер настоящего production, даже если допустимо для production-like acceptance. +3. Прототипный PG init даёт runtime role `CREATE` schema и не разделяет migration/runtime roles; runbook требует ужесточения. +4. Прототипные TLS scripts используют отдельный service Compose/standalone downtime, тогда как целевая архитектура требует root Compose и two-phase webroot. +5. Prototype публиковал `/bitrix-internal/*` и использовал `/internal/v1/*`; целевой контур это запрещает и использует `/internal/openlines/v1/*`. +6. `arch-04` не содержит ряд proposed env из module-04–09; production `.env.example` должен быть синхронизирован до реализации. +7. Точные RPO/RTO, retention, SLO, Keycloak version/TTL и Bitrix retry semantics не утверждены; начальные значения runbook не закрывают product/security decision. +8. `init-managed-postgres.py` по умолчанию не задаёт TLS parameters при bootstrap connection и печатает credential-bearing DSN; его production-hardening обязателен. + +## 29. Ссылки на прототип + +- Исторические шаги: [`../../HAN_chat/Deploy_steps.md`](../../HAN_chat/Deploy_steps.md). +- Исторические команды эксплуатации: [`../../HAN_chat/backend-managing.md`](../../HAN_chat/backend-managing.md). +- VM baseline: [`../../HAN_chat/deploy/setup-vm-han-chat.sh`](../../HAN_chat/deploy/setup-vm-han-chat.sh). +- PG bootstrap: [`../../HAN_chat/deploy/init-managed-postgres.py`](../../HAN_chat/deploy/init-managed-postgres.py), [`../../HAN_chat/deploy/init-managed-postgres.sql`](../../HAN_chat/deploy/init-managed-postgres.sql), [`../../HAN_chat/deploy/pg-init.env.example`](../../HAN_chat/deploy/pg-init.env.example). +- Legacy nginx: [`../../HAN_chat/bitrix-local-app/deploy/nginx-tohin.ru.site.conf`](../../HAN_chat/bitrix-local-app/deploy/nginx-tohin.ru.site.conf), [`../../HAN_chat/bitrix-local-app/deploy/nginx/nginx.conf`](../../HAN_chat/bitrix-local-app/deploy/nginx/nginx.conf). +- Legacy TLS: [`../../HAN_chat/bitrix-local-app/deploy/ssl-issue.sh`](../../HAN_chat/bitrix-local-app/deploy/ssl-issue.sh), [`../../HAN_chat/bitrix-local-app/deploy/ssl-renew.sh`](../../HAN_chat/bitrix-local-app/deploy/ssl-renew.sh), [`../../HAN_chat/bitrix-local-app/deploy/ssl-install-cron.sh`](../../HAN_chat/bitrix-local-app/deploy/ssl-install-cron.sh).