Add multitenancy with apartment
Co-Authored-By: Javi Martín <javim@elretirao.net>
This commit is contained in:
committed by
Javi Martín
parent
fcd8466ddf
commit
382abb3666
1
Gemfile
1
Gemfile
@@ -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"
|
||||
|
||||
@@ -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
37
app/models/tenant.rb
Normal 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
|
||||
117
config/initializers/apartment.rb
Normal file
117
config/initializers/apartment.rb
Normal 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]
|
||||
12
db/migrate/20180502075740_create_tenants.rb
Normal file
12
db/migrate/20180502075740_create_tenants.rb
Normal 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
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
64
spec/models/tenant_spec.rb
Normal file
64
spec/models/tenant_spec.rb
Normal 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
|
||||
@@ -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
|
||||
|
||||
63
spec/system/multitenancy_spec.rb
Normal file
63
spec/system/multitenancy_spec.rb
Normal 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
|
||||
Reference in New Issue
Block a user