Sincronizzare le identità Zitadel tra più ambienti senza trascinare il database

Sincronizzare le identità Zitadel tra più ambienti senza trascinare il database

Quando colleghi un'applicazione esistente a un identity provider, succede una cosa poco appariscente e con conseguenze durature: ogni utente locale guadagna una colonna nuova, che contiene l'identificativo di quella persona presso il sistema di identità. È il collegamento fra i due mondi, ed è ciò che permette di prendere il soggetto di un token e trovare l'utente corrispondente nel database.

Quella colonna ha due proprietà che la rendono un problema interessante. È costosa da produrre: nel caso concreto è stata il risultato di una sincronizzazione che ha creato o allineato oltre milleseicento utenti presso l'identity provider, con altrettante chiamate alle sue API. Ed è materializzata in un solo posto, cioè sull'ambiente su cui quella sincronizzazione è stata eseguita. Gli altri ambienti hanno la loro copia del database, con gli stessi utenti, e quella colonna vuota.

Rifare la sincronizzazione altrove sarebbe sbagliato per due ragioni: costerebbe di nuovo il tempo di tutte quelle chiamate, e soprattutto modificherebbe l'identity provider, che è un sistema condiviso e non un ambiente di prova. Spostare il dump completo del database è sovradimensionato: parliamo di centinaia di megabyte per trasportare l'equivalente di poche migliaia di coppie di valori.

Questo è l'ultimo articolo di una serie in cui ho descritto il modello di identità, l'installazione e l'aggiornamento dell'identity provider, la validazione dei token e l'autorizzazione che resta nell'applicazione. Chiudo con il problema più banale in apparenza e il più insidioso nella pratica: come si porta un collegamento fra identità da un ambiente all'altro senza assegnarlo alla persona sbagliata.

Perché non basta copiare due colonne?

Perché i due database, pur contenendo gli stessi utenti, non hanno necessariamente le stesse chiavi primarie. Se applichi la mappa usando l'identificativo interno della riga, e quel database è stato ricostruito da un dump diverso, o ha subito una rinumerazione, o ha un ordine di inserimento differente, assegni l'identità di una persona a un'altra.

Vale la pena fermarsi su cosa significa concretamente questo errore, perché non è un disallineamento di dati: è che qualcuno accede e vede i dati di qualcun altro. Non produce un errore, non genera un'eccezione, non compare in nessun registro. Funziona, e funziona nel modo sbagliato. È il tipo di difetto che si scopre da una segnalazione di un cliente, con tutto quello che comporta.

Ne segue il primo requisito del progetto: serve una chiave di trasporto stabile fra ambienti, che non sia l'identificativo tecnico della riga. Nel caso concreto era il codice di riferimento del cliente, un numero assegnato dal processo commerciale, unico e immutabile per costruzione, e presente identico in ogni copia del database perché fa parte dei dati e non della meccanica del database.

Il criterio generale, che vale ogni volta che si trasportano dati fra ambienti: si unisce su una chiave che appartiene al dominio, mai su una che appartiene al database. Le prime sono stabili perché qualcuno le ha decise; le seconde sono stabili solo finché nessuno ricostruisce niente.

Perché il problema si presenta anche se non hai tre ambienti

Vale la pena allargare, perché la situazione che ho descritto sembra specifica di chi lavora su più postazioni e invece è la forma generale di un problema molto comune.

Si presenta identica quando ricostruisci un ambiente di collaudo da un dump di esercizio: gli utenti ci sono, il collegamento con l'identity provider no, oppure c'è ma punta a un'istanza diversa. Si presenta quando cambi identity provider, perché ogni persona riceve un identificativo nuovo e serve una corrispondenza fra il vecchio e il nuovo, esattamente la stessa struttura di dati con lo stesso problema di applicarla alla riga giusta. Si presenta quando fondi due sistemi dopo un'acquisizione, con due basi utenti da riconciliare. E si presenta, in forma ridotta, ogni volta che ripristini un backup su un sistema che nel frattempo è andato avanti.

In tutti questi casi la struttura è la stessa: c'è un collegamento fra un'identità interna e una esterna, quel collegamento è costoso o impossibile da ricostruire, e applicarlo alla riga sbagliata produce un incidente silenzioso. La procedura descritta qui si adatta a ciascuno cambiando solo la chiave di trasporto.

C'è una contromisura preventiva che vale più di tutto lo strumento e che consiglio di adottare il primo giorno di qualunque integrazione con un identity provider: non usare mai l'identificativo esterno come chiave primaria applicativa. Tienilo come colonna di collegamento accanto a una chiave che possiedi tu e che non dipende da nessun sistema esterno. Costa una colonna in più e ti risparmia una migrazione: se l'identificativo esterno è la chiave primaria, cambiare identity provider significa riscrivere ogni riferimento in ogni tabella, mentre se è una colonna di collegamento significa aggiornare una colonna.

L'analogia che ha dato il nome all'operazione

Ho chiamato questa operazione resilver, prendendo il termine dal mondo degli archivi ridondati. Nei sistemi di archiviazione che mantengono più copie dei dati, come descritto nella documentazione di OpenZFS sulla gestione dei dispositivi, il resilver è la procedura con cui un disco appena inserito viene riempito ricostruendo il proprio contenuto a partire dagli altri: non è una copia bruta, è una ricostruzione verificata, e il sistema controlla man mano che ciò che scrive corrisponda a ciò che deve esserci.

L'analogia regge su tre punti e per questo l'ho tenuta. La sorgente di verità è altrove: nel caso dei dischi sono gli altri membri dell'insieme, qui è l'identity provider, che sa quali identità esistono. La mappa che trasporto è una cache rigenerabile, non un originale: se la perdo, la riproduco. E soprattutto l'operazione verifica prima di scrivere, invece di fidarsi.

Questo terzo punto è quello che ha determinato la forma dello strumento, ed è il motivo per cui non è semplicemente un file da importare.

La mappa, e cosa ci metto dentro

L'esportazione produce un file di testo con quattro colonne per ogni utente collegato: la chiave di trasporto, cioè il riferimento stabile di dominio; l'identificativo interno atteso della riga; l'identificativo del profilo collegato; e l'identità presso l'identity provider, che è il dato che si vuole trasportare.

Le due colonne centrali non servono all'applicazione: servono alla verifica. Sono le ancore che permettono di stabilire se il database di destinazione sia lo stesso insieme di dati di quello di partenza. Se per la stessa chiave di dominio i due database concordano anche sugli identificativi tecnici, allora sono la stessa cosa e il trasporto è sicuro; se divergono, sono due insiemi diversi e applicare la mappa sarebbe pericoloso.

L'esportazione produce anche un piccolo file di accompagnamento con l'origine, il momento di produzione, i conteggi e l'impronta crittografica del file principale. Serve a rispondere a domande che si presentano sempre a distanza di settimane: da dove viene questo file, quando è stato prodotto, è ancora integro. Costa cinque righe di script e toglie ogni ambiguità, in particolare quella più fastidiosa, cioè scoprire di avere due copie del file e non sapere quale sia la più recente.

dbq "SELECT ref+0, id, profile_id, idp_user_id
     FROM users WHERE idp_user_id IS NOT NULL ORDER BY ref+0;" > "$MAP_TSV"

{
    echo "source_host=$(hostname)"
    echo "generated_at=$(date -u +%FT%TZ)"
    echo "rows_with_identity=${with_id}"
    echo "distinct_refs=${drefs}"
    echo "tsv_sha256=$(sha256sum "$MAP_TSV" | cut -d' ' -f1)"
} > "$MAP_META"

Un dettaglio che vale la pena rubare è il confronto fra il numero di righe e il numero di chiavi distinte. Se non coincidono, la chiave scelta non è unica, e l'intero impianto poggia su un presupposto falso. Meglio saperlo al momento dell'esportazione, con un avviso, che al momento dell'applicazione, con un risultato imprevedibile.

La guardia: tre controlli, e l'abort è il comportamento normale

Prima di scrivere qualunque cosa, lo strumento carica la mappa in una tabella d'appoggio e verifica tre proprietà. La cosa che rende utile questo progetto non è quali siano i tre controlli: è che se uno solo fallisce, non viene scritto niente.

Primo, l'invariante globale. Ogni chiave presente nella mappa deve esistere nel database di destinazione. Non è a campione: è totale, e costa una singola giunzione. Cattura le divergenze all'ingrosso, come un ambiente ricostruito da un dump più vecchio a cui mancano utenti recenti.

Secondo, l'unicità. Nessuna chiave deve comparire due volte nella tabella di destinazione, altrimenti la giunzione è ambigua e l'aggiornamento colpirebbe più righe di quante dovrebbe.

Terzo, il campione di ancore. Su un sottoinsieme di chiavi distribuite uniformemente, si verifica che gli identificativi tecnici combacino con quelli attesi. È il controllo che risponde alla domanda vera, cioè se i due database siano lo stesso insieme di dati.

matched=$(dbq "SELECT COUNT(*) FROM ${STAGE} m JOIN users u ON u.ref=m.ref;")
if (( matched != mapn )); then
    echo "  [FAIL] chiavi mancanti nel target: $(( mapn - matched )) su ${mapn}." >&2
    issues=1
fi

# ... unicità e campione ...

if (( issues == 0 )); then
    echo "COERENTE: il target e' lo stesso dataset della sorgente."
    return 0
fi
echo "INCOERENTE: NON applico nulla (rischio assegnazione errata)." >&2
return 1

Il campionamento è uniforme e non casuale: si prende una chiave ogni tanto lungo l'elenco ordinato, con un passo calcolato sulla dimensione. La differenza rispetto a un campione casuale conta per due ragioni. È riproducibile, quindi due esecuzioni sullo stesso file controllano le stesse ancore e un esito diverso significa che è cambiato il database, non il campione. Ed è distribuito, quindi copre tutto l'intervallo delle chiavi invece di addensarsi da qualche parte per caso, il che è precisamente ciò che serve per intercettare una divergenza che riguardi solo una porzione dei dati.

Il limite va dichiarato invece che nascosto, ed è scritto nel documento operativo: il controllo sulle ancore è a campione, quindi un disallineamento che cada fuori dal campione non viene visto. Si può aumentare la dimensione del campione fino a coprire tutto, al costo di una verifica più lenta. L'invariante globale sulle chiavi, invece, è sempre totale. È un compromesso consapevole fra rigore e tempo di esecuzione, e la cosa che lo rende accettabile è che sia scritto: un limite dichiarato è una decisione, un limite taciuto è un difetto.

Se stai spostando dati sensibili fra ambienti e vuoi che la procedura si rifiuti di lavorare invece di indovinare, nel mio profilo professionale trovi il percorso su migrazioni di dati, disaster recovery e gestione di infrastrutture in esercizio.

Cosa non c'è in questa mappa, ed è deliberato

Vale la pena elencare cosa il file non contiene, perché in un trasporto di dati fra ambienti ciò che si omette è una decisione tanto quanto ciò che si include.

Non contiene nessun dato personale oltre lo stretto necessario: niente nomi, niente indirizzi di posta, niente numeri di telefono. Solo identificativi. È una scelta che semplifica molto la vita, perché un file di identificativi opachi ha un profilo di rischio completamente diverso da uno che contiene un'anagrafica, e la differenza si riflette su dove lo si può appoggiare e per quanto tempo lo si può tenere.

Non contiene nessuna credenziale. Le password non stanno in questo mondo: vivono presso l'identity provider e non attraversano mai il confine. È una proprietà che discende direttamente dall'aver centralizzato l'autenticazione, ed è uno dei benefici meno celebrati di quella scelta: una volta che le credenziali stanno in un posto solo, i trasporti di dati fra ambienti smettono di essere operazioni delicate.

Non contiene lo stato: niente flag di attivazione, niente ruoli, niente permessi. Quelli appartengono a ciascun ambiente e non vanno trasportati, perché un ambiente di prova ha legittimamente stati diversi da quello di esercizio. La mappa porta un collegamento, non una fotografia.

L'insieme di queste tre omissioni è ciò che rende il file di poche decine di kilobyte invece che di centinaia di megabyte, e il principio è più generale del caso specifico: quando devi trasportare qualcosa fra ambienti, chiediti qual è il dato minimo che non puoi ricostruire dall'altra parte, e trasporta solo quello. Nel caso concreto il dato irriducibile era il collegamento fra due identificativi, e tutto il resto era già presente o ricostruibile.

L'applicazione, che è la parte più corta

Se la verifica passa, l'applicazione è una sola istruzione:

START TRANSACTION;
UPDATE users u JOIN _identity_stage m ON u.ref = m.ref
   SET u.idp_user_id = m.idp_user_id;
COMMIT;

Ha tre proprietà che vale la pena isolare, perché sono ciò che rende la procedura ripetibile senza ansia.

È idempotente: rieseguirla scrive gli stessi valori e non cambia niente. Non c'è nessuno stato da azzerare prima, nessun controllo di "l'ho già fatto?", nessuna possibilità di sbagliare eseguendola due volte. Su una procedura che si esegue di rado e sotto pressione, questa è la proprietà che conta di più.

È circoscritta: la giunzione tocca solo le righe la cui chiave compare nella mappa. Gli utenti che nella mappa non ci sono restano intatti, il che significa che si può trasportare un sottoinsieme senza cancellare il resto.

È verificabile subito: lo strumento conta le righe collegate prima e dopo, e mostra i due numeri. Se la procedura era già stata eseguita, i numeri coincidono e lo si vede a colpo d'occhio, il che è un modo elegante di rendere visibile l'idempotenza invece di doverla spiegare.

Vale la pena notare cosa quell'istruzione non fa, perché è altrettanto deliberato: non azzera i collegamenti delle righe assenti dalla mappa, non tocca nessun'altra colonna, e non inserisce righe nuove. Una procedura di allineamento che cancella ciò che non conosce è comodissima da scrivere e pericolosissima da eseguire su un ambiente che è andato avanti per conto suo. Toccare solo ciò che si è portati a toccare è una regola che costa qualche riga di query in più e che rende la procedura eseguibile anche quando non sei del tutto sicuro di cosa ci sia dall'altra parte.

Il flusso completo, dal punto di vista di chi lo esegue, sta in cinque comandi: si esporta sull'ambiente sincronizzato, si trasporta il file, si verifica sull'ambiente di destinazione, e solo se il verdetto è positivo si applica. La verifica è eseguibile da sola, in sola lettura, e questa non è una comodità: è ciò che permette di rispondere alla domanda "questi due ambienti sono allineati?" senza rischiare di modificare niente per scoprirlo.

Come si verifica che abbia funzionato davvero?

Contando prima e dopo non basta: quei due numeri dicono quante righe hanno un collegamento, non che il collegamento sia quello giusto. Sono due domande diverse e la seconda richiede un controllo diverso.

La verifica che uso è a due livelli. Il primo è la controprova sulla mappa: si rilegge dal database di destinazione l'insieme delle coppie chiave-identità e lo si confronta con il file di partenza. Se coincidono riga per riga, l'applicazione ha scritto esattamente ciò che doveva. È un controllo totale, costa una query e un confronto di file, e ha il pregio di non fidarsi dell'operazione appena eseguita.

Il secondo è la verifica dal vivo su qualche caso: si prende un pugno di persone, si guarda che identificativo esterno abbiano nel database di destinazione, e si controlla presso l'identity provider che quell'identificativo corrisponda davvero a quella persona. È l'unico controllo che chiude il cerchio, perché è l'unico che consulta la sorgente di verità invece di confrontare due copie fra loro. Ne bastano pochi: se la mappa era coerente e l'applicazione è stata verificata, il campione serve a intercettare un errore sistematico, non uno sporadico.

C'è un terzo controllo, il più economico e il più convincente, che consiglio di non saltare mai: fare un accesso reale. Autenticarsi con un'utenza di prova sull'ambiente appena allineato e verificare di atterrare sull'account giusto, con le risorse giuste. Non dimostra nulla in senso statistico, ma esercita la catena intera, che è precisamente ciò che nessuna query può fare.

Una nota che vale la pena aggiungere sul senso opposto della procedura. Se la verifica fallisce e i due ambienti risultano insiemi di dati diversi, la soluzione non è forzare l'applicazione: è ricostruire l'ambiente di destinazione dallo stesso dump della sorgente, oppure rigenerare la mappa dall'ambiente che è davvero allineato. Il verdetto di incoerenza non è un ostacolo da aggirare, è un'informazione: ti sta dicendo che credevi due cose uguali e non lo sono, e quella scoperta vale molto più dei dieci minuti che costa.

Il principio generale, oltre questo caso

Il valore di questo strumento non è nelle sue righe di codice, che sono poche e banali. È in una scelta di impostazione che consiglio ogni volta che si trasportano dati fra ambienti: il comportamento predefinito in caso di dubbio è non fare niente.

È una scelta meno ovvia di quanto sembri, perché va contro l'istinto di chi scrive lo strumento. Chi lo scrive vuole che funzioni, e ogni controllo che aborta è una possibilità in più che non funzioni. La tentazione di rendere i controlli avvisi invece che blocchi è concreta, e sempre motivata dalla fretta.

Il modo in cui ho reso quella tentazione meno pericolosa è stato separare i comandi: la verifica è un comando a sé, in sola lettura, che si può eseguire quante volte si vuole senza conseguenze. Chi ha fretta usa quello e vede il verdetto senza dover disarmare niente. È una piccola scelta di forma con un effetto grande: quando il controllo è disponibile separatamente, nessuno ha motivo di indebolire quello incorporato.

Il ragionamento che ho usato per resistere è di asimmetria dei costi. Un'esecuzione abortita costa dieci minuti di indagine e la rigenerazione della mappa. Un'assegnazione sbagliata costa un incidente di sicurezza, con una persona che accede all'account di un'altra, la ricostruzione di quali dati siano stati visti, e una conversazione con il cliente. Quando i due esiti differiscono di tre ordini di grandezza, il dubbio si risolve sempre nella stessa direzione.

La generalizzazione, che è ciò che porto via da questo lavoro e che uso da allora anche altrove, sta in quattro punti. Si unisce su una chiave di dominio, mai su una tecnica. Si trasportano anche le ancore per verificare, non solo il dato utile, perché il costo è irrisorio e il beneficio è la possibilità stessa di verificare. La verifica precede la scrittura ed è eseguibile da sola, così che chiedersi se due ambienti siano allineati non richieda di modificarne uno. E la scrittura è idempotente, così che ripetere l'operazione non sia mai un problema e nessuno debba ricordarsi se l'ha già fatta.

Con questo la serie si chiude. Nei tredici articoli ho raccontato un'integrazione reale dall'inizio alla fine: il modello concettuale, la scelta del prodotto, l'installazione e l'aggiornamento, la validazione dei token, l'autorizzazione applicativa, il recinto sui dati, la revoca, il passaggio di contesto fra applicazioni, la convivenza con le installazioni non federate, l'adeguamento normativo e, adesso, la manutenzione degli ambienti. Se c'è una cosa che mi porto dietro da questo lavoro è che la parte difficile di un progetto di identità non è quasi mai il protocollo: quello è documentato, stabile e ben implementato. La parte difficile è tutto quello che il protocollo non copre, cioè dove vive l'autorizzazione, cosa succede quando qualcuno revoca, come si comporta il sistema quando un pezzo non risponde, e come si trasporta un collegamento senza sbagliare riga. Se stai per iniziare un percorso simile e vuoi confrontarti con qualcuno che l'ha già fatto end-to-end, scrivimi pure: la conversazione più utile è sempre quella che si fa prima di scrivere la prima riga di codice.

Ultima modifica: