How to get Turbo and Pundit to get along

8 minute read

Disclaimer: This “solution” is not suitable for every case. See the #Drawbacks section. Also, I am not the first to explore this topic. See some of the resources and articles I used in the #Links section.

The problem

Many gems like devise, pundit, cancancan, etc. need a globally available current_user variable to work properly. And this makes sense: if the content of a rendered page or partial is to depend on the user who sees the page, Rails needs to know which user the rendered page is for.

1# app/views/posts/_post.html.erb
2
3<% if current_user %>
4  <h3><%= post.title %></h3>
5<% else %>
6  <h3>Login if you want to have access to this cool, but protected, post</h3>
7<% end %>

Let’s say that every time a new post is created, we also want to add a new element to our view. This is quite easy in Rails 7 thanks to after_commit and turbo streams.

 1# app/models/post
 2
 3class Post < ApplicationRecord
 4  ...
 5  after_create_commit -> {
 6    broadcast_append_later_to(
 7      :posts,
 8      partial: "posts/post"
 9    )
10  }
11  ...
12end

The moment a new resource is created, Rails schedules a job that renders the partial and attempts to send the content to the client via a websocket connection.

1# app/views/posts/index.html.erb 
2
3<%= turbo_stream_from :posts %>
4<%= render @posts %>

But in fact, nothing happens. Because we used a global reference in our partial, the job fails and no message is rendered or sent. This behavior is intentional, at least that’s what dhh says. Unfortunately, this is a fairly common use case, and even if you can work around this problem when writing new code, many libraries have global references (like current_user) built deep into their logic.

Working around the problem

Lazy loaded turbo frames

A fairly simple solution would be not to send already rendered messages, but “lazy-loaded” turbo frames. This way, the client only receives “instructions” to fetch new data and provides the server with all the context it needs to render the partial properly. But this solution doesn’t get you very far. There will always be a delay between the moment the content has been replaced by a “lazy-loaded” turbo frame and the replacement by the actual data. You can still get around this by creating skeleton loader animations to at least make it bearable, but depending on how often the data is updated, this will make the user interface very choppy. Imagine you are reading something on a website and suddenly the content disappears, is replaced by an animation, and returns just a few milliseconds later. Yikes!

Just send every user the message

That sounds more like a security problem than an actual solution. But hear me out. Let’s say that we scope the turbo stream channel to the current_user. And every time the model broadcasts, the message is rendered for each user and sent to the scoped channel. This is not as insecure as it sounds, because turbo stream channel subscriptions are tamper-proof. A user should not be able to subscribe themselves to another user scoped channel.

1# app/views/posts/index.html.erb 
2
3<%= turbo_stream_from current_user, :posts %>
4<%= render @posts %>
 1# app/models/post
 2
 3class Post < ApplicationRecord
 4  ...
 5  after_create_commit -> {
 6    User.all.each do |recipient|
 7      broadcast_append_later_to(
 8        [recipient, :post],
 9        partial: "posts/post",
10        locals: { post: self, current_user: recipient}
11      )
12    end
13  }
14  ...
15end

This solution has two obvious problems: If we pass the variable current_user via locals but call a function in our partial that expects the current_user as a global variable, we have to modify the external function to expect a local variable. This is roughly the case with pundit.

1# this is from the pundit documentation 
2
3<% policy_scope(@user.posts).each do |post| %>
4  <p><%= link_to post.title, post_path(post) %></p>
5<% end %>

This code snippet would not work because pundit essentially calls the function pundit_user, which is simply a call to current_user. It would be possible to change policy_scope(@user.posts) to policy_scope(current_user, @user.posts), but depending on how complex and large your codebase is, customizing it becomes tedious and error-prone.

More importantly, this solution has a huge overhead depending on the number of users an app has. It also completely ignores the fact that some users may not have the page in question open or may not even be online.

An attempt of a proper solution (for redis)

The last idea had two obvious problems:

  1. If current_user is not available as a global variable, many functions will stop working and the code will have to be adapted.
  2. Rendering every message for every user is not resource-efficient and could lead to a performance drop.

Make the current_user available as a global variable

The solution to the first problem is actually quite simple. If we take a closer look at the broadcast_* functions of turbo-rails, we will see that the message is rendered using render_format:

1  def render_format(format, **rendering)
2    ApplicationController.render(formats: [ format ], **rendering)
3  end

But instead of simply calling ApplicationController.render, we want to use an instance of ApplicationController.renderer. The documentation says that

ActionController::Renderer allows you to render arbitrary templates without requirement of being in controller actions.

Awesome! We just need to insert the user object to make it available as current_user. Devise is based on Warden, so this is the code needed to set the current_user with any object.

 1  def render_format(format, current_user:, **rendering)
 2    renderer(current_user: current_user).render(formats: [format], **rendering)
 3  end
 4
 5  def renderer(current_user:)
 6    proxy = Warden::Proxy.new({}, Warden::Manager.new({})).tap do |i|
 7      i.set_user(current_user, scope: :user, store: false, run_callbacks: false)
 8    end
 9    return ApplicationController.renderer.new("warden" => proxy)
