Scénario réel : 500K utilisateurs

Chiffrer vos données existantes

Votre base PostgreSQL contient des centaines de milliers d'utilisateurs avec des données personnelles en clair. NIS2 ou le RGPD vous imposent le chiffrement. Voici comment migrer sans interruption de service.

1. Audit de vos données

Avant de chiffrer, identifiez précisément quelles colonnes contiennent des données personnelles (PII). Ne chiffrez que ce qui doit l'être : les identifiants techniques et les horodatages restent en clair.

-- Identifier les colonnes contenant des données sensibles
-- Exemple : table users avec 500 000 lignes
SELECT
  column_name,
  data_type,
  COUNT(*) OVER() as total_rows
FROM information_schema.columns
WHERE table_name = 'users';

-- Résultat :
-- id          | integer   | 500000  → ne pas chiffrer
-- email       | varchar   | 500000  → CHIFFRER (PII)
-- first_name  | varchar   | 500000  → CHIFFRER (PII)
-- last_name   | varchar   | 500000  → CHIFFRER (PII)
-- phone       | varchar   | 500000  → CHIFFRER (PII)
-- address     | text      | 500000  → CHIFFRER (PII)
-- created_at  | timestamp | 500000  → ne pas chiffrer
-- role        | varchar   | 500000  → ne pas chiffrer

2. Migration du schéma

Ajoutez les colonnes chiffrées sans toucher aux colonnes existantes. L'application continue de fonctionner normalement pendant toute la durée de la migration.

-- Étape 1 : Ajouter les colonnes chiffrées
ALTER TABLE users ADD COLUMN email_encrypted JSONB;
ALTER TABLE users ADD COLUMN first_name_encrypted JSONB;
ALTER TABLE users ADD COLUMN last_name_encrypted JSONB;
ALTER TABLE users ADD COLUMN phone_encrypted JSONB;
ALTER TABLE users ADD COLUMN address_encrypted JSONB;
ALTER TABLE users ADD COLUMN email_hash TEXT;

-- Étape 2 : Index pour la recherche
CREATE INDEX idx_users_email_hash ON users(email_hash);

3. Script de chiffrement par lots

Script prêt pour la production : traitement par lots de 500 lignes, barre de progression, reprise après erreur, journalisation et estimation du temps restant.

import { Mafate } from '@mafate/sdk'
import pg from 'pg'

const mafate = new Mafate({ apiKey: process.env.MAFATE_API_KEY })
const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL })

const BATCH_SIZE = 500
const KEY_ID = 'key-users-pii'

async function backfillUsers() {
  // Compter le total
  const { rows: [{ count }] } = await pool.query(
    'SELECT COUNT(*) as count FROM users WHERE email_encrypted IS NULL'
  )
  const total = parseInt(count)
  console.log(`🔐 ${total} utilisateurs à chiffrer`)

  let processed = 0
  let errors = 0
  const startTime = Date.now()

  while (true) {
    // Récupérer un batch
    const { rows } = await pool.query(`
      SELECT id, email, first_name, last_name, phone, address
      FROM users
      WHERE email_encrypted IS NULL
      ORDER BY id
      LIMIT $1
    `, [BATCH_SIZE])

    if (rows.length === 0) break

    // Chiffrer chaque ligne
    for (const user of rows) {
      try {
        const [encEmail, encFirst, encLast, encPhone, encAddress] = await Promise.all([
          mafate.encryptLocal(user.email || '', KEY_ID),
          mafate.encryptLocal(user.first_name || '', KEY_ID),
          mafate.encryptLocal(user.last_name || '', KEY_ID),
          mafate.encryptLocal(user.phone || '', KEY_ID),
          mafate.encryptLocal(user.address || '', KEY_ID),
        ])

        const emailHash = await mafate.hash((user.email || '').toLowerCase(), KEY_ID)

        await pool.query(`
          UPDATE users SET
            email_encrypted = $1,
            first_name_encrypted = $2,
            last_name_encrypted = $3,
            phone_encrypted = $4,
            address_encrypted = $5,
            email_hash = $6
          WHERE id = $7
        `, [
          JSON.stringify(encEmail),
          JSON.stringify(encFirst),
          JSON.stringify(encLast),
          JSON.stringify(encPhone),
          JSON.stringify(encAddress),
          emailHash,
          user.id,
        ])

        processed++
      } catch (err) {
        errors++
        console.error(`❌ Erreur utilisateur #${user.id}:`, err.message)
        // Continue, on ne bloque pas le batch pour une erreur
      }
    }

    // Progression
    const elapsed = (Date.now() - startTime) / 1000
    const rate = processed / elapsed
    const remaining = (total - processed) / rate
    console.log(
      `📊 ${processed}/${total} (${((processed/total)*100).toFixed(1)}%) ` +
      `| ${rate.toFixed(0)} users/sec | ~${Math.ceil(remaining/60)} min restantes | ${errors} erreurs`
    )
  }

  console.log(`\n✅ Migration terminée : ${processed} chiffrés, ${errors} erreurs`)
}

