Grant authority con Zitadel: l'IdP autentica, la tua app autorizza

Grant authority con Zitadel: l'IdP autentica, la tua app autorizza

Nella maggior parte delle integrazioni SSO che ho visto fallire, il fallimento non è tecnico: è che qualcuno ha provato a mettere il modello di business dentro l'identity provider. Comincia sempre allo stesso modo, con un ruolo che sembra innocuo, tipo cliente-attivo. Poi ne serve uno per il cliente sospeso, uno per quello in prova, e a quel punto qualcuno chiede se si possono avere i domini dell'utente dentro il token. Sei arrivato: hai due sorgenti di verità sullo stesso dato, una nel database che le persone aggiornano tutti i giorni e una nell'identity provider che va sincronizzata, e da lì in avanti il lavoro è tenerle allineate.

Nel precedente articolo di questa serie ho mostrato come un access token provi crittograficamente chi sta chiamando, appoggiandosi al modello di progetti e audience descritto in apertura. Qui affronto la seconda metà della domanda, quella che il token non può contenere: cosa quel qualcuno può fare. La risposta che ho adottato è tenere l'autorizzazione dove già vive il dato che la determina, e trasformare il gestionale in autorità di autorizzazione per le altre applicazioni.

Perché l'identity provider non può rispondere a "cosa puoi fare"?

Perché non conosce il tuo dominio, e non deve conoscerlo. Nel caso concreto da cui viene questa serie, la domanda che il pannello di gestione caselle deve porre è: quali domini di posta può amministrare questa persona? La risposta dipende da chi è il proprietario del dominio nell'anagrafica, dallo stato del dominio, dal fatto che il cliente sia attivo o sospeso, ed eventualmente dal fatto che chi sta operando lo faccia per conto di un altro soggetto. Sono quattro tabelle di un gestionale che cambiano tutti i giorni.

Ci sono solo due modi di dare quella risposta. Copiarla nell'identity provider, e allora ti serve una sincronizzazione che gira di continuo, che va monitorata, che avrà un ritardo, e che il giorno in cui sbaglia produce un utente che vede i domini di un altro. Oppure chiederla a chi la conosce, al momento in cui serve. La seconda è più semplice da costruire e non ha uno stato da tenere allineato: l'unico costo è una chiamata di rete al momento del login.

Il criterio in una riga: l'identity provider deve contenere ciò che è vero della persona (chi è, come si autentica, a che organizzazione appartiene). Tutto ciò che è vero della relazione commerciale resta dov'è, e si chiede quando serve.

L'errore che rende inutile tutto il lavoro: l'account di servizio

Quando due sistemi devono parlarsi, la prima soluzione che viene in mente è creare un utente tecnico: il pannello si autentica sul gestionale con un'utenza dedicata e chiede i dati dell'utente 4711. Funziona, e distrugge tutto ciò che il token aveva costruito.

Il motivo è che l'identificativo dell'utente torna a essere un parametro. Il gestionale non sa più chi sta chiamando davvero: sa che sta chiamando il pannello, e si fida del numero che il pannello gli passa. Chiunque riesca a parlare con il gestionale usando quelle credenziali può chiedere i dati di chiunque. Hai fatto tutto il lavoro di validazione crittografica del token per poi buttarlo via nell'ultimo salto.

Il pattern corretto è che il pannello inoltra il token dell'utente, e il gestionale ne ricava il soggetto validandolo come descritto nell'articolo precedente. Nessun account di servizio, nessun segreto condiviso, e soprattutto: l'identità di chi sta chiamando non compare da nessuna parte nel corpo della richiesta.

C'è anche un beneficio pratico che non ha a che vedere con la sicurezza e che ha fatto risparmiare molto lavoro. Se il middleware autentica il vero utente per quella richiesta, allora tutte le rotte già esistenti del gestionale, quelle protette dal controllo di ruolo che c'era da anni, diventano riusabili così come sono. Non serve una API parallela per il consumo esterno: la chiamata iniziale prende i permessi, e da lì in poi si usa lo scaffolding che c'era già.

Il middleware, e la riga che ho dovuto correggere

Il cuore è un middleware Laravel di poche righe. Valida il token, risolve il soggetto in un utente locale, e lo autentica per quella singola richiesta:

$token = $request->bearerToken();
if (empty($token)) {
    throw new JsonException('Missing bearer token', Response::HTTP_UNAUTHORIZED);
}

try {
    $claims = app(IamAuthentication::class)->validateAccessToken($token);
} catch (IamAuthenticationException $e) {
    throw new JsonException('Invalid token', Response::HTTP_UNAUTHORIZED);
}

$user = User::where('iam_id', $claims->sub)->active()->orderBy('account_ref')->first();
if ($user === null) {
    throw new JsonException('Principal not found', Response::HTTP_UNAUTHORIZED);
}

// autentica il principal solo per questa richiesta, senza sessione
Auth::onceUsingId($user->id);

L'ultima riga è la chiave dell'intero disegno. Il metodo che autentica una volta sola, documentato fra i metodi di autenticazione manuale di Laravel, stabilisce l'utente corrente per la durata della richiesta senza creare una sessione, senza scrivere cookie, senza toccare nulla che sopravviva alla risposta. È il modo corretto di fare autenticazione stateless in Laravel, ed è ciò che rende il gestionale un resource server vero invece di un'applicazione con un'API attaccata.

L'ordinamento in quella query, però, è la cosa che ho dovuto correggere in revisione, ed è la parte che vale davvero la pena raccontare.

Il collegamento fra identità esterna e utente locale non è sempre uno a uno. Nel sistema reale ho trovato una persona con otto account distinti, tutti legati alla stessa identità sull'identity provider, tutti attivi. La prima versione del codice diceva semplicemente "prendi il primo", senza ordinamento. In quel caso il database restituisce un risultato non deterministico: la stessa persona con lo stesso token poteva risolversi in account diversi in richieste diverse, con conseguenze che vanno dal fastidioso al grave.

La correzione è l'ordinamento esplicito, che rende la scelta ripetibile e, cosa che conta di più, identica a quella che fa il login interattivo. Se i due percorsi scegliessero account diversi, l'utente vedrebbe cose diverse a seconda di come è entrato, ed è un bug che nessun test scritto guardando un solo percorso può trovare.

La lezione generale, che porto con me da questo lavoro: ogni volta che risolvi un'entità con "il primo che trovi", stai scrivendo un bug che si manifesterà quando i dati diventeranno interessanti. Con un solo record il codice è corretto per fortuna, non per costruzione.

Il gap che ho lasciato aperto, e come l'ho reso richiudibile

Risolvere l'identità sul primo account in ordine è una semplificazione, non una soluzione: quella persona con otto account, sotto SSO, ne vede uno solo. Questo è un limite di prodotto reale.

La scelta è stata non risolverlo in questa fase, per una ragione precisa: il percorso di login interattivo aveva la stessa identica limitazione da anni, con un commento nel codice che la dichiarava temporanea. Chiudere il gap solo sul percorso nuovo avrebbe prodotto due comportamenti diversi per la stessa persona a seconda della porta da cui entra, che è peggio di un limite uniforme.

Quello che ho fatto invece è stato rendere la chiusura futura un cambiamento additivo. La risposta dell'autorità di autorizzazione include, accanto al soggetto su cui si sta operando, l'elenco degli account che quella persona possiede:

/**
 * Gli account propri dell'attore (stessa identita' esterna, piu' account). Si
 * opera su uno per volta, ma l'insieme viene esposto perche' lo switch resti
 * un cambiamento additivo: un parametro futuro sceglie fra questi, senza
 * nuova logica di risoluzione.
 *
 * @return int[] account attivi che condividono l'identita' dell'attore
 */
private function availableSubjects(User $actor): array
{
    if (empty($actor->iam_id)) {
        return [(int) $actor->account_ref];
    }

    return User::where('iam_id', $actor->iam_id)->active()
        ->orderBy('account_ref')
        ->pluck('account_ref')
        ->map(fn ($ref) => (int) $ref)
        ->all();
}

Il comportamento di oggi non cambia di una virgola. Ma il giorno in cui si implementa lo scambio di account, il chiamante passerà un parametro che sceglie fra questi valori, senza che serva scrivere nuova logica di risoluzione da nessuna parte, e la stessa lista servirà anche all'interfaccia del gestionale. Il gap si chiude in un punto solo invece che in due.

È un pattern che consiglio ogni volta che si decide consapevolmente di non risolvere un problema: esporre il dato che servirà alla soluzione, così che la decisione rimandata non diventi una decisione irreversibile. Costa dieci righe oggi e vale un rifacimento domani.

Se stai trasformando un gestionale esistente in resource server per altre applicazioni e vuoi evitare di scoprire in produzione i punti in cui il modello di identità non regge, nel mio profilo professionale trovi il percorso su architetture API, sistemi multi-tenant e modernizzazione di applicazioni Laravel in esercizio.

La risposta, e perché riusa una query che esisteva già

L'endpoint che risponde alla domanda "cosa può fare questa persona" è deliberatamente noioso:

$profile = $subject->profile;
$domains = [];
if ($profile !== null) {
    $domains = $profile->domains()->active()
        ->get(['domain', 'extension', 'status'])
        ->map(fn ($d) => [
            'domain'      => $d->domain,
            'extension'   => $d->extension,
            'status'      => $d->status,
            'permissions' => ['owner'],
        ])->all();
}

La cosa importante di questo frammento non è cosa fa, è cosa non fa: non contiene nessuna logica nuova. Quella query è letteralmente la stessa che alimenta la dashboard del cliente nel gestionale, ed è una scelta di design, non una coincidenza.

Il vantaggio si vede quando entra in gioco l'autorizzazione delegata, cioè il caso in cui una persona opera per conto di un'altra. Se il middleware ha autenticato il soggetto effettivo, la funzione che restituisce i domini è la stessa e non sa nemmeno che esiste una delega in corso. Zero rami condizionali, zero possibilità che il percorso delegato diverga da quello normale con il passare del tempo.

È il criterio che uso per giudicare se un'integrazione è progettata bene: quanta logica di business ho dovuto duplicare per servirla? Se la risposta è zero, l'integrazione sta usando il sistema come è fatto. Se è "una funzione simile ma leggermente diversa", hai appena creato due comportamenti che divergeranno, e il momento in cui te ne accorgi è quando un cliente vede qualcosa che non dovrebbe.

Come si testa un'autorità di autorizzazione?

Enumerando i modi in cui deve dire di no, non quelli in cui deve dire di sì. Un endpoint che concede permessi ha un solo comportamento interessante ed è il rifiuto: il caso positivo lo scopri al primo utilizzo, i casi negativi li scopri quando qualcuno li sfrutta.

La suite che copre questa parte è costruita su quel principio, e i casi che verifica sono la mappa dei modi in cui l'autorizzazione può sbagliare. Il caso normale, in cui la persona chiede i propri permessi e li ottiene. Il caso della delega concessa, in cui chi opera per conto di un altro ha titolo e consenso, e ottiene i permessi del soggetto. Il caso della delega senza consenso, che deve essere rifiutato. Il caso di chi tenta di operare per conto di un soggetto che non gli appartiene, rifiutato. Il caso in cui il consenso è stato revocato durante l'operazione, rifiutato. Il caso dell'amministratore, che passa. Il caso del soggetto inesistente, rifiutato con una risposta che non rivela se quel soggetto esista o meno. E i casi di autenticazione: token assente, token non valido, identità sconosciuta al gestionale.

Vale la pena soffermarsi sull'ultimo della lista, perché è il più sottile: un'identità perfettamente valida sull'identity provider ma senza corrispondenza nel gestionale deve essere respinta. Sembra ovvio detto così, e invece è il buco che si apre quando l'identity provider serve più di un'applicazione: una persona registrata per un altro servizio, con un token assolutamente autentico, non deve poter ottenere niente qui. Il fatto che il token sia valido dice che quella persona esiste, non che abbia titolo a parlare con questo sistema.

Accanto ai test che isolano le singole condizioni ce n'è uno che percorre la catena intera senza sostituire il validatore con un finto: chiede il token con lo scope corretto, verifica che l'audience risultante sia quella attesa, lo valida davvero, risolve l'utente e restituisce i permessi. Copre anche il caso della persona con più account, verificando che venga scelto sempre lo stesso. È il test che avrebbe intercettato in un pomeriggio l'errore di risoluzione non deterministica che ho descritto sopra, ed è la ragione per cui adesso esiste.

Cosa succede quando l'autorità di autorizzazione non risponde

Questa architettura introduce una dipendenza che prima non c'era: il pannello ora ha bisogno del gestionale per completare un login. Va detto con chiarezza invece di scoprirlo il giorno in cui succede.

La scelta che ho fatto è fallire chiuso. Se la chiamata che stabilisce i permessi non va a buon fine, la sessione non viene creata e l'utente vede un messaggio comprensibile. Non c'è nessun percorso in cui l'applicazione a valle procede con un insieme di permessi vuoto, o peggio con uno predefinito. La ragione è che un accesso concesso con permessi sbagliati è un incidente di sicurezza, mentre un accesso negato è un disservizio: fra i due, il disservizio è sempre la scelta corretta.

La distinzione che invece serve fare, e che il codice fa, è fra i due tipi di fallimento. Se il gestionale risponde negando l'accesso, la causa è che quella persona non ha titolo, e il messaggio deve dirle di contattare l'assistenza. Se il gestionale non risponde affatto, la causa è tecnica e transitoria, e il messaggio deve invitare a riprovare. Mostrare "non sei autorizzato" a qualcuno che invece lo è, solo perché un servizio era irraggiungibile, genera ticket e sfiducia; mostrare "riprova più tardi" a chi è stato legittimamente rifiutato gli fa perdere tempo. Sono due stringhe diverse e la differenza la fa il codice di stato della risposta.

Sul piano della continuità la conclusione da mettere nel registro dei rischi è questa: il gestionale è diventato un componente critico anche per l'accesso alle altre applicazioni. Se prima un suo fermo bloccava una cosa, adesso ne blocca due. È il prezzo del non aver duplicato i dati, ed è un prezzo che vale la pena pagare, ma va pagato consapevolmente e con la ridondanza dimensionata di conseguenza.

Le protezioni che non si vedono ma servono

Tre dettagli che non compaiono nel disegno architetturale e che ho aggiunto dopo averci pensato.

Il limite di frequenza sulle rotte. Il gruppo di rotte in questione girava sotto una configurazione che non ne aveva nessuno. Su un endpoint che valida firme crittografiche e prende lock sul database, l'assenza di un limite è un invito: sessanta richieste al minuto per client sono più che sufficienti per l'uso legittimo e mettono un tetto a quello patologico.

Il canale di sviluppo chiuso prima di leggere il segreto. Per poter esercitare la federazione senza un identity provider a disposizione esiste un percorso alternativo con un segreto condiviso. La cosa che lo rende accettabile è l'ordine dei controlli:

if (!app()->environment('production')) {
    $devSecret = (string) config('services.sso.dev_secret', '');
    // ...
}

Il controllo sull'ambiente viene prima della lettura del segreto. Non è una sottigliezza stilistica: significa che anche se quel segreto finisse per errore nella configurazione di produzione, il percorso resterebbe irraggiungibile. La regola che ne ricavo è generale: un canale di sviluppo si chiude sull'ambiente, non sulla presenza della credenziale, perché le credenziali migrano fra ambienti per errore molto più spesso di quanto si creda.

Il codice di risposta giusto per la ragione giusta. Un token assente, scaduto o non valido è un problema di autenticazione. Un utente valido che non ha diritto a un soggetto è un problema di autorizzazione. Tenere separate le due risposte non è pedanteria: il client deve sapere se ha senso ritentare dopo un rinnovo del token oppure se il rifiuto è definitivo, e con un codice unico non può distinguerlo.

Il costo nascosto di questo pattern: una chiamata in più al login

Sarebbe disonesto presentare questa architettura come se non costasse niente, quindi metto per iscritto la voce di costo.

Ogni login su un'applicazione federata comporta una chiamata di rete in più, verso il gestionale, prima che la sessione sia utilizzabile. Nell'ordine dei millisecondi su una rete interna, ma non è zero, e soprattutto è sincrona e bloccante: l'utente aspetta.

La domanda che segue naturalmente è se convenga mettere quei permessi in cache, e la risposta merita di essere motivata perché è controintuitiva. Per il caso normale sì, con cautela; per il caso della delega no, mai. I domini che una persona possiede cambiano raramente, quindi una cache di durata breve è difendibile. Il consenso a operare per conto di un altro, invece, è revocabile in qualunque momento e per ragioni serie: se lo metti in cache, hai costruito una finestra in cui una revoca non ha effetto, ed è precisamente la classe di problema che questa architettura esisteva per evitare.

Nella pratica ho scelto nessuna cache, per una ragione che vale la pena esplicitare: la chiamata avviene una volta per sessione, non una volta per richiesta. Ottimizzare un'operazione che accade all'ingresso e mai più significa spendere complessità dove non serve. La cache diventerebbe sensata se quella chiamata avvenisse su ogni operazione, e in quel caso il posto giusto dove metterla non è il risultato dei permessi ma la validazione del token, che è già locale proprio per questo motivo.

Un secondo costo, meno visibile e più insidioso, è l'accoppiamento fra i cicli di rilascio. Il contratto della risposta è ora un'interfaccia pubblica fra due applicazioni con repository diversi: cambiare il nome di un campo diventa un rilascio coordinato. È il tipo di attrito che si gestisce solo con una regola dichiarata, e la mia è la solita delle interfacce pubbliche: si aggiungono campi, non se ne rimuovono e non se ne rinominano, e le rimozioni si fanno in due tempi con una versione in cui convivono.

Cosa ne guadagna chi non scrive il codice

La sintesi per chi la scelta la valuta, perché in questa architettura c'è un vantaggio di governo che vale quanto quello tecnico.

I permessi restano dove qualcuno li amministra già. Chi sospende un cliente lo fa nel gestionale, come ha sempre fatto, e l'effetto è immediato su tutte le applicazioni federate. Nessuno deve ricordarsi di aggiornare anche l'identity provider, e non esiste la finestra in cui i due sistemi dicono cose diverse.

Il perimetro di chi tocca l'identity provider resta piccolo. Se i permessi commerciali fossero ruoli sull'IdP, chi gestisce i clienti avrebbe bisogno di accedervi. Tenendoli nel gestionale, l'accesso all'infrastruttura di identità resta a chi la amministra, che è quello che vuoi quando qualcuno ti chiede chi può modificare cosa.

La tracciabilità è per persona, non per sistema. Ogni chiamata porta l'identità verificata di un essere umano, e i registri lo riflettono. È la differenza fra poter rispondere e non poter rispondere alla domanda su chi ha fatto una certa operazione, che è esattamente la domanda che arriva quando qualcosa va storto.

Resta un pezzo scoperto, ed è quello dove si annidano i problemi seri: finché l'autorizzazione riguarda chi sei, il modello regge; quando riguarda per conto di chi stai agendo, il permesso diventa uno stato revocabile, e un token che se lo porta dentro diventa un permesso che non si riesce più a togliere. È il tema di un articolo successivo di questa serie. Prima però c'è da chiudere un'altra questione che questa architettura apre e che è più urgente di quanto sembri: se l'applicazione a valle non usa più le credenziali dell'utente per accedere ai dati, il recinto che quelle credenziali garantivano è sparito, e va ricostruito altrove. Se stai portando un gestionale in questa direzione e vuoi che qualcuno riveda il disegno prima che diventi codice, scrivimi pure: in questa fase un'ora di confronto vale settimane di correzioni.

Ultima modifica: