diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 98d3b063475a9c0fc5f2f7be3f1b3db7f766ce57..bf7cc71841dbb395e9857f07a5ead204d57454cb 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -961,6 +961,20 @@ four standard [pagination arguments](#pagination-arguments): | <a id="queryselfmanagedaddoneligibleusersaddontype"></a>`addOnType` | [`GitlabSubscriptionsAddOnType!`](#gitlabsubscriptionsaddontype) | Type of add on to filter the eligible users by. | | <a id="queryselfmanagedaddoneligibleuserssearch"></a>`search` | [`String`](#string) | Search the user list. | +### `Query.selfManagedUsersQueuedForRolePromotion` + +Fields related to users within a self-managed instance that are pending role promotion approval. + +DETAILS: +**Introduced** in GitLab 17.1. +**Status**: Experiment. + +Returns [`UsersQueuedForRolePromotionConnection`](#usersqueuedforrolepromotionconnection). + +This field returns a [connection](#connections). It accepts the +four standard [pagination arguments](#pagination-arguments): +`before: String`, `after: String`, `first: Int`, and `last: Int`. + ### `Query.snippets` Find Snippets visible to the current user. @@ -15334,6 +15348,30 @@ The edge type for [`UserCore`](#usercore). | <a id="usercoreedgecursor"></a>`cursor` | [`String!`](#string) | A cursor for use in pagination. | | <a id="usercoreedgenode"></a>`node` | [`UserCore`](#usercore) | The item at the end of the edge. | +#### `UsersQueuedForRolePromotionConnection` + +The connection type for [`UsersQueuedForRolePromotion`](#usersqueuedforrolepromotion). + +##### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="usersqueuedforrolepromotionconnectioncount"></a>`count` | [`Int!`](#int) | Total count of collection. | +| <a id="usersqueuedforrolepromotionconnectionedges"></a>`edges` | [`[UsersQueuedForRolePromotionEdge]`](#usersqueuedforrolepromotionedge) | A list of edges. | +| <a id="usersqueuedforrolepromotionconnectionnodes"></a>`nodes` | [`[UsersQueuedForRolePromotion]`](#usersqueuedforrolepromotion) | A list of nodes. | +| <a id="usersqueuedforrolepromotionconnectionpageinfo"></a>`pageInfo` | [`PageInfo!`](#pageinfo) | Information to aid in pagination. | + +#### `UsersQueuedForRolePromotionEdge` + +The edge type for [`UsersQueuedForRolePromotion`](#usersqueuedforrolepromotion). + +##### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="usersqueuedforrolepromotionedgecursor"></a>`cursor` | [`String!`](#string) | A cursor for use in pagination. | +| <a id="usersqueuedforrolepromotionedgenode"></a>`node` | [`UsersQueuedForRolePromotion`](#usersqueuedforrolepromotion) | The item at the end of the edge. | + #### `ValueStreamConnection` The connection type for [`ValueStream`](#valuestream). @@ -31308,6 +31346,17 @@ fields relate to interactions between the two entities. | <a id="userstatusmessage"></a>`message` | [`String`](#string) | User status message. | | <a id="userstatusmessagehtml"></a>`messageHtml` | [`String`](#string) | HTML of the user status message. | +### `UsersQueuedForRolePromotion` + +Represents a Pending Member Approval Queued for Role Promotion. + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="usersqueuedforrolepromotionnewaccesslevel"></a>`newAccessLevel` | [`AccessLevel`](#accesslevel) | Highest New GitLab::Access level requested for the member. | +| <a id="usersqueuedforrolepromotionuser"></a>`user` | [`UserCore`](#usercore) | User that is associated with the member approval object. | + ### `ValueStream` #### Fields diff --git a/ee/app/finders/gitlab_subscriptions/member_management/self_managed/max_access_level_member_approvals_finder.rb b/ee/app/finders/gitlab_subscriptions/member_management/self_managed/max_access_level_member_approvals_finder.rb new file mode 100644 index 0000000000000000000000000000000000000000..142fc5c2ac8e5f11661888597d08f89bcde0d4a1 --- /dev/null +++ b/ee/app/finders/gitlab_subscriptions/member_management/self_managed/max_access_level_member_approvals_finder.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module GitlabSubscriptions + module MemberManagement + module SelfManaged + class MaxAccessLevelMemberApprovalsFinder + include GitlabSubscriptions::MemberManagement::PromotionManagementUtils + + def initialize(current_user) + @current_user = current_user + end + + def execute + model = ::Members::MemberApproval + return model.none unless promotion_management_applicable? + return model.none unless current_user.can_admin_all_resources? + + model.pending_member_approvals_with_max_new_access_level + end + + private + + attr_reader :current_user + end + end + end +end diff --git a/ee/app/graphql/ee/types/gitlab_subscriptions/member_management/users_queued_for_role_promotion_type.rb b/ee/app/graphql/ee/types/gitlab_subscriptions/member_management/users_queued_for_role_promotion_type.rb new file mode 100644 index 0000000000000000000000000000000000000000..48d9d772d37f073d45552bd6125f19ffc6b65a0f --- /dev/null +++ b/ee/app/graphql/ee/types/gitlab_subscriptions/member_management/users_queued_for_role_promotion_type.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module EE + module Types + module GitlabSubscriptions + module MemberManagement + class UsersQueuedForRolePromotionType < ::Types::BaseObject + graphql_name 'UsersQueuedForRolePromotion' + description 'Represents a Pending Member Approval Queued for Role Promotion' + + connection_type_class ::Types::CountableConnectionType + + field :new_access_level, ::Types::AccessLevelType, null: true, + description: 'Highest New GitLab::Access level requested for the member.' + + field :user, ::Types::UserType, null: true, + description: 'User that is associated with the member approval object.' + end + end + end + end +end diff --git a/ee/app/graphql/ee/types/query_type.rb b/ee/app/graphql/ee/types/query_type.rb index c9d0185723380c4d6dbb54108868c00c42ab331e..8d34619fd98a74328811c53cf54a9f837315cca9 100644 --- a/ee/app/graphql/ee/types/query_type.rb +++ b/ee/app/graphql/ee/types/query_type.rb @@ -147,6 +147,14 @@ module QueryType description: 'Users within the self-managed instance who are eligible for add-ons.', resolver: ::Resolvers::GitlabSubscriptions::SelfManaged::AddOnEligibleUsersResolver, alpha: { milestone: '16.7' } + field :self_managed_users_queued_for_role_promotion, + EE::Types::GitlabSubscriptions::MemberManagement::UsersQueuedForRolePromotionType.connection_type, + null: true, + alpha: { milestone: '17.1' }, + resolver: ::Resolvers::GitlabSubscriptions::MemberManagement::SelfManaged:: + UsersQueuedForRolePromotionResolver, + description: 'Fields related to users within a self-managed instance that are pending role ' \ + 'promotion approval.' field :audit_events_instance_amazon_s3_configurations, ::Types::AuditEvents::Instance::AmazonS3ConfigurationType.connection_type, null: true, diff --git a/ee/app/graphql/resolvers/gitlab_subscriptions/member_management/self_managed/users_queued_for_role_promotion_resolver.rb b/ee/app/graphql/resolvers/gitlab_subscriptions/member_management/self_managed/users_queued_for_role_promotion_resolver.rb new file mode 100644 index 0000000000000000000000000000000000000000..296b8202ce701f42114f45c8bb01a47cb4542c55 --- /dev/null +++ b/ee/app/graphql/resolvers/gitlab_subscriptions/member_management/self_managed/users_queued_for_role_promotion_resolver.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Resolvers + module GitlabSubscriptions + module MemberManagement + module SelfManaged + class UsersQueuedForRolePromotionResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + include ::GitlabSubscriptions::MemberManagement::PromotionManagementUtils + + type EE::Types::GitlabSubscriptions::MemberManagement:: + UsersQueuedForRolePromotionType.connection_type, null: true + + def resolve + authorize! + + ::GitlabSubscriptions::MemberManagement::SelfManaged::MaxAccessLevelMemberApprovalsFinder.new(current_user) + .execute + end + + def authorize! + raise_resource_not_available_error! unless promotion_management_applicable? && + current_user.can_admin_all_resources? + end + end + end + end + end +end diff --git a/ee/app/models/ee/members/member_approval.rb b/ee/app/models/ee/members/member_approval.rb index e981a223f9e478303bbb88ef7aa64e76fc03fdc8..10434dad8433bbfadb7e924105d9121443bcbf8e 100644 --- a/ee/app/models/ee/members/member_approval.rb +++ b/ee/app/models/ee/members/member_approval.rb @@ -12,6 +12,11 @@ module MemberApproval scope :pending_member_approvals, ->(member_namespace_id) do where(member_namespace_id: member_namespace_id).where(status: statuses[:pending]) end + + scope :pending_member_approvals_with_max_new_access_level, -> do + where(status: statuses[:pending]).select('DISTINCT ON (user_id) *') + .order(:user_id, new_access_level: :desc, created_at: :asc) + end end private diff --git a/ee/spec/finders/gitlab_subscriptions/member_management/self_managed/max_access_level_member_approvals_finder_spec.rb b/ee/spec/finders/gitlab_subscriptions/member_management/self_managed/max_access_level_member_approvals_finder_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..92d514d4de46afb28b88175d68ee97f096e5314f --- /dev/null +++ b/ee/spec/finders/gitlab_subscriptions/member_management/self_managed/max_access_level_member_approvals_finder_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSubscriptions::MemberManagement::SelfManaged::MaxAccessLevelMemberApprovalsFinder, feature_category: :seat_cost_management do + let_it_be(:admin_user) { create(:user) } + let_it_be(:project_member_pending_dev) { create(:member_approval, :for_project_member) } + let_it_be(:project_member_pending_maintainer) do + create(:member_approval, user: project_member_pending_dev.user, new_access_level: Gitlab::Access::MAINTAINER) + end + + let_it_be(:group_member_pending_dev) { create(:member_approval, :for_group_member) } + let_it_be(:group_member_pending_owner) do + create(:member_approval, :for_group_member, user: group_member_pending_dev.user, + new_access_level: Gitlab::Access::OWNER) + end + + let_it_be(:denied_approval_dev) { create(:member_approval, :for_group_member, status: :denied) } + let_it_be(:license) { create(:license, plan: License::ULTIMATE_PLAN) } + let_it_be(:feature_flag) { true } + let_it_be(:feature_setting) { true } + let_it_be(:user) { admin_user } + + subject(:finder) { described_class.new(user) } + + describe '#execute' do + before do + allow(admin_user).to receive(:can_admin_all_resources?).and_return(true) + stub_feature_flags(member_promotion_management: feature_flag) + stub_application_setting(enable_member_promotion_management: feature_setting) + allow(License).to receive(:current).and_return(license) + end + + shared_examples 'returns empty' do + it 'returns empty' do + expect(finder.execute).to be_empty + end + end + + context 'when user does not have admin access' do + let(:user) { create(:user) } + + it_behaves_like 'returns empty' + end + + context 'when user has admin access' do + it 'returns records corresponding to pending users with max new_access_level' do + expect(finder.execute).to contain_exactly(project_member_pending_maintainer, group_member_pending_owner) + end + + context 'when member promotion management feature is disabled' do + let(:feature_flag) { false } + + it_behaves_like 'returns empty' + end + + context 'when member promotion management is disabled in settings' do + let(:feature_setting) { false } + + it_behaves_like 'returns empty' + end + + context 'when subscription plan is not Ultimate' do + let(:license) { create(:license, plan: License::STARTER_PLAN) } + + it_behaves_like 'returns empty' + end + + context 'when instance is saas', :saas do + it_behaves_like 'returns empty' + end + end + end +end diff --git a/ee/spec/graphql/resolvers/gitlab_subscriptions/member_management/self_managed/users_queued_for_role_promotion_resolver_spec.rb b/ee/spec/graphql/resolvers/gitlab_subscriptions/member_management/self_managed/users_queued_for_role_promotion_resolver_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..8a487ca3128c6ed447d30ab54d1081fdf0c099ff --- /dev/null +++ b/ee/spec/graphql/resolvers/gitlab_subscriptions/member_management/self_managed/users_queued_for_role_promotion_resolver_spec.rb @@ -0,0 +1,85 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Resolvers::GitlabSubscriptions::MemberManagement::SelfManaged::UsersQueuedForRolePromotionResolver, feature_category: :seat_cost_management do + include GraphqlHelpers + + let_it_be(:admin_user) { create(:user) } + let_it_be(:normal_user) { create(:user) } + let_it_be(:project_member_pending_dev) { create(:member_approval, :for_project_member) } + let_it_be(:project_member_pending_maintainer) do + create(:member_approval, user: project_member_pending_dev.user, new_access_level: Gitlab::Access::MAINTAINER) + end + + let_it_be(:group_member_pending_dev) { create(:member_approval, :for_group_member) } + let_it_be(:group_member_pending_owner) do + create(:member_approval, :for_group_member, user: group_member_pending_dev.user, + new_access_level: Gitlab::Access::OWNER) + end + + let_it_be(:denied_approval_dev) { create(:member_approval, :for_group_member, status: :denied) } + let_it_be(:license) { create(:license, plan: License::ULTIMATE_PLAN) } + let_it_be(:feature_flag) { true } + let_it_be(:feature_setting) { true } + let_it_be(:current_user) { admin_user } + let_it_be(:promotion_management_applicable) { true } + + describe '#resolve' do + subject(:result) { resolve(described_class, ctx: { current_user: current_user }) } + + before do + stub_feature_flags(member_promotion_management: feature_flag) + stub_application_setting(enable_member_promotion_management: feature_setting) + allow(License).to receive(:current).and_return(license) + + allow(admin_user).to receive(:can_admin_all_resources?).and_return(true) + end + + shared_examples 'not available' do + it 'raises a resource not available error' do + expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ResourceNotAvailable) do + result + end + end + end + + context 'when the user is not an admin' do + let(:current_user) { normal_user } + + it_behaves_like 'not available' + end + + context 'when the user is an admin' do + it 'returns pending member_approvals corresponding to max new_access_level' do + expect(result).to contain_exactly(project_member_pending_maintainer, group_member_pending_owner) + end + + it 'does not return member_approvals with different status' do + expect(result).not_to include(denied_approval_dev) + end + + context 'when member promotion management feature is disabled' do + let(:feature_flag) { false } + + it_behaves_like 'not available' + end + + context 'when member promotion management is disabled in settings' do + let(:feature_setting) { false } + + it_behaves_like 'not available' + end + + context 'when subscription plan is not Ultimate' do + let(:license) { create(:license, plan: License::STARTER_PLAN) } + + it_behaves_like 'not available' + end + + context 'when instance is saas', :saas do + it_behaves_like 'not available' + end + end + end +end diff --git a/ee/spec/graphql/types/gitlab_subscriptions/member_management/users_queued_for_role_promotion_type_spec.rb b/ee/spec/graphql/types/gitlab_subscriptions/member_management/users_queued_for_role_promotion_type_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..0a524939a5d449ad94033c8442f21b60e8119a2c --- /dev/null +++ b/ee/spec/graphql/types/gitlab_subscriptions/member_management/users_queued_for_role_promotion_type_spec.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['UsersQueuedForRolePromotion'], feature_category: :seat_cost_management do + it { expect(described_class.graphql_name).to eq('UsersQueuedForRolePromotion') } + + it 'includes the specific fields' do + expected_fields = %w[ + user + newAccessLevel + ] + + expect(described_class).to include_graphql_fields(*expected_fields) + end + + describe 'user field' do + subject { described_class.fields['user'] } + + it { is_expected.to have_graphql_type(::Types::UserType) } + end + + describe 'newAccessLevel field' do + subject { described_class.fields['newAccessLevel'] } + + it { is_expected.to have_graphql_type(::Types::AccessLevelType) } + end +end diff --git a/ee/spec/graphql/types/query_type_spec.rb b/ee/spec/graphql/types/query_type_spec.rb index 22e7e924781df7b7350b4779fbba3f8c2d2c5863..d2929358e1870ed0f715d7ffea1717df4ad56da2 100644 --- a/ee/spec/graphql/types/query_type_spec.rb +++ b/ee/spec/graphql/types/query_type_spec.rb @@ -39,7 +39,8 @@ :self_managed_add_on_eligible_users, :member_roles, :google_cloud_artifact_registry_repository_artifact, - :audit_events_instance_streaming_destinations + :audit_events_instance_streaming_destinations, + :self_managed_users_queued_for_role_promotion ] all_expected_fields = expected_foss_fields + expected_ee_fields diff --git a/ee/spec/models/ee/members/member_approval_spec.rb b/ee/spec/models/ee/members/member_approval_spec.rb index cef79dc82de0936d12726b6bdfe460a2d37f25ef..496b3ba8e0ef18bfd9904b30bf8dc3ea99463dc7 100644 --- a/ee/spec/models/ee/members/member_approval_spec.rb +++ b/ee/spec/models/ee/members/member_approval_spec.rb @@ -77,4 +77,25 @@ end end end + + describe '#pending_member_approvals_with_max_new_access_level' do + let_it_be(:project_member_pending_dev) { create(:member_approval, :for_project_member) } + let_it_be(:project_member_pending_maintainer) do + create(:member_approval, user: project_member_pending_dev.user, new_access_level: Gitlab::Access::MAINTAINER) + end + + let_it_be(:group_member_pending_dev) { create(:member_approval, :for_group_member) } + let_it_be(:group_member_pending_owner) do + create(:member_approval, :for_group_member, user: group_member_pending_dev.user, + new_access_level: Gitlab::Access::OWNER) + end + + let_it_be(:denied_approval_dev) { create(:member_approval, :for_group_member, status: :denied) } + + it 'returns records corresponding to pending users with max new_access_level' do + expect(described_class.pending_member_approvals_with_max_new_access_level).to contain_exactly( + project_member_pending_maintainer, group_member_pending_owner + ) + end + end end