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

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_chkrequired_step_up IN ('NONE','PIN','BIOMETRIC','OTP','KYC_UPLIFT').

Связи

Логических FK к другим таблицам нет — auth_policies это самостоятельный справочник. Семантически связана с:

  • pm.tx_historycomputeAuthContext считает daily_cumulative как SUM(tx_history.amount) за календарный день UTC, фильтруя по userId, currency, direction = 'DEBIT' и intent.status = 'SETTLED';
  • pm.intentisNewPayee определяется через 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.

Обзорные документы:

Примеры

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):

  1. Если дневной оборот пользователя ≥ 50 000 THB (5 000 000 satang) — требуется KYC_UPLIFT (приоритет 5, проверяется первым — перекрывает все нижние).
  2. Микроплатёж < 100 THB (10 000 satang) — пропускается без подтверждения.
  3. Платёж новому мерчанту от 100 THB — PIN.
  4. Стандартный платёж в диапазоне 100 … 5 000 THB — PIN.
  5. Крупный платёж от 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 }
#   }