← All posts

Un guion, dos inquilinos, una clave de firma

Authagonal·June 25, 2026
authsecuritymultitenancyisolationsaas

Dos de nuestros inquilinos eran el mismo inquilino. Tenían nombres distintos, registros distintos y filas de facturación distintas. También compartían una base de datos y una clave de firma de tokens, y ninguno de los tres, ni los dos inquilinos ni nosotros, tenía la menor idea. Esta es la historia de la función de una sola línea que los fusionó, de por qué cada capa del sistema parecía perfectamente correcta mientras ocurría, y de por qué una auditoría previa al lanzamiento es el seguro más barato que jamás contratarás.

La autenticación multi-inquilino tiene exactamente una tarea que no puede equivocar: mantener separados a los inquilinos. Los usuarios de Acme, las sesiones de Acme y, por encima de todo, la clave de firma de Acme nunca deben ser accesibles para nadie más. La clave de firma es la joya de la corona. Quien pueda firmar con la clave de Acme puede acuñar un token que el propio servidor de autenticación de Acme aceptará como genuino, para cualquier usuario, con cualquier rol, sin contraseña. Por eso cada recurso por inquilino que creamos, cada tabla de almacenamiento y cada clave, lleva como espacio de nombres el slug del inquilino. Acierta con ese espacio de nombres y los inquilinos serán islas. Equivócate de forma sutil y, sin que nadie lo note, pasarán a ser el mismo lugar.

El error de una sola línea

Esta es la función que convierte el slug de un inquilino en el prefijo con el que nombramos su almacenamiento y sus claves. Lee el comentario de documentación. Documenta el error como si fuera una característica.

/// Derive the table name prefix from a tenant slug by stripping hyphens.
/// E.g. "acme-corp" → "acmecorp".
public static string GetTablePrefix(string tenantSlug)
{
    return tenantSlug.Replace("-", "");
}

Replace("-", ""). Elimina los guiones. La intención era de mantenimiento: los slugs fluyen hacia los nombres de Azure Table y los nombres de clave de Vault, que tienen sus propias reglas de caracteres, así que los saneábamos. El problema es que eliminar caracteres es una transformación con pérdida, y una transformación con pérdida sobre un identificador no es inyectiva. acme-corp y acmecorp salen ambos como acmecorp. También lo hacen ac-me-corp y acme--corp. Slugs distintos, un solo espacio de nombres.

Ese espacio de nombres lo es todo aguas abajo. La tabla de usuarios es {prefix}-Users. Y la clave de firma por inquilino es, textualmente, esta:

private string GetKeyName() => $"signing-{ShardRouter.GetTablePrefix(_tenantContext.Slug)}";

Así que acme-corp y acmecorp no solo comparten una lista de usuarios. Firman sus tokens con la misma clave signing-acmecorp en Vault. Un token acuñado para uno es, byte por byte, un token firmado válidamente para el otro. Si puedes registrar un slug que colapse al mismo prefijo que un inquilino existente, puedes emitirte a ti mismo tokens en los que su servidor de autenticación confía por completo. Secuestro de cuentas entre inquilinos, y el exploit es "regístrate con un guion".

Por qué nada lo detectó

Lo inquietante es lo corriente que parecía cada capa. El registro validaba el slug y veía una cadena nueva, sin usar. El aprovisionamiento creaba un registro de inquilino indexado por el slug completo, acme-corp, que es genuinamente distinto de acmecorp en el plano de control. La validación de tokens derivaba la clave a partir del slug y la verificaba sin objeción. Cada componente hacía su trabajo correctamente sobre su propia entrada.

Nada en el sistema comparaba jamás los prefijos de dos slugs, porque ningún componente individual era dueño del invariante "un slug se asigna a exactamente un espacio de nombres". La colisión vivía en el hueco entre un slug, que es lo que indexa el plano de control, y un prefijo, que es lo que indexan el almacenamiento y Vault. Nadie estaba parado en ese hueco. Esta es la firma de la clase de error más peligrosa: no una comprobación que alguien olvidó, sino una suposición que nadie sabía que estaba haciendo.

