diff --git a/Gemfile b/Gemfile index 22a3331a7e..2108fa9ce5 100644 --- a/Gemfile +++ b/Gemfile @@ -52,7 +52,7 @@ group :test do gem 'rubocop', '~> 0.31.0' gem 'simplecov', '>= 0.9', require: false gem 'timecop', '>= 0.5' - + gem 'pundit' platforms :ruby_21, :ruby_22 do gem 'refile', '~> 0.5', require: 'refile/rails' gem 'refile-mini_magick', '>= 0.1.0' diff --git a/gemfiles/rails_4.0.gemfile b/gemfiles/rails_4.0.gemfile index 647ddd263b..7194042abc 100644 --- a/gemfiles/rails_4.0.gemfile +++ b/gemfiles/rails_4.0.gemfile @@ -57,6 +57,7 @@ group :test do gem "rubocop", "~> 0.31.0" gem "simplecov", ">= 0.9", :require => false gem "timecop", ">= 0.5" + gem "pundit" platforms :ruby_21, :ruby_22 do gem "refile", "~> 0.5", :require => "refile/rails" diff --git a/gemfiles/rails_4.1.gemfile b/gemfiles/rails_4.1.gemfile index 3822b2b409..23c1a84fb2 100644 --- a/gemfiles/rails_4.1.gemfile +++ b/gemfiles/rails_4.1.gemfile @@ -55,6 +55,7 @@ group :test do gem "rubocop", "~> 0.31.0" gem "simplecov", ">= 0.9", :require => false gem "timecop", ">= 0.5" + gem "pundit" platforms :ruby_21, :ruby_22 do gem "refile", "~> 0.5", :require => "refile/rails" diff --git a/gemfiles/rails_4.2.gemfile b/gemfiles/rails_4.2.gemfile index f694eb7445..206ba4c8d7 100644 --- a/gemfiles/rails_4.2.gemfile +++ b/gemfiles/rails_4.2.gemfile @@ -56,6 +56,7 @@ group :test do gem "rubocop", "~> 0.31.0" gem "simplecov", ">= 0.9", :require => false gem "timecop", ">= 0.5" + gem "pundit" platforms :ruby_21, :ruby_22 do gem "refile", "~> 0.5", :require => "refile/rails" diff --git a/lib/generators/rails_admin/templates/initializer.erb b/lib/generators/rails_admin/templates/initializer.erb index ca5232166c..b963297808 100644 --- a/lib/generators/rails_admin/templates/initializer.erb +++ b/lib/generators/rails_admin/templates/initializer.erb @@ -11,6 +11,9 @@ RailsAdmin.config do |config| ## == Cancan == # config.authorize_with :cancan + ## == Pundit == + # config.authorize_with :pundit + ## == PaperTrail == # config.audit_with :paper_trail, 'User', 'PaperTrail::Version' # PaperTrail >= 3.0.0 diff --git a/lib/rails_admin.rb b/lib/rails_admin.rb index 77431d59f5..a685e12e91 100644 --- a/lib/rails_admin.rb +++ b/lib/rails_admin.rb @@ -4,6 +4,7 @@ require 'rails_admin/extension' require 'rails_admin/extensions/cancan' require 'rails_admin/extensions/cancancan' +require 'rails_admin/extensions/pundit' require 'rails_admin/extensions/paper_trail' require 'rails_admin/extensions/history' require 'rails_admin/support/csv_converter' diff --git a/lib/rails_admin/extensions/pundit.rb b/lib/rails_admin/extensions/pundit.rb new file mode 100644 index 0000000000..f194e3f28e --- /dev/null +++ b/lib/rails_admin/extensions/pundit.rb @@ -0,0 +1,3 @@ +require 'rails_admin/extensions/pundit/authorization_adapter' + +RailsAdmin.add_extension(:pundit, RailsAdmin::Extensions::Pundit, authorization: true) diff --git a/lib/rails_admin/extensions/pundit/authorization_adapter.rb b/lib/rails_admin/extensions/pundit/authorization_adapter.rb new file mode 100644 index 0000000000..de4f77161a --- /dev/null +++ b/lib/rails_admin/extensions/pundit/authorization_adapter.rb @@ -0,0 +1,60 @@ +module RailsAdmin + module Extensions + module Pundit + # This adapter is for the Pundit[https://github.com/elabs/pundit] authorization library. + # You can create another adapter for different authorization behavior, just be certain it + # responds to each of the public methods here. + class AuthorizationAdapter + # See the +authorize_with+ config method for where the initialization happens. + def initialize(controller) + @controller = controller + @controller.class.send(:alias_method, :pundit_user, :_current_user) + end + + # This method is called in every controller action and should raise an exception + # when the authorization fails. The first argument is the name of the controller + # action as a symbol (:create, :bulk_delete, etc.). The second argument is the + # AbstractModel instance that applies. The third argument is the actual model + # instance if it is available. + def authorize(action, abstract_model = nil, model_object = nil) + record = model_object || abstract_model && abstract_model.model + fail ::Pundit::NotAuthorizedError.new("not allowed to #{action} this #{record}") unless policy(record).send(action) if action + end + + # This method is called primarily from the view to determine whether the given user + # has access to perform the action on a given model. It should return true when authorized. + # This takes the same arguments as +authorize+. The difference is that this will + # return a boolean whereas +authorize+ will raise an exception when not authorized. + def authorized?(action, abstract_model = nil, model_object = nil) + record = model_object || abstract_model && abstract_model.model + policy(record).send(action) if action + end + + # This is called when needing to scope a database query. It is called within the list + # and bulk_delete/destroy actions and should return a scope which limits the records + # to those which the user can perform the given action on. + def query(_action, abstract_model) + @controller.policy_scope(abstract_model.model.all) + rescue ::Pundit::NotDefinedError + abstract_model.model.all + end + + # This is called in the new/create actions to determine the initial attributes for new + # records. It should return a hash of attributes which match what the user + # is authorized to create. + def attributes_for(action, abstract_model) + record = abstract_model && abstract_model.model + policy(record).try(:attributes_for, action) || {} + end + + private + + def policy(record) + @controller.policy(record) + rescue ::Pundit::NotDefinedError + ::ApplicationPolicy.new(@controller.send(:_current_user), record) + end + end + end + end +end diff --git a/spec/integration/authorization/pundit_spec.rb b/spec/integration/authorization/pundit_spec.rb new file mode 100644 index 0000000000..dfb7122b2f --- /dev/null +++ b/spec/integration/authorization/pundit_spec.rb @@ -0,0 +1,167 @@ +require 'spec_helper' +include Pundit + +class ApplicationPolicy + attr_reader :user, :record + + def initialize(user, record) + @user = user + @record = record + end + + def show + user.roles.include? :admin + end + + def destroy + false + end + + def history + user.roles.include? :admin + end + + def show_in_app + user.roles.include? :admin + end + + def dashboard + user.roles.include? :admin + end + + def index + false + end + + def new + user.roles.include? :admin + end + + def edit + user.roles.include? :admin + end + + def export + user.roles.include? :admin + end +end + +class PlayerPolicy < ApplicationPolicy + def new + (user.roles.include?(:create_player) || user.roles.include?(:admin) || user.roles.include?(:manage_player)) + end + + def edit + (user.roles.include? :manage_player) + end + + def destroy + (user.roles.include? :manage_player) + end + + def index + user.roles.include? :admin + end +end + +describe 'RailsAdmin Pundit Authorization', type: :request do + subject { page } + + before do + RailsAdmin.config do |c| + c.authorize_with(:pundit) + c.authenticate_with { warden.authenticate! scope: :user } + c.current_user_method(&:current_user) + end + @player_model = RailsAdmin::AbstractModel.new(Player) + @user = FactoryGirl.create :user + login_as @user + end + + describe 'with no roles' do + before do + @user.update_attributes(roles: []) + end + + it 'GET /admin should raise Pundit::NotAuthorizedError' do + expect { visit dashboard_path }.to raise_error(Pundit::NotAuthorizedError) + end + + it 'GET /admin/player should raise Pundit::NotAuthorizedError' do + expect { visit index_path(model_name: 'player') }.to raise_error(Pundit::NotAuthorizedError) + end + end + + describe 'with read player role' do + before do + @user.update_attributes(roles: [:admin, :read_player]) + end + + it 'GET /admin should show Player but not League' do + visit dashboard_path + is_expected.to have_content('Player') + is_expected.not_to have_content('League') + is_expected.not_to have_content('Add new') + end + + it 'GET /admin/team should raise Pundit::NotAuthorizedError' do + expect { visit index_path(model_name: 'team') }.to raise_error(Pundit::NotAuthorizedError) + end + + it 'GET /admin/player/1/edit should raise access denied' do + @player = FactoryGirl.create :player + expect { visit edit_path(model_name: 'player', id: @player.id) }.to raise_error(Pundit::NotAuthorizedError) + end + end + + describe 'with admin role' do + before do + @user.update_attributes(roles: [:admin, :manage_player]) + end + + it 'GET /admin should show Player but not League' do + visit dashboard_path + is_expected.to have_content('Player') + end + + it 'GET /admin/player/new should render and create record upon submission' do + visit new_path(model_name: 'player') + + is_expected.to have_content('Save and edit') + is_expected.not_to have_content('Delete') + + is_expected.to have_content('Save and add another') + fill_in 'player[name]', with: 'Jackie Robinson' + fill_in 'player[number]', with: '42' + fill_in 'player[position]', with: 'Second baseman' + click_button 'Save' + is_expected.not_to have_content('Edit') + + @player = RailsAdmin::AbstractModel.new('Player').first + expect(@player.name).to eq('Jackie Robinson') + expect(@player.number).to eq(42) + expect(@player.position).to eq('Second baseman') + end + end + + describe 'with all roles' do + it 'shows links to all actions' do + @user.update_attributes(roles: [:admin, :manage_player]) + @player = FactoryGirl.create :player + + visit index_path(model_name: 'player') + is_expected.to have_css('.show_member_link') + is_expected.to have_css('.edit_member_link') + is_expected.to have_css('.delete_member_link') + is_expected.to have_css('.history_show_member_link') + is_expected.to have_css('.show_in_app_member_link') + + visit show_path(model_name: 'player', id: @player.id) + is_expected.to have_content('Show') + is_expected.to have_content('Edit') + is_expected.to have_content('Delete') + is_expected.to have_content('History') + is_expected.to have_content('Show in app') + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 75800a7175..6e444aaa96 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -6,6 +6,7 @@ require 'simplecov' require 'coveralls' +require 'pundit/rspec' SimpleCov.formatters = [SimpleCov::Formatter::HTMLFormatter, Coveralls::SimpleCov::Formatter] @@ -21,6 +22,7 @@ require 'factory_girl' require 'factories' require 'database_cleaner' +require 'support/pundit_matcher.rb' require "orm/#{CI_ORM}" Dir[File.expand_path('../shared_examples/**/*.rb', __FILE__)].each { |f| require f } diff --git a/spec/support/pundit_matcher.rb b/spec/support/pundit_matcher.rb new file mode 100644 index 0000000000..11a895d66f --- /dev/null +++ b/spec/support/pundit_matcher.rb @@ -0,0 +1,13 @@ +RSpec::Matchers.define :permit do |action| + match do |policy| + policy.public_send("#{action}?") + end + + failure_message do |policy| + "#{policy.class} does not permit #{action} on #{policy.record} for #{policy.user.inspect}." + end + + failure_message_when_negated do |policy| + "#{policy.class} does not forbid #{action} on #{policy.record} for #{policy.user.inspect}." + end +end