Seu link de convite é uma senha que você colou no Slack

security

Uma engenheira nova começa na segunda-feira. Você cola o link de entrada no canal de onboarding, ela clica e já está dentro — sem troca de e-mails, sem esperar um e-mail de verificação que foi parar no spam.

Quatro meses depois, o canal continua lá, e o link também. E ele ainda funciona. Funciona para o prestador de serviço que entrou no canal na sexta semana, para qualquer pessoa a quem ele repassou o link e para quem um dia abrir o arquivo exportado do canal.

Um link de convite é uma credencial ao portador. Ele não sabe quem você é, só que você o tem. Essa é toda a graça e todo o problema. A seguir: o que um link precisa ter antes de ser seguro colá-lo em qualquer lugar, o que lancei no Kookee e as partes que foram mais difíceis do que pareciam.

Um convite por e-mail tem endereço. O sistema já sabe quem pode entrar — o endereço para onde mandou o e-mail —, e o token desse e-mail não serve para mais ninguém. É exato e é lento: alguém tem que digitar os endereços, o e-mail tem que chegar, o destinatário tem que encontrá-lo.

O link faz o caminho inverso: é instantâneo e não tem destinatário. Quem abrir é quem entra.

A maioria das equipes quer os dois, pelo mesmo motivo que quer uma lista de convidados e uma porta. O erro é lançar o link como se fosse o convite sem a parte de digitar.

Quem pode usar? Sem resposta, a descrição honesta do recurso é “qualquer pessoa na internet que vir esta string entra no seu projeto”. Um link que pode ser restrito aos domínios de e-mail que os seus colegas usam de verdade é um objeto diferente de um link sem essa opção.

Por quanto tempo ele fica aberto? Todo link que nunca expira acaba virando um link que ninguém lembra de ter criado. O padrão importa mais que a opção: se “nunca” for o padrão, a maioria dos links vai ser “nunca”.

Quem pode revogar? Revogar tem que ser um clique, e a lista de links ativos tem que estar num lugar onde alguém vai olhar. Uma credencial que você não consegue listar é uma credencial que você não consegue revogar.

O que ele concede? A resposta deve ser a função mais baixa que ainda seja útil, sempre. Um link que pode dar direitos de administrador é um link que pode dar o poder de criar mais links.

Uma regra de domínio não vale nada sem um endereço verificado

O jeito óbvio de restringir um link é limitá-lo a um domínio de e-mail. A parte nada óbvia é que a restrição só tem sentido se o endereço foi comprovado.

TypeScript
if (link.allowedDomains?.length) {
  const allowed = link.allowedDomains.includes(domainOf(user.email));
  if (!user.emailVerified || !allowed) throw Forbidden('INVITE_LINK_DOMAIN_MISMATCH');
}

Tire a verificação de emailVerified e o recurso se inverte: qualquer pessoa que abrir o link se cadastra como someone@yourcompany.com, nunca abre o e-mail de confirmação e entra. A regra de domínio viraria só uma pista de quem você esperava que entrasse.

Por baixo dela, há duas decisões menores.

Os itens da lista de permissão são comparados por máquina, não lidos por uma pessoa. O “а” cirílico é um caractere diferente do “a” latino e aparece idêntico na tela, então um homóglifo na lista de permissão é uma porta aberta que parece fechada. Os itens só aceitam letras ASCII, dígitos e hifens, e um domínio internacionalizado entra na forma punycode:

TypeScript
// Letras ASCII, dígitos e hifens, com um TLD. Domínios IDN entram como xn--…
const DOMAIN = /^(?!-)[a-z0-9-]{1,63}(?<!-)(\.(?!-)[a-z0-9-]{1,63}(?<!-))+$/;

O domínio que você guarda tem que ser o domínio que você compara. O Kookee normaliza os endereços na entrada, e googlemail.com é a mesma caixa de e-mail que gmail.com. Uma regra digitada como googlemail.com, então, nunca bateria com ninguém — parece certa, salva sem reclamar e recusa todo mundo em silêncio. Converter o domínio na hora de criar a regra leva três linhas e elimina um chamado de suporte que seria impossível de diagnosticar de fora.

Um admin cria um link em março. Em junho, essa pessoa muda de equipe e é rebaixada a membro, ou sai da empresa e é removida, ou é banida. O link continua no canal.

Há duas abordagens defensáveis. Verificar o criador na hora da entrada — ler a função atual dele a cada entrada e recusar se ele não a tiver mais — ou revogar o link quando o evento acontece. A primeira parece mais correta e é pior: acrescenta uma consulta a cada entrada e precisa responder perguntas incômodas sobre o que significa o link de um proprietário depois que o projeto troca de dono.

