Revoca immediata con Zitadel: perché un permesso non va dentro il token

Revoca immediata con Zitadel: perché un permesso non va dentro il token

Un token è un'istantanea: dice cosa era vero nel momento in cui è stato emesso, ed è firmato proprio perché quel contenuto non cambi più. Un permesso è uno stato: dice cosa è vero adesso, e la sua utilità dipende interamente dal fatto che qualcuno possa toglierlo. Mettere il secondo dentro il primo è una contraddizione, e il modo in cui si manifesta è sempre lo stesso: qualcuno revoca un accesso, il sistema conferma l'operazione, e per i successivi trenta o sessanta minuti quell'accesso continua a funzionare.

La tentazione è forte e ha buone ragioni. Se il permesso viaggia nel token, ogni richiesta si autorizza leggendo un campo già presente, senza toccare il database. È veloce, è elegante, ed è il consiglio che si trova in metà degli articoli su questo tema. Il problema è che l'ottimizzazione è invisibile finché non serve la revoca, e quando serve, di solito, serve con urgenza.

Negli articoli precedenti di questa serie ho descritto come il token provi chi sta chiamando, come l'autorizzazione resti nell'applicazione che conosce il dominio e come il recinto sui dati vada ricostruito quando il bind non è più l'utente. Qui affronto il caso più insidioso: quando l'autorizzazione non riguarda chi sei, ma per conto di chi stai agendo, e quel diritto è revocabile mentre lo stai usando.

Perché mettere un permesso nel token rompe la revoca?

Perché un token, per costruzione, non si può cambiare dopo averlo emesso. È firmato: se ne modifichi il contenuto la firma non torna, e questa è la sua proprietà utile, non un difetto. Ne segue che un permesso scritto dentro un token resta vero, per chiunque lo legga, fino alla sua scadenza naturale.

Se hai emesso token di un'ora, hai costruito una finestra di un'ora in cui una revoca non ha effetto. Se ne hai emessi di otto ore per non far ri-autenticare le persone durante la giornata, la finestra è di otto ore. È aritmetica, non un caso limite.

Vale la pena guardare le tre risposte che di solito si danno a questo problema, perché due sono peggio della malattia. Accorciare la vita dei token riduce la finestra ma non la chiude, e sposta il costo sull'identity provider, che si ritrova a rinnovare token in continuazione. Tenere una lista di token revocati funziona, ma è precisamente il ritorno a uno stato condiviso da consultare a ogni richiesta, cioè la query che si voleva evitare, con in più la complessità di gestire quella lista. Non mettere il permesso nel token è la terza, ed è quella che risolve il problema invece di ridurlo: se il diritto viene letto dalla sorgente al momento dell'uso, la revoca ha effetto immediato e non c'è nessuna lista da mantenere.

Il criterio che uso quando decido cosa può stare in un token: ci metto ciò che è vero della persona e stabile per la durata del token (chi è, a che organizzazione appartiene). Ne tengo fuori tutto ciò che qualcuno potrebbe voler togliere mentre il token è ancora valido. Se ti stai chiedendo in quale categoria cada un dato, è nella seconda.

Il caso in cui questo diventa serio: l'autorizzazione delegata

Finché l'autorizzazione riguarda le tue risorse, la revoca è un caso raro e spesso non urgente. Diventa un problema serio quando entra in gioco la delega: un attore che opera per conto di un soggetto, con il consenso di quest'ultimo.

È uno schema comune e in genere legittimo. Un consulente che amministra i sistemi di un cliente, un rivenditore che gestisce le posizioni dei propri clienti finali, un operatore dell'assistenza che entra nell'account di chi ha chiamato per capire cosa sta vedendo. In tutti questi casi ci sono tre elementi: un attore, un soggetto, e un consenso che il soggetto o un amministratore possono togliere.

