gitlab-org--gitlab-foss/app/models/concerns/cascading_namespace_setting_attribute.rb

241 lines
8.5 KiB
Ruby

# frozen_string_literal: true
#
# Cascading attributes enables managing settings in a flexible way.
#
# - Instance administrator can define an instance-wide default setting, or
# lock the setting to prevent change by group owners.
# - Group maintainers/owners can define a default setting for their group, or
# lock the setting to prevent change by sub-group maintainers/owners.
#
# Behavior:
#
# - When a group does not have a value (value is `nil`), cascade up the
# hierarchy to find the first non-nil value.
# - Settings can be locked at any level to prevent groups/sub-groups from
# overriding.
# - If the setting isn't locked, the default can be overridden.
# - An instance administrator or group maintainer/owner can push settings values
# to groups/sub-groups to override existing values, even when the setting
# is not otherwise locked.
#
module CascadingNamespaceSettingAttribute
extend ActiveSupport::Concern
include Gitlab::Utils::StrongMemoize
class_methods do
def cascading_settings_feature_enabled?
::Feature.enabled?(:cascading_namespace_settings, default_enabled: false)
end
private
# Facilitates the cascading lookup of values and,
# similar to Rails' `attr_accessor`, defines convenience methods such as
# a reader, writer, and validators.
#
# Example: `cascading_attr :delayed_project_removal`
#
# Public methods defined:
# - `delayed_project_removal`
# - `delayed_project_removal=`
# - `delayed_project_removal_locked?`
# - `delayed_project_removal_locked_by_ancestor?`
# - `delayed_project_removal_locked_by_application_setting?`
# - `delayed_project_removal?` (only defined for boolean attributes)
# - `delayed_project_removal_locked_ancestor` - Returns locked namespace settings object (only namespace_id)
#
# Defined validators ensure attribute value cannot be updated if locked by
# an ancestor or application settings.
#
# Requires database columns be present in both `namespace_settings` and
# `application_settings`.
def cascading_attr(*attributes)
attributes.map(&:to_sym).each do |attribute|
# public methods
define_attr_reader(attribute)
define_attr_writer(attribute)
define_lock_methods(attribute)
alias_boolean(attribute)
# private methods
define_validator_methods(attribute)
define_after_update(attribute)
validate :"#{attribute}_changeable?"
validate :"lock_#{attribute}_changeable?"
after_update :"clear_descendant_#{attribute}_locks", if: -> { saved_change_to_attribute?("lock_#{attribute}", to: true) }
end
end
# The cascading attribute reader method handles lookups
# with the following criteria:
#
# 1. Returns the dirty value, if the attribute has changed.
# 2. Return locked ancestor value.
# 3. Return locked instance-level application settings value.
# 4. Return this namespace's attribute, if not nil.
# 5. Return value from nearest ancestor where value is not nil.
# 6. Return instance-level application setting.
def define_attr_reader(attribute)
define_method(attribute) do
strong_memoize(attribute) do
next self[attribute] unless self.class.cascading_settings_feature_enabled?
next self[attribute] if will_save_change_to_attribute?(attribute)
next locked_value(attribute) if cascading_attribute_locked?(attribute)
next self[attribute] unless self[attribute].nil?
cascaded_value = cascaded_ancestor_value(attribute)
next cascaded_value unless cascaded_value.nil?
application_setting_value(attribute)
end
end
end
def define_attr_writer(attribute)
define_method("#{attribute}=") do |value|
clear_memoization(attribute)
super(value)
end
end
def define_lock_methods(attribute)
define_method("#{attribute}_locked?") do
cascading_attribute_locked?(attribute)
end
define_method("#{attribute}_locked_by_ancestor?") do
locked_by_ancestor?(attribute)
end
define_method("#{attribute}_locked_by_application_setting?") do
locked_by_application_setting?(attribute)
end
define_method("#{attribute}_locked_ancestor") do
locked_ancestor(attribute)
end
end
def alias_boolean(attribute)
return unless Gitlab::Database.exists? && type_for_attribute(attribute).type == :boolean
alias_method :"#{attribute}?", attribute
end
# Defines two validations - one for the cascadable attribute itself and one
# for the lock attribute. Only allows the respective value to change if
# an ancestor has not already locked the value.
def define_validator_methods(attribute)
define_method("#{attribute}_changeable?") do
return unless cascading_attribute_changed?(attribute)
return unless cascading_attribute_locked?(attribute)
errors.add(attribute, s_('CascadingSettings|cannot be changed because it is locked by an ancestor'))
end
define_method("lock_#{attribute}_changeable?") do
return unless cascading_attribute_changed?("lock_#{attribute}")
if cascading_attribute_locked?(attribute)
return errors.add(:"lock_#{attribute}", s_('CascadingSettings|cannot be changed because it is locked by an ancestor'))
end
# Don't allow locking a `nil` attribute.
# Even if the value being locked is currently cascaded from an ancestor,
# it should be copied to this record to avoid the ancestor changing the
# value unexpectedly later.
return unless self[attribute].nil? && public_send("lock_#{attribute}?") # rubocop:disable GitlabSecurity/PublicSend
errors.add(attribute, s_('CascadingSettings|cannot be nil when locking the attribute'))
end
private :"#{attribute}_changeable?", :"lock_#{attribute}_changeable?"
end
# When a particular group locks the attribute, clear all sub-group locks
# since the higher lock takes priority.
def define_after_update(attribute)
define_method("clear_descendant_#{attribute}_locks") do
self.class.where(namespace_id: descendants).update_all("lock_#{attribute}" => false)
end
private :"clear_descendant_#{attribute}_locks"
end
end
private
def locked_value(attribute)
ancestor = locked_ancestor(attribute)
return ancestor.read_attribute(attribute) if ancestor
Gitlab::CurrentSettings.public_send(attribute) # rubocop:disable GitlabSecurity/PublicSend
end
def locked_ancestor(attribute)
return unless self.class.cascading_settings_feature_enabled?
return unless namespace.has_parent?
strong_memoize(:"#{attribute}_locked_ancestor") do
self.class
.select(:namespace_id, "lock_#{attribute}", attribute)
.where(namespace_id: namespace_ancestor_ids)
.where(self.class.arel_table["lock_#{attribute}"].eq(true))
.limit(1).load.first
end
end
def locked_by_ancestor?(attribute)
return false unless self.class.cascading_settings_feature_enabled?
locked_ancestor(attribute).present?
end
def locked_by_application_setting?(attribute)
return false unless self.class.cascading_settings_feature_enabled?
Gitlab::CurrentSettings.public_send("lock_#{attribute}") # rubocop:disable GitlabSecurity/PublicSend
end
def cascading_attribute_locked?(attribute)
locked_by_ancestor?(attribute) || locked_by_application_setting?(attribute)
end
def cascading_attribute_changed?(attribute)
public_send("#{attribute}_changed?") # rubocop:disable GitlabSecurity/PublicSend
end
def cascaded_ancestor_value(attribute)
return unless namespace.has_parent?
# rubocop:disable GitlabSecurity/SqlInjection
self.class
.select(attribute)
.joins("join unnest(ARRAY[#{namespace_ancestor_ids.join(',')}]) with ordinality t(namespace_id, ord) USING (namespace_id)")
.where("#{attribute} IS NOT NULL")
.order('t.ord')
.limit(1).first&.read_attribute(attribute)
# rubocop:enable GitlabSecurity/SqlInjection
end
def application_setting_value(attribute)
Gitlab::CurrentSettings.public_send(attribute) # rubocop:disable GitlabSecurity/PublicSend
end
def namespace_ancestor_ids
strong_memoize(:namespace_ancestor_ids) do
namespace.self_and_ancestors(hierarchy_order: :asc).pluck(:id).reject { |id| id == namespace_id }
end
end
def descendants
strong_memoize(:descendants) do
namespace.descendants.pluck(:id)
end
end
end