espace-paie-odentas/SECURITY_AUDIT_FACTURATION_INFORMATIONS.md
2025-10-17 13:02:39 +02:00

24 KiB
Raw Permalink Blame History

🔒 Audit de Sécurité - Facturation & Vos Informations

Date: 16 octobre 2025
Périmètre: Pages /facturation et /informations + routes API associées
Tables critiques: invoices, organization_details, productions


📋 Executive Summary

Critère Statut Notes
RLS (Row Level Security) ⚠️ À vérifier Script de vérification créé
Filtrage org_id 🟢 EXCELLENT Filtrage systématique présent
Authentification 🟢 EXCELLENT Vérification session obligatoire
Autorisation 🟢 EXCELLENT Resolution org_id côté serveur
Injection SQL 🟢 EXCELLENT Supabase ORM utilisé
Isolation des données 🟢 EXCELLENT Séparation staff/client robuste

Niveau de sécurité global: 🟡 BON → 🟢 EXCELLENT (après vérification RLS)


🏗️ Architecture

1. Facturation

┌─────────────────────────────────────────────────────────────┐
│                   FACTURATION ECOSYSTEM                      │
└─────────────────────────────────────────────────────────────┘

CLIENT SIDE:
  app/(app)/facturation/page.tsx
    ├─ SepaInfo (RIB, BIC, mandat)
    ├─ Invoice list (numéro, période, montant, statut)
    ├─ Pagination (25/50 items par page)
    └─ PDF download (signed S3 URLs)

API ROUTES:
  /api/facturation/route.ts (GET)
    ├─ Authentication check (session required)
    ├─ getClientInfoFromSession()
    │   ├─ Staff detection → active_org_id from cookie
    │   └─ Client → org_id from organization_members
    ├─ 1) SEPA info from organization_details
    │   └─ Query: .eq("org_id", clientInfo.id) ✅
    ├─ 2) Invoices from invoices table
    │   └─ Query: .eq("org_id", clientInfo.id) ✅
    └─ 3) S3 presigned URLs for PDFs (15 min expiry)

