diff options
Diffstat (limited to 'app/models/concerns')
22 files changed, 312 insertions, 66 deletions
diff --git a/app/models/concerns/ci/has_status.rb b/app/models/concerns/ci/has_status.rb index 910885c833f..9a04776f1c6 100644 --- a/app/models/concerns/ci/has_status.rb +++ b/app/models/concerns/ci/has_status.rb @@ -110,6 +110,10 @@ module Ci COMPLETED_STATUSES.include?(status) end + def incomplete? + COMPLETED_STATUSES.exclude?(status) + end + def blocked? BLOCKED_STATUS.include?(status) end diff --git a/app/models/concerns/ci/metadatable.rb b/app/models/concerns/ci/metadatable.rb index ff884984099..d93f4a150d5 100644 --- a/app/models/concerns/ci/metadatable.rb +++ b/app/models/concerns/ci/metadatable.rb @@ -87,6 +87,16 @@ module Ci ensure_metadata.id_tokens = value end + def enqueue_immediately? + !!options[:enqueue_immediately] + end + + def set_enqueue_immediately! + # ensures that even if `config_options: nil` in the database we set the + # new value correctly. + self.options = options.merge(enqueue_immediately: true) + end + private def read_metadata_attribute(legacy_key, metadata_key, default_value = nil) diff --git a/app/models/concerns/ci/partitionable.rb b/app/models/concerns/ci/partitionable.rb index df803180e77..68a6714c892 100644 --- a/app/models/concerns/ci/partitionable.rb +++ b/app/models/concerns/ci/partitionable.rb @@ -57,9 +57,14 @@ module Ci end class_methods do - private + def partitionable(scope:, through: nil) + if through + define_singleton_method(:routing_table_name) { through[:table] } + define_singleton_method(:routing_table_name_flag) { through[:flag] } + + include Partitionable::Switch + end - def partitionable(scope:) define_method(:partition_scope_value) do strong_memoize(:partition_scope_value) do next Ci::Pipeline.current_partition_value if respond_to?(:importing?) && importing? diff --git a/app/models/concerns/ci/partitionable/switch.rb b/app/models/concerns/ci/partitionable/switch.rb new file mode 100644 index 00000000000..c1bbd107e9f --- /dev/null +++ b/app/models/concerns/ci/partitionable/switch.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +module Ci + module Partitionable + module Switch + extend ActiveSupport::Concern + + # These methods are cached at the class level and depend on the value + # of `table_name`, changing that value resets them. + # `cached_find_by_statement` is used to cache SQL statements which can + # include the table name. + # + SWAPABLE_METHODS = %i[table_name quoted_table_name arel_table + predicate_builder cached_find_by_statement].freeze + + included do |base| + partitioned = Class.new(base) do + self.table_name = base.routing_table_name + + def self.routing_class? + true + end + end + + base.const_set(:Partitioned, partitioned) + end + + class_methods do + def routing_class? + false + end + + def routing_table_enabled? + return false if routing_class? + + Gitlab::SafeRequestStore.fetch(routing_table_name_flag) do + ::Feature.enabled?(routing_table_name_flag) + end + end + + # We're delegating them to the `Partitioned` model. + # They do not require any check override since they come from AR core + # (are always defined) and we're using `super` to get the value. + # + SWAPABLE_METHODS.each do |name| + define_method(name) do |*args, &block| + if routing_table_enabled? + self::Partitioned.public_send(name, *args, &block) # rubocop: disable GitlabSecurity/PublicSend + else + super(*args, &block) + end + end + end + end + end + end +end diff --git a/app/models/concerns/ci/raw_variable.rb b/app/models/concerns/ci/raw_variable.rb new file mode 100644 index 00000000000..5cfc781c9f1 --- /dev/null +++ b/app/models/concerns/ci/raw_variable.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Ci + module RawVariable + extend ActiveSupport::Concern + + included do + validates :raw, inclusion: { in: [true, false] } + end + + private + + def uncached_runner_variable + super.merge(raw: raw?) + end + end +end diff --git a/app/models/concerns/ci/track_environment_usage.rb b/app/models/concerns/ci/track_environment_usage.rb index 45d9cdeeb59..fe548c77590 100644 --- a/app/models/concerns/ci/track_environment_usage.rb +++ b/app/models/concerns/ci/track_environment_usage.rb @@ -17,7 +17,7 @@ module Ci end def verifies_environment? - has_environment? && environment_action == 'verify' + has_environment_keyword? && environment_action == 'verify' end def count_user_deployment? diff --git a/app/models/concerns/encrypted_user_password.rb b/app/models/concerns/encrypted_user_password.rb new file mode 100644 index 00000000000..97e6592f442 --- /dev/null +++ b/app/models/concerns/encrypted_user_password.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +# Support for both BCrypt and PBKDF2+SHA512 user passwords +# Meant to be used exclusively with User model but extracted +# to a concern for isolation and clarity. +module EncryptedUserPassword + extend ActiveSupport::Concern + + BCRYPT_PREFIX = '$2a$' + PBKDF2_SHA512_PREFIX = '$pbkdf2-sha512$' + + BCRYPT_STRATEGY = :bcrypt + PBKDF2_SHA512_STRATEGY = :pbkdf2_sha512 + + # Use Devise DatabaseAuthenticatable#authenticatable_salt + # unless encrypted password is PBKDF2+SHA512. + def authenticatable_salt + return super unless pbkdf2_password? + + Devise::Pbkdf2Encryptable::Encryptors::Pbkdf2Sha512.split_digest(encrypted_password)[:salt] + end + + # Called by Devise during database authentication. + # Also migrates the user password to the configured + # encryption type (BCrypt or PBKDF2+SHA512), if needed. + def valid_password?(password) + return false unless password_matches?(password) + + migrate_password!(password) + end + + def password=(new_password) + @password = new_password # rubocop:disable Gitlab/ModuleWithInstanceVariables + return unless new_password.present? + + self.encrypted_password = if Gitlab::FIPS.enabled? + Devise::Pbkdf2Encryptable::Encryptors::Pbkdf2Sha512.digest( + new_password, + Devise::Pbkdf2Encryptable::Encryptors::Pbkdf2Sha512::STRETCHES, + Devise.friendly_token[0, 16]) + else + Devise::Encryptor.digest(self.class, new_password) + end + end + + private + + def password_strategy + return BCRYPT_STRATEGY if encrypted_password.starts_with?(BCRYPT_PREFIX) + return PBKDF2_SHA512_STRATEGY if encrypted_password.starts_with?(PBKDF2_SHA512_PREFIX) + + :unknown + end + + def pbkdf2_password? + password_strategy == PBKDF2_SHA512_STRATEGY + end + + def bcrypt_password? + password_strategy == BCRYPT_STRATEGY + end + + def password_matches?(password) + if bcrypt_password? + Devise::Encryptor.compare(self.class, encrypted_password, password) + elsif pbkdf2_password? + Devise::Pbkdf2Encryptable::Encryptors::Pbkdf2Sha512.compare(encrypted_password, password) + end + end + + def migrate_password!(password) + return true if password_strategy == encryptor + + update_attribute(:password, password) + end + + def encryptor + return BCRYPT_STRATEGY unless Gitlab::FIPS.enabled? + + PBKDF2_SHA512_STRATEGY + end +end diff --git a/app/models/concerns/enums/sbom.rb b/app/models/concerns/enums/sbom.rb index 518efa669ad..8848c0c5555 100644 --- a/app/models/concerns/enums/sbom.rb +++ b/app/models/concerns/enums/sbom.rb @@ -6,8 +6,23 @@ module Enums library: 0 }.with_indifferent_access.freeze + PURL_TYPES = { + composer: 1, # refered to as `packagist` in gemnasium-db + conan: 2, + gem: 3, + golang: 4, # refered to as `go` in gemnasium-db + maven: 5, + npm: 6, + nuget: 7, + pypi: 8 + }.with_indifferent_access.freeze + def self.component_types COMPONENT_TYPES end + + def self.purl_types + PURL_TYPES + end end end diff --git a/app/models/concerns/file_store_mounter.rb b/app/models/concerns/file_store_mounter.rb index f1ac734635d..4d267dc69d0 100644 --- a/app/models/concerns/file_store_mounter.rb +++ b/app/models/concerns/file_store_mounter.rb @@ -1,31 +1,35 @@ # frozen_string_literal: true module FileStoreMounter + ALLOWED_FILE_FIELDS = %i[file signed_file].freeze + extend ActiveSupport::Concern class_methods do - # When `skip_store_file: true` is used, the model MUST explicitly call `store_file_now!` - def mount_file_store_uploader(uploader, skip_store_file: false) - mount_uploader(:file, uploader) + # When `skip_store_file: true` is used, the model MUST explicitly call `store_#{file_field}_now!` + def mount_file_store_uploader(uploader, skip_store_file: false, file_field: :file) + raise ArgumentError, "file_field not allowed: #{file_field}" unless ALLOWED_FILE_FIELDS.include?(file_field) + + mount_uploader(file_field, uploader) + + define_method("update_#{file_field}_store") do + # The file.object_store is set during `uploader.store!` and `uploader.migrate!` + update_column("#{file_field}_store", public_send(file_field).object_store) # rubocop:disable GitlabSecurity/PublicSend + end + + define_method("store_#{file_field}_now!") do + public_send("store_#{file_field}!") # rubocop:disable GitlabSecurity/PublicSend + public_send("update_#{file_field}_store") # rubocop:disable GitlabSecurity/PublicSend + end if skip_store_file - skip_callback :save, :after, :store_file! + skip_callback :save, :after, "store_#{file_field}!".to_sym return end # This hook is a no-op when the file is uploaded after_commit - after_save :update_file_store, if: :saved_change_to_file? + after_save "update_#{file_field}_store".to_sym, if: "saved_change_to_#{file_field}?".to_sym end end - - def update_file_store - # The file.object_store is set during `uploader.store!` and `uploader.migrate!` - update_column(:file_store, file.object_store) - end - - def store_file_now! - store_file! - update_file_store - end end diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb index f8389865f91..31b2a8d7cc1 100644 --- a/app/models/concerns/issuable.rb +++ b/app/models/concerns/issuable.rb @@ -217,6 +217,10 @@ module Issuable false end + def supports_confidentiality? + false + end + def severity return IssuableSeverity::DEFAULT unless supports_severity? @@ -236,7 +240,6 @@ module Issuable end def validate_assignee_size_length - return true unless Feature.enabled?(:limit_assignees_per_issuable) return true unless assignees.size > MAX_NUMBER_OF_ASSIGNEES_OR_REVIEWERS errors.add :assignees, @@ -460,18 +463,6 @@ module Issuable end end - def today? - Date.today == created_at.to_date - end - - def created_hours_ago - (Time.now.utc.to_i - created_at.utc.to_i) / 3600 - end - - def new? - created_hours_ago < 24 - end - def open? opened? end diff --git a/app/models/concerns/milestoneable.rb b/app/models/concerns/milestoneable.rb index 14c54d99ef3..a95bed7ad42 100644 --- a/app/models/concerns/milestoneable.rb +++ b/app/models/concerns/milestoneable.rb @@ -18,7 +18,7 @@ module Milestoneable scope :with_milestone, ->(title) { left_joins_milestones.where(milestones: { title: title }) } scope :without_particular_milestones, ->(titles) { left_outer_joins(:milestone).where("milestones.title NOT IN (?) OR milestone_id IS NULL", titles) } scope :any_release, -> { joins_milestone_releases } - scope :with_release, -> (tag, project_id) { joins_milestone_releases.where( milestones: { releases: { tag: tag, project_id: project_id } } ) } + scope :with_release, -> (tag, project_id) { joins_milestone_releases.where(milestones: { releases: { tag: tag, project_id: project_id } }) } scope :without_particular_release, -> (tag, project_id) { joins_milestone_releases.where.not(milestones: { releases: { tag: tag, project_id: project_id } }) } scope :left_joins_milestones, -> { joins("LEFT OUTER JOIN milestones ON #{table_name}.milestone_id = milestones.id") } diff --git a/app/models/concerns/mirror_authentication.rb b/app/models/concerns/mirror_authentication.rb index 14c8be93ce0..e3bfeaf7f95 100644 --- a/app/models/concerns/mirror_authentication.rb +++ b/app/models/concerns/mirror_authentication.rb @@ -11,7 +11,7 @@ module MirrorAuthentication # We should generate a key even if there's no SSH URL present before_validation :generate_ssh_private_key!, if: -> { - regenerate_ssh_private_key || ( auth_method == 'ssh_public_key' && ssh_private_key.blank? ) + regenerate_ssh_private_key || (auth_method == 'ssh_public_key' && ssh_private_key.blank?) } credentials_field :auth_method, reader: false diff --git a/app/models/concerns/noteable.rb b/app/models/concerns/noteable.rb index c1aac235d33..492d55c74e2 100644 --- a/app/models/concerns/noteable.rb +++ b/app/models/concerns/noteable.rb @@ -210,11 +210,23 @@ module Noteable # Synthetic system notes don't have discussion IDs because these are generated dynamically # in Ruby. These are always root notes anyway so we don't need to group by discussion ID. def synthetic_note_ids_relations - [ - resource_label_events.select("'resource_label_events'", "'NULL'", :id, :created_at), - resource_milestone_events.select("'resource_milestone_events'", "'NULL'", :id, :created_at), - resource_state_events.select("'resource_state_events'", "'NULL'", :id, :created_at) - ] + relations = [] + + # currently multiple models include Noteable concern, but not all of them support + # all resource events, so we check if given model supports given resource event. + if respond_to?(:resource_label_events) + relations << resource_label_events.select("'resource_label_events'", "'NULL'", :id, :created_at) + end + + if respond_to?(:resource_state_events) + relations << resource_state_events.select("'resource_state_events'", "'NULL'", :id, :created_at) + end + + if respond_to?(:resource_milestone_events) + relations << resource_milestone_events.select("'resource_milestone_events'", "'NULL'", :id, :created_at) + end + + relations end end diff --git a/app/models/concerns/packages/debian/distribution.rb b/app/models/concerns/packages/debian/distribution.rb index 1520ec0828e..75fd45d13a9 100644 --- a/app/models/concerns/packages/debian/distribution.rb +++ b/app/models/concerns/packages/debian/distribution.rb @@ -85,8 +85,7 @@ module Packages scope :with_codename_or_suite, ->(codename_or_suite) { with_codename(codename_or_suite).or(with_suite(codename_or_suite)) } mount_file_store_uploader Packages::Debian::DistributionReleaseFileUploader - mount_uploader :signed_file, Packages::Debian::DistributionReleaseFileUploader - after_save :update_signed_file_store, if: :saved_change_to_signed_file? + mount_file_store_uploader Packages::Debian::DistributionReleaseFileUploader, file_field: :signed_file def component_names components.pluck(:name).sort @@ -119,12 +118,6 @@ module Packages self.class.with_container(container).with_codename(suite).exists? end - - def update_signed_file_store - # The signed_file.object_store is set during `uploader.store!` - # which happens after object is inserted/updated - self.update_column(:signed_file_store, signed_file.object_store) - end end end end diff --git a/app/models/concerns/pg_full_text_searchable.rb b/app/models/concerns/pg_full_text_searchable.rb index 335fcec2611..562c8cf23f3 100644 --- a/app/models/concerns/pg_full_text_searchable.rb +++ b/app/models/concerns/pg_full_text_searchable.rb @@ -25,6 +25,7 @@ module PgFullTextSearchable TSVECTOR_MAX_LENGTH = 1.megabyte.freeze TEXT_SEARCH_DICTIONARY = 'english' URL_SCHEME_REGEX = %r{(?<=\A|\W)\w+://(?=\w+)}.freeze + TSQUERY_DISALLOWED_CHARACTERS_REGEX = %r{[^a-zA-Z0-9 .@/\-_"]}.freeze def update_search_data! tsvector_sql_nodes = self.class.pg_full_text_searchable_columns.map do |column, weight| @@ -102,21 +103,16 @@ module PgFullTextSearchable end end - def pg_full_text_search(search_term) + def pg_full_text_search(query, matched_columns: []) search_data_table = reflect_on_association(:search_data).klass.arel_table - # This fixes an inconsistency with how to_tsvector and websearch_to_tsquery process URLs - # See https://gitlab.com/gitlab-org/gitlab/-/issues/354784#note_905431920 - search_term = remove_url_scheme(search_term) - search_term = ActiveSupport::Inflector.transliterate(search_term) - joins(:search_data).where( Arel::Nodes::InfixOperation.new( '@@', search_data_table[:search_vector], Arel::Nodes::NamedFunction.new( - 'websearch_to_tsquery', - [Arel::Nodes.build_quoted(TEXT_SEARCH_DICTIONARY), Arel::Nodes.build_quoted(search_term)] + 'to_tsquery', + [Arel::Nodes.build_quoted(TEXT_SEARCH_DICTIONARY), build_tsquery(query, matched_columns)] ) ) ) @@ -124,8 +120,39 @@ module PgFullTextSearchable private - def remove_url_scheme(search_term) - search_term.gsub(URL_SCHEME_REGEX, '') + def build_tsquery(query, matched_columns) + # URLs get broken up into separate words when : is removed below, so we just remove the whole scheme. + query = remove_url_scheme(query) + # Remove accents from search term to match indexed data + query = ActiveSupport::Inflector.transliterate(query) + # Prevent users from using tsquery operators that can cause syntax errors. + query = filter_allowed_characters(query) + + weights = matched_columns.map do |column_name| + pg_full_text_searchable_columns[column_name] + end.compact.join + prefix_search_suffix = ":*#{weights}" + + tsquery = Gitlab::SQL::Pattern.split_query_to_search_terms(query).map do |search_term| + case search_term + when /\A\d+\z/ # Handles https://gitlab.com/gitlab-org/gitlab/-/issues/375337 + "(#{search_term + prefix_search_suffix} | -#{search_term + prefix_search_suffix})" + when /\s/ + search_term.split.map { |t| "#{t}:#{weights}" }.join(' <-> ') + else + search_term + prefix_search_suffix + end + end.join(' & ') + + Arel::Nodes.build_quoted(tsquery) + end + + def remove_url_scheme(query) + query.gsub(URL_SCHEME_REGEX, '') + end + + def filter_allowed_characters(query) + query.gsub(TSQUERY_DISALLOWED_CHARACTERS_REGEX, ' ') end end end diff --git a/app/models/concerns/project_features_compatibility.rb b/app/models/concerns/project_features_compatibility.rb index 2976b6f02a7..d37f20e2e7c 100644 --- a/app/models/concerns/project_features_compatibility.rb +++ b/app/models/concerns/project_features_compatibility.rb @@ -110,6 +110,10 @@ module ProjectFeaturesCompatibility write_feature_attribute_string(:releases_access_level, value) end + def infrastructure_access_level=(value) + write_feature_attribute_string(:infrastructure_access_level, value) + end + # TODO: Remove this method after we drop support for project create/edit APIs to set the # container_registry_enabled attribute. They can instead set the container_registry_access_level # attribute. diff --git a/app/models/concerns/protected_ref.rb b/app/models/concerns/protected_ref.rb index ec56f4a32af..7e1ebd1eba3 100644 --- a/app/models/concerns/protected_ref.rb +++ b/app/models/concerns/protected_ref.rb @@ -7,7 +7,6 @@ module ProtectedRef belongs_to :project, touch: true validates :name, presence: true - validates :project, presence: true delegate :matching, :matches?, :wildcard?, to: :ref_matcher diff --git a/app/models/concerns/protected_ref_access.rb b/app/models/concerns/protected_ref_access.rb index 618ad96905d..facf0808e7a 100644 --- a/app/models/concerns/protected_ref_access.rb +++ b/app/models/concerns/protected_ref_access.rb @@ -21,8 +21,8 @@ module ProtectedRefAccess included do scope :maintainer, -> { where(access_level: Gitlab::Access::MAINTAINER) } scope :developer, -> { where(access_level: Gitlab::Access::DEVELOPER) } - scope :by_user, -> (user) { where(user_id: user ) } - scope :by_group, -> (group) { where(group_id: group ) } + scope :by_user, -> (user) { where(user_id: user) } + scope :by_group, -> (group) { where(group_id: group) } scope :for_role, -> { where(user_id: nil, group_id: nil) } scope :for_user, -> { where.not(user_id: nil) } scope :for_group, -> { where.not(group_id: nil) } diff --git a/app/models/concerns/redis_cacheable.rb b/app/models/concerns/redis_cacheable.rb index 2d4ed51ce3b..f1d29ad5a90 100644 --- a/app/models/concerns/redis_cacheable.rb +++ b/app/models/concerns/redis_cacheable.rb @@ -26,8 +26,8 @@ module RedisCacheable end def cache_attributes(values) - Gitlab::Redis::Cache.with do |redis| - redis.set(cache_attribute_key, values.to_json, ex: CACHED_ATTRIBUTES_EXPIRY_TIME) + with_redis do |redis| + redis.set(cache_attribute_key, Gitlab::Json.dump(values), ex: CACHED_ATTRIBUTES_EXPIRY_TIME) end clear_memoization(:cached_attributes) @@ -41,13 +41,17 @@ module RedisCacheable def cached_attributes strong_memoize(:cached_attributes) do - Gitlab::Redis::Cache.with do |redis| + with_redis do |redis| data = redis.get(cache_attribute_key) Gitlab::Json.parse(data, symbolize_names: true) if data end end end + def with_redis(&block) + Gitlab::Redis::Cache.with(&block) # rubocop:disable CodeReuse/ActiveRecord + end + def cast_value_from_cache(attribute, value) self.class.type_for_attribute(attribute.to_s).cast(value) end diff --git a/app/models/concerns/repository_storage_movable.rb b/app/models/concerns/repository_storage_movable.rb index b7fd52ab305..87ff413f2c1 100644 --- a/app/models/concerns/repository_storage_movable.rb +++ b/app/models/concerns/repository_storage_movable.rb @@ -19,9 +19,7 @@ module RepositoryStorageMovable inclusion: { in: ->(_) { Gitlab.config.repositories.storages.keys } } validate :container_repository_writable, on: :create - default_value_for(:destination_storage_name, allows_nil: false) do - Repository.pick_storage_shard - end + attribute :destination_storage_name, default: -> { Repository.pick_storage_shard } state_machine initial: :initial do event :schedule do diff --git a/app/models/concerns/subquery.rb b/app/models/concerns/subquery.rb new file mode 100644 index 00000000000..ae92d2137c1 --- /dev/null +++ b/app/models/concerns/subquery.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +# Distinguish between a top level query and a subselect. +# +# Retrieve column values when the relation has already been loaded, otherwise reselect the relation. +# Useful for preload query patterns where the typical Rails #preload does not fit. Such as: +# +# projects = Project.where(...) +# projects.load +# ... +# options[members] = ProjectMember.where(...).where(source_id: projects.select(:id)) +module Subquery + extend ActiveSupport::Concern + + class_methods do + def subquery(*column_names, max_limit: 5_000) + if current_scope.loaded? && current_scope.size <= max_limit + current_scope.pluck(*column_names) + else + current_scope.reselect(*column_names) + end + end + end +end diff --git a/app/models/concerns/ttl_expirable.rb b/app/models/concerns/ttl_expirable.rb index 1c2147beedd..d09ce4873b1 100644 --- a/app/models/concerns/ttl_expirable.rb +++ b/app/models/concerns/ttl_expirable.rb @@ -4,8 +4,8 @@ module TtlExpirable extend ActiveSupport::Concern included do + attribute :read_at, default: -> { Time.zone.now } validates :status, presence: true - default_value_for :read_at, Time.zone.now enum status: { default: 0, pending_destruction: 1, processing: 2, error: 3 } |