29 May 2013

Multi-tenanting Ruby on Rails Applications on Heroku - Part IV: using the gem Milia

Milia tutorial
This is the final article in the four-part series on Multi-Tenanting RoR apps on Heroku.
Milia, which means "stripe" in Swahili, is the name of the row-based multi-tenanting gem I developed.
Aside: the project name, for the Rails 3.1 version of my app, is punda (small horse/donkey in Swahili), so the combination would be: punda milia (which is zebra in Swahili) .. cute.
My app's environment might be similar to others on Heroku: using Devise for user authentication, using DelayedJob for background task processing (emails, etc), and using Postgres. I also use the cedar stack.
So, fasten your seat belts and let's get started.

[update Jan 2014]
A newer version of Milia v1.0.0 is now available. It supports Rails 4.0.x and Devise 3.1.x. There is now a working sample application that can be generated for checking out milia. This post contains older information parts of which have updated information on the README.

Basic concepts
  • All user authentication must also determine the current tenant.
  • Every user belongs to at least one tenant.
  • New account sign ups create both a new tenant and the first user within that tenant.
  • No controller actions are permitted without a current tenant and the current user must be valid within that tenant. (Except, of course, for sign up and sign in.)
  • All tenanted model DB operations (CRUD actions) must be constrained to the current tenant. This includes associations, joins, etc.
  • The tenanting enforcement for DB operations should be as transparent as possible to any individual CRUD action.
  • Background task execution must take place within the context of the tenant appropriate to the queued task itself.
  • Migrations must be able to function correctly in a multi-tenanted realm.
  • Admin tools must be present for when an admin works in console mode.
  • Rake tasks are required to function correctly in multi-tenanted realm.
  • The tenant_id field within any record must not be alterable by user input.
Limitations
  • milia assumes that the current running instance of the http response process is a singleton thread; non-reentreant by any other process.
  • Milia uses Thread.current[:tenant_id] to hold the current tenant for the existing action request in the application.
  • milia enforces a default_scope for each model (Danger Will Robinson: Rails only uses the last defined default_scope! Thus an application using milia cannot also use default_scope!)

Structure
  • necessary models: user, tenant
  • necessary migrations: user, tenant, tenants_users (join table)
Dependency requirements
  • Rails 3.1 or higher
  • Devise 1.4.8 or higher
Installation
In the Gemfile:
  gem 'milia'
Getting started
Rails setup
Milia expects a user session, so please set one up
$ rails g session_migration invoke active_record create db/migrate/20111012060818_add_sessions_table.rb
Devise setup
  • See https://github.com/plataformatec/devise for how to set up devise.
  • The current version of milia requires that devise use a *User* model.
Milia setup
migrations
*ALL* models require a tenanting field, whether they are to be universal or to be tenanted. So make sure the following is added to each migration
db/migrate
  t.references :tenant
Tenanted models will also require indexes for the tenant field:
  add_index :TABLE, :tenant_id
Also create a tenants_users join table:
db/migrate/20111008081639_create_tenants_users.rb
  class CreateTenantsUsers < ActiveRecord::Migration
    def change
      create_table :tenants_users, :id => false  do |t|
        t.references   :tenant
        t.references   :user
      end
      add_index :tenants_users, :tenant_id
      add_index :tenants_users, :user_id
    end
  end
application controller
add the following line AFTER the devise-required filter for authentications:
app/controllers/application_controller.rb
  before_filter :authenticate_tenant!   # authenticate user and setup tenant

# ------------------------------------------------------------------------------
# authenticate_tenant! -- authorization & tenant setup
# -- authenticates user
# -- sets current tenant
# -- sets up app environment for this user
# ------------------------------------------------------------------------------
  def authenticate_tenant!()

    unless authenticate_user!
      email = ( params.nil? || params[:user].nil?  ?  ""  : " as: " + params[:user][:email] )

      flash[:notice] = "cannot sign you in#{email}; check email/password and try again"
      
      return false  # abort the before_filter chain
    end

    # user_signed_in? == true also means current_user returns valid user
    raise SecurityError,"*** invalid sign-in  ***" unless user_signed_in?

    set_current_tenant   # relies on current_user being non-nil
    
    # any application-specific environment set up goes here
    
    true  # allows before filter chain to continue
  end

catch any exceptions with the following (be sure to also add the designated methods!)
  rescue_from ::Milia::Control::MaxTenantExceeded, :with => :max_tenants
  rescue_from ::Milia::Control::InvalidTenantAccess, :with => :invalid_tenant
routes
Add the following line into the devise_for :users block
config/routes.rb
  devise_for :users do
    post  "users" => "milia/registrations#create"
  end
Designate which model determines account
Add the following acts_as_... to designate which model will be used as the key into tenants_users to find the tenant for a given user. Only designate one model in this manner.
app/models/user.rb
  class User < ActiveRecord::Base
    
    acts_as_universal_and_determines_account
  
  end  # class User
Designate which model determines tenant
Add the following acts_as_... to designate which model will be used as the tenant model. It is this id field which designates the tenant for an entire group of users which exist within a single tenanted domain. Only designate one model in this manner.
app/models/tenant.rb
  class Tenant < ActiveRecord::Base
    
    acts_as_universal_and_determines_tenant
    
  end  # class Tenant
Designate universal models
Add the following acts_as_universal to *ALL* models which are to be universal and remove any superfluous
  belongs_to  :tenant
which the generator might have generated ( acts_as_tenant will specify that ).
Example for a model called Eula:
app/models/eula.rb
  class Eula < ActiveRecord::Base
    
    acts_as_universal
  
  end  # class Eula
Designate tenanted models
Add the following acts_as_tenant to *ALL* models which are to be tenanted and remove any superfluous
  belongs_to  :tenant
which the generator might have generated ( acts_as_tenant will specify that ).
Example for a tenanted model called Post:
app/models/post.rb
  class Post < ActiveRecord::Base
    
    acts_as_tenant
  
  end  # class Post
Exceptions raised
  Milia::Control::InvalidTenantAccess
  Milia::Control::MaxTenantExceeded
Tenant pre-processing hooks
Milia expects a tenant pre-processing & setup hook within the designated Tenant model. Example of method invocation:
  Tenant.create_new_tenant(params)   # see sample code below
where the sign-up params are passed, the new tenant must be validated, created, and then returned. Any other kinds of prepatory processing are permitted here, but should be minimal, and should not involve any tenanted models. At this point in the new account sign-up chain, no tenant has been set up yet (but will be immediately after the new tenant has been created).
Example of expected minimum for create_new_tenant:
app/models/tenant.rb
  def self.create_new_tenant(params)
    
    tenant # Tenant.new(:cname => params[:user][:email], :company => params[:tenant][:company])

    if new_signups_not_permitted?(params)
      
      raise ::Milia::Control::MaxTenantExceeded, "Sorry, new accounts not permitted at this time" 
      
    else 
      tenant.save    # create the tenant
    end
    return tenant
  end
Milia expects a tenant post-processing hook within the model Tenant:
  Tenant.tenant_signup(user,tenant,other)   # see sample code below
The purpose here is to do any tenant initialization AFTER devise has validated and created a user. Objects for the user and tenant are passed. It is recommended that only minimal processing be done here ... for example, queueing a background task to do the actual work in setting things up for a new tenant.
Example:
app/models/tenant.rb
# ------------------------------------------------------------------------
# tenant_signup -- setup a new tenant in the system
# CALLBACK from devise RegistrationsController (milia override)
# AFTER user creation and current_tenant established
# args:
#   user  -- new user  obj
#   tenant -- new tenant obj
#   other  -- any other parameter string from initial request
# ------------------------------------------------------------------------
  def self.tenant_signup(user, tenant, other = nil)
    StartupJob.queue_startup( tenant, user, other )
  end
Alternate use case: user belongs to multiple tenants
Your application might allow a user to belong to multiple tenants. You will need to provide some type of mechanism to allow the user to choose which account (thus tenant) they wish to access. Once chosen, in your controller, you will need to put:
app/controllers/any_controller.rb
  set_current_tenant( new_tenant_id )
joins might require additional tenanting restrictions
Subordinate join tables will not get the Rails default scope. Theoretically, the default scope on the master table alone should be sufficient in restricting answers to the current_tenant alone .. HOWEVER, it doesn't feel right.
BUT If the master table for the join is a universal table, then you really MUST use the following workaround, otherwise the database will access data in other tenanted areas even if no records are returned. This is a potential security breach. Further details can be found in various discussions about the behavior of databases such as POSTGRES.
The milia workaround is to add an additional which invokes a milia method to generate the SQL necessary to constrain the tenants for the given classes.
     .where( where_restrict_tenants(klass1, klass2,...))
for each of the subordinate models in the join.
usage of where_restrict_tenants
    Comment.joins(stuff).where( where_restrict_tenants(Post, Author) ).all
console
Note that even the console ($ rails console) will be run in multi-tenanting mode. You will need to establish a current_user and setup the current_tenant, otherwise most Model DB accesses will fail.
For the author's own application, I have set up a small ruby file which I load when I start the console. This does the following:
    def change_tenant(my_id,my_tenant_id)
      @me = User.find( my_id )
      @w  = Tenant.find( my_tenant_id )
      Tenant.set_current_tenant @w
    end

change_tenant(1,1)   # or whatever is an appropriate starting user, tenant
Cautions
  • Milia designates a default_scope for all models (both universal and tenanted). From Rails 3.2 onwards, the last designated default scope overrides any prior scopes and will invalidate multi-tenanting; so *DO NOT USE default_scope*
  • SQL statements executed outside the context of ActiveRecord pose a potential danger; the current milia implementation does not extend to the DB connection level and so cannot enforce tenanting at this point.
  • The tenant_id of a universal model will always be forced to nil.
  • The tenant_id of a tenanted model will be set to the current_tenant of the current_user upon creation.