10  end

I am neither for nor against monkey-patching, but in this case I decided against it because I still want to be able to use the standard turbo-rails methods when only broadcasting messages that do not depend on the current_user. To make this possible, I wrote my own TurboUserBroadcastHelper module, which has essentially the same functions as the Turbo::Streams::Broadcasts module from the turbo-rails gem. To enable asynchronous broadcasting via jobs, I also reimplemented Turbo::Streams::ActionBroadcastJob as a custom Turbo::UserStreams::ActionBroadcastJob that uses the functions of my custom module. Now I can simply call Turbo::UserStreams::ActionBroadcastJob.perform_later with my Channel, Action, Target, and current_user. Great!

Limit the amount of rendered messages to a minimum

The second problem was not so easy to solve. Rails has to somehow know which user would actually be able to receive a broadcast. turbo-rails essentially just uses ActionCable under the hood to broadcast messages.
My first approach was to add the current_user to the connection object when a user connects. That way, you would only have to iterate over the current connections, extract the user, and check if the user is currently subscribed to the channel to send to. In this case, the channel would not even have to be scoped to the user. (Disclaimer: this does not work properly! I just want to state my overall thought process.)

First, I add the current_user to the connection:

 1module ApplicationCable
 2  class Connection < ActionCable::Connection::Base
 3    identified_by :current_user
 4
 5    def connect
 6      self.current_user = find_verified_user
 7    end
 8
 9    private
10
11    def find_verified_user
12      if (verified_user = env["warden"].user)
13        verified_user
14      else
15        reject_unauthorized_connection
16      end
17    end
18  end
19end

The next step is to go through the current connections and find the users who should receive the message. For this, I create the signed_stream_name for my channel:

1signed_stream_name = Turbo::StreamsChannel.signed_stream_name(channel)

Then you can iterate over ActionCable.server.connections to look at each connection. If a user is currently subscribed to a channel with the same signed_stream_name, it will be added to the list of recipients.

1ActionCable.server.connections.each do |connection|
2  current_user = connection.current_user
3  stream_subscriptions = connection.statistics[:subscriptions].flatten.map { |str| JSON.parse str }.filter { |a| a["signed_stream_name"] == signed_stream_name }
4  recipients << current_user if stream_subscriptions.any?
5end

Now the message can simply be sent to all recipients. But this does not work as expected. Going into the console with rails c and trying to broadcast a message to a user, nothing happens. In this case, the culprit is ActionCable.server.connections, which is empty even though the page is open in a browser. ActionCable.server is only per process and therefore does not work in the console or, more importantly, with multiple servers. Depending on the scope of the application, this would lead to unacceptable inconsistencies.

The last solution I found is tailored to redis, because that is what I use. I’m not sure how other adapters work, but it might be possible to implement the same logic with another adapter. Since the channels are scoped to the user anyway, redis just needs to tell which channels currently have active subscribers. Fortunately, there is a redis command called channels that you can call from Rails.

1channel_instances = ActionCable.server.pubsub.send(:redis_connection).pubsub(:channels, "*:#{stream_name_from(channel)}")

Since I’ve scoped my channels as <%= turbo_stream_from current_user, :posts %> to the user, our actual (as yet unsigned) channel names look something like this: Z2lkOi8vY3VyaW91c19tdWNoPy9Vc2VyLzE:posts. Up to the colon, this is just the user’s base64-encoded gid. channel_instances is basically a list of channels that contain “posts.” Now it’s pretty easy to extract the user from the channel and create a list of recipients.

1subscriber_gids = channel_instances.map { |instance| Base64.decode64(instance[/^(.*?)(?=:)/]) }
2subscribers = GlobalID::Locator.locate_many subscriber_gids
3
4subscribers.uniq.each do |recipient|
5  broadcast_action_later_to([recipient, channel], action: :replace, target: target, current_user: recipient, partial: partial, locals: locals)
6end

Drawbacks

Even with my final solution, there is still some overhead depending on which partial is rendered. Take pundit, for example. Even if there are only three different policy checks in a partial, that already gives a total of nine possible combinations of partials that can be rendered. Even if there are nine users, all with different permissions, there is an overhead of one render if only one more user is added. Of course, this only gets worse if you have more users.

Also, while writing a sample application for this post, I came across another global reference that Rails uses in its default templates:

1<% if action_name != "show" %>
2  <%= link_to "Show this post", post, class: "rounded-lg py-3 px-5 bg-gray-200 inline-block font-medium" %>
3  <%= link_to 'Edit this post', edit_post_path(post), class: "rounded-lg py-3 ml-2 px-5 bg-gray-200 inline-block font-medium" %>
4<% end %>

Theoretically, you can do the same with the action_name as with the current_user, but I think this can get confusing quickly.

If you want to have a closer look at the code, you can check out the source code on my GitHub.

Also, here are some of the resources that I used to write this solution: