08 auth policies
Таблица pm.auth_policies хранит правила step-up-аутентификации: какой уровень дополнительного подтверждения (PIN / биометрия / OTP / KYC-uplift) требуется от пользователя в зависимости от контекста платежа — суммы, валюты, накопленного дневного оборота и новизны получателя.
Имя¶
pm.auth_policies
Назначение¶
auth_policies — конфигурация policy-based step-up authentication engine. Решение «можно ли провести платёж сразу или нужно потребовать ввод PIN / биометрию / OTP / KYC-uplift» принимается динамически на каждом вызове POST /policies/evaluate, а не зашивается в код инициатора (Auth Center / mini-app / клиент).
Каждая строка — отдельное правило, описывающее один сценарий:
- где применяется — поле
scope(global/app:<appId>/merchant:<merchantId>); - когда срабатывает — JSONB-выражение
condition(комбинация порогов суммы, проверки валюты, дневного оборота и флага «новый получатель» — все ключи соединяются логическим AND); - что требовать —
required_step_up(один из пяти уровней:NONE,PIN,BIOMETRIC,OTP,KYC_UPLIFT); - под каким кодом —
reason_code(возвращается клиенту, используется UI для подсказки пользователю и для аналитики); - в каком порядке —
priority(ASC: меньше = первее; первое совпавшее правило — финальное решение, перебор прекращается).
Таблица введена в миграции 0008_auth_policies (релиз 0.3.0). Используется в основном Auth Center'ом — он вызывает POST /policies/evaluate перед созданием инвойса/перевода, получает required: PIN | BIOMETRIC | OTP | KYC_UPLIFT | NONE, и либо запрашивает соответствующее подтверждение у пользователя, либо пропускает шаг.
Изменения вступают в силу немедленно — движок читает таблицу при каждом вызове, кэширования нет.
DDL¶
Из drizzle/migrations/0008_auth_policies.sql:
CREATE TABLE pm.auth_policies (
id BIGSERIAL PRIMARY KEY,
scope VARCHAR(80) NOT NULL,
condition JSONB NOT NULL,
required_step_up VARCHAR(20) NOT NULL,
reason_code VARCHAR(50) NOT NULL,
priority INTEGER NOT NULL DEFAULT 100,
active BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT auth_policies_step_up_chk
CHECK (required_step_up IN ('NONE','PIN','BIOMETRIC','OTP','KYC_UPLIFT'))
);
CREATE INDEX auth_policies_lookup_idx ON pm.auth_policies (scope, priority, active);
Drizzle-описание — src/shared/schema.ts, раздел Auth Policies.
Поля¶
| Колонка | Тип | NULL | Default | Описание |
|---|---|---|---|---|
id |
bigserial |
NO | nextval(...) |
PK, технический. |
scope |
varchar(80) |
NO | — | Область применения правила (см. ниже). Три формы: global / app:<appId> / merchant:<merchantId>. |
condition |
jsonb |
NO | — | Объект-выражение из набора поддерживаемых ключей (amount_lt, amount_gte, amount_lte, currency, daily_cumulative_gte, new_payee). Все указанные ключи соединяются AND. Пустой {} — сработает на любой запрос (catch-all). |
required_step_up |
varchar(20) |
NO | — | Требуемый уровень доп. аутентификации: NONE, PIN, BIOMETRIC, OTP, KYC_UPLIFT. CHECK ограничивает значения. |
reason_code |
varchar(50) |
NO | — | Машиночитаемый код причины срабатывания (например, large_amount, new_payee, daily_limit). Возвращается в ответе POST /policies/evaluate и используется UI / аналитикой. |
priority |
integer |
NO | 100 |
Порядок проверки по возрастанию (ASC). Меньшее значение = выше приоритет — правило проверяется раньше. Первое совпавшее правило завершает перебор. |
active |
boolean |
NO | true |
Soft-disable. Неактивные правила исключаются из выборки. |
created_at |
timestamptz |
NO | now() |
Время создания строки. |
updated_at |
timestamptz |
NO | now() |
Время последнего изменения. Обновлять вручную при UPDATE. |
scope — область применения¶
| Форма | Когда применять |
|---|---|
global |
Универсальные правила, действующие на все платежи. |
app:<appId> |
Правила, специфичные для конкретного mini-app (appId приходит из тела запроса /policies/evaluate). |
merchant:<merchantId> |
Правила, специфичные для конкретного мерчанта (используется, только если в запросе указан merchantId). |
При оценке движок загружает сразу все подходящие скоупы (global всегда + app:<appId> всегда + merchant:<merchantId>, если передан) одним запросом и сортирует по priority. Правила из разных скоупов конкурируют между собой только по приоритету — нет иерархии вроде «merchant перекрывает app, app перекрывает global».
required_step_up — уровни step-up¶
5 уровней, тип StepUpLevel (строка 330):
| Уровень | Назначение |
|---|---|
NONE |
Доп. подтверждение не требуется — платёж проходит без задержки. Используется для микроплатежей. |
PIN |
Ввод PIN-кода. Стандартный уровень для большинства бытовых операций. |
BIOMETRIC |
Биометрия (Face ID / Touch ID / отпечаток пальца). Сильнее PIN — труднее подделать. |
OTP |
Одноразовый код (SMS / email / authenticator). Используется, если биометрия недоступна / отключена пользователем, или для специфичных high-risk-сценариев. |
KYC_UPLIFT |
Требуется поднять уровень KYC пользователя до проведения операции (full KYC, верификация ID-документов). Применяется при превышении лимитов нижнего тарифа. |
Уровни в auth_policies не упорядочены по «строгости» — движок не сравнивает их друг с другом и не выбирает «максимум»; он просто возвращает уровень из первого подошедшего правила. Если нужно «всегда требовать минимум PIN», добавьте правило с condition: {} и низким приоритетом (catch-all).
condition — поддерживаемые ключи JSONB¶
Все ключи в condition соединяются логическим AND — правило срабатывает только если все указанные ключи выполнены. Полный список (sync с matcher'ом в src/limits/evaluate-policy.ts):
| Ключ | Тип | Семантика |
|---|---|---|
amount_lt |
number |
Сумма платежа строго меньше значения. В минимальных единицах валюты (для THB — satang). |
amount_gte |
number |
Сумма больше или равна значению. |
amount_lte |
number |
Сумма меньше или равна значению. |
currency |
string |
Фиксирует валюту (ISO 4217, 3 символа, например "THB"). Правило сработает только если req.currency совпал. |
daily_cumulative_gte |
number |
Накопленный дневной DEBIT-оборот пользователя в указанной валюте больше или равен значению. Считается как SUM(tx_history.amount) за календарный день UTC по SETTLED-интентам в той же currency. |
new_payee |
boolean |
true — этот пользователь ещё не платил указанному merchantId ранее (нет ни одной SETTLED-операции с intent.metadata.merchantUserId == req.merchantId). false — наоборот, уже платил. Если в запросе нет merchantId, флаг всегда false и правило не сработает. |
Важно: суммы хранятся в минимальных единицах валюты. Для THB: 1 бат = 100 сатангов → 100 бат =
10000, 5 000 бат =500 000, 50 000 бат =5 000 000.
Любые другие ключи в condition игнорируются matcher'ом (нет ошибки, нет предупреждения), поэтому опечатки вроде ammount_gte / dailyCumulativeGte приведут к тому, что правило сработает не так, как ожидалось. См. раздел «Типичные ошибки» в API-документации.
Индексы¶
| Индекс | Колонки | Назначение |
|---|---|---|
auth_policies_pkey |
id |
Первичный ключ. |
auth_policies_lookup_idx |
(scope, priority, active) |
Покрывает основной запрос движка: WHERE scope IN (...) AND active = true ORDER BY priority. |
CHECK-ограничение:
auth_policies_step_up_chk—required_step_up IN ('NONE','PIN','BIOMETRIC','OTP','KYC_UPLIFT').
Связи¶
Логических FK к другим таблицам нет — auth_policies это самостоятельный справочник. Семантически связана с:
pm.tx_history—computeAuthContextсчитаетdaily_cumulativeкакSUM(tx_history.amount)за календарный день UTC, фильтруя поuserId,currency,direction = 'DEBIT'иintent.status = 'SETTLED';pm.intent—isNewPayeeопределяется черезtx_history → intent(поintent.metadata.merchantUserIdиintent.status = 'SETTLED');scope = 'app:<appId>'—appIdприходит из тела запроса/policies/evaluate, не валидируется в БД (нет FK к таблице приложений);scope = 'merchant:<merchantId>'—merchantIdприходит из запроса; matcher не проверяет существование мерчанта вtb_account_map.
Связанный код¶
| Файл | Роль |
|---|---|
src/shared/schema.ts |
Drizzle-определение authPolicies, тип StepUpLevel. |
src/limits/evaluate-policy.ts |
Основная логика: computeAuthContext (дневной оборот, isNewPayee) + matchesCondition (matcher по JSONB) + evaluatePolicy (выборка по скоупам, сортировка по priority, возврат первого совпадения). |
src/policies/routes.ts |
HTTP-эндпоинт POST /policies/evaluate (HMAC-аутентификация). Принимает Zod-валидированное тело, вызывает evaluatePolicy, возвращает { required, reasonCode, policyId, context: { dailyCumulative, isNewPayee } }. |
drizzle/migrations/0008_auth_policies.sql |
Миграция: DDL таблицы + индекс + 5 базовых seed-строк. |
test/limits/evaluate-policy.test.ts |
Unit-тесты matcher'а и логики выборки по приоритетам. |
test/policies/routes.test.ts |
Интеграционные тесты эндпоинта POST /policies/evaluate. |
Обзорные документы:
- Module —
dev/modules/policies.md(TODO, файл ещё не создан). - API —
dev/api/policies.md(TODO, файл ещё не создан).
Примеры¶
Seed-строки¶
Из drizzle/migrations/0008_auth_policies.sql — 5 базовых правил (суммы в satang):
scope |
condition |
required_step_up |
reason_code |
priority |
|---|---|---|---|---|
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 |
Логика чтения таблицы (по возрастанию priority):
- Если дневной оборот пользователя ≥ 50 000 THB (5 000 000 satang) — требуется
KYC_UPLIFT(приоритет 5, проверяется первым — перекрывает все нижние). - Микроплатёж < 100 THB (10 000 satang) — пропускается без подтверждения.
- Платёж новому мерчанту от 100 THB —
PIN. - Стандартный платёж в диапазоне 100 … 5 000 THB —
PIN. - Крупный платёж от 5 000 THB —
BIOMETRIC.
Если ни одно правило не подошло — возвращается required: 'NONE', reasonCode: 'no_match', policyId: 0.
Полезные SQL-запросы¶
-- Все активные правила, отсортированные по приоритету (как движок их видит)
SELECT id, scope, priority, required_step_up, reason_code, condition
FROM pm.auth_policies
WHERE active = true
ORDER BY scope, priority;
-- Сколько правил в каждом скоупе
SELECT scope, count(*) FILTER (WHERE active) AS active_rules, count(*) AS total
FROM pm.auth_policies
GROUP BY scope
ORDER BY scope;
-- Найти правила с конкретным ключом condition (например, все правила про дневной лимит)
SELECT id, scope, required_step_up, reason_code, priority, condition
FROM pm.auth_policies
WHERE condition ? 'daily_cumulative_gte';
-- Soft-disable правила без удаления
UPDATE pm.auth_policies SET active = false, updated_at = now() WHERE id = $1;
-- Поднять приоритет правила (сделать его проверяемым раньше)
UPDATE pm.auth_policies SET priority = 3, updated_at = now() WHERE id = $1;
-- Изменить порог суммы в condition
UPDATE pm.auth_policies
SET condition = jsonb_set(condition, '{amount_gte}', '200000'::jsonb),
updated_at = now()
WHERE id = $1;
Добавление новых правил¶
Особое правило только для одного mini-app:
INSERT INTO pm.auth_policies (scope, condition, required_step_up, reason_code, priority)
VALUES ('app:lottery-app', '{"amount_gte": 50000}', 'PIN', 'app_threshold', 25);
Строгое правило для конкретного мерчанта (id 42):
INSERT INTO pm.auth_policies (scope, condition, required_step_up, reason_code, priority)
VALUES ('merchant:42', '{"amount_gte": 100000}', 'BIOMETRIC', 'merchant_strict', 5);
Комбинированное условие (новый мерчант + крупная сумма + строго THB):
INSERT INTO pm.auth_policies (scope, condition, required_step_up, reason_code, priority)
VALUES (
'global',
'{"new_payee": true, "amount_gte": 100000, "currency": "THB"}',
'BIOMETRIC',
'new_payee_large_thb',
8
);
Пример запроса к эндпоинту¶
curl -X POST https://pm.example.com/policies/evaluate \
-H 'Content-Type: application/json' \
-H 'X-Service-Id: auth-center' \
-H 'X-Timestamp: ...' \
-H 'X-Signature: ...' \
-d '{
"userId": 1001,
"merchantId": 42,
"amount": "600000",
"currency": "THB",
"channel": "INTERNAL_P2P",
"appId": "default",
"operationType": "P2P_TRANSFER"
}'
# → {
# "required": "BIOMETRIC",
# "reasonCode": "large_amount",
# "policyId": 5,
# "context": { "dailyCumulative": "0", "isNewPayee": false }
# }