Skip to content

fix(gcp): scope workload identity federation to one building block definition - #291

Closed
JohannesRudolph wants to merge 3 commits into
mainfrom
claude/gcp-wif-bbd-scoped-subject
Closed

fix(gcp): scope workload identity federation to one building block definition#291
JohannesRudolph wants to merge 3 commits into
mainfrom
claude/gcp-wif-bbd-scoped-subject

Conversation

@JohannesRudolph

Copy link
Copy Markdown
Member

The defect

Both GCP backplanes built their workload identity pool provider's condition as

attribute_condition = join(" || ", [
  for subject in var.workload_identity_federation.subjects :
  "google.subject.startsWith('${subject}')"
])

and both integrations passed a subject that stopped short of the building block definition uuid:

system:serviceaccount:<ns>:workspace.<workspace>.buildingblockdefinition

The building block runner names its per-run Kubernetes service account
workspace.<workspace>.buildingblockdefinition.<bbd-uuid>, so the token's sub is that prefix plus
the uuid. A startsWith against a subject without the uuid therefore admits every building block
definition owned by that workspace.

Concretely: a workspace member who can create a second building block definition in the same
workspace — any definition, running any Terraform — gets a runner token the pool accepts. It can then
impersonate the backplane's service account and use it, inheriting roles/storage.admin on the
project (storage-bucket) or roles/billing.costsManager and roles/billing.viewer on the billing
account (budget-alert). No compromise needed; ordinary workspace-level rights are enough.

