← All posts

Nous avons désactivé un réglage par défaut dangereux sans migrer une seule ligne

Authagonal·July 29, 2026

Le provisionnement juste-à-temps est la fonctionnalité qui donne au SSO d'entreprise des airs de magie. Un nouveau salarié se connecte via le fournisseur d'identité de son entreprise, aucun compte n'existe encore pour lui dans votre application, et un compte est créé sur-le-champ à partir de l'assertion. Personne n'ouvre de ticket, personne n'envoie d'invitation, la personne se met simplement au travail.

C'est aussi, lu dans l'autre sens, une fonctionnalité qui permet à quiconque contrôle ce fournisseur d'identité de créer des comptes dans le tenant de votre client en affirmant qu'une personne existe. Cela ne pose pas de problème quand la connexion est étroitement limitée à l'annuaire d'une seule entreprise et que chaque personne qui s'y trouve doit avoir accès. C'est plus gênant quand la connexion est un annuaire partagé, ou un tenant de sous-traitants, ou l'une de ces fédérations tentaculaires où l'ensemble des personnes pour lesquelles le fournisseur d'identité accepte de se porter garant est bien plus vaste que l'ensemble des personnes que votre client entendait laisser entrer.

Chez nous, la valeur par défaut était « activé ». Non parce que quelqu'un l'avait décidé, et c'est le point sur lequel il vaut la peine de s'attarder. Elle était sur « activé » parce que, lorsque le champ a été ajouté, le booléen qui l'exprimait s'appelait DisableJitProvisioning, et un booléen non défini vaut false, et false signifiait « ne pas désactiver ». La lecture la plus prudente d'un réglage par défaut que personne n'a choisi est qu'il relève de l'accident, et celui-ci s'était installé en tant que comportement.

Pour être précis sur l'exposition, car ce n'a jamais été aussi grave que « n'importe qui peut créer n'importe qui » : deux garde-fous s'exécutaient déjà avant le provisionnement. Une connexion peut porter une liste de domaines d'e-mail autorisés, et une assertion en dehors de ceux-ci est rejetée. Une connexion peut exiger un attribut d'invitation, et un utilisateur non invité est rejeté. Le vrai risque du « activé par défaut » concernait une connexion sans aucun des deux configurés, ce qui est exactement la forme d'une connexion mise en place à la hâte pour faire fonctionner le SSO.

Basculer le réglage par défaut tient en un mot. Le basculer sans risque, non.

Le changement que tout le monde imagine consiste à renommer le champ en JitProvisioningEnabled et à le laisser à false par défaut. Les nouvelles connexions sont sécurisées par défaut, terminé.

Sauf que ce champ est persisté, et qu'il existe en stockage des connexions écrites avant même son existence. Leurs lignes n'ont pas du tout la colonne. Ce qui leur arrive dépend entièrement du sens dans lequel pointe le booléen, car une colonne absente se désérialise en false dans les deux cas. Sous l'ancien nom négatif, absente signifie « non désactivé » et le provisionnement continue. Sous un nouveau nom positif, absente signifie « non activé » et le provisionnement s'arrête.

Un simple renommage désactive donc en silence le provisionnement juste-à-temps pour chaque connexion qu'un client a configurée à l'époque où il était activé. Ils n'ont rien choisi, on ne les a pas prévenus, et la première chose qu'ils en apprennent est un salarié qui n'arrive pas à se connecter, à n'importe quelle heure que cela survienne. Ce n'est pas une amélioration de sécurité, c'est une panne livrée par déploiement.

La réponse évidente est un backfill : parcourir chaque connexion stockée, écrire la colonne explicitement, puis basculer le réglage par défaut. Cela fonctionne, et c'est une migration qu'il faut écrire, tester, exécuter contre le stockage de chaque tenant, et dont il faut s'assurer qu'elle s'est achevée partout avant que le code qui en dépend ne soit livré. Pour un booléen.

La double négation

Nous n'avons pas écrit la migration. La colonne stockée conserve pour toujours son ancienne signification négative, et le modèle gagne une propriété positive placée devant elle :

public bool JitProvisioningEnabled { get; set; }

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

La propriété positive est la vraie, avec un vrai stockage derrière, et elle vaut false par défaut, ce qui est le nouveau réglage sûr. Le nom négatif est désormais un alias calculé qui inverse dans les deux sens.

Suivez une ancienne ligne de bout en bout. La colonne est absente, donc elle se lit comme false, donc le setter de DisableJitProvisioning s'exécute avec false, donc JitProvisioningEnabled devient true. La connexion continue de provisionner, exactement comme son propriétaire l'avait configurée, et rien n'a été migré. Suivez une nouvelle connexion de bout en bout. Personne ne définit l'une ou l'autre propriété, JitProvisioningEnabled reste à sa valeur par défaut false, et la connexion rejette les utilisateurs inconnus jusqu'à ce que quelqu'un choisisse de l'activer.

