From 7afd03b24062df4e554e87f6311f979da0fd5e50 Mon Sep 17 00:00:00 2001 From: David Fernandez Date: Tue, 23 Jun 2026 18:10:50 -0300 Subject: [PATCH 1/5] feat(k8s/tofu): add IAM agent-permissions OpenTofu module Port the permissions-role half of tofu-modules .../aws/iam/agent into the k8s scope as a reusable module, mirroring lambda/scope/tofu/iam/modules. Creates an IAM role holding the agent workload policies (Route53, EKS, ELB, AVP) whose trust policy allows only the agent IRSA role (agent_role_arn) to assume it via sts:AssumeRole. The IRSA agent role itself is provisioned at cluster setup and stays out of scope. Co-Authored-By: Claude Opus 4.8 (1M context) --- k8s/scope/tofu/iam/modules/data.tf | 2 + k8s/scope/tofu/iam/modules/locals.tf | 17 ++ k8s/scope/tofu/iam/modules/main.tf | 197 ++++++++++++++++++++++++ k8s/scope/tofu/iam/modules/outputs.tf | 14 ++ k8s/scope/tofu/iam/modules/variables.tf | 38 +++++ k8s/scope/tofu/iam/modules/versions.tf | 10 ++ 6 files changed, 278 insertions(+) create mode 100644 k8s/scope/tofu/iam/modules/data.tf create mode 100644 k8s/scope/tofu/iam/modules/locals.tf create mode 100644 k8s/scope/tofu/iam/modules/main.tf create mode 100644 k8s/scope/tofu/iam/modules/outputs.tf create mode 100644 k8s/scope/tofu/iam/modules/variables.tf create mode 100644 k8s/scope/tofu/iam/modules/versions.tf diff --git a/k8s/scope/tofu/iam/modules/data.tf b/k8s/scope/tofu/iam/modules/data.tf new file mode 100644 index 00000000..038d1e22 --- /dev/null +++ b/k8s/scope/tofu/iam/modules/data.tf @@ -0,0 +1,2 @@ +data "aws_caller_identity" "current" {} +data "aws_region" "current" {} diff --git a/k8s/scope/tofu/iam/modules/locals.tf b/k8s/scope/tofu/iam/modules/locals.tf new file mode 100644 index 00000000..79be043e --- /dev/null +++ b/k8s/scope/tofu/iam/modules/locals.tf @@ -0,0 +1,17 @@ +locals { + # Module identifier + iam_module_name = "iam" + + # Whether resources are created + iam_create = var.iam_create_role + + # Derived names (overridable via variables) + permissions_role_name = var.permissions_role_name != "" ? var.permissions_role_name : "nullplatform-${var.cluster_name}-agent-permissions-role" + policies_name_prefix = var.policies_name_prefix != "" ? var.policies_name_prefix : "nullplatform_${var.cluster_name}" + + # Default tags applied to every IAM resource + iam_default_tags = merge(var.iam_resource_tags_json, { + ManagedBy = "custom-scope-role" + Module = local.iam_module_name + }) +} diff --git a/k8s/scope/tofu/iam/modules/main.tf b/k8s/scope/tofu/iam/modules/main.tf new file mode 100644 index 00000000..3e53a4e6 --- /dev/null +++ b/k8s/scope/tofu/iam/modules/main.tf @@ -0,0 +1,197 @@ +################################################################################ +# IAM permissions role assumed by the nullplatform agent role +# +# Holds the actual workload policies (Route53, EKS, ELB, AVP). Its trust policy +# trusts only the agent IRSA role, so the agent's IRSA token cannot exercise +# these permissions without first assuming this role (sts:AssumeRole). +# +# This is the "permissions role" half of the reference module +# tofu-modules/infrastructure/aws/iam/agent. The IRSA agent role itself is +# created once at cluster setup and is out of scope for this module. +################################################################################ + +resource "aws_iam_role" "nullplatform_agent_permissions" { + count = local.iam_create ? 1 : 0 + + name = local.permissions_role_name + description = "Permissions role assumed by the nullplatform agent role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { AWS = var.agent_role_arn } + Action = "sts:AssumeRole" + }] + }) + + tags = local.iam_default_tags +} + +################################################################################ +# Policy attachments +################################################################################ + +resource "aws_iam_role_policy_attachment" "permissions_route53" { + count = local.iam_create ? 1 : 0 + + role = aws_iam_role.nullplatform_agent_permissions[0].name + policy_arn = aws_iam_policy.nullplatform_route53_policy[0].arn +} + +resource "aws_iam_role_policy_attachment" "permissions_eks" { + count = local.iam_create ? 1 : 0 + + role = aws_iam_role.nullplatform_agent_permissions[0].name + policy_arn = aws_iam_policy.nullplatform_eks_policy[0].arn +} + +resource "aws_iam_role_policy_attachment" "permissions_elb" { + count = local.iam_create ? 1 : 0 + + role = aws_iam_role.nullplatform_agent_permissions[0].name + policy_arn = aws_iam_policy.nullplatform_elb_policy[0].arn +} + +resource "aws_iam_role_policy_attachment" "permissions_avp" { + count = local.iam_create ? 1 : 0 + + role = aws_iam_role.nullplatform_agent_permissions[0].name + policy_arn = aws_iam_policy.nullplatform_avp_policy[0].arn +} + +################################################################################ +# Route 53 IAM policy +# Manage Route 53 DNS records for service discovery. +################################################################################ + +resource "aws_iam_policy" "nullplatform_route53_policy" { + count = local.iam_create ? 1 : 0 + + name = "${local.policies_name_prefix}_route53_policy" + description = "Policy for managing Route 53 DNS records" + tags = local.iam_default_tags + policy = jsonencode({ + "Version" : "2012-10-17", + "Statement" : [ + { + "Effect" : "Allow", + "Action" : [ + "route53:ChangeResourceRecordSets", + "route53:ListResourceRecordSets", + "route53:GetHostedZone", + "route53:ListHostedZones", + "route53:ListHostedZonesByName" + ], + "Resource" : [ + "arn:aws:route53:::hostedzone/*" + ], + } + ] + }) +} + +################################################################################ +# Elastic Load Balancing (ELB) IAM policy +# Describe and monitor load balancers and target groups. +################################################################################ + +resource "aws_iam_policy" "nullplatform_elb_policy" { + count = local.iam_create ? 1 : 0 + + name = "${local.policies_name_prefix}_elb_policy" + description = "Policy for managing Elastic Load Balancing resources" + tags = local.iam_default_tags + policy = jsonencode( + { + "Version" : "2012-10-17", + "Statement" : [ + { + "Effect" : "Allow", + "Action" : [ + "elasticloadbalancing:DescribeLoadBalancers", + "elasticloadbalancing:DescribeTargetGroups" + ], + "Resource" : "*", + "Condition" : { + "StringEquals" : { + "aws:RequestedRegion" : [ + data.aws_region.current.region + ] + } + } + }, + { + "Effect" : "Allow", + "Action" : [ + "elasticloadbalancing:DescribeTargetHealth", + "elasticloadbalancing:DescribeListeners", + "elasticloadbalancing:DescribeRules" + ], + "Resource" : [ + "arn:aws:elasticloadbalancing:*:*:loadbalancer/app/k8s-nullplatform-*", + "arn:aws:elasticloadbalancing:*:*:targetgroup/k8s-nullplatform-*" + ], + } + ] + } + ) +} + +################################################################################ +# EKS IAM policy +# Describe and list EKS cluster resources. +################################################################################ + +resource "aws_iam_policy" "nullplatform_eks_policy" { + count = local.iam_create ? 1 : 0 + + name = "${local.policies_name_prefix}_eks_policy" + description = "Policy for managing EKS cluster resources" + tags = local.iam_default_tags + policy = jsonencode({ + "Version" : "2012-10-17", + "Statement" : [ + { + "Effect" : "Allow", + "Action" : [ + "eks:DescribeCluster", + "eks:ListClusters", + "eks:DescribeNodegroup", + "eks:ListNodegroups", + "eks:DescribeAddon", + "eks:ListAddons" + ], + "Resource" : [ + "arn:aws:eks:*:*:cluster/*", + "arn:aws:eks:*:*:nodegroup/*", + "arn:aws:eks:*:*:addon/*" + ], + } + ] + }) +} + +################################################################################ +# AVP (Amazon Verified Permissions) IAM policy +################################################################################ + +resource "aws_iam_policy" "nullplatform_avp_policy" { + count = local.iam_create ? 1 : 0 + + name = "${local.policies_name_prefix}_avp_policy" + description = "Policy for managing AVP resources" + tags = local.iam_default_tags + policy = jsonencode({ + "Version" : "2012-10-17", + "Statement" : [ + { + "Effect" : "Allow", + "Action" : [ + "verifiedpermissions:*" + ], + "Resource" : "*", + } + ] + }) +} diff --git a/k8s/scope/tofu/iam/modules/outputs.tf b/k8s/scope/tofu/iam/modules/outputs.tf new file mode 100644 index 00000000..2e750313 --- /dev/null +++ b/k8s/scope/tofu/iam/modules/outputs.tf @@ -0,0 +1,14 @@ +output "permissions_role_arn" { + description = "ARN of the permissions role assumed by the nullplatform agent role" + value = local.iam_create ? aws_iam_role.nullplatform_agent_permissions[0].arn : "" +} + +output "permissions_role_name" { + description = "Name of the permissions role" + value = local.iam_create ? aws_iam_role.nullplatform_agent_permissions[0].name : "" +} + +output "permissions_role_id" { + description = "ID of the permissions role" + value = local.iam_create ? aws_iam_role.nullplatform_agent_permissions[0].id : "" +} diff --git a/k8s/scope/tofu/iam/modules/variables.tf b/k8s/scope/tofu/iam/modules/variables.tf new file mode 100644 index 00000000..5bed5a83 --- /dev/null +++ b/k8s/scope/tofu/iam/modules/variables.tf @@ -0,0 +1,38 @@ +variable "agent_role_arn" { + description = "ARN of the nullplatform agent IRSA role allowed to assume this permissions role via sts:AssumeRole. This is the trusted principal of the role's trust policy." + type = string + + validation { + condition = can(regex("^arn:aws:iam::[0-9]{12}:role/.+", var.agent_role_arn)) + error_message = "agent_role_arn must match arn:aws:iam:::role/" + } +} + +variable "cluster_name" { + description = "Name of the cluster where the agent runs. Used to derive default resource names." + type = string +} + +variable "permissions_role_name" { + description = "Override for the permissions IAM role name. Defaults to nullplatform-{cluster_name}-agent-permissions-role." + type = string + default = "" +} + +variable "policies_name_prefix" { + description = "Override for the IAM policy name prefix. Defaults to nullplatform_{cluster_name}." + 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 = {} +} diff --git a/k8s/scope/tofu/iam/modules/versions.tf b/k8s/scope/tofu/iam/modules/versions.tf new file mode 100644 index 00000000..e54c7789 --- /dev/null +++ b/k8s/scope/tofu/iam/modules/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_providers { + aws = { + source = "hashicorp/aws" + # v6+ required: the ELB policy reads data.aws_region.current.region, + # which replaced the v5 `.name` attribute. + version = ">= 6.0" + } + } +} From c129ebdaa2583f4d4ead5a0d743fdc96c2102f62 Mon Sep 17 00:00:00 2001 From: David Fernandez Date: Wed, 24 Jun 2026 15:22:21 -0300 Subject: [PATCH 2/5] refactor(k8s/tofu): remove AVP policy from agent-permissions module Drop the verifiedpermissions:* policy and its role attachment from the agent permissions role. The role keeps only the Route53, EKS and ELB workload policies. Co-Authored-By: Claude Opus 4.8 (1M context) --- k8s/scope/tofu/iam/modules/main.tf | 33 +----------------------------- 1 file changed, 1 insertion(+), 32 deletions(-) diff --git a/k8s/scope/tofu/iam/modules/main.tf b/k8s/scope/tofu/iam/modules/main.tf index 3e53a4e6..6364d292 100644 --- a/k8s/scope/tofu/iam/modules/main.tf +++ b/k8s/scope/tofu/iam/modules/main.tf @@ -1,7 +1,7 @@ ################################################################################ # IAM permissions role assumed by the nullplatform agent role # -# Holds the actual workload policies (Route53, EKS, ELB, AVP). Its trust policy +# Holds the actual workload policies (Route53, EKS, ELB). Its trust policy # trusts only the agent IRSA role, so the agent's IRSA token cannot exercise # these permissions without first assuming this role (sts:AssumeRole). # @@ -53,13 +53,6 @@ resource "aws_iam_role_policy_attachment" "permissions_elb" { policy_arn = aws_iam_policy.nullplatform_elb_policy[0].arn } -resource "aws_iam_role_policy_attachment" "permissions_avp" { - count = local.iam_create ? 1 : 0 - - role = aws_iam_role.nullplatform_agent_permissions[0].name - policy_arn = aws_iam_policy.nullplatform_avp_policy[0].arn -} - ################################################################################ # Route 53 IAM policy # Manage Route 53 DNS records for service discovery. @@ -171,27 +164,3 @@ resource "aws_iam_policy" "nullplatform_eks_policy" { ] }) } - -################################################################################ -# AVP (Amazon Verified Permissions) IAM policy -################################################################################ - -resource "aws_iam_policy" "nullplatform_avp_policy" { - count = local.iam_create ? 1 : 0 - - name = "${local.policies_name_prefix}_avp_policy" - description = "Policy for managing AVP resources" - tags = local.iam_default_tags - policy = jsonencode({ - "Version" : "2012-10-17", - "Statement" : [ - { - "Effect" : "Allow", - "Action" : [ - "verifiedpermissions:*" - ], - "Resource" : "*", - } - ] - }) -} From d93c510ca861f81bf74be41fe63fefbba6ee5bb8 Mon Sep 17 00:00:00 2001 From: David Fernandez Date: Wed, 24 Jun 2026 17:45:42 -0300 Subject: [PATCH 3/5] refactor(k8s/tofu): move agent-permissions module to k8s/specs/tofu Co-Authored-By: Claude Opus 4.8 (1M context) --- k8s/{scope/tofu/iam/modules => specs/tofu}/data.tf | 0 k8s/{scope/tofu/iam/modules => specs/tofu}/locals.tf | 0 k8s/{scope/tofu/iam/modules => specs/tofu}/main.tf | 0 k8s/{scope/tofu/iam/modules => specs/tofu}/outputs.tf | 0 k8s/{scope/tofu/iam/modules => specs/tofu}/variables.tf | 0 k8s/{scope/tofu/iam/modules => specs/tofu}/versions.tf | 0 6 files changed, 0 insertions(+), 0 deletions(-) rename k8s/{scope/tofu/iam/modules => specs/tofu}/data.tf (100%) rename k8s/{scope/tofu/iam/modules => specs/tofu}/locals.tf (100%) rename k8s/{scope/tofu/iam/modules => specs/tofu}/main.tf (100%) rename k8s/{scope/tofu/iam/modules => specs/tofu}/outputs.tf (100%) rename k8s/{scope/tofu/iam/modules => specs/tofu}/variables.tf (100%) rename k8s/{scope/tofu/iam/modules => specs/tofu}/versions.tf (100%) diff --git a/k8s/scope/tofu/iam/modules/data.tf b/k8s/specs/tofu/data.tf similarity index 100% rename from k8s/scope/tofu/iam/modules/data.tf rename to k8s/specs/tofu/data.tf diff --git a/k8s/scope/tofu/iam/modules/locals.tf b/k8s/specs/tofu/locals.tf similarity index 100% rename from k8s/scope/tofu/iam/modules/locals.tf rename to k8s/specs/tofu/locals.tf diff --git a/k8s/scope/tofu/iam/modules/main.tf b/k8s/specs/tofu/main.tf similarity index 100% rename from k8s/scope/tofu/iam/modules/main.tf rename to k8s/specs/tofu/main.tf diff --git a/k8s/scope/tofu/iam/modules/outputs.tf b/k8s/specs/tofu/outputs.tf similarity index 100% rename from k8s/scope/tofu/iam/modules/outputs.tf rename to k8s/specs/tofu/outputs.tf diff --git a/k8s/scope/tofu/iam/modules/variables.tf b/k8s/specs/tofu/variables.tf similarity index 100% rename from k8s/scope/tofu/iam/modules/variables.tf rename to k8s/specs/tofu/variables.tf diff --git a/k8s/scope/tofu/iam/modules/versions.tf b/k8s/specs/tofu/versions.tf similarity index 100% rename from k8s/scope/tofu/iam/modules/versions.tf rename to k8s/specs/tofu/versions.tf From bde6d91a79420e70df25f82e869a30c4b2a1eb6e Mon Sep 17 00:00:00 2001 From: David Date: Fri, 26 Jun 2026 10:09:37 -0300 Subject: [PATCH 4/5] Update locals.tf update locals --- k8s/specs/tofu/locals.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/k8s/specs/tofu/locals.tf b/k8s/specs/tofu/locals.tf index 79be043e..8387c7e3 100644 --- a/k8s/specs/tofu/locals.tf +++ b/k8s/specs/tofu/locals.tf @@ -11,7 +11,7 @@ locals { # Default tags applied to every IAM resource iam_default_tags = merge(var.iam_resource_tags_json, { - ManagedBy = "custom-scope-role" + ManagedBy = "nullplatform-custom-scope-role" Module = local.iam_module_name }) } From 52e7a68dcbe3a415a8a4d111973dc2f21770fe60 Mon Sep 17 00:00:00 2001 From: David Fernandez Date: Fri, 26 Jun 2026 11:46:43 -0300 Subject: [PATCH 5/5] feat(k8s/tofu): allow additional roles in permissions trust policy Make agent_role_arn optional (defaults to the conventional nullplatform--agent-role) and add additional_agent_role_arns so extra roles can be appended to the trust policy alongside the primary one. Co-Authored-By: Claude Opus 4.8 (1M context) --- k8s/specs/tofu/locals.tf | 4 ++++ k8s/specs/tofu/main.tf | 6 +++--- k8s/specs/tofu/variables.tf | 18 +++++++++++++++--- 3 files changed, 22 insertions(+), 6 deletions(-) diff --git a/k8s/specs/tofu/locals.tf b/k8s/specs/tofu/locals.tf index 8387c7e3..42a8cf3d 100644 --- a/k8s/specs/tofu/locals.tf +++ b/k8s/specs/tofu/locals.tf @@ -9,6 +9,10 @@ locals { permissions_role_name = var.permissions_role_name != "" ? var.permissions_role_name : "nullplatform-${var.cluster_name}-agent-permissions-role" policies_name_prefix = var.policies_name_prefix != "" ? var.policies_name_prefix : "nullplatform_${var.cluster_name}" + # Primary agent role trusted by the permissions role. Defaults to the + # conventional agent role name for the cluster when not provided explicitly. + 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" + # Default tags applied to every IAM resource iam_default_tags = merge(var.iam_resource_tags_json, { ManagedBy = "nullplatform-custom-scope-role" diff --git a/k8s/specs/tofu/main.tf b/k8s/specs/tofu/main.tf index 6364d292..9b4d63c6 100644 --- a/k8s/specs/tofu/main.tf +++ b/k8s/specs/tofu/main.tf @@ -2,8 +2,8 @@ # IAM permissions role assumed by the nullplatform agent role # # Holds the actual workload policies (Route53, EKS, ELB). Its trust policy -# trusts only the agent IRSA role, so the agent's IRSA token cannot exercise -# these permissions without first assuming this role (sts:AssumeRole). +# trusts only the agent IRSA role (plus any additional roles), so an agent's IRSA +# token cannot exercise these permissions without first assuming it (sts:AssumeRole). # # This is the "permissions role" half of the reference module # tofu-modules/infrastructure/aws/iam/agent. The IRSA agent role itself is @@ -20,7 +20,7 @@ resource "aws_iam_role" "nullplatform_agent_permissions" { Version = "2012-10-17" Statement = [{ Effect = "Allow" - Principal = { AWS = var.agent_role_arn } + Principal = { AWS = concat([local.agent_role_arn], var.additional_agent_role_arns) } Action = "sts:AssumeRole" }] }) diff --git a/k8s/specs/tofu/variables.tf b/k8s/specs/tofu/variables.tf index 5bed5a83..84838ca4 100644 --- a/k8s/specs/tofu/variables.tf +++ b/k8s/specs/tofu/variables.tf @@ -1,10 +1,22 @@ variable "agent_role_arn" { - description = "ARN of the nullplatform agent IRSA role allowed to assume this permissions role via sts:AssumeRole. This is the trusted principal of the role's trust policy." + description = "ARN of the primary nullplatform agent IRSA 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 = can(regex("^arn:aws:iam::[0-9]{12}:role/.+", var.agent_role_arn)) - error_message = "agent_role_arn must match arn:aws:iam:::role/" + 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/" } }