исправление багов

This commit is contained in:
Zuev
2026-07-13 03:28:18 +03:00
parent 39c58440cf
commit 85f61436b6
76 changed files with 9219 additions and 1258 deletions

View File

@@ -93,6 +93,9 @@ Redirect по ролям:
```
Refresh-токен ротируется при каждом успешном обновлении, а старый refresh-токен отзывается.
Один refresh-токен можно успешно использовать только один раз, в том числе при параллельных
запросах: первый запрос получает новую пару токенов, остальные получают `401`, а их cookie
очищается.
### `POST /api/auth/logout`
@@ -426,6 +429,14 @@ GET /api/schedule?groupId=1&startDate=2026-04-27&endDate=2026-05-03
}
```
`PUT /api/admin/academic-calendars/{id}/grid` выполняет атомарную полную замену. До
удаления прежних строк backend проверяет весь список: он должен быть непустым и не
содержать `null`, курс должен входить в `1..courseCount`, дата — в учебный год,
`dayOfWeek` — совпадать с ISO-днём даты, а `weekNumber`с номером семидневного периода
от начала учебного года. Ключ `(courseNumber, date)` не должен повторяться, каждый
`activityTypeId` или `activityCode` должен существовать. При любом `400` старая сетка
остаётся без изменений; `calendarId` из строки не переопределяет ID в URL.
**Привязка дисциплин к графику:**
```json
[
@@ -476,15 +487,32 @@ CRUD доступен по:
| `GET` | `/api/admin/schedule-rules/{id}` | Одно правило |
| `POST` | `/api/admin/schedule-rules` | Создать правило |
| `PUT` | `/api/admin/schedule-rules/{id}` | Обновить правило |
| `DELETE` | `/api/admin/schedule-rules/{id}` | Удалить правило |
| `DELETE` | `/api/admin/schedule-rules/{id}` | Архивировать правило |
`timeSlotId` должен ссылаться на базовый слот (`scopeApplyMode = DEFAULT`). Субботняя и ручные сетки не выбираются в правиле напрямую.
`subgroupIds` можно передавать только для лабораторного слота. Каждая подгруппа должна относиться к одной из групп правила. Если лабораторная проводится у нескольких групп одновременно, в одном слоте можно передать разные подгруппы этих групп, например `[10, 22]`. Для совместимости одиночный `subgroupId` тоже принимается, но новый формат — `subgroupIds`. Для лекций и практик оба поля должны быть пустыми, иначе API вернёт ошибку валидации. В одном слоте нельзя выбрать больше одной подгруппы одной и той же группы.
Часы и недели начала задаются отдельно для лекций, лабораторных и практик. Если для типа занятий указан ненулевой лимит часов, в правиле должен быть хотя бы один слот этого типа; если слот типа есть, его лимит часов должен быть больше нуля.
Часы и недели начала задаются отдельно для лекций, лабораторных и практик. Каждый лимит
часов обязателен, неотрицателен и кратен двум; ноль разрешён для неиспользуемого типа, но
суммарно хотя бы один тип должен иметь положительный лимит. Если лимит типа ненулевой, в
правиле должен быть хотя бы один слот этого типа; слот типа не принимается при нулевом
лимите. Вложенные идентификаторы, которых нет в БД, считаются ошибкой payload и дают `400`.
`parity` принимает только `BOTH`, `ODD` или `EVEN`. При создании и обновлении правила backend проверяет занятость в рамках семестра: конфликтом считается пересечение дня, базового временного слота, чётности и активных недель слотов, если совпадает преподаватель, аудитория или учебная группа. Активные недели рассчитываются по лимиту часов типа занятия, неделе начала, чётности и порядку слотов правила, поэтому правило, которое фактически идёт с 1 по 3 неделю, не блокирует тот же слот с 4 недели. Для лабораторных слотов подгруппы учитываются отдельно: разные подгруппы одной группы могут занимать один слот, но слот для всей группы конфликтует с любой её подгруппой. При конфликте API возвращает `409 Conflict`:
`teacherId` должен ссылаться на активного пользователя с ролью `TEACHER`, а
`lessonFormat` принимает только `Очно` или `Онлайн`. `parity` принимает только `BOTH`,
`ODD` или `EVEN`.
До сохранения backend попарно проверяет все слоты нового payload: точные дубли и
пересечения преподавателя, аудитории или аудитории обучающихся отклоняются. `ODD` и `EVEN`
не пересекаются; `BOTH` пересекается с обеими чётностями. Затем выполняется та же проверка
с активными правилами семестра. Активные недели рассчитываются по лимиту часов типа
занятия, неделе начала, чётности и порядку слотов правила, поэтому правило, которое
фактически идёт с 1 по 3 неделю, не блокирует тот же слот с 4 недели. Для лабораторных
слотов подгруппы учитываются отдельно: разные подгруппы одной группы могут занимать один
слот, но слот для всей группы конфликтует с любой её подгруппой. Создание правил одного
семестра сериализуется блокировкой строки семестра в PostgreSQL. Конфликт с уже сохранённым
правилом возвращает `409 Conflict`:
```json
{
@@ -501,7 +529,19 @@ CRUD доступен по:
}
```
`conflictFields` содержит технические причины пересечения: `teacher`, `classroom` и/или `group`. `conflictReasons` содержит те же причины в русских подписях для интерфейса.
`conflictFields` содержит технические причины пересечения: `teacher`, `classroom` и/или
`group`. Для точного дубля используется `slot`. При конфликте внутри нового payload
`conflictRule` отсутствует, потому что конфликтующей сохранённой записи ещё нет:
```json
{
"message": "Невозможно сохранить правило: слоты внутри правила конфликтуют",
"conflictFields": ["teacher", "group"],
"conflictReasons": ["Преподаватель", "Группа"]
}
```
`conflictReasons` содержит те же причины в русских подписях для интерфейса.
### `GET /api/lesson-types`
@@ -532,7 +572,17 @@ CRUD доступен по:
| `timeSlotId` | Временной слот |
| `parity` | `BOTH`, `ODD`, `EVEN` |
Если указан только `teacherId` без `groupId` и `departmentId`, поиск строит расписание преподавателя напрямую и не обходит все группы. Широкий поиск без `groupId` и `departmentId` разрешён только до 50 активных групп; при большем количестве групп API вернёт `400` с просьбой уточнить группу или кафедру.
Если указан только `teacherId` без `groupId` и `departmentId`, базовое расписание
преподавателя строится напрямую. Затем поиск учитывает точечные изменения, где преподаватель
назначен через `newTeacherId`: для каждой уникальной даты такой замены один раз строится
базовый день, из него добавляются только указанные `baseRuleSlotId`, после чего применяются
все overrides и выполняется окончательный фильтр преподавателя. Поэтому новый преподаватель
видит назначенную замену, а исходный больше её не видит. Если релевантных замен нет, обход
всех групп не выполняется.
Широкий пользовательский поиск без `groupId`, `departmentId` и teacher-only режима
разрешён только до 50 активных групп; при большем количестве групп API вернёт `400` с
просьбой уточнить группу или кафедру.
Пример:
@@ -562,7 +612,24 @@ GET /api/schedule/search?classroomId=1&startDate=2026-05-20&endDate=2026-05-27
}
```
`action=CANCEL` отменяет конкретную пару. `MOVE` и `REPLACE` могут менять аудиторию, преподавателя, формат и временной слот.
Правила payload:
- `CANCEL` отменяет конкретную пару; поля `newTimeSlotId`, `newClassroomId`,
`newTeacherId` и `newLessonFormat` должны отсутствовать;
- `MOVE` требует новый временной слот или аудиторию; преподавателя и формат можно изменить
в том же запросе;
- `REPLACE` требует нового преподавателя, аудиторию или формат; временной слот можно
изменить в том же запросе;
- формат принимает только `Очно` или `Онлайн`;
- `MOVE` и `REPLACE` должны фактически менять основные параметры действия. Другой ID
временного слота с тем же интервалом не считается переносом.
До сохранения backend строит базовую пару на `lessonDate` по тем же правилам, что и обычное
расписание: семестр, календарный график, чётность, активность сущностей и остаток часов.
Если пара не формируется или нарушена матрица действия, API возвращает `400` с русским
сообщением. Если результирующее время пересекается с занятым преподавателем, аудиторией,
группой или той же подгруппой, API возвращает `409 Conflict`; соседние интервалы и разные
подгруппы одной группы не конфликтуют.
## Загруженность
@@ -1059,7 +1126,12 @@ GET /api/workload/teachers?departmentId=1&startDate=2026-05-20&endDate=2026-06-0
**Логика:**
1. Создаёт HikariCP пул для нового тенанта
2. Запускает Flyway миграции на его БД
3. Обновляет Kubernetes ConfigMap
3. Обновляет внешний Kubernetes Secret `tenants-secret`
Backend соединяется с Kubernetes API только через проверенный service-account CA и
hostname verification. Если безопасно сохранить tenant-конфигурацию не удалось, операция
не возвращается как успешная. Значения credentials никогда не включаются в ответ или лог
Kubernetes updater.
### `DELETE /api/database/tenants/{domain}`

View File

@@ -82,7 +82,7 @@ sequenceDiagram
| `TenantDataSourceConfig` | Загружает конфигурацию тенантов из JSON-файла, создаёт HikariCP пулы |
| `TenantWebMvcConfig` | Регистрирует `TenantInterceptor` и `AuthorizationInterceptor` в MVC-слое |
| `TenantConfigWatcher` | Периодически (каждые 30 сек) перечитывает `tenants.json`, синхронизирует тенантов |
| `ConfigMapUpdater` | Обновляет Kubernetes ConfigMap при добавлении/удалении тенанта через API |
| `KubernetesTenantSecretUpdater` | Обновляет Kubernetes Secret с tenant-конфигурацией через API, проверяя TLS по service-account CA |
| `TenantConfig` | POJO с параметрами тенанта: `name`, `domain`, `url`, `username`, `password` |
### Определение тенанта
@@ -101,7 +101,10 @@ sequenceDiagram
Список тенантов хранится в JSON-файле:
- **Локально:** `backend/tenants.json` (не коммитится; шаблон — `backend/tenants.example.json`)
- **Продакшн:** Kubernetes ConfigMap `tenants-config`, монтируется в `/config/tenants.json`
- **Продакшн:** внешний Kubernetes Secret `tenants-secret`, ключ `tenants.json` монтируется в `/config/tenants.json`
`tenants-secret` не создаётся отслеживаемыми production-манифестами. Его подготавливает
оператор через secret manager по [`SECURITY_RUNBOOK.md`](SECURITY_RUNBOOK.md).
Формат:
```json
@@ -118,9 +121,14 @@ sequenceDiagram
### Жизненный цикл тенанта
1. **Добавление через API:** `POST /api/database/tenants` → создаёт HikariCP пул → запускает Flyway миграции → обновляет ConfigMap
2. **Синхронизация подов:** `TenantConfigWatcher` каждые 30 сек проверяет `tenants.json` → добавляет новые / удаляет отсутствующие тенанты
3. **Удаление:** `DELETE /api/database/tenants/{domain}` → закрывает пул → обновляет ConfigMap
1. **Добавление через API:** `POST /api/database/tenants` → создаёт HikariCP пул → запускает Flyway миграции → обновляет `tenants-secret`
2. **Синхронизация подов:** `TenantConfigWatcher` каждые 30 сек проверяет смонтированный `tenants.json` → добавляет новые / удаляет отсутствующие тенанты
3. **Удаление:** `DELETE /api/database/tenants/{domain}` → закрывает пул → обновляет `tenants-secret`
Для обращения к Kubernetes API backend загружает
`/var/run/secrets/kubernetes.io/serviceaccount/ca.crt`, использует стандартную PKIX-проверку
цепочки и обязательную проверку hostname. Trust-all fallback отсутствует: ошибка CA или TLS
завершает персистенцию безопасным отказом.
`TenantConfigWatcher` сравнивает содержимое `tenants.json` по SHA-256, а не по `String.hashCode()`, чтобы изменение ConfigMap не пропускалось из-за 32-битной коллизии.
@@ -134,6 +142,47 @@ sequenceDiagram
---
## Транзакционные изменения расписания
`ScheduleRuleAdminController` является тонким HTTP-адаптером. Чтение, создание, изменение
и архивация правил выполняются через `ScheduleRuleService`; публичные write-методы сервиса
образуют транзакционные границы, поэтому ошибки валидации и конфликты выходят за Spring
proxy и приводят к rollback.
Создание правила до остальных запросов к БД захватывает `PESSIMISTIC_WRITE` на строке
целевого семестра. Update сначала блокирует строку правила, затем старый и новый семестры в
порядке ID; архивация блокирует правило и его семестр. После блокировок сервис заново
проверяет слоты payload и активные правила семестра, поэтому конкурентные запросы разных
pod не сохраняют два конфликтующих правила после проверки одного снимка.
`AcademicCalendarController` делегирует полную замену дневной сетки
`AcademicCalendarGridService`. Публичный `replaceGrid()` проходит через транзакционный
Spring proxy: весь payload и все activity types проверяются до bulk delete, затем выполняются
`delete → flush → saveAll → flush`. Исключение не перехватывается внутри сервиса и вызывает
rollback. Инвалидация кэша зарегистрирована через transaction synchronization и выполняется
только после успешного commit.
`ScheduleOverrideController` является HTTP-адаптером, а create/update/delete выполняет
`ScheduleOverrideService` через вызываемые Spring proxy-методы с `@Transactional`.
Сервис строит базовый день через `ScheduleQueryService`, накладывает сохранённые overrides и
кандидат общей функцией и только затем проверяет результирующие интервалы и ресурсы.
До чтения снимка даты сервис захватывает `pg_advisory_xact_lock` с отдельным namespace и
точным `LocalDate.toEpochDay()`. Блокировка живёт до commit/rollback, работает между pod и
изолирована по tenant-БД. При `PUT` и `DELETE` дополнительно блокируется ID override;
при смене даты старая и новая даты захватываются в стабильном порядке. После блокировок
строка перечитывается с `PESSIMISTIC_WRITE`, поэтому параллельное изменение не приводит к
lost update или проверке устаревшего снимка.
Teacher-only чтение строит базовое расписание напрямую через
`ScheduleGeneratorService.buildScheduleForTeacher()`. `ScheduleQueryService` дополнительно
находит overrides с `newTeacher`, группирует их по дате, один раз строит базовый день каждой
релевантной даты и добавляет только целевые `baseRuleSlotId`. Все overrides применяются до
финального teacher-фильтра и дедупликации; без релевантных замен полный обход групп не
выполняется.
---
## Аутентификация
Система использует access JWT и отзывные refresh-токены без включения полноценного Spring Security flow:
@@ -145,6 +194,23 @@ sequenceDiagram
5. При истечении access JWT клиент вызывает `POST /api/auth/refresh`; refresh-токен ротируется, старый хэш отзывается.
6. `POST /api/auth/logout` отзывает текущий refresh-токен и очищает cookie.
Ротация refresh-токена имеет single-use семантику. `RefreshTokenService.rotate()` выполняется
в транзакции, а `AuthRefreshTokenRepository` захватывает исходную строку через
`PESSIMISTIC_WRITE`. Поэтому два одновременных запроса с одним cookie сериализуются:
только первый создаёт следующий refresh-токен, второй видит уже отозванную строку и
завершается без выпуска новой сессии.
Access JWT содержит claim'ы `tenant`, `userId`, `username`, `role`, `departmentId`, `iat`, `exp`, `jti`. `AuthorizationInterceptor` проверяет подпись, срок действия и совпадение `tenant` с `TenantContext`, затем применяет `@RequireRoles`. Это означает, что UI-роль в `localStorage` остаётся только удобством: backend возвращает `401`, если токен отсутствует/некорректен, и `403`, если роли недостаточно.
`AuthContext` существует только в границах одного servlet-запроса. Интерцептор очищает
`ThreadLocal` до любых ранних выходов, устанавливает пользователя только после успешной
проверки роли и повторно очищает контекст в `afterCompletion`. Поэтому отказ с `401`/`403`
или публичный endpoint не может получить пользователя от предыдущего запроса того же
потока контейнера.
`TenantInterceptor` по-прежнему отвечает за выбор БД тенанта по домену. Проверка авторизации выполняется отдельным интерцептором после tenant-resolution, поэтому токен, выданный на одном домене, не принимается на другом tenant-домене.
JWT-секрет не имеет встроенного значения и обязателен во всех окружениях. При профиле
`prod` или `production` startup-проверка дополнительно отклоняет известные legacy/placeholder
значения и требует `JWT_REFRESH_COOKIE_SECURE=true`. Production deployment явно включает
профиль `production` и получает `JWT_SECRET` только через `secretKeyRef`.

View File

@@ -135,6 +135,13 @@ Bearer-токен проверяется на backend. Frontend-скрытие
| `schedule_rule_slot_subgroups` | Подгруппы лабораторного слота |
| `schedule_overrides` | Точечные переносы, отмены и замены конкретных сгенерированных пар |
Полная замена `academic_calendar_days` выполняется через транзакционный
`AcademicCalendarGridService`. Сервис сначала проверяет и строит весь новый набор, включая
уникальность `(course, date)`, соответствие даты учебному году, номеру недели и ISO-дню,
и разрешает все коды активностей. Только после этого прежняя сетка удаляется и новый набор
записывается одной транзакцией. Ошибка любой строки сохраняет прежнюю сетку целиком, а кэш
расписания очищается только после commit.
Генератор `ScheduleGeneratorService` рендерит расписание по запросу:
1. Определяет семестр для каждой даты диапазона.
2. Вычисляет номер недели и чётность.
@@ -152,7 +159,13 @@ Bearer-токен проверяется на backend. Frontend-скрытие
В генерацию попадают только активные на дату правила, дисциплины, группы, преподаватели и аудитории. Для будущих дат аудитория с `is_available=false` не выводится в расписании, но прошлые занятия остаются доступными для просмотра.
Расширенный поиск расписания ограничивает широкие запросы: если не указаны `groupId` и `departmentId`, сервис не будет обходить больше 50 активных групп и вернёт ошибку валидации. Запросы по одному `teacherId` без группы или кафедры строятся через генерацию расписания преподавателя, чтобы не выполнять полный перебор групп.
Расширенный поиск расписания ограничивает широкие запросы: если не указаны `groupId`,
`departmentId` и teacher-only режим, сервис не будет обходить больше 50 активных групп и
вернёт ошибку валидации. Запросы по одному `teacherId` сначала строятся через генерацию
базового расписания преподавателя. Для overrides с совпадающим `newTeacher` сервис один раз
на каждую релевантную дату достраивает базовый день, выбирает только целевые слоты, применяет
единый снимок изменений и затем фильтрует итогового преподавателя. При отсутствии таких
замен полный список групп не загружается.
Лабораторные работы могут делиться на подгруппы через `schedule_rule_slot_subgroups`. Если подгруппы выбраны, занятие выводится только для родительских групп этих подгрупп, а лимит лабораторных часов списывается отдельно по каждой подгруппе. Если лабораторная проводится у нескольких групп одновременно, один слот может содержать разные подгруппы разных групп. Лекции и практики не делятся на подгруппы.
@@ -178,26 +191,47 @@ Bearer-токен проверяется на backend. Frontend-скрытие
### Валидация правил расписания
- **Правило:** обязательны дисциплина, семестр, хотя бы один положительный лимит часов по типу занятий, положительные недели начала и хотя бы одна группа.
- **Правило:** обязательны дисциплина, семестр, положительные недели начала и хотя бы одна группа; каждый лимит часов неотрицателен и чётен, а сумма лимитов положительна. Ноль разрешён для неиспользуемого типа занятия.
- **Покрытие типов:** если для лекций, лабораторных или практик указан лимит часов, должен быть хотя бы один слот этого типа; слот типа не сохраняется с нулевым лимитом часов.
- **Слот:** день недели должен быть от 1 до 7, чётность недели обязательна и принимает только `BOTH`, `ODD` или `EVEN`.
- **Связанные сущности:** базовый временной слот, преподаватель, аудитория и тип занятия должны существовать в БД.
- **Связанные сущности:** базовый временной слот, преподаватель, аудитория и тип занятия должны существовать в БД; преподавателем может быть только активный пользователь с ролью `TEACHER`.
- **Подгруппы:** `subgroupIds` разрешены только для лабораторных слотов, должны относиться к группам правила, и в одном слоте можно выбрать не больше одной подгруппы каждой группы.
- **Формат:** `lessonFormat` обязателен и хранится в слоте правила.
- **Формат:** `lessonFormat` обязателен и принимает только `Очно` или `Онлайн`.
- **Жизненный цикл:** архивные преподаватели, аудитории, группы и дисциплины не принимаются в новых правилах.
- **Правило расписания:** `status=ARCHIVED` или дата вне `valid_from` / `valid_to` исключают правило из генерации.
- **Доступность аудитории:** `is_available=false` запрещает новые назначения, но не удаляет историю.
- **Конфликты слотов:** при создании и обновлении правил проверяются активные правила того же семестра. Конфликт возникает при пересечении дня, базового временного слота, чётности (`BOTH` пересекается с любой чётностью) и активных недель слота, если совпадает преподаватель, аудитория или учебная группа. Активные недели считаются из лимита часов типа занятия, недели начала, чётности и порядка слотов внутри правила; например, занятие на 1-3 неделях не конфликтует с тем же ресурсом с 4 недели. Для лабораторных занятий разные подгруппы одной группы могут идти параллельно, но занятие для всей группы конфликтует с любой её подгруппой. Backend возвращает `409 Conflict` с ранее созданным `conflictRule`, чтобы frontend мог предложить перенос этого правила.
- **Конфликты слотов:** сначала попарно проверяются слоты самого нового payload, включая точные дубли, затем — активные правила того же семестра. Конфликт возникает при пересечении дня, базового временного слота, чётности (`BOTH` пересекается с любой чётностью) и активных недель слота, если совпадает преподаватель, аудитория или учебная группа. `ODD` и `EVEN` между собой не конфликтуют. Активные недели считаются из лимита часов типа занятия, недели начала, чётности и порядка слотов внутри правила; например, занятие на 1-3 неделях не конфликтует с тем же ресурсом с 4 недели. Для лабораторных занятий разные подгруппы одной группы могут идти параллельно, но занятие для всей группы конфликтует с любой её подгруппой. Backend возвращает `409 Conflict`; `conflictRule` присутствует только для конфликта с сохранённым правилом, а внутренний конфликт описывается полями и русскими причинами без искусственной записи.
- **Конкурентная запись:** публичные методы `ScheduleRuleService` являются транзакционными. Создание сначала блокирует строку семестра, а update блокирует правило и старый/новый семестры в стабильном порядке, поэтому два backend-pod не могут одновременно пройти проверку одного семестра по устаревшему снимку.
### Точечные изменения расписания
Учебный отдел может создать изменение конкретной пары:
- `CANCEL` — отменить пару;
- `MOVE` — перенести пару на другой временной слот или в другую аудиторию;
- `MOVE` — перенести пару на другой фактический интервал или в другую аудиторию;
- `REPLACE` — заменить преподавателя, аудиторию или формат.
Изменения не переписывают базовое правило, а накладываются поверх сгенерированного расписания на конкретную дату.
`CANCEL` не принимает новые ресурсы. Для `MOVE` обязателен новый временной слот или
аудитория, для `REPLACE` — преподаватель, аудитория или формат. Дополнительные изменения
можно объединять в одном payload, но основное действие должно фактически менять свою
часть пары. Формат ограничен значениями `Очно` и `Онлайн`.
Изменения не переписывают базовое правило, а накладываются поверх сгенерированного
расписания на конкретную дату. Перед записью `ScheduleOverrideService`:
1. захватывает transaction advisory lock PostgreSQL для tenant-БД и даты;
2. строит базовый день для всех групп без интерактивного лимита широкого поиска;
3. доказывает существование исходной пары с учётом семестра, календарного графика,
чётности, недели начала, лимита часов и lifecycle;
4. применяет сохранённые overrides и кандидат общей логикой `ScheduleQueryService`;
5. проверяет полуоткрытые временные интервалы `[start, end)` и итоговые ресурсы;
6. сохраняет изменение только при отсутствии конфликта.
Совпадение преподавателя или аудитории в пересекающееся время всегда является конфликтом.
Для общей учебной группы занятие целой группы конфликтует с любой её подгруппой; разные
подгруппы одной группы могут идти параллельно при свободных преподавателях и аудиториях.
Конфликт возвращается как `409 Conflict` с русским сообщением. Блокировка PostgreSQL общая
для backend-pod, поэтому два конкурентных изменения одной даты проверяются последовательно.
---

View File

@@ -709,6 +709,10 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
`ScheduleRule` использует собственные поля жизненного цикла `status`, `valid_from` и `valid_to`: архивированное правило или правило вне периода действия не участвует в генерации расписания. В отличие от справочников на `LifecycleEntity`, таблица не содержит `active_from`/`active_to`, поэтому состояние правила проверяется по `valid_*`.
Миграция V4 требует, чтобы лимиты лекций, лабораторных и практик были кратны двум.
Неотрицательность каждого лимита и положительная сумма уже закреплены ограничениями V1;
нули допустимы только как лимиты неиспользуемых типов занятия.
#### `schedule_rule_groups` — Группы правила
| Колонка | Тип | Описание |
|---------|-----|----------|
@@ -734,6 +738,11 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
| `locked_at` | TIMESTAMP | Когда выполнено закрепление |
| `lock_comment` | TEXT | Комментарий к закреплению |
V4 добавляет `uq_schedule_rule_slots_exact_payload`: в одном правиле нельзя повторить
одинаковые день, чётность, базовый временной слот, преподавателя, аудиторию, тип и формат
занятия. Более широкие ресурсные пересечения и семантика подгрупп проверяются сервисом,
поскольку зависят от нескольких таблиц и фактических активных недель.
#### `schedule_rule_slot_subgroups` — Подгруппы лабораторного слота
| Колонка | Тип | Описание |
|---------|-----|----------|
@@ -757,7 +766,16 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
| `created_by` | BIGINT FK → users | Автор изменения |
| `created_at` | TIMESTAMP | Дата создания |
Ограничение `uq_schedule_overrides_slot_date` не позволяет создать две разные правки для одной и той же пары.
Ограничение `uq_schedule_overrides_slot_date` не позволяет создать две разные правки для
одной и той же пары. Миграция V3 добавляет структурные инварианты:
- `CANCEL` не содержит новых ресурсов;
- `MOVE` содержит новый временной слот или аудиторию;
- `REPLACE` содержит нового преподавателя, аудиторию или формат;
- `new_lesson_format` равен `Очно`, `Онлайн` либо `NULL`.
Фактическое существование пары, реальность изменения и ресурсные конфликты зависят от
календаря конкретной даты и проверяются транзакционным сервисом, а не SQL CHECK.
---
@@ -771,23 +789,30 @@ Seed создаёт `Базовая сетка` (`DEFAULT`) и `Субботня
4. Flyway запускается **программно** при первом обращении к БД тенанта (`TenantConfigWatcher.initDatabaseForTenant()`)
5. Настройка `baselineOnMigrate=true` — если в БД уже есть данные, Flyway начнёт с baseline
> Текущая правка `V1__init.sql` является осознанным исключением по прямой просьбе пользователя: файл обновлён как новая базовая схема, а применение предполагает полный сброс tenant-БД без переноса старых Flyway checksum.
### Текущие миграции
| Файл | Описание |
|------|----------|
| `V1__init.sql` | Инициализация: справочники, роли, refresh-сессии JWT, lifecycle-поля, история кафедр преподавателей, заявки кафедр на создание преподавателей, комментарии дисциплин, календарные учебные графики, привязки дисциплин к графикам по семестрам, динамическое расписание, версии/закрепления правил, точечные изменения расписания, тестовые правила, триггеры, комментарии |
| `V2__subgroups_active_unique_name.sql` | Уникальность имени среди активных подгрупп одной группы |
| `V3__schedule_override_invariants.sql` | Матрица payload и допустимый формат точечных изменений расписания |
| `V4__schedule_rule_even_hours.sql` | Чётность лимитов академических часов и уникальность точного payload слота правила |
### Накатывание на существующих тенантов
Для применения новой базовой схемы к уже существующим тенантам нужен полный сброс БД. Перезапуск backend без сброса не изменит уже применённую `V1__init.sql`.
V2V4 накатываются на существующие tenant-БД без изменения контрольных сумм V1V3.
Перед добавлением ограничений V3 считает нарушения `CANCEL`, `MOVE`, `REPLACE` и формата.
Если найдены legacy-строки, миграция полностью откатывается и сообщает только количества
нарушений. Оператор должен исправить бизнес-данные tenant-БД и повторить миграцию;
автоматическое удаление или переписывание overrides не выполняется.
Перед ограничениями V4 отдельно считаются нечётные лимиты лекций, лабораторных и практик,
а также группы точных дублей слотов. Любое нарушение останавливает V4 с русским сообщением;
ограничения не остаются частично применёнными и legacy-данные автоматически не меняются.
```bash
# Kubernetes
kubectl rollout restart deployment backend -n magistr
# Docker Compose (локально)
# После исправления legacy-данных перезапустите backend,
# чтобы TenantConfigWatcher повторил Flyway migrate для tenant-БД.
docker compose restart backend
```

