diff --git a/app/finders/group_members_finder.rb b/app/finders/group_members_finder.rb index 639db58b00d2718095d045e601a245da9294ab9a..0336135835ac5f94c35b3264cbf2e8c7be59b4ce 100644 --- a/app/finders/group_members_finder.rb +++ b/app/finders/group_members_finder.rb @@ -20,6 +20,8 @@ class GroupMembersFinder < UnionFinder # search: string # created_after: datetime # created_before: datetime + # non_invite: boolean + # with_custom_role: boolean attr_reader :params def initialize(group, user = nil, params: {}) @@ -34,7 +36,10 @@ def execute(include_relations: DEFAULT_RELATIONS) Group.shared_into_ancestors(group).public_or_visible_to_user(user) end - members = all_group_members(groups, shared_from_groups).distinct_on_user_with_max_access_level + members = all_group_members(groups, shared_from_groups) + if static_roles_only? + members = members.distinct_on_user_with_max_access_level + end filter_members(members) end @@ -70,7 +75,10 @@ def filter_members(members) members = filter_by_user_type(members) members = apply_additional_filters(members) - by_created_at(members) + members = by_created_at(members) + members = members.non_invite if params[:non_invite] + + members end def can_manage_members @@ -137,6 +145,10 @@ def apply_additional_filters(members) # overridden in EE to include additional filtering conditions. members end + + def static_roles_only? + true + end end GroupMembersFinder.prepend_mod_with('GroupMembersFinder') diff --git a/config/feature_flags/development/custom_roles_in_members_page.yml b/config/feature_flags/development/custom_roles_in_members_page.yml new file mode 100644 index 0000000000000000000000000000000000000000..cb6bea5ca421ba58a3e83e40b7d9a2bb0b37cddd --- /dev/null +++ b/config/feature_flags/development/custom_roles_in_members_page.yml @@ -0,0 +1,8 @@ +--- +name: custom_roles_in_members_page +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/128491 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/422897 +milestone: '16.3' +type: development +group: group::authentication and authorization +default_enabled: false diff --git a/ee/app/controllers/ee/groups/group_members_controller.rb b/ee/app/controllers/ee/groups/group_members_controller.rb index f4612e4ee729909391cbefe6659dcb41bd3d088d..6835c0153aac02781ca01351754fd5589eaf7990 100644 --- a/ee/app/controllers/ee/groups/group_members_controller.rb +++ b/ee/app/controllers/ee/groups/group_members_controller.rb @@ -33,7 +33,14 @@ def admin_not_required_endpoints def index super - @banned = presented_banned_members # rubocop:disable Gitlab/ModuleWithInstanceVariables + # rubocop:disable Gitlab/ModuleWithInstanceVariables + @banned = presented_banned_members + @memberships_with_custom_role = if ::Feature.enabled?(:custom_roles_in_members_page, group) + present_group_members(group_memberships_with_custom_role) + else + [] + end + # rubocop:enable Gitlab/ModuleWithInstanceVariables end # rubocop:disable Gitlab/ModuleWithInstanceVariables @@ -115,6 +122,13 @@ def presented_banned_members present_members(banned_members(params: filter_params)) end + def group_memberships_with_custom_role + @memberships_with_custom_role ||= + ::GroupMembersFinder + .new(group, current_user, params: filter_params.merge({ non_invite: true, with_custom_role: true })) + .execute(include_relations: requested_relations) + end + def authorize_update_group_member! unless can?(current_user, :admin_group_member, group) || can?(current_user, :override_group_member, group) render_403 diff --git a/ee/app/finders/ee/group_members_finder.rb b/ee/app/finders/ee/group_members_finder.rb index d45b046266569fd5a7bd49606a2950d0156a31a5..238e6dc0aa2f43dcb02e161138dda87ccc0ee084 100644 --- a/ee/app/finders/ee/group_members_finder.rb +++ b/ee/app/finders/ee/group_members_finder.rb @@ -8,6 +8,14 @@ module EE::GroupMembersFinder attr_reader :group end + override :execute + def execute(include_relations: ::GroupMembersFinder::DEFAULT_RELATIONS) + members = super + members = members.with_custom_role if params[:with_custom_role] + + members + end + # rubocop: disable CodeReuse/ActiveRecord def not_managed group.group_members.non_owners.joins(:user).merge(User.not_managed(group: group)) @@ -49,4 +57,9 @@ def filter_by_enterprise_users(members) def can_filter_by_enterprise? can_manage_members && group.root_ancestor.saml_enabled? end + + override :static_roles_only? + def static_roles_only? + !params[:with_custom_role] + end end diff --git a/ee/app/models/ee/member.rb b/ee/app/models/ee/member.rb index fd5c47f2a377c9978a4d599308cc72db78b45aa5..03e1fd46723aaf9e43c4c71945760c465cf638e9 100644 --- a/ee/app/models/ee/member.rb +++ b/ee/app/models/ee/member.rb @@ -37,6 +37,8 @@ module Member where.not(user_id: ::Namespaces::NamespaceBan.where(namespace: namespace).select(:user_id)) end + scope :with_custom_role, -> { where.not(member_role_id: nil) } + scope :elevated_guests, -> do where(access_level: ::Gitlab::Access::GUEST).joins(:member_role).merge(MemberRole.elevating) end diff --git a/ee/spec/controllers/groups/group_members_controller_spec.rb b/ee/spec/controllers/groups/group_members_controller_spec.rb index 2b0314acd0de56a786286db17195f2b4fb384781..cd5df43a62bb58f1e9ec8ce468b3cab57cdb8ea8 100644 --- a/ee/spec/controllers/groups/group_members_controller_spec.rb +++ b/ee/spec/controllers/groups/group_members_controller_spec.rb @@ -43,6 +43,85 @@ expect(count_queries).to be(false) end end + + context 'when querying customizable roles' do + let_it_be(:root_group, reload: true) { create(:group) } + let_it_be(:sub_group, reload: true) { create(:group, parent: root_group) } + let_it_be(:sub_2_group, reload: true) { create(:group, parent: sub_group) } + let_it_be(:sub_3_group, reload: true) { create(:group, parent: sub_2_group) } + + before do + sign_in(user) + end + + context 'when there is no customizable role' do + it 'returns no membership' do + user = create(:user) + + create(:group_member, user: user, group: sub_2_group, access_level: Gitlab::Access::OWNER) + create(:group_member, user: user, group: sub_group, access_level: Gitlab::Access::MAINTAINER) + create(:group_member, user: user, group: root_group, access_level: Gitlab::Access::DEVELOPER) + + get :index, params: { group_id: sub_3_group } + + expect(assigns(:memberships_with_custom_role)).to be_empty + end + end + + context 'when there are customizable roles defined' do + let_it_be(:member_user) { create(:user) } + let_it_be(:sub_group_membership) do + maintainer_member_role = create(:member_role, { name: 'custom maintainer', + namespace: root_group, + base_access_level: ::Gitlab::Access::MAINTAINER }) + create(:group_member, { user: member_user, + group: sub_group, + access_level: Gitlab::Access::MAINTAINER, + member_role: maintainer_member_role }) + end + + let_it_be(:custom_owner_role) do + create(:member_role, { name: 'custom owner', + namespace: root_group, + base_access_level: ::Gitlab::Access::OWNER }) + end + + let_it_be(:sub_2_group_membership) do + create(:group_member, { user: member_user, + group: sub_2_group, + access_level: Gitlab::Access::OWNER, + member_role: custom_owner_role }) + end + + let_it_be(:invited_membership) do + create(:group_member, :invited, { user: member_user, + group: sub_3_group, + access_level: Gitlab::Access::OWNER, + member_role: custom_owner_role }) + end + + it 'queries all customizable role of a user' do + create(:group_member, user: member_user, group: root_group, access_level: Gitlab::Access::DEVELOPER) + + get :index, params: { group_id: sub_3_group } + + expect(assigns(:memberships_with_custom_role).map(&:id)) + .to(contain_exactly(sub_group_membership.id, sub_2_group_membership.id)) + end + + context 'when custom_roles_members_page is disabled' do + before do + stub_feature_flags(custom_roles_in_members_page: false) + end + + it 'returns no membership' do + get :index, params: { group_id: sub_3_group } + + expect(assigns(:memberships_with_custom_role)).to be_empty + end + end + end + end end describe 'DELETE #leave' do diff --git a/ee/spec/finders/ee/group_members_finder_spec.rb b/ee/spec/finders/ee/group_members_finder_spec.rb index d52ac0113c927fd648495b625064837137360086..a7ab1d903f1db6d6f4b178896669401179e04f03 100644 --- a/ee/spec/finders/ee/group_members_finder_spec.rb +++ b/ee/spec/finders/ee/group_members_finder_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe GroupMembersFinder do +RSpec.describe GroupMembersFinder, feature_category: :groups_and_projects do subject(:finder) { described_class.new(group) } let_it_be(:group) { create :group } @@ -21,6 +21,145 @@ end describe '#execute' do + context 'with custom roles' do + let_it_be(:group) { create(:group) } + let_it_be(:sub_group) { create(:group, parent: group) } + let_it_be(:sub_sub_group) { create(:group, parent: sub_group) } + let_it_be(:public_shared_group) { create(:group, :public) } + let_it_be(:private_shared_group) { create(:group, :private) } + let_it_be(:user1) { create(:user) } + let_it_be(:user2) { create(:user) } + let_it_be(:user3) { create(:user) } + let_it_be(:user4) { create(:user) } + let_it_be(:user5_2fa) { create(:user, :two_factor_via_otp) } + + let_it_be(:link) do + create(:group_group_link, shared_group: group, shared_with_group: public_shared_group) + create(:group_group_link, shared_group: sub_group, shared_with_group: private_shared_group) + end + + let(:groups) do + { + group: group, + sub_group: sub_group, + sub_sub_group: sub_sub_group, + public_shared_group: public_shared_group, + private_shared_group: private_shared_group + } + end + + let_it_be(:members) do + group_custom_maintainer_role = create(:member_role, { name: 'custom maintainer', + namespace: group, + base_access_level: ::Gitlab::Access::MAINTAINER }) + group_custom_developer_role = create(:member_role, { name: 'custom developer', + namespace: group, + base_access_level: ::Gitlab::Access::DEVELOPER }) + group_custom_reporter_role = create(:member_role, { name: 'custom reporter', + namespace: group, + base_access_level: ::Gitlab::Access::REPORTER }) + public_shared_group_custom_maintainer_role = create(:member_role, { name: 'custom maintainer', + namespace: public_shared_group, + base_access_level: ::Gitlab::Access::MAINTAINER }) + public_shared_group_custom_developer_role = create(:member_role, { name: 'custom developer', + namespace: public_shared_group, + base_access_level: ::Gitlab::Access::DEVELOPER }) + public_shared_group_custom_reporter_role = create(:member_role, { name: 'custom reporter', + namespace: public_shared_group, + base_access_level: ::Gitlab::Access::REPORTER }) + private_shared_group_custom_maintainer_role = create(:member_role, { name: 'custom maintainer', + namespace: private_shared_group, + base_access_level: ::Gitlab::Access::MAINTAINER }) + private_shared_group_custom_developer_role = create(:member_role, { name: 'custom developer', + namespace: private_shared_group, + base_access_level: ::Gitlab::Access::DEVELOPER }) + private_shared_group_custom_reporter_role = create(:member_role, { name: 'custom reporter', + namespace: private_shared_group, + base_access_level: ::Gitlab::Access::REPORTER }) + { + user1_sub_sub_group: create(:group_member, :maintainer, group: sub_sub_group, user: user1, member_role: group_custom_maintainer_role), + user1_sub_group: create(:group_member, :developer, group: sub_group, user: user1, member_role: group_custom_developer_role), + user1_group: create(:group_member, :reporter, group: group, user: user1, member_role: group_custom_reporter_role), + user1_public_shared_group: create(:group_member, :maintainer, group: public_shared_group, user: user1, member_role: public_shared_group_custom_maintainer_role), + user1_private_shared_group: create(:group_member, :maintainer, group: private_shared_group, user: user1, member_role: private_shared_group_custom_maintainer_role), + user2_sub_sub_group: create(:group_member, :reporter, group: sub_sub_group, user: user2, member_role: group_custom_reporter_role), + user2_sub_group: create(:group_member, :developer, group: sub_group, user: user2, member_role: group_custom_developer_role), + user2_group: create(:group_member, :maintainer, group: group, user: user2, member_role: group_custom_maintainer_role), + user2_public_shared_group: create(:group_member, :developer, group: public_shared_group, user: user2, member_role: public_shared_group_custom_developer_role), + user2_private_shared_group: create(:group_member, :developer, group: private_shared_group, user: user2, member_role: private_shared_group_custom_developer_role), + user3_sub_sub_group: create(:group_member, :developer, group: sub_sub_group, user: user3, expires_at: 1.day.from_now, member_role: group_custom_developer_role), + user3_sub_group: create(:group_member, :developer, group: sub_group, user: user3, expires_at: 2.days.from_now, member_role: group_custom_developer_role), + user3_group: create(:group_member, :reporter, group: group, user: user3, member_role: group_custom_reporter_role), + user3_public_shared_group: create(:group_member, :reporter, group: public_shared_group, user: user3, member_role: public_shared_group_custom_reporter_role), + user3_private_shared_group: create(:group_member, :reporter, group: private_shared_group, user: user3, member_role: private_shared_group_custom_reporter_role), + user4_sub_sub_group: create(:group_member, :reporter, group: sub_sub_group, user: user4, member_role: group_custom_reporter_role), + user4_sub_group: create(:group_member, :developer, group: sub_group, user: user4, expires_at: 1.day.from_now, member_role: group_custom_developer_role), + user4_group: create(:group_member, :developer, group: group, user: user4, expires_at: 2.days.from_now, member_role: group_custom_developer_role), + user4_public_shared_group: create(:group_member, :developer, group: public_shared_group, user: user4, member_role: public_shared_group_custom_developer_role), + user4_private_shared_group: create(:group_member, :developer, group: private_shared_group, user: user4, member_role: private_shared_group_custom_developer_role), + user5_private_shared_group: create(:group_member, :developer, group: private_shared_group, user: user5_2fa, member_role: private_shared_group_custom_developer_role) + } + end + + using RSpec::Parameterized::TableSyntax + + # rubocop: disable Layout/ArrayAlignment + where(:subject_relations, :subject_group, :expected_members) do + [] | :group | [] + GroupMembersFinder::DEFAULT_RELATIONS | :group | [:user1_group, :user2_group, :user3_group, :user4_group] + [:direct] | :group | [:user1_group, :user2_group, :user3_group, :user4_group] + [:inherited] | :group | [] + [:descendants] | :group | [:user1_sub_group, :user1_sub_sub_group, + :user2_sub_group, :user2_sub_sub_group, + :user3_sub_group, :user3_sub_sub_group, + :user4_sub_group, :user4_sub_sub_group] + [:shared_from_groups] | :group | [:user1_public_shared_group, :user2_public_shared_group, :user3_public_shared_group, :user4_public_shared_group] + [:direct, :inherited, :descendants, :shared_from_groups] | :group | [:user1_group, :user1_sub_group, :user1_sub_sub_group, :user1_public_shared_group, + :user2_group, :user2_sub_group, :user2_sub_sub_group, :user2_public_shared_group, + :user3_group, :user3_sub_group, :user3_sub_sub_group, :user3_public_shared_group, + :user4_group, :user4_sub_group, :user4_sub_sub_group, :user4_public_shared_group] + [] | :sub_group | [] + GroupMembersFinder::DEFAULT_RELATIONS | :sub_group | [:user1_group, :user1_sub_group, + :user2_group, :user2_sub_group, + :user3_group, :user3_sub_group, + :user4_group, :user4_sub_group] + [:direct] | :sub_group | [:user1_sub_group, :user2_sub_group, :user3_sub_group, :user4_sub_group] + [:inherited] | :sub_group | [:user1_group, :user2_group, :user3_group, :user4_group] + [:descendants] | :sub_group | [:user1_sub_sub_group, :user2_sub_sub_group, :user3_sub_sub_group, :user4_sub_sub_group] + [:shared_from_groups] | :sub_group | [:user1_public_shared_group, :user2_public_shared_group, :user3_public_shared_group, :user4_public_shared_group] + [:direct, :inherited, :descendants, :shared_from_groups] | :sub_group | [:user1_group, :user1_sub_group, :user1_sub_sub_group, :user1_public_shared_group, + :user2_group, :user2_sub_group, :user2_sub_sub_group, :user2_public_shared_group, + :user3_group, :user3_sub_group, :user3_sub_sub_group, :user3_public_shared_group, + :user4_group, :user4_sub_group, :user4_sub_sub_group, :user4_public_shared_group] + [] | :sub_sub_group | [] + GroupMembersFinder::DEFAULT_RELATIONS | :sub_sub_group | [:user1_group, :user1_sub_group, :user1_sub_sub_group, + :user2_group, :user2_sub_group, :user2_sub_sub_group, + :user3_group, :user3_sub_group, :user3_sub_sub_group, + :user4_group, :user4_sub_group, :user4_sub_sub_group] + [:direct] | :sub_sub_group | [:user1_sub_sub_group, :user2_sub_sub_group, :user3_sub_sub_group, :user4_sub_sub_group] + [:inherited] | :sub_sub_group | [:user1_group, :user1_sub_group, + :user2_group, :user2_sub_group, + :user3_group, :user3_sub_group, + :user4_group, :user4_sub_group] + [:descendants] | :sub_sub_group | [] + [:shared_from_groups] | :sub_sub_group | [:user1_public_shared_group, :user2_public_shared_group, :user3_public_shared_group, :user4_public_shared_group] + [:direct, :inherited, :descendants, :shared_from_groups] | :sub_sub_group | [:user1_group, :user1_sub_group, :user1_sub_sub_group, :user1_public_shared_group, + :user2_group, :user2_sub_group, :user2_sub_sub_group, :user2_public_shared_group, + :user3_group, :user3_sub_group, :user3_sub_sub_group, :user3_public_shared_group, + :user4_group, :user4_sub_group, :user4_sub_sub_group, :user4_public_shared_group] + end + # rubocop: enable Layout/ArrayAlignment + with_them do + it 'returns correct members' do + result = described_class + .new(groups[subject_group], params: { with_custom_role: true }) + .execute(include_relations: subject_relations) + + expect(result.to_a).to match_array(expected_members.map { |name| members[name] }) + end + end + end + context 'minimal access' do let_it_be(:group_minimal_access_membership) do create(:group_member, :minimal_access, source: group) diff --git a/ee/spec/models/member_spec.rb b/ee/spec/models/member_spec.rb index 0d0ae1bd1f7ee09bece31aa9d4ea3904926f6db9..193ac9dec633b6c36d7491a7f104a40986342ab3 100644 --- a/ee/spec/models/member_spec.rb +++ b/ee/spec/models/member_spec.rb @@ -168,6 +168,27 @@ end end + describe 'scopes' do + describe '.with_custom_role' do + let_it_be(:membership_with_custom_role) do + member_role = create(:member_role, { name: 'custom maintainer', + namespace: group, + base_access_level: ::Gitlab::Access::MAINTAINER }) + + create(:group_member, group: sub_group, access_level: ::Gitlab::Access::MAINTAINER, member_role: member_role) + end + + let_it_be(:membership_without_custom_role) do + create(:group_member, group: sub_group, access_level: ::Gitlab::Access::MAINTAINER) + end + + subject { described_class.with_custom_role } + + it { is_expected.to include(membership_with_custom_role) } + it { is_expected.not_to include(membership_without_custom_role) } + end + end + describe '#notification_service' do it 'returns a NullNotificationService instance for LDAP users' do member = described_class.new diff --git a/spec/finders/group_members_finder_spec.rb b/spec/finders/group_members_finder_spec.rb index 18473a5e70bb4872d52e79bf747132d2bb5f2732..b8a5be4424178e34d428832b17c230ac73f128cb 100644 --- a/spec/finders/group_members_finder_spec.rb +++ b/spec/finders/group_members_finder_spec.rb @@ -288,4 +288,39 @@ end end end + + context 'filter by non-invite' do + let_it_be(:member) { group.add_maintainer(user1) } + let_it_be(:invited_member) do + create(:group_member, :invited, { user: user2, group: group }) + end + + context 'params is not passed in' do + subject { described_class.new(group, user1).execute } + + it 'does not filter members by invite' do + expect(subject).to match_array([member, invited_member]) + end + end + + context 'params is passed in' do + subject { described_class.new(group, user1, params: { non_invite: non_invite_param }).execute } + + context 'filtering is set to false' do + let(:non_invite_param) { false } + + it 'does not filter members by invite' do + expect(subject).to match_array([member, invited_member]) + end + end + + context 'filtering is set to true' do + let(:non_invite_param) { true } + + it 'filters members by invite' do + expect(subject).to match_array([member]) + end + end + end + end end