Add multitenancy with apartment

Co-Authored-By: Javi Martín <javim@elretirao.net>
This commit is contained in:
Eduardo Vilar
2018-07-30 08:52:54 +02:00
committed by Javi Martín
parent fcd8466ddf
commit 382abb3666
10 changed files with 326 additions and 1 deletions

View File

@@ -49,6 +49,7 @@ gem "recipient_interceptor", "~> 0.3.1"
gem "redcarpet", "~> 3.5.1"
gem "responders", "~> 3.0.1"
gem "rinku", "~> 2.0.6", require: "rails_rinku"
gem "ros-apartment", "~> 2.11.0", require: "apartment"
gem "sassc-rails", "~> 2.1.2"
gem "savon", "~> 2.13.0"
gem "sitemap_generator", "~> 6.3.0"

View File

@@ -472,7 +472,7 @@ GEM
pronto-scss (0.11.0)
pronto (~> 0.11.0)
scss_lint (~> 0.43, >= 0.43.0)
public_suffix (5.0.0)
public_suffix (4.0.7)
puma (4.3.12)
nio4r (~> 2.0)
racc (1.6.0)
@@ -534,6 +534,11 @@ GEM
retriable (3.1.2)
rexml (3.2.5)
rinku (2.0.6)
ros-apartment (2.11.0)
activerecord (>= 5.0.0, < 7.1)
parallel (< 2.0)
public_suffix (>= 2.0.5, < 5.0)
rack (>= 1.3.6, < 3.0)
rspec-core (3.11.0)
rspec-support (~> 3.11.0)
rspec-expectations (3.11.0)
@@ -784,6 +789,7 @@ DEPENDENCIES
redcarpet (~> 3.5.1)
responders (~> 3.0.1)
rinku (~> 2.0.6)
ros-apartment (~> 2.11.0)
rspec-rails (~> 5.1.2)
rubocop (~> 1.35.1)
rubocop-performance (~> 1.11.4)

37
app/models/tenant.rb Normal file
View File

@@ -0,0 +1,37 @@
class Tenant < ApplicationRecord
validates :schema,
presence: true,
uniqueness: true,
exclusion: { in: ->(*) { excluded_subdomains }}
validates :name, presence: true, uniqueness: true
after_create :create_schema
after_update :rename_schema
after_destroy :destroy_schema
def self.excluded_subdomains
Apartment::Elevators::Subdomain.excluded_subdomains + %w[mail]
end
def self.switch(...)
Apartment::Tenant.switch(...)
end
private
def create_schema
Apartment::Tenant.create(schema)
end
def rename_schema
if saved_change_to_schema?
ActiveRecord::Base.connection.execute(
"ALTER SCHEMA \"#{schema_before_last_save}\" RENAME TO \"#{schema}\";"
)
end
end
def destroy_schema
Apartment::Tenant.drop(schema)
end
end

View File

@@ -0,0 +1,117 @@
# You can have Apartment route to the appropriate Tenant by adding some Rack middleware.
# Apartment can support many different "Elevators" that can take care of this routing to your data.
# Require whichever Elevator you're using below or none if you have a custom one.
#
# require "apartment/elevators/generic"
# require "apartment/elevators/domain"
require "apartment/elevators/subdomain"
# require "apartment/elevators/first_subdomain"
# require "apartment/elevators/host"
#
# Apartment Configuration
#
Apartment.configure do |config|
config.seed_after_create = true
# Add any models that you do not want to be multi-tenanted, but remain in the global (public) namespace.
# A typical example would be a Customer or Tenant model that stores each Tenant's information.
#
config.excluded_models = %w[Tenant]
# In order to migrate all of your Tenants you need to provide a list of Tenant names to Apartment.
# You can make this dynamic by providing a Proc object to be called on migrations.
# This object should yield either:
# - an array of strings representing each Tenant name.
# - a hash which keys are tenant names, and values custom db config
# (must contain all key/values required in database.yml)
#
# config.tenant_names = lambda{ Customer.pluck(:tenant_name) }
# config.tenant_names = ["tenant1", "tenant2"]
# config.tenant_names = {
# "tenant1" => {
# adapter: "postgresql",
# host: "some_server",
# port: 5555,
# database: "postgres" # this is not the name of the tenant's db
# # but the name of the database to connect to before creating the tenant's db
# # mandatory in postgresql
# },
# "tenant2" => {
# adapter: "postgresql",
# database: "postgres" # this is not the name of the tenant's db
# # but the name of the database to connect to before creating the tenant's db
# # mandatory in postgresql
# }
# }
# config.tenant_names = lambda do
# Tenant.all.each_with_object({}) do |tenant, hash|
# hash[tenant.name] = tenant.db_configuration
# end
# end
#
config.tenant_names = -> { Tenant.pluck :schema }
# PostgreSQL:
# Specifies whether to use PostgreSQL schemas or create a new database per Tenant.
#
# MySQL:
# Specifies whether to switch databases by using `use` statement or re-establish connection.
#
# The default behaviour is true.
#
# config.use_schemas = true
#
# ==> PostgreSQL only options
# Apartment can be forced to use raw SQL dumps instead of schema.rb for creating new schemas.
# Use this when you are using some extra features in PostgreSQL that can't be represented in
# schema.rb, like materialized views etc. (only applies with use_schemas set to true).
# (Note: this option doesn't use db/structure.sql, it creates SQL dump by executing pg_dump)
#
# config.use_sql = false
# There are cases where you might want some schemas to always be in your search_path
# e.g when using a PostgreSQL extension like hstore.
# Any schemas added here will be available along with your selected Tenant.
#
# config.persistent_schemas = %w{ hstore }
# <== PostgreSQL only options
#
# By default, and only when not using PostgreSQL schemas, Apartment will prepend the environment
# to the tenant name to ensure there is no conflict between your environments.
# This is mainly for the benefit of your development and test environments.
# Uncomment the line below if you want to disable this behaviour in production.
#
# config.prepend_environment = !Rails.env.production?
# When using PostgreSQL schemas, the database dump will be namespaced, and
# apartment will substitute the default namespace (usually public) with the
# name of the new tenant when creating a new tenant. Some items must maintain
# a reference to the default namespace (ie public) - for instance, a default
# uuid generation. Uncomment the line below to create a list of namespaced
# items in the schema dump that should *not* have their namespace replaced by
# the new tenant
#
# config.pg_excluded_names = ["uuid_generate_v4"]
# Specifies whether the database and schema (when using PostgreSQL schemas) will prepend in ActiveRecord log.
# Uncomment the line below if you want to enable this behavior.
#
# config.active_record_log = true
end
# Setup a custom Tenant switching middleware. The Proc should return the name of the Tenant that
# you want to switch to.
# Rails.application.config.middleware.use Apartment::Elevators::Generic, lambda { |request|
# request.host.split(".").first
# }
# Rails.application.config.middleware.use Apartment::Elevators::Domain
Rails.application.config.middleware.use Apartment::Elevators::Subdomain
# Rails.application.config.middleware.use Apartment::Elevators::FirstSubdomain
# Rails.application.config.middleware.use Apartment::Elevators::Host
Apartment::Elevators::Subdomain.excluded_subdomains = %w[public www]

View File

@@ -0,0 +1,12 @@
class CreateTenants < ActiveRecord::Migration[4.2]
def change
create_table :tenants do |t|
t.string :name
t.string :schema
t.timestamps null: false
t.index :name, unique: true
t.index :schema, unique: true
end
end
end

View File

@@ -1555,6 +1555,15 @@ ActiveRecord::Schema.define(version: 2022_09_15_154808) do
t.index ["proposals_count"], name: "index_tags_on_proposals_count"
end
create_table "tenants", id: :serial, force: :cascade do |t|
t.string "name"
t.string "schema"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
t.index ["name"], name: "index_tenants_on_name", unique: true
t.index ["schema"], name: "index_tenants_on_schema", unique: true
end
create_table "topics", id: :serial, force: :cascade do |t|
t.string "title", null: false
t.text "description"

