Merge branch 'authz' of https://github.com/rubyforgood/openstreetmap-website into...
authorAndy Allan <git@gravitystorm.co.uk>
Wed, 10 Oct 2018 09:26:30 +0000 (11:26 +0200)
committerAndy Allan <git@gravitystorm.co.uk>
Wed, 10 Oct 2018 09:26:30 +0000 (11:26 +0200)
13 files changed:
Gemfile
Gemfile.lock
app/controllers/application_controller.rb
app/controllers/diary_entry_controller.rb
app/controllers/site_controller.rb
app/controllers/user_preferences_controller.rb
app/controllers/users_controller.rb
app/models/ability.rb [new file with mode: 0644]
app/models/capability.rb [new file with mode: 0644]
test/controllers/user_preferences_controller_test.rb
test/models/abilities_test.rb [new file with mode: 0644]
test/models/capability_test.rb [new file with mode: 0644]
test/test_helper.rb

diff --git a/Gemfile b/Gemfile
index 9ba2703134d5d9b9bf8e5c47904c188396a762e7..b559027c2062fc1625604bc679417dca36e28831 100644 (file)
--- a/Gemfile
+++ b/Gemfile
@@ -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"
index 76a31e169e59a8c4aacd58ef738f2de056957302..cd94df5e102759d260642e7a06f226a667655a1d 100644 (file)
@@ -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)
@@ -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)
index bd1995014882452fab08990432203104f5701381..2c5fbe51d0f95315c34cfd3c97e3911d5e6b67c0 100644 (file)
@@ -1,8 +1,11 @@
 class ApplicationController < ActionController::Base
   include SessionPersistence
+  # check_authorization
 
   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? }
 
@@ -466,6 +469,23 @@ class ApplicationController < ActionController::Base
     raise
   end
 
+  def current_ability
+    Ability.new(current_user).merge(granted_capabily)
+  end
+
+  def granted_capabily
+    Capability.new(current_user, current_token)
+  end
+
+  def deny_access(_exception)
+    if current_user
+      set_locale
+      report_error t("oauth.permissions.missing"), :forbidden
+    else
+      require_user
+    end
+  end
+
   private
 
   # extract authorisation credentials from headers, returns user = nil if none
index 723fff17ec0e885c129087e003c343bfcf748bcd..d3d7f6a7cee9d7fbbe4836d4fd3e17d05813c108 100644 (file)
@@ -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
@@ -215,6 +216,22 @@ class DiaryEntryController < ApplicationController
 
   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("user.filter.not_an_administrator")
+      redirect_to :action => "view"
+    else
+      super
+    end
+  end
+
   ##
   # return permitted diary entry parameters
   def entry_params
@@ -229,16 +246,6 @@ class DiaryEntryController < ApplicationController
     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("user.filter.not_an_administrator")
-      redirect_to :action => "show"
-    end
-  end
-
   ##
   # decide on a location for the diary entry map
   def set_map_location
index efb77e2f52aad92574600d3d87082e3e1376bbe1..2fa91256e68d723cde675e16c4e49d80c225870b 100644 (file)
@@ -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
@@ -102,7 +103,9 @@ class SiteController < ApplicationController
     @locale = params[:copyright_locale] || I18n.locale
   end
 
-  def welcome; end
+  def welcome
+    require_user
+  end
 
   def help; end
 
index 0aa2e8d523240c5f1eb3add8f6c978204bcab86f..915c847de4f221fc8a45662efa712d1ab79a5b3a 100644 (file)
@@ -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
 
   ##
index d18cf188cf669ef002fbc975f6f08d091f697d6f..09bdd6d3ec873d6f926e0d570afd0cc1f40cfc52 100644 (file)
@@ -1,6 +1,8 @@
 class UsersController < ApplicationController
   layout "site", :except => [:api_details]
 
+  skip_authorization_check :only => [:login, :logout]
+
   skip_before_action :verify_authenticity_token, :only => [:api_read, :api_users, :api_details, :api_gpx_files, :auth_success]
   before_action :disable_terms_redirect, :only => [:terms, :save, :logout, :api_details]
   before_action :authorize, :only => [:api_details, :api_gpx_files]
