Aller au contenu
gaetancottrez.dev

À qui est ce document ? Pourquoi on ne résout jamais un tenant depuis le contenu

Publié: at 08:00 | (9 min de lecture)
À qui est ce document ? Pourquoi on ne résout jamais un tenant depuis le contenu

Table des matières

Ouvrir table des matières

Une question anodine qui ne l’est pas du tout

Si vous développez une plateforme multi-tenant, il y a une question que votre backend se pose des milliers de fois par jour sans que personne n’y prête attention : « à quel client appartient ce qui vient d’arriver ? »

Tant que tout entre par une API authentifiée, la réponse est triviale : le jeton porte l’identité, fin de l’histoire. Mais le jour où votre plateforme grandit, d’autres portes s’ouvrent. Chez moi : un dépôt de fichiers par répertoire pour les clients qui s’intègrent à l’ancienne, et un réseau inter-plateformes qui livre des documents émis par des systèmes tiers — une enveloppe de transport, un document dedans, et personne d’authentifié au sens classique.

Et là, la tentation est immédiate, presque irrésistible : le document lui-même dit à qui il est destiné. Il y a un champ « acheteur » avec un identifiant d’entreprise (pensez SIREN). Il suffit de le lire, de chercher le client correspondant, et hop, le document est rattaché. Logique, non ?

Eh bien c’est exactement comme ça qu’on fabrique une fuite de données entre clients. Je le sais parce que deux fuites de ce type ont été détectées sur la plateforme sur laquelle je travaille — en staging, heureusement, avant tout trafic client. Cet article raconte ce qu’elles m’ont appris, et la doctrine qui en est sortie : la tenance est une décision d’autorité, jamais une lecture de document.

Deux fuites, une seule racine

Fuite n°1. Un document arrive du réseau, émis par un système tiers. Son champ « acheteur » désigne… un autre client de notre plateforme — erreur de saisie chez l’émetteur, ou malveillance, peu importe. Le code de l’époque lisait ce champ pour rattacher le document. Résultat : le document apparaissait chez ce client-là. Un client voyait passer un document qui ne le concernait pas — avec tout ce qu’un document B2B contient de données d’affaires.

Fuite n°2. Plus subtile. L’ordre des contrôles vérifiait l’émetteur avant le destinataire. Conséquence : un document dont l’émetteur était inconnu chez nous échouait tôt, et l’échec était classé… dans la liste « documents rejetés » d’un client qui n’avait rien demandé, choisi par la même logique de rattachement fragile. Pas de contenu exposé cette fois, mais des métadonnées d’un tiers dans l’écran d’un client : une fuite quand même.

Deux bugs différents, deux endroits différents — et pourtant une seule racine, et c’est elle qu’il faut retenir : dans les deux cas, la tenance était dérivée d’une entrée attaquable. Un champ rempli par la contrepartie. Une valeur saisie par quelqu’un que nous ne contrôlons pas. C’est le même péché originel que l’injection SQL : faire confiance à une entrée extérieure pour prendre une décision qui n’appartient qu’à vous. Sauf qu’ici, la décision est « qui a le droit de voir ça » — la pire décision à déléguer.

La doctrine : résoudre depuis ce que VOUS contrôlez

La correction ne consistait pas à « mieux valider le champ ». Elle consistait à changer la question. Au lieu de demander « que dit le document ? », on demande : « que savons-nous, de source sûre, sur l’arrivée de ce document ? » Et il se trouve qu’on sait toujours quelque chose — parce que c’est nous qui avons provisionné le chemin par lequel il est arrivé.

Canal fichiers : le préfixe fait foi. Chaque client intégré en mode fichier dépose dans un espace que nous avons provisionné : ses identifiants de connexion sont à lui seul, et ses fichiers atterrissent sous un préfixe de stockage inbound/<clientId>/…. La tenance se résout depuis l’endroit où le fichier a atterri — un fait que nous avons construit — et jamais depuis ce que le fichier raconte. Deux règles d’accompagnement, non négociables : aucun repli global si les identifiants ne mappent pas un client (« pas trouvé » n’est pas « client par défaut » !), et une résolution nulle ou ambiguë produit un refus avec quarantaine, jamais une devinette. Un fichier en quarantaine se rejoue ; une fuite ne se rattrape pas.

Canal réseau : une échelle de confiance explicite. Pour les documents livrés par le réseau inter-plateformes, il n’y a pas de préfixe — mais il y a mieux qu’un champ de contenu. La résolution suit désormais une échelle à trois clés, par fiabilité décroissante :

  1. L’adresse de substitution que nous avons émise. Notre plateforme délivre à certains clients une adresse technique de réception qui nous appartient. Si l’enveloppe la porte, le rattachement est sans ambiguïté — nous avons créé cette adresse, elle mappe un unique client, et elle survit même au cas tordu où l’identifiant légal de l’entreprise serait un jour réattribué.
  2. Le destinataire porté par l’enveloppe de transport. Réfléchissez à pourquoi ce message nous a été livré : parce que l’adresse du destinataire résout vers notre plateforme dans l’annuaire du réseau — une publication que nous avons faite. Ce n’est pas une déclaration de la contrepartie, c’est un fait d’adressage que nous contrôlons.
  3. L’identifiant écrit dans le contenu — en dernier recours seulement. C’est la valeur remplie par la plateforme d’en face : elle peut être fausse, ou renvoyer par écho vers un client sans aucun rapport.

