LoginSignup
6
5

Subdomain or Multi-Tenant in Ruby On Rails(Multi-Tenant SaaS App)

Last updated at Posted at 2017-07-17

Subdomains are quite useful in a number of different scenarios. For example, let's say you are building a multi-user application. Each user gets their own space. Being able to give each user a unique subdomain allows you to give users their own personal space.

For security point of view, this is also very beneficial because all subdomain has their own DB schema.So, if you do not have access rights then you can not able to reach other domain.

Now let's implement the SubDomain functionality.I used PostgreSQL as a database.

Below are the following steps:

Step1: Add the below line in your Gemfile.

to achieve the subdomain functionality, I'm going to use the 'apartment' gem.
for more details, you can check the below URL.
https://github.com/influitive/apartment

gem 'apartment'
gem 'devise',           '~> 4.2'
gem 'devise_invitable', '~> 1.7.1'

For devise gem implementation, please refer the below article.
http://qiita.com/alokrawat050/items/5267e6ab0e274ad1188a

Step2: Create some useful controller,model, let's say account.

rails g controller account new
rails g controller welcome index
rails g model account new

write some code in controller,
→accounts_controller.rb

class AccountsController < ApplicationController
  skip_before_filter :authenticate_user!, only: [:new, :create]
  def new
    @account = Account.new
    @account.build_owner    
    #store plan id in session
    session[:plan_id] = params[:plan_id]
  end

  def create
    @account = Account.new(accounts_params)
    @account.assign_attributes(:plan_id => session[:plan_id])
    if @account.valid?
      Apartment::Tenant.create(@account.subdomain_name)
      Apartment::Tenant.switch!(@account.subdomain_name)
      if @account.save
        session[:plan_id] = nil
        redirect_to new_user_session_url(subdomain: "#{@account.subdomain_name}.demo-alokrawat050")
      else
        render action: 'new'
      end
    else
      render action: 'new'
    end
  end
  
  private
    def accounts_params
      params.require(:account).permit(:subdomain_name, owner_attributes: [:username, :is_admin, :email, :password, :password_confirmation, :password_updated_at])
    end 
end

→welcome_controller.rb

class WelcomeController < ApplicationController
  skip_before_filter :authenticate_user!, only: [:index, :find_team, :find_team_index]
  def index
  end

  def find_team
    if params[:team_name].present? && !params[:team_name].nil? && !params[:team_name].blank?
      Apartment::Tenant.switch!('public')
      if search_team(params[:team_name])
        Apartment::Tenant.switch!(params[:team_name])
        redirect_to new_user_session_url(subdomain: "#{params[:team_name]}.demo-alokrawat050")
      else
        flash[:alert] = "Team Not Found."
        redirect_to root_path
      end
    else
      flash[:alert] = "Please Enter the Team Name."
      redirect_to root_path
    end
  end
  
  def find_team_index
  end
  
  protected
  def search_team(team_name)
    @account ||= Account.find_by(subdomain_name: team_name)
  end
end

application_controller.rb file look alike,

class ApplicationController < ActionController::Base
  # Prevent CSRF attacks by raising an exception.
  # For APIs, you may want to use :null_session instead.
  protect_from_forgery with: :exception
  
  before_filter :load_schema, :authenticate_user!, :set_mailer_host
  before_filter :configure_permitted_parameters, if: :devise_controller?
  
  protected
  def configure_permitted_parameters
    devise_parameter_sanitizer.permit(:accept_invitation){|u|
      u.permit(:username, :password, :password_confirmation, :invitation_token, :password_updated_at)
    }
  end
  
  private
    def load_schema
      Apartment::Tenant.switch!('public')
      return unless get_subdomain_acc.present?
      
      if current_account
        Apartment::Tenant.switch!(current_account.subdomain_name)
      else
        redirect_to root_url(subdomain: false)
      end
    end
    
    def current_account
      @current_account ||= Account.find_by(subdomain_name: get_subdomain_acc)
    end
    
    helper_method :current_account
    
    def set_mailer_host
      subdomain = current_account ? "#{current_account.subdomain_name}." : ""
      if Rails.env == "production"
  	    ActionMailer::Base.default_url_options[:host] = "#{subdomain}<your production name>.com"
      elsif Rails.env == "staging"
        ActionMailer::Base.default_url_options[:host] = "#{subdomain}<your staging name>.com"
      else
        ActionMailer::Base.default_url_options[:host] = "#{subdomain}demo-alokrawat050.c9users.io"
  	  end
    end
    
    def get_subdomain_acc
      if Rails.env == "production"
  	    return request.subdomain
      elsif Rails.env == "staging"
        return request.subdomain
      else
        return request.subdomain.gsub!(".demo-alokrawat050","")
  	  end
    end
    
    def after_sign_out_path_for(resource_or_scope)
      new_user_session_path
    end
    
    def after_invite_path_for(resource)
      #invite_users_path
      root_path
    end
