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

Cookbook: Написать новую auth-policy (step-up правило)

Краткое руководство по созданию нового правила step-up аутентификации в таблице pm.auth_policies. Правило определяет, какой уровень подтверждения (PIN, BIOMETRIC, OTP, KYC_UPLIFT) требуется от пользователя для конкретного платёжного сценария.

Перед чтением рекомендуется ознакомиться со справочником полей в ../../AUTH-POLICIES.md — здесь мы концентрируемся на пошаговом workflow и end-to-end примере.

1. Когда использовать

Используйте этот рецепт, если:

  • Появилось новое требование compliance / антифрод (например, KYC при дневном обороте > 50 000 THB).
  • Нужно подтвердить PIN'ом первый платёж новому мерчанту.
  • Нужно установить более строгое правило для отдельного mini-app или конкретного мерчанта.
  • Нужно временно отключить step-up для микроплатежей (например, маркетинговая акция).

Не используйте, если:

  • Требуется новый уровень step-up (например, VIDEO_CALL) — это изменение enum StepUpLevel в src/shared/schema.ts + миграция CHECK-constraint, не просто новая строка.
  • Требуется новый ключ condition (например, weekly_cumulative_gte) — нужно править matcher в src/limits/evaluate-policy.ts, см. §10.
  • Нужно блокировать платёж полностью — это работа rule-engine, а не auth-policy. Auth-policy не отказывает, она только повышает требования.

2. Решения, которые надо принять ДО SQL

Четыре параметра задаются для каждого правила:

Параметр Возможные значения Где описано
scope global / app:<id> / merchant:<id> §3
condition (JSONB) amount_lt, amount_gte, amount_lte, currency, channel, daily_cumulative_gte, new_payee §4
required_step_up NONE / PIN / BIOMETRIC / OTP / KYC_UPLIFT §5
priority integer; меньше = первее (lower-wins) §6

3. Шаг 1 — выбрать scope

global                  ← применяется ко всем платежам, независимо от приложения и мерчанта
app:<appId>             ← только платежи из конкретного mini-app (поле PolicyRequest.appId)
merchant:<merchantId>   ← только платежи конкретному мерчанту (PolicyRequest.merchantId)

Движок одновременно загружает все три скоупа (global, app:<id>, merchant:<id>) и сортирует все найденные правила по priority. Конкретный скоуп не имеет преимущества перед глобальным — правит только priority.

4. Шаг 2 — выбрать ключи condition

Все указанные ключи объединяются AND — правило срабатывает, только если все условия выполняются одновременно.

Ключ Тип Семантика
amount_lt number req.amount < value
amount_gte number req.amount >= value
amount_lte number req.amount <= value
currency string req.currency === value (ISO 4217)
channel string req.channel === value (INTERNAL_P2P, IPPS_TRANSFER, MERCHANT_INVOICE, ADMIN, ...)
daily_cumulative_gte number Сумма settled DEBIT-операций пользователя за сегодня (UTC) >= value
new_payee boolean true — мерчант ранее не получал settled-платежей от этого пользователя. Работает только если в запросе передан merchantId.

Суммы — в наименьших единицах валюты. Для THB: 1 бат = 100 сатангов → 100 бат = 10000, 5 000 бат = 500000.

5. Шаг 3 — выбрать required_step_up

Уровень Когда уместен
NONE Микроплатежи, известный получатель, всё прозрачно — UX-friendly.
PIN Стандартное подтверждение (любые «обычные» суммы и новые получатели).
BIOMETRIC Крупные суммы, high-value операции.
OTP Чувствительные операции, требующие out-of-band подтверждения.
KYC_UPLIFT Превышение дневных лимитов — пользователь должен повысить уровень KYC, прежде чем платёж пойдёт.

CHECK-constraint в БД (auth_policies_step_up_chk) гарантирует, что других значений быть не может — попытка вставить KYC_LITE упадёт на уровне Postgres.

6. Шаг 4 — выбрать priority

Правило: lower-wins. Движок сортирует политики по priority ASC и возвращает первое совпавшее — поиск останавливается сразу после match.

priority 1   ← самый строгий, проверяется первым
priority 5
priority 10
...
priority 100 ← default (см. DDL), наименьший приоритет

Если хотите, чтобы строгое правило перекрывало мягкое — дайте ему меньший номер. Пример из seed-данных:

priority  5 → daily_cumulative_gte 5_000_000  → KYC_UPLIFT  (самый строгий)
priority 10 → amount_lt 10_000                → NONE
priority 15 → new_payee + amount_gte 10_000   → PIN
priority 20 → 10_000 ≤ amount < 500_000       → PIN
priority 30 → amount_gte 500_000              → BIOMETRIC

Для нового правила выбирайте priority так, чтобы оно встало в нужное место в этой иерархии. Если правило должно перекрывать существующие — берите меньший номер; если быть «последним шансом» — больший.

7. Шаг 5 — миграция или seed

Auth-policies живут в pm.* схеме, поэтому только через drizzle-kit. Не редактируйте таблицу руками в production — потеряется аудиторский след.

Вариант A — отдельная миграция

Создайте файл drizzle/migrations/00XX_auth_policy_<name>.sql:

-- Пример: PIN при amount > 100 THB для P2P new payee
INSERT INTO pm.auth_policies (scope, condition, required_step_up, reason_code, priority)
VALUES (
  'global',
  '{"channel": "INTERNAL_P2P", "new_payee": true, "amount_gte": 10000}',
  'PIN',
  'p2p_new_payee_over_100thb',
  12
);

