Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Move to CanCanCan for authorization #2023

Merged
merged 29 commits into from Nov 3, 2018
Merged
Show file tree
Hide file tree
Changes from 28 commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
ffa65d4
Add cancancan and the first ability definitions for site_controller
gravitystorm Mar 1, 2018
2ab3d56
don't check authorization everywhere
cflipse Jun 8, 2018
b16aa11
fix tests for site controller
cflipse Jun 8, 2018
6da3ece
use token in ability checks
cflipse Jun 8, 2018
6b44a19
use a controller method to handle cancan denials
cflipse Jun 9, 2018
5232914
Implement the cancan filters for diary entries
cflipse Jun 9, 2018
ac7c45b
add test helper to set oauth tokens
cflipse Jun 9, 2018
060c686
Use cancancan to authorize user_preference_controller
cflipse Jun 9, 2018
2a44ff5
fix and improve ability coverage to account for tokens
cflipse Jun 10, 2018
464c7f8
Update capabilities check to actually reflect the existing logic
cflipse Jun 10, 2018
4d20a2c
Authorize actions on GeocoderController with CanCanCan Ability
benreyn Jun 10, 2018
91fc65a
separate ability and capability
cflipse Jun 17, 2018
25256a4
Make rubocop happy
cflipse Jun 18, 2018
420a728
Merge branch 'authz' of https://github.com/rubyforgood/openstreetmap-…
gravitystorm Oct 10, 2018
f8f7ab1
Change abilities based on upstream renamings
gravitystorm Oct 10, 2018
fb2c1f6
Refactor site#welcome to use abilities instead of require_user
gravitystorm Oct 10, 2018
901c29a
Fix typo in method name
gravitystorm Oct 10, 2018
dfb9e40
Move issues and reports to authorization system
gravitystorm Oct 10, 2018
8360f27
Refactor to show the Issues link based on the calculated permissions
gravitystorm Oct 10, 2018
b7baa2c
Remove temporary development code
gravitystorm Oct 10, 2018
ce761b3
Combine site permissions declarations
gravitystorm Oct 17, 2018
a50ad1c
Rework the default denied access handler to give different responses …
gravitystorm Oct 24, 2018
71b21ec
Rework capabilities to avoid assumptions about missing tokens
gravitystorm Oct 24, 2018
0888f43
Check the oauth token and then use the capabilities directly
gravitystorm Oct 24, 2018
f11221f
Merge branch 'master' into cancancan
gravitystorm Oct 31, 2018
149c07f
Remove unnecessary token granting from the user_preferences tests
gravitystorm Oct 31, 2018
4161959
Add testing for moderator users and issues
gravitystorm Oct 31, 2018
7a177cb
Fix error messages when users should not be able to do things
gravitystorm Oct 31, 2018
8c269ab
Move abilities to a sepatarate top level directory
tomhughes Nov 3, 2018
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions Gemfile
Expand Up @@ -45,6 +45,7 @@ gem "image_optim_rails"

# Load rails plugins
gem "actionpack-page_caching"
gem "cancancan"
gem "composite_primary_keys", "~> 11.0.0"
gem "dynamic_form"
gem "http_accept_language", "~> 2.0.0"
Expand Down
2 changes: 2 additions & 0 deletions Gemfile.lock
Expand Up @@ -66,6 +66,7 @@ GEM
bootsnap (1.3.2)
msgpack (~> 1.0)
builder (3.2.3)
cancancan (2.1.3)
canonical-rails (0.2.4)
rails (>= 4.1, < 5.3)
capybara (2.18.0)
Expand Down Expand Up @@ -387,6 +388,7 @@ DEPENDENCIES
bigdecimal (~> 1.1.0)
binding_of_caller
bootsnap (>= 1.1.0)
cancancan
canonical-rails
capybara (~> 2.13)
coffee-rails (~> 4.2)
Expand Down
25 changes: 25 additions & 0 deletions app/controllers/application_controller.rb
Expand Up @@ -3,6 +3,8 @@ class ApplicationController < ActionController::Base

protect_from_forgery :with => :exception

rescue_from CanCan::AccessDenied, :with => :deny_access

before_action :fetch_body
around_action :better_errors_allow_inline, :if => proc { Rails.env.development? }

Expand Down Expand Up @@ -466,6 +468,29 @@ def better_errors_allow_inline
raise
end

def current_ability
# Add in capabilities from the oauth token if it exists and is a valid access token
if Authenticator.new(self, [:token]).allow?
Ability.new(current_user).merge(Capability.new(current_token))
else
Ability.new(current_user)
end
end

