skip to content
Kian Reiling

How to get Turbo and Pundit to get along

/

Updated:
Table of Contents

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 depends on who is viewing it, Rails needs to know which user the page is being rendered for.

app/views/posts/_post.html.erb
<% if current_user %>
<h3><%= post.title %></h3>
<% else %>
<h3>Login if you want to have access to this cool, but protected, post</h3>
<% end %>

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

app/models/post.rb
class Post < ApplicationRecord
...
after_create_commit -> {
broadcast_append_later_to(
:posts,
partial: "posts/post"
)
}
...
end

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.

app/views/posts/index.html.erb
<%= turbo_stream_from :posts %>
<%= render @posts %>

But 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 it 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 simple solution would be to send lazy-loaded turbo frames instead of already-rendered messages. 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 doesn’t get you very far. There will always be a delay between the moment the content is replaced by a lazy-loaded turbo frame and the moment the actual data arrives. You can work around this with skeleton loader animations to make it bearable, but depending on how often the data updates, the interface will feel very choppy. Imagine reading something on a website and suddenly the content disappears, gets replaced by an animation, and returns 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 we scope the turbo stream channel to the current_user. Every time the model broadcasts, the message is rendered for each user and sent to their 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 to another user’s channel.

app/views/posts/index.html.erb
<%= turbo_stream_from current_user, :posts %>
<%= render @posts %>
app/models/post.rb
class Post < ApplicationRecord
...
after_create_commit -> {
User.all.each do |recipient|
broadcast_append_later_to(
[recipient, :post],
partial: "posts/post",
locals: { post: self, current_user: recipient}
)
end
}
...
end

This solution has two obvious problems: if we pass current_user via locals but our partial calls a function that expects current_user as a global variable, we have to modify that function to accept a local variable instead. This is roughly the case with pundit.

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

This wouldn’t work because pundit essentially calls pundit_user, which is just a wrapper around current_user. You could change policy_scope(@user.posts) to policy_scope(current_user, @user.posts), but depending on how large your codebase is, this becomes tedious and error-prone.

More importantly, this solution has a huge overhead that scales with the number of users. It also completely ignores the fact that some users may not have the page open or may not even be online.

An attempt at 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 in turbo-rails, we can see that the message is rendered using render_format:

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

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 here’s the code needed to set current_user to any object.

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

I’m neither for nor against monkey-patching, but in this case I decided against it because I still want to use the standard turbo-rails methods for broadcasts that don’t depend on current_user. Instead, I wrote my own TurboUserBroadcastHelper module with essentially the same functions as Turbo::Streams::Broadcasts from turbo-rails. To enable asynchronous broadcasting, I also reimplemented Turbo::Streams::ActionBroadcastJob as a custom Turbo::UserStreams::ActionBroadcastJob that uses the functions from my 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 harder to solve. Rails has to somehow know which users can actually receive a broadcast. turbo-rails essentially just uses ActionCable under the hood to broadcast messages. My first approach was to add current_user to the connection object when a user connects. That way, you’d only have to iterate over current connections, extract the user, and check if they’re subscribed to the target channel. The channel wouldn’t even have to be scoped to the user. (Disclaimer: this does not work properly! I just want to walk through my thought process.)

First, add current_user to the connection:

module ApplicationCable
class Connection < ActionCable::Connection::Base
identified_by :current_user
def connect
self.current_user = find_verified_user
end
private
def find_verified_user
if (verified_user = env["warden"].user)
verified_user
else
reject_unauthorized_connection
end
end
end
end

Next, go through the current connections and find the users who should receive the message. First, create the signed_stream_name for the channel:

signed_stream_name = Turbo::StreamsChannel.signed_stream_name(channel)

Then iterate over ActionCable.server.connections and check each one. If a user is subscribed to a channel with the same signed_stream_name, they get added to the list of recipients.

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

Now the message can simply be sent to all recipients. But this doesn’t work as expected. If you open a rails c session and try to broadcast a message, nothing happens. The culprit is ActionCable.server.connections, which is empty even though the page is open in a browser. ActionCable.server is per process, so it doesn’t work from the console or, more importantly, across multiple servers. Depending on the scale of the application, this would lead to unacceptable inconsistencies.

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

channel_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 %>, the actual (unsigned) channel names look something like this: Z2lkOi8vY3VyaW91c19tdWNoPy9Vc2VyLzE:posts. Everything before the colon is just the user’s base64-encoded gid. channel_instances is a list of channels that contain “posts.” From there, it’s easy to extract the user and build a list of recipients.

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

Drawbacks

Even with this final solution, there is still some overhead depending on which partial is rendered. Take pundit for example: if there are only three different policy checks in a partial, that already gives nine possible render combinations. Even with nine users all having different permissions, adding just one more user means at least one redundant render. And of course, this only gets worse with more users.

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

<% if action_name != "show" %>
<%= link_to "Show this post", post, class: "rounded-lg py-3 px-5 bg-gray-200 inline-block font-medium" %>
<%= 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" %>
<% end %>

You could apply the same approach to action_name as with current_user, but I think that gets 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: