In my previous post, we explored OpenTofu 1.9’s new provider for_each
feature for managing multi-region infrastructure. Today, we’ll take this concept further by managing infrastructure across multiple AWS accounts and IAM roles, in addition to regions. This is a common requirement in enterprise environments where you need to manage resources across development, staging, and production accounts, with different IAM roles for varying levels of access.
Challenge#
Enterprise AWS environments typically involve:
- Multiple AWS accounts (dev, qa, prod)
- Different IAM roles for various access levels (admin, reader)
- Multiple regions for redundancy
- Need to assume different roles in different accounts
Without for_each
, this would require manually writing provider configurations for every combination of account, role, and region. For just 2 accounts, 2 roles, and 2 regions, that’s already 8 provider blocks!
Solution#
Using OpenTofu’s provider for_each
, we can create a dynamic configuration that handles all these combinations elegantly. Let’s break it down step by step.
1. Define Your Variables#
First, define your regions and account IDs:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| variable "aws_regions" {
type = map(string)
default = {
"global" : "us-east-1",
"backup" : "us-west-2"
}
}
variable "aws_account_ids" {
type = map(string)
default = {
"dev" : "123456789012"
"qa" : "234567890123"
}
}
|
2. Define Role Configurations#
Set up your role definitions in a local variable:
1
2
3
4
5
6
| locals {
role_definitions = {
"admin" = "admin-role"
"reader" = "reader-role"
}
}
|
3. Create Provider Configurations#
Here’s where the magic happens. We’ll use nested for_each
operations to create all possible combinations:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| locals {
# Create account + role combinations
account_role_combinations = merge([
for account_name, account_id in var.aws_account_ids : {
for role_name, role_path in local.role_definitions :
"${account_name}_${role_name}" => "arn:aws:iam::${account_id}:role/${role_path}"
}
]...)
# Create final provider configurations combining account_roles with regions
provider_configurations = merge([
for account_role, role_arn in local.account_role_combinations : {
for region_name, region in var.aws_regions :
"${account_role}_${region_name}" => {
role_arn = role_arn
region = region
}
}
]...)
}
|
With our configurations ready, we can create the provider block:
1
2
3
4
5
6
7
8
9
| provider "aws" {
for_each = local.provider_configurations
alias = "this"
region = each.value.region
assume_role {
role_arn = each.value.role_arn
}
}
|
5. Validate the Configuration#
Let’s add some data sources and outputs to verify everything works:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| data "aws_availability_zones" "all_zones" {
for_each = local.provider_configurations
provider = aws.this[each.key]
}
data "aws_caller_identity" "assumed_roles" {
for_each = local.provider_configurations
provider = aws.this[each.key]
}
output "availability_zones" {
description = "Available AZs for each provider configuration"
value = {
for k, v in data.aws_availability_zones.all_zones : k => tolist(v.group_names)[0]
}
}
output "assumed_role_arns" {
description = "Assumed role ARN for each provider configuration"
value = {
for k, v in data.aws_caller_identity.assumed_roles : k => v.arn
}
}
|
Results#
When you apply this configuration, you’ll see outputs confirming that OpenTofu has successfully:
- Created provider configurations for all combinations
- Assumed the correct roles in each account
- Accessed the correct regions
Example output:
1
2
3
4
5
6
7
8
9
10
| assumed_role_arns = {
"dev_admin_backup" = "arn:aws:sts::123456789012:assumed-role/admin-role/..."
"dev_admin_global" = "arn:aws:sts::123456789012:assumed-role/admin-role/..."
"dev_reader_backup" = "arn:aws:sts::123456789012:assumed-role/reader-role/..."
"dev_reader_global" = "arn:aws:sts::123456789012:assumed-role/reader-role/..."
"qa_admin_backup" = "arn:aws:sts::234567890123:assumed-role/admin-role/..."
"qa_admin_global" = "arn:aws:sts::234567890123:assumed-role/admin-role/..."
"qa_reader_backup" = "arn:aws:sts::234567890123:assumed-role/reader-role/..."
"qa_reader_global" = "arn:aws:sts::234567890123:assumed-role/reader-role/..."
}
|
Benefits of This Approach#
- Maintainability: Adding new accounts, roles, or regions is as simple as adding entries to the respective maps.
- DRY Code: No repetition of provider blocks, even with many combinations.
- Validation: Easy to verify that all configurations are working through the outputs.
- Flexibility: Easy to modify role paths or add new configurations without changing the core structure.
Real World Usage#
This pattern is particularly useful when:
- Managing shared infrastructure across multiple AWS accounts
- Implementing least-privilege access patterns
- Setting up disaster recovery across regions
- Managing development, staging, and production environments
Conclusion#
OpenTofu’s provider for_each
feature transforms what would have been dozens of repetitive provider blocks into a clean, maintainable configuration. This approach scales well as your infrastructure grows, making it easier to manage complex, multi-account AWS environments.
Complete example#
I know in the end, we all just want a complete example to copy-paste. Here you go:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
| variable "aws_regions" {
type = map(string)
default = {
"global" : "us-east-1",
"backup" : "us-west-2"
}
}
variable "aws_account_ids" {
type = map(string)
default = {
"dev" : "123456789012"
"qa" : "234567890123"
}
}
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~>5"
}
}
}
locals {
role_definitions = {
"admin" = "admin-role"
"reader" = "reader-role"
}
# Create account + role combinations
account_role_combinations = merge([
for account_name, account_id in var.aws_account_ids : {
for role_name, role_path in local.role_definitions :
"${account_name}_${role_name}" => "arn:aws:iam::${account_id}:role/${role_path}"
}
]...)
# Create final provider configurations combining account_roles with regions
provider_configurations = merge([
for account_role, role_arn in local.account_role_combinations : {
for region_name, region in var.aws_regions :
"${account_role}_${region_name}" => {
role_arn = role_arn
region = region
}
}
]...)
}
provider "aws" {
for_each = local.provider_configurations
alias = "this"
region = each.value.region
assume_role {
role_arn = each.value.role_arn
}
}
data "aws_availability_zones" "all_zones" {
for_each = local.provider_configurations
provider = aws.this[each.key]
}
data "aws_caller_identity" "assumed_roles" {
for_each = local.provider_configurations
provider = aws.this[each.key]
}
output "availability_zones" {
description = "Available AZs for each provider configuration"
value = {
for k, v in data.aws_availability_zones.all_zones : k => tolist(v.group_names)[0]
}
}
output "assumed_role_arns" {
description = "Assumed role ARN for each provider configuration"
value = {
for k, v in data.aws_caller_identity.assumed_roles : k => v.arn
}
}
|