def deny_access(_exception)
if current_token
set_locale
report_error t("oauth.permissions.missing"), :forbidden
elsif current_user
set_locale
report_error t("application.permission_denied"), :forbidden
elsif request.get?
redirect_to :controller => "users", :action => "login", :referer => request.fullpath
else
head :forbidden
end
end

private

# extract authorisation credentials from headers, returns user = nil if none
Expand Down
31 changes: 19 additions & 12 deletions app/controllers/diary_entry_controller.rb
Expand Up @@ -3,11 +3,12 @@ class DiaryEntryController < ApplicationController

before_action :authorize_web
before_action :set_locale
before_action :require_user, :only => [:new, :edit, :comment, :hide, :hidecomment, :subscribe, :unsubscribe]

authorize_resource

before_action :lookup_user, :only => [:show, :comments]
before_action :check_database_readable
before_action :check_database_writable, :only => [:new, :edit, :comment, :hide, :hidecomment, :subscribe, :unsubscribe]
before_action :require_administrator, :only => [:hide, :hidecomment]
before_action :allow_thirdparty_images, :only => [:new, :edit, :index, :show, :comments]

def new
Expand Down Expand Up @@ -215,6 +216,22 @@ def comments

private

# This is required because, being a default-deny system, cancancan
# _cannot_ tell you the reason you were denied access; and so
# the "nice" feedback presenting next steps can't be gleaned from
# the exception
##
# for the hide actions, require that the user is a administrator, or fill out
# a helpful error message and return them to the user page.
def deny_access(exception)
if current_user && exception.action.in?([:hide, :hidecomment])
flash[:error] = t("users.filter.not_an_administrator")
redirect_to :action => "show"
else
super
end
end

##
# return permitted diary entry parameters
def entry_params
Expand All @@ -229,16 +246,6 @@ def comment_params
params.require(:diary_comment).permit(:body)
end

##
# require that the user is a administrator, or fill out a helpful error message
# and return them to the user page.
def require_administrator
unless current_user.administrator?
flash[:error] = t("users.filter.not_an_administrator")
redirect_to :action => "show"
end
end

##
# decide on a location for the diary entry map
def set_map_location
Expand Down
10 changes: 6 additions & 4 deletions app/controllers/issue_comments_controller.rb
Expand Up @@ -3,8 +3,8 @@ class IssueCommentsController < ApplicationController

before_action :authorize_web
before_action :set_locale
before_action :require_user
before_action :check_permission

authorize_resource

def create
@issue = Issue.find(params[:issue_id])
Expand All @@ -22,10 +22,12 @@ def issue_comment_params
params.require(:issue_comment).permit(:body)
end

def check_permission
unless current_user.administrator? || current_user.moderator?
def deny_access(_exception)
if current_user
flash[:error] = t("application.require_moderator_or_admin.not_a_moderator_or_admin")
redirect_to root_path
else
super
end
end

Expand Down
11 changes: 7 additions & 4 deletions app/controllers/issues_controller.rb
Expand Up @@ -3,8 +3,9 @@ class IssuesController < ApplicationController

before_action :authorize_web
before_action :set_locale
before_action :require_user
before_action :check_permission

authorize_resource

before_action :find_issue, :only => [:show, :resolve, :reopen, :ignore]

def index
Expand Down Expand Up @@ -82,10 +83,12 @@ def find_issue
@issue = Issue.find(params[:id])
end

def check_permission
unless current_user.administrator? || current_user.moderator?
def deny_access(_exception)
if current_user
flash[:error] = t("application.require_moderator_or_admin.not_a_moderator_or_admin")
redirect_to root_path
else
super
end
end
end
3 changes: 2 additions & 1 deletion app/controllers/reports_controller.rb
Expand Up @@ -3,7 +3,8 @@ class ReportsController < ApplicationController

before_action :authorize_web
before_action :set_locale
before_action :require_user

authorize_resource

def new
if required_new_report_params_present?
Expand Down
3 changes: 2 additions & 1 deletion app/controllers/site_controller.rb
Expand Up @@ -6,10 +6,11 @@ class SiteController < ApplicationController
before_action :set_locale
before_action :redirect_browse_params, :only => :index
before_action :redirect_map_params, :only => [:index, :edit, :export]
before_action :require_user, :only => [:welcome]
before_action :require_oauth, :only => [:index]
before_action :update_totp, :only => [:index]

authorize_resource :class => false

def index
session[:location] ||= OSM.ip_location(request.env["REMOTE_ADDR"]) unless STATUS == :database_readonly || STATUS == :database_offline
end
Expand Down
5 changes: 3 additions & 2 deletions app/controllers/user_preferences_controller.rb
Expand Up @@ -2,8 +2,9 @@
class UserPreferencesController < ApplicationController
skip_before_action :verify_authenticity_token
before_action :authorize
before_action :require_allow_read_prefs, :only => [:read_one, :read]
before_action :require_allow_write_prefs, :except => [:read_one, :read]

