From 684e04b8c5f0c66cf0b6631ae659a16aedcd265e Mon Sep 17 00:00:00 2001 From: Johannes Rudolph Date: Tue, 25 Aug 2026 12:18:10 +0200 Subject: [PATCH 1/3] docs(gcp-backplane): require a BBD-scoped, exactly matched WIF subject MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The GCP backplanes were the only ones whose federation subject stopped at `buildingblockdefinition`, so their pools admitted every building block definition in the workspace. The uuid was missing because feeding it back in closes a dependency cycle: the definition carries the backplane's credentials. Write down both halves — the subject convention every other provider already follows, and the two steps that keep the uuid off the credential path (subjects in their own variable, pool provider resource name assembled rather than read back). Co-Authored-By: Claude Opus 5 --- .agents/references/gcp-backplane.md | 96 ++++++++++++++++++++++++++--- 1 file changed, 88 insertions(+), 8 deletions(-) diff --git a/.agents/references/gcp-backplane.md b/.agents/references/gcp-backplane.md index beb85275..6240d87b 100644 --- a/.agents/references/gcp-backplane.md +++ b/.agents/references/gcp-backplane.md @@ -1,5 +1,5 @@ --- -description: GCP backplane identity conventions for meshstack-hub modules under modules/gcp/. Covers the mandatory workload identity federation pattern (pool + provider + service account), why there is no service account key path, project API enablement with disable_on_destroy = false, required permissions for the applying identity, the workload identity pool soft-delete constraint, and the GCP backplane checklist. +description: GCP backplane identity conventions for meshstack-hub modules under modules/gcp/. Covers the mandatory workload identity federation pattern (pool + provider + service account), why there is no service account key path, scoping the subject to one building block definition and the two steps that keep its uuid from closing a dependency cycle, project API enablement with disable_on_destroy = false, required permissions for the applying identity, the workload identity pool soft-delete constraint, and the GCP backplane checklist. --- # GCP Backplane Conventions @@ -56,10 +56,10 @@ resource "google_iam_workload_identity_pool_provider" "meshstack" { "google.subject" = "assertion.sub" } - # Restrict token acceptance to the configured subjects. + # Exact match, never startsWith() — see "Scope the subject to one building block definition". attribute_condition = join(" || ", [ - for subject in var.workload_identity_federation.subjects : - "google.subject.startsWith('${subject}')" + for subject in var.workload_identity_subjects : + "google.subject == '${subject}'" ]) } @@ -81,7 +81,7 @@ resource "google_project_iam_member" "buildingblock" { } ``` -`issuer`, `audience` and `subjects` must always come from `data.meshstack_integrations` in +`issuer`, `audience` and the subjects must always come from `data.meshstack_integrations` in `meshstack_integration.tf` — never hardcoded. See the AWS and Azure references for the shared subject-derivation idiom. @@ -90,6 +90,73 @@ The backplane's `credentials_json` output is then an document (`type = "external_account"`) pointing at the runner's token file, passed to the building block as a `FILE` input with `GOOGLE_APPLICATION_CREDENTIALS` set to its path. +## Scope the subject to one building block definition + +The building block runner names its per-run Kubernetes service account +`workspace..buildingblockdefinition.`, so the token's `sub` claim is +`system:serviceaccount::workspace..buildingblockdefinition.`. The +subject must carry that uuid and be matched **exactly**, as every other provider's backplane already +does. A subject that stops at `buildingblockdefinition`, or an exact subject matched with +`startsWith()`, admits **every** building block definition in the workspace — a second definition in +the same workspace can then federate into this backplane's service account and inherit its roles. + +```hcl +# meshstack_integration.tf +workload_identity_subjects = [ + "${trimsuffix(data.meshstack_integrations.integrations.workload_identity_federation.replicator.subject, ":replicator")}:workspace.${var.meshstack.owning_workspace_identifier}.buildingblockdefinition.${meshstack_building_block_definition.this.metadata.uuid}" +] +``` + +### Breaking the dependency cycle the uuid creates + +The building block definition carries `credentials_json` as a `FILE` input, so it already depends on +the backplane. Feeding its uuid back in as a subject closes that loop — unless **nothing on the +credential path depends on a resource that consumes the subjects**. GCP needs two deliberate steps +for that. + +**1. Keep the subjects out of `workload_identity_federation`.** OpenTofu tracks module input +dependencies **per variable**, not per attribute. A `subjects` field inside that object taints every +resource that reads any other field of it — the pool via its identifier, the +`roles/iam.workloadIdentityUser` binding via the pool, and the credentials via the IAM propagation +wait. A separate `variable "workload_identity_subjects"` confines the dependency to the pool +provider, the only resource that needs it. + +**2. Assemble the pool provider's resource name instead of reading the attribute back** — the same +trick `modules/aws/` uses for its role ARN. + +```hcl +data "google_project" "this" { + project_id = var.project_id +} + +locals { + # Not google_iam_workload_identity_pool_provider.meshstack.name: that resource consumes + # var.workload_identity_subjects, and the subjects name the building block definition that + # carries these credentials. + workload_identity_pool_provider_name = "projects/${data.google_project.this.number}/locations/global/workloadIdentityPools/${var.workload_identity_federation.workload_identity_pool_identifier}/providers/${var.workload_identity_federation.workload_identity_pool_identifier}" +} + +output "credentials_json" { + # ... + value = jsonencode({ + audience = "//iam.googleapis.com/${local.workload_identity_pool_provider_name}" + # ... + }) +} +``` + +The pool itself does **not** consume the subjects, so `google_service_account_iam_binding` and +`time_sleep.wait_for_iam` may keep referencing it. That is why the binding stays pool-wide +(`principalSet://.../workloadIdentityPools//*`) rather than naming the subject with +`principal://.../subject/`: a subject-scoped member would put the subjects back on the credential +path through the IAM propagation wait, and dropping the binding from that wait would trade a real +`iam.serviceAccounts.getAccessToken` 403 for a theoretical second gate. The pool-wide set has exactly +one member anyway — one pool, one provider, one accepted subject. + +`tofu validate` on the backplane alone surfaces neither cycle. Check both by initialising and +validating a root that wires `meshstack_integration.tf` against a local `./backplane` — that is where +OpenTofu reports `Error: Cycle: ...`. + ## Project API enablement @@ -262,12 +329,18 @@ variable "workload_identity_federation" { workload_identity_pool_identifier = string audience = string issuer = string - subjects = list(string) subject_token_file_path = string }) nullable = false # required: there is no service account key fallback description = "Workload identity federation settings sourced from data.meshstack_integrations." } + +# Deliberately not a field of the object above — see "Breaking the dependency cycle the uuid creates". +variable "workload_identity_subjects" { + type = list(string) + nullable = false + description = "Full `sub` claims of the OIDC tokens the pool provider accepts, matched exactly." +} ``` @@ -295,7 +368,11 @@ Both existing modules expose these two. Do not add a `documentation_md` output - ❌ `google_service_account_key` — use workload identity federation - ❌ Conditional WIF-vs-key logic: a nullable `workload_identity_federation` and a `count` on every federation resource -- ❌ Hardcoded `issuer`, `audience` or `subjects` — source them from `data.meshstack_integrations` +- ❌ Hardcoded `issuer`, `audience` or subjects — source them from `data.meshstack_integrations` +- ❌ A subject that stops at `buildingblockdefinition`, or `startsWith()` on the subject — both admit + every building block definition in the workspace +- ❌ `subjects` as a field of `workload_identity_federation` — it drags the whole object onto the + credential path and the configuration no longer plans - ❌ Hardcoded workload identity pool identifier — soft-delete makes it unreusable for ~30 days - ❌ Granting the building block's service account `roles/serviceusage.serviceUsageAdmin` when the building block does not enable services @@ -310,7 +387,10 @@ Both existing modules expose these two. Do not add a `documentation_md` output - [ ] Workload identity pool + provider present - [ ] No `google_service_account_key` anywhere in `backplane/` - [ ] `workload_identity_federation` is `nullable = false` — no key fallback, no `default = null` -- [ ] `attribute_condition` restricts `google.subject` to the configured subjects +- [ ] The accepted subjects live in their own `workload_identity_subjects` variable, not in that object +- [ ] `attribute_condition` matches `google.subject` **exactly** against the configured subjects — no `startsWith()` +- [ ] The subject in `meshstack_integration.tf` ends in `.${meshstack_building_block_definition..metadata.uuid}` +- [ ] `credentials_json` reads no attribute of `google_iam_workload_identity_pool_provider` — the audience is assembled from `data.google_project..number` and the pool identifier - [ ] `google_service_account_iam_binding` grants `roles/iam.workloadIdentityUser` on the pool - [ ] Workload identity pool identifier is an input, not a hardcoded literal - [ ] A `time_sleep` absorbs IAM propagation and the `credentials_json` output `depends_on` it From 7bcbf60e11ec5f2c98b1747bc2246fb9547e3e7b Mon Sep 17 00:00:00 2001 From: Johannes Rudolph Date: Tue, 25 Aug 2026 12:18:24 +0200 Subject: [PATCH 2/3] fix(gcp/storage-bucket): scope WIF to this building block definition The pool provider matched `google.subject.startsWith()` against a subject that stopped at `buildingblockdefinition`, so any building block definition in the owning workspace could federate into the backplane's service account and inherit `roles/storage.admin` on the project. Pin the definition's uuid in the subject and match it exactly, as every other provider's backplane already does. Two changes keep the uuid from closing a dependency cycle back through `credentials_json`, which the definition carries as a FILE input: - The subjects move out of `workload_identity_federation` into their own variable. OpenTofu tracks module input dependencies per variable, so a subjects field would drag the pool, the workloadIdentityUser binding and the IAM propagation wait onto the credential path with it. - The audience is assembled from the project number and the pool identifier instead of reading the pool provider's `name` back. Co-Authored-By: Claude Opus 5 --- .../gcp/storage-bucket/backplane/README.md | 81 +++++++++++-------- modules/gcp/storage-bucket/backplane/iam.tf | 16 +++- .../gcp/storage-bucket/backplane/outputs.tf | 12 ++- .../gcp/storage-bucket/backplane/variables.tf | 22 +++-- .../storage-bucket/meshstack_integration.tf | 12 ++- 5 files changed, 94 insertions(+), 49 deletions(-) diff --git a/modules/gcp/storage-bucket/backplane/README.md b/modules/gcp/storage-bucket/backplane/README.md index a2ff4a4f..e346ad4b 100644 --- a/modules/gcp/storage-bucket/backplane/README.md +++ b/modules/gcp/storage-bucket/backplane/README.md @@ -60,12 +60,11 @@ module "gcp_storage_bucket_backplane" { workload_identity_pool_identifier = "your-pool-identifier" audience = "your-audience" issuer = "https://your-oidc-issuer" - subjects = [ - "system:serviceaccount:your-namespace:your-service-account-name", - "system:serviceaccount:your-namespace:another-service-account", - ] - subject_token_file_path = "/path/to/your/token/file" + subject_token_file_path = "/path/to/your/token/file" } + workload_identity_subjects = [ + "system:serviceaccount:your-namespace:workspace.your-workspace.buildingblockdefinition.your-bbd-uuid", + ] } ``` @@ -97,40 +96,50 @@ the backplane well before any building block runs). > fresh identifier per deployment if you need to recreate the backplane, and be aware that > soft-deleted pools still count towards the project's workload identity pool limit. -The module grants access to the entire workload identity pool at the IAM level, then uses attribute conditions at the provider level to restrict which identities can actually authenticate. - -### Subject Matching +### Subject matching is exact -The module supports both exact matching and partial matching for subjects: +The provider's `attribute_condition` compares `google.subject` for **equality** against each entry of +`workload_identity_subjects`. Pass the complete `sub` claim of the token you want accepted, not a +prefix: -**Exact matching** - Grant access to specific subjects: ```hcl -workload_identity_federation = { - issuer = "https://your-oidc-issuer" - subjects = [ - "system:serviceaccount:namespace1:service-account-1", - "system:serviceaccount:namespace1:service-account-2", - ] -} +workload_identity_subjects = [ + "system:serviceaccount::workspace..buildingblockdefinition.", +] ``` -**Partial matching** - Use `startsWith()` to match multiple subjects with a common prefix. Note: The module doesn't use special syntax for this; instead, pass the prefix pattern as-is and it will be matched using CEL's `startsWith()` function: - -```hcl -workload_identity_federation = { - issuer = "https://your-oidc-issuer" - subjects = [ - "system:serviceaccount:namespace1:", # Matches all service accounts in namespace1 - ] -} -``` - -This configuration will accept any subject that starts with `system:serviceaccount:namespace1:`, allowing all service accounts in that namespace to authenticate without listing each one individually. - -**How it works:** -- IAM binding grants access to the entire workload identity pool (`principalSet://iam.googleapis.com/.../pools/POOL_ID/*`) -- Attribute conditions in the provider filter which tokens are accepted based on the `google.subject` claim -- Subjects are evaluated as exact matches first, then partial matches via `startsWith()` checking +`meshstack_integration.tf` derives exactly this value, so the pool admits the runner of **one** +building block definition. Prefix matching is deliberately not supported: the building block runner +names its per-run service account `workspace..buildingblockdefinition.`, so a +prefix that stopped at `buildingblockdefinition` would let every other building block definition in +the same workspace federate into this service account. + +The `roles/iam.workloadIdentityUser` binding on the service account is pool-wide +(`principalSet://iam.googleapis.com/.../workloadIdentityPools/POOL_ID/*`). That is not a wider grant +than the condition above: the pool holds a single provider, and that provider accepts a single +subject, so the set has exactly one member. Naming the subject in the binding as well would make the +binding depend on the building block definition uuid — and `credentials_json` waits on that binding +for IAM propagation, which would close a dependency cycle. + +### Why the subjects are their own variable + +The subjects name the building block definition, and that same definition carries `credentials_json` +as an input. Nothing on the credential path may therefore depend on them. OpenTofu tracks module +input dependencies **per variable**, not per attribute, so a `subjects` field inside +`workload_identity_federation` would taint every resource that reads any other field of that object — +including the pool, the `roles/iam.workloadIdentityUser` binding, and through the propagation wait, +the credentials themselves. Keeping the subjects in `workload_identity_subjects` confines the +dependency to the one resource that needs it, the pool provider. + +For the same reason `credentials_json` builds its `audience` from the project number and the pool +identifier +(`//iam.googleapis.com/projects//locations/global/workloadIdentityPools//providers/`) +instead of reading `google_iam_workload_identity_pool_provider.meshstack.name` — reading the +attribute back would put the pool provider, the one subject-dependent resource, on the credential +path. + +`tofu validate` on this module alone does not surface either cycle. Only a root that wires +`meshstack_integration.tf` against a local `backplane/` does. ## Requirements @@ -156,6 +165,7 @@ No modules. | [google_service_account.buildingblock_storage_sa](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/service_account) | resource | | [google_service_account_iam_binding.workload_identity_binding](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/service_account_iam_binding) | resource | | [time_sleep.wait_for_iam](https://registry.terraform.io/providers/hashicorp/time/latest/docs/resources/sleep) | resource | +| [google_project.this](https://registry.terraform.io/providers/hashicorp/google/latest/docs/data-sources/project) | data source | ## Inputs @@ -164,7 +174,8 @@ No modules. | [iam\_propagation\_delay\_seconds](#input\_iam\_propagation\_delay\_seconds) | Seconds to wait after granting the building block's IAM roles before publishing its credentials. GCP IAM is eventually consistent, and Google's guidance is to allow two to seven minutes before retrying a denied impersonation. Set to 0 if the backplane is always provisioned well before any building block run. | `number` | `180` | no | | [project\_id](#input\_project\_id) | The GCP project ID | `string` | n/a | yes | | [service\_account\_id](#input\_service\_account\_id) | The ID of the service account to create | `string` | `"buildingblock-storage-sa"` | no | -| [workload\_identity\_federation](#input\_workload\_identity\_federation) | Configuration for workload identity federation. Supports multiple subjects with exact matching and partial matching using startsWith(). |
object({
workload_identity_pool_identifier = string // Identifier for the workload identity pool
audience = string // Audience for the OIDC tokens
issuer = string // OIDC issuer URL
subjects = list(string) // Subjects for workload identity federation - can use exact matches or startsWith patterns
subject_token_file_path = string // Path to the file containing the OIDC token
})
| n/a | yes | +| [workload\_identity\_federation](#input\_workload\_identity\_federation) | Workload identity federation settings, sourced from data.meshstack\_integrations. The accepted subjects are a separate variable — see workload\_identity\_subjects. |
object({
workload_identity_pool_identifier = string // Identifier for the workload identity pool
audience = string // Audience for the OIDC tokens
issuer = string // OIDC issuer URL
subject_token_file_path = string // Path to the file containing the OIDC token
})
| n/a | yes | +| [workload\_identity\_subjects](#input\_workload\_identity\_subjects) | Full `sub` claims of the OIDC tokens the pool provider accepts, matched exactly. Each must name the building block definition that authenticates with these credentials. | `list(string)` | n/a | yes | ## Outputs diff --git a/modules/gcp/storage-bucket/backplane/iam.tf b/modules/gcp/storage-bucket/backplane/iam.tf index f789c97e..4eaf9f68 100644 --- a/modules/gcp/storage-bucket/backplane/iam.tf +++ b/modules/gcp/storage-bucket/backplane/iam.tf @@ -1,3 +1,7 @@ +data "google_project" "this" { + project_id = var.project_id +} + resource "google_iam_workload_identity_pool" "meshstack" { # Nothing below references the APIs, so Terraform cannot infer this ordering on its own. depends_on = [google_project_service.required] @@ -24,10 +28,12 @@ resource "google_iam_workload_identity_pool_provider" "meshstack" { "google.subject" = "assertion.sub" } - # Restrict token acceptance to configured subjects + # Exact match, not a prefix: the subjects carry the building block definition uuid, and a + # startsWith() against a prefix would admit every other building block definition whose runner + # service account name happens to share it. attribute_condition = join(" || ", [ - for subject in var.workload_identity_federation.subjects : - "google.subject.startsWith('${subject}')" + for subject in var.workload_identity_subjects : + "google.subject == '${subject}'" ]) } @@ -44,6 +50,10 @@ resource "google_service_account_iam_binding" "workload_identity_binding" { service_account_id = google_service_account.buildingblock_storage_sa.name role = "roles/iam.workloadIdentityUser" + # Pool-wide, and that is as narrow as it gets here: the pool holds exactly one provider and that + # provider admits exactly one subject, so this set has exactly one member. Naming the subject here + # instead would make this binding depend on the building block definition uuid, and the + # credentials output waits on this binding — see the comment on the audience in outputs.tf. members = ["principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.meshstack.name}/*"] } diff --git a/modules/gcp/storage-bucket/backplane/outputs.tf b/modules/gcp/storage-bucket/backplane/outputs.tf index 007cb54c..e872c83a 100644 --- a/modules/gcp/storage-bucket/backplane/outputs.tf +++ b/modules/gcp/storage-bucket/backplane/outputs.tf @@ -1,3 +1,13 @@ +locals { + # Deliberately assembled from the provider's own identifiers rather than read off + # google_iam_workload_identity_pool_provider.meshstack.name, to keep the credentials output free of + # any dependency on the pool provider — the one resource that consumes + # var.workload_identity_subjects. Those subjects name the building block definition, the definition + # carries these credentials, and reading the attribute back would close that loop into a cycle. + # The format is the documented resource name of a pool provider. + workload_identity_pool_provider_name = "projects/${data.google_project.this.number}/locations/global/workloadIdentityPools/${var.workload_identity_federation.workload_identity_pool_identifier}/providers/${var.workload_identity_federation.workload_identity_pool_identifier}" +} + output "credentials_json" { # A consumer embeds these credentials in a building block definition and can order a building # block seconds later. Waiting here orders that consumer after the wait without it knowing. @@ -8,7 +18,7 @@ output "credentials_json" { value = jsonencode({ universe_domain = "googleapis.com" type = "external_account" - audience = "//iam.googleapis.com/${google_iam_workload_identity_pool_provider.meshstack.name}" + audience = "//iam.googleapis.com/${local.workload_identity_pool_provider_name}" subject_token_type = "urn:ietf:params:oauth:token-type:jwt" token_url = "https://sts.googleapis.com/v1/token" service_account_impersonation_url = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${google_service_account.buildingblock_storage_sa.email}:generateAccessToken" diff --git a/modules/gcp/storage-bucket/backplane/variables.tf b/modules/gcp/storage-bucket/backplane/variables.tf index 397c4542..262b04ab 100644 --- a/modules/gcp/storage-bucket/backplane/variables.tf +++ b/modules/gcp/storage-bucket/backplane/variables.tf @@ -16,13 +16,23 @@ variable "iam_propagation_delay_seconds" { } variable "workload_identity_federation" { - description = "Configuration for workload identity federation. Supports multiple subjects with exact matching and partial matching using startsWith()." + description = "Workload identity federation settings, sourced from data.meshstack_integrations. The accepted subjects are a separate variable — see workload_identity_subjects." type = object({ - workload_identity_pool_identifier = string // Identifier for the workload identity pool - audience = string // Audience for the OIDC tokens - issuer = string // OIDC issuer URL - subjects = list(string) // Subjects for workload identity federation - can use exact matches or startsWith patterns - subject_token_file_path = string // Path to the file containing the OIDC token + workload_identity_pool_identifier = string // Identifier for the workload identity pool + audience = string // Audience for the OIDC tokens + issuer = string // OIDC issuer URL + subject_token_file_path = string // Path to the file containing the OIDC token }) nullable = false } + +# Deliberately not a field of workload_identity_federation. Its value names the building block +# definition, which in turn carries the credentials_json output — and OpenTofu tracks module input +# dependencies per variable, not per attribute. Folding the subjects into that object would make +# every resource that reads any of its fields depend on the building block definition, and the +# credentials would depend on themselves. +variable "workload_identity_subjects" { + description = "Full `sub` claims of the OIDC tokens the pool provider accepts, matched exactly. Each must name the building block definition that authenticates with these credentials." + type = list(string) + nullable = false +} diff --git a/modules/gcp/storage-bucket/meshstack_integration.tf b/modules/gcp/storage-bucket/meshstack_integration.tf index ebeb30b6..b732bea6 100644 --- a/modules/gcp/storage-bucket/meshstack_integration.tf +++ b/modules/gcp/storage-bucket/meshstack_integration.tf @@ -57,11 +57,15 @@ module "backplane" { workload_identity_pool_identifier = var.workload_identity.pool_identifier audience = data.meshstack_integrations.integrations.workload_identity_federation.replicator.gcp.audience issuer = data.meshstack_integrations.integrations.workload_identity_federation.replicator.issuer - subjects = [ - "${trimsuffix(data.meshstack_integrations.integrations.workload_identity_federation.replicator.subject, ":replicator")}:workspace.${var.meshstack.owning_workspace_identifier}.buildingblockdefinition" - ] - subject_token_file_path = var.workload_identity.subject_token_file_path + subject_token_file_path = var.workload_identity.subject_token_file_path } + + # The building block runner names its per-run service account after the building block definition, + # so pinning the uuid here is what keeps a second definition in this workspace from federating into + # the backplane's service account. + workload_identity_subjects = [ + "${trimsuffix(data.meshstack_integrations.integrations.workload_identity_federation.replicator.subject, ":replicator")}:workspace.${var.meshstack.owning_workspace_identifier}.buildingblockdefinition.${meshstack_building_block_definition.gcp_storage_bucket.metadata.uuid}" + ] } resource "meshstack_building_block_definition" "gcp_storage_bucket" { From bd6982abce6cfdf6e596af5a70bbee350d754852 Mon Sep 17 00:00:00 2001 From: Johannes Rudolph Date: Tue, 25 Aug 2026 12:18:35 +0200 Subject: [PATCH 3/3] fix(gcp/budget-alert): scope WIF to this building block definition Same defect and same fix as the storage-bucket backplane: the pool provider matched `google.subject.startsWith()` against a subject without the building block definition uuid, so any definition in the owning workspace could federate into the backplane's service account and inherit `roles/billing.costsManager` on the billing account. Co-Authored-By: Claude Opus 5 --- modules/gcp/budget-alert/backplane/README.md | 39 +++++++++++++++++-- modules/gcp/budget-alert/backplane/main.tf | 14 +++++-- modules/gcp/budget-alert/backplane/outputs.tf | 12 +++++- .../gcp/budget-alert/backplane/variables.tf | 14 ++++++- .../gcp/budget-alert/meshstack_integration.tf | 12 ++++-- 5 files changed, 78 insertions(+), 13 deletions(-) diff --git a/modules/gcp/budget-alert/backplane/README.md b/modules/gcp/budget-alert/backplane/README.md index cacc6e22..b82d76fd 100644 --- a/modules/gcp/budget-alert/backplane/README.md +++ b/modules/gcp/budget-alert/backplane/README.md @@ -8,8 +8,8 @@ The building block authenticates by **workload identity federation**; `workload_ is required. The backplane creates a workload identity pool and provider alongside the service account, grants the pool `roles/iam.workloadIdentityUser` on it, and exports `credentials_json` as an [external account](https://cloud.google.com/iam/docs/workload-identity-federation) document. -`issuer`, `audience` and `subjects` must come from `data.meshstack_integrations` — see -`meshstack_integration.tf`. +`issuer`, `audience` and `workload_identity_subjects` must come from `data.meshstack_integrations` — +see `meshstack_integration.tf`. GCP **soft-deletes** workload identity pools and providers for ~30 days and will not reissue their identifiers in that window. `workload_identity_pool_identifier` is an input for exactly that reason: @@ -17,6 +17,38 @@ a backplane that has to be recreated needs a fresh one, and a caller that create backplanes repeatedly (an e2e test) must derive a unique identifier per run. The soft-deleted pools count against the project's pool limit while they linger. +#### Subject matching is exact + +The provider's `attribute_condition` compares `google.subject` for **equality** against each entry of +`workload_identity_subjects`, so each entry must be the complete `sub` claim of the token to accept — +including the building block definition uuid that the runner puts in its per-run service account name +(`workspace..buildingblockdefinition.`). A prefix that stopped at +`buildingblockdefinition` would let every other building block definition in the same workspace +federate into this service account. + +The `roles/iam.workloadIdentityUser` binding is pool-wide +(`principalSet://iam.googleapis.com/.../workloadIdentityPools/POOL_ID/*`), which is not wider than +that condition: the pool holds one provider and the provider accepts one subject. Naming the subject +in the binding as well would make the binding depend on the building block definition uuid, and +`credentials_json` waits on that binding for IAM propagation — closing a dependency cycle. + +#### Why the subjects are their own variable + +The subjects name the building block definition, and that same definition carries `credentials_json` +as an input, so nothing on the credential path may depend on them. OpenTofu tracks module input +dependencies **per variable**, not per attribute: a `subjects` field inside +`workload_identity_federation` would taint every resource that reads any other field of that object, +including the pool, the `roles/iam.workloadIdentityUser` binding and — through the propagation wait — +the credentials themselves. `workload_identity_subjects` confines the dependency to the pool provider, +the one resource that needs it. + +For the same reason `credentials_json` assembles its `audience` from the project number and the pool +identifier rather than reading `google_iam_workload_identity_pool_provider.meshstack.name`: reading +it back would put the pool provider on the credential path. + +`tofu validate` on this module alone surfaces neither cycle. Only a root that wires +`meshstack_integration.tf` against a local `backplane/` does. + ### Roles granted to the building block's service account The service account the backplane creates is the identity the **building block** runs as. It @@ -117,7 +149,8 @@ No modules. | [backplane\_service\_account\_name](#input\_backplane\_service\_account\_name) | The name of the service account to be created for the backplane | `string` | `"building-block-budget-alert"` | no | | [billing\_account\_id](#input\_billing\_account\_id) | The billing account ID where budget permissions will be granted | `string` | n/a | yes | | [iam\_propagation\_delay\_seconds](#input\_iam\_propagation\_delay\_seconds) | Seconds to wait after granting the building block's IAM roles before publishing its credentials. GCP IAM is eventually consistent, and billing-account grants are among the slower ones. Set to 0 if the backplane is always provisioned well before any building block run. | `number` | `180` | no | -| [workload\_identity\_federation](#input\_workload\_identity\_federation) | Workload identity federation settings, sourced from data.meshstack\_integrations. |
object({
workload_identity_pool_identifier = string
audience = string
issuer = string
subjects = list(string)
subject_token_file_path = string
})
| n/a | yes | +| [workload\_identity\_federation](#input\_workload\_identity\_federation) | Workload identity federation settings, sourced from data.meshstack\_integrations. The accepted subjects are a separate variable — see workload\_identity\_subjects. |
object({
workload_identity_pool_identifier = string
audience = string
issuer = string
subject_token_file_path = string
})
| n/a | yes | +| [workload\_identity\_subjects](#input\_workload\_identity\_subjects) | Full `sub` claims of the OIDC tokens the pool provider accepts, matched exactly. Each must name the building block definition that authenticates with these credentials. | `list(string)` | n/a | yes | ## Outputs diff --git a/modules/gcp/budget-alert/backplane/main.tf b/modules/gcp/budget-alert/backplane/main.tf index 7efc17b2..1852327c 100644 --- a/modules/gcp/budget-alert/backplane/main.tf +++ b/modules/gcp/budget-alert/backplane/main.tf @@ -35,16 +35,24 @@ resource "google_iam_workload_identity_pool_provider" "meshstack" { "google.subject" = "assertion.sub" } + # Exact match, not a prefix: the subjects carry the building block definition uuid, and a + # startsWith() against a prefix would admit every other building block definition whose runner + # service account name happens to share it. attribute_condition = join(" || ", [ - for subject in var.workload_identity_federation.subjects : - "google.subject.startsWith('${subject}')" + for subject in var.workload_identity_subjects : + "google.subject == '${subject}'" ]) } resource "google_service_account_iam_binding" "workload_identity" { service_account_id = google_service_account.backplane.name role = "roles/iam.workloadIdentityUser" - members = ["principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.meshstack.name}/*"] + + # Pool-wide, and that is as narrow as it gets here: the pool holds exactly one provider and that + # provider admits exactly one subject, so this set has exactly one member. Naming the subject here + # instead would make this binding depend on the building block definition uuid, and the + # credentials output waits on this binding — see the comment on the audience in outputs.tf. + members = ["principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.meshstack.name}/*"] } # Grant billing account permissions to create budgets diff --git a/modules/gcp/budget-alert/backplane/outputs.tf b/modules/gcp/budget-alert/backplane/outputs.tf index 38ec01a9..0f558fd1 100644 --- a/modules/gcp/budget-alert/backplane/outputs.tf +++ b/modules/gcp/budget-alert/backplane/outputs.tf @@ -1,3 +1,13 @@ +locals { + # Deliberately assembled from the provider's own identifiers rather than read off + # google_iam_workload_identity_pool_provider.meshstack.name, to keep the credentials output free of + # any dependency on the pool provider — the one resource that consumes + # var.workload_identity_subjects. Those subjects name the building block definition, the definition + # carries these credentials, and reading the attribute back would close that loop into a cycle. + # The format is the documented resource name of a pool provider. + workload_identity_pool_provider_name = "projects/${data.google_project.backplane.number}/locations/global/workloadIdentityPools/${var.workload_identity_federation.workload_identity_pool_identifier}/providers/${var.workload_identity_federation.workload_identity_pool_identifier}" +} + output "service_account_email" { description = "Email address of the backplane service account" value = google_service_account.backplane.email @@ -18,7 +28,7 @@ output "credentials_json" { value = jsonencode({ universe_domain = "googleapis.com" type = "external_account" - audience = "//iam.googleapis.com/${google_iam_workload_identity_pool_provider.meshstack.name}" + audience = "//iam.googleapis.com/${local.workload_identity_pool_provider_name}" subject_token_type = "urn:ietf:params:oauth:token-type:jwt" token_url = "https://sts.googleapis.com/v1/token" service_account_impersonation_url = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${google_service_account.backplane.email}:generateAccessToken" diff --git a/modules/gcp/budget-alert/backplane/variables.tf b/modules/gcp/budget-alert/backplane/variables.tf index 20144658..20598ceb 100644 --- a/modules/gcp/budget-alert/backplane/variables.tf +++ b/modules/gcp/budget-alert/backplane/variables.tf @@ -19,11 +19,21 @@ variable "workload_identity_federation" { workload_identity_pool_identifier = string audience = string issuer = string - subjects = list(string) subject_token_file_path = string }) nullable = false - description = "Workload identity federation settings, sourced from data.meshstack_integrations." + description = "Workload identity federation settings, sourced from data.meshstack_integrations. The accepted subjects are a separate variable — see workload_identity_subjects." +} + +# Deliberately not a field of workload_identity_federation. Its value names the building block +# definition, which in turn carries the credentials_json output — and OpenTofu tracks module input +# dependencies per variable, not per attribute. Folding the subjects into that object would make +# every resource that reads any of its fields depend on the building block definition, and the +# credentials would depend on themselves. +variable "workload_identity_subjects" { + type = list(string) + nullable = false + description = "Full `sub` claims of the OIDC tokens the pool provider accepts, matched exactly. Each must name the building block definition that authenticates with these credentials." } variable "iam_propagation_delay_seconds" { diff --git a/modules/gcp/budget-alert/meshstack_integration.tf b/modules/gcp/budget-alert/meshstack_integration.tf index 6381faa9..49ed9d91 100644 --- a/modules/gcp/budget-alert/meshstack_integration.tf +++ b/modules/gcp/budget-alert/meshstack_integration.tf @@ -74,11 +74,15 @@ module "backplane" { workload_identity_pool_identifier = var.workload_identity.pool_identifier audience = data.meshstack_integrations.integrations.workload_identity_federation.replicator.gcp.audience issuer = data.meshstack_integrations.integrations.workload_identity_federation.replicator.issuer - subjects = [ - "${trimsuffix(data.meshstack_integrations.integrations.workload_identity_federation.replicator.subject, ":replicator")}:workspace.${var.meshstack.owning_workspace_identifier}.buildingblockdefinition" - ] - subject_token_file_path = var.workload_identity.subject_token_file_path + subject_token_file_path = var.workload_identity.subject_token_file_path } + + # The building block runner names its per-run service account after the building block definition, + # so pinning the uuid here is what keeps a second definition in this workspace from federating into + # the backplane's service account. + workload_identity_subjects = [ + "${trimsuffix(data.meshstack_integrations.integrations.workload_identity_federation.replicator.subject, ":replicator")}:workspace.${var.meshstack.owning_workspace_identifier}.buildingblockdefinition.${meshstack_building_block_definition.this.metadata.uuid}" + ] } resource "meshstack_building_block_definition" "this" {