backfillUsers()

4. Validation

Avant de supprimer les colonnes en clair, vérifiez sur un échantillon aléatoire que le chiffrement est correct.

// Validation : déchiffrer et comparer avec l'original
async function validateMigration(sampleSize = 100) {
  const { rows } = await pool.query(`
    SELECT id, email, email_encrypted
    FROM users
    WHERE email_encrypted IS NOT NULL
    ORDER BY RANDOM()
    LIMIT $1
  `, [sampleSize])

  let ok = 0, ko = 0
  for (const row of rows) {
    const decrypted = await mafate.decryptLocalToString(JSON.parse(row.email_encrypted))
    if (decrypted === row.email) ok++
    else { ko++; console.error(`❌ Mismatch user #${row.id}`) }
  }
  console.log(`✅ Validation: ${ok}/${rows.length} OK, ${ko} erreurs`)
}

5. Nettoyage

Ne supprimez les colonnes en clair qu'après validation complète et sauvegarde vérifiée.

-- UNIQUEMENT après validation complète
-- Backup d'abord !
ALTER TABLE users DROP COLUMN email;
ALTER TABLE users DROP COLUMN first_name;
ALTER TABLE users DROP COLUMN last_name;
ALTER TABLE users DROP COLUMN phone;
ALTER TABLE users DROP COLUMN address;

-- Renommer les colonnes chiffrées
ALTER TABLE users RENAME COLUMN email_encrypted TO email;
-- etc.

6. Estimation du temps

VolumeTemps estimé (5 champs/user)Opérations de chiffrement
10K users~5 min50K
100K users~45 min500K
500K users~4h2,5M
1M+ users~8h5M+

Guide pratique

Migration depuis une app existante

Votre application est déjà en production avec des données non chiffrées ? Ce guide explique comment migrer progressivement sans interruption de service et sans risque de perte de données.

Stratégie : chiffrement progressif, pas de big bang

Ne jamais migrer toutes les données d'un coup en production. Utilisez le pattern dual-write + backfill pour garantir une migration sans risque, rollback possible à chaque étape.

1. Les 4 phases de migration

1

Dual-write

1-2 jours

Modifier le code de sauvegarde pour écrire en clair ET en chiffré simultanément. Aucune donnée perdue, rollback facile.

2

Lecture depuis encrypted

1-2 jours

Modifier le code de lecture pour utiliser la colonne chiffrée en priorité, avec fallback sur la colonne en clair.

3

Backfill des données existantes

2-7 jours (selon volume)

Script de migration qui chiffre toutes les lignes existantes par batchs. L'application continue de fonctionner normalement.

4

Suppression des colonnes en clair

1 jour

Une fois le backfill validé à 100%, supprimer les colonnes en clair et renommer les colonnes chiffrées.

2. Migrations SQL (PostgreSQL)

Les migrations SQL correspondent aux phases. Exécutez-les dans l'ordre, en validant chaque phase avant de passer à la suivante.

