Skip to content

feat: scope Editor group membership by group (#990 slice 3) - #1048

Merged
milkway merged 2 commits into
mainfrom
feat/990-slice3-scope-groups
Jul 29, 2026
Merged

feat: scope Editor group membership by group (#990 slice 3)#1048
milkway merged 2 commits into
mainfrom
feat/990-slice3-scope-groups

Conversation

@milkway

@milkway milkway commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Fatia 3 de 5 da #990, a mais contida. Implementada por agente codex exec (gpt-5.6-sol, high); diff e gate revisados e reexecutados por mim, com uma correção minha (60f6505).

O que muda

A seção Grupos abre para Editor, limitada a filiação: ele vê só os grupos dele, com membros não-Admin e apps que ele pode tocar, e adiciona/remove membros nesses grupos. Criar, renomear e apagar seguem RequireAdminrewrite_group reescreve users.groups e o access-groups de todos os specs, efeito muito além do escopo de quem chamou.

Detalhe sutil que o agente acertou

As rotas de filiação recebem o grupo no corpo do formulário, não no path — então a proteção não pode vir de um extractor de path: está no valor do form, com 404 para grupo fora do escopo.

E o caso do "adicionar membro" é mais fino do que parece: um membro recém-inscrito ainda não compartilha grupo nenhum com o Editor, então avaliar may_touch_user sobre a filiação atual recusaria a própria operação que se quer permitir. Ele avalia sobre a linha proposta (existente + grupo novo), o que mantém a exclusão de Admin intacta — um Admin proposto continua Admin e é recusado. Está documentado no código.

Verifiquei que remover de um grupo preserva as outras filiações (merge_preserving_out_of_scope), e que um usuário inexistente cai em 404.

Efeito colateral que exigiu julgamento, e a correção que fiz

O agente generalizou a lição da fatia 2 e tornou Role::parse case-insensitive — mudança num primitivo de autorização que eu não havia pedido. Avaliei a direção: antes, uma linha gravada como Admin virava None e, por unwrap_or(Role::Viewer), a conta era rebaixada a Viewer; depois, é reconhecida como Admin.

Conceder privilégio a partir de valor não-canônico anda na direção fail-open, o que me fez hesitar. O que decidiu a favor: consistência entre camadas. A correção da fatia 2 já esconde essas linhas do Editor via lower(role) no SQL; se o Rust acreditasse que a mesma linha é um Viewer, SQL e código discordariam sobre o papel de uma conta — que é como nascem bugs de confused deputy.

Mas isso descobriu o defeito de verdade, que nunca foi a caixa: o unwrap_or(Role::Viewer) era silencioso. O fallback para o menor privilégio está certo; engoli-lo não. Uma conta que "de repente virou Viewer" é mistério impossível de depurar pela interface, e a coluna é TEXT sem CHECK. Agora emite warn com o username e o valor armazenado — só a silêncio saiu.

Outras verificações

  • may_touch_group é o predicado novo no primitivo: irrestrito para Admin/token, exato e case-sensitive para Editor, consistente com Spec::access_allows. Teste inclui "Blue""blue".
  • can_access_section ganhou só "groups" no nível Editor; o restante segue Admin-only e o Viewer continua sem alcançar seção alguma.
  • Os cards de grupo respeitam may_touch_spec, então um app fora do escopo não aparece no card.

Gate reexecutado por mim: cargo test limpo, clippy --all-targets -- -D warnings sem warning, i18n-check OK.

🤖 Generated with Claude Code

milkway and others added 2 commits July 29, 2026 20:33
…990 slice 3)

The slice made `Role::parse` case-insensitive, which is the right call for
consistency: slice 2's SQL filter already hides `Admin`-cased rows from
Editors via `lower(role)`, and having Rust believe the same row is a Viewer
would leave the two layers disagreeing about an account's role — how
confused-deputy bugs start.

Chasing that turned up the actual latent defect, which was never the
casing: `Role::parse(&role).unwrap_or(Role::Viewer)` swallowed the failure.
Falling back to the LEAST privilege is correct, but doing it silently means
an account that suddenly reads as Viewer is an undebuggable mystery from
the UI — and `users.role` is plain TEXT with no CHECK, so a hand-edited row
or an external provisioner (#934) can put anything there.

Now it warns with the username and the stored value. The fallback is
unchanged; only the silence is gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@milkway
milkway merged commit 47f7c21 into main Jul 29, 2026
5 checks passed
@milkway
milkway deleted the feat/990-slice3-scope-groups branch July 29, 2026 23:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant