diff --git a/.agents/references/aws-backplane.md b/.agents/references/aws-backplane.md index a4a70008..dca201a1 100644 --- a/.agents/references/aws-backplane.md +++ b/.agents/references/aws-backplane.md @@ -17,7 +17,52 @@ Use WIF when the building block acts within a single AWS account (the backplane - **OIDC-native**: AWS supports federated OIDC identities via `aws_iam_openid_connect_provider` out of the box. - **Shared OIDC provider**: Multiple backplanes can share a single OIDC provider in the same AWS account using `create_oidc_provider = false`. - + +### The shared OIDC provider + +**A WIF backplane does not create its OIDC provider. It takes the ARN of one as an input.** + +AWS registers one OIDC provider per issuer URL per AWS account. The meshStack runner has one issuer, +so an account has room for exactly one provider for it no matter how many building block backplanes +federate through it. A backplane that creates its own is claiming shared infrastructure: the second +backplane in the account fails with `EntityAlreadyExists`, and destroying whichever one owns it +breaks every other backplane there. + +So it is deployed separately, once per AWS account that hosts backplanes: + +```hcl +module "meshstack_oidc_provider" { + source = "github.com/meshcloud/meshstack-hub//modules/aws/oidc-provider?ref=main" +} +``` + +`modules/aws/oidc-provider` takes no inputs — it reads the issuer, audience and thumbprint from +`data.meshstack_integrations`. Pass its `arn` output to every backplane in that account. + +This is where AWS differs from the other providers, and why the repetition is not the same kind of +repetition: an Azure federated identity credential is a child of its UAMI and a GCP workload +identity pool is a named per-project resource, so each module can own its own and none of them can +collide. Only AWS has an account-level singleton keyed by the issuer URL. + +#### Migrating an account whose provider lives in a backplane's state + +No destroy is needed. Ship a `removed` block in the backplane so the next apply forgets the +provider instead of deleting it: + +```hcl +removed { + from = aws_iam_openid_connect_provider.buildingblock_oidc_provider + + lifecycle { + destroy = false + } +} +``` + +Then `import` the provider into the root that applies `modules/aws/oidc-provider`, and pass its ARN +to the backplanes that used to create it. + + ### Implementation Pattern (WIF) ```hcl @@ -31,25 +76,6 @@ resource "random_string" "suffix" { upper = false } -resource "aws_iam_openid_connect_provider" "backplane" { - count = var.create_oidc_provider ? 1 : 0 - - url = var.workload_identity_federation.issuer - client_id_list = [var.workload_identity_federation.audience] -} - -data "aws_iam_openid_connect_provider" "backplane" { - count = var.create_oidc_provider ? 0 : 1 - url = var.workload_identity_federation.issuer -} - -locals { - oidc_provider_arn = try( - aws_iam_openid_connect_provider.backplane[0].arn, - data.aws_iam_openid_connect_provider.backplane[0].arn - ) -} - data "aws_iam_policy_document" "workload_identity_federation" { version = "2012-10-17" @@ -57,7 +83,7 @@ data "aws_iam_policy_document" "workload_identity_federation" { effect = "Allow" principals { type = "Federated" - identifiers = [local.oidc_provider_arn] + identifiers = [var.oidc_provider_arn] } actions = ["sts:AssumeRoleWithWebIdentity"] @@ -83,7 +109,7 @@ resource "aws_iam_role" "backplane" { # Attach a service-specific policy to aws_iam_role.backplane ``` - + ### Backplane Variables (WIF) ```hcl @@ -97,13 +123,19 @@ variable "workload_identity_federation" { description = "WIF issuer, audience, and subjects for federated authentication." } -variable "create_oidc_provider" { - type = bool - default = true - description = "Set to false if the OIDC provider for the meshStack issuer already exists in this AWS account (e.g., created by another backplane). The existing provider will be looked up by URL instead of created." +variable "oidc_provider_arn" { + type = string + nullable = false + description = <<-EOT + ARN of the IAM OIDC provider for the meshStack runner WIF token issuer in this AWS account. + See .agents/references/aws-backplane.md#the-shared-oidc-provider + EOT } ``` +The `oidc_provider_arn` description is a fixed notice, copied verbatim into the matching +`aws_oidc_provider_arn` variable in `meshstack_integration.tf`. The scorecard enforces both. + ### Backplane Outputs (WIF) @@ -307,10 +339,19 @@ the next time you are in it. ### WIF pattern ```hcl +variable "aws_oidc_provider_arn" { + type = string + nullable = false + description = <<-EOT + ARN of the IAM OIDC provider for the meshStack runner WIF token issuer in this AWS account. + See .agents/references/aws-backplane.md#the-shared-oidc-provider + EOT +} + module "backplane" { source = "github.com/meshcloud/meshstack-hub//modules/aws//backplane?ref=${var.hub.git_ref}" - create_oidc_provider = var.create_oidc_provider + oidc_provider_arn = var.aws_oidc_provider_arn workload_identity_federation = { issuer = data.meshstack_integrations.integrations.workload_identity_federation.replicator.issuer @@ -365,8 +406,8 @@ role_name = { ## Checklist for AWS Backplanes **WIF pattern (Pattern A):** -- [ ] Uses `aws_iam_openid_connect_provider` (not a hardcoded ARN) -- [ ] `create_oidc_provider` variable present to allow sharing across backplanes +- [ ] Creates no `aws_iam_openid_connect_provider` — takes `oidc_provider_arn` as a required, non-nullable input +- [ ] `oidc_provider_arn` and the integration's `aws_oidc_provider_arn` carry the fixed notice verbatim - [ ] `workload_identity_federation` variable is non-nullable - [ ] Trust policy scopes `sub` condition to the specific BBD UUID via meshStack WIF subjects - [ ] Role ARN output is named `workload_identity_federation_role` diff --git a/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 6c04cc03..2038690f 100755 --- a/tools/scorecard/scorecard.mjs +++ b/tools/scorecard/scorecard.mjs @@ -99,11 +99,23 @@ function awsBackplanePattern(mod) { if (!allTf) return "none"; const federated = /(resource|data)\s+"aws_iam_openid_connect_provider"/.test(allTf) || - /^variable\s+"workload_identity_federation"/m.test(allTf); + /^variable\s+"(workload_identity_federation|oidc_provider_arn)"/m.test(allTf); if (federated) return "wif"; return /resource\s+"aws_iam_access_key"/.test(allTf) ? "cross_account" : "none"; } +// The fixed notice on `oidc_provider_arn` / `aws_oidc_provider_arn`. Deliberately two lines and +// nothing more, so it can be copied verbatim into every meshstack_integration.tf. +const AWS_OIDC_PROVIDER_NOTICE = [ + "ARN of the IAM OIDC provider for the meshStack runner WIF token issuer in this AWS account.", + "See .agents/references/aws-backplane.md#the-shared-oidc-provider", +]; + +function hasOidcProviderNotice(variableBlock) { + const text = variableBlock.replace(/\s+/g, " "); + return AWS_OIDC_PROVIDER_NOTICE.every((line) => text.includes(line.replace(/\s+/g, " "))); +} + const NOT_WIF = { pass: null, detail: "not a workload identity federation backplane" }; const NOT_CROSS_ACCOUNT = { pass: null, detail: "not a cross-account backplane" }; @@ -468,16 +480,56 @@ const detectors = [ // implements before it can judge it. Pattern B mints an `aws_iam_access_key` on purpose, so a // blanket "no access key" check would be wrong there — it only applies on the federation path. { - id: "aws_wif_oidc_provider", + id: "aws_wif_external_oidc_provider", category: "aws_backplane", - name: "Federates via aws_iam_openid_connect_provider", + name: "Takes oidc_provider_arn instead of creating a provider", emoji: "🔐", fn: (mod) => { if (awsBackplanePattern(mod) !== "wif") return NOT_WIF; const allTf = readAllBackplaneTf(mod); + if (/resource\s+"aws_iam_openid_connect_provider"/.test(allTf)) { + return { + pass: false, + detail: "creates its own aws_iam_openid_connect_provider — AWS registers one per issuer URL per account, so it is shared platform infrastructure a backplane must be given, not claim", + }; + } + const varsTf = readBackplaneFile(mod, "variables.tf"); + const arnVar = varsTf ? extractVariableBlocks(varsTf).get("oidc_provider_arn") : null; + if (!arnVar) return { pass: false, detail: 'missing variable "oidc_provider_arn"' }; + if (/^\s*default\s*=/m.test(arnVar)) + return { pass: false, detail: "oidc_provider_arn has a default — the provider has to be deployed first, so there is nothing sensible to default to" }; + if (/^variable\s+"create_oidc_provider"/m.test(allTf)) + return { pass: false, detail: "leftover create_oidc_provider variable — the toggle is gone with the resource" }; return { - pass: /(resource|data)\s+"aws_iam_openid_connect_provider"/.test(allTf), - detail: "no aws_iam_openid_connect_provider — the trust policy must reference a managed OIDC provider, not a hardcoded ARN", + pass: /nullable\s*=\s*false/.test(arnVar), + detail: "oidc_provider_arn is not nullable = false", + }; + }, + }, + { + id: "aws_oidc_provider_notice", + category: "aws_backplane", + name: "oidc_provider_arn carries the shared-provider notice", + emoji: "📌", + fn: (mod) => { + if (awsBackplanePattern(mod) !== "wif") return NOT_WIF; + // The notice is the only signpost a first-time platform engineer gets: AWS has no plural + // OIDC-provider data source, so a missing provider cannot be turned into a friendly + // precondition, and terraform-docs renders backplane variables but not integration comments. + // It is therefore copied verbatim into both variables and linted here. + const varsTf = readBackplaneFile(mod, "variables.tf"); + const backplaneVar = varsTf ? extractVariableBlocks(varsTf).get("oidc_provider_arn") : null; + if (!backplaneVar) return { pass: false, detail: 'missing variable "oidc_provider_arn"' }; + if (!hasOidcProviderNotice(backplaneVar)) + return { pass: false, detail: "backplane oidc_provider_arn description is not the fixed notice — see AWS_OIDC_PROVIDER_NOTICE in this file" }; + + const integration = readIntegrationTf(mod); + if (!integration) return { pass: null, detail: "no integration file" }; + const integrationVar = extractVariableBlocks(integration).get("aws_oidc_provider_arn"); + if (!integrationVar) return { pass: false, detail: 'integration is missing variable "aws_oidc_provider_arn"' }; + return { + pass: hasOidcProviderNotice(integrationVar), + detail: "integration aws_oidc_provider_arn description is not the fixed notice", }; }, }, @@ -515,21 +567,6 @@ const detectors = [ }; }, }, - { - id: "aws_wif_create_oidc_provider", - category: "aws_backplane", - name: "create_oidc_provider variable allows sharing the provider", - emoji: "♻️", - fn: (mod) => { - if (awsBackplanePattern(mod) !== "wif") return NOT_WIF; - const varsTf = readBackplaneFile(mod, "variables.tf"); - if (!varsTf) return { pass: false, detail: "no variables.tf" }; - return { - pass: extractVariableBlocks(varsTf).has("create_oidc_provider"), - detail: "missing create_oidc_provider — a second backplane in the same AWS account cannot reuse the meshStack OIDC provider and its apply fails on EntityAlreadyExists", - }; - }, - }, { id: "aws_wif_subject_condition", category: "aws_backplane",