-- PHASE 0 : Ajouter les nouvelles colonnes (sans casser l'existant)
ALTER TABLE users
  ADD COLUMN IF NOT EXISTS email_encrypted JSONB,
  ADD COLUMN IF NOT EXISTS phone_encrypted JSONB,
  ADD COLUMN IF NOT EXISTS email_hash      TEXT;

-- Index sur le hash (pour la recherche future)
CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_users_email_hash ON users(email_hash);

-- PHASE 1 : Dual-write en production (code applicatif)
-- → Modifier saveUser() pour écrire dans les deux colonnes

-- PHASE 3 : Vérifier que le backfill est complet
SELECT
  COUNT(*) FILTER (WHERE email_encrypted IS NULL) AS not_yet_encrypted,
  COUNT(*) AS total
FROM users;
-- → not_yet_encrypted = 0 ← backfill terminé, on peut passer à la phase 4

-- PHASE 4 : Supprimer les colonnes en clair
ALTER TABLE users
  DROP COLUMN IF EXISTS email,
  DROP COLUMN IF EXISTS phone;

-- Renommer les colonnes chiffrées
ALTER TABLE users
  RENAME COLUMN email_encrypted TO email;
ALTER TABLE users
  RENAME COLUMN phone_encrypted TO phone;

3. Code de dual-write (Phase 1 & 2)

Modifier vos fonctions de sauvegarde et de lecture pour gérer les deux colonnes. Le fallback sur la colonne en clair garantit que l'application fonctionne pendant la migration.

import { Mafate } from '@mafate/sdk'

const mafate = new Mafate({ apiKey: process.env.MAFATE_API_KEY })

async function computeHash(value: string): Promise<string> {
  return await mafate.hash(value.toLowerCase(), 'key-users-pii')
}

// PHASE 1 : Dual-write, écrire les deux versions
// Conserver email en clair ET ajouter email_encrypted
async function saveUser(userId: string, email: string, phone: string) {
  const [encEmail, encPhone] = await Promise.all([
    mafate.encryptLocal(email, 'key-users-pii'),
    mafate.encryptLocal(phone, 'key-users-pii'),
  ])

  await db.query(`
    UPDATE users SET
      email           = $1,                    -- conserver en clair (temporaire)
      phone           = $2,                    -- conserver en clair (temporaire)
      email_encrypted = $3,                    -- ajouter la version chiffrée
      phone_encrypted = $4,                    -- ajouter la version chiffrée
      email_hash      = $5                     -- hash pour la recherche
    WHERE id = $6
  `, [
    email,
    phone,
    JSON.stringify(encEmail),
    JSON.stringify(encPhone),
    await computeHash(email),
    userId,
  ])
}

// PHASE 2 : Lire depuis encrypted, fallback sur plain
async function getUser(emailHash: string) {
  const row = await db.query(
    'SELECT * FROM users WHERE email_hash = $1',
    [emailHash]
  )
  const r = row.rows[0]
  if (!r) return null

  // Lecture depuis la colonne chiffrée avec fallback
  let email: string
  if (r.email_encrypted) {
    email = mafate.decryptLocalToString(JSON.parse(r.email_encrypted))
  } else {
    email = r.email // fallback sur l'ancien champ non chiffré
  }

  return { ...r, email }
}

4. Script de backfill (Phase 3)

Le backfill chiffre toutes les lignes existantes par batchs de 500, avec une concurrence de 50 pour maximiser le débit sans surcharger l'API MAFATE.

import { Mafate } from '@mafate/sdk'

const mafate = new Mafate({ apiKey: process.env.MAFATE_API_KEY })
const BATCH_SIZE = 500

async function computeHash(value: string): Promise<string> {
  return await mafate.hash(value.toLowerCase(), 'key-users-pii')
}

async function backfillUsers(): Promise<void> {
  let totalProcessed = 0
  let hasMore = true

  while (hasMore) {
    // Sélectionner un batch d'utilisateurs sans chiffrement
    const { rows } = await db.query(
      'SELECT id, email, phone FROM users WHERE email_encrypted IS NULL LIMIT $1',
      [BATCH_SIZE]
    )

    if (rows.length === 0) {
      hasMore = false
      break
    }

    // Chiffrer en parallèle par batch de 50
    const chunks = chunkArray(rows, 50)
    for (const chunk of chunks) {
      await Promise.all(chunk.map(async (user) => {
        const [encEmail, encPhone] = await Promise.all([
          mafate.encryptLocal(user.email, 'key-users-pii'),
          mafate.encryptLocal(user.phone, 'key-users-pii'),
        ])

        await db.query(
          'UPDATE users SET email_encrypted = $1, phone_encrypted = $2, email_hash = $3 WHERE id = $4',
          [JSON.stringify(encEmail), JSON.stringify(encPhone), await computeHash(user.email), user.id]
        )
      }))
    }

    totalProcessed += rows.length
    console.log(`Backfill en cours : ${totalProcessed} utilisateurs chiffrés`)

    // Pause de 100ms entre les batchs pour ne pas surcharger l'API
    await new Promise(r => setTimeout(r, 100))
  }

  console.log(`Backfill terminé : ${totalProcessed} utilisateurs migrés`)
}

function chunkArray<T>(arr: T[], size: number): T[][] {
  return Array.from({ length: Math.ceil(arr.length / size) }, (_, i) =>
    arr.slice(i * size, i * size + size)
  )
}

// Lancer le backfill
backfillUsers().catch(console.error)

5. Timeline recommandée

SemaineActionRisque
S1Ajout des colonnes SQL + déploiement dual-writeFaible
S1-S2Lancement du backfill en production (nuit)Moyen
S2-S3Validation : 100% des lignes chiffréesFaible
S3-S4Suppression des colonnes en clairFaible (après validation)

Plan de rollback

Phases 1 et 2 : rollback immédiat possible, supprimer les colonnes ajoutées et redéployer l'ancienne version. Phase 3 (backfill) : arrêter le script et vérifier les données. Phase 4 : pas de rollback facile, ne supprimer les colonnes en clair qu'après validation complète.

Besoin d’accompagnement pour votre migration ?

Notre équipe peut réaliser la migration de vos données existantes. Audit, script sur mesure, validation et accompagnement, de 10K à 100M+ lignes.