View File

@@ -257,7 +257,7 @@ com.magistr.app/
│ ├── TenantDataSourceConfig.java # Конфигурация DataSource/JPA
│ ├── TenantWebMvcConfig.java # Регистрация MVC-интерцепторов
│ ├── TenantConfigWatcher.java # Периодическая синхронизация
│ └── ConfigMapUpdater.java # Обновление K8s ConfigMap
│ └── KubernetesTenantSecretUpdater.java # Обновление tenant Secret с проверкой TLS
├── controller/ # REST-контроллеры
│ ├── AuthController.java
│ ├── GlobalExceptionHandler.java

View File

@@ -34,6 +34,9 @@ JWT_REFRESH_TOKEN_TTL=7d
`POSTGRES_PASSWORD` обязателен для `docker compose up`: пароль не хранится в `compose.yaml`. `JWT_SECRET` должен быть случайным секретом длиной минимум 32 байта. В продакшене секреты задаются через Kubernetes Secret, а не через коммитимые файлы.
Встроенного JWT fallback в приложении нет. Отсутствующее, короткое или шаблонное значение
останавливает запуск; профиль `production` также требует Secure refresh-cookie.
Локальный `backend/tenants.json` тоже не коммитится. Для ручного запуска backend вне Docker можно взять `backend/tenants.example.json`, создать рядом `tenants.json` и подставить локальный пароль.
### Dockerfile (Backend)
@@ -64,7 +67,7 @@ RUN chown -R www-data:www-data /usr/local/apache2/htdocs/
|--------|-----|----------|
| `backend` | Deployment | Spring Boot приложение |
| `frontend` | Deployment | Apache httpd |
| `tenants-config` | ConfigMap | JSON-список тенантов |
| `tenants-secret` | Внешний Secret | JSON-список tenant-подключений; значения отсутствуют в манифестах |
### JWT настройки
@@ -74,16 +77,35 @@ RUN chown -R www-data:www-data /usr/local/apache2/htdocs/
- `JWT_REFRESH_TOKEN_TTL=7d`
- `JWT_REFRESH_COOKIE_SECURE=true`
`app-secret` задаёт `JWT_SECRET`. Его нельзя логировать или хранить в публичных артефактах как реальный продакшн-секрет.
`app-secret` задаёт `JWT_SECRET`. Этот Secret не создаётся файлами `../k8s/`: его заранее
предоставляет внешний secret manager или оператор. Deployment явно включает профиль
`production`, поэтому небезопасная cookie-конфигурация останавливает startup.
### ConfigMap для тенантов
### Secrets для приложения и тенантов
ConfigMap `tenants-config` монтируется в под backend по пути `/config/tenants.json`.
Обязательные внешние объекты:
| Secret | Назначение |
|---|---|
| `app-secret` | JWT и fallback credentials backend |
| `tenants-secret` | Полный `tenants.json` с credentials tenant-БД |
| `otel-postgres-secret` | Credentials PostgreSQL receivers для OTel Collector |
| `gitea-registry` | Доступ Kubernetes к registry |
Secret `tenants-secret` монтируется в pod backend по пути `/config/tenants.json`.
При добавлении тенанта через API:
1. `DatabaseController` обновляет in-memory DataSource
2. `ConfigMapUpdater` обновляет ConfigMap через Kubernetes API
3. `TenantConfigWatcher` на остальных подах подхватывает изменения (каждые 30 сек)
2. `KubernetesTenantSecretUpdater` обновляет Secret через Kubernetes API
3. `TenantConfigWatcher` на остальных pod подхватывает смонтированное изменение (каждые 30 сек)
Kubernetes-клиент доверяет только service-account CA и проверяет hostname API server.
RBAC ограничен объектом `tenants-secret`. Скрипт `../k8s/deploy.sh` проверяет наличие
объектов и ключей до rollout, не читая и не печатая их значения.
Создание, ротация, отзыв скомпрометированных значений и очистка истории описаны в
[`SECURITY_RUNBOOK.md`](SECURITY_RUNBOOK.md). Эти действия требуют полномочий оператора и
не выполняются автоматически.
### Обновление backend
@@ -148,6 +170,9 @@ Tenant ID добавляется в:
- Метрики производительности страниц
- Трейсы пользовательских действий
PostgreSQL receivers collector получают endpoint и credentials только из
`otel-postgres-secret`; ConfigMap collector содержит лишь `${env:...}` ссылки.
### Дашборды SigNoz
- JVM Dashboard (Heap, GC, Threads)

