http://www.linuxjournal.com/content/users-permissions-and-multitenant-sites
In my last article, I started to look at
multitenant Web applications. These
are applications that run a single time, but that can be retrieved
via a variety of hostnames. As I explained in that article, even a simple
application can be made multitenant by having it check the hostname
used to connect to the HTTP server, and then by displaying a different
set of content based on that.
For a simple set of sites, that technique can work well. But if you
are working on a multitenant system, you more likely will need a
more sophisticated set of techniques.
For example, I recently have been working on a set of sites that help
people practice their language skills. Each site uses the same
software but displays a different interface, as well as (obviously) a
different set of words. Similarly, one of my clients has long
operated a set of several dozen geographically targeted sites. Each
site uses the same software and database, but appears to the outside
world to be completely separate. Yet another reason to use a
multitenant architecture is if you allow users to create their own
sites—and, perhaps, add users to those private sites.
In this article, I describe how to set up all of the above types of
sites. I hope you will see that creating such a multitenant
system doesn't have to be too complex, and that, on the contrary, it
can be a relatively easy way to provide a single software service to a
variety of audiences.
Identifying the Site
In my last article, I explained how to modify /etc/passwd such that more than one hostname
would be associated with the same IP address. Every multitenant site
uses this same idea. A limited set of IP addresses (and sometimes only
a single IP address) can be mapped to a larger number of hostnames
and/or domain names. When a request comes in, the application first
checks to see which site has been requested, and then decides what to
do based on it.
The examples in last month's article used Sinatra, a lightweight framework for Web
development. It's true that you can do sophisticated things with
Sinatra, but when it comes to working with databases and large-scale
projects, I prefer to use Ruby on Rails. So here I'm
using Rails, along with a back end in PostgreSQL.
In order to do that, you first need to create a simple Rails
application:
rails new -d postgresql multiatf
Then create a "multiatf" user in your PostgreSQL installation:
createuser multiatf
Finally, go into the multiatf directory, and create the database:
rake db:create
With this in place, you now have a working (if trivially simple)
Rails application. Make sure you still have the following two
lines in your /etc/hosts file:
127.0.0.1 atf1
127.0.0.1 atf2
And when you start up the Rails application:
rails s
you can go to http://atf1:3000 or http://atf2:3000, and you should see the
same
results—namely, the basic "hello" that you get from a Rails
application before you have done anything.
The next step then is
to create a default controller, which will provide actual content for
your users. You can do this by saying:
rails g controller welcome
Now that you have a "welcome" controller, you should uncomment the
appropriate route in config/routes.rb:
root 'welcome#index'
If you start your server again and go to http://atf1:3000, you'll now get
an error message, because Rails knows to go to the "welcome"
controller and invoke the "index" action, but no such action exists.
So, you'll have to go into your controller and add an action:
def index
render text: "Hello!"
end
With that in place, going to your home page gives you the text.
So far, that's not very exciting, and it doesn't add to what I explored in
my last
article. You can, of course, take advantage of the fact that your
"index"
method is rendering text, and that you can interpolate
values into your text dynamically:
def index
render text: "Hello, visitor to #{request.host}!"
end
But again, this is not what you're likely to want. You will want to use
the hostname in multiple places in your application, which means that
you'll repeatedly end up calling "request.host" in your application. A
better solution is to assign a
@hostname
variable in a
before_action
declaration, which will ensure that it takes place for
everyone in the system. You could create this "before" filter in your
welcome controller, but given that this is something you'll want for
all controllers and all actions, I think it would be wiser to
put it in the application controller.
Thus, you should open app/controllers/application_controller.rb, and
add the following:
before_action :get_hostname
def get_hostname
@hostname = request.host
end
Then, in your welcome controller, you can change the "index" action to
be:
def index
render text: "Hello, visitor to #{@hostname}!"
end
Sure enough, your hostname now will be available as @hostname and can
be used anywhere on your site.
Moving to the Database
In most cases, you'll want to move beyond this simple scheme. In order
to do that, you should create a "hosts" table in the database. The idea
is that the "hosts" table will contain a list of hostnames and IDs.
It also might contain additional configuration information (I
discuss that below). But for now, you can just add a new resource to the
system. I even would suggest using the built-in scaffolding mechanism
that Rails provides:
rails g scaffold hosts name:string
Why use a scaffold? I know that it's very popular among Rails
developers to hate scaffolds, but I actually love them when I start a
simple project. True, I'll eventually need to remove and rewrite
parts, but I like being able to move ahead quickly and being able to
poke and prod at my application from the very first moments.
Creating a scaffold in Rails means creating a resource (that is, a
model, a controller that handles the seven basic RESTful actions and
views for each of them), as well as the basic tests needed to ensure
that the actions work correctly. Now, it's true that on a production
system, you probably won't want to allow anyone and everyone with an
Internet connection to create and modify existing hosts. And indeed,
you'll fix this in a little bit. But for now, this is a good and easy
way to set things up.
You will need to run the new migration that was created:
rake db:migrate
And then you will want to add your two sites into the database. One way to
do this is to modify db/seeds.rb, which contains the initial data that
you'll want in the database. You can use plain-old Active Record method
calls in there, such as:
Host.create([{name: 'atf1'}, {name: 'atf2'}])
Before you add the seeded data, make sure the model will
enforce some constraints. For example, in app/models/host.rb, I
add the following:
validates :name, {:uniqueness => true}
This ensures that each hostname will appear only once in the
"hosts"
table. Moreover, it ensures that when you run
rake
db:seed
, only new
hosts will be added; errors (including attempts to enter the same data
twice) will be ignored.
With the above in place, you can add the seeded data:
rake db:seed
Now, you should have two records in your "hosts" table:
[local]/multiatf_development=# select name from hosts;
--------
| name |
--------
| atf1 |
--------
| atf2 |
--------
(2 rows)
With this in place, you now can change your application controller:
before_action :get_host
def get_host
@requested_host = Host.where(name: request.host).first
if @requested_host.nil?
render text: "No such host '#{request.host}'.", status: 500
return false
end
end
(By the way, I use
@requested_host
here, so as not to collide with
the
@host
variable that will be set in
hosts_controller
.)
@requested_host
is no longer a string, but rather an object. It, like
@requested_host
before, is an instance variable set in a before
filter, so it is available in all of your controllers and views.
Notice that it is now potentially possible for someone to access your
site via a hostname that is not in your "hosts" table. If and when that
happens,
@requested_host
will be nil, and you give an appropriate
error message.
This also means that you now have to change your "welcome" controller,
ever so slightly:
def index
render text: "Hello, visitor to #{@requested_host.name}!"
end
This change, from the string
@requested_host
to the object
@requested_host
, is about much more than just textual strings. For
one, you now can restrict access to your site, such that only those
hosts that are active can now be seen. For example, let's add a new
boolean column,
is_active
, to the
"hosts" table:
rails g migration add_is_active_to_hosts
On my machine, I then edit the new migration:
class AddIsActiveToHosts < ActiveRecord::Migration
def change
add_column :hosts, :is_active, :boolean, default: true,
↪null: false
end
end
According to this definition, sites are active by default, and every
site must have a value for
is_active
. You now can
change your
application controller's
get_host
method:
def get_host
@requested_host = Host.where(name: request.host).first
if @requested_host.nil?
render text: "No such host '#{request.host}'.", status: 500
return false
end
if !@requested_host.is_active?
render text: "Sorry, but '#{@requested_host.name}'
↪is not active.", status: 500
return false
end
end
Notice how even a simple database now allows you to check two
conditions that were not previously possible. You want to restrict the
hostnames that can be used on your system, and you want to be able to
turn hosts on and off via the database. If I change
is_active
to
false for the "atf1" site:
UPDATE Hosts SET is_active = 'f' WHERE name = 'atf1';
immediately, I'm unable to access the "atf1" site, but the
"atf2" site
works just fine.
This also means that you now can add any number of sites—without
regard to host or domain—so long as they all have DNS entries that
point to your IP addresses. Adding a new site is as simple as
registering the domain (if it hasn't been registered already),
configuring its DNS entries such that the hostname points to your IP
address, and then adding a new entry in your Hosts table.
Users and Permissions
Things become truly interesting when you use this technique to
allow users to create and manage their own sites. Suddenly, it is not
just a matter of displaying different text to different users, but
allowing different users to log in to different sites. The above shows
how you can have a set of top-level administrators and users who can
log in to each site. However, there often are times when you will want
to restrict users to be on a particular site.
There are a variety of ways to handle this. No matter what, you need to
create a "users" table and a model that will handle your users and
their ability to register and log in. I used to make the foolish
mistake of implementing such login systems on my own; nowadays, I just
use "Devise", the amazing Ruby gem that handles nearly anything you
can imagine having to do with registration and authentication.
I add the following line to my project's Gemfile:
gem 'devise'
Next, I run
bundle install
, and then:
rails g devise:install
on the command line. Now that I have Devise installed, I'll create a
user model:
rails g devise user
This creates a new "user" model, with all of the Devise goodies in
it. But before running the migrations that Devise has provided, let's
make a quick change to the Devise migration.
In the migration, you're going to add an
is_admin
column, which
indicates whether the user in question is an administrator. This line
should go just before the
t.timestamps
line at the bottom, and
it indicates that users are not administrators by default:
t.boolean :is_admin, default: false, null: false
With this in place, you now can run the migrations. This means that
users can log in to your system, but they don't have to. It also means
that you can designate users as administrators. Devise provides a
method that you can use to restrict access to particular areas of a
site to logged-in users. This is not generally something you want to
put in the application controller, since that would restrict people
from logging in. However, you can say that your "welcome" and
"host"
controllers are open only to registered and logged-in users by putting
the following at the top of these controllers:
before_action :authenticate_user!
With the above, you already have made it such that only registered and
logged-in users are able to see your "welcome" controller. You could
argue that this is a foolish decision, but it's one that I'm
comfortable with for now, and its wisdom depends on the type of
application you're running. (SaaS applications, such as Basecamp and
Harvest, do this, for example.) Thanks to Devise, I can register and
log in, and then...well, I can do anything I want, including
adding and removing hosts.
It's probably a good idea to restrict your users, such that only
administrators can see or modify the hosts controller. You can do that
with another
before_action
at the top of that controller:
before_action :authenticate_user!
before_action :only_allow_admins
before_action :set_host, only: [:show, :edit, :update, :destroy]
Then you can define
only_allow_admins
:
def only_allow_admins
if !current_user.is_admin?
render text: "Sorry, but you aren't allowed there",
↪status: 403
return false
end
end
Notice that the above
before_action
filter assumes
that
current_user
already has been set, and that it contains a user object. You can be
sure that this is true, because your call to
only_allow_admins
will
take place only if
authenticate_user!
has fired and has allowed the
execution to continue.
That's actually not much of a problem. You can create a
"memberships"
table that joins "users" and "hosts" in a many-to-many
relationship. Each user thus can be a member of any number of
hosts. You then can create a
before_action
routine that checks to be
sure not only whether users are logged in, but also whether they are a member of
the host they're currently trying to access. If you want to
provide administrative rights to users within their site only, you can
put such a column (for example, "is_host_admin") on the memberships table.
This allows users to be a member of as many sites as they might
want, but to administer only those that have been specifically
approved.
Additional Considerations
Multitenant sites raise a number of additional questions and
possibilities. Perhaps you want to have a different style for each
site. That's fine. You can add a new "styles" table, which has two
columns: "host_id" (a number, pointing to a row in the host table) and
"style", text containing CSS, which you can read into your program at
runtime. In this way, you can let users style and restyle things to
their heart's content.
In the architecture described here, the assumption is that all data is
in the same database. I tend to prefer to use this architecture,
because I believe that it makes life easier for the administrators.
But if you're particularly worried about data security, or if you are
being crushed by a great load, you might want to consider a
different approach, such as firing up a new cloud server for each new
tenant site.
Also note that with this system, a user has to register only once on
the entire site. In some cases, it's not desirable for end users to
share logins across different sites. Moreover, there are cases
(such as with medical records) that might require separating information
into different databases. In such situations, you might be able to get
away with a single database anyway, but use different
"schemas", or
namespaces, within it. PostgreSQL has long offered this capability,
and it's something that more sites might be able to exploit.
Conclusion
Creating a multitenant site, including separate administrators and
permissions, can be a quick-and-easy process. I have created several
such sites for my clients through the years, and it has only gotten
easier during that time. However, at the end of the day, the
combination of HTTP, IP addresses and a database is truly what allows
me to create such flexible SaaS applications.
Resources
The Devise home page is at
https://github.com/plataformatec/devise.
For information and ideas about multitenant sites in Ruby on Rails,
you might want to read
Multitenancy with Rails, an e-book written by
Ryan Bigg and available at
https://leanpub.com/multi-tenancy-rails.
While the book specifically addresses multitenancy with Rails, it
offers many ideas and approaches that are appropriate for other
software systems.
Now Available: Practice Makes Python by Reuven
M. Lerner
My new e-book,
Practice Makes Python, is now available for purchase. The
book is aimed at people who have taken a Python course or learned it on
their own, but want to feel more comfortable with the
"Pythonic" way of
doing things—using built-in data structures, writing functions, using
functional techniques, such as comprehensions, and working with objects.
Practice Makes Python contains 50 exercises that I have used in nearly a
decade of on-site training classes in the US, Europe, Israel and China.
Each exercise comes with a solution, as well as a detailed description of
why the solution works, often along with alternatives. All are aimed at
improving your proficiency with Python, so that you can use it effectively
in your work.
You can read more about the book at
http://lerner.co.il/practice-makes-python.