исправление багов
This commit is contained in:
86
docs/API.md
86
docs/API.md
@@ -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}`
|
||||
|
||||
|
||||
@@ -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`.
|
||||
|
||||
@@ -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, поэтому два конкурентных изменения одной даты проверяются последовательно.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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`.
|
||||
V2–V4 накатываются на существующие tenant-БД без изменения контрольных сумм V1–V3.
|
||||
Перед добавлением ограничений 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
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 | Запросы динамического расписания и ошибки генерации |
|
||||
|
||||
|
||||
@@ -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
77
docs/SECURITY_RUNBOOK.md
Normal 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.
|
||||
Reference in New Issue
Block a user