From dfd78b8d774926305508cd0396c831bf990b97b5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Martin=20=C4=8Cavoj?= <mcavoj@gitlab.com>
Date: Fri, 26 Jul 2024 21:14:37 +0000
Subject: [PATCH] Refactor CI ProjectConfig sources to use static precedence

This change refactors sources for ProjectConfig to use a static list
of sources instead of manipulating it in EE to make the precedence of
sources clearer.

EE-only configs return `nil` for CE.
---
 .../gitlab/feature_available_usage.yml        |   1 -
 .rubocop_todo/gitlab/strong_memoize_attr.yml  |   1 -
 ee/lib/ee/gitlab/ci/project_config.rb         |  32 --
 .../ee/gitlab/ci/project_config/compliance.rb |  40 +++
 .../pipeline_execution_policy_forced.rb       |  41 +++
 .../project_config/security_policy_default.rb |  54 ++++
 ee/lib/gitlab/ci/project_config/compliance.rb |  44 ---
 .../pipeline_execution_policy_forced.rb       |  39 ---
 .../project_config/security_policy_default.rb |  52 ----
 .../lib/ee/gitlab/ci/project_config_spec.rb   | 287 ++++++++++++++++++
 ee/spec/lib/gitlab/ci/project_config_spec.rb  | 283 -----------------
 lib/gitlab/ci/project_config.rb               |  12 +-
 lib/gitlab/ci/project_config/compliance.rb    |  27 ++
 .../pipeline_execution_policy_forced.rb       |  23 ++
 .../project_config/security_policy_default.rb |  23 ++
 lib/gitlab/ci/project_config/source.rb        |   3 +-
 spec/lib/gitlab/ci/project_config_spec.rb     |  10 +-
 17 files changed, 513 insertions(+), 459 deletions(-)
 delete mode 100644 ee/lib/ee/gitlab/ci/project_config.rb
 create mode 100644 ee/lib/ee/gitlab/ci/project_config/compliance.rb
 create mode 100644 ee/lib/ee/gitlab/ci/project_config/pipeline_execution_policy_forced.rb
 create mode 100644 ee/lib/ee/gitlab/ci/project_config/security_policy_default.rb
 delete mode 100644 ee/lib/gitlab/ci/project_config/compliance.rb
 delete mode 100644 ee/lib/gitlab/ci/project_config/pipeline_execution_policy_forced.rb
 delete mode 100644 ee/lib/gitlab/ci/project_config/security_policy_default.rb
 create mode 100644 ee/spec/lib/ee/gitlab/ci/project_config_spec.rb
 delete mode 100644 ee/spec/lib/gitlab/ci/project_config_spec.rb
 create mode 100644 lib/gitlab/ci/project_config/compliance.rb
 create mode 100644 lib/gitlab/ci/project_config/pipeline_execution_policy_forced.rb
 create mode 100644 lib/gitlab/ci/project_config/security_policy_default.rb

diff --git a/.rubocop_todo/gitlab/feature_available_usage.yml b/.rubocop_todo/gitlab/feature_available_usage.yml
index 3833fe08cac75..bfdfba161363c 100644
--- a/.rubocop_todo/gitlab/feature_available_usage.yml
+++ b/.rubocop_todo/gitlab/feature_available_usage.yml
@@ -99,7 +99,6 @@ Gitlab/FeatureAvailableUsage:
     - 'ee/lib/ee/gitlab/gon_helper.rb'
     - 'ee/lib/ee/gitlab/tree_summary.rb'
     - 'ee/lib/gitlab/alert_management.rb'
-    - 'ee/lib/gitlab/ci/project_config/compliance.rb'
     - 'ee/lib/gitlab/code_owners.rb'
     - 'ee/lib/gitlab/path_locks_finder.rb'
     - 'ee/spec/models/ee/project_spec.rb'
diff --git a/.rubocop_todo/gitlab/strong_memoize_attr.yml b/.rubocop_todo/gitlab/strong_memoize_attr.yml
index 06b7d974b432f..8d7df60bdcf57 100644
--- a/.rubocop_todo/gitlab/strong_memoize_attr.yml
+++ b/.rubocop_todo/gitlab/strong_memoize_attr.yml
@@ -396,7 +396,6 @@ Gitlab/StrongMemoizeAttr:
     - 'ee/lib/gitlab/ci/minutes/cached_quota.rb'
     - 'ee/lib/gitlab/ci/minutes/gitlab_contribution_cost_factor.rb'
     - 'ee/lib/gitlab/ci/parsers/security/container_scanning.rb'
-    - 'ee/lib/gitlab/ci/project_config/compliance.rb'
     - 'ee/lib/gitlab/ci/reports/license_scanning/reports_comparer.rb'
     - 'ee/lib/gitlab/ci/reports/metrics/reports_comparer.rb'
     - 'ee/lib/gitlab/code_owners/entry.rb'
diff --git a/ee/lib/ee/gitlab/ci/project_config.rb b/ee/lib/ee/gitlab/ci/project_config.rb
deleted file mode 100644
index dae0913f684d0..0000000000000
--- a/ee/lib/ee/gitlab/ci/project_config.rb
+++ /dev/null
@@ -1,32 +0,0 @@
-# frozen_string_literal: true
-
-module EE
-  module Gitlab
-    module Ci
-      module ProjectConfig
-        extend ::Gitlab::Utils::Override
-
-        private
-
-        override :sources
-        def sources
-          # SecurityPolicyDefault should come last. It is only necessary if no other source is available.
-          sources = [::Gitlab::Ci::ProjectConfig::Compliance].concat(super)
-          # PipelineExecutionPolicyForced must come before AutoDevops because it handles
-          # the empty CI config case.
-          # We want to run Pipeline Execution Policies instead of AutoDevops (if they are present).
-          insert_before_autodevops(sources, ::Gitlab::Ci::ProjectConfig::PipelineExecutionPolicyForced)
-          sources.concat([::Gitlab::Ci::ProjectConfig::SecurityPolicyDefault])
-        end
-
-        def insert_before_autodevops(sources, new_source)
-          auto_devops_source_index = sources.find_index do |source|
-            source == ::Gitlab::Ci::ProjectConfig::AutoDevops
-          end
-
-          sources.insert(auto_devops_source_index, new_source)
-        end
-      end
-    end
-  end
-end
diff --git a/ee/lib/ee/gitlab/ci/project_config/compliance.rb b/ee/lib/ee/gitlab/ci/project_config/compliance.rb
new file mode 100644
index 0000000000000..d03fb0c132d91
--- /dev/null
+++ b/ee/lib/ee/gitlab/ci/project_config/compliance.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+module EE
+  module Gitlab
+    module Ci
+      module ProjectConfig
+        module Compliance
+          extend ::Gitlab::Utils::Override
+          include ::Gitlab::Utils::StrongMemoize
+
+          override :content
+          def content
+            return unless available?
+            return unless pipeline_configuration_full_path.present?
+            return if pipeline_source_bridge && pipeline_source == :parent_pipeline
+
+            return if [:security_orchestration_policy, :ondemand_dast_scan].include?(pipeline_source)
+
+            path_file, path_project = pipeline_configuration_full_path.split('@', 2)
+            YAML.dump('include' => [{ 'project' => path_project, 'file' => path_file }])
+          end
+          strong_memoize_attr :content
+
+          private
+
+          def pipeline_configuration_full_path
+            return unless project
+
+            project.compliance_pipeline_configuration_full_path
+          end
+          strong_memoize_attr :pipeline_configuration_full_path
+
+          def available?
+            project.licensed_feature_available?(:evaluate_group_level_compliance_pipeline)
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/ee/lib/ee/gitlab/ci/project_config/pipeline_execution_policy_forced.rb b/ee/lib/ee/gitlab/ci/project_config/pipeline_execution_policy_forced.rb
new file mode 100644
index 0000000000000..5bb596079fec8
--- /dev/null
+++ b/ee/lib/ee/gitlab/ci/project_config/pipeline_execution_policy_forced.rb
@@ -0,0 +1,41 @@
+# frozen_string_literal: true
+
+module EE
+  module Gitlab
+    module Ci
+      module ProjectConfig
+        # This class is responsible for generating the content of the `.gitlab-ci.yml`
+        # file that will trigger the execution of the pipeline execution policy jobs.
+        #
+        # If there are Pipeline Execution Policies defined for a project,
+        # this source will generate a dummy job that will force the creation
+        # of the project pipeline, so that the execution policy jobs can be merged
+        # into it. The dummy job will be removed in `MergeJobs` step of the pipeline chain.
+        #
+        # This source should be loaded before AutoDevops source.
+        module PipelineExecutionPolicyForced
+          extend ::Gitlab::Utils::Override
+          include ::Gitlab::Utils::StrongMemoize
+
+          DUMMY_CONTENT = {
+            'Pipeline execution policy trigger' => {
+              'stage' => ::Gitlab::Ci::Config::EdgeStagesInjector::PRE_PIPELINE,
+              'script' => ['echo "Forcing project pipeline to run policy jobs."']
+            }
+          }.freeze
+
+          override :content
+          def content
+            return if ::Feature.disabled?(:pipeline_execution_policy_type, project.group)
+            return unless has_pipeline_execution_policies
+
+            # Create a dummy job to ensure that project pipeline gets created.
+            # Pipeline execution policy jobs will be merged onto the project pipeline.
+            YAML.dump(DUMMY_CONTENT)
+          end
+          strong_memoize_attr :content
+        end
+      end
+    end
+  end
+end
diff --git a/ee/lib/ee/gitlab/ci/project_config/security_policy_default.rb b/ee/lib/ee/gitlab/ci/project_config/security_policy_default.rb
new file mode 100644
index 0000000000000..3a07a561268b4
--- /dev/null
+++ b/ee/lib/ee/gitlab/ci/project_config/security_policy_default.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+module EE
+  module Gitlab
+    module Ci
+      module ProjectConfig
+        module SecurityPolicyDefault
+          extend ::Gitlab::Utils::Override
+          include ::Gitlab::Utils::StrongMemoize
+
+          override :content
+          def content
+            return unless triggered_for_branch
+            return unless ::Enums::Ci::Pipeline.ci_and_security_orchestration_sources.key?(pipeline_source)
+            return unless project.licensed_feature_available?(:security_orchestration_policies)
+            return unless active_scan_execution_policies?
+
+            # We merge the security scans with the pipeline configuration in ee/lib/ee/gitlab/ci/config_ee.rb.
+            # An empty config with no content is enough to trigger the merge process when the Auto DevOps is disabled
+            # and no .gitlab-ci.yml is present.
+            YAML.dump(nil)
+          end
+          strong_memoize_attr :content
+
+          private
+
+          def active_scan_execution_policies?
+            return false unless ref
+
+            service = ::Security::SecurityOrchestrationPolicies::PolicyBranchesService.new(project: project)
+
+            ::Gitlab::Security::Orchestration::ProjectPolicyConfigurations
+              .new(project).all
+              .to_a
+              .flat_map(&:active_scan_execution_policies_for_pipelines)
+              .any? { |policy| policy_applicable?(policy) && applicable_for_branch?(service, policy) }
+          end
+
+          def policy_applicable?(policy)
+            ::Security::SecurityOrchestrationPolicies::PolicyScopeChecker
+              .new(project: project)
+              .policy_applicable?(policy)
+          end
+
+          def applicable_for_branch?(service, policy)
+            applicable_branches = service.scan_execution_branches(policy[:rules])
+
+            ref.in?(applicable_branches)
+          end
+        end
+      end
+    end
+  end
+end
diff --git a/ee/lib/gitlab/ci/project_config/compliance.rb b/ee/lib/gitlab/ci/project_config/compliance.rb
deleted file mode 100644
index 5015b9445fc6f..0000000000000
--- a/ee/lib/gitlab/ci/project_config/compliance.rb
+++ /dev/null
@@ -1,44 +0,0 @@
-# frozen_string_literal: true
-
-module Gitlab
-  module Ci
-    class ProjectConfig
-      class Compliance < Gitlab::Ci::ProjectConfig::Source
-        def content
-          strong_memoize(:content) do
-            next unless available?
-            next unless pipeline_configuration_full_path.present?
-            next if pipeline_source_bridge && pipeline_source == :parent_pipeline
-
-            next if [:security_orchestration_policy, :ondemand_dast_scan].include?(pipeline_source)
-
-            path_file, path_project = pipeline_configuration_full_path.split('@', 2)
-            YAML.dump('include' => [{ 'project' => path_project, 'file' => path_file }])
-          end
-        end
-
-        def internal_include_prepended?
-          true
-        end
-
-        def source
-          :compliance_source
-        end
-
-        private
-
-        def pipeline_configuration_full_path
-          strong_memoize(:pipeline_configuration_full_path) do
-            next unless project
-
-            project.compliance_pipeline_configuration_full_path
-          end
-        end
-
-        def available?
-          project.feature_available?(:evaluate_group_level_compliance_pipeline)
-        end
-      end
-    end
-  end
-end
diff --git a/ee/lib/gitlab/ci/project_config/pipeline_execution_policy_forced.rb b/ee/lib/gitlab/ci/project_config/pipeline_execution_policy_forced.rb
deleted file mode 100644
index a2242a5c528af..0000000000000
--- a/ee/lib/gitlab/ci/project_config/pipeline_execution_policy_forced.rb
+++ /dev/null
@@ -1,39 +0,0 @@
-# frozen_string_literal: true
-
-module Gitlab
-  module Ci
-    class ProjectConfig
-      # This class is responsible for generating the content of the `.gitlab-ci.yml`
-      # file that will trigger the execution of the pipeline execution policy jobs.
-      #
-      # If there are Pipeline Execution Policies defined for a project,
-      # this source will generate a dummy job that will force the creation
-      # of the project pipeline, so that the execution policy jobs can be merged
-      # into it. The dummy job will be removed in `MergeJobs` step of the pipeline chain.
-      #
-      # This source should be loaded before AutoDevops source.
-      class PipelineExecutionPolicyForced < Gitlab::Ci::ProjectConfig::Source
-        DUMMY_CONTENT = {
-          'Pipeline execution policy trigger' => {
-            'stage' => ::Gitlab::Ci::Config::EdgeStagesInjector::PRE_PIPELINE,
-            'script' => ['echo "Forcing project pipeline to run policy jobs."']
-          }
-        }.freeze
-
-        def content
-          return if ::Feature.disabled?(:pipeline_execution_policy_type, @project.group)
-          return unless @has_pipeline_execution_policies
-
-          # Create a dummy job to ensure that project pipeline gets created.
-          # Pipeline execution policy jobs will be merged onto the project pipeline.
-          YAML.dump(DUMMY_CONTENT)
-        end
-        strong_memoize_attr :content
-
-        def source
-          :pipeline_execution_policy_forced
-        end
-      end
-    end
-  end
-end
diff --git a/ee/lib/gitlab/ci/project_config/security_policy_default.rb b/ee/lib/gitlab/ci/project_config/security_policy_default.rb
deleted file mode 100644
index e975c68c207c5..0000000000000
--- a/ee/lib/gitlab/ci/project_config/security_policy_default.rb
+++ /dev/null
@@ -1,52 +0,0 @@
-# frozen_string_literal: true
-
-module Gitlab
-  module Ci
-    class ProjectConfig
-      class SecurityPolicyDefault < Gitlab::Ci::ProjectConfig::Source
-        def content
-          return unless @triggered_for_branch
-          return unless Enums::Ci::Pipeline.ci_and_security_orchestration_sources.key?(@pipeline_source)
-          return unless @project.licensed_feature_available?(:security_orchestration_policies)
-          return unless active_scan_execution_policies?
-
-          # We merge the security scans with the pipeline configuration in ee/lib/ee/gitlab/ci/config_ee.rb.
-          # An empty config with no content is enough to trigger the merge process when the Auto DevOps is disabled
-          # and no .gitlab-ci.yml is present.
-          YAML.dump(nil)
-        end
-        strong_memoize_attr :content
-
-        def source
-          :security_policies_default_source
-        end
-
-        private
-
-        def active_scan_execution_policies?
-          return false unless @ref
-
-          service = ::Security::SecurityOrchestrationPolicies::PolicyBranchesService.new(project: @project)
-
-          ::Gitlab::Security::Orchestration::ProjectPolicyConfigurations
-            .new(@project).all
-            .to_a
-            .flat_map(&:active_scan_execution_policies_for_pipelines)
-            .any? { |policy| policy_applicable?(policy) && applicable_for_branch?(service, policy) }
-        end
-
-        def policy_applicable?(policy)
-          ::Security::SecurityOrchestrationPolicies::PolicyScopeChecker
-            .new(project: @project)
-            .policy_applicable?(policy)
-        end
-
-        def applicable_for_branch?(service, policy)
-          applicable_branches = service.scan_execution_branches(policy[:rules])
-
-          @ref.in?(applicable_branches)
-        end
-      end
-    end
-  end
-end
diff --git a/ee/spec/lib/ee/gitlab/ci/project_config_spec.rb b/ee/spec/lib/ee/gitlab/ci/project_config_spec.rb
new file mode 100644
index 0000000000000..4ef86b8dacb9f
--- /dev/null
+++ b/ee/spec/lib/ee/gitlab/ci/project_config_spec.rb
@@ -0,0 +1,287 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Ci::ProjectConfig, feature_category: :pipeline_composition do
+  let_it_be_with_reload(:project) { create(:project, :empty_repo) }
+  let(:sha) { '123456' }
+  let(:content) { nil }
+  let(:source) { :push }
+  let(:bridge) { nil }
+  let(:triggered_for_branch) { true }
+  let(:ref) { 'master' }
+  let(:has_pipeline_execution_policies) { false }
+
+  subject(:config) do
+    described_class.new(
+      project: project,
+      sha: sha,
+      custom_content: content,
+      pipeline_source: source,
+      pipeline_source_bridge: bridge,
+      triggered_for_branch: triggered_for_branch,
+      ref: ref,
+      has_pipeline_execution_policies: has_pipeline_execution_policies
+    )
+  end
+
+  context 'when config is Compliance' do
+    let(:content_result) do
+      <<~CICONFIG
+        ---
+        include:
+        - project: compliance/hippa
+          file: ".compliance-gitlab-ci.yml"
+      CICONFIG
+    end
+
+    shared_examples 'does not include compliance pipeline configuration content' do
+      it do
+        expect(config.source).not_to eq(:compliance_source)
+        expect(config.content).not_to eq(content_result)
+      end
+    end
+
+    context 'when project has compliance label defined' do
+      let_it_be(:compliance_group) { create(:group, :private, name: "compliance") }
+      let_it_be(:compliance_project) { create(:project, namespace: compliance_group, name: "hippa") }
+
+      context 'when feature is available' do
+        before do
+          stub_licensed_features(evaluate_group_level_compliance_pipeline: true)
+        end
+
+        context 'when compliance pipeline configuration is defined' do
+          let(:framework) do
+            create(
+              :compliance_framework,
+              namespace: compliance_group,
+              pipeline_configuration_full_path: ".compliance-gitlab-ci.yml@compliance/hippa"
+            )
+          end
+
+          let!(:framework_project_setting) do
+            create(:compliance_framework_project_setting, project: project,
+              compliance_management_framework: framework)
+          end
+
+          it 'includes compliance pipeline configuration content' do
+            expect(config.source).to eq(:compliance_source)
+            expect(config.content).to eq(content_result)
+          end
+
+          context 'when pipeline is downstream of a bridge' do
+            let(:bridge) { create(:ci_bridge) }
+
+            it 'does include compliance pipeline configuration' do
+              expect(config.source).to eq(:compliance_source)
+              expect(config.content).to eq(content_result)
+            end
+
+            context 'when pipeline source is parent pipeline' do
+              let(:source) { :parent_pipeline }
+
+              it_behaves_like 'does not include compliance pipeline configuration content'
+            end
+          end
+
+          context 'when the source is on-demand dast scan' do
+            let(:source) { :ondemand_dast_scan }
+            let(:content) { "---\ninclude:\n- template: DAST-On-Demand-Scan.gitlab-ci.yml\n" }
+            let(:content_result) do
+              <<~CICONFIG
+                ---
+                include:
+                - template: DAST-On-Demand-Scan.gitlab-ci.yml
+              CICONFIG
+            end
+
+            it 'does not include compliance pipeline configuration' do
+              expect(config.source).to eq(:parameter_source)
+              expect(config.content).to eq(content_result)
+            end
+          end
+        end
+
+        context 'when compliance pipeline configuration is not defined' do
+          let(:framework) { create(:compliance_framework, namespace: compliance_group) }
+          let!(:framework_project_setting) do
+            create(:compliance_framework_project_setting, project: project,
+              compliance_management_framework: framework)
+          end
+
+          it_behaves_like 'does not include compliance pipeline configuration content'
+        end
+
+        context 'when compliance pipeline configuration is empty' do
+          let(:framework) do
+            create(:compliance_framework, namespace: compliance_group, pipeline_configuration_full_path: '')
+          end
+
+          let!(:framework_project_setting) do
+            create(:compliance_framework_project_setting, project: project,
+              compliance_management_framework: framework)
+          end
+
+          it_behaves_like 'does not include compliance pipeline configuration content'
+        end
+      end
+
+      context 'when feature is not licensed' do
+        before do
+          stub_licensed_features(evaluate_group_level_compliance_pipeline: false)
+        end
+
+        it_behaves_like 'does not include compliance pipeline configuration content'
+      end
+    end
+
+    context 'when project does not have compliance label defined' do
+      context 'when feature is available' do
+        before do
+          stub_licensed_features(evaluate_group_level_compliance_pipeline: true)
+        end
+
+        it_behaves_like 'does not include compliance pipeline configuration content'
+      end
+    end
+  end
+
+  context 'when config is SecurityPolicyDefault' do
+    let_it_be_with_reload(:project) { create(:project, :repository) }
+    let!(:security_policy_configuration) do
+      create(:security_orchestration_policy_configuration, project: project)
+    end
+
+    let(:policy) { build(:scan_execution_policy, enabled: true, rules: [rule]) }
+    let(:branches) { %w[master production] }
+
+    before do
+      allow(project).to receive(:all_security_orchestration_policy_configurations)
+                          .and_return([security_policy_configuration])
+
+      allow(security_policy_configuration).to receive(:active_scan_execution_policies).and_return([policy])
+    end
+
+    context 'when policies should be enforced' do
+      context 'when security_orchestration_policies feature is available' do
+        before do
+          stub_licensed_features(security_orchestration_policies: true)
+        end
+
+        let(:security_policy_default_content) { YAML.dump(nil) }
+
+        context 'when auto devops is not enabled' do
+          before do
+            stub_application_setting(auto_devops_enabled: false)
+          end
+
+          context 'when active policies includes a rule with pipeline type' do
+            let(:rule) { { type: 'pipeline', branches: branches } }
+
+            context 'when policy applies to the pipeline\'s branch' do
+              it 'includes security policies default pipeline configuration content' do
+                expect(config.source).to eq(:security_policies_default_source)
+                expect(config.content).to eq(security_policy_default_content)
+              end
+            end
+          end
+        end
+      end
+    end
+
+    context 'when policies should not be enforced' do
+      let(:rule) { { type: 'pipeline', branches: branches } }
+      let(:licensed_security_orchestration_policies) { true }
+
+      before do
+        stub_licensed_features(security_orchestration_policies: licensed_security_orchestration_policies)
+      end
+
+      shared_examples 'does not include security policies default pipeline configuration content' do
+        context 'when auto devops is not enabled' do
+          before do
+            stub_application_setting(auto_devops_enabled: false)
+          end
+
+          it 'does not include security policies default pipeline configuration content' do
+            expect(config.source).to eq(nil)
+          end
+        end
+      end
+
+      context 'when security_orchestration_policies feature is not available' do
+        let(:licensed_security_orchestration_policies) { false }
+
+        it_behaves_like 'does not include security policies default pipeline configuration content'
+      end
+
+      context 'when is not triggered for branch' do
+        let(:triggered_for_branch) { false }
+
+        it_behaves_like 'does not include security policies default pipeline configuration content'
+      end
+
+      context 'when auto devops is enabled' do
+        it 'does not include security policies default pipeline configuration content' do
+          expect(config.source).to eq(:auto_devops_source)
+        end
+      end
+
+      context 'when auto devops is not enabled' do
+        before do
+          stub_application_setting(auto_devops_enabled: false)
+        end
+
+        context 'when active policies does not include a rule with pipeline type' do
+          let(:rule) { { type: 'schedule', branches: branches, cadence: '*/20 * * * *' } }
+
+          it 'does not include security policies default pipeline configuration content' do
+            expect(config.source).to eq(nil)
+          end
+        end
+
+        context 'when policy does not apply to the branch' do
+          let(:rule) { { type: 'pipeline', branches: ['main'] } }
+
+          it 'does not include security policies default pipeline configuration content' do
+            expect(config.source).to eq(nil)
+          end
+        end
+
+        context 'when the policy should not be enforced to the pipeline source' do
+          Enums::Ci::Pipeline.dangling_sources.except(:security_orchestration_policy).each_key do |source|
+            context "when pipeline source is #{source}" do
+              let(:source) { source }
+
+              it_behaves_like 'does not include security policies default pipeline configuration content'
+            end
+          end
+        end
+      end
+    end
+  end
+
+  context 'when config is PipelineExecutionPolicyForced' do
+    let(:has_pipeline_execution_policies) { true }
+
+    shared_examples_for 'forces the pipeline creation by including dummy content' do
+      let(:expected_content) { YAML.dump(Gitlab::Ci::ProjectConfig::PipelineExecutionPolicyForced::DUMMY_CONTENT) }
+
+      it 'includes dummy job to force the pipeline creation' do
+        expect(config.source).to eq(:pipeline_execution_policy_forced)
+        expect(config.content).to eq(expected_content)
+      end
+    end
+
+    it_behaves_like 'forces the pipeline creation by including dummy content'
+
+    context 'when auto devops is not enabled' do
+      before do
+        stub_application_setting(auto_devops_enabled: false)
+      end
+
+      it_behaves_like 'forces the pipeline creation by including dummy content'
+    end
+  end
+end
diff --git a/ee/spec/lib/gitlab/ci/project_config_spec.rb b/ee/spec/lib/gitlab/ci/project_config_spec.rb
deleted file mode 100644
index 98231d315aabc..0000000000000
--- a/ee/spec/lib/gitlab/ci/project_config_spec.rb
+++ /dev/null
@@ -1,283 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe ::Gitlab::Ci::ProjectConfig, feature_category: :continuous_integration do
-  let_it_be_with_reload(:project) { create(:project, :repository, ci_config_path: nil) }
-  let(:sha) { '123456' }
-  let(:content) { nil }
-  let(:source) { :push }
-  let(:bridge) { nil }
-  let(:triggered_for_branch) { true }
-  let(:ref) { 'master' }
-  let(:security_policies) { {} }
-  let(:has_pipeline_execution_policies) { false }
-
-  let(:content_result) do
-    <<~CICONFIG
-      ---
-      include:
-      - project: compliance/hippa
-        file: ".compliance-gitlab-ci.yml"
-    CICONFIG
-  end
-
-  subject(:config) do
-    described_class.new(
-      project: project,
-      sha: sha,
-      custom_content: content,
-      pipeline_source: source,
-      pipeline_source_bridge: bridge,
-      triggered_for_branch: triggered_for_branch,
-      ref: ref,
-      has_pipeline_execution_policies: has_pipeline_execution_policies
-    )
-  end
-
-  shared_examples 'does not include compliance pipeline configuration content' do
-    it do
-      expect(config.source).not_to eq(:compliance_source)
-      expect(config.content).not_to eq(content_result)
-    end
-  end
-
-  context 'when project has compliance label defined' do
-    let(:compliance_group) { create(:group, :private, name: "compliance") }
-    let(:compliance_project) { create(:project, namespace: compliance_group, name: "hippa") }
-
-    context 'when feature is available' do
-      before do
-        stub_licensed_features(evaluate_group_level_compliance_pipeline: true)
-      end
-
-      context 'when compliance pipeline configuration is defined' do
-        let(:framework) do
-          create(
-            :compliance_framework,
-            namespace: compliance_group,
-            pipeline_configuration_full_path: ".compliance-gitlab-ci.yml@compliance/hippa"
-          )
-        end
-
-        let!(:framework_project_setting) do
-          create(:compliance_framework_project_setting, project: project, compliance_management_framework: framework)
-        end
-
-        it 'includes compliance pipeline configuration content' do
-          expect(config.source).to eq(:compliance_source)
-          expect(config.content).to eq(content_result)
-        end
-
-        context 'when pipeline is downstream of a bridge' do
-          let(:bridge) { create(:ci_bridge) }
-
-          it 'does include compliance pipeline configuration' do
-            expect(config.source).to eq(:compliance_source)
-            expect(config.content).to eq(content_result)
-          end
-
-          context 'when pipeline source is parent pipeline' do
-            let(:source) { :parent_pipeline }
-
-            it_behaves_like 'does not include compliance pipeline configuration content'
-          end
-        end
-
-        context 'when the source is on-demand dast scan' do
-          let(:source) { :ondemand_dast_scan }
-          let(:content) { "---\ninclude:\n- template: DAST-On-Demand-Scan.gitlab-ci.yml\n" }
-          let(:content_result) do
-            <<~CICONFIG
-              ---
-              include:
-              - template: DAST-On-Demand-Scan.gitlab-ci.yml
-            CICONFIG
-          end
-
-          it 'does not include compliance pipeline configuration' do
-            expect(config.source).to eq(:parameter_source)
-            expect(config.content).to eq(content_result)
-          end
-        end
-      end
-
-      context 'when compliance pipeline configuration is not defined' do
-        let(:framework) { create(:compliance_framework, namespace: compliance_group) }
-        let!(:framework_project_setting) do
-          create(:compliance_framework_project_setting, project: project, compliance_management_framework: framework)
-        end
-
-        it_behaves_like 'does not include compliance pipeline configuration content'
-      end
-
-      context 'when compliance pipeline configuration is empty' do
-        let(:framework) do
-          create(:compliance_framework, namespace: compliance_group, pipeline_configuration_full_path: '')
-        end
-
-        let!(:framework_project_setting) do
-          create(:compliance_framework_project_setting, project: project, compliance_management_framework: framework)
-        end
-
-        it_behaves_like 'does not include compliance pipeline configuration content'
-      end
-    end
-
-    context 'when feature is not licensed' do
-      before do
-        stub_licensed_features(evaluate_group_level_compliance_pipeline: false)
-      end
-
-      it_behaves_like 'does not include compliance pipeline configuration content'
-    end
-  end
-
-  context 'when project does not have compliance label defined' do
-    context 'when feature is available' do
-      before do
-        stub_licensed_features(evaluate_group_level_compliance_pipeline: true)
-      end
-
-      it_behaves_like 'does not include compliance pipeline configuration content'
-    end
-
-    context 'when project has active scan_execution policies' do
-      let(:security_policies) { { enabled: true, policies: [security_policy_configuration] } }
-      let!(:security_policy_configuration) do
-        create(:security_orchestration_policy_configuration, project: project)
-      end
-
-      let(:policy) { build(:scan_execution_policy, enabled: true, rules: [rule]) }
-      let(:branches) { %w[master production] }
-
-      before do
-        allow(project).to receive(:all_security_orchestration_policy_configurations)
-                            .and_return([security_policy_configuration])
-
-        allow(security_policy_configuration).to receive(:active_scan_execution_policies).and_return([policy])
-      end
-
-      context 'when policies should be enforced' do
-        context 'when security_orchestration_policies feature is available' do
-          before do
-            stub_licensed_features(security_orchestration_policies: true)
-          end
-
-          let(:security_policy_default_content) { YAML.dump(nil) }
-
-          context 'when auto devops is not enabled' do
-            before do
-              stub_application_setting(auto_devops_enabled: false)
-            end
-
-            context 'when active policies includes a rule with pipeline type' do
-              let(:rule) { { type: 'pipeline', branches: branches } }
-
-              context 'when policy applies to the pipeline\'s branch' do
-                it 'includes security policies default pipeline configuration content' do
-                  expect(config.source).to eq(:security_policies_default_source)
-                  expect(config.content).to eq(security_policy_default_content)
-                end
-              end
-            end
-          end
-        end
-      end
-
-      context 'when policies should not be enforced' do
-        let(:rule) { { type: 'pipeline', branches: branches } }
-        let(:licensed_security_orchestration_policies) { true }
-
-        before do
-          stub_licensed_features(security_orchestration_policies: licensed_security_orchestration_policies)
-        end
-
-        shared_examples 'does not include security policies default pipeline configuration content' do
-          context 'when auto devops is not enabled' do
-            before do
-              stub_application_setting(auto_devops_enabled: false)
-            end
-
-            it 'does not include security policies default pipeline configuration content' do
-              expect(config.source).to eq(nil)
-            end
-          end
-        end
-
-        context 'when security_orchestration_policies feature is not available' do
-          let(:licensed_security_orchestration_policies) { false }
-
-          it_behaves_like 'does not include security policies default pipeline configuration content'
-        end
-
-        context 'when is not triggered for branch' do
-          let(:triggered_for_branch) { false }
-
-          it_behaves_like 'does not include security policies default pipeline configuration content'
-        end
-
-        context 'when auto devops is enabled' do
-          it 'does not include security policies default pipeline configuration content' do
-            expect(config.source).to eq(:auto_devops_source)
-          end
-        end
-
-        context 'when auto devops is not enabled' do
-          before do
-            stub_application_setting(auto_devops_enabled: false)
-          end
-
-          context 'when active policies does not include a rule with pipeline type' do
-            let(:rule) { { type: 'schedule', branches: branches, cadence: '*/20 * * * *' } }
-
-            it 'does not include security policies default pipeline configuration content' do
-              expect(config.source).to eq(nil)
-            end
-          end
-
-          context 'when policy does not apply to the branch' do
-            let(:rule) { { type: 'pipeline', branches: ['main'] } }
-
-            it 'does not include security policies default pipeline configuration content' do
-              expect(config.source).to eq(nil)
-            end
-          end
-
-          context 'when the policy should not be enforced to the pipeline source' do
-            Enums::Ci::Pipeline.dangling_sources.except(:security_orchestration_policy).each_key do |source|
-              context "when pipeline source is #{source}" do
-                let(:source) { source }
-
-                it_behaves_like 'does not include security policies default pipeline configuration content'
-              end
-            end
-          end
-        end
-      end
-    end
-
-    context 'when project has active pipeline execution policies' do
-      shared_examples_for 'forces the pipeline creation by including dummy content' do
-        let(:expected_content) { YAML.dump(Gitlab::Ci::ProjectConfig::PipelineExecutionPolicyForced::DUMMY_CONTENT) }
-
-        it 'includes dummy job to force the pipeline creation' do
-          expect(config.source).to eq(:pipeline_execution_policy_forced)
-          expect(config.content).to eq(expected_content)
-        end
-      end
-
-      let(:has_pipeline_execution_policies) { true }
-
-      it_behaves_like 'forces the pipeline creation by including dummy content'
-
-      context 'when auto devops is not enabled' do
-        before do
-          stub_application_setting(auto_devops_enabled: false)
-        end
-
-        it_behaves_like 'forces the pipeline creation by including dummy content'
-      end
-    end
-  end
-end
diff --git a/lib/gitlab/ci/project_config.rb b/lib/gitlab/ci/project_config.rb
index 72ced07619929..dd00c2b254244 100644
--- a/lib/gitlab/ci/project_config.rb
+++ b/lib/gitlab/ci/project_config.rb
@@ -6,19 +6,25 @@ module Ci
     class ProjectConfig
       # The order of sources is important:
       # - EE uses Compliance first since it must be used first if compliance templates are enabled.
