docs(openspec): спеки синхронизированы с кодом issue #9 (ревью R-4)

- reference-data-seed: матрица role_permission целиком — только в пустую
  таблицу, в заполненную — лишь пары ролей/прав, вставленных тем же запуском
  (сценарии: снятое право, новые роль и право, все связи сняты); `--dev` только
  при DEBUG=true (код 2 до обращения к БД).
- audit-trail: требование «События входа» (LOGIN/LOGIN_FAILED, раздел SESSION)
  — исключение из «той же транзакции» с обоснованием, обрезка полей, доверенные
  прокси; строки в таблице событий.
- authn-authz: ссылка на аудит входа; требование «Прод-конфигурация
  HTTP-поверхности» — /docs, /redoc, /openapi.json только при DEBUG, CORS по
  CORS_ORIGINS, 405 на неверный метод, 404 под /api/v1, GET / без редиректа.

`openspec validate --specs`: 9 passed, 0 failed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-05 13:49:46 +04:00
parent 7b28554817
commit 8bc76c69a0
3 changed files with 98 additions and 3 deletions

View File

@ -10,7 +10,8 @@
Сервис аудита 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 выполняться в той же транзакции, что и изменение данных.
`occurred_at` SHALL проставляться сервером (UTC). Запись SHALL выполняться в той же транзакции, что и изменение данных;
единственное исключение — события входа раздела `SESSION` (требование «События входа»).
NOTE: В Срезе 0 обмен через recognition_job аудит не пишет (рулинг R-7, design #20); сервис готов к использованию Срезом 2+.
@ -44,6 +45,11 @@ ORM-модель `audit_event` MUST NOT использоваться для `UPD
| пользователь создан/изменён/деактивирован | `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` —
имя поля при изменении одного поля.
@ -55,3 +61,35 @@ ORM-модель `audit_event` MUST NOT использоваться для `UPD
#### 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` нет

View File

@ -12,6 +12,8 @@
настроенным проверяющим (dev HS256 при `AUTH_DEV_ISSUER=true`, иначе JWKS/публичный ключ по `AUTH_*`), извлечь email из
claim `AUTH_EMAIL_CLAIM` (по умолчанию `email`) и найти `app_user` с этим `email` и `is_active = true`. Нет или невалидный
токен → 401; пользователь не найден или неактивен → 403. Тело ошибки — `{"detail": "<причина по-русски>"}`.
Отказы в аутентификации (401 при предъявленном токене, 403 «не найден / отключён / неоднозначен») и вход SHALL
фиксироваться в журнале по требованию «События входа» спеки `audit-trail` (`LOGIN_FAILED` / `LOGIN`).
#### Scenario: Валидный токен активного пользователя
- **WHEN** запрос несёт корректный токен с `email = ka_rf@armka.local`, и такой активный пользователь есть
@ -64,3 +66,32 @@ claim `AUTH_EMAIL_CLAIM` (по умолчанию `email`) и найти `app_us
#### Scenario: Прод-конфигурация
- **WHEN** `DEBUG=false`
- **THEN** `POST /api/v1/dev/token` возвращает 404, токен, подписанный dev-секретом, отвергается с 401
### Requirement: Прод-конфигурация HTTP-поверхности
Документация API (`/docs`, `/redoc`, `/openapi.json`) SHALL существовать только при `DEBUG=true`; при `DEBUG=false`
эти пути MUST отвечать 404 (не схемой и не страницей SPA) (OQ-66). CORS при `DEBUG=true` SHALL разрешать любой источник
`*` без `allow_credentials`; при `DEBUG=false` — только источники из `CORS_ORIGINS` (JSON-список или строка через
запятую), пустой список SHALL запрещать кросс-доменные запросы, `allow_credentials` — только для явного списка без `*`
(OQ-65). Под `/api/v1` SPA MUST NOT отвечать: неверный метод на существующий маршрут SHALL давать 405 с заголовком
`Allow` и `detail` по-русски, несуществующий путь (в том числе dev-маршрут при выключенных флагах) — 404 (OQ-61);
`GET /` SHALL отдавать `index.html` SPA без редиректа при любом `ROOT_PATH` (OQ-62).
#### Scenario: Документация в проде
- **WHEN** `DEBUG=false` и запрошен `/openapi.json`
- **THEN** ответ 404
#### Scenario: Кросс-доменный запрос в проде без настроенных источников
- **WHEN** `DEBUG=false`, `CORS_ORIGINS` пуст, браузер шлёт preflight с `Origin: https://other.example`
- **THEN** в ответе нет `Access-Control-Allow-Origin`
#### Scenario: Неверный метод
- **WHEN** выполнен `POST /api/v1/me`
- **THEN** ответ 405, заголовок `Allow: GET`
#### Scenario: Несуществующий путь API
- **WHEN** выполнен `GET /api/v1/no-such-route`
- **THEN** ответ 404, не `index.html`
#### Scenario: Корень приложения
- **WHEN** при `ROOT_PATH=/` выполнен `GET /`
- **THEN** ответ 200 с `index.html`, без редиректа на `//`

View File

@ -14,6 +14,12 @@ dev-данные загружаются из файлов репозитория
`elimination_rule` + `elimination_rule_account` версии 1 для ББ (§4), `required_document_rule` для `BASIC` и `EXTENDED`.
Записи SHALL вставляться или обновляться по натуральному ключу; удаление MUST NOT выполняться.
Исключение — матрица `role_permission` (ей после первичной загрузки владеет администратор, issue #9): в ПУСТУЮ таблицу
матрица файла SHALL загружаться целиком; в заполненную SHALL вставляться только пары, у которых роль или право
вставлены этим же запуском seed (их администратор снять не мог), а остальные пары файла MUST NOT добавляться — право,
снятое администратором, не возвращается, добавленное сверх файла не отзывается. Отчёт команды SHALL сообщать, что
матрица пропущена (или сколько добавлено пар новых ролей и прав).
#### Scenario: Первый запуск на пустой схеме
- **WHEN** после `alembic upgrade head` выполняется `python -m scripts.seed`
- **THEN** в `role` 7 строк, в `permission` 16, в `role_permission` — число строк равно сумме прав по матрице §10,
@ -22,7 +28,21 @@ dev-данные загружаются из файлов репозитория
#### Scenario: Повторный запуск
- **WHEN** `python -m scripts.seed` выполняется второй раз без изменения файлов
- **THEN** число строк во всех затронутых таблицах не меняется, команда завершается с кодом 0
- **THEN** число строк во всех затронутых таблицах не меняется, команда завершается с кодом 0, в отчёте
`role_permission` помечена как пропущенная
#### Scenario: Право, снятое администратором
- **WHEN** администратор снял с роли `EXPERT` право `EX_VIEW`, после чего seed запущен снова
- **THEN** связь `EXPERT × EX_VIEW` не восстановлена, остальная матрица не изменилась
#### Scenario: Новые роль и право в файлах seed
- **WHEN** матрица заполнена, а в `permissions.yaml` добавлено новое право, в `roles.yaml` — новая роль, и в
`role_permissions.yaml` — их пары (новое право существующей роли, существующее право новой роли)
- **THEN** seed вставляет ровно эти пары; снятые ранее администратором связи существующих ролей и прав не возвращаются
#### Scenario: Администратор снял все связи
- **WHEN** таблица `role_permission` пуста и seed запущен снова
- **THEN** матрица файла загружается целиком, как при первом запуске
#### Scenario: Изменение названия роли в файле
- **WHEN** в `roles.yaml` изменено `name` роли `EXPERT` и seed запущен снова
@ -47,7 +67,9 @@ dev-данные загружаются из файлов репозитория
### Requirement: Dev-данные только по явному флагу
`python -m scripts.seed --dev` SHALL дополнительно загрузить из `api/seed/dev/`: примеры региональных филиалов и по одному
`app_user` на каждую системную роль (`<role>@armka.local`, активные, с назначенной ролью через `user_role`). Без флага
`--dev` эти данные MUST NOT загружаться.
`--dev` эти данные MUST NOT загружаться. Флаг `--dev` SHALL приниматься только при `DEBUG=true`: при `DEBUG=false`
команда MUST завершиться с кодом 2 и сообщением «--dev доступен только при DEBUG=true (dev-пользователи не для прода)»
до чтения файлов seed и обращения к БД (OQ-60).
#### Scenario: Dev-пользователи
- **WHEN** выполнен `python -m scripts.seed --dev`
@ -56,3 +78,7 @@ dev-данные загружаются из файлов репозитория
#### Scenario: Прод-режим
- **WHEN** выполнен `python -m scripts.seed` без `--dev`
- **THEN** таблица `app_user` пуста, `regional_office` содержит только Головной офис
#### Scenario: `--dev` без DEBUG
- **WHEN** при `DEBUG=false` выполнен `python -m scripts.seed --dev`
- **THEN** код возврата 2, в stderr — сообщение об отказе, БД не затронута