From ddf7eadc64ac5f2e47e0cf68ef1624260745f3f0 Mon Sep 17 00:00:00 2001 From: Tim Thacker Date: Sun, 19 Apr 2026 13:41:53 +1000 Subject: [PATCH 1/2] feat(gcp): switch GCP cloud connector to OIDC + WIF (matches Nullify backend) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Nullify backend mints OIDC JWTs (subject_token_type=urn:ietf:params:oauth:token-type:jwt, signed by the platform oidc-gcp Lambda's RSA key in SSM, with `tenant_id` as a custom claim) and exchanges them via Google STS. The customer-facing terraform module was creating an AWS-typed WIF provider, which Google STS rejects on the subject token type — every customer install failed at Verify. This PR rewrites the WIF provider to OIDC, using `nullify_oidc_issuer_uri` and `nullify_tenant_id` as the new required inputs. The pool's `attribute_condition` pins trust to the customer's specific Nullify tenant id, so even if Nullify's signing key were stolen, an attacker could not mint a token accepted by another tenant's provider. The IAM binding moves from `attribute.aws_role/...` to `attribute.tenant_id/...` for the same reason. Other changes: - New `apis.tf` enables prerequisite Google Cloud APIs (iam, iamcredentials, sts, cloudresourcemanager, cloudasset, serviceusage) on the host project so the first `terraform apply` against a fresh project doesn't 403 at pool-creation time. `disable_on_destroy = false` so we don't break unrelated resources on `terraform destroy`. - Removes `roles/viewer` from the granted roles. Per the internal architecture doc, it grants data-plane reads (compute.instances.getSerialPortOutput, cloudbuild.builds.get) that leak secrets; granular per-service viewer roles + roles/cloudasset.viewer cover the same surface without those. - README: drops the stale `tenant_external_id` reference, adds Prerequisites (installer IAM roles + APIs) and Troubleshooting sections. - gcloud install.sh: switches to `create-oidc`, enables APIs up front, drops AWS env vars in favour of NULLIFY_OIDC_ISSUER_URI + NULLIFY_TENANT_ID. - host_project_id now validated against the GCP project-ID regex. Verified locally: - terraform fmt -check / terraform validate clean for the module + every example dir - shellcheck clean for install.sh + uninstall.sh Co-Authored-By: Claude Opus 4.7 (1M context) --- gcp-integration-setup/docs/permissions.md | 17 ++-- gcp-integration-setup/scripts/install.sh | 48 +++++----- gcp-integration-setup/scripts/uninstall.sh | 3 +- gcp-integration-setup/terraform/README.md | 90 +++++++++++++++---- .../terraform/examples/folder/main.tf | 6 +- .../terraform/examples/organization/main.tf | 6 +- .../terraform/examples/single-project/main.tf | 6 +- gcp-integration-setup/terraform/main.tf | 22 ++--- .../modules/nullify-gcp-integration/apis.tf | 23 +++++ .../modules/nullify-gcp-integration/main.tf | 73 +++++++-------- .../nullify-gcp-integration/outputs.tf | 4 +- .../nullify-gcp-integration/variables.tf | 29 +++--- .../terraform/terraform.tfvars.example | 9 +- gcp-integration-setup/terraform/variables.tf | 22 +++-- 14 files changed, 234 insertions(+), 124 deletions(-) create mode 100644 gcp-integration-setup/terraform/modules/nullify-gcp-integration/apis.tf diff --git a/gcp-integration-setup/docs/permissions.md b/gcp-integration-setup/docs/permissions.md index 8c7f4ab..c520389 100644 --- a/gcp-integration-setup/docs/permissions.md +++ b/gcp-integration-setup/docs/permissions.md @@ -5,14 +5,18 @@ Cloud Connector requests, and why. Use it to satisfy security review. ## Trust model -- **Workload Identity Federation (WIF)**, AWS source. +- **Workload Identity Federation (WIF)**, OIDC source. +- Nullify acts as an OpenID Connect identity provider. The JWKS document + (`{nullify_oidc_issuer_uri}/.well-known/jwks.json`) is publicly fetched + by Google STS to verify subject token signatures. - Nullify never holds a long-lived service account key. - Every API call is authenticated with a short-lived token (~1 hour) minted - by exchanging a signed AWS STS GetCallerIdentity request through your - workload identity pool. -- The pool's attribute condition restricts trust to a single AWS IAM role - in Nullify's AWS account. Any other AWS principal is rejected by GCP - before any permission check happens. + by exchanging a per-tenant RS256 JWT through your workload identity pool. +- The pool's attribute condition pins trust to your specific Nullify + tenant id (`assertion.tenant_id == ""`). Any other + Nullify tenant's token is rejected by GCP before any permission check + happens — so even if Nullify's signing key were stolen, an attacker + could not mint a token accepted by another tenant's provider. ## Predefined roles @@ -20,7 +24,6 @@ Cloud Connector requests, and why. Use it to satisfy security review. | --- | --- | | `roles/cloudasset.viewer` | Org-wide asset enumeration via Cloud Asset Inventory. The cheapest way to discover everything. | | `roles/iam.securityReviewer` | Read all IAM bindings, custom roles, deny policies, recommendations. Drives the IAM exposure analysis. | -| `roles/viewer` | Generic project read for the long tail of services that don't have a specific viewer role. | | `roles/compute.viewer` | VPC, instances, firewalls, load balancers, routes, NAT, peering. Drives the network topology. | | `roles/container.clusterViewer` | GKE cluster + node pool config. | | `roles/cloudsql.viewer` | Cloud SQL instance + replica config. | diff --git a/gcp-integration-setup/scripts/install.sh b/gcp-integration-setup/scripts/install.sh index ed6a8b8..3b65f21 100755 --- a/gcp-integration-setup/scripts/install.sh +++ b/gcp-integration-setup/scripts/install.sh @@ -9,8 +9,8 @@ # Usage: # export NULLIFY_HOST_PROJECT="acme-security" # export NULLIFY_ORG_ID="123456789012" -# export NULLIFY_AWS_PRINCIPAL_ARN="arn:aws:iam::000000000000:role/nullify-cloud-connector" -# export NULLIFY_AWS_ACCOUNT_ID="000000000000" +# export NULLIFY_OIDC_ISSUER_URI="https://gcp.nullify.ai" +# export NULLIFY_TENANT_ID="Nullify-XXXXXXXXXXXX" # ./install.sh # # Re-running this script is idempotent — every gcloud command checks for @@ -20,18 +20,26 @@ set -euo pipefail : "${NULLIFY_HOST_PROJECT:?NULLIFY_HOST_PROJECT is required}" : "${NULLIFY_ORG_ID:?NULLIFY_ORG_ID is required}" -: "${NULLIFY_AWS_PRINCIPAL_ARN:?NULLIFY_AWS_PRINCIPAL_ARN is required}" -: "${NULLIFY_AWS_ACCOUNT_ID:?NULLIFY_AWS_ACCOUNT_ID is required}" +: "${NULLIFY_OIDC_ISSUER_URI:?NULLIFY_OIDC_ISSUER_URI is required (e.g. https://gcp.nullify.ai)}" +: "${NULLIFY_TENANT_ID:?NULLIFY_TENANT_ID is required (copy from the Nullify console)}" POOL_ID="${NULLIFY_WIF_POOL_ID:-nullify-cloud-connector}" -PROVIDER_ID="${NULLIFY_WIF_PROVIDER_ID:-nullify-aws}" +PROVIDER_ID="${NULLIFY_WIF_PROVIDER_ID:-nullify-oidc}" SA_NAME="${NULLIFY_SA_NAME:-nullify-cloud-connector}" SA_EMAIL="${SA_NAME}@${NULLIFY_HOST_PROJECT}.iam.gserviceaccount.com" -# Path-tolerant friendly name extraction. Mirrors the Terraform module's -# `nullify_aws_role_name` local. Assumed-role assertions never include a -# path so the WIF condition we pin must reference only the friendly name. -NULLIFY_ROLE_NAME="${NULLIFY_AWS_PRINCIPAL_ARN##*/}" +# Required APIs on the host project. Without these enabled, the WIF pool / +# provider / service account creation calls fail with cryptic 403s. Mirrors +# `apis.tf` in the Terraform module. +echo "==> Enabling required Google Cloud APIs on ${NULLIFY_HOST_PROJECT}" +gcloud services enable \ + iam.googleapis.com \ + iamcredentials.googleapis.com \ + sts.googleapis.com \ + cloudresourcemanager.googleapis.com \ + cloudasset.googleapis.com \ + serviceusage.googleapis.com \ + --project="${NULLIFY_HOST_PROJECT}" echo "==> Creating service account ${SA_EMAIL}" gcloud iam service-accounts describe "${SA_EMAIL}" --project="${NULLIFY_HOST_PROJECT}" >/dev/null 2>&1 || \ @@ -46,30 +54,29 @@ gcloud iam workload-identity-pools describe "${POOL_ID}" \ --project="${NULLIFY_HOST_PROJECT}" --location=global \ --display-name="Nullify Cloud Connector" -# Mirror the Terraform module's WIF attribute_condition: trust ONLY the -# Nullify AWS role, not any principal in the Nullify AWS account. Without -# this condition the pool would accept any caller from the Nullify AWS -# account, which is meaningfully weaker than the Terraform path. -ATTRIBUTE_CONDITION="attribute.aws_role == \"arn:aws:sts::${NULLIFY_AWS_ACCOUNT_ID}:assumed-role/${NULLIFY_ROLE_NAME}\"" +# Pin trust to this specific Nullify tenant. Nullify's OIDC issuer is +# multi-tenant; the JWT carries a `tenant_id` custom claim. Without this +# condition any Nullify tenant could exchange a token against this provider. +ATTRIBUTE_CONDITION="assertion.tenant_id == \"${NULLIFY_TENANT_ID}\"" -echo "==> Creating workload identity provider ${PROVIDER_ID} (AWS source)" +echo "==> Creating workload identity provider ${PROVIDER_ID} (OIDC source)" gcloud iam workload-identity-pools providers describe "${PROVIDER_ID}" \ --project="${NULLIFY_HOST_PROJECT}" --location=global \ --workload-identity-pool="${POOL_ID}" >/dev/null 2>&1 || \ - gcloud iam workload-identity-pools providers create-aws "${PROVIDER_ID}" \ + gcloud iam workload-identity-pools providers create-oidc "${PROVIDER_ID}" \ --project="${NULLIFY_HOST_PROJECT}" --location=global \ --workload-identity-pool="${POOL_ID}" \ - --account-id="${NULLIFY_AWS_ACCOUNT_ID}" \ - --attribute-mapping="google.subject=assertion.arn,attribute.account=assertion.account,attribute.aws_role=assertion.arn.contains(\"assumed-role\") ? assertion.arn.extract(\"{anything}assumed-role/\") + \"assumed-role/\" + assertion.arn.extract(\"assumed-role/{role}/\") : assertion.arn" \ + --issuer-uri="${NULLIFY_OIDC_ISSUER_URI}" \ + --attribute-mapping="google.subject=assertion.sub,attribute.tenant_id=assertion.tenant_id" \ --attribute-condition="${ATTRIBUTE_CONDITION}" -echo "==> Allowing Nullify principal to impersonate the service account" +echo "==> Allowing the Nullify tenant principal to impersonate the service account" POOL_NAME="$(gcloud iam workload-identity-pools describe "${POOL_ID}" \ --project="${NULLIFY_HOST_PROJECT}" --location=global --format='value(name)')" gcloud iam service-accounts add-iam-policy-binding "${SA_EMAIL}" \ --project="${NULLIFY_HOST_PROJECT}" \ --role="roles/iam.workloadIdentityUser" \ - --member="principalSet://iam.googleapis.com/${POOL_NAME}/attribute.aws_role/arn:aws:sts::${NULLIFY_AWS_ACCOUNT_ID}:assumed-role/${NULLIFY_ROLE_NAME}" + --member="principalSet://iam.googleapis.com/${POOL_NAME}/attribute.tenant_id/${NULLIFY_TENANT_ID}" # Custom role for the long-tail permissions Nullify needs that are not # covered by predefined viewer roles. Mirrors locals.custom_role_permissions @@ -103,7 +110,6 @@ echo "==> Granting predefined viewer roles at the organisation" ROLES=( roles/cloudasset.viewer roles/iam.securityReviewer - roles/viewer roles/compute.viewer roles/container.clusterViewer roles/cloudsql.viewer diff --git a/gcp-integration-setup/scripts/uninstall.sh b/gcp-integration-setup/scripts/uninstall.sh index 1d610b8..73f3aea 100755 --- a/gcp-integration-setup/scripts/uninstall.sh +++ b/gcp-integration-setup/scripts/uninstall.sh @@ -10,7 +10,7 @@ set -euo pipefail : "${NULLIFY_ORG_ID:?NULLIFY_ORG_ID is required}" POOL_ID="${NULLIFY_WIF_POOL_ID:-nullify-cloud-connector}" -PROVIDER_ID="${NULLIFY_WIF_PROVIDER_ID:-nullify-aws}" +PROVIDER_ID="${NULLIFY_WIF_PROVIDER_ID:-nullify-oidc}" SA_NAME="${NULLIFY_SA_NAME:-nullify-cloud-connector}" SA_EMAIL="${SA_NAME}@${NULLIFY_HOST_PROJECT}.iam.gserviceaccount.com" CUSTOM_ROLE_ID="nullifyCloudConnector" @@ -18,7 +18,6 @@ CUSTOM_ROLE_ID="nullifyCloudConnector" ROLES=( roles/cloudasset.viewer roles/iam.securityReviewer - roles/viewer roles/compute.viewer roles/container.clusterViewer roles/cloudsql.viewer diff --git a/gcp-integration-setup/terraform/README.md b/gcp-integration-setup/terraform/README.md index 4057e0f..13790a4 100644 --- a/gcp-integration-setup/terraform/README.md +++ b/gcp-integration-setup/terraform/README.md @@ -1,20 +1,26 @@ # Nullify GCP Cloud Connector — Terraform -Read-only access to GCP for the Nullify Cloud Connector. Workload Identity -Federation only — no service account JSON keys, no long-lived secrets. +Read-only access to GCP for the Nullify Cloud Connector. OIDC + Workload +Identity Federation only — no service account JSON keys, no long-lived +secrets. ## What this provisions - A `google_service_account` named `nullify-cloud-connector` in the host project. -- A `google_iam_workload_identity_pool` and AWS-source provider trusting the - Nullify AWS IAM role. -- A `google_project_iam_custom_role` with read-only permissions on the - long-tail services that don't have a predefined viewer role (Cloud Armor, - VPC Service Controls, AlloyDB, Filestore, Memorystore, Cloud DNS, - API Gateway, Artifact Registry). +- A `google_iam_workload_identity_pool` and an OIDC provider trusting + Nullify's OIDC issuer URL, with an `attribute_condition` pinned to your + specific Nullify tenant id. +- A custom role with read-only permissions on the long-tail services that + don't have a predefined viewer role (Cloud Armor, VPC Service Controls, + AlloyDB, Filestore, Memorystore, Cloud DNS, API Gateway, Artifact + Registry). Defined at the org for `scope = "organization" | "folder"`, + at the project for `scope = "projects"`. - IAM bindings granting the Nullify service account a curated set of predefined viewer roles plus the custom role above. Bound at organisation - scope by default; per-project scope is also supported. + scope by default; folder and per-project scopes are also supported. +- The required Google Cloud APIs on the host project + (`iam`, `iamcredentials`, `sts`, `cloudresourcemanager`, `cloudasset`, + `serviceusage`). ## What this does NOT provision @@ -24,6 +30,42 @@ Federation only — no service account JSON keys, no long-lived secrets. - No write permissions. Nullify cannot modify your environment. - No long-lived secrets. Revoke at any time with `terraform destroy`. +## Prerequisites + +### Installer-side IAM + +The human / service identity running `terraform apply` needs, at minimum: + +- `roles/iam.workloadIdentityPoolAdmin` — create the WIF pool + provider +- `roles/iam.serviceAccountAdmin` — create the Nullify service account +- `roles/serviceusage.serviceUsageAdmin` — enable the required APIs +- `roles/iam.organizationRoleAdmin` — only when `scope` is `organization` + or `folder` (the long-tail custom role must be defined at the org) +- `roles/resourcemanager.organizationAdmin` or `roles/resourcemanager.folderAdmin` — only + when granting bindings at the org or folder + +If you want a least-privilege one-off install, request these roles on the +operator running the apply and revoke them afterwards. + +### APIs + +The Terraform module enables the required APIs for you (see `apis.tf`). +If your organisation restricts API enablement via Service Usage org +policies, ensure the following are allowlisted on the host project: +`iam.googleapis.com`, `iamcredentials.googleapis.com`, `sts.googleapis.com`, +`cloudresourcemanager.googleapis.com`, `cloudasset.googleapis.com`, +`serviceusage.googleapis.com`. + +To enable them manually: + +```bash +gcloud services enable \ + iam.googleapis.com iamcredentials.googleapis.com sts.googleapis.com \ + cloudresourcemanager.googleapis.com cloudasset.googleapis.com \ + serviceusage.googleapis.com \ + --project=YOUR_HOST_PROJECT +``` + ## Quick start ```bash @@ -48,18 +90,19 @@ and click "Verify". | --- | --- | | `customer_name` | You. Anything short and unique. | | `host_project_id` | A GCP project you control. Typically a dedicated security project. | -| `scope` | `"organization"` (recommended) or `"projects"`. | -| `organization_id` | Required when `scope = "organization"`. Find with `gcloud organizations list`. | +| `scope` | `"organization"` (recommended), `"folder"`, or `"projects"`. | +| `organization_id` | Required when `scope = "organization"` or `scope = "folder"`. Find with `gcloud organizations list`. | +| `folder_id` | Required when `scope = "folder"`. | | `project_ids` | Required when `scope = "projects"`. List of project IDs. | -| `nullify_aws_principal_arn` | Nullify console. | -| `nullify_aws_account_id` | Nullify console. | -| `tenant_external_id` | Nullify console. | +| `nullify_oidc_issuer_uri` | Nullify console. Use `https://gcp.nullify.ai` for prod, `https://gcp.dev.nullify.ai` for dev. | +| `nullify_tenant_id` | Nullify console. Pinned in the WIF provider's `attribute_condition` so this pool only accepts subject tokens minted for your tenant. | ## Permissions See `modules/nullify-gcp-integration/main.tf` for the full list of predefined roles + custom role permissions, with a comment next to each explaining why -Nullify needs it. +Nullify needs it. Full justification per role lives in +[`docs/permissions.md`](../docs/permissions.md). ## Revoking access @@ -68,4 +111,19 @@ terraform destroy ``` This deletes the workload identity provider, the service account and every -IAM binding in one shot. +IAM binding in one shot. Note: the prerequisite Google Cloud APIs are +intentionally NOT disabled on `destroy` (they may be in use by other +resources in your project). + +## Troubleshooting + +| Symptom | Likely cause | +|---|---| +| `Error 400: The attribute condition must reference one of the provider's claims` on `terraform apply` | Your `nullify_tenant_id` is empty or contains characters outside `[A-Za-z0-9_-]`. Re-paste from the Nullify console. | +| `Error 403: Permission 'iam.workloadIdentityPools.create' denied` | The installer is missing `roles/iam.workloadIdentityPoolAdmin` on the host project. | +| `Error: Constraint constraints/iam.workloadIdentityPoolProviders violated` | Your org policy restricts which OIDC issuers are accepted by WIF. Ask your security team to add `nullify_oidc_issuer_uri` to the allowlist. | +| `Error 403: serviceusage.services.use` | The host project doesn't allow the required APIs to be enabled. Have an org admin enable them ahead of `terraform apply` (see *Prerequisites > APIs*). | +| Verify in Nullify console returns red with `oauth2/google: status code 401: ... invalid token` | `nullify_oidc_issuer_uri` doesn't match the URL Nullify's issuer actually signs with. Re-copy from the console — `https://gcp.nullify.ai` for prod, `https://gcp.dev.nullify.ai` for dev. | +| Verify returns red with `permission denied: assertion.tenant_id == "..."` from STS | Your `nullify_tenant_id` doesn't match your actual Nullify tenant id. Re-paste from the console. | +| Verify returns red per project with `PERMISSION_DENIED` | The Nullify SA is missing `roles/iam.serviceAccountTokenCreator` (the WIF binding) or the predefined viewer roles on the project. Re-run `terraform apply`. | +| `Error: ... iam.disableServiceAccountCreation` | An org policy blocks SA creation in this project. Either drop the org policy on the host project, or have an org admin pre-create the SA and contact Nullify support to wire it up. | diff --git a/gcp-integration-setup/terraform/examples/folder/main.tf b/gcp-integration-setup/terraform/examples/folder/main.tf index 65433ff..a386f65 100644 --- a/gcp-integration-setup/terraform/examples/folder/main.tf +++ b/gcp-integration-setup/terraform/examples/folder/main.tf @@ -16,9 +16,9 @@ module "nullify" { organization_id = "123456789012" folder_id = "987654321098" - # From the Nullify console. - nullify_aws_principal_arn = "arn:aws:iam::000000000000:role/nullify-cloud-connector" - nullify_aws_account_id = "000000000000" + # From the Nullify console (Settings -> Cloud Integrations -> GCP). + nullify_oidc_issuer_uri = "https://gcp.nullify.ai" + nullify_tenant_id = "Nullify-XXXXXXXXXXXX" } output "service_account_email" { diff --git a/gcp-integration-setup/terraform/examples/organization/main.tf b/gcp-integration-setup/terraform/examples/organization/main.tf index 040dc77..9db4020 100644 --- a/gcp-integration-setup/terraform/examples/organization/main.tf +++ b/gcp-integration-setup/terraform/examples/organization/main.tf @@ -9,9 +9,9 @@ module "nullify" { scope = "organization" organization_id = "123456789012" - # From the Nullify console. - nullify_aws_principal_arn = "arn:aws:iam::000000000000:role/nullify-cloud-connector" - nullify_aws_account_id = "000000000000" + # From the Nullify console (Settings -> Cloud Integrations -> GCP). + nullify_oidc_issuer_uri = "https://gcp.nullify.ai" + nullify_tenant_id = "Nullify-XXXXXXXXXXXX" } output "service_account_email" { diff --git a/gcp-integration-setup/terraform/examples/single-project/main.tf b/gcp-integration-setup/terraform/examples/single-project/main.tf index 393c6e2..dde3678 100644 --- a/gcp-integration-setup/terraform/examples/single-project/main.tf +++ b/gcp-integration-setup/terraform/examples/single-project/main.tf @@ -12,9 +12,9 @@ module "nullify" { scope = "projects" project_ids = ["acme-security"] - # From the Nullify console. - nullify_aws_principal_arn = "arn:aws:iam::000000000000:role/nullify-cloud-connector" - nullify_aws_account_id = "000000000000" + # From the Nullify console (Settings -> Cloud Integrations -> GCP). + nullify_oidc_issuer_uri = "https://gcp.nullify.ai" + nullify_tenant_id = "Nullify-XXXXXXXXXXXX" } output "service_account_email" { diff --git a/gcp-integration-setup/terraform/main.tf b/gcp-integration-setup/terraform/main.tf index cead575..4aa29c0 100644 --- a/gcp-integration-setup/terraform/main.tf +++ b/gcp-integration-setup/terraform/main.tf @@ -1,15 +1,15 @@ module "nullify_gcp_integration" { source = "./modules/nullify-gcp-integration" - customer_name = var.customer_name - host_project_id = var.host_project_id - scope = var.scope - organization_id = var.organization_id - folder_id = var.folder_id - project_ids = var.project_ids - nullify_aws_principal_arn = var.nullify_aws_principal_arn - nullify_aws_account_id = var.nullify_aws_account_id - wif_pool_id = var.wif_pool_id - wif_provider_id = var.wif_provider_id - service_account_name = var.service_account_name + customer_name = var.customer_name + host_project_id = var.host_project_id + scope = var.scope + organization_id = var.organization_id + folder_id = var.folder_id + project_ids = var.project_ids + nullify_oidc_issuer_uri = var.nullify_oidc_issuer_uri + nullify_tenant_id = var.nullify_tenant_id + wif_pool_id = var.wif_pool_id + wif_provider_id = var.wif_provider_id + service_account_name = var.service_account_name } diff --git a/gcp-integration-setup/terraform/modules/nullify-gcp-integration/apis.tf b/gcp-integration-setup/terraform/modules/nullify-gcp-integration/apis.tf new file mode 100644 index 0000000..8665a4c --- /dev/null +++ b/gcp-integration-setup/terraform/modules/nullify-gcp-integration/apis.tf @@ -0,0 +1,23 @@ +# Required Google Cloud APIs on the host project. Without these enabled, +# the very first `terraform apply` against a fresh project fails with +# cryptic 403 errors from the resource-creation calls (e.g. "Workload +# Identity Pools API has not been used in project … before or it is +# disabled"). Enabling them up front lets Terraform create everything in +# one shot. +# +# `disable_on_destroy = false` because these APIs may be in use by other +# resources in the project; disabling them on `terraform destroy` would +# break those. +resource "google_project_service" "required" { + for_each = toset([ + "iam.googleapis.com", + "iamcredentials.googleapis.com", + "sts.googleapis.com", + "cloudresourcemanager.googleapis.com", + "cloudasset.googleapis.com", + "serviceusage.googleapis.com", + ]) + project = var.host_project_id + service = each.value + disable_on_destroy = false +} diff --git a/gcp-integration-setup/terraform/modules/nullify-gcp-integration/main.tf b/gcp-integration-setup/terraform/modules/nullify-gcp-integration/main.tf index 3be5866..bc5a632 100644 --- a/gcp-integration-setup/terraform/modules/nullify-gcp-integration/main.tf +++ b/gcp-integration-setup/terraform/modules/nullify-gcp-integration/main.tf @@ -2,9 +2,13 @@ # # This module provisions read-only access to a GCP environment for the Nullify # Cloud Connector. The trust model is Workload Identity Federation (WIF) with -# AWS as the source — Nullify's lambdas run on AWS and exchange a signed AWS -# STS GetCallerIdentity request for a short-lived GCP access token, then -# impersonate the service account this module creates. +# OIDC as the source — Nullify acts as an OpenID Connect identity provider, +# minting a per-tenant RS256 JWT with `tenant_id` as a custom claim. The +# subject token is exchanged via Google STS for a short-lived federated +# access token, then used to impersonate the service account this module +# creates. The pool's `attribute_condition` pins trust to the customer's +# specific Nullify tenant id, so even if Nullify's signing key were stolen +# an attacker could not mint a token accepted by another tenant's provider. # # No long-lived secrets are minted by this module. The customer can revoke # access at any time by deleting the workload identity provider, the service @@ -63,10 +67,6 @@ locals { # Read all IAM bindings, custom roles, deny policies, recommendations. "roles/iam.securityReviewer", - # Generic project viewer — gives read on the long tail of services that - # don't have a more specific viewer role. - "roles/viewer", - # Compute (VPC, instances, firewalls, load balancers, routes, NAT, peering). "roles/compute.viewer", @@ -104,14 +104,6 @@ locals { "roles/pubsub.viewer", ] - # Robust extraction of the friendly role name from the Nullify principal - # ARN. The previous regex (`/^arn:aws:iam::[0-9]+:role\\//`) silently broke - # if the role were ever issued under a path (e.g. `role/some/path/Name`), - # because it would leave `some/path/Name` and the assumed-role assertion - # arrives without a path component. The split-and-take-last approach is - # path-tolerant: arn:aws:iam::000000000000:role/some/path/RoleName → RoleName. - nullify_aws_role_name = element(reverse(split("/", var.nullify_aws_principal_arn)), 0) - # When organization_id is set, the custom role can be defined at the # organisation and granted on any project/folder/org within it. This is # the only way `scope = "projects"` with multiple project_ids (or with a @@ -223,6 +215,8 @@ resource "google_service_account" "nullify_cloud_connector" { account_id = var.service_account_name display_name = "Nullify Cloud Connector" description = "Read-only service account impersonated by Nullify via Workload Identity Federation. Managed by Terraform." + + depends_on = [google_project_service.required] } # --------------------------------------------------------------------------- @@ -233,41 +227,50 @@ resource "google_iam_workload_identity_pool" "nullify" { project = var.host_project_id workload_identity_pool_id = var.wif_pool_id display_name = "Nullify Cloud Connector" - description = "Workload identity pool for the Nullify Cloud Connector. Trusts an AWS IAM role from Nullify's AWS account." + description = "Workload identity pool for the Nullify Cloud Connector. Trusts Nullify's OIDC issuer for the customer's specific tenant id." + + depends_on = [google_project_service.required] } -resource "google_iam_workload_identity_pool_provider" "nullify_aws" { +resource "google_iam_workload_identity_pool_provider" "nullify_oidc" { project = var.host_project_id workload_identity_pool_id = google_iam_workload_identity_pool.nullify.workload_identity_pool_id workload_identity_pool_provider_id = var.wif_provider_id - display_name = "Nullify AWS" - description = "Trusts the Nullify AWS IAM role for federated access." - - # AWS source — Nullify's lambdas run on AWS and present a signed STS - # GetCallerIdentity request as the subject token. - aws { - account_id = var.nullify_aws_account_id + display_name = "Nullify OIDC" + description = "Trusts Nullify's OIDC issuer for federated access scoped to this tenant." + + # OIDC source — Nullify mints a signed RS256 JWT in-process and presents + # it as the subject token. Google STS fetches the JWKS document from + # `${nullify_oidc_issuer_uri}/.well-known/jwks.json` to verify the + # signature, then evaluates the attribute_condition below before issuing + # a federated access token. + oidc { + issuer_uri = var.nullify_oidc_issuer_uri } - # Restrict the trust to the exact AWS IAM role Nullify uses. The - # attribute condition runs after Google has validated the signed AWS STS - # request, so a Nullify-account principal that is NOT this role will be - # rejected. - attribute_condition = "attribute.aws_role == \"arn:aws:sts::${var.nullify_aws_account_id}:assumed-role/${local.nullify_aws_role_name}\"" + # Pin trust to this specific Nullify tenant. Nullify's OIDC issuer is + # multi-tenant; the JWT carries a `tenant_id` custom claim. Without this + # condition any Nullify tenant could exchange a token against this + # provider. With it, even if Nullify's signing key were stolen, an + # attacker could not mint a token accepted by another tenant's provider. + attribute_condition = "assertion.tenant_id == \"${var.nullify_tenant_id}\"" attribute_mapping = { - "google.subject" = "assertion.arn" - "attribute.aws_role" = "assertion.arn.contains(\"assumed-role\") ? assertion.arn.extract(\"{anything}assumed-role/\") + \"assumed-role/\" + assertion.arn.extract(\"assumed-role/{role}/\") : assertion.arn" - "attribute.account" = "assertion.account" + "google.subject" = "assertion.sub" + "attribute.tenant_id" = "assertion.tenant_id" } + + depends_on = [google_project_service.required] } -# Allow the Nullify AWS principal (after exchange) to impersonate the -# Nullify service account. +# Allow the Nullify federated principal scoped to this tenant id to +# impersonate the Nullify service account. principalSet on `attribute.tenant_id` +# is the per-tenant binding that makes the integration safe in a +# multi-tenant Nullify deployment. resource "google_service_account_iam_member" "nullify_workload_identity_user" { service_account_id = google_service_account.nullify_cloud_connector.name role = "roles/iam.workloadIdentityUser" - member = "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.nullify.name}/attribute.aws_role/arn:aws:sts::${var.nullify_aws_account_id}:assumed-role/${local.nullify_aws_role_name}" + member = "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.nullify.name}/attribute.tenant_id/${var.nullify_tenant_id}" } # --------------------------------------------------------------------------- diff --git a/gcp-integration-setup/terraform/modules/nullify-gcp-integration/outputs.tf b/gcp-integration-setup/terraform/modules/nullify-gcp-integration/outputs.tf index fda0e05..20417c7 100644 --- a/gcp-integration-setup/terraform/modules/nullify-gcp-integration/outputs.tf +++ b/gcp-integration-setup/terraform/modules/nullify-gcp-integration/outputs.tf @@ -4,8 +4,8 @@ output "service_account_email" { } output "workload_identity_provider" { - description = "Full resource path of the Workload Identity Provider Nullify will use to exchange AWS credentials for GCP tokens. Paste this into the Nullify console." - value = "projects/${data.google_project.host.number}/locations/global/workloadIdentityPools/${google_iam_workload_identity_pool.nullify.workload_identity_pool_id}/providers/${google_iam_workload_identity_pool_provider.nullify_aws.workload_identity_pool_provider_id}" + description = "Full resource path of the Workload Identity Provider Nullify will use to exchange OIDC subject tokens for GCP access tokens. Paste this into the Nullify console." + value = "projects/${data.google_project.host.number}/locations/global/workloadIdentityPools/${google_iam_workload_identity_pool.nullify.workload_identity_pool_id}/providers/${google_iam_workload_identity_pool_provider.nullify_oidc.workload_identity_pool_provider_id}" } output "workload_identity_pool" { diff --git a/gcp-integration-setup/terraform/modules/nullify-gcp-integration/variables.tf b/gcp-integration-setup/terraform/modules/nullify-gcp-integration/variables.tf index f5240c6..f64da1e 100644 --- a/gcp-integration-setup/terraform/modules/nullify-gcp-integration/variables.tf +++ b/gcp-integration-setup/terraform/modules/nullify-gcp-integration/variables.tf @@ -10,6 +10,12 @@ variable "customer_name" { variable "host_project_id" { description = "The GCP project that owns the workload identity pool, the Nullify service account and (when no organization_id is provided) the project-level custom role. For org-wide installs this is typically a dedicated security project." type = string + validation { + # GCP project ID rules: 6-30 chars, start with letter, end with + # letter/digit, lowercase letters/digits/hyphens only. + condition = can(regex("^[a-z][a-z0-9-]{4,28}[a-z0-9]$", var.host_project_id)) + error_message = "host_project_id must be 6-30 chars, start with a lowercase letter, end with a lowercase letter or digit, and contain only lowercase letters, digits, and hyphens." + } } variable "scope" { @@ -44,24 +50,21 @@ variable "project_ids" { default = [] } -variable "nullify_aws_principal_arn" { - description = "The AWS IAM role ARN that Nullify uses to call your GCP environment via Workload Identity Federation. Provided in the Nullify console; never change this value yourself." +variable "nullify_oidc_issuer_uri" { + description = "Nullify's OIDC issuer URL (e.g. https://gcp.nullify.ai for prod, https://gcp.dev.nullify.ai for dev). Provided in the Nullify console under Settings -> Cloud Integrations -> GCP. Google STS fetches the JWKS document from `{issuer}/.well-known/jwks.json` to verify subject token signatures." type = string validation { - # Allow paths in the role ARN — e.g. arn:aws:iam::000:role/path/to/Name. - # The friendly name (everything after the last "/") is what shows up in - # the assumed-role assertion and is what we pin the WIF condition on. - condition = can(regex("^arn:aws:iam::[0-9]{12}:role/.+$", var.nullify_aws_principal_arn)) - error_message = "nullify_aws_principal_arn must be a valid AWS IAM role ARN." + condition = startswith(var.nullify_oidc_issuer_uri, "https://") && !endswith(var.nullify_oidc_issuer_uri, "/") + error_message = "nullify_oidc_issuer_uri must start with https:// and not end with a trailing slash." } } -variable "nullify_aws_account_id" { - description = "The AWS account ID Nullify operates from. Used as the audience subject in the workload identity provider attribute condition. Provided in the Nullify console." +variable "nullify_tenant_id" { + description = "Your Nullify tenant id. Provided in the Nullify console under Settings -> Cloud Integrations -> GCP. Embedded in the WIF provider's attribute_condition so the pool only accepts subject tokens minted for THIS tenant; this is the per-tenant isolation that makes the integration safe in a multi-tenant Nullify deployment." type = string validation { - condition = can(regex("^[0-9]{12}$", var.nullify_aws_account_id)) - error_message = "nullify_aws_account_id must be a 12 digit AWS account ID." + condition = length(var.nullify_tenant_id) > 0 && length(var.nullify_tenant_id) <= 100 && can(regex("^[A-Za-z0-9_-]+$", var.nullify_tenant_id)) + error_message = "nullify_tenant_id must be 1-100 characters of [A-Za-z0-9_-]." } } @@ -72,9 +75,9 @@ variable "wif_pool_id" { } variable "wif_provider_id" { - description = "ID for the Workload Identity Provider that trusts the Nullify AWS principal. Must be unique within the pool." + description = "ID for the Workload Identity Provider that trusts Nullify's OIDC issuer. Must be unique within the pool." type = string - default = "nullify-aws" + default = "nullify-oidc" } variable "service_account_name" { diff --git a/gcp-integration-setup/terraform/terraform.tfvars.example b/gcp-integration-setup/terraform/terraform.tfvars.example index 22b293f..b8629c4 100644 --- a/gcp-integration-setup/terraform/terraform.tfvars.example +++ b/gcp-integration-setup/terraform/terraform.tfvars.example @@ -24,6 +24,9 @@ organization_id = "123456789012" # project_ids = ["acme-prod", "acme-staging"] # organization_id = "123456789012" # required for multi-project installs -# Values from the Nullify console — DO NOT change these manually. -nullify_aws_principal_arn = "arn:aws:iam::000000000000:role/nullify-cloud-connector" -nullify_aws_account_id = "000000000000" +# Values from the Nullify console (Settings -> Cloud Integrations -> GCP). +# DO NOT change these manually. +# - Use https://gcp.nullify.ai for the prod Nullify environment. +# - Use https://gcp.dev.nullify.ai for the dev Nullify environment. +nullify_oidc_issuer_uri = "https://gcp.nullify.ai" +nullify_tenant_id = "Nullify-XXXXXXXXXXXX" diff --git a/gcp-integration-setup/terraform/variables.tf b/gcp-integration-setup/terraform/variables.tf index 22bcc1a..6a7abf9 100644 --- a/gcp-integration-setup/terraform/variables.tf +++ b/gcp-integration-setup/terraform/variables.tf @@ -6,6 +6,10 @@ variable "customer_name" { variable "host_project_id" { description = "GCP project that owns the Nullify service account, workload identity pool and (when no organization_id is provided) the project-level custom role. Typically a dedicated security project." type = string + validation { + condition = can(regex("^[a-z][a-z0-9-]{4,28}[a-z0-9]$", var.host_project_id)) + error_message = "host_project_id must be 6-30 chars, start with a lowercase letter, end with a lowercase letter or digit, and contain only lowercase letters, digits, and hyphens." + } } variable "scope" { @@ -36,14 +40,22 @@ variable "project_ids" { default = [] } -variable "nullify_aws_principal_arn" { - description = "AWS IAM role ARN that Nullify uses to call your GCP environment. Provided in the Nullify console under Settings -> Cloud Integrations -> GCP." +variable "nullify_oidc_issuer_uri" { + description = "Nullify's OIDC issuer URL (e.g. https://gcp.nullify.ai for prod). Provided in the Nullify console under Settings -> Cloud Integrations -> GCP." type = string + validation { + condition = startswith(var.nullify_oidc_issuer_uri, "https://") && !endswith(var.nullify_oidc_issuer_uri, "/") + error_message = "nullify_oidc_issuer_uri must start with https:// and not end with a trailing slash." + } } -variable "nullify_aws_account_id" { - description = "AWS account ID Nullify operates from. Provided in the Nullify console." +variable "nullify_tenant_id" { + description = "Your Nullify tenant id. Provided in the Nullify console under Settings -> Cloud Integrations -> GCP. Pinned in the workload identity provider's attribute_condition so this pool only accepts subject tokens minted for this tenant." type = string + validation { + condition = length(var.nullify_tenant_id) > 0 && length(var.nullify_tenant_id) <= 100 && can(regex("^[A-Za-z0-9_-]+$", var.nullify_tenant_id)) + error_message = "nullify_tenant_id must be 1-100 characters of [A-Za-z0-9_-]." + } } variable "wif_pool_id" { @@ -55,7 +67,7 @@ variable "wif_pool_id" { variable "wif_provider_id" { description = "ID for the Workload Identity Provider." type = string - default = "nullify-aws" + default = "nullify-oidc" } variable "service_account_name" { From 0aa2f51bbe7058d7ffc9093eafc6200ca2ba4e77 Mon Sep 17 00:00:00 2001 From: Tim Thacker Date: Sun, 19 Apr 2026 14:42:36 +1000 Subject: [PATCH 2/2] fix(gcp): address review feedback on PR #42 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - main.tf: drop stale "Nullify AWS principal" section header; WIF provider now says "OIDC Provider trusting Nullify's OIDC issuer" matching the code below. - main.tf: fix broken reference to non-existent `modules/nullify-gcp-integration/README.md` — point at the real file `../../docs/permissions.md` instead. - install.sh: wrap the first IAM call after `gcloud services enable` in a 5-attempt retry with 10s backoff. The IAM API's "enabled" state can take 10-30s to propagate on a fresh host project, and the previous straight-through invocation would fail with a cryptic 403 on the first install for customers. - README.md: add a Prerequisites bullet clarifying that `scope = "projects"` requires `roles/resourcemanager.projectIamAdmin` on EVERY project in `project_ids`, not just `host_project_id`. Without this the predefined-role bindings fail on each sibling project. All three are nits individually but each is the kind of thing that derails a live customer install demo. Verified: - `terraform fmt -check -recursive` clean - `terraform validate` clean on module + every example - `shellcheck scripts/install.sh` clean Co-Authored-By: Claude Opus 4.7 (1M context) --- gcp-integration-setup/scripts/install.sh | 25 ++++++++++++++++--- gcp-integration-setup/terraform/README.md | 12 ++++++--- .../modules/nullify-gcp-integration/main.tf | 4 +-- 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/gcp-integration-setup/scripts/install.sh b/gcp-integration-setup/scripts/install.sh index 3b65f21..cf0d9e8 100755 --- a/gcp-integration-setup/scripts/install.sh +++ b/gcp-integration-setup/scripts/install.sh @@ -41,11 +41,28 @@ gcloud services enable \ serviceusage.googleapis.com \ --project="${NULLIFY_HOST_PROJECT}" +# API enablement is documented as synchronous but the IAM API's "enabled" +# state propagates eventually-consistent. On a fresh project the next +# service-accounts call can still return 403 for 10-30s. Retry the first +# IAM call a few times to absorb this, then every later gcloud iam call +# reuses the same warmed state. echo "==> Creating service account ${SA_EMAIL}" -gcloud iam service-accounts describe "${SA_EMAIL}" --project="${NULLIFY_HOST_PROJECT}" >/dev/null 2>&1 || \ - gcloud iam service-accounts create "${SA_NAME}" \ - --project="${NULLIFY_HOST_PROJECT}" \ - --display-name="Nullify Cloud Connector" +for attempt in 1 2 3 4 5; do + if gcloud iam service-accounts describe "${SA_EMAIL}" --project="${NULLIFY_HOST_PROJECT}" >/dev/null 2>&1; then + break + fi + if gcloud iam service-accounts create "${SA_NAME}" \ + --project="${NULLIFY_HOST_PROJECT}" \ + --display-name="Nullify Cloud Connector" 2>/dev/null; then + break + fi + if [ "${attempt}" -eq 5 ]; then + echo "ERROR: failed to create service account after 5 attempts (IAM API not yet usable on ${NULLIFY_HOST_PROJECT}?)" >&2 + exit 1 + fi + echo " waiting for IAM API to propagate (attempt ${attempt}/5)..." >&2 + sleep 10 +done echo "==> Creating workload identity pool ${POOL_ID}" gcloud iam workload-identity-pools describe "${POOL_ID}" \ diff --git a/gcp-integration-setup/terraform/README.md b/gcp-integration-setup/terraform/README.md index 13790a4..25fabea 100644 --- a/gcp-integration-setup/terraform/README.md +++ b/gcp-integration-setup/terraform/README.md @@ -38,11 +38,17 @@ The human / service identity running `terraform apply` needs, at minimum: - `roles/iam.workloadIdentityPoolAdmin` — create the WIF pool + provider - `roles/iam.serviceAccountAdmin` — create the Nullify service account -- `roles/serviceusage.serviceUsageAdmin` — enable the required APIs +- `roles/serviceusage.serviceUsageAdmin` — enable the required APIs (on + the host project) - `roles/iam.organizationRoleAdmin` — only when `scope` is `organization` or `folder` (the long-tail custom role must be defined at the org) -- `roles/resourcemanager.organizationAdmin` or `roles/resourcemanager.folderAdmin` — only - when granting bindings at the org or folder +- `roles/resourcemanager.organizationAdmin` or + `roles/resourcemanager.folderAdmin` — only when granting bindings at the + org or folder +- `roles/resourcemanager.projectIamAdmin` — when `scope = "projects"`, + needed on **every** project listed in `project_ids` (not just + `host_project_id`) so the module can grant the viewer + custom role + bindings on each If you want a least-privilege one-off install, request these roles on the operator running the apply and revoke them afterwards. diff --git a/gcp-integration-setup/terraform/modules/nullify-gcp-integration/main.tf b/gcp-integration-setup/terraform/modules/nullify-gcp-integration/main.tf index bc5a632..cb6060b 100644 --- a/gcp-integration-setup/terraform/modules/nullify-gcp-integration/main.tf +++ b/gcp-integration-setup/terraform/modules/nullify-gcp-integration/main.tf @@ -20,7 +20,7 @@ # - secret manager: secret metadata only, never secret payloads # - bigquery: dataset metadata only, never table rows # -# See modules/nullify-gcp-integration/README.md for the full permission list. +# See ../../docs/permissions.md for the full permission list + rationale. # --------------------------------------------------------------------------- # Input validation that needs to look at multiple variables. Per-variable @@ -220,7 +220,7 @@ resource "google_service_account" "nullify_cloud_connector" { } # --------------------------------------------------------------------------- -# Workload Identity Pool + Provider trusting the Nullify AWS principal. +# Workload Identity Pool + OIDC Provider trusting Nullify's OIDC issuer. # --------------------------------------------------------------------------- resource "google_iam_workload_identity_pool" "nullify" {