Задачи 2.1/2.2 отмечены: #159 слит до #141, кнопка выгрузки — PR #158, строка в frontend-api-guide — follow-up в arm-ka-docs. M-7 отклонено (design.md). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
175 lines
16 KiB
Markdown
175 lines
16 KiB
Markdown
# 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 и числом ячеек
|
||
|
||
### Requirement: Аудит экспорта результирующей таблицы ВГО
|
||
Каждый успешный `GET …/intercompany-matrix/result-table/export` SHALL добавлять отдельное неизменяемое событие
|
||
`audit_event` с `action = EXPORT`, `section = EXPORT`, `object_type = consolidation_version`,
|
||
`object_id = consolidation_version_id`, `consolidation_version_id` версии, `user_id` инициатора, `before_value = null`
|
||
и `after_value = {report: "intercompany_result_table", articles}`, где `articles` — число статей в книге. Неуспешная
|
||
выгрузка (404, 422, ошибка сборки книги) события не пишет.
|
||
|
||
#### Scenario: Выгрузка таблицы
|
||
- **WHEN** пользователь выгружает результирующую таблицу версии
|
||
- **THEN** в `audit_event` одна запись `EXPORT` с `after_value = {report: "intercompany_result_table", articles: 5}`
|
||
|
||
#### Scenario: Нет счёта статьи
|
||
- **WHEN** выгрузка отвечает 422 `RESULT_ACCOUNTS_MISSING`
|
||
- **THEN** событие `EXPORT` не записано
|