Vai al contenuto
mcpd
nessun daemon
Codice

Oscuramento e conservazione

Che cosa non viene mai archiviato, e che cosa smette di esserlo. L’oscuramento gira prima della scrittura, perché un segreto cancellato dopo era comunque stato messo per iscritto.

L’oscuramento gira prima dello storage

I prompt e i completion sono la cosa più preziosa che mcpd tiene, e la più pericolosa. L’oscuramento è la terza fase della pipeline di ingest, prima della scrittura, così un valore che una regola intercetta non arriva mai al database.

Ingest pipeline
  1. Decode

    OTLP or native, gzip or plain

  2. Sample

    Decided once, at the root

  3. Redact

    Before anything is written

  4. Price

    From the configured price book

  5. Store

    One transaction per batch

Redaction is stage three, not a cleanup job. A value that a rule matches never reaches storage at all.

L’ordine è tutto il punto. Un valore ripulito a cose fatte è comunque stato su disco, nel write-ahead log e, molto probabilmente, nel backup di ieri notte. Mai scritto è una garanzia diversa da cancellato dopo, ed è l’unica che puoi dichiarare senza giri di parole a chi te la chiede.

L’oscuramento è attivo per impostazione predefinita. Uno strumento che ti protegge solo dopo che lo hai configurato non protegge nessuno. --no-redact, oppure MCPD_NO_REDACT, lo spegne, il che ha senso solo quando qualcosa a monte oscura già.

L’oscuramento è il capo del filo che sta dal lato del daemon. Gli SDK tengono l’altro: MCPD_CAPTURE_PAYLOADS=false, o capture_payloads nel codice, tiene prompt e completion dentro il processo. Uno span arriva lo stesso con i tempi, lo stato, il consumo, il modello, la correlazione e gli errori, mentre input e output vengono tolti prima di finire in coda, così non attraversano mai la rete. Un team che quei byte non li deve proprio mandare lo imposta, e uno span che non porta niente di delicato può rientrare per conto suo.

Le regole predefinite

Una regola è un insieme di sottostringhe minuscole e una modalità. Una chiave corrisponde quando il suo nome, reso minuscolo, contiene una qualunque di quelle. È il confronto sulla sottostringa che fa prendere X-API-Key, authorization e customer_email senza dover elencare ogni maiuscola e ogni prefisso.

ModalitàNomi di chiave che corrispondono
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 una chiave intercettata contiene un oggetto o un array, viene oscurato anche tutto quello che ci sta sotto. {"credential": {"value": "hunter2", "note": "prod"}} perde tutti e due i campi, non solo quello dal nome pericoloso.

Il limite va detto senza giri di parole: le regole corrispondono a nomi di chiave, non a valori. Una chiave API che un utente ha incollato in mezzo a un prompt, archiviata sotto una chiave chiamata content, non viene intercettata da niente qui dentro. L’oscuramento ferma la cattura strutturata di credenziali e identificatori. Non è uno scanner di contenuti, e prenderlo per tale ti deluderà.

Le quattro modalità

ModalitàChe cosa succede al valore
drop La chiave viene tolta del tutto, come se non fosse mai stata mandata.
mask Il valore viene sostituito con [redacted].
partial Sopravvivono i primi due e gli ultimi due caratteri, uniti da un’ellissi: [email protected] diventa al…om. Abbastanza per riconoscere un valore che già conosci, non abbastanza per usarlo. Un valore di sei caratteri o meno viene invece mascherato per intero, perché tenerne quattro su cinque ne rivela quasi tutto.
hash Il valore viene sostituito con sha256: e i primi dodici byte di un digest SHA-256 con chiave, come ventiquattro caratteri esadecimali.

Le regole integrate ne usano tre su quattro. Per impostazione predefinita non viene scartato niente: drop sta lì per una regola che aggiungi tu, dove anche la sola presenza di un campo è più di quanto tu voglia tenere.

che cosa è tornato indietro
$ 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"}

Perché l’hash ha una chiave

L’hash tiene utile un campo. Sotto una stessa chiave, input uguali danno digest uguali, quindi puoi ancora raggruppare per un id utente oscurato, contare quante persone distinte hanno incontrato un errore e seguirne una lungo una sessione, senza tenere chi sono.

Senza chiave, quella proprietà è un buco. Gli identificatori vengono da spazi piccoli e indovinabili, e chi ha il digest e questo sorgente può calcolare l’hash dei candidati finché uno non corrisponde. È la chiave a rendere il digest irrecuperabile invece che soltanto scomodo.

La chiave integrata è una costante nel sorgente, quindi è pubblica. In qualunque deploy che calcoli hash, imposta la tua.

un deploy che calcola hash e fa scadere

mcpd --redact-key "$MCPD_REDACT_KEY" --retention-days 30

Ruotare la chiave spezza il collegamento, ed è voluto. Ogni digest cambia, quindi le righe scritte prima della rotazione non si possono più unire a quelle scritte dopo. È a questo che serve una rotazione, ed è anche quello che costa. Ruota quando vuoi il collegamento spezzato, non a scadenza fissa per abitudine.

Prima di ruotare, leggi la seconda metà di quel costo. La cancellazione per soggetto confronta il digest archiviato, quindi ogni riga scritta sotto la vecchia chiave è fuori dalla portata di una richiesta di cancellazione dal momento in cui la nuova chiave è in vigore. mcpd non ti può dire che è successo questo, perché niente registra quale chiave ha scritto quale riga. Tutto quello che può fare è non lasciar passare per risposta lo zero che ne esce, e la sezione qui sotto sullo zero è quel rifiuto.

Ogni segnale, non solo gli span

Un segreto esce da una riga di log con la stessa facilità con cui esce da un prompt, quindi lo stesso oscuratore gira su tutti e sei i segnali in entrata:

  • Span: input, output, attributes e metadata.
  • Log: i fields strutturati.
  • Metriche: le labels.
  • Punteggi: i metadata.
  • Feedback: i metadata, e il comment in testo libero.
  • Note di aspetto: la frase, in text.

Il nome di uno span e il messaggio di un record di log non passano dall’oscuratore. Sono identificatori, non payload, e riscriverli romperebbe il raggruppamento. Non costruire il nome di uno span con l’indirizzo email di un cliente.

Lo storage delle esecuzioni non è oscurato

Le esecuzioni durevoli sono l’eccezione, ed è voluto. Lo storage delle esecuzioni tiene l’input, lo stato, l’output e la correlazione di un’esecuzione, e l’input, l’output e l’errore di ogni passo, esattamente come sono stati mandati, con user_id senza hash. Sono lo stato di lavoro del workflow: un passo successivo li rilegge, e un valore che l’oscuratore avesse riscritto cambierebbe quello che fa l’esecuzione. Gli span che un’esecuzione scrive nello storage delle tracce seguono il percorso di ingest ordinario, quindi lo storage delle tracce li tiene oscurati e sottoposti a hash come ogni altro span. La conservazione e la cancellazione raggiungono entrambe lo storage delle esecuzioni, come dicono le sezioni qui sotto, e ogni lettura di un’esecuzione attraverso l’API viene registrata nel registro di audit.

Stringhe lunghe e documenti profondi

Due limiti valgono per tutto, che una regola abbia corrisposto o no.

Qualunque singola stringa più lunga di 128 KiB viene troncata, con … [N bytes truncated] in coda, così chi legge sa che il valore è incompleto e non corto. Il taglio cade su un confine di carattere, mai dentro un codepoint su più byte. Un prompt fuori controllo non deve decidere la dimensione del tuo database.

La discesa si ferma a una profondità di 32, e tutto quello che sta sotto viene sostituito con [too deeply nested]. Un documento patologico o autoreferenziale che arriva su una porta di ingest aperta non può inchiodare una CPU.

Conservazione

--retention-days, oppure MCPD_RETENTION_DAYS, cancella i segnali più vecchi di quel numero di giorni. Non impostarlo e non viene cancellato mai niente. Tenere i dati per impostazione predefinita è il verso giusto: uno strumento che scartasse in silenzio le tracce del mese scorso lo scopriresti nel momento peggiore.

La passata gira su un timer ogni ora dentro il daemon, non al momento della query. Una passata di conservazione può cancellare moltissime righe, e quel lavoro non deve mai finire dentro la richiesta di qualcuno. Ogni passata toglie, in una transazione sola, gli span, i log, le metriche e le righe di traccia il cui received_at è più vecchio del taglio, e i punteggi, i feedback e le note di aspetto il cui timestamp proprio lo è, poi scrive nei log quante righe se ne sono andate. Le voci dell’indice full-text vengono ripulite per prime, mentre le righe che le nominano esistono ancora, così non resta niente orfano.

