Implementing account impersonation in ActionCable

A woman being followed by her shadow.

Account impersonation is when you allow accounts (usually privileged accounts, e.g. support staff or developers) to operate your Rails application as if they are the owner of another account entirely. It’s a helpful feature to have when diagnosing issues that your customers write in about. There are plenty of gems that implement impersonation for your Rails controllers, but I wasn’t able to turn up any suggestions for implementing this for ActionCable.

This post outlines a simple method for adding impersonation to your Rails application in both your controllers and your ActionCable channels. I discuss some ActionCable features that are not well-documented and that I found a little confusing, then show examples of how to use the same pattern for identifying both your active account and your impersonating account — called a “shadower” in this article — in your channels.

Active accounts in controllers

In Rails tutorials, it’s common to see the authors reach for a batteries-included authentication gem like Devise, Clearance, or AuthLogic. For the purposes of this post, we ignore all of these gems and hand-wave over the authentication bits in favor of a simple example. In the interest of shared understanding, we use the ApplicationController#current_user pattern common to all three of the big libraries1.

class SessionsController < ActionController::Base
  def create
    if authenticated_user
      reset_session
      session[:user_id] = authenticated_user.id

      redirect_to root_path
    else
      # Do some error handling
    end
  end

  private

  def authenticated_user
    # Do something here to authenticate the session or return `nil`
  end
end

class ApplicationController < ActionController::Base
  protected

  def current_user
    @_current_user ||= User.find_by(id: session[:user_id])
  end
  helper_method :current_user
end

In this example, we see a simple way to load the operating User from the database. In our sessions controller, we authenticate the session and then store an account identifier in the session. In our ApplicationController, we then use that identifier to look the account up and set up our current_user value. This is the simplest implementation and works well enough for our purposes.

Rails gives us some straightforward, but powerful, tools for making the stateless web feel like we are writing a stateful application. Session management is one of those tools2. By storing a small value in the session, we suddenly have access to the current account in any controller descending from ApplicationController. Neat!

Extending this to ActionCable channels

As nice as sessions are, they are a construct only available inside the controllers of your applications. Since ActionCable channels are not controllers, we must find a different way to share information between our controllers and channels. Even the controversial ActiveSupport::CurrentAttributes tool can’t help us here because your ActionCable channel works on a separate thread from the controller serving the page that starts the channel.

The behavior of decoding the session cookie belongs to the controller and is not accessible in ActionCable channels3. However, that does not mean that cookies are inaccessible in channels, only that the session isn’t available. In fact, the Rails Guides give an example of using a cookie to identify the account for the channel. Wire this in as an encrypted cookie4 at the same place that you set the session value:

class SessionsController < ActionController::Base
  def create
    if authenticated_user
      reset_session
      session[:user_id] = authenticated_user.id
      cookies.encrypted[:user_id] = { values: authenticated_user.id, expires: 1.hour }

      redirect_to root_path
    else
      # Do some error handling
    end
  end
end

An important decision here is the expires value. Depending on what you’re going to do with ActionCable, you might need this to be longer or shorter. If you’re using ActionCable only for short-lived actions and most of the time your customers navigate through your application via controllers, prefer a short expiry. But if you’re building something like a persistent chat service where your customers spend a long time on one page, a longer expiry is likely what you want.

The trade-off between too short and too long is a trade-off between convenience and security. If you have an overly long expiry and an attacker manages to get hold of your customer’s cookie, then the attacker has a long time to perform session hijacking attacks against your system and your customer. But if you have an expiry that is too short and the customer gets disconnected — whether that’s through a dropped connection, a redeploy, or any other of a number of reasons — they either must refresh the page for their web socket to reconnect or use some JavaScript to refresh their cookie from an API endpoint.

Once you’ve decided upon an expiry length and set your cookie, access its value in your channel like the Rails guide shows.

class ApplicationCable::Connection < ActionCable::Connection::Base
  identified_by :current_user

  def connect
    unless (self.current_user = User.find_by(id: cookies.encrypted[:user_id]))
      reject_unauthorized_connection
    end
  end
end

The identified_by call metaprograms three things for us:

  1. It creates an attr_accessor on the connection for current_user, which we then use in the connect callback.
  2. It also adds a delegated method in your ApplicationCable::Channel class that exposes the Connection’s current_user as an instance method on the Channel.
  3. It sets up connection identification for the connection when the browser initiates a subscription. This helps ActionCable to route broadcasts meant for a particular account.

Our simple implementation of #connect:

  1. Attempts to find the current_user via the cookie that we set in the SessionsController.
  2. If the cookie is not set or the User does not exist, the channel rejects the connection and disconnects.

Once we have this setup, we can stream broadcasts in a particular channel for the current_user.

class CommandsChannel < ApplicationCable::Channel
  def subscribed
    stream_for current_user
  end
end

This simple channel now streams any messages you broadcast to the current_user. To broadcast a message, use the ActionCable::Channel.broadcast_to method, like so:

CommandsChannel.broadcast_to(User.first, data)

Simple impersonation in controllers

Now that you have your current_user set up for both controllers and channels, it’s time to add the behavior for shadowing a user.

class ShadowsController < ActionController::Base
  # Secure this to make sure it's only accessible
  # to those who should have access
  def create
    if shadowed_user
      session[:shadower_id] = session.delete(:user_id)
      session[:user_id] = shadowed_user.id

      redirect_to root_path
    else
      # Do some error handling
    end
  end

  private

  def shadowed_user
    @_shadowed_user ||= User.find_by(id: params[:user_id])
  end
end

class ApplicationController < ActionController::Base
  protected

  def current_user
    @_current_user ||= User.find_by(id: session[:user_id])
  end

  # Secure this to make sure it's only accessible
  # to those who should have access
  def shadower
    @_shadower ||= User.find_by(id: session[:shadower_id])
  end
end

This simple addition retains the meaning of session[:user_id] to mean “the account whose permissions we should use for accessing resources” and layers on a shadower, an account who is “shadowing” or “looking over the shoulder” of the other account.

As an exercise, think about how you would adjust your access policies for specific resources based on who is shadowing whom.

Impersonation in channels

Extending our shadowing system to ActionCable channels is simple, but leaves some open questions as to how to handle the feature. First, we set the cookies in the controller:

class ShadowsController < ActionController::Base
  def create
    if shadowed_user
      session[:shadower_id] = session.delete(:user_id)
      session[:user_id] = shadowed_user.id
      cookies.encrypted[:user_id] = { value: session[:user_id], expires: 1.hour }
      cookies.encrypted[:shadower_id] = { value: session[:shadower_id], expires: 1.hour }

      redirect_to root_path
    else
      # ...
    end
  end

  private

  def shadowed_user
    @_shadowed_user ||= User.find_by(id: params[:user_id])
  end
end

Next, we add information to ApplicationCable::Connection about a subscription’s identification:

class ApplicationCable::Connection < ActionCable::Connection::Base
  identified_by :current_user, :shadower

  def connect
    unless (self.current_user = User.find_by(id: cookies.encrypted[:user_id]))
      reject_unauthorized_connection and return
    end
    self.shadower = User.find_by(id: cookies.encrypted[:shadower_id])
  end
end

Notice how we do not reject the connection when a shadower doesn’t exist; that’s because it’s the normal mode of operation for the app. Now there are two states for “who is viewing this session”: an account with a shadower and one without. Depending on the type of application and the type of channel that you’re writing, you now have to think about broadcasting strategies for messages within your channel.

Broadcasting strategies

When there is only a single account viewing a session at a time, it’s easy to decide the receiver for a given broadcast: the account. However, when you have two means of identifying a session, you have to decide between two different broadcasting strategies for messages5:

  1. The broadcast makes sense to show on sessions for only the account without a shadower.
  2. The broadcast makes sense to show on sessions for an account with a shadower.

Broadcasting to accounts, regardless of shadower

For messages that should show up for any session involving the shadowed account (whether operated by the account owner or a shadower), you must stream messages for that account and broadcast to that account:

class CommandsChannel < ApplicationCable::Channel
  def subscribed
    stream_for current_user
  end
end

As shown in the table below, messages on this stream get delivered anywhere the “current user” matches the “broadcast to,” whether or not the session involves a shadower.

Current User Shadower Broadcast to Received
Alice nil Alice x
Alice nil Bob
Alice nil [Alice, Bob]
Alice Bob Alice x
Alice Bob Bob
Alice Bob [Alice, Bob]

This broadcasting strategy is suitable for functionality like notifications, messages, or other streams of information.

Broadcasting only to shadowed sessions

For messages that should show up on any current-shadowed sessions, stream messages to an identifier that uses both accounts by passing them as an array:

class CommandsChannel < ApplicationCable::Channel
  def subscribed
    stream_for [current_user, shadower]
  end
end

This channel streams messages only where shadower currently shadows current_user, as shown below. Note that if shadower is nil, it’s not equivalent to stream_for current_user because the identifier looks different.

Current User Shadower Broadcast to Received
Alice nil Alice
Alice nil Bob
Alice nil [Alice, Bob]
Alice Bob Alice
Alice Bob Bob
Alice Bob [Alice, Bob] x

This broadcasting strategy is suitable for functionality like transactional actions where the message governs the action to take after a command.

Mixing and matching streams

If you have a general channel like, in our example, a CommandsChannel that’s accessible for both the shadowed account and the shadower, you can stream messages from multiple sources:

class CommandsChannel < ApplicationCable::Channel
  def subscribed
    stream_for current_user
    stream_for [current_user, shadower]
  end
end

This channel streams messages that match either of the preceding patterns, as shown in the following table.

Current User Shadower Broadcast to Received
Alice nil Alice x
Alice nil Bob
Alice nil [Alice, Bob]
Alice Bob Alice x
Alice Bob Bob
Alice Bob [Alice, Bob] x

If you use the pattern shown here, you might prefer this construction, where it subscribes to a shadowed session if there is a shadower, otherwise subscribing as the current user:

class CommandChannel < ApplicationCable::Channel
  def subscribed
    stream_for [current_user, shadower].compact
  end
end

The semantics of issuing a broadcast get a little tricky here. As an exercise, think of how you would write an easy-to-use interface for broadcasting all of these types of strategies without needing to litter your codebase with that logic.

Conclusion

Because ActionCable does not, necessarily, have access to the session from the browser, you have to replicate the behavior for tracking the current user and shadower in the encrypted (or signed) cookie store. There are security implications with your decision, so be aware when you’re selecting an expiry. Tracking a session identifier that you control outside the cookie store is also a good idea; keep in mind the sessions section of the Rails security guide for tips on securely handling cookies.

Once you have the shadowing system set up, you must think about the broadcasting strategy for each channel that you implement. Depending on the type of information you broadcast to the channel, you might want to make sure to only broadcast to sessions by the same “identity pair” so that messages don’t intermingle between an account and a shadowed account; these types of messages are for transactional changes. Or, perhaps, you have something like a notifications channel that you want to send to anyone viewing an account. Your choice of strategy can make either of those things happen.

Do you allow shadowing in your application? If so, who do you allow to shadow? Do customers shadow each other, or is it only for support?


  1. I truly detest the term “user” to mean a person who is currently operating our software. Only in drug addiction, abusive relationships, and software development do we find it acceptable to call someone a “user”. But since this is the established pattern, we bend to societal pressure for clarity. ↩︎

  2. For more information about sessions, Justin Weiss has a nice article about Rails sessions and how they interoperate with cookies by default. ↩︎

  3. Incidentally, if you use the built-in cookie store from Rails, you can access session[:user_id] using cookies.encrypted[Rails.application.config.session_options.fetch(:key, '_session')]. I don’t recommend doing this because there’s no guarantee that it will continue to work in future versions of Rails outside of being briefly mentioned in the ActionCable overview. ↩︎

  4. You can also use cookies.signed here instead of cookies.encrypted. I recommend an encrypted cookie instead of a signed cookie because there’s no reason you should expose an internal identifier for your models. ↩︎

  5. In actuality, there are four different configurations: current_user, [current_user, shadower], shadower, and [shadower, current_user], but the last two don’t have cases where I think it makes sense to use them if you’re using the former two. “Shadower-only” streams don’t do what you’d expect them to; they get delivered to other sessions of the shadower that might still be open without shadowing anyone. [shadower, current_user] reads more confusingly to me than [current_user, shadower] and has the same semantics, as long as you’re consistent. ↩︎