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 definitionsIt 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
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.
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 …