Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion terraform/environments/aws/ci-cd-permissions/policy_compute.tf
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,19 @@ resource "aws_iam_policy" "compute" {
# Function mutations live in policy_compute_b.tf — the function
# resource type does not support aws:ResourceTag per the AWS
# Service Authorization Reference, so it needs ARN scoping.
#
# StringEqualsIgnoreCase for the same reason as KMSMutateTaggedOnly
# below (see PR #1496): the distribution's Project tag comes from
# local.common_tags (terraform/environments/aws/main.tf), which sets
# Project = var.project_name, and project_name is the lowercase
# "cudly". That resource-level tag overrides the provider's
# default_tags value of "CUDly" for this distribution, so a
# case-sensitive match against "CUDly" never matched and every action
# in this statement was silently denied. This is the same defect
# #1496 fixed on the KMS statements; it was left unfixed here because
# enable_cdn is false in every tfvars, so nothing exercised it. Note
# it would also have killed the destroy path: deleting a distribution
# requires UpdateDistribution to disable it first.
Sid = "CloudFrontMutateTaggedOnly"
Effect = "Allow"
Action = [
Expand All @@ -162,7 +175,7 @@ resource "aws_iam_policy" "compute" {
]
Resource = "*"
Condition = {
StringEquals = {
StringEqualsIgnoreCase = {
"aws:ResourceTag/Project" = "CUDly"
}
}
Expand Down
32 changes: 32 additions & 0 deletions terraform/environments/aws/ci-cd-permissions/policy_compute_b.tf
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,38 @@ resource "aws_iam_policy" "compute_b" {
}
}
},
{
# ecr:PutImageScanningConfiguration is the writer for
# aws_ecr_repository's image_scanning_configuration block. Its sibling
# ecr:PutImageTagMutability is granted in policy_compute.tf's
# ECRRepositoryScoped but this one was not, so any change to
# scan_on_push (or importing a repository whose setting differs from
# the config) fails the apply. Same repository ARN scope as the
# statement it belongs with; it lives here only because
# policy_compute.tf is at the 6144-char ceiling.
Sid = "ECRImageScanningConfig"
Effect = "Allow"
Action = ["ecr:PutImageScanningConfiguration"]
Resource = "arn:aws:ecr:*:*:repository/cudly-*"
},
{
# The ELB update path that policy_compute.tf's ELBFargate misses.
# ELBFargate grants Create/Delete plus the Modify* family, but changing
# a load balancer's subnets or security groups (which is what bumping
# az_count does) goes through the Set* family instead, and
# ModifyListenerAttributes is the writer whose reader
# (DescribeListenerAttributes) is already granted. Resource = "*"
# matches ELBFargate, whose ARNs are not name-scopeable.
Sid = "ELBFargateSetAttributes"
Effect = "Allow"
Action = [
"elasticloadbalancing:ModifyListenerAttributes",
"elasticloadbalancing:SetIpAddressType",
"elasticloadbalancing:SetSecurityGroups",
"elasticloadbalancing:SetSubnets",
]
Resource = "*"
},
Comment on lines +114 to +131

@coderabbitai coderabbitai Bot Aug 3, 2026 •

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

mapfile -t files < <(
  fd --type f 'policy_compute(_b)?\.tf$' \
    terraform/environments/aws/ci-cd-permissions
)

test "${`#files`[@]}" -gt 0

rg -n -C 12 \
  'ELBFargate|ModifyListenerAttributes|SetIpAddressType|SetSecurityGroups|SetSubnets|Resource\s*=' \
  "${files[@]}"

Repository: LeanerCloud/CUDly

Length of output: 207


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

files=()
while IFS= read -r f; do files+=("$f"); done < <(
  git ls-files 'terraform/environments/aws/ci-cd-permissions/policy_compute.tf' \
    'terraform/environments/aws/ci-cd-permissions/policy_compute_b.tf'
)
printf '%s\n' "${files[@]}"
test "${`#files`[@]}" -gt 0

for f in "${files[@]}"; do
  echo "===== $f ====="
  wc -l "$f"
  rg -n -C 18 \
    'ELBFargate|ModifyListenerAttributes|SetIpAddressType|SetSecurityGroups|SetSubnets|Resource\s*=|load_balancer|listener' \
    "$f" || true
done

Repository: LeanerCloud/CUDly

Length of output: 207


🌐 Web query:

AWS ELBv2 service authorization reference ModifyListenerAttributes SetIpAddressType SetSecurityGroups SetSubnets resource-level permissions resource types

💡 Result:

In AWS Elastic Load Balancing V2 (ELBv2), the actions ModifyListenerAttributes, SetIpAddressType, SetSecurityGroups, and SetSubnets all support resource-level permissions [1]. This means you can restrict these actions to specific load balancer resources in your IAM policies by specifying the appropriate Amazon Resource Name (ARN) in the Resource element [1][2]. According to the official AWS Service Authorization Reference for ELBv2, these actions require the following resource types [1]: 1. ModifyListenerAttributes: Requires the listener resource type (arn:aws:elasticloadbalancing:region:account-id:listener/load-balancer-id/target-group-id/listener-id) or load balancer resource type depending on the specific configuration. 2. SetIpAddressType, SetSecurityGroups, and SetSubnets: These actions require the load balancer resource type (e.g., arn:aws:elasticloadbalancing:region:account-id:loadbalancer/app/load-balancer-name/load-balancer-id or arn:aws:elasticloadbalancing:region:account-id:loadbalancer/gwy/load-balancer-name/load-balancer-id) [1]. In AWS IAM policies, if an action supports resource-level permissions, you should avoid using a wildcard ("*") and instead specify the ARN of the specific load balancer or listener you wish to permit access to [3][2]. If you specify a resource that is not valid for the action, the request will fail [3]. These actions are classified with an "Write" access level [1][4].

Citations:


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

for f in \
  terraform/environments/aws/ci-cd-permissions/policy_compute.tf \
  terraform/environments/aws/ci-cd-permissions/policy_compute_b.tf
do
  if [ -f "$f" ]; then
    echo "===== $f ====="
    wc -l "$f"
    rg -n -C 18 \
      'ELBFargate|ModifyListenerAttributes|SetIpAddressType|SetSecurityGroups|SetSubnets|Resource\s*=|load_balancer|listener' \
      "$f" || true
  else
    echo "MISSING $f"
  fi
done

Repository: LeanerCloud/CUDly

Length of output: 24354


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

for f in \
  terraform/environments/aws/ci-cd-permissions/policy_compute.tf \
  terraform/environments/aws/ci-cd-permissions/policy_compute_b.tf
do
  [ -f "$f" ] || continue
  echo "===== resources and ARN expressions in $f ====="
  rg -n -C 8 \
    'arn:|loadbalancer|listener|targetgroup|cudly|ELB|elasticloadbalancing' \
    "$f" || true
done

Repository: LeanerCloud/CUDly

Length of output: 14790


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

echo "===== ELB resources and names ====="
rg -n -C 8 \
  'aws_lb|aws_lb_listener|aws_lb_target_group|load_balancer_type|name_prefix|name\s*=.*cudly|cudly-' \
  terraform modules -g '*.tf' || true

echo "===== ELB ARN references ====="
rg -n -C 5 \
  'elasticloadbalancing|load_balancer_arn|listener_arn|target_group_arn|arn:aws:elasticloadbalancing' \
  . -g '*.tf' || true

Repository: LeanerCloud/CUDly

Length of output: 50374


🌐 Web query:

site:docs.aws.amazon.com/service-authorization/latest/reference/list_elasticloadbalancingv2.html ModifyListenerAttributes SetIpAddressType SetSecurityGroups SetSubnets resource types

💡 Result:

For AWS ELBv2 IAM actions, the required resource types are:

Action Resource type
ModifyListenerAttributes listener/app*, listener/gwy*, listener/net*
SetIpAddressType loadbalancer/app/, loadbalancer/gwy/, loadbalancer/net/
SetSecurityGroups loadbalancer/app/, loadbalancer/net/
SetSubnets loadbalancer/app/, loadbalancer/gwy/, loadbalancer/net/

SetSecurityGroups additionally supports the elasticloadbalancing:SecurityGroup condition key; SetSubnets supports elasticloadbalancing:Subnet. [1]


🏁 Script executed:

#!/usr/bin/env bash
set -euo pipefail

rg -l --glob '*.tf' \
  'resource[[:space:]]+"aws_lb"|resource[[:space:]]+"aws_lb_listener"|resource[[:space:]]+"aws_lb_target_group"' \
  terraform/modules terraform/environments |
while IFS= read -r f; do
  echo "===== $f ====="
  rg -n -C 12 \
    'resource[[:space:]]+"aws_lb"|resource[[:space:]]+"aws_lb_listener"|resource[[:space:]]+"aws_lb_target_group"|name_prefix|name[[:space:]]*=' \
    "$f"
done

Repository: LeanerCloud/CUDly

Length of output: 30667


Scope the ELB write permissions to deployment resources.

Use listener ARNs for ModifyListenerAttributes and load-balancer ARNs for the three Set* actions. Restrict them to the cudly-* deployment resources instead of Resource = "*".

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@terraform/environments/aws/ci-cd-permissions/policy_compute_b.tf` around
lines 114 - 131, Update the ELBFargateSetAttributes statement to scope
permissions by action: use listener ARNs restricted to cudly-* resources for
ModifyListenerAttributes, and load-balancer ARNs restricted to cudly-* resources
for SetIpAddressType, SetSecurityGroups, and SetSubnets. Replace Resource = "*"
with the appropriate action-specific resource statements while preserving the
existing Effect and actions.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pre-existing hardening advice, not a defect this PR introduces. Deferring with reasons, tracked in #1703.

First, a correction in CodeRabbit's favour, because the opposite claim was made during review of this PR and it is wrong: ELB ARNs are not opaque and these actions can be prefix-scoped. The ARN is arn:aws:elasticloadbalancing:<region>:<acct>:loadbalancer/app/<name>/<id> and the name is ours: aws_lb.main.name = local.name_prefix = "${var.stack_name}-fargate" (terraform/modules/compute/aws/fargate/main.tf:5,414), i.e. cudly-<env>-<hex>-fargate. So loadbalancer/app/cudly-*/* and listener/app/cudly-*/* would genuinely match. CodeRabbit's factual premise is correct and I am not disputing it.

The reason to defer is different, and it is that scoping these four actions changes the blast radius by approximately nothing.

The statement these belong to is ELBFargate in policy_compute.tf:250. It grants 19 elasticloadbalancing:* actions on Resource = "*", including DeleteLoadBalancer, DeleteTargetGroup, DeleteListener, CreateLoadBalancer, ModifyLoadBalancerAttributes and ModifyTargetGroup. That statement predates this PR and is untouched by it.

ELBFargateSetAttributes exists only because policy_compute.tf is at 5933 of 6144 characters and could not hold four more actions; it is a size-driven split of ELBFargate, which is exactly what policy_compute_b.tf's file header describes. So the proposal is to scope 4 of 23 sibling actions while the other 19 — including every destructive one — keep Resource = "*" in the adjacent file. An actor who can already DeleteLoadBalancer on any load balancer in the account does not need SetSubnets on it. The scoped version would read as hardened without being hardened, which is a failure mode this PR is specifically about.

The coherent change is to scope all 23 ELB actions together, in one statement, verified against live IAM. That is a hardening change to pre-existing grants and does not belong in a p0 outage fix where deploys have been down all session and the Fargate path cannot be exercised from here.

Two further notes, so the follow-up gets done correctly rather than quickly:

  • ModifyListenerAttributes and the three Set* actions take different resource types (listener vs loadbalancer), so they cannot share one Resource list without one of the two legs silently matching nothing. CodeRabbit is right to call for splitting them; that is a detail worth not losing.
  • Listener ARNs embed both the load-balancer id and the listener id (listener/app/<name>/<lb-id>/<listener-id>), both AWS-assigned, so the pattern has to be listener/app/cudly-*/*/* and not listener/app/cudly-*/*. A one-segment error there produces a grant that matches nothing and 403s on the next az_count change — the precise failure this PR exists to fix (rds:DescribeDBInstances was granted on arn:aws:rds:*:*:db:cudly-*, the name matched, and production still returned AccessDenied).

Tracked in #1703 with the ARN formats above so the next person does not re-derive them. Not resolving this thread.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@cristim, acknowledged.

The finding is valid, but scoping only these four actions would not materially reduce the deploy role’s effective ELB permissions while ELBFargate retains wildcard access for the related destructive actions.

Deferring the complete ELB hardening to #1703 is appropriate for this outage-focused PR. The follow-up should scope all related ELB actions together and use separate statements for listener and load-balancer resource types. The listener pattern must use listener/app/cudly-*/*/*.

🐇


✏️ Learnings added
Learnt from: cristim
Repo: LeanerCloud/CUDly PR: 1699
File: terraform/environments/aws/ci-cd-permissions/policy_compute_b.tf:114-131
Timestamp: 2026-08-03T14:34:24.808Z
Learning: In `terraform/environments/aws/ci-cd-permissions`, `ELBFargateSetAttributes` in `policy_compute_b.tf` is a managed-policy-size split from the pre-existing wildcard-scoped `ELBFargate` statement in `policy_compute.tf`. Do not treat scoping only the four split ELB actions as meaningful hardening while the related `ELBFargate` actions remain `Resource = "*"`. Track the coherent all-action ELB scoping work in GitHub issue `#1703`. `ModifyListenerAttributes` requires listener ARNs and must use the pattern `listener/app/cudly-*/*/*`; `SetIpAddressType`, `SetSecurityGroups`, and `SetSubnets` require load-balancer ARNs.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

You are interacting with an AI system.

{
Sid = "KMSAliasMutate"
Effect = "Allow"
Expand Down
204 changes: 188 additions & 16 deletions terraform/environments/aws/ci-cd-permissions/policy_data.tf
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,17 @@ resource "aws_iam_policy" "data" {
"iam:UntagInstanceProfile",
"iam:UntagPolicy",
"iam:UntagRole",
# iam:UpdateAssumeRolePolicy / UpdateRole / UpdateRoleDescription are
# what the provider calls when a role's assume_role_policy,
# max_session_duration or description drifts (resourceRoleUpdate in
# internal/service/iam/role.go). Without them any edit to a trust
# policy or role description in terraform/modules/** fails the apply
# rather than the plan, which is why this gap stayed invisible.
# IAMDenyModifyDeployRoleAndPolicies below stops these from being
# turned on the deploy role itself.
"iam:UpdateAssumeRolePolicy",
"iam:UpdateRole",
"iam:UpdateRoleDescription",
]
Resource = [
"arn:aws:iam::*:role/cudly-*",
Expand All @@ -65,6 +76,20 @@ resource "aws_iam_policy" "data" {
"lambda.amazonaws.com",
"ecs-tasks.amazonaws.com",
"rds.amazonaws.com",
# ec2: the fck-nat instance profile referenced by the NAT launch
# template and Auto Scaling group
# (terraform/modules/networking/aws, enable_nat_gateway is
# hardcoded true in terraform/environments/aws/networking.tf, so
# this is on the deploy path in every environment).
"ec2.amazonaws.com",
# vpc-flow-logs: aws_flow_log passes its delivery role via
# DeliverLogsPermissionArn (enable_flow_logs is true for staging
# and prod).
"vpc-flow-logs.amazonaws.com",
# events: EventBridge targets that carry a role_arn, used by the
# scheduled ECS tasks on the Fargate deploy path
# (enable_scheduled_tasks is true in every tfvars).
"events.amazonaws.com",
Comment thread
coderabbitai[bot] marked this conversation as resolved.
]
}
}
Expand All @@ -85,6 +110,54 @@ resource "aws_iam_policy" "data" {
Action = ["iam:PassRole"]
Resource = ["arn:aws:iam::*:role/cudly-terraform-deploy"]
},
{
# IAMDenyPassDeployRole above closes the pass-the-deploy-role door.
# This closes the other two doors into the same escalation, both of
# which IAMRolesAndPolicies leaves open because cudly-terraform-deploy
# is itself a cudly-* role and cudly-deploy-* are cudly-* policies:
#
# 1. Role side: iam:AttachRolePolicy / iam:PutRolePolicy let the
# deploy role grant itself AdministratorAccess, and
# iam:UpdateAssumeRolePolicy (added to IAMRolesAndPolicies above)
# lets it rewrite its own trust policy so an arbitrary principal
# can assume it.
# 2. Policy side: iam:CreatePolicyVersion on cudly-deploy-data (or
# any sibling) rewrites the deploy role's own permissions in
# place, reaching the same admin without ever touching the role.
# Denying only the role side would look complete and would not be.
#
# Either turns a leaked GitHub Actions token into persistent
# account-wide admin. An explicit Deny always beats the Allow, so this
# closes both loops while leaving the workload roles and policies
# (cudly-<env>-<suffix>-*) fully manageable.
#
# This costs the deploy path nothing: cudly-terraform-deploy and the
# four cudly-deploy-* policies are all declared in this bootstrap
# root, which is applied manually by a privileged human and never by a
# deploy workflow. Action and Resource are matched as a cross product,
# so the combinations that do not apply (a role action against a
# policy ARN and vice versa) are simply inert.
Sid = "IAMDenyModifyDeployRoleAndPolicies"
Effect = "Deny"
Action = [
"iam:AttachRolePolicy",
"iam:CreatePolicyVersion",
"iam:DeletePolicy",
"iam:DeletePolicyVersion",
"iam:DeleteRole",
"iam:DeleteRolePolicy",
"iam:DetachRolePolicy",
"iam:PutRolePolicy",
"iam:SetDefaultPolicyVersion",
"iam:UpdateAssumeRolePolicy",
"iam:UpdateRole",
"iam:UpdateRoleDescription",
]
Resource = [
"arn:aws:iam::*:role/cudly-terraform-deploy",
"arn:aws:iam::*:policy/cudly-deploy-*",
]
},
{
Sid = "IAMReadForPassRole"
Effect = "Allow"
Expand All @@ -95,28 +168,61 @@ resource "aws_iam_policy" "data" {
Resource = "*"
},
{
# Service-linked roles are created lazily by AWS on the FIRST use of a
# service in an account/region, and the caller needs
# iam:CreateServiceLinkedRole for it. That makes these gaps invisible
# in an account that already has the role and a hard failure in a
# fresh region, which is exactly the kind of latent break this audit
# was meant to surface. autoscaling (fck-nat ASG, on every deploy
# path), elasticloadbalancing and ecs (Fargate ALB and cluster) were
# all missing; note that ecs.application-autoscaling is a different
# principal from plain ecs and does not cover it.
Sid = "IAMServiceLinkedRole"
Effect = "Allow"
Action = ["iam:CreateServiceLinkedRole"]
Resource = [
"arn:aws:iam::*:role/aws-service-role/rds.amazonaws.com/AWSServiceRoleForRDS",
"arn:aws:iam::*:role/aws-service-role/ecs.application-autoscaling.amazonaws.com/AWSServiceRoleForApplicationAutoScaling_ECSService",
"arn:aws:iam::*:role/aws-service-role/autoscaling.amazonaws.com/AWSServiceRoleForAutoScaling",
"arn:aws:iam::*:role/aws-service-role/elasticloadbalancing.amazonaws.com/AWSServiceRoleForElasticLoadBalancing",
"arn:aws:iam::*:role/aws-service-role/ecs.amazonaws.com/AWSServiceRoleForECS",
]
Condition = {
StringLike = {
"iam:AWSServiceName" = [
"rds.amazonaws.com",
"ecs.application-autoscaling.amazonaws.com",
"autoscaling.amazonaws.com",
"elasticloadbalancing.amazonaws.com",
"ecs.amazonaws.com",
]
}
}
},
{
# RDS actions that take a specific resource ARN are scoped to
# cudly-* DB instances, subnet groups, and proxies. This prevents
# the deploy SA from deleting or modifying unrelated RDS instances
# in the same account. DescribeDBEngineVersions does not accept a
# resource ARN (account-wide catalogue lookup) and is split below.
# RDS actions whose request carries a name we control are scoped to
# cudly-* DB instances, subnet groups and snapshots. This prevents the
# deploy SA from deleting or modifying unrelated RDS resources in the
# same account.
#
# NO rds:Describe* action lives here; they are all in
# RDSDescribeAccountWide below. See that statement for why.
#
# KNOWN DEAD SCOPES: the proxy actions below (DeleteDBProxy,
# ModifyDBProxy, RegisterDBProxyTargets, DeregisterDBProxyTargets)
# can never be authorized, because their only resource types are
# `proxy` and `target-group`, whose real ARNs are
# arn:aws:rds:<r>:<a>:db-proxy:prx-<opaque> and
# arn:aws:rds:<r>:<a>:target-group:prx-tg-<opaque>: a different ARN
# segment (`db-proxy`, not `proxy`) and a server-assigned opaque id
# that never contains the resource name. `proxy:cudly-*` and
# `target-group:cudly-*` therefore match nothing. Nothing exercises
# this today (modules/database/aws is instantiated with
# enable_rds_proxy = false, terraform/environments/aws/database.tf),
# so it is left as-is rather than widened to db-proxy:* here; fixing
# it needs a scoping mechanism that actually constrains an opaque id
# (tag condition), tracked separately. Do NOT add new proxy actions
# to this statement; they would be dead on arrival.
Sid = "RDSResourceScoped"
Effect = "Allow"
Action = [
Expand All @@ -126,16 +232,12 @@ resource "aws_iam_policy" "data" {
"rds:CreateDBSubnetGroup",
"rds:DeleteDBInstance",
"rds:DeleteDBSubnetGroup",
"rds:DescribeDBInstances",
"rds:DescribeDBSubnetGroups",
"rds:ListTagsForResource",
"rds:DeleteDBProxy",
"rds:DeregisterDBProxyTargets",
"rds:DescribeDBProxies",
"rds:DescribeDBProxyTargetGroups",
"rds:DescribeDBProxyTargets",
"rds:ModifyDBInstance",
"rds:ModifyDBProxy",
"rds:ModifyDBSubnetGroup",
"rds:RegisterDBProxyTargets",
"rds:RemoveTagsFromResource",
]
Expand All @@ -148,11 +250,66 @@ resource "aws_iam_policy" "data" {
]
},
{
# DescribeDBEngineVersions is a catalogue lookup that doesn't
# accept a resource ARN at the API level. Read-only, no state change.
Sid = "RDSDescribeEngineVersions"
Effect = "Allow"
Action = ["rds:DescribeDBEngineVersions"]
# RDS reads that an ARN-scoped grant can never authorize.
#
# rds:DescribeDBEngineVersions is a catalogue lookup with no resource
# type at all, so it can only be granted on "*".
#
# rds:DescribeDBInstances is here because of the request shape the AWS
# provider uses, not because the action lacks a resource type. The
# Terraform id of aws_db_instance IS the DbiResourceId (`db-<opaque>`,
# set at internal/service/rds/instance.go in provider v5.100.0), and
# findDBInstanceByID branches on that: when the id looks like a
# DbiResourceId it sends DescribeDBInstances with
# Filters=[dbi-resource-id] and leaves DBInstanceIdentifier nil. RDS
# authorizes an identifier-less DescribeDBInstances against the
# wildcard ARN arn:aws:rds:<region>:<account>:db:*, which
# `db:cudly-*` cannot match, so every refresh of the instance 403'd:
# AccessDenied: ... not authorized to perform: rds:DescribeDBInstances
# on resource: arn:aws:rds:us-east-1:...:db:*
# This is unconditional on every read, not a not-found fallback: the
# provider's retry with the plain identifier only fires on NotFound,
# and AccessDenied is not NotFound, so the plan hard-fails.
#
# rds:DescribeDBSubnetGroups is here for the same reason as
# DescribeDBInstances, and it is worth being explicit about why,
# because the tempting simplification is wrong in both directions.
# Neither action lacks a resource type: per AWS's Service
# Authorization Reference both DO support one (`db` and `subgrp`
# respectively), and the real names DO match the patterns
# (cudly-<env>-<hex>-postgres against db:cudly-*,
# cudly-<env>-<hex>-db-subnet against subgrp:cudly-*). So this is not
# "ARN-scoped Describes are always dead" and it is not a name-prefix
# bug; the scoped grants were syntactically valid. What differs is the
# request shape: a resource-scoped grant authorizes only the form that
# targets one named resource, while the enumerate form (no identifier
# in the request) is authorized against the wildcard ARN. The
# production 403 on DescribeDBInstances is the empirical proof that
# the scoped form did not suffice, and DescribeDBSubnetGroups sits in
# exactly the same position: aws_db_subnet_group.main
# (terraform/modules/database/aws/main.tf) is created in every
# environment, so leaving it scoped just primes the next 403.
#
# The DB proxy reads are here for a third reason: their resource ARNs
# use the `db-proxy:`/`target-group:` segments with server-assigned
# opaque ids (see the RDSResourceScoped note above), so no name-based
# ARN scope can ever match them. Those are belt-and-braces rather than
# load-bearing: enable_rds_proxy is a literal `false` at
# terraform/environments/aws/database.tf:28, not a variable, so no
# tfvars can turn the proxy resources on.
#
# All six are read-only and expose only resource metadata; every
# mutating RDS action stays ARN-scoped above.
Sid = "RDSDescribeAccountWide"
Effect = "Allow"
Action = [
"rds:DescribeDBEngineVersions",
"rds:DescribeDBInstances",
"rds:DescribeDBProxies",
"rds:DescribeDBProxyTargetGroups",
"rds:DescribeDBProxyTargets",
"rds:DescribeDBSubnetGroups",
]
Resource = "*"
},
{
Expand All @@ -171,6 +328,20 @@ resource "aws_iam_policy" "data" {
Resource = "*"
},
{
# secretsmanager:ListSecrets used to be in this list. It has no
# resource type per the AWS Service Authorization Reference, so
# inside an ARN-scoped statement it could never be satisfied: a
# silently dead grant. Nothing in the provider calls it (secret
# lookups go through DescribeSecret with an explicit id), so it is
# dropped rather than moved to a "*" statement.
#
# ListSecretVersionIds and UpdateSecretVersionStage are what
# aws_secretsmanager_secret_version calls when a version carries a
# stage other than a lone AWSCURRENT, and on the delete path when it
# has to move stages off the version before removing it
# (resourceSecretVersionDelete/Update in
# internal/service/secretsmanager/secret_version.go). Both take the
# secret ARN, so the cudly-* scope below applies.
Sid = "SecretsManager"
Effect = "Allow"
Action = [
Expand All @@ -179,13 +350,14 @@ resource "aws_iam_policy" "data" {
"secretsmanager:DescribeSecret",
"secretsmanager:GetResourcePolicy",
"secretsmanager:GetSecretValue",
"secretsmanager:ListSecrets",
"secretsmanager:ListSecretVersionIds",
"secretsmanager:PutSecretValue",
"secretsmanager:RestoreSecret",
"secretsmanager:RotateSecret",
"secretsmanager:TagResource",
"secretsmanager:UntagResource",
"secretsmanager:UpdateSecret",
"secretsmanager:UpdateSecretVersionStage",
]
Resource = "arn:aws:secretsmanager:*:*:secret:cudly-*"
},
Expand Down
Loading
Loading