La stessa passata, sulla stessa finestra, sfoltisce lo storage delle esecuzioni. Cancella ogni esecuzione durevole la cui radice è finita prima del taglio, con le sue esecuzioni figlie e tutto quello che è archiviato per loro, a lotti, così i worker non restano bloccati mentre lavora. Un’esecuzione che non è finita non viene mai cancellata, per quanto a lungo abbia aspettato. Le definizioni non vengono sfoltite.

La passata copre il progetto nominato da --project e ogni progetto nel blocco projects del file di configurazione, non solo quello predefinito. Un progetto che lì imposta il proprio retention_days viene ripulito su quella cifra, e gli altri sulla cifra del daemon. Un progetto che non sta né in un posto né nell’altro, uno che un mittente ha nominato per conto suo, non ha una finestra e non viene mai sfoltito. Conta quando un solo daemon serve più team: un team che ha concordato di tenere trenta giorni non se ne tiene in silenzio di più perché l’unica impostazione letta dalla passata era quella di un altro team, e un team che nessuno ha messo per iscritto tiene tutto.

Una richiesta di cancellazione su una persona

DELETE /v1/api/subjects/{user_id} risponde in una sola chiamata a una richiesta su una persona. Manda l’identificatore così come lo ha impostato la tua strumentazione. Il daemon applica lo stesso hash che ha applicato all’ingest, quindi un deploy che calcola l’hash di user_id non ti chiede di calcolarlo prima.

L’unità di cancellazione è l’esecuzione, non la riga. Di solito solo uno span radice porta un user_id, e sono gli span figli a tenere il prompt e la completion, quindi cancellare le righe che nominano il soggetto lascerebbe in piedi il payload. Un’esecuzione rientra quando la proiezione della sua traccia nomina il soggetto, oppure quando lo nomina uno qualunque dei suoi span, e allora se ne va l’esecuzione intera:

  • Ogni span di quell’esecuzione, con gli input e gli output che portava.
  • I log, le metriche, i punteggi, i feedback e le note di aspetto che ne dipendono.
  • I segnali che nominano uno span_id e nessun trace_id. Un punteggio o una riga di log appuntati a un solo passo dell’esecuzione del soggetto si raggiungono attraverso gli id di span delle esecuzioni in questione, non solo attraverso i loro id di traccia.
  • Le righe di log registrate a nome della persona senza nessuna esecuzione a cui appendersi, confrontate sul loro user_id.
  • Il feedback che la persona ha scritto, confrontato su author_id, dovunque sia appeso. Quella riga porta la sua prosa, e può stare appesa all’esecuzione di qualcun altro, che è dove prima sopravviveva.
  • I casi di dataset catturati da un’esecuzione in questione. Quelli vengono svuotati invece che cancellati. Perché lo dice la sezione dopo la prossima.
  • Su un daemon che serve esecuzioni durevoli, ogni esecuzione durevole finita la cui correlazione porta il user_id, confrontato nella forma con cui hai avviato l’esecuzione perché lo storage delle esecuzioni lo tiene senza hash, con tutto il suo albero di esecuzioni figlie e tutto quello che lo storage delle esecuzioni tiene per loro. Un’esecuzione di quella persona che non è finita non viene toccata: cancellarla corromperebbe il workflow di cui fa parte. È contata in runs_in_flight e nominata in note, così puoi annullarla, o lasciarla finire, e mandare di nuovo la richiesta.

Lo storage delle esecuzioni viene cancellato per primo. Gli span di un’esecuzione aspettano nel suo outbox finché non vengono scritti nello storage delle tracce, quindi cancellare prima le esecuzioni cancella ogni span ancora in attesa, e la cancellazione nello storage delle tracce toglie poi quelli già scritti.

Che cosa porta la risposta

Un conteggio per segnale, due conteggi per lo storage delle esecuzioni, e accanto sei campi che portano quello che un conteggio non può portare: che cosa è stato lasciato indietro di proposito, e se un numero piccolo si può leggere come «di questa persona qui non è archiviato niente». deleted è il totale dei sei conteggi per segnale; i conteggi delle esecuzioni e aspect_notes_erased sono riportati accanto, non sommati dentro.

