DEV Community

Cover image for Fearless Multitenancy
Jonathan Frias
Jonathan Frias

Posted on

Fearless Multitenancy

Most Rails apps have an authentication feature. In those apps, the vast majority of work is done within the context of an authenticated user. This is so prevalent that Rails developers create separate spaces for unauthenticated public views and controllers.

Data access becomes a concern as a natural consequence of having multiple users. By default, when you load a Post or similar you have to remember to filter by some condition Post.where(user: current_user), you have always remember to filter for which records your current_user has access. I've seen solutions around the internet that go to extreme lengths to avoid data sharing and exposure. For example, authorization layering, customer data shards, separate schemas, and the Apartment gem.

These solutions all have the same problem. They are complicated. With multiple databases, schemas, and shards it starts to feel like you're maintaining multiple apps. You have a ton of big data problems when you might not even have big data to begin with. If you use an Authorization layer, it's error-prone, and writing a ton of tests is the only way to have any confidence that you aren't sharing data between customers. That takes a ton of time and patience. Do you really want to keep passing the user around?

Wait a minute!? Those solutions aren't extreme you say. Well, they all sound extreme to me when you realize that all those solutions can be rendered obsolete by a single global variable.

Big claims, so let's back them up with some code:

class Current < ActiveSupport::CurrentAttributes
  attribute :user
end

# ActiveRecord
module CurrentUserFilterable
  def self.included(base)
    base.send(:default_scope, -> () do
      raise "No Current.user!" unless Current.user
      # Exclude the system user from this constraint
      return self if Current.user.system?
      where(user: Current.user)
    end)

    base.belongs_to :user, default: -> { Current.user }
  end
end

# Here's a version if you use Sequel instead:
module CurrentUserFilterable
  def dataset
    raise "No Current.user!" unless Current.user
    return super if Current.user.sysetm?
    super.where({
      Sequel[@dataset.first_source_alias][:user_id] => Current.user.id
    })
  end

  def before_save
    self.user_id = Current.user.id
  end
end
Enter fullscreen mode Exit fullscreen mode

And in any resource that needs such protection with a user_id:

class Post < ApplicationRecord
  include CurrentUserFilterable
end
Enter fullscreen mode Exit fullscreen mode

Yes, this code uses a global variable for control access inside of ActiveRecord's default_scope. Give yourself a minute to get over the initial shock. This seems restrictive. This seems like a loss of control. This seems like bad code. After all, we are combining two patterns that are widely considered as problems together at the same time: Global State, and ActiveRecord default_scope.

I have fallen to the allure of default_scope and deeply regretted it so much so that I stopped using it and happily didn't look back until now.. more than 5 years later. The problem is always that it's nigh impossible to write certain features because there are always exceptions to your default scope, and you can get into situations where it's impossible to proceed without getting rid of the scope or doing weird ruby things. That inflexibility is exactly what you want when dealing with security and data access. It becomes so hard to share data that it will not happen by accident.

Instead of thinking of thinking of default_scope as a bad design, think of it as good security. Clients always want control over who can access data.

Imagine all the things you no longer have to do. You no longer have to remember to always add .where(user: current_user) to your queries removing an entire class of bugs from your data fetching layer. You no longer have the pain of running migrations multiple times for many different targets. You no longer have to write a ton of Rspec just to have confidence that you aren't leaking any data. You no longer need to rely on the apartment gem or another complex multitenant strategy.

You need a bit of supporting code to make this approach viable. Let's start with our ApplicationController. You need to make sure it's set at the start of a request:

class ApplicationController < ActionController::Base
  before_action :set_current_user
  private

  def set_current_user
    Current.user = current_user
  end
end
Enter fullscreen mode Exit fullscreen mode

Since this change will also affect your Rails console, let's add a warning upfront:

# config/initializers/current_user_reminder.rb

Rails.application.console do
  puts "****************************************"
  puts "** You are not logged in. To login,   **"
  puts "** the following methods available:   **"
  puts "** - admin                            **"
  puts "** - anon (anonymous)                 **"
  puts "** - sys                              **"
  puts "** - Current.user = User.find(?)      **"
  puts "****************************************"

  def admin
    puts "logged in as admin"
    Current.user = User.find_by(email: "admin@example.com")
  end

  def sys
    puts "logged in as system"
    Current.user = User.find_by(some_system_condition: true)
  end

  def anon
    puts "logging out"
    Current.user = nil
  end

  # Uncomment to log in automatically
  # admin

end
Enter fullscreen mode Exit fullscreen mode

Finally, in any jobs or mailers, you will need to pass the user that you are acting as. My recommendation is to pass it as the first argument.

NotifyPostPublishedJob.perform_later(Current.user, post)
UserMailer.weekly_articles(Current.user).deliver_later
Enter fullscreen mode Exit fullscreen mode

Just for completeness, you might not cross boundaries by User, you could just as easily replace User with your Organization or Team or Company model and achieve a similar effect.

Hopefully, you can see that you align your core application with your business goals. This solves a base problem in multitenancy, but it doesn't solve authorization requirements within a tenant. Since, I've never really been 100% satisfied with any of the authorization gems in the Rails community so watch out for my next post where we approach this problem formally, and expose the issues with every single authorization gem.

If you like these ideas and would like to work with me, drop me an email at contact@gofrias.com.

Top comments (2)

Collapse
 
kinnalru profile image
Samoilenko Yuri

See another solution do completly switch databases by tenant: github.com/RND-SOFT/gorynich

Collapse
 
jonathanfrias profile image
Jonathan Frias

Yeah database sharding is still a ton more complicated than having a global variable. I talked about that in the article