From 667f4042bbabe5deb492c25cb7d7a5cfa2624dfd Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 8 Sep 2026 08:50:49 +0200 Subject: [PATCH 1/4] fix(iac/aws): grant the runtime role nine actions the code calls The Lambda module, the Fargate module and the CloudFormation stack all grant the same action list, and all three miss actions the application calls under the runtime role: the ladder baseline (ce:GetCostAndUsage), the RI Marketplace sell path (ec2:{Create,Describe,Cancel} ReservedInstancesListing[s]), EC2 SKU enrichment (ec2:DescribeInstanceTypes) and post-purchase commitment tagging (ec2:CreateTags scoped to reserved-instances/*, redshift:DescribeTags, redshift:CreateTags, es:AddTags). The Redshift DescribeTags gap blocks every Redshift purchase in an account that already holds a reserved node, because the idempotency lookup refuses to buy on any error. Closes #1967 Closes #1968 Co-Authored-By: claude-flow Claude-Session: https://claude.ai/code/session_01Fu9uWjxtDFx5HDKeMRt1jC --- cloudformation/stacks/CUDly/template.yaml | 16 ++++++++++++++++ terraform/modules/compute/aws/fargate/main.tf | 17 +++++++++++++++++ terraform/modules/compute/aws/lambda/main.tf | 17 +++++++++++++++++ 3 files changed, 50 insertions(+) diff --git a/cloudformation/stacks/CUDly/template.yaml b/cloudformation/stacks/CUDly/template.yaml index 8da66879f..36bf15997 100644 --- a/cloudformation/stacks/CUDly/template.yaml +++ b/cloudformation/stacks/CUDly/template.yaml @@ -420,6 +420,7 @@ Resources: - Sid: CostExplorer Effect: Allow Action: + - ce:GetCostAndUsage - ce:GetReservationPurchaseRecommendation - ce:GetReservationUtilization - ce:GetReservationCoverage @@ -458,8 +459,20 @@ Resources: - ec2:AcceptReservedInstancesExchangeQuote - ec2:DescribeRegions - ec2:DescribeInstanceTypeOfferings + - ec2:DescribeInstanceTypes + - ec2:CreateReservedInstancesListing + - ec2:DescribeReservedInstancesListings + - ec2:CancelReservedInstancesListing Resource: "*" + # Post-purchase tagging of EC2 RIs; the only action here that + # supports resource-level scoping. + - Sid: EC2ReservedInstanceTagging + Effect: Allow + Action: + - ec2:CreateTags + Resource: "arn:aws:ec2:*:*:reserved-instances/*" + # OpenSearch Reserved Instances - Sid: OpenSearchReservedInstances Effect: Allow @@ -467,6 +480,7 @@ Resources: - es:DescribeReservedInstanceOfferings - es:DescribeReservedInstances - es:PurchaseReservedInstanceOffering + - es:AddTags Resource: "*" # Redshift Reserved Nodes @@ -476,6 +490,8 @@ Resources: - redshift:DescribeReservedNodeOfferings - redshift:DescribeReservedNodes - redshift:PurchaseReservedNodeOffering + - redshift:DescribeTags + - redshift:CreateTags Resource: "*" # MemoryDB Reserved Nodes diff --git a/terraform/modules/compute/aws/fargate/main.tf b/terraform/modules/compute/aws/fargate/main.tf index 31b6362ae..6308c525c 100644 --- a/terraform/modules/compute/aws/fargate/main.tf +++ b/terraform/modules/compute/aws/fargate/main.tf @@ -347,7 +347,12 @@ resource "aws_iam_role_policy" "ri_exchange" { "ec2:AcceptReservedInstancesExchangeQuote", "ec2:PurchaseReservedInstancesOffering", "ec2:DescribeInstanceTypeOfferings", + "ec2:DescribeInstanceTypes", "ec2:DescribeRegions", + # EC2 RI Marketplace listings (sell path) + "ec2:CreateReservedInstancesListing", + "ec2:DescribeReservedInstancesListings", + "ec2:CancelReservedInstancesListing", # RDS reserved instances "rds:DescribeReservedDBInstances", "rds:DescribeReservedDBInstancesOfferings", @@ -361,15 +366,19 @@ resource "aws_iam_role_policy" "ri_exchange" { "es:DescribeReservedInstances", "es:DescribeReservedInstanceOfferings", "es:PurchaseReservedInstanceOffering", + "es:AddTags", # Redshift reserved nodes "redshift:DescribeReservedNodes", "redshift:DescribeReservedNodeOfferings", "redshift:PurchaseReservedNodeOffering", + "redshift:DescribeTags", + "redshift:CreateTags", # MemoryDB reserved nodes "memorydb:DescribeReservedNodes", "memorydb:DescribeReservedNodesOfferings", "memorydb:PurchaseReservedNodesOffering", # Cost Explorer + "ce:GetCostAndUsage", "ce:GetReservationUtilization", "ce:GetReservationPurchaseRecommendation", "ce:GetReservationCoverage", @@ -383,6 +392,14 @@ resource "aws_iam_role_policy" "ri_exchange" { "savingsplans:CreateSavingsPlan", ] Resource = "*" + }, + { + # Post-purchase tagging of EC2 RIs (tagReservedInstance in + # providers/aws/services/ec2/client.go). Unlike the purchase and + # describe actions above, this one supports resource-level scoping. + Effect = "Allow" + Action = ["ec2:CreateTags"] + Resource = "arn:aws:ec2:*:*:reserved-instances/*" } ] }) diff --git a/terraform/modules/compute/aws/lambda/main.tf b/terraform/modules/compute/aws/lambda/main.tf index 133b6e60e..b881ddc0b 100644 --- a/terraform/modules/compute/aws/lambda/main.tf +++ b/terraform/modules/compute/aws/lambda/main.tf @@ -355,7 +355,12 @@ resource "aws_iam_role_policy" "ri_exchange" { "ec2:AcceptReservedInstancesExchangeQuote", "ec2:PurchaseReservedInstancesOffering", "ec2:DescribeInstanceTypeOfferings", + "ec2:DescribeInstanceTypes", "ec2:DescribeRegions", + # EC2 RI Marketplace listings (sell path) + "ec2:CreateReservedInstancesListing", + "ec2:DescribeReservedInstancesListings", + "ec2:CancelReservedInstancesListing", # RDS reserved instances "rds:DescribeReservedDBInstances", "rds:DescribeReservedDBInstancesOfferings", @@ -369,15 +374,19 @@ resource "aws_iam_role_policy" "ri_exchange" { "es:DescribeReservedInstances", "es:DescribeReservedInstanceOfferings", "es:PurchaseReservedInstanceOffering", + "es:AddTags", # Redshift reserved nodes "redshift:DescribeReservedNodes", "redshift:DescribeReservedNodeOfferings", "redshift:PurchaseReservedNodeOffering", + "redshift:DescribeTags", + "redshift:CreateTags", # MemoryDB reserved nodes "memorydb:DescribeReservedNodes", "memorydb:DescribeReservedNodesOfferings", "memorydb:PurchaseReservedNodesOffering", # Cost Explorer + "ce:GetCostAndUsage", "ce:GetReservationUtilization", "ce:GetReservationPurchaseRecommendation", "ce:GetReservationCoverage", @@ -391,6 +400,14 @@ resource "aws_iam_role_policy" "ri_exchange" { "savingsplans:CreateSavingsPlan", ] Resource = "*" + }, + { + # Post-purchase tagging of EC2 RIs (tagReservedInstance in + # providers/aws/services/ec2/client.go). Unlike the purchase and + # describe actions above, this one supports resource-level scoping. + Effect = "Allow" + Action = ["ec2:CreateTags"] + Resource = "arn:aws:ec2:*:*:reserved-instances/*" } ] }) From 1b97700196d659d584240c5e39d17bc03c84fedd Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 8 Sep 2026 08:51:28 +0200 Subject: [PATCH 2/4] test(iac/aws): guard that every SDK action the code calls is granted by each runtime flavor check-aws-iam-parity.sh compares the templates with each other, so an action missing from all of them passes. Derive the called set from the aws-sdk-go-v2 *Input literals under providers/aws and internal and assert each runtime flavor grants it. Co-Authored-By: claude-flow Claude-Session: https://claude.ai/code/session_01Fu9uWjxtDFx5HDKeMRt1jC --- .../compute/aws/runtime_iam_coverage_test.go | 262 ++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 terraform/modules/compute/aws/runtime_iam_coverage_test.go diff --git a/terraform/modules/compute/aws/runtime_iam_coverage_test.go b/terraform/modules/compute/aws/runtime_iam_coverage_test.go new file mode 100644 index 000000000..4f4530e11 --- /dev/null +++ b/terraform/modules/compute/aws/runtime_iam_coverage_test.go @@ -0,0 +1,262 @@ +// Guards the class of defect behind #1967 and #1968: an AWS action the +// application calls under the runtime role that no runtime IaC flavor grants. +// check-aws-iam-parity.sh cannot see this class because it only compares the +// three runtime flavors with each other, so a gap present in all three passes. +// This derives the called-action set straight from the SDK request structs +// under providers/aws and internal and asserts each flavor grants every one. +package aws_test + +import ( + "go/ast" + "go/parser" + "go/token" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "testing" +) + +// sdkServiceToIAMPrefix maps an aws-sdk-go-v2 service package name to the IAM +// action prefix it authorizes against. Two services rename: Cost Explorer's +// package is costexplorer but its actions are ce:*, and OpenSearch's package +// is opensearch but its actions (for historical reasons) are es:*. sts is +// deliberately absent: GetCallerIdentity requires no IAM permission at all, +// so a call to it must never enter the derived set. +var sdkServiceToIAMPrefix = map[string]string{ + "costexplorer": "ce", + "ec2": "ec2", + "rds": "rds", + "elasticache": "elasticache", + "opensearch": "es", + "redshift": "redshift", + "memorydb": "memorydb", + "savingsplans": "savingsplans", + "organizations": "organizations", +} + +// requiredDerivedActions is the floor from #1967/#1968: actions the code is +// known to call that no runtime flavor grants today. Asserting the scanner +// finds these (independent of whether any file grants them) catches a broken +// walker directly, rather than letting it pass by inspecting nothing. +var requiredDerivedActions = []string{ + "ce:GetCostAndUsage", + "ec2:CreateReservedInstancesListing", + "ec2:DescribeReservedInstancesListings", + "ec2:CancelReservedInstancesListing", + "ec2:DescribeInstanceTypes", + "ec2:CreateTags", + "redshift:DescribeTags", + "redshift:CreateTags", + "es:AddTags", +} + +// scanRoots are walked, relative to the repo root, for SDK call sites. Not +// cmd/: the CLI runs under operator credentials, a different identity than +// the runtime role this test guards (organizations:DescribeAccount is +// CLI-only for exactly this reason, per #1322). +var scanRoots = []string{ + filepath.Join("providers", "aws"), + "internal", +} + +// runtimeIAMFiles are the IaC files that grant the runtime role's IAM +// actions, relative to this package's directory (the three flavors compared +// by check-aws-iam-parity.sh comparison 1). +var runtimeIAMFiles = []string{ + filepath.Join("lambda", "main.tf"), + filepath.Join("fargate", "main.tf"), + filepath.Join("..", "..", "..", "..", "cloudformation", "stacks", "CUDly", "template.yaml"), +} + +// calledAction records where a derived action was first seen, so a failure +// message names a place the reader can open. +type calledAction struct { + file string + line int +} + +func TestRuntimeGrantsEveryCalledAction(t *testing.T) { + called := deriveCalledActions(t) + + for _, action := range requiredDerivedActions { + if _, ok := called[action]; !ok { + t.Errorf("scanner did not derive %s from %v; it is a known SDK call the runtime role must be granted (#1967/#1968) and its absence here means the walker is broken, not that the call went away", action, scanRoots) + } + } + + for _, rel := range runtimeIAMFiles { + t.Run(rel, func(t *testing.T) { + granted := grantedActions(t, rel) + for action, site := range called { + if !granted[action] { + t.Errorf("%s does not grant %s, called at %s:%d", rel, action, site.file, site.line) + } + } + }) + } +} + +// deriveCalledActions walks scanRoots and returns every IAM action implied by +// an SDK *Input request literal, keyed by action. +func deriveCalledActions(t *testing.T) map[string]calledAction { + t.Helper() + + root := repoRoot(t) + fset := token.NewFileSet() + actions := map[string]calledAction{} + filesScanned := 0 + + for _, rel := range scanRoots { + dir := filepath.Join(root, rel) + err := filepath.WalkDir(dir, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + if d.IsDir() || filepath.Ext(path) != ".go" || strings.HasSuffix(path, "_test.go") { + return nil + } + filesScanned++ + return scanFileForActions(t, fset, root, path, actions) + }) + if err != nil { + t.Fatalf("walking %s: %v", dir, err) + } + } + + if filesScanned == 0 { + t.Fatalf("walked %v and parsed zero Go files; every assertion below would pass by inspecting nothing", scanRoots) + } + if len(actions) == 0 { + t.Fatalf("derived zero SDK actions from %v; a broken import or literal match would pass every assertion below by inspecting nothing", scanRoots) + } + return actions +} + +// scanFileForActions parses one Go file and records every action its SDK +// *Input literals imply into actions, keyed by action so the first call site +// wins. +func scanFileForActions(t *testing.T, fset *token.FileSet, root, path string, actions map[string]calledAction) error { + t.Helper() + + file, parseErr := parser.ParseFile(fset, path, nil, parser.SkipObjectResolution) + if parseErr != nil { + t.Fatalf("parsing %s: %v", path, parseErr) + } + + aliasToPrefix := sdkImportAliases(file) + if len(aliasToPrefix) == 0 { + return nil + } + + relPath, relErr := filepath.Rel(root, path) + if relErr != nil { + return relErr + } + + ast.Inspect(file, func(n ast.Node) bool { + action, ok := actionFromCompositeLit(n, aliasToPrefix) + if !ok { + return true + } + if _, exists := actions[action]; exists { + return true + } + actions[action] = calledAction{file: relPath, line: fset.Position(n.Pos()).Line} + return true + }) + return nil +} + +// actionFromCompositeLit reports the IAM action implied by n, if n is a +// composite literal of an SDK *Input request type whose package alias +// resolves through aliasToPrefix (e.g. &ec2.CreateTagsInput{...} -> "ec2:CreateTags"). +func actionFromCompositeLit(n ast.Node, aliasToPrefix map[string]string) (string, bool) { + cl, ok := n.(*ast.CompositeLit) + if !ok { + return "", false + } + sel, ok := cl.Type.(*ast.SelectorExpr) + if !ok { + return "", false + } + ident, ok := sel.X.(*ast.Ident) + if !ok { + return "", false + } + prefix, ok := aliasToPrefix[ident.Name] + if !ok { + return "", false + } + typeName := sel.Sel.Name + op := strings.TrimSuffix(typeName, "Input") + if op == "" || op == typeName { + return "", false + } + return prefix + ":" + op, true +} + +// sdkImportAliases returns, for one file, the map from the local identifier +// an aws-sdk-go-v2 service package is used under to the IAM prefix it +// authorizes against. A subpackage import such as .../service/ec2/types is +// deliberately excluded: it defines the enum and shape types the *Input +// structs embed, not the *Input structs themselves, so its alias must never +// stand in for the service package's. +func sdkImportAliases(file *ast.File) map[string]string { + const svcPrefix = "github.com/aws/aws-sdk-go-v2/service/" + + aliases := map[string]string{} + for _, imp := range file.Imports { + path, err := strconv.Unquote(imp.Path.Value) + if err != nil { + continue + } + rest := strings.TrimPrefix(path, svcPrefix) + if rest == path || strings.Contains(rest, "/") { + continue + } + prefix, ok := sdkServiceToIAMPrefix[rest] + if !ok { + continue + } + alias := rest + if imp.Name != nil { + if imp.Name.Name == "_" || imp.Name.Name == "." { + continue + } + alias = imp.Name.Name + } + aliases[alias] = prefix + } + return aliases +} + +// grantedActionPattern is check-aws-iam-parity.sh's extraction regex, +// rewritten with a capture group so the match includes only the action +// itself, not the boundary byte before it. +var grantedActionPattern = regexp.MustCompile(`(?:^|[^A-Za-z])((?:ce|ec2|rds|elasticache|es|redshift|memorydb|savingsplans|organizations):[A-Z][A-Za-z]+)`) + +// hashCommentLinePattern matches a whole line whose first non-blank byte is +// #, the comment style both Terraform and CloudFormation YAML use here. +var hashCommentLinePattern = regexp.MustCompile(`(?m)^[ \t]*#.*$`) + +// grantedActions reads path (relative to this package's directory, matching +// how go test sets its working directory) and returns the set of IAM actions +// it grants. Comment lines are stripped first, so a comment naming an action +// cannot satisfy the guard. +func grantedActions(t *testing.T, path string) map[string]bool { + t.Helper() + + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("reading %s: %v", path, err) + } + content := hashCommentLinePattern.ReplaceAllString(string(data), "") + + granted := map[string]bool{} + for _, m := range grantedActionPattern.FindAllStringSubmatch(content, -1) { + granted[m[1]] = true + } + return granted +} From c337080789cc8c31baca9958bf6e3e539eac011f Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Tue, 8 Sep 2026 09:06:31 +0200 Subject: [PATCH 3/4] test(iac/aws): scan pkg/ for SDK calls and state the guard's limits Adversarial review found the walker skipped pkg/, whose exchange package builds the RI quote and accept inputs and is reached at runtime from internal/api and internal/server. Both actions happen to be granted, so the blind spot was empty, but the header claimed to cover runtime call sites and did not. Adding pkg/ closes it: removing ec2:GetReservedInstancesExchangeQuote from one flavor now fails naming pkg/exchange/exchange.go:238. Also records two limits the code did not state. The guard is one-way, so a grant no code uses passes silently; that is #1322's subject. And the prefix map covers only the reservation-related services, matching the parity script's scope, so platform namespaces are out of reach and have gaps of their own on #1204. Both were true before; neither was written down, which is how a guard gets trusted for more than it does. The cmd/ exclusion note now says why it is safe rather than only that it is: cmd/server is a runtime entry point, and the exclusion holds only while it imports no SDK service package directly. Co-Authored-By: claude-flow Claude-Session: https://claude.ai/code/session_01Fu9uWjxtDFx5HDKeMRt1jC --- .../compute/aws/runtime_iam_coverage_test.go | 28 +++++++++++++++---- 1 file changed, 23 insertions(+), 5 deletions(-) diff --git a/terraform/modules/compute/aws/runtime_iam_coverage_test.go b/terraform/modules/compute/aws/runtime_iam_coverage_test.go index 4f4530e11..6f5a47562 100644 --- a/terraform/modules/compute/aws/runtime_iam_coverage_test.go +++ b/terraform/modules/compute/aws/runtime_iam_coverage_test.go @@ -3,7 +3,14 @@ // check-aws-iam-parity.sh cannot see this class because it only compares the // three runtime flavors with each other, so a gap present in all three passes. // This derives the called-action set straight from the SDK request structs -// under providers/aws and internal and asserts each flavor grants every one. +// under the scanned roots and asserts each flavor grants every one. +// +// Two limits are deliberate. The guard is one-way: it catches an action the +// code calls that a flavor does not grant, never a grant no code uses. Dead +// grants are #1322's subject. And it covers only the reservation-related +// service prefixes below, matching check-aws-iam-parity.sh's scope; platform +// namespaces (sns, ses, secretsmanager, lambda, kms, logs) are out of reach +// and have known gaps of their own, tracked on #1204. package aws_test import ( @@ -52,13 +59,24 @@ var requiredDerivedActions = []string{ "es:AddTags", } -// scanRoots are walked, relative to the repo root, for SDK call sites. Not -// cmd/: the CLI runs under operator credentials, a different identity than -// the runtime role this test guards (organizations:DescribeAccount is -// CLI-only for exactly this reason, per #1322). +// scanRoots are walked, relative to the repo root, for SDK call sites. +// +// pkg/ is included because internal/ imports it at runtime: pkg/exchange +// builds the RI exchange quote and accept inputs, reached from +// internal/api/handler_ri_exchange.go and internal/server/ladder_write.go. +// Being a separate Go module does not matter here; the walker reads files. +// +// cmd/ is excluded because the CLI runs under operator credentials, a +// different identity than the runtime role this test guards +// (organizations:DescribeAccount is CLI-only for exactly this reason, per +// #1322). Note cmd/server IS a runtime entry point: the exclusion is safe +// only while cmd/server and cmd/cudly-mcp import no SDK service package +// directly, which holds today. If either starts issuing its own SDK calls, +// add it here. var scanRoots = []string{ filepath.Join("providers", "aws"), "internal", + "pkg", } // runtimeIAMFiles are the IaC files that grant the runtime role's IAM From 899ab790d40cf4bf0475edd7cd32ce2f21481186 Mon Sep 17 00:00:00 2001 From: Cristian Magherusan-Stanciu Date: Mon, 14 Sep 2026 23:59:38 +0200 Subject: [PATCH 4/4] fix(iam): limit runtime additions to supported CE and EC2 calls Keep the six runtime grants backed by existing CE and EC2 requests. Remove reserved-resource tag grants whose API contracts do not support the claimed fix, and narrow the source-action guard to its actual scope. OpenSearch tagging is tracked in #1204; Redshift support is tracked separately in #2092. Neither is repaired by widening runtime IAM. --- cloudformation/stacks/CUDly/template.yaml | 3 - terraform/modules/compute/aws/fargate/main.tf | 3 - terraform/modules/compute/aws/lambda/main.tf | 3 - .../compute/aws/runtime_iam_coverage_test.go | 56 ++++++------------- 4 files changed, 17 insertions(+), 48 deletions(-) diff --git a/cloudformation/stacks/CUDly/template.yaml b/cloudformation/stacks/CUDly/template.yaml index 36bf15997..d2d5fd717 100644 --- a/cloudformation/stacks/CUDly/template.yaml +++ b/cloudformation/stacks/CUDly/template.yaml @@ -480,7 +480,6 @@ Resources: - es:DescribeReservedInstanceOfferings - es:DescribeReservedInstances - es:PurchaseReservedInstanceOffering - - es:AddTags Resource: "*" # Redshift Reserved Nodes @@ -490,8 +489,6 @@ Resources: - redshift:DescribeReservedNodeOfferings - redshift:DescribeReservedNodes - redshift:PurchaseReservedNodeOffering - - redshift:DescribeTags - - redshift:CreateTags Resource: "*" # MemoryDB Reserved Nodes diff --git a/terraform/modules/compute/aws/fargate/main.tf b/terraform/modules/compute/aws/fargate/main.tf index 6308c525c..546dc44b0 100644 --- a/terraform/modules/compute/aws/fargate/main.tf +++ b/terraform/modules/compute/aws/fargate/main.tf @@ -366,13 +366,10 @@ resource "aws_iam_role_policy" "ri_exchange" { "es:DescribeReservedInstances", "es:DescribeReservedInstanceOfferings", "es:PurchaseReservedInstanceOffering", - "es:AddTags", # Redshift reserved nodes "redshift:DescribeReservedNodes", "redshift:DescribeReservedNodeOfferings", "redshift:PurchaseReservedNodeOffering", - "redshift:DescribeTags", - "redshift:CreateTags", # MemoryDB reserved nodes "memorydb:DescribeReservedNodes", "memorydb:DescribeReservedNodesOfferings", diff --git a/terraform/modules/compute/aws/lambda/main.tf b/terraform/modules/compute/aws/lambda/main.tf index b881ddc0b..acbdbd4ae 100644 --- a/terraform/modules/compute/aws/lambda/main.tf +++ b/terraform/modules/compute/aws/lambda/main.tf @@ -374,13 +374,10 @@ resource "aws_iam_role_policy" "ri_exchange" { "es:DescribeReservedInstances", "es:DescribeReservedInstanceOfferings", "es:PurchaseReservedInstanceOffering", - "es:AddTags", # Redshift reserved nodes "redshift:DescribeReservedNodes", "redshift:DescribeReservedNodeOfferings", "redshift:PurchaseReservedNodeOffering", - "redshift:DescribeTags", - "redshift:CreateTags", # MemoryDB reserved nodes "memorydb:DescribeReservedNodes", "memorydb:DescribeReservedNodesOfferings", diff --git a/terraform/modules/compute/aws/runtime_iam_coverage_test.go b/terraform/modules/compute/aws/runtime_iam_coverage_test.go index 6f5a47562..06b13146e 100644 --- a/terraform/modules/compute/aws/runtime_iam_coverage_test.go +++ b/terraform/modules/compute/aws/runtime_iam_coverage_test.go @@ -1,16 +1,8 @@ -// Guards the class of defect behind #1967 and #1968: an AWS action the -// application calls under the runtime role that no runtime IaC flavor grants. -// check-aws-iam-parity.sh cannot see this class because it only compares the -// three runtime flavors with each other, so a gap present in all three passes. -// This derives the called-action set straight from the SDK request structs -// under the scanned roots and asserts each flavor grants every one. -// -// Two limits are deliberate. The guard is one-way: it catches an action the -// code calls that a flavor does not grant, never a grant no code uses. Dead -// grants are #1322's subject. And it covers only the reservation-related -// service prefixes below, matching check-aws-iam-parity.sh's scope; platform -// namespaces (sns, ses, secretsmanager, lambda, kms, logs) are out of reach -// and have known gaps of their own, tracked on #1204. +// Detects CE/EC2 action names missing from runtime IaC, including gaps shared +// by all three flavors that check-aws-iam-parity.sh cannot detect (#1967/#1968). +// This is source/action-presence coverage, not IAM evaluation: Effect, resource +// scope, role attachment, and inline comments require separate review. Unused +// grants (#1322) and other service namespaces are outside this guard's scope. package aws_test import ( @@ -25,22 +17,13 @@ import ( "testing" ) -// sdkServiceToIAMPrefix maps an aws-sdk-go-v2 service package name to the IAM -// action prefix it authorizes against. Two services rename: Cost Explorer's -// package is costexplorer but its actions are ce:*, and OpenSearch's package -// is opensearch but its actions (for historical reasons) are es:*. sts is -// deliberately absent: GetCallerIdentity requires no IAM permission at all, -// so a call to it must never enter the derived set. +// sdkServiceToIAMPrefix maps the CE/EC2 aws-sdk-go-v2 service package names to +// the IAM action prefixes they authorize against. Cost Explorer's package is +// costexplorer but its actions are ce:*. sts is deliberately absent: +// GetCallerIdentity requires no IAM permission and must never enter the set. var sdkServiceToIAMPrefix = map[string]string{ - "costexplorer": "ce", - "ec2": "ec2", - "rds": "rds", - "elasticache": "elasticache", - "opensearch": "es", - "redshift": "redshift", - "memorydb": "memorydb", - "savingsplans": "savingsplans", - "organizations": "organizations", + "costexplorer": "ce", + "ec2": "ec2", } // requiredDerivedActions is the floor from #1967/#1968: actions the code is @@ -54,9 +37,6 @@ var requiredDerivedActions = []string{ "ec2:CancelReservedInstancesListing", "ec2:DescribeInstanceTypes", "ec2:CreateTags", - "redshift:DescribeTags", - "redshift:CreateTags", - "es:AddTags", } // scanRoots are walked, relative to the repo root, for SDK call sites. @@ -250,19 +230,17 @@ func sdkImportAliases(file *ast.File) map[string]string { return aliases } -// grantedActionPattern is check-aws-iam-parity.sh's extraction regex, -// rewritten with a capture group so the match includes only the action -// itself, not the boundary byte before it. -var grantedActionPattern = regexp.MustCompile(`(?:^|[^A-Za-z])((?:ce|ec2|rds|elasticache|es|redshift|memorydb|savingsplans|organizations):[A-Z][A-Za-z]+)`) +// grantedActionPattern is the CE/EC2 subset of check-aws-iam-parity.sh's +// extraction regex, rewritten with a capture group so the match includes only +// the action itself, not the boundary byte before it. +var grantedActionPattern = regexp.MustCompile(`(?:^|[^A-Za-z])((?:ce|ec2):[A-Z][A-Za-z]+)`) // hashCommentLinePattern matches a whole line whose first non-blank byte is // #, the comment style both Terraform and CloudFormation YAML use here. var hashCommentLinePattern = regexp.MustCompile(`(?m)^[ \t]*#.*$`) -// grantedActions reads path (relative to this package's directory, matching -// how go test sets its working directory) and returns the set of IAM actions -// it grants. Comment lines are stripped first, so a comment naming an action -// cannot satisfy the guard. +// grantedActions extracts action names from path after stripping whole-line +// # comments. It does not parse policy semantics or other comment forms. func grantedActions(t *testing.T, path string) map[string]bool { t.Helper()