# 🏗 Архитектура системы ## Общая схема ```mermaid graph TD Client["🌐 Браузер"] -->|HTTPS| Caddy["Caddy Proxy"] Caddy -->|:80| Frontend["Frontend
(Apache httpd + строгий CSP)"] Caddy -->|/api/*| Backend["Backend
(Spring Boot 3.2.5)"] Backend --> TenantRouter{"TenantRoutingDataSource"} TenantRouter -->|swsu.zuev.company| DB1["PostgreSQL
swsu_db"] TenantRouter -->|mgu.zuev.company| DB2["PostgreSQL
mgu_db"] TenantRouter -->|...| DBn["PostgreSQL
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
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 ` и устанавливается `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`.