ADR 0127 — Два потолка понижения в project_members, а перекрытие остаётся
Контекст
ResolveEffectiveRoleUseCase.forProject начиная с Фазы 1 — это
projectRole ?? workspaceRole: если есть строка в project_members, она
побеждает, без сравнения с ролью в workspace. Перекрытие действует в ОБЕ
стороны — и вверх, и вниз.
Это обнаружило ревью #444, закрывшее gate роли в разделе Участники, и это
зафиксировано в RN-471. У находки две половины.
Первая — документационная: ЧЕТЫРЕ места обещали обратное — "the EFFECTIVE role is the
higher of this one and what the person already has in the workspace",
"associating someone as viewer here doesn't downgrade a workspace owner" —
три описания OpenAPI и комментарий в apps/web/src/lib/roles.ts. RN-471
исправила комментарий web (он был худшим: жил в модуле, экспортирующем
roleAtLeast и ROLE_ORDER, то есть в коде, который следующий человек из
этого семейства откроет раньше всего) и ОБЪЯВИЛА три описания api
неисправленными, поскольку это изменение контракта.
Вторая половина нигде не была защищена, и она — причина этого ADR: при
перекрытии, действующем вниз, AddProjectMemberUseCase был однострочным
passthrough к upsert репозитория, а RemoveProjectMemberUseCase лишь
проверял, существует ли проект. Ни один из них не смотрел, КТО является целью,
и на отношение между актором и целью. Два последствия, достижимые сегодня
любым maintainer:
- понизить
ownerworkspace.maintainerзаписываетviewerвладельцу в проекте, и владелец теряет собственный проект — чтение дашборда, решения, всё. Восстановление требуетmaintainer, который владелец только что потерял в этой области; - необратимое самопонижение.
maintainerзаписывает себя какviewerи не может это отменить, потому что отмена — тот же маршрут, требующийmaintainer. Пути назад через экран нет.
Ничто из этого не является эскалацией привилегий — никто не получает роль,
которой у него не было. Это потеря доступа, и разница важна: maintainer и
раньше мог навредить проекту, но не мог создать состояние, из которого НИКТО не
выходит, не залезая в базу.
Очевидным путём было бы заставить forProject возвращать «большую из двух» и
устранить проблему в корне, приведя код в соответствие с тремя описаниями, а не
наоборот. Это рассматривалось и было ОТВЕРГНУТО: ограничить кого-то в
чувствительном проекте — developer workspace, который становится viewer в
проекте X, — это намеренная возможность продукта и единственный существующий
способ сдерживать доступ по проекту. «Большая из двух» её уничтожает, а
resolve-effective-role.use-case.spec.ts с самого начала фиксирует половину
«вверх» ("papel de projeto sobrepõe o de workspace").
Решение
Перекрытие остаётся в обе стороны, а два опасных движения начинают
отклоняться. forProject НЕ меняется: остаётся projectRole ?? workspaceRole
и остаётся единственной композицией этой роли в системе.
Потолок 1 — никто не понижает того, кто является owner workspace.
Запрошенная роль для того, чья роль в workspace — owner, должна быть
owner; всё ниже — 403. Запись роли проекта owner для того, кто уже owner
workspace, избыточна и проходит — отклоняется только понижение.
owner читается из workspace_members.role, никогда из
workspaces.created_by. Оба существуют в db/schema/iam.ts, и это не одно и
то же. created_by — исторический факт: кто создал workspace. Именно role
авторизация использует во ВСЕЙ остальной системе —
ResolveEffectiveRoleUseCase.forWorkspace — это workspaces.findMemberRole,
а created_by не встречается ни на одном пути авторизации (он встречается в
get-credential-spend.use-case.ts, чтобы знать, чей ключ LLM расходуется, —
это другой вопрос). Потолок, читающий created_by, защищал бы не того человека
в двух конкретных и противоположных случаях: создатель, который уже передал
владение и сегодня является developer, был бы защищён, не будучи владельцем,
а текущий owner, ничего не создававший, — обычный случай workspace с
несколькими владельцами и случай переданного владения, — остался бы
незащищённым, а это ровно та дыра, которую потолок призван закрыть.
Потолок 2 — никто не понижает САМОГО СЕБЯ. Если цель — сам вызывающий, а запрошенная роль ниже эффективной роли, которая у него есть СЕГОДНЯ в проекте, — это 403.
Формулировка именно такая, без порога. Рассматривавшаяся альтернатива —
«нельзя понизить себя ниже maintainer», с доводом, что maintainer —
роль, которая может отменить. Обе отклоняют опасное движение, но вариант с
порогом — это доменное правило, копирующее число из @RequireRole
контроллера: оно молча устаревает в тот день, когда у маршрута сменится
минимум, и его нельзя сформулировать, не объяснив, откуда взялся
maintainer. «Никто не понижает самого себя» — это условие, в нём нет числа,
которое могло бы устареть, и это ТА ЖЕ форма, что у потолка 1 — кто, а не
сколько. Цена — одно безвредное движение, которое отклоняется заодно: owner,
ставящий себя maintainer в собственном проекте, был бы обратим, но теперь
отклоняется. Оно остаётся достижимым через другого maintainer, а цена
формулировки правила с исключением выше, чем цена потери этого движения.
Повышение собственной роли не является понижением и здесь не затрагивается.
Где живут потолки: в use case, с правилом в домене. Не в RolesGuard.
Guard отвечает на другой вопрос — авторизует ВЫЗЫВАЮЩЕГО против @RequireRole
маршрута — и не видит ни тела (dto.role), ни цели (dto.userId), тогда как
оба потолка касаются ЦЕЛИ и отношения актор↔цель. Guard, которому нужно тело,
должен был бы знать DTO каждого маршрута, а это ровно та граница, которую он
существует, чтобы не пересекать.
ФОРМА следует прецеденту, который в репозитории уже есть для абсолютного
потолка: domain/actions/decide.ts (RN-154 и RN-418, пересмотревшая RN-106) —
чистая функция в домене, сообщение рядом с условием, без ключа конфигурации и
без «всегда разрешать». Новый apps/api/src/domain/iam/tetos-de-rebaixamento.ts
— это та же форма, применённая здесь, и AddProjectMemberUseCase вызывает её
перед записью. Что НЕ переносится из прецедента — это исход: в decide.ts
потолок превращается в require_approval над proposed_action агента, потому
что с другой стороны есть очередь человеческого одобрения. Здесь вызов уже
ЯВЛЯЕТСЯ человеческим и синхронным, очереди, куда его отправить, нет, и исход —
отказ.
Статус: 403 в обоих случаях. Это отказ в АВТОРИЗАЦИИ — у вызывающего есть
роль маршрута, но нет полномочий для этого конкретного движения. Не 409, потому
что это не конфликт с проходящим состоянием (ожидание ничего не меняет); не
400, потому что тело валидно, и то же тело с другой целью прошло бы.
ForbiddenException с собственным сообщением — то, что уже делают другие
применения политики в api (ActivateAgentUseCase, CreateSocketTicketUseCase,
RegisterUseCase), и сообщение доходит до экрана: #444 поставила try/catch с
mensagemDaApi в два действия, которые падали молча.
Три описания OpenAPI, объявленные RN-471, начинают описывать то, что делает
код — описания POST :projectId/members, AddMemberDto.role и резюме
GET :projectId/members, которое обещало "includes whoever inherits
access from the workspace", тогда как listMembers — это innerJoin по
project_members. openapi.json и api-types.generated.ts выходят из
генератора.
Сторона web остаётся ВНЕ этого PR, и не из-за размера. Экран может сам
вычислить потолок 2 (userIdDaSessao() и эффективная роль есть там со времён
#444), но не может вычислить потолок 1: listProjectMembers возвращает
роль СТРОКИ ПРОЕКТА, а роль цели в WORKSPACE недоступна ни одному запросу
клиента. Отключение половины опций дало бы Select, честный в отношении одного
отказа и молча ошибочный в отношении другого, — тот второй источник роли,
выдуманный на экране, против которого пишет сама RN-471. Пока что отказ
ПОЯВЛЯЕТСЯ: handleRoleChange уже показывает mensagemDaApi(erro, …) в toast,
и именно фраза api объясняет, КАКОЙ потолок сработал.
Последствия
Перекрытие остаётся обоюдоострым ножом, и именно это было куплено. Ни один
из этих двух потолков не делает projectRole ?? workspaceRole безопасным в
целом; они отклоняют два названных движения. maintainer по-прежнему может
понизить любого, кто НЕ является owner workspace и не является им самим, —
включая другого maintainer, включая так, что жертва этого не отменит. Это
возможность, а не недосмотр: это та же механика, которая ограничивает
developer до viewer в чувствительном проекте, и сохранить одно без другого
нельзя. Изменилось то, что состояние, из которого НИКТО не выходит (владелец вне
собственного проекта; единственный maintainer, стирающий себя), перестало быть
достижимым.
По-прежнему возможно, с названием:
- понизить другого
maintainer, который не являетсяownerworkspace, без его согласия и без возврата его собственными руками; - самопонижение через УДАЛЕНИЕ.
RemoveProjectMemberUseCaseНЕ получил потолка, и это ещё одно движение, вне двух выбранных. Удаление собственной строки опускает эффективную роль до роли в workspace: когда workspace удерживает (maintainerтам), это безвредно и именно так кто-то уходит из списка; когда НЕ удерживает —maintainer, чьи полномочия шли от строки проекта, сviewerв workspace, — это необратимое самопонижение через другую дверь. Посылка «удаление всегда безвредно» ложна, и она записана здесь, чтобы её не открывали заново. Оба случая ЗАФИКСИРОВАНЫ в тесте (tetos-de-rebaixamento.use-case.spec.ts), включая оставшийся открытым, чтобы следующий человек знал, что это решение, а не забывчивость. Закрытие стоит сигнатурыRemoveProjectMemberUseCase.execute, которую сегодня вызываетlist-projects-and-members.use-case.spec.tsс двумя аргументами; - САМОПОВЫШЕНИЕ.
maintainerможет записать себя какownerпроекта. Это было возможно до этого ADR и остаётся возможным; потолки касаются спуска, и открыть здесь вопрос подъёма значило бы мимоходом изменить возможность, которую никто не просил менять; - понизить
ownerworkspace в workspace. Потолок 1 касаетсяproject_members.POST workspaces/:workspaceId/membersостаётся upsert без каких-либо потолков — тот же класс дефекта, областью выше, здесь не рассматривается.
Экран пока предлагает то, что api отклоняет. Это ровно тот дефект, который
закрыли последние PR этого семейства, и он возвращается через один PR,
намеренно: половина gate сегодня невозможна на клиенте (см. Решение). PR web
должен будет выбрать между тем, чтобы раскрыть роль цели в workspace в
GET :projectId/members — изменение контракта, и RN-471 уже зафиксировала, что
маршрут обещает innerJoin, которого не делает, — и тем, чтобы погасить только
то, что он знает. До тех пор отказ сообщается через toast, фразой api.
500 превратился в 404. AddProjectMemberUseCase начал вызывать
projects.findById (ему нужен workspaceId, чтобы прочитать роль цели) и
бросает NotFoundException, когда проекта не существует, как уже делали
ListProjectMembersUseCase и RemoveProjectMemberUseCase. По HTTP это
недостижимо — RolesGuard раньше отклоняет с 403, — но use case перестал
делать допущения о вызывающем.
Два дополнительных запроса на каждое добавление. findById,
findMemberRole в workspace и forProject актора (который заново делает
findById). Это административный маршрут, вызываемый раз на приглашение, а
альтернатива — переиспользовать request.effectiveRole, уже вычисленный
guard, — привязала бы use case к состоянию guard, а это ровно то сцепление,
избежать которого призван выбор того, ГДЕ живёт потолок.
Три описания OpenAPI меняются, не меняя ни одного ответа. Кто
интегрировался, читая старое обещание ("higher of"), ошибался уже с Фазы 1;
сгенерированный контракт теперь описывает реальное поведение, а 403 обоих
потолков действительно нов для того, кто вызывает маршрут.