Skip to content

ТЗ — Аудит действий (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_logid, user_id, action, auditable_type, auditable_id, site_id, old_values (json), new_values (json), truncated (bool), ip, entry_hash, prev_hash, created_atappend-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/auditfilter[user_id], filter[auditable_type], filter[period], sortFormRequest, 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)выборка записей по фильтрам экспорта, включая «дело» по сущности за период — для расследований инцидентовпередаётся как данные для генерации файла, сам модуль файл не создаёт
Подписчики событийAuditEntryRecordedpayload события (EventBus)
cms:audit:cleanup --jsonотчёт об удалённых по ретеншну записяхJSON-отчёт команды
cms:audit:verify-chain --jsonотчёт о целостности hash-цепочки (при hash_chain_enabled)JSON-отчёт команды, номер первой разорванной записи

Whitelist-принцип: всё, что не перечислено во входной таблице (нетипизированные поля фильтра, неизвестные значения auditable_type, произвольные ключи маскирования вне схемы настройки), отвергается на границе FormRequest — 422, не молчаливый игнор.

Настройки (группа audit)

КлючТипДефолтaffectsPageCacheОписание
audit.enabledbooltrueнетГлобальный включатель модуля
audit.retention_daysint365нетСрок хранения записей до автоочистки
audit.tracked_eventsarray["created","updated","deleted"]нетТипы событий Eloquent для журналирования
audit.mask_fieldsarray["password","token"]нетПоля, маскируемые в old/new values
audit.max_value_size_kbint64нетЛимит размера old_values/new_values, сверх которого значение обрезается (флаг truncated)
audit.log_ipbooltrueнетФиксировать IP автора действия (сам IP — персональные данные, см. «Безопасность»)
audit.log_auth_eventsbooltrueнетЖурналировать UserLoggedIn/UserLoggedOut/UserPasswordChanged (ревизия ядра 14.07.2026, п.4)
audit.hash_chain_enabledboolfalseнетВключить hash-цепочку записей (доп. защита от точечной подмены в БД, дороже по CPU на запись)
audit.write_pausedboolfalseнетKill-switch (матрица v2.2): аварийная приостановка новой записи без выключения модуля — просмотр/экспорт истории остаются доступны
audit.max_export_rows_per_caseint100000нетЛимит строк на «экспорт дела» — заградительный барьер до постановки в очередь

audit.write_paused отличается от audit.enabled: enabled=false — полное выключение модуля (деградация §3 стандарта, теряется и виджет истории), write_paused=true — точечная мера при инциденте (например аномальный всплеск записи, забивающий очередь/БД) без потери доступа к уже накопленному журналу и UI.

API

МетодПутьДоступНазначение
GET/api/v1/admin/auditadmin (audit.view)Список записей журнала с фильтрами
GET/api/v1/admin/audit/{id}admin (audit.view)Детали одной записи (до/после)
POST/api/v1/admin/audit/exportadmin (audit.export)Постановка экспорта выборки в очередь
POST/api/v1/admin/audit/export-caseadmin (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/healthhealth-чек ядраoutОтдаёт рост таблицы и наличие индексов в агрегат /api/v1/system/health
RequestContext (ядро)прямой вызов контракта cms/core-contractsinИсточник 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_pausedcms:doctor --json (секция audit), проверка настроек группы
Ретеншн не чистит старые записирасписание ScheduleRegistrar, права БД на DELETE по возрастуcms:audit:cleanup --json --dry-run
Подозрение на подмену записицелостность hash-цепочкиcms:audit:verify-chain --json
Экспорт «дела» зависает/падаетдоступность cms/export, объём выборки vs max_export_rows_per_casecms: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 запрещён

Внутренняя база знаний студии