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

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:

  1. понизить owner workspace. maintainer записывает viewer владельцу в проекте, и владелец теряет собственный проект — чтение дашборда, решения, всё. Восстановление требует maintainer, который владелец только что потерял в этой области;
  2. необратимое самопонижение. 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, который не является owner workspace, без его согласия и без возврата его собственными руками;
  • самопонижение через УДАЛЕНИЕ. 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 и остаётся возможным; потолки касаются спуска, и открыть здесь вопрос подъёма значило бы мимоходом изменить возможность, которую никто не просил менять;
  • понизить owner workspace в 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 обоих потолков действительно нов для того, кто вызывает маршрут.