← All posts

Wir haben eine gefährliche Standardeinstellung abgeschaltet, ohne eine einzige Zeile zu migrieren

Authagonal·July 29, 2026

Just-in-Time-Provisionierung ist die Funktion, die Enterprise-SSO wie Magie wirken lässt. Ein neuer Mitarbeiter meldet sich über den Identitätsanbieter seines Unternehmens an, es existiert noch kein Konto für ihn in Ihrer Anwendung, und aus der Assertion heraus wird direkt eines angelegt. Niemand erstellt ein Ticket, niemand wird eingeladen, die Person kann einfach arbeiten.

Aus der anderen Richtung gelesen ist es zugleich eine Funktion, die es demjenigen, der diesen Identitätsanbieter kontrolliert, erlaubt, Konten im Tenant Ihres Kunden anzulegen, indem er behauptet, dass eine Person existiert. Das ist in Ordnung, wenn die Verbindung eng auf das Verzeichnis eines einzigen Unternehmens beschränkt ist und jeder Mensch darin Zugriff haben soll. Weniger in Ordnung ist es, wenn die Verbindung ein gemeinsam genutztes Verzeichnis ist, oder ein Auftragnehmer-Tenant, oder eine dieser ausufernden Föderationen, in denen die Menge der Personen, für die der Identitätsanbieter bürgen möchte, viel größer ist als die Menge der Personen, die Ihr Kunde tatsächlich hereinlassen wollte.

Bei uns war die Voreinstellung „an". Nicht, weil das jemand so entschieden hätte, und genau das ist der Punkt, bei dem es sich zu verweilen lohnt. Sie stand auf „an", weil das Boolean, das sie ausdrückte, beim Hinzufügen des Feldes DisableJitProvisioning hieß, und ein nicht gesetztes Boolean ist false, und false bedeutete „nicht deaktivieren". Die sicherste Lesart eines Standards, den niemand gewählt hat, ist, dass er ein Zufall ist, und dieser hier hatte sich als Verhalten festgesetzt.

Um genau zu sein, was die Gefährdung angeht, denn so schlimm wie „jeder kann jeden anlegen" war es nie: Zwei Schranken liefen bereits vor der Provisionierung. Eine Verbindung kann eine Liste erlaubter E-Mail-Domains führen, und eine Assertion außerhalb davon wird abgewiesen. Eine Verbindung kann ein Einladungsattribut verlangen, und ein nicht eingeladener Nutzer wird abgewiesen. Das eigentliche Risiko der Voreinstellung „an" bestand bei einer Verbindung, die keines von beidem konfiguriert hatte, was genau die Form einer Verbindung ist, die jemand schnell eingerichtet hat, um SSO zum Laufen zu bringen.

Den Standard umzulegen ist ein Wort. Ihn sicher umzulegen nicht.

Die Änderung, die sich alle vorstellen, besteht darin, das Feld in JitProvisioningEnabled umzubenennen und es standardmäßig auf false zu setzen. Neue Verbindungen sind von Haus aus sicher, fertig.

Nur wird dieses Feld persistiert, und es gibt Verbindungen im Speicher, die geschrieben wurden, bevor es existierte. Ihre Zeilen haben die Spalte überhaupt nicht. Was mit ihnen geschieht, hängt vollständig davon ab, in welche Richtung das Boolean zeigt, denn eine fehlende Spalte deserialisiert in beiden Fällen zu false. Unter dem alten negativen Namen bedeutet fehlend „nicht deaktiviert", und die Provisionierung läuft weiter. Unter einem neuen positiven Namen bedeutet fehlend „nicht aktiviert", und die Provisionierung stoppt.

Eine schlichte Umbenennung schaltet also stillschweigend die Just-in-Time-Provisionierung für jede Verbindung ab, die ein Kunde konfiguriert hat, als sie noch an war. Sie haben nichts gewählt, sie wurden nicht informiert, und das Erste, was sie davon mitbekommen, ist ein Mitarbeiter, der sich nicht anmelden kann, zu welcher Uhrzeit das auch geschieht. Das ist keine Sicherheitsverbesserung, das ist ein Ausfall, ausgeliefert per Deploy.

