├── .release-please-manifest.json ├── examples ├── binary │ ├── provider.tf │ ├── README.md │ └── main.tf ├── key-value │ ├── provider.tf │ ├── README.md │ └── main.tf ├── plaintext │ ├── provider.tf │ ├── README.md │ └── main.tf ├── replication │ ├── provider.tf │ ├── main.tf │ └── README.md ├── rotation │ ├── provider.tf │ ├── secrets_manager_rotation.zip │ ├── README.md │ └── main.tf ├── migration-scripts │ ├── README.md │ ├── secret_list_to_map.sh │ └── rotate_secret_list_to_map.sh ├── ephemeral │ ├── provider.tf │ ├── validation-test.tf │ ├── test_key.pem │ ├── migration.tf │ ├── main.tf │ ├── ephemeral-for-each-example.tf │ ├── error-examples.tf │ ├── README.md │ ├── ephemeral-limitations.md │ └── ephemeral-for-each-patterns.md └── complete │ └── main.tf ├── versions.tf ├── .release-please-config.json ├── data.tf ├── .gitignore ├── .pre-commit-config.yaml ├── renovate.json ├── .github ├── ISSUE_TEMPLATE │ ├── new-secrets-feature.md │ ├── secrets-bug-fix.md │ └── secrets-deprecation.md ├── workflows │ ├── claude-dispatch.yml │ ├── release-please.yml │ ├── claude.yml │ ├── pre-commit.yml │ └── test.yml ├── feature-tracker │ └── secrets-manager-features.json └── STATUS.md ├── .tflint.hcl ├── test ├── go.mod ├── cleanup │ └── main.go ├── terraform_validation_test.go ├── helpers.go ├── README.md └── terraform_ephemeral_test.go ├── outputs.tf ├── variables.tf ├── main.tf ├── CHANGELOG.md └── LICENSE /.release-please-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | ".": "0.23.0" 3 | } 4 | -------------------------------------------------------------------------------- /examples/binary/provider.tf: -------------------------------------------------------------------------------- 1 | provider "aws" { 2 | profile = "default" 3 | region = "us-east-1" 4 | } 5 | -------------------------------------------------------------------------------- /examples/key-value/provider.tf: -------------------------------------------------------------------------------- 1 | provider "aws" { 2 | profile = "default" 3 | region = "us-east-1" 4 | } 5 | -------------------------------------------------------------------------------- /examples/plaintext/provider.tf: -------------------------------------------------------------------------------- 1 | provider "aws" { 2 | profile = "default" 3 | region = "us-east-1" 4 | } 5 | -------------------------------------------------------------------------------- /examples/replication/provider.tf: -------------------------------------------------------------------------------- 1 | provider "aws" { 2 | profile = "default" 3 | region = "us-east-1" 4 | } 5 | -------------------------------------------------------------------------------- /examples/rotation/provider.tf: -------------------------------------------------------------------------------- 1 | provider "aws" { 2 | profile = "default" 3 | region = "us-east-1" 4 | } 5 | -------------------------------------------------------------------------------- /examples/rotation/secrets_manager_rotation.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lgallard/terraform-aws-secrets-manager/HEAD/examples/rotation/secrets_manager_rotation.zip -------------------------------------------------------------------------------- /versions.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_version = ">= 1.11.0" # Required for ephemeral resources and write-only arguments 3 | 4 | required_providers { 5 | aws = { 6 | source = "hashicorp/aws" 7 | version = ">= 5.0" 8 | } 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /.release-please-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "packages": { 3 | ".": { 4 | "release-type": "terraform-module", 5 | "changelog-path": "CHANGELOG.md", 6 | "include-v-in-tag": false, 7 | "include-component-in-tag": false 8 | } 9 | }, 10 | "pull-request-title-pattern": "chore: release ${version}" 11 | } -------------------------------------------------------------------------------- /examples/migration-scripts/README.md: -------------------------------------------------------------------------------- 1 | # Migration scripts: 2 | 3 | ## secret_list_to_map.sh 4 | You can use this example script to migrate secret lists states to map, after you change you Terraform code to use secret map definitions. 5 | 6 | ## rotate_secret_list_to_map.sh 7 | You can use this example script to migrate rotate secret lists states to map, after you change you Terraform code to use secret map definitions. 8 | -------------------------------------------------------------------------------- /examples/ephemeral/provider.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_version = ">= 1.11.0" # Required for ephemeral resources and write-only arguments 3 | required_providers { 4 | aws = { 5 | source = "hashicorp/aws" 6 | version = ">= 5.0" 7 | } 8 | random = { 9 | source = "hashicorp/random" 10 | version = ">= 3.0" 11 | } 12 | } 13 | } 14 | 15 | # Configure the AWS Provider 16 | provider "aws" { 17 | region = "us-east-1" 18 | } -------------------------------------------------------------------------------- /data.tf: -------------------------------------------------------------------------------- 1 | # Data sources for existing secrets 2 | # These can be used to reference secrets that exist outside of this module 3 | 4 | data "aws_secretsmanager_secret" "existing" { 5 | for_each = var.existing_secrets 6 | 7 | # Handle both ARN and name formats 8 | arn = can(regex("^arn:", each.value)) ? each.value : null 9 | name = can(regex("^arn:", each.value)) ? null : each.value 10 | } 11 | 12 | data "aws_secretsmanager_secret_version" "existing" { 13 | for_each = var.existing_secrets 14 | secret_id = data.aws_secretsmanager_secret.existing[each.key].arn 15 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Terraform state files 2 | *.tfstate 3 | *.tfstate.* 4 | 5 | # Terraform plan files 6 | *.tfplan 7 | *.tfplan.* 8 | 9 | # Terraform provider cache 10 | .terraform/ 11 | .terraform.lock.hcl 12 | 13 | # Terraform variables files containing sensitive data 14 | *.tfvars 15 | *.tfvars.json 16 | 17 | # OS generated files 18 | .DS_Store 19 | .DS_Store? 20 | ._* 21 | .Spotlight-V100 22 | .Trashes 23 | ehthumbs.db 24 | Thumbs.db 25 | 26 | # IDE files 27 | .vscode/ 28 | .idea/ 29 | *.swp 30 | *.swo 31 | *~ 32 | 33 | # Temporary files 34 | *.tmp 35 | *.temp 36 | *.log 37 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | # See https://pre-commit.com for more information 2 | # See https://pre-commit.com/hooks.html for more hooks 3 | repos: 4 | - repo: https://github.com/pre-commit/pre-commit-hooks 5 | rev: v3.2.0 6 | hooks: 7 | - id: trailing-whitespace 8 | - id: end-of-file-fixer 9 | - id: check-added-large-files 10 | - id: detect-aws-credentials 11 | - repo: https://github.com/antonbabenko/pre-commit-terraform 12 | rev: v1.62.3 # Get the latest from: https://github.com/antonbabenko/pre-commit-terraform/releases 13 | hooks: 14 | - id: terraform_fmt 15 | - id: terraform_validate 16 | - id: terraform_docs 17 | -------------------------------------------------------------------------------- /examples/ephemeral/validation-test.tf: -------------------------------------------------------------------------------- 1 | # This file demonstrates validation behavior 2 | # Uncomment the module below to test validation error 3 | 4 | # This should fail validation - missing secret_string_wo_version 5 | # module "validation_test_fail" { 6 | # source = "../../" 7 | # 8 | # ephemeral = true 9 | # 10 | # secrets = { 11 | # invalid_secret = { 12 | # description = "This should fail validation" 13 | # secret_string = "test-value" 14 | # # Missing: secret_string_wo_version = 1 15 | # } 16 | # } 17 | # } 18 | 19 | # This should pass validation 20 | module "validation_test_pass" { 21 | source = "../../" 22 | 23 | ephemeral = true 24 | 25 | secrets = { 26 | valid_secret = { 27 | description = "This should pass validation" 28 | secret_string = "test-value" 29 | secret_string_wo_version = 1 30 | } 31 | } 32 | } -------------------------------------------------------------------------------- /examples/migration-scripts/secret_list_to_map.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | module_name=$1 4 | 5 | # Secret 6 | for secret in $(terraform state list module.${module_name}.aws_secretsmanager_secret.sm) 7 | do 8 | name=$(terraform state show ${secret} | grep name | cut -d "\"" -f2) 9 | index=$(echo ${secret} | cut -d "[" -f2 | cut -d "]" -f1) 10 | terraform state mv module.${module_name}.aws_secretsmanager_secret.sm[${index}] module.${module_name}.aws_secretsmanager_secret.sm["${name}"] 11 | terraform state mv module.${module_name}.aws_secretsmanager_secret_version.sm-sv[${index}] module.${module_name}.aws_secretsmanager_secret_version.sm-sv["${name}"] 12 | 13 | # Unmanaged - Uncomment to migrate unmanaged unmanaged secret versions 14 | #terraform state mv module.${module_name}.aws_secretsmanager_secret_version.sm-svu[${index}] module.${module_name}.aws_secretsmanager_secret_version.sm-svu["${name}"] 15 | done 16 | -------------------------------------------------------------------------------- /examples/migration-scripts/rotate_secret_list_to_map.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | module_name=$1 4 | 5 | # Rotation secret 6 | for secret in $(terraform state list module.${module_name}.aws_secretsmanager_secret.rsm) 7 | do 8 | name=$(terraform state show ${secret} | grep name | cut -d "\"" -f2) 9 | index=$(echo ${secret} | cut -d "[" -f2 | cut -d "]" -f1) 10 | terraform state mv module.${module_name}.aws_secretsmanager_secret.rsm[${index}] module.${module_name}.aws_secretsmanager_secret.rsm["${name}"] 11 | terraform state mv module.${module_name}.aws_secretsmanager_secret_version.rsm-sv[${index}] module.${module_name}.aws_secretsmanager_secret_version.rsm-sv["${name}"] 12 | 13 | # Unmanaged - Uncomment to migrate unmanaged unmanaged rotation secret versions 14 | #terraform state mv module.${module_name}.aws_secretsmanager_secret_version.rsm-svu[${index}] module.${module_name}.aws_secretsmanager_secret_version.rsm-svu["${name}"] 15 | done 16 | -------------------------------------------------------------------------------- /examples/plaintext/README.md: -------------------------------------------------------------------------------- 1 | # Plain text example 2 | ``` 3 | module "secrets-manager-1" { 4 | 5 | #source = "lgallard/secrets-manager/aws" 6 | source = "../../" 7 | 8 | secrets = { 9 | secret-1 = { 10 | description = "My secret 1" 11 | recovery_window_in_days = 7 12 | secret_string = "This is an example" 13 | policy = <.*)$", 54 | "versioning": "go-mod-directive", 55 | "ignoreUnstable": true 56 | } 57 | ] 58 | } 59 | -------------------------------------------------------------------------------- /examples/key-value/main.tf: -------------------------------------------------------------------------------- 1 | terraform { 2 | required_version = ">= 1.11.0" 3 | 4 | required_providers { 5 | aws = { 6 | source = "hashicorp/aws" 7 | version = ">= 5.0" 8 | } 9 | } 10 | } 11 | 12 | module "secrets-manager-2" { 13 | 14 | #source = "lgallard/secrets-manager/aws" 15 | source = "../../" 16 | 17 | secrets = { 18 | secret-kv-1 = { 19 | description = "This is a key/value secret" 20 | secret_key_value = { 21 | key1 = "value1" 22 | key2 = "value2" 23 | } 24 | recovery_window_in_days = 7 25 | policy = < secret.arn 124 | } 125 | } 126 | 127 | output "secret_names" { 128 | description = "Names of created secrets" 129 | value = { 130 | for k, secret in aws_secretsmanager_secret.db_secrets : 131 | k => secret.name 132 | } 133 | } 134 | 135 | output "kms_key_arn" { 136 | description = "ARN of the KMS key used for encryption" 137 | value = aws_kms_key.secrets_key.arn 138 | } 139 | 140 | output "summary" { 141 | description = "Summary of the deployed resources" 142 | value = { 143 | app_name = var.app_name 144 | users_count = length(var.db_users) 145 | users = keys(var.db_users) 146 | pattern_used = "direct-aws-resources-with-ephemeral" 147 | security_note = "Ephemeral passwords are not stored in Terraform state" 148 | } 149 | } -------------------------------------------------------------------------------- /.github/workflows/pre-commit.yml: -------------------------------------------------------------------------------- 1 | name: Pre-commit 2 | 3 | on: 4 | pull_request: 5 | branches: [master] 6 | paths: 7 | - '**.tf' 8 | - '**.tfvars' 9 | - '**.md' 10 | - '.pre-commit-config.yaml' 11 | push: 12 | branches: [master] 13 | paths: 14 | - '**.tf' 15 | - '**.tfvars' 16 | - '**.md' 17 | - '.pre-commit-config.yaml' 18 | 19 | jobs: 20 | pre-commit: 21 | runs-on: ubuntu-latest 22 | timeout-minutes: 15 23 | permissions: 24 | contents: read 25 | pull-requests: read 26 | 27 | steps: 28 | - name: Checkout repository 29 | uses: actions/checkout@v4 30 | with: 31 | fetch-depth: 0 32 | 33 | - name: Set up Python 34 | uses: actions/setup-python@v4 35 | with: 36 | python-version: '3.13' 37 | 38 | - name: Set up Terraform 39 | uses: hashicorp/setup-terraform@v3 40 | with: 41 | terraform_version: '1.3.0' 42 | 43 | - name: Cache terraform tools 44 | uses: actions/cache@v3 45 | with: 46 | path: | 47 | ~/.local/bin/terraform-docs 48 | ~/.local/bin/tflint 49 | key: terraform-tools-${{ runner.os }}-v1 50 | restore-keys: | 51 | terraform-tools-${{ runner.os }}- 52 | 53 | - name: Install terraform-docs 54 | run: | 55 | if [ ! -f ~/.local/bin/terraform-docs ]; then 56 | echo "Installing terraform-docs..." 57 | mkdir -p ~/.local/bin 58 | curl -sSLo ./terraform-docs.tar.gz https://terraform-docs.io/dl/v0.16.0/terraform-docs-v0.16.0-$(uname)-amd64.tar.gz 59 | tar -xzf terraform-docs.tar.gz 60 | chmod +x terraform-docs 61 | mv terraform-docs ~/.local/bin/ 62 | rm terraform-docs.tar.gz 63 | fi 64 | echo "$HOME/.local/bin" >> $GITHUB_PATH 65 | 66 | - name: Install tflint 67 | run: | 68 | if ! command -v tflint &> /dev/null; then 69 | echo "Installing tflint..." 70 | curl -s https://raw.githubusercontent.com/terraform-linters/tflint/master/install_linux.sh | bash 71 | fi 72 | 73 | - name: Install pre-commit 74 | run: | 75 | python -m pip install --upgrade pip 76 | pip install pre-commit 77 | 78 | - name: Cache pre-commit hooks 79 | uses: actions/cache@v3 80 | with: 81 | path: ~/.cache/pre-commit 82 | key: pre-commit-${{ runner.os }}-${{ hashFiles('.pre-commit-config.yaml') }} 83 | restore-keys: | 84 | pre-commit-${{ runner.os }}- 85 | 86 | - name: Install pre-commit hooks 87 | run: pre-commit install-hooks 88 | 89 | - name: Run pre-commit on all files (push to master) 90 | if: github.event_name == 'push' && github.ref == 'refs/heads/master' 91 | run: pre-commit run --all-files 92 | 93 | - name: Run pre-commit on changed files (pull request) 94 | if: github.event_name == 'pull_request' 95 | run: | 96 | # Get the list of changed files 97 | git fetch origin ${{ github.base_ref }} 98 | CHANGED_FILES=$(git diff --name-only origin/${{ github.base_ref }}...HEAD -- '*.tf' '*.tfvars' '*.md') 99 | 100 | if [ -n "$CHANGED_FILES" ]; then 101 | echo "Running pre-commit on changed files:" 102 | echo "$CHANGED_FILES" 103 | pre-commit run --files $CHANGED_FILES 104 | else 105 | echo "No relevant files changed, skipping pre-commit checks" 106 | fi 107 | 108 | - name: Pre-commit summary 109 | if: always() 110 | run: | 111 | echo "## 🔍 Pre-commit Results" >> $GITHUB_STEP_SUMMARY 112 | echo "" >> $GITHUB_STEP_SUMMARY 113 | 114 | if [ "${{ job.status }}" == "success" ]; then 115 | echo "✅ All pre-commit checks passed!" >> $GITHUB_STEP_SUMMARY 116 | echo "" >> $GITHUB_STEP_SUMMARY 117 | echo "**Tools verified:**" >> $GITHUB_STEP_SUMMARY 118 | echo "- 🔧 Terraform formatting" >> $GITHUB_STEP_SUMMARY 119 | echo "- ✅ Terraform validation" >> $GITHUB_STEP_SUMMARY 120 | echo "- 📚 Documentation generation" >> $GITHUB_STEP_SUMMARY 121 | echo "- 🔍 TFLint analysis" >> $GITHUB_STEP_SUMMARY 122 | echo "- 🧹 File formatting" >> $GITHUB_STEP_SUMMARY 123 | else 124 | echo "❌ Pre-commit checks failed" >> $GITHUB_STEP_SUMMARY 125 | echo "" >> $GITHUB_STEP_SUMMARY 126 | echo "Please check the logs above for specific failures." >> $GITHUB_STEP_SUMMARY 127 | echo "You can run \`pre-commit run --all-files\` locally to fix issues." >> $GITHUB_STEP_SUMMARY 128 | fi 129 | 130 | echo "" >> $GITHUB_STEP_SUMMARY 131 | echo "**Configured hooks:**" >> $GITHUB_STEP_SUMMARY 132 | echo "- trailing-whitespace" >> $GITHUB_STEP_SUMMARY 133 | echo "- end-of-file-fixer" >> $GITHUB_STEP_SUMMARY 134 | echo "- check-yaml" >> $GITHUB_STEP_SUMMARY 135 | echo "- terraform_fmt" >> $GITHUB_STEP_SUMMARY 136 | echo "- terraform_validate" >> $GITHUB_STEP_SUMMARY 137 | echo "- terraform_docs" >> $GITHUB_STEP_SUMMARY 138 | echo "- terraform_tflint" >> $GITHUB_STEP_SUMMARY -------------------------------------------------------------------------------- /examples/ephemeral/error-examples.tf: -------------------------------------------------------------------------------- 1 | # Error Examples for Ephemeral Secrets 2 | # These examples demonstrate common configuration errors and how to fix them 3 | 4 | # ERROR EXAMPLE 1: Missing version parameter when ephemeral is enabled 5 | # This will fail validation 6 | # module "error_missing_version" { 7 | # source = "../../" 8 | # 9 | # ephemeral = true 10 | # 11 | # secrets = { 12 | # bad_secret = { 13 | # description = "This will fail - missing secret_string_wo_version" 14 | # secret_string = "some-secret" 15 | # # Missing: secret_string_wo_version = 1 16 | # } 17 | # } 18 | # } 19 | # Error: secret_string_wo_version is required and must be >= 1 when ephemeral is enabled. 20 | 21 | # CORRECT VERSION: 22 | module "correct_version" { 23 | source = "../../" 24 | 25 | ephemeral = true 26 | 27 | secrets = { 28 | good_secret = { 29 | description = "This works - has required version parameter" 30 | secret_string = "some-secret" 31 | secret_string_wo_version = 1 # Required when ephemeral = true 32 | } 33 | } 34 | } 35 | 36 | # ERROR EXAMPLE 2: Invalid version value (zero or negative) 37 | # This will fail validation 38 | # module "error_invalid_version" { 39 | # source = "../../" 40 | # 41 | # ephemeral = true 42 | # 43 | # secrets = { 44 | # bad_version = { 45 | # description = "This will fail - invalid version value" 46 | # secret_string = "some-secret" 47 | # secret_string_wo_version = 0 # Must be >= 1 48 | # } 49 | # } 50 | # } 51 | # Error: secret_string_wo_version is required and must be >= 1 when ephemeral is enabled. 52 | 53 | # ERROR EXAMPLE 3: Using conflicting version parameters 54 | # This will fail validation 55 | # module "error_conflicting_versions" { 56 | # source = "../../" 57 | # 58 | # ephemeral = true 59 | # 60 | # secrets = { 61 | # conflicting_secret = { 62 | # description = "This will fail - conflicting version parameters" 63 | # secret_string = "some-secret" 64 | # secret_string_wo_version = 1 65 | # secret_binary_wo_version = 1 # Should not be used with secret_string_wo_version 66 | # } 67 | # } 68 | # } 69 | # Error: Cannot specify both secret_string_wo_version and secret_binary_wo_version for the same secret when ephemeral is enabled. 70 | 71 | # ERROR EXAMPLE 4: Using ephemeral with incompatible Terraform version 72 | # This will fail if using Terraform < 1.11 73 | # The provider.tf should specify: required_version = ">= 1.11" 74 | 75 | # ERROR EXAMPLE 5: Trying to use write-only parameters without ephemeral mode 76 | # This configuration is valid but the write-only parameters will be ignored 77 | module "ignored_wo_parameters" { 78 | source = "../../" 79 | 80 | ephemeral = false # Default value 81 | 82 | secrets = { 83 | regular_secret = { 84 | description = "Regular secret - wo_version will be ignored" 85 | secret_string = "some-secret" 86 | secret_string_wo_version = 1 # This will be ignored when ephemeral = false 87 | } 88 | } 89 | } 90 | 91 | # BEST PRACTICES EXAMPLES 92 | 93 | # 1. Proper ephemeral configuration 94 | module "best_practice_ephemeral" { 95 | source = "../../" 96 | 97 | ephemeral = true 98 | 99 | secrets = { 100 | # String secret 101 | app_password = { 102 | description = "Application password (ephemeral)" 103 | secret_string = var.app_password 104 | secret_string_wo_version = 1 105 | } 106 | 107 | # Key-value secret 108 | database_config = { 109 | description = "Database configuration (ephemeral)" 110 | secret_key_value = { 111 | username = var.db_username 112 | password = var.db_password # Uses db_password from main.tf 113 | host = var.db_host 114 | } 115 | secret_string_wo_version = 1 116 | } 117 | 118 | # Binary secret (SSH key) 119 | ssh_key = { 120 | description = "SSH private key (ephemeral)" 121 | secret_binary = file("${path.module}/test_key.pem") 122 | secret_string_wo_version = 1 # Note: Use string version for binary too 123 | } 124 | } 125 | 126 | tags = { 127 | Environment = "production" 128 | Security = "ephemeral" 129 | Compliance = "required" 130 | } 131 | } 132 | 133 | # 2. Version increment for updates 134 | module "version_increment_example" { 135 | source = "../../" 136 | 137 | ephemeral = true 138 | 139 | secrets = { 140 | updated_secret = { 141 | description = "Secret that needs updating" 142 | secret_string = var.new_secret_value 143 | secret_string_wo_version = 2 # Increment to trigger update 144 | } 145 | } 146 | } 147 | 148 | # Variables for examples (additional to main.tf variables) 149 | variable "app_password" { 150 | description = "Application password" 151 | type = string 152 | sensitive = true 153 | default = "app-secret-password" 154 | } 155 | 156 | variable "db_username" { 157 | description = "Database username" 158 | type = string 159 | default = "dbuser" 160 | } 161 | 162 | variable "db_host" { 163 | description = "Database host" 164 | type = string 165 | default = "db.example.com" 166 | } 167 | 168 | variable "new_secret_value" { 169 | description = "New secret value for update example" 170 | type = string 171 | sensitive = true 172 | default = "updated-secret-value" 173 | } -------------------------------------------------------------------------------- /test/go.mod: -------------------------------------------------------------------------------- 1 | module github.com/lgallard/terraform-aws-secrets-manager/test 2 | 3 | go 1.21 4 | 5 | require ( 6 | github.com/aws/aws-sdk-go v1.44.240 7 | github.com/gruntwork-io/terratest v0.46.8 8 | github.com/stretchr/testify v1.8.4 9 | ) 10 | 11 | require ( 12 | cloud.google.com/go v0.112.1 // indirect 13 | cloud.google.com/go/compute v1.25.1 // indirect 14 | cloud.google.com/go/compute/metadata v0.2.3 // indirect 15 | cloud.google.com/go/iam v1.1.6 // indirect 16 | cloud.google.com/go/storage v1.38.0 // indirect 17 | github.com/agext/levenshtein v1.2.3 // indirect 18 | github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect 19 | github.com/bgentry/go-netrc v0.0.0-20140422174119-9fd32a8b3d3d // indirect 20 | github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc // indirect 21 | github.com/cpuguy83/go-md2man/v2 v2.0.2 // indirect 22 | github.com/davecgh/go-spew v1.1.1 // indirect 23 | github.com/emicklei/go-restful/v3 v3.9.0 // indirect 24 | github.com/felixge/httpsnoop v1.0.4 // indirect 25 | github.com/go-errors/errors v1.4.2 // indirect 26 | github.com/go-logr/logr v1.4.1 // indirect 27 | github.com/go-logr/stdr v1.2.2 // indirect 28 | github.com/go-openapi/jsonpointer v0.19.6 // indirect 29 | github.com/go-openapi/jsonreference v0.20.2 // indirect 30 | github.com/go-openapi/swag v0.22.3 // indirect 31 | github.com/go-sql-driver/mysql v1.4.1 // indirect 32 | github.com/gogo/protobuf v1.3.2 // indirect 33 | github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect 34 | github.com/golang/protobuf v1.5.3 // indirect 35 | github.com/google/gnostic-models v0.6.8 // indirect 36 | github.com/google/go-cmp v0.6.0 // indirect 37 | github.com/google/gofuzz v1.2.0 // indirect 38 | github.com/google/s2a-go v0.1.7 // indirect 39 | github.com/google/uuid v1.6.0 // indirect 40 | github.com/googleapis/enterprise-certificate-proxy v0.3.2 // indirect 41 | github.com/googleapis/gax-go/v2 v2.12.2 // indirect 42 | github.com/gruntwork-io/go-commons v0.17.0 // indirect 43 | github.com/hashicorp/errwrap v1.1.0 // indirect 44 | github.com/hashicorp/go-cleanhttp v0.5.2 // indirect 45 | github.com/hashicorp/go-getter v1.7.1 // indirect 46 | github.com/hashicorp/go-multierror v1.1.1 // indirect 47 | github.com/hashicorp/go-safetemp v1.0.0 // indirect 48 | github.com/hashicorp/go-version v1.6.0 // indirect 49 | github.com/hashicorp/hcl/v2 v2.16.2 // indirect 50 | github.com/hashicorp/terraform-json v0.16.0 // indirect 51 | github.com/imdario/mergo v0.3.11 // indirect 52 | github.com/jinzhu/copier v0.3.5 // indirect 53 | github.com/jmespath/go-jmespath v0.4.0 // indirect 54 | github.com/josharian/intern v1.0.0 // indirect 55 | github.com/json-iterator/go v1.1.12 // indirect 56 | github.com/klauspost/compress v1.16.0 // indirect 57 | github.com/mailru/easyjson v0.7.7 // indirect 58 | github.com/mattn/go-zglob v0.0.4 // indirect 59 | github.com/mitchellh/go-homedir v1.1.0 // indirect 60 | github.com/mitchellh/go-testing-interface v1.14.1 // indirect 61 | github.com/mitchellh/go-wordwrap v1.0.1 // indirect 62 | github.com/moby/spdystream v0.2.0 // indirect 63 | github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect 64 | github.com/modern-go/reflect2 v1.0.2 // indirect 65 | github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect 66 | github.com/pmezard/go-difflib v1.0.0 // indirect 67 | github.com/pquerna/otp v1.2.0 // indirect 68 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 69 | github.com/spf13/pflag v1.0.5 // indirect 70 | github.com/tmccombs/hcl2json v0.3.3 // indirect 71 | github.com/ulikunitz/xz v0.5.11 // indirect 72 | github.com/urfave/cli/v2 v2.10.3 // indirect 73 | github.com/xrash/smetrics v0.0.0-20201216005158-039620a65673 // indirect 74 | github.com/zclconf/go-cty v1.13.1 // indirect 75 | go.opencensus.io v0.24.0 // indirect 76 | go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc v0.49.0 // indirect 77 | go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.49.0 // indirect 78 | go.opentelemetry.io/otel v1.24.0 // indirect 79 | go.opentelemetry.io/otel/metric v1.24.0 // indirect 80 | go.opentelemetry.io/otel/trace v1.24.0 // indirect 81 | golang.org/x/crypto v0.19.0 // indirect 82 | golang.org/x/exp v0.0.0-20221106115401-f9659909a136 // indirect 83 | golang.org/x/net v0.21.0 // indirect 84 | golang.org/x/oauth2 v0.17.0 // indirect 85 | golang.org/x/sync v0.6.0 // indirect 86 | golang.org/x/sys v0.17.0 // indirect 87 | golang.org/x/term v0.17.0 // indirect 88 | golang.org/x/text v0.14.0 // indirect 89 | golang.org/x/time v0.5.0 // indirect 90 | google.golang.org/api v0.169.0 // indirect 91 | google.golang.org/appengine v1.6.8 // indirect 92 | google.golang.org/genproto v0.0.0-20240213162025-012b6fc9bca9 // indirect 93 | google.golang.org/genproto/googleapis/api v0.0.0-20240311132316-a219d84964c2 // indirect 94 | google.golang.org/genproto/googleapis/rpc v0.0.0-20240304161311-37d4d3c04a78 // indirect 95 | google.golang.org/grpc v1.62.1 // indirect 96 | google.golang.org/protobuf v1.33.0 // indirect 97 | gopkg.in/inf.v0 v0.9.1 // indirect 98 | gopkg.in/yaml.v2 v2.4.0 // indirect 99 | gopkg.in/yaml.v3 v3.0.1 // indirect 100 | k8s.io/api v0.28.4 // indirect 101 | k8s.io/apimachinery v0.28.4 // indirect 102 | k8s.io/client-go v0.28.4 // indirect 103 | k8s.io/klog/v2 v2.100.1 // indirect 104 | k8s.io/kube-openapi v0.0.0-20230717233707-2695361300d9 // indirect 105 | k8s.io/utils v0.0.0-20230406110748-d93618cff8a2 // indirect 106 | sigs.k8s.io/json v0.0.0-20221116044647-bc3834ca7abd // indirect 107 | sigs.k8s.io/structured-merge-diff/v4 v4.2.3 // indirect 108 | sigs.k8s.io/yaml v1.3.0 // indirect 109 | ) 110 | -------------------------------------------------------------------------------- /outputs.tf: -------------------------------------------------------------------------------- 1 | # Backward compatible outputs (maintained for existing users) 2 | output "secret_ids" { 3 | description = "Map of secret names to their resource IDs. Use these IDs to reference secrets in other Terraform resources." 4 | value = { for k, v in aws_secretsmanager_secret.sm : k => v["id"] } 5 | } 6 | 7 | output "secret_arns" { 8 | description = "Map of secret names to their ARNs. Use these ARNs to grant permissions or reference secrets in IAM policies and other AWS resources." 9 | value = { for k, v in aws_secretsmanager_secret.sm : k => v["arn"] } 10 | } 11 | 12 | output "rotate_secret_ids" { 13 | description = "Map of rotating secret names to their resource IDs. Use these IDs to reference rotating secrets in other Terraform resources." 14 | value = { for k, v in aws_secretsmanager_secret.rsm : k => v["id"] } 15 | } 16 | 17 | output "rotate_secret_arns" { 18 | description = "Map of rotating secret names to their ARNs. Use these ARNs to grant permissions or reference rotating secrets in IAM policies and other AWS resources." 19 | value = { for k, v in aws_secretsmanager_secret.rsm : k => v["arn"] } 20 | } 21 | 22 | # Enhanced comprehensive outputs 23 | output "secrets" { 24 | description = "Complete map of regular secrets with all attributes including ARNs, names, KMS keys, descriptions, and replica information." 25 | value = { for k, v in aws_secretsmanager_secret.sm : k => { 26 | arn = v.arn 27 | id = v.id 28 | name = v.name 29 | description = v.description 30 | kms_key_id = v.kms_key_id 31 | policy = v.policy 32 | recovery_window_in_days = v.recovery_window_in_days 33 | tags = v.tags 34 | tags_all = v.tags_all 35 | replica = v.replica 36 | }} 37 | } 38 | 39 | output "rotate_secrets" { 40 | description = "Complete map of rotating secrets with all attributes including ARNs, names, KMS keys, descriptions, and rotation information." 41 | value = { for k, v in aws_secretsmanager_secret.rsm : k => { 42 | arn = v.arn 43 | id = v.id 44 | name = v.name 45 | description = v.description 46 | kms_key_id = v.kms_key_id 47 | policy = v.policy 48 | recovery_window_in_days = v.recovery_window_in_days 49 | tags = v.tags 50 | tags_all = v.tags_all 51 | }} 52 | } 53 | 54 | # Secret version outputs (conditional based on management mode) 55 | output "secret_versions" { 56 | description = "Map of managed secret versions with their ARNs and version information." 57 | value = var.unmanaged ? {} : { for k, v in aws_secretsmanager_secret_version.sm-sv : k => { 58 | arn = v.arn 59 | id = v.id 60 | secret_id = v.secret_id 61 | version_id = v.version_id 62 | version_stages = v.version_stages 63 | }} 64 | } 65 | 66 | output "rotate_secret_versions" { 67 | description = "Map of managed rotating secret versions with their ARNs and version information." 68 | value = var.unmanaged ? {} : { for k, v in aws_secretsmanager_secret_version.rsm-sv : k => { 69 | arn = v.arn 70 | id = v.id 71 | secret_id = v.secret_id 72 | version_id = v.version_id 73 | version_stages = v.version_stages 74 | }} 75 | } 76 | 77 | # Rotation configuration outputs 78 | output "secret_rotations" { 79 | description = "Map of secret rotation configurations with Lambda ARN and rotation schedule information." 80 | value = { for k, v in aws_secretsmanager_secret_rotation.rsm-sr : k => { 81 | arn = v.arn 82 | id = v.id 83 | secret_id = v.secret_id 84 | rotation_enabled = v.rotation_enabled 85 | rotation_lambda_arn = v.rotation_lambda_arn 86 | rotation_rules = v.rotation_rules 87 | }} 88 | } 89 | 90 | # Summary outputs for easy reference 91 | output "all_secret_arns" { 92 | description = "List of all secret ARNs (both regular and rotating) for easy reference in IAM policies." 93 | value = concat( 94 | [for v in aws_secretsmanager_secret.sm : v.arn], 95 | [for v in aws_secretsmanager_secret.rsm : v.arn] 96 | ) 97 | } 98 | 99 | output "secrets_by_name" { 100 | description = "Map of actual secret names to their ARNs, useful for referencing secrets by their AWS names rather than Terraform keys." 101 | value = merge( 102 | { for k, v in aws_secretsmanager_secret.sm : v.name => v.arn }, 103 | { for k, v in aws_secretsmanager_secret.rsm : v.name => v.arn } 104 | ) 105 | } 106 | 107 | # Data source outputs for existing secrets 108 | output "existing_secrets" { 109 | description = "Map of existing secrets referenced as data sources with their complete attributes." 110 | value = length(var.existing_secrets) > 0 ? { for k, v in data.aws_secretsmanager_secret.existing : k => { 111 | arn = v.arn 112 | id = v.id 113 | name = v.name 114 | description = v.description 115 | kms_key_id = v.kms_key_id 116 | policy = v.policy 117 | recovery_window_in_days = v.recovery_window_in_days 118 | tags = v.tags 119 | replica = v.replica 120 | }} : {} 121 | } 122 | 123 | output "existing_secret_versions" { 124 | description = "Map of existing secret versions with their current values and metadata." 125 | value = length(var.existing_secrets) > 0 ? { for k, v in data.aws_secretsmanager_secret_version.existing : k => { 126 | arn = v.arn 127 | id = v.id 128 | secret_id = v.secret_id 129 | version_id = v.version_id 130 | version_stages = v.version_stages 131 | # Note: secret_string and secret_binary are sensitive and not exposed 132 | }} : {} 133 | } 134 | -------------------------------------------------------------------------------- /examples/ephemeral/README.md: -------------------------------------------------------------------------------- 1 | # Ephemeral Secrets Example 2 | 3 | This example demonstrates how to use the ephemeral feature to prevent sensitive data from being stored in Terraform state. 4 | 5 | ## Overview 6 | 7 | When `ephemeral = true` is set, the module uses write-only arguments (`_wo` parameters) that prevent sensitive values from being persisted in the Terraform state file. This feature requires Terraform 1.11 or later. 8 | 9 | ## Key Features 10 | 11 | - **Security**: Sensitive values are not stored in Terraform state 12 | - **Compatibility**: Works with all secret types (plaintext, key-value, binary) 13 | - **Versioning**: Uses version parameters to control updates 14 | - **Backward Compatibility**: Default behavior remains unchanged 15 | 16 | ## Usage 17 | 18 | To use ephemeral secrets, set `ephemeral = true` in your module configuration: 19 | 20 | ```hcl 21 | module "secrets_manager" { 22 | source = "../../" 23 | 24 | ephemeral = true 25 | 26 | secrets = { 27 | db_password = { 28 | description = "Database password (ephemeral)" 29 | secret_string = var.db_password 30 | secret_string_wo_version = 1 31 | } 32 | } 33 | } 34 | ``` 35 | 36 | ## Version Control 37 | 38 | When using ephemeral secrets, you can control when secrets are updated by incrementing the version parameter: 39 | 40 | - `secret_string_wo_version` - for string secrets and binary secrets (binary secrets are stored as base64-encoded strings when ephemeral is enabled) 41 | 42 | ## Requirements 43 | 44 | - Terraform >= 1.11 45 | - AWS Provider >= 2.67.0 46 | 47 | ## Benefits 48 | 49 | 1. **Enhanced Security**: Sensitive data never appears in state files 50 | 2. **Compliance**: Meets security requirements for sensitive data handling 51 | 3. **Audit Trail**: Version parameters provide update tracking 52 | 4. **Flexibility**: Can be used with ephemeral resources for end-to-end security 53 | 54 | ## Example with Ephemeral Resources 55 | 56 | ```hcl 57 | # Generate ephemeral password 58 | ephemeral "random_password" "db_password" { 59 | length = 16 60 | special = true 61 | } 62 | 63 | # Use ephemeral password in secret 64 | module "secrets_manager" { 65 | source = "../../" 66 | 67 | ephemeral = true 68 | 69 | secrets = { 70 | db_password = { 71 | description = "Database password (ephemeral)" 72 | secret_string = ephemeral.random_password.db_password.result 73 | secret_string_wo_version = 1 74 | } 75 | } 76 | } 77 | ``` 78 | 79 | This configuration ensures that the password remains ephemeral throughout the entire workflow without being exposed in Terraform's plan or state files. 80 | 81 | ## Migration from Regular to Ephemeral Secrets 82 | 83 | ⚠️ **Important**: Migrating from regular to ephemeral secrets will recreate the secret resources. 84 | 85 | ### Migration Steps 86 | 87 | 1. **Update Configuration**: Add `ephemeral = true` and `secret_string_wo_version = 1` to each secret 88 | 2. **Plan Changes**: Run `terraform plan` to review the changes (resources will be recreated) 89 | 3. **Apply Changes**: Run `terraform apply` to migrate to ephemeral mode 90 | 4. **Verify**: Check that sensitive values are no longer in the state file 91 | 92 | ### Before Migration 93 | ```hcl 94 | module "secrets" { 95 | source = "../../" 96 | 97 | secrets = { 98 | db_password = { 99 | description = "Database password" 100 | secret_string = var.db_password 101 | } 102 | } 103 | } 104 | ``` 105 | 106 | ### After Migration 107 | ```hcl 108 | module "secrets" { 109 | source = "../../" 110 | 111 | ephemeral = true 112 | 113 | secrets = { 114 | db_password = { 115 | description = "Database password (ephemeral)" 116 | secret_string = var.db_password 117 | secret_string_wo_version = 1 118 | } 119 | } 120 | } 121 | ``` 122 | 123 | See `migration.tf` for a complete migration example. 124 | 125 | ## Common Issues and Solutions 126 | 127 | ### Issue: Version Parameter Missing 128 | **Error**: `secret_string_wo_version is required and must be >= 1 when ephemeral is enabled` 129 | **Solution**: Add `secret_string_wo_version = 1` to your secret configuration 130 | 131 | ### Issue: Invalid Version Value 132 | **Error**: Version parameter validation fails 133 | **Solution**: Ensure `secret_string_wo_version` is a positive integer (>= 1) 134 | 135 | ### Issue: Conflicting Version Parameters 136 | **Error**: Cannot specify both version parameters 137 | **Solution**: Use only `secret_string_wo_version` for all secret types (including binary) 138 | 139 | ## Advanced Patterns 140 | 141 | ### GitHub Issue #80: For_each with Ephemeral Passwords 142 | 143 | See `ephemeral-for-each-example.tf` for the **working solution** to use ephemeral `random_password` resources with `for_each` patterns. 144 | 145 | **Problem**: Module variables cannot accept ephemeral values with `for_each` due to Terraform limitations. 146 | 147 | **Solution**: Use direct AWS resources instead of the module wrapper: 148 | 149 | ```hcl 150 | ephemeral "random_password" "db_passwords" { 151 | for_each = var.db_users 152 | length = 24 153 | special = true 154 | } 155 | 156 | resource "aws_secretsmanager_secret_version" "db_secret_versions" { 157 | for_each = var.db_users 158 | secret_id = aws_secretsmanager_secret.db_secrets[each.key].id 159 | 160 | secret_string_wo = jsonencode({ 161 | password = ephemeral.random_password.db_passwords[each.key].result 162 | username = each.key 163 | # ... other fields 164 | }) 165 | 166 | secret_string_wo_version = 1 167 | } 168 | ``` 169 | 170 | This approach provides the same security benefits while working within Terraform's architectural constraints. 171 | 172 | ## Files in this Directory 173 | 174 | - `main.tf` - Basic ephemeral secrets using the module 175 | - `ephemeral-for-each-example.tf` - Working solution for ephemeral + for_each patterns 176 | - `migration.tf` - Example migration from regular to ephemeral secrets 177 | - `validation-test.tf` - Test configuration for validation 178 | - `ephemeral-for-each-patterns.md` - Detailed technical analysis and solutions 179 | - `ephemeral-limitations.md` - Explanation of Terraform limitations -------------------------------------------------------------------------------- /.github/STATUS.md: -------------------------------------------------------------------------------- 1 | # CI/CD Status and Quality Gates 2 | 3 | ## Status Badges 4 | 5 | Add these badges to your README.md to show the current status: 6 | 7 | ```markdown 8 | [![Test](https://github.com/lgallard/terraform-aws-secrets-manager/workflows/Test/badge.svg)](https://github.com/lgallard/terraform-aws-secrets-manager/actions/workflows/test.yml) 9 | [![Security](https://github.com/lgallard/terraform-aws-secrets-manager/workflows/Security/badge.svg)](https://github.com/lgallard/terraform-aws-secrets-manager/actions) 10 | [![Release](https://github.com/lgallard/terraform-aws-secrets-manager/workflows/Release/badge.svg)](https://github.com/lgallard/terraform-aws-secrets-manager/releases) 11 | ``` 12 | 13 | ## Quality Gates 14 | 15 | ### Pull Request Requirements 16 | 17 | Before merging, the following checks must pass: 18 | 19 | - ✅ **Format Check** - All Terraform files properly formatted 20 | - ✅ **Validation** - Terraform configuration validates successfully 21 | - ✅ **Security Scan** - No high-severity security issues found 22 | - ✅ **Linting** - TFLint passes with no errors 23 | - ✅ **Unit Tests** - Validation and ephemeral tests pass 24 | - ✅ **Examples** - All example configurations validate 25 | 26 | ### Master Branch Requirements 27 | 28 | Additional checks for master branch: 29 | 30 | - ✅ **Integration Tests** - Full integration testing passes 31 | - ✅ **Multi-Region** - Tests pass in multiple AWS regions 32 | - ✅ **Ephemeral Security** - State files contain no sensitive data 33 | - ✅ **Resource Cleanup** - No test resources left behind 34 | 35 | ### Manual Quality Checks 36 | 37 | For major releases, perform these additional checks: 38 | 39 | - 📋 **Documentation** - README and examples are up to date 40 | - 📋 **Breaking Changes** - Migration guide provided if needed 41 | - 📋 **Performance** - No significant performance regressions 42 | - 📋 **Security Review** - Security implications reviewed 43 | 44 | ## Test Coverage Goals 45 | 46 | | Test Category | Target Coverage | Current Status | 47 | |---------------|----------------|----------------| 48 | | Validation | 100% | ✅ Complete | 49 | | Ephemeral Functionality | 100% | ✅ Complete | 50 | | Basic Integration | 90% | ✅ Complete | 51 | | Edge Cases | 80% | ✅ Complete | 52 | | Error Scenarios | 70% | ✅ Complete | 53 | 54 | ## Metrics and Monitoring 55 | 56 | ### Test Execution Times 57 | 58 | | Test Suite | Target Time | Actual Time | 59 | |------------|-------------|-------------| 60 | | Validation | < 5 minutes | ~2 minutes | 61 | | Ephemeral | < 20 minutes | ~15 minutes | 62 | | Integration | < 40 minutes | ~30 minutes | 63 | | Full Suite | < 60 minutes | ~45 minutes | 64 | 65 | ### Success Rates 66 | 67 | Target: 95% success rate over 30-day rolling window 68 | 69 | ### Resource Usage 70 | 71 | - Cost per test run: Target < $0.50 72 | - Resources created per test: Target < 10 73 | - Cleanup success rate: Target > 99% 74 | 75 | ## Failure Handling 76 | 77 | ### Test Failures 78 | 79 | 1. **Immediate Actions:** 80 | - Review test logs in GitHub Actions 81 | - Check for infrastructure issues 82 | - Verify AWS service availability 83 | 84 | 2. **Common Failure Scenarios:** 85 | - Resource limit exceeded → Cleanup and retry 86 | - Network timeout → Increase timeout values 87 | - Permission issues → Verify IAM roles 88 | 89 | 3. **Escalation Process:** 90 | - 3 consecutive failures → Investigate root cause 91 | - Security test failure → Block deployment 92 | - Integration test failure → Review changes 93 | 94 | ### Cleanup Failures 95 | 96 | 1. **Automatic Cleanup:** 97 | - Runs after every test suite 98 | - Targets test-specific resource patterns 99 | - Reports cleanup statistics 100 | 101 | 2. **Manual Cleanup:** 102 | ```bash 103 | cd test && go run cleanup/main.go 104 | ``` 105 | 106 | 3. **Monitoring:** 107 | - Weekly cleanup audits 108 | - Cost monitoring for orphaned resources 109 | - Automated alerts for resource accumulation 110 | 111 | ## Security Monitoring 112 | 113 | ### Continuous Security Scanning 114 | 115 | - **tfsec** - Terraform security scanning 116 | - **Checkov** - Policy and compliance checking 117 | - **SARIF** - Security results uploaded to GitHub Security tab 118 | 119 | ### Ephemeral Security Validation 120 | 121 | Special monitoring for ephemeral functionality: 122 | 123 | - State file analysis for sensitive data leakage 124 | - Write-only parameter validation 125 | - Version control mechanism testing 126 | 127 | ### Security Incident Response 128 | 129 | 1. **High-severity finding** → Block deployment immediately 130 | 2. **Medium-severity finding** → Create issue, fix within 7 days 131 | 3. **Low-severity finding** → Create issue, fix within 30 days 132 | 133 | ## Performance Monitoring 134 | 135 | ### Test Performance Metrics 136 | 137 | - Execution time trending 138 | - Resource creation/deletion times 139 | - AWS API response times 140 | - Parallel execution efficiency 141 | 142 | ### Optimization Targets 143 | 144 | - Reduce test execution time by 20% annually 145 | - Improve parallel execution efficiency 146 | - Minimize AWS resource costs 147 | - Optimize cleanup procedures 148 | 149 | ## Compliance and Auditing 150 | 151 | ### Test Audit Trail 152 | 153 | - All test executions logged with timestamps 154 | - Git commit hash recorded for each test run 155 | - AWS resources tagged with test metadata 156 | - Test results archived for 90 days 157 | 158 | ### Compliance Checks 159 | 160 | - SOC 2 compliance validation 161 | - GDPR data handling verification 162 | - AWS security best practices adherence 163 | - Infrastructure as Code governance 164 | 165 | ## Continuous Improvement 166 | 167 | ### Weekly Reviews 168 | 169 | - Test failure rate analysis 170 | - Performance trend review 171 | - Security finding assessment 172 | - Cost optimization opportunities 173 | 174 | ### Monthly Reports 175 | 176 | - Test coverage metrics 177 | - Quality gate effectiveness 178 | - Security posture summary 179 | - Performance benchmarking 180 | 181 | ### Quarterly Assessments 182 | 183 | - Testing strategy review 184 | - Tool and process evaluation 185 | - Security framework updates 186 | - Performance optimization planning -------------------------------------------------------------------------------- /test/cleanup/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | "regexp" 7 | "strings" 8 | "time" 9 | 10 | "github.com/aws/aws-sdk-go/aws" 11 | "github.com/aws/aws-sdk-go/aws/session" 12 | "github.com/aws/aws-sdk-go/service/secretsmanager" 13 | ) 14 | 15 | func main() { 16 | region := os.Getenv("AWS_DEFAULT_REGION") 17 | if region == "" { 18 | region = "us-east-1" 19 | } 20 | 21 | sess, err := session.NewSession(&aws.Config{ 22 | Region: aws.String(region), 23 | }) 24 | if err != nil { 25 | log.Fatalf("Failed to create AWS session: %v", err) 26 | } 27 | 28 | svc := secretsmanager.New(sess) 29 | 30 | // Define test prefixes to clean up 31 | testPrefixes := []string{ 32 | "plan-test-", 33 | "ephemeral-vs-regular-", 34 | "ephemeral-types-", 35 | "ephemeral-versioning-", 36 | "ephemeral-rotation-", 37 | "test-secret-", 38 | "ephemeral-secret-", 39 | "tagged-secret-", 40 | "regular-secret-", 41 | "ephemeral-plaintext-", 42 | "ephemeral-kv-", 43 | "ephemeral-binary-", 44 | "versioned-secret-", 45 | "ephemeral-rotating-", 46 | // Additional patterns found in tests 47 | "plaintext-", 48 | "keyvalue-", 49 | "rotation-", 50 | "binary-", 51 | "multiple-secrets-", 52 | "basic-", 53 | "complete-", 54 | "example-", 55 | } 56 | 57 | log.Printf("Starting cleanup of test secrets in region %s", region) 58 | 59 | // List all secrets with pagination support 60 | var allSecrets []*secretsmanager.SecretListEntry 61 | input := &secretsmanager.ListSecretsInput{} 62 | 63 | for { 64 | result, err := svc.ListSecrets(input) 65 | if err != nil { 66 | log.Fatalf("Failed to list secrets: %v", err) 67 | } 68 | 69 | allSecrets = append(allSecrets, result.SecretList...) 70 | 71 | // Check if there are more results 72 | if result.NextToken == nil { 73 | break 74 | } 75 | input.NextToken = result.NextToken 76 | } 77 | 78 | log.Printf("Found %d total secrets to evaluate", len(allSecrets)) 79 | deletedCount := 0 80 | for _, secret := range allSecrets { 81 | if secret.Name == nil { 82 | continue 83 | } 84 | 85 | secretName := *secret.Name 86 | shouldDelete := false 87 | 88 | // Check if secret matches any test prefix 89 | for _, prefix := range testPrefixes { 90 | if strings.HasPrefix(secretName, prefix) { 91 | shouldDelete = true 92 | break 93 | } 94 | } 95 | 96 | // Also check for secrets created in the last 6 hours with test-like patterns 97 | // This catches test secrets that may not match exact prefixes 98 | if !shouldDelete && secret.CreatedDate != nil { 99 | timeSinceCreation := time.Since(*secret.CreatedDate) 100 | if timeSinceCreation < 6*time.Hour { 101 | // Check for common test patterns (more aggressive) 102 | testPatterns := []string{ 103 | "test-", 104 | "terratest-", 105 | "ephemeral-", 106 | "validation-", 107 | // UUID patterns that indicate test names 108 | "-abcdef", "-123456", "-test", "-demo", 109 | // Common Terratest random ID patterns 110 | "-random-", "-unique-", 111 | } 112 | secretNameLower := strings.ToLower(secretName) 113 | for _, pattern := range testPatterns { 114 | if strings.Contains(secretNameLower, pattern) { 115 | shouldDelete = true 116 | break 117 | } 118 | } 119 | 120 | // Add time bounds validation to prevent negative durations or clock skew issues 121 | if !shouldDelete && timeSinceCreation >= 0 && timeSinceCreation < 6*time.Hour { 122 | // Check for names with random suffix patterns (like Terratest generates) 123 | if len(secretName) > 10 && strings.Contains(secretName, "-") { 124 | parts := strings.Split(secretName, "-") 125 | for _, part := range parts { 126 | // Look for hex patterns or purely numeric patterns that indicate test IDs 127 | if len(part) >= 6 && (isHexString(part) || isNumericString(part)) { 128 | shouldDelete = true 129 | break 130 | } 131 | } 132 | } 133 | } 134 | } 135 | } 136 | 137 | if shouldDelete { 138 | log.Printf("Deleting test secret: %s", secretName) 139 | 140 | _, err := svc.DeleteSecret(&secretsmanager.DeleteSecretInput{ 141 | SecretId: aws.String(secretName), 142 | ForceDeleteWithoutRecovery: aws.Bool(true), 143 | }) 144 | 145 | if err != nil { 146 | log.Printf("Warning: Failed to delete secret %s: %v", secretName, err) 147 | } else { 148 | deletedCount++ 149 | } 150 | } 151 | } 152 | 153 | log.Printf("Cleanup completed. Deleted %d test secrets.", deletedCount) 154 | 155 | // Additional cleanup for any remaining test resources using the same secret list 156 | cleanupByTags(svc, allSecrets) 157 | } 158 | 159 | func cleanupByTags(svc *secretsmanager.SecretsManager, secrets []*secretsmanager.SecretListEntry) { 160 | log.Println("Performing tag-based cleanup...") 161 | 162 | deletedCount := 0 163 | for _, secret := range secrets { 164 | if secret.Name == nil { 165 | continue 166 | } 167 | 168 | // Check if secret has test-related tags 169 | shouldDelete := false 170 | for _, tag := range secret.Tags { 171 | if tag.Key != nil && tag.Value != nil { 172 | key := strings.ToLower(*tag.Key) 173 | value := strings.ToLower(*tag.Value) 174 | 175 | if (key == "environment" && value == "test") || 176 | (key == "managedby" && value == "terratest") || 177 | (key == "testrun" && value != "") || 178 | (key == "purpose" && strings.Contains(value, "test")) { 179 | shouldDelete = true 180 | break 181 | } 182 | } 183 | } 184 | 185 | if shouldDelete { 186 | log.Printf("Deleting tagged test secret: %s", *secret.Name) 187 | 188 | _, err := svc.DeleteSecret(&secretsmanager.DeleteSecretInput{ 189 | SecretId: secret.Name, 190 | ForceDeleteWithoutRecovery: aws.Bool(true), 191 | }) 192 | 193 | if err != nil { 194 | log.Printf("Warning: Failed to delete tagged secret %s: %v", *secret.Name, err) 195 | } else { 196 | deletedCount++ 197 | } 198 | } 199 | } 200 | 201 | log.Printf("Tag-based cleanup completed. Deleted %d additional test secrets.", deletedCount) 202 | } 203 | 204 | // isHexString checks if a string contains only hexadecimal characters 205 | func isHexString(s string) bool { 206 | if len(s) < 6 { 207 | return false 208 | } 209 | matched, _ := regexp.MatchString("^[a-fA-F0-9]+$", s) 210 | return matched 211 | } 212 | 213 | // isNumericString checks if a string contains only numeric characters 214 | func isNumericString(s string) bool { 215 | if len(s) < 6 { 216 | return false 217 | } 218 | matched, _ := regexp.MatchString("^[0-9]+$", s) 219 | return matched 220 | } -------------------------------------------------------------------------------- /examples/ephemeral/ephemeral-limitations.md: -------------------------------------------------------------------------------- 1 | # Ephemeral Resources with terraform-aws-secrets-manager 2 | 3 | ## TL;DR: Current Limitation 4 | 5 | ❌ **The user's desired pattern is NOT directly supported due to Terraform core limitations:** 6 | 7 | ```hcl 8 | # THIS PATTERN DOES NOT WORK: 9 | ephemeral "random_password" "db_passwords" { 10 | for_each = var.db_users # Creates ephemeral passwords 11 | } 12 | 13 | module "db_users_secrets_manager" { 14 | ephemeral = true 15 | rotate_secrets = { 16 | for username, role in var.db_users : "db-${var.name}-${username}" => { 17 | password = ephemeral.random_password.db_passwords[username].result # ❌ Cannot pass ephemeral to module 18 | } 19 | } 20 | } 21 | ``` 22 | 23 | ✅ **Working alternatives are provided below.** 24 | 25 | ## Root Cause Analysis 26 | 27 | ### Issue 1: Module Variables Cannot Accept Ephemeral Values (BY DESIGN) 28 | - Terraform variables that accept ephemeral values must be declared with `ephemeral = true` 29 | - However, when a variable has `ephemeral = true`, the ENTIRE variable becomes ephemeral 30 | - Ephemeral variables cannot be used with `for_each` (Terraform needs persistent resource keys) 31 | 32 | ### Issue 2: Architectural Limitation 33 | ```hcl 34 | variable "rotate_secrets" { 35 | ephemeral = true # This makes the ENTIRE map ephemeral 36 | } 37 | 38 | resource "aws_secretsmanager_secret" "rsm" { 39 | for_each = var.rotate_secrets # ❌ FAILS: Cannot use ephemeral value in for_each 40 | } 41 | ``` 42 | 43 | **This is a fundamental Terraform limitation, not a bug in our module.** 44 | 45 | ## Working Solutions 46 | 47 | ### Solution 1: Direct Resource Usage (RECOMMENDED) 48 | 49 | Create secrets directly without the module wrapper: 50 | 51 | ```hcl 52 | # Variables 53 | variable "db_users" { 54 | type = map(object({ 55 | role = string 56 | })) 57 | } 58 | 59 | variable "app_name" { 60 | type = string 61 | } 62 | 63 | # Ephemeral passwords 64 | ephemeral "random_password" "db_passwords" { 65 | for_each = var.db_users 66 | length = 24 67 | special = true 68 | override_special = "!@#%^&*-_=<>?" 69 | min_numeric = 1 70 | } 71 | 72 | # KMS key 73 | resource "aws_kms_key" "secrets_key" { 74 | description = "KMS key for secrets" 75 | } 76 | 77 | # Create secrets directly (THIS WORKS!) 78 | resource "aws_secretsmanager_secret" "db_secrets" { 79 | for_each = var.db_users 80 | 81 | name = "db-${var.app_name}-${each.key}" 82 | description = "${var.app_name} database credentials for ${each.key}" 83 | kms_key_id = aws_kms_key.secrets_key.arn 84 | recovery_window_in_days = 0 85 | 86 | tags = { 87 | Environment = "production" 88 | User = each.key 89 | } 90 | } 91 | 92 | # Create secret versions with ephemeral values (THIS WORKS!) 93 | resource "aws_secretsmanager_secret_version" "db_secret_versions" { 94 | for_each = var.db_users 95 | 96 | secret_id = aws_secretsmanager_secret.db_secrets[each.key].id 97 | 98 | # Using write-only parameter with ephemeral value 99 | secret_string_wo = jsonencode({ 100 | username = each.key 101 | password = ephemeral.random_password.db_passwords[each.key].result 102 | host = "db.${var.app_name}.internal" 103 | port = 5432 104 | engine = "postgres" 105 | dbname = var.app_name 106 | }) 107 | 108 | secret_string_wo_version = 1 109 | } 110 | 111 | # Add rotation 112 | resource "aws_secretsmanager_secret_rotation" "db_rotations" { 113 | for_each = var.db_users 114 | 115 | secret_id = aws_secretsmanager_secret.db_secrets[each.key].id 116 | rotation_lambda_arn = "arn:aws:lambda:us-east-1:123456789012:function:rotate-secret" 117 | 118 | rotation_rules { 119 | automatically_after_days = 90 120 | } 121 | } 122 | ``` 123 | 124 | ### Solution 2: Individual Module Instances 125 | 126 | For smaller numbers of secrets, create separate module calls: 127 | 128 | ```hcl 129 | # Ephemeral passwords 130 | ephemeral "random_password" "db_passwords" { 131 | for_each = var.db_users 132 | length = 24 133 | special = true 134 | } 135 | 136 | # Individual module for admin user 137 | module "db_admin_secret" { 138 | source = "lgallard/secrets-manager/aws" 139 | 140 | ephemeral = true 141 | 142 | rotate_secrets = { 143 | "db-${var.app_name}-admin" = { 144 | description = "Database admin credentials" 145 | secret_key_value = { 146 | username = "admin" 147 | password = ephemeral.random_password.db_passwords["admin"].result 148 | host = "db.${var.app_name}.internal" 149 | port = 5432 150 | } 151 | secret_string_wo_version = 1 152 | rotation_lambda_arn = var.rotation_lambda_arn 153 | automatically_after_days = 90 154 | } 155 | } 156 | } 157 | 158 | # Individual module for app user 159 | module "db_app_secret" { 160 | source = "lgallard/secrets-manager/aws" 161 | 162 | ephemeral = true 163 | 164 | rotate_secrets = { 165 | "db-${var.app_name}-app" = { 166 | description = "Database app credentials" 167 | secret_key_value = { 168 | username = "app" 169 | password = ephemeral.random_password.db_passwords["app"].result 170 | host = "db.${var.app_name}.internal" 171 | port = 5432 172 | } 173 | secret_string_wo_version = 1 174 | rotation_lambda_arn = var.rotation_lambda_arn 175 | automatically_after_days = 90 176 | } 177 | } 178 | } 179 | ``` 180 | 181 | ### Solution 3: Module with Pre-defined Keys 182 | 183 | Create a specialized version where secret names are not dynamic: 184 | 185 | ```hcl 186 | module "ephemeral_db_secrets" { 187 | source = "lgallard/secrets-manager/aws" 188 | 189 | ephemeral = true 190 | 191 | rotate_secrets = { 192 | "db-admin" = { 193 | description = "Database admin credentials" 194 | secret_key_value = { 195 | username = "admin" 196 | password = ephemeral.random_password.db_passwords["admin"].result 197 | } 198 | secret_string_wo_version = 1 199 | } 200 | "db-app" = { 201 | description = "Database app credentials" 202 | secret_key_value = { 203 | username = "app" 204 | password = ephemeral.random_password.db_passwords["app"].result 205 | } 206 | secret_string_wo_version = 1 207 | } 208 | } 209 | } 210 | ``` 211 | 212 | ## Key Points 213 | 214 | 1. ✅ **Our module DOES support ephemeral mode** - The `ephemeral = true` parameter works correctly 215 | 2. ✅ **Write-only parameters work** - `secret_string_wo_version` prevents state storage 216 | 3. ❌ **Dynamic for_each with ephemeral values is impossible** - This is a Terraform core limitation 217 | 4. ✅ **Direct resource usage is the best workaround** - Gives full control and flexibility 218 | 5. ✅ **Individual module instances work** - Good for small, known sets of secrets 219 | 220 | ## Migration Path 221 | 222 | If you're currently using the pattern that doesn't work: 223 | 224 | 1. **For dynamic secrets**: Use **Solution 1** (direct resources) 225 | 2. **For small, known sets**: Use **Solution 2** (individual modules) 226 | 3. **For static configurations**: Use **Solution 3** (pre-defined keys) 227 | 228 | ## Version Requirements 229 | 230 | - **Terraform**: >= 1.11.0 (for ephemeral resources and write-only arguments) 231 | - **AWS Provider**: >= 2.67.0 232 | - **Module**: >= 0.16.0 (when released with ephemeral fixes) 233 | 234 | ## State Security 235 | 236 | All solutions properly prevent sensitive data from being stored in Terraform state by using: 237 | - `secret_string_wo` (write-only parameter) 238 | - `secret_string_wo_version` (version control for ephemeral updates) 239 | - `ephemeral = true` (module-level ephemeral mode) -------------------------------------------------------------------------------- /test/terraform_validation_test.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | 7 | "github.com/gruntwork-io/terratest/modules/terraform" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | // TestTerraformFormat tests that all Terraform files are properly formatted 12 | func TestTerraformFormat(t *testing.T) { 13 | t.Parallel() 14 | 15 | terraformOptions := &terraform.Options{ 16 | TerraformDir: "../", 17 | } 18 | 19 | // Run terraform fmt -check to see if files are formatted correctly 20 | output, err := terraform.RunTerraformCommandAndGetStdoutE(t, terraformOptions, "fmt", "-check", "-diff") 21 | 22 | if err != nil { 23 | t.Errorf("Terraform files are not properly formatted. Run 'terraform fmt' to fix.\nOutput: %s", output) 24 | } 25 | } 26 | 27 | // TestTerraformValidate tests that the Terraform configuration is valid 28 | func TestTerraformValidate(t *testing.T) { 29 | t.Parallel() 30 | 31 | terraformOptions := &terraform.Options{ 32 | TerraformDir: "../", 33 | } 34 | 35 | // Initialize and validate 36 | terraform.Init(t, terraformOptions) 37 | terraform.Validate(t, terraformOptions) 38 | } 39 | 40 | // TestExamplesValidation tests that all example configurations are valid 41 | func TestExamplesValidation(t *testing.T) { 42 | testCases := []struct { 43 | name string 44 | path string 45 | }{ 46 | {"plaintext", "../examples/plaintext"}, 47 | {"key-value", "../examples/key-value"}, 48 | {"binary", "../examples/binary"}, 49 | {"ephemeral", "../examples/ephemeral"}, 50 | {"rotation", "../examples/rotation"}, 51 | {"replication", "../examples/replication"}, 52 | } 53 | 54 | for _, tc := range testCases { 55 | t.Run(tc.name, func(t *testing.T) { 56 | t.Parallel() 57 | 58 | terraformOptions := &terraform.Options{ 59 | TerraformDir: tc.path, 60 | } 61 | 62 | // Initialize and validate each example 63 | terraform.Init(t, terraformOptions) 64 | terraform.Validate(t, terraformOptions) 65 | }) 66 | } 67 | } 68 | 69 | // TestTerraformPlan tests that terraform plan runs without errors 70 | func TestTerraformPlan(t *testing.T) { 71 | t.Parallel() 72 | 73 | uniqueID := GenerateTestName("plan-test") 74 | awsRegion := GetTestRegion(t) 75 | 76 | terraformOptions := &terraform.Options{ 77 | TerraformDir: "../", 78 | Vars: map[string]interface{}{ 79 | "secrets": CreateBasicSecretConfig( 80 | fmt.Sprintf("test-secret-%s", uniqueID), 81 | "test-value", 82 | ), 83 | }, 84 | EnvVars: map[string]string{ 85 | "AWS_DEFAULT_REGION": awsRegion, 86 | }, 87 | } 88 | 89 | // Test that plan runs successfully 90 | terraform.Init(t, terraformOptions) 91 | planOutput := terraform.Plan(t, terraformOptions) 92 | 93 | // Basic validation that plan contains expected resources 94 | assert.Contains(t, planOutput, "aws_secretsmanager_secret") 95 | assert.Contains(t, planOutput, "aws_secretsmanager_secret_version") 96 | } 97 | 98 | // TestVariableValidation tests input variable validation 99 | func TestVariableValidation(t *testing.T) { 100 | testCases := []ValidationTestCase{ 101 | { 102 | Name: "valid_basic_secret", 103 | Vars: map[string]interface{}{ 104 | "secrets": CreateBasicSecretConfig("valid-secret", "test-value"), 105 | }, 106 | ExpectError: false, 107 | }, 108 | { 109 | Name: "invalid_secret_name_special_chars", 110 | Vars: map[string]interface{}{ 111 | "secrets": CreateBasicSecretConfig("invalid@secret!", "test-value"), 112 | }, 113 | ExpectError: true, 114 | ErrorText: "Secret names must contain only", 115 | }, 116 | { 117 | Name: "ephemeral_missing_version", 118 | Vars: map[string]interface{}{ 119 | "ephemeral": true, 120 | "secrets": CreateBasicSecretConfig("ephemeral-secret", "test-value"), 121 | }, 122 | ExpectError: true, 123 | ErrorText: "secret_string_wo_version is required", 124 | }, 125 | { 126 | Name: "ephemeral_with_valid_version", 127 | Vars: map[string]interface{}{ 128 | "ephemeral": true, 129 | "secrets": CreateEphemeralSecretConfig("ephemeral-secret", "test-value", 1), 130 | }, 131 | ExpectError: false, 132 | }, 133 | { 134 | Name: "invalid_kms_key_format", 135 | Vars: map[string]interface{}{ 136 | "secrets": map[string]interface{}{ 137 | "test-secret": map[string]interface{}{ 138 | "description": "Test secret", 139 | "secret_string": "test-value", 140 | "kms_key_id": "invalid-kms-key", 141 | }, 142 | }, 143 | }, 144 | ExpectError: true, 145 | ErrorText: "KMS key ID must be a valid", 146 | }, 147 | { 148 | Name: "valid_kms_key_arn", 149 | Vars: map[string]interface{}{ 150 | "secrets": map[string]interface{}{ 151 | "test-secret": map[string]interface{}{ 152 | "description": "Test secret", 153 | "secret_string": "test-value", 154 | "kms_key_id": "arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012", 155 | }, 156 | }, 157 | }, 158 | ExpectError: false, 159 | }, 160 | { 161 | Name: "invalid_tag_key_aws_prefix", 162 | Vars: map[string]interface{}{ 163 | "secrets": CreateBasicSecretConfig("test-secret", "test-value"), 164 | "tags": map[string]string{ 165 | "aws:test": "value", 166 | }, 167 | }, 168 | ExpectError: true, 169 | ErrorText: "Tag keys cannot start with 'aws:'", 170 | }, 171 | { 172 | Name: "invalid_recovery_window", 173 | Vars: map[string]interface{}{ 174 | "secrets": CreateBasicSecretConfig("test-secret", "test-value"), 175 | "recovery_window_in_days": 5, // Invalid: must be 0 or between 7-30 176 | }, 177 | ExpectError: true, 178 | ErrorText: "Recovery window must be 0", 179 | }, 180 | { 181 | Name: "rotation_missing_lambda", 182 | Vars: map[string]interface{}{ 183 | "rotate_secrets": map[string]interface{}{ 184 | "rotating-secret": map[string]interface{}{ 185 | "description": "Rotating secret", 186 | "secret_string": "test-value", 187 | // Missing rotation_lambda_arn 188 | }, 189 | }, 190 | }, 191 | ExpectError: true, 192 | ErrorText: "rotation_lambda_arn", 193 | }, 194 | { 195 | Name: "both_secret_string_and_binary", 196 | Vars: map[string]interface{}{ 197 | "secrets": map[string]interface{}{ 198 | "conflict-secret": map[string]interface{}{ 199 | "description": "Conflict secret", 200 | "secret_string": "test-value", 201 | "secret_binary": "binary-data", 202 | }, 203 | }, 204 | }, 205 | ExpectError: true, 206 | ErrorText: "Cannot specify both secret_string and secret_binary", 207 | }, 208 | } 209 | 210 | for _, tc := range testCases { 211 | t.Run(tc.Name, func(t *testing.T) { 212 | awsRegion := GetTestRegion(t) 213 | 214 | terraformOptions := &terraform.Options{ 215 | TerraformDir: "../", 216 | Vars: tc.Vars, 217 | EnvVars: map[string]string{ 218 | "AWS_DEFAULT_REGION": awsRegion, 219 | }, 220 | } 221 | 222 | // Initialize 223 | terraform.Init(t, terraformOptions) 224 | 225 | // Test validation 226 | if tc.ExpectError { 227 | _, err := terraform.PlanE(t, terraformOptions) 228 | assert.Error(t, err, "Expected validation error for test case: %s", tc.Name) 229 | if tc.ErrorText != "" { 230 | assert.Contains(t, err.Error(), tc.ErrorText, 231 | "Error message should contain expected text for test case: %s", tc.Name) 232 | } 233 | } else { 234 | // Should not error 235 | terraform.Plan(t, terraformOptions) 236 | } 237 | }) 238 | } 239 | } 240 | 241 | // ValidationTestCase represents a test case for validation testing 242 | type ValidationTestCase struct { 243 | Name string 244 | Vars map[string]interface{} 245 | ExpectError bool 246 | ErrorText string 247 | } -------------------------------------------------------------------------------- /examples/ephemeral/ephemeral-for-each-patterns.md: -------------------------------------------------------------------------------- 1 | # Solution for GitHub Issue #80: Ephemeral Resources Support 2 | 3 | ## Executive Summary 4 | 5 | ✅ **GOOD NEWS**: Our terraform-aws-secrets-manager module **DOES support ephemeral resources** with Terraform 1.11+ 6 | 7 | ❌ **LIMITATION**: The user's exact desired pattern is **impossible due to Terraform core limitations** 8 | 9 | ✅ **WORKAROUND**: We provide **working alternative approaches** that achieve the same security goals 10 | 11 | ## User's Original Request 12 | 13 | The user wanted to use ephemeral `random_password` resources to prevent sensitive data from being stored in Terraform state: 14 | 15 | ```hcl 16 | # USER'S DESIRED PATTERN (DOESN'T WORK): 17 | ephemeral "random_password" "db_passwords" { 18 | for_each = var.db_users 19 | length = 24 20 | special = true 21 | } 22 | 23 | module "db_users_secrets_manager" { 24 | source = "lgallard/secrets-manager/aws" 25 | ephemeral = true 26 | rotate_secrets = { 27 | for username, role in var.db_users : "db-${var.name}-${username}" => { 28 | password = ephemeral.random_password.db_passwords[username].result # ❌ FAILS 29 | } 30 | } 31 | } 32 | ``` 33 | 34 | ## Root Cause Analysis 35 | 36 | ### Issue 1: Version Compatibility ✅ **FIXED** 37 | - Module required Terraform `>= v0.15.0` 38 | - Ephemeral resources need Terraform `>= 1.11.0` 39 | - **Solution**: Updated `versions.tf` to require `>= 1.11.0` 40 | 41 | ### Issue 2: Ephemeral + For_each Architectural Limitation 42 | This is a **fundamental Terraform limitation**, not a bug in our module: 43 | 44 | 1. **Module variables cannot accept ephemeral values** unless marked with `ephemeral = true` 45 | 2. **Variables marked `ephemeral = true` make the ENTIRE variable ephemeral** 46 | 3. **Ephemeral values cannot be used with `for_each`** (Terraform needs persistent resource keys) 47 | 48 | ```hcl 49 | variable "rotate_secrets" { 50 | ephemeral = true # Makes the whole map ephemeral 51 | } 52 | 53 | resource "aws_secretsmanager_secret" "rsm" { 54 | for_each = var.rotate_secrets # ❌ FAILS: Cannot use ephemeral in for_each 55 | } 56 | ``` 57 | 58 | ## ✅ WORKING SOLUTIONS 59 | 60 | ### Solution 1: Direct AWS Resources (RECOMMENDED) 61 | 62 | Use AWS resources directly instead of our module wrapper: 63 | 64 | ```hcl 65 | # Variables 66 | variable "db_users" { 67 | type = map(object({ 68 | role = string 69 | })) 70 | default = { 71 | "admin" = { role = "admin" } 72 | "app" = { role = "application" } 73 | } 74 | } 75 | 76 | variable "app_name" { 77 | type = string 78 | default = "myapp" 79 | } 80 | 81 | # Ephemeral passwords - THIS WORKS! 82 | ephemeral "random_password" "db_passwords" { 83 | for_each = var.db_users 84 | length = 24 85 | special = true 86 | override_special = "!@#%^&*-_=<>?" 87 | min_numeric = 1 88 | } 89 | 90 | # KMS key 91 | resource "aws_kms_key" "secrets_key" { 92 | description = "KMS key for secrets" 93 | deletion_window_in_days = 7 94 | } 95 | 96 | # Create secrets directly - THIS WORKS WITH EPHEMERAL! 97 | resource "aws_secretsmanager_secret" "db_secrets" { 98 | for_each = var.db_users 99 | 100 | name = "db-${var.app_name}-${each.key}" 101 | description = "${var.app_name} database credentials for ${each.key}" 102 | kms_key_id = aws_kms_key.secrets_key.arn 103 | recovery_window_in_days = 0 104 | 105 | tags = { 106 | Environment = "production" 107 | User = each.key 108 | } 109 | } 110 | 111 | # Create secret versions with ephemeral values - THIS WORKS! 112 | resource "aws_secretsmanager_secret_version" "db_secret_versions" { 113 | for_each = var.db_users 114 | 115 | secret_id = aws_secretsmanager_secret.db_secrets[each.key].id 116 | 117 | # Using write-only parameter prevents state storage 118 | secret_string_wo = jsonencode({ 119 | username = each.key 120 | password = ephemeral.random_password.db_passwords[each.key].result 121 | host = "db.${var.app_name}.internal" 122 | port = 5432 123 | engine = "postgres" 124 | dbname = var.app_name 125 | }) 126 | 127 | secret_string_wo_version = 1 # Required for ephemeral mode 128 | } 129 | 130 | # Add rotation 131 | resource "aws_secretsmanager_secret_rotation" "db_rotations" { 132 | for_each = var.db_users 133 | 134 | secret_id = aws_secretsmanager_secret.db_secrets[each.key].id 135 | rotation_lambda_arn = "arn:aws:lambda:us-east-1:123456789012:function:rotate-secret" 136 | 137 | rotation_rules { 138 | automatically_after_days = 90 139 | } 140 | } 141 | ``` 142 | 143 | ### Solution 2: Individual Module Instances 144 | 145 | For small, known sets of secrets: 146 | 147 | ```hcl 148 | # Ephemeral passwords 149 | ephemeral "random_password" "db_passwords" { 150 | for_each = var.db_users 151 | length = 24 152 | special = true 153 | } 154 | 155 | # Individual module for admin 156 | module "db_admin_secret" { 157 | source = "lgallard/secrets-manager/aws" 158 | version = "0.16.0" # Use latest version 159 | 160 | ephemeral = true 161 | 162 | rotate_secrets = { 163 | "db-${var.app_name}-admin" = { 164 | description = "Database admin credentials" 165 | secret_key_value = { 166 | username = "admin" 167 | password = ephemeral.random_password.db_passwords["admin"].result 168 | host = "db.${var.app_name}.internal" 169 | port = 5432 170 | } 171 | secret_string_wo_version = 1 172 | rotation_lambda_arn = var.rotation_lambda_arn 173 | automatically_after_days = 90 174 | } 175 | } 176 | } 177 | 178 | # Individual module for app user 179 | module "db_app_secret" { 180 | source = "lgallard/secrets-manager/aws" 181 | version = "0.16.0" 182 | 183 | ephemeral = true 184 | 185 | rotate_secrets = { 186 | "db-${var.app_name}-app" = { 187 | description = "Database app credentials" 188 | secret_key_value = { 189 | username = "app" 190 | password = ephemeral.random_password.db_passwords["app"].result 191 | host = "db.${var.app_name}.internal" 192 | port = 5432 193 | } 194 | secret_string_wo_version = 1 195 | rotation_lambda_arn = var.rotation_lambda_arn 196 | automatically_after_days = 90 197 | } 198 | } 199 | } 200 | ``` 201 | 202 | ## Security Validation 203 | 204 | All solutions properly prevent sensitive data from being stored in Terraform state: 205 | 206 | ### ✅ State Security Features 207 | - **`secret_string_wo`**: Write-only parameter prevents state persistence 208 | - **`secret_string_wo_version`**: Version control for ephemeral updates 209 | - **Ephemeral random passwords**: Never stored in state 210 | - **KMS encryption**: Additional layer of security 211 | 212 | ### ✅ Verified Behavior 213 | - Terraform state shows `(write-only attribute)` instead of actual values 214 | - Sensitive values are not persisted between plan/apply cycles 215 | - Secret values are properly stored in AWS Secrets Manager 216 | - Rotation and versioning work correctly 217 | 218 | ## Implementation Status 219 | 220 | ### ✅ Completed Changes 221 | 1. **Fixed version requirement**: Updated to `>= 1.11.0` 222 | 2. **Validated ephemeral support**: Our module's `ephemeral = true` works correctly 223 | 3. **Tested working solutions**: Direct resources approach proven functional 224 | 4. **Documented limitations**: Clear explanation of Terraform constraints 225 | 5. **Provided alternatives**: Multiple working patterns for different use cases 226 | 227 | ### 🔄 Module Capability Summary 228 | - ✅ **Ephemeral mode supported**: `ephemeral = true` parameter works 229 | - ✅ **Write-only arguments**: `secret_string_wo_version` implemented 230 | - ✅ **State security**: Sensitive data properly excluded from state 231 | - ❌ **Dynamic for_each with ephemeral**: Impossible due to Terraform core limitation 232 | - ✅ **Individual instances**: Work perfectly with ephemeral values 233 | - ✅ **Direct resources**: Full functionality with ephemeral integration 234 | 235 | ## Recommendation for Users 236 | 237 | **For the user who reported this issue:** 238 | 239 | 1. **Use Solution 1 (Direct Resources)** - Provides exactly the functionality you want 240 | 2. **Update to module version 0.16.0+** when released (includes version fix) 241 | 3. **Ensure Terraform >= 1.11.0** for ephemeral resource support 242 | 243 | **Benefits of Direct Resources approach:** 244 | - ✅ Full control over resource configuration 245 | - ✅ Works with ephemeral `for_each` patterns 246 | - ✅ Same security guarantees as module 247 | - ✅ More flexibility for custom configurations 248 | - ✅ No module wrapper limitations 249 | 250 | ## Version Requirements 251 | 252 | - **Terraform**: `>= 1.11.0` (for ephemeral resources) 253 | - **AWS Provider**: `>= 2.67.0` 254 | - **Module**: `>= 0.16.0` (when released with fixes) 255 | 256 | The ephemeral password functionality the user requested is **fully achievable** with the direct resources approach, providing the same security benefits while working within Terraform's architectural constraints. -------------------------------------------------------------------------------- /variables.tf: -------------------------------------------------------------------------------- 1 | variable "recovery_window_in_days" { 2 | description = "Specifies the number of days that AWS Secrets Manager waits before it can delete the secret. This value can be 0 to force deletion without recovery or range from 7 to 30 days. Example: 7" 3 | type = number 4 | default = 30 5 | 6 | validation { 7 | condition = var.recovery_window_in_days == 0 || (var.recovery_window_in_days >= 7 && var.recovery_window_in_days <= 30) 8 | error_message = "Recovery window must be 0 (for immediate deletion) or between 7 and 30 days." 9 | } 10 | } 11 | 12 | # Ephemeral mode configuration 13 | variable "ephemeral" { 14 | description = "Enable ephemeral resources and write-only arguments to prevent sensitive data from being stored in state. Requires Terraform >= 1.11. When enabled, secret values use write-only arguments (_wo) and are not persisted to state. Example: true" 15 | type = bool 16 | default = false 17 | } 18 | 19 | # Secrets with rotation 20 | variable "rotate_secrets" { 21 | description = "Map of secrets to keep and rotate in AWS Secrets Manager. Each secret must include rotation_lambda_arn. Example: { mysecret = { description = \"My secret\", secret_string = \"secret-value\", rotation_lambda_arn = \"arn:aws:lambda:us-east-1:123456789012:function:my-function\" } }" 22 | type = any 23 | default = {} 24 | 25 | validation { 26 | condition = alltrue([ 27 | for k, v in var.rotate_secrets : 28 | try(v.rotation_lambda_arn != null && v.rotation_lambda_arn != "", false) 29 | ]) 30 | error_message = "All rotate_secrets must have a valid rotation_lambda_arn specified." 31 | } 32 | 33 | validation { 34 | condition = alltrue([ 35 | for k, v in var.rotate_secrets : length(regexall("^[a-zA-Z0-9/_+=.@-]+$", k)) > 0 36 | ]) 37 | error_message = "Rotate secret names must contain only alphanumeric characters, hyphens, underscores, periods, forward slashes, at signs, plus signs, and equal signs." 38 | } 39 | 40 | validation { 41 | condition = alltrue([ 42 | for k, v in var.rotate_secrets : length(k) >= 1 && length(k) <= 512 43 | ]) 44 | error_message = "Rotate secret names must be between 1 and 512 characters long." 45 | } 46 | 47 | validation { 48 | condition = alltrue([ 49 | for k, v in var.rotate_secrets : 50 | try(v.kms_key_id, null) == null || can(regex("^(arn:aws:kms:[a-z0-9-]+:[0-9]{12}:key/[a-f0-9-]{36}|alias/[a-zA-Z0-9/_-]+|[a-f0-9-]{36})$", v.kms_key_id)) 51 | ]) 52 | error_message = "KMS key ID must be a valid KMS key ARN, alias, or key ID format." 53 | } 54 | 55 | # Validation for automatically_after_days disabled due to terraform validation limitations 56 | # with optional attributes in complex types 57 | # validation { 58 | # condition = alltrue([ 59 | # for k, v in var.rotate_secrets : 60 | # try(v.automatically_after_days, null) == null || (v.automatically_after_days >= 1 && v.automatically_after_days <= 365) 61 | # ]) 62 | # error_message = "automatically_after_days must be between 1 and 365 days." 63 | # } 64 | 65 | validation { 66 | condition = alltrue([ 67 | for k, v in var.rotate_secrets : 68 | var.ephemeral == false || (can(v.secret_string_wo_version) && try(v.secret_string_wo_version >= 1, false)) 69 | ]) 70 | error_message = "secret_string_wo_version is required and must be >= 1 when ephemeral is enabled for rotating secrets. Use secret_string_wo_version for all secret types (string, key-value, and binary)." 71 | } 72 | 73 | validation { 74 | condition = alltrue([ 75 | for k, v in var.rotate_secrets : 76 | can(v.secret_string) && can(v.secret_binary) ? false : true 77 | ]) 78 | error_message = "Cannot specify both secret_string and secret_binary for the same secret." 79 | } 80 | } 81 | 82 | # Regular secrets (non-rotating) 83 | variable "secrets" { 84 | description = "Map of secrets to keep in AWS Secrets Manager. Example: { mysecret = { description = \"My secret\", secret_string = \"secret-value\" } }" 85 | type = any 86 | default = {} 87 | 88 | validation { 89 | condition = alltrue([ 90 | for k, v in var.secrets : length(regexall("^[a-zA-Z0-9/_+=.@-]+$", k)) > 0 91 | ]) 92 | error_message = "Secret names must contain only alphanumeric characters, hyphens, underscores, periods, forward slashes, at signs, plus signs, and equal signs." 93 | } 94 | 95 | validation { 96 | condition = alltrue([ 97 | for k, v in var.secrets : length(k) >= 1 && length(k) <= 512 98 | ]) 99 | error_message = "Secret names must be between 1 and 512 characters long." 100 | } 101 | 102 | validation { 103 | condition = alltrue([ 104 | for k, v in var.secrets : 105 | try(v.kms_key_id, null) == null || can(regex("^(arn:aws:kms:[a-z0-9-]+:[0-9]{12}:key/[a-f0-9-]{36}|alias/[a-zA-Z0-9/_-]+|[a-f0-9-]{36})$", v.kms_key_id)) 106 | ]) 107 | error_message = "KMS key ID must be a valid KMS key ARN, alias, or key ID format." 108 | } 109 | 110 | validation { 111 | condition = alltrue([ 112 | for k, v in var.secrets : 113 | var.ephemeral == false || (can(v.secret_string_wo_version) && try(v.secret_string_wo_version >= 1, false)) 114 | ]) 115 | error_message = "secret_string_wo_version is required and must be >= 1 when ephemeral is enabled. Use secret_string_wo_version for all secret types (string, key-value, and binary)." 116 | } 117 | 118 | validation { 119 | condition = alltrue([ 120 | for k, v in var.secrets : 121 | can(v.secret_string) && can(v.secret_binary) ? false : true 122 | ]) 123 | error_message = "Cannot specify both secret_string and secret_binary for the same secret." 124 | } 125 | 126 | } 127 | 128 | variable "unmanaged" { 129 | description = "Terraform must ignore secrets lifecycle. Using this option you can initialize the secrets and rotate them outside Terraform, avoiding other users changing or rotating secrets by subsequent Terraform runs. Example: true" 130 | type = bool 131 | default = false 132 | } 133 | 134 | variable "automatically_after_days" { 135 | description = "Specifies the number of days between automatic scheduled rotations of the secret. Must be between 1 and 365 days. Example: 30" 136 | type = number 137 | default = 30 138 | 139 | validation { 140 | condition = var.automatically_after_days >= 1 && var.automatically_after_days <= 365 141 | error_message = "Automatically after days must be between 1 and 365." 142 | } 143 | } 144 | 145 | variable "version_stages" { 146 | description = "List of version stages to be handled. Valid values are 'AWSCURRENT' and 'AWSPENDING'. Kept as null for backwards compatibility. Example: [\"AWSCURRENT\"]" 147 | type = list(string) 148 | default = null 149 | 150 | validation { 151 | condition = var.version_stages == null || alltrue([ 152 | for stage in coalesce(var.version_stages, []) : contains(["AWSCURRENT", "AWSPENDING"], stage) 153 | ]) 154 | error_message = "Version stages must be either 'AWSCURRENT' or 'AWSPENDING'." 155 | } 156 | } 157 | 158 | # Tags 159 | variable "tags" { 160 | description = "Key-value map of user-defined tags attached to the secret. Keys cannot start with 'aws:'. Example: { Environment = \"prod\", Owner = \"team\" }" 161 | type = any 162 | default = {} 163 | 164 | validation { 165 | condition = alltrue([ 166 | for k, v in var.tags : !startswith(lower(k), "aws:") 167 | ]) 168 | error_message = "Tag keys cannot start with 'aws:' (case insensitive)." 169 | } 170 | 171 | validation { 172 | condition = alltrue([ 173 | for k, v in var.tags : length(k) >= 1 && length(k) <= 128 174 | ]) 175 | error_message = "Tag keys must be between 1 and 128 characters long." 176 | } 177 | 178 | validation { 179 | condition = alltrue([ 180 | for k, v in var.tags : length(tostring(v)) <= 256 181 | ]) 182 | error_message = "Tag values must be 256 characters or less." 183 | } 184 | } 185 | 186 | variable "default_tags" { 187 | description = "Default tags to apply to all resources. These are merged with resource-specific tags. Example: { Environment = \"prod\", ManagedBy = \"terraform\" }" 188 | type = map(string) 189 | default = {} 190 | 191 | validation { 192 | condition = alltrue([ 193 | for k, v in var.default_tags : !startswith(lower(k), "aws:") 194 | ]) 195 | error_message = "Default tag keys cannot start with 'aws:' (case insensitive)." 196 | } 197 | 198 | validation { 199 | condition = alltrue([ 200 | for k, v in var.default_tags : length(k) >= 1 && length(k) <= 128 201 | ]) 202 | error_message = "Default tag keys must be between 1 and 128 characters long." 203 | } 204 | 205 | validation { 206 | condition = alltrue([ 207 | for k, v in var.default_tags : length(v) <= 256 208 | ]) 209 | error_message = "Default tag values must be 256 characters or less." 210 | } 211 | } 212 | 213 | 214 | # Data sources for existing secrets 215 | variable "existing_secrets" { 216 | description = "Map of existing secret names or ARNs to import as data sources. Useful for referencing secrets created outside this module. Example: { existing_secret = \"arn:aws:secretsmanager:us-east-1:123456789012:secret:my-secret\" }" 217 | type = map(string) 218 | default = {} 219 | 220 | validation { 221 | condition = alltrue([ 222 | for k, v in var.existing_secrets : 223 | can(regex("^(arn:aws:secretsmanager:[a-z0-9-]+:[0-9]{12}:secret:[a-zA-Z0-9/_+=.@-]+|[a-zA-Z0-9/_+=.@-]+)$", v)) 224 | ]) 225 | error_message = "Existing secret values must be valid secret names or ARNs." 226 | } 227 | } 228 | -------------------------------------------------------------------------------- /main.tf: -------------------------------------------------------------------------------- 1 | locals { 2 | # Cache lookups for regular secrets to improve performance and readability 3 | secrets_config = { 4 | for k, v in var.secrets : k => { 5 | name_prefix = lookup(v, "name_prefix", null) 6 | name = lookup(v, "name", null) 7 | description = lookup(v, "description", null) 8 | kms_key_id = lookup(v, "kms_key_id", null) 9 | policy = lookup(v, "policy", null) 10 | force_overwrite_replica_secret = lookup(v, "force_overwrite_replica_secret", false) 11 | recovery_window_in_days = lookup(v, "recovery_window_in_days", var.recovery_window_in_days) 12 | tags = lookup(v, "tags", null) 13 | replica_regions = lookup(v, "replica_regions", {}) 14 | secret_string = lookup(v, "secret_string", null) 15 | secret_key_value = lookup(v, "secret_key_value", null) 16 | secret_binary = lookup(v, "secret_binary", null) 17 | secret_string_wo_version = lookup(v, "secret_string_wo_version", null) 18 | # Computed name based on priority: name > name_prefix > key 19 | computed_name = lookup(v, "name", null) != null ? lookup(v, "name", null) : (lookup(v, "name_prefix", null) != null ? null : k) 20 | computed_name_prefix = lookup(v, "name_prefix", null) 21 | } 22 | } 23 | 24 | # Cache lookups for rotating secrets 25 | rotate_secrets_config = { 26 | for k, v in var.rotate_secrets : k => { 27 | name_prefix = lookup(v, "name_prefix", null) 28 | name = lookup(v, "name", null) 29 | description = lookup(v, "description", null) 30 | kms_key_id = lookup(v, "kms_key_id", null) 31 | policy = lookup(v, "policy", null) 32 | force_overwrite_replica_secret = lookup(v, "force_overwrite_replica_secret", false) 33 | recovery_window_in_days = lookup(v, "recovery_window_in_days", var.recovery_window_in_days) 34 | tags = lookup(v, "tags", null) 35 | replica_regions = lookup(v, "replica_regions", {}) 36 | secret_string = lookup(v, "secret_string", null) 37 | secret_key_value = lookup(v, "secret_key_value", null) 38 | secret_binary = lookup(v, "secret_binary", null) 39 | secret_string_wo_version = lookup(v, "secret_string_wo_version", null) 40 | rotation_lambda_arn = lookup(v, "rotation_lambda_arn", null) 41 | automatically_after_days = lookup(v, "automatically_after_days", var.automatically_after_days) 42 | # Computed name based on priority: name > name_prefix > key 43 | computed_name = lookup(v, "name", null) != null ? lookup(v, "name", null) : (lookup(v, "name_prefix", null) != null ? null : k) 44 | computed_name_prefix = lookup(v, "name_prefix", null) 45 | } 46 | } 47 | 48 | # Helper function to compute secret values based on ephemeral mode - reduces code duplication 49 | compute_secret_values = { 50 | for config_name, config_map in { 51 | "secrets" = local.secrets_config, 52 | "rotate_secrets" = local.rotate_secrets_config 53 | } : config_name => { 54 | for k, v in config_map : k => { 55 | # Regular parameters (when ephemeral is disabled) 56 | secret_string = !var.ephemeral ? ( 57 | v.secret_string != null ? v.secret_string : 58 | (v.secret_key_value != null ? jsonencode(v.secret_key_value) : null) 59 | ) : null 60 | secret_binary = !var.ephemeral ? ( 61 | v.secret_binary != null ? base64encode(v.secret_binary) : null 62 | ) : null 63 | 64 | # Write-only parameters (when ephemeral is enabled) 65 | secret_string_wo = var.ephemeral ? ( 66 | v.secret_string != null ? v.secret_string : 67 | (v.secret_key_value != null ? jsonencode(v.secret_key_value) : 68 | (v.secret_binary != null ? base64encode(v.secret_binary) : null)) 69 | ) : null 70 | 71 | secret_string_wo_version = var.ephemeral ? v.secret_string_wo_version : null 72 | } 73 | } 74 | } 75 | } 76 | 77 | resource "aws_secretsmanager_secret" "sm" { 78 | for_each = var.secrets 79 | name = local.secrets_config[each.key].computed_name 80 | name_prefix = local.secrets_config[each.key].computed_name_prefix 81 | description = local.secrets_config[each.key].description 82 | kms_key_id = local.secrets_config[each.key].kms_key_id 83 | policy = local.secrets_config[each.key].policy 84 | force_overwrite_replica_secret = local.secrets_config[each.key].force_overwrite_replica_secret 85 | recovery_window_in_days = local.secrets_config[each.key].recovery_window_in_days 86 | tags = merge(var.default_tags, var.tags, local.secrets_config[each.key].tags) 87 | 88 | dynamic "replica" { 89 | for_each = local.secrets_config[each.key].replica_regions 90 | content { 91 | region = try(replica.value.region, replica.key) 92 | kms_key_id = try(replica.value.kms_key_id, null) 93 | } 94 | } 95 | } 96 | 97 | resource "aws_secretsmanager_secret_version" "sm-sv" { 98 | for_each = { for k, v in var.secrets : k => v if !var.unmanaged } 99 | secret_id = aws_secretsmanager_secret.sm[each.key].arn 100 | 101 | # Use computed values from locals to eliminate code duplication 102 | secret_string = local.compute_secret_values["secrets"][each.key].secret_string 103 | secret_binary = local.compute_secret_values["secrets"][each.key].secret_binary 104 | secret_string_wo = local.compute_secret_values["secrets"][each.key].secret_string_wo 105 | secret_string_wo_version = local.compute_secret_values["secrets"][each.key].secret_string_wo_version 106 | 107 | version_stages = var.version_stages 108 | depends_on = [aws_secretsmanager_secret.sm] 109 | lifecycle { 110 | ignore_changes = [ 111 | secret_id, 112 | ] 113 | } 114 | } 115 | 116 | resource "aws_secretsmanager_secret_version" "sm-svu" { 117 | for_each = { for k, v in var.secrets : k => v if var.unmanaged } 118 | secret_id = aws_secretsmanager_secret.sm[each.key].arn 119 | 120 | # Use computed values from locals to eliminate code duplication 121 | secret_string = local.compute_secret_values["secrets"][each.key].secret_string 122 | secret_binary = local.compute_secret_values["secrets"][each.key].secret_binary 123 | secret_string_wo = local.compute_secret_values["secrets"][each.key].secret_string_wo 124 | secret_string_wo_version = local.compute_secret_values["secrets"][each.key].secret_string_wo_version 125 | 126 | version_stages = var.version_stages 127 | depends_on = [aws_secretsmanager_secret.sm] 128 | 129 | lifecycle { 130 | ignore_changes = [ 131 | secret_string, 132 | secret_binary, 133 | secret_string_wo, 134 | secret_string_wo_version, 135 | secret_id, 136 | ] 137 | } 138 | } 139 | 140 | # Rotate secrets 141 | resource "aws_secretsmanager_secret" "rsm" { 142 | for_each = var.rotate_secrets 143 | name = local.rotate_secrets_config[each.key].computed_name 144 | name_prefix = local.rotate_secrets_config[each.key].computed_name_prefix 145 | description = local.rotate_secrets_config[each.key].description 146 | kms_key_id = local.rotate_secrets_config[each.key].kms_key_id 147 | policy = local.rotate_secrets_config[each.key].policy 148 | force_overwrite_replica_secret = local.rotate_secrets_config[each.key].force_overwrite_replica_secret 149 | recovery_window_in_days = local.rotate_secrets_config[each.key].recovery_window_in_days 150 | tags = merge(var.default_tags, var.tags, local.rotate_secrets_config[each.key].tags) 151 | } 152 | 153 | resource "aws_secretsmanager_secret_version" "rsm-sv" { 154 | for_each = { for k, v in var.rotate_secrets : k => v if !var.unmanaged } 155 | secret_id = aws_secretsmanager_secret.rsm[each.key].arn 156 | 157 | # Use computed values from locals to eliminate code duplication 158 | secret_string = local.compute_secret_values["rotate_secrets"][each.key].secret_string 159 | secret_binary = local.compute_secret_values["rotate_secrets"][each.key].secret_binary 160 | secret_string_wo = local.compute_secret_values["rotate_secrets"][each.key].secret_string_wo 161 | secret_string_wo_version = local.compute_secret_values["rotate_secrets"][each.key].secret_string_wo_version 162 | 163 | version_stages = var.version_stages 164 | depends_on = [aws_secretsmanager_secret.rsm] 165 | lifecycle { 166 | ignore_changes = [ 167 | secret_id, 168 | ] 169 | } 170 | } 171 | 172 | resource "aws_secretsmanager_secret_version" "rsm-svu" { 173 | for_each = { for k, v in var.rotate_secrets : k => v if var.unmanaged } 174 | secret_id = aws_secretsmanager_secret.rsm[each.key].arn 175 | 176 | # Use computed values from locals to eliminate code duplication 177 | secret_string = local.compute_secret_values["rotate_secrets"][each.key].secret_string 178 | secret_binary = local.compute_secret_values["rotate_secrets"][each.key].secret_binary 179 | secret_string_wo = local.compute_secret_values["rotate_secrets"][each.key].secret_string_wo 180 | secret_string_wo_version = local.compute_secret_values["rotate_secrets"][each.key].secret_string_wo_version 181 | 182 | version_stages = var.version_stages 183 | depends_on = [aws_secretsmanager_secret.rsm] 184 | 185 | lifecycle { 186 | ignore_changes = [ 187 | secret_string, 188 | secret_binary, 189 | secret_string_wo, 190 | secret_string_wo_version, 191 | secret_id, 192 | ] 193 | } 194 | } 195 | 196 | resource "aws_secretsmanager_secret_rotation" "rsm-sr" { 197 | for_each = var.rotate_secrets 198 | secret_id = aws_secretsmanager_secret.rsm[each.key].arn 199 | rotation_lambda_arn = local.rotate_secrets_config[each.key].rotation_lambda_arn 200 | 201 | rotation_rules { 202 | automatically_after_days = local.rotate_secrets_config[each.key].automatically_after_days 203 | } 204 | depends_on = [aws_secretsmanager_secret.rsm] 205 | 206 | lifecycle { 207 | ignore_changes = [ 208 | secret_id, 209 | ] 210 | } 211 | } 212 | -------------------------------------------------------------------------------- /test/helpers.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "testing" 7 | "time" 8 | 9 | "github.com/aws/aws-sdk-go/aws" 10 | "github.com/aws/aws-sdk-go/aws/session" 11 | "github.com/aws/aws-sdk-go/service/secretsmanager" 12 | awstest "github.com/gruntwork-io/terratest/modules/aws" 13 | "github.com/gruntwork-io/terratest/modules/random" 14 | "github.com/gruntwork-io/terratest/modules/retry" 15 | "github.com/stretchr/testify/require" 16 | ) 17 | 18 | // TestCase represents a test case for the secrets manager module 19 | type TestCase struct { 20 | Name string 21 | Description string 22 | Vars map[string]interface{} 23 | ExpectError bool 24 | ErrorText string 25 | } 26 | 27 | // GenerateTestName creates a unique test name with prefix 28 | func GenerateTestName(prefix string) string { 29 | return fmt.Sprintf("%s-%s", prefix, strings.ToLower(random.UniqueId())) 30 | } 31 | 32 | // GetTestRegion returns a stable AWS region for testing 33 | func GetTestRegion(t *testing.T) string { 34 | return awstest.GetRandomStableRegion(t, nil, nil) 35 | } 36 | 37 | // WaitForSecretDeletion waits for a secret to be completely deleted from AWS 38 | func WaitForSecretDeletion(t *testing.T, region, secretName string, maxRetries int, sleepBetweenRetries time.Duration) { 39 | retry.DoWithRetry(t, fmt.Sprintf("Waiting for secret %s to be deleted", secretName), maxRetries, sleepBetweenRetries, func() (string, error) { 40 | sess, err := session.NewSession(&aws.Config{ 41 | Region: aws.String(region), 42 | }) 43 | require.NoError(t, err) 44 | svc := secretsmanager.New(sess) 45 | 46 | _, errDesc := svc.DescribeSecret(&secretsmanager.DescribeSecretInput{ 47 | SecretId: aws.String(secretName), 48 | }) 49 | 50 | if errDesc != nil { 51 | // If the secret is not found, it means it's been deleted 52 | if strings.Contains(errDesc.Error(), "ResourceNotFoundException") { 53 | return "Secret deleted successfully", nil 54 | } 55 | return "", errDesc 56 | } 57 | 58 | return "", fmt.Errorf("Secret %s still exists", secretName) 59 | }) 60 | } 61 | 62 | // ValidateSecretExists checks if a secret exists in AWS Secrets Manager 63 | func ValidateSecretExists(t *testing.T, region, secretName string) *secretsmanager.DescribeSecretOutput { 64 | sess, err := session.NewSession(&aws.Config{ 65 | Region: aws.String(region), 66 | }) 67 | require.NoError(t, err) 68 | svc := secretsmanager.New(sess) 69 | 70 | input := &secretsmanager.DescribeSecretInput{ 71 | SecretId: aws.String(secretName), 72 | } 73 | 74 | result, err := svc.DescribeSecret(input) 75 | require.NoError(t, err, "Failed to describe secret %s", secretName) 76 | 77 | return result 78 | } 79 | 80 | // ValidateSecretValue retrieves and validates a secret value 81 | func ValidateSecretValue(t *testing.T, region, secretName string) string { 82 | secretValue := awstest.GetSecretValue(t, region, secretName) 83 | require.NotEmpty(t, secretValue, "Secret value should not be empty") 84 | return secretValue 85 | } 86 | 87 | // ValidateSecretTags checks if expected tags are present on a secret 88 | func ValidateSecretTags(t *testing.T, region, secretName string, expectedTags map[string]string) { 89 | secretInfo := ValidateSecretExists(t, region, secretName) 90 | 91 | actualTags := make(map[string]string) 92 | for _, tag := range secretInfo.Tags { 93 | actualTags[*tag.Key] = *tag.Value 94 | } 95 | 96 | for key, expectedValue := range expectedTags { 97 | actualValue, exists := actualTags[key] 98 | require.True(t, exists, "Tag %s should exist", key) 99 | require.Equal(t, expectedValue, actualValue, "Tag %s should have value %s", key, expectedValue) 100 | } 101 | } 102 | 103 | // ValidateRotationConfiguration checks rotation settings for a secret 104 | func ValidateRotationConfiguration(t *testing.T, region, secretName string, expectedRotationEnabled bool) { 105 | secretInfo := ValidateSecretExists(t, region, secretName) 106 | 107 | if expectedRotationEnabled { 108 | require.NotNil(t, secretInfo.RotationEnabled, "RotationEnabled should not be nil") 109 | require.True(t, *secretInfo.RotationEnabled, "Rotation should be enabled") 110 | require.NotNil(t, secretInfo.RotationLambdaARN, "RotationLambdaARN should not be nil when rotation is enabled") 111 | } else { 112 | if secretInfo.RotationEnabled != nil { 113 | require.False(t, *secretInfo.RotationEnabled, "Rotation should be disabled") 114 | } 115 | } 116 | } 117 | 118 | // CleanupTestSecrets removes test secrets that might be left over 119 | func CleanupTestSecrets(t *testing.T, region string, namePrefix string) { 120 | sess, err := session.NewSession(&aws.Config{ 121 | Region: aws.String(region), 122 | }) 123 | require.NoError(t, err) 124 | svc := secretsmanager.New(sess) 125 | 126 | // List all secrets 127 | input := &secretsmanager.ListSecretsInput{} 128 | result, err := svc.ListSecrets(input) 129 | if err != nil { 130 | t.Logf("Warning: Failed to list secrets for cleanup: %v", err) 131 | return 132 | } 133 | 134 | // Delete secrets that match the test prefix 135 | for _, secret := range result.SecretList { 136 | if secret.Name != nil && strings.HasPrefix(*secret.Name, namePrefix) { 137 | t.Logf("Cleaning up test secret: %s", *secret.Name) 138 | 139 | _, err := svc.DeleteSecret(&secretsmanager.DeleteSecretInput{ 140 | SecretId: secret.Name, 141 | ForceDeleteWithoutRecovery: aws.Bool(true), 142 | }) 143 | 144 | if err != nil { 145 | t.Logf("Warning: Failed to delete test secret %s: %v", *secret.Name, err) 146 | } 147 | } 148 | } 149 | } 150 | 151 | // CleanupAllTestSecrets performs aggressive cleanup of test-related secrets 152 | // This should be called at the beginning of test suites to clean up any orphaned resources 153 | func CleanupAllTestSecrets(t *testing.T, region string) { 154 | sess, err := session.NewSession(&aws.Config{ 155 | Region: aws.String(region), 156 | }) 157 | require.NoError(t, err) 158 | svc := secretsmanager.New(sess) 159 | 160 | // List all secrets with pagination support 161 | var allSecrets []*secretsmanager.SecretListEntry 162 | input := &secretsmanager.ListSecretsInput{} 163 | 164 | for { 165 | result, err := svc.ListSecrets(input) 166 | if err != nil { 167 | t.Logf("Warning: Failed to list secrets for aggressive cleanup: %v", err) 168 | return 169 | } 170 | 171 | allSecrets = append(allSecrets, result.SecretList...) 172 | 173 | // Check if there are more results 174 | if result.NextToken == nil { 175 | break 176 | } 177 | input.NextToken = result.NextToken 178 | } 179 | 180 | testPrefixes := []string{ 181 | "plan-test-", "ephemeral-vs-regular-", "ephemeral-types-", "ephemeral-versioning-", 182 | "ephemeral-rotation-", "test-secret-", "ephemeral-secret-", "tagged-secret-", 183 | "regular-secret-", "ephemeral-plaintext-", "ephemeral-kv-", "ephemeral-binary-", 184 | "versioned-secret-", "ephemeral-rotating-", "plaintext-", "keyvalue-", 185 | "rotation-", "binary-", "multiple-secrets-", "basic-", "complete-", "example-", 186 | } 187 | 188 | t.Logf("Found %d total secrets to evaluate for cleanup", len(allSecrets)) 189 | deletedCount := 0 190 | for _, secret := range allSecrets { 191 | if secret.Name == nil { 192 | continue 193 | } 194 | 195 | secretName := *secret.Name 196 | shouldDelete := false 197 | 198 | // Check prefixes 199 | for _, prefix := range testPrefixes { 200 | if strings.HasPrefix(secretName, prefix) { 201 | shouldDelete = true 202 | break 203 | } 204 | } 205 | 206 | // Check for recent test-pattern secrets (created in last 6 hours - standardized with cleanup/main.go) 207 | if !shouldDelete && secret.CreatedDate != nil { 208 | // Validate time calculation is safe 209 | createdDate := *secret.CreatedDate 210 | if createdDate.IsZero() { 211 | continue // Skip secrets with invalid creation dates 212 | } 213 | 214 | timeSinceCreation := time.Since(createdDate) 215 | // Add bounds checking to prevent negative durations or clock skew issues 216 | if timeSinceCreation >= 0 && timeSinceCreation < 6*time.Hour { 217 | testPatterns := []string{"test-", "terratest-", "ephemeral-", "validation-"} 218 | secretNameLower := strings.ToLower(secretName) 219 | for _, pattern := range testPatterns { 220 | if strings.Contains(secretNameLower, pattern) { 221 | shouldDelete = true 222 | break 223 | } 224 | } 225 | } 226 | } 227 | 228 | if shouldDelete { 229 | t.Logf("Cleaning up orphaned test secret: %s", secretName) 230 | _, err := svc.DeleteSecret(&secretsmanager.DeleteSecretInput{ 231 | SecretId: &secretName, 232 | ForceDeleteWithoutRecovery: aws.Bool(true), 233 | }) 234 | if err != nil { 235 | t.Logf("Warning: Failed to delete orphaned secret %s: %v", secretName, err) 236 | } else { 237 | deletedCount++ 238 | } 239 | } 240 | } 241 | 242 | if deletedCount > 0 { 243 | t.Logf("Cleaned up %d orphaned test secrets", deletedCount) 244 | } 245 | } 246 | 247 | // GetCommonTestVars returns common variables used across tests 248 | func GetCommonTestVars(uniqueID string) map[string]interface{} { 249 | return map[string]interface{}{ 250 | "name_suffix": uniqueID, 251 | "tags": map[string]string{ 252 | "Environment": "test", 253 | "ManagedBy": "terratest", 254 | "TestRun": uniqueID, 255 | }, 256 | } 257 | } 258 | 259 | // ValidateNoSensitiveDataInState checks that sensitive data is not present in Terraform state 260 | func ValidateNoSensitiveDataInState(t *testing.T, stateContent string, sensitiveValues []string) { 261 | for _, sensitiveValue := range sensitiveValues { 262 | require.NotContains(t, stateContent, sensitiveValue, 263 | "Sensitive value '%s' should not be present in Terraform state", sensitiveValue) 264 | } 265 | } 266 | 267 | // CreateBasicSecretConfig creates a basic secret configuration for testing 268 | func CreateBasicSecretConfig(secretName, secretValue string) map[string]interface{} { 269 | return map[string]interface{}{ 270 | secretName: map[string]interface{}{ 271 | "description": fmt.Sprintf("Test secret: %s", secretName), 272 | "secret_string": secretValue, 273 | }, 274 | } 275 | } 276 | 277 | // CreateEphemeralSecretConfig creates an ephemeral secret configuration for testing 278 | func CreateEphemeralSecretConfig(secretName, secretValue string, version int) map[string]interface{} { 279 | return map[string]interface{}{ 280 | secretName: map[string]interface{}{ 281 | "description": fmt.Sprintf("Ephemeral test secret: %s", secretName), 282 | "secret_string": secretValue, 283 | "secret_string_wo_version": version, 284 | }, 285 | } 286 | } 287 | 288 | // CreateKeyValueSecretConfig creates a key-value secret configuration for testing 289 | func CreateKeyValueSecretConfig(secretName string, keyValues map[string]string) map[string]interface{} { 290 | return map[string]interface{}{ 291 | secretName: map[string]interface{}{ 292 | "description": fmt.Sprintf("Key-value test secret: %s", secretName), 293 | "secret_key_value": keyValues, 294 | }, 295 | } 296 | } 297 | 298 | // CreateRotatingSecretConfig creates a rotating secret configuration for testing 299 | func CreateRotatingSecretConfig(secretName, secretValue, lambdaArn string) map[string]interface{} { 300 | return map[string]interface{}{ 301 | secretName: map[string]interface{}{ 302 | "description": fmt.Sprintf("Rotating test secret: %s", secretName), 303 | "secret_string": secretValue, 304 | "rotation_lambda_arn": lambdaArn, 305 | "automatically_after_days": 30, 306 | }, 307 | } 308 | } -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ## [0.20.0](https://github.com/lgallard/terraform-aws-secrets-manager/compare/0.19.1...0.20.0) (2025-07-30) 2 | 3 | 4 | ### Features 5 | 6 | * replicate security-hardened Claude Code Review workflow with PR focus ([#126](https://github.com/lgallard/terraform-aws-secrets-manager/issues/126)) ([a3e9803](https://github.com/lgallard/terraform-aws-secrets-manager/commit/a3e98038376bd2f32956ababb7c7a3e13255a645)) 7 | 8 | ## [0.23.0](https://github.com/lgallard/terraform-aws-secrets-manager/compare/0.22.0...0.23.0) (2025-08-10) 9 | 10 | 11 | ### Features 12 | 13 | * add pre-commit workflow for code quality automation ([#140](https://github.com/lgallard/terraform-aws-secrets-manager/issues/140)) ([00caada](https://github.com/lgallard/terraform-aws-secrets-manager/commit/00caada231d999466104f9f834bb8dd79e5c6da9)) 14 | 15 | ## [0.22.0](https://github.com/lgallard/terraform-aws-secrets-manager/compare/0.21.0...0.22.0) (2025-08-09) 16 | 17 | 18 | ### Features 19 | 20 | * add MCP server support for enhanced documentation access ([#138](https://github.com/lgallard/terraform-aws-secrets-manager/issues/138)) ([dea62f1](https://github.com/lgallard/terraform-aws-secrets-manager/commit/dea62f103068f728c5b0003a7c3ff421c2a2cae2)) 21 | 22 | ## [0.21.0](https://github.com/lgallard/terraform-aws-secrets-manager/compare/0.20.2...0.21.0) (2025-08-07) 23 | 24 | 25 | ### Features 26 | 27 | * add Claude dispatch workflow for repository events ([#135](https://github.com/lgallard/terraform-aws-secrets-manager/issues/135)) ([83960b6](https://github.com/lgallard/terraform-aws-secrets-manager/commit/83960b6ae8a534f136cc8a902aa335932fe2cf8a)) 28 | 29 | ## [0.20.2](https://github.com/lgallard/terraform-aws-secrets-manager/compare/0.20.1...0.20.2) (2025-08-02) 30 | 31 | 32 | ### Bug Fixes 33 | 34 | * improve test cleanup to prevent secret accumulation ([#132](https://github.com/lgallard/terraform-aws-secrets-manager/issues/132)) ([aaca0a7](https://github.com/lgallard/terraform-aws-secrets-manager/commit/aaca0a76e012c9c38105bbc574a9ed00e1b3cfcb)) 35 | 36 | ## [0.20.1](https://github.com/lgallard/terraform-aws-secrets-manager/compare/0.20.0...0.20.1) (2025-07-30) 37 | 38 | 39 | ### Bug Fixes 40 | 41 | * correct CHANGELOG.md ordering for version 0.11.5 ([#129](https://github.com/lgallard/terraform-aws-secrets-manager/issues/129)) ([2deb4fb](https://github.com/lgallard/terraform-aws-secrets-manager/commit/2deb4fb99791ac8145674e152c7de7c61accb1a1)) 42 | 43 | ## [0.19.1](https://github.com/lgallard/terraform-aws-secrets-manager/compare/0.19.0...0.19.1) (2025-07-29) 44 | 45 | 46 | ### Bug Fixes 47 | 48 | * remove variable usage from lifecycle blocks ([edbc5a6](https://github.com/lgallard/terraform-aws-secrets-manager/commit/edbc5a680bfb9b94d6669379e69eff70b5c38e8b)), closes [#122](https://github.com/lgallard/terraform-aws-secrets-manager/issues/122) 49 | 50 | ## [0.19.0](https://github.com/lgallard/terraform-aws-secrets-manager/compare/0.18.2...0.19.0) (2025-07-28) 51 | 52 | 53 | ### Features 54 | 55 | * code quality and best practices improvements ([#115](https://github.com/lgallard/terraform-aws-secrets-manager/issues/115)) ([987ce7b](https://github.com/lgallard/terraform-aws-secrets-manager/commit/987ce7b1150f3d5edf6911994f7d9c98f66c4867)) 56 | 57 | ## [0.18.2](https://github.com/lgallard/terraform-aws-secrets-manager/compare/0.18.1...0.18.2) (2025-07-27) 58 | 59 | 60 | ### Bug Fixes 61 | 62 | * skip CI/CD tests for documentation-only changes ([#118](https://github.com/lgallard/terraform-aws-secrets-manager/issues/118)) ([e3e071a](https://github.com/lgallard/terraform-aws-secrets-manager/commit/e3e071a2b239e94fdc0d4ea3bb3b04017d00cfa5)), closes [#117](https://github.com/lgallard/terraform-aws-secrets-manager/issues/117) 63 | 64 | ## [0.18.1](https://github.com/lgallard/terraform-aws-secrets-manager/compare/0.18.0...0.18.1) (2025-07-24) 65 | 66 | 67 | ### Bug Fixes 68 | 69 | * grant commit permissions to claude.yml workflow ([#109](https://github.com/lgallard/terraform-aws-secrets-manager/issues/109)) ([b4879ad](https://github.com/lgallard/terraform-aws-secrets-manager/commit/b4879ad6e80a7e91c6929c4f7ab6e0a8caa56425)) 70 | 71 | ## [0.18.0](https://github.com/lgallard/terraform-aws-secrets-manager/compare/0.17.0...0.18.0) (2025-07-23) 72 | 73 | 74 | ### Features 75 | 76 | * standardize release-please configuration ([#106](https://github.com/lgallard/terraform-aws-secrets-manager/issues/106)) ([5d31082](https://github.com/lgallard/terraform-aws-secrets-manager/commit/5d310822b06cc612c70d220b61ebaadd171a4d49)) 77 | 78 | ## [0.17.0](https://github.com/lgallard/terraform-aws-secrets-manager/compare/0.16.0...0.17.0) (2025-07-23) 79 | 80 | 81 | ### Features 82 | 83 | * add claude code review workflow ([#104](https://github.com/lgallard/terraform-aws-secrets-manager/issues/104)) ([c09ad78](https://github.com/lgallard/terraform-aws-secrets-manager/commit/c09ad781cb9096beaa4ca6c526096abae34cd8d3)) 84 | 85 | ## [0.16.0](https://github.com/lgallard/terraform-aws-secrets-manager/compare/0.15.0...0.16.0) (2025-07-21) 86 | 87 | 88 | ### Features 89 | 90 | * implement GitHub issue [#80](https://github.com/lgallard/terraform-aws-secrets-manager/issues/80) ephemeral password support with for_each ([#102](https://github.com/lgallard/terraform-aws-secrets-manager/issues/102)) ([963eb0e](https://github.com/lgallard/terraform-aws-secrets-manager/commit/963eb0ee473edecc25c3daeac9d8d690a6d82561)) 91 | 92 | ## [0.15.0](https://github.com/lgallard/terraform-aws-secrets-manager/compare/0.14.0...0.15.0) (2025-07-20) 93 | 94 | 95 | ### Features 96 | 97 | * Implement comprehensive testing and CI/CD infrastructure ([#97](https://github.com/lgallard/terraform-aws-secrets-manager/issues/97)) ([65b7efb](https://github.com/lgallard/terraform-aws-secrets-manager/commit/65b7efbff1e5ba52a8183ed1025c40cfe7e66f58)) 98 | 99 | ## [0.14.0](https://github.com/lgallard/terraform-aws-secrets-manager/compare/0.13.0...0.14.0) (2025-07-16) 100 | 101 | 102 | ### Features 103 | 104 | * Add ephemeral passwords support to prevent sensitive data in state ([#81](https://github.com/lgallard/terraform-aws-secrets-manager/issues/81)) ([6ac0180](https://github.com/lgallard/terraform-aws-secrets-manager/commit/6ac01808c2dd73e35c75c66f2be6d24672dbb999)) 105 | 106 | ## [0.13.0](https://github.com/lgallard/terraform-aws-secrets-manager/compare/0.12.2...0.13.0) (2025-07-06) 107 | 108 | 109 | ### Features 110 | 111 | * Improve Documentation and Input Validation ([#74](https://github.com/lgallard/terraform-aws-secrets-manager/issues/74)) ([3364440](https://github.com/lgallard/terraform-aws-secrets-manager/commit/3364440cf40969b10905fe396c226a0e96ab82ac)) 112 | 113 | ## [0.12.2](https://github.com/lgallard/terraform-aws-secrets-manager/compare/0.12.1...0.12.2) (2025-06-08) 114 | 115 | 116 | ### Bug Fixes 117 | 118 | * Add GitHub Action step to remove "v" prefix from release titles ([#68](https://github.com/lgallard/terraform-aws-secrets-manager/issues/68)) ([5f0233d](https://github.com/lgallard/terraform-aws-secrets-manager/commit/5f0233d66849ee6b25bb47fc8ad588f69c416726)) 119 | 120 | ## [0.12.1](https://github.com/lgallard/terraform-aws-secrets-manager/compare/0.12.0...0.12.1) (2025-06-08) 121 | 122 | 123 | ### Bug Fixes 124 | 125 | * Add tag-separator configuration to ensure Release Please creates tags without prefixes ([#65](https://github.com/lgallard/terraform-aws-secrets-manager/issues/65)) ([c37f85e](https://github.com/lgallard/terraform-aws-secrets-manager/commit/c37f85e1b2d7660c7415e67bc0c3f44a82c55fc5)) 126 | 127 | ## [0.12.0](https://github.com/lgallard/terraform-aws-secrets-manager/compare/0.11.5...0.12.0) (2025-06-08) 128 | 129 | 130 | ### Features 131 | 132 | * Add release-please support ([#62](https://github.com/lgallard/terraform-aws-secrets-manager/issues/62)) ([e84b772](https://github.com/lgallard/terraform-aws-secrets-manager/commit/e84b7729e83b762745c55da164ce85b1a759745d)) 133 | * Add release-please workflow ([4965e14](https://github.com/lgallard/terraform-aws-secrets-manager/commit/4965e1405029cd86a1a355827766cf6791783114)) 134 | 135 | ## 0.11.5 (June 3, 2024) 136 | 137 | ENHANCEMENTS: 138 | 139 | * Add Renovate 140 | 141 | ## 0.11.4 (June 3, 2024) 142 | 143 | FIX: 144 | 145 | * Fix required constraints 146 | * Remove Dependabot 147 | 148 | ## 0.11.3 (June 3, 2024) 149 | 150 | FIX: 151 | 152 | * Change Terraform version requirements syntax 153 | 154 | ## 0.11.2 (June 3, 2024) 155 | 156 | FIX: 157 | 158 | * Terraform version requirements syntax 159 | 160 | ## 0.11.1 (June 3, 2024) 161 | 162 | ENHANCEMENTS: 163 | 164 | * Add Dependabot 165 | 166 | ## 0.11.0 (December 19, 2023) 167 | 168 | ENHANCEMENTS: 169 | 170 | * Allow to use ´version_stages´ on secret versions (thanks @magmax) 171 | 172 | ## 0.10.1 (October 29, 2023) 173 | 174 | FIXES: 175 | 176 | * Not defined index in context issue 177 | 178 | ## 0.10.0 (October 26, 2023) 179 | 180 | FIXES: 181 | 182 | * Update secret version and secret rotation to support using name_prefix (thanks @bensharp1) 183 | * Allow 'Name' parameter to overwrite each.key as Secret Name (thanks @bensharp1) 184 | 185 | ## 0.9.0 (September 8, 2023) 186 | 187 | ENHANCEMENTS: 188 | 189 | * Allow empty replica region `kms_key_id` to use AWS default (thanks @siteopsio) 190 | * Update README file in replication example (thanks @siteopsio) 191 | 192 | ## 0.8.0 (April 28, 2023) 193 | 194 | ENHANCEMENTS: 195 | 196 | * Add ´force_overwrite_replica_secret´ (thanks @btougeiro) 197 | * Add replication example 198 | 199 | ## 0.7.0 (April 5, 2023) 200 | 201 | ENHANCEMENTS: 202 | 203 | * Add separate secrets replication configuration (thanks @wiseelf) 204 | 205 | ## 0.6.2 (January 11, 2023) 206 | 207 | FIXES: 208 | 209 | * Add lifecycle ignore for `secret_id` (thanks @jmonte-sph) 210 | 211 | ## 0.6.1 (October 19, 2022) 212 | 213 | FIXES: 214 | 215 | * Patch for the missing dependency in the "rsm-sr" resource (thanks @cschwarze) 216 | 217 | ## 0.6.0 (Sep 1, 2022) 218 | 219 | ENHANCEMENTS: 220 | 221 | * Adding Replica support to Secrets (thanks @ppapishe) 222 | 223 | ## 0.5.3 (Aug 29, 2022) 224 | 225 | ENHANCEMENTS: 226 | 227 | * Adds replica support 228 | 229 | ## 0.5.2 (Jan 2, 2022) 230 | 231 | ENHANCEMENTS: 232 | 233 | * Add secret policy examples 234 | 235 | ## 0.5.1 (August 26, 2021) 236 | 237 | FIXES: 238 | 239 | * Fix secret map output 240 | 241 | ## 0.5.0 (August 22, 2021) 242 | 243 | ENHANCEMENTS: 244 | 245 | * Change secret list to map definitions 246 | * Update READMEs 247 | 248 | ## 0.4.2 (June 10, 2021) 249 | 250 | ENHANCEMENTS: 251 | 252 | * Add pre-commit script 253 | * Update README 254 | 255 | ## 0.4.1 (December 1, 2020) 256 | 257 | FIXES: 258 | 259 | * Fix typo in README and improve variable description 260 | 261 | ## 0.4.0 (December 1, 2020) 262 | 263 | ENHANCEMENTS: 264 | 265 | * Add support for unmanaged rotated secrets: Avoid rotation of secrets by subsequent runs of Terraform 266 | 267 | Thanks @moliver-aicradle 268 | 269 | ## 0.3.0 (October 22, 2020) 270 | 271 | ENHANCEMENTS: 272 | 273 | * Add support for unmanaged secrets: Using this option you can initialize the secrets and rotate them outside Terraform, thus, avoiding other users to change the secrets. 274 | 275 | Thanks @fabio42 276 | 277 | ## 0.2.1 (July 3, 2020) 278 | 279 | ENHANCEMENTS: 280 | 281 | * Update rotation lambda example 282 | 283 | ## 0.2.0 (June 29, 2020) 284 | 285 | FIXES: 286 | 287 | * Add AWS provider version requirement 288 | 289 | ## 0.1.2 (June 27, 2020) 290 | 291 | FIXES: 292 | 293 | * Fix typo in README 294 | 295 | ## 0.1.1 (June 26, 2020) 296 | 297 | FIXES: 298 | 299 | * Update README 300 | 301 | ## 0.1.0 (June 26, 2020) 302 | 303 | FEATURES: 304 | 305 | * Module implementation 306 | -------------------------------------------------------------------------------- /test/README.md: -------------------------------------------------------------------------------- 1 | # Terraform AWS Secrets Manager - Testing Guide 2 | 3 | This directory contains comprehensive tests for the terraform-aws-secrets-manager module, including validation tests, integration tests, and specialized tests for ephemeral functionality. 4 | 5 | ## Quick Start 6 | 7 | ### Prerequisites 8 | 9 | 1. **Go 1.21+** 10 | ```bash 11 | go version 12 | ``` 13 | 14 | 2. **Terraform 1.11+** (for ephemeral support) 15 | ```bash 16 | terraform version 17 | ``` 18 | 19 | 3. **AWS Credentials** 20 | ```bash 21 | aws configure 22 | # OR 23 | export AWS_ACCESS_KEY_ID=your-key 24 | export AWS_SECRET_ACCESS_KEY=your-secret 25 | export AWS_DEFAULT_REGION=us-east-1 26 | ``` 27 | 28 | ### Run All Tests 29 | 30 | ```bash 31 | cd test 32 | go test -v -timeout 45m ./... 33 | ``` 34 | 35 | ## Test Categories 36 | 37 | ### 1. Validation Tests (Fast - ~2 minutes) 38 | 39 | Tests that don't require AWS resources: 40 | 41 | ```bash 42 | go test -v -timeout 10m -run "TestTerraform.*Validation|TestTerraformFormat|TestTerraformValidate|TestExamplesValidation" 43 | ``` 44 | 45 | **What it tests:** 46 | - Terraform configuration syntax 47 | - Variable validation rules 48 | - Example configurations 49 | - Format compliance 50 | - Input validation logic 51 | 52 | ### 2. Ephemeral Tests (Medium - ~15 minutes) 53 | 54 | Tests specific to ephemeral functionality: 55 | 56 | ```bash 57 | go test -v -timeout 30m -run "TestEphemeral.*" 58 | ``` 59 | 60 | **What it tests:** 61 | - Ephemeral vs regular mode comparison 62 | - State file security (no sensitive data leakage) 63 | - Different secret types in ephemeral mode 64 | - Version control mechanisms 65 | - Write-only parameter usage 66 | 67 | ### 3. Integration Tests (Slow - ~30 minutes) 68 | 69 | Full integration tests with AWS resources: 70 | 71 | ```bash 72 | go test -v -timeout 45m -run "TestTerraformAwsSecretsManager.*" 73 | ``` 74 | 75 | **What it tests:** 76 | - End-to-end module functionality 77 | - Multiple secret types (plaintext, key-value, binary) 78 | - Secret rotation capabilities 79 | - Tag management 80 | - Cross-region functionality 81 | 82 | ## Test Organization 83 | 84 | ### File Structure 85 | 86 | ``` 87 | test/ 88 | ├── go.mod # Go dependencies 89 | ├── go.sum # Dependency checksums 90 | ├── helpers.go # Shared utilities 91 | ├── terraform_validation_test.go # Format/syntax validation 92 | ├── terraform_ephemeral_test.go # Ephemeral functionality 93 | ├── terraform_aws_secrets_manager_test.go # Integration tests 94 | ├── cleanup/ 95 | │ └── main.go # Resource cleanup utility 96 | └── README.md # This file 97 | ``` 98 | 99 | ### Helper Functions 100 | 101 | The `helpers.go` file provides utilities for: 102 | 103 | - **Test naming:** `GenerateTestName(prefix)` 104 | - **AWS regions:** `GetTestRegion(t)` 105 | - **Secret validation:** `ValidateSecretExists()`, `ValidateSecretValue()` 106 | - **State analysis:** `ValidateNoSensitiveDataInState()` 107 | - **Config builders:** `CreateBasicSecretConfig()`, `CreateEphemeralSecretConfig()` 108 | 109 | ## Specific Test Scenarios 110 | 111 | ### Testing Ephemeral Mode 112 | 113 | Ephemeral mode is the key security feature that prevents sensitive data from being stored in Terraform state: 114 | 115 | ```bash 116 | # Test ephemeral vs regular mode comparison 117 | go test -v -run "TestEphemeralVsRegularMode" 118 | 119 | # Test different secret types in ephemeral mode 120 | go test -v -run "TestEphemeralSecretTypes" 121 | 122 | # Test version control in ephemeral mode 123 | go test -v -run "TestEphemeralSecretVersioning" 124 | ``` 125 | 126 | ### Testing Validation Rules 127 | 128 | Variable validation ensures proper input handling: 129 | 130 | ```bash 131 | # Test all validation rules 132 | go test -v -run "TestVariableValidation" 133 | 134 | # Test specific validation cases 135 | go test -v -run "TestVariableValidation/ephemeral_missing_version" 136 | go test -v -run "TestVariableValidation/invalid_secret_name" 137 | ``` 138 | 139 | ### Testing Multiple Secret Types 140 | 141 | ```bash 142 | # Test key-value secrets 143 | go test -v -run "TestTerraformAwsSecretsManagerKeyValue" 144 | 145 | # Test binary secrets 146 | go test -v -run "TestTerraformAwsSecretsManagerBinarySecret" 147 | 148 | # Test multiple secrets at once 149 | go test -v -run "TestTerraformAwsSecretsManagerMultipleSecrets" 150 | ``` 151 | 152 | ## Environment Configuration 153 | 154 | ### Required Environment Variables 155 | 156 | ```bash 157 | export AWS_DEFAULT_REGION=us-east-1 158 | ``` 159 | 160 | ### Optional Environment Variables 161 | 162 | ```bash 163 | export AWS_PROFILE=your-profile # Use specific AWS profile 164 | export TF_VAR_name_suffix=test-$(date +%s) # Unique suffix for resources 165 | export TERRATEST_REGION=us-west-2 # Override test region 166 | ``` 167 | 168 | ### Test Isolation 169 | 170 | Each test automatically generates unique resource names to prevent conflicts: 171 | 172 | ```go 173 | uniqueID := GenerateTestName("test-prefix") // Generates: test-prefix-abc123 174 | ``` 175 | 176 | ## Debugging Tests 177 | 178 | ### Verbose Output 179 | 180 | ```bash 181 | go test -v -run "TestName" 182 | ``` 183 | 184 | ### Keep Resources for Investigation 185 | 186 | Temporarily comment out the cleanup: 187 | 188 | ```go 189 | // defer terraform.Destroy(t, terraformOptions) // Comment this line 190 | ``` 191 | 192 | ### Debug Specific Test Cases 193 | 194 | ```bash 195 | go test -v -run "TestVariableValidation/ephemeral_missing_version" 196 | ``` 197 | 198 | ### View Terraform Logs 199 | 200 | ```bash 201 | export TF_LOG=DEBUG 202 | go test -v -run "TestName" 203 | ``` 204 | 205 | ## Parallel Execution 206 | 207 | Tests are designed to run in parallel for efficiency: 208 | 209 | ```bash 210 | # All tests run in parallel by default 211 | go test -v -parallel 8 ./... 212 | 213 | # Limit parallelism if needed 214 | go test -v -parallel 2 ./... 215 | ``` 216 | 217 | ## Cleanup 218 | 219 | ### Automatic Cleanup 220 | 221 | - Each test automatically cleans up its resources via `defer terraform.Destroy()` 222 | - CI/CD pipeline includes a cleanup job for orphaned resources 223 | 224 | ### Manual Cleanup 225 | 226 | If tests fail and leave resources behind: 227 | 228 | ```bash 229 | cd test 230 | go run cleanup/main.go 231 | ``` 232 | 233 | This utility removes: 234 | - Test secrets matching known prefixes 235 | - Secrets tagged as test resources 236 | - Secrets created within the last 24 hours matching test patterns 237 | 238 | ## CI/CD Integration 239 | 240 | ### GitHub Actions 241 | 242 | The tests integrate with GitHub Actions (`.github/workflows/test.yml`): 243 | 244 | - **On every push/PR:** Validation, security, and linting 245 | - **On PR to master:** Unit tests (validation + ephemeral) 246 | - **On master branch:** Full integration tests 247 | - **Manual trigger:** Add `run-integration-tests` label 248 | 249 | ### Local Testing Before CI 250 | 251 | Run the same checks locally: 252 | 253 | ```bash 254 | # Format check 255 | terraform fmt -check -recursive 256 | 257 | # Validation 258 | terraform init && terraform validate 259 | 260 | # Security scan (if tfsec installed) 261 | tfsec . 262 | 263 | # Unit tests 264 | cd test && go test -v -timeout 30m -run "TestTerraform.*Validation|TestEphemeral.*" 265 | ``` 266 | 267 | ## Common Issues & Solutions 268 | 269 | ### Issue: AWS Credentials 270 | 271 | **Error:** `NoCredentialProviders: no valid providers in chain` 272 | 273 | **Solution:** 274 | ```bash 275 | aws configure 276 | # OR 277 | export AWS_ACCESS_KEY_ID=your-key 278 | export AWS_SECRET_ACCESS_KEY=your-secret 279 | ``` 280 | 281 | ### Issue: Resource Conflicts 282 | 283 | **Error:** `AlreadyExistsException: A resource with the ID X already exists` 284 | 285 | **Solution:** 286 | ```bash 287 | # Run cleanup 288 | cd test && go run cleanup/main.go 289 | 290 | # Or use unique suffix 291 | export TF_VAR_name_suffix=test-$(date +%s) 292 | ``` 293 | 294 | ### Issue: Timeout in Tests 295 | 296 | **Error:** `Test timed out after 30m` 297 | 298 | **Solution:** 299 | ```bash 300 | # Increase timeout 301 | go test -v -timeout 60m ./... 302 | ``` 303 | 304 | ### Issue: Region-Specific Failures 305 | 306 | **Error:** Test fails in specific regions 307 | 308 | **Solution:** 309 | ```bash 310 | # Test in specific region 311 | export AWS_DEFAULT_REGION=us-west-2 312 | go test -v -run "TestName" 313 | ``` 314 | 315 | ### Issue: State File Analysis Fails 316 | 317 | **Error:** Ephemeral tests fail state validation 318 | 319 | **Solution:** 320 | - Ensure Terraform >= 1.11 for ephemeral support 321 | - Check that `ephemeral = true` is set in test configuration 322 | - Verify `secret_string_wo_version` is provided 323 | 324 | ## Performance Tips 325 | 326 | ### Speed Up Tests 327 | 328 | 1. **Run validation tests first** (fastest feedback): 329 | ```bash 330 | go test -v -run "TestTerraform.*Validation" 331 | ``` 332 | 333 | 2. **Use parallel execution**: 334 | ```bash 335 | go test -v -parallel 8 ./... 336 | ``` 337 | 338 | 3. **Target specific functionality**: 339 | ```bash 340 | go test -v -run "TestEphemeral.*" 341 | ``` 342 | 343 | ### Resource Optimization 344 | 345 | 1. **Use consistent regions** to leverage provider caching 346 | 2. **Clean up regularly** to avoid hitting service limits 347 | 3. **Use small secret values** to reduce transfer time 348 | 349 | ## Contributing 350 | 351 | ### Adding New Tests 352 | 353 | 1. **Follow naming conventions:** 354 | - `TestTerraformAwsSecretsManager*` for integration tests 355 | - `TestEphemeral*` for ephemeral functionality 356 | - `TestTerraform*Validation` for validation tests 357 | 358 | 2. **Use helper functions:** 359 | ```go 360 | uniqueID := GenerateTestName("new-feature") 361 | config := CreateBasicSecretConfig("secret-name", "secret-value") 362 | ``` 363 | 364 | 3. **Include cleanup:** 365 | ```go 366 | defer terraform.Destroy(t, terraformOptions) 367 | ``` 368 | 369 | 4. **Enable parallel execution:** 370 | ```go 371 | t.Parallel() 372 | ``` 373 | 374 | 5. **Add descriptive assertions:** 375 | ```go 376 | assert.Equal(t, expected, actual, "Secret value should match expected") 377 | ``` 378 | 379 | ### Test Coverage Guidelines 380 | 381 | - **New features:** Must include tests 382 | - **Bug fixes:** Should include regression tests 383 | - **Modifications:** Add tests if missing 384 | - **Security features:** Require security-specific tests 385 | 386 | ## Security Considerations 387 | 388 | ### Ephemeral Testing 389 | 390 | The ephemeral tests specifically validate: 391 | - Sensitive data is NOT stored in Terraform state 392 | - Write-only arguments work correctly 393 | - Version parameters control updates 394 | - Secrets are properly created in AWS despite state protection 395 | 396 | ### Test Data 397 | 398 | - Use non-sensitive test values only 399 | - Avoid real credentials or production data 400 | - Use short-lived test resources 401 | - Implement proper cleanup procedures 402 | 403 | ## Support 404 | 405 | For test-related issues: 406 | 407 | 1. Check this README for common solutions 408 | 2. Review test output for specific error messages 409 | 3. Run cleanup utility if resource conflicts occur 410 | 4. Ensure proper AWS credentials and permissions 411 | 5. Verify Terraform and Go versions meet requirements 412 | 413 | For ephemeral functionality questions, see: 414 | - `examples/ephemeral/README.md` 415 | - Main module documentation 416 | - Test cases in `terraform_ephemeral_test.go` -------------------------------------------------------------------------------- /test/terraform_ephemeral_test.go: -------------------------------------------------------------------------------- 1 | package test 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "strings" 7 | "testing" 8 | 9 | "github.com/gruntwork-io/terratest/modules/terraform" 10 | "github.com/stretchr/testify/assert" 11 | "github.com/stretchr/testify/require" 12 | ) 13 | 14 | // TestEphemeralVsRegularMode compares ephemeral and regular modes 15 | func TestEphemeralVsRegularMode(t *testing.T) { 16 | t.Parallel() 17 | 18 | uniqueID := GenerateTestName("ephemeral-vs-regular") 19 | awsRegion := GetTestRegion(t) 20 | secretValue := "supersecretpassword123" 21 | 22 | testCases := []struct { 23 | name string 24 | ephemeral bool 25 | vars map[string]interface{} 26 | }{ 27 | { 28 | name: "regular_mode", 29 | ephemeral: false, 30 | vars: map[string]interface{}{ 31 | "ephemeral": false, 32 | "secrets": CreateBasicSecretConfig( 33 | fmt.Sprintf("regular-secret-%s", uniqueID), 34 | secretValue, 35 | ), 36 | }, 37 | }, 38 | { 39 | name: "ephemeral_mode", 40 | ephemeral: true, 41 | vars: map[string]interface{}{ 42 | "ephemeral": true, 43 | "secrets": CreateEphemeralSecretConfig( 44 | fmt.Sprintf("ephemeral-secret-%s", uniqueID), 45 | secretValue, 46 | 1, 47 | ), 48 | }, 49 | }, 50 | } 51 | 52 | for _, tc := range testCases { 53 | t.Run(tc.name, func(t *testing.T) { 54 | terraformOptions := &terraform.Options{ 55 | TerraformDir: "../", 56 | Vars: tc.vars, 57 | EnvVars: map[string]string{ 58 | "AWS_DEFAULT_REGION": awsRegion, 59 | }, 60 | } 61 | 62 | // Initialize Terraform first 63 | terraform.Init(t, terraformOptions) 64 | 65 | // Deploy the infrastructure 66 | terraform.Apply(t, terraformOptions) 67 | 68 | // Ensure cleanup happens even if test fails - set up after successful apply 69 | defer terraform.Destroy(t, terraformOptions) 70 | 71 | // Get the Terraform state 72 | state := terraform.Show(t, terraformOptions) 73 | stateJSON, err := json.Marshal(state) 74 | require.NoError(t, err) 75 | stateString := string(stateJSON) 76 | 77 | // Verify the secret exists and has the correct value in AWS BEFORE validating state 78 | secretArns := terraform.OutputMap(t, terraformOptions, "secret_arns") 79 | require.Len(t, secretArns, 1) 80 | 81 | // Get the first (and only) ARN from the map 82 | var secretArn string 83 | for _, arn := range secretArns { 84 | secretArn = arn 85 | break 86 | } 87 | 88 | // Use the full ARN to validate the secret value 89 | actualSecretValue := ValidateSecretValue(t, awsRegion, secretArn) 90 | assert.Equal(t, secretValue, actualSecretValue) 91 | 92 | // Validate state content based on mode 93 | if tc.ephemeral { 94 | // In ephemeral mode, sensitive data should NOT be in state 95 | ValidateNoSensitiveDataInState(t, stateString, []string{ 96 | secretValue, 97 | "supersecretpassword", 98 | }) 99 | 100 | // State should contain write-only parameter references but not values 101 | assert.Contains(t, stateString, "secret_string_wo_version") 102 | assert.NotContains(t, stateString, secretValue) 103 | } else { 104 | // In regular mode, secret data will be in state (this is expected behavior) 105 | // We're not checking for presence as terraform.Show() may not include sensitive values 106 | // The key difference is that ephemeral mode explicitly prevents this 107 | } 108 | }) 109 | } 110 | } 111 | 112 | // TestEphemeralSecretTypes tests different secret types in ephemeral mode 113 | func TestEphemeralSecretTypes(t *testing.T) { 114 | t.Parallel() 115 | 116 | uniqueID := GenerateTestName("ephemeral-types") 117 | awsRegion := GetTestRegion(t) 118 | 119 | testCases := []struct { 120 | name string 121 | secretConfig map[string]interface{} 122 | expectedValue string 123 | valueCheck func(t *testing.T, value string) 124 | }{ 125 | { 126 | name: "ephemeral_plaintext", 127 | secretConfig: CreateEphemeralSecretConfig( 128 | fmt.Sprintf("ephemeral-plaintext-%s", uniqueID), 129 | "plaintext-secret-value", 130 | 1, 131 | ), 132 | expectedValue: "plaintext-secret-value", 133 | valueCheck: func(t *testing.T, value string) { 134 | assert.Equal(t, "plaintext-secret-value", value) 135 | }, 136 | }, 137 | { 138 | name: "ephemeral_key_value", 139 | secretConfig: map[string]interface{}{ 140 | fmt.Sprintf("ephemeral-kv-%s", uniqueID): map[string]interface{}{ 141 | "description": "Ephemeral key-value secret", 142 | "secret_key_value": map[string]string{ 143 | "username": "testuser", 144 | "password": "testpass123", 145 | }, 146 | "secret_string_wo_version": 1, 147 | }, 148 | }, 149 | valueCheck: func(t *testing.T, value string) { 150 | var secretData map[string]interface{} 151 | err := json.Unmarshal([]byte(value), &secretData) 152 | require.NoError(t, err) 153 | assert.Equal(t, "testuser", secretData["username"]) 154 | assert.Equal(t, "testpass123", secretData["password"]) 155 | }, 156 | }, 157 | { 158 | name: "ephemeral_binary", 159 | secretConfig: map[string]interface{}{ 160 | fmt.Sprintf("ephemeral-binary-%s", uniqueID): map[string]interface{}{ 161 | "description": "Ephemeral binary secret", 162 | "secret_binary": "binary-data-content", 163 | "secret_string_wo_version": 1, 164 | }, 165 | }, 166 | valueCheck: func(t *testing.T, value string) { 167 | // In ephemeral mode, binary secrets are stored as base64-encoded strings 168 | assert.NotEmpty(t, value) 169 | // Could decode and verify, but main point is that it exists and is retrievable 170 | }, 171 | }, 172 | } 173 | 174 | for _, tc := range testCases { 175 | t.Run(tc.name, func(t *testing.T) { 176 | terraformOptions := &terraform.Options{ 177 | TerraformDir: "../", 178 | Vars: map[string]interface{}{ 179 | "ephemeral": true, 180 | "secrets": tc.secretConfig, 181 | }, 182 | EnvVars: map[string]string{ 183 | "AWS_DEFAULT_REGION": awsRegion, 184 | }, 185 | } 186 | 187 | // Initialize Terraform first 188 | terraform.Init(t, terraformOptions) 189 | 190 | // Deploy the infrastructure 191 | terraform.Apply(t, terraformOptions) 192 | 193 | // Ensure cleanup happens even if test fails - set up after successful apply 194 | defer terraform.Destroy(t, terraformOptions) 195 | 196 | // Verify the secret exists and validate its value FIRST 197 | secretArns := terraform.OutputMap(t, terraformOptions, "secret_arns") 198 | require.Len(t, secretArns, 1) 199 | 200 | // Get the first (and only) ARN from the map 201 | var secretArn string 202 | for _, arn := range secretArns { 203 | secretArn = arn 204 | break 205 | } 206 | 207 | actualValue := ValidateSecretValue(t, awsRegion, secretArn) 208 | 209 | if tc.valueCheck != nil { 210 | tc.valueCheck(t, actualValue) 211 | } 212 | 213 | // Verify no sensitive data in state 214 | state := terraform.Show(t, terraformOptions) 215 | stateJSON, err := json.Marshal(state) 216 | require.NoError(t, err) 217 | stateString := string(stateJSON) 218 | 219 | // Check that sensitive values are not in state 220 | if tc.expectedValue != "" { 221 | ValidateNoSensitiveDataInState(t, stateString, []string{tc.expectedValue}) 222 | } 223 | }) 224 | } 225 | } 226 | 227 | // TestEphemeralSecretVersioning tests version control in ephemeral mode 228 | func TestEphemeralSecretVersioning(t *testing.T) { 229 | t.Parallel() 230 | 231 | uniqueID := GenerateTestName("ephemeral-versioning") 232 | awsRegion := GetTestRegion(t) 233 | secretName := fmt.Sprintf("versioned-secret-%s", uniqueID) 234 | 235 | // Initial deployment with version 1 236 | terraformOptions := &terraform.Options{ 237 | TerraformDir: "../", 238 | Vars: map[string]interface{}{ 239 | "ephemeral": true, 240 | "secrets": CreateEphemeralSecretConfig( 241 | secretName, 242 | "initial-value", 243 | 1, 244 | ), 245 | }, 246 | EnvVars: map[string]string{ 247 | "AWS_DEFAULT_REGION": awsRegion, 248 | }, 249 | } 250 | 251 | // Initialize Terraform first 252 | terraform.Init(t, terraformOptions) 253 | 254 | // Deploy initial version 255 | terraform.Apply(t, terraformOptions) 256 | 257 | // Ensure cleanup happens even if test fails - set up after successful apply 258 | defer terraform.Destroy(t, terraformOptions) 259 | 260 | // Verify initial secret value 261 | secretArns := terraform.OutputMap(t, terraformOptions, "secret_arns") 262 | require.Len(t, secretArns, 1) 263 | 264 | // Get the first (and only) ARN from the map 265 | var secretArn string 266 | for _, arn := range secretArns { 267 | secretArn = arn 268 | break 269 | } 270 | 271 | initialValue := ValidateSecretValue(t, awsRegion, secretArn) 272 | assert.Equal(t, "initial-value", initialValue) 273 | 274 | // Update to version 2 with new value 275 | terraformOptions.Vars = map[string]interface{}{ 276 | "ephemeral": true, 277 | "secrets": CreateEphemeralSecretConfig( 278 | secretName, 279 | "updated-value", 280 | 2, // Increment version 281 | ), 282 | } 283 | 284 | // Apply the update 285 | terraform.Apply(t, terraformOptions) 286 | 287 | // Verify updated secret value 288 | updatedValue := ValidateSecretValue(t, awsRegion, secretArn) 289 | assert.Equal(t, "updated-value", updatedValue) 290 | 291 | // Verify state still doesn't contain sensitive data 292 | state := terraform.Show(t, terraformOptions) 293 | stateJSON, err := json.Marshal(state) 294 | require.NoError(t, err) 295 | stateString := string(stateJSON) 296 | 297 | ValidateNoSensitiveDataInState(t, stateString, []string{ 298 | "initial-value", 299 | "updated-value", 300 | }) 301 | } 302 | 303 | // TestEphemeralRotatingSecrets tests rotating secrets in ephemeral mode 304 | func TestEphemeralRotatingSecrets(t *testing.T) { 305 | t.Parallel() 306 | 307 | uniqueID := GenerateTestName("ephemeral-rotation") 308 | awsRegion := GetTestRegion(t) 309 | 310 | // Note: This test requires a Lambda function ARN for rotation 311 | // In a real test environment, you would need to create or reference an actual Lambda function 312 | lambdaArn := fmt.Sprintf("arn:aws:lambda:%s:123456789012:function:test-rotation-function", awsRegion) 313 | 314 | terraformOptions := &terraform.Options{ 315 | TerraformDir: "../", 316 | Vars: map[string]interface{}{ 317 | "ephemeral": true, 318 | "rotate_secrets": map[string]interface{}{ 319 | fmt.Sprintf("ephemeral-rotating-%s", uniqueID): map[string]interface{}{ 320 | "description": "Ephemeral rotating secret", 321 | "secret_string": "rotating-secret-value", 322 | "secret_string_wo_version": 1, 323 | "rotation_lambda_arn": lambdaArn, 324 | "automatically_after_days": 30, 325 | }, 326 | }, 327 | }, 328 | EnvVars: map[string]string{ 329 | "AWS_DEFAULT_REGION": awsRegion, 330 | }, 331 | } 332 | 333 | // This test validates the configuration but may not apply due to Lambda function requirements 334 | terraform.Init(t, terraformOptions) 335 | 336 | // Validate the plan (rotation configuration should be valid) 337 | planOutput := terraform.Plan(t, terraformOptions) 338 | 339 | // Verify that the plan includes rotation configuration 340 | assert.Contains(t, planOutput, "aws_secretsmanager_secret_rotation") 341 | assert.Contains(t, planOutput, lambdaArn) 342 | 343 | // Verify that ephemeral parameters are used 344 | assert.Contains(t, planOutput, "secret_string_wo") 345 | assert.Contains(t, planOutput, "secret_string_wo_version") 346 | } 347 | 348 | // ExtractSecretNameFromArn extracts the secret name from an ARN 349 | func ExtractSecretNameFromArn(arn string) string { 350 | // ARN format: arn:aws:secretsmanager:region:account:secret:name-suffix 351 | parts := strings.Split(arn, ":") 352 | if len(parts) >= 7 { 353 | return parts[6] 354 | } 355 | return arn // fallback to full ARN if parsing fails 356 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [ master, main ] 6 | paths-ignore: 7 | - '**.md' 8 | - 'LICENSE' 9 | - '.gitignore' 10 | - 'docs/**' 11 | - '.github/ISSUE_TEMPLATE/**' 12 | - '.github/PULL_REQUEST_TEMPLATE/**' 13 | pull_request: 14 | branches: [ master, main ] 15 | paths-ignore: 16 | - '**.md' 17 | - 'LICENSE' 18 | - '.gitignore' 19 | - 'docs/**' 20 | - '.github/ISSUE_TEMPLATE/**' 21 | - '.github/PULL_REQUEST_TEMPLATE/**' 22 | 23 | env: 24 | TERRAFORM_VERSION: latest 25 | GO_VERSION: 1.21 26 | ACTIONS_STEP_DEBUG: true 27 | 28 | jobs: 29 | # Validation tests - fast feedback 30 | validate: 31 | name: Validate 32 | runs-on: ubuntu-latest 33 | steps: 34 | - name: Checkout 35 | uses: actions/checkout@v4 36 | 37 | - name: Setup Terraform 38 | uses: hashicorp/setup-terraform@v3 39 | with: 40 | terraform_version: ${{ env.TERRAFORM_VERSION }} 41 | 42 | - name: Terraform Format Check 43 | run: terraform fmt -check -recursive 44 | 45 | - name: Terraform Init 46 | run: terraform init 47 | 48 | - name: Terraform Validate 49 | run: terraform validate 50 | 51 | - name: Validate Examples 52 | run: | 53 | for example in examples/*/; do 54 | echo "Validating $example" 55 | cd "$example" 56 | terraform init 57 | terraform validate 58 | cd ../.. 59 | done 60 | 61 | # Security scanning 62 | security: 63 | name: Security Scan 64 | runs-on: ubuntu-latest 65 | steps: 66 | - name: Checkout 67 | uses: actions/checkout@v4 68 | 69 | - name: Setup Terraform 70 | uses: hashicorp/setup-terraform@v3 71 | with: 72 | terraform_version: ${{ env.TERRAFORM_VERSION }} 73 | 74 | - name: Terraform Init 75 | run: terraform init 76 | 77 | - name: Run tfsec 78 | uses: aquasecurity/tfsec-action@v1.0.3 79 | with: 80 | soft_fail: true 81 | 82 | 83 | # Linting with additional tools 84 | lint: 85 | name: Lint 86 | runs-on: ubuntu-latest 87 | steps: 88 | - name: Checkout 89 | uses: actions/checkout@v4 90 | 91 | - name: Setup Terraform 92 | uses: hashicorp/setup-terraform@v3 93 | with: 94 | terraform_version: ${{ env.TERRAFORM_VERSION }} 95 | 96 | - name: Setup TFLint 97 | uses: terraform-linters/setup-tflint@v4 98 | with: 99 | tflint_version: latest 100 | github_token: ${{ secrets.GITHUB_TOKEN }} 101 | 102 | - name: Init TFLint 103 | run: tflint --init 104 | 105 | - name: Run TFLint 106 | run: tflint --recursive 107 | 108 | 109 | # Unit tests using Terratest 110 | unit-tests: 111 | name: Unit Tests 112 | runs-on: ubuntu-latest 113 | if: github.event_name == 'pull_request' || github.ref == 'refs/heads/master' 114 | strategy: 115 | fail-fast: false 116 | matrix: 117 | test-suite: 118 | - validation 119 | steps: 120 | - name: Checkout 121 | uses: actions/checkout@v4 122 | 123 | - name: Setup Go 124 | uses: actions/setup-go@v5 125 | with: 126 | go-version: ${{ env.GO_VERSION }} 127 | cache: false 128 | 129 | - name: Setup Terraform 130 | uses: hashicorp/setup-terraform@v3 131 | with: 132 | terraform_version: ${{ env.TERRAFORM_VERSION }} 133 | terraform_wrapper: false 134 | 135 | - name: Configure AWS credentials 136 | uses: aws-actions/configure-aws-credentials@v4 137 | with: 138 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} 139 | aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 140 | aws-region: us-east-1 141 | 142 | - name: Cache Go modules 143 | uses: actions/cache@v4 144 | with: 145 | path: | 146 | ~/.cache/go-build 147 | ~/go/pkg/mod 148 | key: ${{ runner.os }}-go-${{ hashFiles('test/go.sum') }} 149 | restore-keys: | 150 | ${{ runner.os }}-go- 151 | 152 | - name: Install dependencies 153 | working-directory: test 154 | run: go mod download 155 | 156 | - name: Run validation tests 157 | if: matrix.test-suite == 'validation' 158 | working-directory: test 159 | run: | 160 | go test -v -timeout 10m -run "TestTerraform.*Validation|TestTerraformFormat|TestTerraformValidate|TestExamplesValidation|TestTerraformPlan|TestVariableValidation" \ 161 | -json > validation-test-results.json 162 | env: 163 | AWS_DEFAULT_REGION: us-east-1 164 | 165 | - name: Run ephemeral tests 166 | if: matrix.test-suite == 'ephemeral' 167 | working-directory: test 168 | run: | 169 | echo "Starting ephemeral tests..." 170 | echo "AWS Region: $AWS_DEFAULT_REGION" 171 | echo "Go version: $(go version)" 172 | echo "Terraform version: $(terraform version)" 173 | 174 | # Run tests sequentially to avoid state conflicts 175 | go test -v -timeout 30m -p=1 -run "TestEphemeral.*" \ 176 | -json > ephemeral-test-results.json 177 | 178 | exit_code=$? 179 | echo "Tests completed with exit code: $exit_code" 180 | 181 | if [ $exit_code -ne 0 ]; then 182 | echo "Test failures detected. Showing recent test output:" 183 | tail -50 ephemeral-test-results.json || echo "No test results file found" 184 | fi 185 | 186 | exit $exit_code 187 | env: 188 | AWS_DEFAULT_REGION: us-east-1 189 | 190 | - name: Debug test artifacts 191 | if: always() 192 | working-directory: test 193 | run: | 194 | echo "=== Test directory contents ===" 195 | ls -la 196 | echo "=== Test result files ===" 197 | ls -la *-test-results.json 2>/dev/null || echo "No test result files found" 198 | echo "=== Terraform state files ===" 199 | ls -la ../*.tfstate* 2>/dev/null || echo "No state files found" 200 | 201 | - name: Upload test results 202 | uses: actions/upload-artifact@v4 203 | if: always() 204 | with: 205 | name: test-results-${{ matrix.test-suite }} 206 | path: test/*-test-results.json 207 | 208 | # Integration tests - only on master or when specifically requested 209 | integration-tests: 210 | name: Integration Tests 211 | runs-on: ubuntu-latest 212 | if: github.ref == 'refs/heads/master' || contains(github.event.pull_request.labels.*.name, 'run-integration-tests') 213 | strategy: 214 | fail-fast: false 215 | matrix: 216 | aws-region: 217 | - us-east-1 218 | - us-west-2 219 | steps: 220 | - name: Checkout 221 | uses: actions/checkout@v4 222 | 223 | - name: Setup Go 224 | uses: actions/setup-go@v5 225 | with: 226 | go-version: ${{ env.GO_VERSION }} 227 | cache: false 228 | 229 | - name: Setup Terraform 230 | uses: hashicorp/setup-terraform@v3 231 | with: 232 | terraform_version: ${{ env.TERRAFORM_VERSION }} 233 | terraform_wrapper: false 234 | 235 | - name: Configure AWS credentials 236 | uses: aws-actions/configure-aws-credentials@v4 237 | with: 238 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} 239 | aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 240 | aws-region: ${{ matrix.aws-region }} 241 | 242 | - name: Cache Go modules 243 | uses: actions/cache@v4 244 | with: 245 | path: | 246 | ~/.cache/go-build 247 | ~/go/pkg/mod 248 | key: ${{ runner.os }}-go-${{ hashFiles('test/go.sum') }} 249 | restore-keys: | 250 | ${{ runner.os }}-go- 251 | 252 | - name: Install dependencies 253 | working-directory: test 254 | run: go mod download 255 | 256 | - name: Run integration tests 257 | working-directory: test 258 | run: | 259 | go test -v -timeout 45m -run "TestTerraformAwsSecretsManager.*" \ 260 | -json > integration-test-results-${{ matrix.aws-region }}.json 261 | env: 262 | AWS_DEFAULT_REGION: ${{ matrix.aws-region }} 263 | 264 | - name: Upload integration test results 265 | uses: actions/upload-artifact@v4 266 | if: always() 267 | with: 268 | name: integration-test-results-${{ matrix.aws-region }} 269 | path: test/integration-test-results-${{ matrix.aws-region }}.json 270 | 271 | # Test result processing and reporting 272 | test-results: 273 | name: Process Test Results 274 | runs-on: ubuntu-latest 275 | needs: [validate, security, lint, unit-tests] 276 | if: always() 277 | steps: 278 | - name: Download test artifacts 279 | uses: actions/download-artifact@v4 280 | with: 281 | path: test-results 282 | 283 | - name: Process test results 284 | run: | 285 | echo "# Test Results Summary" >> $GITHUB_STEP_SUMMARY 286 | echo "" >> $GITHUB_STEP_SUMMARY 287 | 288 | # Count test files 289 | VALIDATION_PASSED=$(find test-results -name "*validation*" -type f | wc -l) 290 | EPHEMERAL_PASSED=$(find test-results -name "*ephemeral*" -type f | wc -l) 291 | INTEGRATION_PASSED=$(find test-results -name "*integration*" -type f | wc -l) 292 | 293 | echo "| Test Suite | Status |" >> $GITHUB_STEP_SUMMARY 294 | echo "|------------|--------|" >> $GITHUB_STEP_SUMMARY 295 | echo "| Validation | ✅ $VALIDATION_PASSED suites completed |" >> $GITHUB_STEP_SUMMARY 296 | echo "| Ephemeral | ✅ $EPHEMERAL_PASSED suites completed |" >> $GITHUB_STEP_SUMMARY 297 | echo "| Integration | ✅ $INTEGRATION_PASSED suites completed |" >> $GITHUB_STEP_SUMMARY 298 | echo "" >> $GITHUB_STEP_SUMMARY 299 | echo "View detailed results in the artifacts section." >> $GITHUB_STEP_SUMMARY 300 | 301 | - name: Check overall test status 302 | run: | 303 | if [ "${{ needs.validate.result }}" != "success" ] || \ 304 | [ "${{ needs.security.result }}" != "success" ] || \ 305 | [ "${{ needs.lint.result }}" != "success" ] || \ 306 | [ "${{ needs.unit-tests.result }}" != "success" ]; then 307 | echo "One or more test suites failed" 308 | exit 1 309 | fi 310 | echo "All test suites passed successfully" 311 | 312 | # Cleanup job to remove test resources 313 | cleanup: 314 | name: Cleanup Test Resources 315 | runs-on: ubuntu-latest 316 | if: always() && (needs.unit-tests.result == 'success' || needs.unit-tests.result == 'failure' || needs.integration-tests.result == 'success' || needs.integration-tests.result == 'failure') 317 | needs: [unit-tests, integration-tests] 318 | steps: 319 | - name: Checkout 320 | uses: actions/checkout@v4 321 | 322 | - name: Setup Go 323 | uses: actions/setup-go@v5 324 | with: 325 | go-version: ${{ env.GO_VERSION }} 326 | cache: false 327 | 328 | - name: Configure AWS credentials 329 | uses: aws-actions/configure-aws-credentials@v4 330 | with: 331 | aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }} 332 | aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 333 | aws-region: us-east-1 334 | 335 | - name: Install dependencies 336 | working-directory: test 337 | run: go mod download 338 | 339 | - name: Cleanup test resources 340 | working-directory: test 341 | run: | 342 | go run -v cleanup/main.go 343 | env: 344 | AWS_DEFAULT_REGION: us-east-1 345 | continue-on-error: true --------------------------------------------------------------------------------