NosotrosServiciosProyectosBlogCredencialesContacto
← Volver al blog Gobernanza y seguridad

Un banner de cookies que se prueba, con Azure Tables y Functions

El banner que se copia de un proveedor es, él mismo, un rastreador. Un banner propio, sin terceros, y la parte difícil no es el banner: es guardar la prueba de que la persona consintió. Aquí está todo, del navegador a la Azure Table, paso a paso.

pH7x Systems® · · 8 min de lectura

Un sitio web necesita pedir consentimiento para cookies. La solución fácil es pegar el widget de un proveedor, que resuelve el banner y trae un problema mayor: ese widget es, a menudo, un rastreador de más, y la elección de la persona queda guardada en su casa, no en la nuestra.

Vale más un banner propio, sin terceros, y la parte difícil no es el banner. Es la prueba. El RGPD, en el artículo 7.1, no pide solo que se pida consentimiento: pide que se pueda demostrar que la persona consintió. Aquí está toda la configuración, del navegador a la Azure Table, paso a paso.

El problema de verdad: un consentimiento que no se prueba

Un consentimiento que vive solo en el localStorage del visitante no se demuestra. No lo tenemos, no lo podemos enseñar a nadie, y la persona puede borrarlo cuando quiera. Ante una auditoría, decir «tenemos consentimiento» sin poder mostrarlo vale cero.

La elección tiene que quedar registrada también de nuestro lado, con fecha y versión de la política. Aquí es donde entran las Azure Functions y las Azure Tables: la Function recibe la elección, la Table la guarda, y es esa Table la que respondemos cuando alguien pregunta qué se consintió, por quién, y cuándo.

Un solo banner, y ningún script de terceros en el HTML

La regla que no se negocia: ningún <script> de terceros está en el HTML. Se inyectan todos por código nuestro, y solo después del «sí». La ePrivacy exige consentimiento previo a la cookie, y una etiqueta de Google pegada en el HTML ya la habría puesto antes de que alguien pulsara.

html
<!-- 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>

Por defecto, todo denegado, sin casillas premarcadas. Y «Aceptar todo» y «Rechazar todo» tienen el mismo peso visual: un aceptar llamativo al lado de un rechazar apagado no es elección libre, es un patrón manipulador, y ya ha costado multas a quien lo usó.

Consent Mode v2: denegar primero, conceder lo aceptado

Google Analytics no entra antes del consentimiento. Cuando se acepta la categoría Estadística, inyectamos gtag y usamos el Consent Mode v2: las cuatro señales parten denegadas, y solo después se concede lo que la persona aceptó. No vendemos publicidad, así que las tres señales de anuncios quedan denegadas siempre, incluso en «Aceptar todo».

js
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);
}

El orden importa: denegar antes de que gtag.js cargue, y conceder después. Al revés habría una ventana en la que Analytics correría sin consentimiento, que es exactamente lo que el Consent Mode existe para evitar.

El identificador, y el envío de la prueba

El vínculo entre la persona y el registro es un identificador aleatorio, generado en el navegador y guardado en el localStorage del visitante. Es ella quien lo tiene en la mano: si nos pide demostrar lo que consintió, o borrarlo, es por este id que se encuentra el registro. La IP no se guarda, porque guardarla para probar un consentimiento exigiría consentimiento para guardarla.

js
// 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(() => {});
}

El keepalive hace que la petición sobreviva a quien pulsa y cierra la pestaña justo después. Y el envío falla en silencio a propósito: la elección ya está en el localStorage, así que lo que se pierde en un error es nuestra prueba, no el derecho de la persona. Nunca se rompe la página de quien está leyendo.

La prueba: una tabla append-only

Del lado del servidor hay una Azure Function que recibe la elección y la escribe en la Table. Y el detalle que hace de esto una prueba, y no telemetría, es que es append-only: cada elección es un evento nuevo, con partitionKey en el id del visitante y rowKey en el instante. Nada se reescribe.

ts
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 primera versión usaba upsert, y cambiar de idea sustituía el registro, borrando la historia. Si alguien consentía el día 1 y retiraba el día 5, quedaba solo el «no», y dejábamos de poder demostrar que teníamos consentimiento cuando Analytics corrió los días 1 a 4. Con partitionKey en el id, la historia de una persona se lee en una sola partición, que es lo que una solicitud de acceso del artículo 15 necesita.

Sin claves, y sin IP

La Function no tiene ninguna clave de cuenta en ningún sitio. Las claves compartidas están desactivadas en la cuenta de almacenamiento, y la identidad gestionada de la Function tiene el rol Storage Table Data Contributor, con alcance a esa cuenta y a ninguna otra. El secreto que no existe no se filtra.

powershell
# 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

El DefaultAzureCredential hace el resto: en la nube usa la identidad gestionada, en nuestra máquina usa la sesión de az login. La Function valida además el id contra un patrón de 32 hexadecimales antes de escribir, porque el id viene del navegador y tiene que parecer un id nuestro, no un sitio para inyectar.

El botón que nunca desaparece

Un consentimiento que no se puede retirar no es válido, y la retirada tiene que ser tan fácil como el consentimiento. Por eso el botón de cookies queda siempre visible en una esquina: lo que abre y cierra es la tarjeta de preferencias, nunca el botón. Una vez intentamos hacer desaparecer el botón tras la elección, y después no había forma de reabrirlo para cambiar de idea. El botón es el mecanismo de retirada, y borrarlo rompía el derecho.

La elección caduca a los 12 meses, y entonces se vuelve a preguntar. Cerrar la tarjeta sin decidir no cuenta como consentimiento, y pulsar fuera solo la cierra cuando ya hay una elección registrada. Son detalles pequeños, y cada uno es la diferencia entre un consentimiento válido y uno que no se sostiene.

Lo que cuesta, y lo que prueba

Una Table Storage cuesta céntimos al mes, y una Function en el plan de consumo casi nada para este volumen. Por ese precio nos quedamos con un banner que no depende de nadie, que no deja caer una sola cookie antes del «sí», y sobre todo con un registro que se abre y se lee el día en que alguien pregunta. El banner es la parte que se ve. La prueba es la parte que cuenta.

Seguir leyendo