feat: закрыть остаточный backlog

- keyset-пагинация аудита с курсором (occurred_at, id) и UI «Показать ещё»
- пакетный /api/v1/labs/details и переход progress на один запрос
- абсолютный потолок сессии 30 дней (identity V12 + web), очистка refresh
- админская инвентаризация legacy-активов (/admin/legacy-assets, скрипт)
- E2E в Chromium/Firefox/WebKit (18/18), CI preflight
- LDD: 51 claim, 346 тестов, docs и handoff обновлены
This commit is contained in:
2026-10-28 19:29:23 +03:00
parent d45137220c
commit 1e1e9ae0e4
64 changed files with 2124 additions and 372 deletions

View File

@@ -227,9 +227,10 @@ CREATE INDEX audit_events_action_idx ON identity.audit_events (action);
```
Внешних ключей нет: событие должно переживать удаление субъекта. Запись идёт
из сервисов через `POST /api/v1/internal/audit` (`X-Internal-Token`), чтение —
`GET /api/v1/audit?page=&limit=&action=` и `/admin/audit` только для админа.
Страница аудита поддерживает `page` (с нуля) и `limit` (максимум 500), в UI —
переход «Показать ещё».
`GET /api/v1/audit?limit=&action=&cursor=` и `/admin/audit` только для админа.
Страница аудита продолжается keyset-курсором `(occurred_at, id)`; offset `page`
остаётся для совместимости, `limit` — максимум 500. В UI — «Показать ещё» и
«В начало»; битый курсор даёт `400`.
**V11 — refresh-токены (добавлена при закрытии рисков аутентификации)**
```sql
@@ -422,7 +423,8 @@ V13 идемпотентна: если старая feature-миграция V10
и защиты. Свой профиль студент открывает без записи в аудит, чтобы не
засорять журнал и не нагружать identity на горячем пути. Админ смотрит
журнал на `/admin/audit` с фильтром по действию; время отображается в МСК.
Журнал читается страницами (`page`/`limit`, «Показать ещё»), а не только
Журнал читается keyset-курсором (`cursor`/`limit`, «Показать ещё»; offset
`page` — только совместимость), а не только
последними N событиями. `membership_history` и `promotion_runs/items`
остаются доменной историей и дополняют общий журнал.
@@ -433,19 +435,20 @@ V13 идемпотентна: если старая feature-миграция V10
сервисами на каждом запросе).
- **Refresh-токен** — 7 дней (`LMS_REFRESH_TOKEN_TTL`), хранится хешем в
`identity.refresh_tokens` (`V11`), ротируется при каждом обновлении.
Повторное использование старого токена отзывает все активные сессии
аккаунта; выход, смена пароля, отчисление/выпуск/архив и закрытая группа
обновление запрещают.
Абсолютный потолок сессии — 30 дней (`LMS_SESSION_ABSOLUTE_TIMEOUT`, `V12`):
ротация не продлевает `absolute_expires_at`. Повторное использование старого
токена отзывает все активные сессии аккаунта; выход, смена пароля,
отчисление/выпуск/архив и закрытая группа обновление запрещают. Истёкшие
токены старше retention удаляет ночная задача.
- **Прямой API-клиент** обязан обновлять пару через
`POST /api/v1/auth/refresh`; `POST /api/v1/auth/logout` отзывает refresh.
- **Web-сессия** — 7 дней sliding (`LMS_SESSION_TIMEOUT`), cookie `LMS_SESSION`
(`HttpOnly`, `SameSite=Lax`, `Max-Age` обновляется на каждом ответе).
Access-токен автоматически обновляется за 2 минуты до истечения, поэтому
активная вкладка не требует повторного входа каждые 15 минут; при выходе
refresh отзывается.
- **Ограничение:** абсолютного лимита жизни сессии нет — при непрерывной
активности она продлевается. Если политика потребует принудительный
повторный вход раз в N дней, добавляется отдельный абсолютный `max-age`.
(`HttpOnly`, `SameSite=Lax`, `Max-Age` обновляется на каждом ответе), но не
дольше 30 дней с момента входа (`LMS_SESSION_ABSOLUTE_TIMEOUT`). После
абсолютного срока cookie получает `Max-Age=0`, а пользователь — повторный
вход. Access-токен автоматически обновляется за 2 минуты до истечения,
поэтому активная вкладка не требует повторного входа каждые 15 минут; при
выходе refresh отзывается.
---
@@ -457,8 +460,9 @@ V13 идемпотентна: если старая feature-миграция V10
- **Активы (реализовано):** видимость актива наследуется от статьи → раздела →
главы, маршрут `/api/v1/content/assets/{name}` проверяет доступ. Файл без
связи со статьёй не публичен: аноним/студент/преподаватель получают `404`,
админ может скачать файл и получить список таких файлов через
`GET /api/v1/content/legacy-assets` для привязки или удаления. Старые
админ может скачать файл, открыть страницу `/admin/legacy-assets`, вызвать
`GET /api/v1/content/legacy-assets` или `scripts/legacy-assets.sh` для
привязки или удаления. Старые
внешние ссылки на несвязанные файлы у обычных пользователей перестают
работать — это осознанное secure-by-default поведение.
- Поисковая индексация: для `ALL` — по умолчанию разрешена; для остального — закрыть (robots/noindex).
@@ -527,13 +531,16 @@ V13 идемпотентна: если старая feature-миграция V10
---
## 12. Интеграция «Контингент» / next.uniyar.ac.ru (задел)
## 12. Интеграция «Контингент» / next.uniyar.ac.ru — исключена из плана
- Позже — **отдельный сервис-адаптер** (anti-corruption layer); внешняя система — источник истины по
курсу/группе/статусу; LMS — зеркало; синхронизация односторонняя + отчёт дрифта.
- Сейчас закладываем: `student_no` уникальный и стабильный (ключ склейки); внешние id добавим миграцией.
- Обязательно: идемпотентность, журнал синхронизаций, отчёт расхождений, обработка академа/переводов/отчислений,
устойчивость к недоступности внешней системы, ПДн.
Решением от 2026-10-28 интеграция **не входит в объём работ**: нет API-спецификации
и доступа к внешней системе, а без них можно сделать только непроверяемую заглушку.
`student_no` остаётся уникальным и стабильным, поэтому при появлении нового ТЗ
адаптер можно добавить без переделки модели.
Если интеграция понадобится позже: отдельный anti-corruption layer, идемпотентная
синхронизация, журнал, отчёт дрифта, обработка академа/переводов/отчислений,
устойчивость к недоступности внешней системы и ПДн.
---
@@ -573,6 +580,6 @@ V13 идемпотентна: если старая feature-миграция V10
4. Запись/снятие групп на курс.
5. Повышение курса: dry-run, журнал, идемпотентность, откат, выпуск.
6. Академический отпуск/возврат, отчисление, выпуск, архив; усиленное удаление.
7. (Позже) сервис-адаптер «Контингент»/`next.uniyar.ac.ru`.
7. ~~Сервис-адаптер «Контингент»/`next.uniyar.ac.ru`~~ — исключён из плана.
Открытых вопросов нет: все решения зафиксированы в §1.