Skip to content

[BUG] Broadcast ActionCable ignora visibilidade de times e causa milhares de GET /conversations/:id 401 em escala #468

@HELIOPOTELICKI

Description

@HELIOPOTELICKI

¿Qué está pasando?

Em uma instância self-hosted em produção com uso intenso de equipes e visibilidade restrita, identificamos um problema de fan-out do ActionCable: eventos de conversas são enviados para usuários que são membros do inbox, mas que não têm permissão para visualizar a conversa pela API HTTP.

O efeito prático é que o frontend recebe eventos como message.created, conversation.updated, assignee.changed ou team.changed para conversas que o usuário não pode abrir. Em seguida, o frontend tenta hidratar a conversa chamando:

GET /api/v1/accounts/:account_id/conversations/:conversation_id

A API responde corretamente com 401 Unauthorized, mas novos eventos continuam chegando via WebSocket e o navegador tenta hidratar novamente. Em escala, isso gera um volume muito alto de requisições 401, aumento de CPU no PostgreSQL, aumento de latência em endpoints de conversas e lentidão generalizada no CRM.

No nosso ambiente, a situação ocorreu com:

  • um inbox principal com aproximadamente 248 membros;
  • 12 administradores na conta;
  • dezenas de times com restrict_visibility_to_assignee = true;
  • conversas atribuídas a times/assignees específicos;
  • vários atendentes com o CRM aberto em múltiplas abas durante o dia.

Durante o incidente, observamos em uma amostra de aproximadamente 14 minutos:

GET /api/v1/accounts/:id/conversations/:id -> 43.438 respostas 401
GET /api/v1/accounts/:id/conversations/meta -> 32.323 chamadas aproximadamente
GET /api/v1/accounts/:id/conversations/unread_counts -> 12.310 chamadas aproximadamente

Um único IP de provedor/NAT do cliente chegou a concentrar mais de 92.000 requests na amostra, mas o IP não era a causa raiz: ele representava vários usuários/abas legítimos do cliente atrás do mesmo provedor.

A causa raiz aparente está no desalinhamento entre:

  1. a lista de destinatários usada nos broadcasts ActionCable; e
  2. a regra de visibilidade aplicada pela API HTTP ao carregar a conversa.

Pelo código observado no runtime, vários métodos do listener montam destinatários com todos os membros do inbox:

user_tokens(account, conversation.inbox.members)

Isso ocorre em fluxos como:

  • message_created
  • message_updated
  • first_reply_created
  • conversation_created
  • conversation_read
  • conversation_status_changed
  • conversation_updated
  • assignee_changed
  • team_changed
  • conversation_contact_changed
  • eventos de typing

Por outro lado, o endpoint HTTP de conversa aplica regras mais restritas, similares ao comportamento de Conversations::PermissionFilterService, considerando inbox, time, participação e a flag teams.restrict_visibility_to_assignee.

Assim, o WebSocket informa o cliente sobre uma conversa que o mesmo cliente não consegue carregar pela API.

Pasos para reproducirlo

  1. Criar ou usar uma instância self-hosted com Mega/Chatwoot v4.17.0.
  2. Criar uma conta com um inbox compartilhado por muitos usuários.
  3. Criar vários times vinculados à conta.
  4. Ativar restrict_visibility_to_assignee nos times.
  5. Manter usuários como membros do inbox, mas não necessariamente como membros de todos os times.
  6. Criar uma conversa no inbox e atribuí-la a um time restrito e/ou a um assignee específico.
  7. Logar no frontend com um usuário que é membro do inbox, mas que não deveria visualizar aquela conversa por regra de time/assignee.
  8. Gerar eventos na conversa restrita, por exemplo nova mensagem, atualização de conversa, alteração de assignee ou alteração de time.
  9. Observar no navegador/rede ou nos logs de proxy que o usuário recebe evento via WebSocket e tenta buscar:
GET /api/v1/accounts/:account_id/conversations/:conversation_id
  1. Observar a resposta 401 Unauthorized.
  2. Repetir o cenário com muitos usuários/abas e conversas ativas. O volume de 401 cresce rapidamente e pode saturar banco/CPU.

Comportamiento esperado

O broadcast via ActionCable deveria respeitar a mesma visibilidade efetiva que a API HTTP usa para conversas.

Para conversas sem restrição de time, manter o comportamento atual de notificar membros do inbox parece adequado.