modules/azure/*, modules/stackit/* and modules/aws/* all pin the uuid already. GCP was the
outlier.

Why the uuid was missing

Both integrations feed the backplane's credentials into the definition:

secret_value = "data:application/json;base64,${base64encode(module.backplane.credentials_json)}"

so the definition already depends on the backplane. Passing the definition's uuid back in as a
subject closes a cycle. Appending it naively yields, verbatim from tofu validate:

Error: Cycle: module.backplane.local.workload_identity_pool_provider_name (expand),
module.backplane.time_sleep.wait_for_iam, module.backplane.output.credentials_json (expand),
meshstack_building_block_definition.gcp_storage_bucket,
module.backplane.var.workload_identity_federation (expand, input),
module.backplane.var.workload_identity_federation (expand, reference),
module.backplane.google_iam_workload_identity_pool.meshstack,
module.backplane.google_service_account_iam_binding.workload_identity_binding

How the cycle is broken

Same principle as modules/aws/: nothing on the credential path may depend on a resource that
consumes the subjects. GCP needed two steps.

1. The subjects moved out of workload_identity_federation into their own
variable "workload_identity_subjects".
OpenTofu tracks module input dependencies per variable,
not per attribute — the two var.workload_identity_federation nodes in the cycle above are exactly
this. A subjects field inside that object taints every resource that reads any other field of it:
the pool (via workload_identity_pool_identifier), the roles/iam.workloadIdentityUser binding (via
the pool), and credentials_json (via subject_token_file_path and via time_sleep.wait_for_iam).
Splitting the variable confines the dependency to the pool provider, the only resource that needs it.

This is the one interface change: callers of backplane/ pass workload_identity_subjects alongside
workload_identity_federation instead of nesting it. Both in-repo callers are updated.

2. credentials_json assembles the pool provider's resource name instead of reading it back.

Identifiers now constructed as strings

Reviewers should check these against the live resources — they are the part that can silently drift.

Identifier Was Now
WIF audience in credentials_json //iam.googleapis.com/${google_iam_workload_identity_pool_provider.meshstack.name} //iam.googleapis.com/projects/${data.google_project.<x>.number}/locations/global/workloadIdentityPools/${var.workload_identity_federation.workload_identity_pool_identifier}/providers/${var.workload_identity_federation.workload_identity_pool_identifier}

That is the only one. The format is the provider's documented resource name for a pool provider
(projects/{project_number}/locations/global/workloadIdentityPools/{pool_id}/providers/{provider_id}),
and both modules already set the pool id and the provider id to the same input, so the string is
fully determined by inputs plus the project number. storage-bucket gains a data "google_project"
for that number; budget-alert already had one.

Everything else still reads real attributes — in particular the service account email, the pool name
in the IAM binding, and the time_sleep.

What I chose not to narrow

The roles/iam.workloadIdentityUser binding stays
principalSet://iam.googleapis.com/<pool>/* rather than becoming
principal://iam.googleapis.com/<pool>/subject/<sub>.

A subject-scoped member would put the subjects back on the credential path: credentials_json
depends_on time_sleep.wait_for_iam, which must wait for this binding — GCP IAM propagation here is
the documented two-to-seven-minute iam.serviceAccounts.getAccessToken 403, and dropping the binding
from that wait to buy the narrower member would trade a real failure mode for a theoretical gate.

It also buys nothing here: each backplane creates its own pool, that pool holds one provider, and
that provider now accepts exactly one subject — so the principal set has exactly one member. Both
backplane READMEs and the reference doc say this explicitly so nobody "fixes" it later.

Verification

  • tofu init -backend=false && tofu validate in both backplane/ directories.
  • Both integration roots validated against a local ./backplane (copied to a scratch path with
    the module source rewritten). This is what catches the cycle — the backplane alone validates fine
    either way. Also confirmed tofu graph -type=plan builds cycle-free for both.
  • node tools/scorecard/scorecard.mjs --module=gcp/storage-bucket --module=gcp/budget-alert — both
    100%, no regression.
  • pre-commit run --all-files passes.
  • The e2e smoke tests for gcp/storage-bucket and gcp/budget-alert were run against this branch
    and are green. They are the real proof that the assembled audience string is correct: a wrong
    audience fails the STS token exchange, and a wrong subject fails the attribute condition.

🤖 Generated with Claude Code

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 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Scorecard Check

Scorecard run on commit 95522089505613fbd10bc8e29f9925c32846ffb3 relative to origin/main

📊 meshstack-hub Module Scorecard

Generated: 2026-08-25 | Modules scanned: 2 | Categories: 6

📋 Per-Module Category Summary

Score per category per building block. n/a = category does not apply to this module.

Module Overall Core Structure Integration Azure Backplane GCP Backplane STACKIT Backplane Testing
gcp/budget-alert 🟢 100% 🟢 100% 🟢 100% n/a 🟢 100% n/a 🟢 100%
gcp/storage-bucket 🟢 100% 🟢 100% 🟢 100% n/a 🟢 100% n/a 🟢 100%

All checks passing! This module meets all scorecard criteria.

Core Structure — ✅ all passing

Basic module file structure and documentation — applies to 2 modules

Module Score 📦 🔗 📋 📝 🖼️ 📌 🔒
gcp/budget-alert 🟢 100%
gcp/storage-bucket 🟢 100%

Core Structure — Summary

Emoji Criterion Coverage Status
📦 buildingblock/ directory exists 2/2 🟢 100%
🔗 meshstack_integration.tf present 2/2 🟢 100%
📋 buildingblock/APP_TEAM_README.md present (no-integration fallback) n/a
📝 buildingblock/README.md with YAML front-matter 2/2 🟢 100%
🖼️ buildingblock/logo.png included 2/2 🟢 100%
📌 buildingblock/versions.tf present 2/2 🟢 100%
🔒 Provider versions use minimum constraint (>=) 2/2 🟢 100%
Integration — ✅ all passing

meshstack_integration.tf conventions — applies to 2 modules

Module Score 🏷️ 🏢 📤 🔌 📎 🔀 🌱 📋 🏷️ 🧱 📖 📝 📊 🚫 🔄
gcp/budget-alert 🟢 100%
gcp/storage-bucket 🟢 100%

Integration — Summary

Emoji Criterion Coverage Status
🏷️ variable "hub" in integration 2/2 🟢 100%
🏢 variable "meshstack" in integration 2/2 🟢 100%
📤 building_block_definition output exposed 2/2 🟢 100%
🔌 meshcloud/meshstack in required_providers 2/2 🟢 100%
📎 backplane source uses var.hub.git_ref 2/2 🟢 100%
🔀 ref_name uses var.hub.git_ref 2/2 🟢 100%
🌱 BBD terraform_version >= 1.12.0 2/2 🟢 100%
📋 version_spec.draft uses var.hub.bbd_draft 2/2 🟢 100%
🏷️ BBD metadata.tags forwards var.meshstack.tags 2/2 🟢 100%
🧱 BBD input argument vars with optional() have explicit defaults 2/2 🟢 100%
📖 BBD readme field present 2/2 🟢 100%
📝 BBD readme starts with plain-text description (no heading) 2/2 🟢 100%
📊 BBD readme has shared responsibility table (✅/❌) 2/2 🟢 100%
🚫 No documentation_md output in backplane 2/2 🟢 100%
🔄 meshstack_platform has lifecycle ignore_changes = [availability] n/a
Azure Backplane — not applicable

Azure UAMI-based automation principal conventions — applies to 0 modules

No applicable modules.

GCP Backplane — ✅ all passing

GCP workload-identity-federation automation principal conventions — applies to 2 modules

Module Score 🔐 🚫 🪪 🛂 📤 🔌 🧹
gcp/budget-alert 🟢 100%
gcp/storage-bucket 🟢 100%

GCP Backplane — Summary

Emoji Criterion Coverage Status
🔐 Uses google_iam_workload_identity_pool + _provider 2/2 🟢 100%
🚫 No google_service_account_key resource 2/2 🟢 100%
workload_identity_federation is non-nullable 2/2 🟢 100%
🪪 Grants roles/iam.workloadIdentityUser on the service account 2/2 🟢 100%
🛂 Pool provider restricts google.subject via attribute_condition 2/2 🟢 100%
📤 Outputs credentials_json (sensitive) and service_account_email 2/2 🟢 100%
credentials_json waits on a time_sleep for IAM propagation 2/2 🟢 100%
🔌 google_project_service sets disable_on_destroy = false 2/2 🟢 100%
🧹 No provider "google" block in backplane/ 2/2 🟢 100%
STACKIT Backplane — not applicable

STACKIT WIF-based automation principal conventions — applies to 0 modules

No applicable modules.

Testing — ✅ all passing

End-to-end test coverage — applies to 2 modules

Module Score ⚙️ 🧪 🚫
gcp/budget-alert 🟢 100%
gcp/storage-bucket 🟢 100%

Testing — Summary

Emoji Criterion Coverage Status
⚙️ backplane/ directory (optional tier) 2/2 🟢 100%
🧪 e2e/ test directory exists 2/2 🟢 100%
🚫 no .tftest.hcl outside e2e/ 2/2 🟢 100%
e2e/ contains .tftest.hcl files 2/2 🟢 100%

📈 Overall Summary

Overall Average Score: 100%

Score Distribution

  • 🟢 High maturity (≥80%): 2 modules
  • 🟡 Medium maturity (50–79%): 0 modules
  • 🔴 Low maturity (<50%): 0 modules

@aws-amplify-eu-central-1

Copy link
Copy Markdown

This pull request is automatically being deployed by Amplify Hosting (learn more).

Access this pull request here: https://pr-291.d1o16zfeoh2slu.amplifyapp.com

JohannesRudolph and others added 2 commits August 25, 2026 12:28
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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@JohannesRudolph
JohannesRudolph force-pushed the claude/gcp-wif-bbd-scoped-subject branch from 2c32c0b to bd6982a Compare August 25, 2026 10:28
@JohannesRudolph
JohannesRudolph marked this pull request as ready for review August 25, 2026 10:28
@@ -1,3 +1,13 @@
locals {
# Deliberately assembled from the provider's own identifiers rather than read off

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

d: a short "workaround: construct pool identifier to break dependency cycle, see ./agents"

is sufficient

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

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

d: this is now a general pattern for all GCP backplanes. don't repeat this blurb in every README.md when its already in the shared instructions. Same applies to the other backplane/README.md touched in this PR

@JohannesRudolph

Copy link
Copy Markdown
Member Author

Not pursuing this.

Pinning the building block definition uuid into the pool provider's attribute_condition makes the provider depend on the definition, while the definition already depends on the backplane because it embeds credentials_json — whose audience is that same pool provider's resource name. Breaking that cycle costs a hand-assembled audience string with no compile-time check against the real resource, plus an inverted create order in which the pool provider becomes the last resource applied, after the definition is already live.

The trade-off is accepted deliberately. startsWith admits any building block definition owned by the same platform workspace, so all of them share the backplane's federated identity — but authoring a definition in that workspace is already a privileged action that in practice coincides with being able to change the backplane itself. The residual cost is audit attribution: Cloud Audit Logs cannot tell which definition acted.

Note that credentials_json is not a barrier here: it is an external_account credential document carrying no key material, so a second definition can simply reconstruct it. The gate is workspace-level BBD authoring rights, not possession of the credential.

Recorded in .agents/references/gcp-backplane.md so the divergence from azure/aws/stackit reads as a decision rather than an oversight. The underlying limitation is going to meshStack product as a request to make a definition's WIF subject knowable before the definition exists.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant