Перейти к основному содержимому

Журнал аудита и отменяемые пакеты

Каждая инициированная пользователем мутация в Api записывается — кто, что, когда и откуда — во всех модулях, без какой-либо точечной настройки на уровне контроллера. Поверх этого журнала лежит слой пакетов: импорт или массовое действие можно пометить как пакет и позже отменить построчно, в стиле Planning Center. Оба механизма живут в единой таблице auditLogs в базе данных членства и полностью управляются из одной контрольной точки — BaseController.actionWrapper. На этой странице описано, что подлежит аудиту, где хранятся данные, какие компромиссы производительности его формируют и как отмена безопасно откатывает пакет без межбазовых транзакций.

Обзор

every mutating request (POST/PUT/PATCH/DELETE)


BaseController.actionWrapper ──▶ derive {module, entityType, category, action}
│ from req.baseUrl + method (AUDIT_REGISTRY = overrides/opt-outs only)

├─ normal mode ─────────▶ run action ─▶ await AuditLogHelper.log(after-values) ──┐
│ (deletes also capture a before-image) │
│ ▼
└─ X-Batch-Id present ──▶ snapshot before-images (strict) ─▶ run action ─▶ audit rows tagged batchId


auditLogs (membership DB, one table, all modules)

POST /membership/batches/:id/undo ──▶ BatchUndoHelper ──▶ walk rows reverse, per entity ┘
conflict guard → restore / delete / re-insert

Два структурных факта определяют всё, что описано ниже:

  1. Слой контроллера — единственное место, которое знает субъекта действия. Репозитории никогда не видят AuthenticatedUser; только контроллеры хранят au. Контроллеры каждого модуля уже проходят через BaseController.actionWrapper, поэтому именно здесь подключается аудит — без изменения сигнатур репозиториев где бы то ни было.
  2. Одна таблица обслуживает все модули. Строки аудита для пожертвований, посещаемости, контента и т.д. записываются в auditLogs базы данных членства через RepoManager.getRepos("membership"), даже из контроллера, не относящегося к членству. «Всё, что Джейн изменила сегодня» остаётся одним запросом.

Что подлежит аудиту

Аудит включён по умолчанию для каждого мутирующего глагола на каждом маршруте. actionWrapper выводит поля аудита из запроса без какой-либо конфигурации на уровне маршрута:

ПолеВыводится из
modulethis.moduleName (владеющий модуль)
entityTypeсингуляризованный последний сегмент req.baseUrl (например, /membership/peopleperson)
categoryпо умолчанию равна entityType
action${entityType}_saved для POST /, ${entityType}_deleted для DELETE /:id, иначе ${entityType}_${method}:${routePath}, так что не-CRUD подмаршруты (например, task_post:/:id/move) фиксируются автоматически

BaseController.AUDIT_REGISTRY предназначен только для переопределений и исключений — это не список разрешённых. Маршрут появляется в нём, чтобы переименовать категорию/тип сущности, объявить { dbModule, table } (что делает маршрут пригодным для пакетов и отмены), пометить его как sensitive (аудит анонимных мутаций) или отключить с помощью optOut: true.

Список исключений (пути записи с высокой интенсивностью, которые захлестнули бы журнал): посещаемость visits / visitsessions / sessions / checkin (воскресный шквал регистрации при прибытии) и сообщения messages / connections / devices (чат и присутствие). Всё остальное логируется.

Массовые конечные точки (people/bulk-delete, people/bulk-update, groupmembers/bulk-add, groupmembers/bulk-remove) зарегистрированы в BULK_ROUTES и создают одну строку аудита на каждый затронутый id, поэтому импорт 10 тысяч человек производит 10 тысяч строк — именно эта детализация по сущностям и делает пакет отменяемым.

Анонимные мутации (actionWrapperAnon — пожертвование гостя, регистрация гостя, отправка формы) подлежат аудиту только для маршрутов, помеченных в реестре как sensitive, и записываются с userId="anonymous" плюс IP-адресом клиента. Пожертвования возглавляют этот список; у этого пути есть реальная история регрессий.

