Applicazioni multi-cliente con la gemma Apartment
Quante volte è nata l'esigenza di fornire uno stesso servizio a diversi clienti, potendo contare su un'unica istanza di una applicazione rails?, Cosa e come fare affinché ogni cliente possa accedere solo ed esclusivamente ai propri dati, mantenendo al 100% la riservatezza dei dati?
- Il caso d'uso
- Implementazione 'from scratch'
- Perché doverla usare?
- La gemma Apartment
- Utilizzo
- Jobs
- Gli 'Elevators'
- Apartment::Elevators::Domain
- Apartment::Elevators::Subdomain
- Apartment::Elevators::FirstSubdomain
- Apartment::Elevator::HostHash
- Apartment::Elevator::Generic
- Custom Middleware
- Considerazioni
- Troppi elevator!
- Database adapters
Il caso d'uso
Immaginiamo di implementare un servizio di gestione fatture come Saas, disponibile all'ipotetico indirizzo fatture.example.com.
Un nuovo utente si registra all'applicazione e il sistema gli fornisce un'istanza all'indirizzo abcdefg1.fatture.example.com.
Supponiamo infine che, per qualche motivo, non sia possibile tirare su, per ogni nuovo cliente, un'istanza dell'applicazione Rails.
In virtù di questo, avremo un server con un certo numero di domini che puntano ad un'unica applicazione il cui database conterrà i dati di tutti i clienti. Il requisito di impedire in ogni modo che un cliente possa vedere i dati di altri clienti, diventa un requisito fondamentale per motivi di riservatezza.
Implementazione 'from scratch'
Una soluzione abbastanza diffusa è quella di aggiungere ad ogni tabella che contiene i dati condivisi, ad esempio la tabella invocies, una colonna aggiuntiva che contenga un riferimento al dominio di appartenenza di quella specifica riga.
class AddDomainOwnerToInvoices < ActiveRecord::Migration[5.0] def change add_column :invoices, :domain_owner, :string, null: false end end
Fatto questo, possiamo aggiungere al nostro model Invoice uno scope apposito:
class Invoice < ActiveRecord::Base ... scope :of_owner, ->(domain) { where(domain_owner: domain } ... end
e un helper che ci fornisce l'owner attuale della richiesta
class ApplicationHelper ... def current_owner base_url end end
Infine, vediamo un possibile utilizzo in un controller:
class InvoiceController before_action :load_invoice, only: [:show, :edit, :update, :destroy] def index @invoices = Invoice.of_owner(current_owner) end def create @invoice = Invoice.create( params.require[:invoice].permit([...]) ) end private def load_invoice @invoice = Invoice.of_owner(current_owner).find(params[:id]) end end
Perché doverla usare?
Immaginiamo adesso di avere a che fare con decine di tabelle, molte delle quali necessitano di un controller CRUD, altri potrebbero essere utilizzati in dei Jobs. Dal mio punto di vista la scrittura del codice diventa faticosa e c'è una probabilità non bassa di dimenticarsi di utilizzare lo scope nei punti critici.
Potremmo pensare di definire un certo set di shared_examples da utilizzare dentro i test dei model, ma anche in questo caso tutto dipenderà se verranno utilizzati correttamente.
Personalmente, mi sento un po' male all'idea di dover gestire un codice di questo tipo. Se, per caso, si dovesse commettere una dimenticanza di questo tipo, si corre il rischio di esporre dei dati sensibili, e le conseguenze potrebbero essere non di poco conto
La gemma Apartment
La gemma Apartment nasce proprio per venire incontro a questo tipo di esigenze.
L'idea è la seguente: anziché doverci inventare un meccanismo che ci permetta di selezionare solo le righe di proprietà di un utente, andiamo a creare repliche del database e ogni utente va ad utilizzare un sottoinsieme del database.
Se il nostro Saas possiede 2 clienti, in pratica ci ritroveremmo con 3 differenti database (con sqlite3 ci ritroveremmo 3 file .db nella directory db/)
Se utilizziamo Postgresql come database (il db di questo tutorial _ndr_), è possibile utilizzare gli schema di postgres. Per chi non li conoscesse sono una sorta di namespace. Avremo quindi un unico database con tre differenti schemas:
- "public"."invoices"
- "client1"."invoices"
- "client2"."invoices"
Al momento di ricevere una richiesta, Apartment imposterà la connessione di ActiveRecord verso il database specifico o, nel caso di postgresql, apporrà ai table_name il nome del tenant come prefisso.
Grazie a questo meccanismo, tutti i model, senza codice aggiuntivo, accederanno alla tabella associata al dato cliente, senza correre alcun rischio.
Vediamo adesso come si configura in un applicazione
Installazione e Configurazione
Per l'installazione, nel Gemfile aggiungere la seguente riga:
gem "apartment"
e lanciare
$ bundle install
Adesso definiamo una tabella di supporto che conterrà le informazioni per i singoli clienti:
class CreateAccounts < ActiveRecord::Migration[5.0] def change create_table :accounts do |t| t.string :name t.string :domain end end end
Un esempio di Account potrebbe essere
account = Account.create(name: "abcdefg1", domain: "abcdefg1.fatture.example.com") Apartment::Tenant.create(account.name)
La creazione del 'tenant' (inquilino) comporterà l'esecuzione di TUTTE le migration all'interno del nuovo schema tramite l'utilizzo di db/schema.rb. Nell'eventualità in cui state gestendo lo schema del db in formato sql, dovrete eseguire a mano le migration.
All'avvio dell'applicazione possiamo leggere i vari account e impostare i tenants di Apartment.
# config/initializers/apartment.rb require "apartment/adapters/abstract_adapter" require "apartment/adapters/postgresql_adapter" Apartment.configure do |config| config.excluded_models = %w{Account Delayed::Job} config.use_schemas = true config.persistent_schemas = %w{} config.tenant_names = -> { Account.table_exists? ? Account.pluck(:name) : [] } end
Arrivati a questo punto nel nostro database ci saranno tante repliche delle tabelle quante sono gli Account.
L'ultimo step da effettuare è dare indicazioni ad Apartment di come settare lo specifico tenant per una data richiesta HTTP.
Per fare questo Apartment mette a disposizione dei middleware che esegue tutto il codice della richiesta in uno specifico tenant (che nel vocabolario di Apartment prendono il nome di Elevator). Nel nostro caso ci serve l'elevator sui sottodomini.
# config/application.rb require 'apartment/elevators/subdomain' module MyApplication class Application < Rails::Application ... config.middleware.use 'Apartment::Elevators::Subdomain' ... end end
Utilizzo
Frontend
Adesso il controller può essere scritto senza preoccuparsi di filtrare le singole righe: Apartment dirotterà ActiveRecord direttamente nel suo tenant
class InvoiceController before_action :load_invoice, only: [:show, :edit, :update, :destroy] def index @invoices = Invoice.all end def create @invoice = Invoice.create( params.require[:invoice].permit([...]) ) end private def load_invoice @invoice = Invoice.find(params[:id]) end end
Jobs
Abbiamo visto come l'utilizzo di Apartment automatizzi la scelta del tenant in base all'url della richiesta (è possibile usare il dominio e/o il path della richiesta).
Nel caso di job (task rake, active jobs) la situazione è leggermente differente. Non abbiamo una richiesta HTTP ne, di conseguenza, un dominio. Apartment ci mette a disposizione due metodi per poter selezionare manualmente i tenant da utilizzare:
Apartment::Teanant.switch(tenant_name) do ... end
che eseguirà il blocco dentro a 'tenant_name' per poi ripristinarlo a come'era prima, oppure
Apartment::Tenant.swtich!(tenant_name) ...
che effettuerà uno switch da quel punto in poi, fintanto che il job non si completa o un'altro switch viene effettuato.
Serve quindi fornire ai job un parametro addizionale che è il tenant dentro al quale bisogna eseguire lo script.
class MyJob < ApplicationJob def perform(tenant, ...) Apartment::Tenant.switch(tenant) do # code goes here end end end
Gli 'Elevators'
Apartment porta con sé un certo numero di Elevators con diverse logiche di selezione del nome del tenant. Analizziamoli nel dettaglio.
Apartment::Elevators::Domain
Questo elevator analizza il dominio completo della richiesta, rimuovendo un eventuale 'www'. Una richiesta ai domini www.example.com, example.com, example.it o example.co.uk verrà eseguita nello scope del tenant "example".
Apartment::Elevators::Subdomain
Questo elevator impone l'utilizzo di un sottodominio per poter essere eseguito. Una richiesta a 'example.com' solleverà un'eccezione, mentre una a 'foo.example.com' sceglierà il tenant "foo". È possibile, nell'initializer, configurare la lunghezza del dominio base (valore default 1)
# config/inizializers/apartment.rb Apartment.configure do |config| ... config.tdl_length = 2 ... end
In questo caso sarà necessario avere almeno un terzo livello per selezionare un tenant (foo.bar.example.com) userà il tenant "foo", mentre bar.example.com non verrà risolto in alcun tenant.
Apartment::Elevators::FirstSubdomain
Questo esegue esattamente lo stesso mestiere dell'elevator Subdomain
Apartment::Elevator::HostHash
Questo elevator non fa uso di logiche complesse, riceve come parametro un hash che ha per chiave il dominio e valore il nome del tenant
config.middleware.use 'Apartment::Elevators::HostHash', { 'example.com' => 'example_tenant', 'example.it' => 'another_tenant' }
Apartment::Elevator::Generic
Se invece abbiamo esigenze particolari, possiamo fare ricorso all'ultimo elevator: Generic. Saremo noi a fornire all'Elevator la logica completa da eseguire, partendo dall'oggetto request.
config.middleware.use Apartment::Elevators::Generic, Proc.new { |request| return computed_tenant_from(request) }
Custom Middleware
Gli elevator sono a tutti gli effetti dei middleware, quindi è possibile creare i propri elevators. Nell'esempio precedente di Generic
class MyCustomElevator < Apartnent::Elevators::Generic def parse_tenant_nane(request) computed_tenant_from(request) end end
approccio che consiglio nel caso in cui il Proc dell'esempio generic diventi troppo complesso.
Considerazioni
A parer mio, è una gemma molto utile che permette di gestire diverse istanze del servizio sfruttando un'unica istanza fisica dell'applicazione; su piattaforme come Heroku, significa poter allocare un singolo dyno per gestire più utenze. Mi sento però di contestare un paio di aspetti della gemma: gli elevator e la gestione degli adapter differenti da postgres.
Troppi elevator!
Ne vengono forniti ben cinque e mi sono sembrati un po' confusionari nel funzionamento.
Domain non fa altro che restituire il primo dominio che segue 'www', quindi www.example.com, www.example.co.uk e example.pippo.pluto.com rispondono tutti al tenant "example".
Dopo aver letto il codice sorgente, ho constatato che Subdomain e FirstSubdomain fanno esattamente la stessa cosa. Inoltre il concetto di configurazione della lunghezza del top level domain, crea confusione se si utilizzano domini inglesi (.co.uk) e turchi (.com.tr) assieme agli altri domini standard (.com, .it, .fr ecc).
Infine HostHash può essere comodo per gestire due, massimo tre domini statici.
In virtù di queste considerazioni reputo sensato l'utilizzo di un middleware ad-hoc, sia per termini di pulizia del codice sia di flessibilità.
Database adapters
Per quanto riguarda gli adapter, postgresql grazie al concetto di schema offre una soluzione che reputo estremamente pulita ed elegante: tanti database virtuali allocati dentro un unico cluster, con notevoli vantaggi. A naso non mi piace avere N file di sqlite3, oppure N database differenti su MySql. Detto questo, direi che ha senso usarlo solo con postgres (tra l'altro Heroku non da problemi riguardo all'utilizzo degli schemas nei db).