Тема
ТЗ — Аудит действий (cms/audit)
Слой: 🔵 инфра-модуль · Зрелость доноров: ★★ · Донор: catalog (
catalog/src/app/Services/AuditLogger.php), freelance, referendum Статус: ТЗ к разработке
Назначение и возможности
Журнал действий пользователей и системы: кто/что/когда/до-после, по событиям ядра и модулей. Записи неизменяемы (append-only), с ретеншном и экспортом для комплаенса.
- Фиксация автора, действия, модели, IP, значений до/после
- Подписка на события ядра и модулей без хардкода списка моделей
- Фиксация входа/выхода/смены пароля через канонические события ядра
UserLoggedIn/UserLoggedOut/UserPasswordChanged(ревизия ядра 14.07.2026, п.4), не черезIlluminate\Auth\Events\*напрямую - Просмотр и фильтрация журнала в Filament (по пользователю, модели, периоду)
- Экспорт выборки в CSV/XLSX через
cms/export, включая «экспорт дела» — полная выборка по одной сущности за период для расследований - Ретеншн записей с автоочисткой по возрасту, партиционирование по месяцу при больших объёмах
- Неизменяемость записей (запрет UPDATE/DELETE на уровне модели и БД-прав), опциональная hash-цепочка записей для защиты от подмены на уровне самой БД
- Быстрый просмотр «история изменений записи» на карточке в Filament
- Аварийная приостановка записи (
audit.write_paused) без выключения модуля и потери истории
Зависимости и выключение
requires: ядро · suggests: cms/export
Поведение при выключении: журналирование действий прекращается, история изменений в Filament недоступна — деградация трассируемости, операции пользователей не блокируются.
При отсутствии/выключении cms/export (suggests) — просмотр и фильтрация журнала работают в полном объёме, но кнопка/эндпоинт экспорта выборки недоступны и отвечают понятной ошибкой (409) вместо попытки вызвать несуществующий сервис. См. ⚠️ «Крайние случаи»: в исходной версии этого ТЗ cms/export не был объявлен зависимостью, хотя раздел «События и обмен» описывал вызов его сервиса — расхождение устранено добавлением suggests здесь.
Модель данных
| Таблица | Ключевые поля | Примечание |
|---|---|---|
cms_audit_log | id, user_id, action, auditable_type, auditable_id, site_id, old_values (json), new_values (json), truncated (bool), ip, entry_hash, prev_hash, created_at | append-only, без updated_at |
Заметки: action — PHP Enum; old_values/new_values — JSONB с касом 'array', GIN-индекс не обязателен (запись, не поиск по значениям); составной индекс по (auditable_type, auditable_id) под виджет «История изменений записи»; дополнительный составной индекс (user_id, created_at) под фильтр «действия пользователя за период»; таблица журнальная и растёт линейно — BRIN по created_at вместо B-tree снижает объём индекса. site_id — nullable, измерение мультисайта: заполняется из RequestContext на момент фиксации, отсутствует смысла только на одиночном сайте (правило измерений §4 стандарта — модуль обязан корректно работать и с site_id = null, и при cms/multisite).
entry_hash/prev_hash (nullable, включаются audit.hash_chain_enabled) — опциональная hash-цепочка: entry_hash = hash(entry_payload + prev_hash), каждая запись ссылается на хеш предыдущей записи того же site_id. REVOKE UPDATE/DELETE защищает от изменения через приложение, хеш-цепочка — дополнительный слой против прямой правки в БД в обход приложения (административный доступ к серверу/дампу): разрыв цепочки обнаруживается командой проверки и виден в health-чеке. Не панацея (при полном доступе к БД цепочку можно пересчитать целиком), но поднимает порог для незаметной точечной подмены одной записи — соразмерная мера для комплаенс-журнала, не криптографический аудит уровня блокчейна. truncated — флаг, что old_values/new_values обрезаны по лимиту audit.max_value_size_kb.
Входные и выходные данные
Входы
| Источник | Данные/поля | Чем валидируется |
|---|---|---|
События Eloquent (created/updated/deleted) моделей ядра и модулей | auditable_type, auditable_id, атрибуты до/после | подписка ограничена конфигом audit.tracked_events, не хардкодом списка моделей |
Канонические события аутентификации ядра UserLoggedIn/UserLoggedOut/UserPasswordChanged (ревизия ядра 14.07.2026, п.4) | user_id, ip, at | внутренние DTO ядра — модуль слушает их, не сырые Illuminate\Auth\Events\* |
RequestContext (ядро) | user_id, ip, site_id на момент действия | берётся ядром из аутентифицированного запроса, не из пользовательского ввода |
API GET /api/v1/admin/audit | filter[user_id], filter[auditable_type], filter[period], sort | FormRequest, whitelist параметров фильтрации/сортировки |
API POST /api/v1/admin/audit/export | те же фильтры выборки для экспорта, включая «экспорт дела» по одной сущности | FormRequest, тот же whitelist, что и у списка |
audit.mask_fields (настройка) | список имён полей, маскируемых перед записью | схема настройки (массив строк), применяется до сохранения old_values/new_values |
Выходы
| Потребитель | Данные | Формат |
|---|---|---|
| Filament, журнал | строки cms_audit_log с диффом до/после | таблица UI, keyset-пагинация |
| Filament, виджет «История изменений записи» | записи по (auditable_type, auditable_id) | построчный diff на карточке модели |
API GET /api/v1/admin/audit, /{id} | список / детали записи | JSON {data, meta} |
cms/export (сервис-вызов по suggests) | выборка записей по фильтрам экспорта, включая «дело» по сущности за период — для расследований инцидентов | передаётся как данные для генерации файла, сам модуль файл не создаёт |
| Подписчики событий | AuditEntryRecorded | payload события (EventBus) |
cms:audit:cleanup --json | отчёт об удалённых по ретеншну записях | JSON-отчёт команды |
cms:audit:verify-chain --json | отчёт о целостности hash-цепочки (при hash_chain_enabled) | JSON-отчёт команды, номер первой разорванной записи |
Whitelist-принцип: всё, что не перечислено во входной таблице (нетипизированные поля фильтра, неизвестные значения auditable_type, произвольные ключи маскирования вне схемы настройки), отвергается на границе FormRequest — 422, не молчаливый игнор.
Настройки (группа audit)
| Ключ | Тип | Дефолт | affectsPageCache | Описание |
|---|---|---|---|---|
audit.enabled | bool | true | нет | Глобальный включатель модуля |
audit.retention_days | int | 365 | нет | Срок хранения записей до автоочистки |
audit.tracked_events | array | ["created","updated","deleted"] | нет | Типы событий Eloquent для журналирования |
audit.mask_fields | array | ["password","token"] | нет | Поля, маскируемые в old/new values |
audit.max_value_size_kb | int | 64 | нет | Лимит размера old_values/new_values, сверх которого значение обрезается (флаг truncated) |
audit.log_ip | bool | true | нет | Фиксировать IP автора действия (сам IP — персональные данные, см. «Безопасность») |
audit.log_auth_events | bool | true | нет | Журналировать UserLoggedIn/UserLoggedOut/UserPasswordChanged (ревизия ядра 14.07.2026, п.4) |
audit.hash_chain_enabled | bool | false | нет | Включить hash-цепочку записей (доп. защита от точечной подмены в БД, дороже по CPU на запись) |
audit.write_paused | bool | false | нет | Kill-switch (матрица v2.2): аварийная приостановка новой записи без выключения модуля — просмотр/экспорт истории остаются доступны |
audit.max_export_rows_per_case | int | 100000 | нет | Лимит строк на «экспорт дела» — заградительный барьер до постановки в очередь |
audit.write_paused отличается от audit.enabled: enabled=false — полное выключение модуля (деградация §3 стандарта, теряется и виджет истории), write_paused=true — точечная мера при инциденте (например аномальный всплеск записи, забивающий очередь/БД) без потери доступа к уже накопленному журналу и UI.
API
| Метод | Путь | Доступ | Назначение |
|---|---|---|---|
| GET | /api/v1/admin/audit | admin (audit.view) | Список записей журнала с фильтрами |
| GET | /api/v1/admin/audit/{id} | admin (audit.view) | Детали одной записи (до/после) |
| POST | /api/v1/admin/audit/export | admin (audit.export) | Постановка экспорта выборки в очередь |
| POST | /api/v1/admin/audit/export-case | admin (audit.export) | «Экспорт дела»: полная выборка по одной сущности за период для расследования |
Список записей — keyset-пагинация (не OFFSET), обязательна при больших объёмах журнала.
Компоненты
Filament: страница журнала, виджет «История изменений записи» на карточках моделей (построчный diff, не голый JSON), кнопка «Экспортировать дело» на карточке сущности. Команды: cms:audit:cleanup --json (ретеншн), cms:audit:export --json, cms:audit:verify-chain --json (проверка hash-цепочки). Демо-контент (матрица v2.2): демо-сидер с несколькими тестовыми записями журнала и историей одной демо-сущности — playground показывает виджет истории не пустым сразу после установки.
События и обмен
| Событие | Когда | Payload |
|---|---|---|
AuditEntryRecorded | зафиксирована новая запись журнала | auditable_type, auditable_id, action |
Слушает: события Eloquent (created/updated/deleted) моделей ядра и модулей, подписка по конфигу audit.tracked_events, не хардкодом; канонические события аутентификации ядра UserLoggedIn / UserLoggedOut / UserPasswordChanged (ревизия ядра 14.07.2026, п.4) — фиксируются как отдельный action, не проходят через tracked_events (это не Eloquent CRUD, а факты канала 1 из другого источника). Экспорт выборки делегируется cms/export через его сервис-вызов (по suggests), сам модуль файлы не генерирует.
Таблица взаимодействий
| Сущность/модуль | Канал | Направление | Что происходит |
|---|---|---|---|
| Модели ядра (страницы, типы контента, меню, виджеты, настройки, пользователи, лиды) | шина событий (Eloquent-события created/updated/deleted) | in | Каждое отслеживаемое изменение порождает запись журнала |
| Модели модулей (любой модуль с сущностями) | шина событий (Eloquent-события) | in | Та же подписка без хардкода — новый модуль не требует правок cms/audit |
cms/export | сервис-вызов (по suggests) | out | Передача выборки записей на генерацию CSV/XLSX |
cms/attack-monitor (если включён) | шина событий (AuditEntryRecorded) | out | Подписка на подозрительные паттерны действий (например, массовые удаления одним пользователем) |
cms/health | health-чек ядра | out | Отдаёт рост таблицы и наличие индексов в агрегат /api/v1/system/health |
RequestContext (ядро) | прямой вызов контракта cms/core-contracts | in | Источник user_id, ip, site_id для каждой записи |
Ядро: UserLoggedIn/UserLoggedOut/UserPasswordChanged (ревизия ядра 14.07.2026, п.4) | шина событий (канал 1) | in | Отдельная запись action для событий аутентификации, не через tracked_events |
Фоновая работа
Именованная очередь audit для постановки экспорта (делегируется в cms/export) и для ретеншн-джобы автоочистки; расписание автоочистки — через ScheduleRegistrar ядра по audit.retention_days.
Фиксация самой записи журнала — синхронный слушатель события (тонкий, без внешних вызовов), но обязан перехватывать собственные исключения: сбой записи (например, недоступна БД журнала) логируется и алертится через cms/health, не пробрасывается выше в HTTP-цикл — непойманное исключение в синхронном слушателе после afterCommit не может откатить уже закоммиченную бизнес- операцию, но способно превратить её успешный ответ пользователю в 500, что нарушает инвариант «модуль не может уронить сайт» (§0 стандарта); это правило зафиксировано здесь, детали разбора исходного расхождения — в «Крайних случаях».
Мини-ранбук (§15 стандарта):
| Симптом | Что проверить | Команда |
|---|---|---|
Журнал не пополняется, audit.enabled=true | не пуст ли audit.tracked_events, не включён ли write_paused | cms:doctor --json (секция audit), проверка настроек группы |
| Ретеншн не чистит старые записи | расписание ScheduleRegistrar, права БД на DELETE по возрасту | cms:audit:cleanup --json --dry-run |
| Подозрение на подмену записи | целостность hash-цепочки | cms:audit:verify-chain --json |
| Экспорт «дела» зависает/падает | доступность cms/export, объём выборки vs max_export_rows_per_case | cms:audit:export --json |
| Резкий рост нагрузки на БД от записи журнала | всплеск tracked_events-действий, инцидент у модуля-источника | временно audit.write_paused=true, разбор источника, затем снятие |
Производительность и кеш
Ожидаемые объёмы: managed-парк студии, десятки сайтов. На активном проекте (формы, лиды, публикации, у commerce-проектов — заказы) реалистичны тысячи–десятки тысяч отслеживаемых изменений в день; при audit.retention_days=365 это от сотен тысяч до нескольких миллионов строк на один нагруженный сайт за год хранения. Таблица журнальная и монотонно растёт — прямой кандидат на разбиение.
Горячие пути и бюджет: виджет «История изменений записи» на карточке — 1 запрос по индексу (auditable_type, auditable_id), keyset при длинной истории; список журнала с фильтром по периоду и пользователю — 1 запрос по индексу (user_id, created_at) либо BRIN по created_at при диапазонном фильтре без пользователя. Запись самой записи (синхронный слушатель) — 1 INSERT в горячем пути бизнес-операции, не должна требовать дополнительных SELECT (модель события уже содержит diff).
Индексы: (auditable_type, auditable_id) — под виджет истории; (user_id, created_at) — под фильтр по автору; BRIN по created_at — журнал пишется строго по времени, BRIN даёт компактный индекс почти без обслуживания при кратно меньшем размере, чем B-tree, что критично при миллионах строк.
Партиционирование при больших объёмах: на высоконагруженном сайте с большим потоком отслеживаемых событий таблица — кандидат на RANGE-партиционирование по месяцу created_at. При партиционировании ретеншн-джоба переключается с DELETE ... WHERE created_at < X (дорого — bloat, нагрузка на vacuum) на DROP PARTITION устаревших месяцев — на порядок дешевле при больших объёмах. BRIN остаётся полезным индексом и внутри отдельных партиций.
Кеш: собственных тегов кеша не объявляет — журнал читается напрямую из БД без кеш-слоя (данные всегда свежие для комплаенс-задач, а объём чтения на горячих путях уже покрыт индексами выше — отдельный кеш-слой избыточен и рискует показать устаревшую историю в момент разбора инцидента).
Безопасность
Границы входа: запись журнала недоступна для изменения через Eloquent/SQL — неизменяемость обеспечивается на уровне модели (запрет update()/delete()) и БД-прав (REVOKE UPDATE/DELETE для роли приложения на эту таблицу). Маскирование audit.mask_fields — обязательный барьер перед записью в old_values/new_values, не постфактум. Экспорт — только под audit.export.
Специфичные векторы атаки:
- Утечка чувствительных полей, не попавших в
audit.mask_fields— конфиг маскирует по точному имени поля; новое чувствительное поле в чужой модели (например,card_last4в модуле оплаты) не попадёт в маску автоматически. Второй барьер: эвристическая маска по regex на имя поля (например,/token|secret|password|card|otp/i) поверх точного списка — не заменяетaudit.mask_fields, а подстраховывает его пробелы. - Утечка через экспорт — так как маскирование выполняется на запись (до сохранения в
old_values/new_values), а не на чтение,cms/exportфизически не может выгрузить то, что не было сохранено — экспорт не обходит барьер модуля-владельца данных. - IP как персональные данные —
ipв журнале сам подпадает под режим ПДн; хранение регулируется тем жеaudit.retention_days, доступ — только подaudit.view; отключаемо черезaudit.log_ipдля проектов с более строгими требованиями комплаенса, если сама фиксация IP избыточна для их политики. - Инъекция через параметры фильтра —
filter[auditable_type]/filter[period]только через FormRequest-whitelist и биндинги Eloquent, без сырых SQL-фрагментов от клиента. - Просмотр чужих действий без ограничения —
audit.viewдаёт доступ ко всей истории всех пользователей (комплаенс-инструмент, не персональный лог); если появится сценарий с делегированными ролями («редактор видит только свои действия»), потребуется более гранулярное право (audit.view.own) — за рамками текущего ТЗ, фиксируется как открытый вопрос при появлении спроса.
ПДн-паспорт (матрица v2.2). Хранит: ip (персональные данные посетителя/автора действия), user_id (ссылка на пользователя ядра), а также любые ПДн, попавшие в old_values/new_values чужих сущностей (например email/телефон лида при изменении карточки) — если они не попали под audit.mask_fields. Срок хранения — audit.retention_days (дефолт 365), для hash-цепочки audit.hash_chain_enabled действует тот же ретеншн (записи не выпадают из цепочки выборочно — цепочка целиком уходит блоком, партиционирование по месяцу упрощает это). Участие в «выгрузить всё по субъекту» (152-ФЗ): по запросу выгружаются записи, где пользователь — user_id, и записи чужих сущностей, где он упоминается в old_values/new_values (требует полнотекстового поиска по JSONB — дорогая операция, выполняется вне HTTP-бюджета, командой). Участие в «забыть по запросу»: удаление пользователя ядром не удаляет записи журнала (это нарушило бы неизменяемость и сам смысл аудита) — модуль анонимизирует user_id → null, ip → null, реагируя на событие ядра об удалении пользователя; сам факт действия и old_values/new_values остаются для комплаенса.
Матрица ролей:
| Роль | Просмотр журнала | Экспорт (включая «дело») | Настройки/hash-цепочка |
|---|---|---|---|
| studio | ✅ | ✅ | ✅ |
| админ | ✅ | ✅ | ✅ |
| менеджер | ✅ | ✅ | ❌ |
| редактор | ❌ | ❌ | ❌ |
Права: audit.view, audit.export, audit.manage.
UX-требования
Админ. Пустое состояние журнала — подсказка «Записи появятся здесь после первых изменений в CMS» (для только что включённого модуля) или «Нет действий за выбранный период» (для суженного фильтра). Массовых операций над записями нет — журнал append-only, ручного удаления/редактирования в UI не существует вовсе; единственная «массовая» операция — постановка выборки на экспорт по фильтрам (уже описана в API). Ошибки — на человеческом языке: «Экспорт не запущен: выбранный период содержит слишком много записей, сузьте фильтр» вместо голого таймаута очереди. Подтверждение необратимых операций не требуется — необратимой ручной операции удаления в UI модуля нет (единственное удаление — автоматический ретеншн по расписанию, не по клику администратора). Виджет «История изменений записи» показывает diff построчно с подсветкой изменённых полей, не сырой JSON.
Посетитель. Посетитель не взаимодействует с модулем напрямую.
Крайние случаи и типовые баги
- ⚠️ Противоречие: синхронный слушатель события после
afterCommit. Раздел «Фоновая работа» описывал фиксацию записи как «синхронный слушатель, без внешних вызовов». Канал 1 (шина событий) по стандарту работаетafterCommit, поэтому сбой слушателя не может откатить уже закоммиченную бизнес- операцию — но, если исключение не перехвачено внутри слушателя, оно всё равно всплывает в текущем HTTP-цикле и превращает успешно сохранённые данные в 500-ответ пользователю, что нарушает инвариант §0 «модуль не может уронить сайт». Разрешение: слушатель обязан перехватывать собственные исключения, логировать и алертить черезcms/health, никогда не пробрасывать их выше (зафиксировано в разделе «Фоновая работа» этого ТЗ). - ⚠️ Противоречие:
cms/exportне был объявлен зависимостью. Раздел «События и обмен» описывал вызов сервисаcms/export«поrequires», при этом «Зависимости и выключение» указывал толькоrequires: ядро— модульcms/exportформально не был частью манифеста. Разрешение: добавленsuggests: cms/export(неrequires, так как просмотр и фильтрация журнала работают и без него — деградирует только функция экспорта, что соответствует смыслу необязательной зависимости). - ⚠️ Противоречие: отсутствие измерения
site_idв модели данных. Стандарт (§4) требует, чтобы контентные/журнальные сущности несли измеренияlocale/city_id/site_id(nullable), а исходная таблицаcms_audit_logне содержалаsite_id— приcms/multisiteзаписи разных сайтов были бы неразличимы в общем журнале. Разрешение:site_idдобавлен в модель данных как nullable-поле, заполняемое изRequestContext. - Гонка параллельных изменений одной записи (два одновременных запроса меняют одну модель) → каждое изменение — отдельное Eloquent-событие → отдельная строка журнала, данные не теряются; порядок между строками — по
created_at/автоинкременту, глобальный порядок между параллельными потоками не гарантируется (согласуется с правилом «порядок не гарантирован между событиями разных джобов» изdata-exchange.md) — журнал фиксирует факт, не последовательность гонки. - Повтор ретеншн-/экспорт-джобы после сбоя воркера (at-least-once доставка) →
cms:audit:cleanupидемпотентен по построению (DELETE WHERE created_at < Xне создаёт побочных эффектов при повторе); повторный экспорт той же выборки создаёт новый файл черезcms/export, не дублирует и не изменяет записи вcms_audit_log(экспорт — чтение, а не запись). - Выключение модуля посреди накопленных джоб очереди
audit→ уже поставленные джобы (ретеншн, экспорт) доигрываются, новые события не порождают записей (слушатель не активен), history-виджет в Filament скрывается на карточках моделей. - Отсутствие свежих отслеживаемых событий из-за пустого
audit.tracked_events→ приaudit.tracked_events = []иaudit.enabled = trueмодуль формально включён, но ничего не журналирует — health-чек и Filament обязаны явно показывать это расхождение (например, баннер «Аудит включён, но список отслеживаемых событий пуст — журнал не пополняется»), а не тихо простаивать. - Событие без реального изменения полей (
touch()/повторное сохранение без diff) → запись не создаётся, еслиold_values === new_values— иначе журнал раздувается шумом без ценности для комплаенса и быстрее выбираетaudit.retention_days/партиции впустую. - Огромные
old_values/new_values(модель с большим rich-text/JSON-полем меняется целиком) → значения свышеaudit.max_value_size_kbобрезаются с флагомtruncated: true; компромисс между полнотой аудита и раздутием таблицы — полное значение восстановимо из истории ревизий владельца сущности (если она есть), не из журнала аудита. - Рост журнала и ретеншн при больших объёмах → см. «Производительность и кеш»: партиционирование по месяцу + BRIN держат стоимость ретеншна и диапазонных выборок предсказуемой даже при миллионах строк; без партиционирования
DELETEпо возрасту на таблице такого размера — сам по себе тяжёлая операция, требующая батчевания (чанками по времени), не единого запроса на весь диапазон. - Разрыв hash-цепочки (
audit.hash_chain_enabled=true) →cms:audit:verify-chainнаходит первую запись, чейentry_hashне совпадает с пересчитанным поpayload + prev_hash, — health-чек модуля переходит вdegradedс указанием номера записи; сама подмена не откатывается автоматически (журнал append-only) — это сигнал для ручного расследования, не самовосстанавливающийся механизм. audit.write_paused=trueво время инцидента → новые действия в CMS не блокируются (это не пауза бизнес-логики, только пауза записи журнала) — они просто не попадают в аудит на время паузы; UI и Filament явно показывают баннер «Запись аудита приостановлена с {дата}», чтобы разрыв в истории не выглядел как незамеченный технический сбой.- Выгрузка «по субъекту» находит ПДн в чужих
old_values/new_values→ полнотекстовый поиск по JSONB — команда, не синхронный API-запрос (может быть медленной на больших объёмах); результат — отчёт со ссылками на записи, финальное решение о раскрытии/маскировании — за оператором, не автоматическое включение сырых значений в ответ по 152-ФЗ без проверки.
Донорский код
| Что взять | Путь |
|---|---|
| Логика фиксации действий (кто/что/до-после) | catalog/src/app/Services/AuditLogger.php — ⚠️ проект catalog ещё не перенесён на этот сервер |
| Практики журналирования действий (без готового пути) | freelance, referendum (имена проектов, пути не выданы) |
Миграция legacy-данных (§16 стандарта, матрица v2.2): не применимо в смысле «перенос исторических записей аудита» — журнал начинает жизнь с нуля при подключении модуля к проекту (импорт задним числом записей о действиях, не зафиксированных в момент совершения, нарушил бы смысл аудита как факта, а не реконструкции). Из донора переносятся только практики (что логировать, как маскировать), не данные.
Тесты и приёмка
- [ ] Контрактный тест: запись журнала невозможно изменить или удалить через Eloquent/SQL
- [ ] Health-чек модуля проверяет рост таблицы и наличие индексов по
(auditable_type, auditable_id)и(user_id, created_at) - [ ] При выключении модуля создание/изменение записей в приложении не блокируется
- [ ] Ретеншн
audit.retention_daysудаляет только просроченные записи (тест на границе) - [ ] Права
audit.view/audit.exportразграничивают просмотр и выгрузку - [ ] Маскирование
audit.mask_fieldsподтверждено тестом на утечку чувствительных полей - [ ] Слушатель фиксации не пробрасывает исключения выше — принудительный сбой записи журнала не превращает успешную бизнес-операцию в 500-ответ пользователю
- [ ] Запись без реального изменения полей (
touch()/resave) не создаёт строку в журнале - [ ] Обрезка
old_values/new_valuesсверхaudit.max_value_size_kbпомечается флагомtruncated - [ ] Экспорт отвечает понятной ошибкой (409), если
cms/exportвыключен; просмотр журнала продолжает работать - [ ] Записи журнала содержат
site_idи различимы по сайтам при включённомcms/multisite - [ ] Ретеншн-джоба корректно и предсказуемо по стоимости работает при больших объёмах (нагрузочный тест на партиционированной/BRIN-индексированной таблице)
- [ ]
UserLoggedIn/UserLoggedOut/UserPasswordChangedфиксируются как записи журнала приaudit.log_auth_events=true - [ ] Hash-цепочка:
cms:audit:verify-chainнаходит внесённую в обход приложения подмену записи - [ ]
audit.write_paused=trueостанавливает запись новых записей, не блокирует бизнес-операции CMS - [ ] «Экспорт дела» отдаёт полную выборку по одной сущности за период, ограничен
max_export_rows_per_case - [ ] «Забыть по запросу»: удаление пользователя ядром анонимизирует
user_id/ipв записях журнала, сами факты действий сохраняются - [ ] Контрактный набор
cms-testingпройден, пакет протестирован в testbench-изоляции - [ ] Feature-тест на каждый роут модуля; тестовая БД только
audit_test,migrate:freshзапрещён