From 382abb36665916e4b3c94a9678190634fd9ec599 Mon Sep 17 00:00:00 2001 From: Eduardo Vilar Date: Mon, 30 Jul 2018 08:52:54 +0200 Subject: [PATCH] Add multitenancy with apartment MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Javi Martín --- Gemfile | 1 + Gemfile.lock | 8 +- app/models/tenant.rb | 37 +++++++ config/initializers/apartment.rb | 117 ++++++++++++++++++++ db/migrate/20180502075740_create_tenants.rb | 12 ++ db/schema.rb | 9 ++ spec/factories/administration.rb | 5 + spec/models/tenant_spec.rb | 64 +++++++++++ spec/rails_helper.rb | 11 ++ spec/system/multitenancy_spec.rb | 63 +++++++++++ 10 files changed, 326 insertions(+), 1 deletion(-) create mode 100644 app/models/tenant.rb create mode 100644 config/initializers/apartment.rb create mode 100644 db/migrate/20180502075740_create_tenants.rb create mode 100644 spec/models/tenant_spec.rb create mode 100644 spec/system/multitenancy_spec.rb diff --git a/Gemfile b/Gemfile index 9d247a509..7cc13c7fe 100644 --- a/Gemfile +++ b/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" diff --git a/Gemfile.lock b/Gemfile.lock index 5cece524d..9693b05ca 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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) diff --git a/app/models/tenant.rb b/app/models/tenant.rb new file mode 100644 index 000000000..b3f203727 --- /dev/null +++ b/app/models/tenant.rb @@ -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 diff --git a/config/initializers/apartment.rb b/config/initializers/apartment.rb new file mode 100644 index 000000000..a38b8fd3e --- /dev/null +++ b/config/initializers/apartment.rb @@ -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] diff --git a/db/migrate/20180502075740_create_tenants.rb b/db/migrate/20180502075740_create_tenants.rb new file mode 100644 index 000000000..323fa3d6f --- /dev/null +++ b/db/migrate/20180502075740_create_tenants.rb @@ -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 diff --git a/db/schema.rb b/db/schema.rb index 20c37835e..429b1b9fc 100644 --- a/db/schema.rb +++ b/db/schema.rb @@ -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" diff --git a/spec/factories/administration.rb b/spec/factories/administration.rb index 07a110ca5..de6df90be 100644 --- a/spec/factories/administration.rb +++ b/spec/factories/administration.rb @@ -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 diff --git a/spec/models/tenant_spec.rb b/spec/models/tenant_spec.rb new file mode 100644 index 000000000..a6d872e8c --- /dev/null +++ b/spec/models/tenant_spec.rb @@ -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 diff --git a/spec/rails_helper.rb b/spec/rails_helper.rb index 5d8e11d91..08fa8c4c3 100644 --- a/spec/rails_helper.rb +++ b/spec/rails_helper.rb @@ -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 diff --git a/spec/system/multitenancy_spec.rb b/spec/system/multitenancy_spec.rb new file mode 100644 index 000000000..cdf0891ba --- /dev/null +++ b/spec/system/multitenancy_spec.rb @@ -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