Sentinel
Stupid-simple authorization for Rails
Let’s Start with an Example
Sentinels are objects that track permissions. They’re flexible, handy, and very easy to use.
For example, here’s a ForumSentinel:
class ForumSentinel < Sentinel::Sentinel
def creatable?
current_user_admin?
end
def reorderable?
current_user_admin?
end
def viewable?
return true if self.forum.public? || current_user_admin?
(current_user? && self.forum.members.include?(self.current_user))
end
def editable?
return true if current_user_admin?
(current_user? && self.forum.owner == self.current_user)
end
def destroyable?
editable?
end
private
def current_user?
!self.current_user.nil?
end
def current_user_admin?
current_user? && self.current_user.admin?
end
end
So, what’s this guy do? He personally tracks ability to essentially CRUD a forum, based on the current user.
How do we instantiate something like this?
forum_sentinel = ForumSentinel.new :current_user => User.first, :forum => Forum.firstFrom there, you can call methods like any PORO.
But, there’s more.
You may be asking, “What about when I’m looping through a recordset and want to determine permissions on the fly?” That’s a legitimate question, really. I’ve got an easy answer for you. Imagine your view looks something like this:
<% @forums.each do |forum| %>
<% sentinel = ForumSentinel.new(:current_user => current_user, :forum => forum) %>
<% if sentinel.viewable? %>
<div id="<%= dom_id(forum) %>">
<h3><%= link_to h(forum.name), forum %></h3>
<%= textilize(forum.description) %>
</div>
<% end %>
<% end %>
You get the idea. This is still pretty nasty though, since we’re instantiating a new sentinel for each item in the recordset. Let’s handle this in the controller.
class ForumsController < ApplicationController
controls_access_with do
ForumSentinel.new :current_user => current_user, :forum => @forum
end
# ...etc
end
Here, we setup the sentinel in the controller and make a sentinel
view helper to access the instantiated object. So, if @forum
is set up in the show action, we’ll have access to it. The index action, not so much. Not to fear.
<% @forums.each do |forum| %>
<% if sentinel[:forum => forum].viewable? %>
<div id="<%= dom_id(forum) %>">
<h3><%= link_to h(forum.name), forum %></h3>
<%= textilize(forum.description) %>
</div>
<% end %>
<% end %>
Essentially the same view as before, except we’re not instantiating on every line and it keeps the view nice and clean. Notice we call []
, passing in a hash? Those are temporary (as in, that call only) overrides. We assign forum to the current forum we’re looping through and have the sentinel return permissions scoped to itself with whatever overrides.
So, handling permissions in the views are pretty easy now; hell, testing should be pretty simple too, since stubbing out simple methods like viewable?
, editable?
, etc will be cake.
“What about the controllers?” you may ask. Don’t worry about the controllers; this is just as easy.
I introduce to you… grants_access_to
.
class ForumsController < ApplicationController
controls_access_with do
ForumSentinel.new :current_user => current_user, :forum => @forum
end
grants_access_to :reorderable?, :only => [:reorder]
grants_access_to :creatable?, :only => [:new, :create]
grants_access_to :viewable?, :only => [:show]
grants_access_to :destroyable?, :only => [:destroy]
end
grants_access_to
is essentially a before_filter
on crack. It uses the sentinel we’ve set up and calls methods on it. So, if the sentinel returns true when :reorderable? is called, it won’t deny the request. Other filters, however, may.
You need not call methods on the sentinel if you don’t want to. Let’s say you want to check if a user is logged in and an admin (contrived example, I know).
class ForumsController < ApplicationController
controls_access_with do
ForumSentinel.new :current_user => current_user, :forum => @forum
end
grants_access_to :only => [:search] do
current_user && current_user.admin? && sentinel.creatable
end
grants_access_to :only => [:weird] do |s|
s.creatable? && s.forum.private?
end
end
The first grants_access_to
evaluates in the scope of the controller. If the block passed has an arity of 1 (one required block-level variable), it evaluates in the context of the sentinel.
When granting access, you may want to handle different checks differently. You can essentially how the controller handles how things are denied. For example, you may want to include a couple basics within ApplicationController
.
class ApplicationController < ActionController::Base
on_denied_with :forbid_access do
respond_to do |wants|
wants.html { render :text => "You're forbidden to do this", :status => :forbidden }
wants.any { head :forbidden }
end
end
on_denied_with :redirect_home do
redirect_to root_path
end
# this would override the default denial handler
on_denied_with do
respond_to do |wants|
wants.html { render :text => "Unauthorized request", :status => :unauthorized }
wants.any { head :unauthorized }
end
end
end
If these are set up, you can then have your actions deny with whatever you want, like so:
class ForumsController < ApplicationController
controls_access_with do
ForumSentinel.new :current_user => current_user, :forum => @forum
end
grants_access_to :reorderable?, :only => [:reorder], :denies_with => :redirect_home
grants_access_to :creatable?, :only => [:new, :create]
grants_access_to :viewable?, :only => [:show], :denies_with => :unauthorized
grants_access_to :destroyable?, :only => [:destroy], :denies_with => :forbidden
end
Testing the sentinels themselves are fairly easy to do; I won’t go into detail with that.
Testing the controllers, however, can be a bit tricky. Luckily, there are a handful of Shoulda macros (easily grok’able, in case you want to port to RSpec or the like).
Here’s a short example of what you may want to test:
class SentinelControllerTest < ActionController::TestCase
include ActionView::Helpers::UrlHelper
include ActionView::Helpers::TagHelper
def setup
@controller = ForumsController.new
end
sentinel_context do
should_not_guard "get :index"
end
sentinel_context({:viewable? => true}) do
should_grant_access_to "get :show"
end
sentinel_context({:creatable? => false}) do
should_deny_access_to "get :new",
"post :create, :forum => {:name => 'My New Forum'}",
:with => :redirect_to_index
end
sentinel_context({:creatable? => true}) do
should_grant_access_to "get :new",
"post :create, :forum => {:name => 'My New Forum'}"
end
end
sentinel_context
allows you to stub out responses for whatever methods you want on the sentinel. Assign attributes (:current_user
, :forum
, etc) or stub the permission methods themselves (that’s what I would recommend, since your sentinel unit tests should check what the permissions return).
should_not_guard
ensures that grants_access_to
never gets called on that action. should_grant_access_to
and should_deny_access_to
are fairly straightforward. If grants_access_to
denies with a certain handler, you’ll want to pass that handler name in (otherwise, you’ll have failing tests).
Why?
I’m all for putting permissions stuff like this in presenters. However, my presenters have been getting fat, a bit harder to test, and in my mind, that’s just not cool. I also hate trying to test controllers with a ton of contrived examples that are a pain in the ass to set up. This plugin provides the best of all worlds; encapsulated, easy-to-test permissions (controller, unit, AND view) that are simple to set up, extensible with different handlers, and easy to read.
Questions or Comments?
If you like this plugin but have ideas, tweaks, fixes, or issues, shoot me a message on Github or fork/send a pull request. This is alpha software, so I’m pretty open to change.
Copyright © 2009 Joshua Clayton, released under the MIT license