Summary
The audit in LeanerCloud/cloud-commitments-cli#1698 / LeanerCloud/cloud-commitments-cli#1699 enumerated every resource/data block under terraform/environments/aws/ and its modules against the four cudly-terraform-deploy policies. LeanerCloud/cloud-commitments-cli#1699 fixed everything that blocks a deploy today. This issue tracks the remainder: grants that are dead as written but currently unexercised, and permissions that are entirely absent for code paths gated behind opt-in flags.
None of these break a deploy today. All of them will break the moment the corresponding flag is flipped, which is exactly the failure mode that made LeanerCloud/cloud-commitments-cli#1496, LeanerCloud/cloud-commitments-cli#1514, LeanerCloud/cloud-commitments-cli#1671 and LeanerCloud/cloud-commitments-cli#1698 each look like a one-off.
1. RDS proxy ARN scopes are unmatchable
In policy_data.tf, Sid RDSResourceScoped:
arn:aws:rds:*:*:proxy:cudly-* is wrong on two counts: per the AWS Service Authorization Reference the proxy ARN segment is db-proxy:, not proxy:, and the identifier is a server-assigned prx-<opaque> that never contains the resource name.
arn:aws:rds:*:*:target-group:cudly-* has the right segment but the same opaque-id problem (prx-tg-<opaque>).
Consequently rds:DeleteDBProxy, rds:ModifyDBProxy, rds:RegisterDBProxyTargets and rds:DeregisterDBProxyTargets can never be authorized, and rds:ModifyDBProxyTargetGroup (needed by aws_db_proxy_default_target_group) is missing entirely.
Gated off by enable_rds_proxy = false, hardcoded in terraform/environments/aws/database.tf. LeanerCloud/cloud-commitments-cli#1699 marks the dead scopes in place with a KNOWN DEAD SCOPES comment rather than widening them to db-proxy:*, because constraining an opaque id properly needs a tag condition whose satisfiability has to be verified against the real create sequence, not assumed. Assuming it is precisely what LeanerCloud/cloud-commitments-cli#1671 got wrong.
Fix: determine whether aws:ResourceTag/Project is populated for db-proxy and target-group at each call site (in particular whether the default target group created by CreateDBProxy inherits the proxy's tags), then either tag-gate a db-proxy:* / target-group:* statement or accept Resource = "*" with a documented justification. Add rds:ModifyDBProxyTargetGroup at the same time.
2. CloudFront cannot be enabled
enable_cdn = false in all three tfvars, so module.frontend is count = 0. Turning it on hits three separate walls:
3. Monitoring module has no permissions and several would be dead on arrival
terraform/modules/monitoring/aws is not instantiated by any environment. If it is ever wired in, none of its permissions exist, and naive ARN scoping would produce fresh dead grants:
| Resource |
Required actions |
Scoping trap |
aws_sns_topic / aws_sns_topic_subscription |
sns:CreateTopic, Get/SetTopicAttributes, DeleteTopic, Subscribe, Unsubscribe, Get/SetSubscriptionAttributes, ListSubscriptionsByTopic, TagResource, UntagResource, ListTagsForResource |
SNS topic ARNs have no resource-type segment (arn:aws:sns:<region>:<account>:<name>), so topic/cudly-* would be dead. Subscription ops authorize against the topic ARN. |
aws_cloudwatch_dashboard |
cloudwatch:PutDashboard, GetDashboard, DeleteDashboards |
Dashboard ARNs are arn:aws:cloudwatch::<account>:dashboard/<name> — empty region segment, / separator. Folding these into the existing alarm:cudly-* statement would be dead. |
aws_cloudwatch_query_definition |
logs:PutQueryDefinition, DescribeQueryDefinitions, DeleteQueryDefinition |
No resource type; must be Resource = "*". |
aws_xray_sampling_rule |
xray:CreateSamplingRule, UpdateSamplingRule, DeleteSamplingRule, GetSamplingRules, TagResource, UntagResource, ListTagsForResource |
xray:GetSamplingRules (the Read path) has no resource type; scoping it makes terraform plan fail permanently. |
aws_guardduty_detector |
guardduty:CreateDetector, ListDetectors, GetDetector, UpdateDetector, DeleteDetector, TagResource, UntagResource, ListTagsForResource |
Create/List have no resource type. Detector ids are opaque 32-char hex, so detector/cudly-* would be dead; use a tag gate. |
aws_securityhub_account / aws_securityhub_standards_subscription |
securityhub:EnableSecurityHub, DisableSecurityHub, DescribeHub, UpdateSecurityHubConfiguration, GetEnabledStandards, BatchEnableStandards, BatchDisableStandards |
The hub ARN is literally hub/default. A cudly-* scope would be dead. |
These will not fit in policy_compute.tf (5933 of 6144 characters used). They belong in a new fifth policy, attached alongside the existing four in ci-cd-permissions/role.tf.
4. Incorrect comment in the tree
terraform/modules/compute/aws/lambda/migration-alarm.tf:25-29 states that the bootstrap grants logs:PutMetricFilter. It does not. Setting enable_migration_alarm = true today would 403. Either grant logs:PutMetricFilter / DescribeMetricFilters / DeleteMetricFilter (scoped to the same three log-group ARNs as the existing CloudWatchLogs statement) or correct the comment.
5. Remaining opt-in / update-path gaps
| Missing action |
Reachable when |
lambda:PutFunctionConcurrency, lambda:DeleteFunctionConcurrency |
lambda_reserved_concurrency >= 0 (currently -1) |
acm:RemoveTagsFromCertificate |
any tag change on a certificate; requires frontend_domain_names + subdomain_zone_name |
route53:CreateHostedZone, DeleteHostedZone, ChangeTagsForResource |
create_subdomain_zone = true |
logs:DeleteRetentionPolicy |
a retention variable set to 0 |
iam:PassRole for monitoring.rds.amazonaws.com |
RDS enhanced monitoring (monitoring_interval / monitoring_role_arn) |
iam:CreateOpenIDConnectProvider and friends |
only if the deploy role is ever pointed at the ci-cd-permissions bootstrap root, which it should not be |
6. Scope the EC2 tag grants, then the launch-template boundary (coupled)
From CodeRabbit on LeanerCloud/cloud-commitments-cli#1699 (policy_networking.tf), and the sequencing matters:
ec2:CreateTags is granted on Resource = "*" with no condition. Introduce an ec2:ResourceTag/Project gate anywhere in EC2 and that unconditioned grant immediately defeats it: the role self-tags the target, then passes its own gate. This is the hole KMSTagOnCreate guards with its Null condition on aws:ResourceTag/Project. So a launch-template tag boundary is worthless unless CreateTags is bounded first — they are one change, not two.
ec2:DeleteTags (added in LeanerCloud/cloud-commitments-cli#1699, required because the provider removes dropped keys before adding new ones) should be bounded in the same pass. It is the one grant in that PR that added a genuinely new capability: removing tags from any EC2 resource.
Do it in this order:
- Scope
ec2:CreateTags and ec2:DeleteTags by resource type and aws:TagKeys. Handle with care: CreateTags is on the critical path of every EC2 create in this config (VPC, subnets, route tables, both gateways, all seven security groups, launch template) via ec2:CreateAction tag-on-create semantics. A marginally wrong boundary fails terraform apply at the first resource.
- Only then add the launch-template boundary for
ec2:CreateLaunchTemplateVersion / DeleteLaunchTemplateVersions / ModifyLaunchTemplate.
Two constraints on step 2, both verified:
7. Scope the ELB grants as one statement, not four actions
From CodeRabbit on LeanerCloud/cloud-commitments-cli#1699 (policy_compute_b.tf). CodeRabbit is factually right that these support resource-level permissions, and ELB ARNs are not opaque: arn:aws:elasticloadbalancing:<region>:<acct>:loadbalancer/app/<name>/<id> where the name is ours (aws_lb.main.name = "${var.stack_name}-fargate"), so loadbalancer/app/cudly-*/* genuinely matches.
The reason LeanerCloud/cloud-commitments-cli#1699 did not do it: ELBFargate (policy_compute.tf:250) grants 19 elasticloadbalancing:* actions on Resource = "*", including DeleteLoadBalancer, DeleteTargetGroup and DeleteListener. ELBFargateSetAttributes is a size-driven split of that same statement. Scoping 4 of 23 siblings while the other 19 — every destructive one — stay on "*" reads as hardened without being hardened.
Scope all 23 together. Two details worth not re-deriving:
ModifyListenerAttributes takes the listener resource type; SetIpAddressType / SetSecurityGroups / SetSubnets take loadbalancer. They cannot share one Resource list or one leg silently matches nothing.
- Listener ARNs embed both the load-balancer id and the listener id:
listener/app/<name>/<lb-id>/<listener-id>. The pattern is listener/app/cudly-*/*/*, not listener/app/cudly-*/*. A one-segment error produces a grant that matches nothing and 403s on the next az_count change.
Proposed approach
Rather than draining this list one action at a time (the pattern that produced four consecutive one-action patches), pair each fix with the flag that reaches it: when a feature flag is turned on, grant its permissions in the same change, and verify each new grant's ARN pattern and condition against the Service Authorization Reference before merging. The three questions worth asking of every new statement are:
- Does this action support resource-level permissions at all? If not it must be
Resource = "*".
- Does the real ARN actually match this pattern — right segment, and is the identifier a name we choose or an opaque id AWS assigns?
- Can every condition key be populated at the moment the call is made? Tag-on-create is the classic case where it cannot.
Two consequences of taking that literally:
- Section 3 is notes, not a work item.
terraform/modules/monitoring/aws is instantiated by no environment. Its grants, and the fifth policy file to hold them, should be written by whoever first instantiates it, using the scoping traps in that table as the checklist. Pre-writing a policy for an unused module means maintaining grants nothing exercises, and every ARN fact would need re-verifying against the Service Authorization Reference at that point anyway, which is the whole content of the table.
- Item 4 is independent and small: the comment at
migration-alarm.tf:25-29 is wrong today regardless of whether enable_migration_alarm is ever flipped. Correcting it (or granting the three logs:*MetricFilter actions) does not need to wait for the flag.
Remedy simplified (2026-08-03): the proposed approach was left as-is, since refusing to drain the list action-by-action is already the restrained option; added only that section 3's fifth-policy prescription should not be built ahead of the module being instantiated, and that item 4's incorrect comment can be fixed on its own.
References
Severity
Medium — nothing is broken today, but every item is a deploy-blocking failure the moment its flag is flipped, and several would be introduced by a naive fix.
Findings from the 2026-09-02 codebase audit
Added by an automated audit of 3c0f8ac94048a2c36fce5ccddee54e6c4849a5cd (tip of origin/main). Each item below was reported by one reviewer and independently confirmed by a second that did not write it. Full report: docs/audits/codebase-audit-2026-09-02.md.
A13-009 (medium)
Section 3 treats the uninstantiated monitoring module as IAM notes, which is right for this issue, but the module itself deserves a delete-or-wire decision somewhere. It is about 2,500 lines across all three providers (aws/main.tf 465, azure/main.tf 507, gcp/main.tf 659, plus a 788-line README), no .tf file anywhere declares module "monitoring", and none of it is reached by any environment's terraform validate. One concrete side effect beyond the dead grants: aws_sns_topic is declared exactly once in the repo, at terraform/modules/monitoring/aws/main.tf:11, so the AVD-AWS-0136 entry at .trivyignore:49-52 exists solely for this tree and will outlive it. (audit finding A13-009)
Summary
The audit in LeanerCloud/cloud-commitments-cli#1698 / LeanerCloud/cloud-commitments-cli#1699 enumerated every
resource/datablock underterraform/environments/aws/and its modules against the fourcudly-terraform-deploypolicies. LeanerCloud/cloud-commitments-cli#1699 fixed everything that blocks a deploy today. This issue tracks the remainder: grants that are dead as written but currently unexercised, and permissions that are entirely absent for code paths gated behind opt-in flags.None of these break a deploy today. All of them will break the moment the corresponding flag is flipped, which is exactly the failure mode that made LeanerCloud/cloud-commitments-cli#1496, LeanerCloud/cloud-commitments-cli#1514, LeanerCloud/cloud-commitments-cli#1671 and LeanerCloud/cloud-commitments-cli#1698 each look like a one-off.
1. RDS proxy ARN scopes are unmatchable
In
policy_data.tf, SidRDSResourceScoped:arn:aws:rds:*:*:proxy:cudly-*is wrong on two counts: per the AWS Service Authorization Reference the proxy ARN segment isdb-proxy:, notproxy:, and the identifier is a server-assignedprx-<opaque>that never contains the resource name.arn:aws:rds:*:*:target-group:cudly-*has the right segment but the same opaque-id problem (prx-tg-<opaque>).Consequently
rds:DeleteDBProxy,rds:ModifyDBProxy,rds:RegisterDBProxyTargetsandrds:DeregisterDBProxyTargetscan never be authorized, andrds:ModifyDBProxyTargetGroup(needed byaws_db_proxy_default_target_group) is missing entirely.Gated off by
enable_rds_proxy = false, hardcoded interraform/environments/aws/database.tf. LeanerCloud/cloud-commitments-cli#1699 marks the dead scopes in place with aKNOWN DEAD SCOPEScomment rather than widening them todb-proxy:*, because constraining an opaque id properly needs a tag condition whose satisfiability has to be verified against the real create sequence, not assumed. Assuming it is precisely what LeanerCloud/cloud-commitments-cli#1671 got wrong.Fix: determine whether
aws:ResourceTag/Projectis populated fordb-proxyandtarget-groupat each call site (in particular whether the default target group created byCreateDBProxyinherits the proxy's tags), then either tag-gate adb-proxy:*/target-group:*statement or acceptResource = "*"with a documented justification. Addrds:ModifyDBProxyTargetGroupat the same time.2. CloudFront cannot be enabled
enable_cdn = falsein all three tfvars, somodule.frontendiscount = 0. Turning it on hits three separate walls:cloudfront:TagResourceis unsatisfiable at create. The provider callsCreateDistributionWithTags, which requiresCreateDistributionplusTagResource. The only grant isCloudFrontMutateTaggedOnly, gated onaws:ResourceTag/Project— and the distribution has no tags at authorization time. This is the identical shape to thekms:TagResource-at-CreateKeybug from fix(iac/aws): grant kms:TagResource for KMS CreateKey tag-on-create cloud-commitments-cli#1671. Fix by mirroringKMSTagOnCreate(policy_compute_b.tf), including itsNullguard onaws:ResourceTag/Projectso the grant cannot be used to claim a pre-existing distribution.aws_cloudfront_origin_access_controlhas no permissions at all.cloudfront:CreateOriginAccessControlhas no resource type per the SAR and must beResource = "*"; Get/Update/Delete take anorigin-access-controlARN whose id is an opaqueE…, socudly-*scoping is impossible.CloudFrontMutateTaggedOnlywas fixed in fix(iac/aws): unblock deploy on rds:DescribeDBInstances and close audited grant gaps cloud-commitments-cli#1699 (it was the same lowercase-cudlybug as fix(iac/aws): grant deploy role KMS key-lifecycle for OIDC signing-key replace cloud-commitments-cli#1496) but is noted here because it was only discoverable via this path.3. Monitoring module has no permissions and several would be dead on arrival
terraform/modules/monitoring/awsis not instantiated by any environment. If it is ever wired in, none of its permissions exist, and naive ARN scoping would produce fresh dead grants:aws_sns_topic/aws_sns_topic_subscriptionsns:CreateTopic,Get/SetTopicAttributes,DeleteTopic,Subscribe,Unsubscribe,Get/SetSubscriptionAttributes,ListSubscriptionsByTopic,TagResource,UntagResource,ListTagsForResourcearn:aws:sns:<region>:<account>:<name>), sotopic/cudly-*would be dead. Subscription ops authorize against the topic ARN.aws_cloudwatch_dashboardcloudwatch:PutDashboard,GetDashboard,DeleteDashboardsarn:aws:cloudwatch::<account>:dashboard/<name>— empty region segment,/separator. Folding these into the existingalarm:cudly-*statement would be dead.aws_cloudwatch_query_definitionlogs:PutQueryDefinition,DescribeQueryDefinitions,DeleteQueryDefinitionResource = "*".aws_xray_sampling_rulexray:CreateSamplingRule,UpdateSamplingRule,DeleteSamplingRule,GetSamplingRules,TagResource,UntagResource,ListTagsForResourcexray:GetSamplingRules(the Read path) has no resource type; scoping it makesterraform planfail permanently.aws_guardduty_detectorguardduty:CreateDetector,ListDetectors,GetDetector,UpdateDetector,DeleteDetector,TagResource,UntagResource,ListTagsForResourcedetector/cudly-*would be dead; use a tag gate.aws_securityhub_account/aws_securityhub_standards_subscriptionsecurityhub:EnableSecurityHub,DisableSecurityHub,DescribeHub,UpdateSecurityHubConfiguration,GetEnabledStandards,BatchEnableStandards,BatchDisableStandardshub/default. Acudly-*scope would be dead.These will not fit in
policy_compute.tf(5933 of 6144 characters used). They belong in a new fifth policy, attached alongside the existing four inci-cd-permissions/role.tf.4. Incorrect comment in the tree
terraform/modules/compute/aws/lambda/migration-alarm.tf:25-29states that the bootstrap grantslogs:PutMetricFilter. It does not. Settingenable_migration_alarm = truetoday would 403. Either grantlogs:PutMetricFilter/DescribeMetricFilters/DeleteMetricFilter(scoped to the same three log-group ARNs as the existingCloudWatchLogsstatement) or correct the comment.5. Remaining opt-in / update-path gaps
lambda:PutFunctionConcurrency,lambda:DeleteFunctionConcurrencylambda_reserved_concurrency >= 0(currently-1)acm:RemoveTagsFromCertificatefrontend_domain_names+subdomain_zone_nameroute53:CreateHostedZone,DeleteHostedZone,ChangeTagsForResourcecreate_subdomain_zone = truelogs:DeleteRetentionPolicy0iam:PassRoleformonitoring.rds.amazonaws.commonitoring_interval/monitoring_role_arn)iam:CreateOpenIDConnectProviderand friendsci-cd-permissionsbootstrap root, which it should not be6. Scope the EC2 tag grants, then the launch-template boundary (coupled)
From CodeRabbit on LeanerCloud/cloud-commitments-cli#1699 (
policy_networking.tf), and the sequencing matters:ec2:CreateTagsis granted onResource = "*"with no condition. Introduce anec2:ResourceTag/Projectgate anywhere in EC2 and that unconditioned grant immediately defeats it: the role self-tags the target, then passes its own gate. This is the holeKMSTagOnCreateguards with itsNullcondition onaws:ResourceTag/Project. So a launch-template tag boundary is worthless unlessCreateTagsis bounded first — they are one change, not two.ec2:DeleteTags(added in LeanerCloud/cloud-commitments-cli#1699, required because the provider removes dropped keys before adding new ones) should be bounded in the same pass. It is the one grant in that PR that added a genuinely new capability: removing tags from any EC2 resource.Do it in this order:
ec2:CreateTagsandec2:DeleteTagsby resource type andaws:TagKeys. Handle with care:CreateTagsis on the critical path of every EC2 create in this config (VPC, subnets, route tables, both gateways, all seven security groups, launch template) viaec2:CreateActiontag-on-create semantics. A marginally wrong boundary failsterraform applyat the first resource.ec2:CreateLaunchTemplateVersion/DeleteLaunchTemplateVersions/ModifyLaunchTemplate.Two constraints on step 2, both verified:
arn:aws:ec2:<region>:<acct>:launch-template/lt-<opaque>; the id is AWS-assigned and contains nothing of${var.stack_name}-fck-nat-. It has to belaunch-template/*plus a tag condition, so the whole boundary rests on the tag.StringEqualsIgnoreCase, neverStringEquals.aws_launch_template.fck_natsets no top-leveltags, onlytag_specifications(which tag launched instances and volumes, not the template), so it currently inheritsProject = "CUDly"from providerdefault_tagsand a case-sensitive match would work today. But every sibling resource that sets resource-leveltagsgetslocal.common_tags, whoseProject = var.project_nameis lowercase"cudly"and overridesdefault_tags. Addtags = var.tagsto the launch template, as every neighbouring resource already has, and a case-sensitive gate silently stops matching. That is the fix(iac/aws): grant deploy role KMS key-lifecycle for OIDC signing-key replace cloud-commitments-cli#1496 / fix(iac/aws): case-insensitive tag match on kms:GetKeyPolicy deploy grant cloud-commitments-cli#1514 bug, found a third time onCloudFrontMutateTaggedOnlyduring fix(iac/aws): unblock deploy on rds:DescribeDBInstances and close audited grant gaps cloud-commitments-cli#1699.7. Scope the ELB grants as one statement, not four actions
From CodeRabbit on LeanerCloud/cloud-commitments-cli#1699 (
policy_compute_b.tf). CodeRabbit is factually right that these support resource-level permissions, and ELB ARNs are not opaque:arn:aws:elasticloadbalancing:<region>:<acct>:loadbalancer/app/<name>/<id>where the name is ours (aws_lb.main.name = "${var.stack_name}-fargate"), soloadbalancer/app/cudly-*/*genuinely matches.The reason LeanerCloud/cloud-commitments-cli#1699 did not do it:
ELBFargate(policy_compute.tf:250) grants 19elasticloadbalancing:*actions onResource = "*", includingDeleteLoadBalancer,DeleteTargetGroupandDeleteListener.ELBFargateSetAttributesis a size-driven split of that same statement. Scoping 4 of 23 siblings while the other 19 — every destructive one — stay on"*"reads as hardened without being hardened.Scope all 23 together. Two details worth not re-deriving:
ModifyListenerAttributestakes thelistenerresource type;SetIpAddressType/SetSecurityGroups/SetSubnetstakeloadbalancer. They cannot share oneResourcelist or one leg silently matches nothing.listener/app/<name>/<lb-id>/<listener-id>. The pattern islistener/app/cudly-*/*/*, notlistener/app/cudly-*/*. A one-segment error produces a grant that matches nothing and 403s on the nextaz_countchange.Proposed approach
Rather than draining this list one action at a time (the pattern that produced four consecutive one-action patches), pair each fix with the flag that reaches it: when a feature flag is turned on, grant its permissions in the same change, and verify each new grant's ARN pattern and condition against the Service Authorization Reference before merging. The three questions worth asking of every new statement are:
Resource = "*".Two consequences of taking that literally:
terraform/modules/monitoring/awsis instantiated by no environment. Its grants, and the fifth policy file to hold them, should be written by whoever first instantiates it, using the scoping traps in that table as the checklist. Pre-writing a policy for an unused module means maintaining grants nothing exercises, and every ARN fact would need re-verifying against the Service Authorization Reference at that point anyway, which is the whole content of the table.migration-alarm.tf:25-29is wrong today regardless of whetherenable_migration_alarmis ever flipped. Correcting it (or granting the threelogs:*MetricFilteractions) does not need to wait for the flag.Remedy simplified (2026-08-03): the proposed approach was left as-is, since refusing to drain the list action-by-action is already the restrained option; added only that section 3's fifth-policy prescription should not be built ahead of the module being instantiated, and that item 4's incorrect comment can be fixed on its own.
References
CreateRole+AttachRolePolicyescalation: sec(iac/aws): deploy role can create an unbounded role and attach AdministratorAccess to it cloud-commitments-cli#1705rds:DescribeDBSnapshotson the prod destroy path: fix(iac/aws): grant rds:DescribeDBSnapshots for the prod final-snapshot destroy path #160Severity
Medium — nothing is broken today, but every item is a deploy-blocking failure the moment its flag is flipped, and several would be introduced by a naive fix.
Findings from the 2026-09-02 codebase audit
Added by an automated audit of
3c0f8ac94048a2c36fce5ccddee54e6c4849a5cd(tip oforigin/main). Each item below was reported by one reviewer and independently confirmed by a second that did not write it. Full report:docs/audits/codebase-audit-2026-09-02.md.A13-009 (medium)
Section 3 treats the uninstantiated monitoring module as IAM notes, which is right for this issue, but the module itself deserves a delete-or-wire decision somewhere. It is about 2,500 lines across all three providers (aws/main.tf 465, azure/main.tf 507, gcp/main.tf 659, plus a 788-line README), no
.tffile anywhere declaresmodule "monitoring", and none of it is reached by any environment'sterraform validate. One concrete side effect beyond the dead grants:aws_sns_topicis declared exactly once in the repo, atterraform/modules/monitoring/aws/main.tf:11, so the AVD-AWS-0136 entry at.trivyignore:49-52exists solely for this tree and will outlive it. (audit finding A13-009)