ADR 0201 — Отзыв нацелен на КЛЮЧ, а не на пару {проект, пользователь}
Статус
Accepted. 2026-10-01 (AT-013, история HS-008, эпик EP-003, раунд 36; решение владельца от 01.10: «да, отзыв по КЛЮЧУ, с ADR — колонка ключа в тикете сокета и новый контракт auth; отзыв обрывает только соединения, открытые этим ключом. Трогает engine, намеренно.»). Пересматривает RN-520 — которая родилась в ADR 0147, пункт 6, — и ссылается, не редактируя их, на ADR 0154 (ключ МАШИНЫ, который поставил «Отзыв по ключу» в число того, чего НЕ делал) и ADR 0155 (ключ машины, который рождается в терминале и который регистрирует установка). Новое правило: RN-685.
Контекст
Начиная с RN-520 отзыв ключа устройства обрывает ЖИВОЕ соединение runner, а не
только следующий тикет. Целью этого обрыва была {проект, пользователь}, и
RN-520 объясняла почему: идентичность учётных данных, запросивших тикет, —
kid ключа (это id записи, RN-475) или PAT — умирала в PatAuthGuard.
runner_socket_tickets хранила project_id, user_id и kind, и больше
ничего; канал умел сравнивать только пользователя.
Измерено в коде до изменения (origin/dev, ec0fbea52d):
- Как рождается тикет.
POST /projects/:projectId/runner-ticketпроходит черезPatAuthGuard, который аутентифицирует по PAT (validarEUsarвозвращает{id, userId, projectId}) или по ключу устройства (kidиз заголовка JWT находит публичный ключ вrunner_device_keys;chave.id— это и естьkid). Guard выставлял толькоrequest.user.RequestRunnerTicketUseCaseзапрашивал у enginePOST /internal/projects/:projectId/runner-ticketsс{userId, kind}, аEngine.Runners.SocketTicket.emitir/3записывал строку. - Как engine потребляет.
EngineWeb.RunnerSocket.connect/3проверяет тикет и кладётproject_id/user_id/kindвassigns;id/1сокета былrunner_socket:<kind>:<projectId>:<userId>;join/3канала потребляет тикет и регистрирует присутствие вEngine.Runners.Registry(:global, не более ОДНОГО runner на проект в кластере). - Где отзыв обрывал.
RevokeRunnerDeviceKeyUseCaseзаписывает отзыв и вызываетdisconnectRunnerOfUser(projectId, userId)—POST /internal/projects/:projectId/runner/disconnect, — engine находит pid черезRegistry, канал сравниваетuser_idи рассылаетdisconnectна СОБСТВЕННЫЙ id. Для ключа МАШИНЫ (проект пустой) PR #534 сделал цель МНОЖЕСТВЕННОЙ: та же пара, по одному разу на каждый проект в режимеrunner, доступный владельцу (listRunnerModeReachableBy). - Что обрывается сегодня, хотя не должно. Runner того же пользователя, подключённый с PAT или с другим ключом, в этом проекте. С ключом машины цена умножается: отзыв ключа ноутбука обрывает агента десктопа в каждом проекте, где они пересекаются.
- Что НЕ обрывается сегодня, хотя должно. (1) Соединение ключа машины в
проекте, которого нет в списке api, — роль владельца упала, проект
переведён в другой режим при работающем runner. (2) Тикет, запрошенный за
секунды ДО отзыва и использованный ПОСЛЕ:
connect/3иjoin/3не спрашивают об учётных данных. (3) Отзыв PAT не обрывал ничего — только мешал новому тикету.
AT-013 была заблокирована по двум наложенным причинам: она была вне рамок ФАЗЫ 30, а изменить цель — значит изменить то, что означает «отозвать». Владелец решил.
Решение
-
Тикет фиксирует, КАКИЕ учётные данные его запросили. Две ДОПУСКАЮЩИЕ NULL колонки в
engine.runner_socket_tickets—credential_kind("device_key"|"pat") иcredential_id(kidили id PAT), — миграция Ecto20261001120000_add_credential_to_runner_socket_tickets.exs, аддитивная, без backfill (тикеты живут 30 с). Таблица принадлежит ENGINE (схемаengine), поэтому и миграция его; журнал Drizzle не меняется (остаётся на0068). Null — законное состояние: тикетterminalпринадлежит вкладке web, аутентифицированной сессией, а тикетrunner, выпущенный api до этого ADR, приходит без него во время rollout. -
Контракт auth начинает доносить учётные данные до engine.
PatAuthGuardзаполняетrequest.credencialDeDispositivo({tipo: 'pat', id}или{tipo: 'device_key', id: chave.id}) после авторизации; контроллер передаёт дальше;RequestRunnerTicketUseCaseотправляет их только дляkind: "runner"(тикетterminalникогда их не несёт, даже если кто-то передаст); внутренний запрос получаетcredentialKind/credentialId, которые ездят только вместе. Engine нормализует пару (SocketTicket.credencial/2): неизвестный вид или пустой id становятсяnil, никогда выдуманными учётными данными, — и тикет продолжает работать. -
Engine хранит учётные данные в сокете.
connect/3кладётassigns.credencial, аid/1получает два сегмента, когда они есть, —runner_socket:<kind>:<projectId>:<userId>:<espécie>:<id>, — так чтоdisconnect, который канал рассылает на СОБСТВЕННЫЙ id, достигает только соединений с этими учётными данными. Без учётных данных id прежний. -
Отзыв ОДНИХ учётных данных имеет собственный маршрут и собственную цель.
POST /internal/runner/disconnect-credential(без:projectId: у ключа машины его нет),Engine.Runners.Revogacao.derrubar_credencial/4:- аннулирует ОЖИДАЮЩИЕ тикеты этих учётных данных (
consumed_atзаполнен — то же состояние «уже использован», которое отклоняютvalidar/1иconsumir/2, никакого нового состояния), закрывая окно между выпуском и использованием; - опрашивает КАЖДЫЙ зарегистрированный в кластере runner
(
Registry.todos/0), родился ли он от этих учётных данных, — знает только канал, потому что учётные данные живут вassigns. Кто родился от них, падает (транспорт +{:stop, …}, освобождающий присутствие); кто нет, отвечает:intocadoи остаётся на ногах; - отвечает 200 с ИТОГОМ (
derrubados,legados,intocados,semResposta,ticketsAnulados), с потолком 5 с на ВСЁ ВМЕСТЕ. 400 — только для учётных данных неправильной формы, чтобы «ни во что не прицелился» никогда не выдавалось за «ничего не было».
Ключ МАШИНЫ перестаёт быть парой, применённой N раз: это ОДИН запрос, и он падает в каждом проекте, где открыл соединение, включая тот, который api не перечислил бы.
- аннулирует ОЖИДАЮЩИЕ тикеты этих учётных данных (
-
Отзыв ключа и отзыв PAT используют эту цель.
RevokeRunnerDeviceKeyUseCase(и, через делегирование, дверь учётной записи, RN-611) запрашивает обрыв по{tipo: 'device_key', id}; оба отзыва PAT (владельца, RN-426, иmaintainer, RN-427) начинают обрывать по{tipo: 'pat', id}— раньше они не обрывали ничего. Порядок не меняется: СНАЧАЛА отзыв, потом обрыв, и обрыв — это побочный эффект, который только ЛОГИРУЕТСЯ. -
Не перестать обрывать то, что уже обрывалось (запрет AT-013). Две ПЕРЕХОДНЫЕ детали, и ни одна из них не является целью:
- УНАСЛЕДОВАННОЕ соединение (тикет без учётных данных) того же
владельца, в одном из проектов, которые api передаёт как охват (проект
строки или кандидаты машины), падает по паре, как раньше
(
:derrubado_legado). Только для ключа устройства: PAT раньше ничего не обрывал, поэтому унаследованного охвата он не получает — он получил бы обрыв, которого у него никогда не было, по тому, кто к нему не имеет отношения. - ПЛАН Б: если engine не обслуживает запрос по ключу (engine, более
старый, чем этот маршрут, отвечает 404), отзыв ключа возвращается к
старой цели,
disconnectRunnerOfUserпо проекту. Оборвать лишнее — обратимо; не оборвать — значит заново открыть RN-519.
- УНАСЛЕДОВАННОЕ соединение (тикет без учётных данных) того же
владельца, в одном из проектов, которые api передаёт как охват (проект
строки или кандидаты машины), падает по паре, как раньше
(
-
{проект, пользователь}продолжает существовать, с ДРУГИМ хозяином.POST /internal/projects/:projectId/runner/disconnectиRevogacao.derrubar/3остаются байт в байт (с более тонким id сокета): это цель УДАЛЕНИЯ УЧАСТНИКА (RN-615), которое убирает ЧЕЛОВЕКА из проекта и должно оборвать любое его соединение, с какими угодно учётными данными, — помимо плана Б выше. -
Экран меняет текст, а не поведение. Подтверждение отзыва — в разделе ключей устройства в Настройках и в разделе ключей машины в Учётной записи — заменяет «побочный ущерб» («outro runner seu no mesmo projeto também cai») ТОЧНОСТЬЮ («só cai o que se conectou com ESTA chave»), потому что RN-561 требует, чтобы экран говорил правильный охват, а старая фраза стала бы утверждать обрыв, которого не происходит. Это ответ на
TODO(humano)из AT-013 («меняется поведение экрана или только надпись?»): только надпись — клик, маршрут и инвалидация те же. -
Runner не меняется. Он уже отправлял
kidв заголовке JWT, и это всё, что нужно пути. Ни одной строки вapps/runner/.
Чего этот ADR НЕ делает
- Не меняет то, что АВТОРИЗУЕТ. Ни одного нового потолка, ни одного
ослабленного:
PatAuthGuardрешает как всегда, а учётные данные лишь ЗАПИСЫВАЮТСЯ после авторизации. Этот ADR перемещает цель обрыва, никогда полномочия. - Не обрывает вкладку Terminal в web. Сокет
kind: "terminal"не имеет учётных данных устройства, не занимаетRegistryи никогда не опрашивается — как и раньше. - Не закрывает тикет, выпущенный и ещё не ПОДКЛЮЧЁННЫЙ, с учётными данными старого api. Тикет без учётных данных (выпущенный более старым api) при отзыве не аннулируется: сравнивать не с чем. Окно — 30 с, и оно существует только во время rollout.
- Не даёт представлению
maintainerотзыв ключа другого пользователя. Остаётся вне рамок по решению RN-519.
Последствия
- Другой runner того же пользователя остаётся на ногах. Цена, которую объявляла RN-520, исчезает: отзыв ключа ноутбука не обрывает десктоп, подключённый с другим ключом, и runner, подключённый с PAT.
- Отзыв ключа машины — это ОДИН запрос, и он полнее множественного. Он достигает проектов, которых нет в списке api. Цена — опрашивать КАЖДЫЙ runner кластера: линейно по числу подключённых runners, только когда кто-то отзывает, и с потолком 5 с на всё вместе.
- Отзыв PAT начинает обрывать. Это наблюдаемое изменение поведения:
runner, подключённый с отозванным токеном, падает сразу, а не продолжает до
переподключения. Для отзыва
maintainer(реагирование на инцидент, RN-427) это то, чего не хватало, чтобы он хоть что-то сдерживал. - Итог заменяет единичный исход в логе отзыва ключа. Четыре исхода
derrubar/3продолжают существовать для удаления участника. - Rollout без действий оператора. Миграция аддитивная и выполняется в
обычном задании миграции (
Engine.Release.migrate/0). Новый api со старым engine: новые поля запроса тикета игнорируются, и отзыв уходит в план Б. Старый api с новым engine: тикеты без учётных данных, которые падают по паре (унаследованные), а старый отзыв по-прежнему вызывает маршрут пары, который не изменился. Поэтому веткаfeature/, а неbreaking/. - id сокета runner меняет форму. Снаружи его никто не читает — это
внутренний топик
disconnectPhoenix, — а сокет, открытый до деплоя, умирает вместе со старым pod. - Доказательство engine — только в CI. ExUnit (реальный канал с учётными
данными, PAT, остающийся на ногах, машина в двух проектах без списка,
унаследованное по паре, аннулированный ожидающий тикет, контроллер 200/400)
написан без локального запуска (
repo.hex.pmв этой среде даёт 403); синтаксис и форматирование проверены.