ADR 0157 — O teto de auto-movimento no upsert de workspace, e a auto-promoção que era brecha
Status
Accepted. É o TERCEIRO da linha aberta pelo
ADR 0127 e continuada pelo
ADR 0156. Fecha o último
movimento que os dois deixaram declarado por escrito, e revisa uma frase do
0127: a auto-PROMOÇÃO, que ele registrou nas Consequences como capacidade que
ficava, passa a ser recusada. ADR aceito não se edita — este o referencia e diz
o que mudou. Corresponde ao BRB-002, P1 de segurança do registro do
mantenedor.
Context
Os dois ADRs anteriores fecharam as três portas de project_members — as duas
de escrita e a de remoção — e cada um deles apontou, com todas as letras, para o
que ficava aberto. O 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.
E o 0156, reafirmando:
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.
O defeito, medido. AddWorkspaceMemberUseCase era um passa-adiante de doze
linhas: execute(workspaceId, userId, role) chamando workspaces.addMember.
Não recebia o ator, e o controller
(@Post(':workspaceId/members'), @RequireRole('owner')) chamava com o corpo
puro, sem @CurrentUser() — não havia com o que aplicar teto nenhum, do mesmo
jeito que não havia na remoção antes do 0156.
É a mesma classe de defeito um escopo ACIMA, e é mais grave por duas razões que não existem no projeto:
- não há nível acima para segurar a queda. No projeto, rebaixar-se derruba
o efetivo para o papel de workspace, que muitas vezes o segura. No workspace
o papel é o papel: um
ownerque se gravaviewerperde o workspace inteiro, e com ele todos os projetos, o gasto de LLM que é dele por RN-060, e a própria capacidade de convidar quem o reponha; - não existe rota de remoção de membro para desfazer por outro caminho.
WorkspacesControllernão tem@Deletede membro — o 0156 já tinha medido isso e usado como argumento para não estender o teto naquela PR. Desfazer é ESTA mesma rota, que pede oownerrecém-abandonado. Não há caminho de volta pela tela, nem pela api.
A segunda metade: a auto-promoção. O ADR 0127 escreveu, na lista do que seguia possível:
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.
Havia teste fixando isso — "subir o próprio papel não é rebaixamento e continua
passando" —, com um maintainer de workspace se gravando owner do projeto.
A decisão de não abrir a questão naquela PR estava certa pelo processo, e a
resposta, agora que ela está aberta, é que é brecha. As duas metades são o
mesmo movimento — uma pessoa decidindo sozinha qual autoridade tem —, e a de
cima é a única das duas que escala privilégio: o 0127 pôde dizer que os
tetos dele não eram sobre escalação ("ninguém ganha papel que não tinha"), e
sobre a auto-promoção isso é falso. Um maintainer vira owner de projeto sem
que ninguém o promova, e num workspace com projetos sensíveis é a diferença
entre restringir e ser irrestringível.
Decision
1. O upsert de workspace recebe o ator e recusa o auto-movimento.
AddWorkspaceMemberUseCase.execute vira
(workspaceId, atorId, alvoId, papel) — a mesma ordem que
AddProjectMemberUseCase tem desde o 0127 e que RemoveProjectMemberUseCase
ganhou no 0156 —, o controller passa @CurrentUser(), e mudar o PRÓPRIO papel
ali é 403.
2. O teto NÃO conta owners. Recusar só quando o chamador for o último dono
seria a regra "segura" óbvia, e ela é recusada pelo critério literal do ADR
0127: "se enuncia numa cláusula, não tem número para envelhecer". Uma
contagem envelhece de três maneiras — ela introduz uma leitura a mais que pode
sair de fase com a escrita; ela tem de decidir o que fazer quando o segundo dono
é uma conta de serviço ou uma pessoa que saiu da empresa, que a api não sabe
distinguir; e ela transforma a resposta da rota em algo que depende do estado de
OUTRAS linhas, então a mesma chamada com o mesmo corpo passa hoje e recusa
amanhã sem que nada do que o chamador controla tenha mudado. Quem quiser sair do
próprio papel pede a outro owner, que é o mesmo desfecho que o teto 2 já
produzia no projeto.
E há um argumento mais forte que a estética: a cláusula já produz o invariante
que a contagem existiria para garantir. Um workspace nunca fica sem owner,
porque a rota pede owner e o único ator capaz de tirar o último dono seria ele
mesmo — que é exatamente o movimento recusado. O número não precisa ser contado
porque a forma da regra o torna inalcançável.
3. O teto 1 NÃO tem par neste escopo, e a ausência é decisão. Foi
considerado de verdade, porque aqui — diferente da remoção do 0156 — o
movimento EXISTE: um owner pode rebaixar outro owner. A recusa vem de o
teto 1 ser uma regra sobre inversão de hierarquia, e a inversão não ser
possível neste escopo:
- no projeto,
projectRole ?? workspaceRolefaz a linha de projeto SOBREPOR a de workspace (RN-471), então ummaintaineralcança quem está ACIMA dele. Foi assim que o 0127 descreveu o dano: "o dono perde o próprio projeto (…) restaurar exigemaintainer, que o dono acabou de perder naquele escopo". Quem agia estava ABAIXO de quem sofria; - no workspace isso não acontece.
@RequireRole('owner')é o topo doROLE_ORDER, então quem chama a rota nunca está abaixo de ninguém que ela alcança. A precondição do teto 1 está ausente — não é simetria esquecida, é regra sem sujeito.
E o custo de pôr o par mesmo assim seria alto e irreversível: somado ao teto do
ponto 1 e à ausência de rota de remoção, owner viraria um estado
absorvente — ninguém sairia dele por HTTP nunca, nem por vontade própria nem
pela mão de outro dono. Offboarding de quem deixa a empresa, rotação de
propriedade e correção de um convite errado passariam a exigir escrita direta no
banco. Isso é a classe de estado que o ADR 0127 nasceu para eliminar, montada de
novo com o sinal trocado.
Rebaixar outro owner, portanto, FICA — e é reversível pela mesma rota, por
qualquer dono restante, o que o movimento do projeto não era.
4. A auto-promoção fecha, nas DUAS rotas de associação. O teto 2 deixa de
ser "ninguém rebaixa a si mesmo" e passa a ser "ninguém mexe no próprio papel",
nos dois sentidos, no projeto e no workspace. Reescrever o MESMO papel continua
passando: upsert idempotente não é movimento, e recusá-lo quebraria o
seed.ts, que reescreve papéis de propósito.
5. A régua não é copiada: a comparação vira UM classificador. O módulo
domain/iam/tetos-de-rebaixamento.ts ganha
autoMovimentoDoProprioPapel, que devolve o SENTIDO
('rebaixamento' | 'promocao' | null) em vez de um booleano. Foi ele, e não um
ehAutoPromocao separado, porque quem chama precisa do sentido: a decisão
do ponto 6 exige mensagens diferentes, e dois predicados booleanos fariam cada
caso de uso perguntar duas vezes a mesma comparação para descobrir qual das duas
respondeu. ehAutoRebaixamento continua existindo e passa a ser uma leitura
do classificador (=== 'rebaixamento'), byte a byte no comportamento.
Ela sobrevive por um motivo de comportamento e não de compatibilidade: quem
delega a ela é remocaoEhAutoRebaixamento, e a REMOÇÃO só enxerga a metade de
baixo. Alargá-la faria a remoção recusar também a auto-promoção — e tirar a
própria linha de projeto que restringia alguém é justamente como se desfaz a
restrição que o teto 1 impede de criar, o movimento benigno que o ponto 3 do ADR
0156 protegeu explicitamente. Unificar tudo teria mudado, de passagem, uma porta
que este ADR não abriu.
6. As mensagens são próprias por SENTIDO e por ESCOPO. São quatro, e
nenhuma serve para duas coisas: quem tentou se promover não recebe a frase de
rebaixamento (não foi o que fez), e quem esbarra no teto do workspace não é
mandado falar com um maintainer — lá o papel que desfaz é owner. É a mesma
disciplina do ponto 5 do ADR 0156 e da RN-470:
não colapsar dois estados por eles compartilharem um desfecho.
7. O papel do ator vem do repositório, não do
ResolveEffectiveRoleUseCase. É o inverso da escolha do 0156, pela mesma
razão que ela deu ("nenhuma dependência a mais por uma leitura"): nos casos de
uso de projeto o ResolveEffectiveRoleUseCase entra para o papel do ator não
virar uma segunda composição de projectRole ?? workspaceRole escrita à mão;
no workspace não há composição a proteger — forWorkspace É
workspaces.findMemberRole —, e este caso de uso já tem o repositório na mão,
porque é com ele que escreve.
8. 403, no caso de uso, nunca no RolesGuard. Sem novidade: o guard
autoriza o CHAMADOR contra o @RequireRole da rota e não vê corpo (dto.role)
nem alvo (dto.userId), e este teto é sobre a relação ator↔alvo. O raciocínio
inteiro está no ADR 0127 e não se repete aqui.
9. A promoção é aplicada mesmo sendo inalcançável por HTTP nesta rota. Com
@RequireRole('owner'), o ator já está no topo e não há para onde subir — a
metade de cima do teto, no escopo de workspace, é mecanismo que hoje não
dispara. Ela entra assim mesmo, e testada, pelo motivo que o teto 2 sempre teve
para não ter limiar: o caso de uso não presume o guard, e é ele que fica certo
no dia em que a rota mudar de mínimo. É o mesmo desenho da recusa por capacidade
do RN-514, implementada e testada antes de ter
disparo.
O que este ADR recusa explicitamente
- Contar owners. Ver o ponto 2. Nem "só o último", nem "só se não sobrar outro dono ativo". A cláusula não tem número para envelhecer, e já produz o invariante de nunca haver zero owners.
- Um teto de
ownerdo workspace neste escopo. Ver o ponto 3. Não é simetria esquecida nem descarte por reflexo: é regra sobre inversão de hierarquia numa rota onde a inversão é impossível, e pô-la faria deownerum estado do qual ninguém sai por HTTP. - Uma rota de remoção de membro de workspace. A ausência dela é parte do contexto deste ADR (é por ela que o auto-rebaixamento não tem volta), e criá-la seria abrir uma porta nova enquanto se fecha outra — e a porta nova nasceria precisando do mesmo teto, mais o julgamento sobre remover o último dono, que este ADR não tem material para fazer. Decisão separada, se alguém a pedir.
- Alargar o teto da REMOÇÃO de membro de projeto para a promoção. Ver o ponto 5. O ADR 0156 protegeu esse movimento por escrito e ele fica byte a byte.
- Mexer em
ResolveEffectiveRoleUseCase.forProject. Terceiro ADR seguido em que isto é dito:projectRole ?? workspaceRolefica nos dois sentidos. - Gate na tela. Não há tela:
POST /workspaces/:workspaceId/membersnão tem chamador noapps/web— medido; a única ocorrência é o tipo gerado emapi-types.generated.ts. Quem chama a rota hoje é oseed.ts(pelo caso de uso, com oownercomo ator) e integrações diretas. Não há toast para ajustar, e nenhuma linha de web muda.
Consequences
Um maintainer legítimo que precise de owner no projeto passa a depender de
outra pessoa. É o custo direto da decisão do ponto 4, e não é hipotético: era
o caminho pelo qual alguém com o papel da rota se dava o papel de que precisava
para a tarefa seguinte. Continua alcançável — por qualquer outro maintainer do
projeto, e por qualquer owner do workspace —, e o que se compra é que a
elevação de papel passe a ter duas pessoas. É a mesma troca que o ADR 0127 fez
para descer, com o mesmo preço na mesma moeda.
A frase do ADR 0127 sobre a auto-promoção fica FALSA, e é este ADR que a
substitui. Ela segue escrita lá, como toda decisão superada neste
repositório; quem a ler tem de chegar até aqui. É por isso que a linha do índice
e o docblock de ehAutoRebaixamento nomeiam a revisão.
O teste que fixava a permissão foi INVERTIDO, não apagado, com o nome guardando a origem — "subir o próprio papel era permitido por ser 'não rebaixamento', e passa a ser 403 (ADR 0157)". Mesma forma que o 0156 usou para o teste de lacuna da remoção. É a evidência de por que a capacidade existiu, e ela não se joga fora.
Uma consulta a mais por associação de workspace, e só uma: o papel do ator. A rota é de administração e a comparação é pura, então o custo é o mesmo que o 0127 aceitou no projeto (onde eram duas) e o 0156 na remoção (duas).
A assinatura mudou, e havia DOIS chamadores — o controller e
apps/api/src/db/seed.ts, que passa owner.id como ator. Ali o ator e o alvo
são pessoas diferentes de propósito, então nenhum teto é alcançado e o seed não
ganha exceção nenhuma. CreateWorkspaceUseCase não é chamador, e é bom que
não seja: ele grava o criador como owner pelo repositório, dentro da
transação de criação, o que é uma auto-promoção legítima — a única do produto —
e passaria a ser recusada se um dia fosse roteada por aqui. O comentário ao lado
da cláusula de papel nulo registra isso.
A superfície da rota mudou sem mudar nenhuma resposta de sucesso, e está em
docs/security-surface.md: role:owner continua na tabela e continua sendo o
que o RolesGuard aplica, mas deixou de ser a resposta completa para "quem pode
o quê aqui" — a terceira rota desta família a receber essa nota.
Segue possível, com nome: um owner rebaixando OUTRO owner (ponto 3,
reversível pela mesma rota); rebaixar qualquer pessoa que não seja o chamador,
em qualquer escopo, que é a capacidade de sempre; e a auto-PROMOÇÃO pela
remoção da própria linha de projeto — remover a linha que restringia alguém
eleva o efetivo dele, e o ADR 0156 protegeu esse movimento de propósito.