DEV Community

Rails Designer
Rails Designer

Posted on • Originally published at railsdesigner.com

Adding user impersonation to Rails 8 authentication

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!
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

(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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)