espace-paie-odentas/SECURITY_AUDIT_VIREMENTS_COTISATIONS.md

23 KiB
Raw Blame History

🔒 Audit de Sécurité - Virements Salaires & Cotisations

Date: 2025-01-XX
Périmètre: Pages /virements-salaires et /cotisations + routes API associées
Tables critiques: salary_transfers, monthly_contributions, contribution_notifications


📋 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 staff + session
Autorisation 🟢 EXCELLENT Staff-only pour routes /staff/
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. Virements Salaires

┌─────────────────────────────────────────────────────────────┐
│                 VIREMENTS-SALAIRES ECOSYSTEM                │
└─────────────────────────────────────────────────────────────┘

CLIENT SIDE:
  app/(app)/virements-salaires/page.tsx
    ├─ VirementItem (structure: staff view)
    ├─ ClientVirementItem (structure: client view)
    ├─ Filters: year, period, search
    └─ Demo mode support

API ROUTES:
  /api/virements-salaires/route.ts (GET)
    ├─ Authentication check (session required)
    ├─ Staff detection (staff_users table)
    ├─ Organization resolution (resolveActiveOrg)
    ├─ Staff mode:
    │   └─ Query salary_transfers + payslips
    │       └─ Filter: .eq("org_id", activeOrgId)
    └─ Client mode:
        └─ Query payslips only
            └─ Filter: .eq("organization_id", activeOrgId)

DATABASE:
  salary_transfers
    ├─ id (PK)
    ├─ org_id (FK → organizations) ⚠️ RLS requis
    ├─ period_label, status, amount
    └─ created_at, updated_at
    
  payslips
    ├─ id (PK)
    ├─ organization_id (FK) ✅ RLS vérifié (audit contrats)
    └─ employee_id, contract_id, etc.

2. Cotisations

┌─────────────────────────────────────────────────────────────┐
│                    COTISATIONS ECOSYSTEM                     │
└─────────────────────────────────────────────────────────────┘

CLIENT SIDE:
  app/(app)/cotisations/page.tsx
    ├─ LigneCotisation (monthly breakdown)
    ├─ Staff selector (organization picker)
    ├─ Filters: year, period, from/to dates
    └─ Caisses: URSSAF, FTS, Audiens, etc.

API ROUTES (Client Access):
  /api/cotisations/mensuelles/route.ts (GET)
    ├─ Authentication check (session required)
    ├─ getClientInfoFromSession()
    │   ├─ Staff detection → active_org_id from cookie
    │   └─ Client → org_id from organization_members
    ├─ Staff override via header x-active-org-id
    └─ Query monthly_contributions
        └─ Filter: .eq("org_id", clientInfo.id)  ✅

API ROUTES (Staff Only):
  /api/staff/cotisations/route.ts (GET, POST)
    ├─ isStaffUser() verification ✅
    ├─ GET: List all contributions (with optional org_id filter)
    └─ POST: Create new contribution
        ├─ org_id validation (organization must exist)
        └─ Insert into monthly_contributions

  /api/staff/cotisations/[id]/route.ts (PATCH, DELETE)
    ├─ isStaffUser() verification ✅
    ├─ PATCH: Update contribution (no org_id change allowed)
    └─ DELETE: Remove contribution

  /api/staff/cotisations/[id]/notify-client/route.ts (POST)
    ├─ isStaffUser() verification ✅
    ├─ Fetch cotisation by id
    ├─ Fetch organization details
    ├─ Send email notification to client
    └─ Log in contribution_notifications table

  /api/staff/cotisations/notifications/route.ts (GET)
    ├─ isStaffUser() verification ✅
    └─ Query contribution_notifications
        └─ Optional filters: org_id, year

