Skip to content

chore(iac/aws): drain remaining deploy-role grant gaps found by the #1698 audit (dead scopes + opt-in paths) #157

Description

@cristim

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:

  1. 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.
  2. 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:

  1. Does this action support resource-level permissions at all? If not it must be Resource = "*".
  2. Does the real ARN actually match this pattern — right segment, and is the identifier a name we choose or an opaque id AWS assigns?
  3. 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)

No activity

Activity on this issue will appear here.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions