From bfacde78e61da8539b8aee268cf5da8a5115369b Mon Sep 17 00:00:00 2001 From: Tim Thacker Date: Wed, 8 Apr 2026 10:55:14 +1000 Subject: [PATCH 1/2] feat(gcp): add GCP cloud connector terraform module + gcloud installer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a read-only GCP integration option matching the AWS pattern. Trust model is Workload Identity Federation only — no service account JSON keys, no long-lived secrets. Customer can revoke at any time via terraform destroy or the uninstall.sh script. Layout (mirrors aws-integration-setup): gcp-integration-setup/ terraform/ main.tf, variables.tf, outputs.tf, providers.tf, versions.tf terraform.tfvars.example README.md modules/nullify-gcp-integration/ main.tf - WIF pool + AWS provider, SA, custom role, bindings variables.tf, outputs.tf, versions.tf examples/ organization/main.tf - org-wide install single-project/main.tf - per-project install scripts/ install.sh - idempotent gcloud one-shot installer uninstall.sh - revocation counterpart docs/ permissions.md - every role + custom permission documented with rationale Permissions are intentionally read-only and limited to service-config and network-topology metadata. Explicit non-grants: - storage.objectViewer (object data) - secretmanager.secretAccessor (secret payloads) - bigquery.dataViewer (table rows) - any write/admin role Predefined viewer roles: cloudasset.viewer, iam.securityReviewer, viewer, compute.viewer, container.clusterViewer, cloudsql.viewer, spanner.viewer, cloudkms.viewer, logging.viewer, run.viewer, cloudfunctions.viewer, appengine.appViewer, dataproc.viewer, dataflow.viewer, pubsub.viewer. Custom role nullifyCloudConnector covers the long tail (Cloud Armor, VPC Service Controls, AlloyDB, Filestore, Memorystore, Cloud DNS, API Gateway, Artifact Registry metadata) with strict *.get/*.list allowlist. The Workload Identity provider is configured with an attribute_condition restricting trust to a single Nullify AWS IAM role. Even if the WIF pool is exposed, only the exact Nullify principal can mint a token. This matches the external_account credential JSON the Nullify backend synthesises in hyperdrive/pkg/cloudintegrations/gcp/auth.go. Module supports two scoping modes via the `scope` variable: - "organization" (recommended): bind roles at the org level - "projects": bind roles only on the project_ids list (POC mode) After terraform apply the customer pastes service_account_email and workload_identity_provider into the Nullify console -> Settings -> Cloud Integrations -> GCP and clicks Verify. Co-Authored-By: Claude Opus 4.6 (1M context) --- README.md | 1 + gcp-integration-setup/docs/permissions.md | 79 ++++++ gcp-integration-setup/scripts/install.sh | 93 +++++++ gcp-integration-setup/scripts/uninstall.sh | 57 +++++ gcp-integration-setup/terraform/README.md | 71 ++++++ .../terraform/examples/organization/main.tf | 24 ++ .../terraform/examples/single-project/main.tf | 24 ++ gcp-integration-setup/terraform/main.tf | 16 ++ .../modules/nullify-gcp-integration/main.tf | 237 ++++++++++++++++++ .../nullify-gcp-integration/outputs.tf | 28 +++ .../nullify-gcp-integration/variables.tf | 82 ++++++ .../nullify-gcp-integration/versions.tf | 14 ++ gcp-integration-setup/terraform/outputs.tf | 26 ++ gcp-integration-setup/terraform/providers.tf | 7 + .../terraform/terraform.tfvars.example | 20 ++ gcp-integration-setup/terraform/variables.tf | 70 ++++++ gcp-integration-setup/terraform/versions.tf | 14 ++ 17 files changed, 863 insertions(+) create mode 100644 gcp-integration-setup/docs/permissions.md create mode 100755 gcp-integration-setup/scripts/install.sh create mode 100755 gcp-integration-setup/scripts/uninstall.sh create mode 100644 gcp-integration-setup/terraform/README.md create mode 100644 gcp-integration-setup/terraform/examples/organization/main.tf create mode 100644 gcp-integration-setup/terraform/examples/single-project/main.tf create mode 100644 gcp-integration-setup/terraform/main.tf create mode 100644 gcp-integration-setup/terraform/modules/nullify-gcp-integration/main.tf create mode 100644 gcp-integration-setup/terraform/modules/nullify-gcp-integration/outputs.tf create mode 100644 gcp-integration-setup/terraform/modules/nullify-gcp-integration/variables.tf create mode 100644 gcp-integration-setup/terraform/modules/nullify-gcp-integration/versions.tf create mode 100644 gcp-integration-setup/terraform/outputs.tf create mode 100644 gcp-integration-setup/terraform/providers.tf create mode 100644 gcp-integration-setup/terraform/terraform.tfvars.example create mode 100644 gcp-integration-setup/terraform/variables.tf create mode 100644 gcp-integration-setup/terraform/versions.tf diff --git a/README.md b/README.md index 5a7638e..5ffb3dc 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,7 @@ This repository provides comprehensive infrastructure-as-code templates for inte 1. **Helm Charts** (`helm-charts/`) - For Kubernetes-native deployments 2. **CloudFormation** (`aws-integration-setup/cloudformation/`) - For AWS-centric infrastructure 3. **Terraform** (`aws-integration-setup/terraform/`) - For infrastructure-as-code workflows with modular architecture +4. **GCP Terraform** (`gcp-integration-setup/terraform/`) - For Google Cloud read-only integration via Workload Identity Federation ## 🚀 **Quick Start** diff --git a/gcp-integration-setup/docs/permissions.md b/gcp-integration-setup/docs/permissions.md new file mode 100644 index 0000000..8c7f4ab --- /dev/null +++ b/gcp-integration-setup/docs/permissions.md @@ -0,0 +1,79 @@ +# GCP permissions granted to Nullify + +This document explains every IAM role and custom permission the Nullify +Cloud Connector requests, and why. Use it to satisfy security review. + +## Trust model + +- **Workload Identity Federation (WIF)**, AWS source. +- 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. + +## Predefined roles + +| Role | Why Nullify needs it | +| --- | --- | +| `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. | +| `roles/spanner.viewer` | Spanner instance + database config. | +| `roles/cloudkms.viewer` | KMS key ring + crypto key config (no key material). | +| `roles/logging.viewer` | Logging sink + exclusion config (no log content). | +| `roles/run.viewer` | Cloud Run service + revision config. | +| `roles/cloudfunctions.viewer` | Cloud Functions config. | +| `roles/appengine.appViewer` | App Engine service + version config. | +| `roles/dataproc.viewer` | Dataproc cluster + job config. | +| `roles/dataflow.viewer` | Dataflow job config. | +| `roles/pubsub.viewer` | Pub/Sub topic + subscription config. | + +## Custom role: `nullifyCloudConnector` + +Read-only permissions on services that don't have a predefined viewer role. +Strict allowlist of `*.get` / `*.list` only. + +| Permission | Purpose | +| --- | --- | +| `compute.securityPolicies.get/list` | Cloud Armor WAF rule discovery. | +| `accesscontextmanager.accessPolicies.get/list` | VPC Service Controls access policies. | +| `accesscontextmanager.servicePerimeters.get/list` | VPC Service Controls perimeters. | +| `orgpolicy.policies.list` + `orgpolicy.policy.get` | Organisation policy discovery. | +| `alloydb.clusters.get/list` + `alloydb.instances.get/list` | AlloyDB topology. | +| `file.instances.get/list` | Filestore instance config. | +| `redis.instances.get/list` + `memcache.instances.get/list` | Memorystore instance config. | +| `artifactregistry.repositories.get/list` | Artifact Registry repo metadata (no image content). | +| `dns.managedZones.get/list` + `dns.resourceRecordSets.list` | Cloud DNS zone + record discovery. | +| `apigateway.gateways.get/list` + `apigateway.apis.get/list` + `apigateway.apiconfigs.get/list` | API Gateway topology. | + +## What Nullify cannot do + +| Capability | Granted? | Why not | +| --- | --- | --- | +| Read object data from Cloud Storage | No | `roles/storage.objectViewer` is intentionally **not** granted. We only see bucket metadata. | +| Read secret payloads from Secret Manager | No | `roles/secretmanager.secretAccessor` is intentionally **not** granted. We only see secret names and metadata. | +| Read BigQuery table rows | No | `roles/bigquery.dataViewer` is intentionally **not** granted. We only see dataset metadata. | +| Modify your environment | No | Every role above is read-only. There are no write/admin roles. | +| Run code or workloads | No | No `roles/run.invoker`, `roles/cloudfunctions.invoker` etc. | + +## Revoking access + +```bash +cd nullify-cloud-connector/gcp-integration-setup/terraform +terraform destroy +``` + +Or via gcloud: + +```bash +nullify-cloud-connector/gcp-integration-setup/scripts/uninstall.sh +``` + +Either path deletes the workload identity pool, the service account, and +every IAM binding in one shot. diff --git a/gcp-integration-setup/scripts/install.sh b/gcp-integration-setup/scripts/install.sh new file mode 100755 index 0000000..a36b4e0 --- /dev/null +++ b/gcp-integration-setup/scripts/install.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash +# install.sh — gcloud-only installer for the Nullify GCP Cloud Connector. +# +# This is an alternative to the Terraform module under ../terraform. Use it +# when you want to provision read-only Nullify access without Terraform. +# +# 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_TENANT_EXTERNAL_ID="REPLACE_WITH_VALUE_FROM_NULLIFY_CONSOLE" +# ./install.sh +# +# Re-running this script is idempotent — every gcloud command checks for +# existing resources before creating them. + +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_TENANT_EXTERNAL_ID:?NULLIFY_TENANT_EXTERNAL_ID is required}" + +POOL_ID="${NULLIFY_WIF_POOL_ID:-nullify-cloud-connector}" +PROVIDER_ID="${NULLIFY_WIF_PROVIDER_ID:-nullify-aws}" +SA_NAME="${NULLIFY_SA_NAME:-nullify-cloud-connector}" +SA_EMAIL="${SA_NAME}@${NULLIFY_HOST_PROJECT}.iam.gserviceaccount.com" + +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" + +echo "==> Creating workload identity pool ${POOL_ID}" +gcloud iam workload-identity-pools describe "${POOL_ID}" \ + --project="${NULLIFY_HOST_PROJECT}" --location=global >/dev/null 2>&1 || \ + gcloud iam workload-identity-pools create "${POOL_ID}" \ + --project="${NULLIFY_HOST_PROJECT}" --location=global \ + --display-name="Nullify Cloud Connector" + +echo "==> Creating workload identity provider ${PROVIDER_ID} (AWS 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}" \ + --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" + +echo "==> Allowing Nullify principal to impersonate the service account" +NULLIFY_ROLE_NAME="${NULLIFY_AWS_PRINCIPAL_ARN##*/}" +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}" + +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 + roles/spanner.viewer + roles/cloudkms.viewer + roles/logging.viewer + roles/run.viewer + roles/cloudfunctions.viewer + roles/appengine.appViewer + roles/dataproc.viewer + roles/dataflow.viewer + roles/pubsub.viewer +) +for role in "${ROLES[@]}"; do + gcloud organizations add-iam-policy-binding "${NULLIFY_ORG_ID}" \ + --member="serviceAccount:${SA_EMAIL}" \ + --role="${role}" \ + --condition=None >/dev/null +done + +echo +echo "Nullify GCP integration installed successfully." +echo +echo "Paste these values into the Nullify console under Settings -> Cloud Integrations -> GCP:" +echo " Service Account Email: ${SA_EMAIL}" +echo " Workload Identity Provider: projects/$(gcloud projects describe "${NULLIFY_HOST_PROJECT}" --format='value(projectNumber)')/locations/global/workloadIdentityPools/${POOL_ID}/providers/${PROVIDER_ID}" diff --git a/gcp-integration-setup/scripts/uninstall.sh b/gcp-integration-setup/scripts/uninstall.sh new file mode 100755 index 0000000..8cdf8d1 --- /dev/null +++ b/gcp-integration-setup/scripts/uninstall.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# uninstall.sh — revoke the Nullify GCP Cloud Connector installed by install.sh. +# +# This is the gcloud-only counterpart to `terraform destroy`. If you used the +# Terraform module, run `terraform destroy` instead. + +set -euo pipefail + +: "${NULLIFY_HOST_PROJECT:?NULLIFY_HOST_PROJECT is required}" +: "${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}" +SA_NAME="${NULLIFY_SA_NAME:-nullify-cloud-connector}" +SA_EMAIL="${SA_NAME}@${NULLIFY_HOST_PROJECT}.iam.gserviceaccount.com" + +ROLES=( + roles/cloudasset.viewer + roles/iam.securityReviewer + roles/viewer + roles/compute.viewer + roles/container.clusterViewer + roles/cloudsql.viewer + roles/spanner.viewer + roles/cloudkms.viewer + roles/logging.viewer + roles/run.viewer + roles/cloudfunctions.viewer + roles/appengine.appViewer + roles/dataproc.viewer + roles/dataflow.viewer + roles/pubsub.viewer +) + +echo "==> Removing organisation IAM bindings" +for role in "${ROLES[@]}"; do + gcloud organizations remove-iam-policy-binding "${NULLIFY_ORG_ID}" \ + --member="serviceAccount:${SA_EMAIL}" \ + --role="${role}" \ + --condition=None >/dev/null 2>&1 || true +done + +echo "==> Deleting workload identity provider ${PROVIDER_ID}" +gcloud iam workload-identity-pools providers delete "${PROVIDER_ID}" \ + --project="${NULLIFY_HOST_PROJECT}" --location=global \ + --workload-identity-pool="${POOL_ID}" --quiet 2>/dev/null || true + +echo "==> Deleting workload identity pool ${POOL_ID}" +gcloud iam workload-identity-pools delete "${POOL_ID}" \ + --project="${NULLIFY_HOST_PROJECT}" --location=global --quiet 2>/dev/null || true + +echo "==> Deleting service account ${SA_EMAIL}" +gcloud iam service-accounts delete "${SA_EMAIL}" \ + --project="${NULLIFY_HOST_PROJECT}" --quiet 2>/dev/null || true + +echo +echo "Nullify GCP integration uninstalled." diff --git a/gcp-integration-setup/terraform/README.md b/gcp-integration-setup/terraform/README.md new file mode 100644 index 0000000..4057e0f --- /dev/null +++ b/gcp-integration-setup/terraform/README.md @@ -0,0 +1,71 @@ +# 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. + +## 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). +- 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. + +## What this does NOT provision + +- No data-plane permissions. Nullify can list buckets but cannot read + objects. Nullify can list secrets but cannot read secret payloads. + Nullify can list BigQuery datasets but cannot read table rows. +- No write permissions. Nullify cannot modify your environment. +- No long-lived secrets. Revoke at any time with `terraform destroy`. + +## Quick start + +```bash +git clone https://github.com/Nullify-Platform/nullify-cloud-connector.git +cd nullify-cloud-connector/gcp-integration-setup/terraform + +cp terraform.tfvars.example terraform.tfvars +$EDITOR terraform.tfvars # fill in values from the Nullify console + +terraform init +terraform plan +terraform apply +``` + +After `apply`, copy the `service_account_email` and `workload_identity_provider` +outputs into the Nullify console under Settings -> Cloud Integrations -> GCP +and click "Verify". + +## Required inputs + +| Variable | Source | +| --- | --- | +| `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`. | +| `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. | + +## 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. + +## Revoking access + +```bash +terraform destroy +``` + +This deletes the workload identity provider, the service account and every +IAM binding in one shot. diff --git a/gcp-integration-setup/terraform/examples/organization/main.tf b/gcp-integration-setup/terraform/examples/organization/main.tf new file mode 100644 index 0000000..872b086 --- /dev/null +++ b/gcp-integration-setup/terraform/examples/organization/main.tf @@ -0,0 +1,24 @@ +# Example: organisation-wide install. + +module "nullify" { + source = "../../" + + customer_name = "acme-corp" + host_project_id = "acme-security" + + 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" + tenant_external_id = "REPLACE_WITH_VALUE_FROM_NULLIFY_CONSOLE" +} + +output "service_account_email" { + value = module.nullify.service_account_email +} + +output "workload_identity_provider" { + value = module.nullify.workload_identity_provider +} diff --git a/gcp-integration-setup/terraform/examples/single-project/main.tf b/gcp-integration-setup/terraform/examples/single-project/main.tf new file mode 100644 index 0000000..f7c43cb --- /dev/null +++ b/gcp-integration-setup/terraform/examples/single-project/main.tf @@ -0,0 +1,24 @@ +# Example: per-project install (e.g. for proof-of-concept on a single project). + +module "nullify" { + source = "../../" + + customer_name = "acme-corp" + host_project_id = "acme-security" + + scope = "projects" + project_ids = ["acme-prod"] + + # From the Nullify console. + nullify_aws_principal_arn = "arn:aws:iam::000000000000:role/nullify-cloud-connector" + nullify_aws_account_id = "000000000000" + tenant_external_id = "REPLACE_WITH_VALUE_FROM_NULLIFY_CONSOLE" +} + +output "service_account_email" { + value = module.nullify.service_account_email +} + +output "workload_identity_provider" { + value = module.nullify.workload_identity_provider +} diff --git a/gcp-integration-setup/terraform/main.tf b/gcp-integration-setup/terraform/main.tf new file mode 100644 index 0000000..1c87649 --- /dev/null +++ b/gcp-integration-setup/terraform/main.tf @@ -0,0 +1,16 @@ +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 + project_ids = var.project_ids + nullify_aws_principal_arn = var.nullify_aws_principal_arn + nullify_aws_account_id = var.nullify_aws_account_id + tenant_external_id = var.tenant_external_id + wif_pool_id = var.wif_pool_id + wif_provider_id = var.wif_provider_id + service_account_name = var.service_account_name + labels = var.labels +} diff --git a/gcp-integration-setup/terraform/modules/nullify-gcp-integration/main.tf b/gcp-integration-setup/terraform/modules/nullify-gcp-integration/main.tf new file mode 100644 index 0000000..979be80 --- /dev/null +++ b/gcp-integration-setup/terraform/modules/nullify-gcp-integration/main.tf @@ -0,0 +1,237 @@ +# Nullify GCP Cloud Connector +# +# 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. +# +# 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 +# account, or both. +# +# Permissions are intentionally read-only and limited to service-configuration +# and network-topology metadata. There are NO data-plane permissions: +# - storage: bucket metadata only, never object data +# - 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. + +locals { + common_labels = merge( + { + managed-by = "nullify-cloud-connector" + customer-name = lower(var.customer_name) + tenant-external-id = lower(var.tenant_external_id) + }, + var.labels, + ) + + # Predefined viewer roles granted to the Nullify service account. Each role + # is named here so an auditor can trace why each binding exists. + predefined_viewer_roles = [ + # Organisation-wide asset enumeration. Cheapest way to read everything. + "roles/cloudasset.viewer", + + # 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", + + # GKE clusters and node pools. + "roles/container.clusterViewer", + + # Cloud SQL instances + replicas. + "roles/cloudsql.viewer", + + # Spanner instances + databases. + "roles/spanner.viewer", + + # KMS key rings + crypto keys. + "roles/cloudkms.viewer", + + # Logging sinks + exclusions (config only — no log payload access). + "roles/logging.viewer", + + # Cloud Run services + revisions. + "roles/run.viewer", + + # Cloud Functions config (function metadata, source URLs). + "roles/cloudfunctions.viewer", + + # App Engine services + versions. + "roles/appengine.appViewer", + + # Dataproc clusters and jobs. + "roles/dataproc.viewer", + + # Dataflow jobs. + "roles/dataflow.viewer", + + # Pub/Sub topics + subscriptions. + "roles/pubsub.viewer", + ] +} + +# --------------------------------------------------------------------------- +# Custom role: long-tail read permissions Nullify needs that are not covered +# by any predefined viewer role. Strict allowlist of *.get / *.list only. +# --------------------------------------------------------------------------- + +resource "google_project_iam_custom_role" "nullify_cloud_connector" { + project = var.host_project_id + role_id = "nullifyCloudConnector" + title = "Nullify Cloud Connector (read-only)" + description = "Read-only access to security-relevant config Nullify needs that is not covered by predefined viewer roles." + stage = "GA" + + permissions = [ + # Cloud Armor security policies (ingress WAF rules). + "compute.securityPolicies.get", + "compute.securityPolicies.list", + + # VPC Service Controls perimeters and access policies. + "accesscontextmanager.accessPolicies.get", + "accesscontextmanager.accessPolicies.list", + "accesscontextmanager.servicePerimeters.get", + "accesscontextmanager.servicePerimeters.list", + + # Organisation policies. + "orgpolicy.policies.list", + "orgpolicy.policy.get", + + # AlloyDB clusters + instances. + "alloydb.clusters.get", + "alloydb.clusters.list", + "alloydb.instances.get", + "alloydb.instances.list", + + # Filestore instances. + "file.instances.get", + "file.instances.list", + + # Memorystore (Redis + Memcache) instances. + "redis.instances.get", + "redis.instances.list", + "memcache.instances.get", + "memcache.instances.list", + + # Artifact Registry repositories (metadata only — no image content). + "artifactregistry.repositories.get", + "artifactregistry.repositories.list", + + # Cloud DNS managed zones + record sets. + "dns.managedZones.get", + "dns.managedZones.list", + "dns.resourceRecordSets.list", + + # API Gateway gateways + APIs + configs. + "apigateway.gateways.get", + "apigateway.gateways.list", + "apigateway.apis.get", + "apigateway.apis.list", + "apigateway.apiconfigs.get", + "apigateway.apiconfigs.list", + ] +} + +# --------------------------------------------------------------------------- +# Service account that Nullify impersonates after the WIF token exchange. +# --------------------------------------------------------------------------- + +resource "google_service_account" "nullify_cloud_connector" { + project = var.host_project_id + 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." +} + +# --------------------------------------------------------------------------- +# Workload Identity Pool + Provider trusting the Nullify AWS principal. +# --------------------------------------------------------------------------- + +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." +} + +resource "google_iam_workload_identity_pool_provider" "nullify_aws" { + 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 + } + + # 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/${replace(var.nullify_aws_principal_arn, "/^arn:aws:iam::[0-9]+:role\\//", "")}\"" + + 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" + } +} + +# Allow the Nullify AWS principal (after exchange) to impersonate the +# Nullify service account. +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/${replace(var.nullify_aws_principal_arn, "/^arn:aws:iam::[0-9]+:role\\//", "")}" +} + +# --------------------------------------------------------------------------- +# Role bindings — organisation scope. +# --------------------------------------------------------------------------- + +resource "google_organization_iam_member" "predefined" { + for_each = var.scope == "organization" ? toset(local.predefined_viewer_roles) : toset([]) + org_id = var.organization_id + role = each.value + member = "serviceAccount:${google_service_account.nullify_cloud_connector.email}" +} + +resource "google_organization_iam_member" "custom" { + count = var.scope == "organization" ? 1 : 0 + org_id = var.organization_id + role = google_project_iam_custom_role.nullify_cloud_connector.name + member = "serviceAccount:${google_service_account.nullify_cloud_connector.email}" +} + +# --------------------------------------------------------------------------- +# Role bindings — per-project scope. +# --------------------------------------------------------------------------- + +resource "google_project_iam_member" "predefined" { + for_each = var.scope == "projects" ? { + for pair in setproduct(var.project_ids, local.predefined_viewer_roles) : + "${pair[0]}|${pair[1]}" => { project = pair[0], role = pair[1] } + } : {} + project = each.value.project + role = each.value.role + member = "serviceAccount:${google_service_account.nullify_cloud_connector.email}" +} + +resource "google_project_iam_member" "custom" { + for_each = var.scope == "projects" ? toset(var.project_ids) : toset([]) + project = each.value + role = google_project_iam_custom_role.nullify_cloud_connector.name + member = "serviceAccount:${google_service_account.nullify_cloud_connector.email}" +} diff --git a/gcp-integration-setup/terraform/modules/nullify-gcp-integration/outputs.tf b/gcp-integration-setup/terraform/modules/nullify-gcp-integration/outputs.tf new file mode 100644 index 0000000..084afdc --- /dev/null +++ b/gcp-integration-setup/terraform/modules/nullify-gcp-integration/outputs.tf @@ -0,0 +1,28 @@ +output "service_account_email" { + description = "Email address of the service account Nullify will impersonate. Paste this into the Nullify console under Settings -> Cloud Integrations -> GCP." + value = google_service_account.nullify_cloud_connector.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}" +} + +output "workload_identity_pool" { + description = "Resource name of the Workload Identity Pool. Useful when manually inspecting the trust configuration via gcloud." + value = google_iam_workload_identity_pool.nullify.name +} + +output "custom_role_id" { + description = "Full ID of the custom role that grants the long-tail read permissions Nullify needs." + value = google_project_iam_custom_role.nullify_cloud_connector.id +} + +output "scope" { + description = "Echoes the scope chosen at apply time so it shows up in the plan output for auditors." + value = var.scope +} + +data "google_project" "host" { + project_id = var.host_project_id +} diff --git a/gcp-integration-setup/terraform/modules/nullify-gcp-integration/variables.tf b/gcp-integration-setup/terraform/modules/nullify-gcp-integration/variables.tf new file mode 100644 index 0000000..e3288f0 --- /dev/null +++ b/gcp-integration-setup/terraform/modules/nullify-gcp-integration/variables.tf @@ -0,0 +1,82 @@ +variable "customer_name" { + description = "Short identifier for your organisation. Used as a label on every Nullify-managed resource for traceability." + type = string + validation { + condition = length(var.customer_name) >= 2 && length(var.customer_name) <= 30 + error_message = "customer_name must be between 2 and 30 characters." + } +} + +variable "host_project_id" { + description = "The GCP project that owns the workload identity pool, the Nullify service account and the IAM bindings. For org-wide installs this is typically a dedicated security project." + type = string +} + +variable "scope" { + description = "Whether Nullify should be granted read access at the organization level (recommended for full coverage) or only on a list of specific projects." + type = string + default = "organization" + validation { + condition = contains(["organization", "projects"], var.scope) + error_message = "scope must be either \"organization\" or \"projects\"." + } +} + +variable "organization_id" { + description = "GCP organization numeric ID. Required when scope = \"organization\"." + type = string + default = "" +} + +variable "project_ids" { + description = "List of GCP project IDs to grant access to. Required when scope = \"projects\"." + type = list(string) + 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." + type = string + validation { + 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." + } +} + +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." + 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." + } +} + +variable "tenant_external_id" { + description = "Per-tenant external identifier from the Nullify console. Embedded as a label on the WIF pool so Nullify can correlate inbound tokens with the right customer." + type = string +} + +variable "wif_pool_id" { + description = "ID for the Workload Identity Pool that will be created. Must be unique within the host project." + type = string + default = "nullify-cloud-connector" +} + +variable "wif_provider_id" { + description = "ID for the Workload Identity Provider that trusts the Nullify AWS principal. Must be unique within the pool." + type = string + default = "nullify-aws" +} + +variable "service_account_name" { + description = "Name of the customer-side service account that Nullify impersonates after the WIF token exchange." + type = string + default = "nullify-cloud-connector" +} + +variable "labels" { + description = "Additional labels to apply to every resource created by this module." + type = map(string) + default = {} +} diff --git a/gcp-integration-setup/terraform/modules/nullify-gcp-integration/versions.tf b/gcp-integration-setup/terraform/modules/nullify-gcp-integration/versions.tf new file mode 100644 index 0000000..0d03cc5 --- /dev/null +++ b/gcp-integration-setup/terraform/modules/nullify-gcp-integration/versions.tf @@ -0,0 +1,14 @@ +terraform { + required_version = ">= 1.5.0" + + required_providers { + google = { + source = "hashicorp/google" + version = ">= 5.0.0, < 7.0.0" + } + google-beta = { + source = "hashicorp/google-beta" + version = ">= 5.0.0, < 7.0.0" + } + } +} diff --git a/gcp-integration-setup/terraform/outputs.tf b/gcp-integration-setup/terraform/outputs.tf new file mode 100644 index 0000000..80bdf80 --- /dev/null +++ b/gcp-integration-setup/terraform/outputs.tf @@ -0,0 +1,26 @@ +output "service_account_email" { + description = "Paste this into the Nullify console under Settings -> Cloud Integrations -> GCP -> Impersonated Service Account." + value = module.nullify_gcp_integration.service_account_email +} + +output "workload_identity_provider" { + description = "Paste this into the Nullify console under Settings -> Cloud Integrations -> GCP -> Workload Identity Provider." + value = module.nullify_gcp_integration.workload_identity_provider +} + +output "next_steps" { + description = "What to do after a successful terraform apply." + value = <<-EOT + + Nullify GCP integration provisioned successfully. + + Next steps: + 1. Open the Nullify console -> Settings -> Cloud Integrations -> GCP. + 2. Paste the service_account_email output above into "Impersonated Service Account". + 3. Paste the workload_identity_provider output above into "Workload Identity Provider". + 4. Click "Verify". You should see a green check next to every project. + 5. Click "Save". + + To revoke access at any time, run `terraform destroy` from this directory. + EOT +} diff --git a/gcp-integration-setup/terraform/providers.tf b/gcp-integration-setup/terraform/providers.tf new file mode 100644 index 0000000..aa02de2 --- /dev/null +++ b/gcp-integration-setup/terraform/providers.tf @@ -0,0 +1,7 @@ +provider "google" { + project = var.host_project_id +} + +provider "google-beta" { + project = var.host_project_id +} diff --git a/gcp-integration-setup/terraform/terraform.tfvars.example b/gcp-integration-setup/terraform/terraform.tfvars.example new file mode 100644 index 0000000..bcfc98b --- /dev/null +++ b/gcp-integration-setup/terraform/terraform.tfvars.example @@ -0,0 +1,20 @@ +# Copy this file to terraform.tfvars and fill in the values from the Nullify +# console (Settings -> Cloud Integrations -> GCP). + +customer_name = "acme-corp" +host_project_id = "acme-security" + +# Choose ONE of the two scoping modes below. + +# Option A: Organization-wide (recommended for full coverage). +scope = "organization" +organization_id = "123456789012" + +# Option B: Per-project. Comment out the two lines above and uncomment these. +# scope = "projects" +# project_ids = ["acme-prod", "acme-staging"] + +# 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" +tenant_external_id = "REPLACE_WITH_VALUE_FROM_NULLIFY_CONSOLE" diff --git a/gcp-integration-setup/terraform/variables.tf b/gcp-integration-setup/terraform/variables.tf new file mode 100644 index 0000000..6c3513b --- /dev/null +++ b/gcp-integration-setup/terraform/variables.tf @@ -0,0 +1,70 @@ +variable "customer_name" { + description = "Short identifier for your organisation. Used as a label on every Nullify-managed resource." + type = string +} + +variable "host_project_id" { + description = "GCP project that owns the Nullify service account, workload identity pool and IAM bindings. Typically a dedicated security project." + type = string +} + +variable "scope" { + description = "Granularity of access. \"organization\" grants the read role on every project in the org (recommended). \"projects\" grants only on the project_ids list." + type = string + default = "organization" + validation { + condition = contains(["organization", "projects"], var.scope) + error_message = "scope must be either \"organization\" or \"projects\"." + } +} + +variable "organization_id" { + description = "Numeric GCP organization ID. Required when scope = organization." + type = string + default = "" +} + +variable "project_ids" { + description = "List of project IDs to grant access on. Required when scope = projects." + type = list(string) + 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." + type = string +} + +variable "nullify_aws_account_id" { + description = "AWS account ID Nullify operates from. Provided in the Nullify console." + type = string +} + +variable "tenant_external_id" { + description = "Per-tenant external identifier from the Nullify console. Embedded as a label so Nullify can correlate inbound tokens with the right customer." + type = string +} + +variable "wif_pool_id" { + description = "ID for the Workload Identity Pool that will be created." + type = string + default = "nullify-cloud-connector" +} + +variable "wif_provider_id" { + description = "ID for the Workload Identity Provider." + type = string + default = "nullify-aws" +} + +variable "service_account_name" { + description = "Name of the customer-side service account Nullify impersonates." + type = string + default = "nullify-cloud-connector" +} + +variable "labels" { + description = "Extra labels to apply to every Nullify-managed resource." + type = map(string) + default = {} +} diff --git a/gcp-integration-setup/terraform/versions.tf b/gcp-integration-setup/terraform/versions.tf new file mode 100644 index 0000000..0d03cc5 --- /dev/null +++ b/gcp-integration-setup/terraform/versions.tf @@ -0,0 +1,14 @@ +terraform { + required_version = ">= 1.5.0" + + required_providers { + google = { + source = "hashicorp/google" + version = ">= 5.0.0, < 7.0.0" + } + google-beta = { + source = "hashicorp/google-beta" + version = ">= 5.0.0, < 7.0.0" + } + } +} From bd18d49a3887738a555e1413ae8a163649c7de57 Mon Sep 17 00:00:00 2001 From: Tim Thacker Date: Thu, 9 Apr 2026 14:58:16 +1000 Subject: [PATCH 2/2] fix(gcp): address PR #40 review feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Larger pass through vik-nullify's review of the GCP integration module. ## Custom-role binding bug (apply-time failure) `google_project_iam_custom_role` only assigns within its defining project, so binding it on the organisation (`google_organization_iam_member.custom`) or on a sibling project (`google_project_iam_member.custom` when project_ids != [host_project_id]) failed at apply time. The `examples/single-project` example tripped this today. Fix: introduce `google_organization_iam_custom_role.nullify_cloud_connector` and select between the two variants via `local.use_org_custom_role` based on whether organization_id is set. Bindings now reference `local.custom_role_id`. The single-project example is rewired so host_project_id and project_ids match (the only configuration that works without an organization_id), and a new module-level `terraform_data.input_validation` precondition rejects multi-project installs that omit organization_id at plan time rather than blowing up mid-apply. ## Folder scope Added `scope = "folder"` with a `folder_id` variable, matching `google_folder_iam_member` bindings (predefined + custom), and a new `examples/folder/` example. Org_id is required for folder scope so the custom role can be defined at the organisation and assigned on the folder. ## install.sh ↔ Terraform parity The shell installer now mirrors the Terraform module's security posture: 1. **`--attribute-condition`** is now passed to `gcloud iam workload-identity-pools providers create-aws`. Previously the WIF provider trusted any principal in the Nullify AWS account rather than the single role Terraform pins. 2. **Custom role created and bound** at the organisation, mirroring `local.custom_role_permissions`. Previously the gcloud path silently missed Cloud Armor / VPC SC / orgpolicy / AlloyDB / Filestore / Memorystore / Artifact Registry / Cloud DNS / API Gateway permissions. 3. **`uninstall.sh`** now removes the custom-role binding and deletes the org-level custom role. 4. **`NULLIFY_TENANT_EXTERNAL_ID`** is no longer required by install.sh because the variable was dead code in the Terraform module too (see below). 5. The script header documents that install.sh is org-scope-only and points folder/project-scope users at Terraform. ## `tenant_external_id` / `labels` were dead code The module declared `tenant_external_id` and `labels`, merged them into `local.common_labels`, and never referenced `common_labels` anywhere else. GCP IAM resources don't expose a `labels` argument and no resource block in the module set one, so the variables had zero on-cluster effect. README and module docstrings claimed otherwise. Removed the merge, the locals block, and both variables. The examples and tfvars.example are updated. The `customer_name` variable is kept (still useful for support correlation) but its description now reflects that it isn't actually a resource label. ## AWS role-path regex `replace(var.nullify_aws_principal_arn, "/^arn:aws:iam::[0-9]+:role\\//", "")` left a `path/RoleName` if the principal were ever issued under a path, which would never match the assumed-role assertion (assumed-role ARNs contain only the friendly name). Replaced with a path-tolerant `element(reverse(split("/", ...)), 0)` extraction in the new `local.nullify_aws_role_name`, used in both the WIF provider's `attribute_condition` and the service account's `workloadIdentityUser` binding. install.sh already used `${var##*/}` which is equivalent. ## Cleanup - `google-beta` provider declaration and `providers.tf` block dropped from the root and the module — no resource was using it. - `data "google_project" "host"` moved out of `outputs.tf` into a new `data.tf` so reviewers don't have to find a data source at the bottom of an outputs file. - `next_steps` output wording updated to read sensibly at org / folder / project scope (was hardcoded "green check next to every project"). - Top-level README gains a GCP quick-start section pointing at the module's README. ## CI New `.github/workflows/terraform-validate.yml` runs `terraform fmt -check`, `terraform init -backend=false`, and `terraform validate` against the root module and every example (`gcp-integration-setup/terraform`, the three examples, and the existing AWS modules), plus `shellcheck` on install.sh / uninstall.sh. Targets the same set of paths the customer would actually apply, so a regression of the org-binding bug above (or any future structural mistake) gets caught at PR time rather than at customer apply time. Verified locally: - `terraform fmt -check -recursive gcp-integration-setup/` clean - `terraform validate` passes for the root module + all 3 examples - `shellcheck` clean for install.sh and uninstall.sh Co-Authored-By: Claude Opus 4.6 (1M context) --- .github/workflows/terraform-validate.yml | 70 ++++++++ README.md | 18 ++- gcp-integration-setup/scripts/install.sh | 52 +++++- gcp-integration-setup/scripts/uninstall.sh | 6 + .../terraform/examples/folder/main.tf | 30 ++++ .../terraform/examples/organization/main.tf | 3 +- .../terraform/examples/single-project/main.tf | 8 +- gcp-integration-setup/terraform/main.tf | 23 ++- .../modules/nullify-gcp-integration/data.tf | 6 + .../modules/nullify-gcp-integration/main.tf | 151 ++++++++++++++---- .../nullify-gcp-integration/outputs.tf | 7 +- .../nullify-gcp-integration/variables.tf | 36 +++-- .../nullify-gcp-integration/versions.tf | 4 - gcp-integration-setup/terraform/outputs.tf | 4 +- gcp-integration-setup/terraform/providers.tf | 4 - .../terraform/terraform.tfvars.example | 19 ++- gcp-integration-setup/terraform/variables.tf | 31 ++-- gcp-integration-setup/terraform/versions.tf | 4 - 18 files changed, 368 insertions(+), 108 deletions(-) create mode 100644 .github/workflows/terraform-validate.yml create mode 100644 gcp-integration-setup/terraform/examples/folder/main.tf create mode 100644 gcp-integration-setup/terraform/modules/nullify-gcp-integration/data.tf diff --git a/.github/workflows/terraform-validate.yml b/.github/workflows/terraform-validate.yml new file mode 100644 index 0000000..85de7d1 --- /dev/null +++ b/.github/workflows/terraform-validate.yml @@ -0,0 +1,70 @@ +name: Terraform validate + +# Lints and validates the customer-facing Terraform modules so a regression +# in either gcp-integration-setup or aws-integration-setup gets caught in CI +# rather than at customer apply time. The historical incident this is meant +# to prevent: a project-scoped custom role bound at the org level (#40) that +# only blew up when a customer ran terraform apply. + +on: + pull_request: + paths: + - "gcp-integration-setup/**" + - "aws-integration-setup/**" + - ".github/workflows/terraform-validate.yml" + push: + branches: + - main + paths: + - "gcp-integration-setup/**" + - "aws-integration-setup/**" + - ".github/workflows/terraform-validate.yml" + +permissions: + contents: read + +jobs: + terraform: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + directory: + - gcp-integration-setup/terraform + - gcp-integration-setup/terraform/examples/organization + - gcp-integration-setup/terraform/examples/folder + - gcp-integration-setup/terraform/examples/single-project + - aws-integration-setup/terraform + - aws-integration-setup/terraform/examples/basic + - aws-integration-setup/terraform/examples/multi-cluster-complete + steps: + - uses: actions/checkout@v4 + + - uses: hashicorp/setup-terraform@v3 + with: + terraform_version: 1.9.5 + terraform_wrapper: false + + - name: terraform fmt -check + working-directory: ${{ matrix.directory }} + run: terraform fmt -check -recursive + + - name: terraform init -backend=false + working-directory: ${{ matrix.directory }} + run: terraform init -backend=false + + - name: terraform validate + working-directory: ${{ matrix.directory }} + run: terraform validate + + shellcheck: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: shellcheck install + run + run: | + sudo apt-get update -y >/dev/null + sudo apt-get install -y shellcheck >/dev/null + shellcheck gcp-integration-setup/scripts/install.sh + shellcheck gcp-integration-setup/scripts/uninstall.sh diff --git a/README.md b/README.md index 5ffb3dc..8d15168 100644 --- a/README.md +++ b/README.md @@ -37,7 +37,23 @@ This repository provides comprehensive infrastructure-as-code templates for inte |--------|----------|---------------| | **🎯 Helm Charts** | Kubernetes-native teams, GitOps workflows | EKS cluster, Helm 3.x, kubectl | | **🏗️ CloudFormation** | AWS-centric infrastructure, ClickOps teams | AWS CLI, appropriate IAM permissions | -| **🔧 Terraform** | Infrastructure-as-code, multi-cluster teams | Terraform, AWS provider configured | +| **🔧 Terraform (AWS)** | Infrastructure-as-code, multi-cluster teams | Terraform, AWS provider configured | +| **☁️ Terraform (GCP)** | GCP environments, Workload Identity Federation | Terraform, `gcloud` auth on the host project, org or folder admin access | + +### **GCP Quick Start** + +The GCP integration uses Workload Identity Federation only — no long-lived service account JSON keys are minted. + +```bash +cd gcp-integration-setup/terraform +cp terraform.tfvars.example terraform.tfvars +$EDITOR terraform.tfvars # paste values from the Nullify console +terraform init && terraform apply +``` + +Then paste the `service_account_email` and `workload_identity_provider` outputs into the Nullify console under **Settings → Cloud Integrations → GCP** and click **Verify**. + +See [`gcp-integration-setup/terraform/README.md`](gcp-integration-setup/terraform/README.md) for the full guide, the org / folder / project scope options, and the gcloud-only installer. ### **Prerequisites (All Methods)** diff --git a/gcp-integration-setup/scripts/install.sh b/gcp-integration-setup/scripts/install.sh index a36b4e0..ed6a8b8 100755 --- a/gcp-integration-setup/scripts/install.sh +++ b/gcp-integration-setup/scripts/install.sh @@ -1,15 +1,16 @@ #!/usr/bin/env bash # install.sh — gcloud-only installer for the Nullify GCP Cloud Connector. # -# This is an alternative to the Terraform module under ../terraform. Use it -# when you want to provision read-only Nullify access without Terraform. +# This is an organisation-scope alternative to the Terraform module under +# ../terraform. Use it when you want to provision read-only Nullify access +# without Terraform. Folder and project scopes are NOT supported here — +# use the Terraform module for those modes. # # 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_TENANT_EXTERNAL_ID="REPLACE_WITH_VALUE_FROM_NULLIFY_CONSOLE" # ./install.sh # # Re-running this script is idempotent — every gcloud command checks for @@ -21,13 +22,17 @@ set -euo pipefail : "${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_TENANT_EXTERNAL_ID:?NULLIFY_TENANT_EXTERNAL_ID is required}" POOL_ID="${NULLIFY_WIF_POOL_ID:-nullify-cloud-connector}" PROVIDER_ID="${NULLIFY_WIF_PROVIDER_ID:-nullify-aws}" 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##*/}" + 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}" \ @@ -41,6 +46,12 @@ 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}\"" + echo "==> Creating workload identity provider ${PROVIDER_ID} (AWS source)" gcloud iam workload-identity-pools providers describe "${PROVIDER_ID}" \ --project="${NULLIFY_HOST_PROJECT}" --location=global \ @@ -49,10 +60,10 @@ gcloud iam workload-identity-pools providers describe "${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" + --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" \ + --attribute-condition="${ATTRIBUTE_CONDITION}" echo "==> Allowing Nullify principal to impersonate the service account" -NULLIFY_ROLE_NAME="${NULLIFY_AWS_PRINCIPAL_ARN##*/}" 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}" \ @@ -60,6 +71,34 @@ gcloud iam service-accounts add-iam-policy-binding "${SA_EMAIL}" \ --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}" +# Custom role for the long-tail permissions Nullify needs that are not +# covered by predefined viewer roles. Mirrors locals.custom_role_permissions +# in the Terraform module. Defined at the organisation so it's assignable +# anywhere in the hierarchy. +CUSTOM_ROLE_ID="nullifyCloudConnector" +CUSTOM_ROLE_TITLE="Nullify Cloud Connector (read-only)" +CUSTOM_ROLE_DESCRIPTION="Read-only access to security-relevant config Nullify needs that is not covered by predefined viewer roles." + +# shellcheck disable=SC2089 +CUSTOM_ROLE_PERMISSIONS="compute.securityPolicies.get,compute.securityPolicies.list,accesscontextmanager.accessPolicies.get,accesscontextmanager.accessPolicies.list,accesscontextmanager.servicePerimeters.get,accesscontextmanager.servicePerimeters.list,orgpolicy.policies.list,orgpolicy.policy.get,alloydb.clusters.get,alloydb.clusters.list,alloydb.instances.get,alloydb.instances.list,file.instances.get,file.instances.list,redis.instances.get,redis.instances.list,memcache.instances.get,memcache.instances.list,artifactregistry.repositories.get,artifactregistry.repositories.list,dns.managedZones.get,dns.managedZones.list,dns.resourceRecordSets.list,apigateway.gateways.get,apigateway.gateways.list,apigateway.apis.get,apigateway.apis.list,apigateway.apiconfigs.get,apigateway.apiconfigs.list" + +echo "==> Creating organisation custom role ${CUSTOM_ROLE_ID}" +if gcloud iam roles describe "${CUSTOM_ROLE_ID}" --organization="${NULLIFY_ORG_ID}" >/dev/null 2>&1; then + gcloud iam roles update "${CUSTOM_ROLE_ID}" \ + --organization="${NULLIFY_ORG_ID}" \ + --title="${CUSTOM_ROLE_TITLE}" \ + --description="${CUSTOM_ROLE_DESCRIPTION}" \ + --permissions="${CUSTOM_ROLE_PERMISSIONS}" \ + --stage=GA >/dev/null +else + gcloud iam roles create "${CUSTOM_ROLE_ID}" \ + --organization="${NULLIFY_ORG_ID}" \ + --title="${CUSTOM_ROLE_TITLE}" \ + --description="${CUSTOM_ROLE_DESCRIPTION}" \ + --permissions="${CUSTOM_ROLE_PERMISSIONS}" \ + --stage=GA >/dev/null +fi + echo "==> Granting predefined viewer roles at the organisation" ROLES=( roles/cloudasset.viewer @@ -77,6 +116,7 @@ ROLES=( roles/dataproc.viewer roles/dataflow.viewer roles/pubsub.viewer + "organizations/${NULLIFY_ORG_ID}/roles/${CUSTOM_ROLE_ID}" ) for role in "${ROLES[@]}"; do gcloud organizations add-iam-policy-binding "${NULLIFY_ORG_ID}" \ diff --git a/gcp-integration-setup/scripts/uninstall.sh b/gcp-integration-setup/scripts/uninstall.sh index 8cdf8d1..1d610b8 100755 --- a/gcp-integration-setup/scripts/uninstall.sh +++ b/gcp-integration-setup/scripts/uninstall.sh @@ -13,6 +13,7 @@ POOL_ID="${NULLIFY_WIF_POOL_ID:-nullify-cloud-connector}" PROVIDER_ID="${NULLIFY_WIF_PROVIDER_ID:-nullify-aws}" SA_NAME="${NULLIFY_SA_NAME:-nullify-cloud-connector}" SA_EMAIL="${SA_NAME}@${NULLIFY_HOST_PROJECT}.iam.gserviceaccount.com" +CUSTOM_ROLE_ID="nullifyCloudConnector" ROLES=( roles/cloudasset.viewer @@ -30,6 +31,7 @@ ROLES=( roles/dataproc.viewer roles/dataflow.viewer roles/pubsub.viewer + "organizations/${NULLIFY_ORG_ID}/roles/${CUSTOM_ROLE_ID}" ) echo "==> Removing organisation IAM bindings" @@ -53,5 +55,9 @@ echo "==> Deleting service account ${SA_EMAIL}" gcloud iam service-accounts delete "${SA_EMAIL}" \ --project="${NULLIFY_HOST_PROJECT}" --quiet 2>/dev/null || true +echo "==> Deleting organisation custom role ${CUSTOM_ROLE_ID}" +gcloud iam roles delete "${CUSTOM_ROLE_ID}" \ + --organization="${NULLIFY_ORG_ID}" --quiet 2>/dev/null || true + echo echo "Nullify GCP integration uninstalled." diff --git a/gcp-integration-setup/terraform/examples/folder/main.tf b/gcp-integration-setup/terraform/examples/folder/main.tf new file mode 100644 index 0000000..65433ff --- /dev/null +++ b/gcp-integration-setup/terraform/examples/folder/main.tf @@ -0,0 +1,30 @@ +# Example: folder-scoped install. Use this when your GCP hierarchy is carved +# up by folder (e.g. a dedicated `security/` or `prod/` folder) and you want +# Nullify pinned to one of those folders without going org-wide. +# +# `organization_id` is required even though the bindings are folder-scoped: +# the long-tail custom role must be defined at the organisation so it can +# be assigned on the folder. + +module "nullify" { + source = "../../" + + customer_name = "acme-corp" + host_project_id = "acme-security" + + scope = "folder" + 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" +} + +output "service_account_email" { + value = module.nullify.service_account_email +} + +output "workload_identity_provider" { + value = module.nullify.workload_identity_provider +} diff --git a/gcp-integration-setup/terraform/examples/organization/main.tf b/gcp-integration-setup/terraform/examples/organization/main.tf index 872b086..040dc77 100644 --- a/gcp-integration-setup/terraform/examples/organization/main.tf +++ b/gcp-integration-setup/terraform/examples/organization/main.tf @@ -1,4 +1,4 @@ -# Example: organisation-wide install. +# Example: organisation-wide install (recommended). module "nullify" { source = "../../" @@ -12,7 +12,6 @@ module "nullify" { # From the Nullify console. nullify_aws_principal_arn = "arn:aws:iam::000000000000:role/nullify-cloud-connector" nullify_aws_account_id = "000000000000" - tenant_external_id = "REPLACE_WITH_VALUE_FROM_NULLIFY_CONSOLE" } 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 f7c43cb..393c6e2 100644 --- a/gcp-integration-setup/terraform/examples/single-project/main.tf +++ b/gcp-integration-setup/terraform/examples/single-project/main.tf @@ -1,4 +1,7 @@ -# Example: per-project install (e.g. for proof-of-concept on a single project). +# Example: per-project install (e.g. for a proof of concept on a single +# project). The `host_project_id` and the only entry in `project_ids` MUST +# match — granting on cross-project bindings requires `organization_id` to +# be set so the long-tail custom role can be defined at the org level. module "nullify" { source = "../../" @@ -7,12 +10,11 @@ module "nullify" { host_project_id = "acme-security" scope = "projects" - project_ids = ["acme-prod"] + 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" - tenant_external_id = "REPLACE_WITH_VALUE_FROM_NULLIFY_CONSOLE" } output "service_account_email" { diff --git a/gcp-integration-setup/terraform/main.tf b/gcp-integration-setup/terraform/main.tf index 1c87649..cead575 100644 --- a/gcp-integration-setup/terraform/main.tf +++ b/gcp-integration-setup/terraform/main.tf @@ -1,16 +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 - project_ids = var.project_ids - nullify_aws_principal_arn = var.nullify_aws_principal_arn - nullify_aws_account_id = var.nullify_aws_account_id - tenant_external_id = var.tenant_external_id - wif_pool_id = var.wif_pool_id - wif_provider_id = var.wif_provider_id - service_account_name = var.service_account_name - labels = var.labels + 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 } diff --git a/gcp-integration-setup/terraform/modules/nullify-gcp-integration/data.tf b/gcp-integration-setup/terraform/modules/nullify-gcp-integration/data.tf new file mode 100644 index 0000000..20cb1ab --- /dev/null +++ b/gcp-integration-setup/terraform/modules/nullify-gcp-integration/data.tf @@ -0,0 +1,6 @@ +# Project lookup used by outputs.tf to compose the workload_identity_provider +# resource path. Lives in its own data.tf so reviewers don't have to find it +# at the bottom of outputs.tf. +data "google_project" "host" { + project_id = var.host_project_id +} 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 979be80..3be5866 100644 --- a/gcp-integration-setup/terraform/modules/nullify-gcp-integration/main.tf +++ b/gcp-integration-setup/terraform/modules/nullify-gcp-integration/main.tf @@ -18,16 +18,42 @@ # # See modules/nullify-gcp-integration/README.md for the full permission list. -locals { - common_labels = merge( - { - managed-by = "nullify-cloud-connector" - customer-name = lower(var.customer_name) - tenant-external-id = lower(var.tenant_external_id) - }, - var.labels, - ) +# --------------------------------------------------------------------------- +# Input validation that needs to look at multiple variables. Per-variable +# `validation` blocks can't reference other vars, so we use a no-op +# terraform_data resource with `precondition` checks instead. +# --------------------------------------------------------------------------- +resource "terraform_data" "input_validation" { + lifecycle { + precondition { + condition = var.scope != "organization" || var.organization_id != "" + error_message = "scope = \"organization\" requires organization_id to be set." + } + precondition { + condition = var.scope != "folder" || var.folder_id != "" + error_message = "scope = \"folder\" requires folder_id to be set." + } + precondition { + condition = var.scope != "folder" || var.organization_id != "" + error_message = "scope = \"folder\" requires organization_id to be set so the long-tail custom role can be defined at the organisation and granted on the folder." + } + precondition { + condition = var.scope != "projects" || length(var.project_ids) > 0 + error_message = "scope = \"projects\" requires project_ids to be non-empty." + } + precondition { + # If any project_id is different from host_project_id, the custom role + # must be assignable across projects, which requires the org-level + # variant. Block the apply early rather than having terraform try to + # bind a project-scoped custom role on a sibling project. + condition = var.scope != "projects" || var.organization_id != "" || alltrue([for p in var.project_ids : p == var.host_project_id]) + error_message = "scope = \"projects\" with project_ids that include any project other than host_project_id requires organization_id to be set, because the custom role must be defined at the organisation to be assignable across projects." + } + } +} + +locals { # Predefined viewer roles granted to the Nullify service account. Each role # is named here so an auditor can trace why each binding exists. predefined_viewer_roles = [ @@ -77,21 +103,31 @@ locals { # Pub/Sub topics + subscriptions. "roles/pubsub.viewer", ] -} - -# --------------------------------------------------------------------------- -# Custom role: long-tail read permissions Nullify needs that are not covered -# by any predefined viewer role. Strict allowlist of *.get / *.list only. -# --------------------------------------------------------------------------- -resource "google_project_iam_custom_role" "nullify_cloud_connector" { - project = var.host_project_id - role_id = "nullifyCloudConnector" - title = "Nullify Cloud Connector (read-only)" - description = "Read-only access to security-relevant config Nullify needs that is not covered by predefined viewer roles." - stage = "GA" - - permissions = [ + # 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 + # project_id != host_project_id) can work, because a project-scoped custom + # role is only assignable on resources inside that project. + use_org_custom_role = var.organization_id != "" + + # The fully qualified custom-role ID downstream bindings reference. We + # build it once here so the bindings don't have to know which of the two + # custom-role resources actually exists. + custom_role_id = local.use_org_custom_role ? google_organization_iam_custom_role.nullify_cloud_connector[0].id : google_project_iam_custom_role.nullify_cloud_connector[0].id + + # The full set of permissions Nullify needs above and beyond the predefined + # viewer roles. Strict allowlist of *.get / *.list only — no mutations and + # no data-plane reads. + custom_role_permissions = [ # Cloud Armor security policies (ingress WAF rules). "compute.securityPolicies.get", "compute.securityPolicies.list", @@ -141,6 +177,43 @@ resource "google_project_iam_custom_role" "nullify_cloud_connector" { ] } +# --------------------------------------------------------------------------- +# Custom role: long-tail read permissions Nullify needs that are not covered +# by any predefined viewer role. +# +# Two variants exist because GCP's IAM model is unforgiving here: +# - google_project_iam_custom_role only assignable on resources within the +# defining project. Fine for single-project installs. +# - google_organization_iam_custom_role assignable on any project, folder, +# or the org itself. Required for org-scope, folder-scope, and any +# multi-project install. +# +# Selection is keyed off var.organization_id: providing it switches to the +# org-level role automatically. The previous bug was creating only the +# project-level role and trying to grant it at the org / on cross-project +# bindings, which fails at apply time. +# --------------------------------------------------------------------------- + +resource "google_organization_iam_custom_role" "nullify_cloud_connector" { + count = local.use_org_custom_role ? 1 : 0 + org_id = var.organization_id + role_id = "nullifyCloudConnector" + title = "Nullify Cloud Connector (read-only)" + description = "Read-only access to security-relevant config Nullify needs that is not covered by predefined viewer roles." + stage = "GA" + permissions = local.custom_role_permissions +} + +resource "google_project_iam_custom_role" "nullify_cloud_connector" { + count = local.use_org_custom_role ? 0 : 1 + project = var.host_project_id + role_id = "nullifyCloudConnector" + title = "Nullify Cloud Connector (read-only)" + description = "Read-only access to security-relevant config Nullify needs that is not covered by predefined viewer roles." + stage = "GA" + permissions = local.custom_role_permissions +} + # --------------------------------------------------------------------------- # Service account that Nullify impersonates after the WIF token exchange. # --------------------------------------------------------------------------- @@ -180,7 +253,7 @@ resource "google_iam_workload_identity_pool_provider" "nullify_aws" { # 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/${replace(var.nullify_aws_principal_arn, "/^arn:aws:iam::[0-9]+:role\\//", "")}\"" + attribute_condition = "attribute.aws_role == \"arn:aws:sts::${var.nullify_aws_account_id}:assumed-role/${local.nullify_aws_role_name}\"" attribute_mapping = { "google.subject" = "assertion.arn" @@ -194,7 +267,7 @@ resource "google_iam_workload_identity_pool_provider" "nullify_aws" { 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/${replace(var.nullify_aws_principal_arn, "/^arn:aws:iam::[0-9]+:role\\//", "")}" + 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}" } # --------------------------------------------------------------------------- @@ -211,7 +284,31 @@ resource "google_organization_iam_member" "predefined" { resource "google_organization_iam_member" "custom" { count = var.scope == "organization" ? 1 : 0 org_id = var.organization_id - role = google_project_iam_custom_role.nullify_cloud_connector.name + role = local.custom_role_id + member = "serviceAccount:${google_service_account.nullify_cloud_connector.email}" +} + +# --------------------------------------------------------------------------- +# Role bindings — folder scope. +# +# Folder-scoped installs are common when an org carves its hierarchy into +# functional folders (e.g. `security/`, `prod/`) and the customer wants +# Nullify pinned to one of those without going org-wide. The custom role +# must come from the org-level resource for this to apply, so org_id is +# required when scope = "folder". +# --------------------------------------------------------------------------- + +resource "google_folder_iam_member" "predefined" { + for_each = var.scope == "folder" ? toset(local.predefined_viewer_roles) : toset([]) + folder = var.folder_id + role = each.value + member = "serviceAccount:${google_service_account.nullify_cloud_connector.email}" +} + +resource "google_folder_iam_member" "custom" { + count = var.scope == "folder" ? 1 : 0 + folder = var.folder_id + role = local.custom_role_id member = "serviceAccount:${google_service_account.nullify_cloud_connector.email}" } @@ -232,6 +329,6 @@ resource "google_project_iam_member" "predefined" { resource "google_project_iam_member" "custom" { for_each = var.scope == "projects" ? toset(var.project_ids) : toset([]) project = each.value - role = google_project_iam_custom_role.nullify_cloud_connector.name + role = local.custom_role_id member = "serviceAccount:${google_service_account.nullify_cloud_connector.email}" } 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 084afdc..fda0e05 100644 --- a/gcp-integration-setup/terraform/modules/nullify-gcp-integration/outputs.tf +++ b/gcp-integration-setup/terraform/modules/nullify-gcp-integration/outputs.tf @@ -15,7 +15,7 @@ output "workload_identity_pool" { output "custom_role_id" { description = "Full ID of the custom role that grants the long-tail read permissions Nullify needs." - value = google_project_iam_custom_role.nullify_cloud_connector.id + value = local.custom_role_id } output "scope" { @@ -23,6 +23,7 @@ output "scope" { value = var.scope } -data "google_project" "host" { - project_id = var.host_project_id +output "next_steps" { + description = "Human-readable next steps. Surfaces in the apply output so customers don't have to dig through the Nullify docs." + value = "Paste the service_account_email and workload_identity_provider outputs into the Nullify console under Settings -> Cloud Integrations -> GCP, then click Verify." } 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 e3288f0..f5240c6 100644 --- a/gcp-integration-setup/terraform/modules/nullify-gcp-integration/variables.tf +++ b/gcp-integration-setup/terraform/modules/nullify-gcp-integration/variables.tf @@ -1,5 +1,5 @@ variable "customer_name" { - description = "Short identifier for your organisation. Used as a label on every Nullify-managed resource for traceability." + description = "Short identifier for your organisation. Used by Nullify support to correlate console support requests with this install. Not currently embedded as a label on any resource (GCP IAM resources don't expose `labels`)." type = string validation { condition = length(var.customer_name) >= 2 && length(var.customer_name) <= 30 @@ -8,22 +8,32 @@ variable "customer_name" { } variable "host_project_id" { - description = "The GCP project that owns the workload identity pool, the Nullify service account and the IAM bindings. For org-wide installs this is typically a dedicated security project." + 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 } variable "scope" { - description = "Whether Nullify should be granted read access at the organization level (recommended for full coverage) or only on a list of specific projects." + description = "Whether Nullify should be granted read access at the organization level (recommended for full coverage), at the folder level, or only on a list of specific projects." type = string default = "organization" validation { - condition = contains(["organization", "projects"], var.scope) - error_message = "scope must be either \"organization\" or \"projects\"." + condition = contains(["organization", "folder", "projects"], var.scope) + error_message = "scope must be one of \"organization\", \"folder\", or \"projects\"." } } variable "organization_id" { - description = "GCP organization numeric ID. Required when scope = \"organization\"." + description = "GCP organization numeric ID. Required when scope = \"organization\". Strongly recommended for scope = \"folder\" and any scope = \"projects\" install whose project_ids span more than the host_project_id, because the long-tail custom role must be defined at the organisation to be assignable across projects." + type = string + default = "" + validation { + condition = var.organization_id == "" || can(regex("^[0-9]+$", var.organization_id)) + error_message = "organization_id, when set, must be a numeric organisation ID." + } +} + +variable "folder_id" { + description = "GCP folder numeric ID (without the `folders/` prefix). Required when scope = \"folder\"." type = string default = "" } @@ -38,6 +48,9 @@ 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." 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." } @@ -52,11 +65,6 @@ variable "nullify_aws_account_id" { } } -variable "tenant_external_id" { - description = "Per-tenant external identifier from the Nullify console. Embedded as a label on the WIF pool so Nullify can correlate inbound tokens with the right customer." - type = string -} - variable "wif_pool_id" { description = "ID for the Workload Identity Pool that will be created. Must be unique within the host project." type = string @@ -74,9 +82,3 @@ variable "service_account_name" { type = string default = "nullify-cloud-connector" } - -variable "labels" { - description = "Additional labels to apply to every resource created by this module." - type = map(string) - default = {} -} diff --git a/gcp-integration-setup/terraform/modules/nullify-gcp-integration/versions.tf b/gcp-integration-setup/terraform/modules/nullify-gcp-integration/versions.tf index 0d03cc5..c8095d2 100644 --- a/gcp-integration-setup/terraform/modules/nullify-gcp-integration/versions.tf +++ b/gcp-integration-setup/terraform/modules/nullify-gcp-integration/versions.tf @@ -6,9 +6,5 @@ terraform { source = "hashicorp/google" version = ">= 5.0.0, < 7.0.0" } - google-beta = { - source = "hashicorp/google-beta" - version = ">= 5.0.0, < 7.0.0" - } } } diff --git a/gcp-integration-setup/terraform/outputs.tf b/gcp-integration-setup/terraform/outputs.tf index 80bdf80..f8c5b02 100644 --- a/gcp-integration-setup/terraform/outputs.tf +++ b/gcp-integration-setup/terraform/outputs.tf @@ -10,7 +10,7 @@ output "workload_identity_provider" { output "next_steps" { description = "What to do after a successful terraform apply." - value = <<-EOT + value = <<-EOT Nullify GCP integration provisioned successfully. @@ -18,7 +18,7 @@ output "next_steps" { 1. Open the Nullify console -> Settings -> Cloud Integrations -> GCP. 2. Paste the service_account_email output above into "Impersonated Service Account". 3. Paste the workload_identity_provider output above into "Workload Identity Provider". - 4. Click "Verify". You should see a green check next to every project. + 4. Click "Verify". For org or folder scope you should see a single green status; for project scope you should see a green check next to every project in the list. 5. Click "Save". To revoke access at any time, run `terraform destroy` from this directory. diff --git a/gcp-integration-setup/terraform/providers.tf b/gcp-integration-setup/terraform/providers.tf index aa02de2..156fb59 100644 --- a/gcp-integration-setup/terraform/providers.tf +++ b/gcp-integration-setup/terraform/providers.tf @@ -1,7 +1,3 @@ provider "google" { project = var.host_project_id } - -provider "google-beta" { - project = var.host_project_id -} diff --git a/gcp-integration-setup/terraform/terraform.tfvars.example b/gcp-integration-setup/terraform/terraform.tfvars.example index bcfc98b..22b293f 100644 --- a/gcp-integration-setup/terraform/terraform.tfvars.example +++ b/gcp-integration-setup/terraform/terraform.tfvars.example @@ -4,17 +4,26 @@ customer_name = "acme-corp" host_project_id = "acme-security" -# Choose ONE of the two scoping modes below. +# Choose ONE of the three scoping modes below. # Option A: Organization-wide (recommended for full coverage). scope = "organization" organization_id = "123456789012" -# Option B: Per-project. Comment out the two lines above and uncomment these. -# scope = "projects" -# project_ids = ["acme-prod", "acme-staging"] +# Option B: Folder-scoped. Pin Nullify to a single folder in the hierarchy +# (e.g. a dedicated `security/` or `prod/` folder). +# scope = "folder" +# organization_id = "123456789012" # still required so the custom role can be defined at the org +# folder_id = "987654321098" + +# Option C: Per-project. project_ids must be a list of GCP project IDs. +# When project_ids contains anything other than host_project_id, you also +# need to set organization_id (so the long-tail custom role is assignable +# across projects). +# scope = "projects" +# 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" -tenant_external_id = "REPLACE_WITH_VALUE_FROM_NULLIFY_CONSOLE" diff --git a/gcp-integration-setup/terraform/variables.tf b/gcp-integration-setup/terraform/variables.tf index 6c3513b..22bcc1a 100644 --- a/gcp-integration-setup/terraform/variables.tf +++ b/gcp-integration-setup/terraform/variables.tf @@ -1,31 +1,37 @@ variable "customer_name" { - description = "Short identifier for your organisation. Used as a label on every Nullify-managed resource." + description = "Short identifier for your organisation. Used by Nullify support to correlate requests with this install." type = string } variable "host_project_id" { - description = "GCP project that owns the Nullify service account, workload identity pool and IAM bindings. Typically a dedicated security project." + 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 } variable "scope" { - description = "Granularity of access. \"organization\" grants the read role on every project in the org (recommended). \"projects\" grants only on the project_ids list." + description = "Granularity of access. \"organization\" grants the read role on every project in the org (recommended). \"folder\" grants only on every project under the given folder. \"projects\" grants only on the project_ids list." type = string default = "organization" validation { - condition = contains(["organization", "projects"], var.scope) - error_message = "scope must be either \"organization\" or \"projects\"." + condition = contains(["organization", "folder", "projects"], var.scope) + error_message = "scope must be one of \"organization\", \"folder\", or \"projects\"." } } variable "organization_id" { - description = "Numeric GCP organization ID. Required when scope = organization." + description = "Numeric GCP organization ID. Required when scope = \"organization\" or scope = \"folder\". Strongly recommended for scope = \"projects\" with multiple projects, because the long-tail custom role must be defined at the org to be assignable across projects." + type = string + default = "" +} + +variable "folder_id" { + description = "Numeric GCP folder ID (without the `folders/` prefix). Required when scope = \"folder\"." type = string default = "" } variable "project_ids" { - description = "List of project IDs to grant access on. Required when scope = projects." + description = "List of project IDs to grant access on. Required when scope = \"projects\"." type = list(string) default = [] } @@ -40,11 +46,6 @@ variable "nullify_aws_account_id" { type = string } -variable "tenant_external_id" { - description = "Per-tenant external identifier from the Nullify console. Embedded as a label so Nullify can correlate inbound tokens with the right customer." - type = string -} - variable "wif_pool_id" { description = "ID for the Workload Identity Pool that will be created." type = string @@ -62,9 +63,3 @@ variable "service_account_name" { type = string default = "nullify-cloud-connector" } - -variable "labels" { - description = "Extra labels to apply to every Nullify-managed resource." - type = map(string) - default = {} -} diff --git a/gcp-integration-setup/terraform/versions.tf b/gcp-integration-setup/terraform/versions.tf index 0d03cc5..c8095d2 100644 --- a/gcp-integration-setup/terraform/versions.tf +++ b/gcp-integration-setup/terraform/versions.tf @@ -6,9 +6,5 @@ terraform { source = "hashicorp/google" version = ">= 5.0.0, < 7.0.0" } - google-beta = { - source = "hashicorp/google-beta" - version = ">= 5.0.0, < 7.0.0" - } } }