From b4b755fd26fc49b57c3737699f94f532494ec779 Mon Sep 17 00:00:00 2001 From: Johannes Rudolph Date: Mon, 24 Aug 2026 21:49:03 +0200 Subject: [PATCH 1/7] refactor(aws/route53-dns-record): drop the IAM access key path, WIF only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backplane offered two credential paths: workload identity federation, or an IAM user with an `aws_iam_access_key` when `workload_identity_federation` was left null. Supporting both cost a `count` on six resources, a policy name that branched on the same condition, and a `credentials` output that published the string "N/A; workload identity federation in use" on the federated path. `.agents/references/aws-backplane.md` already forbids this: "Long-lived IAM access keys for single-account building blocks — use WIF (Pattern A) instead". The new `aws_wif_no_access_key` and `aws_wif_nonnullable` scorecard checks now enforce it. Nothing was on the null branch. The integration always passes a non-null `workload_identity_federation` built from `data.meshstack_integrations`, so the IAM user, its policy attachment and its access key were dead code — and `internal-cloudfoundation`'s meshcloud-prod deployment is on the federated path. `workload_identity_federation` becomes `nullable = false`, the `count` guards go, the policy keeps its federated-path name (`Route53RecordBuildingBlockFederatedPolicy-*`) so no live policy is renamed, and the `credentials` output is deleted. `moved` blocks carry `aws_iam_role.assume_federated_role` and its policy attachment across the removed `count`. Without them a deployment already on the federated path would destroy and recreate its live IAM role on the next apply — unlike the GCP equivalent, these `[0]` addresses exist in production state. Module scores 100% on the AWS Backplane category. `tofu validate` passes on the backplane and on the integration root wired against it. Co-Authored-By: Claude Opus 5 --- .../route53-dns-record/backplane/README.md | 47 +++++++++++++----- .../aws/route53-dns-record/backplane/main.tf | 49 +++++++------------ .../route53-dns-record/backplane/outputs.tf | 8 --- .../route53-dns-record/backplane/variables.tf | 4 +- 4 files changed, 55 insertions(+), 53 deletions(-) diff --git a/modules/aws/route53-dns-record/backplane/README.md b/modules/aws/route53-dns-record/backplane/README.md index eb68f083..0c708868 100644 --- a/modules/aws/route53-dns-record/backplane/README.md +++ b/modules/aws/route53-dns-record/backplane/README.md @@ -1,6 +1,26 @@ # AWS Route53 DNS Record Backplane -This will deploy an IAM user (or role only in case of using `workload_identity_federation`) with Route53 access for managing DNS records. +This deploys the IAM role that the Route53 DNS Record building block assumes to manage records in the +hosted zones you list. + +## Authentication + +The building block authenticates by **workload identity federation** — the only credential path this +backplane offers, so `workload_identity_federation` is required. The backplane registers the +meshStack issuer as an OIDC provider, creates an IAM role whose trust policy accepts only the +subjects of this building block definition, and exports the role ARN as +`workload_identity_federation_role`. No long-lived credential exists anywhere in the module. + +AWS allows **one OIDC provider per issuer URL per account**. A second backplane in the same account +must therefore set `create_oidc_provider = false` and reuse the existing one — otherwise its apply +fails with `EntityAlreadyExists`. + +## Required permissions + +The platform engineer or CI principal applying this module needs `iam:*` on the OIDC provider, the +role and the policy it manages (`CreateOpenIDConnectProvider`, `CreateRole`, `CreatePolicy`, +`AttachRolePolicy` and their `Get`/`Delete` counterparts). `arn:aws:iam::aws:policy/IAMFullAccess` +covers it. ## Usage @@ -22,17 +42,24 @@ module "aws_route53_dns_record_backplane" { issuer = "https://your-oidc-issuer" audience = "your-audience" subjects = [ - "system:serviceaccount:your-namespace:your-service-account-name", # Exact match - "system:serviceaccount:your-namespace:*", # Wildcard match + "system:serviceaccount:your-namespace:your-service-account-name", # Exact match + "system:serviceaccount:your-namespace:*", # Wildcard match ] - } # Optional, if not provided, IAM access keys will be created instead -} + } -output "aws_route53_dns_record_backplane" { - value = module.aws_route53_dns_record_backplane + # Set to false when another backplane already created the meshStack OIDC provider in this account. + create_oidc_provider = true } ``` +## Migrating from the access key path + +Earlier revisions created an IAM user and an `aws_iam_access_key` when `workload_identity_federation` +was left null. That path is gone. A deployment still on it must pass `workload_identity_federation`, +and the next apply destroys the IAM user and revokes its key — that is the intended migration. A +deployment already on the federated path is unaffected: `moved` blocks carry its role and policy +attachment across the removed `count`. + ## Requirements @@ -50,13 +77,10 @@ No modules. | Name | Type | |------|------| -| [aws_iam_access_key.buildingblock_route53_record_access_key](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_access_key) | resource | | [aws_iam_openid_connect_provider.buildingblock_oidc_provider](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_openid_connect_provider) | resource | | [aws_iam_policy.buildingblock_route53_record_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_policy) | resource | | [aws_iam_role.assume_federated_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | | [aws_iam_role_policy_attachment.buildingblock_route53_record](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | -| [aws_iam_user.buildingblock_route53_record_user](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_user) | resource | -| [aws_iam_user_policy_attachment.buildingblock_route53_record_user_policy_attachment](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_user_policy_attachment) | resource | | [random_string.name_suffix](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/string) | resource | | [aws_caller_identity.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/caller_identity) | data source | | [aws_iam_openid_connect_provider.buildingblock_oidc_provider](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_openid_connect_provider) | data source | @@ -69,12 +93,11 @@ No modules. |------|-------------|------|---------|:--------:| | [create\_oidc\_provider](#input\_create\_oidc\_provider) | Set to false if the OIDC provider for the meshStack issuer already exists in this AWS account (e.g., created by another backplane). The existing provider will be looked up by URL instead of created. | `bool` | `true` | no | | [hosted\_zone\_ids](#input\_hosted\_zone\_ids) | List of Route53 hosted zone IDs that the building block can manage. Example: '', ''] | `list(string)` | n/a | yes | -| [workload\_identity\_federation](#input\_workload\_identity\_federation) | Set these options to add a trusted identity provider from meshStack to allow workload identity federation for authentication which can be used instead of access keys. Supports multiple subjects and wildcard patterns (e.g., 'system:serviceaccount:namespace:*'). |
object({
issuer = string,
audience = string,
subjects = list(string)
})
| `null` | no | +| [workload\_identity\_federation](#input\_workload\_identity\_federation) | Trusted identity provider from meshStack that the building block runner federates into. Supports multiple subjects and wildcard patterns (e.g., 'system:serviceaccount:namespace:*'). |
object({
issuer = string,
audience = string,
subjects = list(string)
})
| n/a | yes | ## Outputs | Name | Description | |------|-------------| -| [credentials](#output\_credentials) | n/a | | [workload\_identity\_federation\_role](#output\_workload\_identity\_federation\_role) | Workload identity federation role ARN | diff --git a/modules/aws/route53-dns-record/backplane/main.tf b/modules/aws/route53-dns-record/backplane/main.tf index 76d250dc..6f068000 100644 --- a/modules/aws/route53-dns-record/backplane/main.tf +++ b/modules/aws/route53-dns-record/backplane/main.tf @@ -6,12 +6,6 @@ resource "random_string" "name_suffix" { upper = false } -resource "aws_iam_user" "buildingblock_route53_record_user" { - count = var.workload_identity_federation == null ? 1 : 0 - - name = "buildingblock-route53-record-user-${random_string.name_suffix.result}" -} - data "aws_iam_policy_document" "route53_record_access" { # Global Route53 actions that don't support resource-level permissions statement { @@ -39,49 +33,34 @@ data "aws_iam_policy_document" "route53_record_access" { } locals { - policy_name = var.workload_identity_federation == null ? "Route53RecordBuildingBlockPolicy-${random_string.name_suffix.result}" : "Route53RecordBuildingBlockFederatedPolicy-${random_string.name_suffix.result}" - oidc_provider_arn = var.workload_identity_federation == null ? null : try( + oidc_provider_arn = try( aws_iam_openid_connect_provider.buildingblock_oidc_provider[0].arn, data.aws_iam_openid_connect_provider.buildingblock_oidc_provider[0].arn ) } resource "aws_iam_policy" "buildingblock_route53_record_policy" { - name = local.policy_name + name = "Route53RecordBuildingBlockFederatedPolicy-${random_string.name_suffix.result}" description = "Policy for the Route53 DNS Record Building Block" policy = data.aws_iam_policy_document.route53_record_access.json } -resource "aws_iam_user_policy_attachment" "buildingblock_route53_record_user_policy_attachment" { - count = var.workload_identity_federation == null ? 1 : 0 - - user = aws_iam_user.buildingblock_route53_record_user[0].name - policy_arn = aws_iam_policy.buildingblock_route53_record_policy.arn -} - -resource "aws_iam_access_key" "buildingblock_route53_record_access_key" { - count = var.workload_identity_federation == null ? 1 : 0 - - user = aws_iam_user.buildingblock_route53_record_user[0].name -} - # Workload Identity Federation resource "aws_iam_openid_connect_provider" "buildingblock_oidc_provider" { - count = (var.workload_identity_federation != null && var.create_oidc_provider) ? 1 : 0 + count = var.create_oidc_provider ? 1 : 0 url = var.workload_identity_federation.issuer client_id_list = [var.workload_identity_federation.audience] } data "aws_iam_openid_connect_provider" "buildingblock_oidc_provider" { - count = (var.workload_identity_federation != null && !var.create_oidc_provider) ? 1 : 0 + count = var.create_oidc_provider ? 0 : 1 url = var.workload_identity_federation.issuer } data "aws_iam_policy_document" "workload_identity_federation" { - count = var.workload_identity_federation != null ? 1 : 0 version = "2012-10-17" statement { @@ -109,15 +88,23 @@ data "aws_iam_policy_document" "workload_identity_federation" { } resource "aws_iam_role" "assume_federated_role" { - count = var.workload_identity_federation != null ? 1 : 0 - name = "BuildingBlockRoute53RecordIdentityFederation-${random_string.name_suffix.result}" - assume_role_policy = data.aws_iam_policy_document.workload_identity_federation[0].json + assume_role_policy = data.aws_iam_policy_document.workload_identity_federation.json } resource "aws_iam_role_policy_attachment" "buildingblock_route53_record" { - count = var.workload_identity_federation != null ? 1 : 0 - - role = aws_iam_role.assume_federated_role[0].name + role = aws_iam_role.assume_federated_role.name policy_arn = aws_iam_policy.buildingblock_route53_record_policy.arn } + +# Both were count-guarded while the backplane still offered an IAM access key fallback. Without these +# a deployment that is already on the federated path would destroy and recreate its live IAM role. +moved { + from = aws_iam_role.assume_federated_role[0] + to = aws_iam_role.assume_federated_role +} + +moved { + from = aws_iam_role_policy_attachment.buildingblock_route53_record[0] + to = aws_iam_role_policy_attachment.buildingblock_route53_record +} diff --git a/modules/aws/route53-dns-record/backplane/outputs.tf b/modules/aws/route53-dns-record/backplane/outputs.tf index d6820e56..2d98944c 100644 --- a/modules/aws/route53-dns-record/backplane/outputs.tf +++ b/modules/aws/route53-dns-record/backplane/outputs.tf @@ -1,11 +1,3 @@ -output "credentials" { - sensitive = true - value = { - AWS_ACCESS_KEY_ID = var.workload_identity_federation == null ? aws_iam_access_key.buildingblock_route53_record_access_key[0].id : "N/A; workload identity federation in use" - AWS_SECRET_ACCESS_KEY = var.workload_identity_federation == null ? aws_iam_access_key.buildingblock_route53_record_access_key[0].secret : "N/A; workload identity federation in use" - } -} - output "workload_identity_federation_role" { description = "Workload identity federation role ARN" # Manually construct ARN to avoid dependency cycle on input workload_identity_federation (which contains the BBD UUID as subject) diff --git a/modules/aws/route53-dns-record/backplane/variables.tf b/modules/aws/route53-dns-record/backplane/variables.tf index ba72420c..cb1fec0c 100644 --- a/modules/aws/route53-dns-record/backplane/variables.tf +++ b/modules/aws/route53-dns-record/backplane/variables.tf @@ -9,8 +9,8 @@ variable "workload_identity_federation" { audience = string, subjects = list(string) }) - default = null - description = "Set these options to add a trusted identity provider from meshStack to allow workload identity federation for authentication which can be used instead of access keys. Supports multiple subjects and wildcard patterns (e.g., 'system:serviceaccount:namespace:*')." + nullable = false + description = "Trusted identity provider from meshStack that the building block runner federates into. Supports multiple subjects and wildcard patterns (e.g., 'system:serviceaccount:namespace:*')." } variable "create_oidc_provider" { From e4453e01b108468e7f20010bf40ea7eea5bff341 Mon Sep 17 00:00:00 2001 From: Johannes Rudolph Date: Mon, 24 Aug 2026 21:49:17 +0200 Subject: [PATCH 2/7] refactor(aws/route53-dns-alias-record): drop the IAM access key path, WIF only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The backplane offered two credential paths: workload identity federation, or an IAM user with an `aws_iam_access_key` when `workload_identity_federation` was left null. Supporting both cost a `count` on six resources, a policy name that branched on the same condition, and a `credentials` output that published the string "N/A; workload identity federation in use" on the federated path. `.agents/references/aws-backplane.md` already forbids this: "Long-lived IAM access keys for single-account building blocks — use WIF (Pattern A) instead". The new `aws_wif_no_access_key` and `aws_wif_nonnullable` scorecard checks now enforce it. Nothing was on the null branch. The integration always passes a non-null `workload_identity_federation` built from `data.meshstack_integrations`, so the IAM user, its policy attachment and its access key were dead code — and `internal-cloudfoundation`'s meshcloud-prod deployment is on the federated path. `workload_identity_federation` becomes `nullable = false`, the `count` guards go, the policy keeps its federated-path name (`Route53AliasRecordBuildingBlockFederatedPolicy-*`) so no live policy is renamed, and the `credentials` output is deleted. `moved` blocks carry `aws_iam_role.assume_federated_role` and its policy attachment across the removed `count`. Without them a deployment already on the federated path would destroy and recreate its live IAM role on the next apply — unlike the GCP equivalent, these `[0]` addresses exist in production state. Module scores 100% on the AWS Backplane category. `tofu validate` passes on the backplane and on the integration root wired against it. Co-Authored-By: Claude Opus 5 --- .../backplane/README.md | 47 +++++++++++++----- .../backplane/main.tf | 49 +++++++------------ .../backplane/outputs.tf | 8 --- .../backplane/variables.tf | 4 +- 4 files changed, 55 insertions(+), 53 deletions(-) diff --git a/modules/aws/route53-dns-alias-record/backplane/README.md b/modules/aws/route53-dns-alias-record/backplane/README.md index e043d946..5772c640 100644 --- a/modules/aws/route53-dns-alias-record/backplane/README.md +++ b/modules/aws/route53-dns-alias-record/backplane/README.md @@ -1,6 +1,26 @@ # AWS Route53 DNS Alias Record Backplane -This will deploy an IAM user (or role only in case of using `workload_identity_federation`) with Route53 access for managing DNS alias records. +This deploys the IAM role that the Route53 DNS Alias Record building block assumes to manage alias +records in the hosted zones you list. + +## Authentication + +The building block authenticates by **workload identity federation** — the only credential path this +backplane offers, so `workload_identity_federation` is required. The backplane registers the +meshStack issuer as an OIDC provider, creates an IAM role whose trust policy accepts only the +subjects of this building block definition, and exports the role ARN as +`workload_identity_federation_role`. No long-lived credential exists anywhere in the module. + +AWS allows **one OIDC provider per issuer URL per account**. A second backplane in the same account +must therefore set `create_oidc_provider = false` and reuse the existing one — otherwise its apply +fails with `EntityAlreadyExists`. + +## Required permissions + +The platform engineer or CI principal applying this module needs `iam:*` on the OIDC provider, the +role and the policy it manages (`CreateOpenIDConnectProvider`, `CreateRole`, `CreatePolicy`, +`AttachRolePolicy` and their `Get`/`Delete` counterparts). `arn:aws:iam::aws:policy/IAMFullAccess` +covers it. ## Usage @@ -22,17 +42,24 @@ module "aws_route53_dns_alias_record_backplane" { issuer = "https://your-oidc-issuer" audience = "your-audience" subjects = [ - "system:serviceaccount:your-namespace:your-service-account-name", # Exact match - "system:serviceaccount:your-namespace:*", # Wildcard match + "system:serviceaccount:your-namespace:your-service-account-name", # Exact match + "system:serviceaccount:your-namespace:*", # Wildcard match ] - } # Optional, if not provided, IAM access keys will be created instead -} + } -output "aws_route53_dns_alias_record_backplane" { - value = module.aws_route53_dns_alias_record_backplane + # Set to false when another backplane already created the meshStack OIDC provider in this account. + create_oidc_provider = true } ``` +## Migrating from the access key path + +Earlier revisions created an IAM user and an `aws_iam_access_key` when `workload_identity_federation` +was left null. That path is gone. A deployment still on it must pass `workload_identity_federation`, +and the next apply destroys the IAM user and revokes its key — that is the intended migration. A +deployment already on the federated path is unaffected: `moved` blocks carry its role and policy +attachment across the removed `count`. + ## Requirements @@ -50,13 +77,10 @@ No modules. | Name | Type | |------|------| -| [aws_iam_access_key.buildingblock_route53_alias_record_access_key](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_access_key) | resource | | [aws_iam_openid_connect_provider.buildingblock_oidc_provider](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_openid_connect_provider) | resource | | [aws_iam_policy.buildingblock_route53_alias_record_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_policy) | resource | | [aws_iam_role.assume_federated_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | | [aws_iam_role_policy_attachment.buildingblock_route53_alias_record](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | -| [aws_iam_user.buildingblock_route53_alias_record_user](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_user) | resource | -| [aws_iam_user_policy_attachment.buildingblock_route53_alias_record_user_policy_attachment](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_user_policy_attachment) | resource | | [random_string.name_suffix](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/string) | resource | | [aws_caller_identity.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/caller_identity) | data source | | [aws_iam_openid_connect_provider.buildingblock_oidc_provider](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_openid_connect_provider) | data source | @@ -69,12 +93,11 @@ No modules. |------|-------------|------|---------|:--------:| | [create\_oidc\_provider](#input\_create\_oidc\_provider) | Set to false if the OIDC provider for the meshStack issuer already exists in this AWS account (e.g., created by another backplane). The existing provider will be looked up by URL instead of created. | `bool` | `true` | no | | [hosted\_zone\_ids](#input\_hosted\_zone\_ids) | List of Route53 hosted zone IDs that the building block can manage. Example: ['', ''] | `list(string)` | n/a | yes | -| [workload\_identity\_federation](#input\_workload\_identity\_federation) | Set these options to add a trusted identity provider from meshStack to allow workload identity federation for authentication which can be used instead of access keys. Supports multiple subjects and wildcard patterns (e.g., 'system:serviceaccount:namespace:*'). |
object({
issuer = string,
audience = string,
subjects = list(string)
})
| `null` | no | +| [workload\_identity\_federation](#input\_workload\_identity\_federation) | Trusted identity provider from meshStack that the building block runner federates into. Supports multiple subjects and wildcard patterns (e.g., 'system:serviceaccount:namespace:*'). |
object({
issuer = string,
audience = string,
subjects = list(string)
})
| n/a | yes | ## Outputs | Name | Description | |------|-------------| -| [credentials](#output\_credentials) | n/a | | [workload\_identity\_federation\_role](#output\_workload\_identity\_federation\_role) | Workload identity federation role ARN | diff --git a/modules/aws/route53-dns-alias-record/backplane/main.tf b/modules/aws/route53-dns-alias-record/backplane/main.tf index 27a01d03..0d47dbcd 100644 --- a/modules/aws/route53-dns-alias-record/backplane/main.tf +++ b/modules/aws/route53-dns-alias-record/backplane/main.tf @@ -6,12 +6,6 @@ resource "random_string" "name_suffix" { upper = false } -resource "aws_iam_user" "buildingblock_route53_alias_record_user" { - count = var.workload_identity_federation == null ? 1 : 0 - - name = "buildingblock-route53-alias-record-user-${random_string.name_suffix.result}" -} - data "aws_iam_policy_document" "route53_alias_record_access" { # Global Route53 actions that don't support resource-level permissions statement { @@ -39,49 +33,34 @@ data "aws_iam_policy_document" "route53_alias_record_access" { } locals { - policy_name = var.workload_identity_federation == null ? "Route53AliasRecordBuildingBlockPolicy-${random_string.name_suffix.result}" : "Route53AliasRecordBuildingBlockFederatedPolicy-${random_string.name_suffix.result}" - oidc_provider_arn = var.workload_identity_federation == null ? null : try( + oidc_provider_arn = try( aws_iam_openid_connect_provider.buildingblock_oidc_provider[0].arn, data.aws_iam_openid_connect_provider.buildingblock_oidc_provider[0].arn ) } resource "aws_iam_policy" "buildingblock_route53_alias_record_policy" { - name = local.policy_name + name = "Route53AliasRecordBuildingBlockFederatedPolicy-${random_string.name_suffix.result}" description = "Policy for the Route53 DNS Alias Record Building Block" policy = data.aws_iam_policy_document.route53_alias_record_access.json } -resource "aws_iam_user_policy_attachment" "buildingblock_route53_alias_record_user_policy_attachment" { - count = var.workload_identity_federation == null ? 1 : 0 - - user = aws_iam_user.buildingblock_route53_alias_record_user[0].name - policy_arn = aws_iam_policy.buildingblock_route53_alias_record_policy.arn -} - -resource "aws_iam_access_key" "buildingblock_route53_alias_record_access_key" { - count = var.workload_identity_federation == null ? 1 : 0 - - user = aws_iam_user.buildingblock_route53_alias_record_user[0].name -} - # Workload Identity Federation resource "aws_iam_openid_connect_provider" "buildingblock_oidc_provider" { - count = (var.workload_identity_federation != null && var.create_oidc_provider) ? 1 : 0 + count = var.create_oidc_provider ? 1 : 0 url = var.workload_identity_federation.issuer client_id_list = [var.workload_identity_federation.audience] } data "aws_iam_openid_connect_provider" "buildingblock_oidc_provider" { - count = (var.workload_identity_federation != null && !var.create_oidc_provider) ? 1 : 0 + count = var.create_oidc_provider ? 0 : 1 url = var.workload_identity_federation.issuer } data "aws_iam_policy_document" "workload_identity_federation" { - count = var.workload_identity_federation != null ? 1 : 0 version = "2012-10-17" statement { @@ -109,15 +88,23 @@ data "aws_iam_policy_document" "workload_identity_federation" { } resource "aws_iam_role" "assume_federated_role" { - count = var.workload_identity_federation != null ? 1 : 0 - name = "BuildingBlockRoute53AliasRecordIdentityFederation-${random_string.name_suffix.result}" - assume_role_policy = data.aws_iam_policy_document.workload_identity_federation[0].json + assume_role_policy = data.aws_iam_policy_document.workload_identity_federation.json } resource "aws_iam_role_policy_attachment" "buildingblock_route53_alias_record" { - count = var.workload_identity_federation != null ? 1 : 0 - - role = aws_iam_role.assume_federated_role[0].name + role = aws_iam_role.assume_federated_role.name policy_arn = aws_iam_policy.buildingblock_route53_alias_record_policy.arn } + +# Both were count-guarded while the backplane still offered an IAM access key fallback. Without these +# a deployment that is already on the federated path would destroy and recreate its live IAM role. +moved { + from = aws_iam_role.assume_federated_role[0] + to = aws_iam_role.assume_federated_role +} + +moved { + from = aws_iam_role_policy_attachment.buildingblock_route53_alias_record[0] + to = aws_iam_role_policy_attachment.buildingblock_route53_alias_record +} diff --git a/modules/aws/route53-dns-alias-record/backplane/outputs.tf b/modules/aws/route53-dns-alias-record/backplane/outputs.tf index 19bcadaf..13eaad4f 100644 --- a/modules/aws/route53-dns-alias-record/backplane/outputs.tf +++ b/modules/aws/route53-dns-alias-record/backplane/outputs.tf @@ -1,11 +1,3 @@ -output "credentials" { - sensitive = true - value = { - AWS_ACCESS_KEY_ID = var.workload_identity_federation == null ? aws_iam_access_key.buildingblock_route53_alias_record_access_key[0].id : "N/A; workload identity federation in use" - AWS_SECRET_ACCESS_KEY = var.workload_identity_federation == null ? aws_iam_access_key.buildingblock_route53_alias_record_access_key[0].secret : "N/A; workload identity federation in use" - } -} - output "workload_identity_federation_role" { description = "Workload identity federation role ARN" # Manually construct ARN to avoid dependency cycle on input workload_identity_federation (which contains the BBD UUID as subject) diff --git a/modules/aws/route53-dns-alias-record/backplane/variables.tf b/modules/aws/route53-dns-alias-record/backplane/variables.tf index c80d1c47..3c62cb46 100644 --- a/modules/aws/route53-dns-alias-record/backplane/variables.tf +++ b/modules/aws/route53-dns-alias-record/backplane/variables.tf @@ -9,8 +9,8 @@ variable "workload_identity_federation" { audience = string, subjects = list(string) }) - default = null - description = "Set these options to add a trusted identity provider from meshStack to allow workload identity federation for authentication which can be used instead of access keys. Supports multiple subjects and wildcard patterns (e.g., 'system:serviceaccount:namespace:*')." + nullable = false + description = "Trusted identity provider from meshStack that the building block runner federates into. Supports multiple subjects and wildcard patterns (e.g., 'system:serviceaccount:namespace:*')." } variable "create_oidc_provider" { From 9ad376aad2dc60394ccd0a05ecb0892889f9202b Mon Sep 17 00:00:00 2001 From: Johannes Rudolph Date: Mon, 24 Aug 2026 21:49:39 +0200 Subject: [PATCH 3/7] refactor(aws/s3_bucket): drop the IAM access key path, WIF only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same conversion as the two route53 backplanes, plus two conventions this module never had. The backplane offered two credential paths: workload identity federation, or an IAM user with an `aws_iam_access_key` when `workload_identity_federation` was left null — a `count` on four resources, a policy name branching on the same condition, and a `credentials` output. The integration always passes a non-null `workload_identity_federation`, so the key path was dead code. `.agents/references/aws-backplane.md` forbids it for a single-account building block, and `aws_wif_no_access_key` / `aws_wif_nonnullable` now enforce that. Two more scorecard checks were failing here but not on the route53 modules: - **`create_oidc_provider`** was missing, so the backplane always created its own OIDC provider. AWS allows one provider per issuer URL per account, so a second backplane in the same account failed its apply with `EntityAlreadyExists`. The variable and the `data` lookup are now present, matching the route53 modules, and the integration passes it through. - **The role ARN output** was named `workload_identity_federation_role_arn`; the convention is `workload_identity_federation_role`. Renamed, with the BBD's `AWS_ROLE_ARN` input updated. `workload_identity_federation` becomes `nullable = false`, the policy keeps its federated-path name (`S3BuildingBlockFederatedPolicy-*`), and `moved` blocks carry the role and its policy attachment across the removed `count` so a federated deployment does not recreate its live IAM role. Not changed: this integration still takes a hand-supplied `variable "workload_identity"` instead of reading `data.meshstack_integrations` the way the route53 modules and the reference's wiring example do. That is a separate input-contract change for whoever is next in the module — no scorecard check covers it. Module scores 100% on the AWS Backplane category. `tofu validate` passes on the backplane and on the integration root wired against it. Co-Authored-By: Claude Opus 5 --- modules/aws/s3_bucket/backplane/README.md | 54 +++++++++++----- modules/aws/s3_bucket/backplane/iam.tf | 63 +++++++++---------- modules/aws/s3_bucket/backplane/outputs.tf | 11 +--- modules/aws/s3_bucket/backplane/variables.tf | 10 ++- .../aws/s3_bucket/meshstack_integration.tf | 10 ++- 5 files changed, 88 insertions(+), 60 deletions(-) diff --git a/modules/aws/s3_bucket/backplane/README.md b/modules/aws/s3_bucket/backplane/README.md index badd0435..84100e19 100644 --- a/modules/aws/s3_bucket/backplane/README.md +++ b/modules/aws/s3_bucket/backplane/README.md @@ -1,19 +1,38 @@ --- name: AWS S3 Buildingblock Backplane summary: | - Deploys an IAM user with full S3 access + Deploys the federated IAM role the S3 building block assumes, with full S3 access # optional: add additional metadata about implemented security controls --- # AWS S3 Buildingblock Backplane -This will deploy an IAM user (or role only in case of using `workload_identity_federation`) with full S3 access (`s3:*`) +This deploys the IAM role that the S3 building block assumes, with full S3 access (`s3:*`). + +## Authentication + +The building block authenticates by **workload identity federation** — the only credential path this +backplane offers, so `workload_identity_federation` is required. The backplane registers the +meshStack issuer as an OIDC provider, creates an IAM role whose trust policy accepts only the +subjects of this building block definition, and exports the role ARN as +`workload_identity_federation_role`. No long-lived credential exists anywhere in the module. + +AWS allows **one OIDC provider per issuer URL per account**. A second backplane in the same account +must therefore set `create_oidc_provider = false` and reuse the existing one — otherwise its apply +fails with `EntityAlreadyExists`. + +## Required permissions + +The platform engineer or CI principal applying this module needs `iam:*` on the OIDC provider, the +role and the policy it manages (`CreateOpenIDConnectProvider`, `CreateRole`, `CreatePolicy`, +`AttachRolePolicy` and their `Get`/`Delete` counterparts). `arn:aws:iam::aws:policy/IAMFullAccess` +covers it. ## Usage ```hcl provider "aws" { - region = "your-region" # e.g. eu-central-1 + region = "eu-central-1" # or any other region } module "aws_s3_bucket_backplane" { @@ -23,17 +42,24 @@ module "aws_s3_bucket_backplane" { issuer = "https://your-oidc-issuer" audience = "your-audience" subjects = [ - "system:serviceaccount:your-namespace:your-service-account-name", # Exact match - "system:serviceaccount:your-namespace:*", # Wildcard match + "system:serviceaccount:your-namespace:your-service-account-name", # Exact match + "system:serviceaccount:your-namespace:*", # Wildcard match ] - } # Optional, if not provided, IAM access keys will be created instead -} + } -output "aws_s3_bucket_backplane" { - value = module.aws_s3_bucket_backplane + # Set to false when another backplane already created the meshStack OIDC provider in this account. + create_oidc_provider = true } ``` +## Migrating from the access key path + +Earlier revisions created an IAM user and an `aws_iam_access_key` when `workload_identity_federation` +was left null. That path is gone. A deployment still on it must pass `workload_identity_federation`, +and the next apply destroys the IAM user and revokes its key — that is the intended migration. A +deployment already on the federated path is unaffected: `moved` blocks carry its role and policy +attachment across the removed `count`. + ## Requirements @@ -50,15 +76,13 @@ No modules. | Name | Type | |------|------| -| [aws_iam_access_key.buildingblock_s3_access_key](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_access_key) | resource | | [aws_iam_openid_connect_provider.buildingblock_oidc_provider](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_openid_connect_provider) | resource | | [aws_iam_policy.buildingblock_s3_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_policy) | resource | | [aws_iam_role.assume_federated_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | | [aws_iam_role_policy_attachment.buildingblock_s3](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | -| [aws_iam_user.buildingblock_s3_user](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_user) | resource | -| [aws_iam_user_policy_attachment.buildingblock_s3_user_policy_attachment](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_user_policy_attachment) | resource | | [random_string.name_suffix](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/string) | resource | | [aws_caller_identity.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/caller_identity) | data source | +| [aws_iam_openid_connect_provider.buildingblock_oidc_provider](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_openid_connect_provider) | data source | | [aws_iam_policy_document.s3_full_access](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | | [aws_iam_policy_document.workload_identity_federation](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | @@ -66,12 +90,12 @@ No modules. | Name | Description | Type | Default | Required | |------|-------------|------|---------|:--------:| -| [workload\_identity\_federation](#input\_workload\_identity\_federation) | Set these options to add a trusted identity provider from meshStack to allow workload identity federation for authentication, which can be used instead of access keys.
Supports multiple subjects and wildcard patterns (e.g., 'system:serviceaccount:namespace:*'). |
object({
issuer = string,
audience = string,
subjects = list(string)
})
| `null` | no | +| [create\_oidc\_provider](#input\_create\_oidc\_provider) | Set to false if the OIDC provider for the meshStack issuer already exists in this AWS account (e.g., created by another backplane). The existing provider will be looked up by URL instead of created. | `bool` | `true` | no | +| [workload\_identity\_federation](#input\_workload\_identity\_federation) | Trusted identity provider from meshStack that the building block runner federates into.
Supports multiple subjects and wildcard patterns (e.g., 'system:serviceaccount:namespace:*'). |
object({
issuer = string,
audience = string,
subjects = list(string)
})
| n/a | yes | ## Outputs | Name | Description | |------|-------------| -| [credentials](#output\_credentials) | Access credentials for the S3 bucket, only available if workload\_identity\_federation variable is null. | -| [workload\_identity\_federation\_role\_arn](#output\_workload\_identity\_federation\_role\_arn) | Workload identity federation role ARN | +| [workload\_identity\_federation\_role](#output\_workload\_identity\_federation\_role) | Workload identity federation role ARN | diff --git a/modules/aws/s3_bucket/backplane/iam.tf b/modules/aws/s3_bucket/backplane/iam.tf index f17ceef1..170ae257 100644 --- a/modules/aws/s3_bucket/backplane/iam.tf +++ b/modules/aws/s3_bucket/backplane/iam.tf @@ -5,11 +5,6 @@ resource "random_string" "name_suffix" { special = false } -resource "aws_iam_user" "buildingblock_s3_user" { - count = var.workload_identity_federation == null ? 1 : 0 - name = "buildingblock-s3-user-${random_string.name_suffix.result}" -} - data "aws_iam_policy_document" "s3_full_access" { statement { actions = [ @@ -23,42 +18,42 @@ data "aws_iam_policy_document" "s3_full_access" { } resource "aws_iam_policy" "buildingblock_s3_policy" { - name = var.workload_identity_federation == null ? "S3BuildingBlockPolicy-${random_string.name_suffix.result}" : "S3BuildingBlockFederatedPolicy-${random_string.name_suffix.result}" + name = "S3BuildingBlockFederatedPolicy-${random_string.name_suffix.result}" description = "Policy for the S3 Building Block" policy = data.aws_iam_policy_document.s3_full_access.json } -resource "aws_iam_user_policy_attachment" "buildingblock_s3_user_policy_attachment" { - count = var.workload_identity_federation == null ? 1 : 0 - - user = aws_iam_user.buildingblock_s3_user[0].name - policy_arn = aws_iam_policy.buildingblock_s3_policy.arn -} - -resource "aws_iam_access_key" "buildingblock_s3_access_key" { - count = var.workload_identity_federation == null ? 1 : 0 - - user = aws_iam_user.buildingblock_s3_user[0].name -} - # Workload Identity Federation resource "aws_iam_openid_connect_provider" "buildingblock_oidc_provider" { - count = var.workload_identity_federation != null ? 1 : 0 + count = var.create_oidc_provider ? 1 : 0 url = var.workload_identity_federation.issuer client_id_list = [var.workload_identity_federation.audience] } +data "aws_iam_openid_connect_provider" "buildingblock_oidc_provider" { + count = var.create_oidc_provider ? 0 : 1 + + url = var.workload_identity_federation.issuer +} + +locals { + assume_federated_role_name = "BuildingBlockS3IdentityFederation-${random_string.name_suffix.result}" + oidc_provider_arn = try( + aws_iam_openid_connect_provider.buildingblock_oidc_provider[0].arn, + data.aws_iam_openid_connect_provider.buildingblock_oidc_provider[0].arn + ) +} + data "aws_iam_policy_document" "workload_identity_federation" { - count = var.workload_identity_federation != null ? 1 : 0 version = "2012-10-17" statement { effect = "Allow" principals { type = "Federated" - identifiers = [aws_iam_openid_connect_provider.buildingblock_oidc_provider[0].arn] + identifiers = [local.oidc_provider_arn] } actions = ["sts:AssumeRoleWithWebIdentity"] @@ -78,20 +73,24 @@ data "aws_iam_policy_document" "workload_identity_federation" { } } -locals { - assume_federated_role_name = "BuildingBlockS3IdentityFederation-${random_string.name_suffix.result}" -} - resource "aws_iam_role" "assume_federated_role" { - count = var.workload_identity_federation != null ? 1 : 0 - name = local.assume_federated_role_name - assume_role_policy = data.aws_iam_policy_document.workload_identity_federation[0].json + assume_role_policy = data.aws_iam_policy_document.workload_identity_federation.json } resource "aws_iam_role_policy_attachment" "buildingblock_s3" { - count = var.workload_identity_federation != null ? 1 : 0 - - role = aws_iam_role.assume_federated_role[0].name + role = aws_iam_role.assume_federated_role.name policy_arn = aws_iam_policy.buildingblock_s3_policy.arn } + +# Both were count-guarded while the backplane still offered an IAM access key fallback. Without these +# a deployment that is already on the federated path would destroy and recreate its live IAM role. +moved { + from = aws_iam_role.assume_federated_role[0] + to = aws_iam_role.assume_federated_role +} + +moved { + from = aws_iam_role_policy_attachment.buildingblock_s3[0] + to = aws_iam_role_policy_attachment.buildingblock_s3 +} diff --git a/modules/aws/s3_bucket/backplane/outputs.tf b/modules/aws/s3_bucket/backplane/outputs.tf index 2db27762..85546a42 100644 --- a/modules/aws/s3_bucket/backplane/outputs.tf +++ b/modules/aws/s3_bucket/backplane/outputs.tf @@ -1,13 +1,4 @@ -output "credentials" { - sensitive = true - description = "Access credentials for the S3 bucket, only available if workload_identity_federation variable is null." - value = try({ - AWS_ACCESS_KEY_ID = aws_iam_access_key.buildingblock_s3_access_key[0].id - AWS_SECRET_ACCESS_KEY = aws_iam_access_key.buildingblock_s3_access_key[0].secret - }, null) -} - -output "workload_identity_federation_role_arn" { +output "workload_identity_federation_role" { description = "Workload identity federation role ARN" # Manually construct ARN to avoid dependency cycle on input workload_identity_federation (which contains the BBD UUID as subject) value = "arn:aws:iam::${data.aws_caller_identity.current.account_id}:role/${local.assume_federated_role_name}" diff --git a/modules/aws/s3_bucket/backplane/variables.tf b/modules/aws/s3_bucket/backplane/variables.tf index 770d0abf..a113e0ed 100644 --- a/modules/aws/s3_bucket/backplane/variables.tf +++ b/modules/aws/s3_bucket/backplane/variables.tf @@ -4,9 +4,15 @@ variable "workload_identity_federation" { audience = string, subjects = list(string) }) - default = null + nullable = false description = <<-EOT - Set these options to add a trusted identity provider from meshStack to allow workload identity federation for authentication, which can be used instead of access keys. + Trusted identity provider from meshStack that the building block runner federates into. Supports multiple subjects and wildcard patterns (e.g., 'system:serviceaccount:namespace:*'). EOT } + +variable "create_oidc_provider" { + type = bool + default = true + description = "Set to false if the OIDC provider for the meshStack issuer already exists in this AWS account (e.g., created by another backplane). The existing provider will be looked up by URL instead of created." +} diff --git a/modules/aws/s3_bucket/meshstack_integration.tf b/modules/aws/s3_bucket/meshstack_integration.tf index acc3c16e..14df6345 100644 --- a/modules/aws/s3_bucket/meshstack_integration.tf +++ b/modules/aws/s3_bucket/meshstack_integration.tf @@ -12,6 +12,12 @@ variable "workload_identity" { description = "Workload identity federation configuration for AWS authentication." } +variable "create_oidc_provider" { + type = bool + default = true + description = "Set to false if the OIDC provider for the meshStack issuer already exists in this AWS account (e.g., created by another backplane). The existing provider will be looked up by URL instead of created." +} + variable "meshstack" { type = object({ owning_workspace_identifier = string @@ -47,6 +53,8 @@ output "building_block_definition" { module "backplane" { source = "github.com/meshcloud/meshstack-hub//modules/aws/s3_bucket/backplane?ref=${var.hub.git_ref}" + create_oidc_provider = var.create_oidc_provider + workload_identity_federation = { issuer = var.workload_identity.issuer audience = var.workload_identity.audience @@ -121,7 +129,7 @@ resource "meshstack_building_block_definition" "this" { description = "The ARN of the AWS role to assume for provisioning the S3 bucket" assignment_type = "STATIC" is_environment = true - argument = jsonencode(module.backplane.workload_identity_federation_role_arn) + argument = jsonencode(module.backplane.workload_identity_federation_role) } AWS_WEB_IDENTITY_TOKEN_FILE = { type = "STRING" From 7382fc1ff3e6978368ad24ebc51d4ecdafa9a659 Mon Sep 17 00:00:00 2001 From: Johannes Rudolph Date: Mon, 24 Aug 2026 22:09:23 +0200 Subject: [PATCH 4/7] test(aws/s3_bucket): add the hub e2e test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First e2e test for any AWS hub module. Build-from-source mode provisions an ephemeral backplane in the smoke-test AWS account, builds the BBD from this branch, orders a workspace-level building block, and asserts on its outputs — the bucket ARN in particular, since that is what proves the bucket was created by the federated backplane role rather than the name being echoed back. Modelled on `gcp/storage-bucket/e2e`, with two AWS-specific details: - `create_oidc_provider` comes from the fixture and is false. AWS permits one OIDC provider per issuer URL per account, so the meshStack issuer's provider is a long-lived fixture in the smoke-test account rather than something each backplane creates and destroys — otherwise the second AWS case in the parallel matrix fails with EntityAlreadyExists and the first to finish deletes the provider the other is still using. - The integration takes `workload_identity` as a hand-supplied object rather than reading `data.meshstack_integrations` itself (unlike the route53 modules), so the e2e module reads the data source and derives the subject namespace prefix from the replicator's own subject. `provider "aws"` pins `allowed_account_ids` to the fixture account, so a wrong local session errors instead of creating IAM roles in someone else's account. Requires the AWS fixture in the smoke-test harness, which does not exist yet — the test is discovered by CI but cannot pass until that lands and is applied. Co-Authored-By: Claude Opus 5 --- modules/aws/s3_bucket/e2e/main.tf | 97 +++++++++++++++++++ modules/aws/s3_bucket/e2e/terraform.tf | 13 +++ .../e2e/tests/aws_s3_bucket_hub.tftest.hcl | 28 ++++++ 3 files changed, 138 insertions(+) create mode 100644 modules/aws/s3_bucket/e2e/main.tf create mode 100644 modules/aws/s3_bucket/e2e/terraform.tf create mode 100644 modules/aws/s3_bucket/e2e/tests/aws_s3_bucket_hub.tftest.hcl diff --git a/modules/aws/s3_bucket/e2e/main.tf b/modules/aws/s3_bucket/e2e/main.tf new file mode 100644 index 00000000..671266fe --- /dev/null +++ b/modules/aws/s3_bucket/e2e/main.tf @@ -0,0 +1,97 @@ +variable "test_context" { + type = object({ + workspace = string + name_suffix = string + hub_git_ref = string + + # Set to order an already-deployed BBD version; null to build the BBD from hub source. + bbd_version_ref = optional(object({ + uuid = string + })) + + # Only needed to provision the backplane. This building block is workspace-level, so its + # target_ref needs no tenant id. + fixtures = optional(object({ + aws = object({ + account_id = string + region = string + + # False in the smoke-test account: the meshStack issuer's OIDC provider is a long-lived + # fixture there, because AWS permits one per issuer URL per account and several AWS e2e + # cases run in parallel. + create_oidc_provider = bool + }) + })) + }) + nullable = false +} + +# Credentials come from the environment: the CI role in the smoke-test account, or the developer's +# own session locally. allowed_account_ids turns a wrong session into an error instead of an IAM +# role in someone else's account. +provider "aws" { + region = var.test_context.fixtures != null ? var.test_context.fixtures.aws.region : null + allowed_account_ids = var.test_context.fixtures != null ? [var.test_context.fixtures.aws.account_id] : null +} + +data "meshstack_integrations" "this" {} + +locals { + replicator = data.meshstack_integrations.this.workload_identity_federation.replicator + + # The integration composes subjects as `system:serviceaccount::workspace.…`, so strip the + # replicator's own subject down to the namespace part it shares with every building block run. + subject_namespace_prefix = trimsuffix(trimprefix(local.replicator.subject, "system:serviceaccount:"), ":replicator") +} + +module "aws_s3_bucket" { + count = var.test_context.bbd_version_ref == null ? 1 : 0 + source = "../" + + meshstack = { + owning_workspace_identifier = var.test_context.workspace + tags = {} + } + hub = { + git_ref = var.test_context.hub_git_ref + bbd_draft = true + } + + aws_region = var.test_context.fixtures.aws.region + create_oidc_provider = var.test_context.fixtures.aws.create_oidc_provider + + workload_identity = { + issuer = local.replicator.issuer + audience = local.replicator.aws.audience + subject_namespace_prefix = local.subject_namespace_prefix + } +} + +locals { + version_ref = var.test_context.bbd_version_ref != null ? var.test_context.bbd_version_ref : module.aws_s3_bucket[0].building_block_definition.version_ref + + # S3 bucket names are globally unique across all of AWS, so the suffix is what keeps concurrent + # and repeated runs from colliding. + bucket_name = "smoke-test-aws-bucket-${var.test_context.name_suffix}" +} + +resource "meshstack_building_block" "this" { + # Nothing references the backplane role, so without this OpenTofu destroys it in parallel with the + # delete run and the delete run can no longer authenticate against AWS. + depends_on = [module.aws_s3_bucket] + wait_for_completion = true + + spec = { + building_block_definition_version_ref = { uuid = local.version_ref.uuid } + + display_name = "smoke-test-aws-s3-bucket-${var.test_context.name_suffix}" + target_ref = { + kind = "meshWorkspace" + name = var.test_context.workspace + } + + inputs = { + bucket_name = { value = jsonencode(local.bucket_name) } + } + } +} diff --git a/modules/aws/s3_bucket/e2e/terraform.tf b/modules/aws/s3_bucket/e2e/terraform.tf new file mode 100644 index 00000000..d220af25 --- /dev/null +++ b/modules/aws/s3_bucket/e2e/terraform.tf @@ -0,0 +1,13 @@ +terraform { + required_version = ">= 1.0" + + required_providers { + meshstack = { + source = "meshcloud/meshstack" + } + aws = { + source = "hashicorp/aws" + version = ">= 6.0" + } + } +} diff --git a/modules/aws/s3_bucket/e2e/tests/aws_s3_bucket_hub.tftest.hcl b/modules/aws/s3_bucket/e2e/tests/aws_s3_bucket_hub.tftest.hcl new file mode 100644 index 00000000..64071a3e --- /dev/null +++ b/modules/aws/s3_bucket/e2e/tests/aws_s3_bucket_hub.tftest.hcl @@ -0,0 +1,28 @@ +run "building_block_aws_s3_bucket_hub" { + assert { + condition = meshstack_building_block.this.status.status == "SUCCEEDED" + error_message = "aws s3_bucket hub building block expected SUCCEEDED, got ${meshstack_building_block.this.status.status}" + } + + assert { + condition = jsondecode(meshstack_building_block.this.status.outputs["bucket_name"].value) == "smoke-test-aws-bucket-${var.test_context.name_suffix}" + error_message = "aws s3_bucket hub building block expected bucket_name to be 'smoke-test-aws-bucket-${var.test_context.name_suffix}', got ${jsondecode(meshstack_building_block.this.status.outputs["bucket_name"].value)}" + } + + # The ARN is what proves the bucket was created by the federated backplane role rather than the + # bucket name simply being echoed back. + assert { + condition = jsondecode(meshstack_building_block.this.status.outputs["bucket_arn"].value) == "arn:aws:s3:::smoke-test-aws-bucket-${var.test_context.name_suffix}" + error_message = "aws s3_bucket hub building block expected bucket_arn to be 'arn:aws:s3:::smoke-test-aws-bucket-${var.test_context.name_suffix}', got ${jsondecode(meshstack_building_block.this.status.outputs["bucket_arn"].value)}" + } + + assert { + condition = jsondecode(meshstack_building_block.this.status.outputs["bucket_uri"].value) == "s3://smoke-test-aws-bucket-${var.test_context.name_suffix}" + error_message = "aws s3_bucket hub building block expected bucket_uri to be the s3:// URI, got ${jsondecode(meshstack_building_block.this.status.outputs["bucket_uri"].value)}" + } + + assert { + condition = strcontains(jsondecode(meshstack_building_block.this.status.outputs["bucket_regional_domain_name"].value), var.test_context.fixtures.aws.region) + error_message = "aws s3_bucket hub building block expected bucket_regional_domain_name to name the fixture region, got ${jsondecode(meshstack_building_block.this.status.outputs["bucket_regional_domain_name"].value)}" + } +} From 440067a8284d52c8957774752b62a73e799c6883 Mon Sep 17 00:00:00 2001 From: Johannes Rudolph Date: Tue, 25 Aug 2026 12:22:23 +0200 Subject: [PATCH 5/7] feat(aws/oidc-provider): add the shared meshStack OIDC provider module MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AWS registers one OIDC provider per issuer URL per account, so the meshStack runner's issuer can be registered only once in an account regardless of how many building block backplanes federate through it. Today each AWS backplane creates its own and offers a `create_oidc_provider` toggle to opt out, which makes the first backplane deployed the de-facto owner of shared infrastructure: the second one fails with EntityAlreadyExists unless someone remembers the toggle, and destroying the owner breaks every other backplane in the account. This module is that provider, on its own, applied once per AWS account. It takes no inputs — issuer, audience and thumbprint come from `data.meshstack_integrations` — so the only thing a platform team supplies is the two providers. Co-Authored-By: Claude Opus 5 --- modules/aws/oidc-provider/README.md | 45 +++++++++++++++++++++++++++ modules/aws/oidc-provider/main.tf | 25 +++++++++++++++ modules/aws/oidc-provider/outputs.tf | 4 +++ modules/aws/oidc-provider/versions.tf | 14 +++++++++ 4 files changed, 88 insertions(+) create mode 100644 modules/aws/oidc-provider/README.md create mode 100644 modules/aws/oidc-provider/main.tf create mode 100644 modules/aws/oidc-provider/outputs.tf create mode 100644 modules/aws/oidc-provider/versions.tf diff --git a/modules/aws/oidc-provider/README.md b/modules/aws/oidc-provider/README.md new file mode 100644 index 00000000..1565e90a --- /dev/null +++ b/modules/aws/oidc-provider/README.md @@ -0,0 +1,45 @@ +# meshStack OIDC Provider (shared) + +Registers the meshStack building block runner's token issuer as an IAM OIDC provider so that AWS +backplanes in this account can trust it. + +**Apply this once per AWS account that hosts building block backplanes.** AWS registers one OIDC +provider per issuer URL per account, so this cannot belong to an individual backplane: the second +backplane to try would fail with `EntityAlreadyExists`, and destroying whichever owned it would +break every other backplane in the account. Pass the `arn` output to each backplane's +`oidc_provider_arn` input. + +See [the shared OIDC provider](../../../.agents/references/aws-backplane.md#the-shared-oidc-provider) +for the full rationale and for how to migrate an account whose provider is still owned by a +backplane's state. + +## Usage + +```hcl +provider "aws" { + region = "eu-central-1" +} + +provider "meshstack" {} + +module "meshstack_oidc_provider" { + source = "github.com/meshcloud/meshstack-hub//modules/aws/oidc-provider?ref=main" +} + +# Then, per building block definition: +module "s3_bucket" { + source = "github.com/meshcloud/meshstack-hub//modules/aws/s3_bucket?ref=main" + + aws_oidc_provider_arn = module.meshstack_oidc_provider.arn + # ... +} +``` + +The issuer, audience and thumbprint are read from `data.meshstack_integrations`, so this module +takes no inputs — it needs an `aws` provider pointed at the account and a configured `meshstack` +provider. + +## Required permissions + +The identity applying this needs `iam:CreateOpenIDConnectProvider`, `iam:GetOpenIDConnectProvider` +and `iam:TagOpenIDConnectProvider` in the target account. diff --git a/modules/aws/oidc-provider/main.tf b/modules/aws/oidc-provider/main.tf new file mode 100644 index 00000000..62cde6bf --- /dev/null +++ b/modules/aws/oidc-provider/main.tf @@ -0,0 +1,25 @@ +# AWS registers an OIDC provider per issuer URL per AWS account, so the meshStack runner's issuer +# can only be registered once in an account no matter how many building block backplanes federate +# through it. That makes it platform infrastructure rather than something a backplane owns — see +# .agents/references/aws-backplane.md#the-shared-oidc-provider. +# +# Apply this once per AWS account that hosts building block backplanes and pass its `arn` output to +# every backplane in that account. + +data "meshstack_integrations" "this" {} + +locals { + # The replicator's entry is what Terraform can read. The authority for what a building block run + # presents is the runner's own registration, and the two agree as long as the runner shares the + # replicator's cluster and namespace — the assumption every hub module already makes. + replicator = data.meshstack_integrations.this.workload_identity_federation.replicator +} + +resource "aws_iam_openid_connect_provider" "meshstack" { + url = local.replicator.issuer + client_id_list = [local.replicator.aws.audience] + + # This issuer is not in the AWS trust store, unlike the well-known providers, so the thumbprint + # is required. + thumbprint_list = [local.replicator.aws.thumbprint] +} diff --git a/modules/aws/oidc-provider/outputs.tf b/modules/aws/oidc-provider/outputs.tf new file mode 100644 index 00000000..4d252aa9 --- /dev/null +++ b/modules/aws/oidc-provider/outputs.tf @@ -0,0 +1,4 @@ +output "arn" { + description = "ARN of the IAM OIDC provider. Pass this to every AWS backplane in this account as `oidc_provider_arn`." + value = aws_iam_openid_connect_provider.meshstack.arn +} diff --git a/modules/aws/oidc-provider/versions.tf b/modules/aws/oidc-provider/versions.tf new file mode 100644 index 00000000..8b0c8c56 --- /dev/null +++ b/modules/aws/oidc-provider/versions.tf @@ -0,0 +1,14 @@ +terraform { + required_version = ">= 1.12.0" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.0" + } + meshstack = { + source = "meshcloud/meshstack" + version = ">= 0.20.0" + } + } +} From adebfea195d34fd078adbd609721e44fe4c530d3 Mon Sep 17 00:00:00 2001 From: Johannes Rudolph Date: Tue, 25 Aug 2026 12:24:30 +0200 Subject: [PATCH 6/7] feat(scorecard): require AWS WIF backplanes to take an external OIDC provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the two checks that assumed a backplane owns its OIDC provider: - `aws_wif_oidc_provider` asserted the backplane *creates or reads* an `aws_iam_openid_connect_provider`. Now `aws_wif_external_oidc_provider` asserts the opposite — no provider resource, and a required non-nullable `oidc_provider_arn` input instead. - `aws_wif_create_oidc_provider` required the opt-out toggle. The toggle is gone with the resource, so a leftover `create_oidc_provider` variable is now itself a failure. Plus `aws_oidc_provider_notice`, which lints the fixed two-line description on both `oidc_provider_arn` (backplane) and `aws_oidc_provider_arn` (integration). That notice is the only signpost a first-time platform engineer gets: AWS has no plural OIDC-provider data source, so a missing provider cannot be turned into a friendly precondition, and terraform-docs renders backplane variables but not integration comments. Linting it keeps the copy-paste honest. `.agents/references/aws-backplane.md` gains 'The shared OIDC provider' as the canonical section both checks point at: why an account-level singleton cannot belong to a building block, the one-line module that owns it, why Azure and GCP genuinely differ, and the `removed`-block migration for an account whose provider still sits in a backplane's state. The three WIF modules now fail both checks — the next commit converts them. Co-Authored-By: Claude Opus 5 --- .agents/references/aws-backplane.md | 99 ++++++++++++++++++++--------- tools/scorecard/scorecard.mjs | 77 ++++++++++++++++------ 2 files changed, 127 insertions(+), 49 deletions(-) diff --git a/.agents/references/aws-backplane.md b/.agents/references/aws-backplane.md index a4a70008..dca201a1 100644 --- a/.agents/references/aws-backplane.md +++ b/.agents/references/aws-backplane.md @@ -17,7 +17,52 @@ Use WIF when the building block acts within a single AWS account (the backplane - **OIDC-native**: AWS supports federated OIDC identities via `aws_iam_openid_connect_provider` out of the box. - **Shared OIDC provider**: Multiple backplanes can share a single OIDC provider in the same AWS account using `create_oidc_provider = false`. - + +### The shared OIDC provider + +**A WIF backplane does not create its OIDC provider. It takes the ARN of one as an input.** + +AWS registers one OIDC provider per issuer URL per AWS account. The meshStack runner has one issuer, +so an account has room for exactly one provider for it no matter how many building block backplanes +federate through it. A backplane that creates its own is claiming shared infrastructure: the second +backplane in the account fails with `EntityAlreadyExists`, and destroying whichever one owns it +breaks every other backplane there. + +So it is deployed separately, once per AWS account that hosts backplanes: + +```hcl +module "meshstack_oidc_provider" { + source = "github.com/meshcloud/meshstack-hub//modules/aws/oidc-provider?ref=main" +} +``` + +`modules/aws/oidc-provider` takes no inputs — it reads the issuer, audience and thumbprint from +`data.meshstack_integrations`. Pass its `arn` output to every backplane in that account. + +This is where AWS differs from the other providers, and why the repetition is not the same kind of +repetition: an Azure federated identity credential is a child of its UAMI and a GCP workload +identity pool is a named per-project resource, so each module can own its own and none of them can +collide. Only AWS has an account-level singleton keyed by the issuer URL. + +#### Migrating an account whose provider lives in a backplane's state + +No destroy is needed. Ship a `removed` block in the backplane so the next apply forgets the +provider instead of deleting it: + +```hcl +removed { + from = aws_iam_openid_connect_provider.buildingblock_oidc_provider + + lifecycle { + destroy = false + } +} +``` + +Then `import` the provider into the root that applies `modules/aws/oidc-provider`, and pass its ARN +to the backplanes that used to create it. + + ### Implementation Pattern (WIF) ```hcl @@ -31,25 +76,6 @@ resource "random_string" "suffix" { upper = false } -resource "aws_iam_openid_connect_provider" "backplane" { - count = var.create_oidc_provider ? 1 : 0 - - url = var.workload_identity_federation.issuer - client_id_list = [var.workload_identity_federation.audience] -} - -data "aws_iam_openid_connect_provider" "backplane" { - count = var.create_oidc_provider ? 0 : 1 - url = var.workload_identity_federation.issuer -} - -locals { - oidc_provider_arn = try( - aws_iam_openid_connect_provider.backplane[0].arn, - data.aws_iam_openid_connect_provider.backplane[0].arn - ) -} - data "aws_iam_policy_document" "workload_identity_federation" { version = "2012-10-17" @@ -57,7 +83,7 @@ data "aws_iam_policy_document" "workload_identity_federation" { effect = "Allow" principals { type = "Federated" - identifiers = [local.oidc_provider_arn] + identifiers = [var.oidc_provider_arn] } actions = ["sts:AssumeRoleWithWebIdentity"] @@ -83,7 +109,7 @@ resource "aws_iam_role" "backplane" { # Attach a service-specific policy to aws_iam_role.backplane ``` - + ### Backplane Variables (WIF) ```hcl @@ -97,13 +123,19 @@ variable "workload_identity_federation" { description = "WIF issuer, audience, and subjects for federated authentication." } -variable "create_oidc_provider" { - type = bool - default = true - description = "Set to false if the OIDC provider for the meshStack issuer already exists in this AWS account (e.g., created by another backplane). The existing provider will be looked up by URL instead of created." +variable "oidc_provider_arn" { + type = string + nullable = false + description = <<-EOT + ARN of the IAM OIDC provider for the meshStack runner WIF token issuer in this AWS account. + See .agents/references/aws-backplane.md#the-shared-oidc-provider + EOT } ``` +The `oidc_provider_arn` description is a fixed notice, copied verbatim into the matching +`aws_oidc_provider_arn` variable in `meshstack_integration.tf`. The scorecard enforces both. + ### Backplane Outputs (WIF) @@ -307,10 +339,19 @@ the next time you are in it. ### WIF pattern ```hcl +variable "aws_oidc_provider_arn" { + type = string + nullable = false + description = <<-EOT + ARN of the IAM OIDC provider for the meshStack runner WIF token issuer in this AWS account. + See .agents/references/aws-backplane.md#the-shared-oidc-provider + EOT +} + module "backplane" { source = "github.com/meshcloud/meshstack-hub//modules/aws//backplane?ref=${var.hub.git_ref}" - create_oidc_provider = var.create_oidc_provider + oidc_provider_arn = var.aws_oidc_provider_arn workload_identity_federation = { issuer = data.meshstack_integrations.integrations.workload_identity_federation.replicator.issuer @@ -365,8 +406,8 @@ role_name = { ## Checklist for AWS Backplanes **WIF pattern (Pattern A):** -- [ ] Uses `aws_iam_openid_connect_provider` (not a hardcoded ARN) -- [ ] `create_oidc_provider` variable present to allow sharing across backplanes +- [ ] Creates no `aws_iam_openid_connect_provider` — takes `oidc_provider_arn` as a required, non-nullable input +- [ ] `oidc_provider_arn` and the integration's `aws_oidc_provider_arn` carry the fixed notice verbatim - [ ] `workload_identity_federation` variable is non-nullable - [ ] Trust policy scopes `sub` condition to the specific BBD UUID via meshStack WIF subjects - [ ] Role ARN output is named `workload_identity_federation_role` diff --git a/tools/scorecard/scorecard.mjs b/tools/scorecard/scorecard.mjs index 6c04cc03..2038690f 100755 --- a/tools/scorecard/scorecard.mjs +++ b/tools/scorecard/scorecard.mjs @@ -99,11 +99,23 @@ function awsBackplanePattern(mod) { if (!allTf) return "none"; const federated = /(resource|data)\s+"aws_iam_openid_connect_provider"/.test(allTf) || - /^variable\s+"workload_identity_federation"/m.test(allTf); + /^variable\s+"(workload_identity_federation|oidc_provider_arn)"/m.test(allTf); if (federated) return "wif"; return /resource\s+"aws_iam_access_key"/.test(allTf) ? "cross_account" : "none"; } +// The fixed notice on `oidc_provider_arn` / `aws_oidc_provider_arn`. Deliberately two lines and +// nothing more, so it can be copied verbatim into every meshstack_integration.tf. +const AWS_OIDC_PROVIDER_NOTICE = [ + "ARN of the IAM OIDC provider for the meshStack runner WIF token issuer in this AWS account.", + "See .agents/references/aws-backplane.md#the-shared-oidc-provider", +]; + +function hasOidcProviderNotice(variableBlock) { + const text = variableBlock.replace(/\s+/g, " "); + return AWS_OIDC_PROVIDER_NOTICE.every((line) => text.includes(line.replace(/\s+/g, " "))); +} + const NOT_WIF = { pass: null, detail: "not a workload identity federation backplane" }; const NOT_CROSS_ACCOUNT = { pass: null, detail: "not a cross-account backplane" }; @@ -468,16 +480,56 @@ const detectors = [ // implements before it can judge it. Pattern B mints an `aws_iam_access_key` on purpose, so a // blanket "no access key" check would be wrong there — it only applies on the federation path. { - id: "aws_wif_oidc_provider", + id: "aws_wif_external_oidc_provider", category: "aws_backplane", - name: "Federates via aws_iam_openid_connect_provider", + name: "Takes oidc_provider_arn instead of creating a provider", emoji: "🔐", fn: (mod) => { if (awsBackplanePattern(mod) !== "wif") return NOT_WIF; const allTf = readAllBackplaneTf(mod); + if (/resource\s+"aws_iam_openid_connect_provider"/.test(allTf)) { + return { + pass: false, + detail: "creates its own aws_iam_openid_connect_provider — AWS registers one per issuer URL per account, so it is shared platform infrastructure a backplane must be given, not claim", + }; + } + const varsTf = readBackplaneFile(mod, "variables.tf"); + const arnVar = varsTf ? extractVariableBlocks(varsTf).get("oidc_provider_arn") : null; + if (!arnVar) return { pass: false, detail: 'missing variable "oidc_provider_arn"' }; + if (/^\s*default\s*=/m.test(arnVar)) + return { pass: false, detail: "oidc_provider_arn has a default — the provider has to be deployed first, so there is nothing sensible to default to" }; + if (/^variable\s+"create_oidc_provider"/m.test(allTf)) + return { pass: false, detail: "leftover create_oidc_provider variable — the toggle is gone with the resource" }; return { - pass: /(resource|data)\s+"aws_iam_openid_connect_provider"/.test(allTf), - detail: "no aws_iam_openid_connect_provider — the trust policy must reference a managed OIDC provider, not a hardcoded ARN", + pass: /nullable\s*=\s*false/.test(arnVar), + detail: "oidc_provider_arn is not nullable = false", + }; + }, + }, + { + id: "aws_oidc_provider_notice", + category: "aws_backplane", + name: "oidc_provider_arn carries the shared-provider notice", + emoji: "📌", + fn: (mod) => { + if (awsBackplanePattern(mod) !== "wif") return NOT_WIF; + // The notice is the only signpost a first-time platform engineer gets: AWS has no plural + // OIDC-provider data source, so a missing provider cannot be turned into a friendly + // precondition, and terraform-docs renders backplane variables but not integration comments. + // It is therefore copied verbatim into both variables and linted here. + const varsTf = readBackplaneFile(mod, "variables.tf"); + const backplaneVar = varsTf ? extractVariableBlocks(varsTf).get("oidc_provider_arn") : null; + if (!backplaneVar) return { pass: false, detail: 'missing variable "oidc_provider_arn"' }; + if (!hasOidcProviderNotice(backplaneVar)) + return { pass: false, detail: "backplane oidc_provider_arn description is not the fixed notice — see AWS_OIDC_PROVIDER_NOTICE in this file" }; + + const integration = readIntegrationTf(mod); + if (!integration) return { pass: null, detail: "no integration file" }; + const integrationVar = extractVariableBlocks(integration).get("aws_oidc_provider_arn"); + if (!integrationVar) return { pass: false, detail: 'integration is missing variable "aws_oidc_provider_arn"' }; + return { + pass: hasOidcProviderNotice(integrationVar), + detail: "integration aws_oidc_provider_arn description is not the fixed notice", }; }, }, @@ -515,21 +567,6 @@ const detectors = [ }; }, }, - { - id: "aws_wif_create_oidc_provider", - category: "aws_backplane", - name: "create_oidc_provider variable allows sharing the provider", - emoji: "♻️", - fn: (mod) => { - if (awsBackplanePattern(mod) !== "wif") return NOT_WIF; - const varsTf = readBackplaneFile(mod, "variables.tf"); - if (!varsTf) return { pass: false, detail: "no variables.tf" }; - return { - pass: extractVariableBlocks(varsTf).has("create_oidc_provider"), - detail: "missing create_oidc_provider — a second backplane in the same AWS account cannot reuse the meshStack OIDC provider and its apply fails on EntityAlreadyExists", - }; - }, - }, { id: "aws_wif_subject_condition", category: "aws_backplane", From 5dbc74953039a713d26e937ad7fb55d8b3f3f2a2 Mon Sep 17 00:00:00 2001 From: Johannes Rudolph Date: Tue, 25 Aug 2026 12:27:51 +0200 Subject: [PATCH 7/7] refactor(aws): take the OIDC provider ARN as an input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit All three WIF backplanes stop creating an `aws_iam_openid_connect_provider` and take `oidc_provider_arn` instead, supplied by `modules/aws/oidc-provider` once per AWS account. What each backplane loses: the provider resource, the `data` lookup that mirrored it, the `create_oidc_provider` toggle, and the `try()` local that picked between the two. What it gains: one required non-nullable variable. The trust policy is unchanged — it still scopes `:sub` to this building block definition's subjects. `s3_bucket` gains the input rather than trading a toggle for it: the previous commit added `create_oidc_provider` to bring it level with the route53 modules, and this removes it from all three. Reviewing that pair as one change is the honest reading — the toggle was never the destination. Each backplane carries a `removed` block with `destroy = false`. An account that already applied one of these owns the provider in that backplane's state; without the block, the next apply would delete it out from under every other backplane federating through the same issuer. With it, the apply just forgets it and the provider can be imported into the root that owns `modules/aws/oidc-provider`. Concretely for `internal-cloudfoundation`'s meshcloud-prod: `dns` owns the provider today and `dns-alias` was already passing `create_oidc_provider = false`, so the migration is one forget plus one import, with no downtime for either. The integrations expose it as `aws_oidc_provider_arn` — flat and provider-prefixed per the repo convention — carrying the same fixed notice as the backplane variable, which the scorecard's `aws_oidc_provider_notice` check enforces on both. `aws/s3_bucket/e2e` reads the ARN from `fixtures.aws.oidc_provider_arn` instead of passing a create-or-not flag, matching the harness that owns the provider for the whole account. Co-Authored-By: Claude Opus 5 --- .../backplane/README.md | 4 +-- .../backplane/main.tf | 34 +++++++------------ .../backplane/variables.tf | 11 +++--- .../meshstack_integration.tf | 15 ++++---- .../route53-dns-record/backplane/README.md | 4 +-- .../aws/route53-dns-record/backplane/main.tf | 34 +++++++------------ .../route53-dns-record/backplane/variables.tf | 11 +++--- .../meshstack_integration.tf | 15 ++++---- modules/aws/s3_bucket/backplane/README.md | 4 +-- modules/aws/s3_bucket/backplane/iam.tf | 31 +++++++---------- modules/aws/s3_bucket/backplane/variables.tf | 11 +++--- modules/aws/s3_bucket/e2e/main.tf | 11 +++--- .../aws/s3_bucket/meshstack_integration.tf | 13 ++++--- 13 files changed, 94 insertions(+), 104 deletions(-) diff --git a/modules/aws/route53-dns-alias-record/backplane/README.md b/modules/aws/route53-dns-alias-record/backplane/README.md index 5772c640..153beeb4 100644 --- a/modules/aws/route53-dns-alias-record/backplane/README.md +++ b/modules/aws/route53-dns-alias-record/backplane/README.md @@ -77,13 +77,11 @@ No modules. | Name | Type | |------|------| -| [aws_iam_openid_connect_provider.buildingblock_oidc_provider](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_openid_connect_provider) | resource | | [aws_iam_policy.buildingblock_route53_alias_record_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_policy) | resource | | [aws_iam_role.assume_federated_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | | [aws_iam_role_policy_attachment.buildingblock_route53_alias_record](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | | [random_string.name_suffix](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/string) | resource | | [aws_caller_identity.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/caller_identity) | data source | -| [aws_iam_openid_connect_provider.buildingblock_oidc_provider](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_openid_connect_provider) | data source | | [aws_iam_policy_document.route53_alias_record_access](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | | [aws_iam_policy_document.workload_identity_federation](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | @@ -91,8 +89,8 @@ No modules. | Name | Description | Type | Default | Required | |------|-------------|------|---------|:--------:| -| [create\_oidc\_provider](#input\_create\_oidc\_provider) | Set to false if the OIDC provider for the meshStack issuer already exists in this AWS account (e.g., created by another backplane). The existing provider will be looked up by URL instead of created. | `bool` | `true` | no | | [hosted\_zone\_ids](#input\_hosted\_zone\_ids) | List of Route53 hosted zone IDs that the building block can manage. Example: ['', ''] | `list(string)` | n/a | yes | +| [oidc\_provider\_arn](#input\_oidc\_provider\_arn) | ARN of the IAM OIDC provider for the meshStack runner WIF token issuer in this AWS account.
See .agents/references/aws-backplane.md#the-shared-oidc-provider | `string` | n/a | yes | | [workload\_identity\_federation](#input\_workload\_identity\_federation) | Trusted identity provider from meshStack that the building block runner federates into. Supports multiple subjects and wildcard patterns (e.g., 'system:serviceaccount:namespace:*'). |
object({
issuer = string,
audience = string,
subjects = list(string)
})
| n/a | yes | ## Outputs diff --git a/modules/aws/route53-dns-alias-record/backplane/main.tf b/modules/aws/route53-dns-alias-record/backplane/main.tf index 0d47dbcd..b29fbdab 100644 --- a/modules/aws/route53-dns-alias-record/backplane/main.tf +++ b/modules/aws/route53-dns-alias-record/backplane/main.tf @@ -32,13 +32,6 @@ data "aws_iam_policy_document" "route53_alias_record_access" { } } -locals { - oidc_provider_arn = try( - aws_iam_openid_connect_provider.buildingblock_oidc_provider[0].arn, - data.aws_iam_openid_connect_provider.buildingblock_oidc_provider[0].arn - ) -} - resource "aws_iam_policy" "buildingblock_route53_alias_record_policy" { name = "Route53AliasRecordBuildingBlockFederatedPolicy-${random_string.name_suffix.result}" description = "Policy for the Route53 DNS Alias Record Building Block" @@ -47,19 +40,6 @@ resource "aws_iam_policy" "buildingblock_route53_alias_record_policy" { # Workload Identity Federation -resource "aws_iam_openid_connect_provider" "buildingblock_oidc_provider" { - count = var.create_oidc_provider ? 1 : 0 - - url = var.workload_identity_federation.issuer - client_id_list = [var.workload_identity_federation.audience] -} - -data "aws_iam_openid_connect_provider" "buildingblock_oidc_provider" { - count = var.create_oidc_provider ? 0 : 1 - - url = var.workload_identity_federation.issuer -} - data "aws_iam_policy_document" "workload_identity_federation" { version = "2012-10-17" @@ -67,7 +47,7 @@ data "aws_iam_policy_document" "workload_identity_federation" { effect = "Allow" principals { type = "Federated" - identifiers = [local.oidc_provider_arn] + identifiers = [var.oidc_provider_arn] } actions = ["sts:AssumeRoleWithWebIdentity"] @@ -108,3 +88,15 @@ moved { from = aws_iam_role_policy_attachment.buildingblock_route53_alias_record[0] to = aws_iam_role_policy_attachment.buildingblock_route53_alias_record } + +# The provider is account-level shared infrastructure now, applied by modules/aws/oidc-provider. +# `destroy = false` makes an account that already applied this backplane forget it rather than +# delete it out from under every other backplane federating through it — import it into the root +# that owns modules/aws/oidc-provider instead. +removed { + from = aws_iam_openid_connect_provider.buildingblock_oidc_provider + + lifecycle { + destroy = false + } +} diff --git a/modules/aws/route53-dns-alias-record/backplane/variables.tf b/modules/aws/route53-dns-alias-record/backplane/variables.tf index 3c62cb46..78a063fc 100644 --- a/modules/aws/route53-dns-alias-record/backplane/variables.tf +++ b/modules/aws/route53-dns-alias-record/backplane/variables.tf @@ -13,8 +13,11 @@ variable "workload_identity_federation" { description = "Trusted identity provider from meshStack that the building block runner federates into. Supports multiple subjects and wildcard patterns (e.g., 'system:serviceaccount:namespace:*')." } -variable "create_oidc_provider" { - type = bool - default = true - description = "Set to false if the OIDC provider for the meshStack issuer already exists in this AWS account (e.g., created by another backplane). The existing provider will be looked up by URL instead of created." +variable "oidc_provider_arn" { + type = string + nullable = false + description = <<-EOT + ARN of the IAM OIDC provider for the meshStack runner WIF token issuer in this AWS account. + See .agents/references/aws-backplane.md#the-shared-oidc-provider + EOT } diff --git a/modules/aws/route53-dns-alias-record/meshstack_integration.tf b/modules/aws/route53-dns-alias-record/meshstack_integration.tf index 0d77fc75..b46d9499 100644 --- a/modules/aws/route53-dns-alias-record/meshstack_integration.tf +++ b/modules/aws/route53-dns-alias-record/meshstack_integration.tf @@ -26,10 +26,13 @@ variable "record_types" { description = "List of DNS record types offered in the record type selector. Alias records only support A and AAAA." } -variable "create_oidc_provider" { - type = bool - default = true - description = "Set to false if the OIDC provider for the meshStack issuer already exists in this AWS account (e.g., created by another backplane). The existing provider will be looked up by URL instead of created." +variable "aws_oidc_provider_arn" { + type = string + nullable = false + description = <<-EOT + ARN of the IAM OIDC provider for the meshStack runner WIF token issuer in this AWS account. + See .agents/references/aws-backplane.md#the-shared-oidc-provider + EOT } variable "meshstack" { @@ -69,8 +72,8 @@ data "meshstack_integrations" "integrations" {} module "backplane" { source = "github.com/meshcloud/meshstack-hub//modules/aws/route53-dns-alias-record/backplane?ref=${var.hub.git_ref}" - hosted_zone_ids = var.hosted_zone_ids - create_oidc_provider = var.create_oidc_provider + hosted_zone_ids = var.hosted_zone_ids + oidc_provider_arn = var.aws_oidc_provider_arn workload_identity_federation = { issuer = data.meshstack_integrations.integrations.workload_identity_federation.replicator.issuer diff --git a/modules/aws/route53-dns-record/backplane/README.md b/modules/aws/route53-dns-record/backplane/README.md index 0c708868..d0d1889e 100644 --- a/modules/aws/route53-dns-record/backplane/README.md +++ b/modules/aws/route53-dns-record/backplane/README.md @@ -77,13 +77,11 @@ No modules. | Name | Type | |------|------| -| [aws_iam_openid_connect_provider.buildingblock_oidc_provider](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_openid_connect_provider) | resource | | [aws_iam_policy.buildingblock_route53_record_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_policy) | resource | | [aws_iam_role.assume_federated_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | | [aws_iam_role_policy_attachment.buildingblock_route53_record](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | | [random_string.name_suffix](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/string) | resource | | [aws_caller_identity.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/caller_identity) | data source | -| [aws_iam_openid_connect_provider.buildingblock_oidc_provider](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_openid_connect_provider) | data source | | [aws_iam_policy_document.route53_record_access](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | | [aws_iam_policy_document.workload_identity_federation](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | @@ -91,8 +89,8 @@ No modules. | Name | Description | Type | Default | Required | |------|-------------|------|---------|:--------:| -| [create\_oidc\_provider](#input\_create\_oidc\_provider) | Set to false if the OIDC provider for the meshStack issuer already exists in this AWS account (e.g., created by another backplane). The existing provider will be looked up by URL instead of created. | `bool` | `true` | no | | [hosted\_zone\_ids](#input\_hosted\_zone\_ids) | List of Route53 hosted zone IDs that the building block can manage. Example: '', ''] | `list(string)` | n/a | yes | +| [oidc\_provider\_arn](#input\_oidc\_provider\_arn) | ARN of the IAM OIDC provider for the meshStack runner WIF token issuer in this AWS account.
See .agents/references/aws-backplane.md#the-shared-oidc-provider | `string` | n/a | yes | | [workload\_identity\_federation](#input\_workload\_identity\_federation) | Trusted identity provider from meshStack that the building block runner federates into. Supports multiple subjects and wildcard patterns (e.g., 'system:serviceaccount:namespace:*'). |
object({
issuer = string,
audience = string,
subjects = list(string)
})
| n/a | yes | ## Outputs diff --git a/modules/aws/route53-dns-record/backplane/main.tf b/modules/aws/route53-dns-record/backplane/main.tf index 6f068000..36a8b973 100644 --- a/modules/aws/route53-dns-record/backplane/main.tf +++ b/modules/aws/route53-dns-record/backplane/main.tf @@ -32,13 +32,6 @@ data "aws_iam_policy_document" "route53_record_access" { } } -locals { - oidc_provider_arn = try( - aws_iam_openid_connect_provider.buildingblock_oidc_provider[0].arn, - data.aws_iam_openid_connect_provider.buildingblock_oidc_provider[0].arn - ) -} - resource "aws_iam_policy" "buildingblock_route53_record_policy" { name = "Route53RecordBuildingBlockFederatedPolicy-${random_string.name_suffix.result}" description = "Policy for the Route53 DNS Record Building Block" @@ -47,19 +40,6 @@ resource "aws_iam_policy" "buildingblock_route53_record_policy" { # Workload Identity Federation -resource "aws_iam_openid_connect_provider" "buildingblock_oidc_provider" { - count = var.create_oidc_provider ? 1 : 0 - - url = var.workload_identity_federation.issuer - client_id_list = [var.workload_identity_federation.audience] -} - -data "aws_iam_openid_connect_provider" "buildingblock_oidc_provider" { - count = var.create_oidc_provider ? 0 : 1 - - url = var.workload_identity_federation.issuer -} - data "aws_iam_policy_document" "workload_identity_federation" { version = "2012-10-17" @@ -67,7 +47,7 @@ data "aws_iam_policy_document" "workload_identity_federation" { effect = "Allow" principals { type = "Federated" - identifiers = [local.oidc_provider_arn] + identifiers = [var.oidc_provider_arn] } actions = ["sts:AssumeRoleWithWebIdentity"] @@ -108,3 +88,15 @@ moved { from = aws_iam_role_policy_attachment.buildingblock_route53_record[0] to = aws_iam_role_policy_attachment.buildingblock_route53_record } + +# The provider is account-level shared infrastructure now, applied by modules/aws/oidc-provider. +# `destroy = false` makes an account that already applied this backplane forget it rather than +# delete it out from under every other backplane federating through it — import it into the root +# that owns modules/aws/oidc-provider instead. +removed { + from = aws_iam_openid_connect_provider.buildingblock_oidc_provider + + lifecycle { + destroy = false + } +} diff --git a/modules/aws/route53-dns-record/backplane/variables.tf b/modules/aws/route53-dns-record/backplane/variables.tf index cb1fec0c..5fe66121 100644 --- a/modules/aws/route53-dns-record/backplane/variables.tf +++ b/modules/aws/route53-dns-record/backplane/variables.tf @@ -13,8 +13,11 @@ variable "workload_identity_federation" { description = "Trusted identity provider from meshStack that the building block runner federates into. Supports multiple subjects and wildcard patterns (e.g., 'system:serviceaccount:namespace:*')." } -variable "create_oidc_provider" { - type = bool - default = true - description = "Set to false if the OIDC provider for the meshStack issuer already exists in this AWS account (e.g., created by another backplane). The existing provider will be looked up by URL instead of created." +variable "oidc_provider_arn" { + type = string + nullable = false + description = <<-EOT + ARN of the IAM OIDC provider for the meshStack runner WIF token issuer in this AWS account. + See .agents/references/aws-backplane.md#the-shared-oidc-provider + EOT } diff --git a/modules/aws/route53-dns-record/meshstack_integration.tf b/modules/aws/route53-dns-record/meshstack_integration.tf index 2ce8b04e..fb6d4538 100644 --- a/modules/aws/route53-dns-record/meshstack_integration.tf +++ b/modules/aws/route53-dns-record/meshstack_integration.tf @@ -26,10 +26,13 @@ variable "record_types" { description = "List of DNS record types offered in the record type selector." } -variable "create_oidc_provider" { - type = bool - default = true - description = "Set to false if the OIDC provider for the meshStack issuer already exists in this AWS account (e.g., created by another backplane). The existing provider will be looked up by URL instead of created." +variable "aws_oidc_provider_arn" { + type = string + nullable = false + description = <<-EOT + ARN of the IAM OIDC provider for the meshStack runner WIF token issuer in this AWS account. + See .agents/references/aws-backplane.md#the-shared-oidc-provider + EOT } variable "meshstack" { @@ -69,8 +72,8 @@ data "meshstack_integrations" "integrations" {} module "backplane" { source = "github.com/meshcloud/meshstack-hub//modules/aws/route53-dns-record/backplane?ref=${var.hub.git_ref}" - hosted_zone_ids = var.hosted_zone_ids - create_oidc_provider = var.create_oidc_provider + hosted_zone_ids = var.hosted_zone_ids + oidc_provider_arn = var.aws_oidc_provider_arn workload_identity_federation = { issuer = data.meshstack_integrations.integrations.workload_identity_federation.replicator.issuer diff --git a/modules/aws/s3_bucket/backplane/README.md b/modules/aws/s3_bucket/backplane/README.md index 84100e19..71cf9563 100644 --- a/modules/aws/s3_bucket/backplane/README.md +++ b/modules/aws/s3_bucket/backplane/README.md @@ -76,13 +76,11 @@ No modules. | Name | Type | |------|------| -| [aws_iam_openid_connect_provider.buildingblock_oidc_provider](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_openid_connect_provider) | resource | | [aws_iam_policy.buildingblock_s3_policy](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_policy) | resource | | [aws_iam_role.assume_federated_role](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role) | resource | | [aws_iam_role_policy_attachment.buildingblock_s3](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_role_policy_attachment) | resource | | [random_string.name_suffix](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/string) | resource | | [aws_caller_identity.current](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/caller_identity) | data source | -| [aws_iam_openid_connect_provider.buildingblock_oidc_provider](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_openid_connect_provider) | data source | | [aws_iam_policy_document.s3_full_access](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | | [aws_iam_policy_document.workload_identity_federation](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | @@ -90,7 +88,7 @@ No modules. | Name | Description | Type | Default | Required | |------|-------------|------|---------|:--------:| -| [create\_oidc\_provider](#input\_create\_oidc\_provider) | Set to false if the OIDC provider for the meshStack issuer already exists in this AWS account (e.g., created by another backplane). The existing provider will be looked up by URL instead of created. | `bool` | `true` | no | +| [oidc\_provider\_arn](#input\_oidc\_provider\_arn) | ARN of the IAM OIDC provider for the meshStack runner WIF token issuer in this AWS account.
See .agents/references/aws-backplane.md#the-shared-oidc-provider | `string` | n/a | yes | | [workload\_identity\_federation](#input\_workload\_identity\_federation) | Trusted identity provider from meshStack that the building block runner federates into.
Supports multiple subjects and wildcard patterns (e.g., 'system:serviceaccount:namespace:*'). |
object({
issuer = string,
audience = string,
subjects = list(string)
})
| n/a | yes | ## Outputs diff --git a/modules/aws/s3_bucket/backplane/iam.tf b/modules/aws/s3_bucket/backplane/iam.tf index 170ae257..9a6857c5 100644 --- a/modules/aws/s3_bucket/backplane/iam.tf +++ b/modules/aws/s3_bucket/backplane/iam.tf @@ -25,25 +25,8 @@ resource "aws_iam_policy" "buildingblock_s3_policy" { # Workload Identity Federation -resource "aws_iam_openid_connect_provider" "buildingblock_oidc_provider" { - count = var.create_oidc_provider ? 1 : 0 - - url = var.workload_identity_federation.issuer - client_id_list = [var.workload_identity_federation.audience] -} - -data "aws_iam_openid_connect_provider" "buildingblock_oidc_provider" { - count = var.create_oidc_provider ? 0 : 1 - - url = var.workload_identity_federation.issuer -} - locals { assume_federated_role_name = "BuildingBlockS3IdentityFederation-${random_string.name_suffix.result}" - oidc_provider_arn = try( - aws_iam_openid_connect_provider.buildingblock_oidc_provider[0].arn, - data.aws_iam_openid_connect_provider.buildingblock_oidc_provider[0].arn - ) } data "aws_iam_policy_document" "workload_identity_federation" { @@ -53,7 +36,7 @@ data "aws_iam_policy_document" "workload_identity_federation" { effect = "Allow" principals { type = "Federated" - identifiers = [local.oidc_provider_arn] + identifiers = [var.oidc_provider_arn] } actions = ["sts:AssumeRoleWithWebIdentity"] @@ -94,3 +77,15 @@ moved { from = aws_iam_role_policy_attachment.buildingblock_s3[0] to = aws_iam_role_policy_attachment.buildingblock_s3 } + +# The provider is account-level shared infrastructure now, applied by modules/aws/oidc-provider. +# `destroy = false` makes an account that already applied this backplane forget it rather than +# delete it out from under every other backplane federating through it — import it into the root +# that owns modules/aws/oidc-provider instead. +removed { + from = aws_iam_openid_connect_provider.buildingblock_oidc_provider + + lifecycle { + destroy = false + } +} diff --git a/modules/aws/s3_bucket/backplane/variables.tf b/modules/aws/s3_bucket/backplane/variables.tf index a113e0ed..906590b2 100644 --- a/modules/aws/s3_bucket/backplane/variables.tf +++ b/modules/aws/s3_bucket/backplane/variables.tf @@ -11,8 +11,11 @@ variable "workload_identity_federation" { EOT } -variable "create_oidc_provider" { - type = bool - default = true - description = "Set to false if the OIDC provider for the meshStack issuer already exists in this AWS account (e.g., created by another backplane). The existing provider will be looked up by URL instead of created." +variable "oidc_provider_arn" { + type = string + nullable = false + description = <<-EOT + ARN of the IAM OIDC provider for the meshStack runner WIF token issuer in this AWS account. + See .agents/references/aws-backplane.md#the-shared-oidc-provider + EOT } diff --git a/modules/aws/s3_bucket/e2e/main.tf b/modules/aws/s3_bucket/e2e/main.tf index 671266fe..9cd6b655 100644 --- a/modules/aws/s3_bucket/e2e/main.tf +++ b/modules/aws/s3_bucket/e2e/main.tf @@ -16,10 +16,9 @@ variable "test_context" { account_id = string region = string - # False in the smoke-test account: the meshStack issuer's OIDC provider is a long-lived - # fixture there, because AWS permits one per issuer URL per account and several AWS e2e - # cases run in parallel. - create_oidc_provider = bool + # The account's shared OIDC provider for the meshStack runner issuer. AWS permits one per + # issuer URL per account, so the harness owns it rather than each e2e run creating one. + oidc_provider_arn = string }) })) }) @@ -57,8 +56,8 @@ module "aws_s3_bucket" { bbd_draft = true } - aws_region = var.test_context.fixtures.aws.region - create_oidc_provider = var.test_context.fixtures.aws.create_oidc_provider + aws_region = var.test_context.fixtures.aws.region + aws_oidc_provider_arn = var.test_context.fixtures.aws.oidc_provider_arn workload_identity = { issuer = local.replicator.issuer diff --git a/modules/aws/s3_bucket/meshstack_integration.tf b/modules/aws/s3_bucket/meshstack_integration.tf index 14df6345..bdbac48b 100644 --- a/modules/aws/s3_bucket/meshstack_integration.tf +++ b/modules/aws/s3_bucket/meshstack_integration.tf @@ -12,10 +12,13 @@ variable "workload_identity" { description = "Workload identity federation configuration for AWS authentication." } -variable "create_oidc_provider" { - type = bool - default = true - description = "Set to false if the OIDC provider for the meshStack issuer already exists in this AWS account (e.g., created by another backplane). The existing provider will be looked up by URL instead of created." +variable "aws_oidc_provider_arn" { + type = string + nullable = false + description = <<-EOT + ARN of the IAM OIDC provider for the meshStack runner WIF token issuer in this AWS account. + See .agents/references/aws-backplane.md#the-shared-oidc-provider + EOT } variable "meshstack" { @@ -53,7 +56,7 @@ output "building_block_definition" { module "backplane" { source = "github.com/meshcloud/meshstack-hub//modules/aws/s3_bucket/backplane?ref=${var.hub.git_ref}" - create_oidc_provider = var.create_oidc_provider + oidc_provider_arn = var.aws_oidc_provider_arn workload_identity_federation = { issuer = var.workload_identity.issuer