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

·

10 min read

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:

  1. implement user authentication with Devise

  2. configure our app for multi-tenancy using acts_as_tenant

  3. use the after_create filter to create default shelves for new users, and

  4. 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!