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:
@@ -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,
|
||||||
|
|||||||
@@ -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"),
|
||||||
|
|||||||
3
app/components/admin/tenants/edit_component.html.erb
Normal file
3
app/components/admin/tenants/edit_component.html.erb
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<%= back_link_to admin_tenants_path %>
|
||||||
|
<%= header %>
|
||||||
|
<%= render Admin::Tenants::FormComponent.new(tenant) %>
|
||||||
12
app/components/admin/tenants/edit_component.rb
Normal file
12
app/components/admin/tenants/edit_component.rb
Normal 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
|
||||||
7
app/components/admin/tenants/form_component.html.erb
Normal file
7
app/components/admin/tenants/form_component.html.erb
Normal 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 %>
|
||||||
7
app/components/admin/tenants/form_component.rb
Normal file
7
app/components/admin/tenants/form_component.rb
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
class Admin::Tenants::FormComponent < ApplicationComponent
|
||||||
|
attr_reader :tenant
|
||||||
|
|
||||||
|
def initialize(tenant)
|
||||||
|
@tenant = tenant
|
||||||
|
end
|
||||||
|
end
|
||||||
27
app/components/admin/tenants/index_component.html.erb
Normal file
27
app/components/admin/tenants/index_component.html.erb
Normal 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>
|
||||||
18
app/components/admin/tenants/index_component.rb
Normal file
18
app/components/admin/tenants/index_component.rb
Normal 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
|
||||||
3
app/components/admin/tenants/new_component.html.erb
Normal file
3
app/components/admin/tenants/new_component.html.erb
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
<%= back_link_to admin_tenants_path %>
|
||||||
|
<%= header %>
|
||||||
|
<%= render Admin::Tenants::FormComponent.new(tenant) %>
|
||||||
12
app/components/admin/tenants/new_component.rb
Normal file
12
app/components/admin/tenants/new_component.rb
Normal 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
|
||||||
35
app/controllers/admin/tenants_controller.rb
Normal file
35
app/controllers/admin/tenants_controller.rb
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
1
app/views/admin/tenants/edit.html.erb
Normal file
1
app/views/admin/tenants/edit.html.erb
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<%= render Admin::Tenants::EditComponent.new(@tenant) %>
|
||||||
1
app/views/admin/tenants/index.html.erb
Normal file
1
app/views/admin/tenants/index.html.erb
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<%= render Admin::Tenants::IndexComponent.new(@tenants) %>
|
||||||
1
app/views/admin/tenants/new.html.erb
Normal file
1
app/views/admin/tenants/new.html.erb
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<%= render Admin::Tenants::NewComponent.new(@tenant) %>
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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í.
|
||||||
|
|||||||
@@ -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|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
45
spec/system/admin/tenants_spec.rb
Normal file
45
spec/system/admin/tenants_spec.rb
Normal 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
|
||||||
Reference in New Issue
Block a user