419 lines
37 KiB
Markdown
419 lines
37 KiB
Markdown
# 🏗 Архитектура системы
|
||
|
||
## Общая схема
|
||
|
||
```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 не создаёт
|
||
устаревшее назначение.
|
||
|
||
`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 снимки вместо запросов из вложенных циклов.
|
||
`ScheduleQueryService` передаёт набор групп одним вызовом `buildScheduleForGroups()`.
|
||
`AcademicDateService` одним запросом загружает пересекающиеся семестры, затем batch-набор
|
||
назначений календарей и дневную сетку от начала затронутого семестра до конца диапазона.
|
||
`ScheduleGeneratorService` отдельно batch-загружает правила и сетки звонков и строит lookup
|
||
по датам, группам, учебным годам, календарям и номерам пар. Снимки живут только во время
|
||
одного построения: изменения следующего запроса видны сразу, а singleton-кэш отсутствует.
|
||
|
||
Teacher-only чтение строит базовое расписание напрямую через
|
||
`ScheduleGeneratorService.buildScheduleForTeacher()`. `ScheduleQueryService` дополнительно
|
||
находит overrides с `newTeacher`, группирует их по дате, один раз строит базовый день каждой
|
||
релевантной даты и добавляет только целевые `baseRuleSlotId`. Все overrides применяются до
|
||
финального teacher-фильтра и дедупликации; без релевантных замен полный обход групп не
|
||
выполняется.
|
||
|
||
---
|
||
|
||
## Аутентификация
|
||
|
||
Система использует 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`.
|