loading...

Rendering Views through Action Cable with Devise and CanCanCan

briankephart profile image Brian Kephart Originally published at briankephart.com on ・3 min read

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.

Posted on by:

briankephart profile

Brian Kephart

@briankephart

I play guitar and bass. Sometimes I code.

Discussion

pic
Editor guide