Adding user impersonation to Rails 8 authentication

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:

  1. Add password confirmation as described in this article. Require administrators to re-enter their password before impersonating anyone.
  2. Add user consent. Give users control by adding an allow_impersonation boolean to the User model.
  3. Add an audit trail. At the very least, use Rails.logger to 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. 🔐

💬 Over to you…

What did you like about this article? Learned something knew? Found something is missing or even broken? 🫣 Let me (and others) know!

Comments are powered by Chirp Form

Published at .