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
And in any resource that needs such protection with a user_id
:
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:
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:
# 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.
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 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)
See another solution do completly switch databases by tenant: github.com/RND-SOFT/gorynich
Yeah database sharding is still a ton more complicated than having a global variable. I talked about that in the article