This week I worked on rendering partials over websockets with Action Cable. I ran into a problem that tied into my authentication and authorization libraries (Devise and CanCanCan respectively).
In my app, Action Cable uses the following code to identify the current user. Something similar can be found in many articles about Action Cable authentication with Devise.
# app/channels/application_cable/connection.rb
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user
def connect
self.current_user = env['warden'].user || reject_unauthorized_connection
end
end
end
When rendering views over Action Cable, for the most part I've been able to simply pass the current user as a local variable.
# app/channels/my_channel.rb
class MyChannel < ApplicationCable::Channel
def subscribed
stream_for current_user
end
end
<!-- partial_with_local_variable.html.erb -->
Hello <%= current_user.name %>
# any ruby file
MyChannel.broadcast_to(
current_user,
ApplicationController.render(
partial: 'partial_with_local_variable',
locals: { current_user: current_user }
)
)
# => "Hello Brian"
However, things get hairy when CanCanCan is involved.
<!-- partial_with_authorization.html.erb -->
<% if can?(:do_something, to_this_object) %>
Here is the text I want to render.
<% end %>
# Any ruby file
MyChannel.broadcast_to(
current_user,
ApplicationController.render(partial: 'partial_with_authorization')
)
# => ActionView::Template::Error - Devise could not find the `Warden::Proxy`
# instance on your request environment. Make sure that your application is
# loading Devise and Warden as expected and that the `Warden::Manager`
# middleware is present in your middleware stack. If you are seeing this on
# one of your tests, ensure that your tests are either executing the Rails
# middleware stack or that your tests are using the
# `Devise::Test::ControllerHelpers` module to inject the `request.env['warden']`
# object for you.
The problem is related to the can?
method. When called in a view, the call chain of that method includes the current_user
method in the controller that renders the view. Since the method is called on the controller, setting a current_user
variable in the view will not prevent this error.
The current_user
method is defined by the Devise gem, which expects to have the variable request.env['warden']
set as a result of an HTTP request being passed through the Warden middleware. Since Action Cable communication is conducted over websockets instead of HTTP, this middleware is not used, the variable is not set, and the broadcast fails.
You might have noticed near the top of this post that there is a reference to env['warden']
in connection.rb. Since the Action Cable connection is established with an HTTP request, the Warden middleware is used and the environment variable we need is present at that time. The solution is to store that object as an attribute on the connection object and then pass it to the renderer when needed for subsequent communication over the websocket. There's a non-Action-Cable-specific description of how to do this in this article, but you can see my preferred version below.
# app/channels/application_cable/connection.rb
# updated from the version at the top of this post.
module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user
attr_reader :warden
def connect
self.current_user = env['warden'].user || reject_unauthorized_connection
@warden = env['warden'] if current_user
end
end
end
# app/channels/my_channel.rb
# also updated from the earlier example
class MyChannel < ApplicationCable::Channel
def subscribed
stream_for current_user
end
# This method will return ApplicationController.renderer with our
# Warden::Proxy instance added to the default environment hash.
def renderer
ApplicationController.renderer.tap do |default_renderer|
default_env = default_renderer.instance_variable_get(:@env)
env_with_warden = default_env.merge('warden' => connection.warden)
default_renderer.instance_variable_set(:@env, env_with_warden)
end
end
end
# Any ruby file
MyChannel.broadcast_to(
current_user,
MyChannel.renderer.render(partial: 'partial_with_authorization')
)
# => Here is the text I want to render.