← All posts

Un trait d'union, deux tenants, une seule clé de signature

Authagonal·June 25, 2026
authsecuritymultitenancyisolationsaas

Deux de nos tenants étaient le même tenant. Ils avaient des noms différents, des inscriptions différentes et des lignes de facturation différentes. Ils partageaient aussi une base de données et une clé de signature de jeton, et aucun de nous trois, ni les deux tenants ni nous, n'en avait la moindre idée. Voici l'histoire de la fonction d'une seule ligne qui les a fusionnés, pourquoi chaque couche du système semblait parfaitement correcte pendant que cela se produisait, et pourquoi un audit avant lancement est l'assurance la moins chère que vous achèterez jamais.

L'authentification multi-tenant a exactement une mission qu'elle ne peut pas rater : garder les tenants séparés. Les utilisateurs d'Acme, les sessions d'Acme et surtout la clé de signature d'Acme ne doivent jamais être accessibles à quiconque d'autre. La clé de signature est le joyau de la couronne. Quiconque peut signer avec la clé d'Acme peut forger un jeton que le serveur d'authentification d'Acme acceptera comme authentique, pour n'importe quel utilisateur, avec n'importe quel rôle, sans mot de passe. Ainsi, chaque ressource par tenant que nous créons, chaque table de stockage et chaque clé, est isolée par un espace de noms dérivé du slug du tenant. Réussissez cet espacement de noms et les tenants sont des îles. Ratez-le subtilement et ils deviennent discrètement le même endroit.

Le bug d'une seule ligne

Voici la fonction qui transforme un slug de tenant en le préfixe avec lequel nous nommons son stockage et ses clés. Lisez le commentaire de documentation. Il documente le bug comme s'il s'agissait d'une fonctionnalité.

/// 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("-", ""). Cela supprime les traits d'union. L'intention était d'ordre pratique : les slugs alimentent les noms de tables Azure et les noms de clés Vault, qui ont leurs propres règles de caractères, alors nous les avons assainis. L'ennui, c'est que supprimer des caractères est une transformation avec perte, et une transformation avec perte sur un identifiant n'est pas injective. acme-corp et acmecorp ressortent tous deux en acmecorp. Tout comme ac-me-corp et acme--corp. Des slugs distincts, un seul espace de noms.

Cet espace de noms est tout ce qui suit en aval. La table des utilisateurs est {prefix}-Users. Et la clé de signature par tenant est, textuellement, ceci :

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

Ainsi, acme-corp et acmecorp ne partagent pas seulement une liste d'utilisateurs. Ils signent leurs jetons avec la même clé signing-acmecorp dans Vault. Un jeton forgé pour l'un est, octet par octet, un jeton valablement signé pour l'autre. Si vous pouvez enregistrer un slug qui se réduit au même préfixe qu'un tenant existant, vous pouvez vous délivrer des jetons auxquels leur serveur d'authentification fait entièrement confiance. Prise de contrôle de compte inter-tenants, et l'exploitation consiste à « s'inscrire avec un trait d'union ».

Pourquoi rien ne l'a détecté

Le plus troublant, c'est à quel point chaque couche semblait ordinaire. L'inscription validait le slug et voyait une chaîne neuve, inutilisée. Le provisionnement créait un enregistrement de tenant indexé par le slug complet, acme-corp, qui est véritablement distinct de acmecorp dans le plan de contrôle. La validation des jetons dérivait la clé du slug et vérifiait sans broncher. Chaque composant faisait correctement son travail sur sa propre entrée.

Rien dans le système ne comparait jamais les préfixes de deux slugs, car aucun composant unique ne possédait l'invariant « un slug correspond à exactement un espace de noms ». La collision vivait dans l'interstice entre un slug, sur lequel le plan de contrôle s'indexe, et un préfixe, sur lequel le stockage et Vault s'indexent. Personne ne se tenait dans cet interstice. C'est la signature de la classe de bugs la plus dangereuse : pas une vérification que quelqu'un a oubliée, mais une hypothèse que personne ne savait faire.

