Skip to content

Commit 1391680

Browse files
authored
Merge pull request #1219 from LeanerCloud/fix/inf-02-aws-iam-parity
fix(iac/aws): reconcile IAM action drift and add CFN/TF parity gate
2 parents 0401fc5 + d45ffc5 commit 1391680

13 files changed

Lines changed: 391 additions & 6 deletions

File tree

‎.github/workflows/ci.yml‎

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

483+
# Assert that the AWS IAM action lists are identical across the CFN stack,
484+
# the TF lambda/fargate modules, and the federation CFN/TF/CLI templates.
485+
# Fast (shell only), so it always runs.
486+
aws-iam-parity:
487+
name: AWS IAM actions parity (CFN vs TF)
488+
runs-on: ubuntu-latest
489+
490+
steps:
491+
- name: Checkout code
492+
uses: actions/checkout@93cb6efe18208431cddfb8368fd83d5badbf9bfd # v5.0.1
493+
with:
494+
persist-credentials: false
495+
496+
- name: Assert CFN/TF actions parity
497+
run: bash scripts/check-aws-iam-parity.sh
498+
499+
- name: Run parity script self-tests
500+
run: bash scripts/test-aws-iam-parity.sh
501+
483502
# Summary job - all checks must pass
484503
ci-success:
485504
name: CI Success
@@ -493,6 +512,7 @@ jobs:
493512
- security-scan
494513
- e2e-tests
495514
- azure-role-parity
515+
- aws-iam-parity
496516
if: always()
497517

498518
steps:

‎CHANGELOG.md‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,20 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/).
1515
CloudFormation deploy scripts had no registration call at all).
1616
Re-download the bundle from the CUDly UI and the new copy will register
1717
your account automatically with no manual edits required.
18+
- **Federation IaC bundles deployed before #1219 need to be re-applied** to
19+
pick up reconciled IAM action grants. Older bundles silently degrade on
20+
federated accounts: the Cost Explorer `Get*Coverage` actions are missing
21+
(coverage-targeted sizing assumes zero existing coverage), and the
22+
cross-account CloudFormation flavor lacks the optional `EnableOrgDiscovery`
23+
parameter and the legacy CE statement that the original `CUDly-CrossAccount`
24+
template carried. Re-download the federation bundle from the CUDly UI and
25+
re-apply (`terraform apply` / `aws cloudformation update-stack`) -- no
26+
manual edits required, no changes to existing CUDly resources. Customers
27+
on the runtime CloudFormation stack or Terraform lambda/fargate modules
28+
also need an update to pick up the new `ec2:*ReservedInstancesExchangeQuote`
29+
actions, `ec2:DescribeRegions`, `rds:DescribeDBInstances`, and the
30+
new-style `es:*ReservedInstance*` OpenSearch actions (replacing the legacy
31+
`es:*ReservedElasticsearch*` names).
1832

1933
### Fixed
2034

