From a71f4d87f881fab6bf939829f08aacdba06f7651 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Javi=20Mart=C3=ADn?= Date: Sun, 25 Sep 2022 16:21:02 +0200 Subject: [PATCH] Add an interface to manage tenants Note we aren't allowing to delete a tenant because it would delete all its data, so this action is a very dangerous one. We might need to add a warning when creating a tenant, indicating the tenant cannot be destroyed. We can also add an action to delete a tenant which forces the admin to write the name of the tenant before deleting it and with a big warning about the danger of this operation. For now, we're letting administrators of the "main" (default) tenant to create other tenants. However, we're only allowing to manage tenants when the multitenancy configuration option is enabled. This way the interface won't get in the way on single-tenant applications. We've thought about creating a new role to manage tenants or a new URL out of the admin area. We aren't doing so for simplicity purposes and because we want to keep CONSUL working the same way it has for single-tenant installations, but we might change it in the future. There's also the fact that by default we create one user with a known password, and if by default we create a new role and a new user to handle tenants, the chances of people forgetting to change the password of one of these users increases dramatically, particularly if they aren't using multitenancy. --- app/components/admin/menu_component.html.erb | 1 + app/components/admin/menu_component.rb | 15 ++++++- .../admin/tenants/edit_component.html.erb | 3 ++ .../admin/tenants/edit_component.rb | 12 +++++ .../admin/tenants/form_component.html.erb | 7 +++ .../admin/tenants/form_component.rb | 7 +++ .../admin/tenants/index_component.html.erb | 27 +++++++++++ .../admin/tenants/index_component.rb | 18 ++++++++ .../admin/tenants/new_component.html.erb | 3 ++ app/components/admin/tenants/new_component.rb | 12 +++++ app/controllers/admin/tenants_controller.rb | 35 +++++++++++++++ app/models/abilities/administrator.rb | 4 ++ app/models/tenant.rb | 14 ++++-- app/views/admin/tenants/edit.html.erb | 1 + app/views/admin/tenants/index.html.erb | 1 + app/views/admin/tenants/new.html.erb | 1 + config/locales/en/activerecord.yml | 5 +++ config/locales/en/admin.yml | 10 +++++ config/locales/es/activerecord.yml | 5 +++ config/locales/es/admin.yml | 10 +++++ config/routes/admin.rb | 2 + spec/models/abilities/administrator_spec.rb | 33 ++++++++++++++ spec/models/tenant_spec.rb | 20 +++++++++ spec/system/admin/tenants_spec.rb | 45 +++++++++++++++++++ 24 files changed, 286 insertions(+), 5 deletions(-) create mode 100644 app/components/admin/tenants/edit_component.html.erb create mode 100644 app/components/admin/tenants/edit_component.rb create mode 100644 app/components/admin/tenants/form_component.html.erb create mode 100644 app/components/admin/tenants/form_component.rb create mode 100644 app/components/admin/tenants/index_component.html.erb create mode 100644 app/components/admin/tenants/index_component.rb create mode 100644 app/components/admin/tenants/new_component.html.erb create mode 100644 app/components/admin/tenants/new_component.rb create mode 100644 app/controllers/admin/tenants_controller.rb create mode 100644 app/views/admin/tenants/edit.html.erb create mode 100644 app/views/admin/tenants/index.html.erb create mode 100644 app/views/admin/tenants/new.html.erb create mode 100644 spec/system/admin/tenants_spec.rb diff --git a/app/components/admin/menu_component.html.erb b/app/components/admin/menu_component.html.erb index 750ee18ec..8e70d9cb7 100644 --- a/app/components/admin/menu_component.html.erb +++ b/app/components/admin/menu_component.html.erb @@ -110,6 +110,7 @@ <%= t("admin.menu.title_settings") %> <%= link_list( settings_link, + tenants_link, tags_link, geozones_link, images_link, diff --git a/app/components/admin/menu_component.rb b/app/components/admin/menu_component.rb index 4ef5d262f..9fc4c5319 100644 --- a/app/components/admin/menu_component.rb +++ b/app/components/admin/menu_component.rb @@ -1,5 +1,6 @@ class Admin::MenuComponent < ApplicationComponent include LinkListHelper + delegate :can?, to: :helpers private @@ -32,8 +33,8 @@ class Admin::MenuComponent < ApplicationComponent end def settings? - controllers_names = ["settings", "tags", "geozones", "images", "content_blocks", - "local_census_records", "imports"] + controllers_names = ["settings", "tenants", "tags", "geozones", "images", + "content_blocks", "local_census_records", "imports"] controllers_names.include?(controller_name) && controller.class.module_parent != Admin::Poll::Questions::Answers end @@ -300,6 +301,16 @@ class Admin::MenuComponent < ApplicationComponent ] end + def tenants_link + if can?(:read, Tenant) + [ + t("admin.menu.multitenancy"), + admin_tenants_path, + controller_name == "tenants" + ] + end + end + def tags_link [ t("admin.menu.proposals_topics"), diff --git a/app/components/admin/tenants/edit_component.html.erb b/app/components/admin/tenants/edit_component.html.erb new file mode 100644 index 000000000..f83758392 --- /dev/null +++ b/app/components/admin/tenants/edit_component.html.erb @@ -0,0 +1,3 @@ +<%= back_link_to admin_tenants_path %> +<%= header %> +<%= render Admin::Tenants::FormComponent.new(tenant) %> diff --git a/app/components/admin/tenants/edit_component.rb b/app/components/admin/tenants/edit_component.rb new file mode 100644 index 000000000..ee73145d4 --- /dev/null +++ b/app/components/admin/tenants/edit_component.rb @@ -0,0 +1,12 @@ +class Admin::Tenants::EditComponent < ApplicationComponent + include Header + attr_reader :tenant + + def initialize(tenant) + @tenant = tenant + end + + def title + tenant.name + end +end diff --git a/app/components/admin/tenants/form_component.html.erb b/app/components/admin/tenants/form_component.html.erb new file mode 100644 index 000000000..1c01da639 --- /dev/null +++ b/app/components/admin/tenants/form_component.html.erb @@ -0,0 +1,7 @@ +<%= form_for [:admin, tenant] do |f| %> + <%= render "shared/errors", resource: tenant %> + + <%= f.text_field :name %> + <%= f.text_field :schema %> + <%= f.submit %> +<% end %> diff --git a/app/components/admin/tenants/form_component.rb b/app/components/admin/tenants/form_component.rb new file mode 100644 index 000000000..8db70a94b --- /dev/null +++ b/app/components/admin/tenants/form_component.rb @@ -0,0 +1,7 @@ +class Admin::Tenants::FormComponent < ApplicationComponent + attr_reader :tenant + + def initialize(tenant) + @tenant = tenant + end +end diff --git a/app/components/admin/tenants/index_component.html.erb b/app/components/admin/tenants/index_component.html.erb new file mode 100644 index 000000000..005eb377d --- /dev/null +++ b/app/components/admin/tenants/index_component.html.erb @@ -0,0 +1,27 @@ +<%= header do %> + <%= link_to t("admin.tenants.index.create"), new_admin_tenant_path %> +<% end %> + + + + + + + + + + + + <% @tenants.each do |tenant| %> + + + + + + <% end %> + +
<%= attribute_name(:name) %><%= attribute_name(:schema) %><%= t("admin.shared.actions") %>
<%= tenant.name %><%= tenant.schema %> + <%= render Admin::TableActionsComponent.new(tenant, actions: [:edit]) do |actions| %> + <%= actions.action(:show, text: t("admin.shared.view"), path: root_url(host: tenant.host)) %> + <% end %> +
diff --git a/app/components/admin/tenants/index_component.rb b/app/components/admin/tenants/index_component.rb new file mode 100644 index 000000000..eb37a6832 --- /dev/null +++ b/app/components/admin/tenants/index_component.rb @@ -0,0 +1,18 @@ +class Admin::Tenants::IndexComponent < ApplicationComponent + include Header + attr_reader :tenants + + def initialize(tenants) + @tenants = tenants + end + + def title + t("admin.menu.multitenancy") + end + + private + + def attribute_name(attribute) + Tenant.human_attribute_name(attribute) + end +end diff --git a/app/components/admin/tenants/new_component.html.erb b/app/components/admin/tenants/new_component.html.erb new file mode 100644 index 000000000..f83758392 --- /dev/null +++ b/app/components/admin/tenants/new_component.html.erb @@ -0,0 +1,3 @@ +<%= back_link_to admin_tenants_path %> +<%= header %> +<%= render Admin::Tenants::FormComponent.new(tenant) %> diff --git a/app/components/admin/tenants/new_component.rb b/app/components/admin/tenants/new_component.rb new file mode 100644 index 000000000..d0ebacf4d --- /dev/null +++ b/app/components/admin/tenants/new_component.rb @@ -0,0 +1,12 @@ +class Admin::Tenants::NewComponent < ApplicationComponent + include Header + attr_reader :tenant + + def initialize(tenant) + @tenant = tenant + end + + def title + t("admin.tenants.new.title") + end +end diff --git a/app/controllers/admin/tenants_controller.rb b/app/controllers/admin/tenants_controller.rb new file mode 100644 index 000000000..020118ebf --- /dev/null +++ b/app/controllers/admin/tenants_controller.rb @@ -0,0 +1,35 @@ +class Admin::TenantsController < Admin::BaseController + load_and_authorize_resource + + def index + @tenants = @tenants.order(:name) + end + + def new + end + + def edit + end + + def create + if @tenant.save + redirect_to admin_tenants_path, notice: t("admin.tenants.create.notice") + else + render :new + end + end + + def update + if @tenant.update(tenant_params) + redirect_to admin_tenants_path, notice: t("admin.tenants.update.notice") + else + render :edit + end + end + + private + + def tenant_params + params.require(:tenant).permit(:name, :schema) + end +end diff --git a/app/models/abilities/administrator.rb b/app/models/abilities/administrator.rb index f6e2bb038..9e3556e80 100644 --- a/app/models/abilities/administrator.rb +++ b/app/models/abilities/administrator.rb @@ -134,6 +134,10 @@ module Abilities can :manage, LocalCensusRecord can [:create, :read], LocalCensusRecords::Import + + if Rails.application.config.multitenancy && Tenant.default? + can [:create, :read, :update], Tenant + end end end end diff --git a/app/models/tenant.rb b/app/models/tenant.rb index 59b26591c..87b4a2c63 100644 --- a/app/models/tenant.rb +++ b/app/models/tenant.rb @@ -40,12 +40,16 @@ class Tenant < ApplicationRecord end def self.current_host - if default? + host_for(current_schema) + end + + def self.host_for(schema) + if schema == "public" default_host elsif default_host == "localhost" - "#{current_schema}.lvh.me" + "#{schema}.lvh.me" else - "#{current_schema}.#{default_host}" + "#{schema}.#{default_host}" end end @@ -67,6 +71,10 @@ class Tenant < ApplicationRecord end end + def host + self.class.host_for(schema) + end + private def create_schema diff --git a/app/views/admin/tenants/edit.html.erb b/app/views/admin/tenants/edit.html.erb new file mode 100644 index 000000000..710ff10cb --- /dev/null +++ b/app/views/admin/tenants/edit.html.erb @@ -0,0 +1 @@ +<%= render Admin::Tenants::EditComponent.new(@tenant) %> diff --git a/app/views/admin/tenants/index.html.erb b/app/views/admin/tenants/index.html.erb new file mode 100644 index 000000000..51cd3b7a6 --- /dev/null +++ b/app/views/admin/tenants/index.html.erb @@ -0,0 +1 @@ +<%= render Admin::Tenants::IndexComponent.new(@tenants) %> diff --git a/app/views/admin/tenants/new.html.erb b/app/views/admin/tenants/new.html.erb new file mode 100644 index 000000000..be2d4f4ca --- /dev/null +++ b/app/views/admin/tenants/new.html.erb @@ -0,0 +1 @@ +<%= render Admin::Tenants::NewComponent.new(@tenant) %> diff --git a/config/locales/en/activerecord.yml b/config/locales/en/activerecord.yml index 0e9f59e65..c3800181f 100644 --- a/config/locales/en/activerecord.yml +++ b/config/locales/en/activerecord.yml @@ -124,6 +124,9 @@ en: images: one: "Image" other: "Images" + tenant: + one: "tenant" + other: "tenants" topic: one: "Topic" other: "Topics" @@ -378,6 +381,8 @@ en: body: Body tag: name: "Type the name of the topic" + tenant: + schema: "Subdomain" topic: title: "Title" description: "Initial text" diff --git a/config/locales/en/admin.yml b/config/locales/en/admin.yml index b1b68b4b8..68cbbd3d5 100644 --- a/config/locales/en/admin.yml +++ b/config/locales/en/admin.yml @@ -785,6 +785,7 @@ en: comments: "Comments" local_census_records: Manage local census machine_learning: "AI / Machine learning" + multitenancy: Multitenancy administrators: index: title: Administrators @@ -1628,6 +1629,15 @@ en: notice: "Card updated successfully" destroy: notice: "Card removed successfully" + tenants: + create: + notice: Tenant created successfully + index: + create: Create tenant + new: + title: New tenant + update: + notice: Tenant updated successfully homepage: title: Homepage description: The active modules appear in the homepage in the same order as here. diff --git a/config/locales/es/activerecord.yml b/config/locales/es/activerecord.yml index 58305df15..102beb8f4 100644 --- a/config/locales/es/activerecord.yml +++ b/config/locales/es/activerecord.yml @@ -124,6 +124,9 @@ es: images: one: "Imagen" other: "Imágenes" + tenant: + one: "entidad" + other: "entidades" topic: one: "Tema" other: "Temas" @@ -378,6 +381,8 @@ es: body: Contenido tag: name: "Escribe el nombre del tema" + tenant: + schema: "Subdominio" topic: title: "Título" description: "Texto inicial" diff --git a/config/locales/es/admin.yml b/config/locales/es/admin.yml index 08caffd41..cca53ce34 100644 --- a/config/locales/es/admin.yml +++ b/config/locales/es/admin.yml @@ -784,6 +784,7 @@ es: comments: "Comentarios" local_census_records: Gestionar censo local machine_learning: "IA / Machine learning" + multitenancy: Multientidad administrators: index: title: Administradores @@ -1627,6 +1628,15 @@ es: notice: "Tarjeta actualizada con éxito" destroy: notice: "Tarjeta eliminada con éxito" + tenants: + create: + notice: Entidad creada correctamente + index: + create: Crear entidad + new: + title: Nueva entidad + update: + notice: Entidad actualizada correctamente homepage: title: Homepage description: Los módulos activos aparecerán en la homepage en el mismo orden que aquí. diff --git a/config/routes/admin.rb b/config/routes/admin.rb index 56a2cf77e..752942b82 100644 --- a/config/routes/admin.rb +++ b/config/routes/admin.rb @@ -277,6 +277,8 @@ namespace :admin do post :execute, on: :collection delete :cancel, on: :collection end + + resources :tenants, except: [:show, :destroy] end resolve "Milestone" do |milestone| diff --git a/spec/models/abilities/administrator_spec.rb b/spec/models/abilities/administrator_spec.rb index 4579aa4c8..4b8e2376f 100644 --- a/spec/models/abilities/administrator_spec.rb +++ b/spec/models/abilities/administrator_spec.rb @@ -164,4 +164,37 @@ describe Abilities::Administrator do it { should be_able_to(:destroy, SDG::Manager) } it { should be_able_to(:manage, Widget::Card) } + + describe "tenants" do + context "with multitenancy disabled" do + before { allow(Rails.application.config).to receive(:multitenancy).and_return(false) } + + it { should_not be_able_to :create, Tenant } + it { should_not be_able_to :read, Tenant } + it { should_not be_able_to :update, Tenant } + it { should_not be_able_to :destroy, Tenant } + end + + context "with multitenancy enabled" do + before { allow(Rails.application.config).to receive(:multitenancy).and_return(true) } + + it { should be_able_to :create, Tenant } + it { should be_able_to :read, Tenant } + it { should be_able_to :update, Tenant } + it { should_not be_able_to :destroy, Tenant } + + it "does not allow administrators from other tenants to manage tenants " do + create(:tenant, schema: "subsidiary") + + Tenant.switch("subsidiary") do + admin = create(:administrator).user + + expect(admin).not_to be_able_to :create, Tenant + expect(admin).not_to be_able_to :read, Tenant + expect(admin).not_to be_able_to :update, Tenant + expect(admin).not_to be_able_to :destroy, Tenant + end + end + end + end end diff --git a/spec/models/tenant_spec.rb b/spec/models/tenant_spec.rb index a8f41428f..60a658ffa 100644 --- a/spec/models/tenant_spec.rb +++ b/spec/models/tenant_spec.rb @@ -133,6 +133,26 @@ describe Tenant do end end + describe ".host_for" do + before do + allow(Tenant).to receive(:default_url_options).and_return({ host: "consul.dev" }) + end + + it "returns the default host for the default schema" do + expect(Tenant.host_for("public")).to eq "consul.dev" + end + + it "returns the host with a subdomain on other schemas" do + expect(Tenant.host_for("uranus")).to eq "uranus.consul.dev" + end + + it "uses lvh.me for subdomains when the host is localhost" do + allow(Tenant).to receive(:default_url_options).and_return({ host: "localhost" }) + + expect(Tenant.host_for("uranus")).to eq "uranus.lvh.me" + end + end + describe ".run_on_each" do it "runs the code on all tenants, including the default one" do create(:tenant, schema: "andromeda") diff --git a/spec/system/admin/tenants_spec.rb b/spec/system/admin/tenants_spec.rb new file mode 100644 index 000000000..3dabdb7f4 --- /dev/null +++ b/spec/system/admin/tenants_spec.rb @@ -0,0 +1,45 @@ +require "rails_helper" + +describe "Tenants", :admin do + before { allow(Tenant).to receive(:default_host).and_return("localhost") } + + scenario "Create" do + visit admin_root_path + + within("#side_menu") do + click_link "Settings" + click_link "Multitenancy" + end + + click_link "Create tenant" + fill_in "Subdomain", with: "earth" + fill_in "Name", with: "Earthlings" + click_button "Create tenant" + + expect(page).to have_content "Tenant created successfully" + + within("tr", text: "earth") { click_link "View" } + + expect(current_host).to eq "http://earth.lvh.me" + expect(page).to have_current_path root_path + expect(page).to have_link "Sign in" + end + + scenario "Update" do + create(:tenant, schema: "moon") + + visit admin_tenants_path + within("tr", text: "moon") { click_link "Edit" } + + fill_in "Subdomain", with: "the-moon" + click_button "Update tenant" + + expect(page).to have_content "Tenant updated successfully" + + within("tr", text: "the-moon") { click_link "View" } + + expect(current_host).to eq "http://the-moon.lvh.me" + expect(page).to have_current_path root_path + expect(page).to have_link "Sign in" + end +end