Перейти к основному содержимому

ADR 0105 — Personal Access Token (brb_…) для runner, ограниченный по построению одним маршрутом

  • Статус: Принят
  • Дата: 2026-08-22
  • Контекст: backlog ADR 0104 («долгоживущий токен учётной записи (PAT), заменяющий повтор входа... ДО распространения»), RN-424/425/426
  • Спутник: ADR 0103

Контекст​

apps/runner/src/auth.ts вручную воспроизводил то, что браузер делает сам: интерактивные e-mail/пароль при первом запуске, cookies brabo_refresh/brabo_csrf, извлечённые и сохранённые в ~/.brabo/runner-credentials.json, обновление через /auth/refresh при каждой попытке переподключения. Сам ADR 0103 уже фиксировал это как «возможную форму с тем, что существует сегодня», помеченную в коде как модуль, подлежащий замене. npm publish @brabo/runner никогда не запускался — и публикация CLI в таком состоянии распространила бы поток пароль+cookie, сохранённые на диске, — блокировку, ради закрытия которой существует эта поставка.

Были рассмотрены и отвергнуты два существующих механизма:

  • account_tokens — построен для ОДНОРАЗОВЫХ ссылок в e-mail (подтверждение, сброс пароля, начальный пароль после миграции из Keycloak). У него нет понятия нескольких живых токенов на пользователя, ни области ресурса — его повторное использование потребовало бы расширить таблицу и сценарий использования до формата, который ему не свойственен.
  • Ротация семейства refresh token — refresh ПОТРЕБЛЯЕТСЯ и перевыпускается при каждом использовании, с отзывом семейства при обнаружении повторного использования (ADR 0031). PAT — противоположность: он предъявляется многократно, НЕ МЕНЯЯСЬ, по замыслу (именно это позволяет пользователю один раз вставить значение в --token и больше никогда его не трогать). Унаследовать модель ротации значило бы выдумать гарантию, которая PAT не нужна, и без выгоды усложнить то, что должно быть простым.

Решение​

Новая таблица, personal_access_tokens (миграция 0049): id, user_id, project_id (NOT NULL — единственный формат области, который нужен этой поставке), name, token_hash (уникальный индекс), опциональный expires_at, revoked_at/revoked_reason, last_used_at, created_at. Без столбца scope_kind. Enum с одним заполненным сегодня значением ничего не даёт — если в будущем появится формат области, не связанный с проектом, project_id NOT NULL всё равно не подошёл бы, и таблице понадобилась бы собственная миграция и там. Отложить enum до того дня, когда второй формат станет реальной запланированной работой, не раньше.

Хеш: HMAC-SHA256 + pepper через hashDeToken()/TokenFactory, а не argon2. Реальный шаблон продукта для секрета ВЫСОКОЙ энтропии (256 бит из CSPRNG) — уже этот, тот же механизм, что у refresh tokens и account tokens. Argon2 зарезервирован для секрета НИЗКОЙ энтропии (пароля), где важна устойчивость к словарной атаке; его использование здесь лишь сломало бы индексированный поиск WHERE token_hash = $1, ничего не выиграв, потому что нет словаря, которому нужно противостоять для 256-битного значения, сгенерированного CSPRNG. Сырой токен — brb_<32 bytes em base64url>; хеш вычисляется по строке С префиксом (hashDe('brb_' + bruto)), а не по значению, которое возвращает TokenFactory.gerar(), — тот хеш вычисляется без префикса и был бы неверным хешем.

Центральное решение безопасности: PAT никогда не аутентифицирует ни на каком маршруте, кроме POST /projects/:projectId/runner-ticket, ПО ПОСТРОЕНИЮ — а не через if, который новый маршрут завтра мог бы забыть проверить.

Самая простая альтернатива — чтобы глобальный JwtAuthGuard распознавал префикс brb_ и заполнял request.user для ЛЮБОГО маршрута — была рассмотрена и отвергнута: как только request.user установлен, RolesGuard/ @RequireRole разрешили бы этому PAT всё, что реальная роль пользователя позволяет в остальной api. Область «только запрос тикета runner» стала бы декоративной — утёкший PAT начал бы действовать как полная сессия.

Выход повторяет шаблон, который уже существует в коде для той же структурной проблемы (трафик, который не должен проходить через стандартную аутентификацию пользователя): IS_SERVICE_ROUTE_KEY/@ServiceRoute()/EngineServiceGuard. Третья ветка, IS_PAT_ROUTE_KEY/@RequirePatAuth(), была добавлена рядом с двумя существующими в JwtAuthGuard: когда она установлена, глобальный guard возвращает true, не пытаясь выполнить verify() JWT, целиком делегируя PatAuthGuard маршрута (@UseGuards(PatAuthGuard)), который выполняется только в handler runnerTicket — никогда в terminalTicket, который по-прежнему только для JWT сессии (ни одно место web не вызывает runner-ticket; вызывает только CLI).

PatAuthGuard извлекает bearer; если он не начинается с brb_, — 401, никогда не пытается двойной аутентификации с JWT на этом маршруте. Вызывает PersonalAccessTokenRepository.validarEUsar(hash): ОДИН запрос UPDATE ... WHERE token_hash = $1 AND revoked_at IS NULL AND (expires_at IS NULL OR expires_at > now()) RETURNING id, user_id, project_id, тот же шаблон, что AccountTokenRepository.consumir(). Ноль строк покрывает «не существует», «отозван» и «истёк» ОДНИМ И ТЕМ ЖЕ ответом (401) — не давая тому, кто предъявляет украденный/истёкший токен, информации о том, какая из трёх причин. Неверная область проекта (возвращённый project_id ≠ :projectId маршрута) — 403, другая категория: токен аутентифицировался, просто не имеет права на ЭТОТ проект.

last_used_at — БЕЗУСЛОВНЫЙ SET ... = now() в том же запросе проверки — без throttle. Рассмотренная альтернатива (обновлять только когда «NULL или старше 5 мин», с этим условием в ТОМ ЖЕ WHERE) содержала реальную ошибку: PAT, предъявленный дважды менее чем за 5 минут, во второй раз выпал бы из WHERE, и запрос вернул бы ноль строк для ДЕЙСТВИТЕЛЬНОГО токена — отклонив с 401 законное переподключение (цикл повторов runner переподключается за секунды, а не минуты). Цена отсутствия throttle — UPDATE одной строки по уникальному индексу; реальный худший случай (до 10 попыток подряд, потолок runner) далеко не создаёт проблем нагрузки. Проще и без этого класса ошибок.

RolesGuard/ResolveEffectiveRoleUseCase по-прежнему выполняются после PatAuthGuard, без изменений — ремень и подтяжки: если владелец PAT потеряет доступ к проекту обычным путём (ProjectMember/workspace), токен перестанет разрешать, даже не будучи явно отозванным.

Выпуск/перечисление/отзыв ограничены СОБСТВЕННЫМ пользователем, в WHERE запроса — никогда не фильтр в памяти после получения всего. Отзыв со стороны maintainer PAT ДРУГОГО пользователя (реагирование на инцидент — уволенный разработчик с утекающим токеном) остаётся вне этой поставки, объявлено.

Последствия​

  • Разблокирует следующую строку backlog ADR 0104: npm publish @brabo/runner перестаёт распространять поток пароль+cookie, сохранённые на диске. apps/runner/src/auth.ts полностью теряет интерактивный вход, cookies и файл учётных данных — PAT приходит готовым через --token/BRABO_ACCOUNT_TOKEN и никогда не записывается на диск CLI.
  • route-surface.spec.ts по-прежнему классифицирует runner-ticket как role:developer — верно, это то же привычное требование роли, изменился лишь механизм установления идентичности. docs/security-surface.md получил примечание в прозе о том, что этот маршрут принимает только PAT, потому что автоматическая таблица не различает два механизма.
  • Никаких изменений в engine (Elixir) — вся работа — api + runner + web + docs.
  • У пользователя может быть несколько живых PAT одновременно в одном проекте (по одному на машину, намеренно) — в отличие от account_tokens, здесь нет замещения: выпуск нового не делает недействительными предыдущие.