Редактирование секретов и ограничения размера

Перед сохранением любой полезной нагрузки details AuditLogHelper.capDetails() запускает над ней sanitizeValue():

  • Секретные ключи скрываются. Любое поле, чьё имя в нижнем регистре входит в SENSITIVE_KEYS (password, token, cvv, cardnumber, routing_number, accesstoken, clientsecret, …), заменяется на "[redacted]".
  • Огромные скаляры удаляются. Любой URI data: или строка размером более 4 КБ (фото в base64, blob-объекты) заменяется на "[stripped]".
  • Слишком большие строки обрезаются. Если сериализованный JSON превышает ~64 КБ, весь блок заменяется на { truncated: true }. Обрезанные строки по-прежнему можно просматривать — но их нельзя отменить (нет образа «до/после» для восстановления).

Где хранятся данные

Единая таблица auditLogs в базе данных membership обслуживает каждый модуль. Столбцы: id, churchId, userId, category, action, entityType, entityId, details (строка JSON типа MEDIUMTEXT), ipAddress, module, batchId, created. Миграция tools/migrations/membership/2026-07-04_audit_universal.ts добавляет module + batchId, расширяет details с TEXT до MEDIUMTEXT, добавляет индексы ix_auditLogs_batch (batchId) и ix_auditLogs_entity (churchId, module, entityType, entityId, created) и создаёт таблицу batches. Столбец module существует именно для того, чтобы коллизии entityType между разными модулями (note, setting встречаются в нескольких модулях) оставались фильтруемыми, а индекс по сущности — это то, что обеспечивает как историю по конкретной сущности, так и защиту от конфликтов при отмене.

Межмодульные записи проходят через RepoManager.getRepos("membership") изнутри обёртки. Порядок операций продуман намеренно: основная запись сначала фиксируется в базе данных модуля, вставка аудита — вторая. В обычном режиме ошибка вставки аудита проглатывается (console.error, её подхватывает Sentry) — аудит носит рекомендательный характер и никогда не должен срывать запрос пользователя. В режиме пакета это строгое правило (см. ниже).

Почему не триггеры, CDC или таблицы аудита на модуль?
  • Триггеры MySQL не знают действующего пользователя (у соединения нет au), и пришлось бы поддерживать наборы триггеров в каждой схеме.
  • binlog / CDC — это целый инфраструктурный проект с той же проблемой определения субъекта действия.
  • Протягивание userId через каждый репозиторий означало бы затронуть сотни файлов ради передачи информации, которая у слоя контроллера уже есть.
  • Отдельные таблицы аудита на модуль означали бы в 7 раз больше обвязки и веерных запросов для любого межмодульного вопроса. Одна таблица в контрольной точке контроллера — это решение с минимумом кода, которое всё же захватывает субъекта действия.

Позиция по производительности

Горячий путь намеренно дёшев; цена платится только там, где это что-то покупает.

  • Нет чтения-перед-записью при обычных обновлениях. Обычное сохранение не загружает старую запись. Отправленные значения после изменения сохраняются в details.after; интерфейс восстанавливает переход «старое → новое» во время просмотра, сравнивая с предыдущей записью аудита сущности. Один запрос во время просмотра, нулевая стоимость во время записи. Поля, которых никогда не касались с момента запуска, просто не показывают значение «старое» — это приемлемо.
  • Удаления получают образ «до». DELETE /:id на маршруте реестра с { dbModule, table } сначала загружает строку обобщённым способом и сохраняет её в details.before. Удаления редки, и образ «до» — это вся криминалистическая ценность такой записи.
  • Режим пакета — единственный систематический случай чтения-перед-записью, и он опциональный — операция массового импорта уже дорога сама по себе, поэтому N снимков-чтений — это цена возможности отмены.
  • Вставки аудита ожидаются. actionWrapper собирает промисы логирования и делает await Promise.allSettled(...) перед возвратом ответа. Это самый важный инвариант: на Lambda контейнер замораживается в момент возврата ответа, поэтому неожидаемая вставка молча теряется. «Выстрелил и забыл» здесь означает ошибки никогда не срывают запрос, а не не дожидайтесь — одна вставка в уже прогретый пул соединений базы данных членства занимает ~1–3 мс.

