From ea7999eac2042a3d90e41aedc6f71befd1c3b7ee Mon Sep 17 00:00:00 2001 From: Johannes Rudolph Date: Tue, 25 Aug 2026 14:59:41 +0200 Subject: [PATCH 1/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. It is therefore platform infrastructure, not something a building block owns. This module is that provider on its own, applied once per AWS account that hosts backplanes. It takes no inputs — issuer, audience and thumbprint come from `data.meshstack_integrations` — so a platform team supplies only the two provider configurations and passes the `arn` output to every backplane in the account. 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 d319c5c67874e413c0179076ecfdd2c15b7b0a5b Mon Sep 17 00:00:00 2001 From: Johannes Rudolph Date: Tue, 25 Aug 2026 15:00:08 +0200 Subject: [PATCH 2/7] docs(aws): require an external OIDC provider in Pattern A MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pattern A had every backplane create its own `aws_iam_openid_connect_provider` with a `create_oidc_provider` toggle to opt out. That makes the first backplane deployed into an account the de-facto owner of shared infrastructure: the second one fails with EntityAlreadyExists unless somebody remembers the toggle, and destroying the owner breaks every other backplane federating through the same issuer. Pattern A now takes the provider's ARN as a required input, and 'The shared OIDC provider' is the section that says why, which module owns it, and how to migrate an account whose provider still sits in a backplane's state (a `removed` block with `destroy = false`, then an import — no destroy). It also records why this is not the same repetition Azure and GCP carry: a federated identity credential is a child of its UAMI and a workload identity pool is a named per-project resource, so those modules can each own theirs and none can collide. Only AWS has an account-level singleton keyed by the issuer URL. Co-Authored-By: Claude Opus 5 --- .agents/references/aws-backplane.md | 110 ++++++++++++++++++++-------- 1 file changed, 81 insertions(+), 29 deletions(-) diff --git a/.agents/references/aws-backplane.md b/.agents/references/aws-backplane.md index bba6eedb..9a0cf66c 100644 --- a/.agents/references/aws-backplane.md +++ b/.agents/references/aws-backplane.md @@ -17,7 +17,51 @@ 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`. -### Implementation Pattern +### 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 # backplane/main.tf — WIF-based automation principal @@ -30,25 +74,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" @@ -56,7 +81,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"] @@ -95,13 +120,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) ```hcl @@ -125,7 +156,7 @@ Use this pattern when the building block must act in **many target accounts** ac - **OU-scoped access**: Access is limited to the specified OUs; accounts outside those OUs cannot be reached. - **Minimal IAM user**: The IAM user in the backplane account only holds `sts:AssumeRole` on the specific role name — no direct service permissions. -### Implementation Pattern +### Implementation Pattern (Cross-Account) ```hcl # backplane/main.tf — IAM user + CloudFormation StackSet pattern @@ -281,6 +312,18 @@ output "role_name" { - ❌ Overly broad IAM policies (`"*"` actions on `"*"` resources) — scope to minimum required actions and resources - ❌ `retain_stacks_on_account_removal = true` in StackSets — orphaned roles in removed accounts are a security risk +The first of these has a specific shape worth naming: a `workload_identity_federation` variable that +defaults to `null`, with `count = var.workload_identity_federation == null ? 1 : 0` selecting an +`aws_iam_user` and an `aws_iam_access_key` on the null branch. That is a single-account backplane +keeping a long-lived key as a fallback, and it is what the bullet forbids — the choice is between the +two patterns, not between federation and a key inside Pattern A. Pattern B's access key is a +different thing: it is the only credential that pattern has, and it authenticates a principal whose +sole permission is `sts:AssumeRole`. + +`modules/aws/s3_bucket`, `modules/aws/route53-dns-record` and `modules/aws/route53-dns-alias-record` +still carry the fallback shape. They are the remaining exceptions, not a pattern to copy — fix one +the next time you are in it. + --- ## `meshstack_integration.tf` Wiring (AWS) @@ -288,10 +331,19 @@ output "role_name" { ### 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 @@ -346,8 +398,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` From 7d899901f91214155508f33c4344e980e8ade708 Mon Sep 17 00:00:00 2001 From: Johannes Rudolph Date: Tue, 25 Aug 2026 15:00:32 +0200 Subject: [PATCH 3/7] refactor(aws/route53-dns-record): WIF only, with an external OIDC provider 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. Nothing was ever on the null branch — the integration always passes a non-null value built from `data.meshstack_integrations`, and internal-cloudfoundation's production deployment is federated — so the IAM user, its policy attachment and its access key were dead code behind a `count` on six resources, a policy name that branched on the same condition, and a `credentials` output that published the literal string "N/A; workload identity federation in use". `workload_identity_federation` becomes `nullable = false`, and the OIDC provider is no longer created here: `oidc_provider_arn` is a required input, supplied by `modules/aws/oidc-provider` once per AWS account. That removes the provider resource, the `data` lookup that mirrored it, the `create_oidc_provider` toggle and the `try()` local that picked between them. The trust policy is unchanged — it still scopes `:sub` to this building block definition's subjects. The policy keeps its federated-path name (`…FederatedPolicy-*`) so no live IAM policy is renamed. Two migration blocks make this a no-op for a deployment already on the federated path: - `moved` carries `aws_iam_role.assume_federated_role` and its policy attachment across the removed `count`. Unlike the GCP equivalent, these `[0]` addresses exist in production state, so without it the next apply would destroy and recreate a live IAM role. - `removed` with `destroy = false` drops the OIDC provider from this backplane's state without deleting it out from under every other backplane in the account. Import it into the root that applies `modules/aws/oidc-provider` instead. Co-Authored-By: Claude Opus 5 --- .../route53-dns-record/backplane/README.md | 51 ++++++++---- .../aws/route53-dns-record/backplane/main.tf | 77 +++++++------------ .../route53-dns-record/backplane/outputs.tf | 8 -- .../route53-dns-record/backplane/variables.tf | 15 ++-- .../meshstack_integration.tf | 15 ++-- 5 files changed, 82 insertions(+), 84 deletions(-) diff --git a/modules/aws/route53-dns-record/backplane/README.md b/modules/aws/route53-dns-record/backplane/README.md index eb68f083..d0d1889e 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,16 +77,11 @@ 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 | | [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 | @@ -67,14 +89,13 @@ 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 | -| [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 | +| [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 | 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..36a8b973 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 { @@ -38,57 +32,22 @@ 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( - 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 - - 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 - - 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 { effect = "Allow" principals { type = "Federated" - identifiers = [local.oidc_provider_arn] + identifiers = [var.oidc_provider_arn] } actions = ["sts:AssumeRoleWithWebIdentity"] @@ -109,15 +68,35 @@ 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 +} + +# 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/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..5fe66121 100644 --- a/modules/aws/route53-dns-record/backplane/variables.tf +++ b/modules/aws/route53-dns-record/backplane/variables.tf @@ -9,12 +9,15 @@ 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" { - 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 From d98eb08eb61dc689711a53ee0fc7b1775531ece2 Mon Sep 17 00:00:00 2001 From: Johannes Rudolph Date: Tue, 25 Aug 2026 15:00:41 +0200 Subject: [PATCH 4/7] refactor(aws/route53-dns-alias-record): WIF only, with an external OIDC provider 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. Nothing was ever on the null branch — the integration always passes a non-null value built from `data.meshstack_integrations`, and internal-cloudfoundation's production deployment is federated — so the IAM user, its policy attachment and its access key were dead code behind a `count` on six resources, a policy name that branched on the same condition, and a `credentials` output that published the literal string "N/A; workload identity federation in use". `workload_identity_federation` becomes `nullable = false`, and the OIDC provider is no longer created here: `oidc_provider_arn` is a required input, supplied by `modules/aws/oidc-provider` once per AWS account. That removes the provider resource, the `data` lookup that mirrored it, the `create_oidc_provider` toggle and the `try()` local that picked between them. The trust policy is unchanged — it still scopes `:sub` to this building block definition's subjects. The policy keeps its federated-path name (`…FederatedPolicy-*`) so no live IAM policy is renamed. Two migration blocks make this a no-op for a deployment already on the federated path: - `moved` carries `aws_iam_role.assume_federated_role` and its policy attachment across the removed `count`. Unlike the GCP equivalent, these `[0]` addresses exist in production state, so without it the next apply would destroy and recreate a live IAM role. - `removed` with `destroy = false` drops the OIDC provider from this backplane's state without deleting it out from under every other backplane in the account. Import it into the root that applies `modules/aws/oidc-provider` instead. Co-Authored-By: Claude Opus 5 --- .../backplane/README.md | 51 ++++++++---- .../backplane/main.tf | 77 +++++++------------ .../backplane/outputs.tf | 8 -- .../backplane/variables.tf | 15 ++-- .../meshstack_integration.tf | 15 ++-- 5 files changed, 82 insertions(+), 84 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..153beeb4 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,16 +77,11 @@ 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 | | [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 | @@ -67,14 +89,13 @@ 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 | -| [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 | +| [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 | 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..b29fbdab 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 { @@ -38,57 +32,22 @@ 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( - 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 - - 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 - - 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 { effect = "Allow" principals { type = "Federated" - identifiers = [local.oidc_provider_arn] + identifiers = [var.oidc_provider_arn] } actions = ["sts:AssumeRoleWithWebIdentity"] @@ -109,15 +68,35 @@ 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 +} + +# 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/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..78a063fc 100644 --- a/modules/aws/route53-dns-alias-record/backplane/variables.tf +++ b/modules/aws/route53-dns-alias-record/backplane/variables.tf @@ -9,12 +9,15 @@ 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" { - 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 From 77a2f3d66168d3f650ad70dc86d83ea9d0dfc134 Mon Sep 17 00:00:00 2001 From: Johannes Rudolph Date: Tue, 25 Aug 2026 15:01:02 +0200 Subject: [PATCH 5/7] refactor(aws/s3_bucket): WIF only, with an external OIDC provider MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same conversion as the two route53 backplanes — the IAM user, its policy attachment and its `aws_iam_access_key` were dead code on a null branch the integration never takes, `workload_identity_federation` becomes `nullable = false`, and `oidc_provider_arn` replaces a self-created OIDC provider — plus two conventions this module never had: - **No `create_oidc_provider` equivalent existed**, so the backplane always created its own provider and a second backplane in the same AWS account failed its apply with EntityAlreadyExists. It now takes the ARN like the others. - **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. `moved` blocks carry the role and its policy attachment across the removed `count`, and a `removed` block with `destroy = false` drops the OIDC provider from state without deleting it. The policy keeps its federated-path name so no live IAM policy is renamed. Not changed: this integration still takes a hand-supplied `variable "workload_identity"` instead of reading `data.meshstack_integrations` the way the route53 modules do. That is a separate input-contract change for its consumers and no scorecard check covers it. Co-Authored-By: Claude Opus 5 --- modules/aws/s3_bucket/backplane/README.md | 54 ++++++++++----- modules/aws/s3_bucket/backplane/iam.tf | 66 +++++++++---------- modules/aws/s3_bucket/backplane/outputs.tf | 11 +--- modules/aws/s3_bucket/backplane/variables.tf | 13 +++- .../aws/s3_bucket/meshstack_integration.tf | 13 +++- 5 files changed, 92 insertions(+), 65 deletions(-) diff --git a/modules/aws/s3_bucket/backplane/README.md b/modules/aws/s3_bucket/backplane/README.md index badd0435..71cf9563 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,13 +76,9 @@ 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_policy_document.s3_full_access](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_policy_document) | data source | @@ -66,12 +88,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 | +| [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 | 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..9a6857c5 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,25 @@ 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 - - url = var.workload_identity_federation.issuer - client_id_list = [var.workload_identity_federation.audience] +locals { + assume_federated_role_name = "BuildingBlockS3IdentityFederation-${random_string.name_suffix.result}" } 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 = [var.oidc_provider_arn] } actions = ["sts:AssumeRoleWithWebIdentity"] @@ -78,20 +56,36 @@ 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 +} + +# 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/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..906590b2 100644 --- a/modules/aws/s3_bucket/backplane/variables.tf +++ b/modules/aws/s3_bucket/backplane/variables.tf @@ -4,9 +4,18 @@ 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 "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/meshstack_integration.tf b/modules/aws/s3_bucket/meshstack_integration.tf index acc3c16e..bdbac48b 100644 --- a/modules/aws/s3_bucket/meshstack_integration.tf +++ b/modules/aws/s3_bucket/meshstack_integration.tf @@ -12,6 +12,15 @@ variable "workload_identity" { description = "Workload identity federation configuration for AWS authentication." } +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" { type = object({ owning_workspace_identifier = string @@ -47,6 +56,8 @@ output "building_block_definition" { module "backplane" { source = "github.com/meshcloud/meshstack-hub//modules/aws/s3_bucket/backplane?ref=${var.hub.git_ref}" + oidc_provider_arn = var.aws_oidc_provider_arn + workload_identity_federation = { issuer = var.workload_identity.issuer audience = var.workload_identity.audience @@ -121,7 +132,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 55bbac053f969c9aa124e216dbcd8a5dfd2f29f8 Mon Sep 17 00:00:00 2001 From: Johannes Rudolph Date: Tue, 25 Aug 2026 15:01:37 +0200 Subject: [PATCH 6/7] feat(scorecard): enforce the AWS backplane identity conventions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Azure, GCP and STACKIT backplanes have their identity conventions enforced by the scorecard. AWS had none, even though `.agents/references/aws-backplane.md` documents them — which is how three AWS backplanes could carry a nullable-workload-identity-federation-with-an-access-key-fallback and score 100%. Adds an `AWS Backplane` category: ten checks over what that reference documents. **AWS documents two legitimate patterns, so the checks are pattern-scoped.** Pattern A (external OIDC provider + IAM role) is for a building block acting in a single account; Pattern B (IAM user + CloudFormation StackSet) is for org-wide building blocks that must reach every account in an OU, and it mints an `aws_iam_access_key` on purpose. A blanket "no access key" check would therefore be wrong. Each check declares its pattern and reports `➖` for the other, the way `terraform_version` already reports `➖` for `manual` implementations. A backplane carrying federation machinery classifies as Pattern A *even when it also mints a key*: that hybrid is exactly the fallback the reference's first "What to Avoid" bullet forbids, and `aws_wif_no_access_key` reports it. | Check | Pattern | Enforces | |---|---|---| | `aws_wif_external_oidc_provider` | A | creates no OIDC provider; takes a required non-nullable `oidc_provider_arn` | | `aws_oidc_provider_notice` | A | the fixed two-line notice on `oidc_provider_arn` and the integration's `aws_oidc_provider_arn` | | `aws_wif_no_access_key` | A | no `aws_iam_access_key` — the Azure `no_app_password` analogue | | `aws_wif_nonnullable` | A | `nullable = false`, no `default = null` fallback | | `aws_wif_subject_condition` | A | the `:sub` condition uses `var.workload_identity_federation.subjects` | | `aws_wif_role_output` | A | `workload_identity_federation_role`, ARN constructed not read off the resource | | `aws_wif_integration_env` | A | integration wires `AWS_ROLE_ARN` + `AWS_WEB_IDENTITY_TOKEN_FILE` | | `aws_cross_account_provider_aliases` | B | `configuration_aliases` declares `aws.management` and `aws.backplane` | | `aws_stackset_auto_deployment` | B | `SERVICE_MANAGED`, auto-deploying, `retain_stacks_on_account_removal = false`, `administration_role_arn` ignored | | `aws_cross_account_outputs` | B | `aws_access_key_id`, sensitive `aws_secret_access_key`, `role_name` | `aws_oidc_provider_notice` exists because 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 runs only on `backplane/` and `buildingblock/`, so a comment in `meshstack_integration.tf` is rendered nowhere. Linting the copy-pasted notice is what keeps the pointer to the instructions alive. Each check was exercised against deliberately non-compliant fixtures as well as compliant ones — one pair per pattern, plus a backplane matching neither — so none is a vacuous pass, and every branch of every detail message was driven individually. The fixtures are deleted. Two judgement calls worth recording: - `role_name` is required only of StackSet-based Pattern B backplanes. The reference ties that output to "the IAM role deployed by StackSet to each target account"; `aws/opt-in-region` reaches a single management account through a role it creates itself and has no such name to publish. Requiring it there would be inventing a convention rather than enforcing one. - The category does not apply to a `backplane/` holding no `.tf` files of its own. `aws/agentic-coding-sandbox` is a composition whose backplane README says it "does not need any dedicated backplane", keeping only a `landingzone/` submodule there — it declares no automation principal to judge. `aws-backplane.md` joins `REF_FILES` and carries the `scorecard-checks` markers, so `--fix` links the section explaining each fix. Its two `### Implementation Pattern` headings became `(WIF)` and `(Cross-Account)` — they collided on one anchor. One supporting change: a category whose checks are all pattern-scoped can mark every one of them not applicable, leaving no score to render. The per-category table now prints `—` for that instead of `null%`. No module hits it today. Co-Authored-By: Claude Opus 5 --- .agents/references/aws-backplane.md | 8 + tools/scorecard/scorecard.mjs | 276 +++++++++++++++++++++++++++- 2 files changed, 282 insertions(+), 2 deletions(-) diff --git a/.agents/references/aws-backplane.md b/.agents/references/aws-backplane.md index 9a0cf66c..dca201a1 100644 --- a/.agents/references/aws-backplane.md +++ b/.agents/references/aws-backplane.md @@ -17,6 +17,7 @@ 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.** @@ -61,6 +62,7 @@ removed { 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 @@ -107,6 +109,7 @@ resource "aws_iam_role" "backplane" { # Attach a service-specific policy to aws_iam_role.backplane ``` + ### Backplane Variables (WIF) ```hcl @@ -133,6 +136,7 @@ variable "oidc_provider_arn" { 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) ```hcl @@ -156,6 +160,7 @@ Use this pattern when the building block must act in **many target accounts** ac - **OU-scoped access**: Access is limited to the specified OUs; accounts outside those OUs cannot be reached. - **Minimal IAM user**: The IAM user in the backplane account only holds `sts:AssumeRole` on the specific role name — no direct service permissions. + ### Implementation Pattern (Cross-Account) ```hcl @@ -283,6 +288,7 @@ variable "stackset_region" { } ``` + ### Backplane Outputs (Cross-Account) ```hcl @@ -305,6 +311,7 @@ output "role_name" { --- + ## What to Avoid - ❌ Long-lived IAM access keys for single-account building blocks — use WIF (Pattern A) instead @@ -328,6 +335,7 @@ the next time you are in it. ## `meshstack_integration.tf` Wiring (AWS) + ### WIF pattern ```hcl diff --git a/tools/scorecard/scorecard.mjs b/tools/scorecard/scorecard.mjs index 01c1b78f..ec858f74 100755 --- a/tools/scorecard/scorecard.mjs +++ b/tools/scorecard/scorecard.mjs @@ -49,6 +49,15 @@ const CATEGORIES = { description: "meshstack_integration.tf conventions", appliesTo: (mod) => existsSync(join(mod.path, "meshstack_integration.tf")), }, + aws_backplane: { + id: "aws_backplane", + name: "AWS Backplane", + description: "AWS automation principal conventions (WIF or cross-account StackSet)", + // A backplane/ holding no .tf files of its own declares no automation principal — the + // agentic-coding-sandbox composition keeps only a landingzone/ submodule there — so the + // category does not apply to it. + appliesTo: (mod) => mod.provider === "aws" && readAllBackplaneTf(mod) !== null, + }, azure_backplane: { id: "azure_backplane", name: "Azure Backplane", @@ -78,6 +87,38 @@ const CATEGORIES = { }, }; +// AWS backplanes come in two documented identity patterns, and most checks belong to exactly one: +// "wif" — OIDC provider + IAM role, for a building block acting in a single account +// "cross_account" — IAM user + assumable role (usually distributed by a StackSet), for org-wide +// building blocks that must reach every account in an OU +// A backplane carrying federation machinery is classified "wif" even when it also mints an access +// key: that hybrid is the optional-WIF-with-key-fallback shape the reference forbids, and +// aws_wif_no_access_key is what reports it. Everything else that mints a key is cross-account. +function awsBackplanePattern(mod) { + const allTf = readAllBackplaneTf(mod); + if (!allTf) return "none"; + const federated = + /(resource|data)\s+"aws_iam_openid_connect_provider"/.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" }; + // ─── Detector functions ───────────────────────────────────────────────────── // Each detector returns { pass: boolean, detail?: string } @@ -434,6 +475,232 @@ const detectors = [ }, }, + // ─── AWS Backplane ────────────────────────────────────────────────────── + // AWS documents two legitimate identity patterns, so a check has to know which one a backplane + // 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_external_oidc_provider", + category: "aws_backplane", + 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: /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", + }; + }, + }, + { + id: "aws_wif_no_access_key", + category: "aws_backplane", + name: "No aws_iam_access_key on the federation path", + emoji: "🚫", + fn: (mod) => { + if (awsBackplanePattern(mod) !== "wif") return NOT_WIF; + const allTf = readAllBackplaneTf(mod); + return { + pass: !/resource\s+"aws_iam_access_key"/.test(allTf), + detail: "aws_iam_access_key alongside workload identity federation — a long-lived key fallback is not a supported path for a single-account building block", + }; + }, + }, + { + id: "aws_wif_nonnullable", + category: "aws_backplane", + name: "workload_identity_federation is non-nullable", + 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" }; + const wifVar = extractVariableBlocks(varsTf).get("workload_identity_federation"); + if (!wifVar) return { pass: false, detail: 'variable "workload_identity_federation" not found' }; + const hasDefaultNull = /default\s*=\s*null/.test(wifVar); + return { + pass: /nullable\s*=\s*false/.test(wifVar) || !hasDefaultNull, + detail: hasDefaultNull + ? "default = null makes federation optional — the null branch is the access key path" + : undefined, + }; + }, + }, + { + id: "aws_wif_subject_condition", + category: "aws_backplane", + name: "Trust policy scopes :sub to the BBD's WIF subjects", + emoji: "🛂", + fn: (mod) => { + if (awsBackplanePattern(mod) !== "wif") return NOT_WIF; + const allTf = readAllBackplaneTf(mod); + // The condition variable is a template carrying nested quotes — + // `"${trimprefix(var....issuer, "https://")}:sub"` — so match to end of line, not to the + // next quote. + const hasSubCondition = /^\s*variable\s*=.*:sub"/m.test(allTf); + const hasSubjects = /var\.workload_identity_federation\.subjects/.test(allTf); + return { + pass: hasSubCondition && hasSubjects, + detail: hasSubCondition + ? "the :sub condition does not use var.workload_identity_federation.subjects, so it is not scoped to this building block definition" + : "no :sub condition — the role is assumable by every subject the meshStack issuer signs", + }; + }, + }, + { + id: "aws_wif_role_output", + category: "aws_backplane", + name: "Outputs workload_identity_federation_role as a constructed ARN", + emoji: "📤", + fn: (mod) => { + if (awsBackplanePattern(mod) !== "wif") return NOT_WIF; + const outputsTf = readBackplaneFile(mod, "outputs.tf"); + if (!outputsTf) return { pass: false, detail: "no outputs.tf" }; + const blocks = extractOutputBlocks(outputsTf); + const roleOutput = blocks.get("workload_identity_federation_role"); + if (!roleOutput) { + const nearMiss = [...blocks.keys()].find((n) => n.startsWith("workload_identity_federation_role")); + return { + pass: false, + detail: nearMiss + ? `output is named "${nearMiss}" — the convention is "workload_identity_federation_role"` + : 'missing output "workload_identity_federation_role"', + }; + } + return { + pass: /arn:aws:iam::/.test(roleOutput) && !/aws_iam_role\.[\w-]+(\[\d+\])?\.arn/.test(roleOutput), + detail: "ARN is read off aws_iam_role instead of being constructed — that closes a dependency cycle through the BBD UUID in the WIF subjects", + }; + }, + }, + { + id: "aws_wif_integration_env", + category: "aws_backplane", + name: "Integration wires AWS_ROLE_ARN and AWS_WEB_IDENTITY_TOKEN_FILE", + emoji: "🌐", + fn: (mod) => { + if (awsBackplanePattern(mod) !== "wif") return NOT_WIF; + const content = readIntegrationTf(mod); + if (!content) return { pass: false, detail: "no integration file" }; + const hasRoleArn = /\bAWS_ROLE_ARN\b/.test(content); + const hasTokenFile = /\bAWS_WEB_IDENTITY_TOKEN_FILE\b/.test(content); + return { + pass: hasRoleArn && hasTokenFile, + detail: hasRoleArn + ? "AWS_WEB_IDENTITY_TOKEN_FILE is not wired — the AWS SDK has no token to exchange" + : "AWS_ROLE_ARN is not wired as an environment input", + }; + }, + }, + { + id: "aws_cross_account_provider_aliases", + category: "aws_backplane", + name: "Declares aws.management and aws.backplane aliases", + emoji: "🧭", + fn: (mod) => { + if (awsBackplanePattern(mod) !== "cross_account") return NOT_CROSS_ACCOUNT; + const allTf = readAllBackplaneTf(mod); + // Read the configuration_aliases list itself: a bare `provider = aws.backplane` elsewhere in + // the module is a use, not a declaration. + const aliases = allTf.match(/configuration_aliases\s*=\s*\[[^\]]*\]/)?.[0]; + if (!aliases) return { pass: false, detail: "no configuration_aliases — the caller cannot point the backplane at two accounts" }; + const hasManagement = /aws\.management\b/.test(aliases); + const hasBackplane = /aws\.backplane\b/.test(aliases); + return { + pass: hasManagement && hasBackplane, + detail: hasManagement + ? "no aws.backplane alias — the IAM user must live in a dedicated automation account" + : "no aws.management alias — the org-wide resources must be applied against the management account", + }; + }, + }, + { + id: "aws_stackset_auto_deployment", + category: "aws_backplane", + name: "StackSet is SERVICE_MANAGED, auto-deploying, retaining nothing", + emoji: "📚", + fn: (mod) => { + if (awsBackplanePattern(mod) !== "cross_account") return NOT_CROSS_ACCOUNT; + const allTf = readAllBackplaneTf(mod); + const blocks = extractResourceBlocks(allTf, "aws_cloudformation_stack_set"); + if (blocks.size === 0) return { pass: null, detail: "no aws_cloudformation_stack_set resources" }; + const faults = []; + for (const [name, body] of blocks) { + if (!/permission_model\s*=\s*"SERVICE_MANAGED"/.test(body)) faults.push(`${name}: not SERVICE_MANAGED`); + if (!/auto_deployment\s*\{[^}]*enabled\s*=\s*true/.test(body)) faults.push(`${name}: auto_deployment not enabled`); + if (!/retain_stacks_on_account_removal\s*=\s*false/.test(body)) faults.push(`${name}: retain_stacks_on_account_removal is not false`); + if (!/ignore_changes\s*=\s*\[[^\]]*administration_role_arn/.test(body)) faults.push(`${name}: administration_role_arn not in ignore_changes`); + } + return { pass: faults.length === 0, detail: faults.join("; ") }; + }, + }, + { + id: "aws_cross_account_outputs", + category: "aws_backplane", + name: "Outputs the access key, a sensitive secret, and the target role name", + emoji: "🔑", + fn: (mod) => { + if (awsBackplanePattern(mod) !== "cross_account") return NOT_CROSS_ACCOUNT; + const outputsTf = readBackplaneFile(mod, "outputs.tf"); + if (!outputsTf) return { pass: false, detail: "no outputs.tf" }; + const blocks = extractOutputBlocks(outputsTf); + if (!blocks.has("aws_access_key_id")) return { pass: false, detail: 'missing output "aws_access_key_id"' }; + const secret = blocks.get("aws_secret_access_key"); + if (!secret) return { pass: false, detail: 'missing output "aws_secret_access_key"' }; + if (!/sensitive\s*=\s*true/.test(secret)) + return { pass: false, detail: "aws_secret_access_key is not marked sensitive = true" }; + // role_name names the role a StackSet deploys into the target accounts. A backplane that + // reaches a single account through a role it creates itself has no such name to publish, so + // only StackSet-based backplanes are held to it. + const hasStackSet = extractResourceBlocks(readAllBackplaneTf(mod), "aws_cloudformation_stack_set").size > 0; + return { + pass: !hasStackSet || blocks.has("role_name"), + detail: 'missing output "role_name" — the building block cannot name the role it assumes in the target account', + }; + }, + }, + // ─── Azure Backplane ──────────────────────────────────────────────────── { id: "azure_uses_uami", @@ -1211,6 +1478,7 @@ function discoverModules() { const REF_FILES = [ "AGENTS.md", + ".agents/references/aws-backplane.md", ".agents/references/azure-backplane.md", ".agents/references/gcp-backplane.md", ".agents/references/stackit-backplane.md", @@ -1479,9 +1747,13 @@ function main() { const checkMarks = cr.checks .map((c) => (c.result.pass === null ? "➖" : c.result.pass ? "✅" : "❌")) .join(" | "); - const scoreEmoji = cr.score >= 80 ? "🟢" : cr.score >= 50 ? "🟡" : "🔴"; + // A pattern-scoped category can mark every one of its checks not applicable, which leaves + // no score to render. + const scoreCell = cr.score === null + ? "—" + : `${cr.score >= 80 ? "🟢" : cr.score >= 50 ? "🟡" : "🔴"} ${cr.score}%`; lines.push( - `| \`${r.mod.id}\` | ${scoreEmoji} ${cr.score}% | ${checkMarks} |` + `| \`${r.mod.id}\` | ${scoreCell} | ${checkMarks} |` ); } lines.push(""); From 8ce41627c03c3ffdd41afc6ea09a927150a21188 Mon Sep 17 00:00:00 2001 From: Johannes Rudolph Date: Tue, 25 Aug 2026 15:02:02 +0200 Subject: [PATCH 7/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 the branch under test, 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: - `aws_oidc_provider_arn` comes from `fixtures.aws.oidc_provider_arn`. The harness owns the account's OIDC provider, so the backplane is handed the ARN rather than creating one per run — which also keeps parallel AWS e2e cases from contending over an account-level singleton. - The integration takes `workload_identity` as a hand-supplied object rather than reading `data.meshstack_integrations` itself, 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. Co-Authored-By: Claude Opus 5 --- modules/aws/s3_bucket/e2e/main.tf | 96 +++++++++++++++++++ modules/aws/s3_bucket/e2e/terraform.tf | 13 +++ .../e2e/tests/aws_s3_bucket_hub.tftest.hcl | 28 ++++++ 3 files changed, 137 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..9cd6b655 --- /dev/null +++ b/modules/aws/s3_bucket/e2e/main.tf @@ -0,0 +1,96 @@ +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 + + # 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 + }) + })) + }) + 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 + aws_oidc_provider_arn = var.test_context.fixtures.aws.oidc_provider_arn + + 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)}" + } +}