Skip to content

Commit 26b7b65

Browse files
milldrclaude
andauthored
feat: add TerraformAccess, RootAccess permission sets and account_map bypass (#53)
* feat: add TerraformAccess, RootAccess permission sets and idp_groups support New features: - Add TerraformPlanAccess permission set (read-only state and account access) - Add TerraformApplyAccess permission set (read/write state and admin access) - Add RootAccess permission set (centralized root access via sts:AssumeRoot) - Add provider-root.tf for root account assignments via profile - Add idp_groups variable to look up IdP-synced groups - Add group_ids output combining manual and IdP groups - Add account_map_enabled variable to toggle between remote state and static mapping - Add account_map variable for static account ID mapping Changes: - Update main.tf to conditionally use account_map module or static variable - Update main.tf to include new permission sets and idp_groups data source - Update remote-state.tf with conditional bypass for account_map - Update variables.tf with new variables - Update outputs.tf with group_ids output These changes support the account-map deprecation effort by enabling profile-based authentication and static account mappings. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * fix: remove duplicate root provider from providers.tf The root provider with alias "root" is now defined in provider-root.tf as a simple profile-based provider. Remove the duplicate definition and iam_roles_root module from providers.tf to avoid conflict. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor: move profile-based providers to mixins folder Revert providers.tf to include the root provider using account-map and move the profile-based providers to mixins/ for optional vendoring. This allows the component to work with account-map out of the box while still supporting profile-based authentication when using Atmos Auth by vendoring the mixins. Changes: - Restore root provider and iam_roles_root module to src/providers.tf - Delete src/provider-root.tf (profile-based version) - Add mixins/providers.tf with profile-based default provider - Add mixins/provider-root.tf with profile-based root provider - Update .pre-commit-config.yaml to exclude mixins/ from tflint 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * chore: add versions.tf to mixins for CI provider-pinning checks 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * chore: remove unnecessary mixins/providers.tf The main providers.tf is handled by the centralized mixin from cloudposse-terraform-components/mixins. We only need component-specific mixins for unique situations like aliased providers (provider-root.tf). 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> * refactor: simplify account_map lookup to always use module outputs Since module.account_map.outputs provides values from either remote state (when account_map_enabled is true) or from the static var.account_map defaults (when bypassed), we can always reference module.account_map.outputs directly without conditional logic. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent ac149f5 commit 26b7b65

File tree

11 files changed

+283
-7
lines changed

11 files changed

+283
-7
lines changed

.pre-commit-config.yaml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,10 @@ repos:
2626
rev: v1.81.0
2727
hooks:
2828
- id: terraform_fmt
29+
exclude: "mixins/"
2930
- id: terraform_docs
3031
args: ["--args=--lockfile=false"]
3132
- id: terraform_tflint
3233
args:
3334
- --args=--config=__GIT_WORKING_DIR__/.tflint.hcl
34-
exclude: "context.tf$"
35+
exclude: "(context.tf$|mixins/)"

mixins/.tflint.hcl

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Disable tflint for mixins - these are partial terraform files
2+
# that are vendored into other components
3+
config {
4+
disabled_by_default = true
5+
}

mixins/provider-root.tf

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
# This mixin replaces the root provider in providers.tf to use
2+
# profile-based authentication instead of the account-map module.
3+
# Use this when deploying with Atmos Auth profiles.
4+
#
5+
# When using this mixin, you must also use a providers.tf mixin that
6+
# removes the root provider block and iam_roles_root module.
7+
8+
variable "root_profile_name" {
9+
type = string
10+
description = "The profile name to use for the root account"
11+
default = "core-root/terraform"
12+
}
13+
14+
provider "aws" {
15+
# The AWS provider to use to make changes in the root account
16+
alias = "root"
17+
region = var.region
18+
19+
profile = var.root_profile_name
20+
}

mixins/versions.tf

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
# This file exists to satisfy CI provider-pinning checks.
2+
# When vendored, the main component's versions.tf takes precedence.
3+
4+
terraform {
5+
required_version = ">= 1.3.0"
6+
7+
required_providers {
8+
aws = {
9+
source = "hashicorp/aws"
10+
version = ">= 4.0"
11+
}
12+
}
13+
}

src/main.tf

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
locals {
22
enabled = module.this.enabled
33

4+
# module.account_map.outputs provides values from either remote state (when enabled)
5+
# or from the static var.account_map defaults (when bypassed)
46
account_map = module.account_map.outputs.full_account_map
57
root_account = local.account_map[module.account_map.outputs.root_account_account_name]
68

@@ -73,6 +75,20 @@ resource "aws_identitystore_group" "manual" {
7375
identity_store_id = tolist(data.aws_ssoadmin_instances.this.identity_store_ids)[0]
7476
}
7577

78+
# Look up IdP-managed groups (synced from Google Workspace, Okta, etc.)
79+
data "aws_identitystore_group" "idp" {
80+
for_each = toset(var.idp_groups)
81+
82+
identity_store_id = tolist(data.aws_ssoadmin_instances.this.identity_store_ids)[0]
83+
84+
alternate_identifier {
85+
unique_attribute {
86+
attribute_path = "DisplayName"
87+
attribute_value = each.key
88+
}
89+
}
90+
}
91+
7692
module "permission_sets" {
7793
source = "cloudposse/sso/aws//modules/permission-sets"
7894
version = "1.2.0"
@@ -86,6 +102,9 @@ module "permission_sets" {
86102
local.identity_access_permission_sets,
87103
local.poweruser_access_permission_set,
88104
local.read_only_access_permission_set,
105+
local.root_access_permission_set,
106+
local.terraform_plan_access_permission_set,
107+
local.terraform_apply_access_permission_set,
89108
local.terraform_update_access_permission_set,
90109
)
91110

src/outputs.tf

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,9 @@ output "sso_account_assignments" {
99
}
1010

1111
output "group_ids" {
12-
value = { for group_key, group_output in aws_identitystore_group.manual : group_key => group_output.group_id }
13-
description = "Group IDs created for Identity Center"
12+
value = merge(
13+
{ for group_key, group_output in aws_identitystore_group.manual : group_key => group_output.group_id },
14+
{ for group_key, group_output in data.aws_identitystore_group.idp : group_key => group_output.group_id }
15+
)
16+
description = "Group IDs for Identity Center (includes both manually created and IdP-synced groups)"
1417
}

src/policy-RootAccess.tf

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
locals {
2+
root_access_permission_set = [{
3+
name = "RootAccess",
4+
description = "Allow centralized root access to member accounts via sts:AssumeRoot",
5+
relay_state = "",
6+
session_duration = var.session_duration,
7+
tags = {},
8+
inline_policy = jsonencode({
9+
Version = "2012-10-17"
10+
Statement = [{
11+
Sid = "AssumeRootAccess"
12+
Effect = "Allow"
13+
Action = "sts:AssumeRoot"
14+
Resource = "arn:${local.aws_partition}:iam::*:root"
15+
Condition = {
16+
StringEquals = {
17+
"sts:TaskPolicyArn" = [
18+
"arn:${local.aws_partition}:iam::aws:policy/root-task/IAMAuditRootUserCredentials",
19+
"arn:${local.aws_partition}:iam::aws:policy/root-task/IAMCreateRootUserPassword",
20+
"arn:${local.aws_partition}:iam::aws:policy/root-task/IAMDeleteRootUserCredentials",
21+
"arn:${local.aws_partition}:iam::aws:policy/root-task/S3UnlockBucketPolicy",
22+
"arn:${local.aws_partition}:iam::aws:policy/root-task/SQSUnlockQueuePolicy"
23+
]
24+
}
25+
}
26+
}]
27+
})
28+
policy_attachments = []
29+
customer_managed_policy_attachments = []
30+
}]
31+
}

src/policy-TerraformAccess.tf

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
# Shared variables for Terraform state backend access
2+
variable "tf_access_bucket_arn" {
3+
type = string
4+
description = "The ARN of the S3 bucket for the Terraform state backend."
5+
default = ""
6+
}
7+
8+
variable "tf_access_dynamodb_table_arn" {
9+
type = string
10+
description = "The ARN of the DynamoDB table for the Terraform state backend."
11+
default = ""
12+
}
13+
14+
variable "tf_access_role_arn" {
15+
type = string
16+
description = "The ARN of the IAM role for accessing the Terraform state backend."
17+
default = ""
18+
}
19+
20+
locals {
21+
tf_access_enabled = module.this.enabled && var.tf_access_bucket_arn != "" && var.tf_access_role_arn != ""
22+
23+
# Terraform Plan Access permission set
24+
terraform_plan_access_permission_set = local.tf_access_enabled ? [{
25+
name = "TerraformPlanAccess",
26+
description = "Allow read-only access to Terraform state for planning",
27+
relay_state = "",
28+
session_duration = var.session_duration,
29+
tags = {},
30+
inline_policy = one(data.aws_iam_policy_document.terraform_plan_access[*].json),
31+
policy_attachments = ["arn:${local.aws_partition}:iam::aws:policy/ReadOnlyAccess"]
32+
customer_managed_policy_attachments = []
33+
}] : []
34+
35+
# Terraform Apply Access permission set
36+
terraform_apply_access_permission_set = local.tf_access_enabled ? [{
37+
name = "TerraformApplyAccess",
38+
description = "Allow full access to Terraform state and account for applying changes",
39+
relay_state = "",
40+
session_duration = var.session_duration,
41+
tags = {},
42+
inline_policy = one(data.aws_iam_policy_document.terraform_apply_access[*].json),
43+
policy_attachments = ["arn:${local.aws_partition}:iam::aws:policy/AdministratorAccess"]
44+
customer_managed_policy_attachments = []
45+
}] : []
46+
}
47+
48+
# Terraform Plan Access - Read-only state access, read-only account access
49+
data "aws_iam_policy_document" "terraform_plan_access" {
50+
count = local.tf_access_enabled ? 1 : 0
51+
52+
# Read-only access to Terraform state S3 bucket
53+
statement {
54+
sid = "TerraformStateBackendS3BucketReadOnly"
55+
effect = "Allow"
56+
actions = [
57+
"s3:ListBucket",
58+
"s3:GetObject",
59+
]
60+
resources = [var.tf_access_bucket_arn, "${var.tf_access_bucket_arn}/*"]
61+
}
62+
63+
# Allow assuming the Terraform state backend role (needed to read state)
64+
statement {
65+
sid = "TerraformStateBackendAssumeRole"
66+
effect = "Allow"
67+
actions = [
68+
"sts:AssumeRole",
69+
"sts:TagSession",
70+
"sts:SetSourceIdentity",
71+
]
72+
resources = [var.tf_access_role_arn]
73+
}
74+
75+
# Allow EC2 DescribeRegions - required by many Terraform modules for region validation
76+
statement {
77+
sid = "EC2DescribeRegions"
78+
effect = "Allow"
79+
actions = [
80+
"ec2:DescribeRegions",
81+
]
82+
resources = ["*"]
83+
}
84+
}
85+
86+
# Terraform Apply Access - Read/write state access, admin account access
87+
data "aws_iam_policy_document" "terraform_apply_access" {
88+
count = local.tf_access_enabled ? 1 : 0
89+
90+
statement {
91+
sid = "TerraformStateBackendS3Bucket"
92+
effect = "Allow"
93+
actions = [
94+
"s3:ListBucket",
95+
"s3:GetObject",
96+
"s3:PutObject",
97+
]
98+
resources = [var.tf_access_bucket_arn, "${var.tf_access_bucket_arn}/*"]
99+
}
100+
101+
# Only add the DynamoDB table statement if the DynamoDB table ARN is set.
102+
# You may not have created a DynamoDB table if you're using S3 state locking
103+
dynamic "statement" {
104+
for_each = (local.tf_access_enabled && var.tf_access_dynamodb_table_arn != "") ? [1] : []
105+
106+
content {
107+
sid = "TerraformStateBackendDynamoDbTable"
108+
effect = "Allow"
109+
actions = ["dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:DeleteItem"]
110+
resources = [var.tf_access_dynamodb_table_arn]
111+
}
112+
}
113+
114+
# Allow assuming the Terraform state backend role
115+
statement {
116+
sid = "TerraformStateBackendAssumeRole"
117+
effect = "Allow"
118+
actions = [
119+
"sts:AssumeRole",
120+
"sts:TagSession",
121+
"sts:SetSourceIdentity",
122+
]
123+
resources = [var.tf_access_role_arn]
124+
}
125+
126+
# Allow EC2 DescribeRegions - required by many Terraform modules for region validation
127+
statement {
128+
sid = "EC2DescribeRegions"
129+
effect = "Allow"
130+
actions = [
131+
"ec2:DescribeRegions",
132+
]
133+
resources = ["*"]
134+
}
135+
}

src/providers.tf

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,30 @@
1+
variable "account_map_enabled" {
2+
type = bool
3+
description = <<-EOT
4+
When true, uses the account-map component to look up account IDs dynamically.
5+
When false, uses the static account_map variable instead. Set to false when
6+
using Atmos Auth profiles and static account mappings.
7+
EOT
8+
default = true
9+
}
10+
11+
variable "account_map" {
12+
type = object({
13+
full_account_map = map(string)
14+
audit_account_account_name = optional(string, "")
15+
root_account_account_name = optional(string, "")
16+
})
17+
description = <<-EOT
18+
Static account map used when account_map_enabled is false.
19+
Provides account name to account ID mapping without requiring the account-map component.
20+
EOT
21+
default = {
22+
full_account_map = {}
23+
audit_account_account_name = ""
24+
root_account_account_name = ""
25+
}
26+
}
27+
128
# This component is unusual in that part of it must be deployed to the `root`
229
# account. You have the option of where to deploy the remaining part, and
330
# Cloud Posse recommends you deploy it also to the `root` account, however

src/remote-state.tf

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,25 @@
1+
# Remote state lookup for the account-map component (or fallback to static mapping).
2+
#
3+
# When account_map_enabled is true:
4+
# - Performs remote state lookup to retrieve account mappings from the account-map component
5+
# - Uses global tenant/environment/stage from iam_roles module for the lookup
6+
#
7+
# When account_map_enabled is false:
8+
# - Bypasses the remote state lookup (bypass = true)
9+
# - Returns the static account_map variable as defaults instead
10+
# - Allows the component to function without the account-map dependency
111
module "account_map" {
212
source = "cloudposse/stack-config/yaml//modules/remote-state"
313
version = "1.8.0"
414

515
component = var.account_map_component_name
6-
environment = module.iam_roles.global_environment_name
7-
stage = module.iam_roles.global_stage_name
8-
tenant = module.iam_roles.global_tenant_name
9-
privileged = var.privileged
16+
tenant = var.account_map_enabled ? module.iam_roles.global_tenant_name : null
17+
environment = var.account_map_enabled ? module.iam_roles.global_environment_name : null
18+
stage = var.account_map_enabled ? module.iam_roles.global_stage_name : null
1019

1120
context = module.this.context
21+
22+
# When account_map is disabled, bypass remote state and use the static account_map variable
23+
bypass = !var.account_map_enabled
24+
defaults = var.account_map
1225
}

0 commit comments

Comments
 (0)