Use Cases (Mis à jour: 02/06/2026)

SaaS multi-tenant sécurisé avec Claude Code : RLS, auth, facturation et logs

Implémentez un SaaS multi-tenant avec Claude Code : tenant_id, RLS, auth, quotas, jobs, logs et tests.

SaaS multi-tenant sécurisé avec Claude Code : RLS, auth, facturation et logs

Un SaaS multi-tenant sert plusieurs organisations clientes avec une même application. Un tenant est la frontière de données du client : entreprise, espace de travail, portail client ou compte. Claude Code peut modifier rapidement les routes, helpers de base de données, jobs et écrans. Cette vitesse est utile seulement si la frontière tenant est définie avant l’implémentation.

L’incident à éviter est simple : un utilisateur de Tenant A lit un projet, une facture, un fichier, un événement d’audit ou un prompt IA de Tenant B. Chercherwhere tenantIdpendant la revue ne suffit pas. Une architecture sûre propagetenant_iddans l’authentification, la session, la base de données, la facturation, les jobs, la recherche et les logs, puis laisse PostgreSQL bloquer la fuite si une requête applicative oublie un filtre.

Dans ce guide, Claude Code est un partenaire d’implémentation et de revue, pas un raccourci pour éviter la sécurité. RLS signifie Row Level Security : des règles PostgreSQL qui décident quelles lignes sont visibles ou modifiables dans le contexte courant. Utilisez les documents officiels PostgreSQL Row Security Policies, CREATE POLICY et current_setting / set_config comme références.

Pour compléter le socle, lisez aussi l’authentification avec Claude Code, le contrôle d’accès RBAC et les migrations de base de données. Le multi-tenant relie ces trois sujets.

Choisir d’abord le modèle d’isolation

Il existe trois modèles courants. Choisissez avant de demander à Claude Code d’écrire des handlers.

ModèleCas adaptéForcePiège
Base partagée, schema partagéSaaS jeune ou intermédiaire avec beaucoup de petits clientsCoût opérationnel faible, analytics simplesUn filtre tenant oublié peut exposer des données
Base partagée, schema par tenantQuelques grands clients à isoler davantagePermissions et migrations partiellement séparéesLes migrations deviennent lourdes
Base par tenantFinance, santé, contrats strictsBackup, clés et incidents isolablesCoût et déploiement plus complexes

La question pratique est : le modèle tient-il avec 100 ou 1000 clients ? Pour la plupart des SaaS petits et moyens, base partagée et schema partagé restent réalistes sitenant_id, RLS, audit log et restauration par tenant sont solides. Si certains clients enterprise exigent une séparation physique, prévoyez une voie de migration pour ces tenants spécifiques.

Trois cas d’usage rendent le sujet concret. Premier cas : un CRM B2B où comptes, contacts, opportunités et notes appartiennent à un tenant. Deuxième cas : un portail d’agence où l’équipe agence peut rejoindre plusieurs tenants, mais le client final ne voit que son portail. Troisième cas : un SaaS avec IA où prompts, tokens, fichiers et stockage doivent être comptés par tenant pour facturer correctement.

Commencez par un prompt de revue de conception.

Je veux rendre ce SaaS multi-tenant.
Suppose une base PostgreSQL partagée et un schema partagé.
Classe les tables en tenant-owned, global reference et tables nécessitant PostgreSQL RLS.
Interdits : "les WHERE applicatifs suffisent", logs du request body complet, jobs qui devinent tenant_id plus tard.
Retourne les zones modifiées, contraintes DB, cas de test et risques de migration.

Propager tenant_id depuis une source fiable

Ne faites pas confiance à un headerx-tenant-idenvoyé par le navigateur ni à un paramètre d’URL. Le flux sûr est de résoudre un tenant candidat depuis le host ou le path, vérifier côté serveur que l’utilisateur connecté appartient à ce tenant, puis transmettre seulement letenantIdvérifié aux services.

Auth.js explique dans Extending the Session comment enrichir une session. Gardez cependant la session légère : elle identifie l’utilisateur, tandis que l’appartenance au tenant et les rôles doivent être relus côté serveur, car ils changent.

// src/lib/tenant-context.ts
import { z } from "zod";

const hostSchema = z.string().min(1).max(255);

export type SessionUser = {
  id: string;
  email: string;
};

export type TenantContext = {
  tenantId: string;
  userId: string;
  role: "owner" | "admin" | "member" | "viewer";
  requestId: string;
};

type TenantRecord = {
  id: string;
  subdomain: string | null;
  custom_domain: string | null;
};

type MembershipRecord = {
  tenant_id: string;
  user_id: string;
  role: TenantContext["role"];
};

export async function resolveTenantContext(input: {
  request: Request;
  sessionUser: SessionUser | null;
  requestId: string;
  findTenantByHost: (host: string) => Promise<TenantRecord | null>;
  findMembership: (tenantId: string, userId: string) => Promise<MembershipRecord | null>;
}): Promise<TenantContext> {
  if (!input.sessionUser) {
    throw new Response("Unauthorized", { status: 401 });
  }

  const host = hostSchema.parse(new URL(input.request.url).host.toLowerCase());
  const tenant = await input.findTenantByHost(host);
  if (!tenant) {
    throw new Response("Tenant not found", { status: 404 });
  }

  const membership = await input.findMembership(tenant.id, input.sessionUser.id);
  if (!membership) {
    throw new Response("Forbidden for this tenant", { status: 403 });
  }

  return {
    tenantId: tenant.id,
    userId: input.sessionUser.id,
    role: membership.role,
    requestId: input.requestId,
  };
}

Le point le plus important est le refus : le code ne croit pas un tenant venant du client. Les APIs internes et les workers doivent appliquer la même règle. Un tenant_id externe est une affirmation, pas une preuve.

Faire de PostgreSQL RLS le dernier rempart

Les filtres applicatifs restent utiles, mais un oubli ne doit pas devenir une fuite. Dans un schema partagé, chaque table appartenant à un tenant doit avoirtenant_id, clés étrangères, index par tenant et policies RLS. PostgreSQL permet au propriétaire de table et aux rôlesBYPASSRLSde contourner RLS, donc le rôle applicatif ne doit pas posséder les tables protégées. Pour les tables critiques, utilisezFORCE ROW LEVEL SECURITY.

-- db/migrations/20260602_multi_tenant_rls.sql
CREATE EXTENSION IF NOT EXISTS pgcrypto;

CREATE TABLE tenants (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  slug text NOT NULL UNIQUE,
  name text NOT NULL,
  plan text NOT NULL DEFAULT 'starter',
  created_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE app_users (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  email text NOT NULL UNIQUE,
  created_at timestamptz NOT NULL DEFAULT now()
);

CREATE TABLE tenant_memberships (
  tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  user_id uuid NOT NULL REFERENCES app_users(id) ON DELETE CASCADE,
  role text NOT NULL CHECK (role IN ('owner', 'admin', 'member', 'viewer')),
  created_at timestamptz NOT NULL DEFAULT now(),
  PRIMARY KEY (tenant_id, user_id)
);

CREATE TABLE projects (
  id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
  tenant_id uuid NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
  name text NOT NULL,
  status text NOT NULL DEFAULT 'active',
  created_by uuid NOT NULL REFERENCES app_users(id),
  created_at timestamptz NOT NULL DEFAULT now()
);

CREATE INDEX projects_tenant_id_id_idx ON projects (tenant_id, id);
CREATE INDEX tenant_memberships_user_id_tenant_id_idx ON tenant_memberships (user_id, tenant_id);

ALTER TABLE tenant_memberships ENABLE ROW LEVEL SECURITY;
ALTER TABLE tenant_memberships FORCE ROW LEVEL SECURITY;
ALTER TABLE projects ENABLE ROW LEVEL SECURITY;
ALTER TABLE projects FORCE ROW LEVEL SECURITY;

CREATE POLICY tenant_memberships_isolation
  ON tenant_memberships
  FOR ALL
  USING (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid)
  WITH CHECK (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid);

CREATE POLICY projects_isolation
  ON projects
  FOR ALL
  USING (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid)
  WITH CHECK (tenant_id = nullif(current_setting('app.tenant_id', true), '')::uuid);

Avec un pool de connexions, le contexte tenant doit être local à une transaction. Une valeur de session peut survivre sur une connexion réutilisée. set_config(..., true) limite la valeur à la transaction.

// src/db/tenant-db.ts
import { Pool, PoolClient } from "pg";

const pool = new Pool({
  connectionString: process.env.DATABASE_URL,
});

const uuidPattern =
  /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;

export async function withTenant<T>(
  tenantId: string,
  work: (client: PoolClient) => Promise<T>,
): Promise<T> {
  if (!uuidPattern.test(tenantId)) {
    throw new Error("Invalid tenant id");
  }

  const client = await pool.connect();
  try {
    await client.query("BEGIN");
    await client.query("SELECT set_config('app.tenant_id', $1, true)", [tenantId]);
    const result = await work(client);
    await client.query("COMMIT");
    return result;
  } catch (error) {
    await client.query("ROLLBACK");
    throw error;
  } finally {
    client.release();
  }
}

export async function listProjects(tenantId: string) {
  return withTenant(tenantId, async (db) => {
    const result = await db.query(
      "SELECT id, name, status, created_at FROM projects ORDER BY created_at DESC",
    );
    return result.rows;
  });
}

La requête omet volontairementWHERE tenant_id = $1. Si RLS fonctionne, PostgreSQL filtre les lignes. En production, vous pouvez ajouter un filtre applicatif pour la performance, mais il ne remplace pas RLS.

Aligner auth, facturation et jobs

La frontière d’authentification n’est pas “l’utilisateur est connecté”, mais “que peut-il faire dans ce tenant”. Un simpleuser_iddevient dangereux si des consultants, agences ou équipes support appartiennent à plusieurs tenants. Faites circulertenantId, userId, roleetrequestId.

La facturation suit la même frontière. Stripe décrit lausage-based billing, mais votre application doit d’abord compter l’usage du bon tenant. Les quotas de plan doivent être bloqués côté serveur avant l’écriture ou l’exécution IA.

// src/lib/plan-limits.ts
type Plan = "starter" | "growth" | "enterprise";
type Metric = "users" | "projects" | "aiRuns";

const PLAN_LIMITS: Record<Plan, Record<Metric, number>> = {
  starter: { users: 5, projects: 20, aiRuns: 500 },
  growth: { users: 50, projects: 500, aiRuns: 10_000 },
  enterprise: { users: 10_000, projects: 1_000_000, aiRuns: 50_000_000 },
};

export function assertPlanLimit(input: {
  plan: Plan;
  metric: Metric;
  currentUsage: number;
  increment?: number;
}) {
  const nextUsage = input.currentUsage + (input.increment ?? 1);
  const limit = PLAN_LIMITS[input.plan][input.metric];

  if (nextUsage > limit) {
    throw new Response(
      JSON.stringify({
        error: "PLAN_LIMIT_EXCEEDED",
        metric: input.metric,
        limit,
        nextUsage,
      }),
      { status: 402, headers: { "content-type": "application/json" } },
    );
  }
}

Les jobs de fond sont souvent oubliés. N’envoyez pas seulementprojectIddans la queue. Ajoutez letenantIdvérifié au payload et réactivez le contexte RLS dans le worker.

// src/jobs/send-project-digest.ts
import { withTenant } from "../db/tenant-db";

type ProjectDigestJob = {
  tenantId: string;
  projectId: string;
  requestedBy: string;
};

export async function handleProjectDigestJob(job: ProjectDigestJob) {
  return withTenant(job.tenantId, async (db) => {
    const project = await db.query(
      "SELECT id, name FROM projects WHERE id = $1",
      [job.projectId],
    );

    if (project.rowCount !== 1) {
      throw new Error("Project not visible for tenant");
    }

    await db.query(
      "INSERT INTO audit_events (tenant_id, actor_user_id, event_name) VALUES ($1, $2, $3)",
      [job.tenantId, job.requestedBy, "project_digest_sent"],
    );
  });
}

Logger assez, pas tout

Les logs peuvent devenir une deuxième base de données moins protégée. LeLogging Cheat Sheet d’OWASP insiste sur la sanitation et l’exclusion des secrets. Dans un SaaS multi-tenant, gardeztenantId, requestId, event, actor et target. N’écrivez pas cookies, Authorization, clés API, prompts complets, body complet ou adresse de facturation.

// src/lib/safe-log.ts
type LogLevel = "info" | "warn" | "error";

const SENSITIVE_KEYS = new Set([
  "authorization",
  "cookie",
  "password",
  "token",
  "apiKey",
  "prompt",
  "secret",
]);

function redact(value: unknown): unknown {
  if (!value || typeof value !== "object") return value;
  if (Array.isArray(value)) return value.map(redact);

  return Object.fromEntries(
    Object.entries(value).map(([key, child]) => [
      key,
      SENSITIVE_KEYS.has(key) ? "[REDACTED]" : redact(child),
    ]),
  );
}

export function tenantLog(level: LogLevel, message: string, fields: Record<string, unknown>) {
  const safeFields = redact(fields);
  console[level](JSON.stringify({ message, ...safeFields }));
}

Séparez audit logs et logs opérationnels. L’audit doit dire qui a fait quoi, dans quel tenant, sur quelle cible et quand. Ne copiez pas toutes les valeurs métier sauf nécessité claire.

Tester les fuites volontairement

Les tests heureux ne suffisent pas. Testez Tenant A avec un ID de Tenant B, un worker avec payload incohérent, une requête sansapp.tenant_id, un dépassement de plan et un logger recevant des champs sensibles.

-- db/tests/rls-smoke-test.sql
\set ON_ERROR_STOP on

BEGIN;

INSERT INTO tenants (id, slug, name)
VALUES
  ('00000000-0000-4000-8000-000000000001', 'alpha', 'Alpha Inc'),
  ('00000000-0000-4000-8000-000000000002', 'beta', 'Beta Inc');

INSERT INTO app_users (id, email)
VALUES ('10000000-0000-4000-8000-000000000001', 'masa@example.com');

INSERT INTO projects (id, tenant_id, name, created_by)
VALUES
  ('20000000-0000-4000-8000-000000000001', '00000000-0000-4000-8000-000000000001', 'Alpha project', '10000000-0000-4000-8000-000000000001'),
  ('20000000-0000-4000-8000-000000000002', '00000000-0000-4000-8000-000000000002', 'Beta project', '10000000-0000-4000-8000-000000000001');

SELECT set_config('app.tenant_id', '00000000-0000-4000-8000-000000000001', true);

DO $$
DECLARE visible_count integer;
BEGIN
  SELECT count(*) INTO visible_count FROM projects;
  IF visible_count <> 1 THEN
    RAISE EXCEPTION 'RLS failed: expected 1 visible project, got %', visible_count;
  END IF;
END $$;

ROLLBACK;
#!/usr/bin/env bash
set -euo pipefail

: "${DATABASE_URL:?DATABASE_URL is required}"

psql "$DATABASE_URL" -f db/migrations/20260602_multi_tenant_rls.sql
psql "$DATABASE_URL" -f db/tests/rls-smoke-test.sql

Donnez à Claude Code des critères adverses.

Ajoute des tests de fuite multi-tenant :
1. Une session Tenant A utilisant un projectId Tenant B retourne 403 ou 404.
2. Une requête DB sans app.tenant_id retourne zéro ligne ou fail closed.
3. Un job sans tenantId est rejeté.
4. Un dépassement de plan retourne 402 avant écriture.
5. Les logs ne contiennent jamais authorization, cookie, prompt, token, apiKey.
Explique l’implémentation incorrecte que chaque test détecte, puis donne commandes et résultats.

Éviter les pièges de migration

En retrofit, l’erreur classique consiste à ajouter untenant_idnullable, livrer vite, puis promettre le nettoyage. Une nullabilité temporaire peut servir au backfill, mais la livraison n’est pas terminée sansNOT NULL, clés étrangères, contraintes uniques par tenant, index, RLS et tests.

Cinq pièges reviennent souvent. D’abord, mélanger données globales et données tenant : les codes pays peuvent être globaux, les paramètres client non. Ensuite, oublier le tenant dans les clés de stockage de fichiers. Troisième point, oubliertenant_iddans Algolia, Meilisearch ou OpenSearch. Quatrième point, désactiver RLS pour l’admin : le support doit passer par impersonation explicite et auditée. Enfin, ne pas tester la restauration d’un seul tenant expose toute la base en cas d’incident.

Crée un plan de migration vers le multi-tenant.
Phase 1: ajouter tenant_id et mapper les enregistrements existants.
Phase 2: écrire le backfill SQL et la validation des NULL restants.
Phase 3: ajouter NOT NULL, foreign keys, contraintes uniques par tenant et index.
Phase 4: activer RLS et proposer FORCE ROW LEVEL SECURITY.
Phase 5: tester APIs, jobs, logs et index de recherche contre les fuites cross-tenant.
Ajoute conditions de rollback et SQL de vérification à chaque phase.

Prompts sûrs pour Claude Code

Le prompt doit inclure la frontière tenant. Évitez “rends-le multi-tenant vite” ou “les admins voient tout”. Un prompt sûr liste exigences, interdits, tests et périmètre.

Tu implémentes un comportement SaaS multi-tenant sûr.
Périmètre autorisé : src/app, src/lib, db/migrations, tests.

Exigences :
- Résoudre tenant_id côté serveur, ne jamais croire le client.
- Utiliser PostgreSQL RLS pour chaque table tenant-owned.
- Avec le pool, appeler set_config('app.tenant_id', value, true) dans une transaction.
- Propager tenantId dans auth, billing, jobs, logs et search indexing.
- Ajouter des tests qui tentent des lectures/écritures cross-tenant.

Interdits :
- Désactiver RLS.
- Faire confiance à x-tenant-id.
- Logger Authorization, Cookie, API keys ou prompts complets.
- Finir sans tests.

Le rapport final inclut fichiers changés, commandes, échecs trouvés et risque résiduel.

La documentationClaude Code Security explique permissions, prompt injection et responsabilité de revue. Pour un SaaS sensible, gardez les commandes autorisées, connexions MCP et règles projet versionnées.

CTA et passage en production

Le multi-tenant n’est pas spectaculaire, mais il vend de la confiance en B2B. Expliquer isolation des données, audit logs, quotas de plan, accès support et restauration par tenant simplifie les questionnaires sécurité.

Pour une équipe qui adopte Claude Code, placez les règles tenant dansCLAUDE.mdet verrouillez-les par tests. À chaque revue :tenant_idest-il propagé, RLS bloque-t-il les erreurs, les jobs sont-ils scoped, les logs excluent-ils les secrets ? Les équipes peuvent démarrer parformation et conseil Claude Code. Les développeurs solo peuvent utiliser lacheatsheet gratuite pour transformer cette checklist en prompts répétables.

Résultat après essai

Quand Masa a testé ce modèle sur un petit CRM, la première fuite n’était pas dans l’API route mais dans le worker d’email quotidien. L’UI passaittenantId, mais le payload de queue ne contenait queprojectId; le worker pouvait donc chercher hors du tenant prévu. En forçantwithTenantdans le worker et en ajoutant un test SQL sansapp.tenant_id, le bug est devenu reproductible puis corrigé. Le meilleur prompt n’était pas “ajoute des tests”, mais “écris d’abord un test où Tenant A tente de lire Tenant B”.

#Claude Code #multi-tenant #SaaS #PostgreSQL #sécurité
Gratuit

PDF gratuit: cheatsheet Claude Code

Saisissez votre email et téléchargez une page avec commandes, habitudes de review et workflow sûr.

Nous protégeons vos données et n'envoyons pas de spam.

Masa

À propos de l'auteur

Masa

Ingénieur spécialisé dans les workflows pratiques avec Claude Code.