una richiesta, una persona
$ 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."}
CampoChe cosa ti dice
runs_erased Le esecuzioni durevoli cancellate dallo storage delle esecuzioni, esecuzioni figlie comprese. Non sono contate in deleted. Assente quando il daemon non serve esecuzioni
runs_in_flight Le esecuzioni durevoli della persona lasciate intere perché non sono finite. note nomina le esecuzioni da annullare. Assente quando il daemon non serve esecuzioni
dataset_items_erased I casi catturati svuotati sul posto. Non sono contati in deleted, perché le righe ci sono ancora
aspect_notes_erased Le note di aspetto cancellate. Non sono contate in deleted, che somma i sei conteggi per segnale stampati sopra
datasets In quali dataset stanno quei casi, così puoi andare a guardarli
deletes_pending Vero quando il backend applica le cancellazioni dopo averti risposto, quindi i conteggi dicono che cosa ha corrisposto e non che cosa ha già lasciato il disco
unmatchable_identifiers Gli identificatori archiviati in questo progetto che sono in una forma che questa richiesta non poteva far corrispondere
note Perché i numeri qui sopra potrebbero non essere tutta la risposta. Assente quando lo sono

I casi catturati vengono svuotati, non cancellati

Un caso di dataset catturato dalla produzione tiene una copia letterale dell’input, quindi cancellare l’esecuzione e lasciare la copia lascerebbe nel dataset le parole della persona. Cancellare il caso di netto è un danno di un altro tipo: qualcuno ha catturato quell’esecuzione per impedire a un bug di tornare, e distruggere il suo test di regressione senza dire niente non è una decisione che mcpd deve prendere da sé.

Quindi il payload viene sovrascritto e la riga sopravvive. Il caso legge {"erased": true} dove stavano il suo input e i suoi metadata, il suo collegamento all’esecuzione di origine viene azzerato, e la risposta dice quanti casi e quali dataset. Il caso è vuoto in modo visibile, che è una cosa su cui un operatore può intervenire.

Un caso resta del tutto fuori portata, e il motivo è la separazione fra tenant. Un caso catturato dentro un dataset che appartiene a un altro progetto non viene svuotato e non viene contato. La chiamata è autorizzata su un progetto e tocca un progetto, e una cancellazione che attraversasse quel confine sarebbe un modo per scoprire che cosa c’è dentro un dataset che non puoi leggere. Cancella la persona anche da quel progetto, con una chiave che lo raggiunge.

Che cosa non fa

Tocca un progetto solo. ?project= lo nomina, e quando lo lasci fuori si usa il progetto predefinito del daemon. Una chiave che non raggiunge quel progetto viene rifiutata con un 403 e non viene cancellato niente. Le righe della stessa persona in un progetto che la chiave non può aprire non vengono né toccate né contate, quindi un conteggio non può mai rivelare che il soggetto esiste da qualche altra parte. Cancellare una persona da più progetti è una chiamata per progetto, con una chiave che arriva a ognuno.

Un soggetto di cui non è archiviato niente è un 200 che porta degli zeri, non un 404. Lo stato che hai chiesto vale lo stesso, e un 404 risponderebbe a una domanda che nessuno ha il diritto di fare: se quella persona esiste in qualunque punto che questa chiave vede. È anche il motivo per cui ripetere la chiamata dopo un timeout è sicuro. La seconda chiamata riporta i conteggi più piccoli di una seconda passata, invece di un errore.

Non è un modo per cancellare una persona dai tuoi sistemi. mcpd cancella quello che mcpd ha archiviato. Tutto quello che hai inoltrato a un altro endpoint OTLP va cancellato anche lì, e mcpd non sa che fine ha fatto.

Le chiavi, e il registro di tutto questo

La cancellazione richiede una chiave admin quando le chiavi sono configurate, e ricade sulle chiavi di lettura quando nessuna chiave admin è impostata, così un deploy che esiste già tiene la portata che aveva. Viene scritta nel registro di audit con l’azione, il progetto e un digest della chiave che l’ha chiesta, e un tentativo rifiutato viene registrato allo stesso modo. Dove user_id è sottoposto a hash, è il digest che la riga nomina: il registro di audit sopravvive ai dati che descrive, quindi scriverci dentro l’identificatore in chiaro lascerebbe il nome che la persona ti ha chiesto di togliere dentro una tabella che la conservazione non ripulisce.

Resta anche la cancellazione di una sola esecuzione per volta. DELETE /v1/api/traces/{trace_id} toglie una singola esecuzione e tutto quello che ne dipende, in una transazione sola, e restituisce il numero di righe cancellate.

Uno zero da una cancellazione è il valore di cui diffidare

