← All posts

Desactivamos un valor por defecto peligroso sin migrar una sola fila

Authagonal·July 29, 2026

El aprovisionamiento just-in-time es la funcionalidad que hace que el SSO empresarial parezca magia. Un nuevo empleado inicia sesión a través del proveedor de identidad de su empresa, todavía no existe ninguna cuenta para él en tu aplicación, y se crea una en el acto a partir de la aserción. Nadie abre un ticket, nadie envía una invitación, la persona simplemente se pone a trabajar.

También es, leída en sentido inverso, una funcionalidad que permite a quien controle ese proveedor de identidad crear cuentas en el tenant de tu cliente afirmando que una persona existe. Eso está bien cuando la conexión está estrictamente acotada al directorio de una sola empresa y toda persona en él debe tener acceso. Está menos bien cuando la conexión es un directorio compartido, o un tenant de contratistas, o una de esas federaciones desbordantes donde el conjunto de personas por las que el proveedor de identidad está dispuesto a responder es mucho mayor que el conjunto de personas que tu cliente quería dejar entrar.

El nuestro estaba activado por defecto. No porque alguien lo hubiera decidido, que es la parte en la que vale la pena detenerse. Estaba activado porque, cuando se añadió el campo, el booleano que lo expresaba se llamaba DisableJitProvisioning, y un booleano sin asignar es false, y false significaba «no desactivar». La lectura más segura de un valor por defecto que nadie eligió es que es un accidente, y este se había asentado como comportamiento.

Para ser precisos sobre la exposición, porque nunca fue tan grave como «cualquiera puede crear a cualquiera»: ya se ejecutaban dos filtros antes del aprovisionamiento. Una conexión puede llevar una lista de dominios de correo permitidos, y una aserción fuera de ellos se rechaza. Una conexión puede exigir un atributo de invitación, y un usuario no invitado se rechaza. El verdadero riesgo del «activado por defecto» estaba en una conexión sin ninguno de los dos configurados, que es exactamente la forma de una conexión que alguien montó deprisa para que el SSO funcionara.

Cambiar el valor por defecto es una palabra. Cambiarlo con seguridad no lo es.

El cambio que todos imaginan es renombrar el campo a JitProvisioningEnabled y dejar que su valor por defecto sea false. Las conexiones nuevas quedan seguras por defecto, listo.

Salvo que ese campo se persiste, y hay conexiones en almacenamiento escritas antes de que existiera. Sus filas no tienen la columna en absoluto. Lo que les ocurre depende por completo de hacia dónde apunte el booleano, porque una columna ausente se deserializa como false en cualquiera de los dos casos. Bajo el antiguo nombre negativo, ausente significa «no desactivado» y el aprovisionamiento continúa. Bajo un nuevo nombre positivo, ausente significa «no activado» y el aprovisionamiento se detiene.

Así que un simple renombrado desactiva en silencio el aprovisionamiento just-in-time para cada conexión que un cliente configuró cuando estaba activado. No eligieron nada, no se les avisó, y lo primero que saben de ello es un empleado que no puede iniciar sesión, a la hora que sea que eso ocurra. Eso no es una mejora de seguridad, es una caída entregada por despliegue.

La respuesta obvia es un backfill: recorrer cada conexión almacenada, escribir la columna explícitamente y luego cambiar el valor por defecto. Funciona, y es una migración que tienes que escribir, probar, ejecutar contra el almacenamiento de cada tenant, y de la que debes estar seguro de que terminó en todas partes antes de que se despliegue el código que depende de ella. Para un booleano.

La doble negación

No escribimos la migración. La columna almacenada conserva para siempre su antiguo significado negativo, y el modelo gana una propiedad positiva por delante de ella:

public bool JitProvisioningEnabled { get; set; }

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

La propiedad positiva es la real, con almacenamiento real detrás, y su valor por defecto es false, que es el nuevo valor por defecto seguro. El nombre negativo es ahora un alias calculado que invierte en ambos sentidos.

Sigue una fila antigua de principio a fin. La columna está ausente, así que se lee como false, así que el setter de DisableJitProvisioning se ejecuta con false, así que JitProvisioningEnabled pasa a true. La conexión sigue aprovisionando, exactamente como la configuró su propietario, y no se migró nada. Sigue una conexión nueva de principio a fin. Nadie asigna ninguna de las dos propiedades, JitProvisioningEnabled se queda en su valor por defecto false, y la conexión rechaza a los usuarios desconocidos hasta que alguien lo active.

