Une plateforme de gestion multi-succursales, en production, et une deuxième agence à ouvrir. Le nom du client n'apporte rien ici ; le problème, si : 55 entités JPA qui n'avaient jamais eu à se demander à qui appartenait une ligne.
Trois façons de séparer les données
Une base par client, un schéma par client, ou une colonne discriminante.
Les deux premières isolent mieux et coûtent à l'exploitation : autant de migrations, de sauvegardes et de connexions que de clients. La troisième ne coûte presque rien à exploiter, et fait porter toute l'isolation au code — un where oublié, et un client lit les données d'un autre.
C'est la troisième qui a été retenue, à une condition : que le where ne puisse pas être oublié.
Le contexte de requête, et son piège
Le domaine appelé désigne l'agence. Un filtre lit l'en-tête Host, retrouve l'agence en base, et dépose son identifiant dans un ThreadLocal.
Le ThreadLocal est le bon outil et le bon piège. Il rend l'identifiant visible partout dans le traitement de la requête sans avoir à le passer de méthode en méthode. Mais le fil qui a traité la requête est rendu au pool, et ce qui reste dedans sert la requête suivante.
D'où la seule ligne du dispositif qui ne se négocie pas :
finally { TenantContext.clear(); }
Sans elle, la fuite n'est pas systématique. Elle est intermittente — donc introuvable.
Le jeton doit dire la même chose que le domaine
L'identifiant de l'agence est aussi placé dans le jeton à la connexion. Il y a donc deux sources : le domaine appelé, et le jeton présenté.
Elles doivent concorder. Un jeton émis pour l'agence A, rejoué sur le domaine de l'agence B, est refusé avant que la moindre requête ne parte en base. Sans cette vérification, changer de domaine suffirait à changer de client.
55 entités, une seule superclasse
Le where qui ne s'oublie pas, c'est Hibernate qui le pose : un filtre déclaré sur une superclasse mappée dont les 55 entités héritent, activé sur chaque appel de dépôt par un aspect.
L'intérêt n'est pas d'économiser 55 annotations. Il est que la 56ᵉ entité, écrite dans six mois, hérite du filtre sans que son auteur y pense. La sécurité par défaut vaut mieux que la sécurité par discipline — parce que la discipline s'use, et que le défaut ne s'use pas.
Le jeton de 21 903 octets
La migration était finie et fonctionnait. En préparant la mise en production, un détail a failli tout arrêter.
Le jeton embarque les autorisations de l'utilisateur. Après alignement du référentiel de droits, celui du super administrateur en portait assez pour atteindre 21 903 octets — huit fois celui d'un utilisateur ordinaire.
Or un serveur web accepte par défaut des en-têtes de 4 à 8 ko. Au-delà, il répond 400 Request Header Or Cookie Too Large, ou 502. Le symptôme, vu de l'extérieur : la connexion échoue pour un seul compte, sans message exploitable, pendant que tous les autres fonctionnent. C'est le genre de panne qu'on cherche du mauvais côté pendant une demi-journée.
La correction tient en trois directives de configuration. La trouver tient à avoir mesuré la taille du jeton avant la mise en production, pas après.
Ce que j'en retiens
Ajouter l'isolation après coup n'est pas plus difficile que de l'avoir prévue — à condition de la placer là où elle ne peut pas être contournée. Un filtre hérité et un contexte nettoyé valent mieux que 55 relectures attentives.
Et une architecture ne tombe presque jamais sur ce qu'elle a prévu. Elle tombe sur la taille d'un en-tête HTTP.