CollabBlogs is an example of a Rails app that facilitates collaborative writing. The basic functionalities of the app are as follows.
- Guest users (non-logged in users) can read non-restricted posts.
- A restricted post is a post that can be viewed by the creator and the collaborators chosen by the creator. If there is no collaborator, only the creator can see the post.
- A post can be edited by many users, if the post creator chooses to collaborate with other users.
- Logged-in users can create posts.
- A post can only be deleted by its creator.
- An administrator user can do anything he/she wants.
Make sure you have the following installed:
- Ruby 2.0
- Bundler (can be installed via
gem install bundler
) - Rails 4.0 (can be installed via
gem install rails
)
Although SQLite is used throughout this app, you can pick any other database you prefer (MySQL, PostgreSQL, etc.) when you build your own app.
Installing via Rails Installer:
-
Download and execute the installer. The installer already includes RVM, Ruby 1.9.3, and Rails 3.2.
-
Install Ruby 2.0 and use it as default
rvm install 2.0.0 rvm use 2.0.0 --default
-
Install Rails 4.0
gem install rails
rake db:migrate
rake db:seed
rails s
rake db:migrate RAILS_ENV=test
rake test
rake db:migrate RAILS_ENV=spec
rake spec
In this section, you will setup authentication using Devise, as well as the models needed for our CollabBlogs web application.
-
Create a new Rails 4.0 app
rails new collab-blogs
-
Add Devise and CanCan to
Gemfile
# Gemfile # ... gem 'rails', '4.0.0' gem 'devise', '3.0.0.rc' gem 'cancan', '1.6.0' gem "select2-rails" # we are going to need this for forms # ...
After that, run bundle
to ensure that dependencies are met.
-
Setup Devise
rails g devise:install rails g devise User
Edit Devise user migration to include
admin
flag# db/migrate/<timestamp>_devise_create_user.rb # ... t.boolean :admin, null: false, default: false ## Database authenticatable t.string :email, :null => false, :default => "" # ...
-
Edit Devise routes. Add the following code snippet to
config/routes.rb
:# config/routes.rb # ... authenticated :user do root :to => 'posts#index', as: :authenticated_root end root :to => "posts#index" # ...
-
Allow necessary parameters for Devise controllers
before_filter :configure_permitted_parameters, if: :devise_controller? protected def configure_permitted_parameters devise_parameter_sanitizer.for(:sign_in) do |u| u.permit(:username, :email) end devise_parameter_sanitizer.for(:sign_up) do |u| u.permit(:name, :email, :password, :password_confirmation) end devise_parameter_sanitizer.for(:account_update) do |u| u.permit(:name, :email, :password, :password_confirmation, :current_password) end end
-
Run database migrations on your terminal using
rake db:migrate
-
Check if the authentication works.
- Try to sign up at http://localhost:3000/users/sign_up
- Try to sign in at http://localhost:3000/users/sign_in
-
Create
Post
scaffold andCollaboration
modelrails g scaffold post user:references title content:text restricted:boolean rails g model collaboration user:references post:references
Edit the migrations as you wish.
# db/migrate/<timestamp>_create_posts.rb # ... t.references :user, null: false t.string :title, limit: 80, null: false t.text :content, null: false, default: "" t.boolean :restricted, null: false, default: false # ... # db/migrate/<timestamp>_create_collaborations.rb # ... t.references :user, null: false t.references :post, null: false # ...
Change the
PostsController
'screate
action so that it assigns current_user as the post owner.# app/controllers/posts_controller.rb # ... # POST /posts # POST /posts.json def create @post = Post.new(post_params) @post.user = current_user # ...
Make sure that
Post
andCollaboration
models declare enough validations and some database relationships.# app/models/collaboration.rb class Collaboration < ActiveRecord::Base belongs_to :user belongs_to :post end # app/models/collaboration.rb class Post < ActiveRecord::Base belongs_to :user has_many :collaborations has_many :collaborators, through: :collaborations, source: :user validates_presence_of :title, :content end
-
Run database migrations on your terminal using
rake db:migrate
-
You can seed the database too using
rake db:seed
if you wish (seedb/seeds.rb
for examples)
The access control rules are declared in app/models/ability.rb
. Run the following command in your Terminal so that CanCan generate the ability.rb
file for you.
rails g cancan:ability
The CanCan Ability class is the place where most of the access control rules are defined. It should include CanCan::Ability module in order to make it work. The class should also be provided with a constructor that accepts current_user
(passed from the Rails controllers) as a parameter.
# app/models/ability.rb
class Ability
include CanCan::Ability
def initialize(user)
end
end
Within the Ability constructor, some methods (can
and cannot
) can be used for declaring the rules. For simplicity, all of the examples below use can
.
Let's pick one simple rule we can define using CanCan, "Logged-in users can create posts." In typical Rails controllers, creating a post requires access to new
and create
actions. Therefore, the ability should allow logged-in users to access both actions. To do so, use can [:new, :create], Post
within the CanCan initializer.
# app/models/ability.rb
class Ability
include CanCan::Ability
def initialize(user)
if user && user.persisted? # Logged in user
# Logged-in users can create posts
can [:new, :create], Post
end
end
end
We can also add another rule for "A post can only be deleted by its creator." The way we declare the ability for that is similar to the previous rule. The difference is that a hash containing a simple condition is used (user ID of the post should be the same as the current_user
's ID)
# app/models/ability.rb
# ...
can [:new, :create], Post
# A post can only be deleted by its creator.
can :destroy, Post, user_id: user.id
# ...
Another rule that looks similar would be allowing administrators to do anything.
# app/models/ability.rb
# ...
if user && user.persisted? # Logged in user
if user.admin?
can :manage, :all
else
# non-admin abilities like we already have above
# ...
end
end
Notice that :all
is used instead of a class.
Do you see the pattern? Feel free to try implementing the ability for "Guest users (non-logged in users) can read non-restricted posts."
By now, we can see that can
(and cannot
) takes two to three arguments. The first argument is an action or the list of actions to be permitted (or prohibited). The second argument takes a resource class (or sometimes a symbol) that we would like to restrict. The third argument, which is optional, takes a hash of simple conditions. That hash will be used for view helpers and building queries using Model#accessible_by
in controllers, which will be discussed in another section.
This section is important if you want to have SQL subselect or joins in your rules, or when you want to make use of Ability class to help you build SQL queries (using Model#accessible_by
). For that purpose, use an array containing SQL conditions and values to be sanitized.
For example, we can write
can :destroy, Post, user_id: user.id
as
can :destroy, Post, ['user_id = ?', user.id]
Unfortunately, this new rule is not quite the same as the old one. The SQL query will only help building queries using Model#accessible_by
; it does not do any view checks. When such SQL-based rule is used, it is mandatory to implement a block that means the same, for the purpose of checking views (using can?
or cannot?
).
can :destroy, Post, ['user_id = ?', user.id] do |post|
post.user_id == user.id
end
Now, the above rule is the same as the one that we have before, can :destroy, Post, user_id: user.id
There is one thing to remember that the block is evaluated when an instance of post is passed for checking. This is why we do not do this:
can :manage, :all do |project|
user.admin? # this won't always get called
end
Instead, we do this:
if user.admin?
can :manage, :all
else
# ...
end
Now that we know how to use SQL conditions and blocks to implement abilities, let's try to use them for abilities that requires subselects like below:
- A restricted post is a post that can be viewed by the creator and the collaborators chosen by the creator. If there is no collaborator, only the creator can see the post.
- A post can be edited by many users, if the post creator chooses to collaborate with other users.
The first set of rule is related to index
and show
actions in typical Rails controller. In those actions, we need to make sure that:
- Non-restricted posts are viewable (related to one of the other rules), or
- Posts created by the currently logged-in user is viewable, or
- If the post is restricted, the posts' collaborators can view the posts.
# for logged-in users
#...
indexable_condition = <<-EOC
restricted = ? OR posts.user_id = ? OR (restricted = ? AND EXISTS
(SELECT * FROM collaborations WHERE collaborations.post_id = posts.id AND collaborations.user_id = ?))
EOC
can [:index, :show], Post, [indexable_condition, false, user.id, true, user.id] do |post|
!post.restricted? || post.user_id == user.id ||
(post.restricted? &&
post.collaborations.where(user_id: user.id).present?)
end
The second set of rule is very similar to above. Feel free to try implementing it yourself.
can?
and cannot?
are the two CanCan view helpers that are very often used. Below are some examples of the usages for edit post links (see in app/views/posts/index.html.erb
or app/views/posts/show.html.erb
).
<% if can? :edit, @post %>
<%= link_to "Edit", edit_post_path(@post) %>
<% end %>
<% if cannot? :edit, @post %>
In order to edit this post, ask the owner to provide you access.
<% end %>
Run your Rails server (rails s
) if you have not done so, and check if your views are displayed according to the rules. If you would like to see more, check out the views under app/views/
. In those views, only the can?
method is being used for simplicity.
We already hide the views according to the defined rules. We also need to protect our controllers using the same rules.
# app/controllers/posts_controller.rb
# ...
def edit
authorize! :edit, @post
end
def update
authorize! :update, @post
respond_to do |format|
# ...
end
# ...
Since adding the rules to every action can be tedious, you can use authorize_resource
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
before_action :set_post, only: [:show, :edit, :update, :destroy]
authorize_resource
# ...
def edit
# already authorized
end
def update
# already authorized
respond_to do |format|
# ...
end
# ...
end
If you need to ensure authorization check are done in the PostsController
, use check_authorization
# app/controllers/posts_controller.rb
class PostsControllers < ApplicationController
check_authorization
# ...
end
On index action, authorization is not needed. To skip authorization check on certain actions, use skip_authorization_check
# app/controllers/posts_controller.rb
class PostsControllers < ApplicationController
check_authorization
skip_authorization_check only: [:index]
# ...
end
If you need to check authorization on all controllers, do so on the ApplicationController except on the Devise controllers.
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
protect_from_forgery
check_authorization :unless => :devise_controller?
# ...
end
For the index
action, we can build our own scopes to filter the posts. If that way is used, it will create duplication with the existing CanCan abilities. Luckily, CanCan provides model scope for filtering based on defined abilities and the actions.
# app/controllers/posts_controller.rb
def index
@posts = Post.accessible_by(current_ability, :index)
end
Remember that Model#accessible_by
uses SQL query or hashes we provide in the previous sections. That method returns the familiar ActiveRecord query interface. You are free to chain and optimize it further.
It would be nice if unauthorized access is handled, since now cases like that are highly possible. CanCan throws CanCan::AccessDenied
when any controller authorization fails. In order to catch the exception in all controllers, use rescue_from CanCan::AccessDenied
in ApplicationController and show an error message to the user.
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
# ...
check_authorization :unless => :devise_controller?
rescue_from CanCan::AccessDenied do |exception|
if current_user.nil? # user is not logged in
session[:next] = request.fullpath
redirect_to login_url, :alert => "Please log in to continue."
else
if request.env["HTTP_REFERER"].present?
redirect_to :back, :alert => exception.message
else
render :file => "#{Rails.root}/public/403.html", :status => 403, :layout => false
end
end
end
# ...
end
If your app is an API or web service, the above code only applies to html format. Say, we have a JSON format. To handle unauthorized access for JSON, simply render a message with forbidden status
# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
# ...
rescue_from CanCan::AccessDenied do |exception|
respond_to do |format|
format.html do
# Same as above
if current_user.nil?
# ...
else
# ...
end
end
format.json do
# Show authorization error using JSON format
render json: { message: "You are not allowed to access this resource." } , status: :forbidden
end
end
end
# ...
end
If you want to log access denied, you can do it within the rescue_from
block too.
rescue_from CanCan::AccessDenied do |exception|
Rails.logger.debug "Access denied on #{exception.action} #{exception.subject.inspect}"
respond_to do |format|
# ...
end
end
The view helper methods (can?
, cannot?
, etc.) and the authorization methods in controllers (authorize!
, etc.) make use of the current_ability
method to apply the access control rules. You rarely need to customize current_ability
for most cases. However, for the cases below, customizing the method is necessary.
-
Your Devise authenticated user method in controllers is not
current_user
. If you prefer to usecurrent_account
, override the defaultcurrent_ability
method.# app/controllers/application_controller.rb class ApplicationController < ActionController::Base #... private def current_ability # using current_account instead of current_user @current_ability ||= Ability.new(current_account) end end
-
You need to restrict access based on request-related values. In some cases, you might want to block users with certain IP address. This can be done without CanCan, but if you do so, there will be two kinds of codes that do authorization: the IP address blocking and the defined abilities. These different ways can make your code complex. Customizing ability is a way to prevent such complexity.
# app/controllers/application_controller.rb class ApplicationController < ActionController::Base #... private def current_ability @current_ability ||= Ability.new(current_user, request.remote_ip) end end # app/models/ability.rb class Ability include CanCan::Ability def initialize(user, ip_address=nil) can :create, Post unless BLACKLIST_IPS.include? ip_address # ... end end
CanCan does not only come with the neat way of doing authorization but also supplementary features to help you debug and test authorizations.
Testing abilities can be done using several test frameworks, such as Test::Unit
, RSpec, and Cucumber. In this section, using Test::Unit
and RSpec for testing abilities will be explained.
Here are examples of test using the default Test::Unit
.
test "user can destroy his/her own post" do
user = User.create!(email: '[email protected]', password: 'Asdfghj12', password_confirmation: 'Asdfghj12')
ability = Ability.new(user)
assert ability.can?(:destroy, Post.new(user: user))
assert ability.cannot?(:destroy, Post.new)
another_user = User.create!(email: '[email protected]', password: 'Asdfghj12', password_confirmation: 'Asdfghj12')
assert ability.cannot?(:destroy, Post.new(user: another_user))
end
You can see more of those under the test
folder. Feel free to run rake test
on your terminal to execute the tests.
Alternatively, you can also use RSpec. CanCan comes with RSpec matcher that makes it fun to test.
require "cancan/matchers"
# ...
ability.should be_able_to(:destroy, Post.new(user: user))
ability.should_not be_able_to(:destroy, Post.new)
You can see more of the specs under the spec
folder. To execute the specs, run rspec spec
on your terminal.
For very particular cases, you might want to debug the defined abilities in Rails console, during test, or during development. In general, the following are steps to debug abilities.
-
Fetch any user and model you would like to debug.
user = User.first # any user you want to check post = Post.first # any model you want to check ability = Ability.new(user)
-
Check if ability behaves correctly for those records.
Alternatively, you can check using model scope if the defined abilities can filter the correct list of accessible records.# see if it behaves correctly ability.can?(:create, post) ability.can?(:create, Post) Post.accessible_by(ability) # see if returns the accessible records Post.accessible_by(ability).to_sql # see the SQL query