Пакеты и отмена

Пакет группирует набор мутаций, чтобы их можно было просмотреть и отменить вместе. Открыть его можно двумя способами:

  • Явно: POST /membership/batches { label, source } возвращает batchId. Затем клиент (B1Transfer, интерфейс импорта B1Admin) отправляет X-Batch-Id: <id> при каждом последующем сохранении/удалении. POST /membership/batches/:id/complete закрывает пакет и проставляет itemCount.
  • Неявно: четыре массовые конечные точки открывают, наполняют и завершают собственный пакет в рамках одного запроса, возвращая batchId в ответе.

Таблица batches (база данных membership): id, churchId, userId, label, source, status (open|completed|undone|partial|failed), itemCount, created, completedAt, undoneAt.

Режим пакета строгий

Когда присутствует X-Batch-Id, actionWrapper ужесточает каждую проверку (writeBatchAuditRows):

  1. Пакет должен существовать, иметь статус open и принадлежать au.churchId — иначе 403.
  2. Маршрут должен поддерживать пакеты ({ dbModule, table } в реестре) — иначе 400.
  3. Перед выполнением действия образы «до» для всех затронутых id загружаются одним запросом WHERE id IN (...) AND churchId = ?. Если это чтение снимка не удаётся, запрос завершается с ошибкой 500, и действие не выполняется — режим пакета никогда не должен молча создавать неотменяемый журнал. (Обычный режим, напротив, работает по принципу «лучшее из возможного» и проглатывает ошибки снимка.)
  4. После успешного выполнения действия для каждой сущности записывается одна строка аудита с batchId, details.before и details.after, плюс явный маркер создания для строк, которые пакет создал.

Отмена

POST /membership/batches/:id/undo (разрешение: создатель пакета или Permissions.server.admin). Запрос отклоняется, если пакет не в статусе completed или старше 30-дневного окна отмены. Далее BatchUndoHelper.undo():

  1. Загружает строки аудита пакета и группирует их по (module, entityType, entityId). Сущность, изменённая несколько раз внутри одного пакета, откатывается один раз, до своего истинного состояния до пакета — самого раннего образа «до», либо удаления, если пакет её создал. Именно поэтому отмена не наивно воспроизводит каждую строку: восстановление промежуточного снимка из середины пакета было бы неверным.
  2. Для каждой сущности сначала запускает защиту от конфликтов: auditLog.hasLaterModification() проверяет, существует ли более поздняя запись аудита для той же (module, entityType, entityId) вне этого пакета. Если да, значит сущность была изменена после импорта — она пропускается, о чём сообщается, но никогда не перезаписывается. Это использует сам журнал аудита в качестве детектора изменений; никаких столбцов modifiedAt ни в одной таблице не требуется.
  3. Откатывает операцию согласно записанному типу, разрешая { dbModule, table } из реестра и используя обобщённые записи Kysely:
    • created → жёстко удалить строку.
    • updated → записать обратно details.before.
    • deleted → заново вставить details.before (обновление-или-вставка, если строка с этим id снова всплыла).
  4. Каждый откат сам подлежит аудиту (action: "<entityType>_undone", без batchId — отмена отмены вне области действия).

Тип операции определяется явным маркером создания, а не выводится из отсутствия образа «до» — законно пустой образ «до» или обрезанная строка не должны быть ошибочно приняты за создание.

Результирующая полезная нагрузка — { restored, skippedConflicts: [...], failed: [...], status }; пакет переходит в статус undone (чисто) или partial. Межбазовой транзакции нет — отмена работает по принципу «лучшее из возможного» построчно, то же ограничение, которое Planning Center документирует для объединённых профилей.

Сущностям с побочными эффектами нужен хук onUndo