Die naheliegende Antwort ist ein Backfill: jede gespeicherte Verbindung durchlaufen, die Spalte explizit schreiben und dann den Standard umlegen. Das funktioniert, und es ist eine Migration, die man schreiben, testen, gegen den Speicher jedes Tenants ausführen und deren vollständigen Abschluss überall man sicherstellen muss, bevor der Code, der darauf angewiesen ist, ausgeliefert wird. Für ein Boolean.

Die doppelte Verneinung

Wir haben die Migration nicht geschrieben. Die gespeicherte Spalte behält ihre alte negative Bedeutung für immer, und das Modell erhält davor eine positive Eigenschaft:

public bool JitProvisioningEnabled { get; set; }

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

Die positive Eigenschaft ist die echte, mit echtem Speicher dahinter, und sie ist standardmäßig false, was der neue sichere Standard ist. Der negative Name ist jetzt ein berechneter Alias, der in beide Richtungen invertiert.

Verfolgen Sie eine alte Zeile hindurch. Die Spalte fehlt, also wird sie als false gelesen, also läuft der Setter für DisableJitProvisioning mit false, also wird JitProvisioningEnabled zu true. Die Verbindung provisioniert weiter, genau wie ihr Besitzer es konfiguriert hat, und nichts wurde migriert. Verfolgen Sie eine neue Verbindung hindurch. Niemand setzt eine der beiden Eigenschaften, JitProvisioningEnabled bleibt bei seinem Standardwert false, und die Verbindung weist unbekannte Nutzer ab, bis jemand sich aktiv dafür entscheidet.

Beide Verhaltensweisen entstehen aus demselben Code, ohne Verzweigung, ohne Versions-Flag und ohne dass Daten angefasst wurden. Das persistierte Bit hat seine Bedeutung nie geändert. Nur das Feld, in dem es landet, hat sich geändert, und die Invertierung geschieht in einem Property-Setter, der bei jedem Laden ausgeführt wird.

Was es gekostet hat

Das ist nicht umsonst, und die Rechnung kommt an der API-Grenze. Beide Eigenschaften sind public, also werden beide serialisiert, und ein Client, der eine Verbindung liest, etwas ändert und sie zurückschreibt, sendet nun zwei Eigenschaften, die dasselbe beschreiben. Die Deserialisierung wendet sie in der Reihenfolge an, in der sie im Payload erscheinen, also gewinnt die letzte. Setzen Sie die positive Eigenschaft auf true und lassen dabei eine veraltete negative in dem Objekt stehen, das Sie abgerufen haben, und Ihre Änderung wird stillschweigend von einem Feld rückgängig gemacht, von dem Sie gar nicht dachten, dass Sie es senden.

Wir haben das so gefunden, wie man solche Dinge findet: in einem Test, der das Flag einschaltete und dann behauptete, es sei an. Die Regel, die dabei herauskam, lautet, bei jedem Read-Modify-Write beide Formen explizit zu setzen, was unser eigener End-to-End-Test nun tut, mit einem Kommentar, der erklärt, warum. Wenn Sie diesen Kniff übernehmen, kalkulieren Sie das ein. Ein wechselseitiger Alias verschafft Ihnen eine kostenlose Migration und berechnet Ihnen eine Mehrdeutigkeit auf der Leitung.

Der Bug, den das Umlegen ans Licht brachte

Hier ist der Teil, der über Booleans hinaus verallgemeinert. Während wir die Änderung vornahmen, stellten wir fest, dass der Admin-Endpunkt zum Erstellen einer OIDC-Verbindung dieses Flag nie gesetzt hatte. Nicht falsch, nicht auf den falschen Wert. Er wies es schlicht nie zu, und das Request-Objekt hatte gar kein Feld, dem es hätte zugewiesen werden können.

