diff --git a/app/models/ability.rb b/app/models/ability.rb index a185448d5ea24dc8f41b268f7fa1de85fcc4671e..b15143c8c9c4b471cd60848113f5abdec9944763 100644 --- a/app/models/ability.rb +++ b/app/models/ability.rb @@ -26,6 +26,13 @@ def users_that_can_read_personal_snippet(users, snippet) end end + # A list of users that can read confidential notes in a project + def users_that_can_read_internal_notes(users, note_parent) + DeclarativePolicy.subject_scope do + users.select { |u| allowed?(u, :reporter_access, note_parent) } + end + end + # Returns an Array of Issues that can be read by the given user. # # issues - The issues to reduce down to those readable by the user. diff --git a/app/models/concerns/cache_markdown_field.rb b/app/models/concerns/cache_markdown_field.rb index 99dbe464a7c8a947c1c0af933ec5a13003bf7964..9ee0fd1db1dee5779cb4005263d16502deb9d34c 100644 --- a/app/models/concerns/cache_markdown_field.rb +++ b/app/models/concerns/cache_markdown_field.rb @@ -172,7 +172,7 @@ def store_mentions! refs = all_references(self.author) references = {} - references[:mentioned_users_ids] = refs.mentioned_user_ids.presence + references[:mentioned_users_ids] = mentioned_filtered_user_ids_for(refs) references[:mentioned_groups_ids] = refs.mentioned_group_ids.presence references[:mentioned_projects_ids] = refs.mentioned_project_ids.presence @@ -185,6 +185,13 @@ def store_mentions! true end + # Overriden on objects that needs to filter + # mentioned users that cannot read them, for example, + # guest users that are referenced on a confidential note. + def mentioned_filtered_user_ids_for(refs) + refs.mentioned_user_ids.presence + end + def mentionable_attributes_changed?(changes = saved_changes) return false unless is_a?(Mentionable) diff --git a/app/models/concerns/participable.rb b/app/models/concerns/participable.rb index 20743ebcb522d1dda6756b28f366f0403b976839..f59b5d1ecc8f4274802deedcf792b96b1d53623d 100644 --- a/app/models/concerns/participable.rb +++ b/app/models/concerns/participable.rb @@ -92,7 +92,13 @@ def filtered_participants_hash end def raw_participants(current_user = nil, verify_access: false) - ext = Gitlab::ReferenceExtractor.new(project, current_user) + extractor = Gitlab::ReferenceExtractor.new(project, current_user) + + # Used to extract references from confidential notes. + # Referenced users that cannot read confidential notes are + # later removed from participants array. + internal_notes_extractor = Gitlab::ReferenceExtractor.new(project, current_user) + participants = Set.new process = [self] @@ -107,6 +113,8 @@ def raw_participants(current_user = nil, verify_access: false) source.class.participant_attrs.each do |attr| if attr.respond_to?(:call) + ext = use_internal_notes_extractor_for?(source) ? internal_notes_extractor : extractor + source.instance_exec(current_user, ext, &attr) else process << source.__send__(attr) # rubocop:disable GitlabSecurity/PublicSend @@ -121,7 +129,18 @@ def raw_participants(current_user = nil, verify_access: false) end end - participants.merge(ext.users) + participants.merge(users_that_can_read_internal_notes(internal_notes_extractor)) + participants.merge(extractor.users) + end + + def use_internal_notes_extractor_for?(source) + source.is_a?(Note) && source.confidential? + end + + def users_that_can_read_internal_notes(extractor) + return [] unless self.is_a?(Noteable) && self.try(:resource_parent) + + Ability.users_that_can_read_internal_notes(extractor.users, self.resource_parent) end def source_visible_to_user?(source, user) diff --git a/app/models/note.rb b/app/models/note.rb index 41e45a8759f73bbc8bf093467dd918e1d4195c61..f2ddf0efe47cb93316bb4125680e25e77bcf90d6 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -665,6 +665,25 @@ def commands_changes ) end + def mentioned_users(current_user = nil) + users = super + + return users unless confidential? + + Ability.users_that_can_read_internal_notes(users, resource_parent) + end + + def mentioned_filtered_user_ids_for(references) + return super unless confidential? + + user_ids = references.mentioned_user_ids.presence + + return [] if user_ids.blank? + + users = User.where(id: user_ids) + Ability.users_that_can_read_internal_notes(users, resource_parent).pluck(:id) + end + private def system_note_viewable_by?(user) diff --git a/ee/spec/models/concerns/ee/participable_spec.rb b/ee/spec/models/concerns/ee/participable_spec.rb index b369893640969a710608d61654df631eeff995ad..79293578eecb0653d37acf277627f3b5445b761e 100644 --- a/ee/spec/models/concerns/ee/participable_spec.rb +++ b/ee/spec/models/concerns/ee/participable_spec.rb @@ -20,7 +20,7 @@ it 'returns the list of participants' do expect(instance).to receive(:foo).and_return(user2) expect(instance).to receive(:bar).and_return(user3) - expect(instance).to receive(:group).and_return(group) + expect(instance).to receive(:group).thrice.and_return(group) participants = instance.participants(user1) expect(participants).to contain_exactly(user2, user3) diff --git a/ee/spec/models/epic_spec.rb b/ee/spec/models/epic_spec.rb index ae11d5e3c3c9e78fc23dda99100b7954574b7975..16ed27d4402305c14319da56d33d88c2783efc5a 100644 --- a/ee/spec/models/epic_spec.rb +++ b/ee/spec/models/epic_spec.rb @@ -1134,4 +1134,13 @@ def as_item(item) end end end + + describe '#participants' do + it_behaves_like 'issuable participants' do + let_it_be(:issuable_parent) { create(:group, :public) } + let_it_be_with_refind(:issuable) { create(:epic, group: issuable_parent) } + + let(:params) { { noteable: issuable } } + end + end end diff --git a/spec/models/ability_spec.rb b/spec/models/ability_spec.rb index 5bd69ad9fad15ece90c4f2a2aa81f1f86dab0c34..422dd9a463bc8affd5ea23233b2c4913f48d0607 100644 --- a/spec/models/ability_spec.rb +++ b/spec/models/ability_spec.rb @@ -151,6 +151,38 @@ def users_for_snippet(snippet) end end + describe '.users_that_can_read_internal_note' do + shared_examples 'filtering users that can read internal note' do + let_it_be(:guest) { create(:user) } + let_it_be(:reporter) { create(:user) } + + let(:users) { [reporter, guest] } + + before do + parent.add_guest(guest) + parent.add_reporter(reporter) + end + + it 'returns users that can read internal notes' do + result = described_class.users_that_can_read_internal_notes(users, parent) + + expect(result).to match_array([reporter]) + end + end + + context 'for groups' do + it_behaves_like 'filtering users that can read internal note' do + let(:parent) { create(:group) } + end + end + + context 'for projects' do + it_behaves_like 'filtering users that can read internal note' do + let(:parent) { create(:project) } + end + end + end + describe '.merge_requests_readable_by_user' do context 'with an admin when admin mode is enabled', :enable_admin_mode do it 'returns all merge requests' do diff --git a/spec/models/concerns/participable_spec.rb b/spec/models/concerns/participable_spec.rb index 99a3a0fb79a7db38005ad4419051bbd9431bc9ea..b92c7c52f0be5b1323853952f77d63d91de0e896 100644 --- a/spec/models/concerns/participable_spec.rb +++ b/spec/models/concerns/participable_spec.rb @@ -31,7 +31,7 @@ expect(instance).to receive(:foo).and_return(user2) expect(instance).to receive(:bar).and_return(user3) - expect(instance).to receive(:project).twice.and_return(project) + expect(instance).to receive(:project).thrice.and_return(project) participants = instance.participants(user1) @@ -66,7 +66,7 @@ expect(instance).to receive(:foo).and_return(other) expect(other).to receive(:bar).and_return(user2) - expect(instance).to receive(:project).twice.and_return(project) + expect(instance).to receive(:project).thrice.and_return(project) expect(instance.participants(user1)).to eq([user2]) end @@ -86,7 +86,7 @@ instance = model.new - expect(instance).to receive(:project).twice.and_return(project) + expect(instance).to receive(:project).thrice.and_return(project) instance.participants(user1) @@ -138,7 +138,7 @@ allow(instance).to receive_message_chain(:model_name, :element) { 'class' } expect(instance).to receive(:foo).and_return(user2) expect(instance).to receive(:bar).and_return(user3) - expect(instance).to receive(:project).twice.and_return(project) + expect(instance).to receive(:project).thrice.and_return(project) participants = instance.visible_participants(user1) @@ -159,7 +159,7 @@ allow(instance).to receive_message_chain(:model_name, :element) { 'class' } allow(instance).to receive(:bar).and_return(user2) - expect(instance).to receive(:project).twice.and_return(project) + expect(instance).to receive(:project).thrice.and_return(project) expect(instance.visible_participants(user1)).to be_empty end diff --git a/spec/models/issue_spec.rb b/spec/models/issue_spec.rb index d45a23a7ef8082107f0fb63ebbc75cfa5547790e..9f864afc2131d6ddd4534aaa1bd1096a7ff5c1c3 100644 --- a/spec/models/issue_spec.rb +++ b/spec/models/issue_spec.rb @@ -744,25 +744,11 @@ end describe '#participants' do - context 'using a public project' do - let_it_be(:public_project) { create(:project, :public) } - let_it_be(:issue) { create(:issue, project: public_project) } - - let!(:note1) do - create(:note_on_issue, noteable: issue, project: public_project, note: 'a') - end - - let!(:note2) do - create(:note_on_issue, noteable: issue, project: public_project, note: 'b') - end - - it 'includes the issue author' do - expect(issue.participants).to include(issue.author) - end + it_behaves_like 'issuable participants' do + let_it_be(:issuable_parent) { create(:project, :public) } + let_it_be_with_refind(:issuable) { create(:issue, project: issuable_parent) } - it 'includes the authors of the notes' do - expect(issue.participants).to include(note1.author, note2.author) - end + let(:params) { { noteable: issuable, project: issuable_parent } } end context 'using a private project' do diff --git a/spec/services/notification_service_spec.rb b/spec/services/notification_service_spec.rb index 032f35cfc29af2e17de048546b6a2863b113754e..8e0f964d965364f49462f228925485d42d432d91 100644 --- a/spec/services/notification_service_spec.rb +++ b/spec/services/notification_service_spec.rb @@ -147,6 +147,34 @@ end end + shared_examples 'participating by confidential note notification' do + context 'when user is mentioned on confidential note' do + let_it_be(:guest_1) { create(:user) } + let_it_be(:guest_2) { create(:user) } + let_it_be(:reporter) { create(:user) } + + before do + issuable.resource_parent.add_guest(guest_1) + issuable.resource_parent.add_guest(guest_2) + issuable.resource_parent.add_reporter(reporter) + end + + it 'only emails authorized users' do + confidential_note_text = "#{guest_1.to_reference} and #{guest_2.to_reference} and #{reporter.to_reference}" + note_text = "Mentions #{guest_2.to_reference}" + create(:note_on_issue, noteable: issuable, project_id: project.id, note: confidential_note_text, confidential: true) + create(:note_on_issue, noteable: issuable, project_id: project.id, note: note_text) + reset_delivered_emails! + + notification_trigger + + should_not_email(guest_1) + should_email(guest_2) + should_email(reporter) + end + end + end + shared_examples 'participating by assignee notification' do it 'emails the participant' do issuable.assignees << participant @@ -736,6 +764,20 @@ let(:notification_target) { note } let(:notification_trigger) { notification.new_note(note) } end + + context 'when note is confidential' do + let(:note) { create(:note_on_issue, author: author, noteable: issue, project_id: issue.project_id, note: '@all mentioned', confidential: true) } + let(:guest) { create(:user) } + + it 'does not notify users that cannot read note' do + project.add_guest(guest) + reset_delivered_emails! + + notification.new_note(note) + + should_not_email(guest) + end + end end end @@ -1376,6 +1418,11 @@ let(:notification_trigger) { notification.reassigned_issue(issue, @u_disabled, [assignee]) } end + it_behaves_like 'participating by confidential note notification' do + let(:issuable) { issue } + let(:notification_trigger) { notification.reassigned_issue(issue, @u_disabled, [assignee]) } + end + it_behaves_like 'project emails are disabled' do let(:notification_target) { issue } let(:notification_trigger) { notification.reassigned_issue(issue, @u_disabled, [assignee]) } @@ -1494,6 +1541,11 @@ let(:notification_target) { issue } let(:notification_trigger) { notification.removed_milestone_issue(issue, issue.author) } end + + it_behaves_like 'participating by confidential note notification' do + let(:issuable) { issue } + let(:notification_trigger) { notification.removed_milestone_issue(issue, issue.author) } + end end context 'confidential issues' do @@ -1616,6 +1668,11 @@ let(:notification_trigger) { notification.close_issue(issue, @u_disabled) } end + it_behaves_like 'participating by confidential note notification' do + let(:issuable) { issue } + let(:notification_trigger) { notification.close_issue(issue, @u_disabled) } + end + it 'adds "subscribed" reason to subscriber emails' do user_1 = create(:user) issue.subscribe(user_1) @@ -1658,6 +1715,11 @@ let(:notification_trigger) { notification.reopen_issue(issue, @u_disabled) } end + it_behaves_like 'participating by confidential note notification' do + let(:issuable) { issue } + let(:notification_trigger) { notification.reopen_issue(issue, @u_disabled) } + end + it_behaves_like 'project emails are disabled' do let(:notification_target) { issue } let(:notification_trigger) { notification.reopen_issue(issue, @u_disabled) } @@ -1689,6 +1751,11 @@ let(:notification_trigger) { notification.issue_moved(issue, new_issue, @u_disabled) } end + it_behaves_like 'participating by confidential note notification' do + let(:issuable) { issue } + let(:notification_trigger) { notification.issue_moved(issue, new_issue, @u_disabled) } + end + it_behaves_like 'project emails are disabled' do let(:notification_target) { issue } let(:notification_trigger) { notification.issue_moved(issue, new_issue, @u_disabled) } @@ -1720,6 +1787,11 @@ let(:notification_trigger) { notification.issue_cloned(issue, new_issue, @u_disabled) } end + it_behaves_like 'participating by confidential note notification' do + let(:issuable) { issue } + let(:notification_trigger) { notification.issue_cloned(issue, new_issue, @u_disabled) } + end + it_behaves_like 'project emails are disabled' do let(:notification_target) { issue } let(:notification_trigger) { notification.issue_cloned(issue, new_issue, @u_disabled) } @@ -1765,6 +1837,11 @@ let(:notification_trigger) { notification.issue_due(issue) } end + it_behaves_like 'participating by confidential note notification' do + let(:issuable) { issue } + let(:notification_trigger) { notification.issue_due(issue) } + end + it_behaves_like 'project emails are disabled' do let(:notification_target) { issue } let(:notification_trigger) { notification.issue_due(issue) } diff --git a/spec/support/shared_examples/models/issuable_participants_shared_examples.rb b/spec/support/shared_examples/models/issuable_participants_shared_examples.rb new file mode 100644 index 0000000000000000000000000000000000000000..c3eaae0ace2134e1ef36d732c83deb1208d4ade1 --- /dev/null +++ b/spec/support/shared_examples/models/issuable_participants_shared_examples.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +RSpec.shared_examples 'issuable participants' do + context 'when resource parent is public' do + context 'and users are referenced on notes' do + let_it_be(:notes_author) { create(:user) } + + let(:note_params) { params.merge(author: notes_author) } + + before do + create(:note, note_params) + end + + it 'includes the issue author' do + expect(issuable.participants).to include(issuable.author) + end + + it 'includes the authors of the notes' do + expect(issuable.participants).to include(notes_author) + end + + context 'and note is confidential' do + context 'and mentions users' do + let_it_be(:guest_1) { create(:user) } + let_it_be(:guest_2) { create(:user) } + let_it_be(:reporter) { create(:user) } + + before do + issuable_parent.add_guest(guest_1) + issuable_parent.add_guest(guest_2) + issuable_parent.add_reporter(reporter) + + confidential_note_params = + note_params.merge( + confidential: true, + note: "mentions #{guest_1.to_reference} and #{guest_2.to_reference} and #{reporter.to_reference}" + ) + + regular_note_params = + note_params.merge(note: "Mentions #{guest_2.to_reference}") + + create(:note, confidential_note_params) + create(:note, regular_note_params) + end + + it 'only includes users that can read the note as participants' do + expect(issuable.participants).to contain_exactly(issuable.author, notes_author, reporter, guest_2) + end + end + end + end + end +end diff --git a/spec/support/shared_examples/models/mentionable_shared_examples.rb b/spec/support/shared_examples/models/mentionable_shared_examples.rb index e23658d17749027a4f74669ba258ea1d3b705e17..f9612dd61beb625d6fcbeb33fdcb85e033a4ffab 100644 --- a/spec/support/shared_examples/models/mentionable_shared_examples.rb +++ b/spec/support/shared_examples/models/mentionable_shared_examples.rb @@ -260,6 +260,25 @@ expect(mentionable.referenced_projects(user)).to eq [mentionable.project].compact # epic.project is nil, and we want empty [] expect(mentionable.referenced_groups(user)).to eq [group] end + + if [:epic, :issue].include?(mentionable_type) + context 'and note is confidential' do + let_it_be(:guest) { create(:user) } + + let(:note_desc) { "#{guest.to_reference} and #{user2.to_reference} and #{user.to_reference}" } + + before do + note.resource_parent.add_reporter(user2) + note.resource_parent.add_guest(guest) + # Bypass :confidential update model validation for testing purposes + note.update_attribute(:confidential, true) + end + + it 'returns only mentioned users that has permissions' do + expect(note.mentioned_users).to contain_exactly(user, user2) + end + end + end end end @@ -294,6 +313,26 @@ end end + if [:epic, :issue].include?(mentionable_type) + context 'and note is confidential' do + let_it_be(:guest) { create(:user) } + + let(:note_desc) { "#{guest.to_reference} and #{mentioned_user.to_reference}" } + + before do + note.resource_parent.add_reporter(mentioned_user) + note.resource_parent.add_guest(guest) + # Bypass :confidential update model validation for testing purposes + note.update_attribute(:confidential, true) + note.store_mentions! + end + + it 'stores only mentioned users that has permissions' do + expect(mentionable.referenced_users).to contain_exactly(mentioned_user) + end + end + end + context 'when private projects and groups are mentioned' do let(:mega_user) { create(:user) } let(:private_project) { create(:project, :private) }