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

ADR 0157 — Потолок самоперемещения в upsert workspace и самоповышение, которое было брешью

Статус​

Accepted. Это ТРЕТИЙ в линии, открытой ADR 0127 и продолженной ADR 0156. Он закрывает последнее перемещение, которое оба оставили письменно объявленным, и пересматривает одну фразу 0127: самоПОВЫШЕНИЕ, которое тот зафиксировал в Consequences как сохраняющуюся возможность, начинает отклоняться. Принятый ADR не редактируется — этот ссылается на него и говорит, что изменилось. Соответствует BRB-002, P1 безопасности в реестре мейнтейнера.

Контекст​

Два предыдущих ADR закрыли три двери project_members — две записи и одну удаления — и каждый из них прямым текстом указал на то, что оставалось открытым. 0127:

rebaixar o owner do workspace no workspace. O teto 1 é sobre project_members. POST workspaces/:workspaceId/members continua um upsert sem teto nenhum — mesma classe de defeito, escopo acima, não endereçado aqui.

И 0156, повторяя:

Estender o teto ao workspace. POST workspaces/:workspaceId/members é um upsert sem teto nenhum, como o ADR 0127 já declarava (…) Escopo diferente, decisão separada.

Дефект, измеренный. AddWorkspaceMemberUseCase был сквозной передачей из двенадцати строк: execute(workspaceId, userId, role), вызывающий workspaces.addMember. Он не получал актора, а контроллер (@Post(':workspaceId/members'), @RequireRole('owner')) вызывал его с голым телом, без @CurrentUser() — не было, к чему применить какой-либо потолок, так же как не было при удалении до 0156.

Это тот же класс дефекта на одну область ВЫШЕ, и он серьёзнее по двум причинам, которых нет в проекте:

  1. нет уровня выше, чтобы удержать падение. В проекте понижение себя роняет эффективную роль до роли workspace, которая часто её удерживает. В workspace роль — это роль: owner, записавший себя как viewer, теряет весь workspace, а с ним все проекты, расходы на LLM, которые принадлежат ему по RN-060, и саму возможность пригласить того, кто его восстановит;
  2. нет маршрута удаления участника, чтобы отменить другим путём. У WorkspacesController нет @Delete участника — 0156 уже измерил это и использовал как аргумент, чтобы не расширять потолок в том PR. Отмена — это ЭТОТ же маршрут, который требует только что покинутого owner. Пути назад нет ни через экран, ни через api.

Вторая половина: самоповышение. ADR 0127 написал в списке того, что оставалось возможным:

auto-PROMOÇÃO. Um maintainer pode se gravar como owner do projeto. Já era possível antes deste ADR e continua sendo; os tetos são sobre descer, e abrir a questão de subir aqui teria mudado, de passagem, uma capacidade que ninguém pediu para mudar.

Был тест, закреплявший это, — «subir o próprio papel não é rebaixamento e continua passando», — с maintainer workspace, записывающим себя как owner проекта. Решение не открывать вопрос в том PR было верным по процессу, а ответ теперь, когда он открыт, таков: это брешь. Обе половины — одно и то же перемещение — человек единолично решает, какими полномочиями обладает, — и верхняя — единственная из двух, которая повышает привилегии: 0127 мог сказать, что его потолки не касались эскалации («никто не получает роль, которой не имел»), а о самоповышении это ложно. maintainer становится owner проекта без того, чтобы кто-то его повысил, и в workspace с чувствительными проектами это разница между тем, чтобы ограничивать, и тем, чтобы быть неограничиваемым.

Решение​

1. Upsert workspace получает актора и отклоняет самоперемещение. AddWorkspaceMemberUseCase.execute становится (workspaceId, atorId, alvoId, papel) — тот же порядок, что у AddProjectMemberUseCase с 0127 и который RemoveProjectMemberUseCase получил в 0156, — контроллер передаёт @CurrentUser(), и изменение СОБСТВЕННОЙ роли там — это 403.

2. Потолок НЕ считает owners. Отказывать только когда вызывающий — последний владелец, было бы очевидным «безопасным» правилом, и оно отвергнуто буквальным критерием ADR 0127: «se enuncia numa cláusula, não tem número para envelhecer». Подсчёт стареет тремя способами — он вводит лишнее чтение, которое может рассинхронизироваться с записью; он должен решать, что делать, когда второй владелец — служебная учётная запись или человек, покинувший компанию, а api не умеет их различать; и он делает ответ маршрута зависящим от состояния ДРУГИХ строк, так что тот же вызов с тем же телом проходит сегодня и отклоняется завтра, хотя ничто из того, что контролирует вызывающий, не изменилось. Тот, кто хочет выйти из собственной роли, просит другого owner, — тот же исход, который потолок 2 уже давал в проекте.

