Retour aux textes

Une foreign key ne garantit pas le tenant

Une FK prouve que les deux lignes existent. Elle ne prouve pas qu'elles sont du même client.

Dans une base multi-tenant, cette table est référentiellement valide :

user_org_unit(user_id, org_unit_id)

Les deux foreign keys garantissent que l'utilisateur existe et que l'unité existe. Aucune ne garantit qu'ils appartiennent au même tenant. Un bug dans le handler, un script de migration ou un import en masse, et voilà : un utilisateur du client A se retrouve dans une unité du client B. La base accepte, parce que personne ne lui a dit que c'était impossible.

Des invariants tenant-aware

Toute relation sensible devrait préserver l'égalité de tenant :

tenant(user)     = tenant(org_unit)
tenant(resource) = tenant(org_unit)
tenant(grantee)  = tenant(resource) = tenant(grantor)
tenant(user)     = tenant(role)   -- quand le rôle est du tenant

La façon la plus propre de garantir ça, c'est la clé composite. Chaque table expose la paire (id, tenant_id) comme unique, et les tables de jointure référencent la paire :

ALTER TABLE app_user ADD UNIQUE (id, tenant_id);
ALTER TABLE org_unit ADD UNIQUE (id, tenant_id);

CREATE TABLE user_org_unit (
  tenant_id   uuid NOT NULL,
  user_id     uuid NOT NULL,
  org_unit_id uuid NOT NULL,
  PRIMARY KEY (user_id, org_unit_id),
  FOREIGN KEY (user_id, tenant_id)
    REFERENCES app_user (id, tenant_id),
  FOREIGN KEY (org_unit_id, tenant_id)
    REFERENCES org_unit (id, tenant_id)
);

Avec la même colonne tenant_id qui participe aux deux FK, un lien cross-tenant cesse d'être un état possible. Quand migrer tout vers des clés composites d'un coup coûte trop cher, un constraint trigger sert d'étape intermédiaire. Ce qui ne sert à rien, c'est « on valide déjà dans le handler ».

La base doit être capable de refuser les états cross-tenant impossibles.

L'identité n'est pas l'e-mail

Dans la même veine : une relation vivante pointe vers un identifiant stable. L'e-mail est un canal de contact et souvent un login, mais il change. Le domaine de l'entreprise change, l'orthographe est corrigée, les gens changent de nom. Une user_role avec l'e-mail comme clé, c'est une migration douloureuse programmée pour plus tard. Utilisez un UUID immuable dans les relations et, si vous voulez, gardez un snapshot textuel dans les événements d'audit, pour que la trace reste lisible même après un changement dans le profil.

Des tests qui essaient de casser

Le bon test ici ne vérifie pas que le handler refuse. Il essaie de construire l'état interdit directement dans la base et passe quand la base refuse :

  • associer un utilisateur du tenant A à une unité du tenant B ;
  • partager un objet du tenant A avec quelqu'un du tenant B ;
  • attribuer un rôle d'un autre tenant ;
  • lier une ressource à une unité incompatible.

Si l'un de ces inserts passe, l'isolation dépend de la discipline de chaque endpoint. Et la discipline d'endpoint, c'est exactement ce qui lâche le jour où quelqu'un écrit un script à la va-vite.