#386 Authorization from Scratch Part 2 pro
- Download:
- source codeProject Files in Zip (112 KB)
- mp4Full Size H.264 Video (54.9 MB)
- m4vSmaller H.264 Video (26.2 MB)
- webmFull Size VP8 Video (29.9 MB)
- ogvFull Size Theora Video (64.3 MB)
In this episode we’ll continue from where we left off last time where we added authorization to an application from scratch. We’ve been using a forum application to do this and we’re now at a stage where users can only access certain parts of the application if they’re logged in and only have full access if they’re logged in as an admin. For example non-admin users can edit topics but not delete them.
This is accomplished though an allow?
method on a Permission
class. The controller and view layers delegate to this method so that all authorization logic is contained in one place. This approach allows us to test this behaviour easily at a low level so that authorization doesn’t need to be tested with high-level request specs.
Improving The Permission Class
The first thing we’ll do in this episode is refactor the Permission
class so that we can have a type of DSL to define the permissions instead of the return
statements that we currently have.
class Permission < Struct.new(:user) def allow?(controller, action) return true if controller == "sessions" return true if controller == "users" && action.in?(%w[new create]) return true if controller == "topics" && action.in?(%w[index show]) if user return true if controller == "users" && action.in?(%w[edit update]) return true if controller == "topics" && action != "destroy" return true if user.admin? end false end end
Instead of inheriting from a struct we’ll give the class an initializer that takes a User model. In it we’ll use an allow
method that takes a controller and an array of actions.
class Permission def initialize(user) allow :users, [:new, :create] allow :sessions, [:new, :create, :destroy] allow :topics, [:index, :show] if user allow :users, [:edit, :update] allow :topics, [:new, :create, :edit, :update] allow_all if user.admin? end end end
This code does something similar to what the return statements do, but it’s a little cleaner and allows us the flexibility of using symbols or strings. Note that we’re using an allow_all
method to give admin users access to everything. We need to define these two methods and change the behaviour of the allow?
query method. We’ll start by defining allow_all
which will set an instance variable that we can check at a later point. The allow method will be a little trickier as it takes one or more controller names and action names. A quick way to handle this is to wrap the arguments in a call to Array
. This will make a single item into an array with one element but return an array that is passed in unchanged. We then loop through each controller and action and store this information in an instance variable so that we can use it later. We’ll store this data in a hash with an array for a key as a quick way to do a lookup of multiple items. We can pass in either strings or symbols as controller or action names and we need to standardize on either one or the other to make it easier to look them up. We’ll use strings as using symbols can introduce memory leaks if the content is dynamic.
def allow_all @allow_all = true end def allow(controllers, actions) @allowed_actions ||= {} Array(controllers).each do |controller| Array(actions).each do |action| @allowed_actions[[controller.to_s, action.to_s]] = true end end end
The last step here is to implement the allow?
method which will check if @allow_all
is set to true or if the @allowed_actions
hash has a value of true for the key with the controller and action that’s passed in. We should also check that these instance variables are defined before checking their values to reduce the chance of warnings but we don’t do that here.
def allow?(controller, action) @allow_all || @allowed_actions[[controller.to_s, action.to_s]] end
When we run our tests now they all pass so our refactoring of the Permission
class has worked. That said the class is more complex than it was before and it’s up to us to decide whether this extra complexity is worth it in the long run to have a nicer way to set the permissions. For applications with complex permission requirements it probably is.
We might not want to go in this direction if we want to move the permission definitions elsewhere, such as the database. In this case the allow?
method could check the database each time to see if the given user has permission to perform a specific action. Whatever the case having this Permission
class as a central hub of the authorization allows us to easily change how it all works without affecting the rest of the application.
Restricting Access to Certain Items
Let’s move on now to adding some more features to our authorization. Users can currently edit any topic but we only want them to be able to edit a topic that they’ve created themselves. How do we add that restriction? We aren’t currently passing in enough information to perform this authorization check. We’re passing in a controller and action but we also need to pass in the resource record so that we can perform authorization based on various attributes of it. We could pass this resource in as an optional third argument.
Let’s test-drive this behaviour. In our Permission
spec we’ll alter the tests for a non-admin user. We’ll move the User
record that we create out into a let
call so that we can access it directly. This will also allow us to build a topic that is assigned to that user and we’ll also create one that isn’t assigned to a user. We’ll then modify the tests for the edit
and update
actions so that the user shouldn’t be able to access these if we either don’t pass a topic or if we pass in a topic that they haven’t created. For topics that they have created they should be able to access these actions.
describe Permission do # Specs for guests and admins omitted. describe "as member" do let(:user) { create(:user, admin: false) } let(:user_topic) { build(:topic, user: user) } let(:other_topic) { build(:topic) } subject { Permission.new(user) } it { should_not allow("topics", "edit") } it { should_not allow("topics", "update") } it { should_not allow("topics", "edit", other_topic) } it { should_not allow("topics", "update", other_topic) } it { should allow("topics", "edit", user_topic) } it { should allow("topics", "update", user_topic) } # Specs for other actions omitted. end end
Of course these tests fail as the allow?
method doesn’t yet expect a third argument. It’s easy enough to add this as an optional argument but how do we perform an authorization check on its attributes? What we’ll do is have the allow
method accept a block that we can pass that object into so that we can perform the check there. We’ll modify the permissions that we specify in the Permission
class so that the current topic is only checked for the edit
and update
actions.
def initialize(user) allow :users, [:new, :create] allow :sessions, [:new, :create, :destroy] allow :topics, [:index, :show] if user allow :users, [:edit, :update] allow :topics, [:new, :create] allow :topics, [:edit, :update] do |topic| topic.user_id == user.id end allow_all if user.admin? end end def allow?(controller, action, resource = nil) @allow_all || @allowed_actions[[controller.to_s, action.to_s]] end
Now we have to fill in the missing pieces between the definition and the check. We’ll modify the allow
method to take a block. We’ll need to keep track of this so we’ll set it as a value in our allowed_actions
hash. If no block is passed in we’ll set the value to true
.
def allow(controllers, actions, &block) @allowed_actions ||= {} Array(controllers).each do |controller| Array(actions).each do |action| @allowed_actions[[controller.to_s, action.to_s]] = block || true end end end
This means that allow?
will now return either true
or a block. We’ll set this to a local variable and perform a check on it to see that it’s either true
or that we have a resource and that calling allowed and passing in that resource returns either true
or false
.
def allow?(controller, action, resource = nil) allowed = @allow_all || @allowed_actions[[controller.to_s, action.to_s]] allowed && (allowed == true || resource && allowed.call(resource)) end
Our tests all now pass again. Now when we perform the allow
check in the view we can pass in the topic so that the edit and destroy links are only shown for the appropriate user.
<% if allow? "topics", "edit", topic %> | <%= link_to "Edit", edit_topic_path(topic) %> <% end %> <% if allow? "topics", "destroy", topic %> | <%= link_to "Destroy", topic_path(topic), method: :delete %> <% end %>
When we reload the page now only the topic that we created has an “edit” link.
This seems to have worked but when we click that link we stay on the index page and we see the “Not authorized” message. This is because the controller isn’t passing in the resource to the authorization. Before we try to fix this we’ll duplicate the bug in a high-level request spec.
it "edit owned topic as member" do log_in admin: false topic = create(:topic, user: current_user) visit edit_topic_path(topic) page.should_not have_content("Not authorized") end
In this test a non-admin user creates a topic then tries to edit it and shouldn’t see the text “Not authorized” when they do. This test fails, just as we’d expect. To make is pass we’ll need to pass in the current resource into the allow? method that we call in the ApplicationController
’s before_filter
. This can be a little tricky and there are a variety of ways we can do this. We’ll add a current_resource
method to the ApplicationController
. This will return nil
but we can override it in each controller. We can then call this in authorize
.
def current_resource nil end def authorize if !current_permission.allow?(params[:controller], params[:action], current_resource) redirect_to root_url, alert: "Not authorized." end end
In the TopicsController
we’ll override current_resource
to return the current Topic
which it finds by the id
parameter if that parameter exists.
def current_resource @current_resource ||= Topic.find(params[:id]) if params[:id] end
We could go a step further and use this method every time we fetch a Topic
in the other actions to reduce duplication. Our tests are now all passing again and so we should now be able to edit the topic in the browser and when we click the link we now see the edit page like we’d expect.
Restricting Access to Attributes
With this working let’s tackle another problem with authorization: restricting access to specific attributes. Let’s say that in the edit form we don’t want members to be able to mark a topic as sticky but that admins should be able to do this. We’ll start in the form’s view because we want that field to be hidden if the user doesn’t have permission. Starting here allows us to define the interface and how we want it to behave. We’ll define a helper method called allow_param?
that we can pass a model name and an attribute name and we’ll use this for each attribute that we want to perform an authorization check on. We’ll use this method to wrap each field in the form.
<% if allow_param? :topic, :name %> <div class="field"> <%= f.label :name %><br /> <%= f.text_field :name, size: 50 %> </div> <% end %> <% if allow_param? :topic, :sticky %> <div class="field"> <%= f.check_box :sticky %> <%= f.label :sticky %> </div> <% end %>
We’ll need to define this method and delegate it to the current_permission
object in the ApplicationController
just like we did with allow?
.
delegate :allow?, to: :current_permission helper_method :allow? delegate :allow_param?, to: :current_permission helper_method :allow_param?
We need to implement this behaviour in the Permission
class, which we’ll test drive in the permissions spec. When we’re logged in as a member we should be able to access a topic’s name but not its sticky
attribute. An admin user should be able to access any attributes. We’ll add specs to test this behaviour.
describe "as member" do # Other specs omitted it { should allow_param("topic", "name") } it { should_not allow_param("topic", "sticky") } end describe "as admin" do subject { Permission.new(build(:user, admin: true)) } it { should allow("anything", "here") } it { should allow_param("anything", "here") } end
We still need to implement the allow_param
matcher. This will work in a similar way to the allow
matcher that we’ve already defined.
RSpec::Matchers.define :allow_param do |*args| match do |permission| permission.allow_param?(*args).should be_true end end
Our new tests fail because our Permission
class doesn’t respond to allow_param?
. We want the way we define this permission to work in a similar way to the allow
method but instead of specifying a controller and an action here we’ll specify a model and an attribute. We’ll need to define an allow_param
and an allow_param?
method in this class.
def allow_param(resources, attributes) @allowed_params ||= {} Array(resources).each do |resource| @allowed_params[resource.to_s] ||= [] @allowed_params[resource.to_s] += Array(attributes).map(&:to_s) end end def allow_param?(resource, attribute) if @allow_all true elsif @allowed_params && @allowed_params[resource.to_s] @allowed_params[resource.to_s].include? attribute.to_s end end
The allow_param
method accepts either a single resource or an array of resources and an attribute or an array of attributes and stores them in an hash with an array value that stores the attributes. The allow_param?
method can then check this hash to see if that resource exists in the array and, if so, whether the requested attribute exists in the values. We can now add the permissions for the Topic
’s name attribute in initialize
.
def initialize(user) allow :users, [:new, :create] allow :sessions, [:new, :create, :destroy] allow :topics, [:index, :show] if user allow :users, [:edit, :update] allow :topics, [:new, :create] allow :topics, [:edit, :update] do |topic| topic.user_id == user.id end allow_param :topic, :name allow_all if user.admin? end end
When the tests run now they all pass again and if we visit the edit page for a topic we’ve written while logged in as a non-admin user we won’t see the checkbox for the sticky
attribute.
We’ve only fixed one side of the problem, though. If we POST directly to the update
action and supply a sticky
parameter the topic will still be updated. To illustrate this problem we’ll write a new request spec that tests it.
it "cannot create sticky topic as member" do user = create(:user, admin: false, password: "secret") post sessions_path, email: user.email, password: "secret" post topics_path, topic: {name: "Sticky Topic?", sticky: "1"} topic = Topic.last topic.name.should eq("Sticky Topic?") topic.should_not be_sticky end
We usually use Capybara for request specs but here we’re using the Rails integration tests directly so that we can post requests without going through the web interface. In this test we create a non-admin user then log them in. We then try creating a sticky topic by POSTing to update
then test that the newly-created topic has a name
but isn’t sticky
. When we run this it fails as we can currently mark a topic as sticky.
We’re using strong parameters in this application, like we showed in episode 371. This is because it’s a lot more flexible with permissions and because it will be the default in Rails 4. We have a topic_params
method in the TopicsController
which permits the name
and sticky
parameters on the Topic model.
def topic_params params.require(:topic).permit(:name, :sticky) end
We’ll make this more dynamic so that it only accepts the parameters that are marked as accessible in the Permission
class. Ideally we’d like it so that this controller doesn’t have to worry about this at all and can access the topic parameters directly and that we handle the authorization with a before_filter
. Our ApplicationController
already has a before_filter
that calls an authorize
method. We’ll modify this method so that if the current user is authorized we can permit certain parameters and we’ll do this by calling a new permit_params!
on the Permission
object and passing in the parameters. Note that permit_params!
has an exclamation mark at the end as it will modify the parameters that are passed to it.
def authorize if current_permission.allow?(params[:controller], params[:action], current_resource) current_permission.permit_params! params else redirect_to root_url, alert: "Not authorized." end end
Next we’ll write this method in the Permission
class.
def permit_params!(params) if @allow_all params.permit! elsif @allowed_params @allowed_params.each do |resource, attributes| if params[resource].respond_to? :permit params[resource] = params[resource].permit(*attributes) end end end end
This method accepts the params
hash from the controller and first checks to see if the current user has access to everything. If so we call permit!
on the params hash which is a method that strong parameters provides to allow everything. Otherwise we loop through all the allowed parameters and call permit on each of them to define them as allowed. When we check our tests now they almost all pass, apart from one that is raising a ForbiddenAttributes
exception when we try to update a topic as an admin. For some reason admins aren’t able to update a topic’s attributes. It turns out that the permit!
method we’re calling on the params for admins doesn’t work recursively. If there are nested hashes in the parameters these won’t be permitted. This is a known issue as is fixed in the Git repository for Strong Parameters so there should be a new gem release that includes this fix released soon. In the meantime we can change our gemfile so that it references the Git repository instead of the gem.
gem 'strong_parameters', github: 'rails/strong_parameters'
After we run bundle
again to install the new version the tests all pass again.
Testing Permissions
Our authorization system is now feature-complete. As a member we can only edit topics that we own and even then we can’t mark them as sticky. If we’re an admin user, however, we can mark topics as sticky and edit any topic. If we’re logged out then we can’t really do anything except read existing topics.
Next we’ll give you a few tips about testing with permissions. It’s a good idea to keep permission logic out of request specs so that we can avoid all the branching possibilities. It’s tempting to always sign in as an admin when testing at this high level so that we don’t have to worry about permissions but we shouldn’t really do this. Instead we should sign in as the lowest role possible to successfully execute the test. For example the request spec that lists the topics is done as a guest user. This way we don’t run into the case where we accidentally forget to grant permission to a guest or member to perform a specific action which is easy to do if we’re always signed in as an admin. We do have a few request specs that are specifically about testing authorization but these are kept to a minimum and used mainly to test-drive the functionality rather than for testing all the branching paths.
In the permissions spec we follow the practice where we have one assertion per spec with a lot of it
blocks. This can make the tests slow as any setup code will be run for every it
call. This means that we might want to consider collapsing multiple tests into a single spec to speed things up. This could look like this:
it "allows topics" do should allow(:topics, :index) should allow(:topics, :show) should allow(:topics, :new) should allow(:topics, :create) should_not allow(:topics, :edit) should_not allow(:topics, :update) should_not allow(:topics, :edit, other_topic) should_not allow(:topics, :update, other_topic) should allow(:topics, :edit, user_topic) should allow(:topics, :update, user_topic) should_not allow(:topics, :destroy) should allow_param(:topic, :name) should_not allow_param(:topic, :sticky) end
If one of these assertions fails RSpec will show us the exact line that has failed so it’s easy to find the failure even if we have a lot of assertions in a single spec.
In Summary
We’ve covered a lot in these two episodes on authorization so let’s review the end result of what it takes to add authorization from scratch. We’ll start in the ApplicationController
where we’ve added a before_filter
called authorize
and a couple of method delegations. We also have three methods defined here including authorize
, which is run before each request is processed. In the TopicsController
we implemented a current_resource
method and we’d need to implement something like this for each RESTful-style controller in our application.
Finally we have our Permission
class. This contains quite a lot of code but it’s all nicely isolated here and the only part we have to manage and be concerned with is the initialize
method where we define the permissions for each type of user. This type of setup generally works well but for applications with complicated permissions this class can get a little out of hand. In these cases we can split this up into multiple classes, each of which handles the permissions for a specific role, something like this:
module Permissions def self.permission_for(user) if user.nil? GuestPermission.new elsif user.admin? AdminPermission.new(user) else MemberPermission.new(user) end end end
Here we’ve split the Permission
class up into four classes all of which are in a new Permissions
module. This has a permission_for
method which is designed to be called from a controller and which takes the current user. This will return a Permission
instance depending on the user’s role. This way the permission definition logic for each role can be kept separate. As an example our MemberPermission
class looks like this:
module Permissions class MemberPermission < BasePermission def initialize(user) allow :users, [:new, :create, :edit, :update] allow :sessions, [:new, :create, :destroy] allow :topics, [:index, :show, :new, :create] allow :topics, [:edit, :update] do |topic| topic.user_id == user.id end allow_param :topic, :name end end end
All the complex logic is contained within the BasePermission
class that each role’s permission class inherits from. This is something that we won’t need to visit or change regularly so that we can focus entirely on we want the permissions for each role defined. Splitting up the classes like this based on each role can easily be done with CanCan if you want to use that instead of starting from scratch.