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, здесь нет замещения: выпуск нового не делает недействительными предыдущие.