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

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 запрашивал у engine POST /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, а изменить цель — значит изменить то, что означает «отозвать». Владелец решил.

Решение​

  1. Тикет фиксирует, КАКИЕ учётные данные его запросили. Две ДОПУСКАЮЩИЕ NULL колонки в engine.runner_socket_tickets — credential_kind ("device_key" | "pat") и credential_id (kid или id PAT), — миграция Ecto 20261001120000_add_credential_to_runner_socket_tickets.exs, аддитивная, без backfill (тикеты живут 30 с). Таблица принадлежит ENGINE (схема engine), поэтому и миграция его; журнал Drizzle не меняется (остаётся на 0068). Null — законное состояние: тикет terminal принадлежит вкладке web, аутентифицированной сессией, а тикет runner, выпущенный api до этого ADR, приходит без него во время rollout.

  2. Контракт 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, никогда выдуманными учётными данными, — и тикет продолжает работать.

  3. Engine хранит учётные данные в сокете. connect/3 кладёт assigns.credencial, а id/1 получает два сегмента, когда они есть, — runner_socket:<kind>:<projectId>:<userId>:<espécie>:<id>, — так что disconnect, который канал рассылает на СОБСТВЕННЫЙ id, достигает только соединений с этими учётными данными. Без учётных данных id прежний.

  4. Отзыв ОДНИХ учётных данных имеет собственный маршрут и собственную цель. POST /internal/runner/disconnect-credential (без :projectId: у ключа машины его нет), Engine.Runners.Revogacao.derrubar_credencial/4:

    1. аннулирует ОЖИДАЮЩИЕ тикеты этих учётных данных (consumed_at заполнен — то же состояние «уже использован», которое отклоняют validar/1 и consumir/2, никакого нового состояния), закрывая окно между выпуском и использованием;
    2. опрашивает КАЖДЫЙ зарегистрированный в кластере runner (Registry.todos/0), родился ли он от этих учётных данных, — знает только канал, потому что учётные данные живут в assigns. Кто родился от них, падает (транспорт + {:stop, …}, освобождающий присутствие); кто нет, отвечает :intocado и остаётся на ногах;
    3. отвечает 200 с ИТОГОМ (derrubados, legados, intocados, semResposta, ticketsAnulados), с потолком 5 с на ВСЁ ВМЕСТЕ. 400 — только для учётных данных неправильной формы, чтобы «ни во что не прицелился» никогда не выдавалось за «ничего не было».

    Ключ МАШИНЫ перестаёт быть парой, применённой N раз: это ОДИН запрос, и он падает в каждом проекте, где открыл соединение, включая тот, который api не перечислил бы.

  5. Отзыв ключа и отзыв PAT используют эту цель. RevokeRunnerDeviceKeyUseCase (и, через делегирование, дверь учётной записи, RN-611) запрашивает обрыв по {tipo: 'device_key', id}; оба отзыва PAT (владельца, RN-426, и maintainer, RN-427) начинают обрывать по {tipo: 'pat', id} — раньше они не обрывали ничего. Порядок не меняется: СНАЧАЛА отзыв, потом обрыв, и обрыв — это побочный эффект, который только ЛОГИРУЕТСЯ.

  6. Не перестать обрывать то, что уже обрывалось (запрет AT-013). Две ПЕРЕХОДНЫЕ детали, и ни одна из них не является целью:

    • УНАСЛЕДОВАННОЕ соединение (тикет без учётных данных) того же владельца, в одном из проектов, которые api передаёт как охват (проект строки или кандидаты машины), падает по паре, как раньше (:derrubado_legado). Только для ключа устройства: PAT раньше ничего не обрывал, поэтому унаследованного охвата он не получает — он получил бы обрыв, которого у него никогда не было, по тому, кто к нему не имеет отношения.
    • ПЛАН Б: если engine не обслуживает запрос по ключу (engine, более старый, чем этот маршрут, отвечает 404), отзыв ключа возвращается к старой цели, disconnectRunnerOfUser по проекту. Оборвать лишнее — обратимо; не оборвать — значит заново открыть RN-519.
  7. {проект, пользователь} продолжает существовать, с ДРУГИМ хозяином. POST /internal/projects/:projectId/runner/disconnect и Revogacao.derrubar/3 остаются байт в байт (с более тонким id сокета): это цель УДАЛЕНИЯ УЧАСТНИКА (RN-615), которое убирает ЧЕЛОВЕКА из проекта и должно оборвать любое его соединение, с какими угодно учётными данными, — помимо плана Б выше.

  8. Экран меняет текст, а не поведение. Подтверждение отзыва — в разделе ключей устройства в Настройках и в разделе ключей машины в Учётной записи — заменяет «побочный ущерб» («outro runner seu no mesmo projeto também cai») ТОЧНОСТЬЮ («só cai o que se conectou com ESTA chave»), потому что RN-561 требует, чтобы экран говорил правильный охват, а старая фраза стала бы утверждать обрыв, которого не происходит. Это ответ на TODO(humano) из AT-013 («меняется поведение экрана или только надпись?»): только надпись — клик, маршрут и инвалидация те же.

  9. 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 меняет форму. Снаружи его никто не читает — это внутренний топик disconnect Phoenix, — а сокет, открытый до деплоя, умирает вместе со старым pod.
  • Доказательство engine — только в CI. ExUnit (реальный канал с учётными данными, PAT, остающийся на ногах, машина в двух проектах без списка, унаследованное по паре, аннулированный ожидающий тикет, контроллер 200/400) написан без локального запуска (repo.hex.pm в этой среде даёт 403); синтаксис и форматирование проверены.