diff --git a/databases/rds-postgres-db/README.md b/databases/rds-postgres-db/README.md index 74de90a..8b7a857 100644 --- a/databases/rds-postgres-db/README.md +++ b/databases/rds-postgres-db/README.md @@ -115,6 +115,22 @@ This service requires minimal AWS permissions compared to `rds-postgres-server`. No RDS, EC2, or S3 permissions are needed. +The `requirements/` Terraform module creates a dedicated IAM role +(`nullplatform--rds-postgres-db-role`) holding this policy, +with a trust policy allowing the nullplatform agent role to `sts:AssumeRole` +on it. Pass `cluster_name` (required) and optionally `agent_role_arn` +(defaults to `nullplatform--agent-role`) when applying it. +Granting the agent itself permission to assume this role is handled +separately, outside this module. + +This role and its policy are shared per **cluster**, not per linked +`rds-postgres-server` instance — the `GetSecretValue` grant is scoped to the +`nullplatform/rds/*` secret-name prefix (every master secret in the cluster +following that naming convention), not to the single secret this particular +service instance's link actually uses. Anything that assumes this role can +read the master password of any `rds-postgres-server` in the cluster, not +just the linked one. + ### Runtime Dependencies These tools are required inside the agent container: diff --git a/databases/rds-postgres-db/requirements/.terraform.lock.hcl b/databases/rds-postgres-db/requirements/.terraform.lock.hcl new file mode 100644 index 0000000..f8d1ed5 --- /dev/null +++ b/databases/rds-postgres-db/requirements/.terraform.lock.hcl @@ -0,0 +1,25 @@ +# This file is maintained automatically by "tofu init". +# Manual edits may be lost in future updates. + +provider "registry.opentofu.org/hashicorp/aws" { + version = "6.53.0" + constraints = ">= 5.0.0" + hashes = [ + "h1:k4vYcdMr0yU8bknkp6E4dfD4RjXzFFcJ/6G5oS6TiSY=", + "zh:03fb02e200242a11252912d04be8da8eb80a72c06bfc9f4b73a8e97ad2bea21c", + "zh:19411bbcb38cf2644d0a426b52b8f28a29464a1749f5db713b80b443e706d8b8", + "zh:3ad53edba021e4a02415e079de846d2c385964e540b401801c7fd309f88b6b69", + "zh:4661891cb13b70df47f4a5913336c6b4ee81e0a72e22abba5561c0eb9e535f87", + "zh:4ea6ca42462e0377ce4ca50faef4b28a7059142eb88199fa966280b9307b525f", + "zh:6ce7d8598c2664cd3fa765ecebb564897910c7f32fb1139e8213b7d0fc5b86fc", + "zh:6e651398e2fe03b60a1cad41f45060838d47e26463317b34f644943e6e9ce760", + "zh:745d1c6b9c49cec684003fddc8aee4a99b8595cb9a7f1898dac5ee26d369b147", + "zh:7f85f9f0f523c2d220d93b892bae825ef4bca4a187c26a1207d40e3eb7a3693b", + "zh:a9a6c4f35d75b4f7511742d5ba3f02d1ad4dd720c5208c98fa85b47e5e37372b", + "zh:b306267308de2d1ef094702002417baf17359a2f8b09f3e1c9d557fa153506be", + "zh:d1ba9d27b28bb6b356b141b7d5015a37a78d92fb0ce715e13df6e8d98533ef46", + "zh:e78be305a8e0550a09ced9eaa6f5f98060c53079cb1d36cd904eb7afccf09138", + "zh:e9357d850c476ac35f3358ff102df7bce23bc303f87a77e0ecff0a6c308039bc", + "zh:f0918349619590f9f4213a86b74ecf8fa55f44971991c7ff2b460a76a6ae20c6", + ] +} diff --git a/databases/rds-postgres-db/requirements/data.tf b/databases/rds-postgres-db/requirements/data.tf new file mode 100644 index 0000000..8fc4b38 --- /dev/null +++ b/databases/rds-postgres-db/requirements/data.tf @@ -0,0 +1 @@ +data "aws_caller_identity" "current" {} diff --git a/databases/rds-postgres-db/requirements/locals.tf b/databases/rds-postgres-db/requirements/locals.tf new file mode 100644 index 0000000..3cc3f89 --- /dev/null +++ b/databases/rds-postgres-db/requirements/locals.tf @@ -0,0 +1,12 @@ +locals { + iam_module_name = "requirements-rds-postgres-db" + iam_create = var.iam_create_role + + role_name = var.role_name != "" ? var.role_name : "nullplatform-${var.cluster_name}-rds-postgres-db-role" + agent_role_arn = var.agent_role_arn != "" ? var.agent_role_arn : "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/nullplatform-${var.cluster_name}-agent-role" + + iam_default_tags = merge(var.iam_resource_tags_json, { + ManagedBy = "rds-postgres-db" + Module = local.iam_module_name + }) +} diff --git a/databases/rds-postgres-db/requirements/main.tf b/databases/rds-postgres-db/requirements/main.tf new file mode 100644 index 0000000..6477d5c --- /dev/null +++ b/databases/rds-postgres-db/requirements/main.tf @@ -0,0 +1,47 @@ +################################################################################ +# Permissions role — assumed by the nullplatform agent role (sts:AssumeRole) +################################################################################ + +resource "aws_iam_role" "nullplatform_rds_postgres_db" { + count = local.iam_create ? 1 : 0 + + name = local.role_name + description = "Permissions role assumed by the nullplatform agent role for rds-postgres-db in cluster ${var.cluster_name}" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { AWS = concat([local.agent_role_arn], var.additional_agent_role_arns) } + Action = "sts:AssumeRole" + }] + }) + + tags = local.iam_default_tags +} + +################################################################################ +# Secrets Manager IAM policy — read-only access to the RDS master password +################################################################################ + +resource "aws_iam_policy" "nullplatform_rds_postgres_db_secretsmanager_policy" { + count = local.iam_create ? 1 : 0 + + name = "nullplatform-${var.cluster_name}-rds-postgres-db-secretsmanager-policy" + description = "Policy for reading the RDS master password from Secrets Manager" + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = "secretsmanager:GetSecretValue" + Resource = "arn:aws:secretsmanager:*:${data.aws_caller_identity.current.account_id}:secret:nullplatform/rds/*" + }] + }) +} + +resource "aws_iam_role_policy_attachment" "rds_postgres_db_secretsmanager" { + count = local.iam_create ? 1 : 0 + role = aws_iam_role.nullplatform_rds_postgres_db[0].name + policy_arn = aws_iam_policy.nullplatform_rds_postgres_db_secretsmanager_policy[0].arn +} diff --git a/databases/rds-postgres-db/requirements/output.tf b/databases/rds-postgres-db/requirements/output.tf new file mode 100644 index 0000000..f75ac5a --- /dev/null +++ b/databases/rds-postgres-db/requirements/output.tf @@ -0,0 +1,19 @@ +output "permissions_role_arn" { + description = "ARN of the rds-postgres-db permissions role assumed by the nullplatform agent role. Pass to the agent (assume_role_arns)." + value = local.iam_create ? aws_iam_role.nullplatform_rds_postgres_db[0].arn : "" +} + +output "permissions_role_name" { + description = "Name of the rds-postgres-db permissions role" + value = local.iam_create ? aws_iam_role.nullplatform_rds_postgres_db[0].name : "" +} + +output "permissions_role_id" { + description = "ID of the rds-postgres-db permissions role" + value = local.iam_create ? aws_iam_role.nullplatform_rds_postgres_db[0].id : "" +} + +output "secretsmanager_policy_arn" { + description = "ARN of the Secrets Manager read policy" + value = local.iam_create ? aws_iam_policy.nullplatform_rds_postgres_db_secretsmanager_policy[0].arn : "" +} diff --git a/databases/rds-postgres-db/requirements/variables.tf b/databases/rds-postgres-db/requirements/variables.tf new file mode 100644 index 0000000..a0f3af7 --- /dev/null +++ b/databases/rds-postgres-db/requirements/variables.tf @@ -0,0 +1,44 @@ +variable "cluster_name" { + description = "Name of the cluster this bootstrap run is for. Used to derive the permissions role name, the policy name, and the default agent role ARN." + type = string +} + +variable "agent_role_arn" { + description = "ARN of the primary nullplatform agent IAM role allowed to assume this permissions role via sts:AssumeRole, and always a trusted principal of the role's trust policy. Defaults (when empty) to the conventional agent role for the cluster: arn:aws:iam:::role/nullplatform--agent-role." + type = string + default = "" + + validation { + condition = var.agent_role_arn == "" || can(regex("^arn:aws:iam::[0-9]{12}:role/.+", var.agent_role_arn)) + error_message = "agent_role_arn must be empty (to use the derived default) or match arn:aws:iam:::role/" + } +} + +variable "additional_agent_role_arns" { + description = "Extra IAM role ARNs allowed to assume this permissions role, appended to agent_role_arn in the trust policy. Defaults to none." + type = list(string) + default = [] + + validation { + condition = alltrue([for arn in var.additional_agent_role_arns : can(regex("^arn:aws:iam::[0-9]{12}:role/.+", arn))]) + error_message = "each additional_agent_role_arns entry must match arn:aws:iam:::role/" + } +} + +variable "role_name" { + description = "Override for the permissions IAM role name. Defaults to nullplatform-{cluster_name}-rds-postgres-db-role." + type = string + default = "" +} + +variable "iam_create_role" { + description = "Whether to create the permissions role and its policy. When false, the module produces no resources." + type = bool + default = true +} + +variable "iam_resource_tags_json" { + description = "Tags to apply to IAM resources created by this module." + type = map(string) + default = {} +} diff --git a/databases/rds-postgres-db/requirements/versions.tf b/databases/rds-postgres-db/requirements/versions.tf new file mode 100644 index 0000000..da078fc --- /dev/null +++ b/databases/rds-postgres-db/requirements/versions.tf @@ -0,0 +1,8 @@ +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } +} diff --git a/databases/rds-postgres-server/README.md b/databases/rds-postgres-server/README.md index 4f40ce4..a4b6aea 100644 --- a/databases/rds-postgres-server/README.md +++ b/databases/rds-postgres-server/README.md @@ -109,7 +109,13 @@ The agent executing this service needs the following IAM permissions (see `requi - **S3**: Full lifecycle on the `np-service-` bucket - **IAM**: `CreateServiceLinkedRole` (for RDS) -The `requirements/` Terraform module can be used to create and attach the necessary IAM policies to an existing role. +The `requirements/` Terraform module creates a dedicated IAM role +(`nullplatform--rds-postgres-server-role`) holding these +policies, with a trust policy allowing the nullplatform agent role to +`sts:AssumeRole` on it. Pass `cluster_name` (required) and optionally +`agent_role_arn` (defaults to `nullplatform--agent-role`) when +applying it. Granting the agent itself permission to assume this role is +handled separately, outside this module. ### Runtime Dependencies diff --git a/databases/rds-postgres-server/requirements/.terraform.lock.hcl b/databases/rds-postgres-server/requirements/.terraform.lock.hcl new file mode 100644 index 0000000..f8d1ed5 --- /dev/null +++ b/databases/rds-postgres-server/requirements/.terraform.lock.hcl @@ -0,0 +1,25 @@ +# This file is maintained automatically by "tofu init". +# Manual edits may be lost in future updates. + +provider "registry.opentofu.org/hashicorp/aws" { + version = "6.53.0" + constraints = ">= 5.0.0" + hashes = [ + "h1:k4vYcdMr0yU8bknkp6E4dfD4RjXzFFcJ/6G5oS6TiSY=", + "zh:03fb02e200242a11252912d04be8da8eb80a72c06bfc9f4b73a8e97ad2bea21c", + "zh:19411bbcb38cf2644d0a426b52b8f28a29464a1749f5db713b80b443e706d8b8", + "zh:3ad53edba021e4a02415e079de846d2c385964e540b401801c7fd309f88b6b69", + "zh:4661891cb13b70df47f4a5913336c6b4ee81e0a72e22abba5561c0eb9e535f87", + "zh:4ea6ca42462e0377ce4ca50faef4b28a7059142eb88199fa966280b9307b525f", + "zh:6ce7d8598c2664cd3fa765ecebb564897910c7f32fb1139e8213b7d0fc5b86fc", + "zh:6e651398e2fe03b60a1cad41f45060838d47e26463317b34f644943e6e9ce760", + "zh:745d1c6b9c49cec684003fddc8aee4a99b8595cb9a7f1898dac5ee26d369b147", + "zh:7f85f9f0f523c2d220d93b892bae825ef4bca4a187c26a1207d40e3eb7a3693b", + "zh:a9a6c4f35d75b4f7511742d5ba3f02d1ad4dd720c5208c98fa85b47e5e37372b", + "zh:b306267308de2d1ef094702002417baf17359a2f8b09f3e1c9d557fa153506be", + "zh:d1ba9d27b28bb6b356b141b7d5015a37a78d92fb0ce715e13df6e8d98533ef46", + "zh:e78be305a8e0550a09ced9eaa6f5f98060c53079cb1d36cd904eb7afccf09138", + "zh:e9357d850c476ac35f3358ff102df7bce23bc303f87a77e0ecff0a6c308039bc", + "zh:f0918349619590f9f4213a86b74ecf8fa55f44971991c7ff2b460a76a6ae20c6", + ] +} diff --git a/databases/rds-postgres-server/requirements/data.tf b/databases/rds-postgres-server/requirements/data.tf new file mode 100644 index 0000000..8fc4b38 --- /dev/null +++ b/databases/rds-postgres-server/requirements/data.tf @@ -0,0 +1 @@ +data "aws_caller_identity" "current" {} diff --git a/databases/rds-postgres-server/requirements/locals.tf b/databases/rds-postgres-server/requirements/locals.tf new file mode 100644 index 0000000..7ad074e --- /dev/null +++ b/databases/rds-postgres-server/requirements/locals.tf @@ -0,0 +1,12 @@ +locals { + iam_module_name = "requirements-rds-postgres-server" + iam_create = var.iam_create_role + + role_name = var.role_name != "" ? var.role_name : "nullplatform-${var.cluster_name}-rds-postgres-server-role" + agent_role_arn = var.agent_role_arn != "" ? var.agent_role_arn : "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/nullplatform-${var.cluster_name}-agent-role" + + iam_default_tags = merge(var.iam_resource_tags_json, { + ManagedBy = "rds-postgres-server" + Module = local.iam_module_name + }) +} diff --git a/databases/rds-postgres-server/requirements/main.tf b/databases/rds-postgres-server/requirements/main.tf index c5cb268..eb4448e 100644 --- a/databases/rds-postgres-server/requirements/main.tf +++ b/databases/rds-postgres-server/requirements/main.tf @@ -1,29 +1,51 @@ ################################################################################ -# Policy attachments (only when role_name is provided) +# Permissions role — assumed by the nullplatform agent role (sts:AssumeRole) +################################################################################ + +resource "aws_iam_role" "nullplatform_rds_postgres_server" { + count = local.iam_create ? 1 : 0 + + name = local.role_name + description = "Permissions role assumed by the nullplatform agent role for rds-postgres-server in cluster ${var.cluster_name}" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { AWS = concat([local.agent_role_arn], var.additional_agent_role_arns) } + Action = "sts:AssumeRole" + }] + }) + + tags = local.iam_default_tags +} + +################################################################################ +# Policy attachments ################################################################################ resource "aws_iam_role_policy_attachment" "rds" { - count = var.role_name != null ? 1 : 0 - role = var.role_name - policy_arn = aws_iam_policy.nullplatform_rds_policy.arn + count = local.iam_create ? 1 : 0 + role = aws_iam_role.nullplatform_rds_postgres_server[0].name + policy_arn = aws_iam_policy.nullplatform_rds_policy[0].arn } resource "aws_iam_role_policy_attachment" "rds_sg" { - count = var.role_name != null ? 1 : 0 - role = var.role_name - policy_arn = aws_iam_policy.nullplatform_rds_sg_policy.arn + count = local.iam_create ? 1 : 0 + role = aws_iam_role.nullplatform_rds_postgres_server[0].name + policy_arn = aws_iam_policy.nullplatform_rds_sg_policy[0].arn } resource "aws_iam_role_policy_attachment" "rds_secretsmanager" { - count = var.role_name != null ? 1 : 0 - role = var.role_name - policy_arn = aws_iam_policy.nullplatform_rds_secretsmanager_policy.arn + count = local.iam_create ? 1 : 0 + role = aws_iam_role.nullplatform_rds_postgres_server[0].name + policy_arn = aws_iam_policy.nullplatform_rds_secretsmanager_policy[0].arn } resource "aws_iam_role_policy_attachment" "rds_s3" { - count = var.role_name != null ? 1 : 0 - role = var.role_name - policy_arn = aws_iam_policy.nullplatform_rds_s3_policy.arn + count = local.iam_create ? 1 : 0 + role = aws_iam_role.nullplatform_rds_postgres_server[0].name + policy_arn = aws_iam_policy.nullplatform_rds_s3_policy[0].arn } ################################################################################ @@ -32,7 +54,9 @@ resource "aws_iam_role_policy_attachment" "rds_s3" { # Grant permissions to manage RDS instances and subnet groups resource "aws_iam_policy" "nullplatform_rds_policy" { - name = "nullplatform_${var.name}_rds_policy" + count = local.iam_create ? 1 : 0 + + name = "nullplatform-${var.cluster_name}-rds-policy" description = "Policy for managing RDS instances and subnet groups" policy = jsonencode({ @@ -71,7 +95,9 @@ resource "aws_iam_policy" "nullplatform_rds_policy" { # Grant permissions to manage EC2 security groups for RDS resource "aws_iam_policy" "nullplatform_rds_sg_policy" { - name = "nullplatform_${var.name}_rds_sg_policy" + count = local.iam_create ? 1 : 0 + + name = "nullplatform-${var.cluster_name}-rds-sg-policy" description = "Policy for managing EC2 security groups for RDS" policy = jsonencode({ @@ -106,7 +132,9 @@ resource "aws_iam_policy" "nullplatform_rds_sg_policy" { # Grant permissions to manage the per-link S3 bucket used to store tofu state resource "aws_iam_policy" "nullplatform_rds_s3_policy" { - name = "nullplatform_${var.name}_rds_s3_policy" + count = local.iam_create ? 1 : 0 + + name = "nullplatform-${var.cluster_name}-rds-s3-policy" description = "Policy for managing per-service S3 tfstate buckets (np-service-*)" policy = jsonencode({ @@ -141,7 +169,9 @@ resource "aws_iam_policy" "nullplatform_rds_s3_policy" { # Grant permissions to manage Secrets Manager secrets for RDS master password resource "aws_iam_policy" "nullplatform_rds_secretsmanager_policy" { - name = "nullplatform_${var.name}_rds_secretsmanager_policy" + count = local.iam_create ? 1 : 0 + + name = "nullplatform-${var.cluster_name}-rds-secretsmanager-policy" description = "Policy for managing Secrets Manager secrets for RDS master password" policy = jsonencode({ diff --git a/databases/rds-postgres-server/requirements/output.tf b/databases/rds-postgres-server/requirements/output.tf index e42de8b..041efb6 100644 --- a/databases/rds-postgres-server/requirements/output.tf +++ b/databases/rds-postgres-server/requirements/output.tf @@ -1,14 +1,29 @@ output "rds_policy_arn" { description = "ARN of the RDS management policy" - value = aws_iam_policy.nullplatform_rds_policy.arn + value = local.iam_create ? aws_iam_policy.nullplatform_rds_policy[0].arn : "" } output "rds_sg_policy_arn" { description = "ARN of the EC2 security group policy" - value = aws_iam_policy.nullplatform_rds_sg_policy.arn + value = local.iam_create ? aws_iam_policy.nullplatform_rds_sg_policy[0].arn : "" } output "rds_secretsmanager_policy_arn" { description = "ARN of the Secrets Manager policy" - value = aws_iam_policy.nullplatform_rds_secretsmanager_policy.arn + value = local.iam_create ? aws_iam_policy.nullplatform_rds_secretsmanager_policy[0].arn : "" +} + +output "permissions_role_arn" { + description = "ARN of the rds-postgres-server permissions role assumed by the nullplatform agent role. Pass to the agent (assume_role_arns)." + value = local.iam_create ? aws_iam_role.nullplatform_rds_postgres_server[0].arn : "" +} + +output "permissions_role_name" { + description = "Name of the rds-postgres-server permissions role" + value = local.iam_create ? aws_iam_role.nullplatform_rds_postgres_server[0].name : "" +} + +output "permissions_role_id" { + description = "ID of the rds-postgres-server permissions role" + value = local.iam_create ? aws_iam_role.nullplatform_rds_postgres_server[0].id : "" } diff --git a/databases/rds-postgres-server/requirements/variables.tf b/databases/rds-postgres-server/requirements/variables.tf index f760a50..83d0c00 100644 --- a/databases/rds-postgres-server/requirements/variables.tf +++ b/databases/rds-postgres-server/requirements/variables.tf @@ -1,10 +1,44 @@ -variable "name" { - description = "Unique identifier for policy naming. Must be unique per AWS account (IAM policy names are account-global). Example: \"prod-us-east-1\"." +variable "cluster_name" { + description = "Name of the cluster this bootstrap run is for. Used to derive the permissions role name, the policy names, and the default agent role ARN. Must be unique per AWS account (IAM policy names are account-global). Example: \"prod-us-east-1\"." type = string } +variable "agent_role_arn" { + description = "ARN of the primary nullplatform agent IAM role allowed to assume this permissions role via sts:AssumeRole, and always a trusted principal of the role's trust policy. Defaults (when empty) to the conventional agent role for the cluster: arn:aws:iam:::role/nullplatform--agent-role." + type = string + default = "" + + validation { + condition = var.agent_role_arn == "" || can(regex("^arn:aws:iam::[0-9]{12}:role/.+", var.agent_role_arn)) + error_message = "agent_role_arn must be empty (to use the derived default) or match arn:aws:iam:::role/" + } +} + +variable "additional_agent_role_arns" { + description = "Extra IAM role ARNs allowed to assume this permissions role, appended to agent_role_arn in the trust policy. Defaults to none." + type = list(string) + default = [] + + validation { + condition = alltrue([for arn in var.additional_agent_role_arns : can(regex("^arn:aws:iam::[0-9]{12}:role/.+", arn))]) + error_message = "each additional_agent_role_arns entry must match arn:aws:iam:::role/" + } +} + variable "role_name" { - description = "IAM role name to attach the RDS policies to. If set, Terraform manages the attachments and will detach them automatically on destroy." + description = "Override for the permissions IAM role name. Defaults to nullplatform-{cluster_name}-rds-postgres-server-role." type = string - default = null + default = "" +} + +variable "iam_create_role" { + description = "Whether to create the permissions role and its policies. When false, the module produces no resources." + type = bool + default = true +} + +variable "iam_resource_tags_json" { + description = "Tags to apply to IAM resources created by this module." + type = map(string) + default = {} } diff --git a/databases/rds-postgres-server/requirements/versions.tf b/databases/rds-postgres-server/requirements/versions.tf new file mode 100644 index 0000000..da078fc --- /dev/null +++ b/databases/rds-postgres-server/requirements/versions.tf @@ -0,0 +1,8 @@ +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } +} diff --git a/docs/superpowers/plans/2026-07-02-rds-postgres-assume-role.md b/docs/superpowers/plans/2026-07-02-rds-postgres-assume-role.md new file mode 100644 index 0000000..0e03ecd --- /dev/null +++ b/docs/superpowers/plans/2026-07-02-rds-postgres-assume-role.md @@ -0,0 +1,638 @@ +# RDS Postgres AssumeRole IAM Scaffolding Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give `rds-postgres-server` and `rds-postgres-db` each a dedicated IAM role, created by their `requirements/` Terraform modules, that the nullplatform agent can assume via `sts:AssumeRole` — replacing `rds-postgres-server`'s current pattern of attaching managed policies directly to an externally supplied role. + +**Architecture:** Both modules follow the `nullplatform/scopes-static-files` (`static-files/requirements/aws`) reference exactly: a `cluster_name`-keyed IAM role with a trust policy naming the agent role (`agent_role_arn`, defaulted by convention) as the sole principal allowed to assume it. Each service's already-documented minimal permissions (existing 4 policies for `rds-postgres-server`; one new Secrets Manager policy for `rds-postgres-db`) attach to that role. Neither module touches the agent's own role/permissions — granting the agent permission to assume these roles is out of scope, handled centrally elsewhere. + +**Tech Stack:** OpenTofu/Terraform (`hashicorp/aws` provider `>= 5.0`), no test harness or CI in this repo. + +**Design spec:** `docs/superpowers/specs/2026-07-02-rds-postgres-assume-role-design.md` + +## Global Constraints + +- Provider requirement: `hashicorp/aws >= 5.0` (matches the `scopes-static-files` reference). +- AWS-side resource `name` values (IAM role/policy names) use hyphens uniformly — e.g. `nullplatform--rds-postgres-server-role`. Terraform resource **labels** (the `resource "aws_iam_policy" "label"` identifier) use underscores, per normal HCL convention — only the AWS-facing `name` attribute changes. +- `` in naming is always the full service name (`rds-postgres-server` or `rds-postgres-db`), never abbreviated. +- No changes to `build_context`, any entrypoint script, or the `agent` module/role. Granting the agent permission to assume these new roles is explicitly out of scope. +- No new infrastructure-permission content beyond what's already documented: `rds-postgres-server` keeps its existing 4 policies' `Statement` blocks byte-for-byte identical (only their `name` changes); `rds-postgres-db` gets exactly the one Secrets Manager policy specified below, no more. +- Validate every module with `tofu init -backend=false && tofu validate` (no live AWS account/backend available in this environment) — there is no other test harness for these modules. + +--- + +### Task 1: Rewrite `rds-postgres-server/requirements/` to create its own permissions role + +**Files:** +- Modify: `databases/rds-postgres-server/requirements/variables.tf` +- Modify: `databases/rds-postgres-server/requirements/main.tf` +- Modify: `databases/rds-postgres-server/requirements/output.tf` +- Create: `databases/rds-postgres-server/requirements/data.tf` +- Create: `databases/rds-postgres-server/requirements/locals.tf` +- Create: `databases/rds-postgres-server/requirements/versions.tf` +- Modify: `databases/rds-postgres-server/README.md` + +**Interfaces:** +- Produces: `aws_iam_role.nullplatform_rds_postgres_server` (a `count`-based resource, index `[0]` when created), with outputs `permissions_role_arn`, `permissions_role_name`, `permissions_role_id`. Task 2 does not depend on this — the two modules are independent — but keep this shape in mind since it's the pattern Task 2 replicates. + +- [ ] **Step 1: Replace `variables.tf`** + +Current content renames `name` → `cluster_name` and drops the old external-role `role_name` semantics in favor of the reference's override variable. Replace the entire file: + +```hcl +variable "cluster_name" { + description = "Name of the cluster this bootstrap run is for. Used to derive the permissions role name, the policy names, and the default agent role ARN. Must be unique per AWS account (IAM policy names are account-global). Example: \"prod-us-east-1\"." + type = string +} + +variable "agent_role_arn" { + description = "ARN of the primary nullplatform agent IAM role allowed to assume this permissions role via sts:AssumeRole, and always a trusted principal of the role's trust policy. Defaults (when empty) to the conventional agent role for the cluster: arn:aws:iam:::role/nullplatform--agent-role." + type = string + default = "" + + validation { + condition = var.agent_role_arn == "" || can(regex("^arn:aws:iam::[0-9]{12}:role/.+", var.agent_role_arn)) + error_message = "agent_role_arn must be empty (to use the derived default) or match arn:aws:iam:::role/" + } +} + +variable "additional_agent_role_arns" { + description = "Extra IAM role ARNs allowed to assume this permissions role, appended to agent_role_arn in the trust policy. Defaults to none." + type = list(string) + default = [] + + validation { + condition = alltrue([for arn in var.additional_agent_role_arns : can(regex("^arn:aws:iam::[0-9]{12}:role/.+", arn))]) + error_message = "each additional_agent_role_arns entry must match arn:aws:iam:::role/" + } +} + +variable "role_name" { + description = "Override for the permissions IAM role name. Defaults to nullplatform-{cluster_name}-rds-postgres-server-role." + type = string + default = "" +} + +variable "iam_create_role" { + description = "Whether to create the permissions role and its policies. When false, the module produces no resources." + type = bool + default = true +} + +variable "iam_resource_tags_json" { + description = "Tags to apply to IAM resources created by this module." + type = map(string) + default = {} +} +``` + +- [ ] **Step 2: Create `data.tf`** + +```hcl +data "aws_caller_identity" "current" {} +``` + +- [ ] **Step 3: Create `locals.tf`** + +```hcl +locals { + iam_module_name = "requirements-rds-postgres-server" + iam_create = var.iam_create_role + + role_name = var.role_name != "" ? var.role_name : "nullplatform-${var.cluster_name}-rds-postgres-server-role" + agent_role_arn = var.agent_role_arn != "" ? var.agent_role_arn : "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/nullplatform-${var.cluster_name}-agent-role" + + iam_default_tags = merge(var.iam_resource_tags_json, { + ManagedBy = "rds-postgres-server" + Module = local.iam_module_name + }) +} +``` + +- [ ] **Step 4: Create `versions.tf`** + +```hcl +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } +} +``` + +- [ ] **Step 5: Replace `main.tf`** + +Adds the permissions role and its trust policy; renames the 4 existing policies to the hyphenated, `cluster_name`-keyed convention (content of each `Statement` block is untouched — only `name` changes); repoints the 4 attachments at the new role. Replace the entire file: + +```hcl +################################################################################ +# Permissions role — assumed by the nullplatform agent role (sts:AssumeRole) +################################################################################ + +resource "aws_iam_role" "nullplatform_rds_postgres_server" { + count = local.iam_create ? 1 : 0 + + name = local.role_name + description = "Permissions role assumed by the nullplatform agent role for rds-postgres-server in cluster ${var.cluster_name}" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { AWS = concat([local.agent_role_arn], var.additional_agent_role_arns) } + Action = "sts:AssumeRole" + }] + }) + + tags = local.iam_default_tags +} + +################################################################################ +# Policy attachments +################################################################################ + +resource "aws_iam_role_policy_attachment" "rds" { + count = local.iam_create ? 1 : 0 + role = aws_iam_role.nullplatform_rds_postgres_server[0].name + policy_arn = aws_iam_policy.nullplatform_rds_policy[0].arn +} + +resource "aws_iam_role_policy_attachment" "rds_sg" { + count = local.iam_create ? 1 : 0 + role = aws_iam_role.nullplatform_rds_postgres_server[0].name + policy_arn = aws_iam_policy.nullplatform_rds_sg_policy[0].arn +} + +resource "aws_iam_role_policy_attachment" "rds_secretsmanager" { + count = local.iam_create ? 1 : 0 + role = aws_iam_role.nullplatform_rds_postgres_server[0].name + policy_arn = aws_iam_policy.nullplatform_rds_secretsmanager_policy[0].arn +} + +resource "aws_iam_role_policy_attachment" "rds_s3" { + count = local.iam_create ? 1 : 0 + role = aws_iam_role.nullplatform_rds_postgres_server[0].name + policy_arn = aws_iam_policy.nullplatform_rds_s3_policy[0].arn +} + +################################################################################ +# RDS IAM policy +################################################################################ + +# Grant permissions to manage RDS instances and subnet groups +resource "aws_iam_policy" "nullplatform_rds_policy" { + count = local.iam_create ? 1 : 0 + + name = "nullplatform-${var.cluster_name}-rds-policy" + description = "Policy for managing RDS instances and subnet groups" + + policy = jsonencode({ + "Version" : "2012-10-17", + "Statement" : [ + { + "Effect" : "Allow", + "Action" : [ + "rds:CreateDBInstance", + "rds:DeleteDBInstance", + "rds:ModifyDBInstance", + "rds:DescribeDBInstances", + "rds:CreateDBSubnetGroup", + "rds:DeleteDBSubnetGroup", + "rds:DescribeDBSubnetGroups", + "rds:ModifyDBSubnetGroup", + "rds:AddTagsToResource", + "rds:ListTagsForResource", + "rds:RemoveTagsFromResource", + "rds:DescribeDBParameterGroups", + "rds:DescribeDBParameters", + "rds:DescribeDBEngineVersions", + "rds:DescribeOrderableDBInstanceOptions", + "rds:DescribeOptionGroups", + "iam:CreateServiceLinkedRole" + ], + "Resource" : "*" + } + ] + }) +} + +################################################################################ +# EC2 Security Group IAM policy +################################################################################ + +# Grant permissions to manage EC2 security groups for RDS +resource "aws_iam_policy" "nullplatform_rds_sg_policy" { + count = local.iam_create ? 1 : 0 + + name = "nullplatform-${var.cluster_name}-rds-sg-policy" + description = "Policy for managing EC2 security groups for RDS" + + policy = jsonencode({ + "Version" : "2012-10-17", + "Statement" : [ + { + "Effect" : "Allow", + "Action" : [ + "ec2:CreateSecurityGroup", + "ec2:DeleteSecurityGroup", + "ec2:DescribeSecurityGroups", + "ec2:AuthorizeSecurityGroupIngress", + "ec2:RevokeSecurityGroupIngress", + "ec2:AuthorizeSecurityGroupEgress", + "ec2:RevokeSecurityGroupEgress", + "ec2:DescribeVpcs", + "ec2:DescribeVpcAttribute", + "ec2:DescribeSubnets", + "ec2:CreateTags", + "ec2:DescribeNetworkInterfaces", + "ec2:DescribeSecurityGroupRules" + ], + "Resource" : "*" + } + ] + }) +} + +################################################################################ +# S3 IAM policy (per-service tfstate buckets: np-service-) +################################################################################ + +# Grant permissions to manage the per-link S3 bucket used to store tofu state +resource "aws_iam_policy" "nullplatform_rds_s3_policy" { + count = local.iam_create ? 1 : 0 + + name = "nullplatform-${var.cluster_name}-rds-s3-policy" + description = "Policy for managing per-service S3 tfstate buckets (np-service-*)" + + policy = jsonencode({ + "Version" : "2012-10-17", + "Statement" : [ + { + "Effect" : "Allow", + "Action" : [ + "s3:CreateBucket", + "s3:HeadBucket", + "s3:PutBucketVersioning", + "s3:ListBucket", + "s3:ListBucketVersions", + "s3:GetObject", + "s3:PutObject", + "s3:DeleteObject", + "s3:DeleteObjectVersion", + "s3:DeleteBucket" + ], + "Resource" : [ + "arn:aws:s3:::np-service-*", + "arn:aws:s3:::np-service-*/*" + ] + } + ] + }) +} + +################################################################################ +# Secrets Manager IAM policy +################################################################################ + +# Grant permissions to manage Secrets Manager secrets for RDS master password +resource "aws_iam_policy" "nullplatform_rds_secretsmanager_policy" { + count = local.iam_create ? 1 : 0 + + name = "nullplatform-${var.cluster_name}-rds-secretsmanager-policy" + description = "Policy for managing Secrets Manager secrets for RDS master password" + + policy = jsonencode({ + "Version" : "2012-10-17", + "Statement" : [ + { + "Effect" : "Allow", + "Action" : [ + "secretsmanager:CreateSecret", + "secretsmanager:DeleteSecret", + "secretsmanager:DescribeSecret", + "secretsmanager:GetSecretValue", + "secretsmanager:PutSecretValue", + "secretsmanager:UpdateSecret", + "secretsmanager:TagResource", + "secretsmanager:UntagResource", + "secretsmanager:GetResourcePolicy", + "secretsmanager:ListSecretVersionIds" + ], + "Resource" : "*" + } + ] + }) +} +``` + +- [ ] **Step 6: Replace `output.tf`** + +Keeps the 3 existing policy ARN outputs, adds the 3 new role outputs. Replace the entire file: + +```hcl +output "rds_policy_arn" { + description = "ARN of the RDS management policy" + value = local.iam_create ? aws_iam_policy.nullplatform_rds_policy[0].arn : "" +} + +output "rds_sg_policy_arn" { + description = "ARN of the EC2 security group policy" + value = local.iam_create ? aws_iam_policy.nullplatform_rds_sg_policy[0].arn : "" +} + +output "rds_secretsmanager_policy_arn" { + description = "ARN of the Secrets Manager policy" + value = local.iam_create ? aws_iam_policy.nullplatform_rds_secretsmanager_policy[0].arn : "" +} + +output "permissions_role_arn" { + description = "ARN of the rds-postgres-server permissions role assumed by the nullplatform agent role. Pass to the agent (assume_role_arns)." + value = local.iam_create ? aws_iam_role.nullplatform_rds_postgres_server[0].arn : "" +} + +output "permissions_role_name" { + description = "Name of the rds-postgres-server permissions role" + value = local.iam_create ? aws_iam_role.nullplatform_rds_postgres_server[0].name : "" +} + +output "permissions_role_id" { + description = "ID of the rds-postgres-server permissions role" + value = local.iam_create ? aws_iam_role.nullplatform_rds_postgres_server[0].id : "" +} +``` + +- [ ] **Step 7: Validate the module** + +Run: +```bash +cd databases/rds-postgres-server/requirements && tofu init -backend=false && tofu validate +``` +Expected: `Success! The configuration is valid.` (init must succeed first — it downloads the `hashicorp/aws` provider; no backend or AWS credentials are needed since `-backend=false` skips backend init and `validate` doesn't call AWS APIs.) + +If it fails, read the error carefully — a `Reference to undeclared resource`/`variable` error means a rename in one file wasn't propagated to another; fix and re-run before moving on. + +- [ ] **Step 8: Update `databases/rds-postgres-server/README.md`** + +Find this block (the `### AWS IAM Permissions` subsection under `## Requirements`): + +```markdown +The `requirements/` Terraform module can be used to create and attach the necessary IAM policies to an existing role. +``` + +Replace it with: + +```markdown +The `requirements/` Terraform module creates a dedicated IAM role +(`nullplatform--rds-postgres-server-role`) holding these +policies, with a trust policy allowing the nullplatform agent role to +`sts:AssumeRole` on it. Pass `cluster_name` (required) and optionally +`agent_role_arn` (defaults to `nullplatform--agent-role`) when +applying it. Granting the agent itself permission to assume this role is +handled separately, outside this module. +``` + +- [ ] **Step 9: Commit** + +```bash +git add databases/rds-postgres-server/requirements databases/rds-postgres-server/README.md +git commit -m "feat(rds-postgres-server): create dedicated AssumeRole IAM role in requirements/" +``` + +--- + +### Task 2: Create `rds-postgres-db/requirements/` with its own permissions role + +**Files:** +- Create: `databases/rds-postgres-db/requirements/variables.tf` +- Create: `databases/rds-postgres-db/requirements/data.tf` +- Create: `databases/rds-postgres-db/requirements/locals.tf` +- Create: `databases/rds-postgres-db/requirements/versions.tf` +- Create: `databases/rds-postgres-db/requirements/main.tf` +- Create: `databases/rds-postgres-db/requirements/output.tf` +- Modify: `databases/rds-postgres-db/README.md` + +**Interfaces:** +- Consumes: nothing from Task 1 — independent module, same pattern replicated with `rds-postgres-db` naming instead of `rds-postgres-server`. +- Produces: `aws_iam_role.nullplatform_rds_postgres_db` (count-based, index `[0]`), `aws_iam_policy.nullplatform_rds_postgres_db_secretsmanager_policy` (count-based, index `[0]`), outputs `permissions_role_arn`, `permissions_role_name`, `permissions_role_id`, `secretsmanager_policy_arn`. + +- [ ] **Step 1: Create `variables.tf`** + +```hcl +variable "cluster_name" { + description = "Name of the cluster this bootstrap run is for. Used to derive the permissions role name, the policy name, and the default agent role ARN." + type = string +} + +variable "agent_role_arn" { + description = "ARN of the primary nullplatform agent IAM role allowed to assume this permissions role via sts:AssumeRole, and always a trusted principal of the role's trust policy. Defaults (when empty) to the conventional agent role for the cluster: arn:aws:iam:::role/nullplatform--agent-role." + type = string + default = "" + + validation { + condition = var.agent_role_arn == "" || can(regex("^arn:aws:iam::[0-9]{12}:role/.+", var.agent_role_arn)) + error_message = "agent_role_arn must be empty (to use the derived default) or match arn:aws:iam:::role/" + } +} + +variable "additional_agent_role_arns" { + description = "Extra IAM role ARNs allowed to assume this permissions role, appended to agent_role_arn in the trust policy. Defaults to none." + type = list(string) + default = [] + + validation { + condition = alltrue([for arn in var.additional_agent_role_arns : can(regex("^arn:aws:iam::[0-9]{12}:role/.+", arn))]) + error_message = "each additional_agent_role_arns entry must match arn:aws:iam:::role/" + } +} + +variable "role_name" { + description = "Override for the permissions IAM role name. Defaults to nullplatform-{cluster_name}-rds-postgres-db-role." + type = string + default = "" +} + +variable "iam_create_role" { + description = "Whether to create the permissions role and its policy. When false, the module produces no resources." + type = bool + default = true +} + +variable "iam_resource_tags_json" { + description = "Tags to apply to IAM resources created by this module." + type = map(string) + default = {} +} +``` + +- [ ] **Step 2: Create `data.tf`** + +```hcl +data "aws_caller_identity" "current" {} +``` + +- [ ] **Step 3: Create `locals.tf`** + +```hcl +locals { + iam_module_name = "requirements-rds-postgres-db" + iam_create = var.iam_create_role + + role_name = var.role_name != "" ? var.role_name : "nullplatform-${var.cluster_name}-rds-postgres-db-role" + agent_role_arn = var.agent_role_arn != "" ? var.agent_role_arn : "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/nullplatform-${var.cluster_name}-agent-role" + + iam_default_tags = merge(var.iam_resource_tags_json, { + ManagedBy = "rds-postgres-db" + Module = local.iam_module_name + }) +} +``` + +- [ ] **Step 4: Create `versions.tf`** + +```hcl +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } +} +``` + +- [ ] **Step 5: Create `main.tf`** + +The permissions role and trust policy, plus the one Secrets Manager policy this service's README already documents as required (`secretsmanager:GetSecretValue`), scoped to the `nullplatform/rds/*` secret-name prefix that `rds-postgres-server`'s `deployment/main.tf` actually uses (`name = "nullplatform/rds/${var.instance_name}/master"`) rather than `*`: + +```hcl +################################################################################ +# Permissions role — assumed by the nullplatform agent role (sts:AssumeRole) +################################################################################ + +resource "aws_iam_role" "nullplatform_rds_postgres_db" { + count = local.iam_create ? 1 : 0 + + name = local.role_name + description = "Permissions role assumed by the nullplatform agent role for rds-postgres-db in cluster ${var.cluster_name}" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { AWS = concat([local.agent_role_arn], var.additional_agent_role_arns) } + Action = "sts:AssumeRole" + }] + }) + + tags = local.iam_default_tags +} + +################################################################################ +# Secrets Manager IAM policy — read-only access to the RDS master password +################################################################################ + +resource "aws_iam_policy" "nullplatform_rds_postgres_db_secretsmanager_policy" { + count = local.iam_create ? 1 : 0 + + name = "nullplatform-${var.cluster_name}-rds-postgres-db-secretsmanager-policy" + description = "Policy for reading the RDS master password from Secrets Manager" + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = "secretsmanager:GetSecretValue" + Resource = "arn:aws:secretsmanager:*:${data.aws_caller_identity.current.account_id}:secret:nullplatform/rds/*" + }] + }) +} + +resource "aws_iam_role_policy_attachment" "rds_postgres_db_secretsmanager" { + count = local.iam_create ? 1 : 0 + role = aws_iam_role.nullplatform_rds_postgres_db[0].name + policy_arn = aws_iam_policy.nullplatform_rds_postgres_db_secretsmanager_policy[0].arn +} +``` + +- [ ] **Step 6: Create `output.tf`** + +```hcl +output "permissions_role_arn" { + description = "ARN of the rds-postgres-db permissions role assumed by the nullplatform agent role. Pass to the agent (assume_role_arns)." + value = local.iam_create ? aws_iam_role.nullplatform_rds_postgres_db[0].arn : "" +} + +output "permissions_role_name" { + description = "Name of the rds-postgres-db permissions role" + value = local.iam_create ? aws_iam_role.nullplatform_rds_postgres_db[0].name : "" +} + +output "permissions_role_id" { + description = "ID of the rds-postgres-db permissions role" + value = local.iam_create ? aws_iam_role.nullplatform_rds_postgres_db[0].id : "" +} + +output "secretsmanager_policy_arn" { + description = "ARN of the Secrets Manager read policy" + value = local.iam_create ? aws_iam_policy.nullplatform_rds_postgres_db_secretsmanager_policy[0].arn : "" +} +``` + +- [ ] **Step 7: Validate the module** + +Run: +```bash +cd databases/rds-postgres-db/requirements && tofu init -backend=false && tofu validate +``` +Expected: `Success! The configuration is valid.` + +- [ ] **Step 8: Update `databases/rds-postgres-db/README.md`** + +Find this block (the `### AWS IAM Permissions` subsection under `## Requirements`): + +```markdown +### AWS IAM Permissions + +This service requires minimal AWS permissions compared to `rds-postgres-server`. The agent only needs: + +- **Secrets Manager**: `GetSecretValue` — to retrieve the master PostgreSQL password from the ARN stored in service attributes + +No RDS, EC2, or S3 permissions are needed. +``` + +Replace it with: + +```markdown +### AWS IAM Permissions + +This service requires minimal AWS permissions compared to `rds-postgres-server`. The agent only needs: + +- **Secrets Manager**: `GetSecretValue` — to retrieve the master PostgreSQL password from the ARN stored in service attributes + +No RDS, EC2, or S3 permissions are needed. + +The `requirements/` Terraform module creates a dedicated IAM role +(`nullplatform--rds-postgres-db-role`) holding this policy, +with a trust policy allowing the nullplatform agent role to `sts:AssumeRole` +on it. Pass `cluster_name` (required) and optionally `agent_role_arn` +(defaults to `nullplatform--agent-role`) when applying it. +Granting the agent itself permission to assume this role is handled +separately, outside this module. +``` + +- [ ] **Step 9: Commit** + +```bash +git add databases/rds-postgres-db/requirements databases/rds-postgres-db/README.md +git commit -m "feat(rds-postgres-db): add requirements/ module with AssumeRole IAM role" +``` + +--- + +## Out of scope (do not implement as part of this plan) + +- Wiring `build_context` (or any entrypoint script) to call `sts assume-role` and export temporary credentials. +- Granting the agent permission to assume these new roles (`assume_role_arns` or equivalent on the agent's own module). +- Any change to the `agent` module/role itself. diff --git a/docs/superpowers/specs/2026-07-02-rds-postgres-assume-role-design.md b/docs/superpowers/specs/2026-07-02-rds-postgres-assume-role-design.md new file mode 100644 index 0000000..0a3e41d --- /dev/null +++ b/docs/superpowers/specs/2026-07-02-rds-postgres-assume-role-design.md @@ -0,0 +1,276 @@ +# AssumeRole IAM scaffolding for rds-postgres-server and rds-postgres-db + +**Date:** 2026-07-02 +**Status:** Approved for planning + +## 1. Context + +The nullplatform agent runs in EKS and authenticates to AWS via IRSA. Today, +`rds-postgres-server/requirements/` creates managed IAM policies and attaches +them **directly to an externally supplied role** (via the `role_name` +variable) — in practice, the agent's own role. AWS caps managed policies per +role at 10; each service adds ~3-5, which doesn't scale. + +The reference for the fix is `nullplatform/scopes-static-files` +(`static-files/requirements/aws`): a dedicated **permissions role** holds a +scope's policies, with a trust policy naming the agent's role as the only +principal allowed to `sts:AssumeRole` on it. The module creates the role and +outputs its ARN; it does **not** touch the agent's own role or permissions — +granting the agent itself permission to call `AssumeRole` on this ARN is +handled entirely outside the module (per its README: wired into the agent's +own `assume_role_arns`, by whatever stack composes agent + scope modules +together). + +This is the only source of truth for this design — an earlier internal +proposal (branch `feature/iam-assumerone-rds`, doc +`databases/rds-postgres-server/docs/iam-assume-role-proposal.md`) explored a +different, per-service-instance shape, but was superseded and is **not** +used here. + +We replicate the reference's mechanism for two of our own modules, using +`cluster_name` as the identifying variable — same role it plays in the +reference. (Note: the current `rds-postgres-server` variable `name`, example +value `"prod-us-east-1"`, already reads as a cluster/environment identifier, +so renaming it to `cluster_name` is a closer fit to its original intent.) + +## 2. Scope + +This work touches **only** two folders: + +- `databases/rds-postgres-server/requirements/` (existing, modified) +- `databases/rds-postgres-db/requirements/` (new) + +**In scope:** the AssumeRole mechanics — creating each module's dedicated IAM +role and trust policy, and outputting its ARN — plus attaching each service's +already-documented minimal permissions to that role (existing 4 policies for +`rds-postgres-server`, one new Secrets Manager policy for `rds-postgres-db`). + +**Out of scope (explicitly, "otro paso"):** +- Any change to `build_context` or other entrypoint scripts that would + actually call `aws sts assume-role` at runtime and export temporary + credentials. +- Granting the agent permission to assume these roles (wiring their ARNs into + the agent's own `assume_role_arns` or equivalent) — done centrally, outside + either module, per the reference architecture. +- Defining infrastructure-permission policies **beyond what each service's + README already documents as required**. `rds-postgres-server` keeps its 4 + existing policies (RDS, EC2/SG, Secrets Manager, S3) untouched in content — + they're just re-attached to the new role instead of an externally supplied + `role_name`. `rds-postgres-db` gets exactly one new policy — the + `secretsmanager:GetSecretValue` permission its README already documents as + needed — attached to its new role; nothing beyond that minimal, already- + documented requirement. + +## 3. Design + +### Common pattern (both modules) + +New variables (mirroring the reference): +- `cluster_name` (string, required) — identifies the cluster; drives the + role's default name and the default agent role ARN. +- `agent_role_arn` (string, optional, default `""`) — override for the agent + role ARN when it doesn't follow the `nullplatform--agent-role` + convention. Same validation regex as the reference + (`^arn:aws:iam::[0-9]{12}:role/.+`). +- `additional_agent_role_arns` (list(string), optional, default `[]`) — extra + trusted principals, appended to `agent_role_arn` in the trust policy. Same + validation as the reference. +- `role_name` (string, optional, default `""`) — override for the generated + role name. +- `iam_create_role` (bool, optional, default `true`) — when false, the module + creates nothing (matches the reference's escape hatch). +- `iam_resource_tags_json` (map(string), optional, default `{}`) — tags + applied to the role. + +New `data.tf`: +```hcl +data "aws_caller_identity" "current" {} +``` + +New `locals.tf`: +```hcl +locals { + iam_module_name = "requirements-" + iam_create = var.iam_create_role + + role_name = var.role_name != "" ? var.role_name : "nullplatform-${var.cluster_name}--role" + agent_role_arn = var.agent_role_arn != "" ? var.agent_role_arn : "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/nullplatform-${var.cluster_name}-agent-role" + + iam_default_tags = merge(var.iam_resource_tags_json, { + ManagedBy = "" + Module = local.iam_module_name + }) +} +``` +(`` is the full service name — `rds-postgres-server` or +`rds-postgres-db` — not an abbreviation, so names stay unambiguous if another +RDS engine (e.g. `rds-mysql-server`) is added later. Concretely: +`iam_module_name = "requirements-rds-postgres-server"` / +`"requirements-rds-postgres-db"`, and `role_name` defaults to +`"nullplatform-${var.cluster_name}-rds-postgres-server-role"` / +`"...-rds-postgres-db-role"`. All AWS-side `name` values use hyphens +uniformly, including the 4 existing `rds-postgres-server` policies, which +currently use underscores — see "rds-postgres-server specifics" below.) + +`main.tf` addition, both modules — the target role and trust policy (policy +attachments are module-specific, covered below): +```hcl +resource "aws_iam_role" "nullplatform_" { + count = local.iam_create ? 1 : 0 + + name = local.role_name + description = "Permissions role assumed by the nullplatform agent role for in cluster ${var.cluster_name}" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { AWS = concat([local.agent_role_arn], var.additional_agent_role_arns) } + Action = "sts:AssumeRole" + }] + }) + + tags = local.iam_default_tags +} +``` +(HCL resource **labels** use underscores per normal convention — concretely +`aws_iam_role.nullplatform_rds_postgres_server` and +`aws_iam_role.nullplatform_rds_postgres_db` — while the `name` attribute +value, which AWS sees, is the hyphenated `local.role_name`.) + +**Deviation from the reference:** the reference hardcodes `ManagedBy` to the +generic constant `"nullplatform-custom-scope-role"`. Here, `ManagedBy` is the +name of the service folder that actually creates and owns the resource — +`"rds-postgres-server"` or `"rds-postgres-db"` — a literal specific to each +module, same as `iam_module_name` already is. `Module` stays as the more +specific `requirements-` identifier for the exact submodule. + +New `versions.tf` (both modules, matching the reference — these modules have +no `providers.tf`/`backend.tf` today, they're consumed as modules by another +stack, same as `scopes-static-files`): +```hcl +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 5.0" + } + } +} +``` + +`output.tf` additions, both modules (naming matches the reference): +`permissions_role_arn`, `permissions_role_name`, `permissions_role_id` — +each `local.iam_create ? aws_iam_role.nullplatform_rds_postgres_server[0]. : ""` +(or `..._rds_postgres_db[0]` in the other module). + +### rds-postgres-server specifics + +- `variables.tf`: rename `name` → `cluster_name` (module has no live state + anywhere — confirmed safe, no deploy/import concerns); drop `role_name`'s + old meaning (previously nullable, pointed at an externally managed role) — + `role_name` is repurposed as the reference's override variable (see common + pattern above); add `agent_role_arn`, `additional_agent_role_arns`, + `iam_create_role`, `iam_resource_tags_json`. +- `main.tf`: the 4 existing `aws_iam_policy` resources get their `name` + changed to the hyphenated convention and re-keyed on `cluster_name` — no + content (Statement) changes: + - `nullplatform_${var.name}_rds_policy` → `nullplatform-${var.cluster_name}-rds-policy` + - `nullplatform_${var.name}_rds_sg_policy` → `nullplatform-${var.cluster_name}-rds-sg-policy` + - `nullplatform_${var.name}_rds_secretsmanager_policy` → `nullplatform-${var.cluster_name}-rds-secretsmanager-policy` + - `nullplatform_${var.name}_rds_s3_policy` → `nullplatform-${var.cluster_name}-rds-s3-policy` + + The 4 `aws_iam_role_policy_attachment` resources change their `count` from + `var.role_name != null ? 1 : 0` to `local.iam_create ? 1 : 0`, and attach to + `aws_iam_role.nullplatform_rds_postgres_server[0].name`. +- `output.tf`: keep the 4 existing policy ARN outputs, add the 3 new + `permissions_role_*` outputs. + +### rds-postgres-db specifics + +- Brand-new `requirements/` folder: `data.tf`, `locals.tf`, `main.tf`, + `variables.tf`, `output.tf`, `versions.tf` — the common pattern above, plus + one policy: + ```hcl + resource "aws_iam_policy" "nullplatform_rds_postgres_db_secretsmanager_policy" { + count = local.iam_create ? 1 : 0 + name = "nullplatform-${var.cluster_name}-rds-postgres-db-secretsmanager-policy" + description = "Policy for reading the RDS master password from Secrets Manager" + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Action = "secretsmanager:GetSecretValue" + Resource = "arn:aws:secretsmanager:*:${data.aws_caller_identity.current.account_id}:secret:nullplatform/rds/*" + }] + }) + } + + resource "aws_iam_role_policy_attachment" "rds_postgres_db_secretsmanager" { + count = local.iam_create ? 1 : 0 + role = aws_iam_role.nullplatform_rds_postgres_db[0].name + policy_arn = aws_iam_policy.nullplatform_rds_postgres_db_secretsmanager_policy[0].arn + } + ``` + The resource is scoped to the `nullplatform/rds/*` secret-name prefix that + `rds-postgres-server`'s `deployment/main.tf` actually uses + (`name = "nullplatform/rds/${var.instance_name}/master"`) and to this + account, instead of `*`. Region is left wildcarded since RDS instances + aren't pinned to one region across the fleet; the trailing `*` after the + secret name is required because Secrets Manager appends a random suffix to + the full ARN that isn't knowable ahead of time. This is intentionally + tighter than `rds-postgres-server`'s own existing Secrets Manager policy + (still `Resource = "*"`, untouched since it's out of scope), but there's no + reason to carry that permissiveness into a brand-new policy when we know + the exact naming convention. + + The policy name uses the full `rds-postgres-db` service name rather than + the bare `rds-*` pattern `rds-postgres-server`'s policies use — both + services can be deployed against the same `cluster_name`, and + `rds-postgres-server` already owns + `nullplatform--rds-secretsmanager-policy` for its own + (different, full-CRUD) Secrets Manager policy. Reusing that name for + `rds-postgres-db`'s policy collides in IAM (policy names are account-wide + unique) — confirmed by an actual `tofu apply` against a real AWS account + with both modules deployed together, which is exactly the kind of + cross-module collision `tofu validate` run per-module can't catch. +- `output.tf`: add a `secretsmanager_policy_arn` output alongside the 3 + `permissions_role_*` outputs, mirroring `rds-postgres-server`'s existing + `rds_secretsmanager_policy_arn` output. + +### Documentation + +- `databases/rds-postgres-server/README.md`: update the existing "AWS IAM + Permissions" section to mention the dedicated role and the + `cluster_name`/`agent_role_arn` variables. +- `databases/rds-postgres-db/README.md`: add a short "AWS IAM Permissions" + subsection under "Requirements" pointing at the new `requirements/` module + (mirroring how `rds-postgres-server`'s README already does this), noting it + creates the dedicated role and the `secretsmanager:GetSecretValue` policy. + +## 4. Testing / validation + +Neither module has a test harness or CI pipeline in this repo. Validation for +this change is: +- `tofu validate` (or `terraform validate`) in each `requirements/` folder — + catches syntax errors and invalid references without needing AWS + credentials. +- Manual review of the rendered `jsonencode(...)` trust policy document for + correctness (principal, action). + +A live `tofu plan`/`apply` isn't feasible here since these modules have no +backend/provider config of their own (they're consumed by another stack) and +we have no target AWS account in this session. + +## 5. Open follow-ups (not part of this change) + +- Grant the agent permission to actually assume these new roles — add their + ARNs to the agent's own `assume_role_arns` (or equivalent), wired by + whatever stack composes the agent and these service modules together. +- Wire `build_context` (or equivalent) in each service's entrypoint to call + `sts assume-role` using the new role's ARN output and export temporary + credentials before `tofu apply`. +- Decide where `cluster_name` and `agent_role_arn` actually get sourced from + when these modules are invoked (presumably the consuming stack/pipeline + already knows the cluster it's deploying into).