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 0000000000000000000000000000000000000000..29b03a74feea510cb4a5d7fd785b1e2ac505b587 --- /dev/null +++ b/app/models/concerns/cascading_namespace_setting_attribute.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +module CascadingNamespaceSettingAttribute + extend ActiveSupport::Concern + include Gitlab::Utils::StrongMemoize + + class_methods do + private + + def lockable_attr(*attributes) + attributes.each do |attribute| + define_attr_reader(attribute) + define_attr_writer(attribute) + define_lock(attribute) + define_validators(attribute) + define_after_update(attribute) + + alias_boolean(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 steps: + # + # 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.to_sym) do + next self[attribute.to_sym] if will_save_change_to_attribute?(attribute) + + locked_ancestor = self.class + .select("lock_#{attribute}", attribute) + .where("namespace_id IN (?) AND lock_#{attribute} = TRUE", namespace_ancestor_ids) # rubocop:disable GitlabSecurity/SqlInjection + .limit(1).load.first + + locked_ancestor ||= ApplicationSetting.current.public_send("lock_#{attribute}") # rubocop:disable GitlabSecurity/PublicSend + + if locked_ancestor + instance_variable_set("@#{attribute}_locked", true) + next locked_ancestor.read_attribute(attribute.to_sym) + end + + next self[attribute.to_sym] unless self[attribute.to_sym].nil? + + # rubocop:disable GitlabSecurity/SqlInjection + cascaded_value = self.class + .select(attribute.to_sym) + .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.to_sym) + # rubocop:enable GitlabSecurity/SqlInjection + + cascaded_value || ApplicationSetting.current.public_send(attribute.to_sym) # rubocop:disable GitlabSecurity/PublicSend + end + end + end + + def define_attr_writer(attribute) + define_method("#{attribute}=") do |value| + clear_memoization(attribute.to_sym) + + super(value) + end + end + + def define_lock(attribute) + define_method("#{attribute}_locked?") do + return false unless namespace.has_parent? + + # Call the attr reader to initialize the instance variable, if locked + # Make this better - create new methods to retrieve/store the + # locked_ancestor values so we don't have to call the attr reader. + self.send(attribute) # rubocop:disable GitlabSecurity/PublicSend + + !!instance_variable_get("@#{attribute}_locked") + end + end + + def define_validators(attribute) + define_method("#{attribute}_changeable?") do + return unless send("#{attribute}_changed?") && send("#{attribute}_locked?") # rubocop:disable GitlabSecurity/PublicSend + + errors.add(attribute.to_sym, _('is locked by an ancestor')) + end + + define_method("lock_#{attribute}_changeable?") do + return unless send("lock_#{attribute}_changed?") && send("#{attribute}_locked?") # rubocop:disable GitlabSecurity/PublicSend + + errors.add("lock_#{attribute}", _('is locked by an ancestor')) + end + + private :"#{attribute}_changeable?", :"lock_#{attribute}_changeable?" + end + + def define_after_update(attribute) + define_method("clear_descendant_#{attribute}_locks") do + self.class.where('namespace_id IN (?)', descendants).update_all("lock_#{attribute}" => false) + end + + private :"clear_descendant_#{attribute}_locks" + end + + def alias_boolean(attribute) + return unless self.type_for_attribute(attribute).type == :boolean + + alias_method :"#{attribute}?", attribute.to_sym + end + end + + private + + def namespace_ancestor_ids + strong_memoize(:namespace_ancestor_ids) do + namespace.self_and_ancestors(hierarchy_order: :asc).pluck(:id) + end + end + + def descendants + strong_memoize(:descendants) do + namespace.descendants.pluck(:id) + end + end +end diff --git a/app/models/namespace_setting.rb b/app/models/namespace_setting.rb index 50844403d7f5149b41465fadafcf02502cd28766..0b440afc67ca746a09c858d214a6a9685a40a224 100644 --- a/app/models/namespace_setting.rb +++ b/app/models/namespace_setting.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class NamespaceSetting < ApplicationRecord + include CascadingNamespaceSettingAttribute + belongs_to :namespace, inverse_of: :namespace_settings validate :default_branch_name_content @@ -8,6 +10,8 @@ class NamespaceSetting < ApplicationRecord before_validation :normalize_default_branch_name + lockable_attr :prevent_forking_outside_group, :delayed_project_removal + NAMESPACE_SETTINGS_PARAMS = [:default_branch_name].freeze self.primary_key = :namespace_id diff --git a/db/migrate/20210112161200_change_namespaces_delayed_project_removal_null.rb b/db/migrate/20210112161200_change_namespaces_delayed_project_removal_null.rb new file mode 100644 index 0000000000000000000000000000000000000000..ea0842a8cc8134f21c08215d284f4a48760eb080 --- /dev/null +++ b/db/migrate/20210112161200_change_namespaces_delayed_project_removal_null.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +class ChangeNamespacesDelayedProjectRemovalNull < ActiveRecord::Migration[6.0] + DOWNTIME = false + + def up + change_column :namespaces, :delayed_project_removal, :boolean, null: true, default: nil + end + + def down + change_column_default :namespaces, :delayed_project_removal, false + change_column_null :namespaces, :delayed_project_removal, false, false + end +end diff --git a/db/migrate/20210121200443_add_lock_delayed_project_removal_to_namespace_settings.rb b/db/migrate/20210121200443_add_lock_delayed_project_removal_to_namespace_settings.rb new file mode 100644 index 0000000000000000000000000000000000000000..e88f3e7ea0d98e6ac1852b7191be88dc867a65ce --- /dev/null +++ b/db/migrate/20210121200443_add_lock_delayed_project_removal_to_namespace_settings.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddLockDelayedProjectRemovalToNamespaceSettings < ActiveRecord::Migration[6.0] + DOWNTIME = false + + def change + add_column :namespace_settings, :lock_delayed_project_removal, :boolean, default: false, null: false + end +end diff --git a/db/migrate/20210121214957_add_prevent_forking_outside_group_to_namespace_settings.rb b/db/migrate/20210121214957_add_prevent_forking_outside_group_to_namespace_settings.rb new file mode 100644 index 0000000000000000000000000000000000000000..e1886015e5cdaed4b33c6bb325afc5806e7b356f --- /dev/null +++ b/db/migrate/20210121214957_add_prevent_forking_outside_group_to_namespace_settings.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddPreventForkingOutsideGroupToNamespaceSettings < ActiveRecord::Migration[6.0] + DOWNTIME = false + + def change + add_column :namespace_settings, :lock_prevent_forking_outside_group, :boolean, default: false, null: false + end +end diff --git a/db/migrate/20210122144406_add_delayed_project_removal_to_namespace_settings.rb b/db/migrate/20210122144406_add_delayed_project_removal_to_namespace_settings.rb new file mode 100644 index 0000000000000000000000000000000000000000..27dd96c86a23fd43a8bf573ee78984debff4fc2a --- /dev/null +++ b/db/migrate/20210122144406_add_delayed_project_removal_to_namespace_settings.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddDelayedProjectRemovalToNamespaceSettings < ActiveRecord::Migration[6.0] + DOWNTIME = false + + def change + add_column :namespace_settings, :delayed_project_removal, :boolean + end +end diff --git a/db/migrate/20210127154404_add_settings_to_application_setting.rb b/db/migrate/20210127154404_add_settings_to_application_setting.rb new file mode 100644 index 0000000000000000000000000000000000000000..57bac9bff28afc277cf65e0baf216bcf03910da2 --- /dev/null +++ b/db/migrate/20210127154404_add_settings_to_application_setting.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +class AddSettingsToApplicationSetting < ActiveRecord::Migration[6.0] + DOWNTIME = false + + def change + add_column :application_settings, :lock_prevent_forking_outside_group, :boolean, default: false, null: false + add_column :application_settings, :prevent_forking_outside_group, :boolean, default: false, null: false + + add_column :application_settings, :lock_delayed_project_removal, :boolean, default: false, null: false + add_column :application_settings, :delayed_project_removal, :boolean, default: false, null: false + end +end diff --git a/db/schema_migrations/20210112161200 b/db/schema_migrations/20210112161200 new file mode 100644 index 0000000000000000000000000000000000000000..c45e9e44a32d76c3ac64c4753e492698ae0347e2 --- /dev/null +++ b/db/schema_migrations/20210112161200 @@ -0,0 +1 @@ +14c5f18efd78b710d862213b73c442f1cc4ad8220122033480f22422bccca52e \ No newline at end of file diff --git a/db/schema_migrations/20210121200443 b/db/schema_migrations/20210121200443 new file mode 100644 index 0000000000000000000000000000000000000000..18761f84197e4f6c028b55e1606c727c7f7ef434 --- /dev/null +++ b/db/schema_migrations/20210121200443 @@ -0,0 +1 @@ +5be72977297f33c84552e3f8c2b8805e7179c2ee90aef4b6c3d263187cef7c1c \ No newline at end of file diff --git a/db/schema_migrations/20210121214957 b/db/schema_migrations/20210121214957 new file mode 100644 index 0000000000000000000000000000000000000000..6f4855d0f530a88fa92eebd19e50a33b3443c7a2 --- /dev/null +++ b/db/schema_migrations/20210121214957 @@ -0,0 +1 @@ +91b7f62ce32211d49854bee2140f1256f8a27364695b70a78d514f910a7f3093 \ No newline at end of file diff --git a/db/schema_migrations/20210122144406 b/db/schema_migrations/20210122144406 new file mode 100644 index 0000000000000000000000000000000000000000..ad52c5649063bb0622316f8a0a03b133b6457173 --- /dev/null +++ b/db/schema_migrations/20210122144406 @@ -0,0 +1 @@ +096796fdd3321920fa2abe73e21186d6de34e9ca581e36023d0dad60f1ed48fa \ No newline at end of file diff --git a/db/schema_migrations/20210127154404 b/db/schema_migrations/20210127154404 new file mode 100644 index 0000000000000000000000000000000000000000..0a80e7a3245005ed67a69ddc3d7b318964254eb6 --- /dev/null +++ b/db/schema_migrations/20210127154404 @@ -0,0 +1 @@ +67918db3750b65239b2046e61c0f84e9909a98a9cf79479a5b6b2cd5a7eace00 \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index 3088c2d03aa27f6554fe95eeb284ad20a060e3c6..5506d3c112706e1b492035f714cdb23926a5cbbf 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -9407,7 +9407,6 @@ CREATE TABLE application_settings ( disable_feed_token boolean DEFAULT false NOT NULL, personal_access_token_prefix text, rate_limiting_response_text text, - invisible_captcha_enabled boolean DEFAULT false NOT NULL, container_registry_cleanup_tags_service_max_list_size integer DEFAULT 200 NOT NULL, enforce_ssh_key_expiration boolean DEFAULT false NOT NULL, git_two_factor_session_expiry integer DEFAULT 15 NOT NULL, @@ -9415,6 +9414,11 @@ CREATE TABLE application_settings ( keep_latest_artifact boolean DEFAULT true NOT NULL, notes_create_limit integer DEFAULT 300 NOT NULL, notes_create_limit_allowlist text[] DEFAULT '{}'::text[] NOT NULL, + invisible_captcha_enabled boolean DEFAULT false NOT NULL, + lock_prevent_forking_outside_group boolean DEFAULT false NOT NULL, + prevent_forking_outside_group boolean DEFAULT false NOT NULL, + lock_delayed_project_removal boolean DEFAULT false NOT NULL, + delayed_project_removal boolean DEFAULT false NOT NULL, CONSTRAINT app_settings_container_reg_cleanup_tags_max_list_size_positive CHECK ((container_registry_cleanup_tags_service_max_list_size >= 0)), CONSTRAINT app_settings_registry_exp_policies_worker_capacity_positive CHECK ((container_registry_expiration_policies_worker_capacity >= 0)), CONSTRAINT check_17d9558205 CHECK ((char_length((kroki_url)::text) <= 1024)), @@ -14318,6 +14322,10 @@ CREATE TABLE namespace_settings ( allow_mfa_for_subgroups boolean DEFAULT true NOT NULL, default_branch_name text, repository_read_only boolean DEFAULT false NOT NULL, + lock_delayed_project_removal boolean DEFAULT false NOT NULL, + lock_prevent_forking_outside_group boolean DEFAULT false NOT NULL, + delayed_project_removal boolean, + repository_read_only boolean DEFAULT false NOT NULL, CONSTRAINT check_0ba93c78c7 CHECK ((char_length(default_branch_name) <= 255)) ); @@ -14386,7 +14394,7 @@ CREATE TABLE namespaces ( shared_runners_enabled boolean DEFAULT true NOT NULL, allow_descendants_override_disabled_shared_runners boolean DEFAULT false NOT NULL, traversal_ids integer[] DEFAULT '{}'::integer[] NOT NULL, - delayed_project_removal boolean DEFAULT false NOT NULL + delayed_project_removal boolean ); CREATE SEQUENCE namespaces_id_seq