Symfony Security Component e RBAC: controllo accessi per applicazioni multi-tenant

Symfony Security Component e RBAC: controllo accessi per applicazioni multi-tenant

In un'applicazione multi-tenant, dove più clienti vivono sulla stessa istanza e spesso sullo stesso database, il controllo degli accessi non è una funzionalità tra le tante: è il punto in cui si gioca la sicurezza dei dati di ogni cliente. Il peccato capitale di questo tipo di architettura ha un nome preciso, cross-tenant data leak: il cliente A che, per un errore di autorizzazione, vede i dati del cliente B. Non è un bug come gli altri, è una violazione che incrina la fiducia su cui poggia l'intero servizio, e che in un contesto regolato è anche un data breach da notificare. Su una piattaforma SaaS che serve molti clienti business ho passato più tempo a blindare l'isolamento tra tenant che a costruire le funzionalità, ed è tempo speso bene. Il punto da capire subito è che il controllo accessi basato sui ruoli, l'RBAC, è necessario ma non sufficiente: puoi avere i ruoli perfetti e perdere comunque dati tra i tenant, perché i ruoli e l'isolamento sono due assi diversi del problema. Vediamo come implementare entrambi con il Security Component di Symfony 7: ruoli e gerarchie, voter per le decisioni fini, e soprattutto l'isolamento rigoroso dei dati, con gli errori che portano alla fuga di dati tra clienti e come prevenirli.

RBAC e isolamento tenant sono due domande diverse

La confusione da sciogliere, e che è all'origine della maggior parte delle fughe di dati cross-tenant, è tra due domande che sembrano la stessa e non lo sono. L'RBAC risponde alla domanda "cosa può fare questo utente?": può creare ordini, può approvare fatture, può accedere al pannello di amministrazione. L'isolamento tenant risponde a un'altra domanda: "di chi sono i dati che questo utente può vedere?". Un utente con il ruolo di amministratore del tenant A deve poter fare tutto dentro il tenant A, e niente nel tenant B, anche se ha esattamente lo stesso ruolo che un altro amministratore ha nel tenant B.

Ecco perché il ruolo da solo non basta: un controllo che verifica "questo utente è un manager?" risponde correttamente sì, ma non dice nulla su quale tenant quel manager possa toccare. Se l'autorizzazione si ferma al ruolo, un manager del tenant A che chiama l'endpoint per l'ordine numero 123 del tenant B passa il controllo di ruolo, perché è davvero un manager, e accede a un dato che non è suo. L'isolamento tenant è una dimensione ortogonale al ruolo, e va presidiata a parte, con strumenti propri.

Il ruolo risponde a "cosa puoi fare", il tenant a "su cosa puoi farlo". Sono due chiavi diverse della stessa porta: avere quella giusta del tipo sbagliato non apre nulla, e fidarsi di una sola lascia la porta socchiusa per chiunque abbia un identificatore da incrementare.

I ruoli e la gerarchia: la base, non la soluzione

Partiamo comunque dalla base, perché va fatta bene. Symfony gestisce i ruoli in modo solido: ogni utente ha un insieme di ruoli, e la gerarchia dei ruoli permette di esprimere che un ruolo ne include altri, così un ROLE_ADMIN eredita automaticamente i permessi di ROLE_MANAGER senza doverli elencare.

# config/packages/security.yaml
role_hierarchy:
    ROLE_MANAGER: [ROLE_USER]
    ROLE_ADMIN: [ROLE_MANAGER, ROLE_USER]

Questo definisce cosa ciascun livello può fare, e si applica con i controlli di accesso sulle rotte o con is_granted() nel codice. È la struttura giusta per le capacità, ed è dove molti si fermano, convinti di aver fatto il controllo accessi. In un'applicazione single-tenant basterebbe; in una multi-tenant è solo metà del lavoro, perché manca completamente la dimensione del "di chi". I ruoli sanno cosa puoi fare, non sanno su quali dati.

Se gestisci una piattaforma SaaS multi-tenant e vuoi la certezza che l'isolamento tra i clienti regga a un tentativo deliberato di aggirarlo, nel mio profilo professionale trovi l'esperienza concreta su architetture multi-tenant e controllo accessi per applicazioni che servono molti clienti su una sola istanza.

I voter: dove vivono le decisioni di proprietà