‎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: 162 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,162 @@
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)
48+
if [[ $# -lt 2 || -z "${2:-}" ]]; then
49+
echo "ERROR: --root requires a path value" >&2
50+
exit 2
51+
fi
52+
REPO_ROOT="$2"
53+
shift 2
54+
;;
55+
*) echo "Unknown flag: $1" >&2; exit 2 ;;
56+
esac
57+
done
58+
59+
# IAM action namespaces owned by the application code. Keep in sync with the
60+
# services the providers/aws code actually calls.
61+
ACTION_PREFIXES='ce|ec2|rds|elasticache|es|redshift|memorydb|savingsplans|organizations'
62+
63+
# --- extraction ---------------------------------------------------------------
64+
# Pull every token shaped like <prefix>:<CamelCaseAction> out of a file,
65+
# regardless of whether it is YAML, HCL, or an embedded JSON heredoc. IAM
66+
# actions always start with an uppercase letter after the colon, which keeps
67+
# ARNs (region segments are lowercase) out of the match.
68+
69+
extract_actions() {
70+
local file="$1"
71+
local exclude_orgs="${2:-}"
72+
73+
if [[ ! -f "$file" ]]; then
74+
echo "ERROR: file not found: $file" >&2
75+
exit 2
76+
fi
77+
78+
local actions
79+
actions=$(grep -oE "(^|[^A-Za-z])(${ACTION_PREFIXES}):[A-Z][A-Za-z]+" "$file" \
80+
| sed 's/^[^a-zA-Z]//' \
81+
| sort -u)
82+
83+
if [[ "$exclude_orgs" == "no-orgs" ]]; then
84+
actions=$(printf '%s\n' "$actions" | grep -v '^organizations:' || true)
85+
fi
86+
87+
if [[ -z "$actions" ]]; then
88+
echo "ERROR: no IAM actions extracted from: $file" >&2
89+
echo " Did the policy move or change format?" >&2
90+
exit 2
91+
fi
92+
93+
printf '%s\n' "$actions"
94+
}
95+
96+
# --- comparison ---------------------------------------------------------------
97+
98+
FAILURES=0
99+
100+
compare_pair() {
101+
local label="$1" ref_name="$2" ref_actions="$3" other_name="$4" other_actions="$5"
102+
103+
local diff_out
104+
diff_out=$(diff <(printf '%s\n' "$ref_actions") <(printf '%s\n' "$other_actions") || true)
105+
106+
if [[ -n "$diff_out" ]]; then
107+
{
108+
echo "ERROR [$label]: IAM action drift between:"
109+
echo " < $ref_name"
110+
echo " > $other_name"
111+
echo "$diff_out"
112+
echo ""
113+
} >&2
114+
FAILURES=$((FAILURES + 1))
115+
fi
116+
}
117+
118+
# --- 1. runtime: CFN stack vs TF lambda vs TF fargate --------------------------
119+
120+
CFN_STACK="$REPO_ROOT/cloudformation/stacks/CUDly/template.yaml"
121+
TF_LAMBDA="$REPO_ROOT/terraform/modules/compute/aws/lambda/main.tf"
122+
TF_FARGATE="$REPO_ROOT/terraform/modules/compute/aws/fargate/main.tf"
123+
124+
cfn_stack_actions=$(extract_actions "$CFN_STACK")
125+
tf_lambda_actions=$(extract_actions "$TF_LAMBDA")
126+
tf_fargate_actions=$(extract_actions "$TF_FARGATE")
127+
128+
compare_pair "runtime" "$CFN_STACK" "$cfn_stack_actions" "$TF_LAMBDA" "$tf_lambda_actions"
129+
compare_pair "runtime" "$TF_LAMBDA" "$tf_lambda_actions" "$TF_FARGATE" "$tf_fargate_actions"
130+
131+
# --- 2. federation cross-account pair (all namespaces) -------------------------
132+
133+
FED_XACC_CFN="$REPO_ROOT/iac/federation/aws-cross-account/cloudformation/template.yaml"
134+
FED_XACC_TF="$REPO_ROOT/iac/federation/aws-cross-account/terraform/main.tf"
135+
136+
fed_cfn_actions=$(extract_actions "$FED_XACC_CFN")
137+
fed_tf_actions=$(extract_actions "$FED_XACC_TF")
138+
139+
compare_pair "federation cross-account" "$FED_XACC_CFN" "$fed_cfn_actions" "$FED_XACC_TF" "$fed_tf_actions"
140+
141+
# --- 3. federation core across all flavors (org discovery excluded) ------------
142+
143+
FED_WIF_CFN="$REPO_ROOT/iac/federation/aws-target/cloudformation/template.yaml"
144+
FED_WIF_TF="$REPO_ROOT/iac/federation/aws-target/terraform/main.tf"
145+
FED_XACC_CLI="$REPO_ROOT/internal/iacfiles/templates/aws-cross-account-cli.sh.tmpl"
146+
FED_WIF_CLI="$REPO_ROOT/internal/iacfiles/templates/aws-wif-cli.sh.tmpl"
147+
148+
fed_core_ref=$(extract_actions "$FED_XACC_CFN" no-orgs)
149+
150+
for f in "$FED_XACC_TF" "$FED_WIF_CFN" "$FED_WIF_TF" "$FED_XACC_CLI" "$FED_WIF_CLI"; do
151+
compare_pair "federation core" "$FED_XACC_CFN" "$fed_core_ref" "$f" "$(extract_actions "$f" no-orgs)"
152+
done
153+
154+
# --- result --------------------------------------------------------------------
155+
156+
if [[ "$FAILURES" -gt 0 ]]; then
157+
echo "FAILED: $FAILURES IAM parity comparison(s) drifted. Update the lagging file(s) so all lists match." >&2
158+
exit 1
159+
fi
160+
161+
echo "OK: AWS IAM action lists are in parity across CloudFormation, Terraform, and CLI onboarding templates."
162+
exit 0

0 commit comments

Comments
 (0)