Вариант B — добавить в 0008_auth_policies.sql

Допустимо только в момент первичного формирования seed-данных (до production-релиза). После релиза правьте через новые миграции.

8. Шаг 6 — apply

cd projects/payment-manager
npx drizzle-kit migrate

Изменения вступают в силу немедленноevaluatePolicy читает таблицу при каждом вызове, кэша нет.

9. Шаг 7 — verify через POST /policies/evaluate

Endpoint защищён HMAC (X-Service-Id, X-Timestamp, X-Signature). Подпись считается над METHOD\nPATH\nTIMESTAMP\nBODY (точная схема — в ../api/auth.md).

Минимальный curl-пример с готовой подписью:

TS=$(date +%s)
BODY='{"userId":1,"merchantId":42,"amount":"15000","currency":"THB","channel":"INTERNAL_P2P","appId":"wallet","operationType":"P2P_TRANSFER"}'
SIG=$(printf 'POST\n/policies/evaluate\n%s\n%s' "$TS" "$BODY" \
      | openssl dgst -sha256 -hmac "$PM_HMAC_SECRET" -hex \
      | awk '{print $2}')

curl -sS -X POST http://localhost:3000/policies/evaluate \
  -H "Content-Type: application/json" \
  -H "X-Service-Id: auth-center" \
  -H "X-Timestamp: $TS" \
  -H "X-Signature: $SIG" \
  -d "$BODY"

Ожидаемый ответ для правила из §7 (новый получатель, 150 THB):

{
  "required":   "PIN",
  "reasonCode": "p2p_new_payee_over_100thb",
  "policyId":   12,
  "context": {
    "dailyCumulative": "0",
    "isNewPayee":      true
  }
}

Если required пришёл NONE с reasonCode: "no_match" — правило не совпало. Возможные причины:

  • merchantId не передан, а в условии new_payee: true.
  • Сумма указана в батах, а не в сатангах ("100" вместо "10000").
  • channel или currency не совпадают с условием (точное сравнение, без приведения регистра).
  • В правиле использован app:wallet, а в запросе appId: "miniapp-x".
  • Сработало другое правило с меньшим priority — посмотрите policyId в ответе.

10. Шаг 8 — тесты

Добавьте кейс в test/limits/evaluate-policy.test.ts. Шаблон (mock DB через chainable thenable, см. makeDbChain в существующих тестах):

it('returns PIN for INTERNAL_P2P to new payee with amount >= 100 THB', async () => {
  dbMock.select
    .mockReturnValueOnce(makeDbChain([{ sumUsed: '0' }]))    // computeAuthContext: daily
    .mockReturnValueOnce(makeDbChain([{ cnt: '0' }]))         // computeAuthContext: isNewPayee
    .mockReturnValueOnce(makeDbChain([{
      id: 12, scope: 'global',
      condition: { channel: 'INTERNAL_P2P', new_payee: true, amount_gte: 10_000 },
      requiredStepUp: 'PIN', reasonCode: 'p2p_new_payee_over_100thb',
      priority: 12, active: true,
    }]))

  const result = await evaluatePolicy({
    userId: 1, merchantId: 42, amount: 15_000n,
    currency: 'THB', channel: 'INTERNAL_P2P',
    appId: 'wallet', operationType: 'P2P_TRANSFER',
  })

  expect(result.required).toBe('PIN')
  expect(result.reasonCode).toBe('p2p_new_payee_over_100thb')
  expect(result.context.isNewPayee).toBe(true)
})

Запуск:

npm test -- test/limits/evaluate-policy.test.ts

Для интеграционного теста endpoint (с HMAC-обвязкой) — см. test/policies/routes.test.ts.

11. Шаг 9 — CHANGELOG.md

Кратко зафиксируйте новое правило в projects/payment-manager/CHANGELOG.md — это аудиторский след для compliance:

## [Unreleased]
### Added
- auth-policy: PIN при P2P new payee с суммой ≥ 100 THB (priority 12, reason `p2p_new_payee_over_100thb`).

12. Когда нужен новый ключ condition

Если ни один из существующих ключей не покрывает кейс (например, нужен time_of_day или device_risk_score):

  1. В src/limits/evaluate-policy.ts расширить matchesCondition() — добавить ветку if (cond.<key> != null) ....
  2. Если ключу нужны новые поля контекста — расширить AuthContext и computeAuthContext().
  3. Если ключу нужны новые поля запроса — расширить PolicyRequest и EvalReqSchema в src/policies/routes.ts.
  4. Покрыть тестами в test/limits/evaluate-policy.test.ts.
  5. Обновить ../../AUTH-POLICIES.md — таблицу поддерживаемых ключей.
  6. После этого можно вставлять политики с новым ключом.

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

  • ../../AUTH-POLICIES.md — справочник полей и SQL-примеры.
  • ../api/auth.md — схема HMAC-подписи.
  • src/limits/evaluate-policy.ts — matcher.
  • src/policies/routes.ts — endpoint POST /policies/evaluate.
  • drizzle/migrations/0008_auth_policies.sql — DDL + seed.
  • test/limits/evaluate-policy.test.ts — unit-тесты matcher'а.
  • test/policies/routes.test.ts — интеграционные тесты endpoint'а.