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

Безопасность

Модуль CM Ocean Loyalty построен на модели deny-by-default: весь API закрыт аутентификацией, и доступ к каждой операции дополнительно проверяется по роли. Бэкенд loyalty-billing работает как OAuth2 resource server: он не управляет паролями и сессиями, а доверяет токенам, выпущенным внешним сервером авторизации Keycloak, и проверяет их при каждом запросе.

Контур безопасности

flowchart LR
    UI["Core UI / клиент"] -->|Bearer JWT| API["loyalty-billing\n(resource server)"]
    KC["Keycloak\n(сервер авторизации)"] -->|выпуск JWT| UI
    API -->|проверка подписи / issuer| KC
Свойство Значение
Тип компонента OAuth2 resource server (Spring Security)
Сервер авторизации Keycloak
Формат токена JWT (Bearer)
Управление сессиями Stateless — серверная сессия не создаётся
Защита от CSRF Отключена (для stateless API не требуется)
Модель доступа Deny-by-default + method-level @PreAuthorize

Аутентификация: проверка JWT

Каждый запрос к защищённым ресурсам должен содержать заголовок с access-токеном:

Authorization: Bearer <JWT>

Сервис проверяет подпись и срок действия токена, а также его издателя (issuer). Адрес издателя задаётся только через окружение и не хранится в коде:

Параметр Назначение
spring.security.oauth2.resourceserver.jwt.issuer-uri URI издателя (issuer) Keycloak; источник публичных ключей для проверки подписи

Stateless

Сервис не хранит серверное состояние сессии — каждый запрос аутентифицируется заново по JWT. Каждый запрос самодостаточен и аутентифицируется по предъявленному токену. Это упрощает горизонтальное масштабирование и поведение под балансировщиком.

Источник ролей в токене

Роли извлекаются из клейма resource_access[<client-id>].roles JWT и имеют префикс продукта LOYALTY_. Идентификатор клиента (client-id), по которому читаются роли, задаётся конфигурацией модуля. Интерактивный (UI) клиент использует поток Authorization Code; сервисные (не-UI) клиенты — Client Credentials, набор прав при этом тот же.

Авторизация: deny-by-default и @PreAuthorize

Правило доступа двухуровневое:

  1. Транспортный уровень. Всё, что не входит в явный список публичных путей, требует валидного JWT (anyRequest().authenticated()).
  2. Уровень операции. На каждый метод API навешена аннотация @PreAuthorize с требуемой ролью. Наличие валидного токена недостаточно — у субъекта должна быть нужная роль LOYALTY_*.

Роли сгруппированы по доменам и разделяют чтение (*_READ) и запись (*_WRITE):

Группа ролей Назначение
LOYALTY_CUSTOMER_READ / LOYALTY_CUSTOMER_WRITE Клиенты лояльности; запись — создание, правка профиля (в MDM), слияние клиентов
LOYALTY_ACCOUNT_READ / LOYALTY_ACCOUNT_WRITE Счета и баланс баллов
LOYALTY_PROFILE_READ Профиль клиента (MDM identity + лоялти)
LOYALTY_ACCRUAL_WRITE Начисление баллов
LOYALTY_REDEMPTION_WRITE Списание: резерв (hold), commit/cancel, прямое списание
LOYALTY_OPERATION_WRITE Денежные операции: пополнение, списание, перевод, обмен
LOYALTY_CURRENCY_READ / LOYALTY_EXCHANGE_RATE_READ Валюты и курсы обмена
LOYALTY_PROMOTION_READ / LOYALTY_PROMOTION_WRITE Каталог механик и акции; экспорт-импорт акций между контурами; запись — создание, правка и отправка акции на согласование (без запуска)
LOYALTY_PROMOTION_ACTIVATE Запуск акции (одиночный и массовый) — нужен всегда, независимо от того, включено ли согласование
LOYALTY_PROMOTION_APPROVE Решение по согласованию запуска акции (согласовать / отклонить); решение по своей же отправке отклоняется
LOYALTY_PROGRAM_CONFIG_READ / LOYALTY_PROGRAM_CONFIG_WRITE / LOYALTY_PROGRAM_CONFIG_ACTIVATE Конфигурации программы лояльности; активация — отдельное право, запись его не подразумевает
LOYALTY_CLIENT_PROFILE_ACTIVATE / LOYALTY_CLIENT_PROFILE_DELETE Активация и удаление версий настройки карточки клиента (правка самой настройки — LOYALTY_DICTIONARY_WRITE)
LOYALTY_SEGMENT_READ / LOYALTY_SEGMENT_WRITE Экран «Сегменты клиентов» и журнал заливок; запись — архивация, возврат из архива и удаление сегмента
LOYALTY_SEGMENT_INTAKE Приём сегментов из внешней системы — только сервисной учётной записи этой системы: заливка меняет, кому начисляют все сегментные акции
LOYALTY_NOTIFICATION_READ / LOYALTY_NOTIFICATION_WRITE Разбор очереди исходящих уведомлений клиентам
LOYALTY_CITY_*, LOYALTY_STORE_*, LOYALTY_MERCHANT_*, LOYALTY_PRODUCT_CATALOG_* (_READ / _WRITE) Справочники со своим API, отдельные от общих справочников: города, торговые точки, мерчанты, каталог товаров (/v1/admin/cities, /v1/admin/stores, /v1/admin/merchants, /v1/admin/product-categories, /v1/admin/product-items). Экранов для них в веб-интерфейсе пока нет — в разделе «Справочники» их строки неактивны
LOYALTY_ACHIEVEMENT_READ / LOYALTY_ACHIEVEMENT_WRITE Справочник достижений (бейджей)
LOYALTY_EVENT_WRITE Событие покупки (покупка → акция → начисление)
LOYALTY_MAINTENANCE_WRITE Регламентное сгорание баллов
LOYALTY_INTEGRATION_WRITE Приём чеков POS и начислений от модуля Promo
LOYALTY_MONITORING_READ Мониторинг, аномалии, бизнес-день (чтение), сквозной журнал аудита
LOYALTY_TIER_READ / LOYALTY_TIER_WRITE Уровни (тиры); запись — ручное назначение уровня
LOYALTY_BUSINESS_DAY_WRITE Закрытие бизнес-дня
LOYALTY_DICTIONARY_READ / LOYALTY_DICTIONARY_WRITE Справочники (НСИ); запись — LOCAL-записи и лимиты списания по разрезам
LOYALTY_INTEGRATION_READ Резолюция карты лояльности на POS, проверка условий из RT Flow, проекция клиента для Check Scan, сегменты клиента для внешних систем — отдельно от _WRITE, чтобы её не получали скопом все пишущие интеграционные клиенты
LOYALTY_INTAKE_WRITE / LOYALTY_INTAKE_READ Асинхронный приём команд (очередь command_inbox); чтение — разбор застрявших команд
LOYALTY_EXCHANGE_READ / LOYALTY_EXCHANGE_WRITE Обмен данными: ленты изменений и выгрузки в DWH (отдельно от LOYALTY_INTEGRATION_READ — лента отдаёт реестр клиентов целиком, включая ПДн); запись — регламент (расписание, ручной запуск)
LOYALTY_TAG_READ / LOYALTY_TAG_WRITE Справочник тегов клиента (свой экран); запись также заводит недостающие теги при создании клиента и импорте
LOYALTY_CUSTOMER_TAG_WRITE Присвоение и снятие тегов у клиента — отдельно от справочника и от LOYALTY_CUSTOMER_WRITE
LOYALTY_PROMOTION_LABEL_READ / LOYALTY_PROMOTION_LABEL_WRITE Справочник меток акций (свой экран); проставление метки на акцию — по LOYALTY_PROMOTION_WRITE

Полный перечень ролей и соответствие ролей операциям — в разделе «Ролевая модель».

Гранулярность ролей денежных операций

Денежные операции (пополнение, списание, перевод, обмен) сейчас разделяют единую роль LOYALTY_OPERATION_WRITE. При необходимости её можно аддитивно разбить на отдельные роли по операциям без изменения остальной модели доступа.

Метод-уровневая проверка — на всех эндпоинтах, включая клиентский кабинет

