Andrey Epifancev 62807813ab openspec: архив intercompany-matrix (#114)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 15:59:40 +04:00

160 lines
15 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`
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 и числом ячеек