← All posts

Desativamos um padrão perigoso sem migrar uma única linha

Authagonal·July 29, 2026

O provisionamento just-in-time é a funcionalidade que faz o SSO empresarial parecer mágica. Um novo funcionário entra através do provedor de identidade da empresa dele, ainda não existe nenhuma conta para ele na sua aplicação, e uma é criada na hora a partir da asserção. Ninguém abre um chamado, ninguém envia um convite, a pessoa simplesmente começa a trabalhar.

Lida no sentido inverso, é também uma funcionalidade que permite a quem controla esse provedor de identidade criar contas no tenant do seu cliente afirmando que uma pessoa existe. Isso tudo bem quando a conexão está estritamente limitada ao diretório de uma única empresa e todo mundo dentro dele deve ter acesso. É menos tranquilo quando a conexão é um diretório compartilhado, ou um tenant de prestadores de serviço, ou uma dessas federações abrangentes em que o conjunto de pessoas por quem o provedor de identidade se dispõe a responder é muito maior do que o conjunto de pessoas que o seu cliente pretendia deixar entrar.

O nosso vinha ativado por padrão. Não porque alguém tenha decidido, e é justamente nesse ponto que vale a pena se deter. Vinha ativado porque, quando o campo foi adicionado, o booleano que o expressava se chamava DisableJitProvisioning, e um booleano não definido é false, e false significava «não desativar». A leitura mais segura de um padrão que ninguém escolheu é que ele é um acidente, e este tinha se firmado como comportamento.

Para ser preciso quanto à exposição, porque nunca foi tão grave quanto «qualquer um pode criar qualquer um»: dois filtros já rodavam antes do provisionamento. Uma conexão pode carregar uma lista de domínios de e-mail permitidos, e uma asserção fora deles é rejeitada. Uma conexão pode exigir um atributo de convite, e um usuário não convidado é rejeitado. O verdadeiro risco do «ativado por padrão» estava numa conexão sem nenhum dos dois configurados, que é exatamente o formato de uma conexão que alguém montou às pressas para o SSO funcionar.

Virar o padrão é uma palavra. Virá-lo com segurança não é.

A mudança que todos imaginam é renomear o campo para JitProvisioningEnabled e deixá-lo com padrão false. As conexões novas ficam seguras por padrão, pronto.

Só que esse campo é persistido, e há conexões no armazenamento que foram escritas antes de ele existir. As linhas delas não têm a coluna de jeito nenhum. O que acontece com elas depende inteiramente da direção para onde o booleano aponta, porque uma coluna ausente desserializa como false nos dois casos. Sob o antigo nome negativo, ausente significa «não desativado» e o provisionamento continua. Sob um novo nome positivo, ausente significa «não ativado» e o provisionamento para.

Então um simples renomear desativa em silêncio o provisionamento just-in-time para cada conexão que um cliente configurou lá quando estava ativado. Eles não escolheram nada, não foram avisados, e a primeira coisa que ficam sabendo é um funcionário que não consegue entrar, seja qual for a hora em que isso acontece. Isso não é uma melhoria de segurança, é uma queda entregue por deploy.

A resposta óbvia é um backfill: percorrer cada conexão armazenada, escrever a coluna explicitamente e então virar o padrão. Funciona, e é uma migração que você tem de escrever, testar, executar contra o armazenamento de cada tenant, e da qual precisa ter certeza de que terminou em toda parte antes de o código que depende dela ser lançado. Por causa de um booleano.

A dupla negação

Não escrevemos a migração. A coluna armazenada mantém para sempre o seu antigo significado negativo, e o modelo ganha uma propriedade positiva à frente dela:

public bool JitProvisioningEnabled { get; set; }

public bool DisableJitProvisioning
{
    get => !JitProvisioningEnabled;
    set => JitProvisioningEnabled = !value;
}

A propriedade positiva é a de verdade, com armazenamento de verdade por trás, e o padrão dela é false, que é o novo padrão seguro. O nome negativo é agora um alias calculado que inverte nos dois sentidos.

Acompanhe uma linha antiga do início ao fim. A coluna está ausente, então ela é lida como false, então o setter de DisableJitProvisioning roda com false, então JitProvisioningEnabled vira true. A conexão continua provisionando, exatamente como o dono dela configurou, e nada foi migrado. Acompanhe uma conexão nova do início ao fim. Ninguém define nenhuma das duas propriedades, JitProvisioningEnabled fica no seu padrão false, e a conexão rejeita usuários desconhecidos até alguém aderir.

