diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 9311082c86a623d71deff6b0d93a9cfd088fd496..4a3348e9609700c506b2666cca07d6be96e0d245 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -63,6 +63,12 @@ Returns [`ContainerRepositoryDetails`](#containerrepositorydetails). | ---- | ---- | ----------- | | `id` | [`ContainerRepositoryID!`](#containerrepositoryid) | The global ID of the container repository. | +### `currentLicense` + +Fields related to the current license. + +Returns [`CurrentLicense`](#currentlicense). + ### `currentUser` Get information about current user. @@ -181,6 +187,21 @@ Returns [`Iteration`](#iteration). | ---- | ---- | ----------- | | `id` | [`IterationID!`](#iterationid) | Find an iteration by its ID. | +### `licenseHistoryEntries` + +Fields related to entries in the license history. + +Returns [`LicenseHistoryEntryConnection`](#licensehistoryentryconnection). + +#### Arguments + +| Name | Type | Description | +| ---- | ---- | ----------- | +| `after` | [`String`](#string) | Returns the elements in the list that come after the specified cursor. | +| `before` | [`String`](#string) | Returns the elements in the list that come before the specified cursor. | +| `first` | [`Int`](#int) | Returns the first _n_ elements from the list. | +| `last` | [`Int`](#int) | Returns the last _n_ elements from the list. | + ### `metadata` Metadata about GitLab. @@ -1872,6 +1893,27 @@ Autogenerated return type of CreateTestCase. | `errors` | [`[String!]!`](#string) | Errors encountered during execution of the mutation. | | `testCase` | [`Issue`](#issue) | The test case created. | +### `CurrentLicense` + +Represents the current license. + +| Field | Type | Description | +| ----- | ---- | ----------- | +| `activatedAt` | [`Date`](#date) | Date when the license was activated. | +| `billableUsersCount` | [`Int`](#int) | Number of billable users on the system. | +| `company` | [`String`](#string) | Company of the licensee. | +| `email` | [`String`](#string) | Email of the licensee. | +| `expiresAt` | [`Date`](#date) | Date when the license expires. | +| `id` | [`ID!`](#id) | ID of the license. | +| `lastSync` | [`Time`](#time) | Date when the license was last synced. | +| `maximumUserCount` | [`Int`](#int) | Highest number of billable users on the system during the term of the current license. | +| `name` | [`String`](#string) | Name of the licensee. | +| `plan` | [`String!`](#string) | Name of the subscription plan. | +| `startsAt` | [`Date`](#date) | Date when the license started. | +| `type` | [`String!`](#string) | Type of the license. | +| `usersInLicenseCount` | [`Int`](#int) | Number of paid users in the license. | +| `usersOverLicenseCount` | [`Int`](#int) | Number of users over the paid users in the license. | + ### `CustomEmoji` A custom emoji uploaded by user. @@ -3874,6 +3916,42 @@ An edge in a connection. | `cursor` | [`String!`](#string) | A cursor for use in pagination. | | `node` | [`Label`](#label) | The item at the end of the edge. | +### `LicenseHistoryEntry` + +Represents an entry from the Cloud License history. + +| Field | Type | Description | +| ----- | ---- | ----------- | +| `activatedAt` | [`Date`](#date) | Date when the license was activated. | +| `company` | [`String`](#string) | Company of the licensee. | +| `email` | [`String`](#string) | Email of the licensee. | +| `expiresAt` | [`Date`](#date) | Date when the license expires. | +| `id` | [`ID!`](#id) | ID of the license. | +| `name` | [`String`](#string) | Name of the licensee. | +| `plan` | [`String!`](#string) | Name of the subscription plan. | +| `startsAt` | [`Date`](#date) | Date when the license started. | +| `type` | [`String!`](#string) | Type of the license. | +| `usersInLicenseCount` | [`Int`](#int) | Number of paid users in the license. | + +### `LicenseHistoryEntryConnection` + +The connection type for LicenseHistoryEntry. + +| Field | Type | Description | +| ----- | ---- | ----------- | +| `edges` | [`[LicenseHistoryEntryEdge]`](#licensehistoryentryedge) | A list of edges. | +| `nodes` | [`[LicenseHistoryEntry]`](#licensehistoryentry) | A list of nodes. | +| `pageInfo` | [`PageInfo!`](#pageinfo) | Information to aid in pagination. | + +### `LicenseHistoryEntryEdge` + +An edge in a connection. + +| Field | Type | Description | +| ----- | ---- | ----------- | +| `cursor` | [`String!`](#string) | A cursor for use in pagination. | +| `node` | [`LicenseHistoryEntry`](#licensehistoryentry) | The item at the end of the edge. | + ### `MarkAsSpamSnippetPayload` Autogenerated return type of MarkAsSpamSnippet. diff --git a/ee/app/graphql/ee/types/query_type.rb b/ee/app/graphql/ee/types/query_type.rb index 8cdc63c61fb433faef18fb61b4e997d9e9dee066..20db0eaf00356e649efd98f57b5d5464f6415ff6 100644 --- a/ee/app/graphql/ee/types/query_type.rb +++ b/ee/app/graphql/ee/types/query_type.rb @@ -61,6 +61,16 @@ module QueryType null: true, description: 'Get configured DevOps adoption segments on the instance.', resolver: ::Resolvers::Admin::Analytics::DevopsAdoption::SegmentsResolver + + field :current_license, ::Types::Admin::CloudLicenses::CurrentLicenseType, + null: true, + resolver: ::Resolvers::Admin::CloudLicenses::CurrentLicenseResolver, + description: 'Fields related to the current license.' + + field :license_history_entries, ::Types::Admin::CloudLicenses::LicenseHistoryEntryType.connection_type, + null: true, + resolver: ::Resolvers::Admin::CloudLicenses::LicenseHistoryEntriesResolver, + description: 'Fields related to entries in the license history.' end def vulnerability(id:) diff --git a/ee/app/graphql/resolvers/admin/cloud_licenses/current_license_resolver.rb b/ee/app/graphql/resolvers/admin/cloud_licenses/current_license_resolver.rb new file mode 100644 index 0000000000000000000000000000000000000000..03ca1c8eb63ae7e36b2f6135e7ccde302b716ca2 --- /dev/null +++ b/ee/app/graphql/resolvers/admin/cloud_licenses/current_license_resolver.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Resolvers + module Admin + module CloudLicenses + class CurrentLicenseResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + include ::Admin::LicenseRequest + + type ::Types::Admin::CloudLicenses::CurrentLicenseType, null: true + + def resolve + return unless application_settings.cloud_license_enabled? + + authorize! + + license + end + + private + + def application_settings + Gitlab::CurrentSettings.current_application_settings + end + + def authorize! + Ability.allowed?(context[:current_user], :read_licenses) || raise_resource_not_available_error! + end + end + end + end +end diff --git a/ee/app/graphql/resolvers/admin/cloud_licenses/license_history_entries_resolver.rb b/ee/app/graphql/resolvers/admin/cloud_licenses/license_history_entries_resolver.rb new file mode 100644 index 0000000000000000000000000000000000000000..2d93823f633df6e77e517c256762b5bf23e3de7a --- /dev/null +++ b/ee/app/graphql/resolvers/admin/cloud_licenses/license_history_entries_resolver.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Resolvers + module Admin + module CloudLicenses + class LicenseHistoryEntriesResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + + type [::Types::Admin::CloudLicenses::LicenseHistoryEntryType], null: true + + def resolve + return unless application_settings.cloud_license_enabled? + + authorize! + + License.history + end + + private + + def application_settings + Gitlab::CurrentSettings.current_application_settings + end + + def authorize! + Ability.allowed?(context[:current_user], :read_licenses) || raise_resource_not_available_error! + end + end + end + end +end diff --git a/ee/app/graphql/types/admin/cloud_licenses/current_license_type.rb b/ee/app/graphql/types/admin/cloud_licenses/current_license_type.rb new file mode 100644 index 0000000000000000000000000000000000000000..825c681648b7e6cc61c34fd441edb34b4ae577d7 --- /dev/null +++ b/ee/app/graphql/types/admin/cloud_licenses/current_license_type.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Types + module Admin + module CloudLicenses + # rubocop: disable Graphql/AuthorizeTypes + class CurrentLicenseType < BaseObject + include ::Types::Admin::CloudLicenses::LicenseType + + graphql_name 'CurrentLicense' + description 'Represents the current license' + + field :last_sync, ::Types::TimeType, null: true, + description: 'Date when the license was last synced.', + method: :last_synced_at + + field :billable_users_count, GraphQL::INT_TYPE, null: true, + description: 'Number of billable users on the system.', + method: :daily_billable_users_count + + field :maximum_user_count, GraphQL::INT_TYPE, null: true, + description: 'Highest number of billable users on the system during the term of the current license.', + method: :maximum_user_count + + field :users_over_license_count, GraphQL::INT_TYPE, null: true, + description: 'Number of users over the paid users in the license.' + + def users_over_license_count + return 0 if object.trial? + + [object.overage_with_historical_max, 0].max + end + end + end + end +end diff --git a/ee/app/graphql/types/admin/cloud_licenses/license_history_entry_type.rb b/ee/app/graphql/types/admin/cloud_licenses/license_history_entry_type.rb new file mode 100644 index 0000000000000000000000000000000000000000..85e50de344ab311d348856288df9050b6a6c65af --- /dev/null +++ b/ee/app/graphql/types/admin/cloud_licenses/license_history_entry_type.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module Types + module Admin + module CloudLicenses + # rubocop: disable Graphql/AuthorizeTypes + class LicenseHistoryEntryType < BaseObject + include ::Types::Admin::CloudLicenses::LicenseType + + graphql_name 'LicenseHistoryEntry' + description 'Represents an entry from the Cloud License history' + end + end + end +end diff --git a/ee/app/graphql/types/admin/cloud_licenses/license_type.rb b/ee/app/graphql/types/admin/cloud_licenses/license_type.rb new file mode 100644 index 0000000000000000000000000000000000000000..9fd51b4723d2e4aa5fafacc391327a510f1521ce --- /dev/null +++ b/ee/app/graphql/types/admin/cloud_licenses/license_type.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module Types + module Admin + module CloudLicenses + module LicenseType + extend ActiveSupport::Concern + + included do + field :id, GraphQL::ID_TYPE, null: false, + description: 'ID of the license.', + method: :license_id + + field :type, GraphQL::STRING_TYPE, null: false, + description: 'Type of the license.', + method: :license_type + + field :plan, GraphQL::STRING_TYPE, null: false, + description: 'Name of the subscription plan.' + + field :name, GraphQL::STRING_TYPE, null: true, + description: 'Name of the licensee.', + method: :licensee_name + + field :email, GraphQL::STRING_TYPE, null: true, + description: 'Email of the licensee.', + method: :licensee_email + + field :company, GraphQL::STRING_TYPE, null: true, + description: 'Company of the licensee.', + method: :licensee_company + + field :starts_at, ::Types::DateType, null: true, + description: 'Date when the license started.' + + field :expires_at, ::Types::DateType, null: true, + description: 'Date when the license expires.' + + field :activated_at, ::Types::DateType, null: true, + description: 'Date when the license was activated.', + method: :created_at + + field :users_in_license_count, GraphQL::INT_TYPE, null: true, + description: 'Number of paid users in the license.', + method: :restricted_user_count + end + end + end + end +end diff --git a/ee/app/models/license.rb b/ee/app/models/license.rb index de94004a7a7e2c038e16b684295800aec48f3aae..e4e1d6de2dd778bc2179c083f35aaffe9b5e5f81 100644 --- a/ee/app/models/license.rb +++ b/ee/app/models/license.rb @@ -8,6 +8,7 @@ class License < ApplicationRecord PREMIUM_PLAN = 'premium' ULTIMATE_PLAN = 'ultimate' CLOUD_LICENSE_TYPE = 'cloud' + LEGACY_LICENSE_TYPE = 'legacy' ALLOWED_PERCENTAGE_OF_USERS_OVERAGE = (10 / 100.0) EE_ALL_PLANS = [STARTER_PLAN, PREMIUM_PLAN, ULTIMATE_PLAN].freeze @@ -237,6 +238,8 @@ class License < ApplicationRecord { range: (1000..nil), percentage: true, value: 5 } ].freeze + LICENSEE_ATTRIBUTES = %w[Name Email Company].freeze + validate :valid_license validate :check_users_limit, if: :new_record?, unless: :validate_with_trueup? validate :check_trueup, unless: :persisted?, if: :validate_with_trueup? @@ -550,6 +553,10 @@ def cloud? license&.type == CLOUD_LICENSE_TYPE end + def license_type + cloud? ? CLOUD_LICENSE_TYPE : LEGACY_LICENSE_TYPE + end + def auto_renew false end @@ -576,6 +583,12 @@ def remaining_user_count restricted_user_count - daily_billable_users_count end + LICENSEE_ATTRIBUTES.each do |attribute| + define_method "licensee_#{attribute.downcase}" do + licensee[attribute] + end + end + private def restricted_attr(name, default = nil) diff --git a/ee/spec/graphql/resolvers/admin/cloud_licenses/current_license_resolver_spec.rb b/ee/spec/graphql/resolvers/admin/cloud_licenses/current_license_resolver_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..d4a4591f62ec171507b98d60f0c13cd2317dddd8 --- /dev/null +++ b/ee/spec/graphql/resolvers/admin/cloud_licenses/current_license_resolver_spec.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Resolvers::Admin::CloudLicenses::CurrentLicenseResolver do + include GraphqlHelpers + + specify do + expect(described_class).to have_nullable_graphql_type(::Types::Admin::CloudLicenses::CurrentLicenseType) + end + + describe '#resolve' do + subject(:result) { resolve_current_license } + + let_it_be(:admin) { create(:admin) } + let_it_be(:license) { create_current_license } + + def resolve_current_license(current_user: admin) + resolve(described_class, ctx: { current_user: current_user }) + end + + before do + stub_application_setting(cloud_license_enabled: true) + end + + context 'when application setting for cloud license is disabled', :enable_admin_mode do + it 'returns nil' do + stub_application_setting(cloud_license_enabled: false) + + expect(result).to be_nil + end + end + + context 'when current user is unauthorized' do + it 'raises error' do + unauthorized_user = create(:user) + + expect do + resolve_current_license(current_user: unauthorized_user) + end.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + end + end + + context 'when there is no current license', :enable_admin_mode do + it 'returns nil' do + License.delete_all # delete existing license + + expect(result).to be_nil + end + end + + it 'returns the current license', :enable_admin_mode do + expect(result).to eq(license) + end + end +end diff --git a/ee/spec/graphql/resolvers/admin/cloud_licenses/license_history_entries_resolver_spec.rb b/ee/spec/graphql/resolvers/admin/cloud_licenses/license_history_entries_resolver_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..63b2229fd25c6db8e2cc3fa2e8d39b998cc1e1b1 --- /dev/null +++ b/ee/spec/graphql/resolvers/admin/cloud_licenses/license_history_entries_resolver_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Resolvers::Admin::CloudLicenses::LicenseHistoryEntriesResolver do + include GraphqlHelpers + + describe '#resolve' do + subject(:result) { resolve_license_history_entries } + + let_it_be(:admin) { create(:admin) } + + def create_license(data: {}, license_options: { created_at: Time.current }) + gl_license = create(:gitlab_license, data) + create(:license, license_options.merge(data: gl_license.export)) + end + + def resolve_license_history_entries(current_user: admin) + resolve(described_class, ctx: { current_user: current_user }) + end + + before do + stub_application_setting(cloud_license_enabled: true) + end + + context 'when application setting for cloud license is disabled', :enable_admin_mode do + it 'returns nil' do + stub_application_setting(cloud_license_enabled: false) + + expect(result).to be_nil + end + end + + context 'when current user is unauthorized' do + it 'raises error' do + unauthorized_user = create(:user) + + expect do + resolve_license_history_entries(current_user: unauthorized_user) + end.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + end + end + + context 'when no licenses exist' do + it 'returns an empty array', :enable_admin_mode do + License.delete_all # delete license created with ee/spec/support/test_license.rb + + expect(result).to eq([]) + end + end + + it 'returns the license history entries', :enable_admin_mode do + today = Date.current + type = License::CLOUD_LICENSE_TYPE + + past_license = create_license( + data: { starts_at: today - 1.month, expires_at: today + 11.months }, + license_options: { created_at: Time.current - 1.month } + ) + expired_license = create_license(data: { starts_at: today - 1.year, expires_at: today - 1.month }) + another_license = create_license(data: { starts_at: today - 1.month, expires_at: today + 1.year }) + future_license = create_license(data: { starts_at: today + 1.month, expires_at: today + 13.months, type: type }) + current_license = create_license(data: { starts_at: today - 15.days, expires_at: today + 11.months, type: type }) + + expect(result).to eq( + [ + future_license, + current_license, + another_license, + past_license, + expired_license, + License.first # created with ee/spec/support/test_license.rb + ] + ) + end + end +end diff --git a/ee/spec/graphql/types/admin/cloud_licenses/current_license_type_spec.rb b/ee/spec/graphql/types/admin/cloud_licenses/current_license_type_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..cff1ae38b7556b1c577e6883ebaa04b256e520ab --- /dev/null +++ b/ee/spec/graphql/types/admin/cloud_licenses/current_license_type_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['CurrentLicense'], :enable_admin_mode do + let_it_be(:admin) { create(:admin) } + let_it_be(:licensee) do + { + 'Name' => 'User Example', + 'Email' => 'user@example.com', + 'Company' => 'Example Inc.' + } + end + + let_it_be(:license) { create_current_license(licensee: licensee, type: License::CLOUD_LICENSE_TYPE) } + + let(:fields) do + %w[last_sync billable_users_count maximum_user_count users_over_license_count] + end + + def query(field_name) + %( + { + currentLicense { + #{field_name} + } + } + ) + end + + def query_field(field_name) + GitlabSchema.execute(query(field_name), context: { current_user: admin }).as_json + end + + before do + stub_application_setting(cloud_license_enabled: true) + end + + it { expect(described_class.graphql_name).to eq('CurrentLicense') } + it { expect(described_class).to include_graphql_fields(*fields) } + + include_examples 'license type fields', %w[data currentLicense] + + describe "#users_over_license_count" do + context 'when license is for a trial' do + it 'returns 0' do + create_current_license(licensee: licensee, restrictions: { trial: true }) + + result_as_json = query_field('usersOverLicenseCount') + + expect(result_as_json['data']['currentLicense']['usersOverLicenseCount']).to eq(0) + end + end + + it 'returns the number of users over the paid users in the license' do + create(:historical_data, active_user_count: 15) + create_current_license(licensee: licensee, restrictions: { active_user_count: 10 }) + + result_as_json = query_field('usersOverLicenseCount') + + expect(result_as_json['data']['currentLicense']['usersOverLicenseCount']).to eq(5) + end + end +end diff --git a/ee/spec/graphql/types/admin/cloud_licenses/license_history_entry_type_spec.rb b/ee/spec/graphql/types/admin/cloud_licenses/license_history_entry_type_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..03552ebb428913cbd873112c560ca4c15a212468 --- /dev/null +++ b/ee/spec/graphql/types/admin/cloud_licenses/license_history_entry_type_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe GitlabSchema.types['LicenseHistoryEntry'], :enable_admin_mode do + let_it_be(:admin) { create(:admin) } + let_it_be(:licensee) do + { + 'Name' => 'User Example', + 'Email' => 'user@example.com', + 'Company' => 'Example Inc.' + } + end + + let_it_be(:license) { create_current_license(licensee: licensee, type: License::CLOUD_LICENSE_TYPE) } + + def query(field_name) + %( + { + licenseHistoryEntries { + nodes { + #{field_name} + } + } + } + ) + end + + def query_field(field_name) + GitlabSchema.execute(query(field_name), context: { current_user: admin }).as_json + end + + before do + stub_application_setting(cloud_license_enabled: true) + end + + it { expect(described_class.graphql_name).to eq('LicenseHistoryEntry') } + + include_examples 'license type fields', ['data', 'licenseHistoryEntries', 'nodes', -1] +end diff --git a/ee/spec/graphql/types/query_type_spec.rb b/ee/spec/graphql/types/query_type_spec.rb index 06a4ed474a5ca0ea8c00e0063e203a5c854a7594..767a5e9e01b9a71e61cfff98b78662415d521219 100644 --- a/ee/spec/graphql/types/query_type_spec.rb +++ b/ee/spec/graphql/types/query_type_spec.rb @@ -10,7 +10,9 @@ :vulnerabilities, :vulnerability, :instance_security_dashboard, - :vulnerabilities_count_by_day_and_severity + :vulnerabilities_count_by_day_and_severity, + :current_license, + :license_history_entries ).at_least end end diff --git a/ee/spec/models/license_spec.rb b/ee/spec/models/license_spec.rb index 5b1bc73f2e680089e4c4fadabb0acbd5c27c3c43..ea82896f72843f11b2e1b8bf297f25c0c98328c2 100644 --- a/ee/spec/models/license_spec.rb +++ b/ee/spec/models/license_spec.rb @@ -1411,6 +1411,20 @@ def set_restrictions(opts) end end + describe '#license_type' do + subject { license.license_type } + + context 'when the license is not a cloud license' do + it { is_expected.to eq(described_class::LEGACY_LICENSE_TYPE) } + end + + context 'when the license is a cloud license' do + let(:gl_license) { build(:gitlab_license, type: described_class::CLOUD_LICENSE_TYPE) } + + it { is_expected.to eq(described_class::CLOUD_LICENSE_TYPE) } + end + end + describe '#auto_renew' do it 'is false' do expect(license.auto_renew).to be false @@ -1485,4 +1499,28 @@ def set_restrictions(opts) it { is_expected.to eq(result) } end end + + describe '#licensee_name' do + subject { license.licensee_name } + + let(:gl_license) { build(:gitlab_license, licensee: { 'Name' => 'User Example' }) } + + it { is_expected.to eq('User Example') } + end + + describe '#licensee_email' do + subject { license.licensee_email } + + let(:gl_license) { build(:gitlab_license, licensee: { 'Email' => 'user@example.com' }) } + + it { is_expected.to eq('user@example.com') } + end + + describe '#licensee_company' do + subject { license.licensee_company } + + let(:gl_license) { build(:gitlab_license, licensee: { 'Company' => 'Example Inc.' }) } + + it { is_expected.to eq('Example Inc.') } + end end diff --git a/ee/spec/support/shared_examples/graphql/types/admin/cloud_licenses/license_type_shared_examples.rb b/ee/spec/support/shared_examples/graphql/types/admin/cloud_licenses/license_type_shared_examples.rb new file mode 100644 index 0000000000000000000000000000000000000000..88afbcb1d4fdd3856bb4ccaefde9b519e74c2faf --- /dev/null +++ b/ee/spec/support/shared_examples/graphql/types/admin/cloud_licenses/license_type_shared_examples.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +RSpec.shared_examples_for 'license type fields' do |keys| + context 'with license type fields' do + let(:license_fields) do + %w[id type plan name email company starts_at expires_at activated_at users_in_license_count] + end + + it { expect(described_class).to include_graphql_fields(*license_fields) } + end +end