Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
8 changes: 4 additions & 4 deletions docs/app-yaml-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}` |
Expand Down
12 changes: 6 additions & 6 deletions docs/reusable-modules.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
5 changes: 3 additions & 2 deletions scripts/registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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"},
},
},
{
Expand Down
11 changes: 8 additions & 3 deletions terraform/lambda-src/team_provisioner/handler.py
Original file line number Diff line number Diff line change
Expand Up @@ -535,7 +535,8 @@ def _github_api(method, path, token, body=None):
def sync_github_team(team):
"""Create or update a GitHub team and sync its membership.

All members are added as maintainers (no role distinction).
Members get the role from their YAML entry (default: "member").
Only members with role: maintainer can manage the team on GitHub.
"""
team_name = team["name"]
token = _get_github_installation_token()
Expand Down Expand Up @@ -596,13 +597,17 @@ def sync_github_team(team):
)
continue
desired_users.add(github_user)
github_role = m.get("role", "member")
if github_role not in ("maintainer", "member"):
logger.warning("Invalid role '%s' for %s — defaulting to member", github_role, github_user)
github_role = "member"
_github_api(
"PUT",
f"/orgs/{GITHUB_ORG}/teams/{team_slug}/memberships/{github_user}",
token,
{"role": "maintainer"},
{"role": github_role},
)
logger.info("Set %s as maintainer in team %s", github_user, team_slug)
logger.info("Set %s as %s in team %s", github_user, github_role, team_slug)

# Remove members no longer in the team definition
for login in current_members - desired_users:
Expand Down
4 changes: 2 additions & 2 deletions terraform/modules/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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.
2 changes: 1 addition & 1 deletion terraform/modules/ecs-service/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}
}
Expand Down
43 changes: 37 additions & 6 deletions terraform/modules/service-rds/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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]
Expand All @@ -58,6 +86,10 @@ resource "aws_db_instance" "this" {
tags = merge(var.tags, {
Name = var.name
})

lifecycle {
ignore_changes = [password]
}
}

################################################################################
Expand All @@ -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,
]
}
}
5 changes: 5 additions & 0 deletions terraform/modules/service-rds/outputs.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 5 additions & 0 deletions terraform/modules/service-rds/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
19 changes: 13 additions & 6 deletions terraform/modules/service-secret/main.tf
Original file line number Diff line number Diff line change
@@ -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]
}
}

################################################################################
Expand All @@ -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,
]
}
}
12 changes: 6 additions & 6 deletions terraform/modules/service-secret/outputs.tf
Original file line number Diff line number Diff line change
@@ -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" {
Expand Down
2 changes: 1 addition & 1 deletion terraform/modules/service-secret/variables.tf
Original file line number Diff line number Diff line change
@@ -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
}

Expand Down
4 changes: 2 additions & 2 deletions terraform/org/identity-center.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
5 changes: 3 additions & 2 deletions terraform/platform/iam/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -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/*"
}
]
})
Expand Down