Files
magistr/docs/ARCHITECTURE.md
2026-07-19 20:16:12 +03:00

444 lines
40 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 🏗 Архитектура системы
## Общая схема
```mermaid
graph TD
Client["🌐 Браузер"] -->|HTTPS| Caddy["Caddy Proxy"]
Caddy -->|:80| Frontend["Frontend<br/>(Apache httpd + строгий CSP)"]
Caddy -->|/api/*| Backend["Backend<br/>(Spring Boot 3.2.5)"]
Backend --> TenantRouter{"TenantRoutingDataSource"}
TenantRouter -->|swsu.zuev.company| DB1["PostgreSQL<br/>swsu_db"]
TenantRouter -->|mgu.zuev.company| DB2["PostgreSQL<br/>mgu_db"]
TenantRouter -->|...| DBn["PostgreSQL<br/>tenant_n_db"]
Backend -->|Метрики, Логи, Трейсы| OTel["OpenTelemetry Collector"]
OTel --> SigNoz["SigNoz"]
```
## Компоненты
### Frontend (Apache httpd)
- **Тип:** Статические файлы (HTML/CSS/JS)
- **Контейнер:** multi-stage Node/esbuild → Apache HTTP Server на Alpine
- **Порт:** 80
- **Содержание:** Три изолированных интерфейса — `admin/`, `teacher/`, `student/`
- **JS-модули:** Vanilla JavaScript с ES6 Modules (`import`/`export`)
- **Browser security:** same-origin runtime-ресурсы и CSP без `unsafe-inline`/`unsafe-eval`
### Backend (Spring Boot 3.2.5)
- **Тип:** REST API сервер
- **Язык:** Java 17
- **Порт:** 8080 (внутренний)
- **ORM:** Hibernate (JPA), `ddl-auto=none`
- **Миграции:** Flyway (программный запуск при подключении тенанта)
- **Аутентификация:** bcrypt (через `BCryptPasswordEncoder`), access JWT, ротируемые refresh-токены и backend-проверка ролей
### PostgreSQL
- **Версия:** `postgres:alpine3.23`
- **Локально:** Одна БД `app_db` (тенант `default`)
- **Продакшн:** Множество БД, по одной на каждый университет (тенант)
### Caddy (реверс-прокси)
- **Расположение:** `../caddy-proxy/`
- **Назначение:** TLS-терминация, маршрутизация запросов к backend/frontend
- **Домен:** `*.zuev.company`
---
## Мультитенантная архитектура
Ключевая особенность системы — изоляция данных каждого университета в отдельной БД PostgreSQL.
### Принцип работы
```mermaid
sequenceDiagram
participant Browser as Браузер
participant Interceptor as TenantInterceptor
participant Context as TenantContext
participant Router as TenantRoutingDataSource
participant DB as PostgreSQL
Browser->>Interceptor: GET /api/users<br/>Host: swsu.zuev.company
Interceptor->>Interceptor: resolveTenant("swsu.zuev.company") → "swsu"
Interceptor->>Context: setCurrentTenant("swsu")
Note over Interceptor: Проверка: hasTenant("swsu")?
Interceptor-->>Browser: 404 если тенант не найден
Note over Context,Router: Обработка запроса контроллером
Router->>Router: determineCurrentLookupKey() → "swsu"
Router->>DB: SQL запрос к swsu_db
DB-->>Browser: Ответ с данными
```
### Ключевые классы
| Класс | Назначение |
|-------|-----------|
| `TenantInterceptor` | Извлекает поддомен из заголовка `Host` и определяет тенант |
| `TenantContext` | `ThreadLocal`-хранилище имени текущего тенанта |
| `TenantRoutingDataSource` | Маршрутизирует запросы и атомарно публикует неизменяемый снимок `TenantConfig + DataSource` |
| `TenantDataSourceConfig` | Загружает конфигурацию тенантов из JSON-файла, создаёт HikariCP пулы |
| `TenantWebMvcConfig` | Регистрирует `TenantInterceptor` и `AuthorizationInterceptor` в MVC-слое |
| `TenantConfigWatcher` | Периодически перечитывает `tenants.json`, сравнивает полный нормализованный снимок и повторяет неудачную синхронизацию с ограниченным backoff |
| `TenantConfigStore` | Задаёт явные атомарные операции upsert/remove и условную компенсацию persisted-конфигурации |
| `KubernetesTenantSecretUpdater` | Реализует `TenantConfigStore` через GET и условный PUT Kubernetes Secret по `resourceVersion`, проверяя TLS по service-account CA |
| `TenantLifecycleService` | Сериализует prepare/validate/migrate/persist/swap/remove и выполняет компенсацию при отказе |
| `TenantDatabaseMigrationService` | Запускает Flyway для подготовленного, ещё не опубликованного pool |
| `RetiredTenantPoolService` | После swap дожидается завершения активных подключений старого pool и закрывает его |
| `TenantReadinessRegistry` | Хранит атомарный снимок обязательных tenant, миграций и свежести проверок соединения |
| `TenantDatabaseHealthMonitor` | Ограниченно-параллельно проверяет соединения с обязательными tenant-БД в фоне |
| `TenantReadinessHealthIndicator` | Включает агрегированную готовность tenant-БД в Actuator readiness group |
| `TenantConfig` | POJO с параметрами тенанта: `name`, `domain`, `url`, `username`, `password` |
### Определение тенанта
Логика определения тенанта по заголовку `Host`:
| Host | Результат |
|------|----------|
| `swsu.zuev.company` | `swsu` |
| `mgu.zuev.company` | `mgu` |
| `localhost` | `default` |
| `localhost:8080` | `default` |
| `192.168.1.1` | `default` |
### Конфигурация тенантов
Список тенантов хранится в JSON-файле:
- **Локально:** `backend/tenants.json` (не коммитится; шаблон — `backend/tenants.example.json`)
- **Продакшн:** внешний Kubernetes Secret `tenants-secret`, ключ `tenants.json` монтируется в `/config/tenants.json`
`tenants-secret` не создаётся отслеживаемыми production-манифестами. Его подготавливает
оператор через secret manager по [`SECURITY_RUNBOOK.md`](SECURITY_RUNBOOK.md).
Формат:
```json
[
{
"name": "ЮЗГУ",
"domain": "swsu",
"url": "jdbc:postgresql://db-host:5432/swsu_db",
"username": "dbuser",
"password": "dbpass"
}
]
```
### Жизненный цикл тенанта
1. **Добавление или обновление через API:** перед мутацией `TenantConfigWatcher` разбирает,
нормализует и при необходимости полностью применяет текущую mounted-проекцию как baseline.
Ошибка подготовки baseline прерывает API-операцию. Затем `TenantLifecycleService` создаёт
непубликуемый HikariCP candidate, проверяет реальное соединение, выполняет Flyway, а
`TenantConfigStore` читает актуальный Secret и применяет к нему только upsert домена.
2. **Межподовая запись:** `KubernetesTenantSecretUpdater` выполняет `GET` текущего
`tenants-secret`, нормализует и сортирует список, после чего отправляет полный объект
условным `PUT` с прочитанным `metadata.resourceVersion`. При `409 Conflict` актуальное
состояние читается заново, а собственная мутация повторно применяется к нему. Число
попыток ограничено тремя, задержка между попытками возрастает; идемпотентная операция
не отправляет лишний `PUT`.
3. **Атомарная локальная публикация:** только после успешной или подтверждённой повторным
чтением персистенции одной публикацией заменяется связка `TenantConfig + DataSource`.
Успешный lifecycle возвращает внутренний `TenantLifecycleMutationResult` с конфигурацией
и `TenantSecretUpdateReceipt`, содержащей признаки `persisted`/`changed`, а также
семантические снимки `previousTenants` и `committedTenants`.
4. **Безопасный отказ и компенсация:** ошибка credentials, соединения, Flyway или Secret
закрывает candidate, не меняя действующий route. Компенсация прежним снимком разрешена
только для подтверждённой записи и только пока текущий Secret сохраняет выданный ей
`resourceVersion`. Если другой pod уже записал более новую версию, автоматический откат
пропускается. Неопределённый сетевой результат сначала сверяется повторным `GET` и не
создаёт квитанцию для небезопасной компенсации. HTTP-граница получает русское безопасное
сообщение без JDBC/Flyway details.
5. **Drain старого pool:** после успешного swap новые запросы сразу используют candidate,
а прежний Hikari pool закрывается после завершения активных подключений либо по истечении
настраиваемого grace timeout.
6. **Синхронизация подов:** `TenantConfigWatcher` каждые 30 секунд проверяет смонтированный
`tenants.json` и сравнивает все поля нормализованного `TenantConfig`: `name`, `domain`,
`url`, `username` и `password`. Для нового или изменённого тенанта lifecycle без повторной
записи Secret выполняет prepare → проверку соединения → Flyway → атомарный swap; отсутствующие
домены удаляются под тем же локальным monitor.
7. **Удаление:** `DELETE /api/database/tenants/{domain}` применяет к актуальному Secret только
удаление указанного домена через тот же условный `PUT`, затем атомарно исключает tenant
из маршрутизации и передаёт pool на drain. Для отказа локального удаления действуют те же
ограничения компенсации по `resourceVersion`.
Пользовательские ошибки tenant-контура и production-сообщения журнала формулируются на
русском языке. JDBC/Flyway-текст не возвращается в DOM или HTTP-ответ; на уровнях
`WARN`/`ERROR` фиксируется безопасный тип исключения, а стек доступен только в русскоязычной
записи уровня `DEBUG`.
Сериализация lifecycle и атомарный снимок защищают запросы внутри одного backend-процесса.
Между pod потерянное обновление предотвращает optimistic locking Kubernetes: каждый конфликт
заставляет заново прочитать Secret и повторно применить только свою доменную мутацию. Локальный
monitor по-прежнему не считается межподовой блокировкой.
Вне Kubernetes `KubernetesTenantSecretUpdater` работает как runtime-only адаптер: Secret и
локальный `tenants.json` не изменяются. Постоянная локальная конфигурация задаётся файлом до
запуска приложения.
Для обращения к Kubernetes API backend загружает
`/var/run/secrets/kubernetes.io/serviceaccount/ca.crt`, использует стандартную PKIX-проверку
цепочки и обязательную проверку hostname. Trust-all fallback отсутствует: ошибка CA или TLS
завершает персистенцию безопасным отказом.
`TenantConfigWatcher` использует SHA-256 содержимого `tenants.json` как идентификатор файловой
ревизии, но решения о запаздывающих проекциях принимает по нормализованным семантическим
снимкам `name`/`domain`/`url`/`username`/`password`. Хеш становится последним применённым
только после успешного разбора и полного lifecycle sync. Ошибка сохраняет прежний хеш,
снимает readiness и запускает экспоненциальные повторы через 30300 секунд; другая неудачная
файловая ревизия получает собственную попытку без ожидания backoff предыдущей.
После API-операции snapshot fence устанавливается только если `TenantSecretUpdateReceipt`
подтверждает реальное общее изменение: `persisted=true` и `changed=true`. Снимок до записи
и ранее ожидавшиеся committed-снимки временно считаются deferred, а новый committed-снимок —
ожидаемым. Поэтому при быстрых мутациях H0 → H1 → H2 запаздывающие H0 и H1 не откатывают
runtime, а H2 полностью синхронизируется и снимает fence. Неизвестный объединённый снимок,
которого нет среди deferred-состояний, также немедленно проходит полный sync и не теряет
изменение другого pod. Локальная персистенция (`persisted=false`) и подтверждённый no-op
(`changed=false`) fence не создают.
Если ошибка readiness произошла уже после успешного swap, следующий watcher retry видит
совпадающую активную конфигурацию, проверяет действующее соединение и восстанавливает
readiness без повторной миграции и второго swap.
### Liveness и readiness
Spring Boot Actuator публикует две независимые служебные группы:
- `GET /actuator/health/liveness` включает только `livenessState` приложения. Отказ любой
tenant-БД не переводит процесс в liveness failure и не создаёт restart storm;
- `GET /actuator/health/readiness` включает `readinessState` и `tenantReadiness`. Ответ имеет
статус `UP` только если задан непустой набор обязательных tenant, миграция каждого успешно
завершена и для каждого есть свежая успешная проверка соединения.
`TenantDataSourceConfig` регистрирует обязательный набор до startup-активации и сохраняет
неуспешную миграцию как readiness failure. H2-заглушка позволяет инициализировать Spring JPA,
но никогда не делает приложение готовым к production-трафику. Ошибка чтения обязательного
`tenants.json` также фиксируется отдельно от состояния процесса.
`TenantDatabaseHealthMonitor` по умолчанию запускается через секунду после старта, затем
проверяет соединения каждые 10 секунд пакетами до четырёх параллельных задач. Таймаут пакета
составляет 6 секунд: по его истечении отменяются только незавершённые задачи, а уже полученные
результаты сохраняются с фактическим временем завершения проверки. Результат старше 30 секунд
считается просроченным. Запоздалый результат привязан к HMAC-fingerprint конкретной конфигурации
и не может отметить новую конфигурацию готовой; исходные credentials в fingerprint и
health-ответ не попадают. Планировщик использует два потока, поэтому ожидающий health-pass не
задерживает периодическое чтение tenant Secret.
Tenant interceptor исключает `/actuator/**`, а authorization interceptor применяется только
к `/api/**`. При этом наружу опубликован только endpoint `health`, его details и components
скрыты: kubelet получает минимальный `UP`/`DOWN` без доменов и реквизитов tenant-БД.
### Fallback при отсутствии тенантов
Если при запуске нет ни одного настроенного тенанта:
1. Проверяется наличие `spring.datasource.url` → создаётся тенант `default`
2. Если datasource тоже нет → создаётся H2 in-memory заглушка для инициализации Spring JPA
Для production обязательность смонтированной конфигурации задаётся
`TENANTS_CONFIG_REQUIRED=true`; отсутствие или ошибка чтения файла снимают readiness.
`TenantContext` хранит имя текущего тенанта в `ThreadLocal` и очищается интерцептором после завершения запроса. Виртуальные потоки в приложении не включены; если их включать в будущем, нужно отдельно проверить перенос и очистку tenant/auth context на асинхронных участках.
---
## Транзакционные изменения расписания
`GlobalExceptionHandler` является последней границей между ошибками persistence-слоя и
HTTP-клиентом. `DataIntegrityViolationException` сопоставляется с известными ограничениями
V1 и безопасными статусами `400`/`409`; неизвестные SQLState и constraints получают
обобщённый `409`. Constraint и SQLState доступны только в структурированном журнале, а
JDBC/SQL-текст и stack trace не включаются в пользовательский ответ. Контроллеры не должны
формировать HTTP-body из `Exception.getMessage()`.
`ScheduleRuleAdminController` является тонким HTTP-адаптером. Чтение, создание, изменение
и архивация правил выполняются через `ScheduleRuleService`; публичные write-методы сервиса
образуют транзакционные границы, поэтому ошибки валидации и конфликты выходят за Spring
proxy и приводят к rollback.
`AcademicCalendarAdminController` делегирует CRUD учебных годов и семестров
`AcademicPeriodService`. Сервис нормализует названия, проверяет включительные диапазоны,
дубли и пересечения до мутации. Semester create/update блокируют родительский учебный год,
update семестра затем перечитывает собственную строку с `PESSIMISTIC_WRITE`. Кэш расписания
очищается только после commit. Межподовые гонки годов и семестров окончательно закрывают
GiST exclusion constraints и триггеры PostgreSQL, связывающие границы семестра с годом.
`AcademicCalendarController` и `GroupController` делегируют изменение ключевых измерений и
сохранение назначений `AcademicStructureService`. Сервис блокирует изменяемую группу или
график, повторно проверяет назначения, курсы, сетку и дисциплины до мутации, а кэш очищает
только после commit. Триггеры V1 дублируют совместимость на уровне PostgreSQL и блокируют
ссылочные строки, поэтому гонка прямых записей или нескольких backend-pod не создаёт
устаревшее назначение.
Создание и обновление группы используют один validator положительных размерностей.
Уменьшение численности дополнительно сверяется с суммой активных подгрупп. CRUD подгрупп и
изменение группы захватывают строку родительской группы `FOR UPDATE`; V1 повторяет проверку
триггерами обеих таблиц. Поэтому конкурентно могут завершиться только совместимые
изменения, а сумма активных `student_capacity` никогда не превышает `group_size`.
`TeacherDepartmentService` является единым источником датированных решений о кафедре
преподавателя. Списки пользователей, кабинет кафедры, права на привязку дисциплин и отчёты
нагрузки читают `teacher_department_assignments` на целевую дату, не используют
`users.department_id` как fallback и учитывают дополнительные назначения. При построении
нагрузки назначения загружаются одним batch-запросом на весь диапазон, а кафедра разрешается
для даты каждого занятия, поэтому перевод внутри периода корректно разделяет агрегаты.
Перевод блокирует пользователя и историю его основных назначений. Будущий перевод закрывает
текущий период днём перед датой вступления в силу, но не меняет legacy-зеркало текущей
кафедры заранее. GiST exclusion constraint в V1 окончательно запрещает пересекающиеся
основные периоды при гонке нескольких backend-pod.
`WorkloadController` до обращения к `ScheduleQueryService` вычисляет разрешённый scope из
`AuthContext`. Для `DEPARTMENT` обязательна кафедра из подписанного JWT; отсутствие
параметра не расширяет выборку, а несовпадающий `departmentId` отклоняется с `403`. Это же
правило применяется к свободным аудиториям. Глобальный или явно выбранный scope остаётся у
`ADMIN`, `EDUCATION_OFFICE` и `SCHEDULE_VIEWER`.
После проверки scope контроллер вызывает специализированный
`ScheduleQueryService.searchForAggregation()`. Этот путь загружает все активные группы
scope, генерирует их расписание и применяет единый снимок overrides без интерактивного
лимита 50 групп. Публичный `search()` по-прежнему применяет лимит к широкому UI-запросу;
общие range validation, фильтрация и дедупликация находятся в одном внутреннем алгоритме.
Импорт дисциплин из `DepartmentWorkspaceController` делегирован транзакционному
`SubjectImportService`. Сервис сначала нормализует и дедуплицирует весь payload, затем до
мутации проверяет глобального владельца названия. Собственная запись обновляется на месте и
при необходимости восстанавливается; чужая приводит к `409`. Case-insensitive уникальный
индекс V1 окончательно закрывает конкурентное создание одинакового названия.
Создание правила до остальных запросов к БД захватывает `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 или проверке устаревшего снимка.
Генерация диапазона использует request-scoped снимки вместо запросов из вложенных циклов.
Обе границы диапазона включительны, поэтому общий лимит 120 дат вычисляется как
`endDate - startDate + 1` одинаково в query- и generator-слоях.
`ScheduleQueryService` передаёт набор групп одним вызовом `buildScheduleForGroups()`.
`AcademicDateService` одним запросом загружает пересекающиеся семестры, затем batch-набор
назначений календарей и дневную сетку от начала затронутого семестра до конца диапазона.
`ScheduleGeneratorService` отдельно batch-загружает правила и сетки звонков и строит lookup
по датам, группам, учебным годам, календарям и номерам пар. Снимки живут только во время
одного построения: изменения следующего запроса видны сразу, а singleton-кэш отсутствует.
Teacher-only чтение строит базовое расписание напрямую через
`ScheduleGeneratorService.buildScheduleForTeacher()`. `ScheduleQueryService` дополнительно
находит overrides с `newTeacher`, группирует их по дате, один раз строит базовый день каждой
релевантной даты и добавляет только целевые `baseRuleSlotId`. Все overrides применяются до
финального teacher-фильтра и дедупликации; без релевантных замен полный обход групп не
выполняется.
---
## Временная модель
`BusinessTimeService` является единым источником текущей календарной даты и абсолютного
момента. Бизнес-дата вычисляется из инъецируемого `Clock` в зоне
`BUSINESS_TIME_ZONE` (по умолчанию `Europe/Moscow`), поэтому lifecycle, доступность
расписания, назначения кафедр и создание периодов не зависят от timezone JVM или хоста.
Абсолютные моменты представлены `Instant`, хранятся в PostgreSQL как `TIMESTAMPTZ` и
сериализуются в UTC. Hibernate принудительно использует `hibernate.jdbc.time_zone=UTC`,
Jackson — UTC. Отдельные календарные даты представлены `LocalDate`/SQL `DATE`; для них
смещение и время суток не передаются.
В production-конструкторах используется системный UTC clock. Тесты передают фиксированный
`Clock`, включая границу московской полуночи, поэтому переход бизнес-даты воспроизводим.
---
## Аутентификация
Система использует access JWT и отзывные refresh-токены без включения полноценного Spring Security flow:
1. Клиент отправляет `POST /api/auth/login` с `username` и `password`.
2. Backend проверяет пароль через `BCryptPasswordEncoder`.
3. При успехе возвращается access JWT для заголовка `Authorization: Bearer <token>` и устанавливается `HttpOnly` refresh-cookie.
4. Access JWT и профиль клиента хранятся только в памяти страницы; refresh-токен хранится
только в cookie, а в БД сохраняется SHA-256 хэш.
5. При истечении access JWT клиент вызывает `POST /api/auth/refresh`; refresh-токен ротируется, старый хэш отзывается.
6. `POST /api/auth/logout` отзывает текущий refresh-токен и очищает cookie.
`LoginRateLimitService` выполняет проверку пароля и изменение счётчика в одной транзакции.
Строка `auth_login_rate_limits` с ключом tenant + нормализованный username + IP блокируется
через `PESSIMISTIC_WRITE`, поэтому параллельные попытки на разных backend-pod сериализуются
общей tenant-БД. После порога применяется прогрессивная временная блокировка, API отвечает
`429` и передаёт `Retry-After`. Неизвестная, архивная и ошибочная учётная запись проходят
одинаковый bcrypt-путь и получают одинаковый `401`, что не позволяет определить наличие
пользователя по ответу. Отказы сохраняются в `auth_login_attempt_audit` и пишутся в журнал
только с коротким fingerprint имени; пароль не сохраняется и не логируется.
`ClientIpResolver` принимает `X-Forwarded-For` только если непосредственный источник входит
в `TRUSTED_PROXY_CIDRS`. Цепочка разбирается справа налево до первого недоверенного адреса;
заголовок от прямого клиента или некорректная цепочка игнорируются. Для Compose доверена
только внутренняя Docker-сеть, а production ConfigMap задаёт pod-сеть Traefik. При изменении
Docker/K3s CIDR это значение необходимо синхронно заменить фактической сетью proxy.
`LoginAttemptAuditCleanupJob` раз в сутки удаляет старше 90 дней audit-записи и неактивные
счётчики ограниченными пачками отдельно в каждой tenant-БД. `FOR UPDATE SKIP LOCKED`
позволяет нескольким pod безопасно выполнять очистку одновременно; активная блокировка
никогда не удаляется.
Ротация refresh-токена имеет single-use семантику. `RefreshTokenService.rotate()` выполняется
в транзакции, а `AuthRefreshTokenRepository` захватывает исходную строку через
`PESSIMISTIC_WRITE`. Поэтому два одновременных запроса с одним cookie сериализуются:
только первый создаёт следующий refresh-токен, второй видит уже отозванную строку и
завершается без выпуска новой сессии.
`RefreshTokenCleanupJob` раз в час берёт атомарный снимок активных тенантов из
`TenantRoutingDataSource`, устанавливает `TenantContext` до открытия транзакции и очищает
каждую tenant-БД ограниченными пачками. По умолчанию истёкшие и отозванные строки хранятся
30 дней для аудита; активные и более свежие строки не удаляются. PostgreSQL
`FOR UPDATE SKIP LOCKED` позволяет нескольким backend-pod разбирать непересекающиеся пачки,
а повторный проход идемпотентен. Внутри одного pod наложение запусков запрещено локальным
guard, а число пачек одного прохода ограничено.
Access JWT содержит claim'ы `tenant`, `userId`, `username`, `role`, `departmentId`, `iat`,
`exp`, `jti`. `AuthorizationInterceptor` проверяет подпись, срок действия и совпадение
`tenant` с `TenantContext`, затем применяет `@RequireRoles`. После перехода или reload
frontend восстанавливает access JWT и профиль в памяти через refresh-cookie; Web Storage
для данных авторизации не используется. Backend возвращает `401`, если токен отсутствует
или некорректен, и `403`, если роли недостаточно.
`AuthContext` существует только в границах одного servlet-запроса. Интерцептор очищает
`ThreadLocal` до любых ранних выходов, устанавливает пользователя только после успешной
проверки роли и повторно очищает контекст в `afterCompletion`. Поэтому отказ с `401`/`403`
или публичный endpoint не может получить пользователя от предыдущего запроса того же
потока контейнера.
Frontend-матрица вкладок централизована в `admin/js/role-capabilities.js` и используется
основным admin SPA и settings SPA. Для `EDUCATION_OFFICE` backend разрешает read-only GET
специальностей/профилей и GET/POST/DELETE форм обучения; write-операции специальностей
наследуют class-level `ADMIN`. MockMvc role-тест проходит через реальный
`AuthorizationInterceptor`, поэтому видимые штатные экраны не зависят только от скрытия UI.
`TenantInterceptor` по-прежнему отвечает за выбор БД тенанта по домену. Проверка авторизации выполняется отдельным интерцептором после tenant-resolution, поэтому токен, выданный на одном домене, не принимается на другом tenant-домене.
JWT-секрет не имеет встроенного значения и обязателен во всех окружениях. При профиле
`prod` или `production` startup-проверка дополнительно отклоняет известные legacy/placeholder
значения и требует `JWT_REFRESH_COOKIE_SECURE=true`. Production deployment явно включает
профиль `production` и получает `JWT_SECRET` только через `secretKeyRef`.