authorize_resource

around_action :api_call_handle_error

##
Expand Down
57 changes: 57 additions & 0 deletions app/models/ability.rb
@@ -0,0 +1,57 @@
# frozen_string_literal: true

class Ability
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is app/model the right place for this? It doesn't look like a model and the CanCanCan documentation just refers to is as a "class" so I was expecting it to be in lib but I guess this is where the generator put it?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this is where the generator puts it. I'm happy to put it where ever suits - perhaps in config? I'm not sure about lib, I consider that somewhere where stuff goes that (in theory) could be extracted into a gem, but I can see the logic there since we put various classes into lib already.

From https://github.com/CanCanCommunity/cancancan/blob/develop/lib/generators/cancan/ability/USAGE:

The cancan:ability generator creates an Ability class in the models
directory. You can move this file anywhere you want as long as it
is in the load path.

Copy link
Member

@tomhughes tomhughes Oct 17, 2018

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's tricky... I'm not sure rails even creates a lib directory these days, and I know it was removed from the default load path but we put it back in config/application.rb.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just weighing in, FWIW, Ive seen a folder created for these and seen them put in app/abilities/ before. It would also make splitting the ability file (which are likely to want to do later) much easier.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

/lib is almost certainly the wrong place, as this isn't external / library code. I could see putting it in config, but it's active code that gets checked, and config isn't the first place I'd look. Ben's thought about app/abilities appeals, if the plan is to deconstruct the large object at some point ...

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I did do some googling before and there seems to be approximately no consensus on where to put things that aren't one of the "standard" types of class.

I'd always seen lib as being for random bits of non-classifiable code and not just for external things (which wouldn't normally be in the repo at all) and in the early days when lib was on the autoload path I think that was a more common view but then it got removed.

I didn't even know could just create random directories under app to be honest - how does that work? Does rails add every directory there to the autoload path?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It does.

there are some additional app directories that are something approaching a standard: app/presenters, app/forms, app/services (especially the last one) but it's pretty much open season for organizing things under the top layer of app. Rails will loop through those directories and add them to autoload at boot time (so, if you add a new app/foo class, you'd need to reboot your dev server, but otherwise, classes underneath are subject to Rails' loading magicks)

include CanCan::Ability

def initialize(user)
can [:index, :permalink, :edit, :help, :fixthemap, :offline, :export, :about, :preview, :copyright, :key, :id], :site
can [:index, :rss, :show, :comments], DiaryEntry
can [:search, :search_latlon, :search_ca_postcode, :search_osm_nominatim,
:search_geonames, :search_osm_nominatim_reverse, :search_geonames_reverse], :geocoder

if user
can :welcome, :site
can [:create, :edit, :comment, :subscribe, :unsubscribe], DiaryEntry
can [:new, :create], Report
can [:read, :read_one, :update, :update_one, :delete_one], UserPreference

if user.moderator?
can [:index, :show, :resolve, :ignore, :reopen], Issue
can :create, IssueComment
end

if user.administrator?
can [:hide, :hidecomment], [DiaryEntry, DiaryComment]
can [:index, :show, :resolve, :ignore, :reopen], Issue
can :create, IssueComment
end
end

# Define abilities for the passed in user here. For example:
#
# user ||= User.new # guest user (not logged in)
# if user.admin?
# can :manage, :all
# else
# can :read, :all
# end
#
# The first argument to `can` is the action you are giving the user
# permission to do.
# If you pass :manage it will apply to every action. Other common actions
# here are :read, :create, :update and :destroy.
#
# The second argument is the resource the user can perform the action on.
# If you pass :all it will apply to every resource. Otherwise pass a Ruby
# class of the resource.
#
# The third argument is an optional hash of conditions to further filter the
# objects.
# For example, here the user can only update published articles.
#
# can :update, Article, :published => true
#
# See the wiki for details:
# https://github.com/CanCanCommunity/cancancan/wiki/Defining-Abilities
end
end
16 changes: 16 additions & 0 deletions app/models/capability.rb
@@ -0,0 +1,16 @@
# frozen_string_literal: true

class Capability
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Obviously similar comments apply here as with Ability but this one I think is something of your invention as I don't see it mentioned in the documentation?

I wonder if this is even needed - given we are overriding current_ability could we not pass both the user and token to Ability and have it do everything?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't have any views on this, beyond what @cflipse wrote in the commit message when he split them out.

To be blunt, I don't yet have a good understanding of how all the tokens stuff works, so I'm happy to take direction here. My experience elsewhere is just with straightforward approaches around having a current_user with particular roles, and our token handling here is more complex.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's been a while. IIRC, the capabilities reflect permissions granted to the application -- so, if it's making a request to access your GPS, and you say "yes", then you've granted the app that capability. Another common example is when you OAuth login and the system asks for permission to read your contact lists.

