ADR 0127 — Dois tetos de rebaixamento em project_members, e a sobreposição fica
Context
ResolveEffectiveRoleUseCase.forProject é, desde a Fase 1,
projectRole ?? workspaceRole: existindo linha em project_members, ela
vence, sem comparar com o papel de workspace. A sobreposição vale nos DOIS
sentidos — sobe e desce.
Isso foi descoberto pela revisão da #444, que fechou o gate de papel da seção de
Membros, e está registrado na
RN-471. O achado tem duas metades. A primeira é
documental: QUATRO lugares prometiam o contrário — "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" —
três descrições de OpenAPI e um comentário no apps/web/src/lib/roles.ts. A
RN-471 corrigiu o do web (era o pior: morava no módulo que exporta roleAtLeast
e ROLE_ORDER, ou seja, no código que a próxima pessoa desta família abre antes
de qualquer outra coisa) e DECLAROU as três da api como não corrigidas, por
serem mudança de contrato.
A segunda metade não estava guardada em lugar nenhum, e é o motivo deste ADR:
com a sobreposição valendo para baixo, AddProjectMemberUseCase era um
passthrough de uma linha para o upsert do repositório e RemoveProjectMemberUseCase
só checava se o projeto existia. Nenhum dos dois olhava para QUEM é o alvo nem
para a relação entre ator e alvo. Duas consequências alcançáveis por qualquer
maintainer, hoje:
- rebaixar o
ownerdo workspace. Ummaintainergravaviewerpara o dono num projeto e o dono perde o próprio projeto — a leitura do dashboard, as decisões, tudo. Restaurar exigemaintainer, que o dono acabou de perder naquele escopo; - auto-rebaixamento irreversível. Um
maintainerse grava comoviewere não consegue desfazer, porque desfazer é a mesma rota, que pedemaintainer. Não há caminho de volta pela tela.
Nada disto é escalação de privilégio — ninguém ganha papel que não tinha. É
perda de acesso, e a diferença importa: um maintainer já podia fazer estrago
no projeto, mas não podia produzir um estado do qual NINGUÉM sai sem ir ao
banco.
O caminho óbvio seria fazer forProject devolver "o maior dos dois" e apagar o
problema na raiz, alinhando o código às três descrições em vez do contrário.
Foi considerado e RECUSADO: restringir alguém num projeto sensível — um
developer de workspace que vira viewer no projeto X — é capacidade
deliberada do produto, e é a única forma que existe de conter acesso por
projeto. "O maior dos dois" a elimina, e o
resolve-effective-role.use-case.spec.ts já fixa a metade de subir
("papel de projeto sobrepõe o de workspace") desde sempre.
Decision
A sobreposição fica nos dois sentidos, e os dois movimentos perigosos passam a
ser recusados. forProject NÃO muda: continua projectRole ?? workspaceRole,
e continua sendo a única composição desse papel no sistema.
Teto 1 — ninguém rebaixa quem é owner do workspace. O papel pedido para
alguém cujo papel de workspace é owner tem de ser owner; qualquer coisa
abaixo é 403. Gravar owner de projeto para quem já é owner de workspace é
redundante e passa — só o rebaixamento é recusado.
O owner é lido de workspace_members.role, nunca de workspaces.created_by.
Os dois existem em db/schema/iam.ts e não são a mesma coisa. created_by é um
fato histórico: quem criou o workspace. É o role que a autorização usa em TODO
o resto do sistema — ResolveEffectiveRoleUseCase.forWorkspace é
workspaces.findMemberRole, e created_by não aparece em nenhum caminho de
autorização (aparece em get-credential-spend.use-case.ts, para saber de quem é
a chave de LLM que se gasta, que é outra pergunta). Um teto que lesse
created_by protegeria a pessoa errada em dois casos concretos e opostos: o
criador que já transferiu a propriedade e hoje é developer ficaria blindado
sem sê-lo, e o owner corrente que não criou nada — o caso normal de workspace
com mais de um dono, e o de propriedade transferida — ficaria desprotegido, que
é exatamente o buraco que o teto existe para fechar.
Teto 2 — ninguém rebaixa a SI MESMO. Se o alvo é o próprio chamador e o papel pedido é menor que o papel efetivo que ele tem HOJE no projeto, é 403.
A formulação é essa, sem limiar. A alternativa considerada era "não se
auto-rebaixar abaixo de maintainer", com o argumento de que maintainer é
o papel que desfaz. As duas recusam o movimento perigoso, mas a com limiar é uma
regra de domínio que copia um número de um @RequireRole de controller:
envelhece calada no dia em que a rota mudar de mínimo, e não se enuncia sem
explicar de onde veio o maintainer. "Ninguém rebaixa a si mesmo" é uma
cláusula, não tem número para envelhecer, e é a MESMA forma do teto 1 — quem,
não quanto. O preço é um movimento inofensivo que cai junto: um owner se
pondo como maintainer no próprio projeto seria reversível, e passa a ser
recusado. Continua alcançável por outro maintainer, e o custo de enunciar a
regra com exceção é maior que o de perder o movimento. Subir o próprio papel
não é rebaixamento e não é tocado aqui.
Onde os tetos moram: no caso de uso, com a regra no domínio. Não no
RolesGuard. O guard responde outra pergunta — autoriza o CHAMADOR contra o
@RequireRole da rota — e não vê corpo (dto.role) nem alvo (dto.userId),
enquanto os dois tetos são sobre o ALVO e sobre a relação ator↔alvo. Um guard
que precisasse do corpo teria de conhecer o DTO de cada rota, que é a fronteira
que ele existe para não cruzar.
A FORMA segue o precedente que o repositório já tem para teto absoluto:
domain/actions/decide.ts (RN-154, e a RN-418 que revisou a RN-106) — função
pura, no domínio, mensagem ao lado da condição, sem chave de configuração e sem
"sempre permitir". O novo apps/api/src/domain/iam/tetos-de-rebaixamento.ts é
essa forma aplicada aqui, e AddProjectMemberUseCase a chama antes de escrever.
O que NÃO se transporta do precedente é o desfecho: em decide.ts o teto vira
require_approval sobre uma proposed_action de agente, porque existe fila de
aprovação humana do outro lado. Aqui a chamada já É humana e síncrona, não há
fila para onde mandá-la, e o desfecho é a recusa.
Status: 403 nos dois. É recusa de AUTORIZAÇÃO — o chamador tem o papel da
rota e não tem autoridade para este movimento específico. Não é 409, porque não
é conflito com um estado que passa (esperar não muda nada); não é 400, porque o
corpo é válido e o mesmo corpo com outro alvo passaria. ForbiddenException com
mensagem própria é o que os outros usos de política na api já fazem
(ActivateAgentUseCase, CreateSocketTicketUseCase, RegisterUseCase), e a
mensagem chega à tela: a #444 pôs try/catch com mensagemDaApi nas duas ações
que falhavam caladas.
As três descrições de OpenAPI que a RN-471 declarou passam a descrever o que o
código faz — as de POST :projectId/members, de AddMemberDto.role e o
resumo de GET :projectId/members, que prometia "includes whoever inherits
access from the workspace" enquanto listMembers é um innerJoin em
project_members. openapi.json e api-types.generated.ts saem do gerador.
O lado web fica FORA desta PR, e não por tamanho. A tela consegue calcular o
teto 2 sozinha (userIdDaSessao() e o papel efetivo já estão lá desde a #444),
mas não consegue calcular o teto 1: listProjectMembers devolve o papel da
LINHA DE PROJETO, e o papel de WORKSPACE do alvo não está ao alcance de consulta
nenhuma do cliente. Desabilitar metade das opções produziria um Select honesto
sobre uma recusa e caladamente errado sobre a outra — a segunda fonte de papel
inventada na tela contra a qual a própria RN-471 escreve. Enquanto isso, a
recusa APARECE: handleRoleChange já mostra mensagemDaApi(erro, …) num toast,
e é a frase da api que explica QUAL teto bateu.
Consequences
A sobreposição continua sendo faca de dois gumes, e é o que se comprou.
Nenhum destes dois tetos torna projectRole ?? workspaceRole seguro em geral;
eles recusam dois movimentos nomeados. Um maintainer continua podendo rebaixar
qualquer pessoa que NÃO seja owner de workspace e que não seja ele mesmo —
inclusive outro maintainer, inclusive de forma que a vítima não desfaz. Isso é
a capacidade, não um descuido: é a mesma mecânica que restringe um developer a
viewer num projeto sensível, e não há como manter uma sem a outra. O que mudou
é que o estado do qual NINGUÉM sai (dono fora do próprio projeto; único
maintainer se apagando) deixou de ser alcançável.
Segue possível, com nome:
- rebaixar outro
maintainerque não éownerde workspace, sem consentimento dele e sem volta pela mão dele; - auto-rebaixamento pela REMOÇÃO.
RemoveProjectMemberUseCaseNÃO ganhou teto, e isso é um movimento a mais, fora dos dois escolhidos. Remover a própria linha derruba o efetivo para o papel de workspace: quando o workspace segura (maintainerlá), é benigno e é justamente como alguém sai da lista; quando NÃO segura — omaintainercuja autoridade vinha da linha de projeto, comviewerno workspace — é auto-rebaixamento irreversível pela outra porta. A premissa de que "a remoção é sempre benigna" é falsa, e fica escrita aqui para não ser redescoberta. Os dois casos estão FIXADOS em teste (tetos-de-rebaixamento.use-case.spec.ts), inclusive o que segue aberto, para a próxima pessoa saber que é decisão e não esquecimento. Fechá-lo custa a assinatura deRemoveProjectMemberUseCase.execute, que hoje é chamada porlist-projects-and-members.use-case.spec.tscom dois argumentos; - 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; - 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.
A tela oferece o que a api recusa, por enquanto. É exatamente o defeito que
as últimas PRs desta família fecharam, e ele volta por uma PR, de propósito:
metade do gate é impossível no cliente hoje (ver Decision). A PR do web precisa
decidir entre expor o papel de workspace do alvo em GET :projectId/members —
mudança de contrato, e a RN-471 já registrou que a rota promete um innerJoin
que não faz — e apagar só o que ela sabe. Até lá, a recusa é dita pelo toast,
com a frase da api.
Um 500 virou 404. AddProjectMemberUseCase passou a chamar
projects.findById (precisa do workspaceId para ler o papel do alvo) e lança
NotFoundException quando o projeto não existe, como ListProjectMembersUseCase
e RemoveProjectMemberUseCase já faziam. Pelo HTTP é inalcançável — o
RolesGuard recusa antes com 403 —, mas o caso de uso deixou de presumir o
chamador.
Duas consultas a mais por associação. findById, findMemberRole no
workspace e o forProject do ator (que refaz o findById). É uma rota de
administração, chamada uma vez por convite, e a alternativa — reaproveitar o
request.effectiveRole que o guard já calculou — amarraria o caso de uso ao
estado do guard, que é o acoplamento que a escolha de ONDE o teto mora existe
para evitar.
As três descrições da OpenAPI mudam sem mudar nenhuma resposta. Quem
integrava lendo a promessa antiga ("higher of") já estava errado desde a Fase 1;
o contrato gerado agora descreve o comportamento real, e o 403 dos dois tetos
é novo de verdade para quem chama a rota.