diff --git a/config/feature_flags/development/hamilton_seat_management.yml b/config/feature_flags/development/hamilton_seat_management.yml new file mode 100644 index 0000000000000000000000000000000000000000..15ac8e07f682aebeb8338d9b5e3575448a5fbcb3 --- /dev/null +++ b/config/feature_flags/development/hamilton_seat_management.yml @@ -0,0 +1,8 @@ +--- +name: hamilton_seat_management +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/126964 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/419175 +milestone: '16.3' +type: development +group: group::purchase +default_enabled: false diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index c7966e3b138c90dbafd17157e481a9a1f6725beb..f4e9460ace85e9a0d97baeef7b9ee1aaf6ab2e74 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -6865,6 +6865,29 @@ Input type: `UserAchievementsDeleteInput` | <a id="mutationuserachievementsdeleteerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | | <a id="mutationuserachievementsdeleteuserachievement"></a>`userAchievement` | [`UserAchievement`](#userachievement) | Deleted user achievement. | +### `Mutation.userAddOnAssignmentCreate` + +WARNING: +**Introduced** in 16.3. +This feature is an Experiment. It can be changed or removed at any time. + +Input type: `UserAddOnAssignmentCreateInput` + +#### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="mutationuseraddonassignmentcreateaddonpurchaseid"></a>`addOnPurchaseId` | [`GitlabSubscriptionsAddOnPurchaseID!`](#gitlabsubscriptionsaddonpurchaseid) | Global ID of AddOnPurchase to be assinged to. | +| <a id="mutationuseraddonassignmentcreateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| <a id="mutationuseraddonassignmentcreateuserid"></a>`userId` | [`UserID!`](#userid) | Global ID of user to be assigned. | + +#### Fields + +| Name | Type | Description | +| ---- | ---- | ----------- | +| <a id="mutationuseraddonassignmentcreateclientmutationid"></a>`clientMutationId` | [`String`](#string) | A unique identifier for the client performing the mutation. | +| <a id="mutationuseraddonassignmentcreateerrors"></a>`errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | + ### `Mutation.userCalloutCreate` Input type: `UserCalloutCreateInput` diff --git a/ee/app/graphql/ee/types/mutation_type.rb b/ee/app/graphql/ee/types/mutation_type.rb index 63e1ab66249dd712fcfcf66cf1d2aa2121f2a584..6716222103ddee196f2dfe79a472a67a67c3e02d 100644 --- a/ee/app/graphql/ee/types/mutation_type.rb +++ b/ee/app/graphql/ee/types/mutation_type.rb @@ -22,6 +22,7 @@ module MutationType mount_mutation ::Mutations::Epics::AddIssue mount_mutation ::Mutations::Geo::Registries::Update, alpha: { milestone: '16.1' } mount_mutation ::Mutations::GitlabSubscriptions::Activate + mount_mutation ::Mutations::GitlabSubscriptions::UserAddOnAssignments::Create, alpha: { milestone: '16.3' } mount_mutation ::Mutations::Projects::SetLocked mount_mutation ::Mutations::Iterations::Create mount_mutation ::Mutations::Iterations::Update diff --git a/ee/app/graphql/mutations/gitlab_subscriptions/user_add_on_assignments/create.rb b/ee/app/graphql/mutations/gitlab_subscriptions/user_add_on_assignments/create.rb new file mode 100644 index 0000000000000000000000000000000000000000..6c1a573dec962d30bfd79a35edad2c0870e9d3e7 --- /dev/null +++ b/ee/app/graphql/mutations/gitlab_subscriptions/user_add_on_assignments/create.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module Mutations + module GitlabSubscriptions + module UserAddOnAssignments + class Create < BaseMutation + graphql_name 'UserAddOnAssignmentCreate' + + argument :add_on_purchase_id, ::Types::GlobalIDType[::GitlabSubscriptions::AddOnPurchase], + required: true, description: 'Global ID of AddOnPurchase to be assinged to.' + + argument :user_id, ::Types::GlobalIDType[::User], + required: true, description: 'Global ID of user to be assigned.' + + def resolve(**) + authorize! + + create_service = ::GitlabSubscriptions::UserAddOnAssignments::CreateService.new( + add_on_purchase: add_on_purchase, + user: user_to_be_assigned + ).execute + + { + errors: create_service.errors + } + end + + def ready?(add_on_purchase_id:, user_id:) + @add_on_purchase = ::Gitlab::Graphql::Lazy.force(GitlabSchema.find_by_gid(add_on_purchase_id)) + @user_to_be_assigned = ::Gitlab::Graphql::Lazy.force(GitlabSchema.find_by_gid(user_id)) + + raise_resource_not_available_error! unless feature_enabled? && add_on_purchase&.active? && user_to_be_assigned + + super + end + + private + + attr_reader :add_on_purchase, :user_to_be_assigned + + def feature_enabled? + Feature.enabled?(:hamilton_seat_management) + end + + def authorize! + return if self_assignment? || + Ability.allowed?(current_user, :admin_add_on_purchase, add_on_purchase.namespace) + + raise_resource_not_available_error! + end + + def self_assignment? + current_user == user_to_be_assigned + end + end + end + end +end diff --git a/ee/app/models/ee/group.rb b/ee/app/models/ee/group.rb index 60ac5d0af61afd866c702360798d0432caba0f23..fc246721a9f7e21615a71edc0f96a2e6c371af7a 100644 --- a/ee/app/models/ee/group.rb +++ b/ee/app/models/ee/group.rb @@ -789,6 +789,26 @@ def billed_invited_group_to_project_users(exclude_guests: false) users_without_bots(members) end + # Checks if user belongs to billed_group_users + def billed_group_user?(user, exclude_guests: false) + billed_group_users(exclude_guests: exclude_guests).exists?(id: user.id) + end + + # Checks if user belongs to billed_project_users + def billed_project_user?(user, exclude_guests: false) + billed_project_users(exclude_guests: exclude_guests).exists?(id: user.id) + end + + # Checks if user belongs to billed_shared_group_users + def billed_shared_group_user?(user, exclude_guests: false) + billed_shared_group_users(exclude_guests: exclude_guests).exists?(id: user.id) + end + + # Checks if user belongs to billed_invited_group_to_project_users + def billed_shared_project_user?(user, exclude_guests: false) + billed_invited_group_to_project_users(exclude_guests: exclude_guests).exists?(id: user.id) + end + def parent_epic_ids_in_ancestor_groups ids = Set.new epics.has_parent.each_batch(of: EPIC_BATCH_SIZE, column: :iid) do |batch| diff --git a/ee/app/models/gitlab_subscriptions/add_on_purchase.rb b/ee/app/models/gitlab_subscriptions/add_on_purchase.rb index bdbfd3f8b99d6ccefa397179b800f0825b5d86e2..af84c4b98b4bd9aad24ec78db7313b6350e1968f 100644 --- a/ee/app/models/gitlab_subscriptions/add_on_purchase.rb +++ b/ee/app/models/gitlab_subscriptions/add_on_purchase.rb @@ -20,5 +20,13 @@ class AddOnPurchase < ApplicationRecord scope :by_add_on_name, ->(name) { joins(:add_on).where(add_on: { name: name }) } scope :for_code_suggestions, -> { where(subscription_add_on_id: AddOn.code_suggestions.pick(:id)) } scope :for_project, ->(project_id) { where(namespace: Project.find_by_id(project_id)&.root_namespace) } + + def already_assigned?(user) + assigned_users.where(user: user).exists? + end + + def active? + expires_on >= Date.current + end end end diff --git a/ee/app/services/gitlab_subscriptions/user_add_on_assignments/create_service.rb b/ee/app/services/gitlab_subscriptions/user_add_on_assignments/create_service.rb new file mode 100644 index 0000000000000000000000000000000000000000..e927100ab30d8ac1960ee46698825bb261b48e8f --- /dev/null +++ b/ee/app/services/gitlab_subscriptions/user_add_on_assignments/create_service.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module GitlabSubscriptions + module UserAddOnAssignments + class CreateService < BaseService + ERROR_NO_SEATS_AVAILABLE = 'NO_SEATS_AVAILABLE' + ERROR_INVALID_USER_MEMBERSHIP = 'INVALID_USER_MEMBERSHIP' + + def initialize(add_on_purchase:, user:) + @add_on_purchase = add_on_purchase + @user = user + end + + def execute + return ServiceResponse.success if user_already_assigned? + + errors = validate + + if errors.blank? + # TODO: implement resource locking to avoid race condition + # https://gitlab.com/gitlab-org/gitlab/-/issues/415584#race-condition + add_on_purchase.assigned_users.create!(user: user) + + ServiceResponse.success + else + ServiceResponse.error(message: errors) + end + end + + private + + attr_reader :add_on_purchase, :user + + def validate + return ERROR_NO_SEATS_AVAILABLE unless seats_available? + return ERROR_INVALID_USER_MEMBERSHIP unless billed_member_of_namespace? + end + + def seats_available? + add_on_purchase.quantity > assigned_seats + end + + def assigned_seats + @assigned_seats ||= add_on_purchase.assigned_users.count + end + + def user_already_assigned? + add_on_purchase.already_assigned?(user) + end + + def billed_member_of_namespace? + namespace.billed_group_user?(user, exclude_guests: true) || + namespace.billed_project_user?(user, exclude_guests: true) || + namespace.billed_shared_group_user?(user, exclude_guests: true) || + namespace.billed_shared_project_user?(user, exclude_guests: true) + end + + def namespace + @namespace ||= add_on_purchase.namespace + end + end + end +end diff --git a/ee/spec/models/ee/group_spec.rb b/ee/spec/models/ee/group_spec.rb index dd1ca64b930e6435edae633d680d7ac12d136a00..0e64f7e80106a1f58654c5301998d4a4cf4c1879 100644 --- a/ee/spec/models/ee/group_spec.rb +++ b/ee/spec/models/ee/group_spec.rb @@ -1533,6 +1533,110 @@ end end + describe '#billed_group_user?' do + let_it_be(:group) { create(:group) } + let_it_be(:sub_group) { create(:group, parent: group) } + let_it_be(:developer) { group.add_developer(create(:user)).user } + let_it_be(:sub_developer) { sub_group.add_developer(create(:user)).user } + let_it_be(:guest) { group.add_guest(create(:user)).user } + + where(:user, :exclude_guests, :result) do + ref(:developer) | false | true + ref(:sub_developer) | false | true + ref(:guest) | false | true + ref(:developer) | true | true + ref(:sub_developer) | true | true + ref(:guest) | true | false + end + + subject { group.billed_group_user?(user, exclude_guests: exclude_guests) } + + with_them do + it { is_expected.to eq(result) } + end + end + + describe '#billed_project_user?' do + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, namespace: group) } + let_it_be(:sub_group_project) { create(:project, namespace: create(:group, parent: group)) } + let_it_be(:developer) { project.add_developer(create(:user)).user } + let_it_be(:sub_developer) { sub_group_project.add_developer(create(:user)).user } + let_it_be(:guest) { project.add_guest(create(:user)).user } + + where(:user, :exclude_guests, :result) do + ref(:developer) | false | true + ref(:sub_developer) | false | true + ref(:guest) | false | true + ref(:developer) | true | true + ref(:sub_developer) | true | true + ref(:guest) | true | false + end + + subject { group.billed_project_user?(user, exclude_guests: exclude_guests) } + + with_them do + it { is_expected.to eq(result) } + end + end + + describe '#billed_shared_group_user?' do + let_it_be(:group) { create(:group) } + let_it_be(:ancestor_invited_group) { create(:group) } + let_it_be(:invited_group) { create(:group, parent: ancestor_invited_group) } + let_it_be(:ancestor_invited_developer) { ancestor_invited_group.add_developer(create(:user)).user } + let_it_be(:invited_developer) { invited_group.add_developer(create(:user)).user } + let_it_be(:invited_guest) { invited_group.add_guest(create(:user)).user } + + before_all do + create(:group_group_link, { shared_with_group: invited_group, shared_group: group }) + end + + where(:user, :exclude_guests, :result) do + ref(:ancestor_invited_developer) | false | true + ref(:invited_developer) | false | true + ref(:invited_guest) | false | true + ref(:ancestor_invited_developer) | true | true + ref(:invited_developer) | true | true + ref(:invited_guest) | true | false + end + + subject { group.billed_shared_group_user?(user, exclude_guests: exclude_guests) } + + with_them do + it { is_expected.to eq(result) } + end + end + + describe '#billed_shared_project_user?' do + let_it_be(:group) { create(:group) } + let_it_be(:project) { create(:project, namespace: group) } + let_it_be(:ancestor_invited_group) { create(:group) } + let_it_be(:invited_group) { create(:group, parent: ancestor_invited_group) } + let_it_be(:ancestor_invited_developer) { ancestor_invited_group.add_developer(create(:user)).user } + let_it_be(:invited_developer) { invited_group.add_developer(create(:user)).user } + let_it_be(:invited_guest) { invited_group.add_guest(create(:user)).user } + + before_all do + create(:project_group_link, project: project, group: invited_group) + end + + where(:user, :exclude_guests, :result) do + ref(:ancestor_invited_developer) | false | true + ref(:invited_developer) | false | true + ref(:invited_guest) | false | true + ref(:ancestor_invited_developer) | true | true + ref(:invited_developer) | true | true + ref(:invited_guest) | true | false + end + + subject { group.billed_shared_project_user?(user, exclude_guests: exclude_guests) } + + with_them do + it { is_expected.to eq(result) } + end + end + describe '#capacity_left_for_user?' do let_it_be(:group) { create(:group) } let_it_be(:user) { create(:user) } diff --git a/ee/spec/models/gitlab_subscriptions/add_on_purchase_spec.rb b/ee/spec/models/gitlab_subscriptions/add_on_purchase_spec.rb index 8e8de76baf9d1d447ce1601e48f853a91563244d..ac4b36723a3d618f113f8c5c2bac1e55b90a4eba 100644 --- a/ee/spec/models/gitlab_subscriptions/add_on_purchase_spec.rb +++ b/ee/spec/models/gitlab_subscriptions/add_on_purchase_spec.rb @@ -99,4 +99,36 @@ end end end + + describe '#already_assigned?' do + let_it_be(:add_on_purchase) { create(:gitlab_subscription_add_on_purchase) } + + let(:user) { create(:user) } + + subject { add_on_purchase.already_assigned?(user) } + + context 'when the user has been already assigned' do + before do + create(:gitlab_subscription_user_add_on_assignment, add_on_purchase: add_on_purchase, user: user) + end + + it { is_expected.to eq(true) } + end + + context 'when user is not already assigned' do + it { is_expected.to eq(false) } + end + end + + describe '#active?' do + let_it_be(:add_on_purchase) { create(:gitlab_subscription_add_on_purchase) } + + subject { add_on_purchase.active? } + + it { is_expected.to eq(true) } + + context 'when subscription has expired' do + it { travel_to(add_on_purchase.expires_on + 1.day) { is_expected.to eq(false) } } + end + end end diff --git a/ee/spec/requests/api/graphql/gitlab_subscriptions/user_add_on_assignments/create_spec.rb b/ee/spec/requests/api/graphql/gitlab_subscriptions/user_add_on_assignments/create_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..a033a12110d59236f8bdf6fb02c99c82893f3643 --- /dev/null +++ b/ee/spec/requests/api/graphql/gitlab_subscriptions/user_add_on_assignments/create_spec.rb @@ -0,0 +1,169 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'UserAddOnAssignmentCreate', feature_category: :seat_cost_management do + include GraphqlHelpers + + let_it_be(:current_user) { create(:user) } + let_it_be(:namespace) { create(:group) } + let_it_be(:add_on) { create(:gitlab_subscription_add_on) } + let_it_be(:add_on_purchase) { create(:gitlab_subscription_add_on_purchase, namespace: namespace, add_on: add_on) } + let_it_be(:assignee_user) { create(:user) } + + let(:user_id) { global_id_of(assignee_user) } + let(:add_on_purchase_id) { global_id_of(add_on_purchase) } + + let(:input) do + { + user_id: user_id, + add_on_purchase_id: add_on_purchase_id + } + end + + let(:mutation) { graphql_mutation(:user_add_on_assignment_create, input) } + let(:mutation_response) { graphql_mutation_response(:user_add_on_assignment_create) } + + before_all do + namespace.add_owner(current_user) + namespace.add_developer(assignee_user) + end + + shared_examples 'empty response' do + it 'returns nil' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(mutation_response).to be_nil + end + end + + shared_examples 'error response' do |error_message| + it 'returns expected errors' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(mutation_response["errors"]).to include(error_message) + end + end + + shared_examples 'success response' do |_expected_response| + it 'returns expected response' do + post_graphql_mutation(mutation, current_user: current_user) + + expect(mutation_response["errors"]).to eq([]) + end + end + + it_behaves_like 'success response' + + context 'when feature flag hamilton_seat_management is disabled' do + before do + stub_feature_flags(hamilton_seat_management: false) + end + + it_behaves_like 'empty response' + end + + context 'when current_user is admin' do + let(:current_user) { create(:admin) } + + it_behaves_like 'success response' + end + + context 'when current_user is assigning itself' do + let(:current_user) { namespace.add_developer(create(:user)).user } + let(:user_id) { global_id_of(current_user) } + + it_behaves_like 'success response' + end + + context 'when current_user is not owner or admin' do + let(:current_user) { create(:user) } + + it_behaves_like 'empty response' + end + + context 'when the user is already assigned' do + before do + create(:gitlab_subscription_user_add_on_assignment, add_on_purchase: add_on_purchase, user: assignee_user) + end + + it_behaves_like 'success response' + end + + context 'when add_on_purchase_id does not exists' do + let(:add_on_purchase_id) { global_id_of(id: 666, model_name: '::GitlabSubscriptions::AddOnPurchase') } + + it_behaves_like 'empty response' + end + + context 'when ad_on_purchase has expired' do + before do + add_on_purchase.update!(expires_on: 1.day.ago) + end + + it_behaves_like 'empty response' + end + + context 'when user_id does not exists' do + let(:user_id) { global_id_of(id: 666, model_name: '::User') } + + it_behaves_like 'empty response' + end + + context 'when there are no free seats available' do + before do + add_on_purchase.assigned_users.create!(user: create(:user)) + end + + it_behaves_like 'error response', 'NO_SEATS_AVAILABLE' + end + + context 'when user is guest' do + let(:user_id) { global_id_of(namespace.add_guest(create(:user)).user) } + + it_behaves_like 'error response', 'INVALID_USER_MEMBERSHIP' + end + + context 'when user does not belong to namespace' do + let(:user_id) { global_id_of(create(:user)) } + + it_behaves_like 'error response', 'INVALID_USER_MEMBERSHIP' + end + + context 'when user belongs to subgroup' do + let(:subgroup) { create(:group, parent: namespace) } + let(:user_id) { global_id_of(subgroup.add_developer(create(:user)).user) } + + it_behaves_like 'success response' + end + + context 'when user belongs to project' do + let_it_be(:project) { create(:project, namespace: namespace) } + let(:user_id) { global_id_of(project.add_developer(create(:user)).user) } + + it_behaves_like 'success response' + end + + context 'when user is member of shared group' do + let(:invited_group) { create(:group) } + let(:user) { global_id_of(invited_group.add_developer(create(:user)).user) } + + before do + create(:group_group_link, { shared_with_group: invited_group, shared_group: namespace }) + end + + it_behaves_like 'success response' + end + + context 'when user is member of shared project' do + let(:invited_group) { create(:group) } + let_it_be(:project) { create(:project, namespace: namespace) } + let(:user_id) { global_id_of(invited_group.add_developer(create(:user)).user) } + + before do + create(:project_group_link, project: project, group: invited_group) + end + + it_behaves_like 'success response' + end +end diff --git a/ee/spec/services/gitlab_subscriptions/user_add_on_assignments/create_service_spec.rb b/ee/spec/services/gitlab_subscriptions/user_add_on_assignments/create_service_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..7a70af094d29a5baefb747685a98cc6ec3359599 --- /dev/null +++ b/ee/spec/services/gitlab_subscriptions/user_add_on_assignments/create_service_spec.rb @@ -0,0 +1,104 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSubscriptions::UserAddOnAssignments::CreateService, feature_category: :seat_cost_management do + let_it_be(:namespace) { create(:group) } + let_it_be(:add_on) { create(:gitlab_subscription_add_on) } + let_it_be(:add_on_purchase) { create(:gitlab_subscription_add_on_purchase, namespace: namespace, add_on: add_on) } + let_it_be(:user) { create(:user) } + + subject(:response) do + described_class.new(add_on_purchase: add_on_purchase, user: user).execute + end + + before_all do + namespace.add_developer(user) + end + + describe '#execute' do + shared_examples 'success response' do + it 'creates new records' do + expect { subject }.to change { add_on_purchase.assigned_users.where(user: user).count }.by(1) + expect(response).to be_success + end + end + + shared_examples 'error response' do |error_message| + it 'does not create new records' do + expect { subject }.not_to change { add_on_purchase.assigned_users.count } + expect(response.errors).to include(error_message) + end + end + + it_behaves_like 'success response' + + context 'when user is already assigned' do + before do + create(:gitlab_subscription_user_add_on_assignment, add_on_purchase: add_on_purchase, user: user) + end + + it 'does not create new record' do + expect { subject }.not_to change { add_on_purchase.assigned_users.count } + expect(response).to be_success + end + end + + context 'when seats are not available' do + before do + create(:gitlab_subscription_user_add_on_assignment, add_on_purchase: add_on_purchase, user: create(:user)) + end + + it_behaves_like 'error response', 'NO_SEATS_AVAILABLE' + end + + context 'when user is not member of namespace' do + let(:user) { create(:user) } + + it_behaves_like 'error response', 'INVALID_USER_MEMBERSHIP' + end + + context 'when user has guest role' do + let(:user) { namespace.add_guest(create(:user)).user } + + it_behaves_like 'error response', 'INVALID_USER_MEMBERSHIP' + end + + context 'when user is member of subgroup' do + let(:subgroup) { create(:group, parent: namespace) } + let(:user) { subgroup.add_developer(create(:user)).user } + + it_behaves_like 'success response' + end + + context 'when user is member of project' do + let_it_be(:project) { create(:project, namespace: namespace) } + let(:user) { project.add_developer(create(:user)).user } + + it_behaves_like 'success response' + end + + context 'when user is member of shared group' do + let(:invited_group) { create(:group) } + let(:user) { invited_group.add_developer(create(:user)).user } + + before do + create(:group_group_link, { shared_with_group: invited_group, shared_group: namespace }) + end + + it_behaves_like 'success response' + end + + context 'when user is member of shared project' do + let(:invited_group) { create(:group) } + let_it_be(:project) { create(:project, namespace: namespace) } + let(:user) { invited_group.add_developer(create(:user)).user } + + before do + create(:project_group_link, project: project, group: invited_group) + end + + it_behaves_like 'success response' + end + end +end