Use a custom method to detect the current tenant

The subdomain elevator we were using, which is included in apartment,
didn't work on hosts already including a subdomain (like
demo.consul.dev, for instance). In those cases, we would manually add
the subdomain to the list of excluded subdomains. Since these subdomains
will be different for different CONSUL installations, it meant each
installation had to customize the code. Furthermore, existing
installations using subdomains would stop working.

So we're using a custom method to find the current tenant, based on the
host defined in `default_url_options`.

In order to avoid any side-effects on single-tenant applications, we're
adding a new configuration option to enable multitenancy

We're enabling two ways to handle this configuration option:

a) Change the application_custom.rb file, which is under version control
b) Change the secrets.yml file, which is not under version control

This way people prefering to handle configuration options through
version control can do so, while people who prefer handling
configuration options through te secrets.yml file can do so as well.

We're also disabling the super-annoying warnings mentioning there are no
tenants which we got every time we run migrations on single-tenant
applications. These messages will only be enabled when the multitenancy
feature is enabled too. For this reason, we're also disabling the
multitenancy feature in the development environment by default.
This commit is contained in:
Javi Martín
2022-09-30 23:24:34 +02:00
parent d77cf77761
commit 5983006657
6 changed files with 167 additions and 8 deletions

View File

@@ -10,8 +10,25 @@ class Tenant < ApplicationRecord
after_update :rename_schema after_update :rename_schema
after_destroy :destroy_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 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 end
def self.switch(...) def self.switch(...)

View File

@@ -134,6 +134,9 @@ module Consul
config.autoload_paths << "#{Rails.root}/app/graphql/custom" config.autoload_paths << "#{Rails.root}/app/graphql/custom"
config.autoload_paths << "#{Rails.root}/app/models/custom" config.autoload_paths << "#{Rails.root}/app/models/custom"
config.paths["app/views"].unshift(Rails.root.join("app", "views", "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
end end

View File

@@ -59,6 +59,9 @@ Rails.application.configure do
Bullet.raise = true # raise an error if n+1 query occurs Bullet.raise = true # raise an error if n+1 query occurs
end end
end end
# Allow managing different tenants using the same application
config.multitenancy = true
end end
require Rails.root.join("config", "environments", "custom", "test") require Rails.root.join("config", "environments", "custom", "test")

View File

@@ -2,9 +2,9 @@
# Apartment can support many different "Elevators" that can take care of this routing to your data. # 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 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/domain"
require "apartment/elevators/subdomain" # require "apartment/elevators/subdomain"
# require "apartment/elevators/first_subdomain" # require "apartment/elevators/first_subdomain"
# require "apartment/elevators/host" # require "apartment/elevators/host"
@@ -12,6 +12,7 @@ require "apartment/elevators/subdomain"
# Apartment Configuration # Apartment Configuration
# #
Apartment.configure do |config| Apartment.configure do |config|
ENV["IGNORE_EMPTY_TENANTS"] = "true" if Rails.env.test? || Rails.application.config.multitenancy.blank?
config.seed_after_create = true config.seed_after_create = true
# Add any models that you do not want to be multi-tenanted, but remain in the global (public) namespace. # 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 # Setup a custom Tenant switching middleware. The Proc should return the name of the Tenant that
# you want to switch to. # you want to switch to.
# Rails.application.config.middleware.use Apartment::Elevators::Generic, lambda { |request| Rails.application.config.middleware.use Apartment::Elevators::Generic, ->(request) do
# request.host.split(".").first Tenant.resolve_host(request.host)
# } end
# Rails.application.config.middleware.use Apartment::Elevators::Domain # 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::FirstSubdomain
# Rails.application.config.middleware.use Apartment::Elevators::Host # Rails.application.config.middleware.use Apartment::Elevators::Host
Apartment::Elevators::Subdomain.excluded_subdomains = %w[public www]

View File

@@ -18,6 +18,7 @@ http_basic_auth: &http_basic_auth
development: development:
http_basic_username: "dev" http_basic_username: "dev"
http_basic_password: "pass" http_basic_password: "pass"
multitenancy: false
secret_key_base: 56792feef405a59b18ea7db57b4777e855103882b926413d4afdfb8c0ea8aa86ea6649da4e729c5f5ae324c0ab9338f789174cf48c544173bc18fdc3b14262e4 secret_key_base: 56792feef405a59b18ea7db57b4777e855103882b926413d4afdfb8c0ea8aa86ea6649da4e729c5f5ae324c0ab9338f789174cf48c544173bc18fdc3b14262e4
<<: *maps <<: *maps
@@ -48,6 +49,7 @@ staging:
http_basic_password: "" http_basic_password: ""
managers_url: "" managers_url: ""
managers_application_key: "" managers_application_key: ""
multitenancy: false
<<: *maps <<: *maps
<<: *apis <<: *apis
@@ -74,6 +76,7 @@ preproduction:
http_basic_password: "" http_basic_password: ""
managers_url: "" managers_url: ""
managers_application_key: "" managers_application_key: ""
multitenancy: false
twitter_key: "" twitter_key: ""
twitter_secret: "" twitter_secret: ""
facebook_key: "" facebook_key: ""
@@ -105,6 +108,7 @@ production:
http_basic_password: "" http_basic_password: ""
managers_url: "" managers_url: ""
managers_application_key: "" managers_application_key: ""
multitenancy: false
twitter_key: "" twitter_key: ""
twitter_secret: "" twitter_secret: ""
facebook_key: "" facebook_key: ""

View File

@@ -1,6 +1,138 @@
require "rails_helper" require "rails_helper"
describe Tenant do 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 describe "validations" do
let(:tenant) { build(:tenant) } let(:tenant) { build(:tenant) }