From 7859a3431c22b12daf4fdd38b27e0fb961f7174c Mon Sep 17 00:00:00 2001 From: Alexander Amiri Date: Tue, 17 Mar 2026 21:25:24 +0100 Subject: [PATCH] Migrate from Secrets Manager to SSM Parameter Store (#79) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace all Secrets Manager usage with SSM Parameter Store SecureString to eliminate per-secret monthly costs ($0.40/secret/month). - service-secret module: aws_secretsmanager_secret → aws_ssm_parameter at /{project}/apps/{service}/{name} - service-rds module: remove manage_master_user_password, use random_password + SSM parameter at /{project}/apps/{name}/db-master-password - ECS execution role: secretsmanager:GetSecretValue → ssm:GetParameters scoped to parameter/{project}/apps/* - Identity Center: secretsmanager:Describe/List → ssm:DescribeParameters/GetParametersByPath - registry.py: update output_map and exports for new resource types - All docs updated to reflect SSM-only secret storage --- CLAUDE.md | 2 +- README.md | 2 +- docs/app-yaml-reference.md | 8 ++-- docs/reusable-modules.md | 12 +++--- scripts/registry.py | 5 ++- terraform/modules/README.md | 4 +- terraform/modules/ecs-service/variables.tf | 2 +- terraform/modules/service-rds/main.tf | 43 ++++++++++++++++--- terraform/modules/service-rds/outputs.tf | 5 +++ terraform/modules/service-rds/variables.tf | 5 +++ terraform/modules/service-secret/main.tf | 19 +++++--- terraform/modules/service-secret/outputs.tf | 12 +++--- terraform/modules/service-secret/variables.tf | 2 +- terraform/org/identity-center.tf | 4 +- terraform/platform/iam/main.tf | 5 ++- 15 files changed, 90 insertions(+), 40 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index f4c3de8..1e029cb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -149,7 +149,7 @@ terraform/state/ | `terraform/modules/ecs-service/` | ECS Fargate service | | `terraform/modules/service-bucket/` | S3 bucket with IAM policy output | | `terraform/modules/service-database/` | DynamoDB table with IAM policy output | -| `terraform/modules/service-secret/` | Secrets Manager secret with IAM policy output | +| `terraform/modules/service-secret/` | SSM Parameter Store SecureString with IAM policy output | | `terraform/modules/service-queue/` | SQS queue + DLQ with IAM policy output | | `terraform/modules/service-alarm/` | CloudWatch alarms for ECS service | | ~~`terraform/modules/app-stack/`~~ | Removed — replaced by `scripts/expand-modules.py` + `scripts/registry.py` | diff --git a/README.md b/README.md index 59f12f4..6c7d989 100644 --- a/README.md +++ b/README.md @@ -69,7 +69,7 @@ App repos source these via `git::` URLs: | `service-role` | ECS task IAM role with composable policies | | `service-bucket` | S3 bucket with IAM policy output | | `service-database` | DynamoDB table with IAM policy output | -| `service-secret` | Secrets Manager secret with IAM policy output | +| `service-secret` | SSM Parameter Store SecureString with IAM policy output | | `service-queue` | SQS queue + DLQ with IAM policy output | | `service-alarm` | CloudWatch alarms for ECS service | | `platform-data` | Read-only data sources for shared infra | diff --git a/docs/app-yaml-reference.md b/docs/app-yaml-reference.md index 1575b08..17550fd 100644 --- a/docs/app-yaml-reference.md +++ b/docs/app-yaml-reference.md @@ -139,17 +139,17 @@ resources: DynamoDB and PostgreSQL entries can coexist in the same `databases` list. Entries without `engine` (or with `engine: dynamodb`) use the DynamoDB module. Entries with `engine: postgres` or `engine: postgresql` use the RDS module. -RDS instances use `manage_master_user_password = true`, which stores the auto-generated master password in Secrets Manager. The ECS task role automatically receives IAM policies for `rds-db:connect` and `secretsmanager:GetSecretValue` on the password secret. +RDS instances generate a master password at creation time, stored in SSM Parameter Store at `/{project}/apps/{name}/db-master-password`. The ECS task role automatically receives IAM policies for `rds-db:connect` and `ssm:GetParameter` on the password parameter. #### secrets -Secrets Manager secrets. Value is set manually after creation. +SSM Parameter Store SecureString parameters. Value is set manually after creation. ```yaml resources: secrets: - name: api-key - env: API_KEY # injected via ECS secrets (ARN reference) + env: API_KEY # injected via ECS secrets (SSM parameter ARN reference) ``` #### queues @@ -369,7 +369,7 @@ Generated files have a `# GENERATED FROM app.yaml` marker. The script only overw | DynamoDB table | `javabin-{table_name}` | | SQS queue | `javabin-{queue_name}` | | RDS instance | `{db_name}` (identifier) | -| Secrets Manager | `javabin/{secret_name}` | +| SSM (secrets) | `/javabin/apps/{service}/{secret_name}` | | IAM task role | `javabin-{name}` | | CloudWatch logs | `/ecs/javabin/{name}` | | DNS record | `{routing.host}` | diff --git a/docs/reusable-modules.md b/docs/reusable-modules.md index dacb9a6..c422936 100644 --- a/docs/reusable-modules.md +++ b/docs/reusable-modules.md @@ -83,10 +83,10 @@ DynamoDB table with configurable keys, billing mode, TTL, PITR, encryption. ## service-secret -Secrets Manager secret. Value is set manually after creation. +SSM Parameter Store SecureString. Value is set manually after creation. -**Naming:** `{project}/{name}` -**Outputs:** `secret_arn`, `secret_name`, `access_policy_json` +**Naming:** `/{project}/apps/{service}/{name}` +**Outputs:** `parameter_arn`, `parameter_name`, `access_policy_json` ## service-queue @@ -114,11 +114,11 @@ RDS PostgreSQL instance in private subnets. | `multi_az` | false | | `deletion_protection` | true | -**Password:** Managed by AWS via `manage_master_user_password = true` (Secrets Manager). +**Password:** Generated once via `random_password`, stored in SSM at `/{project}/apps/{name}/db-master-password`. -**Outputs:** `endpoint`, `port`, `db_name`, `access_policy_json`, `security_group_id` +**Outputs:** `endpoint`, `port`, `db_name`, `password_parameter_arn`, `access_policy_json`, `security_group_id` -**Auto-wiring:** `access_policy_json` grants `rds-db:connect` + `secretsmanager:GetSecretValue`. Auto-attached to task role via `collect:access_policy_json`. +**Auto-wiring:** `access_policy_json` grants `rds-db:connect` + `ssm:GetParameter` on the password parameter. Auto-attached to task role via `collect:access_policy_json`. **app.yaml:** ```yaml diff --git a/scripts/registry.py b/scripts/registry.py index 067575e..cc6d768 100644 --- a/scripts/registry.py +++ b/scripts/registry.py @@ -302,6 +302,7 @@ "engine_filter": "postgres", "vars": { "name": "item:name", + "project": "yaml:name", "engine_version": "item:engine_version|default:16", "instance_class": "item:instance_class|default:db.t3.micro", "allocated_storage": "item:allocated_storage|default:20", @@ -340,12 +341,12 @@ }, "rename": "secret", "output_map": { - "secret_arn": "aws_secretsmanager_secret.{instance}.arn", + "parameter_arn": "aws_ssm_parameter.{instance}.arn", "access_policy_json": "data.aws_iam_policy_document.{instance}_access.json", }, "exports": { "access_policy_json": True, - "env_var": {"output": "secret_arn", "yaml_field": "env", "target": "secrets"}, + "env_var": {"output": "parameter_arn", "yaml_field": "env", "target": "secrets"}, }, }, { diff --git a/terraform/modules/README.md b/terraform/modules/README.md index c3faf55..b7bf372 100644 --- a/terraform/modules/README.md +++ b/terraform/modules/README.md @@ -21,7 +21,7 @@ module "app" { | [ecs-service](ecs-service/) | ECS Fargate service + task def + logs | `service_name` | | [service-bucket](service-bucket/) | S3 bucket with standard security config | `bucket_name`, `access_policy_json` | | [service-database](service-database/) | DynamoDB table | `table_name`, `access_policy_json` | -| [service-secret](service-secret/) | Secrets Manager secret | `secret_arn`, `access_policy_json` | +| [service-secret](service-secret/) | SSM Parameter Store SecureString | `parameter_arn`, `access_policy_json` | | [service-queue](service-queue/) | SQS queue + DLQ | `queue_url`, `access_policy_json` | | [service-alarm](service-alarm/) | CloudWatch alarms for ECS | `cpu_alarm_arn` | | [app-stack](app-stack/) | Golden path — reads `app.yaml`, creates everything | All of the above | @@ -33,4 +33,4 @@ Most apps should use `app-stack` only. It reads `app.yaml` from the repo root an ## Policy Composition -Resource modules (`service-bucket`, `service-database`, `service-secret`, `service-queue`) export `access_policy_json`. Pass these to `service-role` via `additional_policy_jsons` to compose least-privilege IAM. `app-stack` does this automatically. +Resource modules (`service-bucket`, `service-database`, `service-secret`, `service-queue`) export `access_policy_json`. Pass these to `service-role` via `additional_policy_jsons` to compose least-privilege IAM. The expand-modules pipeline does this automatically. diff --git a/terraform/modules/ecs-service/variables.tf b/terraform/modules/ecs-service/variables.tf index 029b5c9..1ef68a5 100644 --- a/terraform/modules/ecs-service/variables.tf +++ b/terraform/modules/ecs-service/variables.tf @@ -44,7 +44,7 @@ variable "environment" { } variable "secrets" { - description = "Secrets from Secrets Manager (name → ARN)" + description = "Secrets from SSM Parameter Store (name → ARN)" type = map(string) default = {} } diff --git a/terraform/modules/service-rds/main.tf b/terraform/modules/service-rds/main.tf index 05176e8..e2c935c 100644 --- a/terraform/modules/service-rds/main.tf +++ b/terraform/modules/service-rds/main.tf @@ -31,6 +31,35 @@ resource "aws_security_group_rule" "this" { security_group_id = aws_security_group.this.id } +################################################################################ +# Master password — generated once, stored in SSM +################################################################################ + +resource "random_password" "master" { + length = 32 + special = true + override_special = "!#$%&*()-_=+[]{}<>:?" +} + +resource "aws_ssm_parameter" "master_password" { + name = "/${var.project}/apps/${var.name}/db-master-password" + description = "RDS master password for ${var.name}" + type = "SecureString" + value = random_password.master.result + + tags = merge(var.tags, { + Name = "${var.name}-db-master-password" + }) + + lifecycle { + ignore_changes = [value] + } +} + +################################################################################ +# RDS Instance +################################################################################ + resource "aws_db_instance" "this" { identifier = var.name engine = "postgres" @@ -42,8 +71,7 @@ resource "aws_db_instance" "this" { db_name = replace(var.name, "-", "_") username = "postgres" - - manage_master_user_password = true + password = random_password.master.result db_subnet_group_name = aws_db_subnet_group.this.name vpc_security_group_ids = [aws_security_group.this.id] @@ -58,6 +86,10 @@ resource "aws_db_instance" "this" { tags = merge(var.tags, { Name = var.name }) + + lifecycle { + ignore_changes = [password] + } } ################################################################################ @@ -78,14 +110,13 @@ data "aws_iam_policy_document" "access" { } statement { - sid = "SecretsManagerRead" + sid = "SSMReadDBPassword" effect = "Allow" actions = [ - "secretsmanager:GetSecretValue", - "secretsmanager:DescribeSecret", + "ssm:GetParameter", ] resources = [ - aws_db_instance.this.master_user_secret[0].secret_arn, + aws_ssm_parameter.master_password.arn, ] } } diff --git a/terraform/modules/service-rds/outputs.tf b/terraform/modules/service-rds/outputs.tf index 536217c..f3ba129 100644 --- a/terraform/modules/service-rds/outputs.tf +++ b/terraform/modules/service-rds/outputs.tf @@ -13,6 +13,11 @@ output "db_name" { value = aws_db_instance.this.db_name } +output "password_parameter_arn" { + description = "SSM parameter ARN for the master password" + value = aws_ssm_parameter.master_password.arn +} + output "access_policy_json" { description = "IAM policy JSON granting access to this database" value = data.aws_iam_policy_document.access.json diff --git a/terraform/modules/service-rds/variables.tf b/terraform/modules/service-rds/variables.tf index 869a08c..a90b2aa 100644 --- a/terraform/modules/service-rds/variables.tf +++ b/terraform/modules/service-rds/variables.tf @@ -3,6 +3,11 @@ variable "name" { type = string } +variable "project" { + description = "Project name prefix (for SSM parameter path)" + type = string +} + variable "engine_version" { description = "PostgreSQL engine version" type = string diff --git a/terraform/modules/service-secret/main.tf b/terraform/modules/service-secret/main.tf index 94f789e..e55322a 100644 --- a/terraform/modules/service-secret/main.tf +++ b/terraform/modules/service-secret/main.tf @@ -1,15 +1,21 @@ ################################################################################ -# Secrets Manager Secret +# SSM Parameter (SecureString) — app secret ################################################################################ -resource "aws_secretsmanager_secret" "this" { - name = "${var.project}/${var.name}" +resource "aws_ssm_parameter" "this" { + name = "/${var.project}/apps/${var.service}/${var.name}" description = var.description + type = "SecureString" + value = "PLACEHOLDER" tags = { Name = "${var.project}-${var.name}" service = var.service } + + lifecycle { + ignore_changes = [value] + } } ################################################################################ @@ -18,13 +24,14 @@ resource "aws_secretsmanager_secret" "this" { data "aws_iam_policy_document" "access" { statement { - sid = "SecretRead" + sid = "SSMSecretRead" effect = "Allow" actions = [ - "secretsmanager:GetSecretValue", + "ssm:GetParameter", + "ssm:GetParameters", ] resources = [ - aws_secretsmanager_secret.this.arn, + aws_ssm_parameter.this.arn, ] } } diff --git a/terraform/modules/service-secret/outputs.tf b/terraform/modules/service-secret/outputs.tf index 146430d..32440eb 100644 --- a/terraform/modules/service-secret/outputs.tf +++ b/terraform/modules/service-secret/outputs.tf @@ -1,11 +1,11 @@ -output "secret_arn" { - description = "Secret ARN (pass to ECS task definition secrets map)" - value = aws_secretsmanager_secret.this.arn +output "parameter_arn" { + description = "SSM parameter ARN (pass to ECS task definition secrets map)" + value = aws_ssm_parameter.this.arn } -output "secret_name" { - description = "Secret name" - value = aws_secretsmanager_secret.this.name +output "parameter_name" { + description = "SSM parameter name" + value = aws_ssm_parameter.this.name } output "access_policy_json" { diff --git a/terraform/modules/service-secret/variables.tf b/terraform/modules/service-secret/variables.tf index e274f43..77dc003 100644 --- a/terraform/modules/service-secret/variables.tf +++ b/terraform/modules/service-secret/variables.tf @@ -1,5 +1,5 @@ variable "name" { - description = "Secret purpose (e.g. 'slack-token'). Secret name: {project}/{name}" + description = "Secret purpose (e.g. 'slack-token'). Parameter name: /{project}/apps/{service}/{name}" type = string } diff --git a/terraform/org/identity-center.tf b/terraform/org/identity-center.tf index 83e4faa..c56f1f9 100644 --- a/terraform/org/identity-center.tf +++ b/terraform/org/identity-center.tf @@ -101,8 +101,8 @@ resource "aws_ssoadmin_permission_set_inline_policy" "developer" { "dynamodb:DescribeTable", "sqs:GetQueueAttributes", "sqs:ListQueues", - "secretsmanager:DescribeSecret", - "secretsmanager:ListSecrets", + "ssm:DescribeParameters", + "ssm:GetParametersByPath", "logs:GetLogEvents", "logs:FilterLogEvents", "logs:DescribeLogGroups", diff --git a/terraform/platform/iam/main.tf b/terraform/platform/iam/main.tf index 4b9c098..e357bdb 100644 --- a/terraform/platform/iam/main.tf +++ b/terraform/platform/iam/main.tf @@ -894,11 +894,12 @@ resource "aws_iam_role_policy" "ecs_execution_secrets" { Version = "2012-10-17" Statement = [ { + Sid = "SSMAppSecrets" Effect = "Allow" Action = [ - "secretsmanager:GetSecretValue", + "ssm:GetParameters", ] - Resource = "arn:aws:secretsmanager:${var.region}:${var.aws_account_id}:secret:${var.project}/*" + Resource = "arn:aws:ssm:${var.region}:${var.aws_account_id}:parameter/${var.project}/apps/*" } ] })