Une bannière de cookies qui se prouve, avec Azure Tables et Functions
La bannière qu'on copie chez un fournisseur est, elle-même, un traceur. Une bannière à soi, sans tiers, et la partie difficile n'est pas la bannière : c'est garder la preuve que la personne a consenti. Voici tout, du navigateur à l'Azure Table, étape par étape.
Un site web doit demander le consentement aux cookies. La solution facile est de coller le widget d'un fournisseur, qui règle la bannière et apporte un problème plus grand : ce widget est souvent un traceur de plus, et le choix de la personne finit stocké chez lui, pas chez nous.
Une bannière à soi, sans tiers, vaut mieux, et la partie difficile n'est pas la bannière. C'est la preuve. Le RGPD, à l'article 7, paragraphe 1, ne demande pas seulement de demander le consentement : il demande d'être en mesure de démontrer que la personne a consenti. Voici toute la configuration, du navigateur à l'Azure Table, étape par étape.
Le vrai problème : un consentement qu'on ne prouve pas
Un consentement qui vit seulement dans le localStorage du visiteur ne se démontre pas. Nous ne l'avons pas, nous ne pouvons le montrer à personne, et la personne peut l'effacer quand elle veut. Devant un audit, dire «nous avons le consentement» sans pouvoir le montrer vaut zéro.
Le choix doit être enregistré de notre côté aussi, avec une date et une version de la politique. C'est là qu'entrent les Azure Functions et les Azure Tables : la Function reçoit le choix, la Table le garde, et c'est cette Table que nous produisons quand quelqu'un demande ce qui a été consenti, par qui, et quand.
Une seule bannière, et aucun script tiers dans le HTML
La règle qui ne se négocie pas : aucun <script> tiers n'est dans le HTML. Ils sont tous injectés par notre propre code, et seulement après le «oui». L'ePrivacy exige un consentement préalable au cookie, et une balise Google collée dans le HTML l'aurait déjà posé avant que quelqu'un ne clique.
<!-- No third-party <script> lives in the page. They are all injected by
consent.js, and only after "yes". A Google tag here would set a cookie
before anyone clicked. -->
<div class="cb" id="cb">
<button type="button" class="cb-pill" id="cbPill" aria-expanded="false">
Cookies
</button>
<div class="cb-card" id="cbCard" role="dialog" hidden>
<ul class="cb-cats">
<li><strong>Necessary</strong> <span>always on</span></li>
<li><label><strong>Statistics</strong>
<input type="checkbox" id="cbStat"></label></li>
</ul>
<div class="cb-acts">
<button type="button" id="cbNone">Reject all</button>
<button type="button" id="cbSave">Save</button>
<button type="button" id="cbAll">Accept all</button>
</div>
</div>
</div>
Par défaut, tout refusé, sans cases précochées. Et «Tout accepter» et «Tout refuser» ont le même poids visuel : un accepter voyant à côté d'un refuser éteint n'est pas un choix libre, c'est un schéma manipulateur, et il a déjà coûté des amendes à ceux qui l'ont employé.
Consent Mode v2 : refuser d'abord, accorder ce qui a été accepté
Google Analytics n'entre pas avant le consentement. Quand la catégorie Statistiques est acceptée, nous injectons gtag et utilisons le Consent Mode v2 : les quatre signaux partent refusés, et seulement ensuite on accorde ce que la personne a accepté. Nous ne vendons pas de publicité, donc les trois signaux d'annonces restent refusés pour de bon, même en «Tout accepter».
function gtag() { window.dataLayer.push(arguments); }
function enableAnalytics() {
if (window.__ga) return;
window.__ga = 1;
window.dataLayer = window.dataLayer || [];
window.gtag = gtag;
// Consent Mode v2: deny all four signals before gtag.js loads, then grant
// only what was accepted. We sell no ads, so the three ad signals stay
// denied for good, even on "Accept all".
gtag("consent", "default", {
ad_storage: "denied", ad_user_data: "denied",
ad_personalization: "denied", analytics_storage: "denied",
});
gtag("consent", "update", { analytics_storage: "granted" });
const s = document.createElement("script");
s.async = true;
s.src = "https://www.googletagmanager.com/gtag/js?id=" + GA_ID;
document.head.appendChild(s);
gtag("js", new Date());
gtag("config", GA_ID);
}
L'ordre compte : refuser avant que gtag.js ne charge, et accorder après. Dans l'autre sens il y aurait une fenêtre où Analytics tournerait sans consentement, ce qui est exactement ce que le Consent Mode existe pour empêcher.
L'identifiant, et l'envoi de la preuve
Le lien entre la personne et l'enregistrement est un identifiant aléatoire, généré dans le navigateur et gardé dans le localStorage du visiteur. C'est elle qui le tient : si elle nous demande de démontrer ce qu'elle a consenti, ou de l'effacer, c'est par cet id qu'on trouve l'enregistrement. L'IP n'est pas gardée, car la garder pour prouver un consentement exigerait un consentement pour la garder.
// A random id, generated here and kept in the visitor's localStorage. It is
// the only link between the person and the server-side record, and they hold
// it: to prove or to erase, this id is how the record is found. No IP is kept.
function visitorId() {
let id = localStorage.getItem("consent-id");
if (id && /^[0-9a-f]{32}$/.test(id)) return id;
const b = new Uint8Array(16);
crypto.getRandomValues(b);
id = [...b].map((x) => ("0" + x.toString(16)).slice(-2)).join("");
localStorage.setItem("consent-id", id);
return id;
}
function record(choice) {
// Fails in silence on purpose: the choice already lives in localStorage.
// What is lost on an error is our proof, not the visitor's right.
fetch("/api/v1/consent", {
method: "POST",
headers: { "Content-Type": "application/json" },
keepalive: true, // survives the tab closing right after
body: JSON.stringify({ id: visitorId(), v: 1, stat: choice.stat, lang }),
}).catch(() => {});
}
Le keepalive fait survivre la requête à celui qui clique et ferme l'onglet juste après. Et l'envoi échoue en silence à dessein : le choix est déjà dans le localStorage, donc ce qui se perd sur une erreur est notre preuve, pas le droit de la personne. On ne casse jamais la page de qui est en train de lire.
La preuve : une table append-only
Du côté serveur, il y a une Azure Function qui reçoit le choix et l'écrit dans la Table. Et le détail qui fait de ceci une preuve, et non de la télémétrie, c'est qu'elle est append-only : chaque choix est un événement nouveau, avec partitionKey sur l'id du visiteur et rowKey sur l'instant. Rien n'est réécrit.
import { app, HttpRequest, HttpResponseInit } from "@azure/functions";
import { TableClient } from "@azure/data-tables";
import { DefaultAzureCredential } from "@azure/identity";
const NO_CONTENT: HttpResponseInit = { status: 204 };
const VALID_ID = /^[0-9a-f]{32}$/;
function table(): TableClient {
// No account key anywhere: shared keys are disabled on the storage account,
// and the Function's managed identity holds the Table Data Contributor role.
return new TableClient(
`https://${process.env.STORAGE_ACCOUNT}.table.core.windows.net`,
"consent",
new DefaultAzureCredential(),
);
}
async function handler(req: HttpRequest): Promise<HttpResponseInit> {
const body = (await req.json().catch(() => null)) as Record<string, unknown> | null;
const id = body && typeof body.id === "string" && VALID_ID.test(body.id) ? body.id : null;
if (!id) return NO_CONTENT;
// Append-only, and this is what makes it proof. The first version used
// upsert: changing your mind overwrote the record, and erased the history.
// Each choice is a new event. Nothing is rewritten, nothing is deleted:
// the current choice is the latest event, the history is the proof.
const now = new Date().toISOString();
await table()
.createEntity({
partitionKey: id, // one person's history reads from one partition
rowKey: now, // the instant: every choice is an immutable event
version: Number(body.v),
stat: body.stat === true,
lang: String(body.lang || "en"),
})
.catch(() => {}); // never break the page of someone just reading
return NO_CONTENT;
}
app.http("consent", { methods: ["POST"], authLevel: "anonymous", route: "v1/consent", handler });
La première version utilisait upsert, et changer d'avis remplaçait l'enregistrement, effaçant l'histoire. Si quelqu'un consentait le jour 1 et retirait le jour 5, il ne restait que le «non», et nous ne pouvions plus démontrer que nous avions le consentement quand Analytics a tourné les jours 1 à 4. Avec partitionKey sur l'id, l'histoire d'une personne se lit dans une seule partition, ce dont une demande d'accès de l'article 15 a besoin.
Sans clés, et sans IP
La Function n'a aucune clé de compte nulle part. Les clés partagées sont désactivées sur le compte de stockage, et l'identité managée de la Function porte le rôle Storage Table Data Contributor, limité à ce compte et à aucun autre. Le secret qui n'existe pas ne fuit pas.
# The Function's managed identity gets exactly one data-plane role, on one account.
az role assignment create `
--assignee $principalId `
--role "Storage Table Data Contributor" `
--scope $storageAccountId
# And the account key stops being a way in at all.
az storage account update `
--name $storageAccount --resource-group $rg `
--allow-shared-key-access false
Le DefaultAzureCredential fait le reste : dans le cloud il utilise l'identité managée, sur notre machine il utilise la session az login. La Function valide en plus l'id contre un motif de 32 hexadécimaux avant d'écrire, car l'id vient du navigateur et doit ressembler à un id à nous, non à un endroit où injecter.
Le bouton qui ne disparaît jamais
Un consentement qu'on ne peut pas retirer n'est pas valide, et le retrait doit être aussi facile que le consentement. C'est pourquoi le bouton de cookies reste visible en permanence dans un coin : ce qui ouvre et ferme est la carte de préférences, jamais le bouton. Nous avons une fois essayé de faire disparaître le bouton après le choix, et ensuite il n'y avait aucun moyen de le rouvrir pour changer d'avis. Le bouton est le mécanisme de retrait, et l'effacer cassait le droit.
Le choix expire à 12 mois, et alors on le redemande. Fermer la carte sans décider ne compte pas comme consentement, et cliquer en dehors ne la ferme que lorsqu'un choix est déjà enregistré. Ce sont de petits détails, et chacun est la différence entre un consentement valide et un qui ne tient pas.
Ce que ça coûte, et ce que ça prouve
Une Table Storage coûte des centimes par mois, et une Function sur le plan de consommation presque rien pour ce volume. Pour ce prix, nous avons une bannière qui ne dépend de personne, qui ne laisse pas tomber un seul cookie avant le «oui», et surtout un enregistrement qui s'ouvre et se lit le jour où quelqu'un demande. La bannière est la partie qu'on voit. La preuve est la partie qui compte.