View File

@@ -92,7 +92,7 @@ MDC.remove("tenant.id");
| `TenantDataSourceConfig` | INFO, WARN, ERROR | Загрузка тенантов, fallback на H2 |
| `TenantRoutingDataSource` | INFO, WARN | Добавление/удаление тенантов, тест соединения |
| `TenantConfigWatcher` | INFO, ERROR, WARN | Изменения ConfigMap, Flyway миграции |
| `ConfigMapUpdater` | INFO, WARN, ERROR | Обновление ConfigMap в K8s |
| `KubernetesTenantSecretUpdater` | INFO, WARN, ERROR | Безопасное обновление tenant Secret без вывода его содержимого |
| `DataInitializer` | INFO | Инициализация БД при старте |
| `ScheduleController` | INFO, ERROR | Запросы динамического расписания и ошибки генерации |

View File

@@ -112,5 +112,6 @@ magistr/
| [База данных](DATABASE.md) | Схема БД, описание таблиц, Flyway миграции |
| [REST API](API.md) | Все эндпоинты с примерами запросов и ответов |
| [Инфраструктура](INFRASTRUCTURE.md) | Docker, Kubernetes, CI/CD, мониторинг |
| [Runbook безопасности](SECURITY_RUNBOOK.md) | Production-секреты, ротация и очистка истории |
| [Разработка](DEVELOPMENT.md) | Code Style, соглашения, инструкции для разработчиков |
| [Frontend](FRONTEND.md) | Архитектура фронтенда, модули, стили |