Per portare la dimensione del tenant dentro l'autorizzazione, lo strumento di Symfony sono i voter. Un voter è una classe che decide se un utente può compiere una certa azione su uno specifico oggetto, ed è il posto giusto dove esprimere la regola "questa risorsa appartiene al tenant dell'utente?". Invece di controllare solo il ruolo, si chiede al voter se l'utente può vedere o modificare quella entità.

class OrderVoter extends Voter
{
    protected function supports(string $attribute, mixed $subject): bool
    {
        return in_array($attribute, ['VIEW', 'EDIT']) && $subject instanceof Order;
    }

    protected function voteOnAttribute(string $attribute, mixed $order, TokenInterface $token): bool
    {
        $user = $token->getUser();
        // la regola che isola i tenant: l'ordine deve appartenere al tenant dell'utente
        if ($order->getTenantId() !== $user->getTenantId()) {
            return false;
        }
        return match ($attribute) {
            'VIEW' => $user->hasRole('ROLE_USER'),
            'EDIT' => $user->hasRole('ROLE_MANAGER'),
            default => false,
        };
    }
}

Nel controller, invece di controllare il solo ruolo, si chiama denyAccessUnlessGranted('VIEW', $order), e il voter unisce le due dimensioni: il ruolo (cosa puoi fare) e la proprietà (è roba del tuo tenant). Questo è il modo idiomatico di Symfony per le decisioni di autorizzazione fini, ed è la stessa logica che ho descritto parlando del componente Security e dei custom voter. Il voter è il primo presidio dell'isolamento, ma non l'ultimo, perché si applica solo dove ti ricordi di chiamarlo.

L'isolamento a livello di query: la difesa che non puoi dimenticare

Qui sta la difesa più importante e più trascurata. Il voter protegge i punti in cui lo invochi esplicitamente, ma in un'applicazione reale ci sono decine di query, e basta dimenticare una sola condizione WHERE tenant_id = :tenant perché una lista mostri i dati di tutti i tenant insieme. Affidare l'isolamento alla disciplina di ricordarsi un filtro in ogni query è una garanzia che prima o poi fallisce, perché gli esseri umani dimenticano.

La risposta strutturale, in Doctrine, è il filtro SQL (SQLFilter): un filtro che si applica automaticamente a ogni query su una certa entità, aggiungendo la condizione sul tenant senza che lo sviluppatore debba ricordarsene. Lo configuri una volta, lo attivi con il tenant dell'utente corrente all'inizio della richiesta, e da quel momento ogni SELECT su quelle entità è automaticamente confinata al tenant giusto.

class TenantFilter extends SQLFilter
{
    public function addFilterConstraint(ClassMetadata $targetEntity, string $alias): string
    {
        if (! $targetEntity->hasField('tenantId')) {
            return '';
        }
        return sprintf('%s.tenant_id = %s', $alias, $this->getParameter('tenant_id'));
    }
}

Questo trasforma l'isolamento da una cosa che devi ricordarti a una cosa che avviene di default, ed è il modo corretto di proteggere i dati su un database condiviso. È difesa in profondità applicata alle query, e si appoggia alla stessa padronanza del livello dati che ho approfondito parlando di Doctrine ORM avanzato e query builder.

L'errore che porta alla fuga di dati: l'IDOR

Vale la pena nominare esplicitamente il difetto, perché ha un nome e una categoria precisa: l'Insecure Direct Object Reference, l'IDOR (oggi spesso classificato come Broken Object Level Authorization). Si manifesta così: un endpoint accetta l'identificatore di una risorsa, per esempio GET /api/orders/123, e la restituisce senza verificare che la risorsa 123 appartenga al tenant di chi la chiede. Un utente del tenant A cambia il numero nell'URL, prova 124, 125, e si ritrova a sfogliare gli ordini di altri tenant. È banale da sfruttare e devastante negli effetti, ed è una delle voci più costanti della OWASP Top 10, come ho ricordato parlando dei cambiamenti della OWASP Top 10 e delle difese per le applicazioni PHP.

La difesa contro l'IDOR è precisamente la combinazione che stiamo costruendo: non fidarsi mai dell'identificatore da solo, ma verificare sempre la proprietà tramite il voter, e avere sotto il filtro di query che, anche se il controllo dimenticasse un caso, non restituirebbe comunque il dato di un altro tenant. L'IDOR nasce dalla fiducia in un identificatore controllato dall'utente; muore quando ogni accesso è confinato al tenant per costruzione.

Difesa in profondità: voter e filtro insieme, mai uno solo

Il principio che lega tutto è la ridondanza intenzionale. Il voter e il filtro di query proteggono lo stesso bene, l'isolamento dei dati, ma in punti diversi e con modalità diverse, e il loro valore è proprio nel sovrapporsi. Il voter applica l'autorizzazione dove la chiami, in modo esplicito e leggibile; il filtro di query garantisce che, anche dove un controllo manca o è sbagliato, il database non restituisca dati di altri tenant. Se uno dei due fallisce, l'altro tiene. Affidarsi a uno solo significa avere un single point of failure su un dato che non può sfuggire.

Questo è il fail closed applicato alla multi-tenancy: in caso di dubbio o di dimenticanza, il sistema non mostra il dato, non lo mostra per errore. La differenza tra una piattaforma SaaS che si può fidare e una che aspetta solo il suo incidente sta in questa ridondanza: non un controllo perfetto su cui scommettere, ma due controlli che si coprono a vicenda.

I tre modelli di isolamento, e quanto lavoro ti risparmiano

Tutto quanto detto finora assume il modello più comune e più insidioso, il database condiviso con una colonna tenant_id su ogni tabella: tutti i tenant nelle stesse tabelle, distinti solo da un campo. È il modello più economico in infrastruttura e il più pericoloso in sicurezza, perché l'isolamento è interamente a carico dell'applicazione: una query sbagliata mescola i dati. È qui che voter e filtri di Doctrine diventano vitali.

Esistono però altri due modelli, con un diverso bilanciamento. Il database per tenant dà l'isolamento più forte, perché ogni cliente ha il proprio database e una query non può fisicamente raggiungere i dati di un altro: il costo è la complessità di gestire, migrare e fare il backup di molti database, ma per dati altamente sensibili o per requisiti di sovranità è spesso la scelta giusta. In mezzo c'è lo schema per tenant (su database che lo supportano, come PostgreSQL), un compromesso che separa logicamente i dati pur tenendoli su un'istanza sola.

La scelta del modello non è un dettaglio: cambia la natura del rischio. Con il database separato, una dimenticanza nel codice non causa una fuga cross-tenant, perché la barriera è infrastrutturale; con il database condiviso, ogni riga di codice che tocca i dati è un punto in cui l'isolamento può rompersi. La mia regola è far dipendere il modello dalla sensibilità dei dati e dai requisiti di compliance: per un SaaS generalista il database condiviso ben difeso è ragionevole, per dati sanitari o finanziari di clienti diversi il database per tenant ripaga la sua complessità con un isolamento che non dipende dalla perfezione del codice.

Basta il ruolo per isolare i tenant?

No, e ripeterlo è il punto centrale di tutto l'articolo. Il ruolo dice cosa un utente può fare, non di chi sono i dati su cui può farlo. Un sistema che isola i tenant solo con i ruoli ha esattamente zero isolamento, perché due utenti con lo stesso ruolo in due tenant diversi superano gli stessi controlli. L'isolamento richiede una dimensione propria: la verifica di proprietà a livello di oggetto con i voter, e il confinamento automatico a livello di query con i filtri, sotto a tutto il ruolo che governa le capacità. È l'insieme a proteggere, e ciascun pezzo da solo lascia una porta aperta.

Tirando le somme, costruire il controllo accessi di un'applicazione multi-tenant in Symfony significa tenere ben distinti due assi che la fretta tende a confondere: cosa un utente può fare, governato dai ruoli e dalla loro gerarchia, e di chi sono i dati che può vedere, governato dai voter e dai filtri di query. L'RBAC è la base, ma da solo non isola nessuno; l'isolamento vero nasce quando la proprietà del dato entra nell'autorizzazione tramite i voter e, sotto, diventa una condizione automatica su ogni query tramite i filtri di Doctrine, così che la difesa non dipenda dalla memoria di chi scrive la prossima query. L'IDOR, la fuga di dati cross-tenant, non è un attacco sofisticato: è ciò che succede quando ci si ferma al ruolo e si dimentica la proprietà. Difenderlo è difesa in profondità ordinaria, fatta con disciplina. Se gestisci una piattaforma multi-tenant e vuoi la certezza che un cliente non possa, nemmeno per errore, vedere i dati di un altro, contattami per una consulenza diretta: nella mia esperienza, l'isolamento tra tenant è il controllo che merita più paranoia di tutti, perché è l'unico il cui fallimento non si misura in un bug da correggere ma in fiducia perduta e, sempre più spesso, in un data breach da notificare alle autorità.

Ultima modifica: