Chapitre 14.3 — Consentement & droits côté code
⏱️ TL;DR — Deux obligations très concrètes pour un dev : prouver le consentement et outiller les droits. Pour le consentement, un
booleanne suffit pas : il faut un registre — une table qui trace qui a consenti, à quoi, quand, à quelle version des mentions, et le retrait éventuel (le consentement doit être aussi facile à retirer qu’à donner). Pour les droits, on expose des routes : une pour l’accès et la portabilité (art. 15 et 20) qui exporte toutes les données d’une personne dans un JSON structuré, une pour la suppression (art. 17) qui efface le compte et ses données liées en cascade. On montre ici les deux route handlers Next.js, avec la vérification d’identité qui va avec.
🎯 Objectifs
- Modéliser un registre de consentement qui rend le consentement prouvable (horodatage, version, retrait).
- Coder une route d’export JSON couvrant le droit d’accès (art. 15) et la portabilité (art. 20).
- Coder une route de suppression (art. 17) qui efface en cascade.
- Sécuriser ces routes : authentifier la personne, ne jamais exposer les données d’autrui.
Un consentement, ça se prouve : le registre
Le consentement (art. 7) doit être libre, spécifique, éclairé et univoque, donné par un acte positif clair (pas de cases pré-cochées — arrêt Planet49), et aussi facile à retirer qu’à donner. Surtout, l’accountability impose de pouvoir démontrer qu’il a bien été recueilli. Un simple newsletterOptIn: true sur la table User échoue sur toute la ligne : il ne dit ni quand, ni à quelle finalité précise, ni sur la base de quelle information la personne a consenti, ni si elle a retiré son accord depuis.
La bonne structure est un journal append-only : chaque acte de consentement (ou de retrait) est une ligne.
model ConsentRecord {
id String @id @default(cuid())
userId String
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
purpose ConsentPurpose // finalité PRÉCISE, pas un "j'accepte tout"
granted Boolean // true = donné, false = retiré
policyVersion String // version des mentions affichées au moment du clic
createdAt DateTime @default(now()) // horodatage de l'acte
source String // ex. "formulaire_inscription", "centre_prefs"
@@index([userId, purpose])
}
enum ConsentPurpose {
NEWSLETTER
STATISTIQUES_USAGE
PARTAGE_PARTENAIRE
}Pourquoi ces colonnes :
purpose: le consentement est spécifique — un par finalité. On ne mélange pas « recevoir la newsletter » et « partager avec un partenaire » dans une seule case.granted: on trace autant le don que le retrait. L’état courant se lit en prenant la dernière ligne pour un(userId, purpose)donné.policyVersion: la personne a consenti sur la base des mentions telles qu’elles étaient ce jour-là. Si tu changes tes finalités, l’ancien consentement ne couvre pas les nouvelles — la version le prouve.createdAt: l’horodatage, indispensable pour démontrer quand et pour gérer l’ordre des actes.
// Lire l'état courant d'un consentement : la dernière ligne fait foi.
export async function consentementActuel(userId: string, purpose: ConsentPurpose) {
const dernier = await prisma.consentRecord.findFirst({
where: { userId, purpose },
orderBy: { createdAt: "desc" },
})
return dernier?.granted ?? false // par défaut : PAS de consentement
}⚠️ Piège — Le consentement stocké comme simple booléen écrasable (
user.newsletterOptIn = true/false). Le jour où la CNIL ou la personne demande « prouvez qu’elle a consenti, et à quoi », tu n’as rien : ni date, ni version, ni historique du retrait. Pire, unUPDATEa écrasé la trace du consentement initial. Le consentement se journalise, il ne se met pas à jour en place.
💡 Réflexe — État par défaut = pas de consentement. En base comme dans le code, l’absence de ligne « accordée » vaut refus. On ne coche jamais par défaut, on ne présume jamais l’accord. Le consentement est un acte positif que la personne pose, pas un état qu’on lui attribue.
La route d’accès & portabilité : exporter en JSON
Les articles 15 (droit d’accès) et 20 (droit à la portabilité) se satisfont bien tous deux d’un export. L’accès donne à la personne une copie de ses données ; la portabilité exige, pour les données qu’elle a fournies et qui sont traitées sur consentement ou contrat, un format structuré, couramment utilisé et lisible par machine — le JSON coche ces cases. On mutualise donc : une route qui rassemble toutes les données de l’utilisateur connecté et les renvoie en JSON.
// app/api/me/export/route.ts — App Router, Next.js
import { NextResponse } from "next/server"
import { getSession } from "@/lib/auth"
import { prisma } from "@/lib/db"
export async function GET() {
// 1) On n'exporte QUE les données de la personne authentifiée.
const session = await getSession()
if (!session) {
return NextResponse.json({ error: "non authentifié" }, { status: 401 })
}
const userId = session.userId
// 2) On rassemble toutes les données liées à cette personne.
const user = await prisma.user.findUnique({
where: { id: userId },
include: {
profile: true,
results: true,
consents: { orderBy: { createdAt: "asc" } },
// NB : on n'inclut PAS les secrets techniques (passwordHash…),
// ni les données d'un autre utilisateur.
},
})
if (!user) {
return NextResponse.json({ error: "introuvable" }, { status: 404 })
}
// 3) Format structuré et lisible par machine (portabilité, art. 20).
const exportData = {
exportedAt: new Date().toISOString(),
identity: { email: user.email, prenom: user.prenom, nom: user.nom },
profile: user.profile,
results: user.results,
consents: user.consents, // l'historique de consentement fait partie de SES données
}
// 4) On force le téléchargement d'un fichier JSON nommé.
return new NextResponse(JSON.stringify(exportData, null, 2), {
status: 200,
headers: {
"Content-Type": "application/json",
"Content-Disposition": `attachment; filename="mes-donnees-${userId}.json"`,
},
})
}Trois précautions dans cette route :
- Authentification stricte : on exporte les données de
session.userId, jamais unuserIdpassé en paramètre par le client — sinon n’importe qui exfiltre les données d’autrui. - On exclut les secrets : le
passwordHash, les jetons internes, les champs techniques n’ont pas à figurer dans l’export. - Le consentement fait partie des données : l’historique de
ConsentRecordest une donnée sur la personne, elle a le droit de le récupérer.
🔒 Côté personne concernée — Le droit d’accès n’est pas théorique : une personne peut vouloir vérifier ce que tu détiens sur elle, ou partir avec ses données vers un autre service (portabilité). Un export JSON clair, complet et téléchargeable en un clic, c’est le RGPD du bon côté : la personne reprend la main sur ses données. Un export absent, tronqué, ou noyé dans un PDF illisible, c’est l’inverse — et le délai légal de réponse est d’un mois.
La route de suppression : effacer en cascade
Le droit à l’effacement (art. 17) se code comme une route qui déclenche l’effacement complet vu au chapitre 14.2 — pas seulement la ligne User, mais toutes les données liées et les copies (index, caches, tiers).
// app/api/me/route.ts — DELETE : la personne supprime son propre compte
import { NextResponse } from "next/server"
import { getSession } from "@/lib/auth"
import { effacerUtilisateur } from "@/lib/erasure"
export async function DELETE() {
const session = await getSession()
if (!session) {
return NextResponse.json({ error: "non authentifié" }, { status: 401 })
}
// Effacement complet EN CASCADE (base + index + caches + sous-traitants),
// exactement la fonction du chapitre 14.2. On honore ainsi l'article 17.
await effacerUtilisateur(session.userId)
// On invalide la session : le compte n'existe plus.
await session.destroy()
return NextResponse.json({ status: "compte supprimé" }, { status: 200 })
}Ici, la logique métier de l’effacement (effacerUtilisateur) est factorisée : la même fonction sert la route « je supprime mon compte » et la tâche de purge planifiée. Un seul endroit à maintenir, une cascade toujours cohérente.
| Droit (art.) | Ce que la personne demande | Ce que tu codes |
|---|---|---|
| Accès (15) | « Que détenez-vous sur moi ? » | Route d’export JSON de ses données. |
| Portabilité (20) | « Rendez-moi mes données pour les réutiliser. » | Même export, format structuré, lisible par machine. |
| Rectification (16) | « Corrigez cette info. » | Route/écran d’édition de son profil. |
| Effacement (17) | « Supprimez tout. » | Route DELETE → effacement en cascade. |
| Retrait du consentement (7) | « Je ne veux plus de la newsletter. » | Nouvelle ligne ConsentRecord granted=false. |
⚠️ Piège — La route qui prend un
userIden paramètre (/api/export?userId=123) et fait confiance au client. C’est la faille d’accès direct à objet la plus classique : en changeant le numéro, un utilisateur exporte — ou supprime — les données d’un autre. La règle : l’identité de la personne concernée vient toujours de la session authentifiée, jamais d’un paramètre fourni par l’appelant.
🧭 Sur FormaCampus — Un parent demande, au titre de son enfant mineur, l’accès aux données détenues. FormaCampus expose un espace « Mes données » : un bouton Exporter (JSON complet : profil, résultats, historique de consentement), un bouton Supprimer mon compte (effacement en cascade), et un centre de préférences où chaque consentement (newsletter, statistiques) se donne et se retire d’un clic, chaque action créant une ligne
ConsentRecordhorodatée et versionnée. Le jour d’un contrôle, FormaCampus prouve qui a consenti à quoi et quand — et démontre que les droits sont réellement actionnables, dans le délai d’un mois.
📚 Le texte — Les conditions du consentement (dont la charge de la preuve et la facilité de retrait) sont à l’article 7 ; l’exigence d’un acte positif clair et l’interdiction des cases pré-cochées viennent de la jurisprudence (Planet49, CJUE C-673/17). Le droit d’accès est l’article 15, la portabilité l’article 20 (format structuré, couramment utilisé, lisible par machine), l’effacement l’article 17. Le délai de réponse est d’un mois (art. 12), prolongeable si la demande est complexe.
✏️ Exercices
Exercice 1 — Casse le mauvais modèle de consentement. Un collègue propose de stocker le consentement newsletter comme User.newsletter Boolean @default(true). Donne trois raisons pour lesquelles c’est non conforme, et propose le bon modèle.
✅ Solution
(1) @default(true) = consentement présumé / case pré-cochée : interdit (Planet49), le consentement doit être un acte positif, défaut = false/absence. (2) Un booléen écrasable ne conserve ni horodatage, ni version des mentions, ni historique du retrait : impossible de prouver le consentement (art. 7) ou de dater un retrait. (3) Un seul champ pour « la newsletter » ne dit pas à quelle finalité précise on a consenti — le consentement doit être spécifique. Bon modèle : une table ConsentRecord append-only (userId, purpose, granted, policyVersion, createdAt, source), l’état courant étant la dernière ligne par (userId, purpose), défaut = pas de consentement.
Exercice 2 — Sécurise la route d’export. Voici une route : export async function GET(req) { const id = req.nextUrl.searchParams.get("userId"); return NextResponse.json(await prisma.user.findUnique({ where: { id }, include: { profile: true } })) }. Quels sont les deux problèmes majeurs, et comment les corriger ?
✅ Solution
(1) L’userId vient d’un paramètre client : n’importe qui peut exporter les données de n’importe qui (accès direct à objet). Correction : prendre l’identité depuis la session authentifiée (getSession()), ignorer tout userId fourni. (2) L’export renvoie l’objet user brut, donc potentiellement le passwordHash et d’autres champs techniques/secrets. Correction : sélectionner explicitement les champs à exporter (identité, profil, résultats, consentements) et exclure les secrets. Bonus : ajouter le contrôle 401 si non authentifié et forcer un téléchargement nommé.
🧠 Quiz de révision
1. Pourquoi un booléen ne suffit-il pas à gérer le consentement ?
Parce qu’il faut pouvoir prouver le consentement (art. 7) : qui, à quelle finalité, quand, sur quelle version des mentions, et l’éventuel retrait. Un booléen écrasable ne conserve ni date, ni version, ni historique. On utilise un registre append-only (une ligne par acte), l’état courant étant la dernière ligne pour une finalité.
2. Quel format pour un export au titre de la portabilité, et pourquoi ?
Un format structuré, couramment utilisé et lisible par machine (art. 20) — typiquement du JSON (ou CSV). Le but est que la personne puisse réutiliser ses données ailleurs : un PDF non structuré ou une capture d’écran ne conviennent pas.
3. D’où doit venir l’identité de la personne dans une route d’export/suppression ?
Toujours de la session authentifiée, jamais d’un userId passé en paramètre par le client. Faire confiance à un paramètre ouvre une faille d’accès direct à objet : un utilisateur exporterait ou supprimerait les données d’un autre en changeant le numéro.
4. Pourquoi factoriser la logique d’effacement dans une seule fonction ?
Parce que le même effacement en cascade (base + index + caches + sous-traitants) doit servir deux déclencheurs : la route « je supprime mon compte » (art. 17) et la tâche de purge de fin de rétention (chapitre 14.2). Une fonction unique garantit une cascade cohérente et un seul endroit à maintenir.
5. Sous quel délai faut-il répondre à une demande de droit ?
Un mois à compter de la réception (art. 12), prolongeable de deux mois si la demande est complexe (en informant la personne). D’où l’intérêt d’automatiser l’export et la suppression : une demande de droit ne doit pas être un chantier manuel à chaque fois.
Chapitre suivant : Logs & analytics respectueux — zéro PII dans les journaux, IP tronquées, un middleware de masquage, et une mesure d’audience privacy-first (self-hosted / sans cookie).