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

Модуль policies

Step-up policy engine: даёт ответ «нужен ли дополнительный фактор аутентификации» (NONE / PIN / BIOMETRIC / OTP / KYC_UPLIFT) до того, как клиент инициирует платёж.

1. Назначение

Модуль policies — это лёгкий движок политик безопасности. Он отвечает на единственный вопрос: «какой StepUp нужно потребовать у пользователя для конкретной операции?».

Запрос приходит до создания интента — Auth Center зовёт POST /policies/evaluate со связкой userId + amount + currency + appId + (merchantId) + channel + operationType. PM в ответ выдаёт один из пяти уровней (NONE, PIN, BIOMETRIC, OTP, KYC_UPLIFT) плюс reasonCode и policyId для аудита. Auth Center сам решает, как именно собрать этот фактор (показать PIN-pad, запросить FaceID, выдать KYC-челлендж и т. д.).

Кто вызывает:

  • Auth Center (Serverpod) — основной потребитель, перед любой пользовательской платёжной операцией (P2P, оплата инвойса, mini-app charge).
  • Mini-app бэкенды — опционально, если хотят предсказать UX (например, заранее показать «потребуется PIN»).
  • Admin Panel не зовёт /policies/evaluate — административные операции идут через сервисный HMAC и StepUp не требуется.

С чем взаимодействует:

  • pm.auth_policies — таблица правил (scope, condition, requiredStepUp, priority).
  • pm.tx_history + pm.intent — для подсчёта dailyCumulative и проверки isNewPayee.
  • HMAC-middleware — endpoint защищён общим service-key контролем.

Чего модуль НЕ делает:

  • Не выдаёт OTP, не валидирует PIN, не проверяет биометрию — это ответственность Auth Center.
  • Не пишет ничего в БД (read-only).
  • Не блокирует платежи по лимитам — лимиты живут отдельно (см. limits.md). Policies лишь добавляют доп-фактор аутентификации; жёсткие отказы делает checkLimits().

2. Структура файлов

Файл Что делает
src/policies/routes.ts Fastify-плагин с единственным маршрутом POST /policies/evaluate. Парсит запрос Zod-схемой, вызывает evaluatePolicy(), сериализует BigInt-поля как строки.
src/limits/evaluate-policy.ts Реальная логика: computeAuthContext() (дневной оборот + признак нового получателя), matchesCondition() (jsonb-matcher) и evaluatePolicy() (загрузка правил + выбор первого совпадения). Лежит в limits/, потому что переиспользует тот же набор запросов к tx_history/intent, что и checkLimits().
drizzle/migrations/0008_auth_policies.sql Миграция: таблица pm.auth_policies, индекс auth_policies_lookup_idx (scope, priority, active), CHECK на enum StepUp и seed из 5 базовых правил.

Исторически модуль раскидан между двумя папками: HTTP-роут — в src/policies/, а вся бизнес-логика — в src/limits/. При рефакторинге желательно консолидировать, но контракт POST /policies/evaluate это не меняет.

3. Ключевые типы

// src/shared/schema.ts:330
export type StepUpLevel = 'NONE' | 'PIN' | 'BIOMETRIC' | 'OTP' | 'KYC_UPLIFT'

// src/limits/evaluate-policy.ts:16
export interface PolicyRequest {
  userId:        number
  merchantId?:   number   // нужен только для проверки new_payee
  amount:        bigint   // в минимальных единицах валюты
  currency:      string   // ISO 4217, 3 символа
  channel:       string   // INTERNAL_P2P, IPPS_TRANSFER, MERCHANT_INVOICE, …
  appId:         string   // 'wallet', 'mini-app-foo', …
  operationType: string   // P2P_TRANSFER, INVOICE_PAYMENT, …
}

export interface AuthContext {
  dailyCumulative: bigint   // сумма успешных DEBIT-операций за сегодня (UTC) в той же валюте
  isNewPayee:      boolean  // true если merchantId указан и ранее платежей этому мерчанту не было
}

export interface PolicyDecision {
  required:   StepUpLevel
  reasonCode: string        // например 'large_amount', 'new_payee', 'daily_limit', 'no_match'
  policyId:   number        // id из auth_policies; 0 если no_match
  context:    AuthContext
}

Пять уровней StepUp

Уровень Когда выдаётся (по seed) Что делает Auth Center
NONE Микроплатежи (amount_lt: 10_000) или нет совпадений вообще. Пропускает без доп-фактора.
PIN Стандартная сумма (10_000 ≤ amount < 500_000) или новый получатель (new_payee + amount_gte: 10_000). Показывает 6-значный PIN-pad.
BIOMETRIC Крупная сумма (amount_gte: 500_000). FaceID/Fingerprint; fallback на PIN если биометрия недоступна.
OTP Зарезервировано — в текущем seed не используется, но enum поддерживает (для будущих SMS/email-факторов). Отправляет одноразовый код.
KYC_UPLIFT Превышен дневной кумулятив (daily_cumulative_gte: 5_000_000). Запускает re-KYC флоу: догружает документы, поднимает tier.

