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

ADR 0156 — Потолок самопонижения также при удалении участника проекта

Статус​

Accepted. Закрывает пробел, который ADR 0127 объявил письменно и закрепил в тесте. Не отменяет и не пересматривает его: оба потолка того ADR остаются в точности такими, как есть, а меняется набор ДВЕРЕЙ, через которые оценивается потолок 2. Соответствует BRB-001, P1 безопасности из реестра сопровождающего.

Контекст​

ADR 0127 поставил два потолка на путь ЗАПИСИ project_members — никто не понижает owner workspace, никто не понижает самого себя — и намеренно решил не покрывать третье движение, записав причину и цену в разделе «Остаётся возможным, с именем»:

самопонижение через УДАЛЕНИЕ. RemoveProjectMemberUseCase НЕ получил потолка (…) Предпосылка, что «удаление всегда безвредно», ложна, и она записана здесь, чтобы её не открывали заново. Оба случая ЗАКРЕПЛЕНЫ в тесте (…), включая тот, что остаётся открытым, чтобы следующий человек знал, что это решение, а не забывчивость. Его закрытие стоит сигнатуры RemoveProjectMemberUseCase.execute, которую сегодня вызывает list-projects-and-members.use-case.spec.ts с двумя аргументами.

Этот ADR и есть тот следующий человек, и предсказанная цена оказалась ровно взысканной.

Дефект, измеренный. RemoveProjectMemberUseCase.execute получал (projectId, userId) — не получал актора — а controller (@Delete(':projectId/members/:userId'), @RequireRole('maintainer')) вызывал его с двумя параметрами маршрута, без @CurrentUser(). Поскольку ResolveEffectiveRoleUseCase.forProject — это projectRole ?? workspaceRole (RN-471), удаление строки проекта не удаляет роль: оно ЗАМЕНЯЕТ эффективную роль вторым членом. Когда роль workspace меньше, итоговый эффект — то самое понижение, которое потолок 2 существует, чтобы предотвратить, с той же необратимостью — вернуться значит POST :projectId/members, который требует maintainer, только что оставленного. Без какой-либо роли в workspace падение — к доступу НИКАКОМУ.

Случай воспроизводился в тесте со времён ADR 0127, с именем, говорящим, что он документировал: «e a remoção SEGUE podendo rebaixar quem a chamou, quando o workspace não segura — lacuna declarada no ADR 0127». maintainer, чья власть происходила от строки проекта, viewer в workspace, удалял собственную строку и падал до viewer без пути назад через экран.

Почему это то же правило, а не новое. Потолок 2 сравнивает эффективную роль СЕГОДНЯ с эффективной ролью ПОСЛЕ и никогда не нуждался в знании того, по какому маршруту пришла вторая. В add роль-после — роль из тела; при удалении — роль workspace. Написать вторую линейку для второй двери значило бы сделать четвёртую копию сравнения, которое уже живёт в domain/iam/tetos-de-rebaixamento.ts, — а копия линейки — это режим отказа, который этот репозиторий преследует во многих местах (генерируемый список областей, roleAtLeast с одним и тем же именем на обеих сторонах, caminhoDeWorkspaceLocalValido, используемый повторно вместо переписывания).

Решение​

1. Удаление начинает получать актора и отказывать в итоговом самопонижении. RemoveProjectMemberUseCase.execute становится (projectId, atorId, alvoId) — в том же порядке, что и AddProjectMemberUseCase, — и бросает ForbiddenException (403), когда удаление строки САМОГО вызывающего оставило бы его с ролью меньше сегодняшней. Controller передаёт @CurrentUser(), как add уже делал.

2. Правило — это повторно используемый потолок 2, с ролью workspace вместо запрошенной роли. В тот же доменный модуль входит remocaoEhAutoRebaixamento, и она не реализует заново никакого сравнения: делегирует ehAutoRebaixamento, передавая papelPedidoNoProjeto: papelDoAtorNoWorkspace. Она существует, вместо того чтобы сценарий использования вызывал ehAutoRebaixamento напрямую, из-за ОДНОГО случая, который сигнатура другой не умеет выразить, — актор без роли в workspace, где роль-после — «никакой», которая не является Role. Этот случай — максимальное понижение, и он отклоняется.

3. Потолок 1 НЕ получает пары, и её отсутствие — решение. Удаление строки проекта того, кто является owner WORKSPACE, может только ПОВЫСИТЬ его эффективную роль или сохранить её: owner — вершина ROLE_ORDER, поэтому не существует роли workspace, для которой падение было бы падением. Более чем безвредно, это движение — ЕДИНСТВЕННЫЙ способ отменить ограничение, которое потолок 1 из ADR 0127 не даёт создать, — owner workspace, у которого уже была строка viewer в проекте (записанная до того ADR или прямой записью в базу), снова получает доступ к собственному проекту именно так. Потолок там отклонял бы только безвредные движения и сделал бы постоянным состояние, которое ADR 0127 был создан устранить. Отсутствие записано в коде, рядом с функцией, и закреплено в тесте.