La segunda colisión, gratis

La misma transformación con pérdida tenía una segunda víctima. Nuestros inquilinos internos del sistema se nombran por sufijo: {slug}-admin contiene el equipo de portal de un inquilino, {slug}-sandbox contiene su entorno de pruebas. Pasa esos por el mismo eliminador de guiones y acme-admin se convierte en acmeadmin. Lo que significa que un cliente que registrara el slug acmeadmin colapsaría sobre el prefijo del inquilino administrativo de Acme, el único lugar que se supone más privilegiado que el cliente, no compartido con él.

Una sola función de eliminación, dos límites de aislamiento distintos en riesgo: inquilino contra inquilino, e inquilino contra su propio plano de control. Cuando una sola línea amenaza dos límites no relacionados, esa es la señal de un error de causa raíz y no de uno superficial. La corrección tiene que recaer sobre la transformación, no sobre ninguno de los síntomas.

La corrección: inyectiva por construcción

El instinto es hacer la función de prefijo más inteligente. Escapar los guiones, hashear el slug, codificarlo en base32. Cada una de esas opciones sigue siendo una transformación que tienes que demostrar inyectiva para siempre, frente a cada cambio futuro, por parte de alguien que quizá no sepa por qué importa. La corrección más barata y mucho más duradera es eliminar por completo la libertad de la transformación: restringe la entrada para que la transformación sea la identidad.

// Lowercase alphanumeric ONLY, no hyphens. Forbidding hyphens makes prefix == slug,
// so distinct slugs can never share a data store or signing key.
if (!slug.All(c => (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9')))
    return false;

Los slugs ahora son letras minúsculas y dígitos, nada más. Sin guiones que eliminar, GetTablePrefix no tiene nada que hacer, prefix == slug se cumple por construcción, y dos slugs distintos nunca pueden volver a compartir un espacio de nombres. También rechazamos los nombres reservados y cualquier slug que termine en admin o sandbox, lo que cierra la colisión del inquilino del sistema en la misma línea. La comprobación de validez es el punto estrecho donde un slug se convierte por primera vez en un espacio de nombres, así que ahí es exactamente donde corresponde la garantía de uno a uno.

Luego volvemos a afirmar la misma regla una vez más, en el punto de estrangulamiento del aprovisionamiento. Hay dos puertas por las que puede entrar un slug, el registro de autoservicio y el aprovisionamiento dirigido por administrador, y un invariante de seguridad defendido en solo una de las dos puertas no está defendido en ninguna.

Por qué te lo contamos

Encontramos esto en una auditoría de seguridad previa al lanzamiento de nuestro propio producto, antes de que existiera un solo cliente de pago, que es el único momento aceptable para encontrarlo. Ningún inquilino llegó a fusionarse en producción. Pero es un error que da humildad, porque no es una comprobación ausente ni un algoritmo débil. Es una función auxiliar que hace algo del todo razonable, sanear una cadena, en un lugar donde "razonable" e "inyectiva" resultan ser palabras distintas.

La lección sobrevivió a la corrección. Cualquier función que convierta una entrada controlada por el usuario en el nombre de un límite de seguridad, una tabla, una clave, un espacio de nombres, una ruta, debe ser inyectiva, y deberías imponerlo en el punto más estrecho donde se crea la asignación, no esperar que sobreviva a cada capa aguas abajo. Un paso de normalización, poner en minúsculas, recortar, eliminar, colapsar, es precisamente donde dos identidades pasan a ser una sin que nadie lo note.

Es el mismo principio sobre el que está construido todo el producto. La seguridad no es un nivel al que se asciende; es el suelo. Cada garantía de aislamiento, cada clave de firma y cada característica de seguridad que enviamos, SSO y SAML, SCIM, MFA, exportación de auditoría, está activa en todos los planes, porque la alternativa es la clase de cosa que encuentras a las 2 de la madrugada en lugar de en una auditoría. Mira lo que se incluye.