Перейти к содержанию

API: Step-Up Policies

Endpoints для оценки требуемого уровня дополнительной аутентификации (StepUp) перед платёжной операцией. Вычисление только read-only — движок не пишет в БД и не изменяет состояние.

Связанные документы:


Общее

Все policy-эндпоинты находятся под HMAC-защищённым scope (см. ./auth.md). Запросы делает Auth Center (Serverpod) перед созданием инвойса или перевода; реже — mini-app бэкенды для предсказания UX. Admin Panel /policies/* не вызывает (административные операции выполняются service-to-service без StepUp).

Обязательные заголовки

Header Назначение
X-Service-Id Идентификатор сервиса-инициатора (auth-center, mini-app-x).
X-Timestamp Unix-секунды на момент подписи (окно ±60 с).
X-Signature HMAC-SHA256 от канонической строки TS\nMETHOD\nPATH\nSHA256(body).
X-User-Id ID пользователя-инициатора (контекст user-операции).

Опциональные заголовки

Header Назначение
X-App-Id Идентификатор приложения-источника (wallet, mini-app-x). Сейчас передаётся в body (appId); header — план на будущее (см. spec MERCHANT_INVOICE).

Уровни StepUp (StepUpLevel)

Возвращаемое значение required — одно из пяти:

Уровень Что требуется от пользователя
NONE Подтверждение не нужно — операция проходит сразу.
PIN Ввод 4–6-значного PIN-кода.
BIOMETRIC Touch ID / Face ID (платформенная биометрия).
OTP Одноразовый код по SMS / push / e-mail.
KYC_UPLIFT Поднять уровень KYC (загрузить документы, селфи и т. д.).

Определение типа: src/shared/schema.ts:330.


1. POST /policies/evaluate

Назначение

Вычисляет требуемый StepUpLevel для конкретной платёжной операции на основе активных правил из pm.auth_policies. Возвращает первое совпавшее правило (с наименьшим priority) или NONE, если ни одно условие не выполнилось.

Источник: src/policies/routes.ts:50.

Authentication

  • HMAC (см. ./auth.md) — обязательно.
  • X-User-Id — обязательно (операция выполняется в контексте конкретного пользователя; используется для вычисления dailyCumulative и isNewPayee).

Request body

Content-Type: application/json.

Поле Тип Обяз. Описание
userId integer (> 0) да ID пользователя-инициатора. Должен совпадать с X-User-Id.
amount string (^\d+$) да Сумма в минимальных единицах валюты (satang для THB). Строка — чтобы избежать потери точности.
currency string (3 симв.) да ISO 4217: THB, USD, ...
channel string (1–50) да Канал платежа: INTERNAL_P2P, IPPS_TRANSFER, MERCHANT_INVOICE, ADMIN и т. д. (см. ../reference/passport/02-channels.md).
appId string (1–50) да Идентификатор приложения-источника. Используется для scope app:<appId> при выборке политик.
operationType string (1–50) да Тип операции: INVOICE_PAYMENT, P2P_TRANSFER, и т. п. (см. ../reference/passport/03-operation-types.md).
merchantId integer (> 0) нет ID мерчанта-получателя. Если указан — добавляется scope merchant:<id> и вычисляется isNewPayee.

Замечание. Источник scope — комбинация полей appId + merchantId. Самостоятельного поля scope в теле запроса нет; движок сам строит список областей [global, app:<appId>, merchant:<merchantId>?] и выбирает активные правила из этих scope.

Response 200 — PolicyDecision

{
  "required":   "PIN",            // StepUpLevel: NONE | PIN | BIOMETRIC | OTP | KYC_UPLIFT
  "reasonCode": "large_amount",   // машинно-читаемый код причины (для UI / аудита)
  "policyId":   42,               // ID правила из pm.auth_policies (0 — нет совпадения, дефолт NONE)
  "context": {
    "dailyCumulative": "150000",  // дневной оборот пользователя (минимальные единицы; строка)
    "isNewPayee":      true       // true — пользователь раньше не платил этому мерчанту
  }
}
Поле Тип Описание
required StepUpLevel Требуемый уровень аутентификации. Auth Center сам решает, как собрать фактор (PIN-pad, FaceID, OTP, KYC challenge).
reasonCode string Код причины из правила (reason_code в pm.auth_policies). Для дефолтного NONE-ответа — no_match.
policyId number ID совпавшего правила. 0 — ни одно правило не сработало (вернулся NONE).
context.dailyCumulative string (BigInt) Сумма успешных дебет-транзакций пользователя в данной валюте с начала суток UTC. Сериализована строкой.
context.isNewPayee boolean true, если у пользователя нет ни одного SETTLED-перевода данному merchantId (актуально только если merchantId передан).

Алгоритм оценки (кратко)

  1. Считается AuthContext: dailyCumulative (суммa SETTLED-дебетов за сегодня) и isNewPayee (если есть merchantId).
  2. Загружаются активные правила (active = true) для scope-ов global, app:<appId> и, если задан, merchant:<merchantId>, отсортированные по priority ASC.
  3. Для каждого правила проверяется condition (AND по ключам: currency, channel, amount_lt, amount_gte, amount_lte, daily_cumulative_gte, new_payee). Первое совпавшее — финальное.
  4. Если ни одно не сработало — возвращается { required: 'NONE', reasonCode: 'no_match', policyId: 0 }.

Подробнее о matcher-е: ../modules/policies.md, исходник: src/limits/evaluate-policy.ts.

Errors

HTTP Условие Тело
400 Невалидное тело (Zod-валидация: формат amount, длина currency). { statusCode, error: 'Bad Request', message }
401 Отсутствует / просрочена HMAC-подпись. { error: 'unauthorized', reason } (см. ./auth.md)
403 HMAC-сервис не имеет прав вызывать /policies/*. { error: 'forbidden' }
500 Ошибка чтения БД или непредвиденное исключение. { statusCode: 500, error: 'Internal Server Error' }

Эндпоинт не возвращает 404 для «нет правила» — это нормальный кейс, отдаётся 200 { required: 'NONE' }.

Curl-пример

# Подготовка
BODY='{"userId":1,"amount":"200000","currency":"THB","channel":"IPPS_TRANSFER","appId":"wallet","operationType":"P2P_TRANSFER","merchantId":42}'
TS=$(date +%s)
HASH=$(printf '%s' "$BODY" | sha256sum | awk '{print $1}')
MSG=$(printf '%s\nPOST\n/policies/evaluate\n%s' "$TS" "$HASH")
SIG=$(printf '%s' "$MSG" | openssl dgst -sha256 -hmac "$AUTH_CENTER_SECRET" -hex | awk '{print $2}')

curl -X POST http://pm.internal/policies/evaluate \
  -H "Content-Type: application/json" \
  -H "X-Service-Id: auth-center" \
  -H "X-Timestamp: $TS" \
  -H "X-Signature: $SIG" \
  -H "X-User-Id: 1" \
  -d "$BODY"

Пример успешного ответа (правило large_amount сработало для суммы 2000.00 THB):

{
  "required":   "PIN",
  "reasonCode": "large_amount",
  "policyId":   5,
  "context": { "dailyCumulative": "0", "isNewPayee": false }
}

Идемпотентность и побочные эффекты

  • Эндпоинт идемпотентный — повторный вызов с теми же параметрами вернёт тот же результат (если правила и история транзакций не изменились).
  • Не пишет в БД, не меняет состояние интентов, не создаёт Outbox-событий.
  • Не кэшируется на стороне PM — каждый вызов перечитывает pm.auth_policies и pm.tx_history. Изменения правил применяются мгновенно.

Поддерживаемые ключи condition

Все ключи внутри condition (JSONB-поле правила) применяются по AND. Если ключ не указан — он не влияет на проверку.

Ключ Тип Семантика
currency string Совпадает с body.currency. Например, "THB".
channel string Совпадает с body.channel. Например, "IPPS_TRANSFER".
amount_lt number body.amount < value (строго меньше).
amount_gte number body.amount >= value.
amount_lte number body.amount <= value.
daily_cumulative_gte number context.dailyCumulative >= value.
new_payee boolean context.isNewPayee == value. Имеет смысл только при наличии merchantId.

Числовые значения сравниваются как BigInt — точность не теряется. Поле operationType пока не участвует в condition напрямую (фильтрация по типу делается через scope и channel).

Примеры разных уровней StepUp

NONE (правило не сработало)

Запрос: малая сумма, дневной оборот 0, не новый получатель.

{ "required": "NONE", "reasonCode": "no_match", "policyId": 0,
  "context": { "dailyCumulative": "0", "isNewPayee": false } }

PIN (большая сумма)

Правило: condition = { "amount_gte": 100000 }, required_step_up = "PIN", reason_code = "large_amount".

{ "required": "PIN", "reasonCode": "large_amount", "policyId": 5,
  "context": { "dailyCumulative": "0", "isNewPayee": false } }

BIOMETRIC (новый получатель)

Правило: scope = "merchant:*", condition = { "new_payee": true }, required_step_up = "BIOMETRIC".

{ "required": "BIOMETRIC", "reasonCode": "new_payee", "policyId": 9,
  "context": { "dailyCumulative": "100000", "isNewPayee": true } }

OTP (порог дневного оборота)

Правило: condition = { "daily_cumulative_gte": 500000 }, required_step_up = "OTP".

{ "required": "OTP", "reasonCode": "high_daily_volume", "policyId": 12,
  "context": { "dailyCumulative": "520000", "isNewPayee": false } }

KYC_UPLIFT (порог KYC tier)

Правило: condition = { "amount_gte": 5000000 }, required_step_up = "KYC_UPLIFT".

{ "required": "KYC_UPLIFT", "reasonCode": "kyc_tier_required", "policyId": 20,
  "context": { "dailyCumulative": "0", "isNewPayee": false } }

Идемпотентность и побочные эффекты

  • Эндпоинт идемпотентный — повторный вызов с теми же параметрами вернёт тот же результат (если правила и история транзакций не изменились).
  • Не пишет в БД, не меняет состояние интентов, не создаёт Outbox-событий.
  • Не кэшируется на стороне PM — каждый вызов перечитывает pm.auth_policies и pm.tx_history. Изменения правил применяются мгновенно.
  • Производительность: 2 коротких SELECT по индексу + 1 SELECT по auth_policies_lookup_idx. p95 < 20 ms в стабильной нагрузке.

Best practices для вызывающих

  1. Всегда передавайте merchantId для платежей мерчанту — без него политика new_payee не сработает, и пользователь может оплатить нового получателя без BIOMETRIC.
  2. Не кэшируйте PolicyDecision на стороне Auth Center дольше, чем длится одна сессия StepUp — dailyCumulative меняется после каждого SETTLED-перевода.
  3. Обрабатывайте NONE явно — это валидный ответ, не ошибка. Auth Center должен просто пропустить шаг StepUp.
  4. Логируйте policyId и reasonCode в трассировке — это ключ для аудита («почему пользователь увидел OTP-челлендж?»).
  5. Используйте одинаковый appId для всех вызовов из одного клиента (например, wallet), иначе scope app:<appId> не сможет таргетировать правила.

Связанные эндпоинты

  • POST /intents — после успешного StepUp Auth Center создаёт интент.
  • Управление правилами pm.auth_policies — пока только через прямой SQL / Admin Panel (отдельных REST-эндпоинтов нет).

Ссылки