Откат создания groupMember также должен записать groupMemberHistory («left»), иначе аналитика оттока молча ломается — устойчивый инвариант рабочего пространства. Такие сущности регистрируют обратный вызов onUndo в AUDIT_REGISTRY, который возвращает true, когда полностью обработал откат, минуя обобщённый путь. groupMembers — канонический случай (ключом служит id строки на явном пути, но personId на массовых конечных точках, и история отслеживается при каждом добавлении/удалении).

Потребительские поверхности

Обе административные поверхности находятся в разработке; замысел таков:

ПоверхностьРепозиторийНазначение
Страница журнала аудитаB1Admin (ManageChurch → Audit Log)Фильтрация по модулю/категории/пользователю/сущности и отображение различий «старое → новое» — для правок путём сравнения с предыдущей записью сущности, для удалений — из details.before. Опирается на GET /membership/auditlogs, доступ ограничен Permissions.server.admin.
Страница пакетовB1Admin (тот же хаб настроек)Список пакетов со статусом и счётчиками, Просмотр результатов (строки аудита пакета через GET /membership/batches/:id/results) и кнопка Отменить, которая показывает отчёт о пропущенных конфликтах / неудачах.
Пакеты импортаB1TransferОткрыть пакет, отправлять X-Batch-Id при обычных вызовах сохранения, завершить в конце — импорты становятся отменяемыми без новых конечных точек импорта. Устаревший importKey остаётся маркером происхождения только для создания, для отмены он заменён.

Подводные камни, которые нельзя допустить в будущих изменениях

  • Вставки аудита должны оставаться ожидаемыми. Неожидаемый вызов AuditLogHelper.log(...) теряется при заморозке Lambda. Собирайте промисы и делайте await Promise.allSettled перед возвратом ответа.
  • Kysely отбрасывает undefined из .set()/.values(). При восстановлении очищенное поле останется нетронутым. BatchUndoHelper преобразует каждое отсутствующее поле в явный null (nullify) — никогда не обходите это ради «более быстрой» прямой записи.
  • Срок хранения должен оставаться значительно больше окна отмены. AuditLogRepo.deleteOld() запускается ночным таймером (по умолчанию хранение 365 дней); окно отмены — 30 дней. Если срок хранения когда-нибудь приблизится к этому окну, журналы отмены будут вычищены из-под открытых пакетов.
  • Обрезанные строки не подлежат отмене. Полезная нагрузка { truncated: true } не имеет образа «до/после»; отмена сообщает о ней как о failed, никогда не угадывает.
  • Порядок — сначала запись модуля, затем аудит. Никогда не переносите вставку аудита перед реальной записью и сохраняйте её строгой в режиме пакета / рекомендательной в обычном режиме.

Перечень файлов

ОбластьФайлы
Обёртка / реестрApi/src/shared/infrastructure/BaseController.ts (AUDIT_REGISTRY, BULK_ROUTES, actionWrapper, actionWrapperAnon, снимок + запись строк)
Движок отменыApi/src/shared/infrastructure/BatchUndoHelper.ts
Помощник аудитаApi/src/modules/membership/helpers/AuditLogHelper.ts (log, capDetails/sanitizeValue, diffFields, getClientIp)
КонтроллерыApi/src/modules/membership/controllers/AuditLogController.ts, BatchController.ts
Модели / репозиторииApi/src/modules/membership/models/AuditLog.ts, Batch.ts; repositories/AuditLogRepo.ts (loadFiltered, loadForBatch, hasLaterModification, deleteOld), BatchRepo.ts
МиграцияApi/tools/migrations/membership/2026-07-04_audit_universal.ts
Административный интерфейс (в разработке)страницы Audit Log + Batches в B1Admin; заголовок пакета импорта в B1Transfer

Связанные страницы

  • Структура модулей — как контроллер, не относящийся к членству, обращается к репозиториям членства через RepoManager
  • Пожертвования — пути записи пожертвований, которые аудируются как sensitive даже при анонимности
  • Конечные точки членства — REST-поверхность, которая несёт X-Batch-Id и предоставляет /auditlogs и /batches