# 🏗 Архитектура системы

## Общая схема

```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 и запускает экспоненциальные повторы через 30–300 секунд; другая неудачная
файловая ревизия получает собственную попытку без ожидания 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`.
