diff --git a/terraform/modules/compute/aws/fargate/main.tf b/terraform/modules/compute/aws/fargate/main.tf index f44764a5..e7d34705 100644 --- a/terraform/modules/compute/aws/fargate/main.tf +++ b/terraform/modules/compute/aws/fargate/main.tf @@ -215,10 +215,10 @@ resource "aws_iam_role_policy" "ses_access" { } # Cross-account role assumption for multi-account plan execution. Scoped by -# var.cross_account_role_name_prefix (default "CUDly"). ExternalId is still -# enforced at the app layer in the credentials resolver; this IAM constraint -# is defence-in-depth so a single app-layer bug can't pivot into arbitrary -# roles. Mirrors the Lambda module. +# var.cross_account_role_name_prefix (default "CUDly"). ExternalId is also +# enforced at the app layer in the credentials resolver; the IAM StringLike +# "*" condition here is defence-in-depth that requires a non-empty ExternalId +# to be supplied on every AssumeRole call. Mirrors the Lambda module. resource "aws_iam_role_policy" "cross_account_sts" { count = var.enable_cross_account_sts ? 1 : 0 @@ -232,6 +232,11 @@ resource "aws_iam_role_policy" "cross_account_sts" { Effect = "Allow" Action = ["sts:AssumeRole"] Resource = "arn:aws:iam::*:role/${var.cross_account_role_name_prefix}*" + Condition = { + StringLike = { + "sts:ExternalId" = "*" + } + } } ] }) diff --git a/terraform/modules/compute/aws/lambda/main.tf b/terraform/modules/compute/aws/lambda/main.tf index 96feb5ba..20404d66 100644 --- a/terraform/modules/compute/aws/lambda/main.tf +++ b/terraform/modules/compute/aws/lambda/main.tf @@ -380,9 +380,14 @@ resource "aws_iam_role_policy" "ri_exchange" { # Scoped by var.cross_account_role_name_prefix (default "CUDly") so the # Lambda can only assume roles whose names start with that prefix. The # shipped federation templates (iac/federation/aws-*) create roles matching -# this prefix. ExternalId validation still happens at the application layer -# (resolver.go) — this IAM constraint is defence-in-depth so a single app- -# layer bug can't pivot into arbitrary roles. +# this prefix. ExternalId validation also happens at the application layer +# (resolver.go); this IAM condition is defence-in-depth so a single app- +# layer bug cannot pivot into arbitrary roles without a non-empty ExternalId. +# +# The StringLike "*" condition requires that sts:ExternalId is present and +# non-empty in every AssumeRole call. Per-account ExternalId values are +# validated at the application layer; IAM here enforces that the field is +# present at all, closing the gap where an app-layer bug could omit it. resource "aws_iam_role_policy" "cross_account_sts" { count = var.enable_cross_account_sts ? 1 : 0 @@ -396,6 +401,11 @@ resource "aws_iam_role_policy" "cross_account_sts" { Effect = "Allow" Action = ["sts:AssumeRole"] Resource = "arn:aws:iam::*:role/${var.cross_account_role_name_prefix}*" + Condition = { + StringLike = { + "sts:ExternalId" = "*" + } + } } ] })