Das war unsichtbar, solange der Standard der Wert war, den alle wollten. Jede Verbindung kam provisionierend heraus, was genau das ist, was der Code, der vergaß, es zu verdrahten, ohnehin erzeugt hätte, also gab es nichts zu bemerken und keinen Test, der hätte fehlschlagen können. In dem Moment, als der Standard umgelegt wurde, wurde aus derselben Lücke „jede neu erstellte OIDC-Verbindung hat die Provisionierung aus und keine Möglichkeit, sie einzuschalten", was überhaupt kein subtiler Bug ist.

Ein Standard ist der Wert jedes Codepfads, der vergessen hat, das Feld zu setzen. Solange der Standard bequem ist, sind diese Pfade nicht von denen zu unterscheiden, die das Feld absichtlich setzen. Einen Standard zu ändern ändert nicht nur neues Verhalten, es entwickelt das Foto: alles, was sich stillschweigend auf den Standard verlassen hat, wird auf einmal sichtbar, und ein Teil davon ist kaputt.

Das Kontrollkästchen, das sich nicht bewegte

Der letzte Ort, an dem sich ein Standard versteckt, ist die Benutzeroberfläche. Unser Portal hatte ein Kontrollkästchen mit der Aufschrift „JIT-Provisionierung deaktivieren", standardmäßig nicht angehakt. Jetzt steht dort „JIT-Provisionierung aktivieren", und es ist immer noch standardmäßig nicht angehakt. Dasselbe Widget an derselben Stelle mit demselben Anfangszustand, und der gegenteiligen Bedeutung.

Das ist eine wahrhaft gefährliche Art von Änderung, deshalb erhielt die Listenansicht ein Abzeichen. Jede Verbindung, die nicht aktiviert ist, ist nun gekennzeichnet, sodass der Zustand sichtbar ist, ohne dass man irgendetwas öffnen muss, statt aus einem nicht angehakten Kästchen abgeleitet zu werden, das früher das Gegenteil bedeutete.

Und wenn eine Verbindung mit ausgeschalteter Provisionierung eine Assertion für jemand Unbekannten erhält, wird der Nutzer nicht auf einem Stacktrace abgeladen. Er kehrt zu der Anwendung zurück, aus der er kam, mit einer Fehlermeldung, die besagt, dass das Konto nicht gefunden wurde und dass er sich an seinen Administrator wenden soll, was die wahre und umsetzbare Version dessen ist, was gerade geschehen ist.

Standards sind API-Oberfläche, geerbt von vier Populationen

Standards sind API-Oberfläche. Sie werden geerbt von gespeicherten Zeilen, die dem Feld vorausgehen, von Konfigurationsdateien, die es weglassen, von Codepfaden, die es nie setzen, und von Bedienelementen der Benutzeroberfläche, deren nicht angehakter Zustand sie kodiert. Bevor Sie einen verschieben, zählen Sie diese vier Populationen auf und entscheiden Sie für jede, ob sie dem neuen Standard folgen oder das alte Verhalten behalten soll. Meist ist die Antwort für jede eine andere, und das ist die eigentliche Entwurfsarbeit.

Und wenn Sie feststellen, dass Sie im Begriff sind, eine Datenmigration zu schreiben, um ein Boolean zu verschieben, schauen Sie zuerst, ob die Bedeutung bleiben kann, während der Name und der Standard sich davor schieben. Der Speicher ist der teure Ort, um es sich anders zu überlegen. Ein Property-Setter ist der billige.

Wenn Sie lieber möchten, dass Ihr Identitätsanbieter bereits mit sorgfältig gewählten Standards ausgeliefert wird: Authagonal lässt jede SSO-Verbindung sich aktiv für die Provisionierung entscheiden und sagt Ihnen klar, welche das getan haben.