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..6e50775f6 100644 --- a/lib/cancan/model_additions.rb +++ b/lib/cancan/model_additions.rb @@ -25,6 +25,75 @@ 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 accessible + # 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 + # + # The following 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 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: + # + # 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 + # + # 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, 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, 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) + 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