From d6665c6d6a00f2e37eb4158e898ad9e70dc0e267 Mon Sep 17 00:00:00 2001 From: Carlos Palhares Date: Fri, 16 Jul 2021 16:00:52 -0300 Subject: [PATCH 1/4] Introduce Model.accessible_through --- lib/cancan/ability.rb | 7 ++ lib/cancan/ability/rules.rb | 12 ++ lib/cancan/model_additions.rb | 68 +++++++++++ .../accessible_through_integration_spec.rb | 114 ++++++++++++++++++ 4 files changed, 201 insertions(+) create mode 100644 spec/cancan/model_adapters/accessible_through_integration_spec.rb diff --git a/lib/cancan/ability.rb b/lib/cancan/ability.rb index 679ffc909..b153f8d2e 100644 --- a/lib/cancan/ability.rb +++ b/lib/cancan/ability.rb @@ -172,6 +172,13 @@ def model_adapter(model_class, action) adapter_class.new(model_class, relevant_rules_for_query(action, model_class)) end + # @private + def relation_model_adapter(model_class, action, subject, relation) + ::CanCan::ModelAdapters::AbstractAdapter + .adapter_class(model_class) + .new(model_class, relevant_rules_for_relation(model_class, action, subject, relation)) + end + # See ControllerAdditions#authorize! for documentation. def authorize!(action, subject, *args) message = args.last.is_a?(Hash) && args.last.key?(:message) ? args.pop[:message] : nil diff --git a/lib/cancan/ability/rules.rb b/lib/cancan/ability/rules.rb index c7491a6ec..b1dfd26e4 100644 --- a/lib/cancan/ability/rules.rb +++ b/lib/cancan/ability/rules.rb @@ -66,6 +66,18 @@ def relevant_rules_for_match(action, subject) end end + def relevant_rules_for_relation(model_class, action, subject, relation) + relevant_rules(action, subject).map do |rule| + case rule.conditions + when Hash + conditions = rule.conditions[relation] || rule.conditions[relation.to_sym] + Rule.new(rule.base_behavior, action, model_class, conditions, rule.block) + else + raise Error, "accessible_through is only available with hash conditions" + end + end + end + def relevant_rules_for_query(action, subject) rules = relevant_rules(action, subject).reject do |rule| # reject 'cannot' rules with attributes when doing queries diff --git a/lib/cancan/model_additions.rb b/lib/cancan/model_additions.rb index ef2d26eb3..bb1c19479 100644 --- a/lib/cancan/model_additions.rb +++ b/lib/cancan/model_additions.rb @@ -25,6 +25,74 @@ def accessible_by(ability, action = :index, strategy: CanCan.accessible_by_strat ability.model_adapter(self, action).database_records end end + + # Provides a scope within the model, to find instances of the model that + # are accesssible by the given ability, within the given action/subject + # permission pair. + # I.E.: + # Given the scenario below + # + # class Department < ActiveRecord::Base + # end + # + # class User < ActiveRecord::Base + # belongs_to :department + # end + # + # class Ability + # include CanCan::Ability + # + # def initialize(user) + # can :contact, User, { department: { id: user.department_id } } + # can :contact, User, { department: { id: user.managing_department_ids } } if user.manager? + # end + # end + # + # This would give you a list of territories that the given ability can + # contact their users: + # + # > user = User.new(department_id: 13, manager: false) + # > ability = Ability.new(user) + # > Department.accessible_through(ability, :contact, User).to_sql + # => SELECT * FROM territories WHERE id = 13 + # + # > user = User.new(department_id: 13, managing_department_ids: [2, 3, 4], manager: true) + # > ability = Ability.new(user) + # > Department.accessible_through(ability, :contact, User).to_sql + # => SELECT * FROM territories WHERE ((id = 13) OR (id IN (2, 3, 4))) + # + # Sometimes the name of the relation does't match the model, when that happens, you can override it with `relation`: + # + # class User < ActiveRecord::Base + # has_many :managing_users, class_name: "User", foreign_key: :managed_by_id + # end + # + # class Ability + # include CanCan::Ability + # + # def initialize(user) + # can :contact, User, { managing_users: { id: user.department_id } } + # end + # end + # + # This would give you a list of territories that the given ability can + # contact their users: + # + # > user = User.new(department_id: 13, manager: false) + # > ability = Ability.new(user) + # > Department.accessible_through(ability, :contact, User).to_sql + # => SELECT * FROM territories WHERE id = 13 + # + # > user = User.new(department_id: 13, managing_department_ids: [2, 3, 4], manager: true) + # > ability = Ability.new(user) + # > Department.accessible_through(ability, :contact, User).to_sql + # => SELECT * FROM territories WHERE ((id = 13) OR (id IN (2, 3, 4))) + def accessible_through(ability, action, subject, relation: model_name.element, strategy: CanCan.accessible_by_strategy) + CanCan.with_accessible_by_strategy(strategy) do + ability.relation_model_adapter(self, action, subject, relation) + .database_records + end + end end def self.included(base) diff --git a/spec/cancan/model_adapters/accessible_through_integration_spec.rb b/spec/cancan/model_adapters/accessible_through_integration_spec.rb new file mode 100644 index 000000000..285fbd502 --- /dev/null +++ b/spec/cancan/model_adapters/accessible_through_integration_spec.rb @@ -0,0 +1,114 @@ +# frozen_string_literal: true + +require 'spec_helper' + +# integration tests for latest ActiveRecord version. +RSpec.describe CanCan::ModelAdapters::ActiveRecord5Adapter do + let(:ability) { double.extend(CanCan::Ability) } + before :each do + connect_db + ActiveRecord::Migration.verbose = false + + ActiveRecord::Schema.define do + create_table(:divisions) do |t| + end + + create_table(:departments) do |t| + t.string :name + t.integer :division_id + end + + create_table(:employees) do |t| + t.integer :department_id + end + end + + class Division < ActiveRecord::Base + end + + class Department < ActiveRecord::Base + belongs_to :division + has_many :employees + end + + class Employee < ActiveRecord::Base + belongs_to :department + end + end + + before do + @division1 = Division.create! + @division2 = Division.create! + @department1 = Department.create!(division: @division1) + @department2 = Department.create!(division: @division2, name: "People") + @department3 = Department.create!(division: @division2) + @user1 = Employee.create!(department: @department1) + @user2 = Employee.create!(department: @department2) + @user3 = Employee.create!(department: @department3) + end + + it "selects the correct objects through the association" do + ability.can :message, Employee, { department: { id: @department1.id } } + ability.can :message, Employee, { department: { id: @department2.id } } + + departments = Department.accessible_through(ability, :message, Employee) + + expect(departments.pluck(:id)).to match_array [@department1.id, @department2.id] + end + + it "treats no condition unconditional" do + ability.can :message, Employee, { department: { id: @department1.id } } + ability.can :message, Employee + + # Finds all departments that ability can message employees from + departments = Department.accessible_through(ability, :message, Employee) + + expect(departments.pluck(:id)).to match_array [@department1.id, @department2.id, @department3.id] + end + + it "unallowing yields impossible condition" do + ability.can :message, Employee, { department: { id: @department1.id } } + ability.cannot :message, Employee + + # Finds all departments that ability can message employees from + departments = Department.accessible_through(ability, :message, Employee) + + expect(departments.pluck(:id)).to be_empty + end + + describe 'preloading of associatons' do + it 'preloads associations correctly' do + ability.can :message, Employee, { department: { division: { id: @department1.id } } } + + department = Department.accessible_through(ability, :message, Employee) + .includes(:division).first + + expect(department).to eql @department1 + expect(department.association(:division)).to be_loaded + end + end + + describe 'filtering of results' do + it 'adds the where clause correctly' do + ability.can :message, Employee, { department: { division: { id: [@department1.id, @department2.id] } } } + + department = Department.accessible_through(ability, :message, Employee) + .where("name LIKE 'Peo%'").first + + expect(department).to eql @department2 + end + end + + if CanCan::ModelAdapters::ActiveRecordAdapter.version_greater_or_equal?('5.0.0') + describe 'selecting custom columns' do + it 'extracts custom columns correctly' do + ability.can :message, Employee, { department: { division: { id: @department2.id } } } + + department = Department.accessible_through(ability, :message, Employee) + .select('name as title').first + + expect(department.title).to eql @department2.name + end + end + end +end From 854db14c24047d67aa72cf27fc5d82ca69047ea4 Mon Sep 17 00:00:00 2001 From: Carlos Palhares Date: Fri, 16 Jul 2021 16:09:04 -0300 Subject: [PATCH 2/4] Improve documentation --- lib/cancan/model_additions.rb | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/lib/cancan/model_additions.rb b/lib/cancan/model_additions.rb index bb1c19479..ee907c0b8 100644 --- a/lib/cancan/model_additions.rb +++ b/lib/cancan/model_additions.rb @@ -26,8 +26,8 @@ def accessible_by(ability, action = :index, strategy: CanCan.accessible_by_strat end end - # Provides a scope within the model, to find instances of the model that - # are accesssible by the given ability, within the given action/subject + # Provides a scope within the model to find instances of the model that + # are accessible by the given ability within the given action/subject # permission pair. # I.E.: # Given the scenario below @@ -61,7 +61,7 @@ def accessible_by(ability, action = :index, strategy: CanCan.accessible_by_strat # > Department.accessible_through(ability, :contact, User).to_sql # => SELECT * FROM territories WHERE ((id = 13) OR (id IN (2, 3, 4))) # - # Sometimes the name of the relation does't match the model, when that happens, you can override it with `relation`: + # Sometimes the name of the relation doesn't match the model. When that happens, you can override it with `relation`: # # class User < ActiveRecord::Base # has_many :managing_users, class_name: "User", foreign_key: :managed_by_id @@ -75,8 +75,7 @@ def accessible_by(ability, action = :index, strategy: CanCan.accessible_by_strat # end # end # - # This would give you a list of territories that the given ability can - # contact their users: + # The following would give you a list of territories that the given ability can contact their users: # # > user = User.new(department_id: 13, manager: false) # > ability = Ability.new(user) From 0dc1d633009563ca004f8d84fbd57dd4dc08e285 Mon Sep 17 00:00:00 2001 From: Carlos Palhares Date: Fri, 16 Jul 2021 16:45:49 -0300 Subject: [PATCH 3/4] Further documentation improvements --- lib/cancan/model_additions.rb | 92 ++++++++++++++++++----------------- 1 file changed, 47 insertions(+), 45 deletions(-) diff --git a/lib/cancan/model_additions.rb b/lib/cancan/model_additions.rb index ee907c0b8..9c45cf114 100644 --- a/lib/cancan/model_additions.rb +++ b/lib/cancan/model_additions.rb @@ -26,66 +26,68 @@ def accessible_by(ability, action = :index, strategy: CanCan.accessible_by_strat end end - # Provides a scope within the model to find instances of the model that - # are accessible by the given ability within the given action/subject - # permission pair. + # Provides a scope within the model to find instances of the model that are accessible + # by the given ability within the given action/subject permission pair. + # # I.E.: - # Given the scenario below # - # class Department < ActiveRecord::Base - # end + # Given the scenario below: # - # class User < ActiveRecord::Base - # belongs_to :department - # end + # class Department < ActiveRecord::Base + # end # - # class Ability - # include CanCan::Ability + # class User < ActiveRecord::Base + # belongs_to :department + # end # - # def initialize(user) - # can :contact, User, { department: { id: user.department_id } } - # can :contact, User, { department: { id: user.managing_department_ids } } if user.manager? - # end - # end + # class Ability + # include CanCan::Ability # - # This would give you a list of territories that the given ability can - # contact their users: + # def initialize(user) + # can :contact, User, { department: { id: user.department_id } } + # can :contact, User, { department: { id: user.managing_department_ids } } if user.manager? + # end + # end # - # > user = User.new(department_id: 13, manager: false) - # > ability = Ability.new(user) - # > Department.accessible_through(ability, :contact, User).to_sql - # => SELECT * FROM territories WHERE id = 13 + # The following would give you a list of departments that the given ability can contact their users: # - # > user = User.new(department_id: 13, managing_department_ids: [2, 3, 4], manager: true) - # > ability = Ability.new(user) - # > Department.accessible_through(ability, :contact, User).to_sql - # => SELECT * FROM territories WHERE ((id = 13) OR (id IN (2, 3, 4))) + # > user = User.new(department_id: 13, manager: false) + # > ability = Ability.new(user) + # > Department.accessible_through(ability, :contact, User).to_sql + # => SELECT * FROM departments WHERE id = 13 + # # + # > user = User.new(department_id: 13, managing_department_ids: [2, 3, 4], manager: true) + # > ability = Ability.new(user) + # > Department.accessible_through(ability, :contact, User).to_sql + # => SELECT * FROM departments WHERE ((id = 13) OR (id IN (2, 3, 4))) # - # Sometimes the name of the relation doesn't match the model. When that happens, you can override it with `relation`: + # Sometimes the name of the relation doesn't match the model. When that happens, you can override it + # with `relation`: # - # class User < ActiveRecord::Base - # has_many :managing_users, class_name: "User", foreign_key: :managed_by_id - # end + # class User < ActiveRecord::Base + # has_many :managing_users, class_name: "User", foreign_key: :managed_by_id + # end # - # class Ability - # include CanCan::Ability + # class Ability + # include CanCan::Ability # - # def initialize(user) - # can :contact, User, { managing_users: { id: user.department_id } } - # end - # end + # def initialize(user) + # can :contact, User, { managing_users: { id: user.department_id } } + # end + # end # - # The following would give you a list of territories that the given ability can contact their users: + # This would give you a list of departments that the given ability can contact their users: # - # > user = User.new(department_id: 13, manager: false) - # > ability = Ability.new(user) - # > Department.accessible_through(ability, :contact, User).to_sql - # => SELECT * FROM territories WHERE id = 13 + # > user = User.new(department_id: 13, manager: false) + # > ability = Ability.new(user) + # > Department.accessible_through(ability, :contact, User).to_sql + # => SELECT * FROM departments WHERE id = 13 + # > + # > user = User.new(department_id: 13, managing_department_ids: [2, 3, 4], manager: true) + # > ability = Ability.new(user) + # > Department.accessible_through(ability, :contact, User).to_sql + # => SELECT * FROM departments WHERE ((id = 13) OR (id IN (2, 3, 4))) # - # > user = User.new(department_id: 13, managing_department_ids: [2, 3, 4], manager: true) - # > ability = Ability.new(user) - # > Department.accessible_through(ability, :contact, User).to_sql - # => SELECT * FROM territories WHERE ((id = 13) OR (id IN (2, 3, 4))) def accessible_through(ability, action, subject, relation: model_name.element, strategy: CanCan.accessible_by_strategy) CanCan.with_accessible_by_strategy(strategy) do ability.relation_model_adapter(self, action, subject, relation) From 24ebde999e97f3bfaf3fbb78347b331389231010 Mon Sep 17 00:00:00 2001 From: Carlos Palhares Date: Mon, 19 Jul 2021 12:12:43 -0300 Subject: [PATCH 4/4] Fix documentation --- lib/cancan/model_additions.rb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/cancan/model_additions.rb b/lib/cancan/model_additions.rb index 9c45cf114..6e50775f6 100644 --- a/lib/cancan/model_additions.rb +++ b/lib/cancan/model_additions.rb @@ -61,8 +61,7 @@ def accessible_by(ability, action = :index, strategy: CanCan.accessible_by_strat # > Department.accessible_through(ability, :contact, User).to_sql # => SELECT * FROM departments WHERE ((id = 13) OR (id IN (2, 3, 4))) # - # Sometimes the name of the relation doesn't match the model. When that happens, you can override it - # with `relation`: + # Sometimes the name of the relation doesn't match the model: # # class User < ActiveRecord::Base # has_many :managing_users, class_name: "User", foreign_key: :managed_by_id @@ -76,16 +75,17 @@ def accessible_by(ability, action = :index, strategy: CanCan.accessible_by_strat # end # end # - # This would give you a list of departments that the given ability can contact their users: + # When that happens, you can override it with `relation`. This would give you a list of departments + # that the given ability can contact their users: # # > user = User.new(department_id: 13, manager: false) # > ability = Ability.new(user) - # > Department.accessible_through(ability, :contact, User).to_sql + # > Department.accessible_through(ability, :contact, User, relation: :managing_users).to_sql # => SELECT * FROM departments WHERE id = 13 # > # > user = User.new(department_id: 13, managing_department_ids: [2, 3, 4], manager: true) # > ability = Ability.new(user) - # > Department.accessible_through(ability, :contact, User).to_sql + # > Department.accessible_through(ability, :contact, User, relation: :managing_users).to_sql # => SELECT * FROM departments WHERE ((id = 13) OR (id IN (2, 3, 4))) # def accessible_through(ability, action, subject, relation: model_name.element, strategy: CanCan.accessible_by_strategy)