SobreCompetênciasTrabalhoBlogCredenciaisContacto
← Voltar ao blog Governação e segurança

Um banner de cookies que se prova, com Azure Tables e Functions

O banner que se copia de um fornecedor é, ele próprio, um rastreador. Um banner próprio, sem terceiros, e a parte difícil não é o banner: é guardar a prova de que a pessoa consentiu. Aqui fica tudo, do browser à Azure Table, passo a passo.

pH7x Systems® · · 8 min de leitura

Um site precisa de pedir consentimento para cookies. A solução fácil é colar o widget de um fornecedor, que resolve o banner e traz um problema maior: esse widget é, muitas vezes, um rastreador a mais, e a escolha da pessoa fica guardada em casa dele, não em nossa.

Vale mais um banner próprio, sem terceiros, e a parte difícil não é o banner. É a prova. O RGPD, no artigo 7.º/1, não pede só que se peça consentimento: pede que se consiga demonstrar que a pessoa consentiu. Aqui fica a configuração toda, do browser à Azure Table, passo a passo.

O problema a sério: um consentimento que não se prova

Um consentimento que vive só no localStorage do visitante não se demonstra. Não o temos, não o podemos exibir a ninguém, e a pessoa pode apagá-lo quando quiser. Perante uma auditoria, dizer «temos consentimento» sem o poder mostrar vale zero.

A escolha tem de ficar registada também do nosso lado, com data e versão da política. É aqui que entram as Azure Functions e as Azure Tables: a Function recebe a escolha, a Table guarda-a, e é essa Table que respondemos quando alguém pergunta o que foi consentido, por quem, e quando.

Um só banner, e nenhum script de terceiros no HTML

A regra que não se negoceia: nenhum <script> de terceiros está no HTML. São todos injetados por código nosso, e só depois do «sim». A ePrivacy exige consentimento prévio ao cookie, e uma etiqueta da Google colada no HTML já o teria posto antes de alguém clicar.

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 omissão, tudo negado, sem caixas pré-marcadas. E «Aceitar tudo» e «Recusar tudo» têm o mesmo peso visual: um aceitar garrido ao lado de um recusar apagado não é escolha livre, é um padrão manipulador, e já custou coimas a quem o usou.

Consent Mode v2: negar primeiro, conceder o que foi aceite

O Google Analytics não entra antes do consentimento. Quando a categoria Estatística é aceite, injetamos o gtag e usamos o Consent Mode v2: os quatro sinais partem negados, e só depois se concede o que a pessoa aceitou. Não vendemos publicidade, por isso os três sinais de anúncios ficam negados sempre, mesmo em «Aceitar tudo».

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

A ordem importa: negar antes de o gtag.js carregar, e conceder a seguir. Ao contrário, haveria uma janela em que o Analytics correria sem consentimento, que é exatamente o que o Consent Mode existe para evitar.

O identificador, e o envio da prova

A ligação entre a pessoa e o registo é um identificador aleatório, gerado no browser e guardado no localStorage do visitante. É ela que o tem na mão: se nos pedir para demonstrar o que consentiu, ou para apagar, é por este id que se encontra o registo. O IP não se guarda, porque guardá-lo para provar um consentimento exigiria consentimento para o guardar.

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

O keepalive faz o pedido sobreviver a quem clica e fecha o separador logo a seguir. E o envio falha em silêncio de propósito: a escolha já está no localStorage, por isso o que se perde num erro é a nossa prova, não o direito da pessoa. Nunca se parte a página de quem está a ler.

A prova: uma tabela append-only

Do lado do servidor está uma Azure Function que recebe a escolha e a escreve na Table. E o detalhe que faz disto uma prova, e não telemetria, é ser append-only: cada escolha é um evento novo, com partitionKey no id do visitante e rowKey no instante. Nada se reescreve.

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

A primeira versão fazia upsert, e mudar de ideias substituía o registo, apagando a história. Se alguém consentisse no dia 1 e retirasse no dia 5, ficava só o «não», e deixávamos de conseguir demonstrar que tínhamos consentimento quando o Analytics correu nos dias 1 a 4. Com partitionKey no id, a história de uma pessoa lê-se numa só partição, que é o que um pedido de acesso do artigo 15.º precisa.

Sem chaves, e sem IP

A Function não tem nenhuma chave de conta em lado nenhum. As chaves partilhadas estão desativadas na conta de armazenamento, e a identidade gerida da Function tem o papel Storage Table Data Contributor, no âmbito daquela conta e de mais nenhuma. O segredo que não existe não vaza.

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

O DefaultAzureCredential faz o resto: na cloud usa a identidade gerida, na nossa máquina usa a sessão do az login. A Function valida ainda o id contra um padrão de 32 hexadecimais antes de escrever, porque o id vem do browser e tem de parecer um id nosso, não um sítio para injetar.

O botão que nunca desaparece

Um consentimento que não se pode retirar não é válido, e a retirada tem de ser tão fácil como o consentimento. Por isso o botão de cookies fica sempre visível a um canto: o que abre e fecha é o cartão de preferências, nunca o botão. Já tentámos fazer o botão sumir depois da escolha, e depois não havia forma de reabrir para mudar de ideias. O botão é o mecanismo de retirada, e apagá-lo partia o direito.

A escolha caduca aos 12 meses, e então volta a perguntar-se. Fechar o cartão sem decidir não conta como consentimento, e clicar fora só o fecha quando já existe uma escolha registada. São detalhes pequenos, e cada um deles é a diferença entre um consentimento válido e um que não se sustenta.

O que custa, e o que prova

Uma Table Storage custa cêntimos por mês, e uma Function no plano de consumo quase nada para este volume. Por esse preço ficámos com um banner que não depende de ninguém, que não deixa um único cookie cair antes do «sim», e sobretudo com um registo que se abre e se lê no dia em que alguém perguntar. O banner é a parte que se vê. A prova é a parte que conta.

Continuar a ler