From 9563602e8e9e58f36706427177a988510a867ab1 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Wed, 20 May 2026 18:36:01 +0200 Subject: [PATCH] sec(iac): require non-empty ExternalId on cross-account AssumeRole calls (closes #436) Add a StringLike "*" condition on sts:ExternalId to the cross_account_sts IAM policy in both the Lambda and Fargate compute modules. This requires every sts:AssumeRole call from these compute roles to supply a non-empty ExternalId value; AWS will deny the call at the IAM layer if it is absent. Per-account ExternalId validation (matching the registered value in the DB) still happens at the application layer in the credentials resolver. The IAM condition is a second layer of defence: even if a bug in the resolver omitted the ExternalId entirely, IAM would block the call before it reached the target account. This closes the gap noted in #436 where the CloudFormation target-account trust policy enforced ExternalId but the Lambda/Fargate calling policy did not. --- terraform/modules/compute/aws/fargate/main.tf | 13 +++++++++---- terraform/modules/compute/aws/lambda/main.tf | 16 +++++++++++++--- 2 files changed, 22 insertions(+), 7 deletions(-) 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" = "*" + } + } } ] })