← cd ../
// notes

Logiciel de soins multi-tenant sur Supabase : Row Level Security, BSN chiffrés et journal des consultations

Comment Zorg Intelligence maintient les organisations de soins séparées dans une seule base Postgres, stocke le BSN (numéro d'identification citoyen néerlandais) sous forme chiffrée et enregistre qui a ouvert quel dossier client.

publié
auteur
Nicky Mitnick

Le problème

Zorg Intelligence est un outil d’administration des soins pour les soignants indépendants et les petites structures de soins aux Pays-Bas. Toutes les organisations partagent une seule application et une seule base de données. Les données sont sensibles : dossiers clients, plans de soins et BSN (le numéro d’identification citoyen néerlandais) des clients et des collaborateurs.

Il en découle trois exigences qui ne doivent pas dépendre d’un code applicatif correct partout. Une organisation ne doit jamais voir les lignes d’une autre. Un BSN ne doit pas figurer en clair dans la base de données. Et la question « qui a ouvert ce dossier ? » doit pouvoir trouver une réponse après coup.

L’approche

La stack est Next.js avec Supabase (Postgres, Auth, Row Level Security et Storage). Les règles se trouvent dans la base de données, de sorte qu’une liste, une recherche, un rapport et un export CSV passent tous par la même porte.

Row Level Security. RLS est activé sur chaque table du schéma public, et chaque ligne appartient à une organisation et une seule. Un utilisateur est rattaché à une organisation par une adhésion assortie de l’un des quatre rôles : propriétaire, administrateur, collaborateur ou lecteur. Les policies ne répètent pas cette logique. Elles appellent quelques petites fonctions utilitaires (cet utilisateur est-il membre, a-t-il l’un de ces rôles, peut-il écrire) qui lisent les adhésions de l’utilisateur connecté. Un lecteur n’écrit rien.

Les détails qui se sont révélés importants :

  • Les références vers une table dotée d’une colonne d’organisation sont des clés composées. Une référence ne peut donc pas pointer vers une ligne d’une autre organisation.
  • Dans les policies, l’appel qui renvoie l’utilisateur courant est placé dans une sous-requête. Postgres l’évalue alors une fois par requête au lieu d’une fois par ligne.
  • Une organisation peut activer le cloisonnement par portefeuille de clients. Les collaborateurs et les lecteurs ne voient alors que les clients qui leur sont attribués. Comme cela se trouve dans RLS, cela s’applique sans code supplémentaire à chaque écran et à chaque export.
  • L’exploitant de la plateforme (Roosterspecialisten) ne voit aucune donnée de soins. L’accès pour le support n’est possible qu’avec une autorisation temporaire de l’organisation elle-même. Une telle session est en lecture seule, limitée dans le temps et journalisée.

Pour éviter que cela ne se dégrade sans qu’on s’en aperçoive, le dépôt contient une empreinte de toutes les policies et fonctions, avec un test qui compare l’état réel à cette empreinte, ainsi qu’un test d’isolation reproductible de 15 scénarios.

BSN chiffré. Le BSN est chiffré champ par champ avec pgcrypto. La clé provient de Supabase Vault et ne se trouve ni dans le code ni dans git. Un trigger chiffre la valeur à l’insertion et à la modification, conserve les quatre derniers chiffres pour un affichage masqué et vide la colonne en clair. L’application n’obtient jamais le numéro complet par une requête ordinaire. Elle doit appeler une fonction unique qui vérifie l’adhésion, écrit d’abord la ligne de consultation et ne déchiffre qu’ensuite.

Journal des consultations. La première version écrivait une ligne « consultation » dans le journal général des modifications. Dans le schéma actuel, c’est une table à part avec l’horodatage (celui de la base de données), l’organisation, la personne, son rôle à ce moment-là, le client, le type d’objet, l’action, un nombre pour les exports et les recherches, et la session. Elle ne contient jamais de texte libre, de terme de recherche, de nom de fichier ni d’adresse IP.

Il n’existe qu’une seule voie d’entrée : une fonction qui vérifie elle-même si l’appelant a le droit de voir l’enregistrement, et qui déduit l’organisation et le client de l’enregistrement plutôt que des paramètres. Les lignes ne peuvent être ni modifiées ni supprimées, pas même avec le service-role. La seule exception est la purge au terme de la durée de conservation de 5 ans. Le journal échoue en mode fermé : si une ligne ne peut pas être écrite, l’application n’affiche rien, et pour un BSN, un téléchargement ou un export, le serveur ne renvoie les données qu’une fois la ligne écrite. Les propriétaires et les administrateurs lisent toutes les lignes de leur organisation, les collaborateurs uniquement les leurs.

Les compromis

  • L’application journalise, la base de données contrôle. Quiconque lit directement l’API de la base de données avec son propre jeton valide n’obtient toujours que les lignes autorisées par RLS, mais ne laisse aucune ligne de consultation. Le BSN fait exception, car il passe toujours par la fonction de base de données. La liste des clients elle-même (les noms) n’est pas journalisée.
  • Pas encore de copie du journal en dehors de la base de données. C’est un point ouvert.
  • Les fonctions utilitaires s’exécutent avec des droits élevés, sans quoi une policy ne peut pas lire les adhésions. Chacune est petite et figure dans l’empreinte.
  • La vérification en deux étapes est facultative par défaut. Chaque utilisateur peut activer une application d’authentification, et un propriétaire peut la rendre obligatoire pour toute l’organisation. Si elle est obligatoire, l’accès du support n’est pas possible.
  • Pas de certification. Il s’agit de mesures techniques. Le produit n’est pas certifié NEN 7510 et un test d’intrusion externe reste à faire.
  • Des policies sur chaque table coûtent du temps à chaque migration : chaque nouvelle table demande des policies, un essai à blanc et une empreinte mise à jour.

Pour en savoir plus sur le produit, consultez la page projet de Zorg Intelligence.