Quel consenso ha due proprietà che lo rendono il dato peggiore da mettere in un token. È revocabile per ragioni serie: il rapporto si è interrotto, c'è un sospetto, qualcuno ha sbagliato ad assegnarlo. Ed è revocabile con urgenza: chi lo toglie si aspetta che l'effetto sia immediato, perché è esattamente il motivo per cui lo sta togliendo. Un sistema che risponde "ho revocato" e poi lascia l'accesso attivo per mezz'ora non è lento, è falso.

Rileggere non basta: la finestra TOCTOU

La prima correzione è ovvia: non scrivere il consenso nel token, rileggerlo dal database quando serve. Ed è giusta, ma incompleta, perché apre una falla più sottile.

Il codice ingenuo fa così: legge il consenso, verifica che sia valido, e procede con l'operazione. Fra il momento in cui legge e il momento in cui agisce c'è un intervallo, breve quanto si vuole ma non nullo. Se in quell'intervallo qualcuno revoca il consenso, l'operazione procede comunque, perché la decisione era già stata presa su un dato ormai vecchio.

È un difetto con un nome e una classificazione: la vulnerabilità di tipo time-of-check to time-of-use, catalogata come CWE-367. Non è un problema teorico da manuale: è la situazione normale di qualunque sistema con più processi che leggono e scrivono lo stesso dato, e in un'applicazione web con più worker è la condizione ordinaria, non l'eccezione.

La finestra è di millisecondi, e questo porta a sottovalutarla. Il ragionamento giusto non è sulla probabilità: è che il caso in cui qualcuno revoca un consenso è precisamente il caso in cui c'è un problema in corso, e quindi è precisamente il momento in cui è plausibile che dall'altra parte ci sia attività concorrente. La probabilità di collisione non è uniforme nel tempo: si concentra proprio nell'istante in cui non puoi permettertela.

Il pattern: rileggere sotto lock, in una transazione breve

La chiusura corretta è rileggere il consenso prendendo un lock sulla riga, dentro una transazione, e mantenerlo fino a quando la decisione è stata registrata. Il lock impedisce a chi vuole revocare di modificare la riga mentre tu la stai valutando: la sua scrittura aspetta, e quando ottiene la riga la tua decisione è già stata presa e applicata.

// controllo preliminare economico: attore attivo, soggetto attivo, non se stesso
if (!$this->canDelegate($actor, $subject)) {
    return null;
}

// un amministratore non dipende dal consenso per-soggetto
if ($actor->isAdmin()) {
    return self::ROLE_ADMIN;
}

// consenso riletto in modo autoritativo sotto lock di riga
$authorized = DB::transaction(function () use ($actor, $subject) {
    $lockedProfile = Profile::where('id', $subject->profile_id)
        ->lockForUpdate()
        ->first();

    if ($lockedProfile === null) {
        return false;
    }
    if ((int) $lockedProfile->delegation_allowed !== 1) {
        return false;
    }
    if ((int) $lockedProfile->delegate_ref !== (int) $actor->reference) {
        return false;
    }

    return true;
});

return $authorized ? self::ROLE_DELEGATE : null;

Tre scelte in questo frammento meritano di essere motivate, perché sono ciò che distingue il pattern da una transazione messa lì per scaramanzia.

Il controllo economico viene prima. La verifica che l'attore sia attivo, che il soggetto esista e che non stia cercando di delegare sé stesso non richiede nessun lock: è una lettura ordinaria che scarta il novanta per cento dei casi negativi senza costo. Il lock si prende solo quando la risposta potrebbe essere sì. Prendere un lock per poi scoprire che l'utente era disattivato è sprecare contesa sul database per niente.

L'amministratore è escluso dal percorso con lock, e non per pigrizia. Il suo diritto non dipende dal consenso del soggetto: è una proprietà del suo ruolo. Non c'è nessuna riga da rileggere sotto lock, perché non c'è nessun dato per-soggetto da cui dipenda la decisione. Mettercelo comunque avrebbe aggiunto contesa senza aggiungere garanzie, ed è il tipo di ridondanza che sembra prudente e invece degrada il sistema.

Il controllo verifica due condizioni, non una. Il consenso deve essere attivo e deve riguardare proprio quell'attore. Sono due campi diversi e vanno letti entrambi sotto lo stesso lock: verificarne uno soltanto lascia aperto il caso in cui un soggetto ha concesso la delega a qualcuno, ma non a chi sta chiamando adesso.

La transazione è cortissima per costruzione. Contiene una lettura e due confronti in memoria. Non contiene chiamate di rete, non contiene invii di posta, non contiene operazioni sul backend dati. È la regola generale di ogni transazione che prende lock: dentro ci va solo ciò che deve essere atomico, e tutto il resto sta fuori. Una transazione lunga che tiene un lock è il modo più efficace di trasformare una protezione in un blocco del sistema.

La variante stateless, e perché il codice è quasi identico

Il caso in cui questo pattern serve davvero è quello di un'applicazione esterna che chiede, tramite un token, di operare per conto di un soggetto. Non c'è una sessione con uno stato, c'è una singola richiesta che deve essere autorizzata da sola.

La risposta è una versione stateless dello stesso controllo: stesse verifiche, stesso lock, nessuna scrittura in sessione. Restituisce il ruolo effettivo oppure niente, e il chiamante decide cosa farne.

Il fatto che il codice sia quasi identico è una proprietà voluta, non una duplicazione trascurata, e vale la pena spiegarla perché è una decisione di architettura. Le due varianti condividono la funzione di verifica preliminare, che è dove vive la regola di chi può delegare chi. Il che significa che una modifica a quella regola si applica a entrambi i percorsi, e non è possibile che il percorso a token diverga da quello con sessione. È lo stesso principio dell'articolo precedente sulla query dei permessi: se due percorsi devono rispondere alla stessa domanda, devono usare la stessa funzione, altrimenti divergeranno.

Se stai progettando un sistema in cui qualcuno può operare per conto di altri e vuoi che la revoca funzioni davvero il giorno in cui serve, nel mio profilo professionale trovi il percorso su sicurezza applicativa, controllo degli accessi e architetture multi-tenant. E se vuoi verificare cosa contengono davvero i token che il tuo sistema emette, il mio strumento di audit dei JWT mostra i claim e le scadenze in chiaro: se lì dentro trovi un permesso, hai trovato il problema descritto in questo articolo.

La seconda metà del problema: quali operazioni sono permesse

Chiudere la finestra sul se qualcuno può agire per conto di un altro non dice niente su cosa può fare mentre lo fa. Ed è la domanda in cui si nascondono gli incidenti, perché la risposta cambia con ogni funzionalità nuova.

L'impostazione che ho adottato è una tabella esplicita, in un file solo, che elenca le operazioni negate durante la delega e quelle esplicitamente permesse:

return [
    'delegate' => [
        // Negate: identita', credenziali, secondo fattore -> prevenzione del furto di account
        'denied_routes' => [
            'user.profiles.update',
            'user.users.update',
            'user.users.2fa_enable_action',
            'user.users.2fa_disable',
            // Strumenti di pagamento e movimenti -> prevenzione frode
            'user.invoices.add_payment_method',
            'user.invoices.remove_payment_method',
            // Trasferimento della risorsa a terzi -> prevenzione sottrazione
            'user.assets.send_transfer_code',
        ],

        // Permesse in modo esplicito: e' il lavoro per cui la delega esiste
        'explicitly_allowed' => [
            'user.tickets.store',
            'user.tickets.update',
            'user.subscriptions.change_quantity',
            // ... operazioni tecniche sulle risorse gestite
        ],
    ],
];

Il criterio con cui è stata riempita si legge nei commenti ed è quello che consiglio: si negano le operazioni che permetterebbero a chi delega di perdere il controllo dell'account (cambiare le credenziali, disattivare il secondo fattore, modificare l'identità), quelle che muovono denaro, e quelle che trasferiscono a terzi la risorsa gestita. Tutto il resto, cioè il lavoro operativo per cui la delega esiste, è permesso.

Un dettaglio che vale la pena rubare: il controllo a tempo di esecuzione non discrimina in base al metodo HTTP. Una richiesta di sola lettura che ha effetti collaterali, come un collegamento che disattiva il secondo fattore, viene bloccata esattamente come una scrittura. La regola è sulla semantica dell'azione, non sul verbo, perché il verbo è una convenzione e le convenzioni si violano.

Il test che impedisce a un'operazione nuova di sfuggire

Una tabella di questo tipo ha un difetto evidente: invecchia. Chi aggiunge una funzionalità fra sei mesi non saprà che esiste, e quella funzionalità sarà implicitamente permessa durante la delega senza che nessuno l'abbia deciso.

La contromisura è un test che verifica una proprietà strutturale e non un comportamento: ogni operazione che modifica qualcosa e che ha un nome deve comparire o fra le negate o fra le esplicitamente permesse. Se ne compare una nuova non classificata, il test fallisce.

L'effetto non è tecnico ma organizzativo, ed è il motivo per cui lo considero il pezzo più prezioso di tutto questo lavoro: obbliga chi introduce una funzionalità a prendere posizione. Non si può aggiungere un'operazione senza dire se un delegato possa eseguirla, perché la suite non passa. La decisione può anche essere sbagliata, ma è una decisione presa da qualcuno, con un nome accanto, invece di un'omissione.

Lo stesso test verifica anche che ogni operazione elencata esista davvero: una voce che si riferisce a qualcosa di rimosso è una riga morta che dà l'impressione di una protezione che non c'è più. È il tipo di controllo che costa cinque righe e impedisce a un file di sicurezza di diventare archeologia.

Accanto a questo c'è la verifica di comportamento: una richiesta effettuata sotto delega verso un'operazione negata deve essere effettivamente respinta. Serve a dimostrare che la tabella non è decorativa e che il componente che la applica è montato dove deve.

Chi ha fatto cosa: l'audit di un'azione delegata

C'è una conseguenza della delega che si scopre tardi e costa cara: nei registri, di default, compare il soggetto e non l'attore. Il sistema ha autenticato una persona che agisce per conto di un'altra e poi opera "come" quest'ultima, quindi tutto ciò che scrive risulta compiuto dal soggetto. Se qualcuno chiede chi abbia eseguito una certa operazione, la risposta è il nome sbagliato.

Non è un difetto marginale. Toglie senso a metà del lavoro descritto finora: hai costruito una catena che prova crittograficamente l'identità dell'attore e poi la butti via nel momento in cui la registri.

La correzione è un dato aggiuntivo che accompagna ogni scrittura nei registri quando una delega è attiva: chi agisce davvero, oltre a per conto di chi. Diventa così possibile rispondere a entrambe le domande, che sono diverse e servono a scopi diversi.

public function getDelegationData(): ?array
{
    $stack = $this->readStack();
    if (count($stack) === 0) {
        return null;
    }
    $top = $stack[count($stack) - 1];

    return [
        'acting_user_id' => $top['id']   ?? null,
        'acting_name'    => $top['name'] ?? null,
        'delegation_role'=> $top['role'] ?? null,
    ];
}

Il dettaglio che vale la pena rubare è la forma a pila. Un solo campo "sto agendo per conto di" basta finché il livello è uno, ma esistono casi legittimi in cui un amministratore assume il ruolo di un delegato per riprodurre un problema, e a quel punto i livelli sono due. Modellare la cosa come una pila con una profondità massima dichiarata, invece che come una coppia di campi, permette di raccontare correttamente anche quel caso e, soprattutto, di rifiutare le transizioni che non hanno senso: un delegato non può a sua volta delegare, e oltre il secondo livello si nega. Sono regole che si scrivono una volta e impediscono tutta una classe di scenari confusi.

Vale la pena essere espliciti su cosa questo dato serve a fare, perché non è la sicurezza: non impedisce niente. Serve alla ricostruzione a posteriori, che è la cosa che ti salva quando devi spiegare a un cliente perché sul suo account risulta un'operazione che non ha fatto lui. Senza quel campo la risposta onesta sarebbe "non lo sappiamo", e non è una risposta che si possa dare due volte.

Come si comunica una revoca, e perché conta

Un dettaglio operativo che nei documenti di architettura non compare mai e che nella realtà genera più chiamate di qualunque bug: cosa vede chi viene rifiutato.

Quando un consenso viene revocato, l'attore che stava operando riceve un rifiuto. Se quel rifiuto è un errore tecnico grezzo, la persona non capisce cosa sia successo, sospetta un guasto e chiama l'assistenza. Se invece il messaggio dice che l'autorizzazione non è più valida e suggerisce di rivolgersi a chi l'aveva concessa, la persona capisce e la chiamata non arriva, oppure arriva alla persona giusta.

C'è anche un aspetto meno ovvio e più delicato, che è quanto il messaggio debba rivelare. Distinguere apertamente fra "il consenso è stato revocato" e "quel soggetto non esiste" dà a chi prova a indovinare un modo per verificare l'esistenza di soggetti che non gli appartengono. La scelta che consiglio è una risposta uniforme verso l'esterno e la distinzione completa nei registri interni: chi ha titolo a saperlo lo legge nei log, chi sta sondando non ottiene informazione.

E vale la pena predisporre un percorso per il caso opposto, cioè la revoca fatta per errore. Una delega tolta per sbaglio va poter essere ripristinata da chi l'aveva concessa, senza passare da un intervento tecnico. Se ripristinare richiede una modifica manuale sul database, la conseguenza pratica è che nessuno revocherà più niente per paura di sbagliare, e una funzione di sicurezza che le persone hanno paura di usare è una funzione che non esiste.

Quanto costa, e cosa dire a chi lo deve approvare

Il costo di questo pattern è una transazione con un lock di riga per ogni operazione delegata, ed è misurabile: si tratta di una lettura indicizzata e di un blocco che dura microsecondi. La contesa esiste solo fra richieste che riguardano lo stesso soggetto nello stesso istante, che nella pratica è un evento raro. Una protezione contro le raffiche di richieste resta comunque opportuna, e non tanto per il carico quanto per limitare la contesa in caso di un client che ritenta in ciclo.

Detto in termini di rischio, per chi la scelta la deve approvare, il ragionamento sta in tre righe.

La revoca diventa un'azione che ha effetto, non una che ha effetto quasi sempre e con ritardo. È la differenza fra poter rispondere e non poter rispondere alla domanda su cosa sia successo dopo che l'accesso è stato tolto, ed è una domanda che arriva sempre insieme a un incidente.

La finestra di esposizione si misura in microsecondi invece che in decine di minuti. Non è zero, perché lo zero in questo campo non esiste, ma è un ordine di grandezza che si può dichiarare in una valutazione di rischio senza imbarazzo.

La disciplina è verificabile. Esiste un file che elenca cosa un delegato può e non può fare, ed esiste un test che fallisce se qualcuno introduce un'operazione senza classificarla. Questi due artefatti sono la risposta alla domanda "come fate a sapere che è ancora vero", che è l'unica domanda seria che un auditor pone.

Vale la pena chiudere con la generalizzazione, perché questo pattern non riguarda solo la delega. Vale per ogni permesso revocabile: un abbonamento che scade, una licenza sospesa, un accesso condiviso che viene ritirato. La domanda da porsi su ogni dato che entra in una decisione di autorizzazione è sempre la stessa: qualcuno potrebbe volerlo togliere mentre qualcun altro lo sta usando? Se la risposta è sì, quel dato si legge dalla sorgente al momento dell'uso, sotto lock se l'operazione che segue è distruttiva, e non viaggia dentro nessuna istantanea firmata. Se stai valutando un impianto in cui i permessi vivono nei token e vuoi capire quanto è larga la finestra che hai, scrivimi pure: di solito basta guardare la durata dei token e l'elenco dei claim per avere la risposta, e quasi sempre è più larga di quanto chi lo ha costruito ricordi.

Ultima modifica: