Andrey Epifancev 70c5743c77 Архив OpenSpec retry-recognition-and-edit-osv-header (PR #161)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 18:20:08 +04:00

216 lines
20 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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` и
`GET …/intercompany-matrix/preset/{rule_id}/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}`; для `PRESET` в `after_value` SHALL быть дополнительно `rule_id`, `rule_no` и
`rule_version_no` (фактическая версия применённого правила, в том числе при незафиксированной версии правил). `mode` — `ALL`,
`MANUAL` или `PRESET`, массивы счетов — ID из запроса (в ALL и PRESET пустые), `cells` — число ячеек. Выгрузка
сводной ОСВ продолжает писать собственное событие `EXPORT`.
#### Scenario: Две выгрузки
- **WHEN** пользователь дважды выгружает ту же шахматку
- **THEN** в `audit_event` две разные записи `EXPORT`, каждая со своим payload и числом ячеек
#### Scenario: Выгрузка пресета
- **WHEN** пользователь выгружает `GET …/intercompany-matrix/preset/{rule_id}/export`
- **THEN** событие `EXPORT` содержит `mode = PRESET`, `rule_id`, `rule_no`, `rule_version_no`, пустые массивы счетов и число ячеек
### 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` не записано
### Requirement: Аудит ручной повторной обработки файла
Каждая принятая ручная попытка SHALL писать одно audit_event с action = FILE_REPROCESS,
section = PRIMARY_DOCUMENTS, object_type = stored_file, object_id = file_id, user_id инициатора,
IP и User-Agent запроса. before_value SHALL содержать прежние job_id, attempt, status задания, error_code;
after_value — новые job_id, attempt, а при пакетном повторе также consolidation_id. Событие SHALL фиксироваться в транзакции создания попытки.
#### Scenario: Пакетный повтор
- **WHEN** пакет принял два файла ОСВ РБ и пропустил третий
- **THEN** два FILE_REPROCESS с разными object_id и прежними/новыми job_id, с consolidation_id в after_value;
before_value.status — статус прежнего задания (timeout или dlq), error_code — TIMEOUT или NO_ACK; для skipped события нет
#### Scenario: Отказ или откат
- **WHEN** повтор отклонён либо транзакция создания задания откатывается
- **THEN** FILE_REPROCESS для него отсутствует
#### Scenario: Автоматическая повторная публикация
- **WHEN** outbox повторно публикует прежнее задание без ack, не создавая новую попытку
- **THEN** FILE_REPROCESS не пишется
### Requirement: Аудит уточнения шапки ОСВ
Фактический PATCH шапки ОСВ SHALL писать одно RECOGNIZED_DATA_EDIT раздела RECOGNITION,
object_type = document, object_id документа, user_id инициатора. before/after SHALL содержать изменённые
поля и при сбросе подтверждения status, confirmed_by/at; after_value.unbound_version_ids SHALL содержать
версии снятых привязок. Запрос без изменений MUST NOT писать аудит.
#### Scenario: Подтверждённая ОСВ исправлена
- **WHEN** меняется счёт CONFIRMED ОСВ РБ, привязанной к версии DATA_CORRECTION
- **THEN** одно RECOGNIZED_DATA_EDIT с прежним/новым счётом и подтверждением, after_value содержит
unbound_version_ids, пользователь, IP и User-Agent сохранены
#### Scenario: Возврат к исходному
- **WHEN** аналитик возвращает исходные значения после предыдущей правки
- **THEN** новое RECOGNIZED_DATA_EDIT отражает эту правку; старые события не меняются