diff --git a/app/finders/ci/runners_finder.rb b/app/finders/ci/runners_finder.rb index 5d597f94f72c044c304ce68b29b9c4b14f611ac5..e23150f025f94131e50ca94d79fcb3c8708e3561 100644 --- a/app/finders/ci/runners_finder.rb +++ b/app/finders/ci/runners_finder.rb @@ -4,7 +4,7 @@ module Ci class RunnersFinder < UnionFinder include Gitlab::Allowable - ALLOWED_SORTS = %w[contacted_asc contacted_desc created_at_asc created_at_desc created_date].freeze + ALLOWED_SORTS = %w[contacted_asc contacted_desc created_at_asc created_at_desc created_date token_expires_at_asc token_expires_at_desc].freeze DEFAULT_SORT = 'created_at_desc' def initialize(current_user:, params:) diff --git a/app/graphql/types/ci/runner_sort_enum.rb b/app/graphql/types/ci/runner_sort_enum.rb index 95ec1867fea8c883ed24cf706a597d3a9709b955..8f2a13bd69995a347b5fed4fc0ccf3161c572b97 100644 --- a/app/graphql/types/ci/runner_sort_enum.rb +++ b/app/graphql/types/ci/runner_sort_enum.rb @@ -10,6 +10,8 @@ class RunnerSortEnum < BaseEnum value 'CONTACTED_DESC', 'Ordered by contacted_at in descending order.', value: :contacted_desc value 'CREATED_ASC', 'Ordered by created_at in ascending order.', value: :created_at_asc value 'CREATED_DESC', 'Ordered by created_at in descending order.', value: :created_at_desc + value 'TOKEN_EXPIRES_AT_ASC', 'Ordered by token_expires_at in ascending order.', value: :token_expires_at_asc + value 'TOKEN_EXPIRES_AT_DESC', 'Ordered by token_expires_at in descending order.', value: :token_expires_at_desc end end end diff --git a/app/graphql/types/ci/runner_type.rb b/app/graphql/types/ci/runner_type.rb index d37cca0927f0a82545aa8f93e910d545efa05a1e..0aafe1c6ca692ab9a471b9df2977d12f33c4fa5b 100644 --- a/app/graphql/types/ci/runner_type.rb +++ b/app/graphql/types/ci/runner_type.rb @@ -21,6 +21,9 @@ class RunnerType < BaseObject field :contacted_at, Types::TimeType, null: true, description: 'Last contact from the runner.', method: :contacted_at + field :token_expires_at, Types::TimeType, null: true, + description: 'Runner token expiration time.', + method: :token_expires_at field :maximum_timeout, GraphQL::Types::Int, null: true, description: 'Maximum timeout (in seconds) for jobs processed by the runner.' field :access_level, ::Types::Ci::RunnerAccessLevelEnum, null: false, diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index b8ee71daeee6e64d5c3e4b098aec5a5709904fc3..9720634942cb43a72718a06310bb387c0fbcffae 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -411,7 +411,10 @@ def visible_attributes :sidekiq_job_limiter_mode, :sidekiq_job_limiter_compression_threshold_bytes, :sidekiq_job_limiter_limit_bytes, - :suggest_pipeline_enabled + :suggest_pipeline_enabled, + :runner_token_expiration_interval, + :group_runner_token_expiration_interval, + :project_runner_token_expiration_interval ].tap do |settings| settings << :deactivate_dormant_users unless Gitlab.com? end diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 65472615f42e4f18f97070aaa037e0039604195a..a957e63d93b5fb6eacd35a4166891a0101c5c13d 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -77,6 +77,10 @@ def self.kroki_formats_attributes chronic_duration_attr_writer :archive_builds_in_human_readable, :archive_builds_in_seconds + chronic_duration_attr :runner_token_expiration_interval_human_readable, :runner_token_expiration_interval + chronic_duration_attr :group_runner_token_expiration_interval_human_readable, :group_runner_token_expiration_interval + chronic_duration_attr :project_runner_token_expiration_interval_human_readable, :project_runner_token_expiration_interval + validates :grafana_url, system_hook_url: { blocked_message: "is blocked: %{exception_message}. " + GRAFANA_URL_ERROR_MESSAGE diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index a80fd02080fa5a1684ac38d3dc8a71ca197138a6..2e4103b33b14028c1801fa00c961cea90453eafb 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -13,7 +13,7 @@ class Runner < Ci::ApplicationRecord include TaggableQueries include Presentable - add_authentication_token_field :token, encrypted: :optional + add_authentication_token_field :token, encrypted: :optional, expires_at: :generate_token_expires_at enum access_level: { not_protected: 0, @@ -152,6 +152,8 @@ class Runner < Ci::ApplicationRecord scope :order_contacted_at_desc, -> { order(contacted_at: :desc) } scope :order_created_at_asc, -> { order(created_at: :asc) } scope :order_created_at_desc, -> { order(created_at: :desc) } + scope :order_token_expires_at_asc, -> { order(token_expires_at: :asc) } + scope :order_token_expires_at_desc, -> { order(token_expires_at: :desc) } scope :with_tags, -> { preload(:tags) } validate :tag_constraints @@ -218,6 +220,10 @@ def self.order_by(order) order_contacted_at_desc when 'created_at_asc' order_created_at_asc + when 'token_expires_at_asc' + order_token_expires_at_asc + when 'token_expires_at_desc' + order_token_expires_at_desc else order_created_at_desc end @@ -438,6 +444,17 @@ def namespace_ids end end + def generate_token_expires_at + case runner_type + when 'instance_type' + generate_token_expires_at_instance + when 'group_type' + generate_token_expires_at_group + when 'project_type' + generate_token_expires_at_project + end + end + private EXECUTOR_NAME_TO_TYPES = { @@ -454,6 +471,18 @@ def namespace_ids 'kubernetes' => :kubernetes }.freeze + def generate_token_expires_at_instance + Gitlab::CurrentSettings.runner_token_expiration_interval&.seconds&.from_now + end + + def generate_token_expires_at_group + ::Group.where(id: runner_namespaces.map(&:namespace_id)).map(&:effective_runner_token_expiration_interval).compact.min&.from_now + end + + def generate_token_expires_at_project + Project.where(id: runner_projects.map(&:project_id)).map(&:effective_runner_token_expiration_interval).compact.min&.from_now + end + def cleanup_runner_queue Gitlab::Redis::SharedState.with do |redis| redis.del(runner_queue_key) diff --git a/app/models/concerns/runner_token_expiration_interval.rb b/app/models/concerns/runner_token_expiration_interval.rb new file mode 100644 index 0000000000000000000000000000000000000000..483cc0342ad3daca24e082ff02e91fd1103a1604 --- /dev/null +++ b/app/models/concerns/runner_token_expiration_interval.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module RunnerTokenExpirationInterval + extend ActiveSupport::Concern + + class_methods do + private + + def enforced_runner_token_expiration_interval(field_name = :runner_token_expiration_interval, &enforced_interval_block) + define_method("enforced_runner_token_expiration_interval", &enforced_interval_block) + + define_method("enforced_runner_token_expiration_interval_human_readable") do + interval = enforced_runner_token_expiration_interval + ChronicDuration.output(interval, format: :short) if interval + end + + define_method("effective_runner_token_expiration_interval") do + [ + enforced_runner_token_expiration_interval, + send(field_name)&.seconds # rubocop: disable GitlabSecurity/PublicSend + ].compact.min + end + + define_method("effective_runner_token_expiration_interval_human_readable") do + interval = effective_runner_token_expiration_interval + ChronicDuration.output(interval, format: :short) if interval + end + end + end +end diff --git a/app/models/concerns/token_authenticatable.rb b/app/models/concerns/token_authenticatable.rb index 34c8630bb90fd4604a424e3c5e0919a103ccb42d..f44ad8ebe90c2f5822f61c897f79a8251f7be266 100644 --- a/app/models/concerns/token_authenticatable.rb +++ b/app/models/concerns/token_authenticatable.rb @@ -64,6 +64,18 @@ def add_authentication_token_field(token_field, options = {}) mod.define_method("format_#{token_field}") do |token| token end + + mod.define_method("#{token_field}_expires_at") do + strategy.expires_at(self) + end + + mod.define_method("#{token_field}_expired?") do + strategy.expired?(self) + end + + mod.define_method("#{token_field}_with_expiration") do + strategy.token_with_expiration(self) + end end def token_authenticatable_module diff --git a/app/models/concerns/token_authenticatable_strategies/base.rb b/app/models/concerns/token_authenticatable_strategies/base.rb index f72a41f06b1179317b16f35e5a1d07831e49bef0..cfaca81170266fc1bdb54122bb8f0bcdb8977d70 100644 --- a/app/models/concerns/token_authenticatable_strategies/base.rb +++ b/app/models/concerns/token_authenticatable_strategies/base.rb @@ -44,6 +44,23 @@ def reset_token!(instance) instance.save! if Gitlab::Database.read_write? end + def expires_at(instance) + instance.read_attribute("#{@token_field}_expires_at") + end + + def expired?(instance) + exp = expires_at(instance) + !exp.nil? && Time.zone.now > exp + end + + def expirable? + !!@options[:expires_at] + end + + def token_with_expiration(instance) + TokenWithExpiration.new(self, instance) + end + def self.fabricate(model, field, options) if options[:digest] && options[:encrypted] raise ArgumentError, _('Incompatible options set!') @@ -64,6 +81,10 @@ def write_new_token(instance) new_token = generate_available_token formatted_token = format_token(instance, new_token) set_token(instance, formatted_token) + + if @options[:expires_at] + instance["#{@token_field}_expires_at"] = @options[:expires_at].to_proc.call(instance) + end end def unique @@ -82,11 +103,15 @@ def generate_token end def relation(unscoped) - unscoped ? @klass.unscoped : @klass + unscoped ? @klass.unscoped : @klass.where(not_expired) end def token_set?(instance) raise NotImplementedError end + + def not_expired + "#{@token_field}_expires_at IS NULL OR #{@token_field}_expires_at >= NOW()" if @options[:expires_at] + end end end diff --git a/app/models/group.rb b/app/models/group.rb index f51782785f90d94edf206ba2c65af85d9f702794..fdb04c5c40d54784e6cd3914d6feac7fe22bb583 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -17,6 +17,8 @@ class Group < Namespace include GroupAPICompatibility include EachBatch include BulkMemberAccessLoad + include ChronicDurationAttribute + include RunnerTokenExpirationInterval def self.sti_name 'Group' @@ -91,6 +93,9 @@ def self.sti_name has_many :group_callouts, class_name: 'Users::GroupCallout', foreign_key: :group_id delegate :prevent_sharing_groups_outside_hierarchy, :new_user_signups_cap, :setup_for_company, :jobs_to_be_done, to: :namespace_settings + delegate :runner_token_expiration_interval, :runner_token_expiration_interval=, :runner_token_expiration_interval_human_readable, :runner_token_expiration_interval_human_readable=, to: :namespace_settings, allow_nil: true + delegate :subgroup_runner_token_expiration_interval, :subgroup_runner_token_expiration_interval=, :subgroup_runner_token_expiration_interval_human_readable, :subgroup_runner_token_expiration_interval_human_readable=, to: :namespace_settings, allow_nil: true + delegate :project_runner_token_expiration_interval, :project_runner_token_expiration_interval=, :project_runner_token_expiration_interval_human_readable, :project_runner_token_expiration_interval_human_readable=, to: :namespace_settings, allow_nil: true accepts_nested_attributes_for :variables, allow_destroy: true @@ -764,6 +769,17 @@ def dependency_proxy_image_ttl_policy super || build_dependency_proxy_image_ttl_policy end + enforced_runner_token_expiration_interval do + all_parent_groups = Gitlab::ObjectHierarchy.new(Group.where(id: id)).ancestors + all_group_settings = NamespaceSetting.where(namespace_id: all_parent_groups) + group_interval = all_group_settings.where.not(subgroup_runner_token_expiration_interval: nil).minimum(:subgroup_runner_token_expiration_interval)&.seconds + + [ + Gitlab::CurrentSettings.group_runner_token_expiration_interval&.seconds, + group_interval + ].compact.min + end + private def max_member_access(user_ids) diff --git a/app/models/namespace_setting.rb b/app/models/namespace_setting.rb index 170b29e9e21bdfa3b3b98633e10d6c6fb15f50e2..59fe9d30971ff8dc3f61622da3dbef46db961092 100644 --- a/app/models/namespace_setting.rb +++ b/app/models/namespace_setting.rb @@ -3,6 +3,7 @@ class NamespaceSetting < ApplicationRecord include CascadingNamespaceSettingAttribute include Sanitizable + include ChronicDurationAttribute cascading_attr :delayed_project_removal @@ -19,10 +20,15 @@ class NamespaceSetting < ApplicationRecord enum jobs_to_be_done: { basics: 0, move_repository: 1, code_storage: 2, exploring: 3, ci: 4, other: 5 }, _suffix: true + chronic_duration_attr :runner_token_expiration_interval_human_readable, :runner_token_expiration_interval + chronic_duration_attr :subgroup_runner_token_expiration_interval_human_readable, :subgroup_runner_token_expiration_interval + chronic_duration_attr :project_runner_token_expiration_interval_human_readable, :project_runner_token_expiration_interval + NAMESPACE_SETTINGS_PARAMS = [:default_branch_name, :delayed_project_removal, :lock_delayed_project_removal, :resource_access_token_creation_allowed, :prevent_sharing_groups_outside_hierarchy, :new_user_signups_cap, - :setup_for_company, :jobs_to_be_done].freeze + :setup_for_company, :jobs_to_be_done, :runner_token_expiration_interval, + :subgroup_runner_token_expiration_interval, :project_runner_token_expiration_interval].freeze self.primary_key = :namespace_id diff --git a/app/models/project.rb b/app/models/project.rb index a751e8adeb05181ea7f64017196eb4c8b9e8467c..ce20dd187fdbd7b661c5bf62884b099c95bfb3d1 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -37,6 +37,7 @@ class Project < ApplicationRecord include EachBatch include GitlabRoutingHelper include BulkMemberAccessLoad + include RunnerTokenExpirationInterval extend Gitlab::Cache::RequestCache extend Gitlab::Utils::Override @@ -452,6 +453,7 @@ def self.integration_association_name(name) delegate :job_token_scope_enabled, :job_token_scope_enabled=, to: :ci_cd_settings, prefix: :ci, allow_nil: true delegate :keep_latest_artifact, :keep_latest_artifact=, to: :ci_cd_settings, allow_nil: true delegate :restrict_user_defined_variables, :restrict_user_defined_variables=, to: :ci_cd_settings, allow_nil: true + delegate :runner_token_expiration_interval, :runner_token_expiration_interval=, :runner_token_expiration_interval_human_readable, :runner_token_expiration_interval_human_readable=, to: :ci_cd_settings, allow_nil: true delegate :actual_limits, :actual_plan_name, to: :namespace, allow_nil: true delegate :allow_merge_on_skipped_pipeline, :allow_merge_on_skipped_pipeline?, :allow_merge_on_skipped_pipeline=, :has_confluence?, :has_shimo?, @@ -2702,6 +2704,10 @@ def keep_latest_artifact? ci_cd_settings.keep_latest_artifact? end + def runner_token_expiration_interval + ci_cd_settings&.runner_token_expiration_interval + end + def group_runners_enabled? return false unless ci_cd_settings @@ -2733,6 +2739,17 @@ def remove_project_authorizations(user_ids, per_batch = 1000) end end + enforced_runner_token_expiration_interval do + all_parent_groups = Gitlab::ObjectHierarchy.new(Group.where(id: group)).base_and_ancestors + all_group_settings = NamespaceSetting.where(namespace_id: all_parent_groups) + group_interval = all_group_settings.where.not(project_runner_token_expiration_interval: nil).minimum(:project_runner_token_expiration_interval)&.seconds + + [ + Gitlab::CurrentSettings.project_runner_token_expiration_interval&.seconds, + group_interval + ].compact.min + end + private # overridden in EE diff --git a/app/models/project_ci_cd_setting.rb b/app/models/project_ci_cd_setting.rb index c0c2ea42d4686c2b176e2fb5a8ed56b67625ad33..2367afae1ba80e823ed855f4e388f2231cf82c4c 100644 --- a/app/models/project_ci_cd_setting.rb +++ b/app/models/project_ci_cd_setting.rb @@ -1,6 +1,8 @@ # frozen_string_literal: true class ProjectCiCdSetting < ApplicationRecord + include ChronicDurationAttribute + belongs_to :project, inverse_of: :ci_cd_settings DEFAULT_GIT_DEPTH = 50 @@ -17,6 +19,8 @@ class ProjectCiCdSetting < ApplicationRecord default_value_for :forward_deployment_enabled, true + chronic_duration_attr :runner_token_expiration_interval_human_readable, :runner_token_expiration_interval + def forward_deployment_enabled? super && ::Feature.enabled?(:forward_deployment_enabled, project, default_enabled: true) end diff --git a/app/models/token_with_expiration.rb b/app/models/token_with_expiration.rb new file mode 100644 index 0000000000000000000000000000000000000000..62b386cf6aef5d4883f1676b68ac1287e094d6b6 --- /dev/null +++ b/app/models/token_with_expiration.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +# rubocop: todo Gitlab/NamespacedClass +class TokenWithExpiration + def initialize(strategy, instance) + @strategy = strategy + @instance = instance + end + + def token + @strategy.get_token(@instance) + end + + def token_expires_at + @strategy.expires_at(@instance) + end + + def expirable? + @strategy.expirable? + end +end diff --git a/db/migrate/20211214155437_add_runner_token_expiration_interval_settings_to_application_settings.rb b/db/migrate/20211214155437_add_runner_token_expiration_interval_settings_to_application_settings.rb new file mode 100644 index 0000000000000000000000000000000000000000..32ca8a5fb12cfa4087605ffaa367e100b0ff1bae --- /dev/null +++ b/db/migrate/20211214155437_add_runner_token_expiration_interval_settings_to_application_settings.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddRunnerTokenExpirationIntervalSettingsToApplicationSettings < Gitlab::Database::Migration[1.0] + def change + [:runner_token_expiration_interval, :group_runner_token_expiration_interval, :project_runner_token_expiration_interval].each do |field| + add_column :application_settings, field, :integer + end + end +end diff --git a/db/migrate/20211214155438_add_runner_token_expiration_interval_settings_to_namespace_settings.rb b/db/migrate/20211214155438_add_runner_token_expiration_interval_settings_to_namespace_settings.rb new file mode 100644 index 0000000000000000000000000000000000000000..7b83cb2dd554f395936dcf8cbe5664a1651da3d8 --- /dev/null +++ b/db/migrate/20211214155438_add_runner_token_expiration_interval_settings_to_namespace_settings.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +class AddRunnerTokenExpirationIntervalSettingsToNamespaceSettings < Gitlab::Database::Migration[1.0] + enable_lock_retries! + + def change + [:runner_token_expiration_interval, :subgroup_runner_token_expiration_interval, :project_runner_token_expiration_interval].each do |field| + add_column :namespace_settings, field, :integer + end + end +end diff --git a/db/migrate/20211214155439_add_runner_token_expiration_interval_settings_to_project_settings.rb b/db/migrate/20211214155439_add_runner_token_expiration_interval_settings_to_project_settings.rb new file mode 100644 index 0000000000000000000000000000000000000000..ef9591718283c28832ecffb3843d948a199a403d --- /dev/null +++ b/db/migrate/20211214155439_add_runner_token_expiration_interval_settings_to_project_settings.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +class AddRunnerTokenExpirationIntervalSettingsToProjectSettings < Gitlab::Database::Migration[1.0] + enable_lock_retries! + + def change + add_column :project_ci_cd_settings, :runner_token_expiration_interval, :integer + end +end diff --git a/db/migrate/20211214155440_add_token_expires_at_to_ci_runners.rb b/db/migrate/20211214155440_add_token_expires_at_to_ci_runners.rb new file mode 100644 index 0000000000000000000000000000000000000000..b4d7c63d24b04fbd47f704ea49a7a2d6e02ce69b --- /dev/null +++ b/db/migrate/20211214155440_add_token_expires_at_to_ci_runners.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddTokenExpiresAtToCiRunners < Gitlab::Database::Migration[1.0] + def change + add_column :ci_runners, :token_expires_at, :datetime_with_timezone + end +end diff --git a/db/migrate/20211214155441_add_index_to_ci_runners_token_expires_at.rb b/db/migrate/20211214155441_add_index_to_ci_runners_token_expires_at.rb new file mode 100644 index 0000000000000000000000000000000000000000..fdd0ad078bafa3bd4af89e92b9eb02429043f0a1 --- /dev/null +++ b/db/migrate/20211214155441_add_index_to_ci_runners_token_expires_at.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class AddIndexToCiRunnersTokenExpiresAt < Gitlab::Database::Migration[1.0] + disable_ddl_transaction! + + DOWNTIME = false + + def index_name(order) + "index_ci_runners_on_token_expires_at#{order == :desc ? "_desc" : ""}_and_id_desc" + end + + def up + [:asc, :desc].each do |order| + add_concurrent_index :ci_runners, [:token_expires_at, :id], order: { token_expires_at: order, id: :desc }, name: index_name(order) + end + end + + def down + [:desc, :asc].each do |order| + remove_concurrent_index_by_name :ci_runners, index_name(order) + end + end +end diff --git a/db/schema_migrations/20211214155437 b/db/schema_migrations/20211214155437 new file mode 100644 index 0000000000000000000000000000000000000000..8fdbd2ebfb83316065c992188aed97e5f6552b8e --- /dev/null +++ b/db/schema_migrations/20211214155437 @@ -0,0 +1 @@ +147554bbf1a88edd3e8f5c1f0d9e0d3a8a0b64d9e151278abdd30a5a15f5222b \ No newline at end of file diff --git a/db/schema_migrations/20211214155438 b/db/schema_migrations/20211214155438 new file mode 100644 index 0000000000000000000000000000000000000000..c6a25c29768bc3ea93e4183485b60e2611fc9eca --- /dev/null +++ b/db/schema_migrations/20211214155438 @@ -0,0 +1 @@ +ea381b61677a85eaad99827a57d6ca5828a197ec08b092885bcfff8dca967b5a \ No newline at end of file diff --git a/db/schema_migrations/20211214155439 b/db/schema_migrations/20211214155439 new file mode 100644 index 0000000000000000000000000000000000000000..407ab5caa9a95924826c92facd33989c5f8e57c3 --- /dev/null +++ b/db/schema_migrations/20211214155439 @@ -0,0 +1 @@ +cd04a12377d5105a5d0d70c010551853c8c16d67c8d2fa13c38e45e5bacc0366 \ No newline at end of file diff --git a/db/schema_migrations/20211214155440 b/db/schema_migrations/20211214155440 new file mode 100644 index 0000000000000000000000000000000000000000..72d7051fd25534772ed0e5f0fdf91b3940b52a90 --- /dev/null +++ b/db/schema_migrations/20211214155440 @@ -0,0 +1 @@ +35c96a8b7eb89761a1302e513782b0e6fd6c5662ca7d503e29d22d7c036fe7d7 \ No newline at end of file diff --git a/db/schema_migrations/20211214155441 b/db/schema_migrations/20211214155441 new file mode 100644 index 0000000000000000000000000000000000000000..45e9b36353a78699a86e586a797142b15c4686cd --- /dev/null +++ b/db/schema_migrations/20211214155441 @@ -0,0 +1 @@ +27204876498b0e93195efe501d141a3351e9ad57bcd45a23ad18f0c395b704d4 \ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index bde98b6932fb06966ef29b485eb3684345ca9cb2..4606fd003430ebcb4b15178dcaf68a16cd05c7a5 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -10481,6 +10481,9 @@ CREATE TABLE application_settings ( max_ssh_key_lifetime integer, static_objects_external_storage_auth_token_encrypted text, future_subscriptions jsonb DEFAULT '[]'::jsonb NOT NULL, + runner_token_expiration_interval integer, + group_runner_token_expiration_interval integer, + project_runner_token_expiration_interval integer, CONSTRAINT app_settings_container_reg_cleanup_tags_max_list_size_positive CHECK ((container_registry_cleanup_tags_service_max_list_size >= 0)), CONSTRAINT app_settings_dep_proxy_ttl_policies_worker_capacity_positive CHECK ((dependency_proxy_ttl_group_policy_worker_capacity >= 0)), CONSTRAINT app_settings_ext_pipeline_validation_service_url_text_limit CHECK ((char_length(external_pipeline_validation_service_url) <= 255)), @@ -12176,7 +12179,8 @@ CREATE TABLE ci_runners ( public_projects_minutes_cost_factor double precision DEFAULT 0.0 NOT NULL, private_projects_minutes_cost_factor double precision DEFAULT 1.0 NOT NULL, config jsonb DEFAULT '{}'::jsonb NOT NULL, - executor_type smallint + executor_type smallint, + token_expires_at timestamp with time zone ); CREATE SEQUENCE ci_runners_id_seq @@ -16442,6 +16446,9 @@ CREATE TABLE namespace_settings ( new_user_signups_cap integer, setup_for_company boolean, jobs_to_be_done smallint, + runner_token_expiration_interval integer, + subgroup_runner_token_expiration_interval integer, + project_runner_token_expiration_interval integer, CONSTRAINT check_0ba93c78c7 CHECK ((char_length(default_branch_name) <= 255)) ); @@ -18098,7 +18105,8 @@ CREATE TABLE project_ci_cd_settings ( auto_rollback_enabled boolean DEFAULT false NOT NULL, keep_latest_artifact boolean DEFAULT true NOT NULL, restrict_user_defined_variables boolean DEFAULT false NOT NULL, - job_token_scope_enabled boolean DEFAULT false NOT NULL + job_token_scope_enabled boolean DEFAULT false NOT NULL, + runner_token_expiration_interval integer ); CREATE SEQUENCE project_ci_cd_settings_id_seq @@ -25728,6 +25736,10 @@ CREATE INDEX index_ci_runners_on_token ON ci_runners USING btree (token); CREATE INDEX index_ci_runners_on_token_encrypted ON ci_runners USING btree (token_encrypted); +CREATE INDEX index_ci_runners_on_token_expires_at_and_id_desc ON ci_runners USING btree (token_expires_at, id DESC); + +CREATE INDEX index_ci_runners_on_token_expires_at_desc_and_id_desc ON ci_runners USING btree (token_expires_at DESC, id DESC); + CREATE UNIQUE INDEX index_ci_running_builds_on_build_id ON ci_running_builds USING btree (build_id); CREATE INDEX index_ci_running_builds_on_project_id ON ci_running_builds USING btree (project_id); diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 5269b3b65d17079d7d59d84a224323b183f32469..011d1f252f1065cfff24ab1ad9bf3d5d7b544f23 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -8801,6 +8801,7 @@ Represents the total number of issues and their weights for a particular day. | `runnerType` | [`CiRunnerType!`](#cirunnertype) | Type of the runner. | | `shortSha` | [`String`](#string) | First eight characters of the runner's token used to authenticate new job requests. Used as the runner's unique ID. | | `tagList` | [`[String!]`](#string) | Tags associated with the runner. | +| `tokenExpiresAt` | [`Time`](#time) | Runner token expiration time. | | `userPermissions` | [`RunnerPermissions!`](#runnerpermissions) | Permissions for the current user on the resource. | | `version` | [`String`](#string) | Version of the runner. | @@ -16047,6 +16048,8 @@ Values for sorting runners. | `CONTACTED_DESC` | Ordered by contacted_at in descending order. | | `CREATED_ASC` | Ordered by created_at in ascending order. | | `CREATED_DESC` | Ordered by created_at in descending order. | +| `TOKEN_EXPIRES_AT_ASC` | Ordered by token_expires_at in ascending order. | +| `TOKEN_EXPIRES_AT_DESC` | Ordered by token_expires_at in descending order. | ### `CiRunnerStatus` diff --git a/doc/api/runners.md b/doc/api/runners.md index 5e84080ecb5d4d4eab00d1562e9f14dfe809a233..5eff6f210750f73b2605089ae3428dc7a4716860 100644 --- a/doc/api/runners.md +++ b/doc/api/runners.md @@ -602,7 +602,8 @@ Example response: ```json { "id": 12345, - "token": "6337ff461c94fd3fa32ba3b1ff4125" + "token": "6337ff461c94fd3fa32ba3b1ff4125", + "token_expires_at": "2021-09-27T21:05:03.203Z" } ``` @@ -742,6 +743,7 @@ Example response: ```json { - "token": "6337ff461c94fd3fa32ba3b1ff4125" + "token": "6337ff461c94fd3fa32ba3b1ff4125", + "token_expires_at": "2021-09-27T21:05:03.203Z" } ``` diff --git a/lib/api/ci/runners.rb b/lib/api/ci/runners.rb index ef712c84804ec2171c94445e1217a733c4ffb4f0..ca2c57f6eda05ca622cfb5616f1875bc5dac757c 100644 --- a/lib/api/ci/runners.rb +++ b/lib/api/ci/runners.rb @@ -142,7 +142,7 @@ class Runners < ::API::Base authenticate_update_runner!(runner) runner.reset_token! - present runner.token, with: Entities::Ci::ResetTokenResult + present runner.token_with_expiration, with: Entities::Ci::ResetTokenResult end end @@ -246,7 +246,7 @@ class Runners < ::API::Base authorize! :update_runners_registration_token ApplicationSetting.current.reset_runners_registration_token! - present ApplicationSetting.current_without_cache.runners_registration_token, with: Entities::Ci::ResetTokenResult + present ApplicationSetting.current_without_cache.runners_registration_token_with_expiration, with: Entities::Ci::ResetTokenResult end end @@ -264,7 +264,7 @@ class Runners < ::API::Base authorize! :update_runners_registration_token, project project.reset_runners_token! - present project.runners_token, with: Entities::Ci::ResetTokenResult + present project.runners_token_with_expiration, with: Entities::Ci::ResetTokenResult end end @@ -282,7 +282,7 @@ class Runners < ::API::Base authorize! :update_runners_registration_token, group group.reset_runners_token! - present group.runners_token, with: Entities::Ci::ResetTokenResult + present group.runners_token_with_expiration, with: Entities::Ci::ResetTokenResult end end diff --git a/lib/api/entities/ci/reset_token_result.rb b/lib/api/entities/ci/reset_token_result.rb index 4dbf831582bd6236f0c6d47af0b93fd0d607794a..f0b1de6a5a75e532a27ada550e37b8657683cdd5 100644 --- a/lib/api/entities/ci/reset_token_result.rb +++ b/lib/api/entities/ci/reset_token_result.rb @@ -4,7 +4,8 @@ module API module Entities module Ci class ResetTokenResult < Grape::Entity - expose(:token) {|object| object} + expose(:token) + expose(:token_expires_at, if: -> (object, options) { object.expirable? }) end end end diff --git a/lib/api/entities/ci/runner_registration_details.rb b/lib/api/entities/ci/runner_registration_details.rb index fa7e44c9e402a6416ba982ba8457d757e3a9c0bc..53be918406f2ccbc9ce093021a7a1815f2581b2a 100644 --- a/lib/api/entities/ci/runner_registration_details.rb +++ b/lib/api/entities/ci/runner_registration_details.rb @@ -4,7 +4,7 @@ module API module Entities module Ci class RunnerRegistrationDetails < Grape::Entity - expose :id, :token + expose :id, :token, :token_expires_at end end end diff --git a/lib/api/entities/project.rb b/lib/api/entities/project.rb index 1b9299ed17e9f8a6ce29826c803dee92ec4104bd..f1adafff1c4c956df441fb883542d7a3396f5db4 100644 --- a/lib/api/entities/project.rb +++ b/lib/api/entities/project.rb @@ -130,6 +130,7 @@ class Project < BasicProjectDetails Ability.allowed?(options[:current_user], :change_repository_storage, project) } expose :keep_latest_artifacts_available?, as: :keep_latest_artifact + expose :runner_token_expiration_interval # rubocop: disable CodeReuse/ActiveRecord def self.preload_resource(project) diff --git a/lib/api/settings.rb b/lib/api/settings.rb index 508ccdb4b3390e44f331ebd6bf395483c06c0d02..03c81fbcab9eae9c51bbeabb7eec00cad40ac377 100644 --- a/lib/api/settings.rb +++ b/lib/api/settings.rb @@ -177,6 +177,9 @@ def filter_attributes_using_license(attrs) optional :floc_enabled, type: Grape::API::Boolean, desc: 'Enable FloC (Federated Learning of Cohorts)' optional :user_deactivation_emails_enabled, type: Boolean, desc: 'Send emails to users upon account deactivation' optional :suggest_pipeline_enabled, type: Boolean, desc: 'Enable pipeline suggestion banner' + optional :runner_token_expiration_interval, type: Integer, desc: 'Token expiration interval for shared runners, in seconds' + optional :group_runner_token_expiration_interval, type: Integer, desc: 'Token expiration interval for group runners, in seconds' + optional :project_runner_token_expiration_interval, type: Integer, desc: 'Token expiration interval for project runners, in seconds' ApplicationSetting::SUPPORTED_KEY_TYPES.each do |type| optional :"#{type}_key_restriction", diff --git a/spec/factories/projects.rb b/spec/factories/projects.rb index 981f10e8260ec32b0af11da36d98598de3804dbc..2c644c5ec70974190220c9a79005d9755114571f 100644 --- a/spec/factories/projects.rb +++ b/spec/factories/projects.rb @@ -49,6 +49,8 @@ forward_deployment_enabled { nil } restrict_user_defined_variables { nil } ci_job_token_scope_enabled { nil } + runner_token_expiration_interval { nil } + runner_token_expiration_interval_human_readable { nil } end after(:build) do |project, evaluator| @@ -92,6 +94,8 @@ project.keep_latest_artifact = evaluator.keep_latest_artifact unless evaluator.keep_latest_artifact.nil? project.restrict_user_defined_variables = evaluator.restrict_user_defined_variables unless evaluator.restrict_user_defined_variables.nil? project.ci_job_token_scope_enabled = evaluator.ci_job_token_scope_enabled unless evaluator.ci_job_token_scope_enabled.nil? + project.runner_token_expiration_interval = evaluator.runner_token_expiration_interval unless evaluator.runner_token_expiration_interval.nil? + project.runner_token_expiration_interval_human_readable = evaluator.runner_token_expiration_interval_human_readable unless evaluator.runner_token_expiration_interval_human_readable.nil? if evaluator.import_status import_state = project.import_state || project.build_import_state diff --git a/spec/lib/gitlab/import_export/safe_model_attributes.yml b/spec/lib/gitlab/import_export/safe_model_attributes.yml index 0fa3756ff3af0983a34c1d766a31464a8d6e9d6c..2372d5ee945d8a16d0283e8f51cc2886444e5d1e 100644 --- a/spec/lib/gitlab/import_export/safe_model_attributes.yml +++ b/spec/lib/gitlab/import_export/safe_model_attributes.yml @@ -693,6 +693,7 @@ Badge: - type ProjectCiCdSetting: - group_runners_enabled +- runner_token_expiration_interval ProjectSetting: - allow_merge_on_skipped_pipeline - has_confluence diff --git a/spec/models/ci/runner_spec.rb b/spec/models/ci/runner_spec.rb index 5142f70fa2c233782a0594c8d793355c59a9e0d0..7b6fcd58fa2d5e22ac73f2e293085b5595cf7c6c 100644 --- a/spec/models/ci/runner_spec.rb +++ b/spec/models/ci/runner_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' RSpec.describe Ci::Runner do + include StubGitlabCalls + it_behaves_like 'having unique enum values' it_behaves_like 'it has loose foreign keys' do @@ -1231,6 +1233,21 @@ def does_db_update expect(runners).to eq([runner2, runner1]) end + + it 'supports ordering by the token expiration' do + runner1 = create(:ci_runner) + runner1.update!(token_expires_at: 1.year.from_now) + runner2 = create(:ci_runner) + runner3 = create(:ci_runner) + runner3.update!(token_expires_at: 1.month.from_now) + runners = described_class.order_by('token_expires_at_asc') + + expect(runners).to eq([runner3, runner1, runner2]) + + runners = described_class.order_by('token_expires_at_desc') + + expect(runners).to eq([runner2, runner1, runner3]) + end end describe '.runner_matchers' do @@ -1398,4 +1415,206 @@ def does_db_update it_behaves_like 'returns group runners' end end + + describe '#token_expires_at' do + before do + stub_gitlab_calls + end + + shared_examples 'expiring token' do + it 'expires' do + expect(runner.token_expires_at).to be_within(1.second).of(interval.from_now) + end + end + + shared_examples 'non-expiring token' do + it 'does not expire' do + expect(runner.token_expires_at).to be_nil + end + end + + context 'no expiration' do + let(:runner) { create(:ci_runner) } + + it_behaves_like 'non-expiring token' + end + + context 'system-wide shared expiration' do + before do + Gitlab::CurrentSettings.runner_token_expiration_interval = 5.days.to_i + end + + let(:runner) { create(:ci_runner) } + let(:interval) { 5.days } + + it_behaves_like 'expiring token' + end + + context 'system-wide group expiration' do + before do + Gitlab::CurrentSettings.group_runner_token_expiration_interval = 5.days.to_i + end + + let(:runner) { create(:ci_runner) } + + it_behaves_like 'non-expiring token' + end + + context 'system-wide project expiration' do + before do + Gitlab::CurrentSettings.project_runner_token_expiration_interval = 5.days.to_i + end + + let(:runner) { create(:ci_runner) } + + it_behaves_like 'non-expiring token' + end + + context 'human-readable system-wide expiration' do + before do + Gitlab::CurrentSettings.runner_token_expiration_interval_human_readable = '6 days' + end + + let(:runner) { create(:ci_runner) } + let(:interval) { 6.days } + + it_behaves_like 'expiring token' + end + + context 'group expiration' do + let(:group_settings) { create(:namespace_settings, runner_token_expiration_interval: 6.days.to_i) } + let(:group) { create(:group, namespace_settings: group_settings) } + let(:runner) { create(:ci_runner, :group, groups: [group]) } + let(:interval) { 6.days } + + it_behaves_like 'expiring token' + end + + context 'human-readable group expiration' do + let(:group_settings) { create(:namespace_settings, runner_token_expiration_interval_human_readable: '7 days') } + let(:group) { create(:group, namespace_settings: group_settings) } + let(:runner) { create(:ci_runner, :group, groups: [group]) } + let(:interval) { 7.days } + + it_behaves_like 'expiring token' + end + + context 'project expiration' do + let(:project) { create(:project, runner_token_expiration_interval: 4.days.to_i).tap(&:save!) } + let(:runner) { create(:ci_runner, :project, projects: [project]) } + let(:interval) { 4.days } + + it_behaves_like 'expiring token' + end + + context 'human-readable project expiration' do + let(:project) { create(:project, runner_token_expiration_interval_human_readable: '5 days').tap(&:save!) } + let(:runner) { create(:ci_runner, :project, projects: [project]) } + let(:interval) { 5.days } + + it_behaves_like 'expiring token' + end + + context 'multiple projects' do + let(:project1) { create(:project, runner_token_expiration_interval: 8.days.to_i).tap(&:save!) } + let(:project2) { create(:project, runner_token_expiration_interval: 7.days.to_i).tap(&:save!) } + let(:project3) { create(:project, runner_token_expiration_interval: 9.days.to_i).tap(&:save!) } + let(:runner) { create(:ci_runner, :project, projects: [project1, project2, project3]) } + let(:interval) { 7.days } + + it_behaves_like 'expiring token' + end + + context 'project overrides system' do + before do + Gitlab::CurrentSettings.project_runner_token_expiration_interval = 5.days.to_i + end + + let(:project) { create(:project, runner_token_expiration_interval: 4.days.to_i).tap(&:save!) } + let(:runner) { create(:ci_runner, :project, projects: [project]) } + let(:interval) { 4.days } + + it_behaves_like 'expiring token' + end + + context 'system overrides project' do + before do + Gitlab::CurrentSettings.project_runner_token_expiration_interval = 3.days.to_i + end + + let(:project) { create(:project, runner_token_expiration_interval: 4.days.to_i).tap(&:save!) } + let(:runner) { create(:ci_runner, :project, projects: [project]) } + let(:interval) { 3.days } + + it_behaves_like 'expiring token' + end + + context 'group overrides system' do + before do + Gitlab::CurrentSettings.group_runner_token_expiration_interval = 5.days.to_i + end + + let(:group_settings) { create(:namespace_settings, runner_token_expiration_interval: 4.days.to_i) } + let(:group) { create(:group, namespace_settings: group_settings) } + let(:runner) { create(:ci_runner, :group, groups: [group]) } + let(:interval) { 4.days } + + it_behaves_like 'expiring token' + end + + context 'system overrides group' do + before do + Gitlab::CurrentSettings.group_runner_token_expiration_interval = 3.days.to_i + end + + let(:group_settings) { create(:namespace_settings, runner_token_expiration_interval: 4.days.to_i) } + let(:group) { create(:group, namespace_settings: group_settings) } + let(:runner) { create(:ci_runner, :group, groups: [group]) } + let(:interval) { 3.days } + + it_behaves_like 'expiring token' + end + + context 'parent group overrides subgroup' do + let(:parent_group_settings) { create(:namespace_settings, subgroup_runner_token_expiration_interval: 2.days.to_i) } + let(:parent_group) { create(:group, namespace_settings: parent_group_settings) } + let(:group_settings) { create(:namespace_settings, runner_token_expiration_interval: 3.days.to_i) } + let(:group) { create(:group, parent: parent_group, namespace_settings: group_settings) } + let(:runner) { create(:ci_runner, :group, groups: [group]) } + let(:interval) { 2.days } + + it_behaves_like 'expiring token' + end + + context 'subgroup overrides parent group' do + let(:parent_group_settings) { create(:namespace_settings, subgroup_runner_token_expiration_interval: 4.days.to_i) } + let(:parent_group) { create(:group, namespace_settings: parent_group_settings) } + let(:group_settings) { create(:namespace_settings, runner_token_expiration_interval: 3.days.to_i) } + let(:group) { create(:group, parent: parent_group, namespace_settings: group_settings) } + let(:runner) { create(:ci_runner, :group, groups: [group]) } + let(:interval) { 3.days } + + it_behaves_like 'expiring token' + end + + context 'group overrides project' do + let(:group_settings) { create(:namespace_settings, project_runner_token_expiration_interval: 2.days.to_i) } + let(:group) { create(:group, namespace_settings: group_settings) } + let(:project) { create(:project, group: group, runner_token_expiration_interval: 3.days.to_i).tap(&:save!) } + let(:runner) { create(:ci_runner, :project, projects: [project]) } + let(:interval) { 2.days } + + it_behaves_like 'expiring token' + end + + context 'project overrides group' do + let(:group_settings) { create(:namespace_settings, project_runner_token_expiration_interval: 4.days.to_i) } + let(:group) { create(:group, namespace_settings: group_settings) } + let(:project) { create(:project, group: group, runner_token_expiration_interval: 3.days.to_i).tap(&:save!) } + let(:runner) { create(:ci_runner, :project, projects: [project]) } + let(:interval) { 3.days } + + it_behaves_like 'expiring token' + end + end end diff --git a/spec/models/concerns/token_authenticatable_spec.rb b/spec/models/concerns/token_authenticatable_spec.rb index 4bdb3e0a32a8253653efb614d150b2b4a5433a9b..222e797ccdaa6a9eafbc7aa1a0b00b641e25ecfa 100644 --- a/spec/models/concerns/token_authenticatable_spec.rb +++ b/spec/models/concerns/token_authenticatable_spec.rb @@ -290,3 +290,49 @@ end end end + +RSpec.describe Ci::Runner, 'TokenAuthenticatable' do + describe '#token_expired?' do + subject { runner.token_expired? } + + let(:runner) { create(:ci_runner) } + + it 'returns false when there is no token expiration' do + is_expected.to eq(false) + end + + it 'returns false when token is not expired' do + runner['token_expires_at'] = 5.seconds.from_now + is_expected.to eq(false) + end + + it 'returns true when token is expired' do + runner['token_expires_at'] = 5.seconds.ago + is_expected.to eq(true) + end + end + + describe '.find_by_token' do + subject { Ci::Runner.find_by_token(runner.token) } + + let(:runner) { create(:ci_runner) } + + it 'finds by token when there is no expiration' do + is_expected.to eq(runner) + end + + it 'finds by token when token is not expired' do + runner['token_expires_at'] = 5.seconds.from_now + runner.save! + + is_expected.to eq(runner) + end + + it 'does not find by token when token is expired' do + runner['token_expires_at'] = 5.seconds.ago + runner.save! + + is_expected.to be_nil + end + end +end diff --git a/spec/models/concerns/token_authenticatable_strategies/encrypted_spec.rb b/spec/models/concerns/token_authenticatable_strategies/encrypted_spec.rb index b311e302a310d65e32d6f9c4c030f0acb28823fd..1772fd0ff957032947feeee947c7c8ce350bce72 100644 --- a/spec/models/concerns/token_authenticatable_strategies/encrypted_spec.rb +++ b/spec/models/concerns/token_authenticatable_strategies/encrypted_spec.rb @@ -23,6 +23,8 @@ let(:options) { { encrypted: :required } } it 'finds the encrypted resource by cleartext' do + allow(model).to receive(:where) + .and_return(model) allow(model).to receive(:find_by) .with('some_field_encrypted' => [encrypted, encrypted_with_static_iv]) .and_return('encrypted resource') @@ -36,6 +38,8 @@ let(:options) { { encrypted: :optional } } it 'finds the encrypted resource by cleartext' do + allow(model).to receive(:where) + .and_return(model) allow(model).to receive(:find_by) .with('some_field_encrypted' => [encrypted, encrypted_with_static_iv]) .and_return('encrypted resource') @@ -49,6 +53,8 @@ .to receive(:find_token_authenticatable) .and_return('plaintext resource') + allow(model).to receive(:where) + .and_return(model) allow(model).to receive(:find_by) .with('some_field_encrypted' => [encrypted, encrypted_with_static_iv]) .and_return(nil) @@ -62,6 +68,8 @@ let(:options) { { encrypted: :migrating } } it 'finds the cleartext resource by cleartext' do + allow(model).to receive(:where) + .and_return(model) allow(model).to receive(:find_by) .with('some_field' => 'my-value') .and_return('cleartext resource') @@ -71,6 +79,8 @@ end it 'returns nil if resource cannot be found' do + allow(model).to receive(:where) + .and_return(model) allow(model).to receive(:find_by) .with('some_field' => 'my-value') .and_return(nil) diff --git a/spec/models/group_spec.rb b/spec/models/group_spec.rb index fed4ee3f3a48a059bb84b7f9535baf6d907ecca4..cb3365b3b5b96a1519beff2973e8330c0e0daa14 100644 --- a/spec/models/group_spec.rb +++ b/spec/models/group_spec.rb @@ -4,6 +4,7 @@ RSpec.describe Group do include ReloadHelpers + include StubGitlabCalls let!(:group) { create(:group) } @@ -2775,4 +2776,218 @@ def setup_group_members(group) end end end + + describe '#enforced_runner_token_expiration_interval and #effective_runner_token_expiration_interval' do + before do + stub_gitlab_calls + end + + shared_examples 'no enforced expiration interval' do + it { expect(subject.enforced_runner_token_expiration_interval).to be_nil } + end + + shared_examples 'enforced expiration interval' do + it { expect(subject.enforced_runner_token_expiration_interval).to eq(enforced_interval) } + end + + shared_examples 'no effective expiration interval' do + it { expect(subject.effective_runner_token_expiration_interval).to be_nil } + end + + shared_examples 'effective expiration interval' do + it { expect(subject.effective_runner_token_expiration_interval).to eq(effective_interval) } + end + + context 'when there is no interval in group settings' do + subject { create(:group) } + + it_behaves_like 'no enforced expiration interval' + it_behaves_like 'no effective expiration interval' + end + + context 'when there is a group interval' do + let(:group_settings) { create(:namespace_settings, runner_token_expiration_interval: 3.days.to_i) } + let(:effective_interval) { 3.days } + + subject { create(:group, namespace_settings: group_settings) } + + it_behaves_like 'no enforced expiration interval' + it_behaves_like 'effective expiration interval' + end + + context 'when there is a site-wide enforced shared interval' do + before do + Gitlab::CurrentSettings.runner_token_expiration_interval = 5.days.to_i + end + + subject { create(:group) } + + it_behaves_like 'no enforced expiration interval' + it_behaves_like 'no effective expiration interval' + end + + context 'when there is a site-wide enforced group interval' do + before do + Gitlab::CurrentSettings.group_runner_token_expiration_interval = 5.days.to_i + end + + let(:enforced_interval) { 5.days } + let(:effective_interval) { 5.days } + + subject { create(:group) } + + it_behaves_like 'enforced expiration interval' + it_behaves_like 'effective expiration interval' + end + + context 'when there is a site-wide enforced project interval' do + before do + Gitlab::CurrentSettings.project_runner_token_expiration_interval = 5.days.to_i + end + + subject { create(:group) } + + it_behaves_like 'no enforced expiration interval' + it_behaves_like 'no effective expiration interval' + end + + context 'when there is a grandparent group enforced group interval' do + let_it_be(:grandparent_group_settings) { create(:namespace_settings, runner_token_expiration_interval: 4.days.to_i) } + let_it_be(:grandparent_group) { create(:group, namespace_settings: grandparent_group_settings) } + let_it_be(:parent_group) { create(:group, parent: grandparent_group) } + + subject { create(:group, parent: parent_group) } + + it_behaves_like 'no enforced expiration interval' + it_behaves_like 'no effective expiration interval' + end + + context 'when there is a grandparent group enforced subgroup interval' do + let_it_be(:grandparent_group_settings) { create(:namespace_settings, subgroup_runner_token_expiration_interval: 4.days.to_i) } + let_it_be(:grandparent_group) { create(:group, namespace_settings: grandparent_group_settings) } + let_it_be(:parent_group) { create(:group, parent: grandparent_group) } + + let(:enforced_interval) { 4.days } + let(:effective_interval) { 4.days } + + subject { create(:group, parent: parent_group) } + + it_behaves_like 'enforced expiration interval' + it_behaves_like 'effective expiration interval' + end + + context 'when there is a grandparent group enforced project interval' do + let_it_be(:grandparent_group_settings) { create(:namespace_settings, project_runner_token_expiration_interval: 4.days.to_i) } + let_it_be(:grandparent_group) { create(:group, namespace_settings: grandparent_group_settings) } + let_it_be(:parent_group) { create(:group, parent: grandparent_group) } + + subject { create(:group, parent: parent_group) } + + it_behaves_like 'no enforced expiration interval' + it_behaves_like 'no effective expiration interval' + end + + context 'when there is a parent group enforced interval overridden by group interval' do + let_it_be(:parent_group_settings) { create(:namespace_settings, subgroup_runner_token_expiration_interval: 5.days.to_i) } + let_it_be(:parent_group) { create(:group, namespace_settings: parent_group_settings) } + + let(:group_settings) { create(:namespace_settings, runner_token_expiration_interval: 4.days.to_i) } + let(:enforced_interval) { 5.days } + let(:effective_interval) { 4.days } + + subject { create(:group, parent: parent_group, namespace_settings: group_settings) } + + it_behaves_like 'enforced expiration interval' + it_behaves_like 'effective expiration interval' + + it 'has human-readable expiration intervals' do + expect(subject.enforced_runner_token_expiration_interval_human_readable).to eq('5d') + expect(subject.effective_runner_token_expiration_interval_human_readable).to eq('4d') + end + end + + context 'when site-wide enforced interval overrides group interval' do + before do + Gitlab::CurrentSettings.group_runner_token_expiration_interval = 3.days.to_i + end + + let(:group_settings) { create(:namespace_settings, runner_token_expiration_interval: 4.days.to_i) } + let(:enforced_interval) { 3.days } + let(:effective_interval) { 3.days } + + subject { create(:group, namespace_settings: group_settings) } + + it_behaves_like 'enforced expiration interval' + it_behaves_like 'effective expiration interval' + end + + context 'when group interval overrides site-wide enforced interval' do + before do + Gitlab::CurrentSettings.group_runner_token_expiration_interval = 5.days.to_i + end + + let(:group_settings) { create(:namespace_settings, runner_token_expiration_interval: 4.days.to_i) } + let(:enforced_interval) { 5.days } + let(:effective_interval) { 4.days } + + subject { create(:group, namespace_settings: group_settings) } + + it_behaves_like 'enforced expiration interval' + it_behaves_like 'effective expiration interval' + end + + context 'when site-wide enforced interval overrides parent group enforced interval' do + before do + Gitlab::CurrentSettings.group_runner_token_expiration_interval = 3.days.to_i + end + + let_it_be(:parent_group_settings) { create(:namespace_settings, subgroup_runner_token_expiration_interval: 4.days.to_i) } + let_it_be(:parent_group) { create(:group, namespace_settings: parent_group_settings) } + + let(:enforced_interval) { 3.days } + let(:effective_interval) { 3.days } + + subject { create(:group, parent: parent_group) } + + it_behaves_like 'enforced expiration interval' + it_behaves_like 'effective expiration interval' + end + + context 'when parent group enforced interval overrides site-wide enforced interval' do + before do + Gitlab::CurrentSettings.group_runner_token_expiration_interval = 5.days.to_i + end + + let_it_be(:parent_group_settings) { create(:namespace_settings, subgroup_runner_token_expiration_interval: 4.days.to_i) } + let_it_be(:parent_group) { create(:group, namespace_settings: parent_group_settings) } + + let(:enforced_interval) { 4.days } + let(:effective_interval) { 4.days } + + subject { create(:group, parent: parent_group) } + + it_behaves_like 'enforced expiration interval' + it_behaves_like 'effective expiration interval' + end + + context 'when there is an enforced group interval in an unrelated group' do + let_it_be(:unrelated_group_settings) { create(:namespace_settings, subgroup_runner_token_expiration_interval: 4.days.to_i) } + let_it_be(:unrelated_group) { create(:group, namespace_settings: unrelated_group_settings) } + + subject { create(:group) } + + it_behaves_like 'no enforced expiration interval' + it_behaves_like 'no effective expiration interval' + end + + context 'when there is an enforced group interval in a subgroup' do + let(:subgroup_settings) { create(:namespace_settings, subgroup_runner_token_expiration_interval: 4.days.to_i) } + let(:subgroup) { create(:group, parent: subject, namespace_settings: subgroup_settings) } + + subject { create(:group) } + + it_behaves_like 'no enforced expiration interval' + it_behaves_like 'no effective expiration interval' + end + end end diff --git a/spec/models/project_spec.rb b/spec/models/project_spec.rb index 4e38bf7d3e37c4ce00b9663ccc5d086178f05d03..d43b94c25ab6e54ad30fbebc4aa7daa5b2ffb475 100644 --- a/spec/models/project_spec.rb +++ b/spec/models/project_spec.rb @@ -7,6 +7,7 @@ include GitHelpers include ExternalAuthorizationServiceHelpers include ReloadHelpers + include StubGitlabCalls using RSpec::Parameterized::TableSyntax let_it_be(:namespace) { create_default(:namespace).freeze } @@ -7476,6 +7477,259 @@ def has_external_wiki end end + describe '#enforced_runner_token_expiration_interval and #effective_runner_token_expiration_interval' do + before do + stub_gitlab_calls + end + + shared_examples 'no enforced expiration interval' do + it { expect(subject.enforced_runner_token_expiration_interval).to be_nil } + end + + shared_examples 'enforced expiration interval' do + it { expect(subject.enforced_runner_token_expiration_interval).to eq(enforced_interval) } + end + + shared_examples 'no effective expiration interval' do + it { expect(subject.effective_runner_token_expiration_interval).to be_nil } + end + + shared_examples 'effective expiration interval' do + it { expect(subject.effective_runner_token_expiration_interval).to eq(effective_interval) } + end + + context 'when there is no interval' do + subject { create(:project) } + + it_behaves_like 'no enforced expiration interval' + it_behaves_like 'no effective expiration interval' + end + + context 'when there is a project interval' do + let(:effective_interval) { 3.days } + + subject { create(:project, runner_token_expiration_interval: 3.days.to_i) } + + it_behaves_like 'no enforced expiration interval' + it_behaves_like 'effective expiration interval' + end + + context 'when there is a site-wide enforced shared interval' do + before do + Gitlab::CurrentSettings.runner_token_expiration_interval = 5.days.to_i + end + + subject { create(:project) } + + it_behaves_like 'no enforced expiration interval' + it_behaves_like 'no effective expiration interval' + end + + context 'when there is a site-wide enforced group interval' do + before do + Gitlab::CurrentSettings.group_runner_token_expiration_interval = 5.days.to_i + end + + subject { create(:project) } + + it_behaves_like 'no enforced expiration interval' + it_behaves_like 'no effective expiration interval' + end + + context 'when there is a site-wide enforced project interval' do + before do + Gitlab::CurrentSettings.project_runner_token_expiration_interval = 5.days.to_i + end + + let(:enforced_interval) { 5.days } + let(:effective_interval) { 5.days } + + subject { create(:project) } + + it_behaves_like 'enforced expiration interval' + it_behaves_like 'effective expiration interval' + end + + context 'when there is a group-enforced group interval' do + let_it_be(:group_settings) { create(:namespace_settings, runner_token_expiration_interval: 4.days.to_i) } + let_it_be(:group) { create(:group, namespace_settings: group_settings) } + + subject { create(:project, group: group) } + + it_behaves_like 'no enforced expiration interval' + it_behaves_like 'no effective expiration interval' + end + + context 'when there is a group-enforced subgroup interval' do + let_it_be(:group_settings) { create(:namespace_settings, subgroup_runner_token_expiration_interval: 4.days.to_i) } + let_it_be(:group) { create(:group, namespace_settings: group_settings) } + + subject { create(:project, group: group) } + + it_behaves_like 'no enforced expiration interval' + it_behaves_like 'no effective expiration interval' + end + + context 'when there is a group-enforced project interval' do + let_it_be(:group_settings) { create(:namespace_settings, project_runner_token_expiration_interval: 4.days.to_i) } + let_it_be(:group) { create(:group, namespace_settings: group_settings) } + + let(:enforced_interval) { 4.days } + let(:effective_interval) { 4.days } + + subject { create(:project, group: group) } + + it_behaves_like 'enforced expiration interval' + it_behaves_like 'effective expiration interval' + end + + context 'when there is a grandparent group-enforced interval' do + let_it_be(:grandparent_group_settings) { create(:namespace_settings, project_runner_token_expiration_interval: 3.days.to_i) } + let_it_be(:grandparent_group) { create(:group, namespace_settings: grandparent_group_settings) } + let_it_be(:parent_group_settings) { create(:namespace_settings) } + let_it_be(:parent_group) { create(:group, parent: grandparent_group, namespace_settings: parent_group_settings) } + let_it_be(:group_settings) { create(:namespace_settings, project_runner_token_expiration_interval: 4.days.to_i) } + let_it_be(:group) { create(:group, parent: parent_group, namespace_settings: group_settings) } + + let(:enforced_interval) { 3.days } + let(:effective_interval) { 3.days } + + subject { create(:project, group: group) } + + it_behaves_like 'enforced expiration interval' + it_behaves_like 'effective expiration interval' + end + + context 'when there is a parent group-enforced interval overridden by group-enforced interval' do + let_it_be(:parent_group_settings) { create(:namespace_settings, project_runner_token_expiration_interval: 5.days.to_i) } + let_it_be(:parent_group) { create(:group, namespace_settings: parent_group_settings) } + let_it_be(:group_settings) { create(:namespace_settings, project_runner_token_expiration_interval: 4.days.to_i) } + let_it_be(:group) { create(:group, parent: parent_group, namespace_settings: group_settings) } + + let(:enforced_interval) { 4.days } + let(:effective_interval) { 4.days } + + subject { create(:project, group: group) } + + it_behaves_like 'enforced expiration interval' + it_behaves_like 'effective expiration interval' + end + + context 'when site-wide enforced interval overrides project interval' do + before do + Gitlab::CurrentSettings.project_runner_token_expiration_interval = 3.days.to_i + end + + let(:enforced_interval) { 3.days } + let(:effective_interval) { 3.days } + + subject { create(:project, runner_token_expiration_interval: 4.days.to_i) } + + it_behaves_like 'enforced expiration interval' + it_behaves_like 'effective expiration interval' + end + + context 'when project interval overrides site-wide enforced interval' do + before do + Gitlab::CurrentSettings.project_runner_token_expiration_interval = 5.days.to_i + end + + let(:enforced_interval) { 5.days } + let(:effective_interval) { 4.days } + + subject { create(:project, runner_token_expiration_interval: 4.days.to_i) } + + it_behaves_like 'enforced expiration interval' + it_behaves_like 'effective expiration interval' + + it 'has human-readable expiration intervals' do + expect(subject.enforced_runner_token_expiration_interval_human_readable).to eq('5d') + expect(subject.effective_runner_token_expiration_interval_human_readable).to eq('4d') + end + end + + context 'when site-wide enforced interval overrides group-enforced interval' do + before do + Gitlab::CurrentSettings.project_runner_token_expiration_interval = 3.days.to_i + end + + let_it_be(:group_settings) { create(:namespace_settings, project_runner_token_expiration_interval: 4.days.to_i) } + let_it_be(:group) { create(:group, namespace_settings: group_settings) } + + let(:enforced_interval) { 3.days } + let(:effective_interval) { 3.days } + + subject { create(:project, group: group) } + + it_behaves_like 'enforced expiration interval' + it_behaves_like 'effective expiration interval' + end + + context 'when group-enforced interval overrides site-wide enforced interval' do + before do + Gitlab::CurrentSettings.project_runner_token_expiration_interval = 5.days.to_i + end + + let_it_be(:group_settings) { create(:namespace_settings, project_runner_token_expiration_interval: 4.days.to_i) } + let_it_be(:group) { create(:group, namespace_settings: group_settings) } + + let(:enforced_interval) { 4.days } + let(:effective_interval) { 4.days } + + subject { create(:project, group: group) } + + it_behaves_like 'enforced expiration interval' + it_behaves_like 'effective expiration interval' + end + + context 'when group-enforced interval overrides project interval' do + let_it_be(:group_settings) { create(:namespace_settings, project_runner_token_expiration_interval: 3.days.to_i) } + let_it_be(:group) { create(:group, namespace_settings: group_settings) } + + let(:enforced_interval) { 3.days } + let(:effective_interval) { 3.days } + + subject { create(:project, group: group, runner_token_expiration_interval: 4.days.to_i) } + + it_behaves_like 'enforced expiration interval' + it_behaves_like 'effective expiration interval' + end + + context 'when project interval overrides group-enforced interval' do + let_it_be(:group_settings) { create(:namespace_settings, project_runner_token_expiration_interval: 5.days.to_i) } + let_it_be(:group) { create(:group, namespace_settings: group_settings) } + + let(:enforced_interval) { 5.days } + let(:effective_interval) { 4.days } + + subject { create(:project, group: group, runner_token_expiration_interval: 4.days.to_i) } + + it_behaves_like 'enforced expiration interval' + it_behaves_like 'effective expiration interval' + end + + context 'when there is an enforced project interval in an unrelated group' do + let_it_be(:unrelated_group_settings) { create(:namespace_settings, project_runner_token_expiration_interval: 4.days.to_i) } + let_it_be(:unrelated_group) { create(:group, namespace_settings: unrelated_group_settings) } + + subject { create(:project) } + + it_behaves_like 'no enforced expiration interval' + it_behaves_like 'no effective expiration interval' + end + + context 'when there is an enforced project interval in a subgroup' do + let_it_be(:group) { create(:group) } + let_it_be(:subgroup_settings) { create(:namespace_settings, project_runner_token_expiration_interval: 4.days.to_i) } + let_it_be(:subgroup) { create(:group, parent: group, namespace_settings: subgroup_settings) } + + subject { create(:project, group: group) } + + it_behaves_like 'no enforced expiration interval' + it_behaves_like 'no effective expiration interval' + end + end + it_behaves_like 'it has loose foreign keys' do let(:factory_name) { :project } end diff --git a/spec/requests/api/ci/runner/runners_post_spec.rb b/spec/requests/api/ci/runner/runners_post_spec.rb index a51d8b458f8acc2f94d58b9305da685c2da5919f..c3ee3666a851361494026173f1de9b1b8edd96e6 100644 --- a/spec/requests/api/ci/runner/runners_post_spec.rb +++ b/spec/requests/api/ci/runner/runners_post_spec.rb @@ -50,8 +50,7 @@ def request runner = ::Ci::Runner.first expect(response).to have_gitlab_http_status(:created) - expect(json_response['id']).to eq(runner.id) - expect(json_response['token']).to eq(runner.token) + expect(json_response).to eq({ 'id' => runner.id, 'token' => runner.token, 'token_expires_at' => nil }) expect(runner.run_untagged).to be true expect(runner.active).to be true expect(runner.token).not_to eq(registration_token) @@ -239,6 +238,25 @@ def request end end + context 'when runner token expiration interval is provided' do + before do + stub_application_setting(runner_token_expiration_interval: 5.days.to_i) + end + + it 'creates runner with token expiration' do + post api('/runners'), params: { + token: registration_token + } + + runner = ::Ci::Runner.first + runner.reload + + expect(response).to have_gitlab_http_status(:created) + expect(json_response).to eq({ 'id' => runner.id, 'token' => runner.token, 'token_expires_at' => runner.token_expires_at.iso8601(3) }) + expect(runner.token_expires_at).to be_within(1.second).of(5.days.from_now) + end + end + context 'when runner description is provided' do it 'creates runner' do post api('/runners'), params: { diff --git a/spec/requests/api/ci/runner/runners_verify_post_spec.rb b/spec/requests/api/ci/runner/runners_verify_post_spec.rb index 4680076acae73229be651bd9007395c3222a4b2f..038e126deaa835301e1ec53331a05303796e8852 100644 --- a/spec/requests/api/ci/runner/runners_verify_post_spec.rb +++ b/spec/requests/api/ci/runner/runners_verify_post_spec.rb @@ -49,6 +49,30 @@ let(:expected_params) { { client_id: "runner/#{runner.id}" } } end end + + context 'when non-expired token is provided' do + subject { post api('/runners/verify'), params: { token: runner.token } } + + it 'verifies Runner credentials' do + runner["token_expires_at"] = 10.days.from_now + runner.save! + subject + + expect(response).to have_gitlab_http_status(:ok) + end + end + + context 'when expired token is provided' do + subject { post api('/runners/verify'), params: { token: runner.token } } + + it 'does not verify Runner credentials' do + runner["token_expires_at"] = 10.days.ago + runner.save! + subject + + expect(response).to have_gitlab_http_status(:forbidden) + end + end end end end diff --git a/spec/requests/api/ci/runners_spec.rb b/spec/requests/api/ci/runners_spec.rb index 20acdd892e0db400a1cfd1f5add289506a3088ac..6713e5e6be7db93a056aeb47dca23c11a0ef877d 100644 --- a/spec/requests/api/ci/runners_spec.rb +++ b/spec/requests/api/ci/runners_spec.rb @@ -617,7 +617,7 @@ def update_runner(id, user, args) post api("/runners/#{shared_runner.id}/reset_authentication_token", admin) expect(response).to have_gitlab_http_status(:success) - expect(json_response).to eq({ 'token' => shared_runner.reload.token }) + expect(json_response).to eq({ 'token' => shared_runner.reload.token, 'token_expires_at' => nil }) end.to change { shared_runner.reload.token } end @@ -641,7 +641,7 @@ def update_runner(id, user, args) post api("/runners/#{project_runner.id}/reset_authentication_token", user) expect(response).to have_gitlab_http_status(:success) - expect(json_response).to eq({ 'token' => project_runner.reload.token }) + expect(json_response).to eq({ 'token' => project_runner.reload.token, 'token_expires_at' => nil }) end.to change { project_runner.reload.token } end @@ -682,7 +682,22 @@ def update_runner(id, user, args) post api("/runners/#{group_runner_a.id}/reset_authentication_token", user) expect(response).to have_gitlab_http_status(:success) - expect(json_response).to eq({ 'token' => group_runner_a.reload.token }) + expect(json_response).to eq({ 'token' => group_runner_a.reload.token, 'token_expires_at' => nil }) + end.to change { group_runner_a.reload.token } + end + + it 'resets group runner authentication token with owner access with expiration time' do + expect do + expect(group_runner_a.reload.token_expires_at).to be_nil + + group.runner_token_expiration_interval = 5.days + group.save! + post api("/runners/#{group_runner_a.id}/reset_authentication_token", user) + group_runner_a.reload + + expect(response).to have_gitlab_http_status(:success) + expect(json_response).to eq({ 'token' => group_runner_a.token, 'token_expires_at' => group_runner_a.token_expires_at.iso8601(3) }) + expect(group_runner_a.token_expires_at).to be_within(1.second).of(5.days.from_now) end.to change { group_runner_a.reload.token } end end diff --git a/spec/requests/api/settings_spec.rb b/spec/requests/api/settings_spec.rb index 7e940d52a4162c75e2db9ecdfbe3a92686a29e0d..fa0b0e6f4636d8e79a8e67ac66c7950b7e20f0f3 100644 --- a/spec/requests/api/settings_spec.rb +++ b/spec/requests/api/settings_spec.rb @@ -49,6 +49,9 @@ expect(json_response['whats_new_variant']).to eq('all_tiers') expect(json_response['user_deactivation_emails_enabled']).to be(true) expect(json_response['suggest_pipeline_enabled']).to be(true) + expect(json_response['runner_token_expiration_interval']).to be_nil + expect(json_response['group_runner_token_expiration_interval']).to be_nil + expect(json_response['project_runner_token_expiration_interval']).to be_nil end end @@ -644,5 +647,37 @@ end end end + + context 'runner token expiration_intervals' do + it 'updates the settings' do + put api("/application/settings", admin), params: { + runner_token_expiration_interval: 3600, + group_runner_token_expiration_interval: 3600 * 2, + project_runner_token_expiration_interval: 3600 * 3 + } + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to include( + 'runner_token_expiration_interval' => 3600, + 'group_runner_token_expiration_interval' => 3600 * 2, + 'project_runner_token_expiration_interval' => 3600 * 3 + ) + end + + it 'updates the settings with empty values' do + put api("/application/settings", admin), params: { + runner_token_expiration_interval: nil, + group_runner_token_expiration_interval: nil, + project_runner_token_expiration_interval: nil + } + + expect(response).to have_gitlab_http_status(:ok) + expect(json_response).to include( + 'runner_token_expiration_interval' => nil, + 'group_runner_token_expiration_interval' => nil, + 'project_runner_token_expiration_interval' => nil + ) + end + end end end