diff --git a/.github/workflows/opentofu.yml b/.github/workflows/opentofu.yml index 4c2331c..f697508 100644 --- a/.github/workflows/opentofu.yml +++ b/.github/workflows/opentofu.yml @@ -20,6 +20,7 @@ jobs: github.event.pull_request.head.repo.full_name == github.repository uses: makeitworkcloud/shared-workflows/.github/workflows/opentofu.yml@main with: + environment: production gcp-workload-identity-provider: projects/920734942788/locations/global/workloadIdentityPools/github/providers/github gcp-service-account: terraformer@makeitworkcloud.iam.gserviceaccount.com secrets: diff --git a/Makefile b/Makefile index 7108353..ed0b213 100644 --- a/Makefile +++ b/Makefile @@ -3,6 +3,14 @@ TOFU := $(shell command -v tofu) GCS_BUCKET ?= makeitworkcloud-tf-gcp-infra GCS_PREFIX ?= state +# The reusable workflow assigns the protected GitHub Environment and emits this +# marker only from its apply step. Keep local `make apply` interactive and make +# CI non-interactive only after that explicit environment-gated invocation. +TOFU_APPLY_ARGS := +ifeq ($(GITHUB_ACTIONS):$(OPENTOFU_ENVIRONMENT_GATED),true:true) +TOFU_APPLY_ARGS := -auto-approve -input=false +endif + .PHONY: help bootstrap-prepare bootstrap-plan bootstrap-apply bootstrap-migrate init plan apply test pre-commit-config pre-commit-check-deps pre-commit-install-hooks help: @@ -53,7 +61,7 @@ plan: init @$(TOFU) plan -compact-warnings apply: init - @$(TOFU) apply -compact-warnings + @$(TOFU) apply -compact-warnings $(TOFU_APPLY_ARGS) test: pre-commit-config pre-commit-install-hooks @pre-commit run --all-files diff --git a/README.md b/README.md index d72b481..47ef018 100644 --- a/README.md +++ b/README.md @@ -69,16 +69,20 @@ No modules. | [google_billing_budget.project](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/billing_budget) | resource | | [google_iam_workload_identity_pool.github](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/iam_workload_identity_pool) | resource | | [google_iam_workload_identity_pool.kubernetes](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/iam_workload_identity_pool) | resource | +| [google_iam_workload_identity_pool_provider.gcloud_mcp_kubernetes](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/iam_workload_identity_pool_provider) | resource | | [google_iam_workload_identity_pool_provider.github](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/iam_workload_identity_pool_provider) | resource | | [google_iam_workload_identity_pool_provider.opencode_kubernetes](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/iam_workload_identity_pool_provider) | resource | | [google_kms_crypto_key.sops](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/kms_crypto_key) | resource | | [google_kms_key_ring.sops](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/kms_key_ring) | resource | | [google_project.this](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/project) | resource | +| [google_project_iam_member.gcloud_mcp](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/project_iam_member) | resource | | [google_project_iam_member.opencode_mcp](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/project_iam_member) | resource | | [google_project_iam_member.terraformer](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/project_iam_member) | resource | | [google_project_service.this](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/project_service) | resource | +| [google_service_account.gcloud_mcp](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/service_account) | resource | | [google_service_account.opencode_mcp](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/service_account) | resource | | [google_service_account.terraformer](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/service_account) | resource | +| [google_service_account_iam_member.gcloud_mcp_workload_identity_user](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/service_account_iam_member) | resource | | [google_service_account_iam_member.github_workload_identity_user](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/service_account_iam_member) | resource | | [google_service_account_iam_member.opencode_mcp_workload_identity_user](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/service_account_iam_member) | resource | | [google_storage_bucket.state](https://registry.terraform.io/providers/hashicorp/google/latest/docs/resources/storage_bucket) | resource | @@ -103,6 +107,8 @@ No modules. | Name | Description | | ---- | ----------- | +| [gcloud\_mcp\_service\_account\_email](#output\_gcloud\_mcp\_service\_account\_email) | Keyless service account for the cluster-internal gcloud MCP server. | +| [gcloud\_mcp\_workload\_identity\_provider](#output\_gcloud\_mcp\_workload\_identity\_provider) | WIF provider for the gcloud MCP Kubernetes ServiceAccount. | | [github\_workload\_identity\_provider](#output\_github\_workload\_identity\_provider) | GitHub Actions Workload Identity Provider resource name. | | [opencode\_mcp\_service\_account\_email](#output\_opencode\_mcp\_service\_account\_email) | Keyless service account for the managed Google Cloud MCP server. | | [opencode\_mcp\_workload\_identity\_provider](#output\_opencode\_mcp\_workload\_identity\_provider) | WIF provider for the OpenCode Kubernetes ServiceAccount. | diff --git a/identity.tf b/identity.tf index 27d0912..c6555c1 100644 --- a/identity.tf +++ b/identity.tf @@ -39,6 +39,31 @@ resource "google_project_iam_member" "opencode_mcp" { member = "serviceAccount:${google_service_account.opencode_mcp.email}" } +# This identity backs the cluster-internal local gcloud-mcp server. Its roles +# support project-wide asset and IAM metadata inventory without resource +# mutation, Secret Manager payload access, or Cloud Storage object reads. +resource "google_service_account" "gcloud_mcp" { + project = google_project.this.project_id + account_id = "gcloud-mcp" + display_name = "OpenCode gcloud MCP" + + depends_on = [google_project_service.this] +} + +resource "google_project_iam_member" "gcloud_mcp" { + for_each = toset([ + "roles/cloudasset.viewer", + "roles/cloudkms.viewer", + "roles/iam.securityReviewer", + "roles/serviceusage.serviceUsageConsumer", + "roles/serviceusage.serviceUsageViewer", + ]) + + project = google_project.this.project_id + role = each.value + member = "serviceAccount:${google_service_account.gcloud_mcp.email}" +} + resource "google_iam_workload_identity_pool" "github" { project = google_project.this.project_id workload_identity_pool_id = "github" @@ -107,3 +132,29 @@ resource "google_service_account_iam_member" "opencode_mcp_workload_identity_use role = "roles/iam.workloadIdentityUser" member = "principal://iam.googleapis.com/${google_iam_workload_identity_pool.kubernetes.name}/subject/system:serviceaccount:opencode:opencode-mcp" } + +# A separate provider avoids broadening the existing OpenCode federation +# condition. Only the dedicated cluster-internal gcloud-mcp workload can +# impersonate this Google service account. +resource "google_iam_workload_identity_pool_provider" "gcloud_mcp_kubernetes" { + project = google_project.this.project_id + workload_identity_pool_id = google_iam_workload_identity_pool.kubernetes.workload_identity_pool_id + workload_identity_pool_provider_id = "gcloud-mcp" + display_name = "gcloud MCP k3s workload" + + attribute_mapping = { + "google.subject" = "assertion.sub" + } + + attribute_condition = "assertion.sub == \"system:serviceaccount:mcp:gcloud-mcp\"" + + oidc { + issuer_uri = "https://api.makeitwork.cloud" + } +} + +resource "google_service_account_iam_member" "gcloud_mcp_workload_identity_user" { + service_account_id = google_service_account.gcloud_mcp.name + role = "roles/iam.workloadIdentityUser" + member = "principal://iam.googleapis.com/${google_iam_workload_identity_pool.kubernetes.name}/subject/system:serviceaccount:mcp:gcloud-mcp" +} diff --git a/main.tf b/main.tf index c23e1fd..15c515d 100644 --- a/main.tf +++ b/main.tf @@ -3,6 +3,7 @@ locals { project_services = toset([ "apikeys.googleapis.com", + "cloudasset.googleapis.com", "cloudbilling.googleapis.com", "cloudcli.googleapis.com", "cloudkms.googleapis.com", diff --git a/outputs.tf b/outputs.tf index 56b6c3f..ed181a0 100644 --- a/outputs.tf +++ b/outputs.tf @@ -32,3 +32,13 @@ output "opencode_mcp_workload_identity_provider" { description = "WIF provider for the OpenCode Kubernetes ServiceAccount." value = google_iam_workload_identity_pool_provider.opencode_kubernetes.name } + +output "gcloud_mcp_service_account_email" { + description = "Keyless service account for the cluster-internal gcloud MCP server." + value = google_service_account.gcloud_mcp.email +} + +output "gcloud_mcp_workload_identity_provider" { + description = "WIF provider for the gcloud MCP Kubernetes ServiceAccount." + value = google_iam_workload_identity_pool_provider.gcloud_mcp_kubernetes.name +}