View File

@@ -98,4 +98,9 @@ FactoryBot.define do
value_es { "Texto en español" }
value_en { "Text in english" }
end
factory :tenant do
sequence(:name) { |n| "Tenant #{n}" }
sequence(:schema) { |n| "subdomain#{n}" }
end
end

View File

@@ -0,0 +1,64 @@
require "rails_helper"
describe Tenant do
describe "validations" do
let(:tenant) { build(:tenant) }
it "is valid" do
expect(tenant).to be_valid
end
it "is not valid without a schema" do
tenant.schema = nil
expect(tenant).not_to be_valid
end
it "is not valid with an already existing schema" do
expect(create(:tenant, schema: "subdomainx")).to be_valid
expect(build(:tenant, schema: "subdomainx")).not_to be_valid
end
it "is not valid with an excluded subdomain" do
%w[mail public www].each do |subdomain|
tenant.schema = subdomain
expect(tenant).not_to be_valid
end
end
it "is not valid without a name" do
tenant.name = ""
expect(tenant).not_to be_valid
end
it "is not valid with an already existing name" do
expect(create(:tenant, name: "Name X")).to be_valid
expect(build(:tenant, name: "Name X")).not_to be_valid
end
end
describe "#create_schema" do
it "creates a schema creating a record" do
create(:tenant, schema: "new")
expect { Tenant.switch("new") { nil } }.not_to raise_exception
end
end
describe "#rename_schema" do
it "renames the schema when updating the schema" do
tenant = create(:tenant, schema: "typo")
tenant.update!(schema: "notypo")
expect { Tenant.switch("typo") { nil } }.to raise_exception(Apartment::TenantNotFound)
expect { Tenant.switch("notypo") { nil } }.not_to raise_exception
end
end
describe "#destroy_schema" do
it "drops the schema when destroying a record" do
tenant = create(:tenant, schema: "wrong")
tenant.destroy!
expect { Tenant.switch("wrong") { nil } }.to raise_exception(Apartment::TenantNotFound)
end
end
end

View File

@@ -65,3 +65,14 @@ Capybara.enable_aria_label = true
Capybara.disable_animation = true
OmniAuth.config.test_mode = true
def with_subdomain(subdomain, &block)
app_host = Capybara.app_host
begin
Capybara.app_host = "http://#{subdomain}.lvh.me"
block.call
ensure
Capybara.app_host = app_host
end
end

View File

@@ -0,0 +1,63 @@
require "rails_helper"
describe "Multitenancy" do
before do
create(:tenant, schema: "mars")
create(:tenant, schema: "venus")
end
scenario "Disabled features", :no_js do
Tenant.switch("mars") { Setting["process.debates"] = true }
Tenant.switch("venus") { Setting["process.debates"] = nil }
with_subdomain("mars") do
visit debates_path
expect(page).to have_css "#debates"
end
with_subdomain("venus") do
expect { visit debates_path }.to raise_exception(FeatureFlags::FeatureDisabled)
end
end
scenario "Sign up into subdomain" do
with_subdomain("mars") do
visit "/"
click_link "Register"
fill_in "Username", with: "Marty McMartian"
fill_in "Email", with: "marty@consul.dev"
fill_in "Password", with: "20151021"
fill_in "Confirm password", with: "20151021"
check "By registering you accept the terms and conditions of use"
click_button "Register"
confirm_email
expect(page).to have_content "Your account has been confirmed."
end
end
scenario "Users from another tenant can't sign in" do
Tenant.switch("mars") { create(:user, email: "marty@consul.dev", password: "20151021") }
with_subdomain("mars") do
visit new_user_session_path
fill_in "Email or username", with: "marty@consul.dev"
fill_in "Password", with: "20151021"
click_button "Enter"
expect(page).to have_content "You have been signed in successfully."
end
with_subdomain("venus") do
visit new_user_session_path
fill_in "Email or username", with: "marty@consul.dev"
fill_in "Password", with: "20151021"
click_button "Enter"
expect(page).to have_content "Invalid Email or username or password."
end
end
end