diff --git a/app/models/tenant.rb b/app/models/tenant.rb index ebfe0fdb4..d343f8a61 100644 --- a/app/models/tenant.rb +++ b/app/models/tenant.rb @@ -10,8 +10,25 @@ class Tenant < ApplicationRecord after_update :rename_schema after_destroy :destroy_schema + def self.resolve_host(host) + return nil unless Rails.application.config.multitenancy.present? + return nil if host.blank? || host.match?(Resolv::AddressRegex) + + host_domain = allowed_domains.find { |domain| host == domain || host.ends_with?(".#{domain}") } + host.delete_prefix("www.").sub(/\.?#{host_domain}\Z/, "").presence + end + + def self.allowed_domains + dev_domains = %w[localhost lvh.me example.com] + dev_domains + [default_host] + end + def self.excluded_subdomains - Apartment::Elevators::Subdomain.excluded_subdomains + %w[mail shared_extensions] + %w[mail public shared_extensions www] + end + + def self.default_host + ActionMailer::Base.default_url_options[:host] end def self.switch(...) diff --git a/config/application.rb b/config/application.rb index d18d64a15..82c552a05 100644 --- a/config/application.rb +++ b/config/application.rb @@ -134,6 +134,9 @@ module Consul config.autoload_paths << "#{Rails.root}/app/graphql/custom" config.autoload_paths << "#{Rails.root}/app/models/custom" config.paths["app/views"].unshift(Rails.root.join("app", "views", "custom")) + + # Set to true to enable managing different tenants using the same application + config.multitenancy = Rails.application.secrets.multitenancy end end diff --git a/config/environments/test.rb b/config/environments/test.rb index e58456200..467972d2e 100644 --- a/config/environments/test.rb +++ b/config/environments/test.rb @@ -59,6 +59,9 @@ Rails.application.configure do Bullet.raise = true # raise an error if n+1 query occurs end end + + # Allow managing different tenants using the same application + config.multitenancy = true end require Rails.root.join("config", "environments", "custom", "test") diff --git a/config/initializers/apartment.rb b/config/initializers/apartment.rb index b4b5d4160..ad022b372 100644 --- a/config/initializers/apartment.rb +++ b/config/initializers/apartment.rb @@ -2,9 +2,9 @@ # 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/generic" # require "apartment/elevators/domain" -require "apartment/elevators/subdomain" +# require "apartment/elevators/subdomain" # require "apartment/elevators/first_subdomain" # require "apartment/elevators/host" @@ -12,6 +12,7 @@ require "apartment/elevators/subdomain" # Apartment Configuration # Apartment.configure do |config| + ENV["IGNORE_EMPTY_TENANTS"] = "true" if Rails.env.test? || Rails.application.config.multitenancy.blank? config.seed_after_create = true # Add any models that you do not want to be multi-tenanted, but remain in the global (public) namespace. @@ -106,12 +107,11 @@ 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::Generic, ->(request) do + Tenant.resolve_host(request.host) +end # Rails.application.config.middleware.use Apartment::Elevators::Domain -Rails.application.config.middleware.use Apartment::Elevators::Subdomain +# 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/config/secrets.yml.example b/config/secrets.yml.example index dc335a7c5..432ecc587 100644 --- a/config/secrets.yml.example +++ b/config/secrets.yml.example @@ -18,6 +18,7 @@ http_basic_auth: &http_basic_auth development: http_basic_username: "dev" http_basic_password: "pass" + multitenancy: false secret_key_base: 56792feef405a59b18ea7db57b4777e855103882b926413d4afdfb8c0ea8aa86ea6649da4e729c5f5ae324c0ab9338f789174cf48c544173bc18fdc3b14262e4 <<: *maps @@ -48,6 +49,7 @@ staging: http_basic_password: "" managers_url: "" managers_application_key: "" + multitenancy: false <<: *maps <<: *apis @@ -74,6 +76,7 @@ preproduction: http_basic_password: "" managers_url: "" managers_application_key: "" + multitenancy: false twitter_key: "" twitter_secret: "" facebook_key: "" @@ -105,6 +108,7 @@ production: http_basic_password: "" managers_url: "" managers_application_key: "" + multitenancy: false twitter_key: "" twitter_secret: "" facebook_key: "" diff --git a/spec/models/tenant_spec.rb b/spec/models/tenant_spec.rb index f22ef0d46..5a7194eb7 100644 --- a/spec/models/tenant_spec.rb +++ b/spec/models/tenant_spec.rb @@ -1,6 +1,138 @@ require "rails_helper" describe Tenant do + describe ".resolve_host" do + before do + allow(ActionMailer::Base).to receive(:default_url_options).and_return({ host: "consul.dev" }) + end + + it "returns nil for empty hosts" do + expect(Tenant.resolve_host("")).to be nil + expect(Tenant.resolve_host(nil)).to be nil + end + + it "returns nil for IP addresses" do + expect(Tenant.resolve_host("127.0.0.1")).to be nil + end + + it "returns nil using development and test domains" do + expect(Tenant.resolve_host("localhost")).to be nil + expect(Tenant.resolve_host("lvh.me")).to be nil + expect(Tenant.resolve_host("example.com")).to be nil + expect(Tenant.resolve_host("www.example.com")).to be nil + end + + it "treats lvh.me as localhost" do + expect(Tenant.resolve_host("jupiter.lvh.me")).to eq "jupiter" + expect(Tenant.resolve_host("www.lvh.me")).to be nil + end + + it "returns nil for the default host" do + expect(Tenant.resolve_host("consul.dev")).to be nil + end + + it "ignores the www prefix" do + expect(Tenant.resolve_host("www.consul.dev")).to be nil + end + + it "returns subdomains when present" do + expect(Tenant.resolve_host("saturn.consul.dev")).to eq "saturn" + end + + it "ignores the www prefix when subdomains are present" do + expect(Tenant.resolve_host("www.saturn.consul.dev")).to eq "saturn" + end + + it "returns nested additional subdomains" do + expect(Tenant.resolve_host("europa.jupiter.consul.dev")).to eq "europa.jupiter" + end + + it "ignores the www prefix in additional nested subdomains" do + expect(Tenant.resolve_host("www.europa.jupiter.consul.dev")).to eq "europa.jupiter" + end + + it "does not ignore www if it isn't the prefix" do + expect(Tenant.resolve_host("wwwsaturn.consul.dev")).to eq "wwwsaturn" + expect(Tenant.resolve_host("saturn.www.consul.dev")).to eq "saturn.www" + end + + it "returns the host as a subdomain" do + expect(Tenant.resolve_host("consul.dev.consul.dev")).to eq "consul.dev" + end + + it "returns nested subdomains containing the host" do + expect(Tenant.resolve_host("saturn.consul.dev.consul.dev")).to eq "saturn.consul.dev" + end + + it "returns full domains when they don't contain the host" do + expect(Tenant.resolve_host("unrelated.dev")).to eq "unrelated.dev" + expect(Tenant.resolve_host("mercury.anotherconsul.dev")).to eq "mercury.anotherconsul.dev" + end + + it "ignores the www prefix in full domains" do + expect(Tenant.resolve_host("www.unrelated.dev")).to eq "unrelated.dev" + expect(Tenant.resolve_host("www.mercury.anotherconsul.dev")).to eq "mercury.anotherconsul.dev" + end + + context "multitenancy disabled" do + before { allow(Rails.application.config).to receive(:multitenancy).and_return(false) } + + it "always returns nil" do + expect(Tenant.resolve_host("saturn.consul.dev")).to be nil + expect(Tenant.resolve_host("jupiter.lvh.me")).to be nil + end + end + + context "default host contains subdomains" do + before do + allow(ActionMailer::Base).to receive(:default_url_options).and_return({ host: "demo.consul.dev" }) + end + + it "ignores subdomains already present in the default host" do + expect(Tenant.resolve_host("demo.consul.dev")).to be nil + end + + it "ignores the www prefix" do + expect(Tenant.resolve_host("www.demo.consul.dev")).to be nil + end + + it "returns additional subdomains" do + expect(Tenant.resolve_host("saturn.demo.consul.dev")).to eq "saturn" + end + + it "ignores the www prefix in additional subdomains" do + expect(Tenant.resolve_host("www.saturn.demo.consul.dev")).to eq "saturn" + end + + it "returns nested additional subdomains" do + expect(Tenant.resolve_host("europa.jupiter.demo.consul.dev")).to eq "europa.jupiter" + end + + it "ignores the www prefix in additional nested subdomains" do + expect(Tenant.resolve_host("www.europa.jupiter.demo.consul.dev")).to eq "europa.jupiter" + end + + it "does not ignore www if it isn't the prefix" do + expect(Tenant.resolve_host("wwwsaturn.demo.consul.dev")).to eq "wwwsaturn" + expect(Tenant.resolve_host("saturn.www.demo.consul.dev")).to eq "saturn.www" + end + end + + context "default host is similar to development and test domains" do + before do + allow(ActionMailer::Base).to receive(:default_url_options).and_return({ host: "mylvh.me" }) + end + + it "returns nil for the default host" do + expect(Tenant.resolve_host("mylvh.me")).to be nil + end + + it "returns subdomains when present" do + expect(Tenant.resolve_host("neptune.mylvh.me")).to eq "neptune" + end + end + end + describe "validations" do let(:tenant) { build(:tenant) }