-      #   (see ee/lib/ee/gitlab/ci/project_config.rb)
       # - Parameter is used by on-demand security scanning which passes the actual CI YAML to use as argument.
       # - Bridge is used for downstream pipelines since the config is defined in the bridge job. If lower in priority,
       #   it would evaluate the project's YAML file instead.
       # - Repository / ExternalProject / Remote: their order is not important between each other.
+      # - EE uses PipelineExecutionPolicyForced and it must come before AutoDevops because
+      #   it handles the empty CI config case.
+      #   We want to run Pipeline Execution Policies instead of AutoDevops (if they are present).
       # - AutoDevops is used as default option if nothing else is found and if AutoDevops is enabled.
+      # - EE uses SecurityPolicyDefault and it should come last. It is only necessary if no other source is available.
       SOURCES = [
+        ProjectConfig::Compliance,
         ProjectConfig::Parameter,
         ProjectConfig::Bridge,
         ProjectConfig::Repository,
         ProjectConfig::ExternalProject,
         ProjectConfig::Remote,
-        ProjectConfig::AutoDevops
+        ProjectConfig::PipelineExecutionPolicyForced,
+        ProjectConfig::AutoDevops,
+        ProjectConfig::SecurityPolicyDefault
       ].freeze
 
       def initialize(
@@ -58,5 +64,3 @@ def sources
     end
   end
 end
-
-Gitlab::Ci::ProjectConfig.prepend_mod_with('Gitlab::Ci::ProjectConfig')
diff --git a/lib/gitlab/ci/project_config/compliance.rb b/lib/gitlab/ci/project_config/compliance.rb
new file mode 100644
index 0000000000000..bbc058817e666
--- /dev/null
+++ b/lib/gitlab/ci/project_config/compliance.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module Gitlab
+  module Ci
+    class ProjectConfig
+      class Compliance < Gitlab::Ci::ProjectConfig::Source
+        # rubocop:disable Gitlab/NoCodeCoverageComment -- overridden and tested in EE
+        # :nocov:
+        def content
+          nil
+        end
+        # :nocov:
+        # rubocop:enable Gitlab/NoCodeCoverageComment
+
+        def internal_include_prepended?
+          true
+        end
+
+        def source
+          :compliance_source
+        end
+      end
+    end
+  end
+end
+
+Gitlab::Ci::ProjectConfig::Compliance.prepend_mod
diff --git a/lib/gitlab/ci/project_config/pipeline_execution_policy_forced.rb b/lib/gitlab/ci/project_config/pipeline_execution_policy_forced.rb
new file mode 100644
index 0000000000000..2582d816d0a86
--- /dev/null
+++ b/lib/gitlab/ci/project_config/pipeline_execution_policy_forced.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Gitlab
+  module Ci
+    class ProjectConfig
+      class PipelineExecutionPolicyForced < Gitlab::Ci::ProjectConfig::Source
+        # rubocop:disable Gitlab/NoCodeCoverageComment -- overridden and tested in EE
+        # :nocov:
+        def content
+          nil
+        end
+        # :nocov:
+        # rubocop:enable Gitlab/NoCodeCoverageComment
+
+        def source
+          :pipeline_execution_policy_forced
+        end
+      end
+    end
+  end
+end
+
+Gitlab::Ci::ProjectConfig::PipelineExecutionPolicyForced.prepend_mod
diff --git a/lib/gitlab/ci/project_config/security_policy_default.rb b/lib/gitlab/ci/project_config/security_policy_default.rb
new file mode 100644
index 0000000000000..d2b46780ce0e6
--- /dev/null
+++ b/lib/gitlab/ci/project_config/security_policy_default.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+module Gitlab
+  module Ci
+    class ProjectConfig
+      class SecurityPolicyDefault < Gitlab::Ci::ProjectConfig::Source
+        # rubocop:disable Gitlab/NoCodeCoverageComment -- overridden and tested in EE
+        # :nocov:
+        def content
+          nil
+        end
+        # :nocov:
+        # rubocop:enable Gitlab/NoCodeCoverageComment
+
+        def source
+          :security_policies_default_source
+        end
+      end
+    end
+  end
+end
+
+Gitlab::Ci::ProjectConfig::SecurityPolicyDefault.prepend_mod
diff --git a/lib/gitlab/ci/project_config/source.rb b/lib/gitlab/ci/project_config/source.rb
index b7a00c0f2dce9..1b3c565d47f4b 100644
--- a/lib/gitlab/ci/project_config/source.rb
+++ b/lib/gitlab/ci/project_config/source.rb
@@ -44,7 +44,8 @@ def url
 
         private
 
-        attr_reader :project, :sha, :custom_content, :pipeline_source, :pipeline_source_bridge
+        attr_reader :project, :sha, :custom_content, :pipeline_source, :pipeline_source_bridge, :triggered_for_branch,
+          :ref, :has_pipeline_execution_policies
 
         def ci_config_path
           @ci_config_path ||= project.ci_config_path_or_default
diff --git a/spec/lib/gitlab/ci/project_config_spec.rb b/spec/lib/gitlab/ci/project_config_spec.rb
index 76030f7c0b2f3..3405fb50f2273 100644
--- a/spec/lib/gitlab/ci/project_config_spec.rb
+++ b/spec/lib/gitlab/ci/project_config_spec.rb
@@ -3,11 +3,14 @@
 require 'spec_helper'
 
 RSpec.describe Gitlab::Ci::ProjectConfig, feature_category: :pipeline_composition do
-  let_it_be(:project) { create(:project, :empty_repo) }
+  let_it_be_with_reload(:project) { create(:project, :empty_repo) }
   let(:sha) { '123456' }
   let(:content) { nil }
   let(:source) { :push }
   let(:bridge) { nil }
+  let(:triggered_for_branch) { true }
+  let(:ref) { 'master' }
+  let(:has_pipeline_execution_policies) { false }
 
   subject(:config) do
     described_class.new(
@@ -15,7 +18,10 @@
       sha: sha,
       custom_content: content,
       pipeline_source: source,
-      pipeline_source_bridge: bridge
+      pipeline_source_bridge: bridge,
+      triggered_for_branch: triggered_for_branch,
+      ref: ref,
+      has_pipeline_execution_policies: has_pipeline_execution_policies
     )
   end
 
-- 
GitLab