Les deux comportements sortent du même code, sans branchement, sans indicateur de version, et sans qu'aucune donnée ne soit touchée. Le bit persisté n'a jamais changé de sens. Seul le champ dans lequel il atterrit a changé, et l'inversion se produit dans un setter de propriété qui s'exécute à chaque chargement.

Ce que cela a coûté

Ce n'est pas gratuit, et la facture arrive à la frontière de l'API. Les deux propriétés sont publiques, donc les deux sont sérialisées, et un client qui lit une connexion, modifie quelque chose et la réécrit envoie désormais deux propriétés qui décrivent la même chose. La désérialisation les applique dans l'ordre où elles apparaissent dans la charge utile, donc la dernière l'emporte. Mettez la propriété positive à true tout en laissant une propriété négative périmée dans l'objet que vous avez récupéré, et votre changement est silencieusement annulé par un champ que vous ne pensiez même pas envoyer.

Nous l'avons découvert comme on découvre ces choses, dans un test qui activait l'indicateur puis affirmait qu'il était activé. La règle qui en est ressortie est de définir explicitement les deux formes lors de tout read-modify-write, ce que fait désormais notre propre test de bout en bout, avec un commentaire qui explique pourquoi. Si vous adoptez cette astuce, prévoyez-le au budget. Un alias à double sens vous offre une migration gratuite et vous facture une ambiguïté sur le fil.

Le bug que la bascule a révélé

Voici la partie qui se généralise au-delà des booléens. En faisant le changement, nous avons découvert que le endpoint d'administration pour créer une connexion OIDC n'avait jamais défini cet indicateur du tout. Pas de manière incorrecte, pas à la mauvaise valeur. Il ne l'assignait tout simplement jamais, et l'objet de requête n'avait aucun champ à assigner.

C'est resté invisible tant que le réglage par défaut était la valeur que tout le monde voulait. Chaque connexion ressortait en provisionnant, ce qui est exactement ce que le code qui avait oublié de le câbler aurait produit de toute façon, il n'y avait donc rien à remarquer et aucun test qui aurait pu échouer. À l'instant où le réglage par défaut a basculé, cette même lacune est devenue « chaque connexion OIDC nouvellement créée a le provisionnement désactivé et aucun moyen de l'activer », ce qui n'est pas du tout un bug subtil.

Un réglage par défaut est la valeur de tout chemin de code qui a oublié de définir le champ. Tant que le réglage par défaut est commode, ces chemins sont indiscernables de ceux qui définissent le champ délibérément. Changer un réglage par défaut ne change pas seulement le comportement nouveau, cela développe la photographie : tout ce qui s'appuyait silencieusement sur le réglage par défaut devient visible d'un coup, et une partie est cassée.

La case à cocher qui n'a pas bougé

Le dernier endroit où se cache un réglage par défaut, c'est l'interface utilisateur. Notre portail avait une case à cocher intitulée « Désactiver le provisionnement JIT », décochée par défaut. Elle indique désormais « Activer le provisionnement JIT », et elle est toujours décochée par défaut. Le même widget à la même place avec le même état initial, et le sens opposé.

C'est un genre de changement véritablement dangereux, alors la vue en liste a gagné un badge. Toute connexion non activée est désormais étiquetée, de sorte que l'état est visible sans rien ouvrir, plutôt que d'être déduit d'une case décochée qui voulait autrefois dire l'inverse.

Et quand une connexion dont le provisionnement est désactivé reçoit une assertion pour quelqu'un d'inconnu, l'utilisateur n'est pas jeté sur une trace de pile. Il retourne vers l'application d'où il venait avec une erreur indiquant que le compte n'a pas été trouvé et qu'il doit contacter son administrateur, ce qui est la version vraie et actionnable de ce qui vient de se passer.

Les réglages par défaut sont de la surface d'API, héritée par quatre populations

Les réglages par défaut sont de la surface d'API. Ils sont hérités par les lignes stockées antérieures au champ, par les fichiers de configuration qui l'omettent, par les chemins de code qui ne le définissent jamais, et par les contrôles d'interface dont l'état décoché les encode. Avant d'en déplacer un, énumérez ces quatre populations et décidez, pour chacune, si elle doit suivre le nouveau réglage par défaut ou conserver l'ancien comportement. En général la réponse diffère pour chacune, et c'est là le travail de conception.

Et si vous vous apprêtez à écrire une migration de données pour déplacer un booléen, regardez d'abord si le sens peut rester en place pendant que le nom et le réglage par défaut se déplacent devant lui. Le stockage est l'endroit coûteux pour changer d'avis. Un setter de propriété est l'endroit bon marché.

Si vous préférez que votre fournisseur d'identité soit livré avec les réglages par défaut prudents déjà choisis, Authagonal fait en sorte que chaque connexion SSO opte explicitement pour le provisionnement, et vous dit clairement lesquelles l'ont fait.