Vai al contenuto principaleVai al footer
ruby
|
27 febbraio 17

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

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).