Notes

Isolation multi-tenant : surface agent étanche

Votre IA détient un jeton ; il ne doit atteindre que vos données. L'architecture qui fait qu'une fuite cross-tenant échoue en CI, pas en revue.

Quand vous branchez votre IA sur Historis via l'interface MCP, elle détient un jeton et agit seule. La seule propriété que le produit n'a pas le droit de rater, c'est celle-ci : ce jeton ne peut jamais atteindre que les données de votre organisation. Voici l'architecture qui l'impose et, surtout, qui fait qu'une régression casse le build au lieu de dépendre d'un relecteur qui pense à vérifier.

Comment les lectures sont-elles restreintes pour qu'un agent ne voie que vos données ?

Chaque lecture résout les enregistrements que vous avez le droit de voir avant de toucher vos données. Cette résolution passe par une fonction Postgres, visible_event_ids(user, org) (et sa jumelle pour les contacts), qui renvoie l'ensemble des ids autorisés :

const { data } = await supabaseAdmin.rpc('visible_event_ids', {
  p_user_id: userId, // issu du jeton vérifié, jamais du corps de requête
  p_org_id: organizationId,
})
query.in('id', data) // la recherche/lecture est contrainte à cet ensemble

Cette fonction est SECURITY DEFINER et révoquée pour les rôles authenticated et anon. Seul le rôle de service du backend peut l'appeler. Ce REVOKE unique est la clé de voûte du dispositif : une session base de données ordinaire ne peut pas appeler visible_event_ids(quelqu_un_d_autre, une_autre_org) pour sonder ce qui existe à l'échelle de tout le système. Les ids utilisateur et organisation viennent du jeton vérifié ; rien dans la requête ne permet à l'appelant de les fixer.

La frontière échoue en CI, pas en revue de code

Un REVOKE, c'est une ligne. Une migration ultérieure qui supprime puis recrée la fonction (c'est précisément ce que fait un changement de signature) la rend de nouveau exécutable par tout le monde : une fonction nouvellement créée donne EXECUTE à PUBLIC par défaut. Compter sur un relecteur pour le remarquer, c'est exactement le genre de contrôle qui finit par lâcher. Donc la frontière est verrouillée au déploiement. Si l'une des fonctions devient appelable par une session normale, la migration avorte.

DO $$
BEGIN
  IF has_function_privilege('authenticated',
       'public.visible_event_ids(uuid,uuid)', 'execute')
     OR has_function_privilege('anon',
       'public.visible_event_ids(uuid,uuid)', 'execute') THEN
    RAISE EXCEPTION
      'visible_event_ids must be service_role-only';
  END IF;
END $$;

Ce contrôle fait partie d'une petite famille d'invariants vérifiés au déploiement. Un autre scanne chaque politique d'écriture sur les tables régies par la visibilité et avorte si l'une ne référence plus le contrôle de visibilité, pour qu'une politique ne puisse pas glisser de nouveau vers une règle plus faible « même organisation ». Ils s'exécutent à chaque release, et une suite de tests re-vérifie les mêmes invariants à l'exécution. L'isolation des tenants ne dépend plus de la mémoire de qui que ce soit. Le build refuse tout changement qui la casse.

L'identité vient uniquement de la session

Les fonctions que les politiques RLS appellent, can_see_event et can_see_person, prennent un seul argument : l'enregistrement. L'identité de l'appelant est liée à auth.uid(), l'id contenu dans sa session vérifiée. Il n'y a délibérément pas de second paramètre « au nom de quel utilisateur ? ». Un appelant peut demander « est-ce que je peux voir cet enregistrement ? » mais jamais « est-ce que l'utilisateur X peut voir cet enregistrement ? », et c'est ce qui empêche d'utiliser la recherche et les lookups comme oracle pour sonder les données d'autres tenants.

Les écritures sont-elles couvertes aussi, ou seulement les lectures ?

Verrouiller ce que vous pouvez lire ne suffit pas. Lier un tag ou une personne à un enregistrement que vous ne pouvez pas voir doit être rejeté aussi. Sinon, vous pourriez écrire à l'aveugle sur l'enregistrement privé d'un collègue qu'on ne vous a jamais montré. Dans Historis, les politiques d'écriture des tables de liaison sont donc gardées par le même contrôle can_see_* que les lectures plutôt que par un prédicat plus lâche « même organisation ». Lectures et écritures partagent une seule définition de la visibilité.

La surface agent est placée sous alarme

Les agents autonomes détenant des jetons valides sont la surface à surveiller en priorité. Chaque refus MCP passe par un point de passage unique qui émet un signal délibérément sans PII. Il consigne l'outil, l'utilisateur et l'organisation, mais jamais l'id sondé ni le jeton :

function deniedNotFound(tool, userId, organizationId, message) {
  captureAccessDenied('mcp_entity_not_visible', {
    userId, organizationId, url: 'mcp:' + tool,
  })
  return errorContent(message) // l'agent voit juste 'introuvable'
}

« Pas le droit de le voir » et « ça n'existe pas » sont renvoyés à l'identique, donc la réponse ne fuite rien. Mais comme chaque refus passe par un seul endroit, surveillé par une alerte sur la fréquence des refus, une rafale de sondage cross-tenant se remarque sans que personne n'épluche les logs.

Pourquoi est-ce la version crédible de l'isolation des tenants ?

Aucun de ces mécanismes n'est exotique pris isolément : RLS, SECURITY DEFINER, identité liée à la session, politiques côté écriture. Ce qui rend l'isolation fiable, c'est que la frontière est imposée dans la base et re-vérifiée par le build à chaque release. Une politique d'écriture qui dériverait vers une règle plus faible sur une table couverte n'a pas droit à un commentaire de revue ; elle fait échouer le déploiement. (Ajouter une nouvelle table régie par la visibilité implique de l'enregistrer dans la liste de couverture de l'audit, l'unique étape maintenue à la main.)

Là où une alternative auto-hébergée comme Twenty fait reposer ce travail d'isolation sur vous, ici il est vérifié par la machine dans un stockage managé en région unique UE, où vos données ne servent jamais à entraîner un modèle.

Les contrôles en un coup d'œil :

ContrôleCe qu'il bloqueComment c'est vérifié
REVOKE sur visible_event_idsLe sondage cross-tenant par une session ordinaireContrôle has_function_privilege au déploiement
Contrôle de privilèges au déploiementLa disparition silencieuse du REVOKE après un drop puis re-créationExécuté à chaque release ; re-vérifié à l'exécution par les tests
Identité liée à la session (auth.uid())Les requêtes oracle « l'utilisateur X voit-il ceci ? »Les fonctions can_see_* n'ont aucun paramètre utilisateur
Politiques d'écriture gardées par la visibilitéLes écritures à l'aveugle sur des enregistrements jamais montrésL'audit des politiques fait échouer le déploiement en cas de dérive
Point de refus unique sans PIIUn sondage cross-tenant qui passerait inaperçuAlerte sur la fréquence des refus, tous routés par un chemin unique

Pour un agent qui agit seul avec votre jeton, c'est la différence entre « ça devrait aller » et « ça ne peut pas régresser sans passer le pipeline au rouge ». Le modèle de périmètre lui-même continue d'évoluer (unifier la façon dont les enregistrements détenus par leur créateur apparaissent entre équipes est un chantier en cours), mais l'invariant est le plancher sur lequel tout le reste est construit : votre jeton n'atteint que vos données, et cette propriété est vérifiée par la machine.

À lire aussi : comment fonctionne la recherche via le MCP, qui s'exécute entièrement dans ce périmètre de visibilité.

Questions fréquentes

L'IA d'une organisation peut-elle lire les données d'une autre organisation dans Historis ?
Non. Chaque lecture résout les enregistrements visibles via une fonction Postgres SECURITY DEFINER que seul le backend peut appeler, restreinte à l'utilisateur et à l'organisation issus du jeton vérifié. Une vérification au déploiement fait échouer le build si cette fonction devient appelable par une session base de données ordinaire.
L'isolation des tenants s'applique-t-elle aussi aux écritures, ou seulement aux lectures ?
Aux écritures aussi. Les politiques d'écriture des tables de liaison sont gardées par le même contrôle de visibilité can_see_event / can_see_person que les lectures : vous ne pouvez pas lier un tag ou une personne à un enregistrement qu'on ne vous a jamais montré.
Qu'est-ce qui empêche d'utiliser la recherche pour sonder les enregistrements d'autres tenants ?
L'identité est liée à la session (auth.uid()), jamais passée en paramètre, et « pas le droit de le voir » est renvoyé exactement comme « ça n'existe pas ». Chaque refus passe aussi par un point de passage unique sans PII, donc une rafale de sondage cross-tenant fait grimper la fréquence des refus et déclenche une alerte.
Où les données sont-elles stockées, et servent-elles à entraîner une IA ?
Elles vivent dans un stockage managé en région unique UE et ne servent jamais à entraîner un modèle. Il n'y a pas d'installation on-premise. Autrement dit : inférence souveraine si vous le souhaitez (pointez votre propre modèle auto-hébergé dessus via MCP), résidence UE des données, et aucun entraînement.

Articles liés