Os dois comportamentos saem do mesmo código, sem ramificação, sem flag de versão e sem nenhum dado tocado. O bit persistido nunca mudou de significado. Só o campo em que ele cai é que mudou, e a inversão acontece num setter de propriedade que roda a cada carregamento.

O que custou

Isso não sai de graça, e a conta chega na fronteira da API. As duas propriedades são públicas, então as duas são serializadas, e um cliente que lê uma conexão, muda algo e a escreve de volta está agora enviando duas propriedades que descrevem a mesma coisa. A desserialização as aplica na ordem em que aparecem no payload, então a última vence. Ponha a propriedade positiva em true deixando uma negativa desatualizada no objeto que você buscou, e a sua mudança é silenciosamente desfeita por um campo que você nem achava que estava enviando.

Descobrimos do jeito que se descobrem essas coisas, num teste que ligava a flag e depois afirmava que ela estava ligada. A regra que saiu disso é definir as duas formas explicitamente em qualquer read-modify-write, o que o nosso próprio teste de ponta a ponta agora faz, com um comentário que explica por quê. Se você adotar esse truque, coloque isso no orçamento. Um alias de mão dupla te dá uma migração de graça e te cobra uma ambiguidade no fio.

O bug que a virada revelou

Aqui está a parte que se generaliza para além dos booleanos. Enquanto fazíamos a mudança, descobrimos que o endpoint de administração para criar uma conexão OIDC nunca tinha definido essa flag. Não de forma incorreta, não com o valor errado. Ele simplesmente nunca a atribuía, e o objeto da requisição não tinha nenhum campo para atribuir.

Isso ficou invisível enquanto o padrão foi o valor que todo mundo queria. Cada conexão saía provisionando, que é o que o código que esqueceu de ligar teria produzido de qualquer jeito, então não havia nada a notar nem teste que pudesse falhar. No instante em que o padrão virou, essa mesma lacuna virou «cada conexão OIDC recém-criada está com o provisionamento desligado e sem jeito de ligar», o que não é um bug sutil de forma alguma.

Um padrão é o valor de todo caminho de código que esqueceu de definir o campo. Enquanto o padrão é conveniente, esses caminhos são indistinguíveis dos que definem o campo de propósito. Mudar um padrão não muda só o comportamento novo, ele revela a fotografia: tudo que se apoiava em silêncio no padrão fica visível de uma vez, e uma parte está quebrada.

A caixa de seleção que não se moveu

O último lugar onde um padrão se esconde é a interface do usuário. O nosso portal tinha uma caixa de seleção que dizia «Desativar provisionamento JIT», desmarcada por padrão. Agora ela diz «Ativar provisionamento JIT», e continua desmarcada por padrão. O mesmo widget na mesma posição com o mesmo estado inicial, e o significado oposto.

Esse é um tipo de mudança genuinamente perigoso, então a visão em lista ganhou um selo. Toda conexão que não aderiu agora é rotulada, de modo que o estado fica visível sem abrir nada, em vez de ser inferido de uma caixa desmarcada que antes significava o contrário.

E quando uma conexão com o provisionamento desligado recebe uma asserção de alguém desconhecido, o usuário não é jogado num rastreamento de pilha. Ele volta para a aplicação de onde veio com um erro dizendo que a conta não foi encontrada e para contatar o administrador, que é a versão verdadeira e acionável do que acabou de acontecer.

Padrões são superfície de API, herdada por quatro populações

Padrões são superfície de API. São herdados por linhas armazenadas anteriores ao campo, por arquivos de configuração que o omitem, por caminhos de código que nunca o definem, e por controles de interface cujo estado desmarcado os codifica. Antes de mover um, enumere essas quatro populações e decida, para cada uma, se ela deve seguir o novo padrão ou manter o comportamento antigo. Geralmente a resposta é diferente para cada uma, e é aí que está o trabalho de projeto.

E se você perceber que está prestes a escrever uma migração de dados para mover um booleano, veja primeiro se o significado pode ficar parado enquanto o nome e o padrão se movem à frente dele. O armazenamento é o lugar caro para mudar de ideia. Um setter de propriedade é o barato.

Se você prefere que o seu provedor de identidade já venha com os padrões cuidadosos escolhidos, o Authagonal faz cada conexão SSO aderir explicitamente ao provisionamento, e diz claramente quais aderiram.