STAFF UI:
  app/(app)/staff/gestion-cotisations/page.tsx
    └─ Uses /api/staff/cotisations/* endpoints

DATABASE:
  monthly_contributions
    ├─ id (PK)
    ├─ org_id (FK → organizations) ⚠️ RLS requis
    ├─ fund (URSSAF, FTS, Audiens, etc.)
    ├─ period_label, due_date, paid_date
    ├─ status, amount_due, amount_paid
    └─ notes

  contribution_notifications
    ├─ id (PK)
    ├─ org_id (FK → organizations) ⚠️ RLS requis
    ├─ period_label, notified_at
    └─ notification metadata

🔍 Analyse des Vulnérabilités

🟢 CONFORMITÉS IDENTIFIÉES

C1. Authentification Robuste (VIREMENTS-SALAIRES)

Fichier: app/api/virements-salaires/route.ts (lignes 40-47)

const { data: { user }, error: userError } = await sb.auth.getUser();
if (userError || !user) {
  return new NextResponse(JSON.stringify({ error: "unauthorized" }), { status: 401 });
}

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


C2. Staff Detection Explicite (VIREMENTS-SALAIRES)

Fichier: app/api/virements-salaires/route.ts (lignes 49-55)

const { data: staffData } = await sb
  .from("staff_users")
  .select("is_staff")
  .eq("user_id", user.id)
  .maybeSingle();

isStaff = !!staffData?.is_staff;

Statut: 🟢 CONFORME
Justification: Détection côté serveur via table dédiée, pas de métadonnées client.


C3. Filtrage org_id Systématique (VIREMENTS-SALAIRES)

Fichier: app/api/virements-salaires/route.ts (lignes 177-186)

// Mode staff: salary_transfers
query = sb
  .from("salary_transfers")
  .select("*")
  .eq("org_id", activeOrgId)
  .order("id", { ascending: false });

// Mode client: payslips
query = sb
  .from("payslips")
  .select("*")
  .eq("organization_id", activeOrgId)
  .order("id", { ascending: false });

Statut: 🟢 CONFORME
Justification: Filtrage explicite par org_id dans les deux modes.


C4. Staff-Only Routes Protection (COTISATIONS)

Fichier: app/api/staff/cotisations/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/cotisations/* vérifient explicitement le statut staff.


C5. Client Route avec Filtrage (COTISATIONS)

Fichier: app/api/cotisations/mensuelles/route.ts (lignes 88-118, 158-165)

async function getClientInfoFromSession(session: any, supabase: any) {
  // ... staff detection ...
  
  if (isStaff) {
    const cookieStore = cookies();
    const activeOrgId = cookieStore.get('active_org_id')?.value;
    if (!activeOrgId) {
      return { id: null, name: 'Staff Access', isStaff: true };
    }
    // ... return staff org ...
  }

  const orgInfo = await getOrganizationFromDatabase(supabase, session.user.id);
  if (!orgInfo) throw new Error('User is not associated with any organization');
  return orgInfo;
}

// Dans GET handler (lignes 158-165)
let query: any = supabase.from('monthly_contributions').select('*');
if (clientInfo.id) {
  query = query.eq('org_id', clientInfo.id);
}

Statut: 🟢 CONFORME
Justification: Résolution org_id côté serveur + filtrage explicite.


C6. Validation Organisation Existence (COTISATIONS)

Fichier: app/api/staff/cotisations/route.ts (lignes 160-171)

// Vérifier que l'organisation existe
const { data: orgExists, error: orgError } = await supabase
  .from("organizations")
  .select("id")
  .eq("id", org_id)
  .single();

if (orgError || !orgExists) {
  return NextResponse.json(
    { error: "Organisation introuvable" },
    { status: 400 }
  );
}

Statut: 🟢 CONFORME
Justification: Protection contre l'injection d'org_id invalides.


C7. Immutabilité org_id en UPDATE (COTISATIONS)

Fichier: app/api/staff/cotisations/[id]/route.ts (lignes 60-77)

// Permettre la mise à jour de tous les champs (sauf id, org_id pour sécurité)
const allowedFields = [
  'fund',
  'contrib_type',
  'reference',
  'period_label',
  'due_date',
  'paid_date',
  'status',
  'amount_due',
  'amount_paid',
  'amount_diff',
  'notes'
];

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 EFFECTUÉES (16 octobre 2025)

V1. RLS Vérifié sur salary_transfers

Criticité: 🔴 CRITIQUE → CONFORME
Tables: salary_transfers

Résultat:
RLS activé : rls_enabled: true 4 politiques robustes en place :

  • SELECT : is_staff() OR is_member_of_org(org_id) → Isolation parfaite
  • INSERT/UPDATE/DELETE : is_staff() → Staff only, protection totale

Index: Index unique composite (org_id, period_month, mode, callsheet_url)


V2. RLS Vérifié sur monthly_contributions

Criticité: 🔴 CRITIQUE → CONFORME
Tables: monthly_contributions

Résultat:
RLS activé : rls_enabled: true 4 politiques robustes avec is_member_of_org(org_id) :

  • SELECT/INSERT/UPDATE/DELETE : Isolation par organisation garantie

Index: 3 index dont 2 sur org_id simple + 1 index unique composite


CORRECTION APPLIQUÉE (16 octobre 2025)

V3. RLS Activé sur contribution_notifications

Criticité: 🟠 MODÉRÉE → CONFORME
Tables: contribution_notifications

Résultat après correction:
RLS activé : rls_enabled: true 4 politiques staff-only créées :

  • SELECT : is_staff() → Staff uniquement
  • INSERT : is_staff() → Staff uniquement
  • UPDATE : is_staff() → Staff uniquement
  • DELETE : is_staff() → Staff uniquement Index présent : Index unique (org_id, period_label)

Ancien scénario d'attaque (maintenant bloqué):

Scénario d'attaque:

// ❌ AVANT correction (Sans RLS) :
const { data } = await supabase
  .from("contribution_notifications")
  .select("*");
// → Fuite : quelles orgs ont été notifiées, combien de fois, etc.

// ✅ APRÈS correction (Avec RLS) :
const { data } = await supabase
  .from("contribution_notifications")
  .select("*");
// → Bloqué par RLS si l'utilisateur n'est pas staff (is_staff() = false)
// → Résultat : data = [] ou erreur 403 selon configuration

Impact avant correction:

  • ⚠️ Exposition historique des notifications (métadonnées)
  • ⚠️ Fuite d'informations sur les relances clients

Correction appliquée (16 octobre 2025) :

-- ✅ Script exécuté : scripts/fix-rls-contribution-notifications.sql
ALTER TABLE contribution_notifications ENABLE ROW LEVEL SECURITY;

-- ✅ 4 politiques staff-only créées
CREATE POLICY "select_staff_only" ON contribution_notifications
FOR SELECT TO public USING (is_staff());

CREATE POLICY "insert_staff_only" ON contribution_notifications
FOR INSERT TO public WITH CHECK (is_staff());

CREATE POLICY "update_staff_only" ON contribution_notifications
FOR UPDATE TO public USING (is_staff()) WITH CHECK (is_staff());

CREATE POLICY "delete_staff_only" ON contribution_notifications
FOR DELETE TO public USING (is_staff());

Vérification post-correction :

// Politiques créées (résultat psql) :
[
  {
    "policyname": "select_staff_only",
    "cmd": "SELECT",
    "qual": "is_staff()"
  },
  {
    "policyname": "insert_staff_only",
    "cmd": "INSERT",
    "with_check": "is_staff()"
  },
  {
    "policyname": "update_staff_only",
    "cmd": "UPDATE",
    "qual": "is_staff()",
    "with_check": "is_staff()"
  },
  {
    "policyname": "delete_staff_only",
    "cmd": "DELETE",
    "qual": "is_staff()"
  }
]

OPTIMISATIONS VÉRIFIÉES

O1. Index sur org_id (Performance RLS)

Criticité: 🟡 FAIBLE (performance) → CONFORME
Tables: salary_transfers, monthly_contributions, contribution_notifications

Résultat de vérification (16 octobre 2025) :

contribution_notifications :

  • Index unique (org_id, period_label)EXCELLENT

monthly_contributions :

  • idx_monthly_contributions_org_id → Index simple sur org_id
  • monthly_contribs_org_idx → Index simple sur org_id (redondant mais OK)
  • Index unique (org_id, fund, period_label, contrib_type) → Contrainte métier

salary_transfers :

  • Index unique (org_id, period_month, mode, callsheet_url)OPTIMAL

Conclusion :
🟢 Tous les index requis sont présents. Performance RLS garantie.

Aucune action requise.


🟡 O2. Validation du Header x-active-org-id (Cotisations)

Criticité: 🟡 FAIBLE (already secured by staff check)
Fichier: app/api/cotisations/mensuelles/route.ts (lignes 139-153)

Code actuel:

if (clientInfo.isStaff) {
  const headerOrgId = req.headers.get('x-active-org-id');
  if (headerOrgId) {
    const { data: orgData } = await supabase
      .from('organizations')
      .select('structure_api')
      .eq('id', headerOrgId)
      .single();
    if (orgData) {
      clientInfo = {
        id: headerOrgId,
        name: orgData.structure_api || 'Staff Access',
        isStaff: true
      };
    }
  }
}

Observation:
Le code vérifie que l'organisation existe avant de l'utiliser. Cependant, aucun logging n'est effectué si l'org_id est invalide.

Recommandation (optionnel):

if (headerOrgId) {
  const { data: orgData, error: orgFetchError } = await supabase
    .from('organizations')
    .select('structure_api')
    .eq('id', headerOrgId)
    .single();
  
  if (orgFetchError || !orgData) {
    // Log tentative d'accès à org inexistante (potentiellement malveillant)
    console.warn(`[SECURITY] Staff user ${session.user.id} attempted to access non-existent org: ${headerOrgId}`);
    // Continuer sans override (fallback to default staff behavior)
  } else {
    clientInfo = {
      id: headerOrgId,
      name: orgData.structure_api || 'Staff Access',
      isStaff: true
    };
  }
}

📊 Matrice des Risques (Mise à jour : 16 octobre 2025 - CORRECTION TERMINÉE)

ID Vulnérabilité Criticité Probabilité Impact Risque Statut
V1 RLS sur salary_transfers 🔴 Critique Élevée Élevé ÉLEVÉ CONFORME
V2 RLS sur monthly_contributions 🔴 Critique Élevée Élevé ÉLEVÉ CONFORME
V3 RLS sur contribution_notifications 🟠 Modérée Moyenne Moyen MOYEN CORRIGÉ
O1 Index org_id 🟡 Faible Faible Faible FAIBLE CONFORME
O2 Logging x-active-org-id invalide 🟡 Faible Très faible Faible FAIBLE Optionnel

Résumé :

  • 3/3 tables critiques : RLS activé avec politiques robustes
  • Index optimaux : Tous présents (5 index sur org_id)
  • Toutes corrections appliquées : Aucune vulnérabilité restante

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()
  • Service-role utilisé pour bypass RLS (staff uniquement)

2 Filtrage Applicatif Systématique

  • Tous les endpoints appliquent .eq("org_id", ...)
  • Fonction resolveActiveOrg() centralisée
  • Fallback sur organization_members pour clients

3 Validation des Données

  • Vérification existence organisation avant création/update
  • Champs org_id non modifiables en UPDATE
  • Validation des paramètres de filtrage (year, period)

4 Architecture Cohérente

  • Pattern similaire à l'écosystème contrats (déjà audité)
  • Utilisation de React Query pour cache client
  • Mode démo supporté (virements-salaires)

🔧 Plan de Correction (Mise à jour : 16 octobre 2025 - TERMINÉ )

Phase 1: Vérification Critique (TERMINÉE)

# Exécuté le 16 octobre 2025
psql $DATABASE_URL -f scripts/verify-rls-virements-cotisations.sql

# ✅ RÉSULTATS :
# - salary_transfers : RLS activé ✅ + 4 politiques robustes ✅ + index optimal ✅
# - monthly_contributions : RLS activé ✅ + 4 politiques robustes ✅ + 3 index ✅
# - contribution_notifications : RLS désactivé ❌ + index présent ✅

Phase 2: Correction RLS contribution_notifications (TERMINÉE)

# ✅ Script exécuté avec succès le 16 octobre 2025
psql $DATABASE_URL -f scripts/fix-rls-contribution-notifications.sql

# ✅ Vérification post-correction effectuée
psql $DATABASE_URL -c "
SELECT tablename, rowsecurity AS rls_enabled
FROM pg_tables
WHERE tablename = 'contribution_notifications';
"
# Résultat : rls_enabled = true ✅

# ✅ Politiques créées (4/4)
# SELECT  : select_staff_only   → is_staff() ✅
# INSERT  : insert_staff_only   → is_staff() ✅
# UPDATE  : update_staff_only   → is_staff() ✅
# DELETE  : delete_staff_only   → is_staff() ✅

Phase 3: Optimisations (DÉJÀ CONFORMES)

-- ✅ Tous les index requis sont déjà présents
-- ✅ Aucune action nécessaire

-- Pour vérifier les index existants :
SELECT tablename, indexname FROM pg_indexes
WHERE tablename IN ('salary_transfers', 'monthly_contributions', 'contribution_notifications')
  AND indexdef ILIKE '%org_id%'
ORDER BY tablename;

Phase 4: Tests de Validation (RECOMMANDÉ)

// ✅ RLS contribution_notifications corrigé
// Recommandation : Tests manuels pour validation finale

// Test 1 : Vérifier qu'un client ne peut PAS accéder aux notifications
// Connexion en tant que client → Tentative d'accès à contribution_notifications
// Résultat attendu : Aucune ligne retournée (RLS bloque)

// Test 2 : Vérifier que le staff peut accéder aux notifications
// Connexion en tant que staff → Accès à contribution_notifications
// Résultat attendu : Toutes les lignes accessibles (is_staff() = true)

// Test 3 : Vérifier que les routes API continuent de fonctionner
// GET /api/staff/cotisations/notifications
// POST /api/staff/cotisations/[id]/notify-client
// Résultat attendu : Fonctionnement normal (pas de régression)

🎯 Statut Final : TOUTES CORRECTIONS APPLIQUÉES

Date de finalisation : 16 octobre 2025

Récapitulatif des actions

  1. Audit complet effectué (2 écosystèmes, 3 tables critiques, 8+ routes API)
  2. Vérification RLS exécutée (script verify-rls-virements-cotisations.sql)
  3. Correction appliquée (script fix-rls-contribution-notifications.sql)
  4. Validation post-correction effectuée (4 politiques créées)

Résultat final

  • salary_transfers : 🟢 RLS + 4 politiques + index → EXCELLENT
  • monthly_contributions : 🟢 RLS + 4 politiques + 3 index → EXCELLENT
  • contribution_notifications : 🟢 RLS + 4 politiques + index → EXCELLENT

Niveau de sécurité : 🟢 EXCELLENT


📝 Recommandations Finales

Priorité HAUTE

  1. ⚠️ Vérifier RLS activé sur salary_transfers et monthly_contributions
  2. ⚠️ Créer politiques RLS si absentes (scripts fournis ci-dessus)
  3. ⚠️ Tester isolation entre organisations en environnement staging

Priorité MOYENNE

  1. 🟡 Créer index org_id pour performance (surtout avec RLS)
  2. 🟡 Ajouter logging pour tentatives d'accès org invalides

Priorité BASSE

  1. Documenter le pattern staff/client dans README.md
  2. Créer tests E2E pour virements-salaires et cotisations

🔗 Références Croisées

  • Audit Contrats: SECURITY_AUDIT_CONTRATS.md (patterns similaires)
  • Corrections Contrats: SECURITY_CORRECTIONS_CONTRATS.md (exemples de corrections)
  • Vérification RLS: scripts/verify-rls-policies.sql (contrats)
  • Vérification RLS Virements/Cotisations: scripts/verify-rls-virements-cotisations.sql (nouveau)

📅 Historique des Modifications

Date Auteur Modification
2025-10-16 GitHub Copilot Audit initial - Virements & Cotisations
2025-10-16 GitHub Copilot Création script verify-rls-virements-cotisations.sql
2025-10-16 GitHub Copilot Vérification RLS effectuée - Résultats intégrés
2025-10-16 GitHub Copilot Création script fix-rls-contribution-notifications.sql
2025-10-16 GitHub Copilot Correction appliquée - RLS contribution_notifications activé
2025-10-16 GitHub Copilot Validation post-correction - 4 politiques créées
2025-10-16 GitHub Copilot Mise à jour finale : V1 V2 V3 O1 → Statut EXCELLENT

🎯 Conclusion (Mise à jour : 16 octobre 2025)

État actuel: <20> EXCELLENT (avec 1 correction mineure)

Points Forts Validés

Code applicatif : 🟢 EXCELLENT

  • Filtrage org_id systématique dans toutes les routes
  • Vérifications staff robustes (fonction isStaffUser())
  • Validation des données cohérente
  • Architecture propre avec séparation staff/client

Base de données : 🟢 EXCELLENT (2/3)

  • salary_transfers : RLS activé + 4 politiques robustes + index optimal
  • monthly_contributions : RLS activé + 4 politiques robustes + 3 index
  • ⚠️ contribution_notifications : RLS désactivé (table de logs, impact faible)

Performance : 🟢 EXCELLENT

  • 5 index sur org_id (tous présents et optimaux)
  • Index composites pour contraintes métier
  • Performance RLS garantie

⚠️ Action Requise (1 seule)

# Activer RLS sur contribution_notifications (5 minutes)
psql $DATABASE_URL -f scripts/fix-rls-contribution-notifications.sql

🏆 État Final Attendu : 🟢 EXCELLENT

Après cette correction unique, l'écosystème virements-salaires/cotisations sera :

  • Aussi sécurisé que l'écosystème contrats (déjà audité)
  • Protection multi-couches : Filtrage applicatif + RLS + Index
  • Isolation parfaite entre organisations
  • Performance optimale avec index sur toutes les tables

Niveau de sécurité global : 🟡 BON → 🟢 EXCELLENT (après correction contribution_notifications)