Quand on construit un SaaS qui héberge la comptabilité de plusieurs entreprises sur le même schéma PostgreSQL, l’isolation des données n’est pas un détail. Une seule fuite cross-tenant et c’est la fin du produit. Et pourtant, beaucoup d’équipes se contentent encore d’un filtrage applicatif.

Voici pourquoi c’est insuffisant et comment SynkriaOps fait autrement.

Le filtrage applicatif et son piège

Pattern classique : chaque entité porte un tenant_id, et le repository intercale ce filtre dans toutes les requêtes.

// Pattern naïf — INSUFFISANT
const factures = await repo.find({
  where: { tenantId: user.tenantId, statut: "validee" },
});

Le piège : il suffit d’un bug — un where manquant, une jointure mal typée, un queryBuilder qui oublie le scope, un raw SQL trop pressé — pour qu’une requête retourne tous les tenants confondus.

Et ce bug peut rester latent en prod pendant des mois sans qu’aucune alerte ne se déclenche. Côté utilisateur, rien ne signale qu’il vient d’apercevoir les factures d’un concurrent.

Le filtrage moteur (Row Level Security)

PostgreSQL propose depuis la version 9.5 un mécanisme natif : Row Level Security (RLS). Le filtrage est posé côté moteur, pas côté application. Toute requête, qu’elle vienne d’un ORM, d’un raw SQL, d’un psql en console, est filtrée automatiquement avant que les lignes ne sortent.

-- Activation RLS sur la table
ALTER TABLE factures ENABLE ROW LEVEL SECURITY;

-- Policy de lecture
CREATE POLICY factures_tenant_isolation ON factures
  FOR ALL
  USING (tenant_id = current_setting('app.current_tenant_id')::uuid);

À partir de là, toute requête qui ne pose pas explicitement app.current_tenant_id reçoit zéro ligne. C’est un fail-safe, pas un best-effort.

L’architecture SynkriaOps

SynkriaOps utilise une défense en trois couches :

  1. TenantMiddleware (NestJS) — extrait le tenant_id du JWT, vérifie le membership, pose set_config('app.current_tenant_id', <uuid>, false) sur la connexion DB pour la durée de la requête HTTP.
  2. TenantAccessGuard — vérifie au niveau du contrôleur que l’utilisateur a bien l’autorisation d’accéder au tenant courant.
  3. RLS PostgreSQL — filet de sécurité ultime : si les deux couches ci-dessus échouent, la DB retourne quand même 0 ligne.

Le code :

// apps/api/src/common/middleware/tenant.middleware.ts
async use(req: Request, res: Response, next: NextFunction) {
  const tenantId = this.extractTenantId(req);
  if (!tenantId) {
    throw new UnauthorizedException('Tenant context missing');
  }

  await this.dataSource.query(
    `SELECT set_config('app.current_tenant_id', $1, false)`,
    [tenantId],
  );
  next();
}

Le piège du pool de connexions

Petit subtil : PostgreSQL set_config(..., false) fixe la variable pour la session, pas pour la transaction. Avec un pool de connexions (pgBouncer, TypeORM connection pool), une même connexion sert plusieurs requêtes successives. Si une requête réinitialise mal le contexte, la suivante hérite du tenant_id précédent.

Bonnes pratiques :

  • Toujours réinitialiser set_config au début de chaque requête (pattern middleware).
  • Pour les mutations critiques (UPDATE / DELETE / INSERT), encapsuler dans une transaction explicite avec set_config(..., true) qui scope la variable à la transaction.
  • Le pattern de référence dans SynkriaOps : TenantContextService.setForTransaction().

Pourquoi c’est non négociable pour la compta

La comptabilité d’une PME comporte :

  • Les factures clients (montants, identités, marges)
  • Les contrats fournisseurs (conditions, ristournes)
  • Les salaires (DPAE, bulletins)
  • Les flux bancaires (relevés, soldes)

Une fuite cross-tenant sur ces données = fin de carrière du fondateur. Régulateurs, presse, clients, tout le monde vous tombe dessus. C’est un risque existentiel pour un SaaS comptable.

RLS PostgreSQL transforme ce risque existentiel en risque PostgreSQL — c’est-à-dire un risque géré par une équipe de plusieurs centaines d’ingénieurs en open-source depuis 30 ans.


Test de régression

SynkriaOps inclut un test E2E dédié : test/rls-fail-safe.e2e-spec.ts. Il seed deux tenants distincts, fait une requête avec un mauvais tenant_id posé, et asserte que 0 ligne est retournée.

Si quelqu’un désactive accidentellement une policy RLS sur une table, le test échoue avant le merge, pas en prod.


Le pattern complet est documenté dans apps/api/src/common/middleware/tenant.middleware.ts et apps/api/src/common/services/tenant-context.service.ts. Voir aussi le retex LOT-MOMO-C dans docs/audit-correction/ pour un cas réel de RLS silent fail détecté en revue.