La seconde collision, en prime

La même transformation avec perte avait une seconde victime. Nos tenants système internes sont nommés par suffixe : {slug}-admin contient l'équipe portail d'un tenant, {slug}-sandbox contient son environnement de test. Faites passer ceux-là par le même supprimeur de traits d'union et acme-admin devient acmeadmin. Ce qui signifie qu'un client ayant enregistré le slug acmeadmin se réduirait au préfixe du tenant administrateur d'Acme, le seul endroit censé être plus privilégié que le client, et non partagé avec lui.

Une seule fonction de suppression, deux frontières d'isolement distinctes menacées : tenant à tenant, et tenant à son propre plan de contrôle. Quand une seule ligne menace deux frontières sans rapport, c'est le signe d'un bug de cause racine plutôt que d'un bug de surface. Le correctif doit porter sur la transformation, pas sur l'un ou l'autre des symptômes.

Le correctif : injectif par construction

L'instinct est de rendre la fonction de préfixe plus intelligente. Échapper les traits d'union, hacher le slug, l'encoder en base32. Chacune de ces options reste une transformation que vous devez prouver injective pour toujours, face à chaque évolution future, par quelqu'un qui ignore peut-être pourquoi c'est important. Le correctif le moins cher et de loin le plus durable est de retirer entièrement la liberté de la transformation : contraindre l'entrée pour que la transformation soit l'identité.

// 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;

Les slugs sont désormais des lettres minuscules et des chiffres, rien d'autre. Sans trait d'union à supprimer, GetTablePrefix n'a plus rien à faire, prefix == slug est vrai par construction, et deux slugs distincts ne peuvent plus jamais partager un espace de noms. Nous rejetons aussi les noms réservés et tout slug se terminant par admin ou sandbox, ce qui ferme la collision des tenants système dans la même ligne. La vérification de validité est le point étroit où un slug devient pour la première fois un espace de noms, c'est donc exactement là que doit résider la garantie de correspondance un à un.

Nous réaffirmons ensuite la même règle une fois de plus, au point de passage obligé du provisionnement. Un slug peut entrer par deux portes, l'inscription en libre-service et le provisionnement piloté par l'administrateur, et un invariant de sécurité défendu à une seule de ces deux portes n'est défendu à aucune.

Pourquoi nous vous le racontons

Nous avons découvert cela lors d'un audit de sécurité avant lancement de notre propre produit, avant qu'un seul client payant n'existe, ce qui est le seul moment acceptable pour le découvrir. Aucun tenant n'a jamais été fusionné en production. Mais c'est un bug qui rend humble, car ce n'est ni une vérification manquante ni un algorithme faible. C'est une fonction utilitaire qui fait quelque chose de tout à fait raisonnable, assainir une chaîne, à un endroit où « raisonnable » et « injectif » se révèlent être des mots différents.

La leçon a survécu au correctif. Toute fonction qui transforme une entrée contrôlée par l'utilisateur en le nom d'une frontière de sécurité, une table, une clé, un espace de noms, un chemin, doit être injective, et vous devriez l'imposer au point le plus étroit où la correspondance est créée, pas espérer qu'elle survive à chaque couche en aval. Une étape de normalisation, mettre en minuscules, rogner, supprimer, fusionner, est précisément l'endroit où deux identités deviennent discrètement une seule.

C'est le même principe sur lequel tout le produit est bâti. La sécurité n'est pas un palier auquel on accède par diplôme ; c'est le plancher. Chaque garantie d'isolement, chaque clé de signature et chaque fonctionnalité de sécurité que nous livrons, SSO et SAML, SCIM, MFA, export d'audit, est active sur tous les plans, car l'alternative est le genre de chose que l'on découvre à 2 h du matin plutôt que lors d'un audit. Découvrez ce qui est inclus.