Ambos comportamientos salen del mismo código sin ninguna ramificación, sin indicador de versión y sin tocar ningún dato. El bit persistido nunca cambió de significado. Solo cambió el campo en el que aterriza, y la inversión ocurre en un setter de propiedad que se ejecuta en cada carga.

Lo que costó

Esto no es gratis, y la factura llega en la frontera de la API. Ambas propiedades son públicas, así que ambas se serializan, y un cliente que lee una conexión, cambia algo y la vuelve a escribir está enviando ahora dos propiedades que describen lo mismo. La deserialización las aplica en el orden en que aparecen en la carga útil, así que gana la última. Pon la propiedad positiva en true mientras dejas una negativa obsoleta en el objeto que recuperaste, y tu cambio queda deshecho en silencio por un campo que no creías estar enviando.

Lo encontramos como se encuentran estas cosas, en una prueba que activaba el indicador y luego afirmaba que estaba activado. La regla que salió de ahí es asignar ambas formas explícitamente en cualquier read-modify-write, cosa que nuestra propia prueba de extremo a extremo hace ahora con un comentario que explica por qué. Si adoptas este truco, presupuéstalo. Un alias de doble sentido te regala una migración gratuita y te cobra una ambigüedad en el cable.

El bug que destapó el cambio

Aquí está la parte que se generaliza más allá de los booleanos. Mientras hacíamos el cambio, descubrimos que el endpoint de administración para crear una conexión OIDC nunca había asignado este indicador en absoluto. No de forma incorrecta, no con el valor equivocado. Simplemente nunca lo asignaba, y el objeto de la petición no tenía ningún campo que asignar.

Eso fue invisible mientras el valor por defecto fue el valor que todos querían. Cada conexión salía aprovisionando, que es lo que el código que olvidó cablearlo habría producido de todos modos, así que no había nada que notar ni prueba que pudiera fallar. En el instante en que el valor por defecto cambió, esa misma laguna se convirtió en «cada conexión OIDC recién creada tiene el aprovisionamiento desactivado y ninguna forma de activarlo», que no es un bug sutil en absoluto.

Un valor por defecto es el valor de todo camino de código que olvidó asignar el campo. Mientras el valor por defecto es cómodo, esos caminos son indistinguibles de los que asignan el campo deliberadamente. Cambiar un valor por defecto no solo cambia el comportamiento nuevo, revela la fotografía: todo lo que se apoyaba en silencio en el valor por defecto se vuelve visible de golpe, y una parte está rota.

La casilla que no se movió

El último lugar donde se esconde un valor por defecto es la interfaz de usuario. Nuestro portal tenía una casilla que decía «Desactivar aprovisionamiento JIT», desmarcada por defecto. Ahora dice «Activar aprovisionamiento JIT», y sigue desmarcada por defecto. El mismo widget en la misma posición con el mismo estado inicial, y el significado opuesto.

Ese es un tipo de cambio genuinamente peligroso, así que la vista de lista ganó una insignia. Toda conexión que no esté activada queda ahora etiquetada, de modo que el estado es visible sin abrir nada, en lugar de inferirse de una casilla desmarcada que antes significaba lo contrario.

Y cuando una conexión con el aprovisionamiento desactivado recibe una aserción de alguien desconocido, no se abandona al usuario en una traza de pila. Vuelve a la aplicación de la que venía con un error que dice que no se encontró la cuenta y que contacte con su administrador, que es la versión verdadera y accionable de lo que acaba de pasar.

Los valores por defecto son superficie de API, heredada por cuatro poblaciones

Los valores por defecto son superficie de API. Los heredan las filas almacenadas anteriores al campo, los archivos de configuración que lo omiten, los caminos de código que nunca lo asignan, y los controles de interfaz cuyo estado desmarcado los codifica. Antes de mover uno, enumera esas cuatro poblaciones y decide, para cada una, si debe seguir el nuevo valor por defecto o conservar el comportamiento antiguo. Normalmente la respuesta es distinta para cada una, y ese es el trabajo de diseño.

Y si te encuentras a punto de escribir una migración de datos para mover un booleano, mira primero si el significado puede quedarse quieto mientras el nombre y el valor por defecto se mueven por delante de él. El almacenamiento es el lugar caro para cambiar de opinión. Un setter de propiedad es el barato.

Si prefieres que tu proveedor de identidad venga con los valores por defecto cuidadosos ya elegidos, Authagonal hace que cada conexión SSO opte explícitamente por el aprovisionamiento, y te dice con claridad cuáles lo han hecho.