Rails offers filter methods to execute cross-cutting concerns around controller actions. Multiple filters can be attached to any given action, creating a filter chain. But some care is required to ensure you don't get tangled up with complicated filter chains.

Introduction

Rails filter chains are built on ActiveSupport::Callbacks. They offer a convenient structure for executing shared logic before, after or around controller actions. For example, in our AdminFooController below, the before_action method call is used to set up basic authorization before each controller action:

    
class AdminFooController
  before_action :ensure_admin

  def index
  end

  … 

  private

  def ensure_admin
    return if current_user.is_admin?
    render "shared/error", status: :unauthorized
  end
end
    
  
In this case the user will get an unauthorized response unless current_user is an admin. In our scenario we use the before_action to register a callback (namely ensure_admin), and this callback halts the request cycle when the user is not an admin, by calling render.

However, other callbacks exist which can be useful in other scenarios. Contrast the after_action which can be used to register a callback that will fire after the controller action has successfully executed, and will have access to the response object.

Common examples of logic that can be found in controller filters include authentication and authorization checks, application logging and setting response headers.

Clearly controller filters are a very useful concept, so what problems can we encounter?

Well, there are a few things to be aware of. The temptation to cram too much logic into filters is definitely a general anti-pattern which can lead to maintainability problems. But the article today looks at a specific case which caused me some degree of confusion, so I thought it would be worth sharing.

The Problem

We introduce a very basic authorization module FooAuthorizable to demonstrate the effect. The purpose of this controller mixin is to authorize each controller action by checking that the params[:id] is an even number. If params[:id] is not even then a FooAuthorizationError will be raised and the user will receive an error response.

    
module FooAuthorizable
  extend ActiveSupport::Concern

  class FooAuthorizationError < StandardError; end

  included do
    before_action :authorize

    rescue_from FooAuthorizationError do |exception|
     render "shared/error", status: :forbidden
    end
  end

  def authorize
    # Dummy authorization, suppose user can only access even IDs
    raise FooAuthorizationError unless params[:id]&.to_i%2 == 0
  end
end
  

We can see this module employed in the FoosController below:

    
class FoosController < ApplicationController
  include FooAuthorizable

  def show
  end

  def edit
  end
end

This basic controller contains two actions, show and edit. Thanks to the logic included by the FooAuthorizable module we will get successful or unsuccessful responses when we visit different URLs based on whether the id is even or odd, respectively. For example:
  
/foos/1024  # Successful response
/foos/1025 # Unsuccessful response
/foos/1024/edit # Successful response
/foos/1025/edit # Unsuccessful response
  

Now imagine that our project evolves further, and we have some Foo instances that we want to expose to everyone, like public demos. In fact, for any Foo with id<10 we want anyone to be able to view it. So we add new before_action as follows:

    
class FoosController < ApplicationController
  include FooAuthorizable

  before_action :authorize, unless: :public_foo?,  only: :show

  def show
  end

  def edit
  end

  private

  def public_foo?
    params[:id]&.to_i < 10
  end
end

Our intention here is to apply different filtering logic, only when the action is show and only when id<10. And our initial check suggests that this seems to work well; we try to view the different Foo instances and we see the behaviour we expect:
  
/foos/3 # Successful response
/foos/4  # Successful response
/foos/5  # Successful response
/foos/1023 # Unsuccessful response
/foos/1024 # Successful response
/foos/1025 # Unsuccessful response
  
However, when we test the edit URLs we are alarmed to find that it looks like all of our Foo instances are editable! Yikes:
  
/foos/3 # Successful response
/foos/4  # Successful response
/foos/5  # Successful response
/foos/1023 # Successful response
/foos/1024 # Successful response
/foos/1025 # Successful response

So what has happened here? We can see the following note in the actionpack source code for AbstractController (here actionpack/lib/abstract_controller/callbacks.rb):
NOTE: Calling the same callback multiple times will overwrite previous callback definitions
It turns out that, using the same symbol (:authorize) in our new before_action has actually overwritten the original filter. It doesn't matter that our new filter has applied the logic only to the show action. Whilst these options are applied at runtime, they do not contribute to the identity of the filter. Our new filter on the show action overwrites the previous filter which covered all actions, rendering our edit action completely unprotected.

Once we identify this problem, how can we solve it?

There are probably any number of ways to solve the problem and achieve the behaviour we were hoping to model. Here is one option:

  
class FoosController < ApplicationController
  include FooAuthorizable

  skip_before_action :authorize, only: :show
  before_action :authorize_for_show,  only: :show

  def show
  end

  def edit
  end

  private

  def authorize_for_show
    authorize unless public_foo?
  end

  def public_foo?
    params[:id]&.to_i < 10
  end
end
  
In this case we have employed the skip_before_action to disable the existing filter, only for the show. And we then add a new filter method, authorize_for_show. This is applied only to the show action and its unique name ensures that it does not clobber the existing :authorize filter.

Summary

Filters can be applied to Rails controller actions to great effect, for DRYing up cross-cutting concerns like authorization and logging. However, when multiple filters are applied via mixins and inheritance it is easy to construct a tangled mess. This article looked at a case where adding a new before_action filter, targetting a specific action, unintentionally clobbered the existing filter for all other actions.

Comments

  • Submitted by Brian T
    5 months ago

    Thanks for this. Just one of the ways that you can get caught out with controller filter actions! Have lost count of the number of hours I have spent trying to debug issues which arise in controller filter chains.

    • Submitted by Domhnall Murphy
      5 months ago

      Thanks for your feedback. Yep I have experienced that pain. I think controller filter actions work well for cross-cutting concerns with limited logic (e.g. authentication, logic etc.) But if you find yourself putting a lot of logic in there, or with multiple filters that have the potential to interact :-S then you are storing up problems for yourself.

Got your own view or feedback? Share it with us below …