Контур клиентского кабинета (/v1/b2c/**) и без того закрыт отдельной deny-by-default цепочкой безопасности, но каждый из его эндпоинтов дополнительно несёт @PreAuthorize — правило метод-уровневой проверки не делает исключений ни для одного контура API.

Реестр сотрудников

Модуль ведёт реестр сотрудников app_user без обращения к административному API Keycloak: при каждом запросе сотрудника из токена сохраняются идентификатор (sub), отображаемое имя, логин (preferred_username), e-mail и роли LOYALTY_*. В реестр попадают только люди с ролями продукта: клиентские токены без ролей, запросы клиентского кабинета /v1/b2c/** и сервисные учётные записи интеграций (service-account-*) не записываются. Из реестра берутся имя и логин автора конфигурации программы и её активации. Справочник GET /v1/users (роль LOYALTY_PROMOTION_WRITE, фильтр по праву и поиск по имени) отдаёт только идентификатор и отображаемое имя — для выбора согласующих при отправке акции на согласование через API; веб-интерфейс отправляет акцию без выбора согласующих, и согласовать её может любой обладатель LOYALTY_PROMOTION_APPROVE, кроме отправившего; при выборе согласующих через API решение принимают только они (иначе 403 approval_not_assigned). E-mail наружу не отдаётся. В списке появляются только сотрудники, хотя бы раз входившие в продукт.

Публичные эндпоинты

Без аутентификации доступны только служебные эндпоинты эксплуатации и документация API. Первые три вынесены на отдельный management-порт с базовым путём / (то есть без префикса /actuator); документация API отдаётся на прикладном порту, том же, что /v1/*, отдельным исключением из общего правила:

Путь Назначение Порт
/health, /health/** Проверки здоровья, в т.ч. пробы liveness/readiness для Kubernetes management
/metrics Метрики в формате Prometheus management
/info Служебная информация о сервисе management
/v3/api-docs, /v3/api-docs/** Спецификация OpenAPI прикладной
/swagger-ui/**, /swagger-ui.html Интерактивная документация Swagger UI прикладной

Совпадение матчеров с реальными путями проб

Так как management-порт использует базовый путь /, фактические адреса проб — /health/liveness и /health/readiness, а Prometheus отдаётся на /metrics. Эти пути входят в список публичных. Любое расхождение приведёт к ответу 401 на пробах и сборе метрик, что нарушит работу k8s-проб и мониторинга.

Идемпотентность денежных операций

Все денежные операции идемпотентны: повторная отправка запроса с тем же ключом не приводит к повторному эффекту (двойному начислению или списанию), а возвращает результат исходной операции. Идемпотентность задаётся заголовком запроса:

Idempotency-Key: <уникальный-ключ-операции>

Зачем это нужно

Заголовок защищает от дублирования при сетевых повторах и автоматических ретраях со стороны вызывающих систем (POS, модуль Promo, фронтенд). Клиент обязан генерировать уникальный ключ на каждую логическую операцию и переиспользовать тот же ключ только при ретрае той же операции.

Ключ действителен только для своей операции

Пространство ключей идемпотентности общее на все денежные ручки. Если тот же ключ, что уже занят одной операцией (например, пополнением), приходит с запросом к другой (например, списанию), сервис не подменяет ответ ответом чужой операции — вызов отклоняется 422 idempotency_key_reused с указанием, какой операцией ключ уже занят. Повторно использовать один и тот же ключ можно только для ретрая той же логической операции.

Журнал идемпотентности хранит ответ операции ограниченное время (по умолчанию 7 суток, настройка LOYALTY_IDEMPOTENCY_RETENTION), после чего запись регламентно убирается. Само по себе это не открывает окно для двойной проводки: после истечения окна повтор бизнес-факта по тому же ключу проводки всё равно ловит защита на уровне леджера (уникальность ссылки на проводку) — журнал идемпотентности лишь экономит повторный расчёт и возвращает сохранённый ответ, пока ключ ещё помнится.

CORS для встраивания в Core UI

CORS настроен узко: по умолчанию cross-origin-запросы запрещены (список разрешённых источников пуст). Разрешённые источники задаются через окружение — это нужно для встраивания UI лояльности в Core UI, когда браузер делает cross-origin XHR-запросы к API модуля.

Параметр Назначение
LOYALTY_CORS_ALLOWED_ORIGINS Список разрешённых источников через запятую. Если пусто — cross-origin запрещён

Разрешённые HTTP-методы фиксированы: GET, POST, PUT, PATCH, DELETE для основного контура; для клиентского кабинета (/v1/b2c/**) список уже — только GET, POST, OPTIONS, по набору операций, которые кабинет реально вызывает. Заголовки запроса разрешаются (*) только при заданном списке источников.

Конфигурация под среду

Значение LOYALTY_CORS_ALLOWED_ORIGINS должно содержать точные источники хоста Core UI для конкретной среды (dev/stage/prod). Избегайте избыточно широких значений: пустой список безопаснее, чем неоправданно открытый CORS.

Клиентский кабинет (/v1/b2c/**) настраивается отдельным списком источников (LOYALTY_B2C_CORS_ALLOWED_ORIGINS) — на случай, если кабинет встраивается на другом домене, чем интерфейс сотрудника. Если для кабинета список не задан, действует список сотрудника: контур, где оба интерфейса живут на одном домене, задаёт один список, а не дублирует его дважды.

Журнал аудита

Модуль ведёт сквозной журнал аудита: он фиксирует не только изменяющие (write) вызовы API, но и обращения к персональным данным при чтении, а также даёт гарантии, что накопленные записи нельзя незаметно подделать, и передаёт поток событий во внешнюю систему мониторинга безопасности (SIEM) заказчика.

Состав записи

Каждая запись журнала несёт:

Графа Значение
at Момент события
actor Пользователь (имя из токена) или anonymous
actorRoles Роли субъекта на момент действия — роль потом отзовут или выдадут, и по текущему состоянию каталога учётных записей это не восстановить
sourceIp Адрес источника запроса — первый хоп X-Forwarded-For за ingress (то же определение адреса, что у ограничителя частоты, — иначе журнал и ограничитель расходились бы в ответе на вопрос «откуда пришёл запрос»)
action HTTP-метод; у доменных событий — код действия: PROMOTION_APPROVAL_SUBMIT / _APPROVE / _REJECT / _CANCEL — действия согласования запуска акции, включая автоматическую отмену согласования правкой акции; exchange-pair.rate.change — смена курса пары обмена валют
target Путь запроса; у доменных событий — объект действия: promotion:<id> у согласования, пара валют с прежним и новым курсом у смены курса
status HTTP-код ответа, включая отклонения 403/422/404
traceId Идентификатор запроса для корреляции с логами
accessKind Вид обращения — см. ниже
subjectExternalId Субъект персональных данных (только для просмотра карточки)
itemCount Мера раскрытия: сколько записей отдал ответ
criteria Имена параметров отбора без значений (значения фильтров сами бывают персональными данными — поиск идёт в том числе по телефону и e-mail)

Виды обращения

Вид Когда пишется
CHANGE Автоматически на каждый мутирующий запрос (POST/PUT/DELETE/PATCH)
READ Просмотр карточки одного субъекта (карточка клиента, профиль, проекция Check Scan, служебный предпросмотр чужого кабинета в B2C)
SEARCH Поиск/листинг, раскрывающий данные многих людей (каталог клиентов)
EXPORT Выгрузка массива данных (лента обмена клиентами, выгрузка результатов пакетной операции, выгрузка самого журнала аудита)

Обращение клиента к своим данным не аудируется

В журнал попадает только обращение сотрудника к чужим данным. Клиентский кабинет, отдающий человеку его собственные данные по его же токену, — не инцидент и не аудируется; иначе он на порядок чаще служебных обращений вытеснил бы из журнала именно то, ради чего журнал заводится.

Чтение и выгрузка

GET /v1/admin/audit отдаёт журнал страницей с фильтрами по пользователю, периоду, виду обращения и субъекту ПДн; роль — LOYALTY_MONITORING_READ.

GET /v1/admin/audit/export?from=&to= выгружает журнал за период потоком в CSV (разделитель ;, кавычки по RFC 4180, UTF-8 с BOM для Excel) — тот же состав граф и тот же порядок, что на экране. Сама выгрузка попадает в журнал как обращение вида EXPORT: она раскрывает разом всех клиентов, чьи карточки упоминаются в выгруженных записях.

Защита от подделки

Журнал неизменяем: правка существующей записи запрещена всегда (на уровне базы), а удаление возможно только для записей, ещё не покрытых печатью.

Периодически (по умолчанию — каждые 5 минут) на очередной диапазон записей ставится печать — отпечаток (HMAC) содержимого диапазона плюс отпечаток предыдущей печати, то есть цепочка: подменить запись задним числом, не тронув все последующие звенья, нельзя. Печати не покрывают записи моложе минуты — верхняя граница печати обязана быть заведомо зафиксированной в базе.

GET /v1/admin/audit/integrity (роль LOYALTY_MONITORING_READ) отдаёт результат проверки целостности цепочки печатей (проверка идёт и по расписанию, ночью):

Поле Что значит
intact Цела ли цепочка печатей: false — записи, уже покрытые печатью, изменены или удалены. О непокрытой части журнала не говорит ничего
seals Число печатей в цепочке
uncovered Записи после последней печати, которые исправная постановка печатей уже обязана была покрыть, — старше окна lag + 2 × tick (по умолчанию 11 минут). Моложе окна запись законно ждёт очередной печати и в счёт не входит
broken Сломанные печати (sealId) и причина — изменение записей, удаление части журнала или разрыв связи с предыдущей печатью

intact: true при пустой цепочке — не защита

При нуле печатей проверять нечего, и intact истинен — в том числе когда подсистема выключена или печати ещё ни разу не ставились. Защищён ли журнал, показывает uncovered: на исправном контуре — 0 в любой момент; при пустой цепочке, в том числе при выключенных печатях, — весь журнал старше окна; если печати перестали ставиться, число растёт от проверки к проверке, хотя intact остаётся истинным, а broken — пустым. Тот же счёт публикуется показателем loyalty.audit.seal.uncovered, на нём заводится оповещение — см. «Наблюдаемость».

Подсистема печатей выключена по умолчанию

Постановка печатей требует секрета (LOYALTY_AUDIT_SEAL_KEY) и без него не включается: включённая без ключа подсистема отчитывалась бы «журнал цел», не будучи способной это установить.

Передача в SIEM заказчика

Поток событий журнала может передаваться во внешнюю SIEM заказчика (KUMA, MaxPatrol, ArcSight, QRadar и подобные) по syslog (RFC 5424) с полезной нагрузкой в формате CEF (есть и вариант JSON — для приёмников со своим разбором). Подключение сводится к настройке источника на стороне SIEM.

Параметр Назначение
LOYALTY_SIEM_ENABLED Выключено по умолчанию — адрес приёмника знает только контур заказчика
LOYALTY_SIEM_HOST / LOYALTY_SIEM_PORT Адрес приёмника
LOYALTY_SIEM_TRANSPORT TCP (по умолчанию, с подтверждением доставки) или UDP (без гарантии)
LOYALTY_SIEM_FORMAT CEF (по умолчанию) или JSON

Гарантия доставки и курсор

Буфером служит сам журнал аудита — отдельной очереди для потока нет, хранится только курсор (докуда события уже переданы). Курсор двигается только после подтверждённой отправки: если приёмник недоступен, поток останавливается и продолжается с того же места после восстановления связи, без потери событий. Отклонённая попытка доступа передаётся с повышенной важностью — отказ разбирается, а успешный просмотр не привлекает внимания. За состоянием потока следит показатель отставания (сколько записей ещё не передано); растущее отставание означает, что события до SIEM не доходят.

Интеграционный периметр

Три меры защищают периметр интеграций и импорта данных от перегрузки, некорректных файлов и незаметного расхождения учёта.

Ограничение частоты запросов

Ограничение действует сплошным фильтром на все запросы (алгоритм GCRA, ключ — субъект токена или IP-адрес). Правила заводятся в режиме OBSERVE (только метрика и предупреждение в логе, без отказа) и переводятся в ENFORCE после обкатки порога на реальном трафике; выигрывает первое совпавшее по пути и методу правило.

Правило Что покрывает
Резолюция карты POS GET /v1/integration/pos/cards/**
Проекция клиента для Check Scan GET /v1/integration/checkscan/customers/**
Запись в интеграционный контур POST/PUT/PATCH/DELETE /v1/integration/**
Чтение интеграционного контура GET /v1/integration/**
События покупок POST /v1/events/**
Массовые операции приём пакетов ручных операций, импорт настроек, регламентное сгорание
Клиентский кабинет /v1/b2c/** — по субъекту клиентского токена

При превышении лимита ответ 429 несёт заголовок Retry-After и поле retryAfterSeconds в теле problem+json — вызывающая система знает, когда повторить попытку.

Проверка загружаемых файлов

Любой загружаемый файл (импорт клиентов, справочников, ручных операций и т. п.) проходит проверку до разбора содержимого:

  1. Сигнатура содержимого — файл обязан быть текстовым: архивы, документы Office, PDF, изображения и исполняемые файлы отклоняются по первым байтам независимо от расширения и заявленного Content-Type.
  2. Строгое декодирование как UTF-8 — «почти UTF-8» (например, файл, сохранённый в Windows-1251) отклоняется явно, а не превращается в испорченные данные.
  3. Пределы размера (LOYALTY_IMPORT_MAX_FILE_SIZE, по умолчанию 20 МБ) и числа строк (LOYALTY_IMPORT_MAX_ROWS, по умолчанию 10000 — для импорта клиентов и справочников; у файлового пакета ручных операций свой, больший предел строк, см. «Конфигурация»).
  4. Антивирусная проверка через подключаемый сканер — выключена по умолчанию (сканер у заказчика не определён); при включении работает по принципу fail-closed: недоступность сканера отклоняет файл, а не пропускает его непроверенным.

Отказ на любом из шагов возвращает стабильный код import_*, а не общую ошибку разбора.

Сверка итогов и контроль целостности

POST /v1/admin/reconciliation/runs (роль LOYALTY_MAINTENANCE_WRITE) запускает внутреннюю сверку: баланс клиентского счёта против суммы проводок леджера и сумма остатков лотов против баланса счёта; у системных счетов (источник начислений, списание, сгорание, корректировки, счета партнёров) проекция баланса не ведётся и обязана оставаться нулевой — ненулевая означает изменение в обход проводок. Это ловит правку баланса в обход двойной записи — единственный способ, которым учёт может разъехаться незаметно, — и работает на любом контуре уже сегодня, без внешней системы.

POST /v1/admin/reconciliation/external-totals (роль LOYALTY_INTEGRATION_WRITE) принимает итоги операционного дня от внешней системы (начислено, списано, число операций) и сверяет их с собственными. Результат каждого прогона сохраняется всегда, включая совпадение без расхождений; список прогонов и расхождений по прогону читается ролью LOYALTY_MONITORING_READ (GET /v1/admin/reconciliation/runs, GET /v1/admin/reconciliation/runs/{runId}/diffs). Расхождения делятся по серьёзности: ERROR — нарушен инвариант учёта, разбор обязателен; WARN — объяснимое расхождение (например, демо-данные без проводок).

Недоступность MDM

Сбой внешнего реестра клиентов (MDM) — таймаут, обрыв соединения, ошибка 5xx — возвращается как 503 mdm_unavailable, а не как 404 customer_not_found. Это разные факты: клиента может не быть в реестре, а реестр может быть просто недоступен, — и вызывающая система обязана различать их, иначе ретраит первое как второе (повторять 404 бессмысленно, а 503 можно и нужно).

Таблицы смежных систем в карточке клиента

Администратор может вывести в карточку клиента данные из таблиц смежных систем, лежащих в той же базе данных. Чтение ограничено трижды: схемы — только из белого списка LOYALTY_CLIENT_PROFILE_SOURCE_SCHEMAS (по умолчанию пуст — возможность выключена), таблицы и колонки — только те, на которые у роли приложения в БД есть права, а имена в запросах берутся из системного каталога БД, а не из запроса пользователя. Перечень схем, таблиц и колонок доступен только с ролью LOYALTY_DICTIONARY_READ; оператор карточки видит лишь настроенные столбцы, без имён схемы и таблицы источника.

Состав ПО (SBOM)

Каждая сборка образа несёт файл состава ПО (SBOM, формат CycloneDX) со сведениями о версиях и лицензиях всех зависимостей, включая транзитивные. Файл едет внутри образа — так он всегда отвечает на вопрос «что именно внутри вот этого выката» — и позволяет подтвердить состав поставки и отсутствие запрещённых лицензий при приёмке или разборе инцидента. Для веб-интерфейса перечень зависимостей в том же формате формирует CI (отчёт GitLab в пайплайнах веток и merge request), внутрь образа loyalty-ui он не кладётся — см. «Установка и развёртывание» → «Состав ПО». Подробнее о конкретном релизе — в Release Notes.