И есть аргумент сильнее эстетики: условие уже производит инвариант, ради гарантии которого существовал бы подсчёт. Workspace никогда не остаётся без owner, потому что маршрут требует owner, а единственный актор, способный убрать последнего владельца, — он сам, а это ровно отклоняемое перемещение. Число не нужно считать, потому что форма правила делает его недостижимым.

3. У потолка 1 НЕТ пары в этой области, и это отсутствие — решение. Это было рассмотрено всерьёз, потому что здесь — в отличие от удаления в 0156 — перемещение СУЩЕСТВУЕТ: owner может понизить другого owner. Отказ вытекает из того, что потолок 1 — правило об инверсии иерархии, а инверсия в этой области невозможна:

  • в проекте projectRole ?? workspaceRole заставляет строку проекта ПЕРЕКРЫВАТЬ строку workspace (RN-471), поэтому maintainer достаёт до того, кто ВЫШЕ него. Так 0127 описал ущерб: «o dono perde o próprio projeto (…) restaurar exige maintainer, que o dono acabou de perder naquele escopo». Действующий был НИЖЕ пострадавшего;
  • в workspace этого не происходит. @RequireRole('owner') — вершина ROLE_ORDER, поэтому вызывающий маршрут никогда не находится ниже кого-либо, до кого он достаёт. Предусловие потолка 1 отсутствует — это не забытая симметрия, а правило без субъекта.

А цена того, чтобы всё же поставить пару, была бы высокой и необратимой: в сумме с потолком из пункта 1 и отсутствием маршрута удаления owner стал бы поглощающим состоянием — никто никогда не вышел бы из него по HTTP, ни по собственной воле, ни руками другого владельца. Offboarding уходящего из компании, ротация владения и исправление ошибочного приглашения стали бы требовать прямой записи в базу. Это тот класс состояния, ради устранения которого родился ADR 0127, собранный заново с обратным знаком.

Понижение другого owner, следовательно, ОСТАЁТСЯ — и обратимо тем же маршрутом, любым оставшимся владельцем, чем перемещение в проекте не было.

4. Самоповышение закрывается в ОБОИХ маршрутах связывания. Потолок 2 перестаёт быть «никто не понижает сам себя» и становится «никто не трогает собственную роль», в обоих направлениях, в проекте и в workspace. Перезапись ТОЙ ЖЕ роли по-прежнему проходит: идемпотентный upsert — не перемещение, и его отклонение сломало бы seed.ts, который намеренно перезаписывает роли.

5. Мерка не копируется: сравнение становится ОДНИМ классификатором. Модуль domain/iam/tetos-de-rebaixamento.ts получает autoMovimentoDoProprioPapel, который возвращает НАПРАВЛЕНИЕ ('rebaixamento' | 'promocao' | null) вместо булева значения. Именно он, а не отдельный ehAutoPromocao, потому что вызывающему нужно направление: решение пункта 6 требует разных сообщений, а два булевых предиката заставили бы каждый use case дважды задавать один и тот же вопрос сравнения, чтобы выяснить, какой из двух ответил. ehAutoRebaixamento продолжает существовать и становится чтением классификатора (=== 'rebaixamento'), байт в байт по поведению.

Он выживает по причине поведения, а не совместимости: к нему делегирует remocaoEhAutoRebaixamento, а УДАЛЕНИЕ видит только нижнюю половину. Его расширение заставило бы удаление отклонять и самоповышение — а удаление собственной строки проекта, которая кого-то ограничивала, — это как раз то, как снимается ограничение, которое потолок 1 не даёт создать, безвредное перемещение, которое пункт 3 ADR 0156 явно защитил. Объединение всего изменило бы попутно дверь, которую этот ADR не открывал.

6. Сообщения собственные по НАПРАВЛЕНИЮ и по ОБЛАСТИ. Их четыре, и ни одно не служит двум вещам: тот, кто пытался повыситься, не получает фразу о понижении (он делал не это), а того, кто упирается в потолок workspace, не отправляют говорить с maintainer — там роль, которая отменяет, — owner. Это та же дисциплина, что в пункте 5 ADR 0156 и в RN-470: не схлопывать два состояния из-за того, что у них общий исход.

7. Роль актора берётся из репозитория, а не из ResolveEffectiveRoleUseCase. Это противоположность выбору 0156 по той же причине, которую он привёл («ни одной лишней зависимости ради одного чтения»): в use cases проекта ResolveEffectiveRoleUseCase входит, чтобы роль актора не стала второй, вручную написанной композицией projectRole ?? workspaceRole; в workspace нет композиции, которую нужно защищать — forWorkspace ЕСТЬ workspaces.findMemberRole, — а у этого use case репозиторий уже под рукой, потому что именно через него он пишет.

8. 403 в use case, никогда в RolesGuard. Без новизны: guard авторизует ВЫЗЫВАЮЩЕГО по @RequireRole маршрута и не видит ни тела (dto.role), ни цели (dto.userId), а этот потолок — об отношении актор↔цель. Всё рассуждение есть в ADR 0127 и здесь не повторяется.

