# audit-trail Specification ## Purpose Все действия из enum `audit_action` пишутся в `audit_event` одним способом — через сервис аудита; журнал только дополняется, записи неизменяемы (ТЗ 8.2). ## Requirements ### Requirement: Единая точка записи Сервис аудита SHALL предоставлять метод записи события с полями `action` (из enum `audit_action`), `section` (из `audit_section`), `object_type`, `object_id`, `user_id` (NULL для системных действий), `consolidation_version_id`, `description`, `before_value`, `after_value` (jsonb), `comment`, `result`, `changed_field`, `ip_address`, `user_agent`; `occurred_at` SHALL проставляться сервером (UTC). Запись SHALL выполняться в той же транзакции, что и изменение данных; единственное исключение — события входа раздела `SESSION` (требование «События входа»). NOTE: В Срезе 0 обмен через recognition_job аудит не пишет (рулинг R-7, design #20); сервис готов к использованию Срезом 2+. #### Scenario: Системное событие - **WHEN** системный процесс (без пользователя) вызывает сервис аудита - **THEN** в `audit_event` появляется строка с `user_id = NULL`, `before_value`/`after_value` содержат старый и новый статус #### Scenario: Откат транзакции - **WHEN** изменение данных и запись аудита выполняются в одной транзакции, и она откатывается - **THEN** строка `audit_event` не сохраняется ### Requirement: Журнал только дополняется ORM-модель `audit_event` MUST NOT использоваться для `UPDATE` или `DELETE` в коде ядра; тест SHALL проверять, что в `src/` нет обращений, изменяющих или удаляющих `audit_event`, а сервис аудита не имеет методов изменения. #### Scenario: Попытка изменить запись - **WHEN** код вызывает обновление строки `audit_event` через сервис аудита - **THEN** такого метода нет (ошибка на этапе типизации/теста), запись остаётся прежней ### Requirement: События справочников, импорта и администрирования Каждая мутация справочников, импорт и каждое действие администратора SHALL писать `audit_event` через сервис аудита в той же транзакции с `user_id` инициатора, `ip_address` и `user_agent` запроса: | Действие | `action` | `section` | `object_type` | |---|---|---|---| | создание/изменение/архив ГК | `GROUP_CHANGE` | `DIRECTORIES` | `company_group` | | смена филиала ГК | `ASSIGNMENT_CHANGE` | `DIRECTORIES` | `company_group` | | создание/изменение/перевод/закрытие ЮЛ, алиасы | `LEGAL_ENTITY_CHANGE` | `DIRECTORIES` | `legal_entity` / `legal_entity_alias` | | ГСК/ГСЗ и их состав | `MEMBERSHIP_CHANGE` | `DIRECTORIES` | `related_group` / `related_group_member` | | импорт выгрузки | `DIRECTORY_IMPORT` | `DIRECTORIES` | `directory_import` | | пользователь создан/изменён/деактивирован | `USER_CHANGE` | `USERS` | `app_user` | | роли пользователя | `USER_ROLE_CHANGE` | `USERS` | `app_user` | | права роли | `ROLE_PERMISSION_CHANGE` | `ACCESS` | `role` | | вход: выдача dev-токена, первый запрос с токеном | `LOGIN` | `SESSION` | `app_user` | | отказ во входе (401 токена, 403 аутентификации) | `LOGIN_FAILED` | `SESSION` | `app_user` | Строки раздела `SESSION` подчиняются требованию «События входа» (отдельная транзакция), остальные — пишутся в той же транзакции, что и мутация. `before_value`/`after_value` SHALL содержать изменённые поля (для списков — весь набор до и после); `changed_field` — имя поля при изменении одного поля. #### Scenario: Изменение имени ГК - **WHEN** имя ГК меняется с «А» на «Б» - **THEN** в `audit_event` строка `GROUP_CHANGE` с `changed_field = "name"`, `before_value = {"name": "А"}`, `after_value = {"name": "Б"}`, `user_id` инициатора #### Scenario: Откат мутации справочника - **WHEN** транзакция перевода ЮЛ откатывается - **THEN** строки `LEGAL_ENTITY_CHANGE` и `MEMBERSHIP_CHANGE` в `audit_event` отсутствуют ### Requirement: События входа Ядро SHALL писать через сервис аудита события входа раздела `SESSION` с `object_type = "app_user"`, `object_id = user_id`, `ip_address` и `user_agent` запроса (OQ-64): - `LOGIN` — при выдаче dev-токена (`POST /api/v1/dev/token`, в транзакции запроса) и при первом успешном запросе пользователя с токеном после старта процесса (учёт — в памяти процесса; после рестарта первый запрос пишется снова); - `LOGIN_FAILED` — при 401 (токен предъявлен, но не принят: `user_id = NULL`, в `description` причина, сам токен MUST NOT попадать в журнал) и при 403 аутентификации (пользователь не зарегистрирован, отключён или неоднозначен: email в `description`, `user_id` — если пользователь найден). Запрос без заголовка `Authorization` или со схемой не `Bearer` и 403 «недостаточно прав» событиями входа MUST NOT считаться. `LOGIN_FAILED` и `LOGIN` первого запроса SHALL записываться в ОТДЕЛЬНОЙ короткой транзакции и фиксироваться, даже если транзакция запроса откатывается: при 401/403 она откатывается всегда, а отказ во входе — то событие, ради которого журнал ведётся. `user_agent` SHALL обрезаться до 512 символов, `description` — до 1000. Ошибка записи события входа MUST NOT менять ответ запроса (только лог). `ip_address` достоверен только за доверенным прокси: `X-Forwarded-For` принимается лишь от адресов `FORWARDED_ALLOW_IPS` (по умолчанию `127.0.0.1`). #### Scenario: Первый запрос пользователя - **WHEN** пользователь трижды запрашивает `/api/v1/me` с валидным токеном после старта процесса - **THEN** в `audit_event` одна строка `LOGIN` с его `user_id` и описанием «первый запрос с токеном» #### Scenario: Невалидный токен - **WHEN** запрос несёт подделанный токен - **THEN** ответ 401, в `audit_event` строка `LOGIN_FAILED` с `user_id = NULL` и причиной отказа без токена #### Scenario: Отказ переживает откат запроса - **WHEN** запрос с токеном незарегистрированного пользователя получает 403, и транзакция запроса откатывается - **THEN** строка `LOGIN_FAILED` с email в `description` зафиксирована в `audit_event` #### Scenario: Не попытка входа - **WHEN** запрос пришёл без заголовка `Authorization` или пользователь получил 403 «недостаточно прав» - **THEN** строки `LOGIN_FAILED` нет ### Requirement: События файлов и документов Загрузка, автозамена, удаление файла и подтверждение документа SHALL писать `audit_event` через сервис аудита: | Действие | `action` | `section` | `object_type` | `user_id` | |---|---|---|---|---| | файл принят | `FILE_UPLOAD` | `PRIMARY_DOCUMENTS` | `stored_file` | инициатор | | прежний файл заменён новым (автозамена после маппинга) | `FILE_REPLACE` | `PRIMARY_DOCUMENTS` | `stored_file` (заменённый) | NULL (система; инициатор загрузки — в `description`) | | файл удалён | `FILE_DELETE` | `PRIMARY_DOCUMENTS` | `stored_file` | инициатор | | документ подтверждён пользователем | `DOCUMENT_CONFIRM` | `PRIMARY_DOCUMENTS` | `document` | инициатор | | документ подтверждён автоматически при маппинге (чистый) | `DOCUMENT_CONFIRM` | `PRIMARY_DOCUMENTS` | `document` | NULL (система; `description` = «автоподтверждение: без замечаний») | `FILE_REPLACE` SHALL содержать в `after_value` новый `file_id`, в `description` — перечень заменённых документов и `confirmed=true`, если среди них был `CONFIRMED`. `FILE_DELETE` SHALL содержать в `after_value.unbound_version_ids` версии, где сняты привязки удалённых автоподтверждённых документов. Смена статусов задания и файла в обмене и в цикле маппинга `audit_event` не пишет (`ASSUMPTION(OQ-55)`). #### Scenario: Загрузка двух файлов - **WHEN** принято два файла одним запросом - **THEN** два события `FILE_UPLOAD` с `object_id` каждого файла и `after_value` (`original_name`, `content_hash`, `job_id`) #### Scenario: Автоподтверждение - **WHEN** смаплен чистый результат ОСВ - **THEN** одно событие `DOCUMENT_CONFIRM` с `object_id` документа, `user_id` NULL, `before_value.status = RECOGNIZED`, `after_value.status = CONFIRMED`, `description = «автоподтверждение: без замечаний»` ### Requirement: События консолидации Действия с консолидацией SHALL писать `audit_event` через сервис аудита с заполненным `consolidation_version_id`: | Действие | `action` | `section` | `object_type` | `before_value` / `after_value` | |---|---|---|---|---| | консолидация создана | `CONSOLIDATION_CREATE` | `CONSOLIDATION` | `consolidation_version` | — / `{consolidation_id, year, quarter, members}` | | периметр изменён | `MEMBERSHIP_CHANGE` | `CONSOLIDATION` | `consolidation_version` | списки ЮЛ до / после | | взята в работу, снята | `CONSOLIDATION_LOCK` | `CONSOLIDATION` | `consolidation_version` | — / `{locked_by, released}` | | перехват | `CONSOLIDATION_LOCK_TAKEOVER` | `CONSOLIDATION` | `consolidation_version` | `{locked_by: прежний}` / `{locked_by: новый}`, `comment` = причина | | смена статуса (ручная и автоматическая) | `CONSOLIDATION_STATUS_CHANGE` | `CONSOLIDATION` | `consolidation_version` | `{status}` / `{status}`, `comment` | | контрагент сопоставлен вручную | `OSV_COUNTERPARTY_MAP` | `CONSOLIDATION` | `osv_entry` | `{counterparty_id, match_kind}` / `{counterparty_id, match_kind, propagated_entry_ids, alias_created, alias_moved_from}` | | экспорт свода | `EXPORT` | `EXPORT` | `consolidation_version` | — / `{report: "consolidated_osv", filters, rows}` | Автоматические переходы статуса SHALL писаться с `user_id` инициатора события (загрузивший, подтвердивший, `stored_file.uploaded_by`). Создание и удаление алиасов при ручном сопоставлении SHALL дополнительно писать `LEGAL_ENTITY_CHANGE` (раздел `DIRECTORIES`, как в Срезе 1). `FILE_REPLACE` SHALL добавлять в `after_value` `unbound_version_ids`. #### Scenario: Перехват - **WHEN** Б перехватывает блокировку у А с причиной «Отпуск» - **THEN** одно событие `CONSOLIDATION_LOCK_TAKEOVER` с `before_value.locked_by = A`, `after_value.locked_by = B`, `comment = "Отпуск"`, `consolidation_version_id` версии #### Scenario: Автоматический переход - **WHEN** подтверждение документа перевело версию в `DATA_CORRECTION` - **THEN** событие `CONSOLIDATION_STATUS_CHANGE` с `user_id` подтвердившего, `before_value.status = INITIATED` ### Requirement: Аудит экспорта шахматки Каждый успешный `GET …/intercompany-matrix/all/export` и `POST …/intercompany-matrix/manual/export` SHALL добавлять отдельное неизменяемое событие `audit_event` с `action = EXPORT`, `section = EXPORT`, `object_type = consolidation_version`, `object_id = consolidation_version_id`, `user_id` инициатора, `before_value = null` и `after_value = {report: "intercompany_matrix", mode, debit_account_ids, credit_account_ids, cells}`. `mode` — `ALL` или `MANUAL`, массивы счетов — ID из запроса (в ALL пустые), `cells` — число ячеек. Выгрузка сводной ОСВ продолжает писать собственное событие `EXPORT`. #### Scenario: Две выгрузки - **WHEN** пользователь дважды выгружает ту же шахматку - **THEN** в `audit_event` две разные записи `EXPORT`, каждая со своим payload и числом ячеек