diff --git a/.agents/references/aws-backplane.md b/.agents/references/aws-backplane.md index bba6eedb..dca201a1 100644 --- a/.agents/references/aws-backplane.md +++ b/.agents/references/aws-backplane.md @@ -17,7 +17,53 @@ 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 +76,6 @@ resource "random_string" "suffix" { upper = false } -resource "aws_iam_openid_connect_provider" "backplane" { - count = var.create_oidc_provider ? 1 : 0 - - url = var.workload_identity_federation.issuer - client_id_list = [var.workload_identity_federation.audience] -} - -data "aws_iam_openid_connect_provider" "backplane" { - count = var.create_oidc_provider ? 0 : 1 - url = var.workload_identity_federation.issuer -} - -locals { - oidc_provider_arn = try( - aws_iam_openid_connect_provider.backplane[0].arn, - data.aws_iam_openid_connect_provider.backplane[0].arn - ) -} - data "aws_iam_policy_document" "workload_identity_federation" { version = "2012-10-17" @@ -56,7 +83,7 @@ data "aws_iam_policy_document" "workload_identity_federation" { effect = "Allow" principals { type = "Federated" - identifiers = [local.oidc_provider_arn] + identifiers = [var.oidc_provider_arn] } actions = ["sts:AssumeRoleWithWebIdentity"] @@ -82,6 +109,7 @@ resource "aws_iam_role" "backplane" { # Attach a service-specific policy to aws_iam_role.backplane ``` + ### Backplane Variables (WIF) ```hcl @@ -95,13 +123,20 @@ 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 +160,8 @@ 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 @@ -252,6 +288,7 @@ variable "stackset_region" { } ``` + ### Backplane Outputs (Cross-Account) ```hcl @@ -274,6 +311,7 @@ output "role_name" { --- + ## What to Avoid - ❌ Long-lived IAM access keys for single-account building blocks — use WIF (Pattern A) instead @@ -281,17 +319,39 @@ 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) + ### 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 +406,8 @@ role_name = { ## Checklist for AWS Backplanes **WIF pattern (Pattern A):** -- [ ] Uses `aws_iam_openid_connect_provider` (not a hardcoded ARN) -- [ ] `create_oidc_provider` variable present to allow sharing across backplanes +- [ ] Creates no `aws_iam_openid_connect_provider` — takes `oidc_provider_arn` as a required, non-nullable input +- [ ] `oidc_provider_arn` and the integration's `aws_oidc_provider_arn` carry the fixed notice verbatim - [ ] `workload_identity_federation` variable is non-nullable - [ ] Trust policy scopes `sub` condition to the specific BBD UUID via meshStack WIF subjects - [ ] Role ARN output is named `workload_identity_federation_role` diff --git a/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" + } + } +} 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 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 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/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)}" + } +} 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" 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("");