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
59 changes: 53 additions & 6 deletions cloudformation/stacks/CUDly/template.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,22 @@ Parameters:
Default: "rate(1 day)"
Description: How often to check for new recommendations

CrossAccountTargetAccountIds:
Type: CommaDelimitedList
Default: ""
AllowedPattern: "^$|^[0-9]{12}$"
Description: >-
Comma-separated AWS account IDs this stack itself calls sts:AssumeRole
against, for multi-account plan execution. Empty (the default) creates no
cross-account grant at all. There is no "any account" value: the previous
unconditional grant let the hub assume any CUDly* role in any AWS
account, so a mis-selected account produced a successful purchase in the
wrong account rather than an AccessDenied (#1636). For bastion-mode
accounts list the bastion's account, not the target's, because only the
first hop runs on this stack's identity. UPGRADING AN EXISTING STACK WITHOUT
SETTING THIS REMOVES THE GRANT, and multi-account collection then fails
with AccessDenied until the accounts are listed.

Conditions:
DeployDashboard:
Fn::Equals:
Expand All @@ -163,6 +179,17 @@ Conditions:
- Ref: CpuArchitecture
- arm64

# Joining the list back to a string is how a CommaDelimitedList is tested for
# emptiness: the Default of "" arrives as a one-element list holding "", which
# is not equal to an empty list and cannot be compared to one.
HasCrossAccountTargets:
Fn::Not:
- Fn::Equals:
- Fn::Join:
- ""
- Ref: CrossAccountTargetAccountIds
- ""

Resources:
# =============================================================================
# DynamoDB Tables
Expand Down Expand Up @@ -480,12 +507,32 @@ Resources:
- organizations:DescribeOrganization
Resource: "*"

# Cross-account role assumption for multi-account plans
- Sid: CrossAccountAssumeRole
Effect: Allow
Action:
- sts:AssumeRole
Resource: "arn:aws:iam::*:role/CUDly*"
# Cross-account role assumption for multi-account plans.
#
# aws:ResourceAccount pins the grant to the declared accounts. The
# Resource pattern narrows which role but never whose account:
# arn:aws:iam::*:role/CUDly* matches a CUDly role in any AWS account
# on earth, so before #1636 a mis-selected account produced a
# successful AssumeRole instead of an AccessDenied. sts:ExternalId
# StringLike "*" requires the field to be present and non-empty, at
# parity with the Terraform modules; per-account values are checked
# in internal/credentials/resolver.go.
#
# Dropped entirely rather than widened when no accounts are declared.
- Fn::If:
- HasCrossAccountTargets
- Sid: CrossAccountAssumeRole
Effect: Allow
Action:
- sts:AssumeRole
Resource: "arn:aws:iam::*:role/CUDly*"
Condition:
StringEquals:
aws:ResourceAccount:
Ref: CrossAccountTargetAccountIds
StringLike:
sts:ExternalId: "*"
- Ref: AWS::NoValue

# DynamoDB access
- Sid: DynamoDBAccess
Expand Down
25 changes: 25 additions & 0 deletions docs/DEPLOYMENT.md
Original file line number Diff line number Diff line change
Expand Up @@ -605,6 +605,31 @@ aws rds describe-db-proxies --db-proxy-name cudly-dev-proxy
aws rds describe-db-clusters --db-cluster-identifier cudly-dev-postgres
```

### Multi-Account Cross-Account Access

Declare every AWS account the deployment will call `sts:AssumeRole` against. Nothing is reachable
cross-account until you do.

| Deployment shape | Setting |
| ---------------- | ------- |
| Terraform (`terraform/environments/aws`) | `cross_account_target_account_ids = ["111111111111", ...]` in your tfvars |
| CloudFormation (`cloudformation/stacks/CUDly`) | `CrossAccountTargetAccountIds` stack parameter, comma-separated |

Both render an `aws:ResourceAccount` condition onto the grant. An account that is not listed is
denied by IAM, not merely by the app's account selection. Leaving the setting empty creates **no
cross-account grant at all**, which is the intended fail-closed default rather than an oversight.

For accounts using `bastion` auth mode, list the **bastion's** account ID rather than the target's.
Only the first hop runs on the deployment's own identity; the bastion assumes into the target on its
own identity policy, which this setting does not govern.

> **Upgrading an existing multi-account deployment**: the grant used to be scoped by role name only
> (`arn:aws:iam::*:role/CUDly*`), which matched a CUDly role in *every* AWS account rather than in
> yours (#1636). List your linked accounts **in the same change that picks up this version**. On
> Terraform the apply removes `aws_iam_role_policy.cross_account_sts` and exits 0; on CloudFormation
> the stack update drops the statement. Either way the first symptom otherwise is a runtime
> `AccessDenied` during collection, not a failed deploy.

### Multi-Account Credential Encryption

Multi-account support requires an AES-256-GCM encryption key for stored cloud account credentials. Terraform creates the key secret automatically (see `specs/multi-account-execution/iac.md`).
Expand Down
15 changes: 10 additions & 5 deletions terraform/environments/aws/compute.tf
Original file line number Diff line number Diff line change
Expand Up @@ -99,9 +99,12 @@ module "compute_lambda" {
)

# Multi-account IAM capabilities. cross_account_role_name_prefix scopes the
# Lambda role's sts:AssumeRole IAM grant to role names starting with the
# prefix — defence-in-depth on top of the app-layer ExternalId check.
enable_cross_account_sts = true
# Lambda role's sts:AssumeRole grant to role names starting with the prefix;
# cross_account_target_account_ids scopes it to the accounts the operator
# declared. The prefix alone never constrained the account (#1636), so the
# grant is derived from the account list: no accounts declared, no grant.
enable_cross_account_sts = length(var.cross_account_target_account_ids) > 0
cross_account_target_account_ids = var.cross_account_target_account_ids
cross_account_role_name_prefix = "CUDly"
enable_org_discovery = true
credential_encryption_key_secret_arn = module.secrets.credential_encryption_key_secret_arn
Expand Down Expand Up @@ -220,9 +223,11 @@ module "compute_fargate" {

# Multi-account IAM capabilities — kept at parity with the Lambda branch.
# cross_account_role_name_prefix scopes the task role's sts:AssumeRole grant
# to role names starting with the prefix; ExternalId validation still
# to role names starting with the prefix; cross_account_target_account_ids
# scopes it to the declared accounts (#1636). ExternalId validation still
# happens at the app layer (credentials/resolver.go).
enable_cross_account_sts = true
enable_cross_account_sts = length(var.cross_account_target_account_ids) > 0
cross_account_target_account_ids = var.cross_account_target_account_ids
cross_account_role_name_prefix = "CUDly"
enable_org_discovery = true
credential_encryption_key_secret_arn = module.secrets.credential_encryption_key_secret_arn
Expand Down
12 changes: 12 additions & 0 deletions terraform/environments/aws/dev.tfvars.example
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,18 @@ credential_encryption_key = "REPLACE_WITH_64_CHAR_HEX_STRING"
# Maximum number of cloud accounts to process in parallel during plan fan-out.
max_account_parallelism = 10

# AWS account IDs this deployment itself calls sts:AssumeRole against.
# Listing an account here is what authorizes it at IAM; onboarding it is still
# a separate step in the app. Leave empty and no cross-account sts:AssumeRole
# grant is created at all. The grant used to be scoped by role name only,
# which matched a CUDly role in every AWS account rather than in yours (#1636).
# Add every linked account before switching a single-account deployment to
# multi-account, or collection from the new account fails with AccessDenied.
# For bastion-mode accounts list the BASTION's account ID, not the target's:
# only the first hop runs on this deployment's identity, the second runs on
# the bastion role's own policy.
# cross_account_target_account_ids = ["111111111111", "222222222222"]

# ==============================================
# Frontend (CloudFront + S3)
# ==============================================
Expand Down
16 changes: 16 additions & 0 deletions terraform/environments/aws/github-dev.tfvars
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,22 @@ create_subdomain_zone = false
enable_scheduled_tasks = true
recommendation_schedule = "rate(1 day)"

# ==============================================
# Multi-Account Cross-Account Access
# ==============================================

# Accounts this deployment may call sts:AssumeRole against. Empty means the
# cross-account grant is not created at all.
#
# These environments have never declared a linked account in Terraform, so [] is
# the honest value. It is written out rather than left to the default because
# the first apply after #1636 DESTROYS aws_iam_role_policy.cross_account_sts:
# the grant used to be unconditional, scoped by role name only, and matched a
# CUDly* role in every AWS account rather than in ours. If any of these
# environments has had a linked account added through the app, list it here in
# the same change or its collection starts failing with AccessDenied.
cross_account_target_account_ids = []

# ==============================================
# Variables provided by GitHub Actions:
# TF_VAR_admin_email = ${{ secrets.ADMIN_EMAIL }}
Expand Down
16 changes: 16 additions & 0 deletions terraform/environments/aws/github-prod.tfvars
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,22 @@ create_subdomain_zone = false
enable_scheduled_tasks = true
recommendation_schedule = "rate(1 day)"

# ==============================================
# Multi-Account Cross-Account Access
# ==============================================

# Accounts this deployment may call sts:AssumeRole against. Empty means the
# cross-account grant is not created at all.
#
# These environments have never declared a linked account in Terraform, so [] is
# the honest value. It is written out rather than left to the default because
# the first apply after #1636 DESTROYS aws_iam_role_policy.cross_account_sts:
# the grant used to be unconditional, scoped by role name only, and matched a
# CUDly* role in every AWS account rather than in ours. If any of these
# environments has had a linked account added through the app, list it here in
# the same change or its collection starts failing with AccessDenied.
cross_account_target_account_ids = []

# ==============================================
# Variables provided by GitHub Actions:
# TF_VAR_admin_email = ${{ secrets.ADMIN_EMAIL }}
Expand Down
16 changes: 16 additions & 0 deletions terraform/environments/aws/github-staging.tfvars
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,22 @@ create_subdomain_zone = false
enable_scheduled_tasks = true
recommendation_schedule = "rate(1 day)"

# ==============================================
# Multi-Account Cross-Account Access
# ==============================================

# Accounts this deployment may call sts:AssumeRole against. Empty means the
# cross-account grant is not created at all.
#
# These environments have never declared a linked account in Terraform, so [] is
# the honest value. It is written out rather than left to the default because
# the first apply after #1636 DESTROYS aws_iam_role_policy.cross_account_sts:
# the grant used to be unconditional, scoped by role name only, and matched a
# CUDly* role in every AWS account rather than in ours. If any of these
# environments has had a linked account added through the app, list it here in
# the same change or its collection starts failing with AccessDenied.
cross_account_target_account_ids = []

# ==============================================
# Variables provided by GitHub Actions:
# TF_VAR_admin_email = ${{ secrets.ADMIN_EMAIL }}
Expand Down
6 changes: 6 additions & 0 deletions terraform/environments/aws/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,12 @@ variable "max_account_parallelism" {
default = 10
}

variable "cross_account_target_account_ids" {
description = "AWS account IDs this deployment itself calls sts:AssumeRole against, for multi-account plan execution. Empty (the default) grants no cross-account sts:AssumeRole at all: the compute module's grant is not created. Listing an account here is what authorizes it at IAM; it does not onboard it, which still happens in the app. For bastion-mode accounts list the bastion's account, not the target's, because only the first hop runs on this deployment's identity. Deliberately not defaulted to a wildcard: see #1636."
type = list(string)
default = []
}

# ==============================================
# Additional Configuration
# ==============================================
Expand Down
Loading
Loading