Multi-tenant & RLS
Modèle de données multi-tenant
Section intitulée « Modèle de données multi-tenant »Logmis utilise un modèle shared database, shared schema avec Row-Level Security (RLS) Postgres activée en mode FORCE ROW LEVEL SECURITY sur toutes les tables tenant-scoped. Chaque ligne contient une colonne tenant_id.
La table pms.tenants est sans RLS (registre plateforme cross-tenant) — accessible uniquement via le rôle pms_control_plane.
Contexte tenant par transaction
Section intitulée « Contexte tenant par transaction »Le contexte tenant est posé via SET LOCAL en premier statement de chaque transaction :
-- Dans withTenantTransaction (platform/persistence/)SET LOCAL app.tenant_id = $1; -- binding natif pg, jamais interpolé-- Puis les queries métierSET LOCAL s’applique à la transaction uniquement — compatible avec pool_mode=transaction PgBouncer. SET (session) est interdit : le contexte fuiterait entre requêtes sur le même backend PostgreSQL via PgBouncer.
Fonction RLS fail-closed
Section intitulée « Fonction RLS fail-closed »CREATE OR REPLACE FUNCTION pms.current_tenant_id() RETURNS uuid AS $$ SELECT current_setting('app.tenant_id')::uuid; -- RAISE EXCEPTION si absent — jamais current_setting(..., true) = fail-open interdit$$ LANGUAGE sql STABLE;Toutes les policies RLS utilisent pms.current_tenant_id() sans l’option missing_ok. Si le contexte tenant est absent, la requête échoue — fail-closed.
Rôle runtime NOBYPASSRLS
Section intitulée « Rôle runtime NOBYPASSRLS »Le rôle pms_app (rôle runtime de l’API) est créé avec NOLOGIN NOBYPASSRLS. Une garde au démarrage (assertRoleHonorsRls) vérifie ce flag avant toute connexion applicative.
Un rôle SUPERUSER ou BYPASSRLS court-circuiterait FORCE RLS — interdit en runtime.
Isolation 3 couches
Section intitulée « Isolation 3 couches »- RLS Postgres — filtre automatique par
tenant_idsur chaque query ScopeAuthorizer(couche HTTP) — vérifie que la ressource chargée appartient bien à l’établissement/mandat du JWT de l’acteur (re-check IDOR)subjectdu JWT — jamais extrait du body de la requête, toujours du token signé
withTenantTransaction
Section intitulée « withTenantTransaction »Wrapping de toute interaction DB tenant-scoped :
await withTenantTransaction(db, async (tx) => { // tx est un Executor avec le contexte tenant posé // toute query dans ce callback est RLS-filtrée par tenant}, tenantContext);Un AsyncLocalStorage (ALS) propage le contexte tenant dans les chaînes async (NATS consumers, Temporal activities) — sans qu’il soit nécessaire de le passer explicitement à chaque fonction.
Tables append-only (inaltérabilité par GRANT)
Section intitulée « Tables append-only (inaltérabilité par GRANT) »Le rôle pms_app n’a que SELECT + INSERT sur ces tables (jamais UPDATE ni DELETE) :
pms.audit_entrypms.reservation_versionspms.rate_plan_versionspms.folio_entries(sauf GRANT UPDATE(status) restreint pour la clôture)pms.work_order_photospms.invoice*pms.wallet_movementspms.owner_revenue_sharepms.owner_statementpms.main_courante
pms.control_plane_audit_entry : pms_app = zéro accès (exclusif pms_control_plane).
Pool control-plane séparé
Section intitulée « Pool control-plane séparé »Les opérations control-plane (provisioning, entitlements, audit opérateur) utilisent un pool séparé CONTROL_PLANE_DATABASE_URL avec le rôle pms_control_plane. Ce pool est cross-tenant (pas de RLS tenant) et n’est jamais utilisé pour les requêtes applicatives tenant-scoped.
PgBouncer en production
Section intitulée « PgBouncer en production »pool_mode = transaction(obligatoire — session-level incompatible avecSET LOCAL)- Advisory locks en mode transaction-level uniquement (
pg_advisory_xact_lock, paspg_advisory_lock) - La garde
pgbouncer-guardau démarrage vérifie que le mode est bientransaction
Points d’attention
Section intitulée « Points d’attention »withReferenceRead— variante sansSET LOCAL, réservée aux tables de référence globales (ex:compliance-tax/). Ne pas l’utiliser pour des données tenant-scoped.pool.query()direct horswithTenantTransactionsur des données tenant-scoped = fuite cross-tenant potentielle.- Les scripts de seed (
seed-tenant.ts,seed-uat.ts) utilisentwithTenantTransactioncorrectement.