Skip to content

[backend] Add pipeline execution schedule policy schema and models

Following the introduction of the pipeline execution policy, which allows for enforcing CI jobs/scripts within triggered pipelines, we plan to extend support for scheduled enforcement.

The feature can be introduced as a new policy type. This will make it easier to enforce limits, keeps the schema simple, and needs fewer conditions in backend code. The schema can be similar to the pipeline execution policy schema, with the following differences:

  • pipeline_config_strategy is not needed because there will only be one strategy. It behaves like override_project_ci except that other policies can't be injected into it.
  • The schema needs schedule conditions containing the cadence and time zone. Later we can add branch filters to it.

Example policy

pipeline_execution_schedule_policy:
- name: test schedule
  description: ''
  enabled: true
  schedule:
    cadence: 0 0 * * *
  content:
    include:
    - project: my-group/policy-ci-config
      file: policy-ci.yml

Following the pattern of other policy types, the new type needs a model concern ee/app/models/concerns/security/pipeline_execution_schedule_policy.rb and some modifications in the Security::Policy model.

To keep track of scheduled pipeline runs, we need to introduce a new table similar to security_orchestration_policy_rule_schedules, that references security_policies and projects.

Implementation plan

Diff
diff --git a/db/migrate/20241107122451_create_security_pipeline_execution_schedules.rb b/db/migrate/20241107122451_create_security_pipeline_execution_schedules.rb
new file mode 100644
index 000000000000..5f611ca8d8f7
--- /dev/null
+++ b/db/migrate/20241107122451_create_security_pipeline_execution_schedules.rb
@@ -0,0 +1,17 @@
+# frozen_string_literal: true
+
+class CreateSecurityPipelineExecutionSchedules < Gitlab::Database::Migration[2.2]
+  milestone '17.6'
+
+  def change
+    create_table :security_pipeline_execution_schedules do |t|
+      t.timestamps_with_timezone null: false
+      t.references :security_policy, foreign_key: { on_delete: :cascade }, null: false, index: false
+      t.references :project, foreign_key: { on_delete: :cascade }, null: false, index: false
+      t.text :cron, null: false, limit: 255
+      t.datetime_with_timezone :next_run_at, null: false
+    end
+  end
+end
diff --git a/ee/app/models/concerns/security/pipeline_execution_schedule_policy.rb b/ee/app/models/concerns/security/pipeline_execution_schedule_policy.rb
new file mode 100644
index 000000000000..738f4977f487
--- /dev/null
+++ b/ee/app/models/concerns/security/pipeline_execution_schedule_policy.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+module Security
+  module PipelineExecutionSchedulePolicy
+    POLICY_LIMIT = 5
+
+    def active_pipeline_execution_schedule_policies
+      pipeline_execution_policy.select { |config| config[:enabled] }.first(POLICY_LIMIT)
+    end
+
+    def pipeline_execution_schedule_policy
+      policy_by_type(:pipeline_execution_schedule_policy)
+    end
+  end
+end
diff --git a/ee/app/models/security/orchestration_policy_configuration.rb b/ee/app/models/security/orchestration_policy_configuration.rb
index 57f3afcb894d..6fadca07e618 100644
--- a/ee/app/models/security/orchestration_policy_configuration.rb
+++ b/ee/app/models/security/orchestration_policy_configuration.rb
@@ -8,6 +8,7 @@ class OrchestrationPolicyConfiguration < ApplicationRecord
     include Security::PipelineExecutionPolicy
     include Security::CiComponentPublishingPolicy
     include Security::VulnerabilityManagementPolicy
+    include Security::PipelineExecutionSchedulePolicy
     include EachBatch
     include Gitlab::Utils::StrongMemoize
     include FromUnion
diff --git a/ee/app/models/security/pipeline_execution_policy/schedule.rb b/ee/app/models/security/pipeline_execution_policy/schedule.rb
new file mode 100644
index 000000000000..ed39e20fd975
--- /dev/null
+++ b/ee/app/models/security/pipeline_execution_policy/schedule.rb
@@ -0,0 +1,38 @@
+# frozen_string_literal: true
+
+module Security
+  module PipelineExecutionPolicy
+    class Schedule < ApplicationRecord
+      before_save :set_cron
+
+      include Schedulable
+      include CronSchedulable
+
+      self.table_name = 'security_pipeline_execution_schedules'
+
+      belongs_to :project
+      belongs_to :security_policy, class_name: 'Security::Policy', inverse_of: :security_policy_project_links
+  
+      validates :security_policy, uniqueness: { scope: :project_id }
+
+      scope :for_project, ->(project) { where(project: project) }
+      scope :runnable, -> { where("next_run_at < ?", Time.zone.now) }
+
+      private
+
+      def set_cron
+        self.cron = security_policy.schedule
+      end
+
+      def cron_timezone
+        #TODO: Store time zone in DB with default Time.zone.name
+
+        Time.zone.name
+      end
+
+      def worker_cron_expression
+        Settings.cron_jobs['security_pipeline_execution_policies_schedule_worker']['cron']
+      end
+    end
+  end
+end
diff --git a/ee/app/models/security/policy.rb b/ee/app/models/security/policy.rb
index 9129c476f472..80e7e0871f64 100644
--- a/ee/app/models/security/policy.rb
+++ b/ee/app/models/security/policy.rb
@@ -13,7 +13,8 @@ class Policy < ApplicationRecord
       approval_policy: %i[actions approval_settings fallback_behavior policy_tuning],
       scan_execution_policy: %i[actions],
       pipeline_execution_policy: %i[content pipeline_config_strategy suffix],
-      vulnerability_management_policy: %i[actions]
+      vulnerability_management_policy: %i[actions],
+      pipeline_execution_schedule_policy: %i[content schedule]
     }.freeze
 
     belongs_to :security_orchestration_policy_configuration, class_name: 'Security::OrchestrationPolicyConfiguration'
@@ -25,6 +26,8 @@ class Policy < ApplicationRecord
       foreign_key: 'security_policy_id', inverse_of: :security_policy
     has_many :security_policy_project_links, class_name: 'Security::PolicyProjectLink',
       foreign_key: :security_policy_id, inverse_of: :security_policy
+    has_many :security_pipeline_execution_schedules, class_name: 'Security::PipelineExecutionPolicy::Schedule',
+      foreign_key: :security_policy_id, inverse_of: :security_policy
 
     has_many :projects, through: :security_policy_project_links
 
@@ -32,7 +35,8 @@ class Policy < ApplicationRecord
       approval_policy: 0,
       scan_execution_policy: 1,
       pipeline_execution_policy: 2,
-      vulnerability_management_policy: 3
+      vulnerability_management_policy: 3,
+      pipeline_execution_schedule_policy: 4
     }, _prefix: true
 
     validates :security_orchestration_policy_configuration_id,
@@ -93,6 +97,7 @@ def self.delete_by_ids(ids)
     def link_project!(project)
       transaction do
         security_policy_project_links.for_project(project).first_or_create!
+        security_pipeline_execution_schedules.for_project(project).first_or_create! if type_pipeline_execution_schedule_policy?
         link_policy_rules_project!(project)
       end
     end
@@ -100,6 +105,7 @@ def link_project!(project)
     def unlink_project!(project)
       transaction do
         security_policy_project_links.for_project(project).delete_all
+        security_pipeline_execution_schedules.for_project(project).delete_all
         unlink_policy_rules_project!(project)
       end
     end
@@ -171,6 +177,10 @@ def delete_scan_execution_policy_rules
       scan_execution_policy_rules.delete_all(:delete_all)
     end
 
+    def schedule
+      content.dig('schedule', 'cadence')
+    end
+
     private
 
     def link_policy_rules_project!(project, policy_rules = approval_policy_rules.undeleted)
diff --git a/ee/app/services/security/security_orchestration_policies/persist_policy_service.rb b/ee/app/services/security/security_orchestration_policies/persist_policy_service.rb
index 4d3ea598eb82..2ee2eb145f9a 100644
--- a/ee/app/services/security/security_orchestration_policies/persist_policy_service.rb
+++ b/ee/app/services/security/security_orchestration_policies/persist_policy_service.rb
@@ -7,16 +7,16 @@ class PersistPolicyService
       include Gitlab::Loggable
       include Gitlab::Utils::StrongMemoize
 
+      POLICY_TYPE_ALIAS = {
+        scan_result_policy: :approval_policy
+      }.freeze
+
       def initialize(policy_configuration:, policies:, policy_type:)
         @policy_configuration = policy_configuration
         @policies = policies
-        @policy_type = case policy_type
-                       when :approval_policy, :scan_result_policy then :approval_policy
-                       when :scan_execution_policy then :scan_execution_policy
-                       when :pipeline_execution_policy then :pipeline_execution_policy
-                       when :vulnerability_management_policy then :vulnerability_management_policy
-                       else raise ArgumentError, "unrecognized policy_type"
-                       end
+        @policy_type = POLICY_TYPE_ALIAS[policy_type] || policy_type
+
+        raise ArgumentError, "unrecognized policy_type" unless Security::Policy.types.symbolize_keys.key?(@policy_type)
       end
 
       def execute
