Fearless Multitenancy
Tags: rails, ruby, sql, architecture
Published: 2023-10-07 04:26:12

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] (https://github.com/influitive/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](https://dev.to/jonathanfrias/when-globals-are-appropriate-b1o).

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

```ruby
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
```

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

```ruby
class Post < ApplicationRecord
  include CurrentUserFilterable
end
```

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:

```ruby
class ApplicationController < ActionController::Base
  before_action :set_current_user
  private

  def set_current_user
    Current.user = current_user
  end
end
```

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

```ruby
# 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
```

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.

```ruby
NotifyPostPublishedJob.perform_later(Current.user, post)
UserMailer.weekly_articles(Current.user).deliver_later
```

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](https://github.com/CanCanCommunity/cancancan) [single](https://github.com/heartcombo/devise) [authorization](https://github.com/varvet/pundit) gem.

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