From d4ec9fba6c64a01e2875af403126d9570ea539dc Mon Sep 17 00:00:00 2001
From: Timo Furrer <tfurrer@gitlab.com>
Date: Thu, 14 Nov 2024 20:48:40 +0100
Subject: [PATCH] Support `auto_encryption` feature

Refs https://gitlab.com/components/opentofu/-/issues/83

Changelog: added
---
 .gitlab/README.md.template                    | 134 ++++++-----------
 README.md                                     | 137 ++++++------------
 src/gitlab-tofu.sh                            |  79 ++++++++++
 templates/apply.yml                           |  15 ++
 templates/destroy.yml                         |  15 ++
 templates/full-pipeline.yml                   |  30 ++++
 templates/graph.yml                           |  15 ++
 templates/job-templates.yml                   |  30 ++++
 templates/plan.yml                            |  15 ++
 templates/test.yml                            |  15 ++
 templates/validate-plan-apply.yml             |  24 +++
 templates/validate-plan-destroy.yml           |  24 +++
 templates/validate-plan.yml                   |  21 +++
 templates/validate.yml                        |  15 ++
 tests/iac/main.tf                             |  15 ++
 .../AutoEncryption.gitlab-ci.yml              |  39 +++++
 .../AutoEncryptionMigrate.gitlab-ci.yml       |  49 +++++++
 .../WarningOnNonEmptyPlan.gitlab-ci.yml       |   1 +
 tests/integration.gitlab-ci.yml               |  18 +++
 19 files changed, 511 insertions(+), 180 deletions(-)
 create mode 100644 tests/integration-tests/AutoEncryption.gitlab-ci.yml
 create mode 100644 tests/integration-tests/AutoEncryptionMigrate.gitlab-ci.yml

diff --git a/.gitlab/README.md.template b/.gitlab/README.md.template
index c255656..6addbf7 100644
--- a/.gitlab/README.md.template
+++ b/.gitlab/README.md.template
@@ -138,6 +138,50 @@ terraform {
 We recommend having a dedicated `backend.tf` file inside your `root_dir`
 with the aforementioned block.
 
+### State and Plan Encryption
+
+We recommend that you configure the OpenTofu
+[State and Plan Encryption](https://opentofu.org/docs/language/state/encryption).
+
+You may either do this manually by commit your `encryption` config and providing
+it with the necessary secrets - for example defining a `sensitive` `variable`
+and configure a GitLab CI/CD variable for it.
+
+Another option is to let this component auto-encrypt the state and plan for you.
+The only thing you have to do is to provide a passphrase.
+
+All templates related to the state have the following inputs related to auto-encryption:
+
+- `auto_encrpytion` (`boolean`): if set to `true` will auto-encrypt your state and plan.
+- `auto_encrpytion_passphrase` (`string`): is required if `auto_encrpytion` is `true` and
+  defines the passphrase for your state and plan files. Make sure to keep it secured.
+  You may use a protected and masked GitLab CI/CD variable for it.
+- `auto_encryption_enable_migration_from_unencrypted` (`boolean`): if set to `true` will
+  migrate automatically migrate an unencrypted state and plan into an encrypted one.
+  This should only be set to `true` temporarily and disabled again afterwards.
+  Currently, a migration to an encrypted state requires actual changes to the
+  infrastructure.
+  See [this comment](https://gitlab.com/gitlab-org/gitlab/-/issues/450816#note_2228897756)
+  for details.
+
+The following snippet will auto-encrypt your state with a passphrase coming from the
+`PASSPHRASE` CI/CD variable:
+
+```yaml
+include:
+  - component: $CI_SERVER_FQDN/components/opentofu/validate-plan-apply@<VERSION>
+    inputs:
+      # The version must currently be specified explicitly as an input,
+      # to find the correctly associated images. # This can be removed
+      # once https://gitlab.com/gitlab-org/gitlab/-/issues/438275 is solved.
+      version: <VERSION> # component version
+      opentofu_version: <OPENTOFU_VERSION>
+      auto_encrpytion: true
+      auto_encrpytion_passphrase: $PASSPHRASE
+
+stages: [validate, build, deploy]
+```
+
 ### Access to Terraform Module Registry
 
 Similar to automatically configuring the [GitLab-managed Terraform state backend]
@@ -326,96 +370,6 @@ or `TF_CLI_ARGS_init` (handled by OpenTofu directly) to `-lockfile=readonly`
 to prevent any changes to the lockfile during the pipeline job and with
 that ensuring that OpenTofu really uses the locked dependencies.
 
-#### State and Plan Encryption
-
-We recommend that you configure the OpenTofu
-[State and Plan Encryption](https://opentofu.org/docs/language/state/encryption).
-
-You can easily do this by following the guide on the page linked above.
-
-Here is an example:
-
-**Tofu config at `<root-dir>/encryption.tf`**:
-
-```hcl
-variable "passphrase" {
-  sensitive   = true
-  description = "Passphrase to encrypt and decrypt state and plan"
-}
-
-terraform {
-  encryption {
-    key_provider "pbkdf2" "this" {
-      passphrase = var.passphrase
-    }
-
-    method "aes_gcm" "this" {
-      keys = key_provider.pbkdf2.this
-    }
-
-    state {
-      method = method.aes_gcm.this
-    }
-
-    plan {
-      method = method.aes_gcm.this
-    }
-  }
-}
-```
-
-Then you only have to configure a passphrase as CI/CD variable with the name
-`TF_VAR_passphrase`.
-
-Everything else will work out of the box.
-
-In case you want to migrate from an unencrypted state and plan you can
-temporarily configure your encryption block with `fallback`s, like so:
-
-```hcl
-variable "passphrase" {
-  sensitive   = true
-  description = "Passphrase to ecnrypt and decrypt state and plan"
-}
-
-terraform {
-  encryption {
-    method "unencrypted" "migrate" {}
-
-    key_provider "pbkdf2" "this" {
-      passphrase = var.passphrase
-    }
-
-    method "aes_gcm" "this" {
-      keys = key_provider.pbkdf2.this
-    }
-
-    state {
-      method = method.aes_gcm.this
-
-      fallback {
-        method = method.unencrypted.migrate
-      }
-    }
-
-    plan {
-      method = method.aes_gcm.this
-
-      fallback {
-        method = method.unencrypted.migrate
-      }
-    }
-  }
-}
-```
-
-Then you can run the pipeline one time to migrate and then remove the
-`unencrypted` `method` and the `fallback` blocks.
-
-> **Call for Action**:
-> If you have a good proposal on how to make state and plan encryption
-> easier with this component then let us know in an issue!
-
 ### Examples
 
 Here are some example repositories to demonstrate how this component maybe used:
diff --git a/README.md b/README.md
index f3e7b37..11123be 100644
--- a/README.md
+++ b/README.md
@@ -140,6 +140,50 @@ terraform {
 We recommend having a dedicated `backend.tf` file inside your `root_dir`
 with the aforementioned block.
 
+### State and Plan Encryption
+
+We recommend that you configure the OpenTofu
+[State and Plan Encryption](https://opentofu.org/docs/language/state/encryption).
+
+You may either do this manually by commit your `encryption` config and providing
+it with the necessary secrets - for example defining a `sensitive` `variable`
+and configure a GitLab CI/CD variable for it.
+
+Another option is to let this component auto-encrypt the state and plan for you.
+The only thing you have to do is to provide a passphrase.
+
+All templates related to the state have the following inputs related to auto-encryption:
+
+- `auto_encrpytion` (`boolean`): if set to `true` will auto-encrypt your state and plan.
+- `auto_encrpytion_passphrase` (`string`): is required if `auto_encrpytion` is `true` and
+  defines the passphrase for your state and plan files. Make sure to keep it secured.
+  You may use a protected and masked GitLab CI/CD variable for it.
+- `auto_encryption_enable_migration_from_unencrypted` (`boolean`): if set to `true` will
+  migrate automatically migrate an unencrypted state and plan into an encrypted one.
+  This should only be set to `true` temporarily and disabled again afterwards.
+  Currently, a migration to an encrypted state requires actual changes to the
+  infrastructure.
+  See [this comment](https://gitlab.com/gitlab-org/gitlab/-/issues/450816#note_2228897756)
+  for details.
+
+The following snippet will auto-encrypt your state with a passphrase coming from the
+`PASSPHRASE` CI/CD variable:
+
+```yaml
+include:
+  - component: $CI_SERVER_FQDN/components/opentofu/validate-plan-apply@<VERSION>
+    inputs:
+      # The version must currently be specified explicitly as an input,
+      # to find the correctly associated images. # This can be removed
+      # once https://gitlab.com/gitlab-org/gitlab/-/issues/438275 is solved.
+      version: <VERSION> # component version
+      opentofu_version: <OPENTOFU_VERSION>
+      auto_encrpytion: true
+      auto_encrpytion_passphrase: $PASSPHRASE
+
+stages: [validate, build, deploy]
+```
+
 ### Access to Terraform Module Registry
 
 Similar to automatically configuring the [GitLab-managed Terraform state backend]
@@ -274,6 +318,9 @@ The following environment variables are respected by the `gitlab-tofu` script:
 - `GITLAB_TOFU_USE_DETAILED_EXITCODE`: if set to true, `-detailed-exitcode` is supplied to `tofu plan`. Defaults to `false`.
 - `GITLAB_TOFU_PLAN_WITH_JSON`: if set to true, will directly generate a JSON plan file when running `gitlab-tofu plan`. Defaults to `false`.
 - `GITLAB_TOFU_VAR_FILE`: if set to a path it will pass `-var-file` to all `tofu` commands that support it.
+- `GITLAB_TOFU_AUTO_ENCRYPTION`: if set to true, enables auto state and plan encryption. Defaults to `false`.
+- `GITLAB_TOFU_AUTO_ENCRYPTION_PASSPHRASE`: the passphrase to use for state and plan encryption. Required if `GITLAB_TOFU_AUTO_ENCRYPTION` is true.
+- `GITLAB_TOFU_AUTO_ENCRYPTION_ENABLE_MIGRATION_FROM_UNENCRYPTED_ENABLED`: if set to true, enables a fallback for state and plan encryption to migrate unencrypted plans and states to encrypted ones. Defaults to `false`.
 
 #### Respected OpenTofu Environment Variables
 
@@ -402,96 +449,6 @@ or `TF_CLI_ARGS_init` (handled by OpenTofu directly) to `-lockfile=readonly`
 to prevent any changes to the lockfile during the pipeline job and with
 that ensuring that OpenTofu really uses the locked dependencies.
 
-#### State and Plan Encryption
-
-We recommend that you configure the OpenTofu
-[State and Plan Encryption](https://opentofu.org/docs/language/state/encryption).
-
-You can easily do this by following the guide on the page linked above.
-
-Here is an example:
-
-**Tofu config at `<root-dir>/encryption.tf`**:
-
-```hcl
-variable "passphrase" {
-  sensitive   = true
-  description = "Passphrase to encrypt and decrypt state and plan"
-}
-
-terraform {
-  encryption {
-    key_provider "pbkdf2" "this" {
-      passphrase = var.passphrase
-    }
-
-    method "aes_gcm" "this" {
-      keys = key_provider.pbkdf2.this
-    }
-
-    state {
-      method = method.aes_gcm.this
-    }
-
-    plan {
-      method = method.aes_gcm.this
-    }
-  }
-}
-```
-
-Then you only have to configure a passphrase as CI/CD variable with the name
-`TF_VAR_passphrase`.
-
-Everything else will work out of the box.
-
-In case you want to migrate from an unencrypted state and plan you can
-temporarily configure your encryption block with `fallback`s, like so:
-
-```hcl
-variable "passphrase" {
-  sensitive   = true
-  description = "Passphrase to ecnrypt and decrypt state and plan"
-}
-
-terraform {
-  encryption {
-    method "unencrypted" "migrate" {}
-
-    key_provider "pbkdf2" "this" {
-      passphrase = var.passphrase
-    }
-
-    method "aes_gcm" "this" {
-      keys = key_provider.pbkdf2.this
-    }
-
-    state {
-      method = method.aes_gcm.this
-
-      fallback {
-        method = method.unencrypted.migrate
-      }
-    }
-
-    plan {
-      method = method.aes_gcm.this
-
-      fallback {
-        method = method.unencrypted.migrate
-      }
-    }
-  }
-}
-```
-
-Then you can run the pipeline one time to migrate and then remove the
-`unencrypted` `method` and the `fallback` blocks.
-
-> **Call for Action**:
-> If you have a good proposal on how to make state and plan encryption
-> easier with this component then let us know in an issue!
-
 ### Examples
 
 Here are some example repositories to demonstrate how this component maybe used:
diff --git a/src/gitlab-tofu.sh b/src/gitlab-tofu.sh
index 5c33e2b..4b98d61 100644
--- a/src/gitlab-tofu.sh
+++ b/src/gitlab-tofu.sh
@@ -27,6 +27,9 @@
 # - `GITLAB_TOFU_USE_DETAILED_EXITCODE`: if set to true, `-detailed-exitcode` is supplied to `tofu plan`. Defaults to `false`.
 # - `GITLAB_TOFU_PLAN_WITH_JSON`: if set to true, will directly generate a JSON plan file when running `gitlab-tofu plan`. Defaults to `false`.
 # - `GITLAB_TOFU_VAR_FILE`: if set to a path it will pass `-var-file` to all `tofu` commands that support it.
+# - `GITLAB_TOFU_AUTO_ENCRYPTION`: if set to true, enables auto state and plan encryption. Defaults to `false`.
+# - `GITLAB_TOFU_AUTO_ENCRYPTION_PASSPHRASE`: the passphrase to use for state and plan encryption. Required if `GITLAB_TOFU_AUTO_ENCRYPTION` is true.
+# - `GITLAB_TOFU_AUTO_ENCRYPTION_ENABLE_MIGRATION_FROM_UNENCRYPTED_ENABLED`: if set to true, enables a fallback for state and plan encryption to migrate unencrypted plans and states to encrypted ones. Defaults to `false`.
 #
 # #### Respected OpenTofu Environment Variables
 #
@@ -197,6 +200,21 @@ plan_jq_filter='
   }
 '
 
+# auto encryption related variables
+auto_encryption_enabled=${GITLAB_TOFU_AUTO_ENCRYPTION:-false}
+auto_encryption_passphrase="${GITLAB_TOFU_AUTO_ENCRYPTION_PASSPHRASE}"
+auto_encryption_migration_from_unencrypted_enabled=${GITLAB_TOFU_AUTO_ENCRYPTION_ENABLE_MIGRATION_FROM_UNENCRYPTED:-false}
+if $auto_encryption_enabled && [ -z "${auto_encryption_passphrase}" ]; then
+  # shellcheck disable=SC2016 # we actually want to print the variable names and not expand them.
+  echo 'ERROR: a passphrase ($GITLAB_TOFU_AUTO_ENCRYPTION_PASSPHRASE) must be provided when enabled auto encryption ($GITLAB_TOFU_AUTO_ENCRYPTION)' >&2
+  exit 1
+fi
+if ! $auto_encryption_enabled && $auto_encryption_migration_from_unencrypted_enabled; then
+  # shellcheck disable=SC2016 # we actually want to print the variable names and not expand them.
+  echo 'ERROR: you must enable auto encryption ($GITLAB_TOFU_AUTO_ENCRYPTION) to enable migration from an unencrypted state ($GITLAB_TOFU_AUTO_ENCRYPTION_ENABLE_MIGRATION_FROM_UNENCRYPTED)' >&2
+  exit 1
+fi
+
 # Misc variables
 var_file="${GITLAB_TOFU_VAR_FILE}"
 
@@ -268,8 +286,69 @@ tofu_init() {
     1>&2 || $should_ignore_init_errors
 }
 
+
+# configure_encryption_for_tofu configures automatic state and plan encryption
+configure_encryption_for_tofu() {
+  if ! $auto_encryption_enabled; then
+    return
+  fi
+
+  if $auto_encryption_migration_from_unencrypted_enabled; then
+    TF_ENCRYPTION=$(cat <<EOF
+method "unencrypted" "gitlab_tofu_auto_encryption_migrate" {}
+
+key_provider "pbkdf2" "gitlab_tofu_auto_encryption" {
+  passphrase = "${auto_encryption_passphrase}"
+}
+
+method "aes_gcm" "gitlab_tofu_auto_encryption" {
+  keys = key_provider.pbkdf2.gitlab_tofu_auto_encryption
+}
+
+state {
+  method = method.aes_gcm.gitlab_tofu_auto_encryption
+
+  fallback {
+    method = method.unencrypted.gitlab_tofu_auto_encryption_migrate
+  }
+}
+
+plan {
+  method = method.aes_gcm.gitlab_tofu_auto_encryption
+
+  fallback {
+    method = method.unencrypted.gitlab_tofu_auto_encryption_migrate
+  }
+}
+EOF
+)
+  else
+    TF_ENCRYPTION=$(cat <<EOF
+key_provider "pbkdf2" "gitlab_tofu_auto_encryption" {
+  passphrase = "${auto_encryption_passphrase}"
+}
+
+method "aes_gcm" "gitlab_tofu_auto_encryption" {
+  keys = key_provider.pbkdf2.gitlab_tofu_auto_encryption
+}
+
+state {
+  method = method.aes_gcm.gitlab_tofu_auto_encryption
+}
+
+plan {
+  method = method.aes_gcm.gitlab_tofu_auto_encryption
+}
+EOF
+)
+  fi
+
+  export TF_ENCRYPTION
+}
+
 # We always want to configure the tofu variables, even in source-mode.
 configure_variables_for_tofu
+configure_encryption_for_tofu
 
 # If this script is executed and not sourced, a tofu command is ran.
 # Otherwise, nothing happens and the sourced shell can use the defined variables
diff --git a/templates/apply.yml b/templates/apply.yml
index af617ec..cbc4df9 100644
--- a/templates/apply.yml
+++ b/templates/apply.yml
@@ -92,6 +92,18 @@ spec:
       default: pull-push
       type: string
       description: 'Defines the cache policy of the job.'
+    auto_encryption:
+      default: false
+      type: boolean
+      description: 'Whether to enable automatic state and plan encryption.'
+    auto_encryption_passphrase:
+      default: ''
+      type: string
+      description: 'Defines the passphrase to auto encrypt the state and plan. Only used if `auto_encryption` is `true`.'
+    auto_encryption_enable_migration_from_unencrypted:
+      default: false
+      type: boolean
+      description: 'Whether to setup automatic state and plan encryption for currently unencrypted state. This is only temporarily useful when migrating from an unencrypted state.'
 
 ---
 
@@ -115,6 +127,9 @@ spec:
     GITLAB_TOFU_APPLY_NO_PLAN: $[[ inputs.no_plan ]]
     GITLAB_TOFU_PLAN_NAME: $[[ inputs.plan_name ]]
     GITLAB_TOFU_VAR_FILE: '$[[ inputs.var_file ]]'
+    GITLAB_TOFU_AUTO_ENCRYPTION: '$[[ inputs.auto_encryption ]]'
+    GITLAB_TOFU_AUTO_ENCRYPTION_PASSPHRASE: '$[[ inputs.auto_encryption_passphrase ]]'
+    GITLAB_TOFU_AUTO_ENCRYPTION_ENABLE_MIGRATION_FROM_UNENCRYPTED: '$[[ inputs.auto_encryption_enable_migration_from_unencrypted ]]'
   image:
     name: '$[[ inputs.image_registry_base ]]/$[[ inputs.image_name ]]:$[[ inputs.version ]]-opentofu$[[ inputs.opentofu_version ]]-$[[ inputs.base_os ]]$[[ inputs.image_digest ]]'
   script:
diff --git a/templates/destroy.yml b/templates/destroy.yml
index 0e1fb04..7c15961 100644
--- a/templates/destroy.yml
+++ b/templates/destroy.yml
@@ -92,6 +92,18 @@ spec:
       default: pull-push
       type: string
       description: 'Defines the cache policy of the job.'
+    auto_encryption:
+      default: false
+      type: boolean
+      description: 'Whether to enable automatic state and plan encryption.'
+    auto_encryption_passphrase:
+      default: ''
+      type: string
+      description: 'Defines the passphrase to auto encrypt the state and plan. Only used if `auto_encryption` is `true`.'
+    auto_encryption_enable_migration_from_unencrypted:
+      default: false
+      type: boolean
+      description: 'Whether to setup automatic state and plan encryption for currently unencrypted state. This is only temporarily useful when migrating from an unencrypted state.'
 
 ---
 
@@ -115,6 +127,9 @@ spec:
     GITLAB_TOFU_APPLY_NO_PLAN: $[[ inputs.no_plan ]]
     GITLAB_TOFU_PLAN_NAME: $[[ inputs.plan_name ]]
     GITLAB_TOFU_VAR_FILE: '$[[ inputs.var_file ]]'
+    GITLAB_TOFU_AUTO_ENCRYPTION: '$[[ inputs.auto_encryption ]]'
+    GITLAB_TOFU_AUTO_ENCRYPTION_PASSPHRASE: '$[[ inputs.auto_encryption_passphrase ]]'
+    GITLAB_TOFU_AUTO_ENCRYPTION_ENABLE_MIGRATION_FROM_UNENCRYPTED: '$[[ inputs.auto_encryption_enable_migration_from_unencrypted ]]'
   image:
     name: '$[[ inputs.image_registry_base ]]/$[[ inputs.image_name ]]:$[[ inputs.version ]]-opentofu$[[ inputs.opentofu_version ]]-$[[ inputs.base_os ]]$[[ inputs.image_digest ]]'
   script:
diff --git a/templates/full-pipeline.yml b/templates/full-pipeline.yml
index 9b83944..815c43c 100644
--- a/templates/full-pipeline.yml
+++ b/templates/full-pipeline.yml
@@ -188,6 +188,18 @@ spec:
       default: [{when: on_success}]
       type: array
       description: 'Defines the `rules` of the child pipeline bridge job.'
+    auto_encryption:
+      default: false
+      type: boolean
+      description: 'Whether to enable automatic state and plan encryption.'
+    auto_encryption_passphrase:
+      default: ''
+      type: string
+      description: 'Defines the passphrase to auto encrypt the state and plan. Only used if `auto_encryption` is `true`.'
+    auto_encryption_enable_migration_from_unencrypted:
+      default: false
+      type: boolean
+      description: 'Whether to setup automatic state and plan encryption for currently unencrypted state. This is only temporarily useful when migrating from an unencrypted state.'
 
 ---
 
@@ -223,6 +235,9 @@ include:
       state_name: $[[ inputs.state_name ]]
       var_file: $[[ inputs.var_file ]]
       rules: $[[ inputs.validate_rules ]]
+      auto_encryption: $[[ inputs.auto_encryption ]]
+      auto_encryption_passphrase: $[[ inputs.auto_encryption_passphrase ]]
+      auto_encryption_enable_migration_from_unencrypted: $[[ inputs.auto_encryption_enable_migration_from_unencrypted ]]
   - local: '/templates/test.yml'
     rules:
       - if: '"$[[ inputs.trigger_in_child_pipeline ]]" == "true"'
@@ -243,6 +258,9 @@ include:
       var_file: $[[ inputs.var_file ]]
       needs: []
       rules: $[[ inputs.test_rules ]]
+      auto_encryption: $[[ inputs.auto_encryption ]]
+      auto_encryption_passphrase: $[[ inputs.auto_encryption_passphrase ]]
+      auto_encryption_enable_migration_from_unencrypted: $[[ inputs.auto_encryption_enable_migration_from_unencrypted ]]
   - local: '/templates/plan.yml'
     rules:
       - if: '"$[[ inputs.trigger_in_child_pipeline ]]" == "false"'
@@ -262,6 +280,9 @@ include:
       var_file: $[[ inputs.var_file ]]
       rules: $[[ inputs.plan_rules ]]
       warning_on_non_empty_plan: $[[ inputs.warning_on_non_empty_plan ]]
+      auto_encryption: $[[ inputs.auto_encryption ]]
+      auto_encryption_passphrase: $[[ inputs.auto_encryption_passphrase ]]
+      auto_encryption_enable_migration_from_unencrypted: $[[ inputs.auto_encryption_enable_migration_from_unencrypted ]]
   - local: '/templates/apply.yml'
     rules:
       - if: '"$[[ inputs.trigger_in_child_pipeline ]]" == "false"'
@@ -279,6 +300,9 @@ include:
       plan_name: $[[ inputs.plan_name ]]
       var_file: $[[ inputs.var_file ]]
       rules: $[[ inputs.apply_rules ]]
+      auto_encryption: $[[ inputs.auto_encryption ]]
+      auto_encryption_passphrase: $[[ inputs.auto_encryption_passphrase ]]
+      auto_encryption_enable_migration_from_unencrypted: $[[ inputs.auto_encryption_enable_migration_from_unencrypted ]]
   - local: '/templates/destroy.yml'
     rules:
       - if: '"$[[ inputs.trigger_in_child_pipeline ]]" == "false"'
@@ -295,6 +319,9 @@ include:
       state_name: $[[ inputs.state_name ]]
       var_file: $[[ inputs.var_file ]]
       rules: $[[ inputs.destroy_rules ]]
+      auto_encryption: $[[ inputs.auto_encryption ]]
+      auto_encryption_passphrase: $[[ inputs.auto_encryption_passphrase ]]
+      auto_encryption_enable_migration_from_unencrypted: $[[ inputs.auto_encryption_enable_migration_from_unencrypted ]]
   - local: '/templates/delete-state.yml'
     rules:
       - if: '"$[[ inputs.trigger_in_child_pipeline ]]" == "false"'
@@ -378,6 +405,9 @@ stages:
           destroy_rules: $[[ inputs.destroy_rules ]]
           delete_state_rules: $[[ inputs.delete_state_rules ]]
           warning_on_non_empty_plan: $[[ inputs.warning_on_non_empty_plan ]]
+          auto_encryption: $[[ inputs.auto_encryption ]]
+          auto_encryption_passphrase: $[[ inputs.auto_encryption_passphrase ]]
+          auto_encryption_enable_migration_from_unencrypted: $[[ inputs.auto_encryption_enable_migration_from_unencrypted ]]
           trigger_in_child_pipeline: false
     forward:
       yaml_variables: true
diff --git a/templates/graph.yml b/templates/graph.yml
index 561d44e..e8dd1ba 100644
--- a/templates/graph.yml
+++ b/templates/graph.yml
@@ -90,6 +90,18 @@ spec:
       default: pull-push
       type: string
       description: 'Defines the cache policy of the job.'
+    auto_encryption:
+      default: false
+      type: boolean
+      description: 'Whether to enable automatic state and plan encryption.'
+    auto_encryption_passphrase:
+      default: ''
+      type: string
+      description: 'Defines the passphrase to auto encrypt the state and plan. Only used if `auto_encryption` is `true`.'
+    auto_encryption_enable_migration_from_unencrypted:
+      default: false
+      type: boolean
+      description: 'Whether to setup automatic state and plan encryption for currently unencrypted state. This is only temporarily useful when migrating from an unencrypted state.'
 
 ---
 
@@ -107,6 +119,9 @@ spec:
     GITLAB_TOFU_ROOT_DIR: $[[ inputs.root_dir ]]
     GITLAB_TOFU_STATE_NAME: $[[ inputs.state_name ]]
     GITLAB_TOFU_VAR_FILE: '$[[ inputs.var_file ]]'
+    GITLAB_TOFU_AUTO_ENCRYPTION: '$[[ inputs.auto_encryption ]]'
+    GITLAB_TOFU_AUTO_ENCRYPTION_PASSPHRASE: '$[[ inputs.auto_encryption_passphrase ]]'
+    GITLAB_TOFU_AUTO_ENCRYPTION_ENABLE_MIGRATION_FROM_UNENCRYPTED: '$[[ inputs.auto_encryption_enable_migration_from_unencrypted ]]'
   image:
     name: '$[[ inputs.image_registry_base ]]/$[[ inputs.image_name ]]:$[[ inputs.version ]]-opentofu$[[ inputs.opentofu_version ]]-$[[ inputs.base_os ]]$[[ inputs.image_digest ]]'
   script:
diff --git a/templates/job-templates.yml b/templates/job-templates.yml
index 6e7aa58..eda3791 100644
--- a/templates/job-templates.yml
+++ b/templates/job-templates.yml
@@ -93,6 +93,18 @@ spec:
       default: false
       type: boolean
       description: 'Whether to mark the job with a warning if the plan contains a diff.'
+    auto_encryption:
+      default: false
+      type: boolean
+      description: 'Whether to enable automatic state and plan encryption.'
+    auto_encryption_passphrase:
+      default: ''
+      type: string
+      description: 'Defines the passphrase to auto encrypt the state and plan. Only used if `auto_encryption` is `true`.'
+    auto_encryption_enable_migration_from_unencrypted:
+      default: false
+      type: boolean
+      description: 'Whether to setup automatic state and plan encryption for currently unencrypted state. This is only temporarily useful when migrating from an unencrypted state.'
 
 ---
 
@@ -121,6 +133,9 @@ include:
       root_dir: $[[ inputs.root_dir ]]
       state_name: $[[ inputs.state_name ]]
       var_file: $[[ inputs.var_file ]]
+      auto_encryption: $[[ inputs.auto_encryption ]]
+      auto_encryption_passphrase: $[[ inputs.auto_encryption_passphrase ]]
+      auto_encryption_enable_migration_from_unencrypted: $[[ inputs.auto_encryption_enable_migration_from_unencrypted ]]
   - local: '/templates/graph.yml'
     inputs:
       as: '$[[ inputs.job_name_prefix ]]graph'
@@ -132,6 +147,9 @@ include:
       image_name: $[[ inputs.image_name ]]
       root_dir: $[[ inputs.root_dir ]]
       var_file: $[[ inputs.var_file ]]
+      auto_encryption: $[[ inputs.auto_encryption ]]
+      auto_encryption_passphrase: $[[ inputs.auto_encryption_passphrase ]]
+      auto_encryption_enable_migration_from_unencrypted: $[[ inputs.auto_encryption_enable_migration_from_unencrypted ]]
   - local: '/templates/test.yml'
     inputs:
       as: '$[[ inputs.job_name_prefix ]]test'
@@ -145,6 +163,9 @@ include:
       root_dir: $[[ inputs.root_dir ]]
       state_name: $[[ inputs.state_name ]]
       var_file: $[[ inputs.var_file ]]
+      auto_encryption: $[[ inputs.auto_encryption ]]
+      auto_encryption_passphrase: $[[ inputs.auto_encryption_passphrase ]]
+      auto_encryption_enable_migration_from_unencrypted: $[[ inputs.auto_encryption_enable_migration_from_unencrypted ]]
   - local: '/templates/plan.yml'
     inputs:
       as: '$[[ inputs.job_name_prefix ]]plan'
@@ -160,6 +181,9 @@ include:
       plan_name: $[[ inputs.plan_name ]]
       var_file: $[[ inputs.var_file ]]
       warning_on_non_empty_plan: $[[ inputs.warning_on_non_empty_plan ]]
+      auto_encryption: $[[ inputs.auto_encryption ]]
+      auto_encryption_passphrase: $[[ inputs.auto_encryption_passphrase ]]
+      auto_encryption_enable_migration_from_unencrypted: $[[ inputs.auto_encryption_enable_migration_from_unencrypted ]]
   - local: '/templates/apply.yml'
     inputs:
       as: '$[[ inputs.job_name_prefix ]]apply'
@@ -174,6 +198,9 @@ include:
       state_name: $[[ inputs.state_name ]]
       plan_name: $[[ inputs.plan_name ]]
       var_file: $[[ inputs.var_file ]]
+      auto_encryption: $[[ inputs.auto_encryption ]]
+      auto_encryption_passphrase: $[[ inputs.auto_encryption_passphrase ]]
+      auto_encryption_enable_migration_from_unencrypted: $[[ inputs.auto_encryption_enable_migration_from_unencrypted ]]
   - local: '/templates/destroy.yml'
     inputs:
       as: '$[[ inputs.job_name_prefix ]]destroy'
@@ -188,6 +215,9 @@ include:
       state_name: $[[ inputs.state_name ]]
       plan_name: $[[ inputs.plan_name ]]
       var_file: $[[ inputs.var_file ]]
+      auto_encryption: $[[ inputs.auto_encryption ]]
+      auto_encryption_passphrase: $[[ inputs.auto_encryption_passphrase ]]
+      auto_encryption_enable_migration_from_unencrypted: $[[ inputs.auto_encryption_enable_migration_from_unencrypted ]]
   - local: '/templates/delete-state.yml'
     inputs:
       as: '$[[ inputs.job_name_prefix ]]delete-state'
diff --git a/templates/plan.yml b/templates/plan.yml
index 85710be..a0d3235 100644
--- a/templates/plan.yml
+++ b/templates/plan.yml
@@ -99,6 +99,18 @@ spec:
       default: false
       type: boolean
       description: 'Whether to mark the job with a warning if the plan contains a diff.'
+    auto_encryption:
+      default: false
+      type: boolean
+      description: 'Whether to enable automatic state and plan encryption.'
+    auto_encryption_passphrase:
+      default: ''
+      type: string
+      description: 'Defines the passphrase to auto encrypt the state and plan. Only used if `auto_encryption` is `true`.'
+    auto_encryption_enable_migration_from_unencrypted:
+      default: false
+      type: boolean
+      description: 'Whether to setup automatic state and plan encryption for currently unencrypted state. This is only temporarily useful when migrating from an unencrypted state.'
 
 ---
 
@@ -161,6 +173,9 @@ spec:
     GITLAB_TOFU_PLAN_NAME: $[[ inputs.plan_name ]]
     GITLAB_TOFU_PLAN_WITH_JSON: true
     GITLAB_TOFU_VAR_FILE: '$[[ inputs.var_file ]]'
+    GITLAB_TOFU_AUTO_ENCRYPTION: '$[[ inputs.auto_encryption ]]'
+    GITLAB_TOFU_AUTO_ENCRYPTION_PASSPHRASE: '$[[ inputs.auto_encryption_passphrase ]]'
+    GITLAB_TOFU_AUTO_ENCRYPTION_ENABLE_MIGRATION_FROM_UNENCRYPTED: '$[[ inputs.auto_encryption_enable_migration_from_unencrypted ]]'
   image:
     name: '$[[ inputs.image_registry_base ]]/$[[ inputs.image_name ]]:$[[ inputs.version ]]-opentofu$[[ inputs.opentofu_version ]]-$[[ inputs.base_os ]]$[[ inputs.image_digest ]]'
   script:
diff --git a/templates/test.yml b/templates/test.yml
index 7634385..2de8cee 100644
--- a/templates/test.yml
+++ b/templates/test.yml
@@ -92,6 +92,18 @@ spec:
       default: pull-push
       type: string
       description: 'Defines the cache policy of the job.'
+    auto_encryption:
+      default: false
+      type: boolean
+      description: 'Whether to enable automatic state and plan encryption.'
+    auto_encryption_passphrase:
+      default: ''
+      type: string
+      description: 'Defines the passphrase to auto encrypt the state and plan. Only used if `auto_encryption` is `true`.'
+    auto_encryption_enable_migration_from_unencrypted:
+      default: false
+      type: boolean
+      description: 'Whether to setup automatic state and plan encryption for currently unencrypted state. This is only temporarily useful when migrating from an unencrypted state.'
 
 ---
 
@@ -110,6 +122,9 @@ spec:
     GITLAB_TOFU_ROOT_DIR: $[[ inputs.root_dir ]]
     GITLAB_TOFU_STATE_NAME: $[[ inputs.state_name ]]
     GITLAB_TOFU_VAR_FILE: '$[[ inputs.var_file ]]'
+    GITLAB_TOFU_AUTO_ENCRYPTION: '$[[ inputs.auto_encryption ]]'
+    GITLAB_TOFU_AUTO_ENCRYPTION_PASSPHRASE: '$[[ inputs.auto_encryption_passphrase ]]'
+    GITLAB_TOFU_AUTO_ENCRYPTION_ENABLE_MIGRATION_FROM_UNENCRYPTED: '$[[ inputs.auto_encryption_enable_migration_from_unencrypted ]]'
   image:
     name: '$[[ inputs.image_registry_base ]]/$[[ inputs.image_name ]]:$[[ inputs.version ]]-opentofu$[[ inputs.opentofu_version ]]-$[[ inputs.base_os ]]$[[ inputs.image_digest ]]'
   script:
diff --git a/templates/validate-plan-apply.yml b/templates/validate-plan-apply.yml
index 1e8aad0..c74ee0a 100644
--- a/templates/validate-plan-apply.yml
+++ b/templates/validate-plan-apply.yml
@@ -152,6 +152,18 @@ spec:
       default: [{when: on_success}]
       type: array
       description: 'Defines the `rules` of the child pipeline bridge job.'
+    auto_encryption:
+      default: false
+      type: boolean
+      description: 'Whether to enable automatic state and plan encryption.'
+    auto_encryption_passphrase:
+      default: ''
+      type: string
+      description: 'Defines the passphrase to auto encrypt the state and plan. Only used if `auto_encryption` is `true`.'
+    auto_encryption_enable_migration_from_unencrypted:
+      default: false
+      type: boolean
+      description: 'Whether to setup automatic state and plan encryption for currently unencrypted state. This is only temporarily useful when migrating from an unencrypted state.'
 
 ---
 
@@ -188,6 +200,9 @@ include:
       var_file: $[[ inputs.var_file ]]
       rules: $[[ inputs.validate_rules ]]
       cache_policy: pull-push
+      auto_encryption: $[[ inputs.auto_encryption ]]
+      auto_encryption_passphrase: $[[ inputs.auto_encryption_passphrase ]]
+      auto_encryption_enable_migration_from_unencrypted: $[[ inputs.auto_encryption_enable_migration_from_unencrypted ]]
   - local: '/templates/plan.yml'
     rules:
       - if: '"$[[ inputs.trigger_in_child_pipeline ]]" == "false"'
@@ -208,6 +223,9 @@ include:
       rules: $[[ inputs.plan_rules ]]
       cache_policy: pull
       warning_on_non_empty_plan: $[[ inputs.warning_on_non_empty_plan ]]
+      auto_encryption: $[[ inputs.auto_encryption ]]
+      auto_encryption_passphrase: $[[ inputs.auto_encryption_passphrase ]]
+      auto_encryption_enable_migration_from_unencrypted: $[[ inputs.auto_encryption_enable_migration_from_unencrypted ]]
   - local: '/templates/apply.yml'
     rules:
       - if: '"$[[ inputs.trigger_in_child_pipeline ]]" == "false"'
@@ -226,6 +244,9 @@ include:
       var_file: $[[ inputs.var_file ]]
       rules: $[[ inputs.apply_rules ]]
       cache_policy: pull
+      auto_encryption: $[[ inputs.auto_encryption ]]
+      auto_encryption_passphrase: $[[ inputs.auto_encryption_passphrase ]]
+      auto_encryption_enable_migration_from_unencrypted: $[[ inputs.auto_encryption_enable_migration_from_unencrypted ]]
 
 
 # NOTE: the following configuration is only used if `trigger_in_child_pipeline` is enabled.
@@ -279,6 +300,9 @@ stages:
           plan_rules: $[[ inputs.plan_rules ]]
           apply_rules: $[[ inputs.apply_rules ]]
           warning_on_non_empty_plan: $[[ inputs.warning_on_non_empty_plan ]]
+          auto_encryption: $[[ inputs.auto_encryption ]]
+          auto_encryption_passphrase: $[[ inputs.auto_encryption_passphrase ]]
+          auto_encryption_enable_migration_from_unencrypted: $[[ inputs.auto_encryption_enable_migration_from_unencrypted ]]
           trigger_in_child_pipeline: false
     forward:
       yaml_variables: true
diff --git a/templates/validate-plan-destroy.yml b/templates/validate-plan-destroy.yml
index 15cc048..d533d5d 100644
--- a/templates/validate-plan-destroy.yml
+++ b/templates/validate-plan-destroy.yml
@@ -158,6 +158,18 @@ spec:
       default: [{when: on_success}]
       type: array
       description: 'Defines the `rules` of the child pipeline bridge job.'
+    auto_encryption:
+      default: false
+      type: boolean
+      description: 'Whether to enable automatic state and plan encryption.'
+    auto_encryption_passphrase:
+      default: ''
+      type: string
+      description: 'Defines the passphrase to auto encrypt the state and plan. Only used if `auto_encryption` is `true`.'
+    auto_encryption_enable_migration_from_unencrypted:
+      default: false
+      type: boolean
+      description: 'Whether to setup automatic state and plan encryption for currently unencrypted state. This is only temporarily useful when migrating from an unencrypted state.'
 
 ---
 
@@ -194,6 +206,9 @@ include:
       var_file: $[[ inputs.var_file ]]
       rules: $[[ inputs.validate_rules ]]
       cache_policy: pull-push
+      auto_encryption: $[[ inputs.auto_encryption ]]
+      auto_encryption_passphrase: $[[ inputs.auto_encryption_passphrase ]]
+      auto_encryption_enable_migration_from_unencrypted: $[[ inputs.auto_encryption_enable_migration_from_unencrypted ]]
   - local: '/templates/plan.yml'
     rules:
       - if: '"$[[ inputs.trigger_in_child_pipeline ]]" == "false"'
@@ -215,6 +230,9 @@ include:
       rules: $[[ inputs.plan_rules ]]
       cache_policy: pull
       warning_on_non_empty_plan: $[[ inputs.warning_on_non_empty_plan ]]
+      auto_encryption: $[[ inputs.auto_encryption ]]
+      auto_encryption_passphrase: $[[ inputs.auto_encryption_passphrase ]]
+      auto_encryption_enable_migration_from_unencrypted: $[[ inputs.auto_encryption_enable_migration_from_unencrypted ]]
   - local: '/templates/destroy.yml'
     rules:
       - if: '"$[[ inputs.trigger_in_child_pipeline ]]" == "false"'
@@ -234,6 +252,9 @@ include:
       var_file: $[[ inputs.var_file ]]
       rules: $[[ inputs.destroy_rules ]]
       cache_policy: pull
+      auto_encryption: $[[ inputs.auto_encryption ]]
+      auto_encryption_passphrase: $[[ inputs.auto_encryption_passphrase ]]
+      auto_encryption_enable_migration_from_unencrypted: $[[ inputs.auto_encryption_enable_migration_from_unencrypted ]]
   - local: '/templates/delete-state.yml'
     rules:
       - if: '"$[[ inputs.trigger_in_child_pipeline ]]" == "false"'
@@ -312,6 +333,9 @@ stages:
           destroy_rules: $[[ inputs.destroy_rules ]]
           delete_state_rules: $[[ inputs.delete_state_rules ]]
           warning_on_non_empty_plan: $[[ inputs.warning_on_non_empty_plan ]]
+          auto_encryption: $[[ inputs.auto_encryption ]]
+          auto_encryption_passphrase: $[[ inputs.auto_encryption_passphrase ]]
+          auto_encryption_enable_migration_from_unencrypted: $[[ inputs.auto_encryption_enable_migration_from_unencrypted ]]
           trigger_in_child_pipeline: false
     forward:
       yaml_variables: true
diff --git a/templates/validate-plan.yml b/templates/validate-plan.yml
index e172007..0d7d4df 100644
--- a/templates/validate-plan.yml
+++ b/templates/validate-plan.yml
@@ -136,6 +136,18 @@ spec:
       default: [{when: on_success}]
       type: array
       description: 'Defines the `rules` of the child pipeline bridge job.'
+    auto_encryption:
+      default: false
+      type: boolean
+      description: 'Whether to enable automatic state and plan encryption.'
+    auto_encryption_passphrase:
+      default: ''
+      type: string
+      description: 'Defines the passphrase to auto encrypt the state and plan. Only used if `auto_encryption` is `true`.'
+    auto_encryption_enable_migration_from_unencrypted:
+      default: false
+      type: boolean
+      description: 'Whether to setup automatic state and plan encryption for currently unencrypted state. This is only temporarily useful when migrating from an unencrypted state.'
 
 ---
 
@@ -172,6 +184,9 @@ include:
       var_file: $[[ inputs.var_file ]]
       rules: $[[ inputs.validate_rules ]]
       cache_policy: pull-push
+      auto_encryption: $[[ inputs.auto_encryption ]]
+      auto_encryption_passphrase: $[[ inputs.auto_encryption_passphrase ]]
+      auto_encryption_enable_migration_from_unencrypted: $[[ inputs.auto_encryption_enable_migration_from_unencrypted ]]
   - local: '/templates/plan.yml'
     rules:
       - if: '"$[[ inputs.trigger_in_child_pipeline ]]" == "false"'
@@ -192,6 +207,9 @@ include:
       rules: $[[ inputs.plan_rules ]]
       cache_policy: pull
       warning_on_non_empty_plan: $[[ inputs.warning_on_non_empty_plan ]]
+      auto_encryption: $[[ inputs.auto_encryption ]]
+      auto_encryption_passphrase: $[[ inputs.auto_encryption_passphrase ]]
+      auto_encryption_enable_migration_from_unencrypted: $[[ inputs.auto_encryption_enable_migration_from_unencrypted ]]
 
 
 # NOTE: the following configuration is only used if `trigger_in_child_pipeline` is enabled.
@@ -242,6 +260,9 @@ stages:
           validate_rules: $[[ inputs.validate_rules ]]
           plan_rules: $[[ inputs.plan_rules ]]
           warning_on_non_empty_plan: $[[ inputs.warning_on_non_empty_plan ]]
+          auto_encryption: $[[ inputs.auto_encryption ]]
+          auto_encryption_passphrase: $[[ inputs.auto_encryption_passphrase ]]
+          auto_encryption_enable_migration_from_unencrypted: $[[ inputs.auto_encryption_enable_migration_from_unencrypted ]]
           trigger_in_child_pipeline: false
     forward:
       yaml_variables: true
diff --git a/templates/validate.yml b/templates/validate.yml
index 072d953..f272bf6 100644
--- a/templates/validate.yml
+++ b/templates/validate.yml
@@ -89,6 +89,18 @@ spec:
       default: pull-push
       type: string
       description: 'Defines the cache policy of the job.'
+    auto_encryption:
+      default: false
+      type: boolean
+      description: 'Whether to enable automatic state and plan encryption.'
+    auto_encryption_passphrase:
+      default: ''
+      type: string
+      description: 'Defines the passphrase to auto encrypt the state and plan. Only used if `auto_encryption` is `true`.'
+    auto_encryption_enable_migration_from_unencrypted:
+      default: false
+      type: boolean
+      description: 'Whether to setup automatic state and plan encryption for currently unencrypted state. This is only temporarily useful when migrating from an unencrypted state.'
 
 ---
 
@@ -107,6 +119,9 @@ spec:
     GITLAB_TOFU_STATE_NAME: $[[ inputs.state_name ]]
     GITLAB_TOFU_IGNORE_INIT_ERRORS: 'true' # Tofu can report errors which might be the reason init failed.
     GITLAB_TOFU_VAR_FILE: '$[[ inputs.var_file ]]'
+    GITLAB_TOFU_AUTO_ENCRYPTION: '$[[ inputs.auto_encryption ]]'
+    GITLAB_TOFU_AUTO_ENCRYPTION_PASSPHRASE: '$[[ inputs.auto_encryption_passphrase ]]'
+    GITLAB_TOFU_AUTO_ENCRYPTION_ENABLE_MIGRATION_FROM_UNENCRYPTED: '$[[ inputs.auto_encryption_enable_migration_from_unencrypted ]]'
   image:
     name: '$[[ inputs.image_registry_base ]]/$[[ inputs.image_name ]]:$[[ inputs.version ]]-opentofu$[[ inputs.opentofu_version ]]-$[[ inputs.base_os ]]$[[ inputs.image_digest ]]'
   script:
diff --git a/tests/iac/main.tf b/tests/iac/main.tf
index f65a2b5..aa8705c 100644
--- a/tests/iac/main.tf
+++ b/tests/iac/main.tf
@@ -7,6 +7,17 @@ resource "local_file" "foo" {
   filename = "${path.module}/foo.bar"
 }
 
+locals {
+  ts = plantimestamp()
+}
+
+// NOTE: always force a change.
+resource "null_resource" "this" {
+  triggers = {
+    timestamp = local.ts
+  }
+}
+
 variable "ci_project_name" {
   type    = string
   default = "default"
@@ -24,3 +35,7 @@ output "project_name" {
 output "test_variable" {
   value = var.test_variable
 }
+
+output "this_always_changes" {
+  value = local.ts
+}
diff --git a/tests/integration-tests/AutoEncryption.gitlab-ci.yml b/tests/integration-tests/AutoEncryption.gitlab-ci.yml
new file mode 100644
index 0000000..e25b098
--- /dev/null
+++ b/tests/integration-tests/AutoEncryption.gitlab-ci.yml
@@ -0,0 +1,39 @@
+include:
+  - component: $CI_SERVER_FQDN/$CI_PROJECT_PATH/apply@$CI_COMMIT_SHA
+    inputs:
+      image_registry_base: $GITLAB_OPENTOFU_IMAGE_BASE
+      version: $CI_COMMIT_SHA
+      base_os: $GITLAB_OPENTOFU_BASE_IMAGE_OS
+      opentofu_version: $OPENTOFU_VERSION
+      stage: test
+      root_dir: $TEST_GITLAB_TOFU_ROOT_DIR
+      state_name: $TEST_GITLAB_TOFU_STATE_NAME
+      no_plan: true
+      auto_encryption: true
+      auto_encryption_passphrase: '947F23E4-B9FC-4E76-B7B4-1D35ECBE9B09'
+
+  # For CI Terraform state cleanup
+  - component: $CI_SERVER_FQDN/$CI_PROJECT_PATH/delete-state@$CI_COMMIT_SHA
+    inputs:
+      state_name: $TEST_GITLAB_TOFU_STATE_NAME
+      rules: [{when: always}]
+
+stages: [test, verify, cleanup]
+
+state-is-encrypted:
+  stage: verify
+  image: $GITLAB_OPENTOFU_IMAGE_BASE/gitlab-opentofu:$CI_COMMIT_SHA-opentofu$OPENTOFU_VERSION-alpine
+  variables:
+    GITLAB_TOFU_STATE_NAME: $TEST_GITLAB_TOFU_STATE_NAME
+  script:
+    - |
+      . $(which gitlab-tofu)
+      echo "Requesting state at $TF_HTTP_ADDRESS to check if it is encrypted ..."
+      success=$(curl --fail-with-body -u "${TF_HTTP_USERNAME}:${TF_HTTP_PASSWORD}" "${TF_HTTP_ADDRESS}" | jq -r '.encrypted_data != ""')
+      if [ "$success" != 'true' ]; then
+        echo 'Error: no encrypted data found in state.'
+        exit 1
+      else
+        echo 'Success: the state is encrypted.'
+      fi
+  rules: [{when: on_success}]
diff --git a/tests/integration-tests/AutoEncryptionMigrate.gitlab-ci.yml b/tests/integration-tests/AutoEncryptionMigrate.gitlab-ci.yml
new file mode 100644
index 0000000..c2631ad
--- /dev/null
+++ b/tests/integration-tests/AutoEncryptionMigrate.gitlab-ci.yml
@@ -0,0 +1,49 @@
+include:
+  - component: $CI_SERVER_FQDN/$CI_PROJECT_PATH/apply@$CI_COMMIT_SHA
+    inputs:
+      image_registry_base: $GITLAB_OPENTOFU_IMAGE_BASE
+      version: $CI_COMMIT_SHA
+      base_os: $GITLAB_OPENTOFU_BASE_IMAGE_OS
+      opentofu_version: $OPENTOFU_VERSION
+      as: 'apply:unencrypted'
+      stage: unencrypted
+      root_dir: $TEST_GITLAB_TOFU_ROOT_DIR
+      state_name: $TEST_GITLAB_TOFU_STATE_NAME
+      no_plan: true
+
+  - component: $CI_SERVER_FQDN/$CI_PROJECT_PATH/apply@$CI_COMMIT_SHA
+    inputs:
+      image_registry_base: $GITLAB_OPENTOFU_IMAGE_BASE
+      version: $CI_COMMIT_SHA
+      base_os: $GITLAB_OPENTOFU_BASE_IMAGE_OS
+      opentofu_version: $OPENTOFU_VERSION
+      as: 'apply:migrate'
+      stage: migrate
+      root_dir: $TEST_GITLAB_TOFU_ROOT_DIR
+      state_name: $TEST_GITLAB_TOFU_STATE_NAME
+      no_plan: true
+      auto_encryption: true
+      auto_encryption_passphrase: '947F23E4-B9FC-4E76-B7B4-1D35ECBE9B09'
+      auto_encryption_enable_migration_from_unencrypted: true
+
+  - component: $CI_SERVER_FQDN/$CI_PROJECT_PATH/apply@$CI_COMMIT_SHA
+    inputs:
+      image_registry_base: $GITLAB_OPENTOFU_IMAGE_BASE
+      version: $CI_COMMIT_SHA
+      base_os: $GITLAB_OPENTOFU_BASE_IMAGE_OS
+      opentofu_version: $OPENTOFU_VERSION
+      as: 'apply:encrypted'
+      stage: encrypted
+      root_dir: $TEST_GITLAB_TOFU_ROOT_DIR
+      state_name: $TEST_GITLAB_TOFU_STATE_NAME
+      no_plan: true
+      auto_encryption: true
+      auto_encryption_passphrase: '947F23E4-B9FC-4E76-B7B4-1D35ECBE9B09'
+
+  # For CI Terraform state cleanup
+  - component: $CI_SERVER_FQDN/$CI_PROJECT_PATH/delete-state@$CI_COMMIT_SHA
+    inputs:
+      state_name: $TEST_GITLAB_TOFU_STATE_NAME
+      rules: [{when: always}]
+
+stages: [unencrypted, migrate, encrypted, cleanup]
diff --git a/tests/integration-tests/WarningOnNonEmptyPlan.gitlab-ci.yml b/tests/integration-tests/WarningOnNonEmptyPlan.gitlab-ci.yml
index 1edddd7..9ec1043 100644
--- a/tests/integration-tests/WarningOnNonEmptyPlan.gitlab-ci.yml
+++ b/tests/integration-tests/WarningOnNonEmptyPlan.gitlab-ci.yml
@@ -26,6 +26,7 @@ verify:plan-job:has-warning-state:
     - apk add --update curl jq
   script:
     - |
+      backend_address="${GITLAB_TOFU_STATE_ADDRESS:-${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/terraform/state/${backend_state_name}}"
       endpoint="${CI_API_V4_URL}/projects/${CI_PROJECT_ID}/pipelines/${CI_PIPELINE_ID}/jobs"
       is_warning_job=$(curl --silent "$endpoint" | jq -r '.[] | select(.name == "plan") | [.status == "failed", .allow_failure == true] | all')
       if [ "$is_warning_job" != 'true' ]; then
diff --git a/tests/integration.gitlab-ci.yml b/tests/integration.gitlab-ci.yml
index 8e8e0bc..92e1012 100644
--- a/tests/integration.gitlab-ci.yml
+++ b/tests/integration.gitlab-ci.yml
@@ -122,3 +122,21 @@ plan-job-template:
         GITLAB_OPENTOFU_BASE_IMAGE_OS:
           - alpine
           - debian
+
+apply-job-template:
+  stage: test-integration
+  variables:
+    OPENTOFU_VERSION: $LATEST_OPENTOFU_VERSION
+    TEST_GITLAB_TOFU_STATE_NAME: ci-integration-$CI_JOB_NAME_SLUG-$CI_PIPELINE_IID-$CI_NODE_INDEX
+    TEST_GITLAB_TOFU_ROOT_DIR: tests/iac
+  trigger:
+    include: tests/integration-tests/$PIPELINE_NAME.gitlab-ci.yml
+    strategy: depend
+  parallel:
+    matrix:
+      - PIPELINE_NAME:
+          - AutoEncryption
+          - AutoEncryptionMigrate
+        GITLAB_OPENTOFU_BASE_IMAGE_OS:
+          - alpine
+          - debian
-- 
GitLab