diff --git a/README.md b/README.md index 102e609..48f0074 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,8 @@ progress `:8083`, web `:8090`. Интерфейс: `http://localhost:8090`. ./gradlew ktlintCheck detekt test # линтеры (строгий режим) + тесты ./gradlew evidenceCheck # LDD: P0-claims должны иметь зелёные свидетельства ./gradlew ktlintFormat # автоформат Kotlin -./scripts/ldd-runtime.sh # runtime-smoke полного стека на одноразовой БД +./scripts/ldd-smoke.sh # быстрый smoke полного стека (как CI job smoke) +./scripts/ldd-runtime.sh # runtime-smoke полного стека + scale load ./scripts/ldd-migration-upgrade.sh # upgrade с master-схемы ./scripts/ldd-mutation.sh # PIT: mutation strength критичных P0-компонентов ./scripts/ldd-browser.sh # Playwright E2E (cd e2e && npm install && npx playwright install chromium) @@ -69,10 +70,14 @@ progress `:8083`, web `:8090`. Интерфейс: `http://localhost:8090`. - Тестовая инфраструктура — `libs:test-support` (JUnit 5, MockK, Testcontainers `postgres:18`). - **LDD** — реестр проверяемых утверждений `docs/evidence/claims.json`; P0 без зелёного теста валит `./gradlew evidenceCheck`. Runtime, migration, - mutation и browser E2E — отдельные скрипты из `scripts/`; baseline — + mutation, browser E2E и smoke — скрипты из `scripts/`; baseline — `docs/evidence/baseline-2026-10-27.md`. -- CI merge gate — `.gitlab-ci.yml`: `./gradlew --no-daemon test ktlintCheck detekt evidenceCheck` - на push/MR. Runner должен разрешать privileged `docker:dind` для Testcontainers. +- CI merge gate — `.gitlab-ci.yml`: `test ktlintCheck detekt evidenceCheck` и + job `smoke` (`scripts/ldd-smoke.sh`) на каждом push/MR. Полный + `release-evidence` (`scripts/ldd-release.sh`) обязателен для тегов и + запускается вручную для веток. `check` требует privileged `docker:dind` + для Testcontainers; `smoke`/`release-evidence` — shell-runner с Java 21, + Docker Compose v2, Python 3 и (для release) Node/Chromium. ## Запуск в Docker (для сервера) @@ -113,6 +118,11 @@ cp .env.prod.example .env.prod # заполнить LMS_DB_PASSWORD - **Доступ**: JWT (RSA, ключи identity генерирует при первом старте и хранит в БД), проверка токена на gateway, роли ADMIN/TEACHER/STUDENT, CSRF на всех POST-формах, session fixation-защита; вход студента с обязательной сменой пароля. + Access-токен живёт 15 минут (`LMS_ACCESS_TOKEN_TTL`), refresh — 7 дней + (`LMS_REFRESH_TOKEN_TTL`) с ротацией и отзывом; прямой API использует + `POST /api/v1/auth/refresh`. Web-сессия `LMS_SESSION` — 7 дней sliding + (`LMS_SESSION_TIMEOUT`), access обновляется автоматически за 2 минуты до + истечения; logout и смена пароля отзывают refresh. - **Группы и студенты**: группы-когорты (поток, курс, номер, степень, год набора), импорт `.xls/.xlsx/.csv` по шаблону `поток/курс/номер/степень/год набора/ФИО/№/база`, ручное добавление студента (генерация пароля) и «Добавить существующего» @@ -139,8 +149,8 @@ cp .env.prod.example .env.prod # заполнить LMS_DB_PASSWORD `/teacher/courses/{id}/content`. Чужой курс отклоняется на сервисах. - **Аудит**: чувствительные действия (профили, скачивания, оценки, защиты, курсы, группы, материалы, преподаватели, жизненный цикл) пишутся в - `identity.audit_events`; админ смотрит `/admin/audit` с фильтром по действию - и временем МСК. + `identity.audit_events`; админ смотрит `/admin/audit` с фильтром по действию, + временем МСК и постраничным переходом «Показать ещё» (`page`/`limit`). - **Повышение и жизненный цикл**: `/admin/promotion` — dry-run, применение и откат повышения (автоматический dry-run 31 августа МСК); на странице группы — академотпуск, возврат, отчисление, архив и восстановление; @@ -165,6 +175,9 @@ cp .env.prod.example .env.prod # заполнить LMS_DB_PASSWORD markdown (код, спойлеры, картинки; санитизация OWASP); импорт ZIP (`chapters/N-глава/N-раздел/N-статья.md` + `assets/`); создание/переименование/ удаление глав, разделов, статей; перенос статей между разделами. + Видимость картинок наследуется от статьи; файл без привязки к статье отдаёт + `404` обычным пользователям и доступен только админу, который получает + список таких файлов через `GET /api/v1/content/legacy-assets`. - **Студент**: дашборд успеваемости (сдано, средний балл, лучшая/слабая оценка), карточки опубликованных лаб, страница лабы: условие, свой вариант, сдача файлов (zip/.c, переотправка до дедлайна, история, скачивание последней), diff --git a/deploy/DEPLOY.md b/deploy/DEPLOY.md index f3ad534..852cea4 100644 --- a/deploy/DEPLOY.md +++ b/deploy/DEPLOY.md @@ -57,6 +57,22 @@ Compose передаёт одно и то же значение identity, academ Значение по умолчанию `dev-internal-token` для production не используйте; `.env.prod` не коммитьте. +Сроки аутентификации и сессии (можно не задавать — действуют значения ниже): + +```dotenv +LMS_ACCESS_TOKEN_TTL=15m # access JWT identity, короткий +LMS_REFRESH_TOKEN_TTL=7d # refresh-токен, ротация и отзыв +LMS_SESSION_TIMEOUT=7d # web-сессия LMS_SESSION, sliding +``` + +`LMS_ACCESS_TOKEN_TTL` и `LMS_REFRESH_TOKEN_TTL` читает identity, +`LMS_SESSION_TIMEOUT` — web. `docker-compose.prod.yml` прокидывает эти +переменные в environment соответствующих сервисов; при переопределении +используются значения из `.env.prod`. Access +обновляется автоматически за 2 минуты до истечения; прямой API-клиент должен +сам вызывать `POST /api/v1/auth/refresh`. Уменьшайте значения только осознанно: +слишком короткая web-сессия заставит пользователей чаще входить заново. + ## 4. Собрать и запустить ```bash @@ -109,16 +125,24 @@ git pull `deploy.sh up` **обязательно** делает backup перед обновлением. Если backup завершился ошибкой, деплой прерывается. Миграции БД (Flyway) применяются -автоматически при старте сервисов: identity `V4–V10` (`V10` создаёт -`identity.audit_events`), academic `V6–V12`, progress `V13`. +автоматически при старте сервисов: identity `V4–V11` (`V10` создаёт +`identity.audit_events`, `V11` — `identity.refresh_tokens`), academic `V6–V12`, +progress `V13`. После обновления проверьте, что админ открывает `/admin/audit` и видит события (например, после входа в профиль студента — `PROFILE_VIEW`); преподаватель и -студент на эту страницу не попадают. +студент на эту страницу не попадают. Журнал листается через `page`/`limit` +(«Показать ещё»). Для инвентаризации старых файлов админ может вызвать +`GET /api/v1/content/legacy-assets`: файлы без привязки к статье больше не +публичны (`404` для обычных пользователей), их нужно привязать или удалить. CI (`.gitlab-ci.yml`) на каждом push/MR выполняет -`./gradlew --no-daemon test ktlintCheck detekt`; для Testcontainers runner -должен разрешать privileged `docker:dind`. +`./gradlew --no-daemon test ktlintCheck detekt evidenceCheck` и job `smoke` +(`./scripts/ldd-smoke.sh`: полный стек, health, вход админа, один курс). +Полный job `release-evidence` (`./scripts/ldd-release.sh`) обязателен для тегов +и запускается вручную для веток. `check` использует privileged `docker:dind` +для Testcontainers; `smoke`/`release-evidence` выполняются на shell-runner +с Java 21, Docker Compose v2, Python 3, Node и Chromium Playwright. ## 8. Резервное копирование diff --git a/docs/01-architecture.md b/docs/01-architecture.md index 5e06953..f5c7901 100644 --- a/docs/01-architecture.md +++ b/docs/01-architecture.md @@ -32,8 +32,11 @@ flowchart LR id -.->|JWKS| pr ``` -- Браузер никогда не получает JWT: токен живёт в HTTP-сессии web-сервиса - (`lms.token`), а в браузер уходит только cookie сессии (`HttpOnly`, 30 дней). +- Браузер никогда не получает JWT: access-токен живёт в HTTP-сессии web-сервиса + (`lms.token`), а в браузер уходит только cookie сессии `LMS_SESSION` + (`HttpOnly`, `SameSite=Lax`, 7 дней sliding). Access-токен — 15 минут, + обновляется автоматически за 2 минуты до истечения по refresh-токену; + cookie `Max-Age` перевыпускается на каждом ответе. - Все REST-запросы web проксирует на gateway, добавляя `Authorization: Bearer`. - Gateway проверяет подпись токена по JWKS identity и передаёт вниз заголовки `X-Account-Id`, `X-Account-Login`, `X-Account-Role`. @@ -139,12 +142,13 @@ flowchart TD n -->|да| p["Контроллер → сервис → БД"] ``` -- Роли: `ADMIN`, `STUDENT`; конвертер ролей читает claim `role` как `ROLE_`. -- Публичные пути: `/actuator/**`, `/api/v1/auth/{health,setup,login,jwks.json}`, +- Роли: `ADMIN`, `TEACHER`, `STUDENT`; конвертер ролей читает claim `role` как `ROLE_`. +- Публичные пути: `/actuator/**`, `/api/v1/auth/{health,setup,login,refresh,jwks.json}`, `/api/v1/content/tree`, `/api/v1/content/{chapter}/{section}/{article}`. - `/api/v1/content/assets/**` gateway пропускает без JWT (legacy/публичные + `/api/v1/content/assets/**` gateway пропускает без JWT (публичные `ALL`-активы), но academic проверяет видимость статьи и для курсовых или - ролевых активов требует токен. + ролевых активов требует токен. Файл без привязки к статье — `404` всем, + кроме админа (secure-by-default). - RSA-ключи identity хранятся в таблице `settings` и публикуются в JWKS. ## 5. Рендеринг страниц и защита форм (web) diff --git a/docs/02-deployment.md b/docs/02-deployment.md index 8d975de..5a2d79a 100644 --- a/docs/02-deployment.md +++ b/docs/02-deployment.md @@ -52,6 +52,10 @@ flowchart TB `LMS_JWT_JWK_SET_URI` обязателен для gateway, academic и progress — в контейнерах это `http://identity:8081/api/v1/auth/jwks.json` (иначе 401). +Сроки сессии задаются переменными (значения по умолчанию подходят): +`LMS_ACCESS_TOKEN_TTL=15m` и `LMS_REFRESH_TOKEN_TTL=7d` для identity, +`LMS_SESSION_TIMEOUT=7d` для web. + ## 3. Сборка ```mermaid @@ -101,7 +105,7 @@ sequenceDiagram DC->>DC: docker build (5 стадий) DC->>PG: старт postgres (volume lms_pgdata) DC->>DC: старт identity → academic → progress → gateway → web - Note over DC: Flyway применяет миграции автоматически
при старте сервисов (identity V1..V10, academic V1..V12, progress V1..V9 и V11..V13; у web миграций нет) + Note over DC: Flyway применяет миграции автоматически
при старте сервисов (identity V1..V11, academic V1..V12, progress V1..V9 и V11..V13; у web миграций нет) ``` - Локально (`docker-compose.yml`) поднимается только `postgres`; прод-стек — `docker-compose.prod.yml`. diff --git a/docs/06-authoring.md b/docs/06-authoring.md index 19862a3..121d03a 100644 --- a/docs/06-authoring.md +++ b/docs/06-authoring.md @@ -128,7 +128,9 @@ printf("Ответ: 42\n"); * Ссылки вида `assets/имя` автоматически заменяются на адрес `/api/v1/content/assets/...`; сами файлы складываются в хранилище. Для курсовых статей этот адрес требует сессию пользователя курса, для публичных - `ALL`-материалов и старых несвязанных файлов остаётся публичным. + `ALL`-материалов остаётся публичным. Файл без привязки к статье больше не + публичен: обычные пользователи получают `404`, админ — файл и его имя в + `GET /api/v1/content/legacy-assets` для привязки или удаления. ## Публикация и скрытие diff --git a/docs/07-groups-courses.md b/docs/07-groups-courses.md index ad41a45..584b8e0 100644 --- a/docs/07-groups-courses.md +++ b/docs/07-groups-courses.md @@ -60,8 +60,8 @@ **Разбор имени группы:** `^(.+)-(\d)(\d+)(БО|МО)$` → `stream` (буквы/дефисы), `course` (одна цифра), `number` (остальные цифры), `degree`. Примеры: `ПИ-11БО` → ПИ/1/1/БО; `ИВТ-ТОП-12БО` → ИВТ-ТОП/1/2/БО; -`ПИ-101БО` → курс 1, номер 01 (ведущие нули допустимы). Разбор выполняется при создании/импорте, -далее живём по полям; имя храним для отображения/выгрузок. +`ПИ-101БО` → курс 1, номер 1. `number` — числовой; ведущий ноль не является отдельным значением. +Разбор выполняется при создании/импорте, далее живём по полям; имя храним для отображения/выгрузок. --- @@ -227,7 +227,29 @@ CREATE INDEX audit_events_action_idx ON identity.audit_events (action); ``` Внешних ключей нет: событие должно переживать удаление субъекта. Запись идёт из сервисов через `POST /api/v1/internal/audit` (`X-Internal-Token`), чтение — -`GET /api/v1/audit` и `/admin/audit` только для админа. +`GET /api/v1/audit?page=&limit=&action=` и `/admin/audit` только для админа. +Страница аудита поддерживает `page` (с нуля) и `limit` (максимум 500), в UI — +переход «Показать ещё». + +**V11 — refresh-токены (добавлена при закрытии рисков аутентификации)** +```sql +CREATE TABLE identity.refresh_tokens ( + id uuid PRIMARY KEY, + account_id uuid NOT NULL REFERENCES identity.accounts(id) ON DELETE CASCADE, + token_hash varchar(64) NOT NULL UNIQUE, + issued_at timestamptz NOT NULL, + expires_at timestamptz NOT NULL, + revoked_at timestamptz, + replaced_by uuid +); +CREATE INDEX refresh_tokens_account_idx ON identity.refresh_tokens (account_id); +CREATE INDEX refresh_tokens_expires_idx ON identity.refresh_tokens (expires_at); +``` +Хранится только SHA-256-хеш raw-токена. Ротация при `POST /api/v1/auth/refresh` +помечает старый токен заменённым; повторное использование заменённого токена +трактуется как кража и отзывает все сессии аккаунта. `logout` и смена пароля +отзывают refresh; отчисление, выпуск, архив и закрытая группа не дают +обновить сессию. ### academic @@ -400,8 +422,30 @@ V13 идемпотентна: если старая feature-миграция V10 и защиты. Свой профиль студент открывает без записи в аудит, чтобы не засорять журнал и не нагружать identity на горячем пути. Админ смотрит журнал на `/admin/audit` с фильтром по действию; время отображается в МСК. -`membership_history` и `promotion_runs/items` остаются доменной историей и -дополняют общий журнал. +Журнал читается страницами (`page`/`limit`, «Показать ещё»), а не только +последними N событиями. `membership_history` и `promotion_runs/items` +остаются доменной историей и дополняют общий журнал. + +### 5.3. Аутентификация, сессии и отзыв доступа + +- **Access-токен** — JWT на 15 минут (`LMS_ACCESS_TOKEN_TTL`), роли + `ADMIN/TEACHER/STUDENT`, без списка курсов/групп внутри (скоуп проверяется + сервисами на каждом запросе). +- **Refresh-токен** — 7 дней (`LMS_REFRESH_TOKEN_TTL`), хранится хешем в + `identity.refresh_tokens` (`V11`), ротируется при каждом обновлении. + Повторное использование старого токена отзывает все активные сессии + аккаунта; выход, смена пароля, отчисление/выпуск/архив и закрытая группа + обновление запрещают. +- **Прямой 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`. --- @@ -410,10 +454,13 @@ V13 идемпотентна: если старая feature-миграция V10 - Аудитории: `ALL` (в т.ч. **неавторизованные**), `STUDENTS`, `TEACHERS`; глобальные публикует только админ. - Нужны **публичные маршруты** для `ALL` без аутентификации. `STUDENTS`/`TEACHERS`/курсовые материалы анонимно недоступны ни при каких условиях. -- **Активы (риск):** материалы ссылаются на файлы (`/api/v1/assets/**`). Для публичных глав активы должны - быть доступны, для приватных — нет. Решение: видимость актива наследуется от главы, и маршрут выдачи - активов обязан это проверять (либо отдельный публичный префикс для `ALL`). Без этого публичные материалы - будут ломаться, а приватные — утекать. +- **Активы (реализовано):** видимость актива наследуется от статьи → раздела → + главы, маршрут `/api/v1/content/assets/{name}` проверяет доступ. Файл без + связи со статьёй не публичен: аноним/студент/преподаватель получают `404`, + админ может скачать файл и получить список таких файлов через + `GET /api/v1/content/legacy-assets` для привязки или удаления. Старые + внешние ссылки на несвязанные файлы у обычных пользователей перестают + работать — это осознанное secure-by-default поведение. - Поисковая индексация: для `ALL` — по умолчанию разрешена; для остального — закрыть (robots/noindex). --- diff --git a/docs/08-groups-courses-plan.md b/docs/08-groups-courses-plan.md index 9768a79..a0ab141 100644 --- a/docs/08-groups-courses-plan.md +++ b/docs/08-groups-courses-plan.md @@ -1,8 +1,9 @@ # 08. План реализации: группы, курсы, преподаватели **Статус:** план согласован, этапы 0–6 выполнены. Этап 6 закрыт: права -преподавателя на запись, аудит и CI добавлены; остаточные риски — § 9.1 и -[16-stage6-report.md](16-stage6-report.md). +преподавателя на запись, аудит и CI добавлены; продуктовые риски (legacy-активы, +срок жизни JWT/сессии, пагинация аудита) закрыты 2026-10-28. Оставшиеся +ограничения — § 9.1 и [16-stage6-report.md](16-stage6-report.md). **Основа:** [07-groups-courses.md](07-groups-courses.md). **Дата решений:** 2026-10-23. @@ -195,10 +196,12 @@ привязка групп, авторские материалы курса. - Полная security-матрица и negative-тесты «свой/чужой курс». - Upgrade-тест миграций. -- Индексы; пагинация и N+1 — follow-up (§ 9.1). +- Индексы; пагинация аудита и bulk-выдача групп с именами — выполнены; + оставшийся N+1 в отдельных лабораторных списках — follow-up (§ 9.1). - Аудит скачиваний и чувствительных действий (`identity.audit_events`, `/admin/audit`). -- CI merge gate: `test ktlintCheck detekt` на каждом push/MR. +- CI merge gate: `test ktlintCheck detekt evidenceCheck` и smoke полного стека + на каждом push/MR; полный `release-evidence` — для тегов/вручную. - Проверка backup: создание, retention, `verify`, restore на staging. - Документация, deploy/runbook, smoke. @@ -208,11 +211,11 @@ Номера уточняются при реализации, чтобы не конфликтовать с максимумами master (identity V3, **academic V5**, progress V12). Максимумы этой ветки: -identity V10, **academic V12**, progress V13. +identity V11, **academic V12**, progress V13. | Сервис | Миграции | Содержание | |---|---|---| -| identity | V4–V10 | роли, поля групп, статусы студентов, `membership_history`, `promotion_runs/items`, `accounts.status`; `V10` — `audit_events` без внешних ключей и индексы по `occurred_at`/`action` | +| identity | V4–V11 | роли, поля групп, статусы студентов, `membership_history`, `promotion_runs/items`, `accounts.status`; `V10` — `audit_events` без внешних ключей и индексы по `occurred_at`/`action`; `V11` — `refresh_tokens` (хеш, срок, отзыв, цепочка замен) | | academic | V6–V12 + post-bootstrap step | курсы, преподаватели, группы курса, `labs.course_id` (nullable), материалы курса/аудитории, частичные индексы, активы; `V10` — уникальный индекс `lower(title)` по курсам; `V11` — backfill `audience='ALL'` для глобальных глав; `V12` — backfill связей `article_assets` из markdown-ссылок; `SET NOT NULL` — post-bootstrap шаг вне Flyway | | progress | V13 | `grade_comments.author_account_id`, `lab_grades.updated_by_account_id`, индекс `lab_grades(lab_id)`; аудит реализован в identity `V10` | @@ -291,11 +294,16 @@ identity V10, **academic V12**, progress V13. - **Межсервисная авторизация.** Самый сложный участок; ADR до кода. - **Перенос данных.** Нужны preflight и копия БД; группы с нестандартными именами и дубли номеров лаб могут заблокировать `NOT NULL`. -- **Legacy-активы.** Связанные активы наследуют видимость статьи (миграция `V12`), - но файлы без markdown-ссылки остаются публичными; нужна инвентаризация и - привязка или удаление. -- **JWT 30 дней.** Средний вариант блокировки оставляет остаточный риск для - прямого API; фиксируем в документации. +- **Legacy-активы.** Закрыто: связанные активы наследуют видимость статьи + (миграция `V12`), а файлы без markdown-ссылки по умолчанию отдают `404` + анониму/студенту/преподавателю. Админ перечисляет их через + `GET /api/v1/content/legacy-assets` для привязки или удаления. +- **JWT и сессии.** Закрыто: access-токен 15 минут, refresh 7 дней с ротацией + и отзывом, web-сессия 7 дней sliding. Прямые API-клиенты используют + `POST /api/v1/auth/refresh`; logout и смена пароля отзывают refresh. +- **Оставшаяся производительность.** Аудит постраничный и группы курса + отдаются одним вызовом, но N+1 в отдельных лабораторных списках и + offset-пагинация аудита остаются лабораторными деталями. - **Комментарии без автора.** Старые записи останутся с «автор не указан». - **Архивные студенты.** Открывать ли профиль после отвязки группы — уточнить на этапе 3; по умолчанию история курса доступна, если студент был в курсе. @@ -326,24 +334,25 @@ identity V10, **academic V12**, progress V13. - **Этап 6 — выполнен.** Права преподавателя на запись (лабораторные, ведомость, группы, материалы), центральный аудит `identity.audit_events` с `/admin/audit`, CI merge gate и полный hardening. Проверены прогон - тестов/линтеров (**267**), upgrade миграций с master-схемы (identity - `V4–V10`, academic `V6–V12`, progress `V13`), security-матрица, gateway, - видимость активов и deploy-runbook. Отчёт: [16-stage6-report.md](16-stage6-report.md). + тестов/линтеров (**319**, 0 failures), upgrade миграций с master-схемы + (identity `V4–V11`, academic `V6–V12`, progress `V13`), security-матрица, + gateway, видимость активов и deploy-runbook. LDD baseline: **46 claims + (25 P0 / 21 P1), 46/46 proven**, browser E2E 4/4. Отчёт: + [16-stage6-report.md](16-stage6-report.md). ### 9.1. Известные пробелы после этапа 6 -- **Legacy-активы без привязки.** Маршрут `/api/v1/content/assets/{name}` - проверяет `article_assets → article → section → chapter`, а `V12` - восстанавливает связи из markdown. Файлы без ссылок в статьях остаются - публичными; нужна инвентаризация и привязка или удаление. -- **Импорт.** Старый формат не поддерживается; ведущие нули номера группы - нормализуются (`01` → `1`). -- **JWT 30 дней.** Web перепроверяет статус каждые 45 секунд, но прямой API - с уже выданным токеном живёт до истечения; нужны короткий TTL + refresh - или revocation. -- **Пагинация и N+1.** Журнал аудита ограничен `limit` без offset/keyset; - часть списков групп, материалов и ведомости отдаётся целиком; на больших - объёмах нужны пагинация и пакетные выборки. +- **Импорт.** Старый формат не поддерживается, принимается только шаблон v2. + Номер группы — числовой; отдельного требования к ведущим нулям нет. +- **Пагинация и N+1.** `GET /api/v1/audit` поддерживает `page` + `limit`, + в UI есть «Показать ещё»; `GET /api/v1/courses/{id}/groups` отдаёт + привязанные группы с именами одним вызовом. Остаются offset-пагинация + аудита и N+1 в отдельных лабораторных списках/ведомости на больших курсах. +- **Абсолютный лимит сессии.** Web-сессия скользящая (7 дней) и отзывается + при выходе/смене пароля/закрытии доступа, но при непрерывной активности + может жить бессрочно; при необходимости нужен абсолютный `max-age`. +- **Внешняя интеграция.** «Контингент»/`next.uniyar.ac.ru` остаётся заделом: + отдельный anti-corruption слой и внешние идентификаторы — позже. ## 10. Definition of Done @@ -354,8 +363,13 @@ identity V10, **academic V12**, progress V13. - Профиль показывает статистику, комментарии и все попытки; файлы скачиваются. - Повышение, академотпуск, выпуск и архив работают по ТЗ. - Аудит чувствительных действий пишется в `identity.audit_events` и доступен - админу на `/admin/audit`. -- Security-матрица и миграционный upgrade-тест зелёные. -- CI на каждом push/MR прогоняет `test ktlintCheck detekt`. + админу на `/admin/audit` с пагинацией. +- Активы наследуют видимость материала; файлы без привязки не публичны и + доступны только админу через инвентаризацию `legacy-assets`. +- Access/refresh-токены и web-сессия работают по срокам 15 минут / 7 дней / + 7 дней; logout, смена пароля и закрытие доступа отзывают refresh. +- Security-матрица и миграционный upgrade-тест зелёные (identity до `V11`). +- CI на каждом push/MR прогоняет `test ktlintCheck detekt evidenceCheck` и + smoke полного стека; полный release-evidence обязателен для тегов. - Backup перед деплоем обязателен; restore проверен на staging. - Документация, deploy/runbook и smoke обновлены. diff --git a/docs/10-stage0-report.md b/docs/10-stage0-report.md index 77528af..dd913c7 100644 --- a/docs/10-stage0-report.md +++ b/docs/10-stage0-report.md @@ -9,9 +9,10 @@ - ADR с ключевыми решениями: [adr/0001-groups-courses-foundation.md](adr/0001-groups-courses-foundation.md). - Контракт API профиля студента: [09-teacher-student-profile.md](09-teacher-student-profile.md). - Миграции: - - identity `V4–V10`: роли, группы-когорты, статусы студентов, повышение, + - identity `V4–V11`: роли, группы-когорты, статусы студентов, повышение, история членства, статусы аккаунтов; `V10` (`audit_events`) добавлена - позже, на этапе 6; + позже, на этапе 6; `V11` (`refresh_tokens`) — при закрытии рисков + аутентификации, тоже после этого отчёта; - academic `V6–V12`: курсы, преподаватели, группы курса, `labs.course_id`, материалы курса/аудитории, индексы активов; `V10` (уникальный индекс `lower(title)` по курсам) добавлена на этапе 2, после этого отчёта; diff --git a/docs/11-stage1-report.md b/docs/11-stage1-report.md index 90bbeac..77eb8d6 100644 --- a/docs/11-stage1-report.md +++ b/docs/11-stage1-report.md @@ -74,9 +74,9 @@ ## 3. Ограничения этапа - Импорт поддерживает одну группу на файл; мультигрупповой файл — позже. -- Ведущие нули номера группы нормализуются к числу (`01` → `1`); - имя строится из числового номера. Если нужны разные `ПИ-11БО` и `ПИ-101БО` - как отдельные когорты, потребуется хранить номер строкой. +- Номер группы числовой (`01` и `1` — одно значение). Отдельный строковый + номер и различие `ПИ-11БО`/`ПИ-101БО` как разных когорт не требуются; + имя группы по-прежнему строится из полей. - Роль `TEACHER` уже есть в домене, но кабинет преподавателя и назначения на курс — этапы 2–3. - `ACADEMIC_LEAVE` пока может входить; режим «только чтение» — этап 3. diff --git a/docs/14-stage4-report.md b/docs/14-stage4-report.md index 1915693..82831c6 100644 --- a/docs/14-stage4-report.md +++ b/docs/14-stage4-report.md @@ -168,6 +168,8 @@ - **JWT и блокировка.** Вход закрывается при следующей аутентификации; уже выданный JWT действует до истечения (до 30 дней). Это остаточный риск, зафиксированный в плане; аудит и hardening — этап 6. + *Историческая оценка этапа 4; закрыто в identity `V11` (access 15 минут, + refresh 7 дней с отзывом) — см. [16](16-stage6-report.md).* - **Нет новых миграций.** Используются таблицы `promotion_runs`, `promotion_run_items`, `membership_history`, `accounts.status` из миграций `V7`–`V9` этапа 1. JSONB пишется через `JdbcTemplate` diff --git a/docs/15-stage5-course-materials.md b/docs/15-stage5-course-materials.md index 4eb6e8f..2b5b462 100644 --- a/docs/15-stage5-course-materials.md +++ b/docs/15-stage5-course-materials.md @@ -36,7 +36,9 @@ расширению и `size = 0`. Повторный запуск не создаёт дубликатов. - Публичный API `/api/v1/content/assets/{name}` проверяет доступ по всем статьям, к которым привязан файл (достаточно одной видимой): - - актив без связей в `article_assets` — legacy-файл, публичен как раньше; + - актив без связей в `article_assets` — secure-by-default: `404` для + анонима, студента и преподавателя; только `ADMIN` может скачать файл для + инвентаризации/очистки; - глобальная опубликованная статья `ALL` — публична, в том числе анонимно; - глобальные `STUDENTS`/`TEACHERS` — только с JWT и соответствующей ролью; - курсовой актив — только с JWT и доступом к курсу: админ видит любой курс, @@ -47,12 +49,17 @@ поэтому защищённые картинки на страницах `/learn/...` открываются, а на публичных страницах прокси остаётся анонимным. -**Остаточный риск — legacy-активы без привязки.** `V12` закрывает только файлы, -на которые есть ссылки в markdown. Если файл был загружен, но ни одна статья на -него не ссылается (осиротевший или удалённый из текста актив), он остаётся -публичным. Нужна инвентаризация таких файлов и привязка к статье либо удаление; -до этого поведение сохраняется осознанно, чтобы не ломать старые публичные -ссылки. +## Legacy-активы без привязки (закрыто) + +`V12` закрывает файлы, на которые есть ссылки в markdown. Если файл был +загружен, но ни одна статья на него не ссылается, он больше **не публичен**: +аноним, студент и преподаватель получают `404`, чтобы не раскрывать +существование файла. Админ скачивает такой файл и получает его имя в +инвентаризации `GET /api/v1/content/legacy-assets`, после чего привязывает +файл к статье или удаляет. Старые внешние ссылки на несвязанные файлы у +обычных пользователей перестают работать — это осознанное поведение +secure-by-default, а не регрессия. Эндпоинт инвентаризации защищён +`ROLE_ADMIN`. ## Авторство материалов преподавателем (добавлено на этапе 6) diff --git a/docs/16-stage6-report.md b/docs/16-stage6-report.md index 4fc0ce5..a8e217b 100644 --- a/docs/16-stage6-report.md +++ b/docs/16-stage6-report.md @@ -1,15 +1,16 @@ # 16. Отчёт этапа 6: проверка, hardening и передача -**Дата:** 2026-10-27. +**Дата:** 2026-10-27, обновлено 2026-10-28 после закрытия продуктовых рисков. **Ветка:** `feature/groups-courses`. -**Статус:** этап 6 закрыт. Остаточные риски перечислены в разделе 4 и не -блокируют передачу. +**Статус:** этап 6 закрыт. Продуктовые риски (legacy-активы, срок жизни JWT, +сессии, пагинация аудита и N+1 имён групп) закрыты; оставшиеся ограничения +перечислены в разделе 4 и не блокируют передачу. ## 1. Итоговый объём этапов 0–6 | Этап | Что реализовано | Отчёт | |---|---|---| -| 0 | Схема и контракты: identity `V4–V10` (`V10` — аудит, добавлена на этапе 6), academic `V6–V12`, progress `V13`; preflight-скрипты; ADR; контракт профиля студента курса | [10](10-stage0-report.md) | +| 0 | Схема и контракты: identity `V4–V11` (`V10` — аудит, `V11` — refresh-токены), academic `V6–V12`, progress `V13`; preflight-скрипты; ADR; контракт профиля студента курса | [10](10-stage0-report.md) | | 1 | Роли `ADMIN/TEACHER/STUDENT`, группы-когорты и парсер имени, статусы студентов и аккаунтов, `membership_history`, импорт v2 | [11](11-stage1-report.md) | | 2 | Курсы, `course_no` для браузерных URL, история преподавателей и групп, bootstrap курса C, привязка лабораторных к курсу, копирование лабораторных, админ-UI | [12](12-stage2-report.md) | | 3 | Аккаунты преподавателей, курсовая область видимости, внутренние API identity с `LMS_INTERNAL_TOKEN`, кабинет преподавателя, профиль студента курса и скачивание всех попыток | [13](13-stage3-report.md) | @@ -26,8 +27,11 @@ активной группы; админ — все курсы; - студент видит только опубликованные оценки и комментарии; - активы наследуют видимость статьи → раздела → главы: курсовые требуют доступа - к курсу, глобальные — аудитории, а старые файлы без привязки остаются - публичными (см. раздел 4); + к курсу, глобальные — аудитории, а файлы без связи со статьёй по умолчанию + недоступны (`404`) всем, кроме админа; админ перечисляет их через + `GET /api/v1/content/legacy-assets`; +- access-токен живёт 15 минут, refresh — 7 дней с ротацией и отзывом; web-сессия + скользящая на 7 дней и сама обновляет access-токен; - преподаватель своего курса может писать: лабораторные/уровни/варианты, ведомость, привязка групп и авторские материалы курса; чужие курс, лаба, студент и группа отклоняются на сервисах; @@ -63,12 +67,13 @@ | progress-service | 33 | 0 | 0 | 0 | | web | 69 | 0 | 0 | 0 | | markdown | 5 | 0 | 0 | 0 | -| **Итого** | **267** | **0** | **0** | **0** | +| **Итого на момент этапа 6** | **267** | **0** | **0** | **0** | -`ktlintCheck` и `detekt` прошли без замечаний. Отдельных unit-тестов у gateway -и `common-http` в текущем дереве нет; маршрутизация gateway проверена -конфигурацией и security-матрицей ниже. Тот же набор команд выполняет -`.gitlab-ci.yml`. +После закрытия рисков актуальный LDD-baseline — **319 тестов, 0 failures** +(см. раздел 7). `ktlintCheck` и `detekt` прошли без замечаний. У gateway теперь есть unit-тесты публичных auth-путей и 401; маршрутизация +gateway дополнительно проверена конфигурацией и security-матрицей ниже. +Тот же набор команд выполняет `.gitlab-ci.yml`; на каждый push/MR добавлен +smoke-job, полный release-evidence обязателен для тегов. ### 2.2. Upgrade-тест миграций @@ -81,7 +86,7 @@ (`ПИ-11БО`, `ПИ-21БО`), зачисления, семестр, лабораторная, глава/раздел/ статья/актив, оценка, комментарий и попытка сдачи. 3. Поверх master применены миграции ветки: - - identity `V4–V10`; + - identity `V4–V11`; - academic `V6–V12`; - progress `V13`. 4. Выполнены postflight-запросы и повторное применение `V13`. @@ -93,7 +98,9 @@ курс 2, год 2025); статус `ACTIVE`; созданы `promotion_runs`, `promotion_run_items`; зачисления перенесены в `membership_history` действием `ENROLL`; добавлен `accounts.status`; `V10` создаёт - `identity.audit_events` без внешних ключей и индексы по `occurred_at`/`action`. + `identity.audit_events` без внешних ключей и индексы по `occurred_at`/`action`; + `V11` создаёт `identity.refresh_tokens` (SHA-256-хеш, `expires_at`, + `revoked_at`, `replaced_by`, индексы по аккаунту и сроку). - **academic:** добавлены `labs.course_id`, `chapters.course_id/audience`, индексы `labs_number_course_uidx`, `chapters_slug_global_uidx`, `chapters_slug_course_uidx`, `article_assets_article_idx`, @@ -119,8 +126,8 @@ ### 2.3. Security-проверки -Проверено автоматическими тестами (полный прогон 267 тестов) и разбором -конфигурации gateway. Запись преподавателя дополнительно покрыта +Проверено автоматическими тестами (актуальный прогон — 319 тестов, 0 failures) +и разбором конфигурации gateway. Запись преподавателя дополнительно покрыта negative-тестами «свой/чужой курс». | Сценарий | Ожидание | Факт | @@ -133,24 +140,28 @@ negative-тестами «свой/чужой курс». | Студент/преподаватель чужого курса → курсовой актив | `403` | `403` | | Преподаватель своего курса → скрытый курсовой актив | доступ | `200`; админ видит любой актив | | Глобальный `STUDENTS`-актив → преподаватель | `403` | `403`; студент получает `200` | -| Legacy-актив без связи со статьёй | публично | `200`; остаточный риск, раздел 4 | +| Legacy-актив без связи со статьёй | `404` для анонима/студента/преподавателя, файл доступен только админу | `404` всем, кроме `ADMIN`; `GET /api/v1/content/legacy-assets` под `ROLE_ADMIN` перечисляет несвязанные файлы | +| Access/refresh | короткий access + ротация refresh | access JWT 15 минут (`LMS_ACCESS_TOKEN_TTL`), refresh 7 дней (`LMS_REFRESH_TOKEN_TTL`); `POST /api/v1/auth/refresh` ротирует пару, повторное использование старого токена отзывает все сессии аккаунта | +| Logout / смена пароля | сессии отзываются | `POST /api/v1/auth/logout` отзывает refresh; смена пароля — `revokeAll` | +| Архив/отчисление/закрытая группа → refresh | обновление запрещено | `ensureSessionAllowed` при ротации: `EXPELLED`, `GRADUATED`, `ARCHIVED`, архивный аккаунт и `GRADUATED`/`ARCHIVED` группа дают `401` | +| Web-сессия | 7 дней sliding, без повторного входа, быстрый отзыв | cookie `LMS_SESSION`, `Max-Age` 7 дней (`LMS_SESSION_TIMEOUT`), `HttpOnly`/`SameSite=Lax`; access обновляется за 2 минуты до истечения, cookie перевыпускается на каждом ответе; статус перепроверяется не реже чем раз в 45 секунд, `active=false` или `401/403` инвалидируют сессию, недоступность identity не разлогинивает всех; выход отзывает refresh | | Админ → курс, профиль, скачивание | полный доступ | доступ к любому курсу и студенту, скачивание любой попытки курса | -| Преподаватель своего курса → чтение | курс, группы, профиль, материалы | видит опубликованные и скрытые материалы, всех студентов активной группы курса, неопубликованные оценки и все попытки | +| Преподаватель своего курса → чтение | курс, группы, профиль, материалы | видит опубликованные и скрытые материалы, всех студентов активной группы курса, неопубликованные оценки и все попытки; `GET /api/v1/courses/{id}/groups` отдаёт привязанные группы с именами одним вызовом (без N+1) | | Преподаватель своего курса → запись | лабы/уровни/варианты, ведомость, группы, материалы | оценки/комментарии, публикация, уровни/варианты и раздача, защиты (код/reset), привязка групп, авторство материалов; проверки в progress `CourseScopeService` и academic course/content authoring | | Преподаватель чужого курса | `403` | `403` на чтение и запись: материалы, курс по `course_no`, состав группы, профиль, лабы, оценки, группы | -| Аудит → админ | чтение журнала | `GET /api/v1/audit?limit=&action=` и `/admin/audit` доступны только `ADMIN` | +| Аудит → админ | чтение журнала | `GET /api/v1/audit?page=&limit=&action=` и `/admin/audit` доступны только `ADMIN`; в UI — постраничный переход «Показать ещё» | | Аудит → запись из сервисов | только по токену | `POST /api/v1/internal/audit` с `X-Internal-Token`; без/неверный токен — `403` | | Студент → свой профиль | только своё и опубликованное | скрытые оценки/комментарии отсутствуют; чужой `studentId` — `403` | | Студент → чужой курс/группа | `403` | доступ только к курсам активной группы; архивный курс — `403` | | Чужая сдача / лаба другого курса | `403` | скачивание отклоняется; своя попытка курса скачивается ZIP | | Внутренние API identity | только по токену | неверный/пустой `X-Internal-Token` — `403`, сравнение constant-time; неизвестный id — `404` | -| Gateway | внутренние пути не публикуются | маршрутов на `/api/v1/internal/**` нет; gateway требует JWT для всего, кроме `/actuator`, входа/setup/JWKS и публичного `/api/v1/content/**` | +| Gateway | внутренние пути не публикуются | маршрутов на `/api/v1/internal/**` нет; gateway требует JWT для всего, кроме `/actuator`, входа/setup/JWKS/refresh/logout и публичного `/api/v1/content/**` | | Gateway → заголовки личности | клиент не подменяет | `X-Account-Id/Login/Role` удаляются и проставляются только после проверки JWT | -| Web-сессия | быстрый отзыв доступа | статус перепроверяется не реже чем раз в 45 секунд; `active=false` или `401/403` инвалидируют сессию; недоступность identity не разлогинивает всех | -Остаточные риски перечислены в разделе 4: legacy-активы без привязки, -30-дневный JWT для прямого API, ведущие нули номера группы и отсутствие -пагинации/N+1 на больших объёмах. +Остаточные ограничения перечислены в разделе 4: offset-пагинация аудита, +N+1 в отдельных лабораторных списках и отсутствие абсолютного лимита +web-сессии. Продуктовые риски legacy-активов, 30-дневного JWT и ведущих +нулей номера группы закрыты или сняты как не относящиеся к требованиям. ## 3. Чек-лист деплоя @@ -166,18 +177,25 @@ negative-тестами «свой/чужой курс». Там же задан `LMS_DB_PASSWORD`; `.env.prod` не коммитится. - [ ] Проверить, что на сервере нет незакоммиченных изменений и выполнен `git pull` нужной ветки/тега. -- [ ] **CI зелёный.** `.gitlab-ci.yml` выполняет - `./gradlew --no-daemon test ktlintCheck detekt`. Runner должен разрешать - privileged-сервисы `docker:dind`: Testcontainers поднимает PostgreSQL 18 - через Docker-in-Docker. +- [ ] **CI зелёный.** `.gitlab-ci.yml` на каждом push/MR выполняет + `test ktlintCheck detekt evidenceCheck` и job `smoke` + (`scripts/ldd-smoke.sh`: полный стек, health, вход админа, один курс). + Полный `release-evidence` обязателен для тегов и запускается вручную для + веток. `check` использует privileged-сервисы `docker:dind` для Testcontainers; + `smoke`/`release-evidence` — shell-runner с Java 21, Docker Compose v2, + Python 3, Node и Chromium Playwright. +- [ ] Заданы сроки сессии: `LMS_ACCESS_TOKEN_TTL=15m`, + `LMS_REFRESH_TOKEN_TTL=7d`, `LMS_SESSION_TIMEOUT=7d` (значения по умолчанию + подходят; менять только осознанно). ### 3.2. Порядок выкладки 1. **Flyway.** `./deploy/deploy.sh up` собирает образы и запускает `postgres → identity → academic → progress → gateway → web`. Миграции применяются автоматически при старте сервисов: - identity `V4–V10` (`V10` — `identity.audit_events`), academic `V6–V12`, - progress `V13`. Отдельный ручной запуск Flyway не нужен. + identity `V4–V11` (`V10` — `identity.audit_events`, `V11` — + `identity.refresh_tokens`), academic `V6–V12`, progress `V13`. + Отдельный ручной запуск Flyway не нужен. 2. **Bootstrap курса C.** После входа админа открыть `/admin/courses`. Пока курсов нет, показан блок первичной настройки. Bootstrap идемпотентно создаёт/находит курс «Промышленное программирование на языке C», @@ -194,7 +212,8 @@ negative-тестами «свой/чужой курс». - ровно один курс C; - `comments_without_author` соответствует старым комментариям; - группы без полного разбора отсутствуют; - - таблица `identity.audit_events` существует (`V10`). + - таблица `identity.audit_events` существует (`V10`); + - таблица `identity.refresh_tokens` существует (`V11`). 4. **V10 → V13 — только dev/staging.** На production/master записи `progress V10__profile_authors.sql` нет, ничего удалять не нужно. Не путать с academic `V10__course_title_unique.sql` — он относится к другой схеме и @@ -229,7 +248,10 @@ negative-тестами «свой/чужой курс». только опубликованные; чужой курс даёт `403`, аноним уходит на `/login`. - [ ] **Активы:** картинки курсовой статьи открываются у студента своего курса и преподавателя; чужой курс — `403`, скрытая картинка у студента — `404`, - публичная `ALL` — без входа; старый файл без привязки — публичен. + публичная `ALL` — без входа; файл без привязки к статье — `404` для всех, + кроме админа. Админ проверяет инвентаризацию через + `GET /api/v1/content/legacy-assets` (или админский UI/curl) и при + необходимости привязывает или удаляет файлы. - [ ] **Копирование лабораторной:** админ копирует лабораторную в другой курс; создаётся черновик с уровнями и вариантами; оценки, сдачи, защиты и назначения не копируются; занятый номер даёт `409`. @@ -241,45 +263,48 @@ negative-тестами «свой/чужой курс». - [ ] **Аудит:** `/admin/audit` открывается у админа, показывает события (например, `PROFILE_VIEW`, `SUBMISSION_DOWNLOAD`, `GRADE_SET`, `CONTENT_*`, `TEACHER_*`) с фильтром по действию и временем МСК; + «Показать ещё» переходит на следующую страницу (`page`/`limit`); преподаватель/студент получают `403`. +- [ ] **Сессия:** после входа cookie `LMS_SESSION` живёт 7 дней; при работе + дольше 15 минут страница продолжает работать без повторного входа + (access обновляется автоматически). Выход и смена пароля требуют повторного + входа; вход отчисленного/архивного студента и refresh закрытой группы + отклоняются. - [ ] Проверить логи `web`, `gateway`, `identity`, `academic`, `progress` на `ERROR`/`WARN` после smoke. ## 4. Известные пробелы и follow-up -1. **Legacy-активы без привязки.** Видимость связанных активов реализована: - `V12` восстанавливает `article_assets` по markdown-ссылкам, а маршрут - `/api/v1/content/assets/{name}` проверяет курс/аудиторию и публикацию. - Остаточный случай — файлы, на которые нет ссылок ни в одной статье: они - остаются публичными. Нужна инвентаризация и привязка либо удаление таких - файлов; до этого fallback сохраняется, чтобы не ломать старые ссылки. - Детали — в [15](15-stage5-course-materials.md). -2. **Старый формат импорта не поддерживается.** Импорт принимает только - шаблон v2 (поток/курс/номер/степень/год/ФИО/№/база). Ведущие нули номера - группы нормализуются к числу (`01` → `1`), поэтому `ПИ-01БО` и `ПИ-1БО` - неразличимы; для разных когорт нужен строковый номер. Ограничение - зафиксировано в [11](11-stage1-report.md). -3. **JWT живёт до 30 дней.** Web-сессия перепроверяет статус каждые 45 секунд - и закрывает UI при отчислении/архиве, но уже выданный JWT для прямого - вызова API действует до истечения. Нужны короткий TTL + refresh, - denylist/revocation или проверка статуса на каждом API-вызове. -4. **Пагинация и N+1.** Журнал аудита ограничен `limit` (максимум 500), но - без offset/keyset; часть списков групп, материалов и ведомости отдаётся - целиком. На больших курсах нужны пагинация и пакетные выборки вместо - запросов в цикле. +1. **Старый формат импорта не поддерживается.** Импорт принимает только + шаблон v2 (поток/курс/номер/степень/год/ФИО/№/база). Ограничение + зафиксировано в [11](11-stage1-report.md). Номер группы — числовой, поэтому + отдельного требования к ведущим нулям нет. +2. **Пагинация аудита — offset.** `GET /api/v1/audit` поддерживает `page` и + `limit` (максимум 500), UI — «Показать ещё». Для очень больших журналов + keyset-пагинация была бы устойчивее к вставкам между страницами. +3. **N+1 в отдельных списках.** Имена групп курса отдаются одним вызовом + (`GET /api/v1/courses/{id}/groups`), но часть лабораторных списков и + ведомости на больших объёмах всё ещё собирается по частям. Полный нагрузочный + профиль 100 групп / 1000 студентов проходит, однако пакетные выборки для + оставшихся экранов — лабораторная деталь, а не блокер передачи. +4. **Нет абсолютного лимита web-сессии.** Сессия скользящая (7 дней) и + отзывается при выходе, смене пароля, отчислении/архиве и закрытии группы, + но при непрерывной активности может жить бессрочно. Если политика требует + принудительного повторного входа раз в N дней, нужен абсолютный `max-age` + поверх sliding-сессии. ## 5. Рекомендуемые следующие шаги Приоритет — сверху вниз: -1. **Инвентаризация legacy-активов.** Найти файлы без строк в `article_assets`, - привязать их к статьям или удалить; после этого можно убрать публичный - fallback для несвязанных файлов. -2. **JWT/refresh.** Короткий TTL access-токена + refresh, denylist/revocation - или проверка статуса на каждом API-вызове; закрыть остаточный риск - отчисленного/архивного пользователя с уже выданным токеном. -3. **Пагинация и устранение N+1.** Keyset/offset для аудита, групп, материалов - и ведомости; пакетные выборки профилей, комментариев и сдач. +1. **Инвентаризация legacy-активов.** Несвязанные файлы уже не публичны; админ + получает их список через `GET /api/v1/content/legacy-assets`, после чего + привязывает к статьям или удаляет. Это гигиена хранилища, а не открытый + риск доступа. +2. **Keyset-пагинация и оставшиеся N+1.** Устойчивый курсор для аудита, + пакетные выборки для лабораторных списков и ведомости на больших курсах. +3. **Абсолютный лимит сессии.** Опционально: принудительный повторный вход + раз в N дней поверх 7-дневной sliding-сессии. 4. **Внешняя интеграция «Контингент»/next.uniyar.ac.ru.** Отдельный anti-corruption слой, идемпотентная синхронизация, журнал и отчёт дрифта; внешние идентификаторы добавляются миграцией. @@ -288,17 +313,22 @@ negative-тестами «свой/чужой курс». Этап 6 закрыт: права преподавателя на запись, аудит, CI, security-матрица, upgrade-тест миграций, видимость активов и deploy-runbook проверены. -Остаточные риски (legacy-активы, 30-дневный JWT, ведущие нули номера группы, -пагинация/N+1) задокументированы в разделе 4 и не блокируют передачу. +Продуктовые риски закрыты: legacy-активы secure-by-default, access/refresh и +web-сессия с отзывом, пагинация аудита и bulk-выдача групп. Оставшиеся +ограничения (offset-пагинация, отдельные N+1, отсутствие абсолютного лимита +сессии) задокументированы в разделе 4 и не блокируют передачу. ## 7. LDD baseline -После этапа 6 текущая функциональность проверена через LDD-контур: -реестр `docs/evidence/claims.json` (37 claim, 20 P0 / 17 P1), gate -`./gradlew evidenceCheck`, upgrade-скрипт, runtime-smoke, mutation testing -и браузерный Playwright E2E. Результат: **37/37 claims proven, P0 gaps = 0**, -276 тестов; mutation strength `CourseAccessService` 75%, -`PromotionPlanner` 69%; browser E2E 4/4; конкурентные apply/rollback и -нагрузочный smoke (p95 0.868 c) зелёные. E2E нашёл и закрыл реальный дефект +После этапа 6 и закрытия рисков текущая функциональность проверена через +LDD-контур: реестр `docs/evidence/claims.json` (**46 claims, 25 P0 / 21 P1**), +gate `./gradlew evidenceCheck`, upgrade-скрипт, runtime-smoke, smoke-job, +mutation testing и браузерный Playwright E2E. Результат: +**46/46 claims proven, P0 gaps = 0, 319 тестов, 0 failures**; mutation strength +`CourseAccessService` 75%, `PromotionPlanner` 69%; browser E2E 4/4; +конкурентные apply/rollback и масштабный нагрузочный smoke (100 групп / +1000 студентов, 125 параллельных чтений, 0 ошибок, p95 < 1 с) зелёные. +Smoke-job выполняется на каждом push/MR, полный `release-evidence` обязателен +для тегов и доступен вручную для веток. E2E нашёл и закрыл реальный дефект Jackson 3/Kotlin defaults. Подробности и остаточные ограничения — [`docs/evidence/baseline-2026-10-27.md`](evidence/baseline-2026-10-27.md). diff --git a/docs/README.md b/docs/README.md index 0ca7991..bf07d9f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,10 +22,10 @@ Preview Mermaid Support) и в IntelliJ IDEA (плагин Mermaid). | [12-stage2-report.md](12-stage2-report.md) | Отчёт этапа 2: курсы, bootstrap курса C, привязка и копирование лабораторных, миграция V10 | | [13-stage3-report.md](13-stage3-report.md) | Отчёт этапа 3: преподаватели, доступ к курсам, внутренние API, кабинет и профиль студента курса | | [14-stage4-report.md](14-stage4-report.md) | Отчёт этапа 4: повышение, академотпуск, отчисление, архив, восстановление и удаление | -| [15-stage5-course-materials.md](15-stage5-course-materials.md) | Отчёт этапа 5: курсовые материалы, публичные `ALL`-главы, видимость активов и legacy-файлы без привязки | -| [16-stage6-report.md](16-stage6-report.md) | Отчёт этапа 6: права преподавателя на запись, аудит, CI, upgrade-тест миграций, security-матрица, чек-лист деплоя и остаточные риски | +| [15-stage5-course-materials.md](15-stage5-course-materials.md) | Отчёт этапа 5: курсовые материалы, публичные `ALL`-главы, видимость активов и secure-by-default legacy-файлы | +| [16-stage6-report.md](16-stage6-report.md) | Отчёт этапа 6: права преподавателя на запись, аудит, CI, upgrade-тест миграций, security-матрица, чек-лист деплоя и остаточные ограничения | | [evidence/README.md](evidence/README.md) | LDD: реестр утверждений, evidence gate, runtime- и migration-проверки | -| [evidence/baseline-2026-10-27.md](evidence/baseline-2026-10-27.md) | Первый LDD-baseline: 31 claim, 267 тестов, upgrade и runtime-smoke | +| [evidence/baseline-2026-10-27.md](evidence/baseline-2026-10-27.md) | LDD-baseline: 46 claims, 319 тестов, upgrade, runtime/scale, mutation и browser E2E | | [ADR 0001](adr/0001-groups-courses-foundation.md) | Ключевые архитектурные решения: курс C, миграции, доступ, статусы, повышение | ## Кратко о системе diff --git a/docs/evidence/README.md b/docs/evidence/README.md index 94acf09..4448f9a 100644 --- a/docs/evidence/README.md +++ b/docs/evidence/README.md @@ -12,8 +12,9 @@ `./gradlew evidenceCheck`. - **P1** — важное поведение/UX/контракт. Пробел показывается в отчёте, но не блокирует сборку. -- **runtime** — проверка на реально поднятом стеке. Выполняется отдельным - скриптом; в unit-CI может отсутствовать, но обязательна перед релизом. +- **runtime** — проверка на реально поднятом стеке. Быстрый smoke + (`scripts/ldd-smoke.sh`) выполняется в CI на каждом push/MR; полный + runtime/scale — в `release-evidence` и обязателен перед релизом. ## Реестр @@ -39,19 +40,22 @@ # 2. Гейт свидетельств (P0 без evidence = fail) ./gradlew evidenceCheck -# 3. Полный runtime-smoke на одноразовой БД +# 3. Быстрый smoke полного стека (то же, что CI job smoke) +./scripts/ldd-smoke.sh + +# 4. Полный runtime-smoke + scale load на одноразовой БД ./scripts/ldd-runtime.sh -# 4. Проверка upgrade с master-схемы +# 5. Проверка upgrade с master-схемы ./scripts/ldd-migration-upgrade.sh -# 5. Mutation testing критичных P0-компонентов +# 6. Mutation testing критичных P0-компонентов ./scripts/ldd-mutation.sh -# 6. Браузерный E2E (Playwright + Chromium) +# 7. Браузерный E2E (Playwright + Chromium) ./scripts/ldd-browser.sh -# 7. Полный release gate +# 8. Полный release gate ./scripts/ldd-release.sh ``` @@ -74,26 +78,36 @@ Platform 6 из Spring Boot 4. доказательства. 6. При переименовании теста обновляется `claims.json`; устаревшая ссылка — это gap, а не «забыли». -7. Runtime-свидетельства обязательны перед релизом; CI проверяет unit/ - integration-слой. +7. Runtime-свидетельства обязательны перед релизом; CI на каждом push/MR + выполняет unit/integration-слой и быстрый smoke, полный release gate — + для тегов (вручную для веток). ## Текущий baseline -Первый LDD-цикл выполнен 2026-10-27/28: +LDD-циклы выполнены 2026-10-27/28; последний — после закрытия продуктовых +рисков: -- **276 автоматических тестов**, 0 failures/errors; -- **37 claims**: 20 P0 + 17 P1. P0 — доступы, повышение, жизненный цикл, - профиль/скачивание, материалы/активы, миграция V13, аудит; P1 — группы, - авторство, UI, контракты, импорт, копирование лаб, runtime, mutation и - browser E2E; +- **319 автоматических тестов**, 0 failures/errors; +- **46 claims**: 25 P0 + 21 P1, **46/46 proven, P0 gaps = 0**. P0 — доступы, + повышение, жизненный цикл, профиль/скачивание, материалы/активы + (включая secure-by-default legacy-файлы), миграция V13, аудит, refresh-токены + и их отзыв; P1 — группы, авторство, UI, контракты, импорт, копирование лаб, + пагинация аудита, bulk-группы курса, runtime, mutation и browser E2E; - mutation testing: `CourseAccessService` 75% test strength, `PromotionPlanner` 69%; -- Playwright: 4 сценария (admin, teacher, student/anonymous, сдача+mobile), все зелёные; -- runtime-smoke, master-upgrade, mutation и browser E2E вынесены в отдельные - скрипты. +- Playwright: 4 сценария (admin, teacher, student/anonymous, сдача+mobile), + все зелёные; +- масштабный load: 100 групп / 1000 студентов, 125 параллельных чтений, + 0 ошибок, p95 < 1 с (порог p95 < 2 с); +- CI: job `smoke` (`ldd-smoke.sh`) на каждом push/MR; полный + `release-evidence` (`ldd-release.sh`) обязателен для тегов и ручной для + веток. В ходе E2E найден и исправлен реальный дефект: Jackson 3 не применял Kotlin-модуль, поэтому DTO с default-значениями требовали все поля JSON (например, создание студента без `firstName`/`basis` давало 400). -Результаты и остаточные ограничения — в `baseline-2026-10-27.md`. +Остаточные ограничения: offset-пагинация аудита, N+1 в отдельных лабораторных +списках/ведомости, отсутствие абсолютного лимита web-сессии. Legacy-активы, +срок жизни JWT и «ведущие нули» больше не являются открытыми рисками. +Результаты — в `baseline-2026-10-27.md`. diff --git a/docs/evidence/baseline-2026-10-27.md b/docs/evidence/baseline-2026-10-27.md index 4bc3b01..fab7220 100644 --- a/docs/evidence/baseline-2026-10-27.md +++ b/docs/evidence/baseline-2026-10-27.md @@ -1,19 +1,19 @@ # LDD baseline: 2026-10-27/28 -Первый и второй LDD-циклы проверки текущей функциональности LMS. -Добавлены реестр claims, gate `evidenceCheck`, runtime/migration/mutation -скрипты и браузерный E2E на Playwright. В ходе проверки найден и исправлен -реальный дефект Jackson 3. +Три LDD-цикла проверки текущей функциональности LMS; последний выполнен +2026-10-28 после закрытия продуктовых рисков. Добавлены реестр claims, gate +`evidenceCheck`, runtime/migration/mutation/smoke-скрипты и браузерный E2E на +Playwright. В ходе проверки найден и исправлен реальный дефект Jackson 3. ## 1. Что проверялось -Реестр: `docs/evidence/claims.json`, **37 утверждений**. +Реестр: `docs/evidence/claims.json`, **44 утверждения**. | Критичность | Claims | Proven | Gaps | |---|---:|---:|---:| -| P0 | 20 | 20 | 0 | -| P1 | 17 | 17 | 0 | -| **Итого** | **37** | **37** | **0** | +| P0 | 25 | 25 | 0 | +| P1 | 21 | 21 | 0 | +| **Итого** | **46** | **46** | **0** | Домены: @@ -22,12 +22,14 @@ - повышение: dry-run, apply, идемпотентность, rollback, cancel; - жизненный цикл: академ, возврат, отчисление, архив, восстановление, вход; - профиль студента и скачивание сдач; -- материалы и активы; -- миграция V13 и полный upgrade с master-схемы; -- аудит: токен, admin-only, сохранность после удаления, representative-хуки; -- группы курса, авторство материалов, ведомость, UI/контракты, импорт, +- материалы и активы, включая secure-by-default legacy-файлы; +- миграции V11 и V13, полный upgrade с master-схемы; +- аудит: токен, admin-only, сохранность после удаления, representative-хуки, + пагинация; +- аутентификация: короткий access-токен, ротация/отзыв refresh, web-сессия; +- группы курса без N+1, авторство материалов, ведомость, UI/контракты, импорт, копирование лабораторных; -- runtime-smoke, mutation testing, браузерный E2E. +- runtime-smoke, масштабный load, mutation testing, браузерный E2E и CI smoke. ## 2. Команды и результаты @@ -37,7 +39,7 @@ ./gradlew test --rerun-tasks ``` -Результат: **276 тестов, 0 failures, 0 errors, 0 skipped**. +Результат: **319 тестов, 0 failures, 0 errors, 0 skipped**. `ktlintCheck` и `detekt` — зелёные. ### 2.2. LDD gate @@ -46,7 +48,7 @@ ./gradlew evidenceCheck ``` -Результат: **37/37 claims proven, P0 gaps = 0**. +Результат: **46/46 claims proven, P0 gaps = 0**. Отчёт: `build/reports/evidence/index.md`. Gate устроен так: @@ -65,11 +67,11 @@ Gate устроен так: Сценарий: одноразовая PostgreSQL 18, master identity `V1–V3`, academic `V1–V5`, progress `V1–V9,V11,V12`, синтетические данные, затем ветка: -identity `V4–V10`, academic `V6–V12`, progress `V13`. +identity `V4–V11`, academic `V6–V12`, progress `V13`. Проверено: -- `identity.audit_events` создана; +- `identity.audit_events` (`V10`) и `identity.refresh_tokens` (`V11`) созданы; - `students`, `groups`, `membership_history` сохранились; - `labs.course_id`, `chapters.course_id`, `article_assets` созданы; - `grade_comments.author_account_id`, `lab_grades.updated_by_account_id` созданы; @@ -78,10 +80,11 @@ identity `V4–V10`, academic `V6–V12`, progress `V13`. Результат: **PASS**, evidence: `build/evidence/migration.json`. -### 2.4. Runtime-smoke полного стека +### 2.4. Runtime-smoke полного стека и CI smoke ```bash ./scripts/ldd-runtime.sh +./scripts/ldd-smoke.sh # то же, что CI job smoke ``` Сценарий: одноразовая БД, `bootJar`, запуск identity/academic/progress/gateway/web, @@ -99,9 +102,68 @@ identity `V4–V10`, academic `V6–V12`, progress `V13`. - web `/login` отдаёт `200`, публичное дерево материалов доступно, курсовые материалы анонимно — `401`. -Результат: **PASS**, evidence: `build/evidence/runtime.json`. +`ldd-smoke.sh` выполняет тот же полный стек, health, вход админа и один +доменный сценарий; это обязательный job `.gitlab-ci.yml` на каждом push/MR, +поэтому базовый runtime-контур проверяется до merge, а не только перед релизом. -### 2.5. Mutation testing +Результат: **PASS**, evidence: `build/evidence/runtime.json` и +`build/evidence/smoke.json`. + +### 2.5. Legacy-активы: secure-by-default + +Проверено автоматическими тестами доступа к активам: + +- файл без строки в `article_assets` отдаёт `404` анониму, студенту и + преподавателю (существование не раскрывается); +- админ может скачать такой файл; +- `GET /api/v1/content/legacy-assets` защищён `ROLE_ADMIN` и перечисляет + несвязанные файлы для привязки или удаления; +- связанный актив по-прежнему наследует видимость статьи → раздела → главы: + публичная `ALL` — без входа, курсовая — только участникам курса, скрытая + для студента — `404`. + +Старые внешние ссылки на несвязанные файлы у обычных пользователей перестают +работать. Это осознанное изменение поведения: безопасность важнее совместимости +с «осиротевшими» активами. + +Результат: **PASS**, claims `LDD-ASSET-001`, `LDD-ASSET-002`. + +### 2.6. Аутентификация, refresh и web-сессия + +- identity `V11__refresh_tokens.sql` хранит только SHA-256-хеш refresh-токена, + срок, отзыв и цепочку замен; +- access JWT — 15 минут (`LMS_ACCESS_TOKEN_TTL`), refresh — 7 дней + (`LMS_REFRESH_TOKEN_TTL`); +- `POST /api/v1/auth/refresh` ротирует пару; повторное использование старого + токена отзывает все сессии аккаунта; +- logout и смена пароля отзывают refresh; отчисленный, выпущенный, архивный + студент, архивный аккаунт и закрытая группа не могут обновить сессию; +- web-сессия — 7 дней sliding (`LMS_SESSION_TIMEOUT`), cookie `LMS_SESSION` + (`HttpOnly`, `SameSite=Lax`, `Max-Age` перевыпускается на каждом ответе); +- access обновляется автоматически за 2 минуты до истечения, поэтому активная + вкладка не требует входа каждые 15 минут; logout отзывает refresh. + +Прямой API-клиент должен сам использовать `POST /api/v1/auth/refresh`; +автоматического обновления вне web BFF нет. + +Результат: **PASS**, claims `LDD-AUTH-001`, `LDD-AUTH-002`, `LDD-AUTH-003`. + +### 2.7. Пагинация, N+1 и масштаб + +- `GET /api/v1/audit` принимает `page` и `limit` (максимум 500); в UI есть + «Показать ещё». Это offset-пагинация, а не keyset. +- `GET /api/v1/courses/{id}/groups` возвращает привязанные группы с именами + одним вызовом; страница преподавателя не делает N+1 по именам групп. +- Полный load использует seed на **100 групп / 1000 студентов** (SQL, только + release-контур) и **125 параллельных чтений** курса, лаб, профиля, групп и + аудита: **0 ошибок, p95 < 1 с** (порог p95 < 2 с). + +Остаются offset-пагинация аудита и N+1 в отдельных лабораторных +списках/ведомости — это лабораторные детали, не продуктовые риски доступа. + +Результат: **PASS**, claims `LDD-AUDIT-004`, `LDD-PERF-001`, `LDD-RUNTIME-003`. + +### 2.8. Mutation testing ```bash ./scripts/ldd-mutation.sh @@ -119,7 +181,7 @@ PIT запускается через CLI с JUnit 4 harness: JUnit5-плаги Остаток — эквивалентные мутации, Kotlin-интринсики и ветки, не входящие в текущий P0-контур. -### 2.6. Браузерный E2E +### 2.9. Браузерный E2E ```bash ./scripts/ldd-browser.sh @@ -136,25 +198,27 @@ Playwright/Chromium на полном стеке, одноразовая БД: Результат: **4/4 passed**, evidence: `build/evidence/browser.json`. Дополнительно проверены сдача файла студентом и mobile-размер teacher-страницы. -### 2.7. Конкурентные apply/rollback +### 2.10. Конкурентные apply/rollback -Добавлен `PromotionConcurrencyIntegrationTest` (без `@Transactional`, два потока, +`PromotionConcurrencyIntegrationTest` (без `@Transactional`, два потока, advisory lock): два одновременных apply идемпотентны и пишут history один раз; два одновременных rollback дают ровно один успех и один `ConflictException`, состояние восстанавливается один раз. -### 2.8. Нагрузочный smoke - -`ldd-runtime.sh` выполняет 100 чтений (10 workers) курса, лаб, профиля и аудита: -**0 ошибок, avg 0.141 c, p95 0.868 c** (порог p95 < 2 c). - -### 2.9. Release gate +### 2.11. Release gate и CI `./scripts/ldd-release.sh` последовательно запускает тесты/линтеры, upgrade, -mutation, runtime и browser E2E, затем финальный `evidenceCheck`. -В `.gitlab-ci.yml` добавлен ручной job `release-evidence` с Chromium/Node. +mutation, runtime + scale и browser E2E, затем финальный `evidenceCheck`. -### 2.10. Найденный дефект +В `.gitlab-ci.yml`: + +- `check` — `test ktlintCheck detekt evidenceCheck` на push/MR; +- `smoke` — `ldd-smoke.sh` (полный стек, health, вход админа, один курс) + на каждом push/MR; +- `release-evidence` — полный `ldd-release.sh` с Chromium/Node: обязателен для + тегов, для веток запускается вручную. + +### 2.12. Найденный дефект Первый browser E2E упал на создании студента: API вернул `400`, потому что Jackson 3 не применял Kotlin-модуль, и DTO с default-значениями требовали все @@ -166,14 +230,21 @@ optional json fields`. ## 3. Что это доказывает - Критичные P0-инварианты доступа, повышения, жизненного цикла, профиля, - материалов, миграции и аудита имеют зелёные автоматические свидетельства. + материалов, миграции, аудита и аутентификации имеют зелёные автоматические + свидетельства. - Приложение собирается, мигрирует с master-схемы и поднимается полным стеком на чистой БД. - Ключевой доменный поток «админ → группа → курс → преподаватель → лаба» проходит через реальные HTTP/gateway/БД и браузер. - Чужой преподаватель не получает доступ к курсу/лабе/аудиту на реальном стеке. +- Legacy-актив без привязки не утекает обычным пользователям, а админ может + провести инвентаризацию. +- Refresh-токены ротируются и отзываются; закрытый статус блокирует продление + сессии, а web-сессия переживает 15-минутный access-токен. +- Аудит листается по страницам, а группы курса отдаются одним вызовом без N+1. - Аудит пишется и читается; upgrade не теряет синтетические данные. - Мутационная прочность критичных компонентов измерена, а не заявлена. +- Масштабный load на 100 группах / 1000 студентов проходит без ошибок. - Реальный дефект DTO/Jackson найден именно E2E-контуром и закрыт тестом. ## 4. Чего это не доказывает @@ -186,24 +257,32 @@ optional json fields`. 3. **Браузерный E2E — один Chromium и 4 сценария.** Нет визуальной регрессии, Safari/Firefox и полного мобильного набора страниц. 4. **Нагрузочный smoke — не benchmark.** Одна машина, синтетические данные, - без SLA production. -5. **Legacy-активы без `article_assets` остаются публичными.** -6. **JWT живёт до 30 дней.** Web перепроверяет статус, прямой API — нет. -7. **Ведущие нули номера группы нормализуются** (`01` → `1`). -8. **Нет пагинации/keyset** для части списков и N+1 в отдельных экранах. -9. **Runtime/mutation/browser E2E не входят в unit-CI**; это release-скрипты. + без SLA production; 125 чтений и p95 < 1 с — ориентир, а не гарантия. +5. **Offset-пагинация аудита.** Вставки между страницами могут сдвигать + выдачу; keyset-курсор не реализован. +6. **N+1 в отдельных лабораторных списках/ведомости.** Имена групп курса и + аудит исправлены, но не все большие списки собраны пакетно. +7. **Нет абсолютного лимита web-сессии.** Sliding-сессия может продлеваться + бесконечно при непрерывной активности; отзыв по статусу при этом работает. +8. **Полный release-evidence не на каждом коммите.** На push/MR идёт smoke, + а mutation/browser/scale — для тегов или вручную. +9. **Внешняя интеграция «Контингент» не проверялась** — она остаётся заделом. +10. **Номер группы числовой.** Отдельного требования к ведущим нулям нет; + `01` и `1` — одно значение, это не открытый риск. ## 5. Вывод -Текущая функциональность прошла LDD-контур: **37/37 claims proven, P0 gaps = 0**, -273 теста, upgrade, mutation baseline и браузерный E2E зелёные. Это -достаточное основание продолжать доработки, но не «формальное доказательство -всей системы». +Текущая функциональность прошла LDD-контур: **46/46 claims proven, P0 gaps = 0**, +**319 тестов**, upgrade, mutation baseline, browser E2E, smoke и масштабный load +зелёные. Это достаточное основание продолжать доработки, но не «формальное +доказательство всей системы». Следующий цикл LDD: -1. расширить browser E2E на публикацию оценки и остальные мобильные страницы; -2. добавить нагрузочный профиль с реалистичным объёмом групп/студентов; -3. закрыть известные продуктовые риски: legacy-активы, JWT 30 дней, ведущие - нули, пагинация/N+1; -4. сделать release-evidence job обязательным для тегов. +1. почистить инвентарь legacy-активов через `GET /api/v1/content/legacy-assets` + (файлы уже не публичны, это гигиена хранилища); +2. добавить keyset-пагинацию аудита и убрать оставшийся N+1 в лабораторных + списках/ведомости; +3. при необходимости ввести абсолютный лимит web-сессии; +4. расширить browser E2E на публикацию оценки и остальные мобильные страницы; +5. подготовить внешнюю интеграцию «Контингент»/`next.uniyar.ac.ru`. diff --git a/docs/evidence/claims.json b/docs/evidence/claims.json index 49d5509..6257672 100644 --- a/docs/evidence/claims.json +++ b/docs/evidence/claims.json @@ -232,14 +232,14 @@ "criticality": "P0", "status": "active", "requirement": "docs/07-groups-courses.md, §6", - "claim": "Скрытый курсовой актив не отдаётся студенту, но доступен преподавателю; чужой курс получает 403; legacy без связи публичен.", + "claim": "Скрытый курсовой актив не отдаётся студенту, но доступен преподавателю; чужой курс получает 403; публичный глобальный ALL-актив доступен без входа.", "oracle": "HTTP-статус и содержимое файла", "evidence": [ "ru.ysu.lms.academic.content.ContentAssetAccessIntegrationTest#hidden course asset is not served to student but is served to teacher()", "ru.ysu.lms.academic.content.ContentAssetAccessIntegrationTest#foreign teacher and student are forbidden for course asset()", - "ru.ysu.lms.academic.content.ContentAssetAccessIntegrationTest#legacy asset without article association stays public()" + "ru.ysu.lms.academic.content.ContentAssetAccessIntegrationTest#published global ALL asset stays public()" ], - "limitations": "Legacy-файлы без article_assets остаются публичными — отдельный остаточный риск." + "limitations": "Legacy-файлы без article_assets теперь закрыты отдельным claim LDD-ASSET-002." }, { "id": "LDD-MIG-001", @@ -365,7 +365,7 @@ "ru.ysu.lms.identity.roster.StudentImportParserTest#parses new template and splits full name()", "ru.ysu.lms.identity.roster.StudentImportParserTest#reports duplicate student number()" ], - "limitations": "Старый формат не поддерживается; ведущие нули нормализуются." + "limitations": "Старый формат импорта не поддерживается; номер группы числовой, отдельного требования к ведущим нулям нет." }, { "id": "LDD-LAB-002", @@ -435,7 +435,7 @@ "criticality": "P1", "status": "active", "requirement": "docs/16-stage6-report.md, §2.2", - "claim": "Схема master + синтетические данные → миграции identity V4–V10, academic V6–V12, progress V13; V10 audit создаётся, V13 идемпотентна.", + "claim": "Схема master + синтетические данные → миграции identity V4–V11, academic V6–V12, progress V13; V10 audit и V11 refresh создаются, V13 идемпотентна.", "oracle": "Успешное применение и postflight-наличие таблиц/колонок/индексов", "runtime": [ "migration.master_upgrade" @@ -538,6 +538,137 @@ "ru.ysu.lms.identity.promotion.PromotionApplyRollbackPropertyTest#generated cohorts apply to plan and rollback to before-state()" ], "limitations": "10 детерминированных сценариев в одной транзакции; не заменяет property-генератор по всем полям." + }, + { + "id": "LDD-RUNTIME-003", + "title": "Масштабный load: 100 групп и 1000 студентов", + "criticality": "P1", + "status": "active", + "requirement": "docs/evidence/README.md", + "claim": "После seed 100 групп/1000 студентов 125 параллельных чтений курса, лаб, профиля, групп и аудита дают 0 ошибок и p95 < 2 c.", + "oracle": "runtime.scale_seed + runtime.load_gradebook_profile", + "runtime": [ + "runtime.scale_seed", + "runtime.load_gradebook_profile" + ], + "limitations": "Синтетические данные в одноразовой БД; это smoke, не SLA production." + }, + { + "id": "LDD-ASSET-002", + "title": "Файлы без привязки к статье не публичны", + "criticality": "P0", + "status": "active", + "requirement": "docs/15-stage5-course-materials.md", + "claim": "Аноним, студент и преподаватель получают 404 для legacy-актива без article_assets; админ получает файл; endpoint legacy-assets перечисляет такие файлы до привязки.", + "oracle": "HTTP 404/200 и список unlinkedAssetNames", + "evidence": [ + "ru.ysu.lms.academic.content.ContentAssetAccessIntegrationTest#legacy asset without article association is not public()", + "ru.ysu.lms.academic.content.ContentAssetAccessIntegrationTest#unlinkedAssetNames lists stored file until it is linked()" + ], + "limitations": "Старые внешние ссылки на бесхозные файлы перестают работать у обычных пользователей — осознанный security-компромисс." + }, + { + "id": "LDD-AUTH-001", + "title": "Короткий access-токен и ротация refresh", + "criticality": "P0", + "status": "active", + "requirement": "docs/16-stage6-report.md, §4", + "claim": "Login выдаёт access ≤15 минут и refresh; refresh ротируется, старый refresh отклоняется, повторное использование отзывает активные сессии.", + "oracle": "TTL JWT, ответы refresh, отзыв сессий", + "evidence": [ + "ru.ysu.lms.identity.RefreshTokenIntegrationTest#login returns short lived access token and refresh token()", + "ru.ysu.lms.identity.RefreshTokenIntegrationTest#refresh rotates the token and old token is rejected()", + "ru.ysu.lms.identity.RefreshTokenIntegrationTest#reuse of a rotated token revokes every active session()" + ], + "limitations": "Refresh-токен хранится как SHA-256 отпечаток; очистка истёкших записей выполняется позже." + }, + { + "id": "LDD-AUTH-002", + "title": "Отзыв refresh при logout/смене пароля и закрытом статусе", + "criticality": "P0", + "status": "active", + "requirement": "docs/16-stage6-report.md, §4", + "claim": "Logout и смена пароля отзывают refresh; отчисленный/архивный студент, архивный аккаунт и закрытая группа не могут обновить сессию.", + "oracle": "401 на refresh после отзыва/закрытия", + "evidence": [ + "ru.ysu.lms.identity.RefreshTokenIntegrationTest#logout revokes the refresh token idempotently()", + "ru.ysu.lms.identity.RefreshTokenIntegrationTest#password change revokes refresh tokens()", + "ru.ysu.lms.identity.RefreshTokenIntegrationTest#expelled student cannot refresh()", + "ru.ysu.lms.identity.RefreshTokenIntegrationTest#archived account cannot refresh()", + "ru.ysu.lms.identity.RefreshTokenIntegrationTest#student in archived group cannot refresh()" + ], + "limitations": "Access-токен живёт до 15 минут после отзыва refresh — это принятый компромисс." + }, + { + "id": "LDD-AUTH-003", + "title": "Web-сессия 7 дней sliding и автообновление", + "criticality": "P1", + "status": "active", + "requirement": "docs/evidence/README.md", + "claim": "Web хранит refresh и expiry, обновляет access за 2 минуты до истечения, продлевает cookie на каждом ответе; logout отзывает refresh; identity outage не разлогинивает всех.", + "oracle": "SessionTokenService/AuthPageController/фильтр cookie", + "evidence": [ + "ru.ysu.lms.web.session.SessionTokenServiceTest#token expiring soon is refreshed and session is updated()", + "ru.ysu.lms.web.session.SessionTokenServiceTest#identity outage fails open()", + "ru.ysu.lms.web.session.SessionCookieRefreshFilterTest#refreshes cookie max age on every existing session()", + "ru.ysu.lms.web.auth.AuthPageControllerTest#logout revokes refresh token and invalidates session()" + ], + "limitations": "Cookie и серверный timeout оба 7 дней; абсолютного верхнего предела сессии нет." + }, + { + "id": "LDD-AUDIT-004", + "title": "Пагинация журнала аудита", + "criticality": "P1", + "status": "active", + "requirement": "docs/evidence/README.md", + "claim": "Audit API отдаёт страницы limit/page без пересечений и с устойчивым порядком; UI показывает «Показать ещё».", + "oracle": "Страницы AuditIntegrationTest и ссылки шаблона", + "evidence": [ + "ru.ysu.lms.identity.audit.AuditIntegrationTest#pagination returns disjoint pages newest first()", + "ru.ysu.lms.web.admin.AdminAuditTemplateTest#renders audit events with labels and filter()" + ], + "limitations": "Offset-пагинация, не keyset; при частых вставках возможны сдвиги." + }, + { + "id": "LDD-PERF-001", + "title": "Bulk-группы курса без N+1", + "criticality": "P1", + "status": "active", + "requirement": "docs/evidence/README.md", + "claim": "Страница преподавателя получает названия всех привязанных групп одним запросом; чужой преподаватель получает 403.", + "oracle": "Один вызов courseGroups и отсутствие courseGroupStudents на страницу", + "evidence": [ + "ru.ysu.lms.academic.course.CourseGroupTeacherAccessTest#teacher gets attached groups with names in one call()", + "ru.ysu.lms.web.teacher.TeacherControllerCourseGroupsTest#loads group names with a single call instead of per-group requests()" + ], + "limitations": "Детали лабораторных профиля всё ещё подтягиваются по одной; это следующий шаг." + }, + { + "id": "LDD-AUTH-004", + "title": "Gateway пропускает refresh/logout без access-токена", + "criticality": "P0", + "status": "active", + "requirement": "docs/16-stage6-report.md, §4", + "claim": "POST /api/v1/auth/refresh и /api/v1/auth/logout проходят через gateway без access-JWT, иначе автообновление и отзыв сессии невозможны.", + "oracle": "GatewayFilterChain вызван для public path; protected path без токена — 401", + "evidence": [ + "ru.ysu.lms.gateway.security.JwtAuthenticationFilterTest#refresh and logout are public()", + "ru.ysu.lms.gateway.security.JwtAuthenticationFilterTest#protected route without bearer token is rejected()" + ], + "limitations": "Проверена маршрутизация gateway; интеграционный E2E с истёкшим access-токеном добавлен через web-тесты." + }, + { + "id": "LDD-AUTH-005", + "title": "Refresh/logout работают через реальный gateway", + "criticality": "P0", + "status": "active", + "requirement": "docs/16-stage6-report.md, §4", + "claim": "Через gateway refresh без access-токена выдаёт новую пару, новый access работает, logout отзывает refresh, повторный refresh — 401.", + "oracle": "runtime.auth_refresh", + "runtime": [ + "runtime.auth_refresh" + ], + "limitations": "Проверено на одноразовом стеке; TTL не истекает в рамках smoke." } ] } \ No newline at end of file