Matcher: 6 ключей в condition (jsonb)

condition — это JSON-объект, все указанные ключи объединяются AND-логикой. Поддерживаются:

Ключ Тип Семантика
amount_lt number Сработает если req.amount < value.
amount_gte number Сработает если req.amount >= value.
amount_lte number Сработает если req.amount <= value.
currency string Точное совпадение req.currency.
daily_cumulative_gte number Сработает если ctx.dailyCumulative >= value.
new_payee boolean Сравнение с ctx.isNewPayee (true ⇒ первый платёж мерчанту).

Дополнительный ключ channel присутствует в коде matcher, но в текущей версии не считается «канонической» условностью — он позволяет ограничивать политику конкретным каналом (например, только IPPS_TRANSFER), но в seed не используется.

4. Основные функции

evaluatePolicy(req: PolicyRequest): Promise<PolicyDecision>

src/limits/evaluate-policy.ts:106.

Алгоритм:

  1. Считает AuthContext через computeAuthContext() (1-2 SQL).
  2. Формирует список scopes — всегда ['global', 'app:<appId>'] + опционально 'merchant:<merchantId>'.
  3. Загружает все активные политики, попадающие в эти scope-ы, ORDER BY priority ASC (меньше число = выше приоритет).
  4. Идёт по списку и возвращает первое совпадение matchesCondition().
  5. Если ничего не совпало — возвращает { required: 'NONE', reasonCode: 'no_match', policyId: 0 }.

computeAuthContext(req): Promise<AuthContext>

Экспортируется отдельно — может использоваться для debugging/observability.

  • dailyCumulative = SUM(tx_history.amount) где userId = req.userId, currency = req.currency, direction = 'DEBIT', intent.status = 'SETTLED', createdAt ≥ start_of_today_UTC.
  • isNewPayee = COUNT(*) == 0 для SETTLED-интентов с metadata->>'merchantUserId' = req.merchantId. Если merchantId не передан — всегда false.

POST /policies/evaluate handler

src/policies/routes.ts:50. Тонкая обёртка:

POST /policies/evaluate
X-Service-Id: auth-center
X-Timestamp: 1716972000
X-Signature: hmac(...)
Content-Type: application/json

{
  "userId": 1,
  "merchantId": 42,
  "amount": "150000",
  "currency": "THB",
  "channel": "IPPS_TRANSFER",
  "appId": "wallet",
  "operationType": "P2P_TRANSFER"
}

Ответ:

{
  "required": "PIN",
  "reasonCode": "standard_amount",
  "policyId": 4,
  "context": {
    "dailyCumulative": "150000",
    "isNewPayee": false
  }
}

amount и dailyCumulative передаются строками — это защищает от потери точности BigInt при сериализации в JSON.

5. Жизненный цикл

Типичный пользовательский флоу с участием policies:

1. Flutter App: пользователь нажимает «Перевести 5000 THB»
2. Flutter → Auth Center: prepareTransfer(amount=5000, ...)
3. Auth Center → PM:  POST /policies/evaluate { amount: "500000", ... }
4. PM:                evaluatePolicy() → { required: 'PIN', reasonCode: 'standard_amount' }
5. Auth Center → Flutter: { stepUp: 'PIN', token: ... }
6. Flutter:           показывает PIN-pad, собирает PIN
7. Flutter → Auth Center: confirmStepUp(pin=...)
8. Auth Center:       проверяет PIN, выдаёт временный intentToken
9. Auth Center → PM:  POST /intents (HMAC) — реальное создание интента
10. PM:               channel.execute() → ...

Важные моменты:

  • Policies выполняются строго до /intents. Если StepUp требуется, но Auth Center его не собрал, ответственность за отказ лежит на Auth Center — PM на этапе /intents повторно политику не проверяет (это «доверенный» вызов с сервисным HMAC).
  • Идемпотентность не нужна — endpoint read-only, можно дёргать сколько угодно раз.
  • Race conditions: между evaluate и созданием интента состояние может измениться (например, юзер успел сделать другой платёж и перешагнул daily_cumulative_gte). Это допустимо: policy engine даёт «оценку», а финальный жёсткий контроль делает checkLimits().

6. Конфигурация

Таблица pm.auth_policies

Поля:

Колонка Тип Назначение
id BIGSERIAL PK
scope VARCHAR(80) global, app:<id> или merchant:<id>.
condition JSONB Объект с matcher-ключами (см. §3).
required_step_up VARCHAR(20) Enum из 5 значений; защищён CHECK-констрейнтом.
reason_code VARCHAR(50) Свободная строка для аудита (micro_amount, new_payee, …).
priority INTEGER Меньше = строже/раньше. По умолчанию 100.
active BOOLEAN Можно временно отключить политику без удаления.
created_at / updated_at TIMESTAMPTZ

Индекс auth_policies_lookup_idx (scope, priority, active) обслуживает основной запрос evaluatePolicy().

Scope: три уровня

  • global — применяется ко всем пользователям и приложениям. Базовые правила (микроплатежи, дневной KYC-uplift) живут здесь.
  • app:<appId> — переопределение для конкретного приложения. Пример: для app:wallet можно требовать PIN с меньшей суммы.
  • merchant:<merchantId> — для платежей конкретному мерчанту. Пример: для high-risk merchant включить BIOMETRIC начиная с любой суммы.

Все три scope-а складываются в один список и сортируются по priority. Это значит, что policy в merchant:42 с priority=1 выиграет у global с priority=10, но проиграет global с priority=0.

Приоритет (seed для понимания)

-- migrations/0008_auth_policies.sql
INSERT INTO pm.auth_policies (scope, condition, required_step_up, reason_code, priority) VALUES
  ('global', '{"daily_cumulative_gte": 5000000}',              'KYC_UPLIFT', 'daily_limit',       5),  -- самый строгий
  ('global', '{"amount_lt": 10000}',                           'NONE',       'micro_amount',     10),
  ('global', '{"new_payee": true, "amount_gte": 10000}',       'PIN',        'new_payee',        15),
  ('global', '{"amount_gte": 10000, "amount_lt": 500000}',     'PIN',        'standard_amount',  20),
  ('global', '{"amount_gte": 500000}',                         'BIOMETRIC',  'large_amount',     30);

Логика: сначала ловится дневной KYC-перебор, потом микроплатежи проходят без проверки, потом — special-case «новый получатель», и только потом обычные пороги по сумме.

7. Тестирование

Файл Что покрывает
test/limits/evaluate-policy.test.ts Unit-тесты evaluatePolicy() + matcher: NONE при отсутствии правил, PIN/BIOMETRIC по сумме, KYC_UPLIFT по дневному обороту, new_payee, выбор по priority, корректное чтение merchantId. DB замокана через chainable thenable.
test/policies/routes.test.ts Тест HTTP-маршрута через buildApp(): валидация Zod-схемы, проверка HMAC-сигнатуры, корректная сериализация BigInt-полей в строки, проброс ошибок мокированного evaluatePolicy.

Запуск:

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

Интеграционных тестов с реальной БД на данный момент нет — добавлять их рекомендуется при изменении SQL-запросов в computeAuthContext().

8. Связанные модули и документация

  • limits.md — жёсткие пороги (отказы), которые checkLimits() применяет уже после policy evaluation внутри /intents. Policies = «дополнительный фактор», limits = «полный стоп».
  • intent.md — следующий шаг в цепочке: Auth Center, получив StepUp-решение и собрав фактор, зовёт POST /intents.
  • ../api/policies.md — формальная спецификация POST /policies/evaluate (схемы запроса/ответа, коды ошибок). (TODO: будет создано в Phase 4.)
  • ../reference/database/08-auth-policies.md — справка по таблице pm.auth_policies: колонки, индексы, CHECK-констрейнты, примеры SQL для добавления/изменения правил.
  • ../reference/passport/01-dto-contracts.md — канонические DTO-контракты (StepUpLevel экспортируется и в Auth Center, должен совпадать побайтово).

9. Заготовки на будущее

В коде уже видны точки расширения, которые в текущей версии не используются:

  • OTP уровень — поддерживается enum-ом и CHECK-констрейнтом, но в seed не задействован. Готов к включению SMS/email-фактора без миграции схемы.
  • Ключ channel в matcher — позволяет ограничивать политику конкретным каналом (например, более строгие правила для IPPS_TRANSFER vs INTERNAL_P2P). В seed не используется, но код в matchesCondition() его уже учитывает.
  • merchant:<id> scope — обработан в evaluatePolicy(), но в seed нет ни одной merchant-политики. Это задел под per-merchant risk-policies.
  • appId scope — аналогично; пригодится, когда mini-apps захотят свои risk-профили, отличные от основного wallet-приложения.
  • Поле operationType в PolicyRequest — принимается endpoint-ом, но в matchesCondition() пока не используется. Это резерв под будущие правила вида «для MINIAPP_CHARGE всегда требовать PIN».
  • Кэширование политик — сейчас при каждом вызове evaluatePolicy() делается SELECT из auth_policies. При росте RPS логично добавить in-memory кэш с TTL, тем более что таблица меняется редко.