9. Повышение применяется, даже будучи недостижимым по HTTP на этом маршруте. С @RequireRole('owner') актор уже на вершине, и подниматься некуда — верхняя половина потолка в области workspace — механизм, который сегодня не срабатывает. Он всё равно входит, и протестированным, по той причине, которая всегда была у потолка 2, чтобы не иметь порога: use case не предполагает guard, и именно он остаётся правильным в тот день, когда у маршрута сменится минимум. Это та же конструкция, что у отказа по возможности в RN-514, реализованного и протестированного до того, как у него появился триггер.

Что этот ADR явно отвергает​

  • Подсчёт owners. См. пункт 2. Ни «только последнего», ни «только если не останется другого активного владельца». У условия нет числа, которое могло бы устареть, и оно уже производит инвариант, что owners никогда не бывает ноль.
  • Потолок owner workspace в этой области. См. пункт 3. Это не забытая симметрия и не рефлекторный отказ: это правило об инверсии иерархии на маршруте, где инверсия невозможна, и его введение сделало бы owner состоянием, из которого никто не выходит по HTTP.
  • Маршрут удаления участника workspace. Его отсутствие — часть контекста этого ADR (именно из-за него самопонижение необратимо), а его создание означало бы открыть новую дверь, закрывая другую, — и новая дверь родилась бы, нуждаясь в том же потолке плюс суждении об удалении последнего владельца, для которого у этого ADR нет материала. Отдельное решение, если кто-то его попросит.
  • Расширение потолка УДАЛЕНИЯ участника проекта на повышение. См. пункт 5. ADR 0156 письменно защитил это перемещение, и оно остаётся байт в байт.
  • Изменение ResolveEffectiveRoleUseCase.forProject. Третий ADR подряд, где это сказано: projectRole ?? workspaceRole остаётся в обоих направлениях.
  • Gate на экране. Экрана нет: у POST /workspaces/:workspaceId/members нет вызывающего в apps/web — измерено; единственное вхождение — сгенерированный тип в api-types.generated.ts. Сегодня маршрут вызывают seed.ts (через use case, с owner в качестве актора) и прямые интеграции. Нет toast, который нужно подстраивать, и ни одна строка web не меняется.

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

Легитимный maintainer, которому нужен owner в проекте, начинает зависеть от другого человека. Это прямая цена решения пункта 4, и она не гипотетична: это был путь, по которому тот, у кого роль маршрута, давал себе роль, нужную для следующей задачи. Она остаётся достижимой — любым другим maintainer проекта и любым owner workspace, — а покупается то, что повышение роли теперь требует двух людей. Это тот же обмен, что ADR 0127 сделал для понижения, с той же ценой в той же валюте.

Фраза ADR 0127 о самоповышении становится ЛОЖНОЙ, и заменяет её этот ADR. Она остаётся записанной там, как любое превзойдённое решение в этом репозитории; кто её прочтёт, должен дойти досюда. Поэтому строка индекса и docblock ehAutoRebaixamento называют пересмотр.

Тест, закреплявший разрешение, был ИНВЕРТИРОВАН, а не удалён, с именем, сохраняющим происхождение, — «subir o próprio papel era permitido por ser 'não rebaixamento', e passa a ser 403 (ADR 0157)». Та же форма, что 0156 использовал для теста пробела удаления. Это свидетельство того, почему возможность существовала, и его не выбрасывают.

Один лишний запрос на связывание с workspace, и только один: роль актора. Маршрут административный, а сравнение чистое, поэтому стоимость та же, что 0127 принял в проекте (где их было два) и 0156 при удалении (два).

Сигнатура изменилась, и вызывающих было ДВА — контроллер и apps/api/src/db/seed.ts, который передаёт owner.id как актора. Там актор и цель — намеренно разные люди, поэтому ни один потолок не достигается, и seed не получает никакого исключения. CreateWorkspaceUseCase не является вызывающим, и хорошо, что не является: он записывает создателя как owner через репозиторий, внутри транзакции создания, что является законным самоповышением — единственным в продукте — и стало бы отклоняться, если бы однажды было направлено сюда. Комментарий рядом с условием пустой роли это фиксирует.

Поверхность маршрута изменилась, не изменив ни одного успешного ответа, и это отражено в docs/security-surface.md: role:owner по-прежнему в таблице и по-прежнему то, что применяет RolesGuard, но перестал быть полным ответом на «кто что может здесь» — третий маршрут этого семейства, получивший такую пометку.

По-прежнему возможно, поимённо: owner, понижающий ДРУГОГО owner (пункт 3, обратимо тем же маршрутом); понижение любого, кто не является вызывающим, в любой области, — это всегдашняя возможность; и самоПОВЫШЕНИЕ через удаление собственной строки проекта — удаление строки, которая кого-то ограничивала, повышает его эффективную роль, и ADR 0156 намеренно защитил это перемещение.