Uno zero è quello che un operatore ripete a una persona come «su di lei non teniamo niente», ed è l’unico numero che questo endpoint non può dimostrare. Gli identificatori archiviati hanno l’hash calcolato con la chiave di oscuramento, e niente registra quale chiave ha calcolato l’hash di quale riga, quindi uno zero può voler dire altrettanto bene che la chiave è cambiata dopo che quelle righe erano state scritte. mcpd non distingue i due casi. Quello che può fare è non restituire mai uno zero nudo che si legge come il primo.

Ecco lo stesso soggetto, cancellato sotto una chiave di oscuramento ruotata dopo che l’esecuzione era stata registrata. I conteggi sono tutti a zero e la riga è ancora lì:

dopo una rotazione, la stessa richiesta non corrisponde a niente
$ 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."}

Ciò che la nota può dimostrare dipende dal progetto. Quando niente in esso è attribuito a qualcuno, non c’è nessuna riga che un cambio di chiave possa nascondere, e la nota dice esattamente quello. Altrimenti uno zero non è dimostrato: rimetti la chiave di oscuramento sotto cui quelle righe erano state scritte e ripeti la richiesta.

Riferisce invece di rifiutare, ed è uno scambio voluto. Rifiutare spezzerebbe la ripetizione dopo un timeout, farebbe differire la risposta di «mai vista» da quella di «cancellata un’ora fa» e trasformerebbe l’endpoint in un modo per chiedere se una persona è nota a un progetto, e bloccherebbe ogni cancellazione in un deploy la cui impostazione di oscuramento sia mai cambiata, comprese quelle che correttamente non corrispondono a niente.

Identificatori che questa richiesta non poteva far corrispondere

Una rotazione è invisibile. Un cambiamento su se gli identificatori hanno l’hash calcolato non lo è, e unmatchable_identifiers è quello che mcpd riesce a vedere. Un identificatore con hash si riconosce dal suo prefisso sha256:, quindi lo storage conta quanti degli identificatori che tiene sono nella forma opposta a quella che questa richiesta portava: righe in chiaro contro una richiesta con hash, o il contrario. Quelle righe sono state scritte sotto un’altra impostazione di oscuramento, possono appartenere al soggetto, e questa richiesta non le può indirizzare.

Il conteggio gira su ogni cancellazione, non solo su uno zero, e la risposta qui sotto è il motivo. Questo progetto ha ricevuto scritture con l’oscuramento acceso, poi altre con l’oscuramento spento. La richiesta ha corrisposto alle righe più nuove e le ha cancellate, e i due identificatori più vecchi hanno l’hash, quindi questa richiesta non avrebbe mai potuto indirizzarli. Un conteggio che si fermasse a deleted si sarebbe letto come una risposta completa:

una cancellazione parziale, che è il caso da prendere
$ 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."}

Il conteggio è su ogni identificatore che il progetto tiene, non sulle righe del soggetto, quindi quei due possono appartenere a tutt’altra persona. È il massimo che può dire. Quello che esclude è leggere come completa una cancellazione che sembra riuscita.

La garanzia non è la stessa su tutti e tre i backend

SQLite e Postgres cancellano dentro la transazione che ti ha risposto. Quando arriva la risposta le righe non ci sono più, e deletes_pending è false.

ClickHouse no. Lì la cancellazione è una mutation asincrona: il server accetta l’istruzione e la applica in background, quindi la risposta dice che cosa ha corrisposto e non che cosa ha già lasciato il disco. Quelle cancellazioni tornano indietro con deletes_pending a true, che è mcpd che ti dice che in vigore c’è la più debole delle due garanzie. Su quel backend i casi di dataset sono l’eccezione. Vengono riscritti sotto la loro chiave invece che mutati, quindi il caso svuotato è quello che qualunque lettura restituisce dal momento in cui la risposta parte. Una mutation avrebbe lasciato il vecchio testo leggibile fino alla fine.

Se devi certificare a qualcuno che i dati non ci sono più entro un dato momento, è questa la differenza da mettere per iscritto. Su ClickHouse, conferma che la mutation è finita sul server prima di rispondere.

Prima che la porta sia raggiungibile

L’oscuramento protegge i valori dentro una traccia. Non fa niente su chi quella traccia la può leggere. mcpd avverte all’avvio quando si mette in ascolto su un indirizzo che non è di loopback con l’ingest aperto o l’API di lettura aperta, perché i prompt e i completion archiviati sono allora leggibili da chiunque raggiunga la porta. Imposta prima --ingest-key e --read-key, e leggi Docker e Kubernetes.

Poi