@@ -104,6 +104,7 @@ def relation_scope
         when :scan_execution_policy then Security::Policy.type_scan_execution_policy
         when :pipeline_execution_policy then Security::Policy.type_pipeline_execution_policy
         when :vulnerability_management_policy then Security::Policy.type_vulnerability_management_policy
+        when :pipeline_execution_schedule_policy then Security::Policy.type_pipeline_execution_schedule_policy
         end
       end
     end
diff --git a/ee/app/validators/json_schemas/security_orchestration_policy.json b/ee/app/validators/json_schemas/security_orchestration_policy.json
index f9af0db8a48c..a669f6a4e8e0 100644
--- a/ee/app/validators/json_schemas/security_orchestration_policy.json
+++ b/ee/app/validators/json_schemas/security_orchestration_policy.json
@@ -312,6 +312,96 @@
         }
       }
     },
+    "pipeline_execution_schedule_policy": {
+      "type": "array",
+      "description": "Starts pipelines on a schedule with custom pipeline configuration.",
+      "additionalItems": false,
+      "maxItems": 5,
+      "items": {
+        "required": [
+          "name",
+          "enabled",
+          "content",
+          "schedule"
+        ],
+        "type": "object",
+        "properties": {
+          "name": {
+            "description": "Name for the policy.",
+            "minLength": 1,
+            "maxLength": 255,
+            "type": "string"
+          },
+          "description": {
+            "description": "Specifies the longer description of the policy.",
+            "type": "string"
+          },
+          "content": {
+            "description": "Specifies the content of custom configuration.",
+            "type": "object",
+            "properties": {
+              "include": {
+                "type": "array",
+                "maxItems": 1,
+                "minItems": 1,
+                "items": {
+                  "type": "object",
+                  "properties": {
+                    "project": {
+                      "type": "string"
+                    },
+                    "file": {
+                      "type": "string"
+                    },
+                    "ref": {
+                      "type": "string"
+                    }
+                  },
+                  "required": [
+                    "project",
+                    "file"
+                  ],
+                  "additionalProperties": false
+                }
+              }
+            },
+            "required": [
+              "include"
+            ],
+            "additionalProperties": false
+          },
+          "schedule": {
+            "description": "Specifies the condition when this policy should be triggered.",
+            "type": "object",
+            "properties": {
+              "cadence": {
+                "description": "Specifies when this policy should schedule a new pipeline with enforced `actions`. Uses cron expression as a format (ie. `0 22 * * 1-5`). Supported only when `type` is set to `schedule`.",
+                "type": "string",
+                "pattern": "(@(yearly|annually|monthly|weekly|daily|midnight|noon|hourly))|(((\\*|(\\-?\\d+\\,?)+)(\\/\\d+)?|last|L|(sun|mon|tue|wed|thu|fri|sat|SUN|MON|TUE|WED|THU|FRI|SAT\\-|\\,)+|(jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec|JAN|FEB|MAR|APR|MAY|JUN|JUL|AUG|SEP|OCT|NOV|DEC|\\-|\\,)+)\\s?){5,6}"
+              },
+              "timezone": {
+                "type": "string",
+                "description": "Time zone to apply to the cadence. Value must be an IANA Time Zone Database identifier, for example: `America/New_York`."
+              }
+            },
+            "required": [
+              "cadence"
+            ],
+            "additionalProperties": false
+          },
+          "policy_scope": {
+            "$ref": "#/$defs/policy_scope"
+          },
+          "metadata": {
+            "$ref": "#/$defs/metadata"
+          },
+          "enabled": {
+            "description": "Whether to enforce this policy or not.",
+            "type": "boolean"
+          }
+        }
+      }
+    },
     "scan_execution_policy": {
       "type": "array",
       "description": "Declares required security scans to be run on a specified schedule or with the project pipeline.",
diff --git a/ee/app/workers/security/persist_security_policies_worker.rb b/ee/app/workers/security/persist_security_policies_worker.rb
index ce0c2461a868..f9b7a578071e 100644
--- a/ee/app/workers/security/persist_security_policies_worker.rb
+++ b/ee/app/workers/security/persist_security_policies_worker.rb
@@ -20,6 +20,11 @@ def perform(configuration_id)
       persist_policy(configuration, configuration.scan_execution_policy, :scan_execution_policy)
       persist_policy(configuration, configuration.pipeline_execution_policy, :pipeline_execution_policy)
       persist_policy(configuration, configuration.vulnerability_management_policy, :vulnerability_management_policy)
+      persist_policy(
+        configuration,
+        configuration.pipeline_execution_schedule_policy,
+        :pipeline_execution_schedule_policy
+      )
     end
 
     private