ADR 0157 — Потолок самоперемещения в upsert workspace и самоповышение, которое было брешью
Статус
Accepted. Это ТРЕТИЙ в линии, открытой
ADR 0127 и продолженной
ADR 0156. Он закрывает последнее
перемещение, которое оба оставили письменно объявленным, и пересматривает одну фразу
0127: самоПОВЫШЕНИЕ, которое тот зафиксировал в Consequences как сохраняющуюся
возможность, начинает отклоняться. Принятый ADR не редактируется — этот ссылается на него и говорит,
что изменилось. Соответствует BRB-002, P1 безопасности в реестре
мейнтейнера.
Контекст
Два предыдущих ADR закрыли три двери project_members — две
записи и одну удаления — и каждый из них прямым текстом указал на то,
что оставалось открытым. 0127:
rebaixar o
ownerdo workspace no workspace. O teto 1 é sobreproject_members.POST workspaces/:workspaceId/memberscontinua 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.
Это тот же класс дефекта на одну область ВЫШЕ, и он серьёзнее по двум причинам, которых нет в проекте:
- нет уровня выше, чтобы удержать падение. В проекте понижение себя роняет
эффективную роль до роли workspace, которая часто её удерживает. В workspace
роль — это роль:
owner, записавший себя какviewer, теряет весь workspace, а с ним все проекты, расходы на LLM, которые принадлежат ему по RN-060, и саму возможность пригласить того, кто его восстановит; - нет маршрута удаления участника, чтобы отменить другим путём.
У
WorkspacesControllerнет@Deleteучастника — 0156 уже измерил это и использовал как аргумент, чтобы не расширять потолок в том PR. Отмена — это ЭТОТ же маршрут, который требует только что покинутогоowner. Пути назад нет ни через экран, ни через api.
Вторая половина: самоповышение. ADR 0127 написал в списке того, что оставалось возможным:
auto-PROMOÇÃO. Um
maintainerpode se gravar comoownerdo 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 exigemaintainer, 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 никогда не бывает ноль.
- Потолок
ownerworkspace в этой области. См. пункт 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 намеренно защитил это перемещение.