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.
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.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.
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.
- 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)
- Rails 3.1 or higher
- Devise 1.4.8 or higher
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.
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 :tenantTenanted models will also require indexes for the tenant field:
add_index :TABLE, :tenant_idAlso 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 endapplication 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 endcatch 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_tenantroutes
Add the following line into the devise_for :users block
config/routes.rb
devise_for :users do post "users" => "milia/registrations#create" endDesignate 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 UserDesignate 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 TenantDesignate universal models
Add the following acts_as_universal to *ALL* models which are to be universal and remove any superfluous
belongs_to :tenantwhich 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 EulaDesignate tenanted models
Add the following acts_as_tenant to *ALL* models which are to be tenanted and remove any superfluous
belongs_to :tenantwhich 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 PostExceptions raised
Milia::Control::InvalidTenantAccess Milia::Control::MaxTenantExceededTenant 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 belowwhere 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 endMilia expects a tenant post-processing hook within the model Tenant:
Tenant.tenant_signup(user,tenant,other) # see sample code belowThe 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 ) endAlternate 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) ).allconsole
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, tenantCautions
- 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.