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.
This commit is contained in:
Javi Martín
2022-09-25 16:21:02 +02:00
parent d904fe8b4f
commit a71f4d87f8
24 changed files with 286 additions and 5 deletions

View File

@@ -110,6 +110,7 @@
<a href="#" class="settings-link"><%= t("admin.menu.title_settings") %></a> <a href="#" class="settings-link"><%= t("admin.menu.title_settings") %></a>
<%= link_list( <%= link_list(
settings_link, settings_link,
tenants_link,
tags_link, tags_link,
geozones_link, geozones_link,
images_link, images_link,

View File

@@ -1,5 +1,6 @@
class Admin::MenuComponent < ApplicationComponent class Admin::MenuComponent < ApplicationComponent
include LinkListHelper include LinkListHelper
delegate :can?, to: :helpers
private private
@@ -32,8 +33,8 @@ class Admin::MenuComponent < ApplicationComponent
end end
def settings? def settings?
controllers_names = ["settings", "tags", "geozones", "images", "content_blocks", controllers_names = ["settings", "tenants", "tags", "geozones", "images",
"local_census_records", "imports"] "content_blocks", "local_census_records", "imports"]
controllers_names.include?(controller_name) && controllers_names.include?(controller_name) &&
controller.class.module_parent != Admin::Poll::Questions::Answers controller.class.module_parent != Admin::Poll::Questions::Answers
end end
@@ -300,6 +301,16 @@ class Admin::MenuComponent < ApplicationComponent
] ]
end end
def tenants_link
if can?(:read, Tenant)
[
t("admin.menu.multitenancy"),
admin_tenants_path,
controller_name == "tenants"
]
end
end
def tags_link def tags_link
[ [
t("admin.menu.proposals_topics"), t("admin.menu.proposals_topics"),

View File

@@ -0,0 +1,3 @@
<%= back_link_to admin_tenants_path %>
<%= header %>
<%= render Admin::Tenants::FormComponent.new(tenant) %>

View File

@@ -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

View File

@@ -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 %>

View File

@@ -0,0 +1,7 @@
class Admin::Tenants::FormComponent < ApplicationComponent
attr_reader :tenant
def initialize(tenant)
@tenant = tenant
end
end

View File

@@ -0,0 +1,27 @@
<%= header do %>
<%= link_to t("admin.tenants.index.create"), new_admin_tenant_path %>
<% end %>
<table>
<thead>
<tr>
<th><%= attribute_name(:name) %></th>
<th><%= attribute_name(:schema) %></th>
<th><%= t("admin.shared.actions") %></th>
</tr>
</thead>
<tbody>
<% @tenants.each do |tenant| %>
<tr id="<%= dom_id(tenant) %>">
<td><%= tenant.name %></td>
<td><%= tenant.schema %></td>
<td>
<%= render Admin::TableActionsComponent.new(tenant, actions: [:edit]) do |actions| %>
<%= actions.action(:show, text: t("admin.shared.view"), path: root_url(host: tenant.host)) %>
<% end %>
</td>
</tr>
<% end %>
</tbody>
</table>

View File

@@ -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

View File

@@ -0,0 +1,3 @@
<%= back_link_to admin_tenants_path %>
<%= header %>
<%= render Admin::Tenants::FormComponent.new(tenant) %>

View File

@@ -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

View File

@@ -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

View File

@@ -134,6 +134,10 @@ module Abilities
can :manage, LocalCensusRecord can :manage, LocalCensusRecord
can [:create, :read], LocalCensusRecords::Import can [:create, :read], LocalCensusRecords::Import
if Rails.application.config.multitenancy && Tenant.default?
can [:create, :read, :update], Tenant
end
end end
end end
end end

View File

@@ -40,12 +40,16 @@ class Tenant < ApplicationRecord
end end
def self.current_host def self.current_host
if default? host_for(current_schema)
end
def self.host_for(schema)
if schema == "public"
default_host default_host
elsif default_host == "localhost" elsif default_host == "localhost"
"#{current_schema}.lvh.me" "#{schema}.lvh.me"
else else
"#{current_schema}.#{default_host}" "#{schema}.#{default_host}"
end end
end end
@@ -67,6 +71,10 @@ class Tenant < ApplicationRecord
end end
end end
def host
self.class.host_for(schema)
end
private private
def create_schema def create_schema

View File

@@ -0,0 +1 @@
<%= render Admin::Tenants::EditComponent.new(@tenant) %>

View File

@@ -0,0 +1 @@
<%= render Admin::Tenants::IndexComponent.new(@tenants) %>

View File

@@ -0,0 +1 @@
<%= render Admin::Tenants::NewComponent.new(@tenant) %>

View File

@@ -124,6 +124,9 @@ en:
images: images:
one: "Image" one: "Image"
other: "Images" other: "Images"
tenant:
one: "tenant"
other: "tenants"
topic: topic:
one: "Topic" one: "Topic"
other: "Topics" other: "Topics"
@@ -378,6 +381,8 @@ en:
body: Body body: Body
tag: tag:
name: "Type the name of the topic" name: "Type the name of the topic"
tenant:
schema: "Subdomain"
topic: topic:
title: "Title" title: "Title"
description: "Initial text" description: "Initial text"

View File

@@ -785,6 +785,7 @@ en:
comments: "Comments" comments: "Comments"
local_census_records: Manage local census local_census_records: Manage local census
machine_learning: "AI / Machine learning" machine_learning: "AI / Machine learning"
multitenancy: Multitenancy
administrators: administrators:
index: index:
title: Administrators title: Administrators
@@ -1628,6 +1629,15 @@ en:
notice: "Card updated successfully" notice: "Card updated successfully"
destroy: destroy:
notice: "Card removed successfully" notice: "Card removed successfully"
tenants:
create:
notice: Tenant created successfully
index:
create: Create tenant
new:
title: New tenant
update:
notice: Tenant updated successfully
homepage: homepage:
title: Homepage title: Homepage
description: The active modules appear in the homepage in the same order as here. description: The active modules appear in the homepage in the same order as here.

View File

@@ -124,6 +124,9 @@ es:
images: images:
one: "Imagen" one: "Imagen"
other: "Imágenes" other: "Imágenes"
tenant:
one: "entidad"
other: "entidades"
topic: topic:
one: "Tema" one: "Tema"
other: "Temas" other: "Temas"
@@ -378,6 +381,8 @@ es:
body: Contenido body: Contenido
tag: tag:
name: "Escribe el nombre del tema" name: "Escribe el nombre del tema"
tenant:
schema: "Subdominio"
topic: topic:
title: "Título" title: "Título"
description: "Texto inicial" description: "Texto inicial"

View File

@@ -784,6 +784,7 @@ es:
comments: "Comentarios" comments: "Comentarios"
local_census_records: Gestionar censo local local_census_records: Gestionar censo local
machine_learning: "IA / Machine learning" machine_learning: "IA / Machine learning"
multitenancy: Multientidad
administrators: administrators:
index: index:
title: Administradores title: Administradores
@@ -1627,6 +1628,15 @@ es:
notice: "Tarjeta actualizada con éxito" notice: "Tarjeta actualizada con éxito"
destroy: destroy:
notice: "Tarjeta eliminada con éxito" 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: homepage:
title: Homepage title: Homepage
description: Los módulos activos aparecerán en la homepage en el mismo orden que aquí. description: Los módulos activos aparecerán en la homepage en el mismo orden que aquí.

View File

@@ -277,6 +277,8 @@ namespace :admin do
post :execute, on: :collection post :execute, on: :collection
delete :cancel, on: :collection delete :cancel, on: :collection
end end
resources :tenants, except: [:show, :destroy]
end end
resolve "Milestone" do |milestone| resolve "Milestone" do |milestone|

View File

@@ -164,4 +164,37 @@ describe Abilities::Administrator do
it { should be_able_to(:destroy, SDG::Manager) } it { should be_able_to(:destroy, SDG::Manager) }
it { should be_able_to(:manage, Widget::Card) } 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 end

View File

@@ -133,6 +133,26 @@ describe Tenant do
end end
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 describe ".run_on_each" do
it "runs the code on all tenants, including the default one" do it "runs the code on all tenants, including the default one" do
create(:tenant, schema: "andromeda") create(:tenant, schema: "andromeda")

View File

@@ -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