From c355b87b8836090f0d77d23908e139ab10b035bc Mon Sep 17 00:00:00 2001 From: DanliaQwerty20 Date: Sat, 26 Sep 2026 15:43:02 +0300 Subject: [PATCH] docs: define Google Calendar connection flow --- docs/adr/0015-google-calendar-connection.md | 97 +++++++++++++++++++++ docs/delivery/CURRENT_STATUS.md | 16 ++-- docs/delivery/MVP_ROADMAP.md | 10 ++- mkdocs.yml | 1 + scripts/check-docs.ps1 | 18 +++- 5 files changed, 131 insertions(+), 11 deletions(-) create mode 100644 docs/adr/0015-google-calendar-connection.md diff --git a/docs/adr/0015-google-calendar-connection.md b/docs/adr/0015-google-calendar-connection.md new file mode 100644 index 0000000..ab1ee05 --- /dev/null +++ b/docs/adr/0015-google-calendar-connection.md @@ -0,0 +1,97 @@ +# ADR-0015: Google Calendar подключается через отдельный Connection Service + +- Status: accepted +- Date: 2026-09-26 + +## Контекст + +`calendar-mcp` уже выполняет подтверждённую команду, но сейчас пишет событие только в память. +Настоящий Google Calendar требует OAuth-доступ конкретного пользователя. Refresh token нельзя хранить +в Telegram Adapter, Action Service или MCP-коннекторе: иначе смена канала либо провайдера переносит +секреты в бизнес-сервисы. + +Action Service уже сохраняет проверенные `tenantId` и `actorId` из JWT. При выполнении Temporal +использует service account, поэтому обычный `sub` этого токена обозначает сервис, а не владельца +календаря. Нельзя молча подменять его пользовательским идентификатором. + +## Решение + +### Connection Service + +Создаём отдельный `connection-service`. Он отвечает только за подключения внешних аккаунтов: + +- запускает Google OAuth 2.0 Authorization Code flow; +- проверяет одноразовый `state` и использует PKCE; +- запрашивает `access_type=offline` и минимальный scope `calendar.events`; +- связывает подключение с `tenantId` и `actorId` из проверенного Portable Agent JWT; +- хранит refresh token в PostgreSQL только в зашифрованном виде; +- получает новый короткоживущий access token и отдаёт его только разрешённому сервису; +- отключает подключение и обрабатывает истёкший либо отозванный token. + +Ключ шифрования приходит из secret manager. В Git, логах, событиях и Temporal history нет Google +client secret, authorization code, access token или refresh token. + +### Доверенный контекст выполнения + +Модель предлагает только данные встречи и не выбирает внешний аккаунт. Action Service загружает +сохранённое действие и передаёт `tenantId`, `actorId`, `actionId` и `requestKey` как отдельный +доверенный execution context. MCP Gateway принимает этот контекст только от service account Action +Service. Пользовательские JSON-поля не могут переопределить его. + +Calendar MCP передаёт `tenantId` и `actorId` в Connection Service. Тот находит активное подключение +Google Calendar этого пользователя и возвращает короткоживущий access token. Refresh token никогда +не покидает Connection Service. + +Если подключений нет или их несколько без выбранного default, действие не выполняется скрытно. +Пользователь получает понятный результат `connection_required` или выбирает календарь до нового +подтверждения. + +### Провайдер календаря + +В `calendar-mcp` остаётся один use case `create_event` и две стратегии: + +- `fake-calendar` — детерминированный CI и локальная разработка без внешнего аккаунта; +- `google-calendar` — ручной sandbox N2N и дальнейшие окружения. + +Google-адаптер использует Calendar API `events.insert` и scope +`https://www.googleapis.com/auth/calendar.events`. Идентификатор события вычисляется из стабильного +`tenantId + actorId + requestKey`, кодируется в допустимый Google формат base32hex и передаётся как +`event.id`. Повтор после сетевой ошибки делает `events.get`: он возвращает прежнее событие вместо +создания дубля. В `extendedProperties.private` сохраняется обезличенный hash request key для +диагностики, но не исходная пользовательская фраза. + +## Границы API + +- Публичные OAuth start/callback/disconnect принадлежат Connection Service. +- Внутренний token endpoint требует service JWT с audience `connection-service` и scope + `connection:token`. +- Calendar MCP не принимает refresh token через MCP tool. +- MCP Gateway не хранит токены и не выбирает календарь. +- Action Service не шифрует provider credentials и не вызывает Google API. +- Telegram показывает ссылку подключения, но не становится владельцем Google OAuth-сессии. + +## Порядок реализации + +1. Добавить контракт доверенного execution context и тест, что его нельзя подменить payload-ом. +2. Создать `connection-service` с PostgreSQL, jOOQ, шифрованием и Google OAuth stub. +3. Добавить contract-тесты start/callback/refresh/revoke без настоящего Google аккаунта. +4. Разделить `CalendarRepository` на хранилище fake-событий и стратегию внешнего провайдера. +5. Добавить Google strategy и WireMock-совместимый stub Calendar API. +6. Включить ручной sandbox N2N только через локальные secrets. +7. После подтверждённого N2N добавить trace и безопасные логи по всему пути. + +## Последствия + +- появляется отдельный stateful security-сервис и ещё одна БД; +- настоящий календарь нельзя завершить одним изменением только в `calendar-mcp`; +- fake-сценарий остаётся быстрым и доступным каждому contributor; +- новые Jira, Outlook и другие OAuth-подключения используют тот же Connection Service, но отдельные + provider adapters; +- первый ручной запуск потребует создать Google OAuth client и разрешённый redirect URI вне Git. + +## Источники + +- [Google OAuth для server-side приложений](https://developers.google.com/identity/protocols/oauth2/web-server) +- [Google OAuth best practices](https://developers.google.com/identity/protocols/oauth2/resources/best-practices) +- [Google Calendar: создание события](https://developers.google.com/workspace/calendar/api/v3/reference/events/insert) +- [Google Calendar: свои event ID](https://developers.google.com/workspace/calendar/api/guides/create-events) diff --git a/docs/delivery/CURRENT_STATUS.md b/docs/delivery/CURRENT_STATUS.md index bbb2cb5..2eff2c9 100644 --- a/docs/delivery/CURRENT_STATUS.md +++ b/docs/delivery/CURRENT_STATUS.md @@ -1,6 +1,6 @@ # Текущее состояние по репозиториям -Дата среза: 25 сентября 2026 года. Этот файл обновляется после проверенного локального этапа или мержа, +Дата среза: 26 сентября 2026 года. Этот файл обновляется после проверенного локального этапа или мержа, а не после незавершённого эксперимента. | Репа | Что меняем или добавляем | Ожидаемый результат | Фактический результат | Следующий шаг | @@ -8,11 +8,11 @@ | `.github` | Общие workflows и правила | Одинаковый CI/CD для всех сервисов | Java, Python, Node, docs, security и container workflows работают; чистый Trivy runner исправлен | Подключать правила к каждой новой репе | | `contracts` | Версионируемые внешние и внутренние API | Один проверяемый источник сетевых DTO | Release `2.5.0` опубликован с checksum и provenance; общий MessageContext устраняет циклические OpenAPI-ссылки | Использовать release во всех новых адаптерах | | `action-service` | Подтверждение и надёжное выполнение | `AWAITING_APPROVAL → APPROVED → EXECUTING → SUCCEEDED/FAILED` | jOOQ, outbox, Temporal worker и вызов MCP Gateway работают; решение приходит через Channel Gateway | Оставить источником истины для решения и статуса | -| `calendar-mcp` | `create_event` | Идемпотентное создание fake-события | Fake Calendar, OIDC и защита от дублей работают в общем сценарии | Добавить Google provider за тем же контрактом | +| `calendar-mcp` | `create_event` | Идемпотентное создание fake-события | Fake Calendar, OIDC и защита от дублей работают в общем сценарии | Добавить Google provider после trusted execution context и Connection Service | | `mcp-gateway` | OIDC, allowlist и MCP client | Безопасный stateless-маршрутизатор | Foundation смержен и проверен вызовом Calendar MCP | Добавлять коннекторы только по контракту | -| `deploy` | Приложения в локальном Compose | Одна команда поднимает вертикальный backend-срез | Реальный Telegram webhook, Keycloak Device Flow и полный backend работают локально | Сохранить real Telegram N2N как ручной smoke | -| `test-lab` | Календарный acceptance-тест | Один JWT и один контракт на всём пути | Карточка, решение через Gateway, Temporal, offset и отсутствие дубля проверены | Добавить black-box сценарий Telegram и Google stub | -| `agent-runtime` | Текст в предложение действия | Типизированное предложение встречи без скрытого выполнения | Технический ISO-формат работает, но не является пользовательским интерфейсом | Добавить `IntentModel`, естественный язык и structured output | +| `deploy` | Приложения в локальном Compose | Одна команда поднимает вертикальный backend-срез | Реальный Telegram и opt-in Ollama/Qwen профиль работают отдельными командами | Подключить Connection Service и Google stub | +| `test-lab` | Календарный acceptance-тест | Один JWT и один контракт на всём пути | Fake E2E проверяет 19 условий; opt-in AI E2E понимает обычную русскую фразу | Добавить Google OAuth и Calendar API stub | +| `agent-runtime` | Текст в предложение действия | Типизированное предложение встречи без скрытого выполнения | `IntentModel`, demo и OpenAI-compatible provider работают; Qwen 7B проверена локально | Добавить уточнение при неполных данных | | `channel-gateway` | Общий вход каналов | Web и Telegram используют один контракт | Сообщение идёт через Conversation; решение канала — через Gateway в Action | Проверить Telegram в общем Compose-сценарии | | `conversation-service` | Состояние диалога и прикладная оркестрация | Повтор сообщения не создаёт второе действие | PostgreSQL, lease, Agent → Action, карточка и сохранение offset проверены общим E2E | Оставить владельцем диалога при подключении адаптера | | `widget-sdk` | Карточка подтверждения | Один UI-контракт для разных каналов | Renderer-neutral core создаёт проверенную decision command; Telegram использует тот же сетевой контракт | Добавить готовые helpers для следующих UI-каналов | @@ -60,8 +60,10 @@ Telegram Adapter отдельно проверен unit-, contract- и PostgreSQ -> [готово] публичная репа, CI и container image -> [готово] Keycloak client, fake Telegram API и Compose E2E -> [готово] настоящий Telegram bot, webhook и Device Flow - -> [следом] естественный язык и уточнения в Agent Runtime - -> [затем] Google Calendar provider и OAuth + -> [готово] естественный язык через OpenAI-compatible provider и локальную Qwen + -> [решено] Google OAuth принадлежит Connection Service, а не каналу или MCP + -> [следом] trusted execution context и каркас Connection Service + -> [затем] Google Calendar provider и OAuth stub -> [после real N2N] trace в Tempo и связанные логи в Loki/Grafana ``` diff --git a/docs/delivery/MVP_ROADMAP.md b/docs/delivery/MVP_ROADMAP.md index ac49943..b93b73b 100644 --- a/docs/delivery/MVP_ROADMAP.md +++ b/docs/delivery/MVP_ROADMAP.md @@ -10,9 +10,9 @@ Правила первого сценария подтверждены. Начинаем с `fake-calendar`; обязательны название, начало, конец и часовой пояс; создание всегда требует явного подтверждения. Описание и участники необязательны. -Fake-сценарий и настоящий Telegram уже доказали транспортный путь. Следующие продуктовые решения — -разбор естественного языка и подключение настоящего календаря. Speech-to-text остаётся следующим -адаптером того же текстового сценария. +Fake-сценарий и настоящий Telegram уже доказали транспортный путь. Естественная русская фраза проверена +через локальную Qwen с typed structured output. Следующий продуктовый этап — Connection Service и +настоящий Google Calendar. Speech-to-text остаётся следующим адаптером того же текстового сценария. Переводы денег не входят в MVP. До отдельного threat model они доступны только как будущие `read-only` или `simulation` сценарии. @@ -86,6 +86,10 @@ Fake-сценарий и настоящий Telegram уже доказали т - покрыть Google API contract-тестами на stub и отдельным ручным sandbox N2N; - оставить fake-провайдер обязательным для автономного CI и внешних contributors. +Граница OAuth и передача пользователя зафиксированы в ADR-0015: refresh token принадлежит отдельному +Connection Service, а Calendar MCP получает только короткоживущий access token для доверенного +`tenantId + actorId`. + Результат: подтверждённая команда из Telegram создаёт реальное событие Google Calendar, а тесты не требуют внешнего аккаунта. diff --git a/mkdocs.yml b/mkdocs.yml index ca7f48c..b502fe5 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -36,3 +36,4 @@ nav: - Contract-first: adr/0012-contract-first.md - Conversation Service: adr/0013-conversation-orchestration.md - Привязка Telegram: adr/0014-telegram-account-link.md + - Подключение Google Calendar: adr/0015-google-calendar-connection.md diff --git a/scripts/check-docs.ps1 b/scripts/check-docs.ps1 index 6b83d82..1b77319 100644 --- a/scripts/check-docs.ps1 +++ b/scripts/check-docs.ps1 @@ -8,7 +8,8 @@ $requiredFiles = @( "docs/index.md", "docs/development.md", "docs/runbook.md", - "docs/architecture/PLATFORM.md" + "docs/architecture/PLATFORM.md", + "docs/adr/0015-google-calendar-connection.md" ) $missingFiles = $requiredFiles | Where-Object { -not (Test-Path -LiteralPath $_ -PathType Leaf) } @@ -40,4 +41,19 @@ if ($mkdocsText -notmatch "(?m)^docs_dir:\s*docs\s*$") { throw "mkdocs.yml must contain docs_dir: docs." } +$googleAdr = Get-Content -LiteralPath "docs/adr/0015-google-calendar-connection.md" -Raw +foreach ($required in @( + "connection-service", + "Authorization Code", + "refresh token", + "actorId", + "calendar.events", + "base32hex", + "fake-calendar" +)) { + if ($googleAdr -notmatch [regex]::Escape($required)) { + throw "Google Calendar ADR does not contain $required." + } +} + Write-Host "Documentation follows the project standard."