ADR 0032 — Отказ от Keycloak: собственный эмитент, service token и сессия в cookie
Контекст
ADR 0031 построил
first-party auth параллельно Keycloak: argon2id, access token EdDSA,
ротация refresh с обнаружением повторного использования, блокировка, токены
учётной записи. Всё было готово, протестировано и не имело ни одного
потребителя — глобальный JwtAuthGuard продолжал проверять токен Keycloak, а
маршруты /auth/* существовали, но ими никто не пользовался.
Этот ADR фиксирует переход: замену эмитента, замену client-credentials во внутреннем трафике, собственный вход в web и удаление Keycloak из compose, манифестов, скриптов и документации.
Три открытия в ходе исследования переопределили дизайн, и их стоит зафиксировать, потому что каждое делало недействительным разумный план:
- Ни одно решение RBAC не читает claim токена.
RolesGuard,ResolveEffectiveRoleUseCaseиdecide()зависят отrequest.user.idи строк в базе;realm_accessиresource_accessникогда не использовались. Матрица прав структурно невосприимчива к смене эмитента — что превратило критерий «идентичная матрица» из аудиторской работы в дешёвую проверку. request.clientIdбыл единственным claim, доживавшим до авторизации. Он приходил изazpKeycloak и управлял двумя вещами:EngineServiceGuard26 маршрутов/internal/*и освобождением engine от rate limit. У first-party токена нетazp, и без обработки этого переход закрыл бы все 26 маршрутов разом.- Повторное использование
SyncUserUseCaseдало бы 500, а не 401.subнового токена — это самusers.id, ноupsertFromKeycloakделает конфликт поkeycloak_sub— вставка нарушила быusers_email_lower_idx, а throw происходит ВНЕtry/catch, который оборачивает только проверку токена.
Решение
Переход атомарный: периода сосуществования нет
В одном релизе эмитент меняется, а Keycloak уходит. Все разлогиниваются; у кого уже была учётная запись, впервые задают пароль.
Альтернатива — guard, принимающий оба эмитента несколько недель, — стоила бы двух путей сессии в web, двух сетевых конфигураций и двух наборов тестов, и всё это должно было бы работать каждый день, пока кто-то не решит закончить. Цена перехода — объявленный коллективный logout, один раз; цена сосуществования — бессрочный долг. Идёт как breaking change в CHANGELOG.
Guard читает, а не синхронизирует
FirstPartyTokenVerifier делегирует Ed25519AccessTokenIssuer и возвращает
{ sub: userId, email }. JwtAuthGuard переходит на UserRepository.findById,
с 401, когда пользователь не найден, — валидный токен, чей sub исчез,
это осиротевшая сессия (учётная запись удалена в пределах 15-минутного окна), и
401 отправляет клиента на вход, а не превращается в инфраструктурный алерт.
SyncUserUseCase, upsertFromKeycloak и KeycloakTokenVerifier удалены.
Исчезает одна запись в базу на каждый аутентифицированный запрос.
users.keycloak_sub остаётся в этой поставке. Это единственное оставшееся
свидетельство происхождения, и именно его использует скрипт миграции, чтобы
отличить «перенесённую учётную запись, ждущую пароль» от «заброшенной записи».
Миграция, которая её удаляет, придёт после того, как переход устоится.
/internal/* уходит из JWT
Новый декоратор @ServiceRoute(), учитываемый JwtAuthGuard (не требует
Bearer) и RateLimitGuard (освобождает). Освобождение должно идти от
метаданных, а не от guard: RateLimitGuard — это APP_GUARD и
выполняется раньше любого guard контроллера, поэтому, когда он принимает
решение, EngineServiceGuard ещё не выполнялся.
EngineServiceGuard сохранил имя класса и сменил тело: проверяет
X-Brabo-Service-Token за постоянное время. Сохранение имени — не
привязанность: именно это позволяет route-surface.spec.ts по-прежнему
классифицировать 26 маршрутов как engine-service и избегает 26 строк
изменений в docs/security-surface.md, которые спрятали бы посреди diff любое
реальное изменение экспозиции.
request.clientId удалён из AuthenticatedRequest. Без azp и без
потребителя поле идентичности, которое никогда ничего не значит, — это
приглашение кому-нибудь снова вставить его в проверку авторизации.
Один секрет, BRABO_SERVICE_TOKEN, в обе стороны. Два отдельных секрета
ограничили бы ущерб от утечки одним направлением — но обе стороны работают в
одном кластере, разворачиваются вместе и читают один и тот же Secret: кто
читает один, читает и другой. Второй создавал бы видимость разделения, ничего
не разделяя, и удваивал бы то, что нужно ротировать синхронно.
BRABO_SERVICE_TOKEN_PREVIOUS принимается только при проверке, так что обе
стороны можно обновлять в любом порядке.
Собственный заголовок вместо Authorization: Bearer, потому что в этом
заголовке «JWT пользователя» — устоявшееся значение во всём остальном api, и
неоднозначность привела бы к тому, что кто-то отправил бы сервисный токен на
пользовательский маршрут.
Со стороны Elixir: VerifyServiceToken заменяет VerifyApiToken, сохраняя
контракт 401 + JSON + halt() (от этого зависят три утверждения
route_surface_test.exs), восемь сборок заголовка превращаются в одну, и
уходят joken, joken_jwks, jose и tesla.
Сессия web живёт в cookie httpOnly
POST /auth/login перестаёт возвращать refreshToken в теле. Refresh
передаётся в cookie httpOnly, SameSite=Strict, Path=/auth, Secure в
продакшене; access token по-прежнему хранится в памяти JS и в
Authorization: Bearer.
Возвращать refresh ещё и в теле означало бы свести на нет всю защиту:
достаточно было бы XSS прочитать ответ входа. Именно это и произошло бы с
очевидной альтернативой — localStorage, — с тем отягчающим обстоятельством,
что XSS унёс бы длинную сессию (30 дней семейства), а не 15 минут access.
Держать access token ВНЕ cookie — это то, что избавляет от необходимости
требовать CSRF на каждом аутентифицированном маршруте: он нужен только для
/auth/*.
CSRF через double-submit, даже с SameSite=Strict. Атрибут сам по себе
уже не даёт браузеру прикрепить cookie к запросу, исходящему с другого сайта,
что закрывает CSRF на этих маршрутах. Второй слой платит за три вещи, которые
он не покрывает: браузер, игнорирующий атрибут, скомпрометированный поддомен
(который для cookie является «same site»), и день, когда кому-то понадобится
ослабить до Lax из-за redirect-потока. Пара — это читаемая из JS cookie
(brabo_csrf), повторяемая в X-CSRF-Token: кто находится на другом origin,
не может ПРОЧИТАТЬ cookie и поэтому не может собрать заголовок.
Отказ CSRF — это 403, а не 401: 401 говорил бы «ваши учётные данные не годятся», и клиент пытался бы обновить сессию, уходя в цикл.
Refresh в single-flight — требование, а не оптимизация
ADR 0031 уже это зафиксировал, а здесь это реализовано: одно обещание в полёте, общее для всех вызывающих.
Без этого система разлогинивает пользователя при обычном использовании. Два вызова, одновременно получившие 401, запустили бы два refresh; второй предъявил бы токен, который первый уже израсходовал, — что на стороне сервера является ТОЧНОЙ сигнатурой кражи. Семейство отзывается, и пользователь возвращается на вход за то, что открыл два параллельных запроса.
Перенесённого пользователя невозможно отличить
Вход учётной записи, импортированной из Keycloak (есть в users, нет строки в
auth_credentials), отвечает единообразным 401, идентичным ответу на
несуществующий e-mail, и молча отправляет ссылку «задать пароль».
Ответ с явным password_pending был бы очевидным UX и отвергнут: он
подтвердил бы, что адрес существует, и что это унаследованная учётная
запись — самый ценный сигнал перебора в системе, и ровно то, что закрывает
RN-032.
Чтобы стоимость была одинаковой во всех трёх исходах, findByEmail стал
LEFT JOIN от users к auth_credentials: один запрос. Два последовательных
запроса заставили бы ветку ожидания платить лишнее обращение к базе, и по
времени можно было бы отличить перенесённую учётную запись от несуществующего
e-mail.
«Ожидание» — это производное состояние, а не колонка: нет
password_pending, который мог бы рассинхронизироваться, и идемпотентность
скрипта миграции достаётся бесплатно.
Миграция не подключается к Keycloak
Потому что импортировать нечего. JwtAuthGuard делал upsert каждого
пользователя в users при каждом запросе начиная с Фазы 1 — id, e-mail и
связи RBAC всегда были в базе api. Keycloak никогда не был источником истины
для RBAC; он был эмитентом токена. Этим учётным записям не хватает пароля,
которого он тоже не дал бы (хеш пароля не переносится, решение CLAUDE.md).
Последствия
Все разлогиниваются при релизе. Это объявленная цена атомарного перехода, и она указана в CHANGELOG как несовместимое изменение.
Ссылки «задать пароль» попадают в лог api, а не во входящие. MailSender
остаётся log-only, а реальный SMTP по-прежнему — будущая настройка. Для
установки с единственным владельцем этого достаточно; runbook объясняет, как
извлечь их с AUTH_MAIL_LOG_TOKENS=true. Это самое заметное ограничение этой
поставки.
Ротация AUTH_TOKEN_PEPPER или BRABO_SERVICE_TOKEN имеет противоположные
и одинаково резкие эффекты. Первая разлогинивает всех и делает
недействительными открытые ссылки; вторая, если сделана только с одной
стороны, обрывает внутренний трафик. Обе описаны в runbook, с _PREVIOUS,
задокументированным как способ сделать это без простоя.
Web не может сказать «этот e-mail уже используется». Последствие, унаследованное от ADR 0031, теперь с экраном: форма говорит «если адрес доступен».
Smoke-тест зависит от подготовленного пользователя. Без внешнего IdP
готовых учётных данных больше нет, а регистрация через API упирается в
подтверждение e-mail. bootstrap.sh запускает seed, который создаёт учётную
запись с известным паролем и уже подтверждённым e-mail, — и
provisionarUsuario отказывается работать с NODE_ENV=production без
явного переопределения. Это инструмент разработки, и в коде он помечен как
таковой.
users.keycloak_sub остаётся в схеме без какого-либо эмитента за ним. Это
осознанный долг, с целью (§ решение) и со сроком: миграция, которая её
удаляет, войдёт после того, как переход устоится.
Публичная поверхность не изменилась. По-прежнему 12 маршрутов, и 26
engine-service по-прежнему 26 — route-surface.spec.ts закрылся с пустым
diff, что было ожидаемым результатом и доказательством того, что контракт
контроллеров не тронут.
Что было проверено и что нет
Матрица RBAC доказывается тремя замками: decide.spec.ts (24 случая) и
resolve-effective-role.use-case.spec.ts остались неизменными и
зелёными; route-surface.spec.ts закрылся с пустым diff; и две новые
спецификации — roles.guard.spec.ts, с матрицей 4×4, расписанной полностью, и
jwt-auth.guard.spec.ts, утверждающая, что в users не появляется ни одной
новой строки, — покрывают переход идентичность → user.id, у которого раньше
не было ни одного теста и который является единственной точкой, затронутой
переходом.
Набор тестов engine в этой поставке не запускался. Использованная среда
разработки не достаёт до hex.pm (блокировка сетевой политикой), а без
mix deps.get нет ни mix compile, ни mix test. Код Elixir написан и
проверен синтаксическим анализом каждого изменённого файла; реальный запуск
происходит в CI, где есть сеть. Это зафиксировано здесь, а не опущено, потому
что это ровно тот тип пробела, который ADR 0020 велел никогда не
диагностировать методом исключения.
Осознанный backlog (подтверждён)
Вне рамок остаются, теперь уже после перехода: MFA (TOTP, WebAuthn),
социальный вход, федерация OIDC и api как OIDC-провайдер. К ним
добавляются: реальный SMTP, словарь утёкших паролей, оппортунистический
перехеш argon2, очистка таблиц auth и миграция, удаляющая users.keycloak_sub.
Claim версии учётных данных — который сделал бы access token отзываемым —
остаётся отмеченным с новой деталью: JwtAuthGuard в этом переходе
перестал делать запись на каждый запрос, поэтому довод «он всё равно идёт
в базу», который сделал бы её дешёвой, больше не действует. Если она вернётся
на стол, то вернётся по собственным достоинствам.