This article was originally published on Rails Designer's Build a SaaS with Rails
User impersonation is a powerful feature when doing support for your SaaS. The amount of times I made annoyed customers, happy campers again because I quickly did the thing they struggled with is many. It lets you see exactly what a user sees, making it easier to debug issues or provide help.
This article builds on top of basic Rails 8 authentication. See all the previous commits in this repo.
Here's how simple it is to use once set up:
# Impersonate a user
impersonate! User.find(42)
# Check if you're impersonating
impersonating? # => true
# Get the original user
original_user # => #<User id: 1>
# Stop impersonating
unimpersonate!
The impersonation automatically expires after 1 hour (which you can adjust in the concern if needed).
Let's start by adding the routes:
# config/routes.rb
Rails.application.routes.draw do
+ resource :impersonation, only: %w[create destroy]
end
Next, update the Current model to handle impersonated users:
# app/models/current.rb
class Current < ActiveSupport::CurrentAttributes
- attribute :session
- delegate :user, to: :session, allow_nil: true
- delegate :workspace, to: :user
+ attribute :session, :impersonated_user_id, :impersonated_session_id
+
+ def user
+ impersonated_user || session&.user
+ end
+
+ delegate :workspace, to: :user, allow_nil: true
+
+ private
+
+ def impersonated_user
+ if impersonated_user_id.present?
+ User.find_by(id: impersonated_user_id)
+ end
+ end
end
Then most of the required logic happens in the Impersonatable concern:
# app/controllers/concerns/impersonatable.rb
module Impersonatable
extend ActiveSupport::Concern
included do
helper_method :impersonating?, :original_user, :impersonation_expires_at
before_action :set_impersonation_context
before_action :expire_impersonation
end
private
IMPERSONATION_EXPIRY = 1.hour
def impersonating?
session[:impersonated_session_id].present?
end
def original_user
if impersonating?
Session.find_by(id: session[:impersonated_session_id])&.user
end
end
def impersonation_expires_at
if impersonating?
Time.zone.parse(session[:impersonated_at]) + IMPERSONATION_EXPIRY
end
end
def set_impersonation_context
Current.impersonated_user_id = session[:impersonated_user_id]
Current.impersonated_session_id = session[:impersonated_session_id]
end
def expire_impersonation
if impersonating? && impersonation_expired?
unimpersonate!
end
end
def impersonate!(user)
if impersonatable?(user)
session[:impersonated_session_id] = Current.session.id
session[:impersonated_user_id] = user.id
session[:impersonated_at] = Time.current
end
end
def unimpersonate!
session.delete(:impersonated_session_id)
session.delete(:impersonated_user_id)
end
def impersonation_expired?
started_at = Time.zone.parse(session[:impersonated_at])
started_at.blank? || started_at.before?(IMPERSONATION_EXPIRY.ago)
end
def impersonatable?(user)
Current.user.present?
&& !impersonating?
&& Current.user.id != user.id
end
end
(note, you can use && and || at the start of the line since Ruby 4!)
This concern provides several safety checks. It prevents from impersonating yourself, blocks nested impersonation and automatically expires sessions after an hour.
Do not forget to include the concern in your ApplicationController.
The controller handling impersonation is straightforward:
# app/controllers/impersonations_controller.rb
class ImpersonationsController < ApplicationController
# TODO: make sure to "lock down this action"
def create
impersonate! User.find(params[:user_id])
redirect_to root_path
end
def destroy
unimpersonate!
redirect_to root_path
end
end
Notice the TODO comment? You absolutely should lock down the create action. But how depends on your current business logic. Other essential security measures could be:
- Add password confirmation as described in this article. Require administrators to re-enter their password before impersonating anyone.
- Add user consent. Give users control by adding an
allow_impersonationboolean to the User model. - Add an audit trail. At the very least, use
Rails.loggerto record who impersonated whom and when.
Make sure to clean up impersonation when users log out:
# app/controllers/sessions_controller.rb
class SessionsController < ApplicationController
def destroy
+ unimpersonate! if impersonating?
terminate_session
redirect_to new_session_path
end
end
Check out the repo on GitHub how this all could be put together in your views.
That's it! But remember: this is just the foundation. Before deploying to production, implement the security measures mentioned above. Lock down who can impersonate, require password confirmation, respect user preferences and add a clear audit trail. π
Top comments (0)