77
docs/SECURITY_RUNBOOK.md Normal file
View File

@@ -0,0 +1,77 @@
# Runbook безопасности production-секретов
Этот документ описывает действия оператора, которые нельзя выполнять автоматически из репозитория. Он не содержит и не должен содержать значения секретов, паролей, токенов, cookie или строк подключения.
## Обязательные Kubernetes Secrets
Production-манифесты только ссылаются на заранее созданные объекты:
| Secret | Обязательные ключи | Потребитель |
|---|---|---|
| `app-secret` | `JWT_SECRET`, `POSTGRES_USER`, `POSTGRES_PASSWORD` | Backend |
| `tenants-secret` | `tenants.json` | Backend, файл `/config/tenants.json` |
| `otel-postgres-secret` | `MAGISTR_DB_ENDPOINT`, `MAGISTR_DB_USERNAME`, `MAGISTR_DB_PASSWORD`, `MAGISTR_DB_NAME`, `N8N_DB_ENDPOINT`, `N8N_DB_USERNAME`, `N8N_DB_PASSWORD`, `N8N_DB_NAME` | OTel Collector |
| `gitea-registry` | стандартный ключ Docker registry | Kubernetes image pull |
Предпочтительный источник — External Secrets, SOPS/Sealed Secrets либо корпоративный secret manager. Kubernetes Secret, созданный вручную, допустим как переходный вариант, если в кластере включено шифрование etcd и значения никогда не сохраняются в Git.
Для переходного создания из защищённых файлов на рабочей станции оператора:
```bash
kubectl create namespace magistr --dry-run=client -o yaml | kubectl apply -f -
kubectl -n magistr create secret generic app-secret \
--from-env-file=/secure/path/app-secret.env \
--dry-run=client -o yaml | kubectl apply -f -
kubectl -n magistr create secret generic tenants-secret \
--from-file=tenants.json=/secure/path/tenants.json \
--dry-run=client -o yaml | kubectl apply -f -
kubectl -n magistr create secret generic otel-postgres-secret \
--from-env-file=/secure/path/otel-postgres-secret.env \
--dry-run=client -o yaml | kubectl apply -f -
```
Не перенаправляйте YAML из этих команд в файл и не используйте `kubectl get secret -o yaml` в логах CI/CD.
## Порядок ротации
1. Зафиксировать владельца, область и версию каждого скомпрометированного значения в закрытой системе управления инцидентами.
2. Создать новые значения средствами secret manager. JWT-ключ должен быть криптографически случайным и содержать не менее 32 байт.
3. Сначала сменить пароли в каждой tenant-БД, затем атомарно обновить соответствующую версию `tenants-secret`.
4. Обновить OTel credentials и `otel-postgres-secret`, после чего перезапустить только collector и проверить поступление метрик.
5. Обновить `JWT_SECRET` в `app-secret` и выполнить контролируемый rollout backend. Смена ключа немедленно аннулирует ранее выданные access JWT.
6. Если политика инцидента требует завершить все пользовательские сессии, очистить `auth_refresh_tokens` отдельно в каждой tenant-БД через утверждённую DBA-процедуру. Эта операция не должна затрагивать пользователей или демонстрационные данные.
7. Проверить readiness, вход, refresh, logout и доступ каждого tenant. Только после этого отозвать предыдущие версии DB/OTel credentials в secret manager.
8. Хранить предыдущую версию секрета только в защищённом менеджере на период согласованного rollback window; не создавать резервные YAML-файлы.
Команда `../k8s/deploy.sh` проверяет наличие объектов и обязательных ключей, но намеренно не читает и не выводит их значения.
## Очистка истории
Переписывание истории и force-push выполняются только после отдельного согласования со всеми владельцами клонов и CI/CD:
1. Создать закрытый mirror-клон и резервную копию refs.
2. Подготовить вне репозитория файл замен для `git filter-repo`; не помещать исходные или новые значения в аргументы shell, issue или CI-логи.
3. Запустить в mirror-клоне:
```bash
git filter-repo --replace-text /secure/path/replacements.txt --force
```
4. Выполнить secret scan переписанной истории и проверить теги/ветви.
5. В согласованное окно выполнить force-push, инвалидировать старые CI caches/artifacts и потребовать повторное клонирование.
6. Отдельно проверить историю репозитория, которому принадлежат файлы `../k8s/`: эта директория не входит в Git-корень Magistr.
Переписывание истории не заменяет ротацию: опубликованные значения считаются скомпрометированными даже после удаления из Git.
## Проверка после rollout
```bash
bash ../k8s/deploy.sh status
kubectl rollout status deployment/backend -n magistr --timeout=300s
kubectl rollout status deployment/otel-collector-db -n magistr --timeout=180s
```
Дополнительно оператор должен проверить audit-события secret manager, шифрование etcd, минимальные RBAC-права и отсутствие значений секретов в логах, событиях pod и артефактах CI.