diff --git a/.github/scripts/verify-runtime-secrets.sh b/.github/scripts/verify-runtime-secrets.sh new file mode 100755 index 0000000000..094ee0b92e --- /dev/null +++ b/.github/scripts/verify-runtime-secrets.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + echo "Usage: $0 " >&2 + exit 1 +} + +[[ $# -eq 2 ]] || usage + +environment=$1 +enable_additional_parameters=$2 + +if [[ "$enable_additional_parameters" != "true" && "$enable_additional_parameters" != "false" ]]; then + usage +fi + +check_secret() { + local secret_id=$1 + local secret_metadata + + # Check version metadata without retrieving or printing the secret value. + if ! secret_metadata=$(aws secretsmanager describe-secret \ + --secret-id "$secret_id" \ + --query 'VersionIdsToStages' \ + --output json); then + echo "::error::Unable to describe required secret: ${secret_id}" >&2 + exit 1 + fi + + if ! jq -e 'any(.[]; index("AWSCURRENT") != null)' <<<"$secret_metadata" >/dev/null; then + echo "::error::Secret has no AWSCURRENT version: ${secret_id}" >&2 + exit 1 + fi +} + +secret_names=( + DJANGO_ALGOLIA_WRITE_API_KEY + DJANGO_OPEN_AI_SECRET_KEY + DJANGO_REDIS_PASSWORD + DJANGO_SECRET_KEY + DJANGO_SENTRY_DSN + DJANGO_SLACK_BOT_TOKEN + DJANGO_SLACK_SIGNING_SECRET + GITHUB_TOKEN + NEXTAUTH_SECRET + NEXT_SERVER_GITHUB_CLIENT_SECRET +) + +if [[ "$enable_additional_parameters" == "true" ]]; then + secret_names+=(NEST_GITHUB_APP_PRIVATE_KEY SLACK_BOT_TOKEN_T04T40NHX) +fi + +for secret_name in "${secret_names[@]}"; do + check_secret "/nest/${environment}/${secret_name}" +done + +check_secret "nest-${environment}-db-credentials" + +echo "All required runtime secrets have an AWSCURRENT version." diff --git a/.github/workflows/run-deploy.yaml b/.github/workflows/run-deploy.yaml index 257bc73c8e..43b89d97e3 100644 --- a/.github/workflows/run-deploy.yaml +++ b/.github/workflows/run-deploy.yaml @@ -140,6 +140,7 @@ jobs: }} FRONTEND_IMAGE: ${{ secrets.AWS_ACCOUNT_ID }}.dkr.ecr.${{ vars.AWS_REGION }}.amazonaws.com/nest-${{ inputs.environment }}-frontend:${{ inputs.release_version }} + RUNTIME_SECRETS_MODE: ${{ vars.RUNTIME_SECRETS_MODE || 'prepare' }} TF_IN_AUTOMATION: true TF_INPUT: false permissions: @@ -163,6 +164,23 @@ jobs: role-skip-session-tagging: true role-to-assume: arn:aws:iam::${{ secrets.AWS_ACCOUNT_ID }}:role/${{ inputs.aws_role_name }} + - name: Validate runtime secrets migration mode + run: | + if [[ "$RUNTIME_SECRETS_MODE" != "prepare" && "$RUNTIME_SECRETS_MODE" != "complete" ]]; then + echo "::error::RUNTIME_SECRETS_MODE must be prepare or complete." + exit 1 + fi + + - name: Verify runtime secrets are populated + if: env.RUNTIME_SECRETS_MODE == 'complete' + env: + DEPLOY_ENVIRONMENT: ${{ inputs.environment }} + ENABLE_ADDITIONAL_PARAMETERS: ${{ inputs.enable_additional_parameters }} + run: | + bash .github/scripts/verify-runtime-secrets.sh \ + "$DEPLOY_ENVIRONMENT" \ + "$ENABLE_ADDITIONAL_PARAMETERS" + - name: Login to Amazon ECR uses: aws-actions/amazon-ecr-login@d539f0932e70871a027e9d5a9d8fc38589180a64 # v2.1.6 @@ -220,6 +238,7 @@ jobs: frontend_use_fargate_spot=${{ inputs.frontend_use_fargate_spot }} frontend_image_tag="${{ inputs.release_version }}" project_name="nest" + runtime_secrets_mode="${{ vars.RUNTIME_SECRETS_MODE || 'prepare' }}" tasks_use_fargate_spot=${{ inputs.tasks_use_fargate_spot }} working-directory: infrastructure/live diff --git a/infrastructure/bootstrap/main.tf b/infrastructure/bootstrap/main.tf index 94d6a98b74..86097ff041 100644 --- a/infrastructure/bootstrap/main.tf +++ b/infrastructure/bootstrap/main.tf @@ -307,7 +307,7 @@ data "aws_iam_policy_document" "part_two" { for_each = local.environments statement { - sid = "AppAutoscalingManagement" + sid = "AppAutoscalingMgmt" effect = "Allow" actions = [ "application-autoscaling:DeleteScalingPolicy", @@ -365,7 +365,7 @@ data "aws_iam_policy_document" "part_two" { } statement { - sid = "ELBManagement" + sid = "ELBMgmt" effect = "Allow" actions = [ "elasticloadbalancing:AddTags", @@ -393,7 +393,7 @@ data "aws_iam_policy_document" "part_two" { } statement { - sid = "EventBridgeManagement" + sid = "EventBridgeMgmt" effect = "Allow" actions = [ "events:DeleteRule", @@ -412,7 +412,7 @@ data "aws_iam_policy_document" "part_two" { } statement { - sid = "IAMManagement" + sid = "IAMMgmt" effect = "Allow" actions = [ "iam:AttachRolePolicy", @@ -471,7 +471,7 @@ data "aws_iam_policy_document" "part_two" { } statement { - sid = "KMSManagement" + sid = "KMSMgmt" effect = "Allow" actions = [ "kms:CreateKey", @@ -525,7 +525,7 @@ data "aws_iam_policy_document" "part_two" { } statement { - sid = "S3Management" + sid = "S3Mgmt" effect = "Allow" actions = [ "s3:CreateBucket", @@ -571,7 +571,7 @@ data "aws_iam_policy_document" "part_two" { } statement { - sid = "SecretsManagerManagement" + sid = "SecretsManagerMgmt" effect = "Allow" actions = [ "secretsmanager:CreateSecret", @@ -585,11 +585,14 @@ data "aws_iam_policy_document" "part_two" { "secretsmanager:UntagResource", "secretsmanager:UpdateSecret", ] - resources = ["arn:aws:secretsmanager:${var.aws_region}:${data.aws_caller_identity.current.account_id}:secret:${var.project_name}-${each.key}-*"] + resources = [ + "arn:aws:secretsmanager:${var.aws_region}:${data.aws_caller_identity.current.account_id}:secret:${var.project_name}-${each.key}-*", + "arn:aws:secretsmanager:${var.aws_region}:${data.aws_caller_identity.current.account_id}:secret:/${var.project_name}/${each.key}/*", + ] } statement { - sid = "SSMManagement" + sid = "SSMMgmt" effect = "Allow" actions = [ "ssm:AddTagsToResource", diff --git a/infrastructure/bootstrap/tests/unit.tftest.hcl b/infrastructure/bootstrap/tests/unit.tftest.hcl index 2d1c2096a7..d21d4363e0 100644 --- a/infrastructure/bootstrap/tests/unit.tftest.hcl +++ b/infrastructure/bootstrap/tests/unit.tftest.hcl @@ -57,6 +57,42 @@ run "test_part_two_policy_size_production" { } } +run "test_secrets_manager_namespace_staging" { + command = plan + + assert { + condition = alltrue([ + strcontains( + data.aws_iam_policy_document.part_two["staging"].json, + "arn:aws:secretsmanager:${var.aws_region}:160885282306:secret:${var.project_name}-staging-*", + ), + strcontains( + data.aws_iam_policy_document.part_two["staging"].json, + "arn:aws:secretsmanager:${var.aws_region}:160885282306:secret:/${var.project_name}/staging/*", + ), + ]) + error_message = "The staging Terraform policy must allow management of the staging Secrets Manager namespace." + } +} + +run "test_secrets_manager_namespace_production" { + command = plan + + assert { + condition = alltrue([ + strcontains( + data.aws_iam_policy_document.part_two["production"].json, + "arn:aws:secretsmanager:${var.aws_region}:160885282306:secret:${var.project_name}-production-*", + ), + strcontains( + data.aws_iam_policy_document.part_two["production"].json, + "arn:aws:secretsmanager:${var.aws_region}:160885282306:secret:/${var.project_name}/production/*", + ), + ]) + error_message = "The production Terraform policy must allow management of the production Secrets Manager namespace." + } +} + run "test_minified_json_is_smaller_than_pretty_json" { command = plan diff --git a/infrastructure/live/README.md b/infrastructure/live/README.md index 174b6bf245..735de79060 100644 --- a/infrastructure/live/README.md +++ b/infrastructure/live/README.md @@ -123,6 +123,7 @@ No resources. | [redis\_node\_type](#input\_redis\_node\_type) | The node type for the Redis cache. | `string` | `"cache.t3.micro"` | no | | [redis\_num\_cache\_nodes](#input\_redis\_num\_cache\_nodes) | The number of cache nodes in the Redis cluster. | `number` | `1` | no | | [redis\_port](#input\_redis\_port) | The port for the Redis cache. | `number` | `6379` | no | +| [runtime\_secrets\_mode](#input\_runtime\_secrets\_mode) | Runtime secret migration phase: 'prepare' retains SSM injection, while 'complete' uses Secrets Manager. | `string` | n/a | yes | | [secret\_recovery\_window\_in\_days](#input\_secret\_recovery\_window\_in\_days) | The number of days that Secrets Manager waits before it can delete the secret. Set to 0 to delete immediately. | `number` | `7` | no | | [slack\_bot\_token\_suffix](#input\_slack\_bot\_token\_suffix) | The Suffix for the Slack bot token. | `string` | `"T04T40NHX"` | no | | [tasks\_use\_fargate\_spot](#input\_tasks\_use\_fargate\_spot) | Whether to use Fargate Spot for ECS tasks. | `bool` | `true` | no | diff --git a/infrastructure/live/main.tf b/infrastructure/live/main.tf index d05a0e9068..66a3a4951e 100644 --- a/infrastructure/live/main.tf +++ b/infrastructure/live/main.tf @@ -51,6 +51,7 @@ module "backend" { container_cpu = 1024 container_memory = 2048 container_port = 8000 + container_secrets = module.parameters.django_container_secrets desired_count = var.backend_desired_count enable_auto_scaling = var.backend_enable_auto_scaling environment = var.environment @@ -59,8 +60,8 @@ module "backend" { kms_key_arn = module.kms.key_arn max_count = var.backend_max_count min_count = var.backend_min_count - parameters_arns = module.parameters.django_ssm_parameter_arns project_name = var.project_name + secretsmanager_secret_arns = module.parameters.django_secretsmanager_secret_arns security_group_id = module.security.backend_sg_id service_name = "backend" subnet_ids = var.enable_nat_gateway ? module.networking.private_subnet_ids : module.networking.public_subnet_ids @@ -72,16 +73,18 @@ module "backend" { module "cache" { source = "../modules/cache" - common_tags = local.common_tags - environment = var.environment - kms_key_arn = module.kms.key_arn - project_name = var.project_name - redis_engine_version = var.redis_engine_version - redis_node_type = var.redis_node_type - redis_num_cache_nodes = var.redis_num_cache_nodes - redis_port = var.redis_port - security_group_ids = [module.security.redis_sg_id] - subnet_ids = module.networking.private_subnet_ids + common_tags = local.common_tags + environment = var.environment + kms_key_arn = module.kms.key_arn + project_name = var.project_name + redis_engine_version = var.redis_engine_version + redis_node_type = var.redis_node_type + redis_num_cache_nodes = var.redis_num_cache_nodes + redis_port = var.redis_port + runtime_secrets_mode = var.runtime_secrets_mode + secret_recovery_window_in_days = var.secret_recovery_window_in_days + security_group_ids = [module.security.redis_sg_id] + subnet_ids = module.networking.private_subnet_ids } module "database" { @@ -104,6 +107,7 @@ module "database" { kms_key_arn = module.kms.key_arn project_name = var.project_name proxy_security_group_ids = [module.security.rds_proxy_sg_id] + runtime_secrets_mode = var.runtime_secrets_mode secret_recovery_window_in_days = var.secret_recovery_window_in_days security_group_ids = [module.security.rds_sg_id] } @@ -118,6 +122,7 @@ module "frontend" { aws_region = var.aws_region common_tags = local.common_tags container_port = 3000 + container_secrets = module.parameters.frontend_container_secrets desired_count = var.frontend_desired_count enable_auto_scaling = var.frontend_enable_auto_scaling environment = var.environment @@ -126,10 +131,10 @@ module "frontend" { kms_key_arn = module.kms.key_arn max_count = var.frontend_max_count min_count = var.frontend_min_count - parameters_arns = module.parameters.frontend_ssm_parameter_arns project_name = var.project_name security_group_id = module.security.frontend_sg_id service_name = "frontend" + secretsmanager_secret_arns = module.parameters.frontend_secretsmanager_secret_arns subnet_ids = var.enable_nat_gateway ? module.networking.private_subnet_ids : module.networking.public_subnet_ids target_group_arn = module.alb.frontend_target_group_arn use_fargate_spot = var.frontend_use_fargate_spot @@ -182,27 +187,32 @@ module "networking" { module "parameters" { source = "../modules/parameters" - common_tags = local.common_tags - db_password_arn = module.database.db_password_arn - django_configuration = var.django_configuration - django_allowed_hosts = var.domain_name - django_allowed_origins = "https://${var.domain_name}" - django_aws_static_bucket_name = module.storage.static_s3_bucket_name - django_db_host = module.database.db_proxy_endpoint - django_db_name = var.db_name - django_db_port = var.db_port - django_db_user = var.db_user - django_redis_host = module.cache.redis_primary_endpoint - django_release_version = var.django_release_version - django_settings_module = var.django_settings_module - enable_additional_parameters = var.enable_additional_parameters - environment = var.environment - next_server_csrf_url = "https://${var.domain_name}/csrf/" - next_server_graphql_url = "https://${var.domain_name}/graphql/" - nextauth_url = "https://${var.domain_name}" - project_name = var.project_name - redis_password_arn = module.cache.redis_password_arn - slack_bot_token_suffix = var.slack_bot_token_suffix + common_tags = local.common_tags + db_password_arn = module.database.db_password_arn + db_credentials_secret_arn = module.database.db_credentials_secret_arn + django_configuration = var.django_configuration + django_allowed_hosts = var.domain_name + django_allowed_origins = "https://${var.domain_name}" + django_aws_static_bucket_name = module.storage.static_s3_bucket_name + django_db_host = module.database.db_proxy_endpoint + django_db_name = var.db_name + django_db_port = var.db_port + django_db_user = var.db_user + django_redis_host = module.cache.redis_primary_endpoint + django_release_version = var.django_release_version + django_settings_module = var.django_settings_module + enable_additional_parameters = var.enable_additional_parameters + environment = var.environment + kms_key_arn = module.kms.key_arn + next_server_csrf_url = "https://${var.domain_name}/csrf/" + next_server_graphql_url = "https://${var.domain_name}/graphql/" + nextauth_url = "https://${var.domain_name}" + project_name = var.project_name + redis_password_arn = module.cache.redis_password_arn + redis_password_secret_arn = module.cache.redis_password_secret_arn + runtime_secrets_mode = var.runtime_secrets_mode + secret_recovery_window_in_days = var.secret_recovery_window_in_days + slack_bot_token_suffix = var.slack_bot_token_suffix } module "security" { @@ -235,7 +245,7 @@ module "tasks" { assign_public_ip = local.assign_public_ip aws_region = var.aws_region common_tags = local.common_tags - container_parameters_arns = module.parameters.django_ssm_parameter_arns + container_secrets = module.parameters.django_container_secrets ecr_repository_arn = module.backend.ecr_repository_arn ecr_repository_url = module.backend.ecr_repository_url ecs_sg_id = module.security.tasks_sg_id @@ -246,6 +256,7 @@ module "tasks" { image_tag = var.backend_image_tag kms_key_arn = module.kms.key_arn project_name = var.project_name + secretsmanager_secret_arns = module.parameters.django_secretsmanager_secret_arns subnet_ids = var.enable_nat_gateway ? module.networking.private_subnet_ids : module.networking.public_subnet_ids use_fargate_spot = var.tasks_use_fargate_spot } diff --git a/infrastructure/live/terraform.production.tfvars.example b/infrastructure/live/terraform.production.tfvars.example index d4213e8a8a..8a020404dc 100644 --- a/infrastructure/live/terraform.production.tfvars.example +++ b/infrastructure/live/terraform.production.tfvars.example @@ -25,4 +25,5 @@ frontend_max_count = 6 frontend_min_count = 2 frontend_use_fargate_spot = false project_name = "nest" +runtime_secrets_mode = "prepare" tasks_use_fargate_spot = false diff --git a/infrastructure/live/terraform.staging.tfvars.example b/infrastructure/live/terraform.staging.tfvars.example index 59aeac4d04..8df4d578fd 100644 --- a/infrastructure/live/terraform.staging.tfvars.example +++ b/infrastructure/live/terraform.staging.tfvars.example @@ -23,4 +23,5 @@ frontend_max_count = 3 frontend_min_count = 1 frontend_use_fargate_spot = true project_name = "nest" +runtime_secrets_mode = "prepare" tasks_use_fargate_spot = true diff --git a/infrastructure/live/variables.tf b/infrastructure/live/variables.tf index 7bedd26ed4..345fde4f95 100644 --- a/infrastructure/live/variables.tf +++ b/infrastructure/live/variables.tf @@ -351,6 +351,19 @@ variable "redis_port" { } } +variable "runtime_secrets_mode" { + description = "Runtime secret migration phase: 'prepare' retains SSM injection, while 'complete' uses Secrets Manager." + type = string + + validation { + condition = contains( + ["prepare", "complete"], + var.runtime_secrets_mode, + ) + error_message = "runtime_secrets_mode must be either prepare or complete." + } +} + variable "secret_recovery_window_in_days" { description = "The number of days that Secrets Manager waits before it can delete the secret. Set to 0 to delete immediately." type = number diff --git a/infrastructure/modules/cache/README.md b/infrastructure/modules/cache/README.md index 0c981f8c07..04a189d661 100644 --- a/infrastructure/modules/cache/README.md +++ b/infrastructure/modules/cache/README.md @@ -26,6 +26,8 @@ No modules. | [aws_cloudwatch_log_group.slow_log](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | | [aws_elasticache_replication_group.main](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/elasticache_replication_group) | resource | | [aws_elasticache_subnet_group.main](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/elasticache_subnet_group) | resource | +| [aws_secretsmanager_secret.django_redis_password](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/secretsmanager_secret) | resource | +| [aws_secretsmanager_secret_version.django_redis_password](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/secretsmanager_secret_version) | resource | | [aws_ssm_parameter.django_redis_password](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource | | [random_password.redis_auth_token](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/password) | resource | @@ -44,6 +46,8 @@ No modules. | [redis\_node\_type](#input\_redis\_node\_type) | The node type for the Redis cache. | `string` | n/a | yes | | [redis\_num\_cache\_nodes](#input\_redis\_num\_cache\_nodes) | The number of cache nodes in the Redis cluster. | `number` | n/a | yes | | [redis\_port](#input\_redis\_port) | The port for the Redis cache. | `number` | n/a | yes | +| [runtime\_secrets\_mode](#input\_runtime\_secrets\_mode) | Runtime secret migration phase: 'prepare' retains SSM injection, while 'complete' uses Secrets Manager. | `string` | n/a | yes | +| [secret\_recovery\_window\_in\_days](#input\_secret\_recovery\_window\_in\_days) | The number of days Secrets Manager waits before deleting the Redis secret. | `number` | `7` | no | | [security\_group\_ids](#input\_security\_group\_ids) | A list of security group IDs to associate with the Redis cache. | `list(string)` | n/a | yes | | [snapshot\_retention\_limit](#input\_snapshot\_retention\_limit) | The number of days for which automatic snapshots are retained. | `number` | `5` | no | | [snapshot\_window](#input\_snapshot\_window) | The daily time range (in UTC) during which ElastiCache will begin taking a daily snapshot. | `string` | `"03:00-05:00"` | no | @@ -53,6 +57,7 @@ No modules. | Name | Description | | ---- | ----------- | -| [redis\_password\_arn](#output\_redis\_password\_arn) | The SSM Parameter ARN of password of Redis. | +| [redis\_password\_arn](#output\_redis\_password\_arn) | The legacy SSM parameter ARN for the Redis password. | +| [redis\_password\_secret\_arn](#output\_redis\_password\_secret\_arn) | The Secrets Manager ARN containing the Redis password. | | [redis\_primary\_endpoint](#output\_redis\_primary\_endpoint) | The primary endpoint of the Redis replication group. | diff --git a/infrastructure/modules/cache/main.tf b/infrastructure/modules/cache/main.tf index a84c52f020..8d41ecc34f 100644 --- a/infrastructure/modules/cache/main.tf +++ b/infrastructure/modules/cache/main.tf @@ -86,9 +86,27 @@ resource "aws_elasticache_replication_group" "main" { } resource "aws_ssm_parameter" "django_redis_password" { + count = var.runtime_secrets_mode == "prepare" ? 1 : 0 description = "The password of Redis cache (Required by Django)." name = "/${var.project_name}/${var.environment}/DJANGO_REDIS_PASSWORD" tags = var.common_tags type = "SecureString" value = local.redis_auth_token } +moved { + from = aws_ssm_parameter.django_redis_password + to = aws_ssm_parameter.django_redis_password[0] +} + +resource "aws_secretsmanager_secret" "django_redis_password" { + description = "Redis authentication token used by Django." + kms_key_id = var.kms_key_arn + name = "/${var.project_name}/${var.environment}/DJANGO_REDIS_PASSWORD" + recovery_window_in_days = var.secret_recovery_window_in_days + tags = var.common_tags +} + +resource "aws_secretsmanager_secret_version" "django_redis_password" { + secret_id = aws_secretsmanager_secret.django_redis_password.id + secret_string = local.redis_auth_token +} diff --git a/infrastructure/modules/cache/outputs.tf b/infrastructure/modules/cache/outputs.tf index 5c95bfaff5..4e977ff122 100644 --- a/infrastructure/modules/cache/outputs.tf +++ b/infrastructure/modules/cache/outputs.tf @@ -1,6 +1,11 @@ output "redis_password_arn" { - description = "The SSM Parameter ARN of password of Redis." - value = aws_ssm_parameter.django_redis_password.arn + description = "The legacy SSM parameter ARN for the Redis password." + value = try(aws_ssm_parameter.django_redis_password[0].arn, null) +} + +output "redis_password_secret_arn" { + description = "The Secrets Manager ARN containing the Redis password." + value = aws_secretsmanager_secret.django_redis_password.arn } output "redis_primary_endpoint" { diff --git a/infrastructure/modules/cache/tests/integration.tftest.hcl b/infrastructure/modules/cache/tests/integration.tftest.hcl new file mode 100644 index 0000000000..d7f9b090e1 --- /dev/null +++ b/infrastructure/modules/cache/tests/integration.tftest.hcl @@ -0,0 +1,57 @@ +provider "aws" { + access_key = "test" + region = "us-east-1" + s3_use_path_style = true + secret_key = "test" + skip_credentials_validation = true + skip_metadata_api_check = true + skip_requesting_account_id = true +} + +variables { + common_tags = { Environment = "test", Project = "nest" } + environment = "test" + kms_key_arn = "arn:aws:kms:us-east-1:000000000000:key/12345678-1234-1234-1234-123456789012" + log_retention_in_days = 30 + project_name = "nest" + redis_engine_version = "7.0" + redis_node_type = "cache.t3.micro" + redis_num_cache_nodes = 1 + redis_port = 6379 + runtime_secrets_mode = "prepare" + secret_recovery_window_in_days = 0 + security_group_ids = ["sg-12345678"] + subnet_ids = ["subnet-12345678"] +} + +run "cache_integration_plan" { + command = plan + + assert { + condition = aws_secretsmanager_secret.django_redis_password.name == "/${var.project_name}/${var.environment}/DJANGO_REDIS_PASSWORD" + error_message = "Secrets Manager Redis password secret path format is incorrect." + } + + assert { + condition = aws_secretsmanager_secret.django_redis_password.kms_key_id == var.kms_key_arn + error_message = "Secrets Manager Redis password KMS key ID is incorrect." + } + + assert { + condition = aws_ssm_parameter.django_redis_password[0].name == "/${var.project_name}/${var.environment}/DJANGO_REDIS_PASSWORD" + error_message = "SSM Redis password parameter name format is incorrect in prepare mode." + } +} + +run "cache_complete_mode_plan" { + command = plan + + variables { + runtime_secrets_mode = "complete" + } + + assert { + condition = length(aws_ssm_parameter.django_redis_password) == 0 + error_message = "Complete mode must remove legacy SSM Redis password parameter." + } +} diff --git a/infrastructure/modules/cache/tests/unit.tftest.hcl b/infrastructure/modules/cache/tests/unit.tftest.hcl index bdf2fc3fdb..0caaf2a653 100644 --- a/infrastructure/modules/cache/tests/unit.tftest.hcl +++ b/infrastructure/modules/cache/tests/unit.tftest.hcl @@ -10,6 +10,7 @@ variables { redis_node_type = "cache.t3.micro" redis_num_cache_nodes = 1 redis_port = 6379 + runtime_secrets_mode = "prepare" security_group_ids = ["sg-12345678"] subnet_ids = ["subnet-12345678"] } @@ -32,6 +33,19 @@ run "test_auth_token_length" { } } +run "test_complete_mode_removes_legacy_ssm_parameter" { + command = plan + + variables { + runtime_secrets_mode = "complete" + } + + assert { + condition = length(aws_ssm_parameter.django_redis_password) == 0 + error_message = "Complete mode must remove the legacy Redis SSM parameter." + } +} + run "test_encryption_enabled_at_rest" { command = plan @@ -94,6 +108,19 @@ run "test_log_groups_created" { error_message = "Slow log group must be created with correct retention." } } +run "test_redis_secret_configuration" { + command = plan + + assert { + condition = aws_secretsmanager_secret.django_redis_password.name == "/${var.project_name}/${var.environment}/DJANGO_REDIS_PASSWORD" + error_message = "Redis password secret must use the expected name." + } + + assert { + condition = aws_secretsmanager_secret.django_redis_password.kms_key_id == var.kms_key_arn + error_message = "Redis password secret must use the environment KMS key." + } +} run "test_replication_group_id_format" { command = plan @@ -108,7 +135,7 @@ run "test_ssm_parameter_is_secure_string" { command = plan assert { - condition = aws_ssm_parameter.django_redis_password.type == "SecureString" + condition = aws_ssm_parameter.django_redis_password[0].type == "SecureString" error_message = "Redis password must be stored as SecureString." } } @@ -117,7 +144,7 @@ run "test_ssm_parameter_path_format" { command = plan assert { - condition = aws_ssm_parameter.django_redis_password.name == "/${var.project_name}/${var.environment}/DJANGO_REDIS_PASSWORD" + condition = aws_ssm_parameter.django_redis_password[0].name == "/${var.project_name}/${var.environment}/DJANGO_REDIS_PASSWORD" error_message = "SSM parameter must follow path: /{project}/{environment}/DJANGO_REDIS_PASSWORD." } } diff --git a/infrastructure/modules/cache/variables.tf b/infrastructure/modules/cache/variables.tf index fff23e86a0..01a3ccb704 100644 --- a/infrastructure/modules/cache/variables.tf +++ b/infrastructure/modules/cache/variables.tf @@ -72,6 +72,36 @@ variable "redis_port" { } } +variable "runtime_secrets_mode" { + description = "Runtime secret migration phase: 'prepare' retains SSM injection, while 'complete' uses Secrets Manager." + type = string + + validation { + condition = contains( + ["prepare", "complete"], + var.runtime_secrets_mode, + ) + error_message = "runtime_secrets_mode must be either prepare or complete." + } +} + +variable "secret_recovery_window_in_days" { + description = "The number of days Secrets Manager waits before deleting the Redis secret." + type = number + default = 7 + + validation { + condition = ( + var.secret_recovery_window_in_days == 0 || + ( + var.secret_recovery_window_in_days >= 7 && + var.secret_recovery_window_in_days <= 30 + ) + ) + error_message = "secret_recovery_window_in_days must be 0 or between 7 and 30." + } +} + variable "security_group_ids" { description = "A list of security group IDs to associate with the Redis cache." type = list(string) diff --git a/infrastructure/modules/database/README.md b/infrastructure/modules/database/README.md index 440704c706..12cbdba830 100644 --- a/infrastructure/modules/database/README.md +++ b/infrastructure/modules/database/README.md @@ -58,6 +58,7 @@ No modules. | [kms\_key\_arn](#input\_kms\_key\_arn) | The ARN of the KMS key. | `string` | n/a | yes | | [project\_name](#input\_project\_name) | The name of the project. | `string` | n/a | yes | | [proxy\_security\_group\_ids](#input\_proxy\_security\_group\_ids) | A list of security group IDs to associate with the RDS proxy. | `list(string)` | `[]` | no | +| [runtime\_secrets\_mode](#input\_runtime\_secrets\_mode) | Runtime secret migration phase: 'prepare' retains SSM injection, while 'complete' uses Secrets Manager. | `string` | n/a | yes | | [secret\_recovery\_window\_in\_days](#input\_secret\_recovery\_window\_in\_days) | The number of days that Secrets Manager waits before it can delete the secret. Set to 0 to delete immediately. | `number` | `7` | no | | [security\_group\_ids](#input\_security\_group\_ids) | A list of security group IDs to associate with the RDS database. | `list(string)` | n/a | yes | @@ -65,6 +66,7 @@ No modules. | Name | Description | | ---- | ----------- | -| [db\_password\_arn](#output\_db\_password\_arn) | The SSM Parameter ARN of password of the RDS database. | +| [db\_credentials\_secret\_arn](#output\_db\_credentials\_secret\_arn) | The Secrets Manager ARN containing the database credentials. | +| [db\_password\_arn](#output\_db\_password\_arn) | The legacy SSM parameter ARN for the database password. | | [db\_proxy\_endpoint](#output\_db\_proxy\_endpoint) | The endpoint of the RDS proxy. | diff --git a/infrastructure/modules/database/main.tf b/infrastructure/modules/database/main.tf index ac31d8b0f6..9056adb761 100644 --- a/infrastructure/modules/database/main.tf +++ b/infrastructure/modules/database/main.tf @@ -80,12 +80,17 @@ resource "aws_secretsmanager_secret_version" "db_credentials" { } resource "aws_ssm_parameter" "django_db_password" { + count = var.runtime_secrets_mode == "prepare" ? 1 : 0 description = "Database password generated by Terraform (Required by Django)." name = "/${var.project_name}/${var.environment}/DJANGO_DB_PASSWORD" tags = var.common_tags type = "SecureString" value = local.db_password } +moved { + from = aws_ssm_parameter.django_db_password + to = aws_ssm_parameter.django_db_password[0] +} resource "aws_iam_role" "rds_proxy" { count = var.enable_rds_proxy ? 1 : 0 diff --git a/infrastructure/modules/database/outputs.tf b/infrastructure/modules/database/outputs.tf index 46d3b02934..aa424d884d 100644 --- a/infrastructure/modules/database/outputs.tf +++ b/infrastructure/modules/database/outputs.tf @@ -1,6 +1,11 @@ output "db_password_arn" { - description = "The SSM Parameter ARN of password of the RDS database." - value = aws_ssm_parameter.django_db_password.arn + description = "The legacy SSM parameter ARN for the database password." + value = try(aws_ssm_parameter.django_db_password[0].arn, null) +} + +output "db_credentials_secret_arn" { + description = "The Secrets Manager ARN containing the database credentials." + value = aws_secretsmanager_secret.db_credentials.arn } output "db_proxy_endpoint" { diff --git a/infrastructure/modules/database/tests/integration.tftest.hcl b/infrastructure/modules/database/tests/integration.tftest.hcl new file mode 100644 index 0000000000..7850d89e65 --- /dev/null +++ b/infrastructure/modules/database/tests/integration.tftest.hcl @@ -0,0 +1,58 @@ +provider "aws" { + access_key = "test" + region = "us-east-1" + s3_use_path_style = true + secret_key = "test" + skip_credentials_validation = true + skip_metadata_api_check = true + skip_requesting_account_id = true +} + +variables { + common_tags = { Environment = "test", Project = "nest" } + db_allocated_storage = 20 + db_engine_version = "16.13" + db_instance_class = "db.t3.micro" + db_name = "nest_db" + db_subnet_ids = ["subnet-12345678"] + db_user = "nest_user" + enable_rds_proxy = false + environment = "test" + kms_key_arn = "arn:aws:kms:us-east-1:000000000000:key/12345678-1234-1234-1234-123456789012" + project_name = "nest" + runtime_secrets_mode = "prepare" + secret_recovery_window_in_days = 0 + security_group_ids = ["sg-12345678"] +} + +run "database_integration_plan" { + command = plan + + assert { + condition = aws_secretsmanager_secret.db_credentials.kms_key_id == var.kms_key_arn + error_message = "Database credentials secret KMS key ID is incorrect." + } + + assert { + condition = aws_secretsmanager_secret.db_credentials.name == "${var.project_name}-${var.environment}-db-credentials" + error_message = "Database credentials secret name format is incorrect." + } + + assert { + condition = aws_ssm_parameter.django_db_password[0].name == "/${var.project_name}/${var.environment}/DJANGO_DB_PASSWORD" + error_message = "SSM database password parameter name format is incorrect in prepare mode." + } +} + +run "database_complete_mode_plan" { + command = plan + + variables { + runtime_secrets_mode = "complete" + } + + assert { + condition = length(aws_ssm_parameter.django_db_password) == 0 + error_message = "Complete mode must remove legacy SSM database password parameter." + } +} diff --git a/infrastructure/modules/database/tests/unit.tftest.hcl b/infrastructure/modules/database/tests/unit.tftest.hcl index 1e50743fa3..48d10682f1 100644 --- a/infrastructure/modules/database/tests/unit.tftest.hcl +++ b/infrastructure/modules/database/tests/unit.tftest.hcl @@ -12,9 +12,43 @@ variables { environment = "test" kms_key_arn = "arn:aws:kms:us-east-2:123456789012:key/12345678-1234-1234-1234-123456789012" project_name = "nest" + runtime_secrets_mode = "prepare" security_group_ids = ["sg-12345678"] } + +run "test_complete_mode_removes_legacy_ssm_parameter" { + command = plan + + variables { + runtime_secrets_mode = "complete" + } + + assert { + condition = length(aws_ssm_parameter.django_db_password) == 0 + error_message = "Complete mode must remove the legacy database password parameter." + } + + assert { + condition = output.db_password_arn == null + error_message = "Complete mode must not expose a legacy SSM database password ARN." + } +} + +run "test_database_credentials_secret" { + command = plan + + assert { + condition = aws_secretsmanager_secret.db_credentials.kms_key_id == var.kms_key_arn + error_message = "Database credentials must use the environment KMS key." + } + + assert { + condition = aws_secretsmanager_secret.db_credentials.name == "${var.project_name}-${var.environment}-db-credentials" + error_message = "Database credentials secret must use the expected name." + } +} + run "test_database_not_publicly_accessible" { command = plan @@ -137,7 +171,7 @@ run "test_ssm_parameter_is_secure_string" { command = plan assert { - condition = aws_ssm_parameter.django_db_password.type == "SecureString" + condition = aws_ssm_parameter.django_db_password[0].type == "SecureString" error_message = "Database password must be stored as SecureString." } } @@ -146,7 +180,7 @@ run "test_ssm_parameter_path_format" { command = plan assert { - condition = aws_ssm_parameter.django_db_password.name == "/${var.project_name}/${var.environment}/DJANGO_DB_PASSWORD" + condition = aws_ssm_parameter.django_db_password[0].name == "/${var.project_name}/${var.environment}/DJANGO_DB_PASSWORD" error_message = "SSM parameter must follow path: /{project}/{environment}/DJANGO_DB_PASSWORD." } } diff --git a/infrastructure/modules/database/variables.tf b/infrastructure/modules/database/variables.tf index ec2b08581a..1588ade6a0 100644 --- a/infrastructure/modules/database/variables.tf +++ b/infrastructure/modules/database/variables.tf @@ -135,6 +135,19 @@ variable "proxy_security_group_ids" { default = [] } +variable "runtime_secrets_mode" { + description = "Runtime secret migration phase: 'prepare' retains SSM injection, while 'complete' uses Secrets Manager." + type = string + + validation { + condition = contains( + ["prepare", "complete"], + var.runtime_secrets_mode, + ) + error_message = "runtime_secrets_mode must be either prepare or complete." + } +} + variable "secret_recovery_window_in_days" { description = "The number of days that Secrets Manager waits before it can delete the secret. Set to 0 to delete immediately." type = number diff --git a/infrastructure/modules/parameters/README.md b/infrastructure/modules/parameters/README.md index 78a4c95945..1c5950a437 100644 --- a/infrastructure/modules/parameters/README.md +++ b/infrastructure/modules/parameters/README.md @@ -22,6 +22,11 @@ No modules. | Name | Type | | ---- | ---- | +| [aws_secretsmanager_secret.django_secret_key](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/secretsmanager_secret) | resource | +| [aws_secretsmanager_secret.external_runtime](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/secretsmanager_secret) | resource | +| [aws_secretsmanager_secret.nextauth_secret](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/secretsmanager_secret) | resource | +| [aws_secretsmanager_secret_version.django_secret_key](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/secretsmanager_secret_version) | resource | +| [aws_secretsmanager_secret_version.nextauth_secret](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/secretsmanager_secret_version) | resource | | [aws_ssm_parameter.django_algolia_application_id](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource | | [aws_ssm_parameter.django_algolia_write_api_key](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource | | [aws_ssm_parameter.django_allowed_hosts](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/ssm_parameter) | resource | @@ -61,6 +66,7 @@ No modules. | Name | Description | Type | Default | Required | | ---- | ----------- | ---- | ------- | :------: | | [common\_tags](#input\_common\_tags) | A map of common tags to apply to all resources. | `map(string)` | `{}` | no | +| [db\_credentials\_secret\_arn](#input\_db\_credentials\_secret\_arn) | The Secrets Manager ARN containing the database credentials. | `string` | n/a | yes | | [db\_password\_arn](#input\_db\_password\_arn) | The SSM Parameter ARN of password of the database. | `string` | n/a | yes | | [django\_allowed\_hosts](#input\_django\_allowed\_hosts) | Django allowed hosts - hostname only, no protocol (e.g., nest.owasp.dev). | `string` | n/a | yes | | [django\_allowed\_origins](#input\_django\_allowed\_origins) | The Django allowed CORS origins (comma-separated URLs with protocol). | `string` | n/a | yes | @@ -76,17 +82,23 @@ No modules. | [django\_settings\_module](#input\_django\_settings\_module) | The location of the Django settings module to use (e.g., settings.staging, settings.production). | `string` | n/a | yes | | [enable\_additional\_parameters](#input\_enable\_additional\_parameters) | Whether to create additional parameters (e.g. for production). | `bool` | `false` | no | | [environment](#input\_environment) | The environment (e.g., staging, production). | `string` | n/a | yes | +| [kms\_key\_arn](#input\_kms\_key\_arn) | The KMS key ARN used to encrypt runtime secrets | `string` | n/a | yes | | [next\_server\_csrf\_url](#input\_next\_server\_csrf\_url) | The server-side CSRF URL for Next.js SSR (e.g., https://nest.owasp.dev/csrf/). | `string` | n/a | yes | | [next\_server\_graphql\_url](#input\_next\_server\_graphql\_url) | The server-side GraphQL URL for Next.js SSR (e.g., https://nest.owasp.dev/graphql/). | `string` | n/a | yes | | [nextauth\_url](#input\_nextauth\_url) | The NextAuth base URL (frontend URL with protocol). | `string` | n/a | yes | | [project\_name](#input\_project\_name) | The name of the project. | `string` | n/a | yes | | [redis\_password\_arn](#input\_redis\_password\_arn) | The SSM Parameter ARN of password of the Redis cache. | `string` | n/a | yes | +| [redis\_password\_secret\_arn](#input\_redis\_password\_secret\_arn) | The Secrets Manager ARN containing the Redis password. | `string` | n/a | yes | +| [runtime\_secrets\_mode](#input\_runtime\_secrets\_mode) | Runtime secret migration phase: 'prepare' retains SSM injection, while 'complete' uses Secrets Manager. | `string` | n/a | yes | +| [secret\_recovery\_window\_in\_days](#input\_secret\_recovery\_window\_in\_days) | The number of days Secrets Manager waits before deleting a secret. | `number` | `7` | no | | [slack\_bot\_token\_suffix](#input\_slack\_bot\_token\_suffix) | The Suffix for the Slack bot token. | `string` | n/a | yes | ## Outputs | Name | Description | | ---- | ----------- | -| [django\_ssm\_parameter\_arns](#output\_django\_ssm\_parameter\_arns) | Map of environment variable names to the ARNs of all SSM parameters (Required by Django). | -| [frontend\_ssm\_parameter\_arns](#output\_frontend\_ssm\_parameter\_arns) | Map of frontend environment variable names to the ARNs of all SSM parameters. | +| [django\_container\_secrets](#output\_django\_container\_secrets) | Django environment variables mapped to ECS valueFrom references. | +| [django\_secretsmanager\_secret\_arns](#output\_django\_secretsmanager\_secret\_arns) | Bare Secrets Manager ARNs required by Django ECS execution roles. | +| [frontend\_container\_secrets](#output\_frontend\_container\_secrets) | Frontend environment variables mapped to ECS valueFrom references. | +| [frontend\_secretsmanager\_secret\_arns](#output\_frontend\_secretsmanager\_secret\_arns) | Bare Secrets Manager ARNs required by the frontend ECS execution role. | diff --git a/infrastructure/modules/parameters/main.tf b/infrastructure/modules/parameters/main.tf index 837aa5c850..f92e467637 100644 --- a/infrastructure/modules/parameters/main.tf +++ b/infrastructure/modules/parameters/main.tf @@ -13,11 +13,38 @@ terraform { } } +locals { + external_runtime_secrets = merge({ + DJANGO_ALGOLIA_WRITE_API_KEY = "Algolia write API key." + DJANGO_OPEN_AI_SECRET_KEY = "OpenAI API key." + DJANGO_SENTRY_DSN = "Django Sentry DSN." + DJANGO_SLACK_BOT_TOKEN = "Slack bot token." + DJANGO_SLACK_SIGNING_SECRET = "Slack signing secret." + GITHUB_TOKEN = "GitHub API token." + NEXT_SERVER_GITHUB_CLIENT_SECRET = "GitHub OAuth client secret." + }, + var.enable_additional_parameters ? { + NEST_GITHUB_APP_PRIVATE_KEY = "GitHub App private key." + "SLACK_BOT_TOKEN_${var.slack_bot_token_suffix}" = "Slack workspace bot token." + } : {} + ) +} + +resource "aws_secretsmanager_secret" "external_runtime" { + for_each = local.external_runtime_secrets + + description = each.value + kms_key_id = var.kms_key_arn + name = "/${var.project_name}/${var.environment}/${each.key}" + recovery_window_in_days = var.secret_recovery_window_in_days + tags = var.common_tags +} + resource "aws_ssm_parameter" "django_algolia_application_id" { description = "Algolia Application ID." name = "/${var.project_name}/${var.environment}/DJANGO_ALGOLIA_APPLICATION_ID" tags = var.common_tags - type = "SecureString" + type = "String" value = "to-be-set-in-aws-console" lifecycle { @@ -26,6 +53,7 @@ resource "aws_ssm_parameter" "django_algolia_application_id" { } resource "aws_ssm_parameter" "django_algolia_write_api_key" { + count = var.runtime_secrets_mode == "prepare" ? 1 : 0 description = "Algolia Write API Key." name = "/${var.project_name}/${var.environment}/DJANGO_ALGOLIA_WRITE_API_KEY" tags = var.common_tags @@ -148,6 +176,7 @@ resource "aws_ssm_parameter" "django_release_version" { } resource "aws_ssm_parameter" "django_open_ai_secret_key" { + count = var.runtime_secrets_mode == "prepare" ? 1 : 0 description = "OpenAI Secret Key." name = "/${var.project_name}/${var.environment}/DJANGO_OPEN_AI_SECRET_KEY" tags = var.common_tags @@ -176,6 +205,7 @@ resource "aws_ssm_parameter" "django_redis_use_tls" { } resource "aws_ssm_parameter" "django_secret_key" { + count = var.runtime_secrets_mode == "prepare" ? 1 : 0 description = "Django Secret Key generated by Terraform." name = "/${var.project_name}/${var.environment}/DJANGO_SECRET_KEY" tags = var.common_tags @@ -196,6 +226,7 @@ resource "aws_ssm_parameter" "django_settings_module" { } resource "aws_ssm_parameter" "django_sentry_dsn" { + count = var.runtime_secrets_mode == "prepare" ? 1 : 0 description = "The DSN for the Sentry project." name = "/${var.project_name}/${var.environment}/DJANGO_SENTRY_DSN" tags = var.common_tags @@ -207,8 +238,34 @@ resource "aws_ssm_parameter" "django_sentry_dsn" { } } +resource "aws_secretsmanager_secret" "django_secret_key" { + description = "Django secret key generated by Terraform." + kms_key_id = var.kms_key_arn + name = "/${var.project_name}/${var.environment}/DJANGO_SECRET_KEY" + recovery_window_in_days = var.secret_recovery_window_in_days + tags = var.common_tags +} + +resource "aws_secretsmanager_secret_version" "django_secret_key" { + secret_id = aws_secretsmanager_secret.django_secret_key.id + secret_string = random_string.django_secret_key.result +} + +resource "aws_secretsmanager_secret" "nextauth_secret" { + description = "NextAuth secret generated by Terraform." + kms_key_id = var.kms_key_arn + name = "/${var.project_name}/${var.environment}/NEXTAUTH_SECRET" + recovery_window_in_days = var.secret_recovery_window_in_days + tags = var.common_tags +} + +resource "aws_secretsmanager_secret_version" "nextauth_secret" { + secret_id = aws_secretsmanager_secret.nextauth_secret.id + secret_string = random_string.nextauth_secret.result +} resource "aws_ssm_parameter" "django_slack_bot_token" { + count = var.runtime_secrets_mode == "prepare" ? 1 : 0 description = "The bot token for the Slack integration." name = "/${var.project_name}/${var.environment}/DJANGO_SLACK_BOT_TOKEN" tags = var.common_tags @@ -221,6 +278,7 @@ resource "aws_ssm_parameter" "django_slack_bot_token" { } resource "aws_ssm_parameter" "django_slack_signing_secret" { + count = var.runtime_secrets_mode == "prepare" ? 1 : 0 description = "The signing secret for the Slack integration." name = "/${var.project_name}/${var.environment}/DJANGO_SLACK_SIGNING_SECRET" tags = var.common_tags @@ -233,6 +291,7 @@ resource "aws_ssm_parameter" "django_slack_signing_secret" { } resource "aws_ssm_parameter" "github_token" { + count = var.runtime_secrets_mode == "prepare" ? 1 : 0 description = "GitHub Personal Access Token for GitHub API authentication." name = "/${var.project_name}/${var.environment}/GITHUB_TOKEN" tags = var.common_tags @@ -245,7 +304,7 @@ resource "aws_ssm_parameter" "github_token" { } resource "aws_ssm_parameter" "nest_github_app_private_key" { - count = var.enable_additional_parameters ? 1 : 0 + count = var.enable_additional_parameters && var.runtime_secrets_mode == "prepare" ? 1 : 0 description = "GitHub App private key." name = "/${var.project_name}/${var.environment}/NEST_GITHUB_APP_PRIVATE_KEY" tags = var.common_tags @@ -290,6 +349,7 @@ resource "aws_ssm_parameter" "next_server_github_client_id" { } resource "aws_ssm_parameter" "next_server_github_client_secret" { + count = var.runtime_secrets_mode == "prepare" ? 1 : 0 description = "The GitHub OAuth client secret for NextAuth." name = "/${var.project_name}/${var.environment}/NEXT_SERVER_GITHUB_CLIENT_SECRET" tags = var.common_tags @@ -310,6 +370,7 @@ resource "aws_ssm_parameter" "next_server_graphql_url" { } resource "aws_ssm_parameter" "nextauth_secret" { + count = var.runtime_secrets_mode == "prepare" ? 1 : 0 description = "NextAuth secret key generated by Terraform." name = "/${var.project_name}/${var.environment}/NEXTAUTH_SECRET" tags = var.common_tags @@ -330,7 +391,7 @@ resource "aws_ssm_parameter" "nextauth_url" { } resource "aws_ssm_parameter" "slack_bot_token" { - count = var.enable_additional_parameters ? 1 : 0 + count = var.enable_additional_parameters && var.runtime_secrets_mode == "prepare" ? 1 : 0 description = "Slack bot token." name = "/${var.project_name}/${var.environment}/SLACK_BOT_TOKEN_${var.slack_bot_token_suffix}" tags = var.common_tags @@ -352,3 +413,48 @@ resource "random_string" "nextauth_secret" { length = 32 special = true } + +moved { + from = aws_ssm_parameter.django_algolia_write_api_key + to = aws_ssm_parameter.django_algolia_write_api_key[0] +} + +moved { + from = aws_ssm_parameter.django_open_ai_secret_key + to = aws_ssm_parameter.django_open_ai_secret_key[0] +} + +moved { + from = aws_ssm_parameter.django_secret_key + to = aws_ssm_parameter.django_secret_key[0] +} + +moved { + from = aws_ssm_parameter.django_sentry_dsn + to = aws_ssm_parameter.django_sentry_dsn[0] +} + +moved { + from = aws_ssm_parameter.django_slack_bot_token + to = aws_ssm_parameter.django_slack_bot_token[0] +} + +moved { + from = aws_ssm_parameter.django_slack_signing_secret + to = aws_ssm_parameter.django_slack_signing_secret[0] +} + +moved { + from = aws_ssm_parameter.github_token + to = aws_ssm_parameter.github_token[0] +} + +moved { + from = aws_ssm_parameter.next_server_github_client_secret + to = aws_ssm_parameter.next_server_github_client_secret[0] +} + +moved { + from = aws_ssm_parameter.nextauth_secret + to = aws_ssm_parameter.nextauth_secret[0] +} diff --git a/infrastructure/modules/parameters/outputs.tf b/infrastructure/modules/parameters/outputs.tf index 6281e97582..a8ed6e1f32 100644 --- a/infrastructure/modules/parameters/outputs.tf +++ b/infrastructure/modules/parameters/outputs.tf @@ -1,49 +1,107 @@ -output "django_ssm_parameter_arns" { - description = "Map of environment variable names to the ARNs of all SSM parameters (Required by Django)." +output "django_container_secrets" { + description = "Django environment variables mapped to ECS valueFrom references." sensitive = true + value = merge({ "DJANGO_ALGOLIA_APPLICATION_ID" = aws_ssm_parameter.django_algolia_application_id.arn - "DJANGO_ALGOLIA_WRITE_API_KEY" = aws_ssm_parameter.django_algolia_write_api_key.arn "DJANGO_ALLOWED_HOSTS" = aws_ssm_parameter.django_allowed_hosts.arn "DJANGO_ALLOWED_ORIGINS" = aws_ssm_parameter.django_allowed_origins.arn "DJANGO_AWS_STORAGE_BUCKET_NAME" = aws_ssm_parameter.django_aws_storage_bucket_name.arn "DJANGO_CONFIGURATION" = aws_ssm_parameter.django_configuration.arn "DJANGO_DB_HOST" = aws_ssm_parameter.django_db_host.arn "DJANGO_DB_NAME" = aws_ssm_parameter.django_db_name.arn - "DJANGO_DB_PASSWORD" = var.db_password_arn "DJANGO_DB_PORT" = aws_ssm_parameter.django_db_port.arn "DJANGO_DB_USER" = aws_ssm_parameter.django_db_user.arn - "DJANGO_OPEN_AI_SECRET_KEY" = aws_ssm_parameter.django_open_ai_secret_key.arn "DJANGO_REDIS_HOST" = aws_ssm_parameter.django_redis_host.arn - "DJANGO_REDIS_PASSWORD" = var.redis_password_arn "DJANGO_REDIS_USE_TLS" = aws_ssm_parameter.django_redis_use_tls.arn "DJANGO_RELEASE_VERSION" = aws_ssm_parameter.django_release_version.arn - "DJANGO_SECRET_KEY" = aws_ssm_parameter.django_secret_key.arn - "DJANGO_SENTRY_DSN" = aws_ssm_parameter.django_sentry_dsn.arn "DJANGO_SETTINGS_MODULE" = aws_ssm_parameter.django_settings_module.arn - "DJANGO_SLACK_BOT_TOKEN" = aws_ssm_parameter.django_slack_bot_token.arn - "DJANGO_SLACK_SIGNING_SECRET" = aws_ssm_parameter.django_slack_signing_secret.arn - "GITHUB_TOKEN" = aws_ssm_parameter.github_token.arn }, var.enable_additional_parameters ? { - "DJANGO_GITHUB_APP_ID" = aws_ssm_parameter.django_github_app_id[0].arn - "DJANGO_GITHUB_APP_INSTALLATION_ID" = aws_ssm_parameter.django_github_app_installation_id[0].arn - "NEST_GITHUB_APP_PRIVATE_KEY" = aws_ssm_parameter.nest_github_app_private_key[0].arn - "SLACK_BOT_TOKEN_${var.slack_bot_token_suffix}" = aws_ssm_parameter.slack_bot_token[0].arn - } : {} + "DJANGO_GITHUB_APP_ID" = aws_ssm_parameter.django_github_app_id[0].arn + "DJANGO_GITHUB_APP_INSTALLATION_ID" = aws_ssm_parameter.django_github_app_installation_id[0].arn + } : {}, + var.runtime_secrets_mode == "prepare" ? merge( + { + "DJANGO_ALGOLIA_WRITE_API_KEY" = aws_ssm_parameter.django_algolia_write_api_key[0].arn + "DJANGO_DB_PASSWORD" = var.db_password_arn + "DJANGO_OPEN_AI_SECRET_KEY" = aws_ssm_parameter.django_open_ai_secret_key[0].arn + "DJANGO_REDIS_PASSWORD" = var.redis_password_arn + "DJANGO_SECRET_KEY" = aws_ssm_parameter.django_secret_key[0].arn + "DJANGO_SENTRY_DSN" = aws_ssm_parameter.django_sentry_dsn[0].arn + "DJANGO_SLACK_BOT_TOKEN" = aws_ssm_parameter.django_slack_bot_token[0].arn + "DJANGO_SLACK_SIGNING_SECRET" = aws_ssm_parameter.django_slack_signing_secret[0].arn + "GITHUB_TOKEN" = aws_ssm_parameter.github_token[0].arn + }, + var.enable_additional_parameters ? { + "NEST_GITHUB_APP_PRIVATE_KEY" = aws_ssm_parameter.nest_github_app_private_key[0].arn + "SLACK_BOT_TOKEN_${var.slack_bot_token_suffix}" = aws_ssm_parameter.slack_bot_token[0].arn + } : {} + ) : merge( + { + "DJANGO_ALGOLIA_WRITE_API_KEY" = aws_secretsmanager_secret.external_runtime["DJANGO_ALGOLIA_WRITE_API_KEY"].arn + "DJANGO_DB_PASSWORD" = "${var.db_credentials_secret_arn}:password::" + "DJANGO_OPEN_AI_SECRET_KEY" = aws_secretsmanager_secret.external_runtime["DJANGO_OPEN_AI_SECRET_KEY"].arn + "DJANGO_REDIS_PASSWORD" = var.redis_password_secret_arn + "DJANGO_SECRET_KEY" = aws_secretsmanager_secret.django_secret_key.arn + "DJANGO_SENTRY_DSN" = aws_secretsmanager_secret.external_runtime["DJANGO_SENTRY_DSN"].arn + "DJANGO_SLACK_BOT_TOKEN" = aws_secretsmanager_secret.external_runtime["DJANGO_SLACK_BOT_TOKEN"].arn + "DJANGO_SLACK_SIGNING_SECRET" = aws_secretsmanager_secret.external_runtime["DJANGO_SLACK_SIGNING_SECRET"].arn + "GITHUB_TOKEN" = aws_secretsmanager_secret.external_runtime["GITHUB_TOKEN"].arn + }, + var.enable_additional_parameters ? { + "NEST_GITHUB_APP_PRIVATE_KEY" = aws_secretsmanager_secret.external_runtime["NEST_GITHUB_APP_PRIVATE_KEY"].arn + "SLACK_BOT_TOKEN_${var.slack_bot_token_suffix}" = aws_secretsmanager_secret.external_runtime["SLACK_BOT_TOKEN_${var.slack_bot_token_suffix}"].arn + } : {} + ) + ) +} + +output "frontend_container_secrets" { + description = "Frontend environment variables mapped to ECS valueFrom references." + sensitive = true + + value = merge( + { + "NEXT_SERVER_CSRF_URL" = aws_ssm_parameter.next_server_csrf_url.arn + "NEXT_SERVER_DISABLE_SSR" = aws_ssm_parameter.next_server_disable_ssr.arn + "NEXT_SERVER_GITHUB_CLIENT_ID" = aws_ssm_parameter.next_server_github_client_id.arn + "NEXT_SERVER_GRAPHQL_URL" = aws_ssm_parameter.next_server_graphql_url.arn + "NEXTAUTH_URL" = aws_ssm_parameter.nextauth_url.arn + }, + var.runtime_secrets_mode == "prepare" ? { + "NEXT_SERVER_GITHUB_CLIENT_SECRET" = aws_ssm_parameter.next_server_github_client_secret[0].arn + "NEXTAUTH_SECRET" = aws_ssm_parameter.nextauth_secret[0].arn + } : { + "NEXT_SERVER_GITHUB_CLIENT_SECRET" = aws_secretsmanager_secret.external_runtime["NEXT_SERVER_GITHUB_CLIENT_SECRET"].arn + "NEXTAUTH_SECRET" = aws_secretsmanager_secret.nextauth_secret.arn + } ) } -output "frontend_ssm_parameter_arns" { - description = "Map of frontend environment variable names to the ARNs of all SSM parameters." +output "django_secretsmanager_secret_arns" { + description = "Bare Secrets Manager ARNs required by Django ECS execution roles." + sensitive = true + + value = var.runtime_secrets_mode == "complete" ? concat( + [ + for name, secret in aws_secretsmanager_secret.external_runtime : secret.arn + if name != "NEXT_SERVER_GITHUB_CLIENT_SECRET" + ], + [ + aws_secretsmanager_secret.django_secret_key.arn, + var.db_credentials_secret_arn, + var.redis_password_secret_arn, + ] + ) : [] +} + +output "frontend_secretsmanager_secret_arns" { + description = "Bare Secrets Manager ARNs required by the frontend ECS execution role." sensitive = true - value = { - "NEXT_SERVER_CSRF_URL" = aws_ssm_parameter.next_server_csrf_url.arn - "NEXT_SERVER_DISABLE_SSR" = aws_ssm_parameter.next_server_disable_ssr.arn - "NEXT_SERVER_GITHUB_CLIENT_ID" = aws_ssm_parameter.next_server_github_client_id.arn - "NEXT_SERVER_GITHUB_CLIENT_SECRET" = aws_ssm_parameter.next_server_github_client_secret.arn - "NEXT_SERVER_GRAPHQL_URL" = aws_ssm_parameter.next_server_graphql_url.arn - "NEXTAUTH_SECRET" = aws_ssm_parameter.nextauth_secret.arn - "NEXTAUTH_URL" = aws_ssm_parameter.nextauth_url.arn - } + + value = var.runtime_secrets_mode == "complete" ? [ + aws_secretsmanager_secret.external_runtime["NEXT_SERVER_GITHUB_CLIENT_SECRET"].arn, + aws_secretsmanager_secret.nextauth_secret.arn, + ] : [] } diff --git a/infrastructure/modules/parameters/tests/integration.tftest.hcl b/infrastructure/modules/parameters/tests/integration.tftest.hcl index 837d072458..32af5dc014 100644 --- a/infrastructure/modules/parameters/tests/integration.tftest.hcl +++ b/infrastructure/modules/parameters/tests/integration.tftest.hcl @@ -9,26 +9,31 @@ provider "aws" { } variables { - common_tags = { Environment = "test", Project = "nest" } - db_password_arn = "arn:aws:ssm:us-east-1:000000000000:parameter/nest/test/DJANGO_DB_PASSWORD" - django_allowed_hosts = "nest.owasp.dev" - django_allowed_origins = "https://nest.owasp.dev" - django_aws_static_bucket_name = "nest-test-static-abcd1234" - django_configuration = "Staging" - django_db_host = "db.example.com" - django_db_name = "nest_db" - django_db_port = "5432" - django_db_user = "nest_user" - django_redis_host = "redis.example.com" - django_release_version = "1.0.0" - django_settings_module = "settings.staging" - environment = "test" - next_server_csrf_url = "https://nest.owasp.dev/csrf" - next_server_graphql_url = "https://nest.owasp.dev/graphql" - nextauth_url = "https://nest.owasp.dev" - project_name = "nest" - redis_password_arn = "arn:aws:ssm:us-east-1:000000000000:parameter/nest/test/DJANGO_REDIS_PASSWORD" - slack_bot_token_suffix = "T04T40NHX" + common_tags = { Environment = "test", Project = "nest" } + db_credentials_secret_arn = "arn:aws:secretsmanager:us-east-1:000000000000:secret:nest-test-db-credentials" + db_password_arn = "arn:aws:ssm:us-east-1:000000000000:parameter/nest/test/DJANGO_DB_PASSWORD" + django_allowed_hosts = "nest.owasp.dev" + django_allowed_origins = "https://nest.owasp.dev" + django_aws_static_bucket_name = "nest-test-static-abcd1234" + django_configuration = "Staging" + django_db_host = "db.example.com" + django_db_name = "nest_db" + django_db_port = "5432" + django_db_user = "nest_user" + django_redis_host = "redis.example.com" + django_release_version = "1.0.0" + django_settings_module = "settings.staging" + environment = "test" + kms_key_arn = "arn:aws:kms:us-east-1:000000000000:key/12345678-1234-1234-1234-123456789012" + next_server_csrf_url = "https://nest.owasp.dev/csrf" + next_server_graphql_url = "https://nest.owasp.dev/graphql" + nextauth_url = "https://nest.owasp.dev" + project_name = "nest" + redis_password_arn = "arn:aws:ssm:us-east-1:000000000000:parameter/nest/test/DJANGO_REDIS_PASSWORD" + redis_password_secret_arn = "arn:aws:secretsmanager:us-east-1:000000000000:secret:/nest/test/DJANGO_REDIS_PASSWORD" + runtime_secrets_mode = "prepare" + secret_recovery_window_in_days = 0 + slack_bot_token_suffix = "T04T40NHX" } run "parameters_integration_apply" { @@ -65,12 +70,17 @@ run "parameters_integration_apply" { } assert { - condition = aws_ssm_parameter.django_secret_key.name == "/${var.project_name}/${var.environment}/DJANGO_SECRET_KEY" - error_message = "SSM django_secret_key parameter path format is incorrect." + condition = aws_secretsmanager_secret.django_secret_key.name == "/${var.project_name}/${var.environment}/DJANGO_SECRET_KEY" + error_message = "Secrets Manager django_secret_key name format is incorrect." } assert { - condition = aws_ssm_parameter.django_secret_key.type == "SecureString" - error_message = "SSM django_secret_key parameter type must be SecureString." + condition = aws_secretsmanager_secret.django_secret_key.kms_key_id == var.kms_key_arn + error_message = "Secrets Manager django_secret_key KMS key ID is incorrect." + } + + assert { + condition = aws_secretsmanager_secret.external_runtime["GITHUB_TOKEN"].name == "/${var.project_name}/${var.environment}/GITHUB_TOKEN" + error_message = "Secrets Manager GITHUB_TOKEN name format is incorrect." } } diff --git a/infrastructure/modules/parameters/tests/unit.tftest.hcl b/infrastructure/modules/parameters/tests/unit.tftest.hcl index 9d64a0f49f..c70326e720 100644 --- a/infrastructure/modules/parameters/tests/unit.tftest.hcl +++ b/infrastructure/modules/parameters/tests/unit.tftest.hcl @@ -2,6 +2,7 @@ mock_provider "aws" {} variables { common_tags = { Environment = "test", Project = "nest" } + db_credentials_secret_arn = "arn:aws:secretsmanager:us-east-2:123456789012:secret:nest-test-db-credentials" db_password_arn = "arn:aws:ssm:us-east-2:123456789012:parameter/nest/test/DJANGO_DB_PASSWORD" django_allowed_hosts = "nest.owasp.dev" django_allowed_origins = "https://nest.owasp.dev" @@ -15,14 +16,136 @@ variables { django_release_version = "1.0.0" django_settings_module = "settings.staging" environment = "test" + kms_key_arn = "arn:aws:kms:us-east-2:123456789012:key/12345678-1234-1234-1234-123456789012" next_server_csrf_url = "https://nest.owasp.dev/csrf" next_server_graphql_url = "https://nest.owasp.dev/graphql" nextauth_url = "https://nest.owasp.dev" project_name = "nest" redis_password_arn = "arn:aws:ssm:us-east-2:123456789012:parameter/nest/test/DJANGO_REDIS_PASSWORD" + redis_password_secret_arn = "arn:aws:secretsmanager:us-east-2:123456789012:secret:/nest/test/DJANGO_REDIS_PASSWORD" + runtime_secrets_mode = "prepare" slack_bot_token_suffix = "T04T40NHX" } +run "test_secret_recovery_window_rejects_invalid_value" { + command = plan + + variables { + secret_recovery_window_in_days = 6 + } + + expect_failures = [ + var.secret_recovery_window_in_days, + ] +} + +run "test_secret_recovery_window_accepts_minimum_valid_value" { + command = plan + + variables { + secret_recovery_window_in_days = 7 + } +} + +run "test_complete_mode_uses_secrets_manager" { + command = plan + + variables { + enable_additional_parameters = true + runtime_secrets_mode = "complete" + } + + override_resource { + target = aws_secretsmanager_secret.django_secret_key + override_during = plan + values = { + arn = "arn:aws:secretsmanager:us-east-2:123456789012:secret:/nest/test/DJANGO_SECRET_KEY" + } + } + + override_resource { + target = aws_secretsmanager_secret.nextauth_secret + override_during = plan + values = { + arn = "arn:aws:secretsmanager:us-east-2:123456789012:secret:/nest/test/NEXTAUTH_SECRET" + } + } + + assert { + condition = ( + output.django_container_secrets["DJANGO_DB_PASSWORD"] == "${var.db_credentials_secret_arn}:password::" + ) + error_message = "Database password must use the Secrets Manager password JSON key." + } + + assert { + condition = ( + output.django_container_secrets["DJANGO_REDIS_PASSWORD"] == + var.redis_password_secret_arn + ) + error_message = "Redis password must use its Secrets Manager ARN." + } + + assert { + condition = output.django_container_secrets["DJANGO_SECRET_KEY"] == aws_secretsmanager_secret.django_secret_key.arn + error_message = "Django secret key must use its Secrets Manager ARN." + } + + assert { + condition = output.frontend_container_secrets["NEXTAUTH_SECRET"] == aws_secretsmanager_secret.nextauth_secret.arn + error_message = "NextAuth secret must use its Secrets Manager ARN." + } + + assert { + condition = length(nonsensitive(output.frontend_secretsmanager_secret_arns)) == 2 + error_message = "The frontend role must only receive its two Secrets Manager ARNs." + } + + assert { + condition = length(nonsensitive(output.django_secretsmanager_secret_arns)) == 11 + error_message = "The Django role must only receive Django runtime secret ARNs." + } + + assert { + condition = alltrue([ + length(aws_ssm_parameter.django_algolia_write_api_key) == 0, + length(aws_ssm_parameter.django_open_ai_secret_key) == 0, + length(aws_ssm_parameter.django_secret_key) == 0, + length(aws_ssm_parameter.django_sentry_dsn) == 0, + length(aws_ssm_parameter.django_slack_bot_token) == 0, + length(aws_ssm_parameter.django_slack_signing_secret) == 0, + length(aws_ssm_parameter.github_token) == 0, + length(aws_ssm_parameter.nest_github_app_private_key) == 0, + length(aws_ssm_parameter.next_server_github_client_secret) == 0, + length(aws_ssm_parameter.nextauth_secret) == 0, + length(aws_ssm_parameter.slack_bot_token) == 0, + ]) + error_message = "Complete mode must remove all legacy secret-valued SSM parameters." + } +} + +run "test_prepare_mode_does_not_grant_secretsmanager_access" { + command = plan + + assert { + condition = alltrue([ + length(nonsensitive(output.django_secretsmanager_secret_arns)) == 0, + length(nonsensitive(output.frontend_secretsmanager_secret_arns)) == 0, + ]) + error_message = "Prepare mode ECS roles must keep using SSM without Secrets Manager access." + } + + assert { + condition = output.django_container_secrets["DJANGO_DB_PASSWORD"] == var.db_password_arn + error_message = "In prepare mode DJANGO_DB_PASSWORD must still resolve to the SSM parameter ARN." + } + + assert { + condition = output.django_container_secrets["DJANGO_REDIS_PASSWORD"] == var.redis_password_arn + error_message = "In prepare mode DJANGO_REDIS_PASSWORD must still resolve to the SSM parameter ARN." + } +} + run "test_django_algolia_application_id_path_format" { command = plan assert { @@ -31,18 +154,18 @@ run "test_django_algolia_application_id_path_format" { } } -run "test_django_algolia_application_id_is_secure_string" { +run "test_django_algolia_application_id_is_string" { command = plan assert { - condition = aws_ssm_parameter.django_algolia_application_id.type == "SecureString" - error_message = "DJANGO_ALGOLIA_APPLICATION_ID must be stored as SecureString." + condition = aws_ssm_parameter.django_algolia_application_id.type == "String" + error_message = "DJANGO_ALGOLIA_APPLICATION_ID must be stored as String." } } run "test_django_algolia_write_api_key_path_format" { command = plan assert { - condition = aws_ssm_parameter.django_algolia_write_api_key.name == "/${var.project_name}/${var.environment}/DJANGO_ALGOLIA_WRITE_API_KEY" + condition = aws_ssm_parameter.django_algolia_write_api_key[0].name == "/${var.project_name}/${var.environment}/DJANGO_ALGOLIA_WRITE_API_KEY" error_message = "DJANGO_ALGOLIA_WRITE_API_KEY must follow path: /{project}/{environment}/DJANGO_ALGOLIA_WRITE_API_KEY." } } @@ -50,7 +173,7 @@ run "test_django_algolia_write_api_key_path_format" { run "test_django_algolia_write_api_key_is_secure_string" { command = plan assert { - condition = aws_ssm_parameter.django_algolia_write_api_key.type == "SecureString" + condition = aws_ssm_parameter.django_algolia_write_api_key[0].type == "SecureString" error_message = "DJANGO_ALGOLIA_WRITE_API_KEY must be stored as SecureString." } } @@ -246,7 +369,7 @@ run "test_django_github_app_installation_id_path_format" { run "test_django_open_ai_secret_key_path_format" { command = plan assert { - condition = aws_ssm_parameter.django_open_ai_secret_key.name == "/${var.project_name}/${var.environment}/DJANGO_OPEN_AI_SECRET_KEY" + condition = aws_ssm_parameter.django_open_ai_secret_key[0].name == "/${var.project_name}/${var.environment}/DJANGO_OPEN_AI_SECRET_KEY" error_message = "DJANGO_OPEN_AI_SECRET_KEY must follow path: /{project}/{environment}/DJANGO_OPEN_AI_SECRET_KEY." } } @@ -254,7 +377,7 @@ run "test_django_open_ai_secret_key_path_format" { run "test_django_open_ai_secret_key_is_secure_string" { command = plan assert { - condition = aws_ssm_parameter.django_open_ai_secret_key.type == "SecureString" + condition = aws_ssm_parameter.django_open_ai_secret_key[0].type == "SecureString" error_message = "DJANGO_OPEN_AI_SECRET_KEY must be stored as SecureString." } } @@ -310,7 +433,7 @@ run "test_django_release_version_path_format" { run "test_django_secret_key_path_format" { command = plan assert { - condition = aws_ssm_parameter.django_secret_key.name == "/${var.project_name}/${var.environment}/DJANGO_SECRET_KEY" + condition = aws_ssm_parameter.django_secret_key[0].name == "/${var.project_name}/${var.environment}/DJANGO_SECRET_KEY" error_message = "DJANGO_SECRET_KEY must follow path: /{project}/{environment}/DJANGO_SECRET_KEY." } } @@ -318,7 +441,7 @@ run "test_django_secret_key_path_format" { run "test_django_secret_key_is_secure_string" { command = plan assert { - condition = aws_ssm_parameter.django_secret_key.type == "SecureString" + condition = aws_ssm_parameter.django_secret_key[0].type == "SecureString" error_message = "DJANGO_SECRET_KEY must be stored as SecureString." } } @@ -342,7 +465,7 @@ run "test_django_settings_module_is_string" { run "test_django_sentry_dsn_path_format" { command = plan assert { - condition = aws_ssm_parameter.django_sentry_dsn.name == "/${var.project_name}/${var.environment}/DJANGO_SENTRY_DSN" + condition = aws_ssm_parameter.django_sentry_dsn[0].name == "/${var.project_name}/${var.environment}/DJANGO_SENTRY_DSN" error_message = "DJANGO_SENTRY_DSN must follow path: /{project}/{environment}/DJANGO_SENTRY_DSN." } } @@ -350,7 +473,7 @@ run "test_django_sentry_dsn_path_format" { run "test_django_sentry_dsn_is_secure_string" { command = plan assert { - condition = aws_ssm_parameter.django_sentry_dsn.type == "SecureString" + condition = aws_ssm_parameter.django_sentry_dsn[0].type == "SecureString" error_message = "DJANGO_SENTRY_DSN must be stored as SecureString." } } @@ -358,7 +481,7 @@ run "test_django_sentry_dsn_is_secure_string" { run "test_django_slack_bot_token_path_format" { command = plan assert { - condition = aws_ssm_parameter.django_slack_bot_token.name == "/${var.project_name}/${var.environment}/DJANGO_SLACK_BOT_TOKEN" + condition = aws_ssm_parameter.django_slack_bot_token[0].name == "/${var.project_name}/${var.environment}/DJANGO_SLACK_BOT_TOKEN" error_message = "DJANGO_SLACK_BOT_TOKEN must follow path: /{project}/{environment}/DJANGO_SLACK_BOT_TOKEN." } } @@ -366,7 +489,7 @@ run "test_django_slack_bot_token_path_format" { run "test_django_slack_bot_token_is_secure_string" { command = plan assert { - condition = aws_ssm_parameter.django_slack_bot_token.type == "SecureString" + condition = aws_ssm_parameter.django_slack_bot_token[0].type == "SecureString" error_message = "DJANGO_SLACK_BOT_TOKEN must be stored as SecureString." } } @@ -374,7 +497,7 @@ run "test_django_slack_bot_token_is_secure_string" { run "test_django_slack_signing_secret_path_format" { command = plan assert { - condition = aws_ssm_parameter.django_slack_signing_secret.name == "/${var.project_name}/${var.environment}/DJANGO_SLACK_SIGNING_SECRET" + condition = aws_ssm_parameter.django_slack_signing_secret[0].name == "/${var.project_name}/${var.environment}/DJANGO_SLACK_SIGNING_SECRET" error_message = "DJANGO_SLACK_SIGNING_SECRET must follow path: /{project}/{environment}/DJANGO_SLACK_SIGNING_SECRET." } } @@ -382,7 +505,7 @@ run "test_django_slack_signing_secret_path_format" { run "test_django_slack_signing_secret_is_secure_string" { command = plan assert { - condition = aws_ssm_parameter.django_slack_signing_secret.type == "SecureString" + condition = aws_ssm_parameter.django_slack_signing_secret[0].type == "SecureString" error_message = "DJANGO_SLACK_SIGNING_SECRET must be stored as SecureString." } } @@ -390,7 +513,7 @@ run "test_django_slack_signing_secret_is_secure_string" { run "test_github_token_is_secure_string" { command = plan assert { - condition = aws_ssm_parameter.github_token.type == "SecureString" + condition = aws_ssm_parameter.github_token[0].type == "SecureString" error_message = "GITHUB_TOKEN must be stored as SecureString." } } @@ -398,7 +521,7 @@ run "test_github_token_is_secure_string" { run "test_github_token_path_format" { command = plan assert { - condition = aws_ssm_parameter.github_token.name == "/${var.project_name}/${var.environment}/GITHUB_TOKEN" + condition = aws_ssm_parameter.github_token[0].name == "/${var.project_name}/${var.environment}/GITHUB_TOKEN" error_message = "GITHUB_TOKEN must follow path: /{project}/{environment}/GITHUB_TOKEN." } } @@ -484,7 +607,7 @@ run "test_next_server_github_client_id_is_string" { run "test_next_server_github_client_secret_path_format" { command = plan assert { - condition = aws_ssm_parameter.next_server_github_client_secret.name == "/${var.project_name}/${var.environment}/NEXT_SERVER_GITHUB_CLIENT_SECRET" + condition = aws_ssm_parameter.next_server_github_client_secret[0].name == "/${var.project_name}/${var.environment}/NEXT_SERVER_GITHUB_CLIENT_SECRET" error_message = "NEXT_SERVER_GITHUB_CLIENT_SECRET must follow path: /{project}/{environment}/NEXT_SERVER_GITHUB_CLIENT_SECRET." } } @@ -492,7 +615,7 @@ run "test_next_server_github_client_secret_path_format" { run "test_next_server_github_client_secret_is_secure_string" { command = plan assert { - condition = aws_ssm_parameter.next_server_github_client_secret.type == "SecureString" + condition = aws_ssm_parameter.next_server_github_client_secret[0].type == "SecureString" error_message = "NEXT_SERVER_GITHUB_CLIENT_SECRET must be stored as SecureString." } } @@ -516,7 +639,7 @@ run "test_next_server_graphql_url_is_string" { run "test_nextauth_secret_path_format" { command = plan assert { - condition = aws_ssm_parameter.nextauth_secret.name == "/${var.project_name}/${var.environment}/NEXTAUTH_SECRET" + condition = aws_ssm_parameter.nextauth_secret[0].name == "/${var.project_name}/${var.environment}/NEXTAUTH_SECRET" error_message = "NEXTAUTH_SECRET must follow path: /{project}/{environment}/NEXTAUTH_SECRET." } } @@ -524,7 +647,7 @@ run "test_nextauth_secret_path_format" { run "test_nextauth_secret_is_secure_string" { command = plan assert { - condition = aws_ssm_parameter.nextauth_secret.type == "SecureString" + condition = aws_ssm_parameter.nextauth_secret[0].type == "SecureString" error_message = "NEXTAUTH_SECRET must be stored as SecureString." } } diff --git a/infrastructure/modules/parameters/variables.tf b/infrastructure/modules/parameters/variables.tf index 7b6fdd07d3..c5c66d3d94 100644 --- a/infrastructure/modules/parameters/variables.tf +++ b/infrastructure/modules/parameters/variables.tf @@ -4,6 +4,11 @@ variable "common_tags" { default = {} } +variable "db_credentials_secret_arn" { + description = "The Secrets Manager ARN containing the database credentials." + type = string +} + variable "db_password_arn" { description = "The SSM Parameter ARN of password of the database." type = string @@ -82,6 +87,11 @@ variable "environment" { type = string } +variable "kms_key_arn" { + description = "The KMS key ARN used to encrypt runtime secrets" + type = string +} + variable "next_server_csrf_url" { description = "The server-side CSRF URL for Next.js SSR (e.g., https://nest.owasp.dev/csrf/)." type = string @@ -108,6 +118,44 @@ variable "redis_password_arn" { sensitive = true } +variable "redis_password_secret_arn" { + description = "The Secrets Manager ARN containing the Redis password." + type = string +} + +variable "runtime_secrets_mode" { + description = "Runtime secret migration phase: 'prepare' retains SSM injection, while 'complete' uses Secrets Manager." + type = string + + validation { + condition = contains( + ["prepare", "complete"], + var.runtime_secrets_mode, + ) + error_message = "runtime_secrets_mode must be either prepare or complete." + } +} +variable "secret_recovery_window_in_days" { + description = "The number of days Secrets Manager waits before deleting a secret." + type = number + default = 7 + + # A value of 0 maps to ForceDeleteWithoutRecovery and should only be used for + # ephemeral/test environments. Staging and production should use 7-30 days. + validation { + condition = ( + var.secret_recovery_window_in_days == 0 || + ( + var.secret_recovery_window_in_days >= 7 && + var.secret_recovery_window_in_days <= 30 + ) + ) + error_message = "secret_recovery_window_in_days must be 0 (immediate deletion) or between 7 and 30." + } + +} + + variable "slack_bot_token_suffix" { description = "The Suffix for the Slack bot token." type = string diff --git a/infrastructure/modules/service/README.md b/infrastructure/modules/service/README.md index beb7d8c739..e8ae522c89 100644 --- a/infrastructure/modules/service/README.md +++ b/infrastructure/modules/service/README.md @@ -54,6 +54,7 @@ No modules. | [container\_cpu](#input\_container\_cpu) | The CPU units for the container (1024 = 1 vCPU). | `number` | `512` | no | | [container\_memory](#input\_container\_memory) | The memory for the container in MiB. | `number` | `1024` | no | | [container\_port](#input\_container\_port) | The port the container listens on. | `number` | n/a | yes | +| [container\_secrets](#input\_container\_secrets) | Environment variable names mapped to SSM or Secrets Manager valueFrom references. | `map(string)` | `{}` | no | | [desired\_count](#input\_desired\_count) | The desired number of tasks. | `number` | `2` | no | | [enable\_auto\_scaling](#input\_enable\_auto\_scaling) | Whether to enable auto scaling for the service. | `bool` | `false` | no | | [environment](#input\_environment) | The environment name (e.g., staging, production). | `string` | n/a | yes | @@ -64,8 +65,8 @@ No modules. | [log\_retention\_in\_days](#input\_log\_retention\_in\_days) | The CloudWatch log retention in days. | `number` | `30` | no | | [max\_count](#input\_max\_count) | The maximum number of tasks for auto scaling. | `number` | `6` | no | | [min\_count](#input\_min\_count) | The minimum number of tasks for auto scaling. | `number` | `2` | no | -| [parameters\_arns](#input\_parameters\_arns) | Map of environment variable names to the ARNs of SSM parameters. | `map(string)` | `{}` | no | | [project\_name](#input\_project\_name) | The name of the project. | `string` | n/a | yes | +| [secretsmanager\_secret\_arns](#input\_secretsmanager\_secret\_arns) | Bare secrets manager ARNs that the ECS execution role may read | `set(string)` | `[]` | no | | [security\_group\_id](#input\_security\_group\_id) | The ID of the security group for the service. | `string` | n/a | yes | | [service\_name](#input\_service\_name) | The name of the service (e.g., backend, frontend). | `string` | n/a | yes | | [subnet\_ids](#input\_subnet\_ids) | Subnet IDs for ECS tasks (can be public or private). | `list(string)` | n/a | yes | diff --git a/infrastructure/modules/service/main.tf b/infrastructure/modules/service/main.tf index cb0c241443..ab682b4943 100644 --- a/infrastructure/modules/service/main.tf +++ b/infrastructure/modules/service/main.tf @@ -39,7 +39,7 @@ locals { protocol = "tcp" } ] - secrets = [for name, valueFrom in var.parameters_arns : { + secrets = [for name, valueFrom in var.container_secrets : { name = name valueFrom = valueFrom }] @@ -260,12 +260,12 @@ resource "aws_iam_policy" "ecs_task_execution_policy" { } resource "aws_iam_policy" "ecs_task_execution_ssm_policy" { - description = "Policy to allow ECS tasks to read SSM parameters." + description = "Policy to allow ECS tasks to read SSM parameters and Secrets Manager secrets." name = "${local.name_prefix}-ssm-policy" policy = jsonencode({ Version = "2012-10-17" - Statement = [ + Statement = concat([ { Action = [ "ssm:GetParameter", @@ -274,7 +274,24 @@ resource "aws_iam_policy" "ecs_task_execution_ssm_policy" { Effect = "Allow" Resource = "arn:aws:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter/${var.project_name}/${var.environment}/*" } - ] + ], length(var.secretsmanager_secret_arns) > 0 ? [ + { + Action = ["secretsmanager:GetSecretValue"] + Effect = "Allow" + Resource = var.secretsmanager_secret_arns + } + ] : [], length(var.secretsmanager_secret_arns) > 0 ? [ + { + Action = ["kms:Decrypt"] + Condition = { + StringEquals = { + "kms:ViaService" = "secretsmanager.${var.aws_region}.amazonaws.com" + } + } + Effect = "Allow" + Resource = var.kms_key_arn + } + ] : []) }) } diff --git a/infrastructure/modules/service/tests/unit.tftest.hcl b/infrastructure/modules/service/tests/unit.tftest.hcl index d7f1a1b0d8..9ee22dde8c 100644 --- a/infrastructure/modules/service/tests/unit.tftest.hcl +++ b/infrastructure/modules/service/tests/unit.tftest.hcl @@ -12,12 +12,15 @@ variables { image_tag = "test-tag" kms_key_arn = "arn:aws:kms:us-east-2:123456789012:key/12345678-1234-1234-1234-123456789012" log_retention_in_days = 7 - parameters_arns = { "NEXT_PUBLIC_API_URL" = "arn:aws:ssm:us-east-2:123456789012:parameter/nest/test/NEXT_PUBLIC_API_URL" } + container_secrets = { "NEXT_PUBLIC_API_URL" = "arn:aws:ssm:us-east-2:123456789012:parameter/nest/test/NEXT_PUBLIC_API_URL" } project_name = "nest" security_group_id = "sg-service-12345" - service_name = "service" - subnet_ids = ["subnet-1", "subnet-2"] - target_group_arn = "arn:aws:elasticloadbalancing:us-east-2:123456789012:targetgroup/nest-test-service-tg/1234567890123456" + secretsmanager_secret_arns = [ + "arn:aws:secretsmanager:us-east-2:123456789012:secret:/nest/test/EXAMPLE" + ] + service_name = "service" + subnet_ids = ["subnet-1", "subnet-2"] + target_group_arn = "arn:aws:elasticloadbalancing:us-east-2:123456789012:targetgroup/nest-test-service-tg/1234567890123456" } run "test_cloudwatch_log_group_name_format" { @@ -29,6 +32,62 @@ run "test_cloudwatch_log_group_name_format" { } } +run "test_empty_secretsmanager_arns_omit_policy_statement" { + command = plan + + variables { + secretsmanager_secret_arns = [] + } + + assert { + condition = length([ + for statement in jsondecode(aws_iam_policy.ecs_task_execution_ssm_policy.policy).Statement : statement + if contains(statement.Action, "secretsmanager:GetSecretValue") + ]) == 0 + error_message = "The execution policy must omit Secrets Manager access when no secret ARNs are supplied." + } + + assert { + condition = length([ + for statement in jsondecode(aws_iam_policy.ecs_task_execution_ssm_policy.policy).Statement : statement + if contains(statement.Action, "kms:Decrypt") + ]) == 0 + error_message = "The execution policy must omit KMS decrypt access when no secret ARNs are supplied." + } +} + +run "test_kms_decrypt_is_limited_to_secretsmanager" { + command = plan + + assert { + condition = one([ + for statement in jsondecode(aws_iam_policy.ecs_task_execution_ssm_policy.policy).Statement : statement + if contains(statement.Action, "kms:Decrypt") + ]).Condition.StringEquals["kms:ViaService"] == "secretsmanager.${var.aws_region}.amazonaws.com" + error_message = "KMS decrypt access must be limited to requests from Secrets Manager." + } + + assert { + condition = one([ + for statement in jsondecode(aws_iam_policy.ecs_task_execution_ssm_policy.policy).Statement : statement + if contains(statement.Action, "kms:Decrypt") + ]).Resource == var.kms_key_arn + error_message = "KMS decrypt access must be limited to the configured key." + } +} + +run "test_secretsmanager_policy_uses_supplied_arns" { + command = plan + + assert { + condition = toset(one([ + for statement in jsondecode(aws_iam_policy.ecs_task_execution_ssm_policy.policy).Statement : statement + if contains(statement.Action, "secretsmanager:GetSecretValue") + ]).Resource) == var.secretsmanager_secret_arns + error_message = "Secrets Manager access must be limited to the supplied secret ARNs." + } +} + run "test_cloudwatch_log_group_retention" { command = plan @@ -300,7 +359,7 @@ run "test_service_works_with_backend_config" { container_memory = 2048 container_port = 8000 service_name = "backend" - parameters_arns = { + container_secrets = { "DJANGO_SECRET_KEY" = "arn:aws:ssm:us-east-2:123456789012:parameter/nest/test/DJANGO_SECRET_KEY" } } diff --git a/infrastructure/modules/service/variables.tf b/infrastructure/modules/service/variables.tf index dcd785cc7e..194cd64d5b 100644 --- a/infrastructure/modules/service/variables.tf +++ b/infrastructure/modules/service/variables.tf @@ -143,12 +143,18 @@ variable "min_count" { } } -variable "parameters_arns" { - description = "Map of environment variable names to the ARNs of SSM parameters." +variable "container_secrets" { + description = "Environment variable names mapped to SSM or Secrets Manager valueFrom references." type = map(string) default = {} } +variable "secretsmanager_secret_arns" { + description = "Bare secrets manager ARNs that the ECS execution role may read" + type = set(string) + default = [] +} + variable "project_name" { description = "The name of the project." type = string diff --git a/infrastructure/modules/tasks/README.md b/infrastructure/modules/tasks/README.md index b45b9bc868..487e86df64 100644 --- a/infrastructure/modules/tasks/README.md +++ b/infrastructure/modules/tasks/README.md @@ -52,7 +52,7 @@ | [assign\_public\_ip](#input\_assign\_public\_ip) | Whether to assign public IPs to ECS tasks (required for public subnets). | `bool` | `false` | no | | [aws\_region](#input\_aws\_region) | The AWS region. | `string` | n/a | yes | | [common\_tags](#input\_common\_tags) | A map of common tags to apply to all resources. | `map(string)` | `{}` | no | -| [container\_parameters\_arns](#input\_container\_parameters\_arns) | Map of environment variable names to the ARNs of all SSM parameters. | `map(string)` | `{}` | no | +| [container\_secrets](#input\_container\_secrets) | Environment variable names mapped to SSM or Secrets Manager valueFrom references. | `map(string)` | `{}` | no | | [ecr\_repository\_arn](#input\_ecr\_repository\_arn) | The ARN of the ECR repository for the backend image. | `string` | n/a | yes | | [ecr\_repository\_url](#input\_ecr\_repository\_url) | The URL of the ECR repository for the backend image. | `string` | n/a | yes | | [ecs\_sg\_id](#input\_ecs\_sg\_id) | The ID of the security group for the ECS tasks. | `string` | n/a | yes | @@ -71,6 +71,7 @@ | [migrate\_task\_cpu](#input\_migrate\_task\_cpu) | The CPU for the migrate task. | `string` | `"256"` | no | | [migrate\_task\_memory](#input\_migrate\_task\_memory) | The memory for the migrate task. | `string` | `"1024"` | no | | [project\_name](#input\_project\_name) | The name of the project. | `string` | n/a | yes | +| [secretsmanager\_secret\_arns](#input\_secretsmanager\_secret\_arns) | Bare Secrets Manager ARNs that the ECS execution role may read. | `set(string)` | `[]` | no | | [slack\_sync\_data\_task\_cpu](#input\_slack\_sync\_data\_task\_cpu) | The CPU for the slack-sync-data scheduled task. | `string` | `"1024"` | no | | [slack\_sync\_data\_task\_memory](#input\_slack\_sync\_data\_task\_memory) | The memory for the slack-sync-data scheduled task. | `string` | `"2048"` | no | | [subnet\_ids](#input\_subnet\_ids) | Subnet IDs for ECS tasks (can be public or private). | `list(string)` | n/a | yes | diff --git a/infrastructure/modules/tasks/main.tf b/infrastructure/modules/tasks/main.tf index 6440827770..bb71df9806 100644 --- a/infrastructure/modules/tasks/main.tf +++ b/infrastructure/modules/tasks/main.tf @@ -55,11 +55,11 @@ resource "aws_iam_role" "ecs_tasks_execution_role" { resource "aws_iam_policy" "ecs_tasks_execution_role_ssm_policy" { - description = "Allow ECS tasks to read SSM parameters" + description = "Allow ECS tasks to read SSM parameters and Secrets Manager secrets" name = "${var.project_name}-${var.environment}-ecs-tasks-ssm-policy" policy = jsonencode({ Version = "2012-10-17" - Statement = [ + Statement = concat([ { Action = [ "ssm:GetParameters" @@ -67,7 +67,24 @@ resource "aws_iam_policy" "ecs_tasks_execution_role_ssm_policy" { Effect = "Allow" Resource = "arn:aws:ssm:${var.aws_region}:${data.aws_caller_identity.current.account_id}:parameter/${var.project_name}/${var.environment}/*" } - ] + ], length(var.secretsmanager_secret_arns) > 0 ? [ + { + Action = ["secretsmanager:GetSecretValue"] + Effect = "Allow" + Resource = var.secretsmanager_secret_arns + } + ] : [], length(var.secretsmanager_secret_arns) > 0 ? [ + { + Action = ["kms:Decrypt"] + Condition = { + StringEquals = { + "kms:ViaService" = "secretsmanager.${var.aws_region}.amazonaws.com" + } + } + Effect = "Allow" + Resource = var.kms_key_arn + } + ] : []) }) tags = var.common_tags } @@ -226,7 +243,7 @@ module "sync_data_task" { aws_region = var.aws_region command = ["/bin/sh", "-c", "EXEC_MODE=direct make sync-data"] common_tags = var.common_tags - container_parameters_arns = var.container_parameters_arns + container_secrets = var.container_secrets cpu = var.sync_data_task_cpu ecs_cluster_arn = aws_ecs_cluster.main.arn ecs_tasks_execution_role_arn = aws_iam_role.ecs_tasks_execution_role.arn @@ -259,7 +276,7 @@ module "slack_sync_data_task" { EOT ] common_tags = var.common_tags - container_parameters_arns = var.container_parameters_arns + container_secrets = var.container_secrets cpu = var.slack_sync_data_task_cpu ecs_cluster_arn = aws_ecs_cluster.main.arn ecs_tasks_execution_role_arn = aws_iam_role.ecs_tasks_execution_role.arn @@ -291,7 +308,7 @@ module "owasp_update_project_health_metrics_task" { EOT ] common_tags = var.common_tags - container_parameters_arns = var.container_parameters_arns + container_secrets = var.container_secrets cpu = var.update_project_health_metrics_task_cpu ecs_cluster_arn = aws_ecs_cluster.main.arn ecs_tasks_execution_role_arn = aws_iam_role.ecs_tasks_execution_role.arn @@ -315,7 +332,7 @@ module "owasp_update_project_health_scores_task" { aws_region = var.aws_region command = ["/bin/sh", "-c", "EXEC_MODE=direct make owasp-update-project-health-scores"] common_tags = var.common_tags - container_parameters_arns = var.container_parameters_arns + container_secrets = var.container_secrets cpu = var.update_project_health_scores_task_cpu ecs_cluster_arn = aws_ecs_cluster.main.arn ecs_tasks_execution_role_arn = aws_iam_role.ecs_tasks_execution_role.arn @@ -347,7 +364,7 @@ module "mentorship_sync_modules_data" { EOT ] common_tags = var.common_tags - container_parameters_arns = var.container_parameters_arns + container_secrets = var.container_secrets cpu = var.mentorship_sync_modules_data_task_cpu ecs_cluster_arn = aws_ecs_cluster.main.arn ecs_tasks_execution_role_arn = aws_iam_role.ecs_tasks_execution_role.arn @@ -371,7 +388,7 @@ module "migrate_task" { aws_region = var.aws_region command = ["/bin/sh", "-c", "EXEC_MODE=direct make migrate"] common_tags = var.common_tags - container_parameters_arns = var.container_parameters_arns + container_secrets = var.container_secrets cpu = var.migrate_task_cpu ecs_cluster_arn = aws_ecs_cluster.main.arn ecs_tasks_execution_role_arn = aws_iam_role.ecs_tasks_execution_role.arn @@ -414,7 +431,7 @@ module "load_data_task" { EOT ] common_tags = var.common_tags - container_parameters_arns = var.container_parameters_arns + container_secrets = var.container_secrets cpu = var.load_data_task_cpu ecs_cluster_arn = aws_ecs_cluster.main.arn ecs_tasks_execution_role_arn = aws_iam_role.ecs_tasks_execution_role.arn @@ -437,7 +454,7 @@ module "index_data_task" { aws_region = var.aws_region command = ["/bin/sh", "-c", "EXEC_MODE=direct make index-data"] common_tags = var.common_tags - container_parameters_arns = var.container_parameters_arns + container_secrets = var.container_secrets cpu = var.index_data_task_cpu ecs_cluster_arn = aws_ecs_cluster.main.arn ecs_tasks_execution_role_arn = aws_iam_role.ecs_tasks_execution_role.arn diff --git a/infrastructure/modules/tasks/modules/task/README.md b/infrastructure/modules/tasks/modules/task/README.md index be7486992f..e0a5f42230 100644 --- a/infrastructure/modules/tasks/modules/task/README.md +++ b/infrastructure/modules/tasks/modules/task/README.md @@ -33,7 +33,7 @@ No modules. | [aws\_region](#input\_aws\_region) | The AWS region for the CloudWatch logs. | `string` | n/a | yes | | [command](#input\_command) | The command to run in the container. | `list(string)` | n/a | yes | | [common\_tags](#input\_common\_tags) | A map of common tags to apply to all resources. | `map(string)` | `{}` | no | -| [container\_parameters\_arns](#input\_container\_parameters\_arns) | A Map of environment variable names to the ARNs of all SSM parameters. | `map(string)` | `{}` | no | +| [container\_secrets](#input\_container\_secrets) | Environment variable names mapped to SSM or Secrets Manager valueFrom references. | `map(string)` | `{}` | no | | [cpu](#input\_cpu) | The CPU units to allocate for the task. | `string` | n/a | yes | | [ecs\_cluster\_arn](#input\_ecs\_cluster\_arn) | The ARN of the ECS cluster. | `string` | n/a | yes | | [ecs\_tasks\_execution\_role\_arn](#input\_ecs\_tasks\_execution\_role\_arn) | The ARN of the ECS task execution role. | `string` | n/a | yes | diff --git a/infrastructure/modules/tasks/modules/task/main.tf b/infrastructure/modules/tasks/modules/task/main.tf index 56a7963931..129963a5a3 100644 --- a/infrastructure/modules/tasks/modules/task/main.tf +++ b/infrastructure/modules/tasks/modules/task/main.tf @@ -39,7 +39,7 @@ resource "aws_ecs_task_definition" "task" { } } name = "backend" - secrets = [for name, valueFrom in var.container_parameters_arns : { + secrets = [for name, valueFrom in var.container_secrets : { name = name valueFrom = valueFrom }] diff --git a/infrastructure/modules/tasks/modules/task/tests/unit.tftest.hcl b/infrastructure/modules/tasks/modules/task/tests/unit.tftest.hcl index 4a5a195900..ad1d077443 100644 --- a/infrastructure/modules/tasks/modules/task/tests/unit.tftest.hcl +++ b/infrastructure/modules/tasks/modules/task/tests/unit.tftest.hcl @@ -4,7 +4,7 @@ variables { aws_region = "us-east-2" command = ["/bin/sh", "-c", "echo test"] common_tags = { Environment = "test", Project = "nest" } - container_parameters_arns = {} + container_secrets = {} cpu = "256" ecs_cluster_arn = "arn:aws:ecs:us-east-2:123456789012:cluster/test-cluster" ecs_tasks_execution_role_arn = "arn:aws:iam::123456789012:role/test-execution-role" diff --git a/infrastructure/modules/tasks/modules/task/variables.tf b/infrastructure/modules/tasks/modules/task/variables.tf index 6f92a1dedc..fb5eae6aa9 100644 --- a/infrastructure/modules/tasks/modules/task/variables.tf +++ b/infrastructure/modules/tasks/modules/task/variables.tf @@ -20,8 +20,8 @@ variable "common_tags" { default = {} } -variable "container_parameters_arns" { - description = "A Map of environment variable names to the ARNs of all SSM parameters." +variable "container_secrets" { + description = "Environment variable names mapped to SSM or Secrets Manager valueFrom references." type = map(string) default = {} } diff --git a/infrastructure/modules/tasks/tests/unit.tftest.hcl b/infrastructure/modules/tasks/tests/unit.tftest.hcl index ec50716976..f4009f33a2 100644 --- a/infrastructure/modules/tasks/tests/unit.tftest.hcl +++ b/infrastructure/modules/tasks/tests/unit.tftest.hcl @@ -3,7 +3,7 @@ mock_provider "aws" {} variables { aws_region = "us-east-2" common_tags = { Environment = "test", Project = "nest" } - container_parameters_arns = {} + container_secrets = {} ecr_repository_arn = "arn:aws:ecr:us-east-2:123456789012:repository/nest-test-backend" ecr_repository_url = "123456789012.dkr.ecr.us-east-2.amazonaws.com/nest-test-backend" ecs_sg_id = "sg-12345678" @@ -14,7 +14,10 @@ variables { image_tag = "test-tag" kms_key_arn = "arn:aws:kms:us-east-2:123456789012:key/12345678-1234-1234-1234-123456789012" project_name = "nest" - subnet_ids = ["subnet-12345678"] + secretsmanager_secret_arns = [ + "arn:aws:secretsmanager:us-east-2:123456789012:secret:/nest/test/EXAMPLE" + ] + subnet_ids = ["subnet-12345678"] } run "test_cron_tasks_disabled_removes_schedules" { @@ -45,6 +48,62 @@ run "test_cron_tasks_disabled_removes_schedules" { } } +run "test_empty_secretsmanager_arns_omit_policy_statement" { + command = plan + + variables { + secretsmanager_secret_arns = [] + } + + assert { + condition = length([ + for statement in jsondecode(aws_iam_policy.ecs_tasks_execution_role_ssm_policy.policy).Statement : statement + if contains(statement.Action, "secretsmanager:GetSecretValue") + ]) == 0 + error_message = "The tasks execution policy must omit Secrets Manager access when no secret ARNs are supplied." + } + + assert { + condition = length([ + for statement in jsondecode(aws_iam_policy.ecs_tasks_execution_role_ssm_policy.policy).Statement : statement + if contains(statement.Action, "kms:Decrypt") + ]) == 0 + error_message = "The tasks execution policy must omit KMS decrypt access when no secret ARNs are supplied." + } +} + +run "test_kms_decrypt_is_limited_to_secretsmanager" { + command = plan + + assert { + condition = one([ + for statement in jsondecode(aws_iam_policy.ecs_tasks_execution_role_ssm_policy.policy).Statement : statement + if contains(statement.Action, "kms:Decrypt") + ]).Condition.StringEquals["kms:ViaService"] == "secretsmanager.${var.aws_region}.amazonaws.com" + error_message = "KMS decrypt access must be limited to requests from Secrets Manager." + } + + assert { + condition = one([ + for statement in jsondecode(aws_iam_policy.ecs_tasks_execution_role_ssm_policy.policy).Statement : statement + if contains(statement.Action, "kms:Decrypt") + ]).Resource == var.kms_key_arn + error_message = "KMS decrypt access must be limited to the configured key." + } +} + +run "test_secretsmanager_policy_uses_supplied_arns" { + command = plan + + assert { + condition = toset(one([ + for statement in jsondecode(aws_iam_policy.ecs_tasks_execution_role_ssm_policy.policy).Statement : statement + if contains(statement.Action, "secretsmanager:GetSecretValue") + ]).Resource) == var.secretsmanager_secret_arns + error_message = "Secrets Manager access must be limited to the supplied secret ARNs." + } +} + run "test_cron_tasks_enabled_creates_schedules" { command = plan diff --git a/infrastructure/modules/tasks/variables.tf b/infrastructure/modules/tasks/variables.tf index 556130352a..2e2183aaa4 100644 --- a/infrastructure/modules/tasks/variables.tf +++ b/infrastructure/modules/tasks/variables.tf @@ -15,8 +15,8 @@ variable "common_tags" { default = {} } -variable "container_parameters_arns" { - description = "Map of environment variable names to the ARNs of all SSM parameters." +variable "container_secrets" { + description = "Environment variable names mapped to SSM or Secrets Manager valueFrom references." type = map(string) default = {} } @@ -114,6 +114,12 @@ variable "migrate_task_memory" { default = "1024" } +variable "secretsmanager_secret_arns" { + description = "Bare Secrets Manager ARNs that the ECS execution role may read." + type = set(string) + default = [] +} + variable "subnet_ids" { description = "Subnet IDs for ECS tasks (can be public or private)." type = list(string)