DATABASE:
  invoices
    ├─ id (PK)
    ├─ org_id (FK → organizations) ⚠️ RLS requis
    ├─ invoice_number, period_label
    ├─ invoice_date, amount_ht, amount_ttc
    ├─ status, pdf_s3_key
    └─ created_at, updated_at
    
  organization_details
    ├─ id (PK)
    ├─ org_id (FK → organizations) ⚠️ RLS requis
    ├─ iban, bic (SEPA info)
    ├─ email_notifs, email_notifs_cc
    ├─ prenom_contact, nom_contact
    ├─ siret, code_employeur
    └─ ... (40+ colonnes d'infos structure)

2. Vos Informations

┌─────────────────────────────────────────────────────────────┐
│                  VOS INFORMATIONS ECOSYSTEM                  │
└─────────────────────────────────────────────────────────────┘

CLIENT SIDE:
  app/(app)/informations/page.tsx
    ├─ StructureInfos (raison sociale, SIRET, etc.)
    ├─ Contact info (email, téléphone)
    ├─ Caisses & organismes (URSSAF, Audiens, etc.)
    └─ Productions list (nom, n° objet, déclaration)

API ROUTES:
  /api/informations/route.ts (GET)
    ├─ Authentication check (session required)
    ├─ getClientInfoFromSession()
    │   ├─ Staff detection → active_org_id from cookie
    │   └─ Client → org_id from organization_members
    └─ Query organization_details
        └─ Filter: .eq("org_id", clientInfo.id) ✅

  /api/informations/productions/route.ts (GET)
    ├─ Authentication check (session required)
    ├─ getClientInfoFromSession()
    ├─ Pagination (default 25/page, max 50)
    └─ Query productions
        └─ Filter: .eq("org_id", clientInfo.id) ✅

STAFF ROUTES (Bonus - Gestion Productions):
  /api/staff/productions/route.ts (GET, POST)
    ├─ isStaffUser() verification ✅
    ├─ GET: List all productions (with optional org_id filter)
    └─ POST: Create new production
        └─ org_id validation (organization must exist)

  /api/staff/productions/[id]/route.ts (GET, PATCH, DELETE)
    ├─ isStaffUser() verification ✅
    ├─ GET: Read single production
    ├─ PATCH: Update production (no org_id change allowed)
    └─ DELETE: Remove production

DATABASE:
  productions
    ├─ id (PK)
    ├─ org_id (FK → organizations) ⚠️ RLS requis
    ├─ name, reference
    ├─ declaration_date
    └─ created_at, updated_at

🔍 Analyse des Vulnérabilités

🟢 CONFORMITÉS IDENTIFIÉES

C1. Authentification Robuste (FACTURATION)

Fichier: app/api/facturation/route.ts (lignes 85-87)

const supabase = createRouteHandlerClient({ cookies });
const { data: { session } } = await supabase.auth.getSession();
if (!session) return NextResponse.json({ error: 'unauthorized' }, { status: 401 });

Statut: 🟢 CONFORME
Justification: Vérification auth obligatoire avant toute opération.


C2. Resolution org_id Côté Serveur (FACTURATION)

Fichier: app/api/facturation/route.ts (lignes 88-95)

let clientInfo;
try {
  clientInfo = await getClientInfoFromSession(session, supabase);
} catch (e) {
  const message = e instanceof Error ? e.message : String(e);
  return NextResponse.json({ error: 'forbidden', message }, { status: 403 });
}

Statut: 🟢 CONFORME
Justification: Fonction centralisée getClientInfoFromSession() pour résolution org_id.


C3. Filtrage org_id SEPA (FACTURATION)

Fichier: app/api/facturation/route.ts (lignes 98-105)

// 1) SEPA info from organization_details
let details: any = null;
let detailsError: any = null;
if (clientInfo.id) {
  const res = await supabase
    .from('organization_details')
    .select('iban, bic')
    .eq('org_id', clientInfo.id)
    .maybeSingle();
  details = res.data;
  detailsError = res.error;
}

Statut: 🟢 CONFORME
Justification: Filtrage explicite par org_id pour les infos SEPA.


C4. Filtrage org_id Invoices (FACTURATION)

Fichier: app/api/facturation/route.ts (lignes 118-123)

// 2) Invoices from Supabase
let query: any = supabase
  .from('invoices')
  .select('*', { count: 'exact' });
if (clientInfo.id) {
  query = query.eq('org_id', clientInfo.id);
}

Statut: 🟢 CONFORME
Justification: Filtrage explicite par org_id pour les factures.


C5. S3 URLs Sécurisées (FACTURATION)

Fichier: app/api/facturation/route.ts (lignes 127-145)

// 3) Presign S3 URLs for PDFs
const bucket = (process.env.AWS_S3_BUCKET || 'odentas-docs').trim();
const expireSeconds = Math.max(60, Math.min(60 * 60, Number(process.env.INVOICE_URL_EXPIRES ?? 900)));

const maybeSign = async (key?: string | null) => {
  if (!key) return null;
  try {
    if (!signer) {
      const { S3Client, GetObjectCommand, getSignedUrl } = await getS3Presigner();
      signer = { S3Client, GetObjectCommand, getSignedUrl, client: new S3Client({ region }) };
    }
    const cmd = new signer.GetObjectCommand({ Bucket: bucket, Key: key });
    const url = await signer.getSignedUrl(signer.client, cmd, { expiresIn: expireSeconds });
    return url as string;
  } catch (e) {
    console.error('[api/facturation] presign error for key', key, e);
    return null;
  }
};

Statut: 🟢 CONFORME
Justification:

  • URLs pré-signées avec expiration (15 min par défaut)
  • Clés S3 récupérées uniquement pour les factures filtrées par org_id
  • Pas d'accès direct aux clés S3 depuis le client

C6. Authentification Robuste (INFORMATIONS)

Fichier: app/api/informations/route.ts (lignes 77-81)

const supabase = createRouteHandlerClient({ cookies });
const { data: { session } } = await supabase.auth.getSession();
if (!session) {
  return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
}

Statut: 🟢 CONFORME
Justification: Vérification auth obligatoire.


C7. Filtrage org_id organization_details (INFORMATIONS)

Fichier: app/api/informations/route.ts (lignes 92-97)

// Read details from Supabase organization_details
let details: any = null;
let error: any = null;
if (clientInfo.id) {
  const res = await supabase.from('organization_details').select('*').eq('org_id', clientInfo.id).single();
  details = res.data;
  error = res.error;
}

Statut: 🟢 CONFORME
Justification: Filtrage explicite par org_id, requête .single() garantit 1 seule ligne.


C8. Filtrage org_id Productions (INFORMATIONS)

Fichier: app/api/informations/productions/route.ts (lignes 84-88)

// Query productions for this organization
let query: any = supabase.from('productions').select('*', { count: 'exact' });
if (clientInfo.id) {
  query = query.eq('org_id', clientInfo.id);
}

Statut: 🟢 CONFORME
Justification: Filtrage explicite par org_id pour les productions.


C9. Staff-Only Routes Protection (PRODUCTIONS)

Fichier: app/api/staff/productions/route.ts (lignes 11-21, 26-43)

async function isStaffUser(supabase: any, userId: string): Promise<boolean> {
  try {
    const { data: staffRow } = await supabase
      .from("staff_users")
      .select("is_staff")
      .eq("user_id", userId)
      .maybeSingle();
    return !!staffRow?.is_staff;
  } catch {
    return false;
  }
}

export async function GET(req: NextRequest) {
  // ... session check ...
  
  const isStaff = await isStaffUser(supabase, session.user.id);
  if (!isStaff) {
    return NextResponse.json(
      { error: "forbidden", message: "Staff access required" },
      { status: 403 }
    );
  }
  // ... rest of handler ...
}

Statut: 🟢 CONFORME
Justification: Toutes les routes /api/staff/productions/* vérifient explicitement le statut staff.


C10. Immutabilité org_id en UPDATE (PRODUCTIONS)

Fichier: app/api/staff/productions/[id]/route.ts (lignes 94-104)

// Permettre la mise à jour de tous les champs (sauf id, org_id pour sécurité)
const allowedFields = [
  "name",
  "reference",
  "declaration_date"
];

for (const field of allowedFields) {
  if (body[field] !== undefined) {
    updates[field] = body[field];
  }
}

Statut: 🟢 CONFORME
Justification: org_id explicitement exclu des mises à jour possibles.


⚠️ VÉRIFICATIONS REQUISES

⚠️ V1. RLS Non Vérifié sur invoices

Criticité: 🔴 CRITIQUE
Tables: invoices

Problème:
La table invoices contient les factures par organisation. Bien que le filtrage applicatif .eq("org_id", clientInfo.id) soit présent, l'activation du RLS n'a pas été vérifiée.

Scénario d'attaque:

// Si RLS désactivé, un attaquant pourrait contourner l'API:
const supabase = createClient(SUPABASE_URL, SUPABASE_ANON_KEY);
const { data } = await supabase.from("invoices").select("*");
// → Accès à TOUTES les factures de TOUTES les organisations ❌

Impact:

  • ⚠️ Divulgation de montants facturés (HT, TTC)
  • ⚠️ Exposition de périodes de facturation
  • ⚠️ Accès aux numéros de facture
  • ⚠️ Violation RGPD (données financières organisation)

Vérification requise:

# Exécuter le script de vérification
psql $DATABASE_URL -f scripts/verify-rls-facturation-informations.sql

Correction si RLS désactivé:

-- Activer RLS
ALTER TABLE invoices ENABLE ROW LEVEL SECURITY;

-- Créer politique pour clients
CREATE POLICY "Users can view their org invoices"
ON invoices FOR SELECT
USING (
  org_id IN (
    SELECT org_id FROM organization_members
    WHERE user_id = auth.uid()
  )
);

-- Politique pour staff (service-role bypass)
CREATE POLICY "Service role bypass"
ON invoices FOR ALL
USING (true)
WITH CHECK (true)
TO service_role;

⚠️ V2. RLS Non Vérifié sur organization_details

Criticité: 🔴 CRITIQUE
Tables: organization_details

Problème:
Table contenant toutes les informations sensibles de l'organisation :

  • IBAN, BIC (données bancaires)
  • Email notifications
  • Code employeur, SIRET
  • Contacts (prénom, nom, téléphone)
  • Identifiants caisses (URSSAF, Audiens, etc.)

Scénario d'attaque:

// Sans RLS, accès direct à toutes les orgs
const { data } = await supabase
  .from("organization_details")
  .select("iban, bic, email_notifs, siret, code_employeur");
// → Fuite MASSIVE de données sensibles ❌

Impact:

  • 🔴 CRITIQUE: Divulgation IBAN/BIC
  • 🔴 CRITIQUE: Exposition emails et contacts
  • 🔴 CRITIQUE: Accès codes employeurs et SIRET
  • 🔴 CRITIQUE: Violation RGPD majeure

Correction si RLS désactivé:

ALTER TABLE organization_details ENABLE ROW LEVEL SECURITY;

-- Politique SELECT pour clients
CREATE POLICY "Users can view their org details"
ON organization_details FOR SELECT
USING (
  org_id IN (
    SELECT org_id FROM organization_members
    WHERE user_id = auth.uid()
  )
);

-- Politique UPDATE pour admin de l'org (si nécessaire)
CREATE POLICY "Admins can update their org details"
ON organization_details FOR UPDATE
USING (
  org_id IN (
    SELECT org_id FROM organization_members
    WHERE user_id = auth.uid() AND role IN ('ADMIN', 'SUPER_ADMIN')
  )
);

-- Staff bypass
CREATE POLICY "Service role bypass"
ON organization_details FOR ALL
USING (true)
WITH CHECK (true)
TO service_role;

⚠️ V3. RLS Non Vérifié sur productions

Criticité: 🟠 MODÉRÉE
Tables: productions

Problème:
Table des productions/spectacles par organisation. Moins critique que les données financières, mais contient des informations métier.

Scénario d'attaque:

// Sans RLS, accès à toutes les productions
const { data } = await supabase
  .from("productions")
  .select("*");
// → Fuite des noms de spectacles, références, dates de déclaration

Impact:

  • ⚠️ Divulgation des productions en cours
  • ⚠️ Exposition références internes
  • ⚠️ Information concurrentielle (moins critique)

Correction si RLS désactivé:

ALTER TABLE productions ENABLE ROW LEVEL SECURITY;

-- Politique SELECT pour clients
CREATE POLICY "Users can view their org productions"
ON productions FOR SELECT
USING (
  org_id IN (
    SELECT org_id FROM organization_members
    WHERE user_id = auth.uid()
  )
);

-- Staff bypass
CREATE POLICY "Service role bypass"
ON productions FOR ALL
USING (true)
WITH CHECK (true)
TO service_role;

🟡 OPTIMISATIONS RECOMMANDÉES

🟡 O1. Index sur org_id (Performance RLS)

Criticité: 🟡 FAIBLE (performance)
Tables: invoices, organization_details, productions

Justification:
Avec RLS activé, chaque requête sera filtrée par org_id. Un index améliore drastiquement les performances.

Vérification:

-- Exécuter section 3⃣ du script verify-rls-facturation-informations.sql
SELECT indexname, indexdef FROM pg_indexes
WHERE tablename IN ('invoices', 'organization_details', 'productions')
  AND indexdef ILIKE '%org_id%';

Création si absents:

-- invoices
CREATE INDEX IF NOT EXISTS idx_invoices_org_id
ON invoices(org_id);

-- organization_details
CREATE INDEX IF NOT EXISTS idx_organization_details_org_id
ON organization_details(org_id);

-- productions
CREATE INDEX IF NOT EXISTS idx_productions_org_id
ON productions(org_id);

🟡 O2. Logging Accès Factures (Traçabilité)

Criticité: 🟡 FAIBLE (audit)
Fichier: app/api/facturation/route.ts

Observation:
Aucun logging des accès aux factures et génération de URLs S3 signées.

Recommandation (optionnel):

// Après génération des URLs signées
console.log(`[AUDIT] User ${session.user.id} accessed ${items.length} invoices for org ${clientInfo.id}`);

// Log détaillé si nécessaire (compliance)
items.forEach(inv => {
  if (inv.pdf) {
    console.log(`[PDF_ACCESS] User: ${session.user.id}, Invoice: ${inv.id}, Org: ${clientInfo.id}`);
  }
});

📊 Matrice des Risques

ID Vulnérabilité Criticité Probabilité Impact Risque Statut
V1 RLS désactivé sur invoices 🔴 Critique Élevée Élevé ÉLEVÉ ⚠️ À vérifier
V2 RLS désactivé sur organization_details 🔴 Critique Élevée Très élevé CRITIQUE ⚠️ À vérifier
V3 RLS désactivé sur productions 🟠 Modérée Moyenne Moyen MOYEN ⚠️ À vérifier
O1 Index org_id manquants 🟡 Faible Faible Faible FAIBLE Optionnel
O2 Logging accès factures 🟡 Faible Très faible Faible FAIBLE Optionnel

Points Forts de l'Implémentation

1 Séparation Staff/Client Robuste

  • Détection staff via table dédiée staff_users
  • Routes /api/staff/* protégées par isStaffUser()
  • Fonction getClientInfoFromSession() centralisée

2 Filtrage Applicatif Systématique

  • Tous les endpoints appliquent .eq("org_id", clientInfo.id)
  • Aucune requête sans filtrage org_id (si clientInfo.id présent)
  • Fallback sur organization_members pour clients

3 Sécurité S3 Robuste (Facturation)

  • URLs pré-signées avec expiration (15 min)
  • Clés S3 jamais exposées au client
  • Génération côté serveur uniquement après vérification org_id

4 Validation des Données

  • Vérification existence organisation avant création (productions)
  • Champs org_id non modifiables en UPDATE
  • Pagination sécurisée avec limites (max 50/page)

5 Architecture Cohérente

  • Pattern similaire aux écosystèmes contrats/virements (déjà audités)
  • Utilisation de React Query pour cache client
  • Gestion erreurs explicite (401, 403, 500)

🔧 Plan de Correction

Phase 1: Vérification Critique (IMMÉDIAT)

# 1. Exécuter le script de vérification RLS
psql $DATABASE_URL -f scripts/verify-rls-facturation-informations.sql

# 2. Analyser les résultats
# - Vérifier que rls_enabled = true pour les 3 tables
# - Lister les politiques existantes
# - Vérifier les index org_id

Phase 2: Corrections RLS (SI REQUIS)

-- Si RLS désactivé, exécuter les scripts de correction V1, V2, V3
-- Voir sections correspondantes ci-dessus

-- Vérifier après correction
SELECT tablename, rowsecurity FROM pg_tables
WHERE tablename IN ('invoices', 'organization_details', 'productions');

Phase 3: Optimisations (OPTIONNEL)

-- Créer les index pour performance RLS
\i scripts/create-indexes-facturation-informations.sql

-- Analyser les plans d'exécution
EXPLAIN ANALYZE
SELECT * FROM invoices WHERE org_id = 'test-org-id';

Phase 4: Tests de Validation

// Test 1: Vérifier isolation entre organisations (invoices, organization_details)
// Créer 2 orgs, vérifier qu'un client A ne peut pas voir les données de B

// Test 2: Vérifier staff global access
// Staff sans active_org_id doit pouvoir lister toutes les données (via service-role)

// Test 3: Vérifier URLs S3 signées expirées
// Attendre 15 min, vérifier que l'URL ne fonctionne plus

📝 Recommandations Finales

Priorité HAUTE

  1. ⚠️ Vérifier RLS activé sur invoices, organization_details, productions
  2. ⚠️ Créer politiques RLS si absentes (scripts fournis ci-dessus)
  3. ⚠️ organization_details : CRITIQUE - contient IBAN, emails, SIRET
  4. ⚠️ Tester isolation entre organisations en environnement staging

Priorité MOYENNE

  1. 🟡 Créer index org_id pour performance (surtout avec RLS)
  2. 🟡 Ajouter logging pour accès factures (compliance RGPD)

Priorité BASSE

  1. Documenter le pattern staff/client dans README.md
  2. Créer tests E2E pour facturation et informations

🔗 Références Croisées

  • Audit Contrats: SECURITY_AUDIT_CONTRATS.md (patterns similaires)
  • Audit Virements/Cotisations: SECURITY_AUDIT_VIREMENTS_COTISATIONS.md (référence)
  • Vérification RLS: scripts/verify-rls-facturation-informations.sql (nouveau)

📅 Historique des Modifications

Date Auteur Modification
2025-10-16 GitHub Copilot Audit initial - Facturation & Informations
2025-10-16 GitHub Copilot Création script verify-rls-facturation-informations.sql

🎯 Conclusion

État actuel: 🟡 BON (avec réserves critiques)

Points Forts Validés

Code applicatif : 🟢 EXCELLENT

  • Filtrage org_id systématique dans toutes les routes
  • Vérifications staff robustes (fonction isStaffUser())
  • URLs S3 pré-signées sécurisées
  • Architecture propre avec séparation staff/client

Base de données : ⚠️ À VÉRIFIER

  • ⚠️ invoices : RLS non vérifié (données financières)
  • ⚠️ organization_details : RLS non vérifié (CRITIQUE - IBAN, emails, SIRET)
  • ⚠️ productions : RLS non vérifié (données métier)

🚨 Point d'Alerte MAJEUR (RÉSOLU )

organization_details était la table la plus sensible de l'application :

  • 🔴 Contient IBAN/BIC (données bancaires)
  • 🔴 Contient emails et contacts personnels
  • 🔴 Contient codes employeurs et SIRET
  • 🔴 40+ colonnes de données confidentielles

Problème détecté : RLS désactivé → Violation RGPD massive potentielle
Solution appliquée : Script fix-rls-organization-details.sql → 4 politiques créées


📊 RÉSULTATS VÉRIFICATION FINALE (16 octobre 2025)

État RLS (3/3 tables protégées)

Table RLS Politiques Index org_id Statut
invoices Activé 4 (SELECT, INSERT, UPDATE, DELETE) 3 index EXCELLENT
organization_details Activé 4 (SELECT, INSERT, UPDATE, DELETE) 3 index UNIQUE CORRIGÉ
productions Activé 4 (SELECT, INSERT, UPDATE, DELETE) 4 index EXCELLENT

🔒 Politiques Appliquées

Toutes les tables utilisent le pattern is_member_of_org(org_id) :

  • SELECT : Les utilisateurs voient uniquement leur organisation
  • INSERT : Les utilisateurs créent uniquement dans leur organisation
  • UPDATE : Les utilisateurs modifient uniquement leur organisation
  • DELETE : Les utilisateurs suppriment uniquement dans leur organisation

📈 Performance Garantie

  • invoices : 3 index (dont 1 UNIQUE sur org_id + invoice_number)
  • organization_details : 3 index UNIQUE (clé primaire sur org_id)
  • productions : 4 index (dont 1 UNIQUE sur org_id + name)

🎯 État Final : 🟢 EXCELLENT

L'écosystème facturation/informations est maintenant :

  • Aussi sécurisé que les autres écosystèmes (contrats, virements, cotisations)
  • Protection multi-couches : Authentification + Filtrage applicatif + RLS + Index
  • Isolation parfaite entre organisations (is_member_of_org)
  • URLs S3 sécurisées avec expiration (15 min)
  • Données bancaires protégées (IBAN/BIC sous RLS)
  • Conforme RGPD (données personnelles isolées)

Niveau de sécurité global : 🟢 EXCELLENT

Correction appliquée : scripts/fix-rls-organization-details.sql
Scripts de vérification : scripts/verify-rls-facturation-informations.sql