Le croirez-vous ? Avant ce chantier, la clé n°3 était la clé principale. C’est le monde à l’envers, et c’est pourtant le réflexe le plus répandu, parce que le contenu est la donnée la plus visible. La doctrine tient en une phrase, écrite depuis dans la documentation sécurité du projet : préférer ce que nous avons publié et contrôlons à ce qu’une contrepartie a saisi.

Les trois clés de résolution, par ordre de confiance

On veut du code !

L’échelle de résolution s’exprime très simplement — et c’est voulu : une politique de sécurité doit se lire comme elle s’énonce.

type TenantResolution =
  | {
      tenantId: string;
      via: "substitution-address" | "envelope-recipient" | "content-field";
    }
  | { refused: true };

function resolveTenant(
  envelope: Envelope,
  document: ParsedDocument
): TenantResolution {
  // Clé 1 — l'adresse technique que NOUS avons émise : autoritaire.
  const bySubstitution = substitutionRegistry.lookup(envelope.recipientAddress);
  if (bySubstitution)
    return { tenantId: bySubstitution.tenantId, via: "substitution-address" };

  // Clé 2 — le destinataire de l'enveloppe : un fait d'adressage publié par nous.
  const byEnvelope = tenantDirectory.findByLegalId(envelope.recipientLegalId);
  if (byEnvelope) return { tenantId: byEnvelope.id, via: "envelope-recipient" };

  // Clé 3 — le contenu, saisi par un tiers : dernier recours, jamais prioritaire.
  const byContent = tenantDirectory.findByLegalId(document.buyerLegalId);
  if (byContent) return { tenantId: byContent.id, via: "content-field" };

  return { refused: true }; // on refuse, on ne devine pas
}

Deux détails de ce code sont plus importants qu’ils en ont l’air.

Le champ via d’abord : chaque résolution trace quelle clé a gagné, et cette information part dans les logs (identifiants seulement — jamais le contenu du document, on ne logue pas les données métier). Le jour où la clé n°3 gagne anormalement souvent, c’est un signal d’exploitation : quelque chose cloche dans les adressages, et on le voit avant l’incident.

Le refus ensuite. Il n’y a pas de branche « on prend le client le plus probable ». S’il faut choisir entre servir et se tromper de destinataire, on ne sert pas — le document part en quarantaine, avec une erreur de routage renvoyée à l’émetteur, et une propriété soigneusement vérifiée : cette erreur est non destructive. Le document original n’est pas consommé, pas transformé, pas marqué : quand le vrai destinataire se manifeste, tout est rejouable.

Même esprit du côté du canal fichiers, jusque dans les détails d’hygiène : tout nom de répertoire qui finit interpolé dans une clé de stockage refuse les segments .. — parce qu’un chemin composé avec une entrée tierce, c’est une traversée de répertoire en puissance, c’est-à-dire encore une fois le contenu qui essaie de décider de l’endroit.

Le filet sous le filet

Question légitime : et si malgré tout la clé faible se trompe ? Si le contenu erroné passe la troisième marche et rattache le document au mauvais client ?

C’est là que la défense en profondeur paie. Toutes les recherches en aval de la résolution — « retrouve le dossier auquel ce document se rapporte » — sont scopées par tenant : elles cherchent uniquement dans les données du client résolu. Une résolution erronée ne trouve donc… rien. Le document finit dans un registre de non-corrélés, visible des opérateurs, rejouable plus tard — mais il ne s’affiche jamais dans l’écran d’un autre client. L’erreur de résolution dégrade en non-match, pas en fuite. Si vous ne devez retenir qu’un pattern de cet article pour vos propres systèmes, prenez celui-là : faites que la mauvaise réponse à la question « à qui ? » ne trouve porte ouverte nulle part.

Dernière brique, ma préférée côté méthode : les deux fuites corrigées sont épinglées par des tests négatifs. Pas des tests qui vérifient que le bon client voit le document — des tests qui vérifient que le mauvais client ne voit rien : pas de ligne, pas de transition, pas de notification. Les assertions « ne doit PAS apparaître » sont les gardiennes des fuites fermées ; sans elles, la prochaine refonte rouvrira la porte sans que personne ne s’en aperçoive.

Épilogue

La checklist que je rejoue désormais sur tout système multi-tenant qui reçoit des données de l’extérieur :

Rien de tout ça n’est spécifique à mon domaine : remplacez « facture » par « commande », « dossier patient » ou « déclaration », et le problème est le vôtre. Pour aller plus loin sur la famille « ne faites pas confiance aux entrées », relisez les dangers des injections SQL ; pour l’outillage des permissions par tenant, j’avais présenté CASL ; et pour la série en cours sur les systèmes sous contrainte réglementaire, ça commence avec Un compteur ne fait pas un quota.

Et vous, votre plateforme : quand un document arrive, qui décide à qui il est — vous, ou le document ? Racontez-moi vos résolutions de tenant en commentaire, je me ferai une joie de vous répondre.

Vous pourriez aussi aimer

L'IA code plus vite que vous. Mais qui vérifie ce qu'elle raconte ?

L'IA code plus vite que vous. Mais qui vérifie ce qu'elle raconte ?

Un serveur SFTP sans démon SSH : pourquoi et comment j'ai branché les dépôts de fichiers sur un bucket

Un serveur SFTP sans démon SSH : pourquoi et comment j'ai branché les dépôts de fichiers sur un bucket

Article précédent
Un serveur SFTP sans démon SSH : pourquoi et comment j'ai branché les dépôts de fichiers sur un bucket
Article suivant
Ce que ni les tests unitaires ni les E2E ne voient : l'interblocage qui dormait sous les 16 Kio