Managing current user in Rails, our way
I’m working on ~15 years old Rails application at my $DAYJOB and sometimes (loads of times, actually) there is some quirky code that we are not really happy with these days.
One example which I stumbled across years ago and tried to make sense of (and clean a bit) multiple times, mostly unsuccessfully, was how we manage current user.
You see, in modern Rails apps, this is usually either managed by gems like devise, or using Current attributes, possibly with code from authentication generator introduced in Rails 8.
Not us. We have our custom code that contains some of the oldest code in our project, and which (considering it’s authentication) I am pretty afraid to touch.
However, we are slightly getting to somewhat better version of our code, and in this post, I will try to describe our original code, why we wanted to change it and how we did it (or failed to do).
Let’s begin.
Part 0 - let’s paint a scene
Core of our current user logic are two methods, defined on ApplicationController - current_user and current_user=.
That’s not that crazy - if you have experience with devise or similar, that’s pretty common thing. Yes, maybe you have log_in instead of current_user=, but well, it’s pretty ok.
current_user can have multiple different types:
On the beginning of request (or before first calling current_user or current_user=), it was nil.
Then, it could be set to two values:
1) instance of User
2) value :false
Wait, what? :false? Yeah, weird.
It was the way how to say “we know it’s anonymous user”, in opposite of nil representing “we just have no idea yet.
So even though te implementation was weird, the general approach of three possible values (“we don’t know”, “we know they are unknown”, “we can link them to our users”) made sense and was a starting point for the following changes.
Last thing was that we used was Thread.current (and around it our PORO CurrentThread) to be able to access current user not only from controllers and templates, but also, sparingly, from models or background job.
We had some suspicion it leaks and it brought us some overall doubt about this part of code.
So, after a lot of tinkering, thinking, head scratching and writing and deleting of code, we were able to make some improvements, discover interesting patterns and look into many things Rails does “magically” for us, until it doesn’t.
let’s be current with Current attributes
After getting utterly confused and lost in the code I tried to outline for you, the first, mostly simple part, was getting rid of Thread.current and using modern Current attributes API.
If you have never heard of it, this explains it:
Current Attributes
Abstract super class that provides a thread-isolated attributes singleton, which resets automatically before and after each request. This allows you to keep all the per-request attributes easily available to the whole system.
So, I looked for our usage of Thread (and our CurrentThread “wrapper”), and mostly just replaced it with the same using Current.
Easy.
Sidenote: I will use
current_userin remaining code, as we do in our app now. However, if your app usesCurrent.userinstead (as pure Rails 8+ apps might), it is approximately the same (not really, in that Current is global).
This was easy and short. Not so much for our next part!
Finding the right representation
As I said before, we used current_user == :false to indicate that we checked whether we can log in user for this request, and found out we cannot - meaning it’s “anonymous user”, or “guest”.
We tried to think and code different approaches - for some time we played with having AnonymousUser class, or NilUser (borrowing from nil object pattern - read more here or here){:target=”_blank”}, to finally find an approach we liked - using decorated object.
What that means? The idea is simple - what if current_user was not three different types of objects (User, AnonymousUser, nil), but just one - let’s call it CurrentUser. Here is some (incomplete) code:
# app/models/current_user.rb
class CurrentUser
attr_reader :user
delegate_missing_to :user, allow_nil: true
def initialize(user)
@user = user
end
def authenticated? = user.present?
# Or equivalent, if you are not fan of Ruby endless methods:
# def authenticated?
# user.present?
# end
end
First thing you might notice is that this is PORO (plain old ruby object) - no inheritance, no composition. Its usage is very simple:
def log_in(user)
@current_user = CurrentUser.new(user)
end
So - we will try to always have some instance of CurrentUser in our current_user attribute, which will answer if user is authenticated?.
This is great improvement in that we can lean much more on duck-typing - our older code often looked like this:
if current_user && current_user != false
# or sometimes just
if current_user == :false
# or some variations
We would much prefer to be confident that our current_user can always tell us - is the user authenticated?
So, that’s improvement.
Also, as we used delegate_missing_to :user, in cases when we are interested in other user information (name, email or any other user attributes/methods), we can interact with current_user without having a clue what it really is - if it walks like a user, and quacks like a user, you know the rest. The allow_nil: true part helps us even when there is no user - it will just respond with nil, which is legitimate value in cases like name or email (we would probably want to specify some false values on some attributes, but more on that later).
Summary: We used Decorator pattern to achieve duck-typed interface when interacting with current_user object.
I was happy with this approach, changed places where current_user was assigned to use CurrentUser, and ran test suite.
Of course, it was massively failing. Why? Now we get to some bit more interesting and/or confusing parts of Rails.
Using our PORO in associations
It’s pretty common to want use the current_user in association.
Example:
Log.create(kind:, initiator: current_user)
This failed on ActiveRecord::AssociationTypeMismatch error. What does that mean?
Rails/ActiveRecord knows, that initiator should be a user, as we probably defined something like this:
# app/models/log.rb
class Log < ApplicationRecord
belongs_to :initiator, class_name: "User"
end
or, more likely, we even have just
# app/models/some_object.rb
class SomeObject < ApplicationRecord
belongs_to :user
end
, where the class is implicit.
So, when we try to use instance of different class, ActiveRecord is trying to save us from our obvious mistake, and doesn’t allow that.
There was one way to sidestep it, which is ugly, but I will mention it nonetheless:
If we wrote it like this:
Log.create(kind:, initiator_id: current_user.id)
it works.
But let’s not do that, and try to fool ActiveRecord instead!
This was probably most difficult part of all this where I was properly frustrated and claimed defeat multiple times. In the end (as it often is), it was not so difficult when I (with help with my team leader and many people on the internet) realized two things I was missing:
- delegation
I had delegation set by delegate_missing_to, but I didn’t realize this only does instance methods delegation, and I also wanted to delegate Class methods, to behave like the User in all ways.
Once I understood, solution was very simple:
class CurrentUser
...
class << self
delegate_missing_to :User
end
...
end
- pretense
This solved most problems but one - I still needed to convince ActiveRecord that yes, this really is a User object, wink wink just trust me. For this I had to do something bit nasty:
class CurrentUser
...
delegate :is_a?, to: :user, allow_nil: true # This prevents ActiveRecord::AssociationTypeMismatch
...
end
This didn’t happen automatically with delegate_missing_to, as it was not missing - it was defined by inheriting from Object class.
When we do this, CurrentUser is really not easily recognizable from User. It has some drawbacks - we cannot use is_a?, as it will not tell the truth (but there are not many valid uses of that anyway).
Now we can use CurrentUser object instead of a User object all around the place, and ActiveRecord won’t be any wiser.
Summary: I learned a bit more about ActiveRecord magic, and how to bend it to my will.
comparing to other objects
Another thing that we often do with our current_user is to compare it with other objects, like this:
current_user == post.creator
Well, even though current_user behaves as a user in many ways, this will not be true:
<CurrentUser...> is not equal to <User...>
To solve this, we will again delegate:
class CurrentUser
delegate :==, to: :user # This allows us to compare to proper User objects
...
end
And voila, they are equal.
What happens when we change the order - post.creator == current_user?
You guessed it, again we get false. I made it work this way, but I’m not super happy about it:
class User < ApplicationRecord
...
def ==(other)
if other.is_a?(User) && other.respond_to?(:user)
# This is here to be able to compare to CurrentUser decorator
super(other.user)
else
super
end
end
...
end
Yeah, not great. First thing, it will make every comparison to user bit slower.
Another is, User now knows too much - it expect there to be some object or objects that pretend to be of User class, and respond to user method.
This might bring us grief later, so I’m still looking for better way. If you have any ideas, let me know please!
This solved some other failing tests, and showed another problem, and another way Rails use objects.
using with ActiveJob
Next error I got from my tests was Serialization Error for ActiveJob. What is that? Well, when I want to create ActiveJob, using current_user as an argument, Rails needs to send identifier of the object to ActiveJob, which is then able to pick the object up correctly.
For that, I had to add custom ActiveJob serializer:
# app/serializers/current_user_serializer.rb
class CurrentUserSerializer < ActiveJob::Serializers::ObjectSerializer
def serialize?(argument)
argument.respond_to?(:current_user_decorator?) && argument.current_user_decorator?
end
def serialize(current_user)
super("user" => current_user.user)
end
def deserialize(hash)
CurrentUser.new(hash["user"])
end
end
Now this is one case when we really need to recognize if the object is CurrentUser (and, as you remember from previous part, we lost the ability to use is_a?), so we added current_user_decorator? method to it. It’s a bit suspicious code, having to check it this way, but I didn’t yet find any better.
After loading this into app (as explained in Rails guide), ActiveJob knows how to work with CurrentUser.
4 - url helpers
Last problem I encountered with decorator was with url helpers.
When you pass ActiveRecord objects to url_helpers, it is able to make urls from them:
user_path(user) => /user/123
But when I pass random object, it goes terribly wrong:
user_path(current_user) => /user/<CurrentUser...>.
We could once again go around this problem with explicitly using id (user_path(current_user.id) generates correct url), but we don’t want to lose this flexibility Rails provides us with.
Luckily, solutions is extremely simple here - url helpers use to_param method to do this neat trick with converting object to id.
So we just need to define or delegate to_param on our CurrentUser object:
# either
def to_param = id.to_s
# or
delegate :to_param, to: :user, allow_nil: true
With this, I got to passing test suite, which was a nice sight (as I started with hundreds of failing tests). Maybe we will find some other troubles (I’m thinking about ActionMail).
So, we can be almost happy with what our current CurrentUser does. Almost.
stacking dolls situation
There are multiple places where we can change the current_user with code like current_user = Current.user(user). But, as we started to pass CurrentUser as a User all around our app, we are not sure (and we did a lot of work not to care about it) whether user is User or CurrentUser.
So, what happens when we create new CurrentUser, but pass another CurrentUser as a user?
We get a stacking doll situation - we can have current_user, which has CurrentUser as a user, which again has CurrentUser as a user, which…
Maybe it’s not that much of a problem with delegation, but it sure is not great, so let’s get rid of that.
Let’s look at our CurrentUser initiation:
# app/models/current_user.rb, simplified
class CurrentUser
attr_reader :user
def initialize(user)
@user = user
end
def current_user_decorator? = true
end
and think for a moment what we can expect to get:
nil, when user is anonymous- instance of
User, when user is known - instance of
CurrentUser, when passing current_user around
With this understanding, we can change our code to something like this:
def initialize(user = nil)
@user = if user.nil?
nil
elsif user.respond_to?(:current_user_decorator?)
user.user # This prevents stacked CurrentUser objects
elsif user.is_a?(User)
user
else
raise ArgumentError, "Only nil, or instance of User or CurrentUser can be passed"
end
end
We solved two things here - we “unstack” current_user, and we validate the input (so we cannot pass other objects to it by mistake).
Again we used our current_user_decorator? method to recognize instances of CurrentUser.
All right, that’s enough for now. This was really long, and we did a lot of work.
There are still some changes and improvements in our pipeline, but those will wait.