# ADR-0010. Журнал аудита

- Статус: принято, реализовано на этапе 5 (5.5); поправка этапа 6b-2 — записи кабинетов
- Дата: 2026-10-01

## Контекст

Мерчанту и партнёру нужно знать, кто и когда менял программу, вручную начислял баллы, блокировал участников
и смотрел их данные: для разбора инцидентов, споров с клиентами и учёта доступа к ПДн (152-ФЗ). Действия
приходят через Management API, позже — через кабинеты (этап 6). Журнал не должен становиться ещё одним
хранилищем ПДн и не должен позволять задним числом поправить историю.

## Решение

- **Модуль `audit`**: таблица `audit_entries`, контракт `Modules\Audit\Contracts\AuditLog` (`record`,
  `ofTenant`, `ofPartner`). Модуль ничего не знает об HTTP: канал (`management_api`, позже
  `merchant_cabinet`, `partner_cabinet`, `platform_console`) и исполнитель — часть записи.
- **Что пишется из Management API.** Middleware `management.audit` стоит первым в каждой группе маршрутов и
  записывает:
  - каждое изменение (любой метод, кроме GET и HEAD), успешное или нет, с кодом ответа и кодом ошибки;
  - отказы 403 (нет скоупа, чужой мерчант) при любом методе;
  - выданные токены OAuth — от имени клиента, который только что доказал секрет;
  - чтения с ПДн участников (поиск, карточка, история баллов, бонусы) и выгрузку кодов партии промокодов —
    маршруты с `management.audit:reads`.

  Не пишутся запросы без действующих учётных данных, неудачные запросы токена и ответы 429: это шум, который
  нельзя пускать в неудаляемый журнал (их видно в логах, их ограничивают лимиты). Остальные чтения тоже не пишутся.
- **Отказы под лимитом.** Каждая группа маршрутов идёт в порядке: аудит → `management.auth` (только
  аутентификация) → `throttle:management` (лимит учётных данных) → `management.scope` → `management.merchant`.
  Поэтому отказ 403 расходует лимит так же, как любой запрос, и учётные данные не могут без конца писать в журнал
  отказы; сверх лимита — 429, который не пишется.
- **Чей это журнал.** Запись получает мерчанта, только если операция работает с данными мерчанта
  (`management.merchant`). Заголовок `Loyal-Merchant` на операциях партнёра вне мерчантов (мерчанты, каталог
  событий) ничего не значит, и они остаются в журнале партнёра. Архитектурный тест следит, что каждый маршрут
  Management API начинается с аудита, что чтения пишутся ровно на маршрутах с ПДн и кодами и что список операций
  в спецификации совпадает с маршрутами.
- **Состав записи:**
  - время начала запроса;
  - исполнитель: тип и id учётных данных — у OAuth-клиента публичный `client_id` (`lc_…`), у ключа мерчанта — id,
    выданный вместе с ключом; в кабинетах — пользователь;
  - мерчант и партнёр;
  - операция — имя маршрута без префикса (`members.adjust`; список — `AuditAction` в спецификации), метод и путь;
  - объекты: параметры пути и `created` — id созданного объекта из ответа 201 (`id`, `transaction_id`, `batch_id`);
  - что прислали: `query`, `body` и заголовок `Loyal-Merchant`;
  - код ответа, код ошибки (`code` problem+json), `request_id`, IP, User-Agent.

  Ответы не пишутся: в них секреты новых ключей и токены.
- **Без ПДн и секретов.** Перед записью значения проходят `SensitiveDataRedactor` (ADR-0008):
  - ключи `phone`, `email`, `first_name`, `last_name`, `birth_date`, `client_secret`, `token`, … и ключи `card`
    (номер карты лояльности), `external_id` (id участника в системах мерчанта; его стирает обезличивание) и `url`
    (адрес вебхука: в его запросе может быть секрет получателя) заменяются на `[redacted]`;
  - телефоны во всех формах, которые принимает платформа (с `+7`, `7`, `8` и десять цифр мобильного), email и
    номера платёжных карт маскируются внутри строк и в числах;
  - строки маскируются целиком и только потом обрезаются до 500 символов, вложенность — до 8 уровней;
  - запрос, который и после этого больше 32 КиБ, не сохраняется — остаётся только его размер;
  - символы NUL, бесконечные числа, неверный UTF-8 и управляющие символы в колонках убираются: содержимое
    запроса не должно помешать записи, иначе через него можно было бы скрыть действие от журнала.
- **Неизменяемость.** Таблица append-only для всех ролей, включая владельца схемы: триггер и отозванные права,
  как у леджера (ADR-0003).
- **Изоляция.** Запись о мерчанте принадлежит его тенанту (RLS). Действия партнёра вне мерчантов (токены,
  мерчанты, отказы до выбора мерчанта) хранятся без тенанта. Runtime-роль может добавить такую запись (отдельная
  политика только на INSERT), но не прочитать её; читает системное соединение с фильтром по партнёру.
- **Когда пишется.** Когда ответ готов, отдельным INSERT через соединение запроса. Поэтому в журнал попадают и
  неудачные попытки, чьи транзакции откатились. Ошибка записи в журнал не превращает уже выполненное действие в
  ошибку для клиента: она уходит в логи и мониторинг (`report`). Запись теряется, только если процесс упадёт
  между ответом и INSERT. Запись в транзакции действия потеряла бы все неудачные попытки и требовала бы вызывать
  журнал из каждого сервиса.
