Nested Authorisation
In an application we are building, we are using CanCan for authorisation and we have nested routes for scoping and permissions. One benefit of nesting controllers is that authorisation can be constructed in a hierarchy that matches the hierarchy of the routes. However, using traditional load_and_authorize_resource
becomes difficult to manage and understand, let alone debug when tests aren't working as expected.
For example, in our routes.rb
file we might have resources such as:
resources :clients do
resources :projects do
resources :items
end
end
And in our app/controllers/items_controller.rb
we would have:
class ItemsController < ApplicationController
before_filter :authenticate_user!
load_and_authorize_resource :client
load_and_authorize_resource :project, :through => :client
load_and_authorize_resource :item, :through => :project
...
This is pretty straight forward, but as the items may need to appear underneath other resources they relate to, this load_and_authorize_resource
usages become more complex. Let's say there is a need to scope items by user as a single user may have items across multiple projects. Our routes.rb
becomes:
resources :clients do
resources :projects do
resources :items
end
resources :users do
resources :items
end
end
And our app/controllers/items_controller.rb
becomes:
class ItemsController < ApplicationController
before_filter :authenticate_user!
load_and_authorize_resource :client
load_and_authorize_resource :project, :through => :client, :if => lambda { params[:project_id] }
load_and_authorize_resource :item, :through => :project, :if => lambda { params[:project_id] }
load_and_authorize_resource :user, :through => :client, :if => lambda { params[:user_id] }
load_and_authorize_resource :item, :through => :user, :if => lambda { params[:user_id] }
...
As this routing nests a few levels deeper, the combination of load_and_authorize_resource
usages become very difficult to read, let alone alter without breaking the outcome for other routes.
Additionally, because the CanCan load_and_authorize_resource
method creates a before filter, when something goes wrong, there is nothing in an exception stack trace indicating which resource was trying to be loaded when the failure occurred.
Since our routes are being defined as a hierarchy, I began investigating how it define the authorisation in a similar hierarchy. Thankfully, CanCan is very well designed, and it's not too hard to set up the CanCan controller object that does the authorisation from your own code. I created a controller instance method load_and_authorize
which takes parameters just the same as the CanCan class method load_and_authorize_resource
. It also takes a block and invokes the block only if the resource was actually loaded by that call. If the resource is already loaded it has no effect. It takes the same arguments as the CanCan class method, but automatically includes the :through => :model
option based on the nesting of a previous block. This allows app/controllers/items_controller.rb
to be expressed as:
class ItemsController < ApplicationController
before_filter :authenticate_user!
before_filter do
load_and_authorize :client do
if params[:project_id]
load_and_authorize :project do
load_and_authorize :item
end
end
if params[:user_id]
load_and_authorize :user do
load_and_authorize :item
end
end
end
end
...
This is a bit longer, but much easier to maintain and to keep changes under one route from adversely affecting another. ie: changes made under the params[:project_id]
section only affect those routes. By adding a few little syntactic sugar helpers, such as load_and_authorize_if_present
, we can shorten the controller to:
class ItemsController < ApplicationController
before_filter do
authenticate_user!
load_and_authorize :client do
load_and_authorize_if_present :project do
load_and_authorize :item
end
load_and_authorize_if_present :user do
load_and_authorize :item
end
end
end
...
Now we have an authorisation definition that is almost identical to our routes definition. This gives us much more clarity about how we construct our authorisation, more visibility into where authorisation failures are actually occuring if tests are failing, and much easier maintainability as changes to our routes and authorisation are done in the same hierarchical manner.
After implementing this in several of our nested controllers, I'm happy to say that this has indeed delivered on these benefits for us.
And here's the code:
Nice! I built a routing DSL for Sinatra that did a very similar thing, couple of years back. By integrating RESTful routing with loading and authorisation, however, I made it magic enough that no-one wanted to actually use it :(
ReplyDelete