Skip to content
118 changes: 89 additions & 29 deletions .agents/references/aws-backplane.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
<!-- scorecard-checks: aws_wif_external_oidc_provider, aws_oidc_provider_notice -->
### 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.

<!-- scorecard-checks: aws_wif_subject_condition -->
### Implementation Pattern (WIF)

```hcl
# backplane/main.tf — WIF-based automation principal
Expand All @@ -30,33 +76,14 @@ 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"

statement {
effect = "Allow"
principals {
type = "Federated"
identifiers = [local.oidc_provider_arn]
identifiers = [var.oidc_provider_arn]
}
actions = ["sts:AssumeRoleWithWebIdentity"]

Expand All @@ -82,6 +109,7 @@ resource "aws_iam_role" "backplane" {
# Attach a service-specific policy to aws_iam_role.backplane
```

<!-- scorecard-checks: aws_wif_nonnullable -->
### Backplane Variables (WIF)

```hcl
Expand All @@ -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.

<!-- scorecard-checks: aws_wif_role_output -->
### Backplane Outputs (WIF)

```hcl
Expand All @@ -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
<!-- scorecard-checks: aws_cross_account_provider_aliases, aws_stackset_auto_deployment -->
### Implementation Pattern (Cross-Account)

```hcl
# backplane/main.tf — IAM user + CloudFormation StackSet pattern
Expand Down Expand Up @@ -252,6 +288,7 @@ variable "stackset_region" {
}
```

<!-- scorecard-checks: aws_cross_account_outputs -->
### Backplane Outputs (Cross-Account)

```hcl
Expand All @@ -274,24 +311,47 @@ output "role_name" {

---

<!-- scorecard-checks: aws_wif_no_access_key -->
## What to Avoid

- ❌ Long-lived IAM access keys for single-account building blocks — use WIF (Pattern A) instead
- ❌ Hardcoded AWS account IDs or region names in `main.tf` — use `data "aws_caller_identity"` and variables
- ❌ 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)

<!-- scorecard-checks: aws_wif_integration_env -->
### 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/<service>/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
Expand Down Expand Up @@ -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`
Expand Down
45 changes: 45 additions & 0 deletions modules/aws/oidc-provider/README.md
Original file line number Diff line number Diff line change
@@ -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.
25 changes: 25 additions & 0 deletions modules/aws/oidc-provider/main.tf
Original file line number Diff line number Diff line change
@@ -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]
}
4 changes: 4 additions & 0 deletions modules/aws/oidc-provider/outputs.tf
Original file line number Diff line number Diff line change
@@ -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
}
14 changes: 14 additions & 0 deletions modules/aws/oidc-provider/versions.tf
Original file line number Diff line number Diff line change
@@ -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"
}
}
}
51 changes: 36 additions & 15 deletions modules/aws/route53-dns-alias-record/backplane/README.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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`.

<!-- BEGIN_TF_DOCS -->
## Requirements

Expand All @@ -50,31 +77,25 @@ 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 |

## Inputs

| Name | Description | Type | Default | Required |
|------|-------------|------|---------|:--------:|
| <a name="input_create_oidc_provider"></a> [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 |
| <a name="input_hosted_zone_ids"></a> [hosted\_zone\_ids](#input\_hosted\_zone\_ids) | List of Route53 hosted zone IDs that the building block can manage. Example: ['<hosted\_zone\_id\_1>', '<hosted\_zone\_id\_2>'] | `list(string)` | n/a | yes |
| <a name="input_workload_identity_federation"></a> [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:*'). | <pre>object({<br/> issuer = string,<br/> audience = string,<br/> subjects = list(string)<br/> })</pre> | `null` | no |
| <a name="input_oidc_provider_arn"></a> [oidc\_provider\_arn](#input\_oidc\_provider\_arn) | ARN of the IAM OIDC provider for the meshStack runner WIF token issuer in this AWS account.<br/>See .agents/references/aws-backplane.md#the-shared-oidc-provider | `string` | n/a | yes |
| <a name="input_workload_identity_federation"></a> [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:*'). | <pre>object({<br/> issuer = string,<br/> audience = string,<br/> subjects = list(string)<br/> })</pre> | n/a | yes |

## Outputs

| Name | Description |
|------|-------------|
| <a name="output_credentials"></a> [credentials](#output\_credentials) | n/a |
| <a name="output_workload_identity_federation_role"></a> [workload\_identity\_federation\_role](#output\_workload\_identity\_federation\_role) | Workload identity federation role ARN |
<!-- END_TF_DOCS -->
Loading
Loading