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.
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:
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.
The identified_by
call metaprograms three things for us:
- It creates an
attr_accessor
on the connection forcurrent_user
, which we then use in theconnect
callback. - It also adds a delegated method in your
ApplicationCable::Channel
class that exposes theConnection
’scurrent_user
as an instance method on theChannel
. - 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
:
- Attempts to find the
current_user
via the cookie that we set in theSessionsController
. - 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
.
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:
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.
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:
Next, we add information to ApplicationCable::Connection
about a subscription’s identification:
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:
- The broadcast makes sense to show on sessions for only the account without a shadower.
- 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:
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:
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:
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:
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?
-
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. ↩
-
For more information about sessions, Justin Weiss has a nice article about Rails sessions and how they interoperate with cookies by default. ↩
-
Incidentally, if you use the built-in cookie store from Rails, you can access
session[:user_id]
usingcookies.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. ↩ -
You can also use
cookies.signed
here instead ofcookies.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. ↩ -
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. ↩