Repository navigation
fix(iam): drain remaining deploy-role grant gaps and pin them with tests #1782
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
da4a185
test(iam): add a grantNeed helper to check deploy grants against real…
cristim cef804b
fix(iam): drop unmatchable RDS proxy resource scopes
cristim 04e2ffa
test(iam): pin monitoring and migration-alarm deploy grants
cristim 4f48ac0
docs(lambda): point the migration alarm gate at policy_monitoring
cristim 94a0cee
fix(iam): grant acm tag removal and log retention removal
cristim 1fb4332
fix(iam): grant hosted zone lifecycle for the subdomain zone
cristim 5f738f6
test(iam): follow decision A in the RDS proxy tripwire and bound the …
cristim File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
94 changes: 94 additions & 0 deletions
94
terraform/environments/aws/ci-cd-permissions/grant_needs_test.go
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,94 @@ | ||
| package cicdpermissions_test | ||
|
|
||
| import "testing" | ||
|
|
||
| // grantNeed is one action a Terraform apply performs, with a sample real ARN | ||
| // of the resource it runs against. noResource marks an action the SAR gives no | ||
| // resource type (action-only); without it an empty arn is a bug in the need and | ||
| // fails instead of matching any statement. | ||
| type grantNeed struct { | ||
| action string | ||
| arn string | ||
| noResource bool | ||
| } | ||
|
|
||
| // grantedBy reports whether any statement in allow authorizes need. | ||
| func grantedBy(allow []string, need grantNeed) bool { | ||
| for _, stmt := range allow { | ||
| if !actionPermitted(statementActions(stmt), need.action) { | ||
| continue | ||
| } | ||
| if need.noResource { | ||
| return true | ||
| } | ||
| if need.arn == "" { | ||
| return false | ||
| } | ||
| for _, r := range statementResources(stmt) { | ||
| if iamARNMatches(r, need.arn) { | ||
| return true | ||
| } | ||
| } | ||
| } | ||
| return false | ||
| } | ||
|
|
||
| // deployAllowStatements returns every Allow statement of the deploy-role | ||
| // documents. The boundary caps the workload roles and its broad grants | ||
| // (logs:*, rds:*) would mask a missing deploy grant, so it is excluded. | ||
| func deployAllowStatements(t *testing.T) []string { | ||
| t.Helper() | ||
| var allow []string | ||
| for _, f := range deployPolicyFiles(t) { | ||
| for _, stmt := range extractStatementBlocks(readPolicySource(t, f)) { | ||
| if statementEffect(stmt) == "Allow" { | ||
| allow = append(allow, stmt) | ||
| } | ||
| } | ||
| } | ||
| if len(allow) == 0 { | ||
| t.Fatal("found no Allow statements; the statement splitter is broken and this test would pass vacuously") | ||
| } | ||
| return allow | ||
| } | ||
|
|
||
| // assertGranted fails for every need that no deploy Allow statement authorizes. | ||
| // Deny statements are ignored: none touches rds, route53, acm, logs or sns | ||
| // today, so a future Deny on those services must be a conscious change here. | ||
| func assertGranted(t *testing.T, label string, needs []grantNeed) { | ||
| t.Helper() | ||
| allow := deployAllowStatements(t) | ||
| for _, n := range needs { | ||
| if !grantedBy(allow, n) { | ||
| t.Errorf("%s: no deploy policy statement grants %s on %q", label, n.action, n.arn) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| func TestGrantedBy(t *testing.T) { | ||
| stmt := func(resource string) string { | ||
| return `{ | ||
| Effect = "Allow" | ||
| Action = ["rds:ModifyDBProxy"] | ||
| Resource = ` + resource + ` | ||
| }` | ||
| } | ||
| const proxy = "arn:aws:rds:us-east-1:123456789012:db-proxy:prx-0123456789abcdef" | ||
| cases := []struct { | ||
| name string | ||
| stmt string | ||
| need grantNeed | ||
| want bool | ||
| }{ | ||
| {"action only", stmt(`"*"`), grantNeed{action: "rds:ModifyDBProxy", noResource: true}, true}, | ||
| {"wrong action", stmt(`"*"`), grantNeed{action: "rds:DeleteDBProxy", noResource: true}, false}, | ||
| {"real arn", stmt(`["arn:aws:rds:*:*:db-proxy:*"]`), grantNeed{action: "rds:ModifyDBProxy", arn: proxy}, true}, | ||
| {"dead arn scope", stmt(`["arn:aws:rds:*:*:proxy:cudly-*"]`), grantNeed{action: "rds:ModifyDBProxy", arn: proxy}, false}, | ||
| {"empty arn is not action-only", stmt(`"*"`), grantNeed{action: "rds:ModifyDBProxy"}, false}, | ||
| } | ||
| for _, c := range cases { | ||
| if got := grantedBy([]string{c.stmt}, c.need); got != c.want { | ||
| t.Errorf("%s: grantedBy = %v, want %v", c.name, got, c.want) | ||
| } | ||
| } | ||
| } |
51 changes: 51 additions & 0 deletions
51
terraform/environments/aws/ci-cd-permissions/monitoring_apply_test.go
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,51 @@ | ||
| package cicdpermissions_test | ||
|
|
||
| import ( | ||
| "strings" | ||
| "testing" | ||
| ) | ||
|
|
||
| // TestDeployPolicyGrantsEveryActionTheMonitoringModuleNeeds pins grants that | ||
| // policy_monitoring.tf and policy_compute.tf already hold for | ||
| // enable_monitoring and enable_migration_alarm, both default-false and so | ||
| // unexercised by any deploy. The ARNs are the real shapes from the Service | ||
| // Authorization Reference: an SNS topic ARN has NO type segment, a CloudWatch | ||
| // dashboard ARN has an empty region and a `/` before the name, and an alarm ARN | ||
| // uses `alarm:` (#157). | ||
| func TestDeployPolicyGrantsEveryActionTheMonitoringModuleNeeds(t *testing.T) { | ||
| const ( | ||
| topic = "arn:aws:sns:us-east-1:123456789012:cudly-prod-abc-alerts" | ||
| dashboard = "arn:aws:cloudwatch::123456789012:dashboard/cudly-prod-abc-prod" | ||
| alarm = "arn:aws:cloudwatch:us-east-1:123456789012:alarm:cudly-prod-abc-lambda-errors" | ||
| lambdaLG = "arn:aws:logs:us-east-1:123456789012:log-group:/aws/lambda/cudly-prod-abc-api-20260101" | ||
| ecsLG = "arn:aws:logs:us-east-1:123456789012:log-group:/ecs/cudly-prod-abc" | ||
| ) | ||
| var needs []grantNeed | ||
| add := func(arn string, actions ...string) { | ||
| for _, a := range actions { | ||
| needs = append(needs, grantNeed{action: a, arn: arn}) | ||
| } | ||
| } | ||
| add(topic, "sns:CreateTopic", "sns:DeleteTopic", "sns:GetTopicAttributes", "sns:SetTopicAttributes", | ||
| "sns:Subscribe", "sns:Unsubscribe", "sns:GetSubscriptionAttributes", "sns:SetSubscriptionAttributes", | ||
| "sns:ListSubscriptionsByTopic", "sns:TagResource", "sns:UntagResource", "sns:ListTagsForResource") | ||
| add(dashboard, "cloudwatch:PutDashboard", "cloudwatch:GetDashboard", "cloudwatch:DeleteDashboards") | ||
| add(alarm, "cloudwatch:PutMetricAlarm", "cloudwatch:DeleteAlarms", "cloudwatch:TagResource") | ||
| // The migration alarm's filter is on the Lambda log group; the monitoring | ||
| // module's filters are on the Lambda and ECS groups. | ||
| add(lambdaLG, "logs:PutMetricFilter", "logs:DeleteMetricFilter") | ||
| add(ecsLG, "logs:PutMetricFilter", "logs:DeleteMetricFilter") | ||
| // No resource-level permissions for these. | ||
| for _, a := range []string{"logs:DescribeMetricFilters", "logs:PutQueryDefinition", "logs:DescribeQueryDefinitions", "logs:DeleteQueryDefinition"} { | ||
| needs = append(needs, grantNeed{action: a, noResource: true}) | ||
| } | ||
| assertGranted(t, "monitoring", needs) | ||
|
|
||
| for _, stmt := range findStatements(t, "policy_monitoring.tf", func(string) bool { return true }) { | ||
| for _, r := range statementResources(stmt) { | ||
| if strings.Contains(r, ":topic/") || strings.Contains(r, ":sns:*:*:topic") { | ||
| t.Errorf("statement %q: Resource %q has a topic type segment, which an SNS topic ARN does not have", statementSid(stmt), r) | ||
| } | ||
| } | ||
| } | ||
| } | ||
119 changes: 119 additions & 0 deletions
119
terraform/environments/aws/ci-cd-permissions/optin_paths_test.go
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,119 @@ | ||
| package cicdpermissions_test | ||
|
|
||
| import "testing" | ||
|
|
||
| // TestDeployPolicyGrantsTagAndRetentionRemoval covers calls the AWS provider | ||
| // makes when an attribute is REMOVED rather than set, which a first apply never | ||
| // exercises (#157). Real ARN shapes per the Service Authorization Reference. | ||
| func TestDeployPolicyGrantsTagAndRetentionRemoval(t *testing.T) { | ||
| const logs = "arn:aws:logs:us-east-1:123456789012:log-group:" | ||
| assertGranted(t, "acm/logs removal", []grantNeed{ | ||
| // aws_acm_certificate.frontend drops a tag key (environments/aws/acm.tf). | ||
| {action: "acm:RemoveTagsFromCertificate", arn: "arn:aws:acm:us-east-1:123456789012:certificate/11111111-2222-3333-4444-555555555555"}, | ||
| // A log group retention going from N days to 0 (never expire). | ||
| {action: "logs:DeleteRetentionPolicy", arn: logs + "/aws/lambda/cudly-prod-abc-api-20260101"}, | ||
| {action: "logs:DeleteRetentionPolicy", arn: logs + "/ecs/cudly-prod-abc"}, | ||
| {action: "logs:DeleteRetentionPolicy", arn: logs + "/aws/vpc/cudly-prod-abc"}, | ||
| }) | ||
| } | ||
|
|
||
| // TestPutRetentionPolicyIsPairedWithDelete: a statement that can set a log | ||
| // retention must also be able to clear it, or lowering it to 0 fails the apply. | ||
| func TestPutRetentionPolicyIsPairedWithDelete(t *testing.T) { | ||
| seen := false | ||
| for _, stmt := range deployAllowStatements(t) { | ||
| if !actionPermitted(statementActions(stmt), "logs:PutRetentionPolicy") { | ||
| continue | ||
| } | ||
| seen = true | ||
| if !actionPermitted(statementActions(stmt), "logs:DeleteRetentionPolicy") { | ||
| t.Errorf("statement %q grants logs:PutRetentionPolicy without logs:DeleteRetentionPolicy", statementSid(stmt)) | ||
| } | ||
| } | ||
| if !seen { | ||
| t.Fatal("no statement grants logs:PutRetentionPolicy; the pairing check would pass vacuously") | ||
| } | ||
| } | ||
|
|
||
| // TestDeployPolicyGrantsHostedZoneLifecycle covers create_subdomain_zone = true | ||
| // (environments/aws/route53.tf). CreateHostedZone has no resource type; the | ||
| // hostedzone ARN carries an opaque id and the SAR defines no condition keys for | ||
| // it, so neither a name scope nor a tag gate is possible (#157). | ||
| func TestDeployPolicyGrantsHostedZoneLifecycle(t *testing.T) { | ||
| const zone = "arn:aws:route53:::hostedzone/Z0123456789ABCDEFGHIJ" | ||
| assertGranted(t, "route53 zone", []grantNeed{ | ||
| {action: "route53:CreateHostedZone", noResource: true}, | ||
| {action: "route53:ChangeTagsForResource", arn: zone}, | ||
| {action: "route53:DeleteHostedZone", arn: zone}, | ||
| }) | ||
| } | ||
|
|
||
| // resourcesOf returns the Resource entries of every deploy Allow statement that | ||
| // grants action, failing when none does (a vacuous bound). | ||
| func resourcesOf(t *testing.T, action string) map[string][]string { | ||
| t.Helper() | ||
| out := map[string][]string{} | ||
| for _, stmt := range deployAllowStatements(t) { | ||
| if actionPermitted(statementActions(stmt), action) { | ||
| out[statementSid(stmt)] = statementResources(stmt) | ||
| } | ||
| } | ||
| if len(out) == 0 { | ||
| t.Fatalf("no deploy statement grants %s; its upper-bound check would pass vacuously", action) | ||
| } | ||
| return out | ||
| } | ||
|
|
||
| // TestNewActionsAreNotGrantedOnWildcard is the upper bound for the #157 | ||
| // additions: they stay off Resource "*" where the SAR gives them a resource | ||
| // type. route53:CreateHostedZone has none, so it is exempt. The ACM actions | ||
| // stay on "*" like their siblings DeleteCertificate and AddTagsToCertificate, | ||
| // which this PR must not reshape: acm:RemoveTagsFromCertificate is exempt here | ||
| // and a tag gate for the whole ACM group is a follow-up. | ||
| func TestNewActionsAreNotGrantedOnWildcard(t *testing.T) { | ||
| for _, action := range []string{ | ||
| "logs:DeleteRetentionPolicy", | ||
| "route53:DeleteHostedZone", | ||
| "route53:ChangeTagsForResource", | ||
| } { | ||
| for sid, resources := range resourcesOf(t, action) { | ||
| for _, r := range resources { | ||
| if r == "*" { | ||
| t.Errorf("statement %q grants %s on Resource \"*\"; the SAR gives it a resource type", sid, action) | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| func TestRetentionRemovalStaysOnCudlyLogGroups(t *testing.T) { | ||
| prefixes := []string{ | ||
| "arn:aws:logs:*:*:log-group:/aws/lambda/cudly-", | ||
| "arn:aws:logs:*:*:log-group:/aws/vpc/cudly-", | ||
| "arn:aws:logs:*:*:log-group:/ecs/cudly-", | ||
| } | ||
| for sid, resources := range resourcesOf(t, "logs:DeleteRetentionPolicy") { | ||
| for _, r := range resources { | ||
| ok := false | ||
| for _, p := range prefixes { | ||
| ok = ok || (len(r) > len(p) && r[:len(p)] == p) | ||
| } | ||
| if !ok { | ||
| t.Errorf("statement %q grants logs:DeleteRetentionPolicy on %q, outside the cudly log-group prefixes", sid, r) | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| func TestHostedZoneActionsStayOnHostedZoneARNs(t *testing.T) { | ||
| const want = "arn:aws:route53:::hostedzone/*" | ||
| for _, action := range []string{"route53:DeleteHostedZone", "route53:ChangeTagsForResource"} { | ||
| for sid, resources := range resourcesOf(t, action) { | ||
| for _, r := range resources { | ||
| if r != want { | ||
| t.Errorf("statement %q grants %s on %q, want only %q", sid, action, r, want) | ||
| } | ||
| } | ||
| } | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.