Para conversas com team.restrict_visibility_to_assignee = true, os eventos deveriam ser enviados apenas para usuários que podem realmente ver a conversa, por exemplo:

  • administradores da conta;
  • assignee atual, quando existir;
  • membros do time, quando a conversa restrita ainda não tem assignee e a regra de produto permitir que membros do time vejam a conversa;
  • participantes explícitos da conversa, se aplicável ao modelo de permissões;
  • contato da conversa em eventos customer-facing, quando aplicável.

Em eventos de troca de time ou assignee, também é importante considerar os destinatários anteriores, para que clientes que antes viam a conversa possam removê-la/atualizá-la corretamente na UI.

Além disso, o frontend deveria ter proteção adicional: se uma tentativa de hidratação retornar 401 ou 403, deveria existir cache negativo/backoff por conversation_id, evitando novas tentativas imediatas para a mesma conversa.

Versión de Mega

sendingtk/chatwoot:v4.17.0

Também reproduzido em imagem derivada dessa versão antes da correção local.

Entorno

Docker / VM Linux self-hosted.

Nosso runtime atual roda em Oracle Cloud Infrastructure, com Docker Compose, Traefik, PostgreSQL e Redis self-managed na VM core.

Plataforma

Navegador.

Sistema operativo

Servidor: Linux em Oracle Cloud Infrastructure.

Clientes: múltiplos usuários em navegadores desktop. O problema é independente do sistema operacional do cliente; ele aparece pelo fluxo WebSocket + API.

Navegador y versión

Reproduzido/observado em navegador desktop moderno. A causa parece ser de backend/frontend application flow, não de uma versão específica de navegador.

Contexto adicional

Evidência operacional

Antes da mitigação local, uma janela de logs mostrou grande volume de 401 em endpoints de conversa:

GET /api/v1/accounts/:id/conversations/:id 401 -> 43.438 ocorrências

Os IDs de conversas com maior volume de 401 pertenciam ao mesmo inbox principal, mas estavam distribuídos entre times restritos diferentes. Exemplos da nossa análise interna:

conversation display_id 731362 -> team restrito, assignee específico
conversation display_id 731949 -> team restrito, assignee específico
conversation display_id 732470 -> team restrito, assignee específico

Essas conversas não eram visíveis para todos os membros do inbox, mas os eventos chegavam para usuários que não podiam carregá-las pela API.

Mitigação local aplicada

Como mitigação emergencial, criamos uma imagem derivada adicionando um initializer Rails que faz prepend no ActionCableListener e substitui a montagem de destinatários dos eventos de conversa/mensagem por uma função baseada em visibilidade efetiva.

A lógica local aplicada foi:

  • administradores continuam recebendo;
  • conversa sem time restrito continua indo para membros do inbox;
  • conversa com time restrito e assignee vai para assignee + administradores;
  • conversa com time restrito sem assignee vai para membros do time + administradores;
  • eventos de mudança de assignee/time incluem também os destinatários anteriores;
  • tokens do contato continuam incluídos em eventos customer-facing.

Após o rollout local e um teste sintético com 100 abas autenticadas por 180s, observamos:

GET /api/v1/accounts/:id/conversations/:id -> 203 x HTTP 200
GET /api/v1/accounts/:id/conversations/:id -> 0 x HTTP 401

O problema de 401 em massa deixou de ocorrer no nosso ambiente após alinhar o fan-out do ActionCable com a visibilidade da conversa.

Risco em escala

Em instalações pequenas, esse problema pode passar despercebido. Em instalações com muitos usuários no mesmo inbox, muitos times restritos e várias abas abertas, cada evento de conversa pode virar múltiplas tentativas de hidratação não autorizada.

Isso amplifica carga em:

  • Rails web;
  • PostgreSQL;
  • endpoint de conversa individual;
  • endpoints de contadores/metadados acionados em cascata pelo frontend.

O comportamento esperado é que usuários que não podem ver uma conversa não recebam eventos que fazem o frontend tentar carregá-la.

Sugestão de correção oficial

Recomendamos corrigir em duas camadas:

  1. Backend / ActionCable

    • Centralizar uma função de visible_user_tokens(account, conversation, changed_attributes: nil).
    • Reutilizar a mesma semântica do filtro de permissão de conversas.
    • Aplicar essa função nos eventos de conversa/mensagem/typing que hoje usam conversation.inbox.members indiscriminadamente.
    • Considerar destinatários anteriores em mudanças de team_id e assignee_id.
  2. Frontend

    • Ao receber 401 ou 403 em getConversation, aplicar cache negativo/backoff por conversation_id.
    • Evitar repetir hidratação imediata da mesma conversa em eventos subsequentes.

Essa correção deve reduzir drasticamente o volume de requests inválidos em ambientes com times restritos e alta concorrência.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    Status
    Done

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions