Implementazione di Rotte Multi-tenant con Prefissi Dinamici e Constraints

Avanzato
PHP

Guida avanzata all'architettura multi-tenant: come gestire l'isolamento dei domini e dei dati tramite routing dinamico e vincoli di validazione.

Pubblicato
Tag
laravel routing Web Security Software Architecture Multi-tenancy SaaS

Introduzione all'Architettura Multi-tenant

Nel panorama moderno dello sviluppo software as a service (SaaS), l'architettura multi-tenant rappresenta lo standard per scalare applicazioni che servono molteplici clienti (tenant) all'interno di un'unica istanza software. A differenza di un'installazione single-tenant, dove ogni cliente possiede il proprio database e l'own stack applicativo, il multi-tenancy permette di condividere le risorse computazionali, riducendo drasticamente i costi di manutenzione e deployment.

L'una delle sfide più critiche in questo contesto è l'isolamento. L'isolamento non riguarda solo il database (dove i dati di un cliente non devono mai essere visibili a un altro), ma inizia già a livello di rete e di routing. Implementare rotte con prefissi dinamici e constraints (vincoli) permette di identificare univocamente il tenant dalla richiesta HTTP, garantendo che l'applicazione carichi il contesto corretto prima ancora che il controller venga eseguito.

Strategie di Identificazione del Tenant

Prima di addentrarci nell'implementazione tecnica, è fondamentale capire come l'applicazione può identificare chi sta effettuando la richiesta. Esistono tre approcci principali:

  1. Sotto-domini (Subdomains): tenant1.app.com, tenant2.app.com. È l'approccio più professionale e pulito, ideale per l'isolamento DNS.
  2. Prefissi di Percorso (Path Prefixes): app.com/tenant1/dashboard. Utile per applicazioni dove l'installazione di certificati SSL wild-card è complessa o per prototipi rapidi.
  3. Header Personalizzati: L'identificativo del tenant viene passato in un header (es. X-Tenant-ID). Comune in architetture API pure o headless.

In questo articolo ci concentreremo sui prefissi di percorso e i sotto-domini, poiché richiedono una gestione sofisticata del routing e l'uso di constraints per evitare collisioni con le rotte statiche dell'applicazione.

Implementazione Tecnica: Routing Dinamico e Constraints

Per implementare un sistema di questo tipo, non possiamo limitarci a definire una rotta generica come /{tenant}/{page}. Questo causerebbe conflitti con rotte come /admin o /about. Dobbiamo utilizzare i cosiddetti Route Constraints (Vincoli di Rotta), che permettono al router di validare il parametro del tenant contro un database o una lista di tenant attivi prima di risolvere la rotta.

Prendiamo come esempio l'ecosistema Laravel (PHP), che offre uno dei sistemi di routing più flessibili per questa esigenza, ma il concetto è applicabile a Express.js o Fastify con middleware personalizzati.

Definizione del Constraint Personalizzato

Il primo passo è creare un oggetto che validi se il prefisso dinamico fornito nell'URL corrisponde a un tenant esistente e attivo.

namespace App
outes
aints;

use App\\Models\\Tenant;
use Illuminate
outing\\RouteConstraint;

class TenantConstraint implements RouteConstraint
{
    /**
     * Determina se il parametro della rotta soddisfa il vincolo.
     *
     * @param  string  $value
     * @return bool
     */
    public function pass($value)
    {
        // Verifichiamo che il tenant esista nel database e sia attivo
        return Tenant::where('slug', $value)
                     ->where('is_active', true)
                     ->exists();
    }
}

In questo snippet, abbiamo creato una classe TenantConstraint. Il metodo pass viene chiamato dal router durante la fase di matching. Se restituisce false, il router ignorerà questa rotta e passerà alla successiva, evitando che l'utente atterri in una pagina 404 generica o, peggio, che l'app cerchi di processare un tenant inesistente.

Gestione del Contesto e Middleware di Isolamento

Una volta che il router ha validato il tenant, non dobbiamo passare l'ID del tenant manualmente a ogni singolo metodo del controller. Questo porterebbe a un codice ripetitivo e prono a errori (il cosiddetto "leaking" dei dati).

La soluzione è l'utilizzo di un Tenant Manager (un Singleton) e di un Middleware che imposti il contesto globale della richiesta.

Il Middleware di Isolamento

Il middleware intercetta la richiesta dopo che il routing è stato risolto, estrae il parametro {tenant} e configura l'applicazione.

namespace App\\Http\\Middleware;

use Closure;
use App\\Services\\TenantManager;
use Illuminate\\Http\\Request;

class IdentifyTenant
{
    protected $tenantManager;

    public function __construct(TenantManager $tenantManager)
    {
        $this->tenantManager = $tenantManager;
    }

    public function handle(Request $request, Closure $next)
    {
        // Recuperiamo il parametro 'tenant' definito nella rotta
        $tenantSlug = $request->route('tenant');

        if (!$tenantSlug) {
            return response()->json(['error' => 'Tenant not identified'], 400);
        }

        // Impostiamo il tenant nel manager globale
        $this->tenantManager->setTenantBySlug($tenantSlug);

        return $next($request);
    }
}

Configurazione delle Rotte

Ora possiamo raggruppare tutte le rotte tenant-specifiche sotto un unico prefisso e applicare sia il constraint che il middleware.

use Illuminate\\Support\\Facades\\Route;

// Registriamo il constraint nel ServiceProvider
Route::pattern('tenant', '[a-zA-Z0-9_-]+'); // Pattern base

Route::group(['prefix' => '{tenant}', 'middleware' => ['identify.tenant']], function () {
    
    Route::get('/dashboard', [DashboardController::class, 'index']);
    Route::get('/settings', [SettingsController::class, 'index']);
    
    // Queste rotte saranno accessibili come /client-a/dashboard, /client-b/dashboard
});

// Rotte globali (non tenant-specific)
Route::get('/landing', [LandingController::class, 'index']);
Route::get('/login', [AuthController::class, 'showLogin']);

Esempi Pratici e Casi d'Uso Reali

Caso 1: Isolamento del Database (Database-per-Tenant)

In un'applicazione enterprise, l'isolamento fisico è preferibile a quello logico (colonna tenant_id). Quando il TenantManager imposta il tenant, può dinamicamente cambiare la connessione al database:

public function setTenantBySlug($slug)
{
    $tenant = Tenant::where('slug', $slug)->firstOrFail();
    
    // Cambia la configurazione della connessione 'tenant' a runtime
    config(['database.connections.tenant.database' => $tenant->db_name]);
    
    // Purga la connessione esistente per forzare la riconnessione
    DB::purge('tenant');
    DB::reconnect('tenant');
}

Caso 2: Custom Domain Mapping

Molti clienti SaaS richiedono di usare il proprio dominio (es. portal.cliente.it invece di app.com/cliente). In questo caso, il constraint non deve più cercare un prefisso nell'URL, ma deve validare l'host della richiesta:

public function pass($value)
{
    $host = request()->getHost();
    return Tenant::where('custom_domain', $host)->exists();
}

Errori Comuni e Soluzioni

1. Collisioni tra Rotte Statiche e Dinamiche

Errore: Definire /{tenant} prima di /admin. Se un utente visita /admin, il sistema potrebbe interpretare "admin" come lo slug di un tenant.

Soluzione: Definire sempre le rotte statiche prima di quelle con parametri dinamici, oppure usare i constraints per escludere parole riservate (es. tenant non può essere "admin", "api", "static").

2. Cache del Routing

Errore: In framework come Laravel, l'esecuzione di route:cache può appiattire le rotte. Se i constraints dipendono da dati dinamici del database, la cache potrebbe non aggiornarsi quando viene aggiunto un nuovo tenant.

Soluzione: Utilizzare un sistema di caching distribuito (Redis) all'interno del constraint per verificare l'esistenza del tenant senza colpire il DB a ogni richiesta, e invalidare la chiave di cache alla creazione di un nuovo tenant.

3. Leak di Sessione

Errore: L'utente è loggato nel tenant A e, cambiando l'URL in /tenantB/dashboard, mantiene la sessione attiva.

Soluzione: Il middleware IdentifyTenant deve verificare che l'utente autenticato abbia effettivamente i permessi per accedere al tenant identificato nell'URL. Se non è così, deve restituire un errore 403 Forbidden.

Prossimi Passi e Approfondimenti

L'implementazione di rotte multi-tenant è solo l'inizio. Per rendere l'architettura veramente robusta, suggerisco di approfondire i seguenti temi:

  • Global Scopes: Implementare scope automatici nei modelli per aggiungere sempre WHERE tenant_id = ? a ogni query, evitando l'accesso accidentale a dati altrui.
  • Multi-tenancy a livello di Infrastruttura: Esplorare l'uso di Kubernetes e Ingress Controller per gestire il routing dei domini a livello di cluster.
  • Strategie di Migrazione: Gestire le migrazioni del database su centinaia di tenant diversi in modo atomico e sincronizzato.
  • Storage Isolato: Configurare i file system (S3, Azure Blob) affinché ogni tenant abbia una propria cartella o bucket isolato.

L'approccio basato su constraints e middleware descritto in questo articolo fornisce una base solida, sicura e scalabile, permettendo di separare nettamente la logica di business dalla logica di instradamento dei clienti.