From f1fce002c70d9ec31a6fa6059bc5a24aff79bb5b Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Wed, 10 Jun 2026 17:57:48 -0700 Subject: [PATCH 1/3] fix(iac/aws): reconcile IAM action drift and add CFN/TF parity gate The CloudFormation stack, the Terraform lambda/fargate modules, and the federation CFN/TF/CLI templates each granted a different, partially insufficient IAM action set for the same application code: - CFN stack lacked savingsplans:DescribeSavingsPlansOfferings (called in the purchase pre-flight) and the EC2 RI exchange-quote actions. - TF lambda/fargate lacked ce:GetReservationCoverage and ce:GetSavingsPlansCoverage (coverage-targeted sizing silently degraded to zero existing coverage), ec2:DescribeRegions, and rds:DescribeDBInstances; they also still granted the legacy es:*ReservedElasticsearch* actions instead of the new-style es:* actions the OpenSearch SDK calls require (overlaps #1149). - The federation cross-account CFN template lacked the optional org-discovery statement its Terraform sibling has, and all newer federation templates lost the Cost Explorer statement the legacy CUDly-CrossAccount template grants, breaking CE-based recommendations for federated accounts. - savingsplans:DescribeSavingsPlanRates was granted by TF only and has no caller anywhere in the repo; dropped. Reconcile all lists to a single canonical set per deployment role and add scripts/check-aws-iam-parity.sh, the AWS sibling of check-azure-role-parity.sh, wired into ci.yml as a gating job. The check compares the application-owned IAM namespaces across the runtime trio (CFN stack vs TF lambda vs TF fargate), the federation cross-account CFN/TF pair (including org discovery), and the federation core set across both flavors plus the embedded CLI onboarding templates. scripts/test-aws-iam-parity.sh replicates the pre-fix drift shapes in fixture trees and asserts the check fails on them; the check was also verified to fail against the pre-fix tree and pass after reconciliation. Closes #1163 --- .github/workflows/ci.yml | 20 +++ cloudformation/stacks/CUDly/template.yaml | 4 + .../cloudformation/template.yaml | 32 ++++ .../aws-cross-account/terraform/main.tf | 13 ++ .../aws-target/cloudformation/template.yaml | 10 ++ iac/federation/aws-target/terraform/main.tf | 13 ++ .../templates/aws-cross-account-cli.sh.tmpl | 3 + .../iacfiles/templates/aws-wif-cli.sh.tmpl | 3 + scripts/check-aws-iam-parity.sh | 155 ++++++++++++++++++ scripts/test-aws-iam-parity.sh | 93 +++++++++++ terraform/modules/compute/aws/fargate/main.tf | 13 +- terraform/modules/compute/aws/lambda/main.tf | 13 +- 12 files changed, 366 insertions(+), 6 deletions(-) create mode 100755 scripts/check-aws-iam-parity.sh create mode 100755 scripts/test-aws-iam-parity.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bab595a2d..c2a7fbb34 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -480,6 +480,25 @@ jobs: - name: Run parity script self-tests run: bash scripts/test-azure-role-parity.sh + # Assert that the AWS IAM action lists are identical across the CFN stack, + # the TF lambda/fargate modules, and the federation CFN/TF/CLI templates. + # Fast (shell only), so it always runs. + aws-iam-parity: + name: AWS IAM actions parity (CFN vs TF) + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1 + with: + persist-credentials: false + + - name: Assert CFN/TF actions parity + run: bash scripts/check-aws-iam-parity.sh + + - name: Run parity script self-tests + run: bash scripts/test-aws-iam-parity.sh + # Summary job - all checks must pass ci-success: name: CI Success @@ -493,6 +512,7 @@ jobs: - security-scan - e2e-tests - azure-role-parity + - aws-iam-parity if: always() steps: diff --git a/cloudformation/stacks/CUDly/template.yaml b/cloudformation/stacks/CUDly/template.yaml index aa3b5dae0..229bd9a60 100644 --- a/cloudformation/stacks/CUDly/template.yaml +++ b/cloudformation/stacks/CUDly/template.yaml @@ -427,6 +427,8 @@ Resources: - ec2:DescribeReservedInstancesOfferings - ec2:DescribeReservedInstances - ec2:PurchaseReservedInstancesOffering + - ec2:GetReservedInstancesExchangeQuote + - ec2:AcceptReservedInstancesExchangeQuote - ec2:DescribeRegions - ec2:DescribeInstanceTypeOfferings Resource: "*" @@ -464,6 +466,7 @@ Resources: Action: - savingsplans:DescribeSavingsPlans - savingsplans:CreateSavingsPlan + - savingsplans:DescribeSavingsPlansOfferings - savingsplans:DescribeSavingsPlansOfferingRates Resource: "*" @@ -474,6 +477,7 @@ Resources: - sts:GetCallerIdentity - organizations:ListAccounts - organizations:DescribeAccount + - organizations:DescribeOrganization Resource: "*" # Cross-account role assumption for multi-account plans diff --git a/iac/federation/aws-cross-account/cloudformation/template.yaml b/iac/federation/aws-cross-account/cloudformation/template.yaml index bf0effbe6..750785ef9 100644 --- a/iac/federation/aws-cross-account/cloudformation/template.yaml +++ b/iac/federation/aws-cross-account/cloudformation/template.yaml @@ -38,11 +38,24 @@ Parameters: Description: Human-readable name for this account in CUDly. Default: "" + EnableOrgDiscovery: + Type: String + Description: > + Grant organizations:ListAccounts and organizations:DescribeOrganization so + CUDly can enumerate all accounts in the AWS Organization through this role. + Set to "true" only when this role is deployed in an Organizations management + or delegated-administrator account. Leave "false" in member-account + deployments. Mirrors enable_org_discovery in the Terraform module. + Default: "false" + AllowedValues: ["true", "false"] + Conditions: DoRegister: !And - !Not [!Equals [!Ref CUDlyAPIURL, ""]] - !Not [!Equals [!Ref ContactEmail, ""]] + OrgDiscoveryEnabled: !Equals [!Ref EnableOrgDiscovery, "true"] + Resources: CUDlyPolicy: Type: AWS::IAM::ManagedPolicy @@ -52,6 +65,16 @@ Resources: PolicyDocument: Version: "2012-10-17" Statement: + - Sid: CostExplorer + Effect: Allow + Action: + - ce:GetReservationPurchaseRecommendation + - ce:GetReservationUtilization + - ce:GetReservationCoverage + - ce:GetSavingsPlansPurchaseRecommendation + - ce:GetSavingsPlansUtilization + - ce:GetSavingsPlansCoverage + Resource: "*" - Sid: EC2Reservations Effect: Allow Action: @@ -106,6 +129,15 @@ Resources: - es:DescribeReservedInstanceOfferings - es:DescribeReservedInstances Resource: "*" + - !If + - OrgDiscoveryEnabled + - Sid: OrganizationsDiscovery + Effect: Allow + Action: + - organizations:ListAccounts + - organizations:DescribeOrganization + Resource: "*" + - !Ref AWS::NoValue CUDlyRole: Type: AWS::IAM::Role diff --git a/iac/federation/aws-cross-account/terraform/main.tf b/iac/federation/aws-cross-account/terraform/main.tf index 3a21b37c0..206c1ed9d 100644 --- a/iac/federation/aws-cross-account/terraform/main.tf +++ b/iac/federation/aws-cross-account/terraform/main.tf @@ -59,6 +59,19 @@ resource "aws_iam_policy" "cudly" { Version = "2012-10-17" Statement = concat( [ + { + Sid = "CostExplorer" + Effect = "Allow" + Action = [ + "ce:GetReservationPurchaseRecommendation", + "ce:GetReservationUtilization", + "ce:GetReservationCoverage", + "ce:GetSavingsPlansPurchaseRecommendation", + "ce:GetSavingsPlansUtilization", + "ce:GetSavingsPlansCoverage", + ] + Resource = "*" + }, { Sid = "EC2Reservations" Effect = "Allow" diff --git a/iac/federation/aws-target/cloudformation/template.yaml b/iac/federation/aws-target/cloudformation/template.yaml index fc6572e84..1a67b1b4e 100644 --- a/iac/federation/aws-target/cloudformation/template.yaml +++ b/iac/federation/aws-target/cloudformation/template.yaml @@ -83,6 +83,16 @@ Resources: PolicyDocument: Version: "2012-10-17" Statement: + - Sid: CostExplorer + Effect: Allow + Action: + - ce:GetReservationPurchaseRecommendation + - ce:GetReservationUtilization + - ce:GetReservationCoverage + - ce:GetSavingsPlansPurchaseRecommendation + - ce:GetSavingsPlansUtilization + - ce:GetSavingsPlansCoverage + Resource: "*" - Sid: EC2Reservations Effect: Allow Action: diff --git a/iac/federation/aws-target/terraform/main.tf b/iac/federation/aws-target/terraform/main.tf index 4c2db4b96..dff000d14 100644 --- a/iac/federation/aws-target/terraform/main.tf +++ b/iac/federation/aws-target/terraform/main.tf @@ -35,6 +35,19 @@ resource "aws_iam_policy" "cudly" { policy = jsonencode({ Version = "2012-10-17" Statement = [ + { + Sid = "CostExplorer" + Effect = "Allow" + Action = [ + "ce:GetReservationPurchaseRecommendation", + "ce:GetReservationUtilization", + "ce:GetReservationCoverage", + "ce:GetSavingsPlansPurchaseRecommendation", + "ce:GetSavingsPlansUtilization", + "ce:GetSavingsPlansCoverage", + ] + Resource = "*" + }, { Sid = "EC2Reservations" Effect = "Allow" diff --git a/internal/iacfiles/templates/aws-cross-account-cli.sh.tmpl b/internal/iacfiles/templates/aws-cross-account-cli.sh.tmpl index 1e38cbf25..e2aad0d7a 100644 --- a/internal/iacfiles/templates/aws-cross-account-cli.sh.tmpl +++ b/internal/iacfiles/templates/aws-cross-account-cli.sh.tmpl @@ -35,6 +35,9 @@ PERMISSIONS_POLICY=$(cat <<'JSON' "Effect": "Allow", "Resource": "*", "Action": [ + "ce:GetReservationPurchaseRecommendation","ce:GetReservationUtilization", + "ce:GetReservationCoverage","ce:GetSavingsPlansPurchaseRecommendation", + "ce:GetSavingsPlansUtilization","ce:GetSavingsPlansCoverage", "ec2:PurchaseReservedInstancesOffering","ec2:DescribeReservedInstancesOfferings", "ec2:DescribeReservedInstances","ec2:DescribeInstanceTypeOfferings", "ec2:GetReservedInstancesExchangeQuote","ec2:AcceptReservedInstancesExchangeQuote", diff --git a/internal/iacfiles/templates/aws-wif-cli.sh.tmpl b/internal/iacfiles/templates/aws-wif-cli.sh.tmpl index d2d5b10bc..53357cc26 100644 --- a/internal/iacfiles/templates/aws-wif-cli.sh.tmpl +++ b/internal/iacfiles/templates/aws-wif-cli.sh.tmpl @@ -76,6 +76,9 @@ PERMISSIONS_POLICY=$(cat <<'JSON' "Effect": "Allow", "Resource": "*", "Action": [ + "ce:GetReservationPurchaseRecommendation","ce:GetReservationUtilization", + "ce:GetReservationCoverage","ce:GetSavingsPlansPurchaseRecommendation", + "ce:GetSavingsPlansUtilization","ce:GetSavingsPlansCoverage", "ec2:PurchaseReservedInstancesOffering","ec2:DescribeReservedInstancesOfferings", "ec2:DescribeReservedInstances","ec2:DescribeInstanceTypeOfferings", "ec2:GetReservedInstancesExchangeQuote","ec2:AcceptReservedInstancesExchangeQuote", diff --git a/scripts/check-aws-iam-parity.sh b/scripts/check-aws-iam-parity.sh new file mode 100755 index 000000000..7c6d9d429 --- /dev/null +++ b/scripts/check-aws-iam-parity.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash +# check-aws-iam-parity.sh +# +# Asserts that the AWS IAM action lists granted to the CUDly runtime and to the +# customer-deployed federation roles are identical across every IaC flavor that +# encodes them, so CloudFormation and Terraform deployments of the same code +# cannot silently drift apart (the AWS sibling of check-azure-role-parity.sh). +# +# Three comparisons: +# +# 1. runtime : cloudformation/stacks/CUDly/template.yaml +# == terraform/modules/compute/aws/lambda/main.tf +# == terraform/modules/compute/aws/fargate/main.tf +# 2. federation cross-account pair (incl. the optional org-discovery +# statement, which exists in both as an opt-in): +# iac/federation/aws-cross-account/cloudformation/template.yaml +# == iac/federation/aws-cross-account/terraform/main.tf +# 3. federation core (org discovery excluded: the CLI quick-onboarding +# scripts and the aws-target WIF flavor intentionally do not offer it): +# both files from (2) +# == iac/federation/aws-target/cloudformation/template.yaml +# == iac/federation/aws-target/terraform/main.tf +# == internal/iacfiles/templates/aws-cross-account-cli.sh.tmpl +# == internal/iacfiles/templates/aws-wif-cli.sh.tmpl +# +# Only actions in the cloud-API namespaces the application code calls are +# compared (see ACTION_PREFIXES). Platform plumbing (logs, dynamodb, ses, sns, +# secretsmanager, sts, ssmmessages, lambda) legitimately differs per deployment +# flavor and is excluded. +# +# Exit 0 = all lists match. +# Exit 1 = drift found; the diff is printed to stderr. +# Exit 2 = usage / environment error. +# +# Usage: +# scripts/check-aws-iam-parity.sh [--root ] +# +# --root lets the test harness point at a fixture tree that mirrors the repo +# layout without touching the real sources. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +while [[ $# -gt 0 ]]; do + case "$1" in + --root) REPO_ROOT="$2"; shift 2 ;; + *) echo "Unknown flag: $1" >&2; exit 2 ;; + esac +done + +# IAM action namespaces owned by the application code. Keep in sync with the +# services the providers/aws code actually calls. +ACTION_PREFIXES='ce|ec2|rds|elasticache|es|redshift|memorydb|savingsplans|organizations' + +# --- extraction --------------------------------------------------------------- +# Pull every token shaped like : out of a file, +# regardless of whether it is YAML, HCL, or an embedded JSON heredoc. IAM +# actions always start with an uppercase letter after the colon, which keeps +# ARNs (region segments are lowercase) out of the match. + +extract_actions() { + local file="$1" + local exclude_orgs="${2:-}" + + if [[ ! -f "$file" ]]; then + echo "ERROR: file not found: $file" >&2 + exit 2 + fi + + local actions + actions=$(grep -oE "(^|[^A-Za-z])(${ACTION_PREFIXES}):[A-Z][A-Za-z]+" "$file" \ + | sed 's/^[^a-zA-Z]//' \ + | sort -u) + + if [[ "$exclude_orgs" == "no-orgs" ]]; then + actions=$(printf '%s\n' "$actions" | grep -v '^organizations:' || true) + fi + + if [[ -z "$actions" ]]; then + echo "ERROR: no IAM actions extracted from: $file" >&2 + echo " Did the policy move or change format?" >&2 + exit 2 + fi + + printf '%s\n' "$actions" +} + +# --- comparison --------------------------------------------------------------- + +FAILURES=0 + +compare_pair() { + local label="$1" ref_name="$2" ref_actions="$3" other_name="$4" other_actions="$5" + + local diff_out + diff_out=$(diff <(printf '%s\n' "$ref_actions") <(printf '%s\n' "$other_actions") || true) + + if [[ -n "$diff_out" ]]; then + { + echo "ERROR [$label]: IAM action drift between:" + echo " < $ref_name" + echo " > $other_name" + echo "$diff_out" + echo "" + } >&2 + FAILURES=$((FAILURES + 1)) + fi +} + +# --- 1. runtime: CFN stack vs TF lambda vs TF fargate -------------------------- + +CFN_STACK="$REPO_ROOT/cloudformation/stacks/CUDly/template.yaml" +TF_LAMBDA="$REPO_ROOT/terraform/modules/compute/aws/lambda/main.tf" +TF_FARGATE="$REPO_ROOT/terraform/modules/compute/aws/fargate/main.tf" + +cfn_stack_actions=$(extract_actions "$CFN_STACK") +tf_lambda_actions=$(extract_actions "$TF_LAMBDA") +tf_fargate_actions=$(extract_actions "$TF_FARGATE") + +compare_pair "runtime" "$CFN_STACK" "$cfn_stack_actions" "$TF_LAMBDA" "$tf_lambda_actions" +compare_pair "runtime" "$TF_LAMBDA" "$tf_lambda_actions" "$TF_FARGATE" "$tf_fargate_actions" + +# --- 2. federation cross-account pair (all namespaces) ------------------------- + +FED_XACC_CFN="$REPO_ROOT/iac/federation/aws-cross-account/cloudformation/template.yaml" +FED_XACC_TF="$REPO_ROOT/iac/federation/aws-cross-account/terraform/main.tf" + +fed_cfn_actions=$(extract_actions "$FED_XACC_CFN") +fed_tf_actions=$(extract_actions "$FED_XACC_TF") + +compare_pair "federation cross-account" "$FED_XACC_CFN" "$fed_cfn_actions" "$FED_XACC_TF" "$fed_tf_actions" + +# --- 3. federation core across all flavors (org discovery excluded) ------------ + +FED_WIF_CFN="$REPO_ROOT/iac/federation/aws-target/cloudformation/template.yaml" +FED_WIF_TF="$REPO_ROOT/iac/federation/aws-target/terraform/main.tf" +FED_XACC_CLI="$REPO_ROOT/internal/iacfiles/templates/aws-cross-account-cli.sh.tmpl" +FED_WIF_CLI="$REPO_ROOT/internal/iacfiles/templates/aws-wif-cli.sh.tmpl" + +fed_core_ref=$(extract_actions "$FED_XACC_CFN" no-orgs) + +for f in "$FED_XACC_TF" "$FED_WIF_CFN" "$FED_WIF_TF" "$FED_XACC_CLI" "$FED_WIF_CLI"; do + compare_pair "federation core" "$FED_XACC_CFN" "$fed_core_ref" "$f" "$(extract_actions "$f" no-orgs)" +done + +# --- result -------------------------------------------------------------------- + +if [[ "$FAILURES" -gt 0 ]]; then + echo "FAILED: $FAILURES IAM parity comparison(s) drifted. Update the lagging file(s) so all lists match." >&2 + exit 1 +fi + +echo "OK: AWS IAM action lists are in parity across CloudFormation, Terraform, and CLI onboarding templates." +exit 0 diff --git a/scripts/test-aws-iam-parity.sh b/scripts/test-aws-iam-parity.sh new file mode 100755 index 000000000..6da5665cf --- /dev/null +++ b/scripts/test-aws-iam-parity.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# test-aws-iam-parity.sh +# +# Exercises check-aws-iam-parity.sh: +# 1. against the real repository (must pass post-reconciliation), +# 2. against fixture trees seeded from the real files with drift injected, +# replicating the exact pre-fix INF-02 failure shapes (missing CE +# Coverage actions in the Terraform Lambda module; missing org-discovery +# statement in the federation CloudFormation template). +# +# Exits 0 when all cases pass; exits 1 on any failure. + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)" +CHECK="${SCRIPT_DIR}/check-aws-iam-parity.sh" + +pass=0 +fail=0 + +run_case() { + local label="$1" + local expected_exit="$2" + shift 2 + + actual_exit=0 + bash "$CHECK" "$@" >/dev/null 2>&1 || actual_exit=$? + + if [[ "$actual_exit" -eq "$expected_exit" ]]; then + echo "PASS: $label" + (( pass++ )) || true + else + echo "FAIL: $label (expected exit $expected_exit, got $actual_exit)" + (( fail++ )) || true + fi +} + +# seed_fixture copies the six compared files from the repo into a +# fixture tree that mirrors the repo layout. +seed_fixture() { + local dest="$1" + local files=( + cloudformation/stacks/CUDly/template.yaml + terraform/modules/compute/aws/lambda/main.tf + terraform/modules/compute/aws/fargate/main.tf + iac/federation/aws-cross-account/cloudformation/template.yaml + iac/federation/aws-cross-account/terraform/main.tf + iac/federation/aws-target/cloudformation/template.yaml + iac/federation/aws-target/terraform/main.tf + internal/iacfiles/templates/aws-cross-account-cli.sh.tmpl + internal/iacfiles/templates/aws-wif-cli.sh.tmpl + ) + local f + for f in "${files[@]}"; do + mkdir -p "$dest/$(dirname "$f")" + cp "$REPO_ROOT/$f" "$dest/$f" + done +} + +TMP_BASE="$(mktemp -d)" +trap 'rm -rf "$TMP_BASE"' EXIT + +# Case 1: the real repository must be in parity. +run_case "real repository is in parity" 0 + +# Case 2: pre-fix INF-02 shape - TF Lambda module missing the CE Coverage +# actions while the CFN stack grants them. +FIX2="$TMP_BASE/drift-runtime" +seed_fixture "$FIX2" +grep -v 'ce:GetReservationCoverage\|ce:GetSavingsPlansCoverage' \ + "$REPO_ROOT/terraform/modules/compute/aws/lambda/main.tf" \ + > "$FIX2/terraform/modules/compute/aws/lambda/main.tf" +run_case "runtime drift (TF lambda missing CE Coverage actions) exits 1" 1 --root "$FIX2" + +# Case 3: pre-fix INF-02 shape - federation CFN template missing the +# org-discovery statement its Terraform sibling carries. +FIX3="$TMP_BASE/drift-federation" +seed_fixture "$FIX3" +grep -v 'organizations:ListAccounts\|organizations:DescribeOrganization' \ + "$REPO_ROOT/iac/federation/aws-cross-account/cloudformation/template.yaml" \ + > "$FIX3/iac/federation/aws-cross-account/cloudformation/template.yaml" +run_case "federation drift (CFN missing org-discovery statement) exits 1" 1 --root "$FIX3" + +# Case 4: a missing source file must fail loud (exit 2), never pass silently. +FIX4="$TMP_BASE/missing-file" +seed_fixture "$FIX4" +rm "$FIX4/terraform/modules/compute/aws/fargate/main.tf" +run_case "missing compared file exits 2" 2 --root "$FIX4" + +echo "" +echo "Results: ${pass} passed, ${fail} failed." +[[ "$fail" -eq 0 ]] diff --git a/terraform/modules/compute/aws/fargate/main.tf b/terraform/modules/compute/aws/fargate/main.tf index 47c6082cb..e4fc370e2 100644 --- a/terraform/modules/compute/aws/fargate/main.tf +++ b/terraform/modules/compute/aws/fargate/main.tf @@ -255,8 +255,12 @@ resource "aws_iam_role_policy" "org_discovery" { Version = "2012-10-17" Statement = [ { - Effect = "Allow" - Action = ["organizations:ListAccounts", "organizations:DescribeOrganization"] + Effect = "Allow" + Action = [ + "organizations:ListAccounts", + "organizations:DescribeAccount", + "organizations:DescribeOrganization", + ] Resource = "*" } ] @@ -304,10 +308,12 @@ resource "aws_iam_role_policy" "ri_exchange" { "ec2:AcceptReservedInstancesExchangeQuote", "ec2:PurchaseReservedInstancesOffering", "ec2:DescribeInstanceTypeOfferings", + "ec2:DescribeRegions", # RDS reserved instances "rds:DescribeReservedDBInstances", "rds:DescribeReservedDBInstancesOfferings", "rds:PurchaseReservedDBInstancesOffering", + "rds:DescribeDBInstances", # ElastiCache reserved nodes "elasticache:DescribeReservedCacheNodes", "elasticache:DescribeReservedCacheNodesOfferings", @@ -327,11 +333,12 @@ resource "aws_iam_role_policy" "ri_exchange" { # Cost Explorer "ce:GetReservationUtilization", "ce:GetReservationPurchaseRecommendation", + "ce:GetReservationCoverage", "ce:GetSavingsPlansPurchaseRecommendation", "ce:GetSavingsPlansUtilization", + "ce:GetSavingsPlansCoverage", # Savings Plans "savingsplans:DescribeSavingsPlans", - "savingsplans:DescribeSavingsPlanRates", "savingsplans:DescribeSavingsPlansOfferingRates", "savingsplans:DescribeSavingsPlansOfferings", "savingsplans:CreateSavingsPlan", diff --git a/terraform/modules/compute/aws/lambda/main.tf b/terraform/modules/compute/aws/lambda/main.tf index c2d71bb23..53c940b0d 100644 --- a/terraform/modules/compute/aws/lambda/main.tf +++ b/terraform/modules/compute/aws/lambda/main.tf @@ -354,10 +354,12 @@ resource "aws_iam_role_policy" "ri_exchange" { "ec2:AcceptReservedInstancesExchangeQuote", "ec2:PurchaseReservedInstancesOffering", "ec2:DescribeInstanceTypeOfferings", + "ec2:DescribeRegions", # RDS reserved instances "rds:DescribeReservedDBInstances", "rds:DescribeReservedDBInstancesOfferings", "rds:PurchaseReservedDBInstancesOffering", + "rds:DescribeDBInstances", # ElastiCache reserved nodes "elasticache:DescribeReservedCacheNodes", "elasticache:DescribeReservedCacheNodesOfferings", @@ -377,11 +379,12 @@ resource "aws_iam_role_policy" "ri_exchange" { # Cost Explorer "ce:GetReservationUtilization", "ce:GetReservationPurchaseRecommendation", + "ce:GetReservationCoverage", "ce:GetSavingsPlansPurchaseRecommendation", "ce:GetSavingsPlansUtilization", + "ce:GetSavingsPlansCoverage", # Savings Plans "savingsplans:DescribeSavingsPlans", - "savingsplans:DescribeSavingsPlanRates", "savingsplans:DescribeSavingsPlansOfferingRates", "savingsplans:DescribeSavingsPlansOfferings", "savingsplans:CreateSavingsPlan", @@ -463,8 +466,12 @@ resource "aws_iam_role_policy" "org_discovery" { Version = "2012-10-17" Statement = [ { - Effect = "Allow" - Action = ["organizations:ListAccounts", "organizations:DescribeOrganization"] + Effect = "Allow" + Action = [ + "organizations:ListAccounts", + "organizations:DescribeAccount", + "organizations:DescribeOrganization", + ] Resource = "*" # Organizations API does not support resource-level restrictions } ] From 5b1eb35c7fa0769fe19a439af5965d0473d09645 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Fri, 19 Jun 2026 23:40:37 +0200 Subject: [PATCH 2/3] fix(iac/aws): validate --root has a value in IAM parity check Under set -euo pipefail, passing --root without a following path value hit `$2` as an unbound variable, exiting 1 with a bash error instead of the intended exit 2 plus a usage message. Guard the argument before dereferencing it and emit an explicit "--root requires a path value" error. Add a self-test case asserting exit 2 for the bare --root flag. --- scripts/check-aws-iam-parity.sh | 9 ++++++++- scripts/test-aws-iam-parity.sh | 4 ++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/scripts/check-aws-iam-parity.sh b/scripts/check-aws-iam-parity.sh index 7c6d9d429..6191cda01 100755 --- a/scripts/check-aws-iam-parity.sh +++ b/scripts/check-aws-iam-parity.sh @@ -44,7 +44,14 @@ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" while [[ $# -gt 0 ]]; do case "$1" in - --root) REPO_ROOT="$2"; shift 2 ;; + --root) + if [[ $# -lt 2 || -z "${2:-}" ]]; then + echo "ERROR: --root requires a path value" >&2 + exit 2 + fi + REPO_ROOT="$2" + shift 2 + ;; *) echo "Unknown flag: $1" >&2; exit 2 ;; esac done diff --git a/scripts/test-aws-iam-parity.sh b/scripts/test-aws-iam-parity.sh index 6da5665cf..e56b273d1 100755 --- a/scripts/test-aws-iam-parity.sh +++ b/scripts/test-aws-iam-parity.sh @@ -88,6 +88,10 @@ seed_fixture "$FIX4" rm "$FIX4/terraform/modules/compute/aws/fargate/main.tf" run_case "missing compared file exits 2" 2 --root "$FIX4" +# Case 5: --root without a path value must fail loud (exit 2), not crash on an +# unbound variable under set -u. +run_case "--root without a value exits 2" 2 --root + echo "" echo "Results: ${pass} passed, ${fail} failed." [[ "$fail" -eq 0 ]] From d45ffc550876378d320be5b6b7f1d97104e34da8 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Fri, 26 Jun 2026 19:10:32 +0200 Subject: [PATCH 3/3] docs(changelog): note federation re-apply needed for IAM action drift fix PR #1219 reconciles the AWS IAM action lists between the CloudFormation stack, the Terraform lambda/fargate modules, and the federation CFN/TF/CLI templates -- but existing customer deployments do not auto-update. Bundles deployed before this PR continue to silently degrade until re-applied: federation roles lack the CE Get*Coverage actions (coverage-targeted sizing assumes zero coverage), the cross-account CFN flavor lacks the optional EnableOrgDiscovery parameter and the legacy CE statement, and runtime deployments lack the new EC2 RI exchange actions and the new-style es:* OpenSearch actions. Add a Notices entry mirroring the existing 2026-04-22 federation-bundle precedent so the operational impact is visible to upgrading customers. --- CHANGELOG.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index e2203831e..643c1df69 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/). CloudFormation deploy scripts had no registration call at all). Re-download the bundle from the CUDly UI and the new copy will register your account automatically with no manual edits required. +- **Federation IaC bundles deployed before #1219 need to be re-applied** to + pick up reconciled IAM action grants. Older bundles silently degrade on + federated accounts: the Cost Explorer `Get*Coverage` actions are missing + (coverage-targeted sizing assumes zero existing coverage), and the + cross-account CloudFormation flavor lacks the optional `EnableOrgDiscovery` + parameter and the legacy CE statement that the original `CUDly-CrossAccount` + template carried. Re-download the federation bundle from the CUDly UI and + re-apply (`terraform apply` / `aws cloudformation update-stack`) -- no + manual edits required, no changes to existing CUDly resources. Customers + on the runtime CloudFormation stack or Terraform lambda/fargate modules + also need an update to pick up the new `ec2:*ReservedInstancesExchangeQuote` + actions, `ec2:DescribeRegions`, `rds:DescribeDBInstances`, and the + new-style `es:*ReservedInstance*` OpenSearch actions (replacing the legacy + `es:*ReservedElasticsearch*` names). ### Fixed