diff --git a/app/models/ability.rb b/app/models/ability.rb
new file mode 100644 (file)
index 0000000..5be0b37
--- /dev/null
@@ -0,0 +1,49 @@
+# frozen_string_literal: true
+
+class Ability
+  include CanCan::Ability
+
+  def initialize(user)
+    can :index, :site
+    can [:permalink, :edit, :help, :fixthemap, :offline, :export, :about, :preview, :copyright, :key, :id, :welcome], :site
+
+    can [:list, :rss, :view, :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 :weclome, :site
+
+      can [:create, :edit, :comment, :subscribe, :unsubscribe], DiaryEntry
+
+      can [:hide, :hidecomment], [DiaryEntry, DiaryComment] if user.administrator?
+    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
diff --git a/app/models/capability.rb b/app/models/capability.rb
new file mode 100644 (file)
index 0000000..db2d717
--- /dev/null
@@ -0,0 +1,21 @@
+# frozen_string_literal: true
+
+class Capability
+  include CanCan::Ability
+
+  def initialize(user, token)
+    if user
+      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
+  end
+
+  private
+
+  # If a user provides no tokens, they've authenticated via a non-oauth method
+  # and permission to access to all capabilities is assumed.
+  def capability?(token, cap)
+    token.nil? || token.read_attribute(cap)
+  end
+end
index 3e5cbb36998b3b3f03b40037f4756adfd122361f..1a51779ed867a1653ad43f4e8f9b82866d672c69 100644 (file)
@@ -35,6 +35,7 @@ class UserPreferencesControllerTest < ActionController::TestCase
 
     # authenticate as a user with no preferences
     basic_authorization create(:user).email, "test"
+    grant_oauth_token :allow_read_prefs
 
     # try the read again
     get :read
@@ -75,6 +76,7 @@ class UserPreferencesControllerTest < ActionController::TestCase
 
     # authenticate as a user with preferences
     basic_authorization user.email, "test"
+    grant_oauth_token :allow_read_prefs
 
     # try the read again
     get :read_one, :params => { :preference_key => "key" }
@@ -108,6 +110,7 @@ class UserPreferencesControllerTest < ActionController::TestCase
 
     # authenticate as a user with preferences
     basic_authorization user.email, "test"
+    grant_oauth_token :allow_write_prefs
 
     # try the put again
     assert_no_difference "UserPreference.count" do
@@ -159,6 +162,7 @@ class UserPreferencesControllerTest < ActionController::TestCase
 
     # authenticate as a user with preferences
     basic_authorization user.email, "test"
+    grant_oauth_token :allow_write_prefs
 
     # try adding a new preference
     assert_difference "UserPreference.count", 1 do
@@ -196,6 +200,7 @@ class UserPreferencesControllerTest < ActionController::TestCase
 
     # authenticate as a user with preferences
     basic_authorization user.email, "test"
+    grant_oauth_token :allow_write_prefs
 
     # try the delete again
     assert_difference "UserPreference.count", -1 do
diff --git a/test/models/abilities_test.rb b/test/models/abilities_test.rb
new file mode 100644 (file)
index 0000000..28a5c7f
--- /dev/null
@@ -0,0 +1,65 @@
+# 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
+    [:list, :rss, :view, :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 be able to #{action} DiaryEntries"
+      assert ability.cannot?(action, DiaryComment), "should be able to #{action} DiaryEntries"
+    end
+  end
+end
+
+class UserAbilityTest < AbilityTest
+  test "Diary permissions" do
+    ability = Ability.new create(:user)
+
+    [:list, :rss, :view, :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 be able to #{action} DiaryEntries"
+      assert ability.cannot?(action, DiaryComment), "should be able to #{action} DiaryEntries"
+    end
+  end
+end
+
+class AdministratorAbilityTest < AbilityTest
+  test "Diary for an administrator" do
+    ability = Ability.new create(:administrator_user)
+    [:list, :rss, :view, :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
+
+  test "administrator does not auto-grant user preferences" do
+    ability = Ability.new create(:administrator_user)
+
+    [:read, :read_one, :update, :update_one, :delete_one].each do |act|
+      assert ability.cannot? act, UserPreference
+    end
+  end
+end
diff --git a/test/models/capability_test.rb b/test/models/capability_test.rb
new file mode 100644 (file)
index 0000000..2d5d650
--- /dev/null
@@ -0,0 +1,51 @@
+# frozen_string_literal: true
+
+require "test_helper"
+
+class CapabilityTest < ActiveSupport::TestCase
+  def tokens(*toks)
+    AccessToken.new do |token|
+      toks.each do |t|
+        token.public_send("#{t}=", true)
+      end
+    end
+  end
+end
+
+class UserCapabilityTest < CapabilityTest
+  test "user preferences" do
+    user = create(:user)
+
+    # a user with no tokens
+    capability = Capability.new create(:user), nil
+    [:read, :read_one, :update, :update_one, :delete_one].each do |act|
+      assert capability.can? act, UserPreference
+    end
+
+    # A user with empty tokens
+    capability = Capability.new create(:user), tokens
+
+    [:read, :read_one, :update, :update_one, :delete_one].each do |act|
+      assert capability.cannot? act, UserPreference
+    end
+
+    capability = Capability.new user, tokens(:allow_read_prefs)
+
+    [:update, :update_one, :delete_one].each do |act|
+      assert capability.cannot? act, UserPreference
+    end
+
+    [:read, :read_one].each do |act|
+      assert capability.can? act, UserPreference
+    end
+
+    capability = Capability.new user, tokens(:allow_write_prefs)
+    [:read, :read_one].each do |act|
+      assert capability.cannot? act, UserPreference
+    end
+
+    [:update, :update_one, :delete_one].each do |act|
+      assert capability.can? act, UserPreference
+    end
+  end
+end
index 83cf909dd94f208c5b89c20e024f8e6941e040f4..b7b93455206e1fb5d01090cdec53b5bca27f94f9 100644 (file)
@@ -87,6 +87,16 @@ module ActiveSupport
       @request.env["HTTP_AUTHORIZATION"] = format("Basic %{auth}", :auth => Base64.encode64("#{user}:#{pass}"))
     end
 
+    ##
+    # set oauth token permissions
+    def grant_oauth_token(*tokens)
+      request.env["oauth.token"] = AccessToken.new do |token|
+        tokens.each do |t|
+          token.public_send("#{t}=", true)
+        end
+      end
+    end
+
     ##
     # set request readers to ask for a particular error format
     def error_format(format)