Aller au contenu

Multi-tenant & RLS

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.

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étier

SET 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.

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.

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.

  1. RLS Postgres — filtre automatique par tenant_id sur chaque query
  2. ScopeAuthorizer (couche HTTP) — vérifie que la ressource chargée appartient bien à l’établissement/mandat du JWT de l’acteur (re-check IDOR)
  3. subject du JWT — jamais extrait du body de la requête, toujours du token signé

Wrapping de toute interaction DB tenant-scoped :

apps/api/src/platform/persistence/
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.

Le rôle pms_app n’a que SELECT + INSERT sur ces tables (jamais UPDATE ni DELETE) :

  • pms.audit_entry
  • pms.reservation_versions
  • pms.rate_plan_versions
  • pms.folio_entries (sauf GRANT UPDATE(status) restreint pour la clôture)
  • pms.work_order_photos
  • pms.invoice*
  • pms.wallet_movements
  • pms.owner_revenue_share
  • pms.owner_statement
  • pms.main_courante

pms.control_plane_audit_entry : pms_app = zéro accès (exclusif pms_control_plane).

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.

  • pool_mode = transaction (obligatoire — session-level incompatible avec SET LOCAL)
  • Advisory locks en mode transaction-level uniquement (pg_advisory_xact_lock, pas pg_advisory_lock)
  • La garde pgbouncer-guard au démarrage vérifie que le mode est bien transaction
  • withReferenceRead — variante sans SET 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 hors withTenantTransaction sur des données tenant-scoped = fuite cross-tenant potentielle.
  • Les scripts de seed (seed-tenant.ts, seed-uat.ts) utilisent withTenantTransaction correctement.