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 RGPD | Implémentation technique | Priorité |
|---|---|---|
| Protection des données (art. 32) | Chiffrement champs sensibles + HTTPS + backups chiffrés | Haute |
| Registre des traitements (art. 30) | Logger middleware + rotation logs 1 an | Haute |
| Droit d'accès (art. 15) | Export JSON/ZIP automatique | Haute |
| Droit à l'effacement (art. 17) | Anonymisation + conservation légale factures | Haute |
| Consentement (art. 7) | Modèle Consentement granulaire + historisation | Moyenne |
| Limitation conservation | Management command purge + cron | Moyenne |
| Portabilité (art. 20) | Export JSON structuré | Basse |
| Notification de faille (art. 33) | Monitoring + alertes + procédure documentée | Moyenne |
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 --encryptou 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 devpour "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.