mTLS, JWS-подпись webhooks и AsyncAPI с outbox
Зачем третья глава
| Глава | Фокус |
|---|---|
| 117 | REST, контракт, партнёрский API заказов, HMAC-ошибки |
| 1171 | Публичный API, OAuth 2.0, webhooks с shared secret |
| Эта глава | Регулируемый B2B: mTLS на транспорте, JWS на теле webhook, AsyncAPI на поток событий, outbox — чтобы событие не потерялось после commit в БД |
Сценарий: банк-партнёр подключается к платформе выплат. Требования: взаимная аутентификация по сертификату, неотрекаемость доставки уведомлений, аудит по eventId, согласованный каталог сообщений до деплоя.
Слои доверия — что где проверять
- mTLS отвечает на вопрос «кто установил TCP/TLS-соединение» (сертификат клиента).
- OAuth / scope (если есть) — «какие операции разрешены этому client_id».
- JWS на webhook — «это тело не менялось после отправки платформой» (асимметричная подпись, ротация ключей через JWKS).
Подробнее про сертификаты: 8.07.112. HMAC из 1171 проще, но у крупных партнёров часто требуют асимметрию (платформа подписывает приватным ключом, банк проверяет публичным).
mTLS — проектирование доступа
Регистрация клиента
В консоли партнёра заводят запись:
| Поле | Пример | Зачем |
|---|---|---|
partnerId | bank_alfa | ключ в логах и rate limit |
clientCertSubjectCN | payments-api.bank.example | сопоставление с сертификатом |
allowedIPs | опционально | дополнительный фильтр |
environment | sandbox / prod | разные trust store |
Сертификат выпускает ваш CA или принимается сертификат из доверенного УЦ партнёра — политика фиксируется в договоре.
Терминация TLS
Типичная схема:
- API Gateway / Ingress завершает mTLS, прокидывает в заголовках
X-Client-Cert-Subjectили SPIFFE ID. - Сервис приложения не парсит PEM в каждом handler — идентичность уже проверена на краю.
Партнёр вызывает:
GET /v1/payouts/po_7f3c9a2e HTTP/1.1
Host: api.payments.example
# TLS: клиентский сертификат bank_alfa (clientAuth)
Accept: application/json
Ответ 403 без валидного клиентского сертификата — ожидаемое поведение; тело ошибки в том же формате, что в 117.
Ротация сертификатов
- Перекрытие сроков: новый cert работает до отзыва старого (30–90 дней).
- В документации — fingerprint SHA-256 и дата
notAfter. - Алерт за 14 дней до истечения — обязанность обеих сторон.
mTLS дополняет OAuth для machine-to-machine: cert = «это именно инфраструктура банка», token = «этому client разрешён scope payouts:read».
Webhooks с JWS — вместо одного shared secret
Заголовки доставки
| Заголовок | Значение |
|---|---|
Content-Type | application/json |
X-Webhook-Id | evt_01JABC123 (идемпотентность — 213) |
X-Webhook-Timestamp | 1716816005 |
X-Webhook-Signature | eyJhbGciOiJFERTQSIs... — compact JWS над каноническим payload |
Что подписываем
Строка для подписи (псевдоконтракт):
{timestamp}.{raw_body_bytes}
raw_body_bytes — тело HTTP до JSON.parse. Партнёр повторяет ту же конкатенацию и проверяет JWS алгоритмом из заголовка JWS (alg: EdDSA, RS256).
Пример тела:
{
"id": "evt_01JABC123",
"type": "payout.completed",
"createdAt": "2026-05-27T14:00:00Z",
"data": {
"payoutId": "po_7f3c9a2e",
"amount": { "value": "1500.00", "currency": "RUB" },
"status": "completed"
}
}
Платформа публикует JWKS по GET https://api.payments.example/.well-known/webhook-jwks.json — ротация kid без смены URL у партнёра.
Проверка на стороне банка (псевдокод)
import { createRemoteJWKSet, compactVerify } from 'jose';
const JWKS = createRemoteJWKSet(
new URL('https://api.payments.example/.well-known/webhook-jwks.json')
);
async function verifyWebhook(rawBody, timestamp, jwsCompact, maxSkewSec = 300) {
if (Math.abs(Date.now() / 1000 - Number(timestamp)) > maxSkewSec) {
throw new Error('timestamp skew');
}
const signingInput = new TextEncoder().encode(`${timestamp}.${rawBody}`);
const { payload } = await compactVerify(jwsCompact, JWKS);
const signed = new TextDecoder().decode(payload);
if (signed !== new TextDecoder().decode(signingInput)) {
throw new Error('payload mismatch');
}
}
Партнёр по-прежнему отвечает 2xx за секунды, тяжёлую обработку кладёт во внутреннюю очередь — как в 1171.
AsyncAPI — контракт на события
OpenAPI описывает синхронные HTTP-вызовы. AsyncAPI — каналы, сообщения, схемы payload для Kafka/RabbitMQ и для исходящих webhooks как «канал доставки».
Фрагмент AsyncAPI 3:
asyncapi: 3.0.0
info:
title: Payments Platform Events
version: 1.0.0
channels:
payoutEvents:
address: payments.payout.v1
messages:
payoutCompleted:
name: payout.completed
payload:
$ref: '#/components/schemas/PayoutCompleted'
partnerWebhook:
address: https://bank.example/hooks/payments
messages:
payoutCompletedWebhook:
name: payout.completed
payload:
$ref: '#/components/schemas/PayoutCompleted'
components:
schemas:
PayoutCompleted:
type: object
required: [id, type, createdAt, data]
properties:
id: { type: string }
type: { const: payout.completed }
createdAt: { type: string, format: date-time }
data:
type: object
required: [payoutId, amount, status]
Практика:
- одна JSON Schema на
PayoutCompleted— и в Kafka, и в webhook (нет расхождения полей); - ревью AsyncAPI до изменения producer;
- генерация типов (TypeScript, Java) и тестовых фикстур.
Синхронный REST по-прежнему в OpenAPI — 7.08.3.
Transactional outbox — от commit до webhook
Проблема: UPDATE payouts SET status='completed' закоммичен, а публикация в Kafka или HTTP webhook упала — партнёр не узнает о выплате.
Шаг 1 — одна транзакция в БД
BEGIN;
UPDATE payouts SET status = 'completed', updated_at = now()
WHERE id = 'po_7f3c9a2e';
INSERT INTO outbox (id, aggregate_type, aggregate_id, event_type, payload, created_at)
VALUES (
'evt_01JABC123',
'payout',
'po_7f3c9a2e',
'payout.completed',
'{"payoutId":"po_7f3c9a2e","amount":{"value":"1500.00","currency":"RUB"},"status":"completed"}',
now()
);
COMMIT;
Таблица outbox — источник правды до успешной доставки. Паттерн разобран в Saga / Outbox и событийной архитектуре.
Шаг 2 — relay в брокер
Worker читает published_at IS NULL, публикует в payments.payout.v1, помечает строку:
UPDATE outbox SET published_at = now() WHERE id = 'evt_01JABC123';
Несколько инстансов — FOR UPDATE SKIP LOCKED (пример в 5.03 Java / outbox).
Шаг 3 — delivery worker → webhook
Отдельный consumer (или тот же relay с веткой) строит конверт по AsyncAPI, подписывает JWS, шлёт POST на URL подписки банка. При 5xx — retry с backoff; при исчерпании — DLQ и алерт partnerId=bank_alfa.
Идемпотентность: банк хранит evt_01JABC123 72 часа; повторная доставка → 200 без повторного списания в их учёте.
Сводка — когда что включать
| Требование партнёра | Механизм |
|---|---|
| Доступ только с их ДЦ, без паролей в конфиге | mTLS |
| Scope и аудит по приложению | OAuth2 client credentials + mTLS |
| Неотрекаемость и ротация ключей подписи | JWS + JWKS |
| Согласованный каталог событий | AsyncAPI 3 |
| Не потерять событие после записи в БД | Transactional outbox → broker → webhook |
Чеклист перед production
- Trust store sandbox/prod разделены; тестовый cert не принимается в prod.
- JWKS содержит минимум два
kidпри ротации. - AsyncAPI и OpenAPI версионируются вместе (
info.version, changelog). - Outbox мониторится: возраст непубликованных строк, DLQ webhook.
- Пентест / 8.07.128 включает replay webhook и отсутствие
alg: noneв JWS.
Смежные материалы
| Тема | Статья |
|---|---|
| Партнёрский REST (заказы) | 7.06.117 |
| OAuth и HMAC-webhooks | 7.06.1171 |
| Outbox в Saga | 7.06.2124 |
| Kafka в интеграции | 2.09.123 |
| Маршрут по главам API | 7.06.117 |
См. также
Другие статьи этого же раздела в боковом меню (как на странице "О разделе"). Каждая система имеет свою архитектуру построения; систему нужно разворачивать под нагрузку; нужно понимать обновления и исправление ошибок; рано или поздно — интеграция, безопасность, расширение и поддержка. Подход к проектированию — это стратегия, которая определяет, откуда начинается работа над системой и в каком порядке формируются её компоненты. SOLID, DRY, KISS, YAGNI, закон Конвея и SOC — критерии оценки решений с примерами нарушений и чек-листами для поддерживаемого кода. Проектирование сервисов - от микросервисов до доменных сервисов в DDD и как не путать уровни ответственности. Любое действие пользователя — это запрос на изменение состояния, а не прямая команда. Как формулировать измеримые NFR и переводить их в архитектурные решения: масштабирование, отказоустойчивость, безопасность, observability. Традиционный подход: 1. Команда проектирует систему, 2. Пишет код, 3. По завершении — создаёт документацию для сдачи заказчику или архивирования. Проектирование баз данных — это системная инженерная дисциплина, направленная на создание структуры хранения данных, которая обеспечивает корректность, целостность, производительность, расширяемость. Современные программные системы редко существуют изолированно. Второй сквозной пример — публичный REST API с OAuth 2.0 (PKCE и client credentials), scopes и входящие webhooks с подписью, идемпотентностью и политикой повторов. Декомпозиция, API Gateway, database per service, Saga, observability и антипаттерны — практика микросервисов. Переходите к изучению этой статьи только после того, как изучите микросервисы.Проектирование программных систем
Подходы к проектированию
Принципы проектирования
Проектирование сервисов и методов
Проектирование функциональных UI
Проектирование под нефункциональные требования
Документация как инструмент проектирования
Проектирование баз данных
Проектирование API и интеграций
Публичный API, OAuth 2.0 и webhooks
Паттерны микросервисной архитектуры
Проектирование веб-разработки