4. Без порога, как и потолок 2. Отказ — «самому себе», а не «ниже maintainer», и объявленная цена ADR 0127 идёт вместе с ним: самоудаление, которое опускает с owner проекта до maintainer workspace, обратимо и ТОЖЕ ОТКЛОНЯЕТСЯ. Оно остаётся доступным другому maintainer. Этот случай проходил раньше и теперь отклоняется — это единственное изменение поведения, которое этот ADR вносит в безвредное движение, и это тот же обмен, который ADR 0127 уже принял для add: клауза без числа против исключения, которое нужно объяснять каждый раз.

5. Сообщение СОБСТВЕННОЕ, не как у add. Тот, кто нажал «удалить», не просил менять никакую роль, и фраза о «понижении самого себя» не объяснила бы, что произошло. MENSAGEM_TETO_AUTO_REBAIXAMENTO_POR_REMOCAO называет механизм: без строки проекта роль падает до роли workspace, а возврат требует оставленной роли.

6. Описание OpenAPI начинает описывать код. Описание маршрута говорило лишь, что «тот, у кого есть роль в workspace, продолжает видеть проект по наследованию», — правда наполовину, и недостающая половина — причина этого решения: наследуемая роль может быть МЕНЬШЕ строки. openapi.json и api-types.generated.ts выходят из генератора.

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

  • Потолок owner workspace при удалении. См. пункт 3. Это не забытая симметрия: не существует удаления, которое понизило бы owner workspace.
  • Вторую линейку сравнения ролей. См. пункт 2. Сравнение по-прежнему живёт в одном месте.
  • Трогать ResolveEffectiveRoleUseCase.forProject. projectRole ?? workspaceRole остаётся байт в байт, в обоих направлениях, — это возможность, от устранения которой ADR 0127 отказался, и ничто здесь не открывает вопрос заново.
  • Распространять потолок на workspace. POST workspaces/:workspaceId/members — это upsert без какого-либо потолка, как ADR 0127 уже объявлял, — а удаления там даже нет: у WorkspacesController нет @Delete участника (измерено), так что эквивалентное движение недостижимо по HTTP в этой области. Другая область, отдельное решение.
  • Блокировать удаление ДРУГОГО человека, даже когда он теряет роль. Это привычная законная возможность — та же, что ограничивает developer до viewer в чувствительном проекте, — и она действует для удаления так же, как для записи.
  • Gate на экране. См. Consequences.

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

Одно безвредное движение отклоняется вместе с остальными, и это уже принятая цена. Самоудаление того, у кого owner в проекте и maintainer в workspace, было разрешено и становится 403. Оно обратимо другим maintainer, а тест, закреплявший разрешение, теперь закрепляет отказ, с именем, говорящим, что это цена потолка без порога. Кто хочет выйти из списка в такой ситуации, просит другого maintainer — тот же исход, который потолок 2 уже давал через add.

Экран по-прежнему предлагает кнопку, которую api отклоняет, и отказ отображается. У MembersSection.handleRemove уже есть try/catch с mensagemDaApi (RN-471), так что фраза api доходит до toast без единой строки web. В отличие от потолка 1, этот gate ВЫЧИСЛИМ на клиенте — userIdDaSessao() и эффективная роль там есть, — но вычислить только его дало бы кнопку, честную об одном отказе и молчащую о другом, а это ровно тот полу-gate, который ADR 0127 отверг. Это остаётся для PR web, который решит проблему целиком.

Два дополнительных запроса на каждое удаление, даже когда цель — другой человек: forProject актора (который повторяет findById) и forWorkspace. Это цена, которую ADR 0127 уже принял для add, на административном маршруте, а альтернатива — if (atorId === alvoId) перед чтениями — повторила бы в сценарии использования клаузу, которую чистая функция существует, чтобы содержать.

Сигнатура изменилась, и её вызывали два теста. У execute теперь три аргумента; list-projects-and-members.use-case.spec.ts и tetos-de-rebaixamento.use-case.spec.ts обновлены. Другого вызывающего нет: конструктор получил ResolveEffectiveRoleUseCase, который уже был зарегистрирован в iam-use-cases.module.ts.

Тест пробела стал тестом потолка. Блок, который ADR 0127 оставил, закрепляя дыру («a remoção SEGUE podendo rebaixar quem a chamou»), был ИНВЕРТИРОВАН, а не удалён, и имя хранит происхождение. Это способ, которым этот репозиторий уже пользуется, чтобы не терять доказательство того, почему что-то существовало.

Остаётся возможным, с именем: понизить другого человека удалением его строки; само-ПОВЫШЕНИЕ (без изменений); и вся область workspace, чей POST участника по-прежнему без потолка.