ee/app/models/license.rb (444 lines of code) (raw):
# frozen_string_literal: true
# License is the artifact of purchasing a GitLab subscription for self-managed
# and it is installed at instance level.
# GitLab SaaS is a special self-managed instance which has a license installed
# that is mapped to an Ultimate plan.
class License < ApplicationRecord
include ActionView::Helpers::NumberHelper
include Gitlab::Utils::StrongMemoize
STARTER_PLAN = 'starter'
PREMIUM_PLAN = 'premium'
ULTIMATE_PLAN = 'ultimate'
ONLINE_CLOUD_TYPE = 'online_cloud'
OFFLINE_CLOUD_TYPE = 'offline_cloud'
LEGACY_LICENSE_TYPE = 'legacy_license'
ALLOWED_PERCENTAGE_OF_USERS_OVERAGE = (10 / 100.0)
NOTIFICATION_DAYS_BEFORE_TRIAL_EXPIRY = 1.week
ADMIN_NOTIFICATION_DAYS_BEFORE_EXPIRY = 15.days
GRACE_PERIOD = 2.weeks
EE_ALL_PLANS = [STARTER_PLAN, PREMIUM_PLAN, ULTIMATE_PLAN].freeze
ACTIVE_USER_COUNT_THRESHOLD_LEVELS = [
{ range: (2..15), percentage: false, value: 1 },
{ range: (16..25), percentage: false, value: 2 },
{ range: (26..99), percentage: true, value: 10 },
{ range: (100..999), percentage: true, value: 8 },
{ 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?, :reconciliation_completed?]
validate :check_trueup, unless: :reconciliation_completed?, if: [:new_record?, :validate_with_trueup?]
validate :check_available_seats, if: [:new_record?, :reconciliation_completed?]
# When an online cloud license subscription is cancelled, the license_key received from the seat link sync
# is an expired license but we still want to be able to create it to reflect that the license is not active anymore.
# That is why we skip the validation on this specific case.
validate :not_expired, if: :new_record?, unless: :subscription_cancelled?
before_validation :reset_license, if: :data_changed?
after_create :update_trial_setting
after_commit :reset_current
after_commit :reset_future_dated, on: [:create, :destroy]
scope :cloud, -> { where(cloud: true) }
scope :recent, -> { reorder(id: :desc) }
scope :last_hundred, -> { recent.limit(100) }
CACHE_KEY = :current_license
class << self
def current
cache.fetch(CACHE_KEY, as: License, expires_in: 1.minute) { load_license }
end
def reset_current
cache.expire(CACHE_KEY)
end
def cache
Gitlab::SafeRequestStore[:license_cache] ||=
Gitlab::Cache::JsonCaches::RedisKeyed.new(namespace: :ee, backend: ::Gitlab::ProcessMemoryCache.cache_backend, cache_key_strategy: :version)
end
def all_plans
EE_ALL_PLANS
end
def block_changes?
!!current&.block_changes?
end
def feature_available?(feature)
# Include features available per plan + usage ping features if Usage Pings is enabled
# as instance setting.
!!current&.feature_available?(feature) ||
GitlabSubscriptions::Features.usage_ping_feature?(feature)
end
def load_license
return unless self.table_exists?
last_hundred = self.last_hundred
# Gather the names of existing online cloud licenses that are `generated_from_cancellation=true` here
# to make sure we don't fall back to an outdated active version of a license that belongs to
# a cancelled subscription.
cancelled_subscription_names = last_hundred.filter_map do |license|
license.subscription_name if license.subscription_cancelled?
end
last_hundred.find { |license| license.valid_started? && !license.expired? && cancelled_subscription_names.exclude?(license.subscription_name) } ||
last_hundred.find(&:valid_started?)
end
def future_dated
Gitlab::SafeRequestStore.fetch(:future_dated_license) { load_future_dated }
end
def reset_future_dated
Gitlab::SafeRequestStore.delete(:future_dated_license)
end
def eligible_for_trial?
Gitlab::CurrentSettings.license_trial_ends_on.nil?
end
def trial_ends_on
Gitlab::CurrentSettings.license_trial_ends_on
end
def history
decryptable_licenses = all.select { |license| license.license.present? }
decryptable_licenses.sort_by { |license| [license.starts_at, license.created_at, license.expires_at] }.reverse
end
def with_valid_license
current_license = License.current
return unless current_license
return if current_license.trial?
yield(current_license) if block_given?
end
def current_cloud_license?(key)
current_license = License.current
return false unless current_license&.cloud_license?
current_license.data == key
end
# rubocop: disable Gitlab/FeatureAvailableUsage -- `License.feature_available?` is allowed, but the cop doesn't detect that we're inside the `License` class itself.
def ai_features_available?
feature_available?(:ai_features) || feature_available?(:ai_chat)
end
# rubocop: enable Gitlab/FeatureAvailableUsage
# rubocop: disable Gitlab/FeatureAvailableUsage -- `License.feature_available?` is allowed, but the cop doesn't detect that we're inside the `License` class itself.
def duo_core_features_available?
feature_available?(:code_suggestions) || feature_available?(:ai_chat)
end
# rubocop: enable Gitlab/FeatureAvailableUsage
private
def load_future_dated
self.last_hundred.find { |license| license.valid? && license.future_dated? }
end
end
def valid_started?
valid? && started?
end
def offline_cloud_license?
cloud_license? && !!license&.offline_cloud_licensing?
end
def seats
restricted_attr(:active_user_count)
end
def ultimate?
plan == License::ULTIMATE_PLAN
end
def premium?
plan == License::PREMIUM_PLAN
end
def customer_service_enabled?
!!license&.operational_metrics?
end
def trial?
restricted_attr(:trial)
end
def data_filename
company_name = self.licensee_company || self.licensee.each_value.first
clean_company_name = company_name.gsub(/[^A-Za-z0-9]/, "")
"#{clean_company_name}.gitlab-license"
end
def data_file=(file)
self.data = file.read
end
def normalized_data
data.gsub("\r\n", "\n").gsub(/\n+$/, '') + "\n"
end
def md5
return if Gitlab::FIPS.enabled?
Digest::MD5.hexdigest(normalized_data)
end
def sha256
Digest::SHA256.hexdigest(normalized_data)
end
def license
return unless self.data
@license ||=
begin
Gitlab::License.import(self.data)
rescue Gitlab::License::ImportError
nil
end
end
def license?
self.license && self.license.valid?
end
def method_missing(method_name, *arguments, &block)
if License.column_names.include?(method_name.to_s)
super
elsif license && license.respond_to?(method_name)
license.__send__(method_name, *arguments, &block) # rubocop:disable GitlabSecurity/PublicSend
else
super
end
end
def respond_to_missing?(method_name, include_private = false)
if License.column_names.include?(method_name.to_s)
super
elsif license && license.respond_to?(method_name)
true
else
super
end
end
# New licenses persists only the `plan` (premium, starter, ..). But, old licenses
# keep `add_ons`.
def add_ons
restricted_attr(:add_ons, {})
end
# License zuora_subscription_id
def subscription_id
restricted_attr(:subscription_id)
end
def subscription_name
restricted_attr(:subscription_name)
end
def reconciliation_completed?
restricted_attr(:reconciliation_completed)
end
def features
@features ||= GitlabSubscriptions::Features.features(plan: plan, add_ons: add_ons)
end
def feature_available?(feature)
return false if trial? && expired?
features.include?(feature)
end
def license_id
restricted_attr(:id)
end
def previous_user_count
restricted_attr(:previous_user_count)
end
def plan
restricted_attr(:plan).presence || STARTER_PLAN
end
def edition
case restricted_attr(:plan)
when 'ultimate'
'EEU'
when 'premium'
'EEP'
when 'starter'
'EES'
else # Older licenses
'EE'
end
end
def daily_billable_users_count
daily_billable_users.count
end
def daily_billable_users_updated_time
(daily_billable_users.try(:recorded_at) || Time.zone.now).to_s
end
def validate_with_trueup?
return false if cloud_license?
[restricted_attr(:trueup_quantity),
restricted_attr(:trueup_from),
restricted_attr(:trueup_to)].all?(&:present?)
end
alias_method :exclude_guests_from_active_count?, :ultimate?
def remaining_days
return 0 if expired?
(expires_at - Date.today).to_i
end
def overage(user_count = nil)
return 0 if seats.nil?
user_count ||= daily_billable_users_count
[user_count - seats, 0].max
end
def overage_with_historical_max
overage(maximum_user_count)
end
def historical_data(from: nil, to: nil)
from ||= starts_at_for_historical_data
to ||= expires_at_for_historical_data
HistoricalData.during(from..to)
end
def historical_max(from: nil, to: nil)
from ||= starts_at_for_historical_data
to ||= expires_at_for_historical_data
HistoricalData.max_historical_user_count(from: from, to: to)
end
def maximum_user_count
[historical_max(from: starts_at), daily_billable_users_count].max
end
def update_trial_setting
return unless license.restrictions[:trial]
return if license.expires_at.nil?
settings = ApplicationSetting.current
return if settings.nil?
return if settings.license_trial_ends_on.present?
settings.update license_trial_ends_on: license.expires_at
end
def paid?
[License::STARTER_PLAN, License::PREMIUM_PLAN, License::ULTIMATE_PLAN].include?(plan)
end
def started?
starts_at <= Date.current
end
def future_dated?
starts_at > Date.current
end
def cloud_license?
!!license&.cloud_licensing?
end
def online_cloud_license?
cloud_license? && !license&.offline_cloud_licensing?
end
def subscription_cancelled?
online_cloud_license? && license&.generated_from_cancellation?
end
def current?
self == License.current
end
def license_type
return OFFLINE_CLOUD_TYPE if offline_cloud_license?
return ONLINE_CLOUD_TYPE if online_cloud_license?
LEGACY_LICENSE_TYPE
end
def grace_period_expired?
return false if expires_at.blank?
(expires_at + GRACE_PERIOD).past?
end
def auto_renew
false
end
def active_user_count_threshold
ACTIVE_USER_COUNT_THRESHOLD_LEVELS.find do |threshold|
threshold[:range].include?(seats)
end
end
def active_user_count_threshold_reached?
return false if seats.nil?
return false if daily_billable_users_count <= 1
return false if daily_billable_users_count > seats
active_user_count_threshold[:value] >= if active_user_count_threshold[:percentage]
remaining_user_count.fdiv(daily_billable_users_count) * 100
else
remaining_user_count
end
end
def remaining_user_count
seats - daily_billable_users_count
end
LICENSEE_ATTRIBUTES.each do |attribute|
define_method "licensee_#{attribute.downcase}" do
licensee[attribute]
end
end
def activated_at
super || created_at
end
# Overrides method from Gitlab::License which will be removed in a future version
def notify_admins?
return false if expires_at.blank?
return true if expired?
notification_days = trial? ? NOTIFICATION_DAYS_BEFORE_TRIAL_EXPIRY : ADMIN_NOTIFICATION_DAYS_BEFORE_EXPIRY
Date.current >= (expires_at - notification_days)
end
# Overrides method from Gitlab::License which will be removed in a future version
def notify_users?
return false if expires_at.blank?
notification_start_date = trial? ? expires_at - NOTIFICATION_DAYS_BEFORE_TRIAL_EXPIRY : block_changes_at
Date.current >= notification_start_date
end
private
def restricted_attr(name, default = nil)
return default unless license? && restricted?(name)
restrictions[name]
end
def reset_current
self.class.reset_current
end
def reset_future_dated
self.class.reset_future_dated
end
def reset_license
@license = nil
end
def valid_license
return if license?
error_message = if online_cloud_license?
_('The license key is invalid.')
else
_('The license key is invalid. Make sure it is exactly as you received it from GitLab Inc.')
end
self.errors.add(:base, error_message)
end
# This method, `previous_started_at` and `previous_expired_at` are
# only used in the validation methods `check_users_limit` and check_trueup
# which are only used when uploading/creating a new license.
# The method will not work in other workflows since it has a dependency to
# use the current license as the previous in the system.
def prior_historical_max
strong_memoize(:prior_historical_max) do
historical_max(from: previous_started_at, to: previous_expired_at)
end
end
# See comment for `prior_historical_max`.
def previous_started_at
(License.current&.starts_at || (starts_at - 1.year)).beginning_of_day
end
# See comment for `prior_historical_max`.
def previous_expired_at
(License.current&.expires_at || (expires_at && (expires_at - 1.year)) || starts_at).end_of_day
end
def seats_with_threshold
(seats * (1 + ALLOWED_PERCENTAGE_OF_USERS_OVERAGE)).to_i
end
def check_users_limit
return if cloud_license?
return unless seats
user_count = daily_billable_users_count
current_period = true
if previous_user_count && (prior_historical_max <= previous_user_count)
return if seats_with_threshold >= daily_billable_users_count
else
return if seats_with_threshold >= prior_historical_max
user_count = prior_historical_max
current_period = false
end
add_limit_error(current_period: current_period, user_count: user_count)
end
def trueup_from
Date.parse(restrictions[:trueup_from]).beginning_of_day
rescue StandardError
previous_started_at
end
def trueup_to
Date.parse(restrictions[:trueup_to]).end_of_day
rescue StandardError
previous_expired_at
end
def check_trueup
unless trueup_period_seat_count
check_available_seats
return
end
trueup_qty = restrictions[:trueup_quantity]
max_historical = historical_max(from: trueup_from, to: trueup_to)
expected_trueup_qty = max_historical - trueup_period_seat_count
if trueup_quantity_with_threshold >= expected_trueup_qty
check_available_seats
else
message = ["You have applied a True-up for #{trueup_qty} #{'user'.pluralize(trueup_qty)}"]
message << "but you need one for #{expected_trueup_qty} #{'user'.pluralize(expected_trueup_qty)}."
message << "Please contact sales at https://about.gitlab.com/sales/"
self.errors.add(:base, :check_trueup, message: message.join(' '))
end
end
def trueup_quantity_with_threshold
(restrictions[:trueup_quantity] * (1 + ALLOWED_PERCENTAGE_OF_USERS_OVERAGE)).to_i
end
def check_available_seats
return if cloud_license?
return unless seats && seats_with_threshold < daily_billable_users_count
add_limit_error(type: :check_available_seats, user_count: daily_billable_users_count)
end
def add_limit_error(user_count:, current_period: true, type: :invalid)
overage_count = overage(user_count)
message = [current_period ? "This GitLab installation currently has" : "During the year before this license started, this GitLab installation had"]
message << "#{number_with_delimiter(user_count)} active #{'user'.pluralize(user_count)},"
message << "exceeding this license's limit of #{number_with_delimiter(seats)} by"
message << "#{number_with_delimiter(overage_count)} #{'user'.pluralize(overage_count)}."
message << "Please add a license for at least"
message << "#{number_with_delimiter(user_count)} #{'user'.pluralize(user_count)} or contact sales at https://about.gitlab.com/sales/"
self.errors.add(:base, type, message: message.join(' '))
end
def not_expired
return unless self.license? && self.expired?
self.errors.add(:base, _('This license has already expired.'))
end
def starts_at_for_historical_data
(starts_at || (Time.current - 1.year)).beginning_of_day
end
def expires_at_for_historical_data
(expires_at || Time.current).end_of_day
end
def daily_billable_users
strong_memoize(:daily_billable_users) do
::Analytics::UsageTrends::Measurement.find_latest_or_fallback(:billable_users)
end
end
def trueup_period_seat_count
restricted_attr(:trueup_period_seat_count) || previous_user_count
end
end
License.prepend_mod