Skip to content

Commit d0792f0

Browse files
committed
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
1 parent cfead3b commit d0792f0

12 files changed

Lines changed: 372 additions & 12 deletions

File tree

‎.github/workflows/ci.yml‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,25 @@ jobs:
423423
- name: Run parity script self-tests
424424
run: bash scripts/test-azure-role-parity.sh
425425

426+
# Assert that the AWS IAM action lists are identical across the CFN stack,
427+
# the TF lambda/fargate modules, and the federation CFN/TF/CLI templates.
428+
# Fast (shell only), so it always runs.
429+
aws-iam-parity:
430+
name: AWS IAM actions parity (CFN vs TF)
431+
runs-on: ubuntu-latest
432+
433+
steps:
434+
- name: Checkout code
435+
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
436+
with:
437+
persist-credentials: false
438+
439+
- name: Assert CFN/TF actions parity
440+
run: bash scripts/check-aws-iam-parity.sh
441+
442+
- name: Run parity script self-tests
443+
run: bash scripts/test-aws-iam-parity.sh
444+
426445
# Summary job - all checks must pass
427446
ci-success:
428447
name: CI Success
@@ -436,6 +455,7 @@ jobs:
436455
- security-scan
437456
- e2e-tests
438457
- azure-role-parity
458+
- aws-iam-parity
439459
if: always()
440460

441461
steps:

‎cloudformation/stacks/CUDly/template.yaml‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -427,6 +427,8 @@ Resources:
427427
- ec2:DescribeReservedInstancesOfferings
428428
- ec2:DescribeReservedInstances
429429
- ec2:PurchaseReservedInstancesOffering
430+
- ec2:GetReservedInstancesExchangeQuote
431+
- ec2:AcceptReservedInstancesExchangeQuote
430432
- ec2:DescribeRegions
431433
- ec2:DescribeInstanceTypeOfferings
432434
Resource: "*"
@@ -464,6 +466,7 @@ Resources:
464466
Action:
465467
- savingsplans:DescribeSavingsPlans
466468
- savingsplans:CreateSavingsPlan
469+
- savingsplans:DescribeSavingsPlansOfferings
467470
- savingsplans:DescribeSavingsPlansOfferingRates
468471
Resource: "*"
469472

@@ -474,6 +477,7 @@ Resources:
474477
- sts:GetCallerIdentity
475478
- organizations:ListAccounts
476479
- organizations:DescribeAccount
480+
- organizations:DescribeOrganization
477481
Resource: "*"
478482

479483
# Cross-account role assumption for multi-account plans

‎iac/federation/aws-cross-account/cloudformation/template.yaml‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,11 +38,24 @@ Parameters:
3838
Description: Human-readable name for this account in CUDly.
3939
Default: ""
4040

41+
EnableOrgDiscovery:
42+
Type: String
43+
Description: >
44+
Grant organizations:ListAccounts and organizations:DescribeOrganization so
45+
CUDly can enumerate all accounts in the AWS Organization through this role.
46+
Set to "true" only when this role is deployed in an Organizations management
47+
or delegated-administrator account. Leave "false" in member-account
48+
deployments. Mirrors enable_org_discovery in the Terraform module.
49+
Default: "false"
50+
AllowedValues: ["true", "false"]
51+
4152
Conditions:
4253
DoRegister: !And
4354
- !Not [!Equals [!Ref CUDlyAPIURL, ""]]
4455
- !Not [!Equals [!Ref ContactEmail, ""]]
4556

57+
OrgDiscoveryEnabled: !Equals [!Ref EnableOrgDiscovery, "true"]
58+
4659
Resources:
4760
CUDlyPolicy:
4861
Type: AWS::IAM::ManagedPolicy
@@ -52,6 +65,16 @@ Resources:
5265
PolicyDocument:
5366
Version: "2012-10-17"
5467
Statement:
68+
- Sid: CostExplorer
69+
Effect: Allow
70+
Action:
71+
- ce:GetReservationPurchaseRecommendation
72+
- ce:GetReservationUtilization
73+
- ce:GetReservationCoverage
74+
- ce:GetSavingsPlansPurchaseRecommendation
75+
- ce:GetSavingsPlansUtilization
76+
- ce:GetSavingsPlansCoverage
77+
Resource: "*"
5578
- Sid: EC2Reservations
5679
Effect: Allow
5780
Action:
@@ -106,6 +129,15 @@ Resources:
106129
- es:DescribeReservedInstanceOfferings
107130
- es:DescribeReservedInstances
108131
Resource: "*"
132+
- !If
133+
- OrgDiscoveryEnabled
134+
- Sid: OrganizationsDiscovery
135+
Effect: Allow
136+
Action:
137+
- organizations:ListAccounts
138+
- organizations:DescribeOrganization
139+
Resource: "*"
140+
- !Ref AWS::NoValue
109141

110142
CUDlyRole:
111143
Type: AWS::IAM::Role

‎iac/federation/aws-cross-account/terraform/main.tf‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,19 @@ resource "aws_iam_policy" "cudly" {
5959
Version = "2012-10-17"
6060
Statement = concat(
6161
[
62+
{
63+
Sid = "CostExplorer"
64+
Effect = "Allow"
65+
Action = [
66+
"ce:GetReservationPurchaseRecommendation",
67+
"ce:GetReservationUtilization",
68+
"ce:GetReservationCoverage",
69+
"ce:GetSavingsPlansPurchaseRecommendation",
70+
"ce:GetSavingsPlansUtilization",
71+
"ce:GetSavingsPlansCoverage",
72+
]
73+
Resource = "*"
74+
},
6275
{
6376
Sid = "EC2Reservations"
6477
Effect = "Allow"

‎iac/federation/aws-target/cloudformation/template.yaml‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,16 @@ Resources:
8383
PolicyDocument:
8484
Version: "2012-10-17"
8585
Statement:
86+
- Sid: CostExplorer
87+
Effect: Allow
88+
Action:
89+
- ce:GetReservationPurchaseRecommendation
90+
- ce:GetReservationUtilization
91+
- ce:GetReservationCoverage
92+
- ce:GetSavingsPlansPurchaseRecommendation
93+
- ce:GetSavingsPlansUtilization
94+
- ce:GetSavingsPlansCoverage
95+
Resource: "*"
8696
- Sid: EC2Reservations
8797
Effect: Allow
8898
Action:

‎iac/federation/aws-target/terraform/main.tf‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,19 @@ resource "aws_iam_policy" "cudly" {
3535
policy = jsonencode({
3636
Version = "2012-10-17"
3737
Statement = [
38+
{
39+
Sid = "CostExplorer"
40+
Effect = "Allow"
41+
Action = [
42+
"ce:GetReservationPurchaseRecommendation",
43+
"ce:GetReservationUtilization",
44+
"ce:GetReservationCoverage",
45+
"ce:GetSavingsPlansPurchaseRecommendation",
46+
"ce:GetSavingsPlansUtilization",
47+
"ce:GetSavingsPlansCoverage",
48+
]
49+
Resource = "*"
50+
},
3851
{
3952
Sid = "EC2Reservations"
4053
Effect = "Allow"

‎internal/iacfiles/templates/aws-cross-account-cli.sh.tmpl‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,9 @@ PERMISSIONS_POLICY=$(cat <<'JSON'
3535
"Effect": "Allow",
3636
"Resource": "*",
3737
"Action": [
38+
"ce:GetReservationPurchaseRecommendation","ce:GetReservationUtilization",
39+
"ce:GetReservationCoverage","ce:GetSavingsPlansPurchaseRecommendation",
40+
"ce:GetSavingsPlansUtilization","ce:GetSavingsPlansCoverage",
3841
"ec2:PurchaseReservedInstancesOffering","ec2:DescribeReservedInstancesOfferings",
3942
"ec2:DescribeReservedInstances","ec2:DescribeInstanceTypeOfferings",
4043
"ec2:GetReservedInstancesExchangeQuote","ec2:AcceptReservedInstancesExchangeQuote",

‎internal/iacfiles/templates/aws-wif-cli.sh.tmpl‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,9 @@ PERMISSIONS_POLICY=$(cat <<'JSON'
7676
"Effect": "Allow",
7777
"Resource": "*",
7878
"Action": [
79+
"ce:GetReservationPurchaseRecommendation","ce:GetReservationUtilization",
80+
"ce:GetReservationCoverage","ce:GetSavingsPlansPurchaseRecommendation",
81+
"ce:GetSavingsPlansUtilization","ce:GetSavingsPlansCoverage",
7982
"ec2:PurchaseReservedInstancesOffering","ec2:DescribeReservedInstancesOfferings",
8083
"ec2:DescribeReservedInstances","ec2:DescribeInstanceTypeOfferings",
8184
"ec2:GetReservedInstancesExchangeQuote","ec2:AcceptReservedInstancesExchangeQuote",

‎scripts/check-aws-iam-parity.sh‎

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
#!/usr/bin/env bash
2+
# check-aws-iam-parity.sh
3+
#
4+
# Asserts that the AWS IAM action lists granted to the CUDly runtime and to the
5+
# customer-deployed federation roles are identical across every IaC flavor that
6+
# encodes them, so CloudFormation and Terraform deployments of the same code
7+
# cannot silently drift apart (the AWS sibling of check-azure-role-parity.sh).
8+
#
9+
# Three comparisons:
10+
#
11+
# 1. runtime : cloudformation/stacks/CUDly/template.yaml
12+
# == terraform/modules/compute/aws/lambda/main.tf
13+
# == terraform/modules/compute/aws/fargate/main.tf
14+
# 2. federation cross-account pair (incl. the optional org-discovery
15+
# statement, which exists in both as an opt-in):
16+
# iac/federation/aws-cross-account/cloudformation/template.yaml
17+
# == iac/federation/aws-cross-account/terraform/main.tf
18+
# 3. federation core (org discovery excluded: the CLI quick-onboarding
19+
# scripts and the aws-target WIF flavor intentionally do not offer it):
20+
# both files from (2)
21+
# == iac/federation/aws-target/cloudformation/template.yaml
22+
# == iac/federation/aws-target/terraform/main.tf
23+
# == internal/iacfiles/templates/aws-cross-account-cli.sh.tmpl
24+
# == internal/iacfiles/templates/aws-wif-cli.sh.tmpl
25+
#
26+
# Only actions in the cloud-API namespaces the application code calls are
27+
# compared (see ACTION_PREFIXES). Platform plumbing (logs, dynamodb, ses, sns,
28+
# secretsmanager, sts, ssmmessages, lambda) legitimately differs per deployment
29+
# flavor and is excluded.
30+
#
31+
# Exit 0 = all lists match.
32+
# Exit 1 = drift found; the diff is printed to stderr.
33+
# Exit 2 = usage / environment error.
34+
#
35+
# Usage:
36+
# scripts/check-aws-iam-parity.sh [--root <path>]
37+
#
38+
# --root lets the test harness point at a fixture tree that mirrors the repo
39+
# layout without touching the real sources.
40+
41+
set -euo pipefail
42+
43+
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
44+
45+
while [[ $# -gt 0 ]]; do
46+
case "$1" in
47+
--root) REPO_ROOT="$2"; shift 2 ;;
48+
*) echo "Unknown flag: $1" >&2; exit 2 ;;
49+
esac
50+
done
51+
52+
# IAM action namespaces owned by the application code. Keep in sync with the
53+
# services the providers/aws code actually calls.
54+
ACTION_PREFIXES='ce|ec2|rds|elasticache|es|redshift|memorydb|savingsplans|organizations'
55+
56+
# --- extraction ---------------------------------------------------------------
57+
# Pull every token shaped like <prefix>:<CamelCaseAction> out of a file,
58+
# regardless of whether it is YAML, HCL, or an embedded JSON heredoc. IAM
59+
# actions always start with an uppercase letter after the colon, which keeps
60+
# ARNs (region segments are lowercase) out of the match.
61+
62+
extract_actions() {
63+
local file="$1"
64+
local exclude_orgs="${2:-}"
65+
66+
if [[ ! -f "$file" ]]; then
67+
echo "ERROR: file not found: $file" >&2
68+
exit 2
69+
fi
70+
71+
local actions
72+
actions=$(grep -oE "(^|[^A-Za-z])(${ACTION_PREFIXES}):[A-Z][A-Za-z]+" "$file" \
73+
| sed 's/^[^a-zA-Z]//' \
74+
| sort -u)
75+
76+
if [[ "$exclude_orgs" == "no-orgs" ]]; then
77+
actions=$(printf '%s\n' "$actions" | grep -v '^organizations:' || true)
78+
fi
79+
80+
if [[ -z "$actions" ]]; then
81+
echo "ERROR: no IAM actions extracted from: $file" >&2
82+
echo " Did the policy move or change format?" >&2
83+
exit 2
84+
fi
85+
86+
printf '%s\n' "$actions"
87+
}
88+
89+
# --- comparison ---------------------------------------------------------------
90+
91+
FAILURES=0
92+
93+
compare_pair() {
94+
local label="$1" ref_name="$2" ref_actions="$3" other_name="$4" other_actions="$5"
95+
96+
local diff_out
97+
diff_out=$(diff <(printf '%s\n' "$ref_actions") <(printf '%s\n' "$other_actions") || true)
98+
99+
if [[ -n "$diff_out" ]]; then
100+
{
101+
echo "ERROR [$label]: IAM action drift between:"
102+
echo " < $ref_name"
103+
echo " > $other_name"
104+
echo "$diff_out"
105+
echo ""
106+
} >&2
107+
FAILURES=$((FAILURES + 1))
108+
fi
109+
}
110+
111+
# --- 1. runtime: CFN stack vs TF lambda vs TF fargate --------------------------
112+
113+
CFN_STACK="$REPO_ROOT/cloudformation/stacks/CUDly/template.yaml"
114+
TF_LAMBDA="$REPO_ROOT/terraform/modules/compute/aws/lambda/main.tf"
115+
TF_FARGATE="$REPO_ROOT/terraform/modules/compute/aws/fargate/main.tf"
116+
117+
cfn_stack_actions=$(extract_actions "$CFN_STACK")
118+
tf_lambda_actions=$(extract_actions "$TF_LAMBDA")
119+
tf_fargate_actions=$(extract_actions "$TF_FARGATE")
120+
121+
compare_pair "runtime" "$CFN_STACK" "$cfn_stack_actions" "$TF_LAMBDA" "$tf_lambda_actions"
122+
compare_pair "runtime" "$TF_LAMBDA" "$tf_lambda_actions" "$TF_FARGATE" "$tf_fargate_actions"
123+
124+
# --- 2. federation cross-account pair (all namespaces) -------------------------
125+
126+
FED_XACC_CFN="$REPO_ROOT/iac/federation/aws-cross-account/cloudformation/template.yaml"
127+
FED_XACC_TF="$REPO_ROOT/iac/federation/aws-cross-account/terraform/main.tf"
128+
129+
fed_cfn_actions=$(extract_actions "$FED_XACC_CFN")
130+
fed_tf_actions=$(extract_actions "$FED_XACC_TF")
131+
132+
compare_pair "federation cross-account" "$FED_XACC_CFN" "$fed_cfn_actions" "$FED_XACC_TF" "$fed_tf_actions"
133+
134+
# --- 3. federation core across all flavors (org discovery excluded) ------------
135+
136+
FED_WIF_CFN="$REPO_ROOT/iac/federation/aws-target/cloudformation/template.yaml"
137+
FED_WIF_TF="$REPO_ROOT/iac/federation/aws-target/terraform/main.tf"
138+
FED_XACC_CLI="$REPO_ROOT/internal/iacfiles/templates/aws-cross-account-cli.sh.tmpl"
139+
FED_WIF_CLI="$REPO_ROOT/internal/iacfiles/templates/aws-wif-cli.sh.tmpl"
140+
141+
fed_core_ref=$(extract_actions "$FED_XACC_CFN" no-orgs)
142+
143+
for f in "$FED_XACC_TF" "$FED_WIF_CFN" "$FED_WIF_TF" "$FED_XACC_CLI" "$FED_WIF_CLI"; do
144+
compare_pair "federation core" "$FED_XACC_CFN" "$fed_core_ref" "$f" "$(extract_actions "$f" no-orgs)"
145+
done
146+
147+
# --- result --------------------------------------------------------------------
148+
149+
if [[ "$FAILURES" -gt 0 ]]; then
150+
echo "FAILED: $FAILURES IAM parity comparison(s) drifted. Update the lagging file(s) so all lists match." >&2
151+
exit 1
152+
fi
153+
154+
echo "OK: AWS IAM action lists are in parity across CloudFormation, Terraform, and CLI onboarding templates."
155+
exit 0

0 commit comments

Comments
 (0)