diff --git a/app/models/plan.rb b/app/models/plan.rb index 071944b2cc5d568c620b21b95db2b19083917389..f545d03f42f868939ca8430ea246287c3bd97c2a 100644 --- a/app/models/plan.rb +++ b/app/models/plan.rb @@ -53,6 +53,10 @@ def default? def paid? false end + + def ultimate? + false + end end Plan.prepend_mod_with('Plan') diff --git a/config/feature_flags/gitlab_com_derisk/automatically_unassign_security_policies_for_expired_licenses.yml b/config/feature_flags/gitlab_com_derisk/automatically_unassign_security_policies_for_expired_licenses.yml new file mode 100644 index 0000000000000000000000000000000000000000..9b100b05836e27643b849e1a2358369737a2670d --- /dev/null +++ b/config/feature_flags/gitlab_com_derisk/automatically_unassign_security_policies_for_expired_licenses.yml @@ -0,0 +1,10 @@ +--- +name: automatically_unassign_security_policies_for_expired_licenses +description: +feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/431229 +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/209602 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/577920 +milestone: '18.6' +group: group::security policies +type: gitlab_com_derisk +default_enabled: false diff --git a/config/initializers/1_settings.rb b/config/initializers/1_settings.rb index d53e5b031b3ff4c6cd6a3abed5ecf814b1bbdf0f..d16eb9106516de88cfafcbefd26259e8e49b5fd2 100644 --- a/config/initializers/1_settings.rb +++ b/config/initializers/1_settings.rb @@ -947,6 +947,9 @@ Settings.cron_jobs['security_pipeline_execution_policies_schedule_worker'] ||= {} Settings.cron_jobs['security_pipeline_execution_policies_schedule_worker']['cron'] ||= '* * * * *' Settings.cron_jobs['security_pipeline_execution_policies_schedule_worker']['job_class'] = 'Security::PipelineExecutionPolicies::ScheduleWorker' + Settings.cron_jobs['security_unassign_policy_configurations_for_expired_licenses_worker'] ||= {} + Settings.cron_jobs['security_unassign_policy_configurations_for_expired_licenses_worker']['cron'] ||= '0 0 * * *' + Settings.cron_jobs['security_unassign_policy_configurations_for_expired_licenses_worker']['job_class'] = 'Security::UnassignPolicyConfigurationsForExpiredLicensesCronWorker' Settings.cron_jobs['users_security_policy_bot_cleanup_cron_worker'] ||= {} Settings.cron_jobs['users_security_policy_bot_cleanup_cron_worker']['cron'] ||= '0 * * * *' Settings.cron_jobs['users_security_policy_bot_cleanup_cron_worker']['job_class'] = 'Users::SecurityPolicyBotCleanupCronWorker' diff --git a/config/sidekiq_queues.yml b/config/sidekiq_queues.yml index 9f2e704d5f589c9bae719954b4b562e2a8b67bdf..5ecd58c36b4d7a96709be20a1f61d6b440e23f9e 100644 --- a/config/sidekiq_queues.yml +++ b/config/sidekiq_queues.yml @@ -1075,6 +1075,8 @@ - 1 - - security_sync_scan_policies - 1 +- - security_unassign_policy_configurations_for_expired_namespace + - 1 - - security_unassign_redundant_policy_configurations - 1 - - security_unenforceable_policy_rules_notification diff --git a/ee/app/models/ee/plan.rb b/ee/app/models/ee/plan.rb index e1b719a3e465fdbbc5109b8e7c254544e888b837..5e458c38e83a2a767b0a93fbe23d53770e534ffd 100644 --- a/ee/app/models/ee/plan.rb +++ b/ee/app/models/ee/plan.rb @@ -35,6 +35,7 @@ module Plan TOP_PLANS = [GOLD, ULTIMATE, OPEN_SOURCE].freeze CURRENT_ACTIVE_PLANS = [FREE, PREMIUM, ULTIMATE].freeze ULTIMATE_TRIAL_PLANS = [ULTIMATE_TRIAL, ULTIMATE_TRIAL_PAID_CUSTOMER].freeze + ALL_ULTIMATE_PLANS = [ULTIMATE, *ULTIMATE_TRIAL_PLANS].freeze has_many :hosted_subscriptions, class_name: 'GitlabSubscription', foreign_key: 'hosted_plan_id' has_many :gitlab_subscription_histories, inverse_of: :hosted_plan, @@ -77,6 +78,11 @@ def paid? PAID_HOSTED_PLANS.include?(name) end + override :ultimate? + def ultimate? + ALL_ULTIMATE_PLANS.include?(name) + end + def paid_excluding_trials?(exclude_oss: false) paid_hosted_plans = PAID_HOSTED_PLANS.dup paid_hosted_plans.delete(OPEN_SOURCE) if exclude_oss diff --git a/ee/app/models/gitlab_subscriptions/subscription_history.rb b/ee/app/models/gitlab_subscriptions/subscription_history.rb index 0e128b95c87408e569796925378053ea1208065a..e4d6f43336bffcb184b1cbb5a3bf761f16f04c2c 100644 --- a/ee/app/models/gitlab_subscriptions/subscription_history.rb +++ b/ee/app/models/gitlab_subscriptions/subscription_history.rb @@ -55,6 +55,14 @@ class SubscriptionHistory < ApplicationRecord ) end + scope :with_a_ultimate_hosted_plan, -> do + joins(:hosted_plan).where(hosted_plan: { name: EE::Plan::ALL_ULTIMATE_PLANS }) + end + + scope :ended_on, ->(date) { where(end_date: date) } + + scope :with_namespace_subscription, -> { includes(namespace: [gitlab_subscription: :hosted_plan]) } + def self.create_from_change(change_type, attrs) create_attrs = attrs .slice(*TRACKED_ATTRIBUTES) diff --git a/ee/app/services/security/orchestration/unassign_service.rb b/ee/app/services/security/orchestration/unassign_service.rb index aa0cd2d198a22595a0e969e31c795f46b412f32a..0108cd5a4247cbab3fbe4354e4c3b0d88ec706f7 100644 --- a/ee/app/services/security/orchestration/unassign_service.rb +++ b/ee/app/services/security/orchestration/unassign_service.rb @@ -5,10 +5,10 @@ module Orchestration class UnassignService < ::BaseContainerService include Gitlab::Utils::StrongMemoize - def execute(delete_bot: true) + def execute(delete_bot: true, skip_csp: true) return error(_('Policy project doesn\'t exist')) unless security_orchestration_policy_configuration - if container.designated_as_csp? + if container.designated_as_csp? && skip_csp return error(_("You cannot unassign security policy project for group designated as CSP.")) end diff --git a/ee/app/workers/all_queues.yml b/ee/app/workers/all_queues.yml index cda2e6e11ecf299f3f2c8473d86327eec9f841d3..20f62a936e96d0ab43489ba16c5272fd0fb97b7c 100644 --- a/ee/app/workers/all_queues.yml +++ b/ee/app/workers/all_queues.yml @@ -893,6 +893,16 @@ :idempotent: true :tags: [] :queue_namespace: :cronjob +- :name: cronjob:security_unassign_policy_configurations_for_expired_licenses_cron + :worker_name: Security::UnassignPolicyConfigurationsForExpiredLicensesCronWorker + :feature_category: :security_policy_management + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] + :queue_namespace: :cronjob - :name: cronjob:security_vulnerability_scanning_destroy_expired_sbom_scans :worker_name: Security::VulnerabilityScanning::DestroyExpiredSbomScansWorker :feature_category: :software_composition_analysis @@ -4176,6 +4186,16 @@ :idempotent: true :tags: [] :queue_namespace: +- :name: security_unassign_policy_configurations_for_expired_namespace + :worker_name: Security::UnassignPolicyConfigurationsForExpiredNamespaceWorker + :feature_category: :security_policy_management + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] + :queue_namespace: - :name: security_unassign_redundant_policy_configurations :worker_name: Security::UnassignRedundantPolicyConfigurationsWorker :feature_category: :security_policy_management diff --git a/ee/app/workers/security/unassign_policy_configurations_for_expired_licenses_cron_worker.rb b/ee/app/workers/security/unassign_policy_configurations_for_expired_licenses_cron_worker.rb new file mode 100644 index 0000000000000000000000000000000000000000..1c17cf151b0513466ea74edce9b9753cc52174fd --- /dev/null +++ b/ee/app/workers/security/unassign_policy_configurations_for_expired_licenses_cron_worker.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +module Security + class UnassignPolicyConfigurationsForExpiredLicensesCronWorker + include ApplicationWorker + include CronjobQueue + + idempotent! + data_consistency :sticky + feature_category :security_policy_management + + BUFFER_DATE = 3.days.ago.to_date.freeze + BATCH_SIZE = 100 + + def perform + if saas? + handle_saas + else + handle_self_managed + end + end + + private + + def saas? + ::Gitlab::Saas.feature_available?(:gitlab_com_subscriptions) + end + + def handle_saas + expired_ultimate_subscriptions.find_each(batch_size: BATCH_SIZE) do |subscription| # rubocop:disable CodeReuse/ActiveRecord -- the logic is specific to this worker + namespace = subscription.namespace + next unless namespace + + current_subscription = namespace.gitlab_subscription + next if active_ultimate_subscription?(current_subscription) + + schedule_unassign_worker_for_namespace(namespace) + end + end + + def expired_ultimate_subscriptions + GitlabSubscriptions::SubscriptionHistory + .ended_on(BUFFER_DATE) + .with_a_ultimate_hosted_plan + .with_namespace_subscription + end + + def active_ultimate_subscription?(subscription) + subscription && !subscription.expired? && subscription.hosted_plan&.ultimate? + end + + def handle_self_managed + return if Security::OrchestrationPolicyConfiguration.none? + + return if License.current&.ultimate? || latest_license_after_buffer_date? + + Namespace.top_level.find_each(batch_size: BATCH_SIZE) do |namespace| # rubocop:disable CodeReuse/ActiveRecord -- the logic is specific to this worker + schedule_unassign_worker_for_namespace(namespace) + end + end + + def latest_license_after_buffer_date? + license = License.current || License.history.first + return false unless license + + comparison_date = license.expired? ? license.expires_at : license.starts_at + comparison_date > BUFFER_DATE + end + + def schedule_unassign_worker_for_namespace(namespace) + with_context(namespace: namespace) do + Security::UnassignPolicyConfigurationsForExpiredNamespaceWorker.perform_async(namespace.id) + end + end + end +end diff --git a/ee/app/workers/security/unassign_policy_configurations_for_expired_namespace_worker.rb b/ee/app/workers/security/unassign_policy_configurations_for_expired_namespace_worker.rb new file mode 100644 index 0000000000000000000000000000000000000000..3dd09fa2c23664ba6824568dab62211243a5eb0f --- /dev/null +++ b/ee/app/workers/security/unassign_policy_configurations_for_expired_namespace_worker.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +module Security + class UnassignPolicyConfigurationsForExpiredNamespaceWorker + include ApplicationWorker + + feature_category :security_policy_management + data_consistency :sticky + deduplicate :until_executed + idempotent! + + BATCH_SIZE = 100 + + def perform(namespace_id) + namespace = Namespace.find_by_id(namespace_id) + return unless namespace + + return unless ::Feature.enabled?(:automatically_unassign_security_policies_for_expired_licenses, namespace) + + namespace_ids = namespace.self_and_descendants.pluck_primary_key + project_ids = namespace.all_project_ids + + policy_configurations(namespace_ids, project_ids).find_each(batch_size: BATCH_SIZE) do |configuration| # rubocop:disable CodeReuse/ActiveRecord -- the logic is specific to this service + container = configuration.project || configuration.namespace + admin_bot = admin_bot_for_container_organization(container) + + Security::Orchestration::UnassignService + .new(container: container, current_user: admin_bot) + .execute(delete_bot: true, skip_csp: false) + end + end + + private + + def policy_configurations(namespace_ids, project_ids) + Security::OrchestrationPolicyConfiguration + .for_namespace_and_projects(namespace_ids, project_ids) + end + + def admin_bot_for_container_organization(container) + @admin_bots ||= {} + @admin_bots[container.organization_id] ||= Users::Internal.for_organization(container.organization_id).admin_bot + end + end +end diff --git a/ee/spec/initializers/1_settings_spec.rb b/ee/spec/initializers/1_settings_spec.rb index 2d6aa24c8998d446f156f1e9b32810061727d7c8..21a64534ec70f562826ce646968d538a6d590fb5 100644 --- a/ee/spec/initializers/1_settings_spec.rb +++ b/ee/spec/initializers/1_settings_spec.rb @@ -171,6 +171,7 @@ security_orchestration_policy_rule_schedule_worker security_pipeline_execution_policies_schedule_worker security_scans_purge_worker + security_unassign_policy_configurations_for_expired_licenses_worker service_desk_custom_email_verification_cleanup ssh_keys_expired_notification_worker ssh_keys_expiring_soon_notification_worker diff --git a/ee/spec/models/ee/plan_spec.rb b/ee/spec/models/ee/plan_spec.rb index 79cfebd345b27f5e39f25986955fe0046643c8fa..06b102099e7ed334f79f606a7847d78a302aaaaf 100644 --- a/ee/spec/models/ee/plan_spec.rb +++ b/ee/spec/models/ee/plan_spec.rb @@ -96,6 +96,26 @@ end end + describe '#ultimate?' do + subject { plan.ultimate? } + + Plan.default_plans.each do |plan| + context "when '#{plan}'" do + let(:plan) { build(:"#{plan}_plan") } + + it { is_expected.to be_falsey } + end + end + + Plan::ALL_ULTIMATE_PLANS.each do |plan| + context "when '#{plan}'" do + let(:plan) { build(:"#{plan}_plan") } + + it { is_expected.to be_truthy } + end + end + end + describe '::PLANS_ELIGIBLE_FOR_TRIAL' do subject { described_class::PLANS_ELIGIBLE_FOR_TRIAL } @@ -108,6 +128,12 @@ it { is_expected.to match_array(%w[ultimate_trial ultimate_trial_paid_customer]) } end + describe '::ALL_ULTIMATE_PLANS' do + subject { described_class::ALL_ULTIMATE_PLANS } + + it { is_expected.to match_array(%w[ultimate ultimate_trial ultimate_trial_paid_customer]) } + end + describe '.with_subscriptions' do it 'includes plans that have attached subscriptions', :saas do group = create(:group_with_plan, plan: :free_plan) diff --git a/ee/spec/models/gitlab_subscriptions/subscription_history_spec.rb b/ee/spec/models/gitlab_subscriptions/subscription_history_spec.rb index 5de1cbb6b2f478cf08657bd08a69b7993c3e1de2..4d698de3e3ad5b0fd3ae13f18fc8d497ce4fc5e6 100644 --- a/ee/spec/models/gitlab_subscriptions/subscription_history_spec.rb +++ b/ee/spec/models/gitlab_subscriptions/subscription_history_spec.rb @@ -65,6 +65,69 @@ it { expect(transitioning_to_plan_after).to eq([]) } end end + + describe '.with_a_ultimate_hosted_plan' do + let_it_be(:ultimate_plan) { create(:ultimate_plan) } + let_it_be(:premium_plan) { create(:premium_plan) } + + let_it_be(:history_with_ultimate_plan) do + create(:gitlab_subscription_history, hosted_plan: ultimate_plan) + end + + let_it_be(:history_with_premium_plan) do + create(:gitlab_subscription_history, hosted_plan: premium_plan) + end + + it 'returns histories with any ultimate plan' do + ultimate_trial_plan = create(:ultimate_trial_plan) + history_with_ultimate_trial_plan = create(:gitlab_subscription_history, hosted_plan: ultimate_trial_plan) + + expect(described_class.with_a_ultimate_hosted_plan).to match_array([ + history_with_ultimate_plan, + history_with_ultimate_trial_plan + ]) + end + end + + describe '.ended_on' do + let_it_be(:history1) do + create(:gitlab_subscription_history, end_date: Date.current) + end + + let_it_be(:history2) do + create(:gitlab_subscription_history, end_date: Date.current + 1.day) + end + + let_it_be(:history3) do + create(:gitlab_subscription_history, end_date: Date.yesterday) + end + + it 'returns histories that ended on a given date' do + date = Date.yesterday + + expect(described_class.ended_on(date)).to contain_exactly(history3) + end + end + + describe '.with_namespace_subscription' do + let_it_be(:history_with_subscription) { create(:gitlab_subscription_history) } + let_it_be(:history_without_subscription) { create(:gitlab_subscription_history) } + + let(:query) do + described_class.with_namespace_subscription.find_each do |history| + history.namespace&.gitlab_subscription&.hosted_plan + end + end + + it 'preloads namespace and gitlab_subscription to avoid N+1 queries' do + control = ActiveRecord::QueryRecorder.new { query } + + create(:gitlab_subscription_history) + create(:gitlab_subscription_history) + + expect { query }.not_to exceed_query_limit(control) + end + end end describe '.create_from_change' do diff --git a/ee/spec/services/security/orchestration/unassign_service_spec.rb b/ee/spec/services/security/orchestration/unassign_service_spec.rb index a86cd8ed5dc765794bc14467c64b64a2a8e8e040..84d1d65706c7724d5375aecceaddf7d1fae4de35 100644 --- a/ee/spec/services/security/orchestration/unassign_service_spec.rb +++ b/ee/spec/services/security/orchestration/unassign_service_spec.rb @@ -68,11 +68,22 @@ include_context 'with csp group configuration' let(:service) { described_class.new(container: csp_group, current_user: current_user) } + let(:policy_configuration) { csp_group.security_orchestration_policy_configuration } it 'respond with an error', :aggregate_failures do expect(result).not_to be_success expect(result.message).to eq('You cannot unassign security policy project for group designated as CSP.') end + + context 'when skip_csp is set to false' do + subject(:result) { service.execute(skip_csp: false) } + + it 'unassigns policy project from the project', :aggregate_failures do + expect(result).to be_success + exists = Security::OrchestrationPolicyConfiguration.exists?(policy_configuration.id) + expect(exists).to be(false) + end + end end end diff --git a/ee/spec/workers/security/unassign_policy_configurations_for_expired_licenses_cron_worker_spec.rb b/ee/spec/workers/security/unassign_policy_configurations_for_expired_licenses_cron_worker_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..9cd21f135480232839107bf1633940fa09a0cc5c --- /dev/null +++ b/ee/spec/workers/security/unassign_policy_configurations_for_expired_licenses_cron_worker_spec.rb @@ -0,0 +1,157 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Security::UnassignPolicyConfigurationsForExpiredLicensesCronWorker, feature_category: :security_policy_management do + subject(:worker) { described_class.new } + + let(:namespace_unassign_worker) { Security::UnassignPolicyConfigurationsForExpiredNamespaceWorker } + + describe '#perform' do + shared_examples 'does not schedule unassign workers' do + it 'does not schedule any unassign workers' do + expect(namespace_unassign_worker).not_to receive(:perform_async) + + worker.perform + end + end + + shared_examples 'schedules unassign workers for all top-level namespaces' do + it 'schedules unassign workers for all top-level namespaces' do + [User.first.namespace_id, namespace.id].each do |namespace_id| + expect(namespace_unassign_worker).to receive(:perform_async).with(namespace_id) + end + + worker.perform + end + end + + context 'when on SaaS', :saas do + let_it_be(:ultimate_plan) { create(:ultimate_plan) } + let_it_be(:premium_plan) { create(:premium_plan) } + + let_it_be(:expired_ultimate_history) do + create(:gitlab_subscription_history, hosted_plan: ultimate_plan, end_date: 3.days.ago.to_date) + end + + let_it_be(:expired_premium_history) do + create(:gitlab_subscription_history, hosted_plan: premium_plan, end_date: 3.days.ago.to_date) + end + + let_it_be(:active_ultimate_history) do + create(:gitlab_subscription_history, hosted_plan: ultimate_plan, end_date: nil) + end + + before do + stub_saas_features(gitlab_com_subscriptions: true) + end + + it 'only schedules worker for expired ultimate subscriptions' do + expect(namespace_unassign_worker).to receive(:perform_async).with(expired_ultimate_history.namespace_id) + + [expired_premium_history, active_ultimate_history].each do |history| + expect(namespace_unassign_worker).not_to receive(:perform_async).with(history.namespace_id) + end + + worker.perform + end + + it 'avoids N+1 queries' do + control = ActiveRecord::QueryRecorder.new { worker.perform } + + create(:gitlab_subscription_history, hosted_plan: ultimate_plan, end_date: 3.days.ago.to_date) + create(:gitlab_subscription_history, hosted_plan: ultimate_plan, end_date: 3.days.ago.to_date) + + # +2 for using with_context(namespace: namespace) when scheduling worker + # SELECT "routes".* FROM "routes" WHERE "routes"."source_id" = ? AND "routes"."source_type" = 'Namespace' + # SELECT "routes".* FROM "routes" WHERE "routes"."source_id" = ? AND "routes"."source_type" = 'Namespace' + expect { worker.perform }.not_to exceed_query_limit(control).with_threshold(7) + end + + context 'when the namespace has a current subscription' do + let_it_be(:expired_but_downgraded_history) do + create(:gitlab_subscription_history, hosted_plan: ultimate_plan, end_date: 3.days.ago.to_date) + end + + before do + expired_but_downgraded_history.namespace.gitlab_subscription.update!(hosted_plan: premium_plan) + expired_ultimate_history.namespace.gitlab_subscription.update!(hosted_plan: ultimate_plan) + end + + it 'skips expired ultimate plans if currently active under ultimate' do + expect(namespace_unassign_worker).to receive(:perform_async).with(expired_but_downgraded_history.namespace_id) + expect(namespace_unassign_worker).not_to receive(:perform_async).with(expired_premium_history.namespace_id) + expect(namespace_unassign_worker).not_to receive(:perform_async).with(expired_ultimate_history.namespace_id) + + worker.perform + end + end + end + + context 'when on self-managed', :without_license do + let_it_be(:namespace) { create(:group) } + + before do + stub_saas_features(gitlab_com_subscriptions: false) + create(:security_orchestration_policy_configuration, :namespace, namespace: namespace) + end + + context 'when current license plan is ultimate' do + before do + create(:license, plan: License::ULTIMATE_PLAN) + end + + include_examples 'does not schedule unassign workers' + end + + context 'when current license plan is not ultimate' do + let!(:license) do + create(:license, plan: License::PREMIUM_PLAN, data: create(:gitlab_license, starts_at: starts_at).export) + end + + context 'when start date is before the buffer date' do + let(:starts_at) { 4.days.ago.to_date } + + include_examples 'schedules unassign workers for all top-level namespaces' + end + + context 'when start date is after the buffer date' do + let(:starts_at) { 2.days.ago.to_date } + + include_examples 'does not schedule unassign workers' + end + end + + context 'with latest license is expired' do + let!(:license) do + create(:license, plan: License::ULTIMATE_PLAN, data: create(:gitlab_license, expires_at: expires_at).export) + end + + context 'when expired before buffer date' do + let(:expires_at) { 4.days.ago.to_date } + + include_examples 'schedules unassign workers for all top-level namespaces' + end + + context 'when expired after buffer date' do + let(:expires_at) { 2.days.ago.to_date } + + include_examples 'does not schedule unassign workers' + end + end + + context 'without any license' do + include_examples 'schedules unassign workers for all top-level namespaces' + end + + context 'without any policy configurations' do + before do + create(:license, plan: License::ULTIMATE_PLAN) + Security::OrchestrationPolicyConfiguration.delete_all + end + + include_examples 'does not schedule unassign workers' + end + end + end +end diff --git a/ee/spec/workers/security/unassign_policy_configurations_for_expired_namespace_worker_spec.rb b/ee/spec/workers/security/unassign_policy_configurations_for_expired_namespace_worker_spec.rb new file mode 100644 index 0000000000000000000000000000000000000000..84c4ca1a057e9af421c0f71413684283a24e897d --- /dev/null +++ b/ee/spec/workers/security/unassign_policy_configurations_for_expired_namespace_worker_spec.rb @@ -0,0 +1,89 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Security::UnassignPolicyConfigurationsForExpiredNamespaceWorker, feature_category: :security_policy_management do + subject(:worker) { described_class.new } + + describe '#perform' do + let_it_be(:organization) { create(:organization) } + let_it_be(:group) { create(:group, organization: organization) } + let_it_be(:subgroup) { create(:group, parent: group) } + let_it_be(:project) { create(:project, group: group) } + let_it_be(:subproject) { create(:project, group: subgroup) } + let_it_be(:project_without_config) { create(:project, group: group) } + + let_it_be(:admin_bot) { create(:user, :admin_bot, organization: organization) } + + let_it_be(:group_policy_config) do + create(:security_orchestration_policy_configuration, :namespace, namespace: group) + end + + let_it_be(:subgroup_policy_config) do + create(:security_orchestration_policy_configuration, :namespace, namespace: subgroup) + end + + let_it_be(:project_policy_config) do + create(:security_orchestration_policy_configuration, project: project) + end + + let_it_be(:subproject_policy_config) do + create(:security_orchestration_policy_configuration, project: subproject) + end + + let_it_be(:other_policy_config) do + create(:security_orchestration_policy_configuration, :namespace) + end + + let(:unassign_service) { instance_double(Security::Orchestration::UnassignService) } + + before do + allow(Security::Orchestration::UnassignService).to receive(:new).and_return(unassign_service) + allow(unassign_service).to receive(:execute) + end + + context 'when namespace does not exist' do + it 'does not call UnassignService' do + worker.perform(non_existing_record_id) + + expect(Security::Orchestration::UnassignService).not_to have_received(:new) + end + end + + context 'when feature flag is disabled' do + before do + stub_feature_flags(automatically_unassign_security_policies_for_expired_licenses: false) + end + + it 'does not call UnassignService' do + worker.perform(group.id) + + expect(Security::Orchestration::UnassignService).not_to have_received(:new) + end + end + + context 'when namespace exists and feature flag is enabled' do + it 'calls UnassignService for each policy configuration in the namespace hierarchy' do + [group, subgroup, project, subproject].each do |container| + expect(Security::Orchestration::UnassignService).to receive(:new).with( + container: container, current_user: admin_bot + ).and_return(unassign_service) + end + + expect(unassign_service).to receive(:execute).with(delete_bot: true, skip_csp: false).exactly(4).times + + worker.perform(group.id) + end + + it 'does not call UnassignService for configurations outside the namespace hierarchy' do + [other_policy_config.namespace, project_without_config].each do |container| + expect(Security::Orchestration::UnassignService).not_to receive(:new).with( + container: container, current_user: anything + ) + end + + worker.perform(group.id) + end + end + end +end diff --git a/spec/models/plan_spec.rb b/spec/models/plan_spec.rb index e6717dd5c43bbc9e58ec8c64c5b805e9c094c6af..de5bfd3adb27b0a626fbe6a37be412f2d8fe0c48 100644 --- a/spec/models/plan_spec.rb +++ b/spec/models/plan_spec.rb @@ -77,4 +77,10 @@ it { is_expected.to eq([default_plan.name]) } end + + describe '#ultimate?' do + subject { described_class.new.ultimate? } + + it { is_expected.to be_falsey } + end end