Модуль 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.
Алгоритм:
- Считает
AuthContextчерезcomputeAuthContext()(1-2 SQL). - Формирует список
scopes— всегда['global', 'app:<appId>']+ опционально'merchant:<merchantId>'. - Загружает все активные политики, попадающие в эти scope-ы,
ORDER BY priority ASC(меньше число = выше приоритет). - Идёт по списку и возвращает первое совпадение
matchesCondition(). - Если ничего не совпало — возвращает
{ 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. |
Запуск:
Интеграционных тестов с реальной БД на данный момент нет — добавлять их рекомендуется при изменении 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_TRANSFERvsINTERNAL_P2P). В seed не используется, но код вmatchesCondition()его уже учитывает. merchant:<id>scope — обработан вevaluatePolicy(), но в seed нет ни одной merchant-политики. Это задел под per-merchant risk-policies.appIdscope — аналогично; пригодится, когда mini-apps захотят свои risk-профили, отличные от основного wallet-приложения.- Поле
operationTypeвPolicyRequest— принимается endpoint-ом, но вmatchesCondition()пока не используется. Это резерв под будущие правила вида «дляMINIAPP_CHARGEвсегда требовать PIN». - Кэширование политик — сейчас при каждом вызове
evaluatePolicy()делается SELECT изauth_policies. При росте RPS логично добавить in-memory кэш с TTL, тем более что таблица меняется редко.