Ocultação e retenção
O que nunca chega a ser armazenado, e o que deixa de ser armazenado. A ocultação roda antes da gravação, porque um segredo apagado depois já tinha sido escrito.
A ocultação roda antes do armazenamento
Prompts e respostas do modelo são a coisa mais valiosa que o mcpd guarda, e a mais perigosa. A ocultação é o terceiro estágio do pipeline de ingestão, antes da gravação, então um valor que uma regra casa nunca chega ao banco de dados.
-
Decode
OTLP or native, gzip or plain
-
Sample
Decided once, at the root
-
Redact
Before anything is written
-
Price
From the configured price book
-
Store
One transaction per batch
Redaction is stage three, not a cleanup job. A value that a rule matches never reaches storage at all.
A ordem é o ponto inteiro. Um valor limpo depois do fato esteve no disco, no write-ahead log, e muito provavelmente no backup de ontem à noite. Nunca ter sido escrito é uma garantia diferente de ter sido apagado depois, e é a única que você consegue afirmar sem rodeios a quem perguntar.
A ocultação vem ligada por padrão. Uma ferramenta que só protege você depois
de configurada não protege ninguém. O --no-redact, ou
MCPD_NO_REDACT, desliga isso, o que só faz sentido quando algo
antes dele já oculta.
A ocultação é a ponta do fio que fica no daemon. Os SDKs seguram a outra
ponta: MCPD_CAPTURE_PAYLOADS=false, ou
capture_payloads no código, mantém prompts e respostas dentro
do processo. O span ainda chega com o tempo, o status, o uso, o modelo, a
correlação e os erros, e input e output são
descartados antes de entrarem na fila, então eles nunca atravessam a rede.
Um time que não pode mandar esses bytes de jeito nenhum define isso, e um
span que não carrega nada sensível pode voltar a participar por conta
própria.
As regras padrão
Uma regra é um conjunto de trechos em minúsculas mais um modo. Uma chave
casa quando o nome dela, em minúsculas, contém qualquer um deles. Casar por
trecho é o que faz X-API-Key, authorization e
customer_email serem todos pegos sem que cada grafia e cada
prefixo estejam listados.
| Modo | Nomes de chave que casam |
|---|---|
mask | api_key, apikey, api-key,
secret, password, passwd,
access_token, refresh_token,
id_token, bearer,
authorization, auth_token,
private_key, client_secret,
session_token, credential,
signature, x-api-key |
partial | email, phone, ssn,
credit_card, card_number, iban |
hash | user_id, userid |
Quando uma chave casada contém um objeto ou um array, tudo o que está
embaixo dela também é ocultado.
{"credential": {"value": "hunter2", "note": "prod"}}
perde os dois campos, não só aquele cujo nome parecia perigoso.
O limite vale ser dito sem rodeios: as regras casam nomes de chave,
não valores. Uma chave de API que alguém colou no meio de um
prompt, e ficou armazenada sob uma chave chamada content, não é
casada por nada aqui. A ocultação impede a captura estruturada de
credenciais e identificadores. Ela não é um varredor de conteúdo, e tratá-la
como se fosse vai decepcionar você.
Os quatro modos
| Modo | O que acontece com o valor |
|---|---|
drop | A chave é removida por completo, como se nunca tivesse sido enviada. |
mask | O valor é trocado por [redacted]. |
partial |
Os dois primeiros e os dois últimos caracteres sobrevivem, unidos por
reticências: alice@example.com vira al…om. O
bastante para reconhecer um valor que você já conhece, não o bastante
para usar. Um valor com seis caracteres ou menos é mascarado por
inteiro, porque manter quatro de cinco caracteres revela quase tudo.
|
hash |
O valor é trocado por sha256: mais os doze primeiros
bytes de um SHA-256 com chave, como vinte e quatro caracteres
hexadecimais.
|
As regras embutidas usam três dos quatro modos. Nada é descartado por
padrão: o drop está ali para uma regra que você acrescenta,
quando até a presença de um campo é mais do que você quer guardar.
$ curl -s "$MCPD/v1/api/traces/$TRACE" | jq .spans[0].input{ "question": "Where is my order A-1042?", "api_key": "[redacted]", "email": "al…om", "user_id": "sha256:9f2c1b7d4a08e5c3f6b1d0a4"} Por que o hash usa uma chave
O hash mantém um campo útil. Entradas iguais dão hashes iguais sob uma mesma chave, então você ainda consegue agrupar por um id de usuário ocultado, contar quantas pessoas distintas bateram em uma falha, e seguir uma delas ao longo de uma sessão, sem guardar quem elas são.
Sem chave, essa propriedade vira um buraco. Identificadores vêm de espaços pequenos e adivinháveis, e quem tiver o hash e este código-fonte pode testar candidatos até um deles bater. A chave é o que torna o hash irreversível, em vez de apenas inconveniente.
A chave embutida é uma constante no código-fonte, ou seja, é pública. Defina a sua própria em qualquer implantação que aplique hash em alguma coisa.
uma implantação que aplica hash e expira dados
mcpd --redact-key "$MCPD_REDACT_KEY" --retention-days 30 Rotacionar a chave quebra o vínculo de propósito. Todo hash muda, então as linhas escritas antes da rotação não podem mais ser ligadas às linhas escritas depois dela. É para isso que a rotação serve, e é também o que ela custa. Rotacione a chave quando você quiser o vínculo cortado, não por hábito de calendário.
Leia a segunda metade desse custo antes de rotacionar a chave. O apagamento por titular casa o hash armazenado, então toda linha escrita sob a chave antiga fica fora do alcance de um pedido de exclusão a partir do momento em que a chave nova entra em vigor. O mcpd não tem como avisar que foi isso que aconteceu, porque nada registra qual chave escreveu qual linha. Tudo o que ele pode fazer é se recusar a deixar o zero resultante passar por resposta, e a seção sobre o zero, mais abaixo, é essa recusa.
Todo sinal, não só spans
Um segredo vaza tão fácil por uma linha de log quanto por um prompt, então o mesmo ocultador roda sobre os seis sinais na entrada:
- Spans:
input,output,attributesemetadata. - Logs: os
fieldsestruturados. - Métricas: os
labels. - Notas: o
metadata. - Feedback: o
metadatae ocommentde texto livre. - Notas de aspecto: a frase, em
text.
O nome de um span e a mensagem de um registro de log não passam pelo ocultador. Eles são identificadores, não payloads, e reescrevê-los quebraria o agrupamento. Não monte o nome de um span com o e-mail de um cliente.
O armazenamento de execuções não é ocultado
As execuções duráveis são a exceção, e de propósito. O armazenamento de
execuções guarda a entrada, o estado, a saída e a correlação de uma
execução, e a entrada, a saída e o erro de cada etapa, exatamente como
foram enviados, com o user_id sem hash. Eles são o estado de
trabalho do workflow: uma etapa posterior os lê de volta, e um valor que o
ocultador tivesse reescrito mudaria o que a execução faz. Os spans que uma
execução escreve no armazenamento de traces seguem o caminho de ingestão
comum, então o armazenamento de traces os guarda ocultados e com hash, como
qualquer outro span. Tanto a retenção quanto o apagamento alcançam o
armazenamento de execuções, como dizem as seções abaixo, e toda leitura de
uma execução pela API é registrada no log de auditoria.
Textos longos e documentos profundos
Dois limites valem para tudo, independentemente de alguma regra ter casado.
Qualquer texto único maior que 128 KiB é truncado, com
… [N bytes truncated] no fim, para que quem lê saiba que o
valor está incompleto, e não que ele é curto. O corte cai em um limite de
caractere, nunca dentro de um ponto de código de vários bytes. Um prompt
descontrolado não deve decidir o tamanho do seu banco de dados.
A varredura para em uma profundidade de 32, e tudo abaixo disso é trocado
por [too deeply nested]. Um documento patológico ou que se
referencia a si mesmo, chegando em uma porta de ingestão aberta, não
consegue prender uma CPU.
Retenção
O --retention-days, ou MCPD_RETENTION_DAYS, apaga
sinais mais antigos que essa quantidade de dias. Omita a opção e nada é
apagado. Guardar os dados por padrão é o sentido certo: uma ferramenta que
descartasse em silêncio os traces do mês passado seria descoberta no pior
momento possível.
A passagem roda em um temporizador de hora em hora dentro do daemon, não na
hora da consulta. Uma varredura de retenção pode apagar um número enorme de
linhas, e esse trabalho nunca pode cair dentro da requisição de alguém. Cada
passagem remove, em uma única transação, os spans, logs, métricas e linhas
de trace cujo received_at é mais antigo que o corte, e as
notas, o feedback e as notas de aspecto cujo próprio carimbo de tempo é,
e depois anota quantas linhas foram embora. As entradas do índice de texto
completo são limpas primeiro, enquanto as linhas que as nomeiam ainda existem, então nada
fica órfão.
A mesma passagem, na mesma janela, corta o armazenamento de execuções. Ela apaga toda execução durável cuja raiz terminou antes do corte, com as execuções filhas dela e tudo o que está guardado para elas, em lotes, para que os workers não fiquem parados enquanto ela trabalha. Uma execução que não terminou nunca é apagada, por mais tempo que esteja esperando. As definições não são cortadas.
A passagem cobre o projeto indicado por --project e todo
projeto no bloco projects do arquivo de configuração, não só o
padrão. Um projeto que define ali o seu próprio
retention_days é varrido por esse número, e os demais são
varridos pelo número do daemon. Um projeto que não está em nenhum dos dois
lugares, criado por um remetente por conta própria, não tem janela e nunca é
cortado. Isso importa quando um daemon serve vários times: um time que
combinou guardar trinta dias não passa a guardar mais em silêncio porque a
configuração de outro time era a única que a varredura leu, e um time que
ninguém anotou guarda tudo.
Um pedido de apagamento sobre uma pessoa
O DELETE /v1/api/subjects/{user_id} responde a um pedido
sobre uma pessoa em uma chamada. Envie o identificador do jeito que a sua
instrumentação o definiu. O daemon aplica o mesmo hash que aplicou na
ingestão, então uma implantação que aplica hash em user_id não
exige que você aplique hash antes.
A unidade do apagamento é a execução, não a linha. Em geral só um span raiz
carrega um user_id, e os spans filhos é que guardam o prompt e
a resposta, então apagar as linhas que nomeiam o titular deixaria o payload
de pé. Uma execução entra no alcance quando a projeção do trace dela nomeia
o titular, ou quando qualquer span dela o nomeia, e então a execução inteira
vai embora:
- Todo span daquela execução, com os inputs e outputs que ele carregava.
- Os logs, métricas, notas, feedback e notas de aspecto pendurados nele.
-
Sinais que nomeiam um
span_ide nenhumtrace_id. Uma nota ou uma linha de log presa a uma etapa da execução do titular é alcançada pelos ids de span das execuções em alcance, e não só pelos ids de trace delas. -
Linhas de log registradas em nome da pessoa sem execução em que se
pendurar, casadas pelo
user_iddelas mesmas. -
O feedback que a pessoa escreveu, casado por
author_id, esteja ele pendurado onde estiver. Essa linha guarda a prosa dela, e pode estar pendurada na execução de outra pessoa, que é onde ela costumava sobreviver. - Casos de dataset capturados de uma execução em alcance. Esses são esvaziados, e não apagados. A seção depois da próxima diz por quê.
-
Em um daemon que serve execuções duráveis, toda execução terminada cuja
correlação carrega o
user_id, casado do jeito que você iniciou a execução com ele, porque o armazenamento de execuções o guarda sem hash, com a árvore inteira de execuções filhas e tudo o que o armazenamento de execuções guarda para elas. Uma execução dessa pessoa que não terminou não é tocada: apagá-la corromperia o workflow de que ela faz parte. Ela é contada emruns_in_flighte nomeada emnote, para que você possa cancelá-la, ou deixá-la terminar, e mandar o pedido de novo.
O armazenamento de execuções é apagado primeiro. Os spans de uma execução esperam no outbox dela até serem escritos no armazenamento de traces, então apagar as execuções primeiro apaga todo span que ainda espera, e o apagamento no armazenamento de traces remove depois os que já foram escritos.
O que a resposta carrega
Uma contagem por sinal, duas contagens para o armazenamento de execuções, e
seis campos ao lado delas carregando o que uma contagem não consegue: o que
foi deixado para trás de propósito, e se um número pequeno pode ser lido
como "não há nada sobre esta pessoa aqui". deleted é o total
das seis contagens de sinal; as contagens de execução e
aspect_notes_erased são informadas ao lado dele, e não somadas
a ele.
$ curl -sX DELETE "$MCPD/v1/api/subjects/u_8841?project=checkout" | jq{ "project": "checkout", "traces": 1, "spans": 2, "logs": 1, "metrics": 0, "scores": 1, "feedback": 1, "deleted": 6, "runs_erased": 1, "runs_in_flight": 0, "dataset_items_erased": 1, "aspect_notes_erased": 0, "datasets": [ "checkout-regressions" ], "deletes_pending": false, "unmatchable_identifiers": 0, "note": "1 captured dataset cases were emptied in place rather than deleted, so a regression test is not destroyed without anyone being told. They hold no payload any more."} | Campo | O que ele diz a você |
|---|---|
runs_erased |
Execuções duráveis apagadas do armazenamento de execuções, execuções
filhas incluídas. Não entram em deleted. Ausente quando
o daemon não serve execuções
|
runs_in_flight |
As execuções duráveis da pessoa deixadas inteiras porque não
terminaram. note nomeia as execuções a cancelar.
Ausente quando o daemon não serve execuções
|
dataset_items_erased |
Casos capturados esvaziados no lugar. Eles não entram em
deleted, porque as linhas continuam ali
|
aspect_notes_erased |
Notas de aspecto apagadas. Não entram em deleted, que
soma as seis contagens de sinal impressas acima dele
|
datasets | Em quais datasets esses casos estão, para você poder ir olhar |
deletes_pending | Verdadeiro quando o backend aplica as exclusões depois de responder, de modo que as contagens dizem o que casou, e não o que já saiu do disco |
unmatchable_identifiers | Identificadores armazenados neste projeto que estão em um formato que este pedido não teria como casar |
note | Por que os números acima podem não ser a resposta inteira. Ausente quando são |
Casos capturados são esvaziados, não apagados
Um caso de dataset capturado da produção guarda uma cópia literal do input, então apagar a execução e deixar a cópia deixaria as palavras da pessoa dentro do dataset. Apagar o caso de vez é um dano de outra natureza: alguém capturou aquela execução para impedir que um defeito voltasse, e destruir o teste de regressão dessa pessoa sem dizer nada não é uma decisão que o mcpd deva tomar sozinho.
Então o payload é sobrescrito e a linha sobrevive. O caso passa a ler
{"erased": true} onde estavam o input e o metadata dele, o
vínculo de volta com a execução de origem é limpo, e a resposta diz quantos
casos e quais datasets. O caso fica visivelmente vazio, que é algo sobre o
qual quem opera consegue agir.
Um caso fica completamente fora de alcance, e o isolamento por projeto é a razão. Um caso capturado para um dataset que pertence a outro projeto não é esvaziado e não é contado. A chamada é autorizada para um projeto e toca um projeto, e um apagamento que atravessasse essa fronteira seria um jeito de descobrir o que há em um dataset que você não pode ler. Apague a pessoa daquele projeto também, com uma chave que o alcance.
O que ele não faz
Ele toca um projeto. O ?project= nomeia esse projeto, e o
projeto padrão do daemon é usado quando você o omite. Uma chave que não
alcança esse projeto é recusada com um 403 e nada é apagado. Linhas da mesma
pessoa em um projeto que a chave não abre não são tocadas nem contadas,
então uma contagem nunca pode revelar que o titular existe em outro lugar.
Apagar uma pessoa de vários projetos é uma chamada por projeto, com uma
chave que alcance cada um deles.
Um titular sem nada armazenado é um 200 com zeros, não um 404. O estado que você pediu vale, e um 404 responderia uma pergunta que ninguém tem direito de fazer: se aquela pessoa existe em algum lugar que esta chave enxerga. É também por isso que repetir depois de um tempo esgotado é seguro. A segunda chamada informa as contagens menores de uma segunda passagem, em vez de um erro.
Ele não é um jeito de apagar uma pessoa dos seus próprios sistemas. O mcpd apaga o que o mcpd armazenou. Qualquer coisa que você encaminhou para outro endpoint OTLP precisa ser apagada lá também, e o mcpd não sabe o que aconteceu com ela.
Chaves e o registro disso
O apagamento exige uma chave admin quando há chaves configuradas, e recua
para as chaves de leitura quando nenhuma chave admin está definida, para que
uma implantação existente mantenha o alcance que tinha. Ele é escrito no log
de auditoria com a ação, o projeto e um resumo da chave que pediu, e uma
tentativa recusada é registrada do mesmo jeito. Onde o user_id
passa por hash, o hash é o que a linha nomeia: o log de auditoria sobrevive
aos dados que descreve, então escrever o identificador cru ali deixaria o
nome que a pessoa pediu para você remover em uma tabela que a retenção não
varre.
Uma execução por vez continua disponível. O
DELETE /v1/api/traces/{trace_id} remove uma única execução
e tudo o que está ligado a ela, em uma transação, devolvendo o número de
linhas apagadas.
Um zero vindo de um apagamento é o valor de que desconfiar
Um zero é o que quem opera repete para uma pessoa como "não temos nada sobre você", e é o único número que este endpoint não consegue provar. Os identificadores armazenados passam por hash com a chave de ocultação, e nada registra qual chave aplicou hash em qual linha, então um zero pode igualmente significar que a chave mudou depois que aquelas linhas foram escritas. O mcpd não consegue distinguir os dois casos. O que ele consegue é nunca devolver um zero pelado que se leia como o primeiro deles.
Aqui está o mesmo titular, apagado sob uma chave de ocultação que foi rotacionada depois de a execução ter sido registrada. As contagens são todas zero e a linha continua ali:
$ curl -sX DELETE "$MCPD/v1/api/subjects/u_8841?project=checkout" | jq "{deleted, unmatchable_identifiers, note}"{ "deleted": 0, "unmatchable_identifiers": 0, "note": "Nothing matched. Stored identifiers are hashed with the redaction key, so this means no stored identifier hashes to this subject under the key in force now. It does not mean nothing about this person was ever stored: rows written under a different redaction key cannot be matched by this endpoint."} O que a nota pode provar depende do projeto. Quando nada nele está atribuído a alguém, não há linha que uma troca de chave pudesse estar escondendo, e a nota diz exatamente isso. Caso contrário, o zero não está provado: restaure a chave de ocultação sob a qual aquelas linhas foram escritas e repita o pedido.
Ele informa em vez de recusar, e essa é uma troca deliberada. Recusar quebraria uma repetição depois de um tempo esgotado, faria a resposta para "nunca visto" ficar diferente da resposta para "apagado há uma hora" e transformaria o endpoint em um jeito de perguntar se uma pessoa é conhecida por um projeto, e bloquearia todo apagamento em uma implantação cuja configuração de ocultação um dia mudou, inclusive os apagamentos que corretamente não casaram nada.
Identificadores que este pedido não teria como casar
Uma rotação é invisível. Uma mudança em se os identificadores
passam por hash já não é, e o unmatchable_identifiers é o que o
mcpd consegue enxergar. Um identificador com hash é reconhecível pelo
prefixo sha256:, então o armazenamento conta quantos dos
identificadores que ele guarda estão no formato oposto ao que este pedido
trouxe: linhas em texto puro contra um pedido com hash, ou o contrário.
Aquelas linhas foram escritas sob outra configuração de ocultação, podem
pertencer ao titular, e este pedido não consegue endereçá-las.
A contagem roda em todo apagamento, não só quando dá zero, e a resposta
abaixo é o motivo. Este projeto recebeu gravações com a ocultação ligada, e
depois recebeu gravações de novo com ela desligada. O pedido casou as linhas
mais novas e as apagou, e os dois identificadores mais antigos têm hash, de
modo que este pedido nunca teria como endereçá-los. Uma contagem que parasse
no deleted teria sido lida como uma resposta completa:
$ curl -sX DELETE "$MCPD/v1/api/subjects/u_8841?project=checkout" | jq "{deleted, unmatchable_identifiers, note}"{ "deleted": 2, "unmatchable_identifiers": 2, "note": "2 stored identifiers in this project are in a form this request could not match, so rows written under a different redaction setting were not searched and rows about this person may remain. Restore the redaction setting those rows were written under and repeat the request."} A contagem é sobre todo identificador que o projeto guarda, e não sobre as linhas do próprio titular, então aqueles dois podem pertencer a outra pessoa inteiramente. É o máximo que ela consegue dizer. O que ela descarta é ler um apagamento de aparência bem-sucedida como um apagamento completo.
A garantia não é a mesma nos três backends
SQLite e Postgres apagam dentro da transação que respondeu a você. Quando a
resposta chega, as linhas já se foram, e o deletes_pending é
false.
O ClickHouse não faz assim. A exclusão ali é uma mutação assíncrona: o
servidor aceita o comando e o aplica em segundo plano, então a resposta diz
o que casou, e não o que já saiu do disco. Esses apagamentos voltam com
deletes_pending em true, que é o mcpd avisando que
a mais fraca das duas garantias é a que está em vigor. Os casos de dataset
são a exceção nesse backend. Eles são reescritos sob a própria chave, em vez
de mutados, então o caso esvaziado é o que qualquer leitura devolve a partir
do instante em que a resposta é enviada. Uma mutação teria deixado o texto
antigo legível até terminar.
Se você precisa certificar a alguém que os dados sumiram até um dado momento, essa é a diferença para anotar. No ClickHouse, confirme que a mutação terminou no servidor antes de responder.
Antes de a porta ficar acessível
A ocultação protege os valores dentro de um trace. Ela não faz nada quanto a
quem pode ler o trace. O mcpd avisa na inicialização quando se liga a um
endereço fora do loopback com a ingestão aberta ou a API de leitura aberta,
porque os prompts e as respostas armazenados ficam então legíveis para
qualquer um que alcance a porta. Defina --ingest-key e
--read-key primeiro, e leia
Docker e Kubernetes.
A seguir
- Guardar menos já de saída: Amostragem.
- Cada opção e variável de ambiente: Configuração.
- Sondas, backups e atualizações: Como executar.