This is, more or less, inverse of the CanCanCan's normal idea of an Ability, which is the app deciding if the user has permission to do something; Capability is the user deciding if the app has permission to do something. (Capability was an inherited name, I suspect that something better could be determined) The end result of the calculation is the same, but separating them helps to keep them from getting too crossed.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The name capability comes from us - it was something we built on top of OAuth 1 to associate a token with a set of things it was allowed to do.

OAuth 2 has something similar built in but calls them scopes where when an application requests a token it indicates what scopes it wants access to.

include CanCan::Ability

def initialize(token)
can [:read, :read_one], UserPreference if capability?(token, :allow_read_prefs)
can [:update, :update_one, :delete_one], UserPreference if capability?(token, :allow_write_prefs)
end

private

def capability?(token, cap)
token&.read_attribute(cap)
end
end
2 changes: 1 addition & 1 deletion app/views/layouts/_header.html.erb
Expand Up @@ -54,7 +54,7 @@
<li id="compact-secondary-nav" class="dropdown">
<a class="dropdown-toggle" data-toggle="dropdown" href="#"><%= t 'layouts.more' %> <b class="caret"></b></a>
<ul class="dropdown-menu">
<% if current_user and ( current_user.administrator? or current_user.moderator? ) %>
<% if can? :index, Issue %>
<li class="<%= current_page_class(issues_path) %>">
<%= link_to issues_path(:status => 'open') do %>
<%= open_issues_count %>
Expand Down
1 change: 1 addition & 0 deletions config/locales/en.yml
Expand Up @@ -1798,6 +1798,7 @@ en:
other: "GPX file with %{count} points from %{user}"
description_without_count: "GPX file from %{user}"
application:
permission_denied: You do not have permission to access that action
require_cookies:
cookies_needed: "You appear to have cookies disabled - please enable cookies in your browser before continuing."
require_admin:
Expand Down
71 changes: 71 additions & 0 deletions test/models/abilities_test.rb
@@ -0,0 +1,71 @@
# frozen_string_literal: true

require "test_helper"

class AbilityTest < ActiveSupport::TestCase
end

class GuestAbilityTest < AbilityTest
test "geocoder permission for a guest" do
ability = Ability.new nil

[:search, :search_latlon, :search_ca_postcode, :search_osm_nominatim,
:search_geonames, :search_osm_nominatim_reverse, :search_geonames_reverse].each do |action|
assert ability.can?(action, :geocoder), "should be able to #{action} geocoder"
end
end

test "diary permissions for a guest" do
ability = Ability.new nil
[:index, :rss, :show, :comments].each do |action|
assert ability.can?(action, DiaryEntry), "should be able to #{action} DiaryEntries"
end

[:create, :edit, :comment, :subscribe, :unsubscribe, :hide, :hidecomment].each do |action|
assert ability.cannot?(action, DiaryEntry), "should not be able to #{action} DiaryEntries"
assert ability.cannot?(action, DiaryComment), "should not be able to #{action} DiaryEntries"
end
end
end

class UserAbilityTest < AbilityTest
test "Diary permissions" do
ability = Ability.new create(:user)

[:index, :rss, :show, :comments, :create, :edit, :comment, :subscribe, :unsubscribe].each do |action|
assert ability.can?(action, DiaryEntry), "should be able to #{action} DiaryEntries"
end

[:hide, :hidecomment].each do |action|
assert ability.cannot?(action, DiaryEntry), "should not be able to #{action} DiaryEntries"
assert ability.cannot?(action, DiaryComment), "should not be able to #{action} DiaryEntries"
end

[:index, :show, :resolve, :ignore, :reopen].each do |action|
assert ability.cannot?(action, Issue), "should not be able to #{action} Issues"
end
end
end

class ModeratorAbilityTest < AbilityTest
test "Issue permissions" do
ability = Ability.new create(:moderator_user)

[:index, :show, :resolve, :ignore, :reopen].each do |action|
assert ability.can?(action, Issue), "should be able to #{action} Issues"
end
end
end

class AdministratorAbilityTest < AbilityTest
test "Diary for an administrator" do
ability = Ability.new create(:administrator_user)
[:index, :rss, :show, :comments, :create, :edit, :comment, :subscribe, :unsubscribe, :hide, :hidecomment].each do |action|
assert ability.can?(action, DiaryEntry), "should be able to #{action} DiaryEntries"
end

[:hide, :hidecomment].each do |action|
assert ability.can?(action, DiaryComment), "should be able to #{action} DiaryComment"
end
end
end