Escolhi revogar no evento. Remover um membro, rebaixá-lo de proprietário ou admin, ou bani-lo revoga todos os links que ele criou, na mesma transação do banco de dados que a própria mudança de participação. A regra por trás disso é fácil de dizer em voz alta: um link é uma extensão da autoridade de quem o criou, e termina quando essa autoridade termina.

Vagas são a outra metade da questão

Um link que qualquer um pode repassar, apontado para um plano com número fixo de vagas, também é um jeito de gastar o dinheiro de outra pessoa. Por isso, a verificação de vagas roda na entrada, não só no convite:

TypeScript
const seatsInUse = members + pendingInvitations;
if (seatsInUse + 1 > plan.members.maxPerProject) throw Conflict('MEMBER_LIMIT_REACHED');

Contar os convites pendentes é a parte fácil de esquecer. Sem ela, três vagas absorvem dez convites, todo mundo recebe um e-mail de boas-vindas, e os últimos sete recebem um erro ao aceitar — o pior momento possível para dizer a alguém que a equipe está cheia.

Contá-los duas vezes é o bug oposto. Se uma pessoa já foi convidada por e-mail e depois entra por um link, a vaga dela já está reservada; cobrar uma segunda vaga recusa uma entrada que está obviamente certa. O convite pendente dela fica fora da contagem e é encerrado quando ela entra.

O que lancei

Proprietários e admins têm uma seção Links de convite na página da equipe do projeto. Cada link ativo aparece na lista com o que permite e quando expira, com um botão de copiar e um de revogar ao lado.

Página da equipe com um medidor de vagas em 2 de 3 vagas usadas, a lista de membros e dois links de convite: um aberto a qualquer endereço de e-mail e outro restrito a lumen.example

Criar um link são duas decisões: quais domínios de e-mail podem usá-lo e por quanto tempo ele fica aberto. Deixe os domínios em branco e qualquer endereço pode entrar; preencha-os e o link só aceita endereços verificados desses domínios. A validade é de 7 dias, 30 dias ou nunca, e se você não escolher, vale 30 dias.

Diálogo Criar link de convite com o campo Domínios de e-mail permitidos preenchido com lumen.example e o seletor Validade do link em 30 dias

Quem abre o link vê o projeto em que está entrando e a restrição, se houver. A pessoa precisa estar conectada com uma conta de verdade — uma sessão de convidado não pode ocupar uma vaga — e sempre entra como membro, nunca como admin.

A página de entrada de um link restrito por domínio, avisando ao visitante que o link exige um endereço de e-mail verificado em lumen.example

Um limite que você descobre ao esbarrar nele é um relatório de bug

As vagas são um dos vários limites que um plano impõe a um projeto, e o mesmo raciocínio vale para todos: um número que só se descobre ao esbarrar nele chega até você como uma mensagem irritada, e não como um upgrade.

Por isso, todo projeto agora mostra o que o plano permite ao lado do que está usando — vagas, entradas, chaves de API, categorias, tags, tipos de entrada e configurações remotas — e cada lista avisa antes de encher, não depois.

Painel Plano e uso no plano Grátis: vagas na equipe 2 de 3, chaves de API 0 de 10, entradas 5 de 2.000 e medidores de categorias, tags, tipos de entrada e configurações remotas

  • Trate o link como uma credencial ao portador, porque é isso que ele é. Projete-o como projetaria uma senha compartilhada, não como projetaria um e-mail.

  • Restrinja-o a domínios verificados e recuse de cara endereços não verificados. Um endereço não verificado torna a regra de domínio decorativa.

  • Mantenha os itens da lista de permissão em ASCII e normalize-os do mesmo jeito que normaliza os endereços com que eles são comparados.

  • Dê ao link uma validade por padrão e faça de “nunca” uma escolha deliberada, não a fácil.

  • Conceda a função mais baixa que ainda seja útil. Um link nunca deve conseguir criar mais links.

  • Revogue o link quando quem o criou perder a autoridade com que o criou.

  • Conte os convites pendentes no limite de vagas e conte cada pessoa uma vez só.

  • Liste todos os links ativos num lugar onde uma pessoa vai olhar, e faça a revogação caber num clique.


Os projetos do Kookee agora têm links de convite para compartilhar, e cada projeto mostra os limites do seu plano. Veja Convidar sua equipe para um projeto para criar um link, ou Limites de requisições, planos e tratamento de erros para saber o que cada plano permite.