end

Step3: Do some work in a helper method.
in application_helper.rb,

module ApplicationHelper
    def get_subdomain
        return request.subdomain.gsub!(".demo-alokrawat050","")
    end
    
    def link_to_add_fields(name, f, association)
        new_object = f.object.send(association).klass.new
        id= new_object.object_id
        fields=f.fields_for(association, new_object, child_index: id) do | builder |
            render(association.to_s.singularize + "_fields", f: builder)
        end
        link_to(name, '#', class: "add_fields", data: {id: id, fields: fields.gsub("\n", "")})
    end
    
    def check_is_admin_rights?
        if current_user.is_admin
          true
        else 
          false
        end
    end
end

create a new helpers, form_helper.rb and layout_helper.rb

module FormHelper
    def errors_for(form, field)
        content_tag(:p, form.object.errors[field].try(:first), class: 'help-block')
    end
    
    def form_group_for(form, field, opts={}, &block)
        label = opts.fetch(:label) { true }
        has_errors = form.object.errors[field].present?
        
        content_tag :div, class: "form-group #{'has-error' if has_errors}" do
            concat form.label(field, class: 'control-label') if label
            concat capture(&block)
            concat errors_for(form, field)
        end
    end
end
module LayoutHelper
    def flash_messages(opts={})
        @layout_flash = opts.fetch(:layout_flash) { true }
        
        capture do
           flash.each do | name, msg|
              concat content_tag(:div, msg, id: "flash_#{name}") 
           end
        end
    end
    
    def show_layout_flash?
        @layout_flash.nil? ? true : @layout_flash
    end
end

Step4: Now it's time, to do some validation.
account.rb model settings,

class Account < ActiveRecord::Base

    RESTRICTED_SUBDOMAIN = %w(www)
    belongs_to :owner, class_name: 'User'
    #validates :owner, presence: true
    accepts_nested_attributes_for :owner, allow_destroy: true
    
    validates :subdomain_name, presence: true,
                            uniqueness: { case_sensitive: false},
                            format: { with: /\A[\w\-]+\Z/i, message: 'contains invalid characters' },
                            exclusion: {in: RESTRICTED_SUBDOMAIN, message: 'restricted'}
                            
    before_validation :downcase_account
    
    private
        def downcase_account
            self.subdomain_name = subdomain_name.try(:downcase) 
        end 
end

Step5: Do some DB work here.
below is my migration file:

def change
  create_table :accounts do |t|
      t.string :subdomain_name
      t.integer :owner_id
      t.integer :plan_id
      t.string :updated_by
      t.boolean :del_flag, default: false
      t.timestamps null: false
  end
 end

Step6: Now the designing part here, it is upto you.but i did some work here.
in views,
/views/accounts/new.html.erb

<% def msg(status) return "#{status}" end %>
<section id="download">
    <div class="container">
        <div class="row">
            <div class="col-md-7 col-md-offset-3 panel panel-default">
                <div class="panel-body">
                    <h2>Create an Acccount</h2>
                    <%= form_for @account do |f| %>
                        <%= f.fields_for :owner do |o| %>
                            <%= form_group_for o, :username, label: false  do %>
                                <div class="input-group">
                                    <span class="input-group-addon"><span class="fa fa-user fa-lg fa-fw"></span></span>
                                    <%= o.text_field :username, class: 'form-control', placeholder: 'UserName' %>
                                </div>
                            <% end %>  
                            <%= form_group_for o, :email, label: false  do %>
                                <div class="input-group">
                                    <span class="input-group-addon"><span class="fa fa-envelope fa-lg fa-fw"></span></span>
                                    <%= o.email_field :email, class: 'form-control', placeholder: 'Email' %>
                                </div>
                            <% end %>  
                            <%= form_group_for o, :password, label: false  do %>
                                <div class="input-group">
                                    <span class="input-group-addon"><span class="fa fa-key fa-lg fa-fw"></span></span>
                                    <%= o.password_field :password, class: 'form-control', placeholder: 'Password' %>
                                </div>
                            <% end %>  
                            <%= form_group_for o, :password_confirmation, label: false  do %>
                                <div class="input-group">
                                    <span class="input-group-addon"><span class="fa fa-key fa-lg fa-fw"></span></span>
                                    <%= o.password_field :password_confirmation, class: 'form-control', placeholder: 'Confirm Password' %>
                                </div>
                            <% end %>
                           
                            <%= o.hidden_field :password_updated_at, :value => Time.zone.now %>
                        <% end %>
                        <%= form_group_for f, :subdomain_name, label: false  do %>
                            <div class="input-group">
                                <%= f.text_field :subdomain_name, class: 'form-control', placeholder: 'Company Name' %>
                                <span class="input-group-addon">
                                    <% if Rails.env == "production" %>
            	                        .<your production name>
                                    <% elsif Rails.env == "staging" %>
                                    	.<your staging name>
                                    <% else %>
                                        .demo-alokrawat050.c9users.io    	
                                    <% end %>
                                </span>
                            </div>
                        <% end %>
                        <%= f.submit("Create Account", class:"btn btn-primary", data: {:confirm => msg('Do you want to create account?'), :disable_with => 'Creating'}) %>
                    <% end %>
                </div>    
            </div>
        </div>
    </div>  
</section>    

and /views/welcome/index.html.erb

<div class="container">
		<div class="row">
		    <div class="col-xs-6 col-sm-12 col-lg-6 wow fadeInUp" data-wow-delay="0.6s">
				<img src="/assets/find_team_img.png" class="img-responsive" alt="feature img" width="300px" height="200px">
			</div>
			<div class="col-xs-12 col-sm-12 col-lg-6 wow fadeInUp templatemo-box" data-wow-delay="0.3s">
				<p>If already joined with us, then please provide the your team name.</p>
				<div class="form-inline">
                    <div class="form-group">
                        <%= form_tag(:action => 'find_team') do |f| %>
                            <%= text_field_tag 'team_name', @team_name, class: "form-control btn-lg", placeholder: "Team Name", style: "height:50px" %>
                            <%= button_tag(type: "submit", class: "btn btn-default") do %>
                                <i class="fa fa-search" aria-hidden="true"></i> Find Team
                            <% end %>
                            <%#= flash_messages layout_flash: false %>
                        <% end %>
                    </div>    
                </div>
			</div>			
		</div>
	</div>

Step6: Now it time to set the routes of your system.
in routes.rb file,

class SubdomainPresent
  def self.matches?(request)
    if Rails.env == "production" || Rails.env == "staging"
	    request.subdomain.present?    
    else
      request.subdomain.gsub!(".demo-alokrawat050","").present?
	  end
  end
end

class SubdomainBlank
  def self.matches?(request)
    if Rails.env == "production" || Rails.env == "staging"
	    request.subdomain.blank?    
    else
      request.subdomain.gsub!(".demo-alokrawat050","").blank?
	  end
  end
end

Rails.application.routes.draw do
  constraints(SubdomainPresent) do 
    root 'home#index', as: :subdomain_root
    devise_for :users,
    controllers: { invitations: 'users/invitations' }
    resources :invite_users
    resources :users#, only: :index
  end  
  
  constraints(SubdomainBlank) do 
    root 'welcome#index'
    resources :accounts, only: [:new, :create]
    resources :welcome do
      collection { post :find_team
      get :find_team_index}
    end
  end  
end

Step7: Last but not least, do some settings for apartment gem.
open /config/application.rb file,add below line.

config.middleware.use 'Apartment::Elevators::Subdomain'

and /config/initializers/apartment.rb

Apartment.configure do |config|
   config.tenant_names = -> { Account.pluck(:subdomain_name) }
   config.excluded_models = ['Account']  
# these models will not be multi-tenanted, but remain in the global (public) namespace
   Apartment::Elevators::Subdomain.excluded_subdomains = ['www', 'demo-alokrawat050', 'admin', 'public']
end

I hope this article will help you to understand, how to implement the SubDomain functionality or SAAS concept.

If you have any doubts then please share with me.

Enjoy Coding:smile::smile:.

Thanks & Best Regards,
Alok Rawat

6
5
8

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
6
5