Photo by Seven Shooter on Unsplash
Building a Reading Log App with the Google Books API and Ruby on Rails - Part 5
User authentication with Devise and multi-tenancy with acts_as_tenant
In part 4 of this series, we finally implemented the ability to create books in our database from Google's Books API response data and assign each book to a shelf when the book record is created! But, as any real book-lover knows, building a personal library is serious work. So, in this post, we will learn how to develop multi-tenant user profiles so that anyone can create an individual account in our app and create their own book collection without interfering with yours.
User authentication and multi-tenancy are technically complex problems to solve. Thankfully, there are reputable Ruby Gems available for us to use. The two gems we will use here abstract away the complexity and provide streamlined and secure solutions that we simply need to wire up in our app.
Installing Devise and acts_as_tenant
Devise is a popular gem offering a flexible authentication solution for Ruby on Rails apps like this one. We'll use this to provide user account creation and login infrastructure.
acts_as_tenant is the gem we'll use to add multi-tenancy to our Rails app through a shared database strategy. This means that any book or shelf records created by a user will be scoped to that specific user and will only be accessible by that user when they are logged into the application.
With that being said, let's get started by adding both gems to our Gemfile.rb
.
# Row-level multitenancy for Ruby on Rails apps.
gem "acts_as_tenant"
# User authentication
gem "devise"
Now, we can install them by executing bundle install
. If successful, you should see the output in your command line showing various gems and dependencies installed in your application. Below is an example of such output (Note: the version numbers you see might differ).
Updating files in vendor/cache
* request_store-1.5.1.gem
* acts_as_tenant-0.5.1.gem
* bcrypt-3.1.17.gem
* orm_adapter-0.5.0.gem
* responders-3.0.1.gem
* warden-1.2.9.gem
* devise-4.8.1.gem
Bundle complete! 19 Gemfile dependencies, 84 gems now installed.
Bundled gems are installed into `./vendor/cache`
Devise for user authentication
With Devise
and acts_as_tenant
installed, we now need to run the Devise generator with the following command:
rails generate devise:install
If successful, you will see a series of instructions appear in the console. We don't need to act on any of these for this tutorial. However, if you'd like to scale up the functionality of our app on your own, I suggest making time later to read the Devise docs.
Above the instructions you see in the console should also be output indicating two new config files were created:
create config/initializers/devise.rb
create config/locales/devise.en.yml
Reading through the contents of each file is a great way to gain a more in-depth understanding of what each does and how each can be customized. Before moving on, we need to update a configuration in the config/initializers/devise.rb
file.
Rails 7 utilizes Hotwire as an alternative approach to building web apps without relying heavily on JavaScript. One concept this introduces is delivering page changes with "Turbo Stream." You can read more about Turbo Streams here. For this tutorial, we just need to uncomment the line in our devise initializer file for config.navigational_formats = ["*/*, :html]
and add :turbo_stream
to the array. That config should now look like this:
# The "*/*" below is required to match Internet Explorer requests.
config.navigational_formats = ['*/*', :html, :turbo_stream]
Now, we can use the newly created Devise initializer to create a User model configured with Devise's default modules by executing:
rails generate devise User
Notice in the output we created a migration file, a user model, and a users route scoped to devise, among other created files.
invoke active_record
create db/migrate/20220508022927_devise_create_users.rb
create app/models/user.rb
invoke test_unit
create test/models/user_test.rb
create test/fixtures/users.yml
insert app/models/user.rb
route devise_for :users
For reference, your config/routes.rb
file should now look similar to the following:
Rails.application.routes.draw do
devise_for :users
resources :shelves
resources :books
# Define your application routes per the DSL in https://guides.rubyonrails.org/routing.html
get '/search', :to => "searches#google_books_search"
# Defines the root path route ("/")
root "shelves#index"
end
Let's run our migration with rails db:migrate
. If successful, you should see the following output:
== 20220508022927 DeviseCreateUsers: migrating ================================
-- create_table(:users)
-> 0.0287s
-- add_index(:users, :email, {:unique=>true})
-> 0.0015s
-- add_index(:users, :reset_password_token, {:unique=>true})
-> 0.0011s
== 20220508022927 DeviseCreateUsers: migrated (0.0315s) =======================
As far as setting up user authentication with Devise
, we're done! Let's move on to multi-tenancy with acts_as_tenant
.
Multi-tenancy with acts_as_tenant
The first step in using acts_as_tenant
is setting the current tenant in our app's ApplicationController
. Navigate to app/controllers/application_controller.rb
and add the following code to your file.
class ApplicationController < ActionController::Base
before_action :authenticate_user!
set_current_tenant_through_filter
before_action :set_tenant
def set_tenant
return if current_user.blank?
set_current_tenant(current_user)
end
end
We're doing a handful of things here utilizing helpers from Devise
and acts_as_tenant
.
At the top of our ApplicationController
class is a before_action
filter. In particular, this filter calls the :authenticate_user!
helper method from Devise
, which ensures that a user is logged in before any other actions are taken. Setting this filter inApplicationController
will allow it to be inherited by all other controllers in our application and run before any actions in those controllers are taken.
Next, we declare set_current_tenant_through_filter
, which is a helper method made available to us by acts_as_tenant
. This method tells acts_as_tenant
that we are going to use a before_action
filter to set the current tenant for each session. We then provide :set_tenant
to our second before_action
filter immediately after set_current_tenant_through_filter
.
Since set_tenant
is a custom method of our own creation, we naturally need to define that method in this class so that our before_action
filter knows what to do. That method definition is the last component of our ApplicationController
class.
Theset_tenant
method checks to see if the Devise
current_user
returns a legitimate user. Assuming a legitimate current_user is returned here, set_tenant
passes that current_user object as an argument to set_current_tenant
, another helper method provided to us. This method ensures that all other code is run within the scope of the current tenant, meaning they get their books and shelves instead of yours.
Migrations adding User to Books, Shelves, & Shelvings
To scope our models to the current tenant/user, we need to add a new field to our tables.
Since we add the same column name to multiple tables, we can avoid repeating unnecessary migration code by creating a group migration.
Let's generate a migration file, which we will then customize.
rails g migration AddUserToModels
If successful, we should see a new migration file created in the console.
invoke active_record
create db/migrate/20220508033915_add_user_to_models.rb
Navigate to our new migration file and add the following code:
class AddUserToModels < ActiveRecord::Migration[7.0]
def change
tables = [:books, :shelves, :shelvings]
tables.each do |table_name|
add_column table_name, :user_id, :integer
end
end
end
Next, run rails db:migrate
to update the books, shelves, and shelvings tables in your schema.rb
file.
Your expected output should look similar to the following:
== 20220508033915 AddUserToModels: migrating ==================================
-- add_column(:books, :user_id, :integer)
-> 0.0048s
-- add_column(:shelves, :user_id, :integer)
-> 0.0008s
-- add_column(:shelvings, :user_id, :integer)
-> 0.0008s
== 20220508033915 AddUserToModels: migrated (0.0066s) =========================
Scoping Models to Tenants
Our multi-tenancy configuration is almost complete! With a user_id
column on each of our tables, the final piece to this puzzle is scoping our models by adding acts_as_tenant :user
to our Book
, Shelf
, and Shelving
classes and then introducing corresponding associations to our User
class. Accomplishing this is done like so:
class Book < ApplicationRecord
acts_as_tenant :user
validates_presence_of :title, :authors, :description, :page_count, :categories, :image_links
has_many :shelvings
has_many :shelves, :through => :shelvings
end
class Shelf < ApplicationRecord
acts_as_tenant :user
validates_presence_of :name
has_many :shelvings
has_many :books, :through => :shelvings
end
class Shelving < ApplicationRecord
acts_as_tenant :user
belongs_to :book
belongs_to :shelf
validates_presence_of :book
validates_presence_of :shelf
end
class User < ApplicationRecord
# Include default devise modules. Others available are:
# :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable
has_many :shelvings
has_many :shelves
has_many :books
end
Setting Default Shelves for Tenants
While we are in the User class, let's take the time to improve the user experience by generating a new set of default shelves for users anytime someone creates a new account. Then, after signing in, they can get right to work searching out the books they're interested in and adding them to their personal library.
For this feature, we are going to use another Rails filter. This time, add the following after_action
filter to the top of your User class declaration in app/models/user.rb
.
class User < ApplicationRecord
after_create :set_default_shelves
...
end
Then, let's define the :set_default_shelves
method in the private interface of our User class, like so:
class User < ApplicationRecord
after_create :set_default_shelves
# Include default devise modules. Others available are:
# :confirmable, :lockable, :timeoutable, :trackable and :omniauthable
devise :database_authenticatable, :registerable,
:recoverable, :rememberable, :validatable,
:omniauthable, omniauth_providers: %i[google_oauth2]
has_many :shelvings
has_many :shelves
has_many :books
private
def set_default_shelves
default_shelves = ["Favorites", "Reading", "To Be Read", "Have Read"]
default_shelves.each do |shelf|
Shelf.create(name: shelf, user_id: id)
end
end
end
Yay! If you spin up your local rails server and create a new user account, you should be directed to the template for the shelves#index
controller action. This view shows the default shelves created in the set_default_shelves
method, which was invoked by the after_create
filter.
If you recall, from part 4 of this series, we populate the shelf select options on our book create form with all existing shelves in our database. Take a moment to search for a book and verify that the select dropdown now lists our default shelves as options.
Adding Books to Shelves
Now that we have shelves to work with, we need to go back to our BooksController class in app/controllers/books_controller.rb
and use the value from params[:shelf]
to create a new Shelving instance to associate each newly created book with a shelf.
We can do this with an after_action
filter for the create
action at the top of our BooksController class like this:
after_action :add_to_shelf, only: %i[create]
Let's now define our add_to_shelf
method in the private section of our BooksController class.
def add_to_shelf
@shelf = Shelf.find(params[:shelf])
@shelf.books << @book
end
This might look familiar. It mimics how we previously added a book to a shelf in our Rails console. The add_to_shelf
method here does two things. First, it assigns the result of searching the Shelf class by the shelf id passed to our controller from the select dropdown in our form. Then it uses the "shovel" Array method to add our newly created book instance to the books array associated with the receiving shelf.
When we test this in our UI by searching for a new book, selecting a shelf from the select dropdown, and clicking create, we see that a new book record has been created. But when we look at our Shelf index and show views, we don't yet see any books! Don't panic. This isn't an issue with the code we've written. It's the result of code we haven't written yet!
For now, we can verify our "shelved books" in one of two ways. We can query our shelved books in the Rails console like this:
> book = Book.first
Book Load (0.4ms) SELECT "books".* FROM "books" ORDER BY "books"."id" ASC LIMIT $1 [["LIMIT", 1]]
=>
#<Book:0x00007fc16d6d7cf8
...
2.7.5 :002 > book.shelves
Shelf Load (0.8ms) SELECT "shelves".* FROM "shelves" INNER JOIN "shelvings" ON "shelves"."id" = "shelvings"."shelf_id" WHERE "shelvings"."book_id" = $1 [["book_id", 17]]
=>
[#<Shelf:0x00007fc16d63c258
id: 10,
name: "Favorites",
created_at: Sun, 08 May 2022 18:29:22.407296000 UTC +00:00,
updated_at: Sun, 08 May 2022 18:29:22.407296000 UTC +00:00,
user_id: 4>]
And, we can update our app/views/shelves/show.html.erb
file to call the books
method on the @shelf
instance variable, like this:
<p style="color: green"><%= notice %></p>
<%= render @shelf.books %>
<div>
<%= link_to "Edit this shelf", edit_shelf_path(@shelf) %> |
<%= link_to "Back to shelves", shelves_path %>
<%= button_to "Destroy this shelf", @shelf, method: :delete %>
</div>
This allows us to view all book data associated with a given shelf by navigating to the show template for the shelf. It's not pretty. But we'll take care of that later.
Conclusion
Together, we learned how to:
implement user authentication with
Devise
configure our app for multi-tenancy using
acts_as_tenant
use the after_create filter to create default shelves for new users, and
use the after_action filter to add book records to specific shelves.
In part 6️⃣ of this series, we will update our user interface so we see our shelved books from our root route and book show templates. Additionally, we will implement the ability to move books from one shelf to another so that the book we are currently reading can be re-shelved to books we have read, or perhaps our favorites shelf!