Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'app/models/concerns/cascading_namespace_setting_attribute.rb')
-rw-r--r--app/models/concerns/cascading_namespace_setting_attribute.rb241
1 files changed, 241 insertions, 0 deletions
diff --git a/app/models/concerns/cascading_namespace_setting_attribute.rb b/app/models/concerns/cascading_namespace_setting_attribute.rb
new file mode 100644
index 00000000000..2b4a108a9a0
--- /dev/null
+++ b/app/models/concerns/cascading_namespace_setting_attribute.rb
@@ -0,0 +1,241 @@
+# 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: true)
+ 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