- **Выгрузка.**
  - `GET /api/management/v1/audit-entries` (скоуп `audit`): страницы по курсору в порядке `(occurred_at, id)`,
    фильтры `from`, `to`, `action` (для него есть индексы). С мерчантом — всё о мерчанте, кем бы ни было сделано;
    без мерчанта — действия партнёра вне мерчантов. Курсор, который журнал не выдавал, — 400 `invalid_cursor`.
  - Оператор платформы выгружает NDJSON командой `php artisan audit:export --merchant=<id> | --partner=<id>`.

## Поправка: записи кабинетов (этап 6b-2)

- **Что пишется.** Каждое изменение данных или чужого доступа из кабинета — защищённое действие (`GuardedAction`,
  ADR-0014): успешное или отклонённое доменом (код ответа и `code` problem+json), а также отказ 403
  `permission_required`, когда человеку действие не положено (подделанный запрос Livewire или доступ, отнятый между
  показом и нажатием). Отказы одного человека пишутся не больше 10 за 15 минут: дальше они только попадают в
  мониторинг, иначе журнал, который нельзя чистить, можно было бы забить. Свою учётную запись человек меняет в
  профиле и «Моих сеансах» — это пишется в журнал безопасности учётной записи (ADR-0013), как и неверный код второго
  фактора; ошибки ввода в форме (валидация) не пишутся.
- **Операции** — словарь `Modules\Audit\Contracts\CabinetAction`. Действие кабинета, которое выполняет ту же
  операцию контракта, что и маршрут Management API, называется именем маршрута; остальные — в том же стиле
  (`operators.role-change`, `accounts.mfa-reset`). Схема `AuditAction` спецификации — объединение маршрутов и
  словаря; архитектурный тест проверяет оба направления.
- **Чтение в кабинетах** (поправка 6c-1). Кабинет мерчанта читает свой журнал сначала новыми записями и с фильтрами
  по каналу и исполнителю (`AuditQuery::$newestFirst`, `$channel`, `$actorId`); под них — индексы
  `(tenant_id, actor_id, occurred_at, id)` и `(tenant_id, channel, occurred_at, id)`, построенные без блокировки
  записи (`concurrently`). Management API по-прежнему отдаёт записи от старых к новым. Операторов платформы кабинет
  мерчанта не называет: «Сотрудник платформы», без адреса и браузера.
- **Исполнитель:** канал по панели (`platform_console`, `merchant_cabinet`, `partner_cabinet`); тип — `user`
  (сотрудник мерчанта или партнёра; `partner_id` — партнёр, через которого он работает у мерчанта) или
  `platform_staff` (оператор в консоли и под доступом поддержки); id — учётная запись.
- **Состав.** Объекты — id, которые объявляет действие; `request` — только объявленный ввод (id, суммы, значения
  перечислений, причина), никогда не вся форма; путь — страница, на которой нажали действие, метод `POST`.
- **Чей это журнал.** Действие в кабинете мерчанта — журнал мерчанта. Собственные действия консоли — без мерчанта
  и партнёра (журнал платформы; читает системное соединение). Действие консоли над самим мерчантом или партнёром, над
  их людьми или учётными данными (статус и данные мерчанта или партнёра, сброс второго фактора, отключение и включение
  сотрудника, отзыв ключей) пишется ещё и в их журналы — копией без введённых данных (причина может называть людей, о
  которых организации знать не нужно), без адреса и браузера того, кто действовал. Команды сотрудника определяются по
  членству, а не по состоянию его учётной записи. Первые копии из консоли — с 6d-1 (ADR-0015). Кабинет мерчанта
  копирует партнёру каждое изменение членства его действующего сотрудника (закрыть доступ,
  вернуть его через партнёра, изменить роль, закрыть и открыть членство) — не больше 30 таких изменений в час от одного
  человека, чтобы чужой журнал, который нельзя чистить, нельзя было забить (`GuardedAction::perHour`; изменения своих
  людей не считаются).
- **Когда.** После выполнения действия, отдельной записью; ошибка записи уходит в `report` и не отменяет действие.

## Последствия

- Раздел «Аудит» кабинета мерчанта читает `ofTenant`; журнал платформы для консоли — в 6d-4. С 6d-1 консоль
  копирует в журнал мерчанта изменения самого мерчанта (название, часовой пояс, страна, статус), а в журнал партнёра —
  изменения партнёра и его новых мерчантов (ADR-0015). Копии не несут ни введённого, ни адреса и браузера того, кто
  действовал. Журналы мерчантов и партнёров (`ofTenant`, `ofPartner`, а значит и `GET /audit-entries`) называют
  оператора платформы только так: без id учётной записи, адреса и браузера; их видит только журнал платформы.
- Запись получает время начала запроса, а появляется после ответа: долгий запрос появится позже более коротких.
  Регулярная выгрузка запрашивает окна `from`–`to` с перекрытием не меньше самого долгого запроса (5 минут) и
  убирает повторы по `id`.
- Удалить записи нельзя даже владельцу схемы. Срок хранения (по договору, ориентир — 3 года) будет реализован на
  этапе эксплуатации переводом таблицы на помесячные партиции, которые удаляются целиком.
- Неудачные запросы токена не пишутся, даже `invalid_scope` после верного секрета: они ничего не меняют, а ошибки
  клиента с правильным секретом видны ему самому. Подбор секрета ограничивает лимит 60 запросов в минуту с адреса.

## Дополнение (этап 6a)

- Журнал безопасности учётных записей сотрудников (`user_security_events`: входы, ошибки, смены пароля и 2FA,
  завершения сеансов) ведётся отдельно от журнала аудита в модуле `identity` (ADR-0013): это события учётной
  записи, а не действия над данными мерчанта. Действия сотрудников в кабинетах пишутся в журнал аудита через
  `AuditLog::record()` с каналами `merchant_cabinet`, `partner_cabinet`, `platform_console` (этап 6b).
