RGPD technique pour applications Django : guide développeur

Tapez "RGPD" dans Google et vous tomberez sur 500 articles juridiques qui parlent de registre de traitements, de DPO et de bases légales. C'est important, mais ce n'est pas ce dont je vais parler. Ici on va traiter le volet technique : comment implémenter concrètement le RGPD dans une application Django. Du code, pas du juridique.

Parce que le problème, c'est que 90% des applications Django que j'audite sont "RGPD compliant" sur le papier (politique de confidentialité, bandeau cookies) mais pas du tout dans le code. Les données personnelles sont en clair partout, il n'y a aucun log d'accès, l'export de données n'existe pas, et le droit à l'oubli c'est "on fera un DELETE dans la console Django".

1. Chiffrement des champs sensibles

Le RGPD demande des "mesures techniques appropriées" pour protéger les données personnelles (article 32). En pratique, ça veut dire chiffrer les champs sensibles en base de données.

Avec django-encrypted-model-fields ou en implémentant votre propre champ :

# fields.py — champ chiffré custom avec Fernet
from cryptography.fernet import Fernet
from django.conf import settings
from django.db import models
import base64
import hashlib

def get_fernet():
    # Dérive une clé Fernet depuis la SECRET_KEY
    key = hashlib.sha256(settings.SECRET_KEY.encode()).digest()
    return Fernet(base64.urlsafe_b64encode(key))

class EncryptedCharField(models.CharField):
    # CharField dont la valeur est chiffree en base (Fernet/AES-128-CBC).

    def get_prep_value(self, value):
        if value is None:
            return value
        f = get_fernet()
        return f.encrypt(value.encode()).decode()

    def from_db_value(self, value, expression, connection):
        if value is None:
            return value
        f = get_fernet()
        try:
            return f.decrypt(value.encode()).decode()
        except Exception:
            return value  # Valeur non chiffrée (migration en cours)

    def get_internal_type(self):
        return "TextField"  # Stockage plus large car chiffré
# models.py — utilisation
class Client(models.Model):
    raison_sociale = models.CharField(max_length=200)
    # Champs sensibles chiffrés
    email = EncryptedCharField(max_length=500)
    telephone = EncryptedCharField(max_length=500)
    adresse = EncryptedCharField(max_length=1000)
    siret = EncryptedCharField(max_length=500)
    # Champs non sensibles en clair
    date_creation = models.DateTimeField(auto_now_add=True)
    actif = models.BooleanField(default=True)

Attention : les champs chiffrés ne sont pas indexables et cassent les lookups Django (filter(email='...') ne fonctionne plus). Solutions : soit maintenir un hash du champ pour les recherches, soit utiliser des identifiants non-sensibles pour les queries.

En pratique, j'ajoute un champ email_hash (SHA-256 tronqué) qui sert d'index de recherche. Le code de recherche devient :

import hashlib

def hash_for_lookup(value):
    return hashlib.sha256(value.lower().strip().encode()).hexdigest()[:16]

# Recherche par email
email_hash = hash_for_lookup("dupont@example.com")
clients = Client.objects.filter(email_hash=email_hash)
# Le vrai email est dechiffre a l affichage, pas en base

C'est un compromis : on perd la possibilité de faire des recherches partielles (LIKE '%dupont%'), mais on gagne le chiffrement au repos. Pour les recherches partielles, il faudrait du chiffrement homomorphe... et on n'en est pas encore là en production.

2. Journalisation des accès aux données personnelles

L'article 30 du RGPD demande un registre des traitements. Côté technique, ça se traduit par un log d'accès aux données personnelles : qui a consulté quoi, quand, pourquoi.

# middleware.py — log d'accès aux données personnelles
import logging
from django.utils import timezone

logger = logging.getLogger('rgpd.access')

class RGPDAccessLogMiddleware:
    # Journalise les acces aux vues contenant des donnees personnelles.

    VUES_SENSIBLES = [
        'client_detail', 'client_edit', 'client_list',
        'facture_detail', 'export_clients',
        'portal_home',
    ]

    def __init__(self, get_response):
        self.get_response = get_response

    def __call__(self, request):
        response = self.get_response(request)

        if hasattr(request, 'resolver_match') and request.resolver_match:
            view_name = request.resolver_match.url_name
            if view_name in self.VUES_SENSIBLES:
                logger.info(
                    "RGPD_ACCESS | user=%s | view=%s | method=%s | path=%s | ip=%s | status=%s | time=%s",
                    getattr(request.user, 'username', 'anonymous'),
                    view_name,
                    request.method,
                    request.path,
                    request.META.get('REMOTE_ADDR', '?'),
                    response.status_code,
                    timezone.now().isoformat(),
                )

        return response
# settings.py — configuration du logger RGPD
LOGGING = {
    'version': 1,
    'handlers': {
        'rgpd_file': {
            'class': 'logging.handlers.TimedRotatingFileHandler',
            'filename': '/var/log/django/rgpd_access.log',
            'when': 'midnight',
            'backupCount': 365,  # Garder 1 an de logs
            'formatter': 'verbose',
        },
    },
    'loggers': {
        'rgpd.access': {
            'handlers': ['rgpd_file'],
            'level': 'INFO',
            'propagate': False,
        },
    },
}

3. Export des données (droit d'accès — article 15)

Tout individu peut demander l'export de ses données personnelles. Il faut pouvoir générer un fichier complet (JSON ou ZIP) contenant toutes les données liées à une personne :

# services/rgpd_export.py
import json
import zipfile
from io import BytesIO
from django.core.serializers.json import DjangoJSONEncoder

def exporter_donnees_client(client):
    # Exporte toutes les donnees personnelles d un client en ZIP.
    data = {
        'informations_personnelles': {
            'raison_sociale': client.raison_sociale,
            'email': client.email,
            'telephone': client.telephone,
            'adresse': client.adresse,
            'siret': client.siret,
            'date_creation_compte': client.date_creation,
        },
        'devis': [],
        'factures': [],
        'communications': [],
    }

    # Devis
    for devis in client.devis.all():
        data['devis'].append({
            'numero': devis.numero,
            'date': devis.date_creation,
            'montant_ht': str(devis.montant_ht),
            'statut': devis.statut,
            'lignes': [
                {'description': l.description, 'quantite': str(l.quantite), 'prix': str(l.prix_unitaire)}
                for l in devis.lignes.all()
            ],
        })

    # Factures
    for facture in client.factures.all():
        data['factures'].append({
            'numero': facture.numero,
            'date': facture.date_emission,
            'montant_ht': str(facture.montant_ht),
            'montant_ttc': str(facture.montant_ttc),
            'statut': facture.statut,
        })

    # Communications (emails échangés)
    for msg in client.messages.all():
        data['communications'].append({
            'date': msg.date,
            'objet': msg.objet,
            'contenu': msg.contenu,
            'direction': msg.direction,
        })

    # Génération ZIP
    buffer = BytesIO()
    with zipfile.ZipFile(buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
        zf.writestr(
            f'export_rgpd_{client.pk}.json',
            json.dumps(data, cls=DjangoJSONEncoder, indent=2, ensure_ascii=False)
        )
        # Joindre les documents (devis PDF, factures PDF)
        for doc in client.documents.all():
            if doc.fichier:
                zf.write(doc.fichier.path, f'documents/{doc.fichier.name}')

    buffer.seek(0)
    return buffer
# views.py — endpoint d'export
from django.http import FileResponse

def export_donnees_rgpd(request, client_id):
    client = get_object_or_404(Client, pk=client_id)
    buffer = exporter_donnees_client(client)
    return FileResponse(
        buffer,
        as_attachment=True,
        filename=f'export_rgpd_{client.raison_sociale}.zip',
        content_type='application/zip',
    )

4. Droit à l'oubli (article 17)

Le droit à l'effacement est le plus délicat techniquement, parce qu'on ne peut pas toujours tout supprimer. Les factures doivent être conservées 10 ans (obligation légale française), les données comptables aussi. On doit donc anonymiser plutôt que supprimer :

# services/rgpd_oubli.py
import uuid
from django.utils import timezone
import logging

logger = logging.getLogger('rgpd.oubli')

def anonymiser_client(client, demandeur, motif="demande client"):
    # Anonymise un client conformement au droit a l oubli.
    # Les donnees comptables sont conservees mais anonymisees.
    ancien_nom = client.raison_sociale
    anonyme_id = f"ANONYME-{uuid.uuid4().hex[:8].upper()}"

    # Anonymisation des données personnelles
    client.raison_sociale = anonyme_id
    client.email = f"{anonyme_id.lower()}@anonymise.local"
    client.telephone = ""
    client.adresse = "Adresse supprimée"
    client.siret = ""
    client.contact_nom = ""
    client.contact_prenom = ""
    client.notes = ""
    client.actif = False
    client.date_anonymisation = timezone.now()
    client.save()

    # Anonymisation des communications
    client.messages.all().update(
        contenu="[Contenu supprimé — droit à l'oubli]",
        objet="[Supprimé]",
    )

    # Les factures sont CONSERVÉES (obligation légale 10 ans)
    # mais le lien avec les données personnelles est rompu
    # car le client est anonymisé

    # Log de l'opération
    logger.info(
        "RGPD_OUBLI | ancien=%s | anonyme=%s | demandeur=%s | motif=%s | date=%s",
        ancien_nom, anonyme_id, demandeur, motif, timezone.now().isoformat(),
    )

    # Suppression des documents joints (sauf factures)
    docs_supprimes = client.documents.exclude(type='facture').delete()

    return {
        'anonyme_id': anonyme_id,
        'messages_anonymises': client.messages.count(),
        'documents_supprimes': docs_supprimes[0],
    }

5. Consentement granulaire

Le bandeau cookies c'est bien, mais le RGPD demande un consentement par finalité de traitement. Voici un modèle de consentement granulaire :

# models.py
class Consentement(models.Model):
    FINALITES = [
        ('newsletter', 'Recevoir la newsletter'),
        ('prospection', 'Être contacté pour des offres commerciales'),
        ('analytics', 'Analyse de navigation (cookies analytics)'),
        ('partage', 'Partage de données avec des partenaires'),
    ]

    client = models.ForeignKey(Client, on_delete=models.CASCADE, related_name='consentements')
    finalite = models.CharField(max_length=50, choices=FINALITES)
    consenti = models.BooleanField()
    date_action = models.DateTimeField(auto_now_add=True)
    ip_address = models.GenericIPAddressField()
    user_agent = models.TextField(blank=True)
    preuve = models.TextField(
        help_text="Contexte de collecte (URL du formulaire, texte affiché...)"
    )

    class Meta:
        ordering = ['-date_action']

    @classmethod
    def client_a_consenti(cls, client, finalite):
        # Verifie le dernier consentement pour une finalite donnee.
        dernier = cls.objects.filter(
            client=client, finalite=finalite
        ).order_by('-date_action').first()
        return dernier.consenti if dernier else False

Chaque action de consentement est historisée (on ne modifie jamais un enregistrement, on en crée un nouveau). C'est important pour la preuve : vous devez pouvoir démontrer que le consentement a été donné, quand et dans quel contexte.

6. Purge automatique des données

Les données personnelles ne doivent pas être conservées indéfiniment. Mettez en place un management command de purge :

# management/commands/rgpd_purge.py
from django.core.management.base import BaseCommand
from datetime import timedelta
from django.utils import timezone
import logging

logger = logging.getLogger('rgpd.purge')

class Command(BaseCommand):
    help = "Purge les données personnelles selon les durées de rétention RGPD"

    def handle(self, *args, **options):
        maintenant = timezone.now()

        # Prospects non convertis > 3 ans
        prospects_old = Client.objects.filter(
            type='prospect',
            date_creation__lt=maintenant - timedelta(days=3*365),
            derniere_activite__lt=maintenant - timedelta(days=3*365),
        )
        nb = prospects_old.count()
        for p in prospects_old:
            anonymiser_client(p, demandeur='system', motif='purge automatique 3 ans')
        logger.info("RGPD_PURGE | prospects anonymisés: %d", nb)

        # Logs d'accès > 1 an
        from apps.core.models import AccessLog
        logs_old = AccessLog.objects.filter(
            date__lt=maintenant - timedelta(days=365)
        )
        nb_logs = logs_old.count()
        logs_old.delete()
        logger.info("RGPD_PURGE | logs supprimés: %d", nb_logs)

        # Communications > 5 ans (sauf clients actifs)
        from apps.communications.models import Message
        msgs_old = Message.objects.filter(
            date__lt=maintenant - timedelta(days=5*365),
            client__actif=False,
        )
        nb_msgs = msgs_old.count()
        msgs_old.delete()
        logger.info("RGPD_PURGE | messages supprimés: %d", nb_msgs)

        self.stdout.write(
            f"Purge RGPD terminée : {nb} prospects, {nb_logs} logs, {nb_msgs} messages"
        )

Ce command se lance en cron, par exemple le 1er de chaque mois :

# crontab
0 3 1 * * cd /app && python manage.py rgpd_purge >> /var/log/django/rgpd_purge.log 2>&1

Checklist technique RGPD — récap

Exigence RGPDImplémentation techniquePriorité
Protection des données (art. 32)Chiffrement champs sensibles + HTTPS + backups chiffrésHaute
Registre des traitements (art. 30)Logger middleware + rotation logs 1 anHaute
Droit d'accès (art. 15)Export JSON/ZIP automatiqueHaute
Droit à l'effacement (art. 17)Anonymisation + conservation légale facturesHaute
Consentement (art. 7)Modèle Consentement granulaire + historisationMoyenne
Limitation conservationManagement command purge + cronMoyenne
Portabilité (art. 20)Export JSON structuréBasse
Notification de faille (art. 33)Monitoring + alertes + procédure documentéeMoyenne

Le plus important, c'est de ne pas traiter le RGPD comme une couche ajoutée après coup. Ces patterns doivent être intégrés dès la conception de l'application. Ça coûte 10x moins cher de le faire dès le début que de retrofitter un système existant.

7. Tests automatisés pour la conformité

Un aspect que personne ne mentionne : il faut tester la conformité RGPD comme on teste les fonctionnalités métier. Voici des exemples de tests que j'écris systématiquement :

from django.test import TestCase

class RGPDExportTest(TestCase):
    def test_export_contient_toutes_les_donnees(self):
        # Verifie que l export RGPD contient bien toutes les donnees du client
        client = ClientFactory()
        DevisFactory(client=client)
        FactureFactory(client=client)
        MessageFactory(client=client)

        buffer = exporter_donnees_client(client)
        data = self.extraire_json_du_zip(buffer)

        self.assertIn('informations_personnelles', data)
        self.assertEqual(len(data['devis']), 1)
        self.assertEqual(len(data['factures']), 1)
        self.assertEqual(len(data['communications']), 1)

    def test_anonymisation_preserve_factures(self):
        # Le droit a l oubli ne doit PAS supprimer les factures (obligation legale)
        client = ClientFactory()
        facture = FactureFactory(client=client)

        anonymiser_client(client, demandeur='test')

        # La facture existe toujours
        facture.refresh_from_db()
        self.assertTrue(Facture.objects.filter(pk=facture.pk).exists())
        # Mais le client est anonymise
        client.refresh_from_db()
        self.assertTrue(client.raison_sociale.startswith('ANONYME-'))

    def test_champs_sensibles_chiffres_en_base(self):
        # Verifie que les champs sensibles sont bien chiffres dans la DB
        client = Client.objects.create(
            raison_sociale='Test RGPD',
            email='test@example.com',
        )
        # Lecture brute en base (bypass ORM)
        from django.db import connection
        with connection.cursor() as cursor:
            cursor.execute("SELECT email FROM billing_client WHERE id = %s", [client.pk])
            raw_email = cursor.fetchone()[0]
        # Le champ en base ne doit PAS etre en clair
        self.assertNotEqual(raw_email, 'test@example.com')

Ces tests tournent dans la CI. Si quelqu'un modifie le schéma ou le code d'export sans mettre à jour la partie RGPD, les tests cassent. C'est le seul moyen fiable de maintenir la conformité dans la durée. Le RGPD, c'est pas un audit ponctuel, c'est un processus continu.

8. Erreurs courantes que je vois en audit

Pour finir, voici les erreurs que je retrouve le plus souvent quand j'audite des applications Django :

  • Logs avec des données personnelles. Les logger.info("Nouveau client: %s %s", nom, email) un peu partout dans le code. Ces logs sont rarement purgés et contiennent des données personnelles en clair. Solution : ne loguer que des identifiants techniques (ID, hash), jamais des données nominatives.
  • Emails en BCC avec tous les clients. J'ai vu ça. Un script d'emailing qui met 200 adresses en BCC pour "envoyer la newsletter". Non seulement c'est un risque RGPD (fuite d'emails si un serveur mail affiche les BCC), mais c'est aussi illégal sans consentement. Utilisez un service d'emailing dédié (Mailjet, Sendinblue) qui gère les désinscriptions.
  • Sauvegardes non chiffrées. Votre base est chiffrée, super. Mais le dump PostgreSQL quotidien envoyé sur un bucket S3, il est chiffré aussi ? Souvent non. Solution : pg_dump | gpg --encrypt ou chiffrement côté bucket (SSE-S3, SSE-KMS).
  • Données de test avec des vraies données. Le développeur qui fait un pg_dump prod | pg_restore dev pour "avoir des données réalistes en dev". Maintenant votre machine de dev non-sécurisée contient les données personnelles de tous vos clients. Solution : anonymiser les dumps avant de les injecter en dev.
  • Pas de registre des traitements technique. Le DPO a son registre Word avec les finalités juridiques. Mais personne ne sait quels endpoints de l'API accèdent à quelles données personnelles. La correspondance entre le registre juridique et le code n'existe pas.

Chacune de ces erreurs est une non-conformité potentielle qui peut coûter cher en cas de contrôle CNIL. La bonne nouvelle, c'est qu'elles sont toutes corrigeables en quelques jours de développement.

Si vous avez une application Django existante et que vous avez besoin d'un audit technique RGPD ou d'une implémentation, n'hésitez pas à me contacter.

Un projet de mise en conformite ?

Applications securisees et conformes RGPD, developpees sur mesure.

En savoir plus →