Da ACL di directory a filtraggio applicativo: SSO con Zitadel senza perdere il recinto
Il momento più pericoloso di una migrazione verso il single sign-on non produce nessun errore. Anzi: tutto comincia a funzionare meglio. L'utente entra senza digitare una password, la sessione si stabilisce, le pagine si aprono. E in quello stesso istante, se l'applicazione usava le credenziali dell'utente anche per accedere al backend dati, il controllo di accesso è appena sparito senza che una riga di codice lo dichiari.
È esattamente quello che succede quando un pannello che si autenticava contro una directory passa a un identity provider. Prima l'applicazione riceveva la password, si connetteva alla directory come quell'utente, e la directory applicava le proprie regole di accesso: se l'utente chiedeva un ramo che non gli apparteneva, era la directory stessa a negare. L'applicazione poteva anche avere un bug nel filtro, e il dato non usciva lo stesso, perché c'era un secondo guardiano che non dipendeva dal codice applicativo.
Sotto SSO quella password non esiste più. L'applicazione deve connettersi al backend dati con un account di servizio, che per definizione vede tutto. Il secondo guardiano è scomparso, e con lui l'unica difesa che funzionava anche quando il codice sbagliava.
Nei precedenti articoli di questa serie ho descritto il modello di progetti e audience, la validazione del token come credenziale e l'autorità di autorizzazione che dice quali risorse una persona può gestire. Qui affronto la conseguenza scomoda di tutto quel lavoro: avere l'elenco delle risorse concesse non serve a niente se poi l'accesso ai dati non lo rispetta.
Perché il passaggio a un bind di servizio è una regressione di sicurezza?
Perché sostituisce un controllo che vive sotto l'applicazione con uno che vive dentro l'applicazione, e i due non hanno la stessa affidabilità.
Con il bind per utente, il modello di autorizzazione è dichiarativo e centralizzato: sta nella configurazione della directory, lo si legge in un posto, e vale per qualunque client si connetta, incluso uno script scritto di fretta. Con il bind di servizio, il modello di autorizzazione diventa la somma di tutti i punti del codice che ricordano di controllare. Basta una funzione che dimentica, e il recinto ha un varco che non appare da nessuna parte.
La differenza in una frase, ed è il modo in cui l'ho spiegata a chi doveva approvare il lavoro: prima il sistema era sicuro perché la directory diceva di no; dopo, il sistema è sicuro solo se il codice si ricorda di chiedere. Non è la stessa cosa, ed è per questo che il lavoro descritto qui è servito.
Va detto anche perché non si può evitare. Si potrebbe conservare una credenziale per tenant e continuare a bindare in modo distinto, ma significherebbe custodire password che l'identity provider ha proprio l'obiettivo di eliminare, e riportare dentro l'applicazione il segreto che il single sign-on aveva tolto. È una via che porta indietro, non avanti.
Il primo livello: il controllo dichiarativo sulla rotta
La prima difesa è la più leggibile: ogni rotta che tocca il backend dati dichiara dove si trova, nella richiesta, la risorsa su cui sta per operare. Un piccolo componente la estrae e verifica che sia nell'insieme concesso, prima ancora che il controllore parta.
const tenantOf = v => (v ? String(v).split(',')[0].split('@')[1] : undefined);
const extractors = {
'param:tenant': req => req.params && req.params.tenant,
'param:base@': req => (req.params ? tenantOf(req.params.base) : undefined),
'body:tenant': req => req.body && req.body.tenant,
'body:mail@': req => (req.body ? tenantOf(req.body.mail) : undefined),
'body:dn': req => { /* estrae il tenant dal DN target */ },
};
export default function ssoScope(...keys) {
return (req, res, next) => {
if (!isSso(req)) return next(); // in legacy le ACL restano il recinto
try {
let tenant;
for (const key of keys) {
tenant = extractors[key](req);
if (tenant) break;
}
assertTenantAllowed(req, tenant); // fail-closed: se non risolve, nega
return next();
} catch (err) {
return next(err);
}
};
}Sulle rotte diventa una dichiarazione che si legge a colpo d'occhio:
app.get('/api/mailboxes/:tenant?', checkAuth, ssoScope('param:tenant'), listMailboxes);
app.post('/api/mailbox', checkAuth, ssoScope('body:mail@', 'body:tenant'), createMailbox);Due proprietà meritano di essere spiegate. La prima è che in modalità legacy questo controllo è trasparente: se l'applicazione gira ancora con il bind per utente, il componente non fa nulla e le regole della directory restano l'unico recinto, esattamente come prima. È ciò che consente di introdurre il layer senza rompere le installazioni che il single sign-on non ce l'hanno.
La seconda è che il fallimento è chiuso per costruzione. Se nessuna delle chiavi dichiarate risolve un valore, la funzione di controllo riceve un valore vuoto e nega. Non c'è nessun ramo che, non trovando la risorsa, lascia passare per prudenza. È una scelta che ha un costo, cioè qualche 403 in fase di sviluppo quando una rotta dichiara la chiave sbagliata, ed è precisamente il costo che vuoi pagare.
Il secondo livello, e perché il primo non basta
Il controllo sulla rotta ha un difetto strutturale: è volontario. Funziona finché chi aggiunge una rotta si ricorda di dichiararlo. Su una base di codice scritta da più persone in anni diversi, "si ricorda" non è una proprietà su cui costruire la sicurezza di un sistema multi-tenant.
Serviva quindi un secondo livello che non si potesse dimenticare, e la risposta è stata spostare il controllo dentro le primitive di accesso ai dati, cioè nelle sette funzioni attraverso cui passa ogni lettura e ogni scrittura verso la directory. Il problema da risolvere era che quelle funzioni ricevono un DN e non hanno accesso alla richiesta HTTP: non sanno chi sia l'utente né cosa gli sia stato concesso.
La soluzione è un contenitore associato alla richiesta, seminato una volta all'ingresso e leggibile da qualunque profondità dello stack senza passare parametri. In Node questo è il compito di AsyncLocalStorage, che mantiene il contesto attraverso le chiamate asincrone senza dover riscrivere le firme di mezza applicazione:
import { AsyncLocalStorage } from 'async_hooks';
export const als = new AsyncLocalStorage();
// seminato una volta per richiesta dal middleware di autenticazione
export function runWithScope(mode, grantedTenants, next) {
const store = {
mode,
grantedTenants: Array.isArray(grantedTenants)
? grantedTenants.map(t => String(t).toLowerCase())
: [],
};
return als.run(store, next);
}
// chiamato da OGNI primitiva di accesso ai dati
export function assertDnAllowed(dn) {
const store = als.getStore();
if (!store || store.mode !== 'sso') return; // legacy e percorsi a token: non applicabile
const tenant = tenantFromDn(dn);
if (!tenant || !store.grantedTenants.includes(tenant)) {
const err = new Error('tenant_not_granted');
err.status = 403;
throw err;
}
}E in cima a ciascuna delle sette primitive, una riga sola:
export const searchDN = async (base, filter, client = ClientRef) => {
assertDnAllowed(base);
// ...
};
export const addEntry = async (dn, entry, client = ClientRef) => {
assertDnAllowed(dn);
// ...
};La proprietà che rende questo livello diverso dal primo è che controlla il DN reale su cui l'operazione sta per agire, non un parametro della richiesta. Fra il parametro e il DN c'è tutto il codice del controllore, che potrebbe trasformarlo, comporlo con altri valori o prenderlo da un posto diverso. Il secondo livello guarda il fatto compiuto.
Un dettaglio di robustezza che vale la pena rubare: l'estrazione del tenant dal DN è scritta per essere indipendente dalla radice della directory. Ambienti diversi hanno basi diverse, e un controllo che confronta stringhe complete funziona in produzione e fallisce in collaudo, o peggio il contrario. Riconoscere l'attributo che identifica il tenant ovunque si trovi nel DN rende il guardiano portabile, e un guardiano che funziona solo in un ambiente è un guardiano che verrà disattivato in collaudo per far girare i test.
La regola che ne ricavo, valida ben oltre questo caso: un controllo di sicurezza dichiarativo è documentazione, un controllo nel percorso dei dati è una garanzia. Servono entrambi, ma se puoi averne uno solo, tieni il secondo.
Il buco che l'audit ha trovato
La parte utile di questa storia non è il disegno, è cosa ha trovato la verifica sistematica delle rotte. Perché ne ha trovato uno vero.
C'era una rotta che dichiarava di prendere il tenant da un certo campo del corpo della richiesta, mentre il controllore, per quella specifica operazione, scriveva su un campo diverso. I due coincidevano in tutti gli usi normali dell'interfaccia, quindi nessuno se ne era mai accorto. Ma erano indipendenti: costruendo la richiesta a mano si poteva mettere nel primo campo un tenant legittimamente concesso, superare il controllo, e far scrivere il controllore su un tenant di un altro cliente.
L'operazione in questione era la creazione di una regola di raccolta generica sulla posta, cioè la cosa che intercetta tutti i messaggi che non hanno un destinatario preciso. Detto senza tecnicismi: la possibilità di far arrivare a sé la posta indirizzata a un altro cliente. Non ci sono molte vulnerabilità peggiori in un sistema di posta.
Tre osservazioni, e sono la ragione per cui ho scelto di raccontarlo.
Il primo livello, da solo, non l'avrebbe fermata. Il controllo dichiarativo c'era ed era stato scritto in buona fede: semplicemente guardava il campo sbagliato. Un controllo che si applica al parametro giusto ma non a quello che l'operazione userà davvero è un controllo che dà una falsa sicurezza.
Il secondo livello l'ha neutralizzata prima ancora che la trovassimo. Il guardiano nel percorso dei dati vede il DN finale, quindi l'operazione sarebbe stata negata comunque. È la dimostrazione pratica del perché due livelli non sono ridondanza inutile: il primo è veloce e leggibile, il secondo è quello che vale.
È stata trovata da una verifica sistematica, non da un'intuizione. Nessuno l'avrebbe notata leggendo il codice, perché il codice sembrava giusto. È emersa elencando meccanicamente ogni rotta e chiedendosi, per ciascuna, quale risorsa tocca davvero.
Se stai portando un'applicazione multi-tenant da un modello di accesso basato sulle credenziali dell'utente a uno federato, e vuoi che qualcuno faccia questa verifica prima che la faccia qualcun altro, nel mio profilo professionale trovi il percorso su sicurezza applicativa, penetration testing e architetture multi-tenant.
Come si enumera davvero la superficie da proteggere?
Partendo dai dati e non dalle rotte, che è l'esatto contrario di come lo fanno quasi tutti. L'istinto è aprire l'elenco delle rotte e scorrerlo: è rassicurante, si esaurisce in un pomeriggio, e ha un difetto fatale, cioè che trova solo le porte di cui conosci l'esistenza. Le rotte che qualcuno ha montato in un file diverso, quelle esposte da una libreria, quelle registrate dinamicamente, non compaiono.
Il metodo che uso, e che ha prodotto il risultato descritto sopra, procede in quattro passaggi in ordine inverso.
Si parte dalle primitive di accesso ai dati. Quante sono le funzioni attraverso cui, in tutta l'applicazione, si legge o si scrive sul backend? Se la risposta è "dipende", il problema da risolvere non è l'autorizzazione ma l'assenza di uno strato di accesso ai dati, e va risolto prima. Nel caso concreto erano sette, ed è un numero gestibile proprio perché qualcuno, anni prima, aveva avuto la disciplina di non sparpagliare le chiamate.
Si risale a chi le chiama. Ogni controllore che importa quelle primitive è, per definizione, un punto in cui il tenant può essere violato. Questa è la lista vera, e si ottiene meccanicamente cercando chi importa cosa, non leggendo il codice.
Si incrocia con le rotte montate. Adesso, e solo adesso, l'elenco delle rotte serve: per ciascun controllore che tocca i dati, si verifica quale rotta lo espone e con quale protezione. Le eventuali discrepanze fra i due elenchi sono la parte interessante: un controllore che tocca i dati e non compare in nessuna rotta è codice morto o, peggio, è raggiungibile per una via che non hai considerato.
Per ogni punto ci si chiede quale risorsa tocca davvero. È la domanda che ha scoperto il difetto: non "questa rotta è protetta", ma "il valore che il controllo guarda è lo stesso che l'operazione userà?". Sono due domande diverse e solo la seconda trova le discrepanze fra un parametro validato e un parametro usato.
Questo modo di procedere è la trasposizione pratica di quanto OWASP scrive sul controllo degli accessi non funzionante, che resta la voce in cima alla classifica dei rischi applicativi: i difetti di questa categoria non si trovano con gli scanner, perché richiedono di sapere cosa il sistema dovrebbe permettere, e quello lo sa solo chi conosce il dominio.
Le operazioni che un tenant non ce l'hanno
Ogni schema di isolamento incontra prima o poi le operazioni che non ricadono nello schema, e il modo in cui le si tratta è ciò che distingue un modello coerente da uno pieno di eccezioni. Nel caso concreto sono emerse tre categorie e ognuna ha richiesto una decisione esplicita.
Le operazioni amministrative trasversali, come la creazione di un nuovo tenant o la gestione di liste di blocco globali. Per definizione non hanno un tenant da verificare, perché o riguardano tutti o riguardano qualcosa che ancora non esiste. Sono protette da un controllo di ruolo, e la garanzia strutturale che le rende sicure è che sotto single sign-on nessun utente ordinario può ottenere quel ruolo: il ruolo di sessione viene impostato dal server in base alla risposta dell'autorità di autorizzazione, e non è un valore che il client possa influenzare.
Le operazioni a token, come la reimpostazione di una password tramite un collegamento ricevuto per posta. Non hanno una sessione e quindi non hanno un contesto di tenant. Restano fuori dallo schema, e ciò che le protegge è la non falsificabilità del token e il fatto che l'associazione fra il token e la risorsa la stabilisce il server, non chi clicca.
Le operazioni che qualcuno pensava avessero un tenant e non ce l'hanno. Questa è la categoria interessante, ed è quella in cui è emerso un secondo problema, funzionale e non di sicurezza: due operazioni sulla configurazione dei record di firma della posta usavano ancora le credenziali dell'utente, che sotto SSO non esistono più. Il risultato è che fallivano. Fallivano chiuse, quindi non era un buco, ed è precisamente la conferma che l'impostazione era giusta: quando qualcosa in questa architettura non è stato aggiornato, smette di funzionare invece di funzionare senza controlli.
La domanda da porsi su ogni eccezione, e che consiglio di scrivere accanto a essa nel codice: se questo controllo saltasse, cosa potrebbe fare un utente malintenzionato? Se la risposta è "niente, perché è protetto da altro", l'eccezione è motivata. Se la risposta è "non lo so", non è un'eccezione, è un buco in attesa di essere trovato.
Il test che impedisce alla protezione di erodersi
Un audit fatto una volta ha una vita utile di poche settimane: la prossima rotta la scriverà qualcuno che non ha letto questo articolo. La contromisura è un controllo automatico che fallisce se compare una rotta non protetta.
È un test che legge il file delle rotte come testo e verifica una proprietà strutturale: ogni rotta il cui controllore accede alla directory deve dichiarare il controllo di tenant, oppure essere in una lista di eccezioni esplicite. Le eccezioni ci sono e sono motivate una per una: il login legacy che binda come utente e ha quindi le regole della directory, il percorso di reimpostazione password che è protetto da un token non falsificabile e la cui associazione fra indirizzo e DN non è scelta dall'utente, e le rotte di controllo della sessione che non toccano dati di un tenant.
const gated = args.some(a => a.startsWith('ssoScope(') || a === 'checkMinAdmin');
if (!gated && !EXEMPT.has(handler)) offenders.push(`${method} ${path} -> ${handler}`);La parte che ne fa un buon test non è l'analisi del testo, è la lista delle eccezioni. Obbliga chi aggiunge una rotta senza protezione a scriverne il nome in un elenco, e scrivere il proprio nome in un elenco di eccezioni di sicurezza è un attrito sufficiente a far riflettere. È lo stesso principio per cui funziona una lista di operazioni classificate come permesse o negate: non impedisce di sbagliare, ma impedisce di sbagliare per distrazione, che è come si sbaglia quasi sempre.
Accanto a questo c'è una verifica a tempo di esecuzione del guardiano stesso: semina un contesto con un insieme di tenant concessi, prova DN che appartengono a quell'insieme e DN che non ci appartengono, e controlla che passi solo la prima categoria. È l'unica prova che il guardiano funzioni davvero e non solo che sia stato chiamato.
Cosa cambia per chi valuta il rischio
La sintesi per chi deve decidere se questo lavoro vale il tempo che costa.
Il rischio non è aumentato, si è spostato. Prima era in una configurazione esterna, oggi è nel codice. Non è di per sé peggio, ma richiede una cosa che prima non serviva: un modo di dimostrare che il controllo c'è ovunque. Le due verifiche automatiche descritte sopra sono quella dimostrazione, e sono l'unica cosa che si può mostrare a chi chiede evidenza.
L'account di servizio è la nuova concentrazione di privilegio. Le sue credenziali vedono tutti i tenant. Vanno trattate come si tratta una credenziale amministrativa: non nel codice, non nei log, ruotabili, e con l'accesso al file limitato all'utente del servizio. Vale la pena aggiungere che il privilegio minimo si applica anche qui: se le operazioni dell'applicazione non richiedono di modificare la struttura della directory ma solo i rami dei tenant, l'account di servizio non deve poterlo fare.
Il costo di questo lavoro è finito, il beneficio è ricorrente. L'audit iniziale e i due meccanismi sono stati alcuni giorni di lavoro, una volta. Le due verifiche automatiche costano secondi a ogni esecuzione della suite e continueranno a fermare gli errori di distrazione per tutta la vita del progetto, incluse quelle di chi arriverà dopo. È il tipo di investimento che si valuta male perché il beneficio non è misurabile: nessuno saprà mai quanti incidenti non sono accaduti.
Il modello di isolamento va scritto da qualche parte. Prima era leggibile nella configurazione della directory; adesso è distribuito in due meccanismi e in una lista di eccezioni. Se non c'è un documento che lo spiega, la persona che arriverà fra un anno lo ricostruirà per tentativi, e nel farlo lo indebolirà.
C'è infine una domanda che consiglio di porre a chiunque proponga una migrazione di questo tipo, e che vale più di qualunque revisione del codice: come facciamo a sapere che il recinto è completo? Se la risposta è "abbiamo controllato", non è sufficiente, perché il controllo vale per la versione di oggi. Se la risposta è "c'è un test che fallisce quando qualcuno aggiunge una rotta non protetta", allora il sistema ha una proprietà che si mantiene da sola, ed è l'unica forma di sicurezza che sopravvive al turnover delle persone.
Un'ultima osservazione che riguarda l'ordine delle cose. Questo lavoro andava fatto prima di attivare il single sign-on, non dopo, perché la finestra fra "l'applicazione non ha più la password" e "il recinto applicativo è completo" è una finestra in cui il sistema è multi-tenant senza isolamento. Nel mio caso è stata breve e su un ambiente non pubblico, ed è stata una scelta e non una fortuna: l'ordine dei lavori è una decisione di progetto come le altre, e su questo tipo di migrazione è la più importante che si prenda. Se stai pianificando la stessa migrazione, mettila in sequenza nel piano: prima il recinto, poi la comodità.
Resta un pezzo di questa architettura che non ho ancora affrontato e che è quello dove si nascondono i problemi più sottili: cosa succede quando l'autorizzazione non riguarda più chi sei, ma per conto di chi stai agendo, e quel permesso è revocabile mentre lo stai usando. Se nel frattempo hai un'applicazione che è passata al bind di servizio e non sei sicuro che il recinto sia completo, scrivimi e la guardiamo insieme: elencare le rotte e chiedersi quale risorsa tocca ciascuna è un lavoro di mezza giornata che ho visto restituire risultati sgradevoli più di una volta.