From 9878c9227c1b32310ce3776242c7d611d5812a1f Mon Sep 17 00:00:00 2001 From: Tim Thacker Date: Wed, 11 Nov 2026 20:41:53 +1100 Subject: [PATCH 1/3] fix(gcp): enable the APIs that read through the host project's quota KMS, Cloud SQL, Spanner, Dataflow, BigQuery, Org Policy and Access Context Manager count each read against the quota of the service account's own project. The Nullify service account lives in the host project, so every read of these APIs failed with "API has not been used in project ". Enable them in apis.tf and install.sh, and list them in the README. Correct docs/permissions.md: Cloud Build trigger and worker pool configuration is read through Cloud Asset Inventory; no Cloud Build permission is granted. Refs FDE-3031, FDE-3030. Co-Authored-By: Claude Opus 5.5 --- gcp-integration-setup/docs/permissions.md | 2 +- gcp-integration-setup/scripts/install.sh | 11 ++++++++++- gcp-integration-setup/terraform/README.md | 13 +++++++++++-- .../modules/nullify-gcp-integration/apis.tf | 16 ++++++++++++++++ 4 files changed, 38 insertions(+), 4 deletions(-) diff --git a/gcp-integration-setup/docs/permissions.md b/gcp-integration-setup/docs/permissions.md index 99c4544..9fa30b9 100644 --- a/gcp-integration-setup/docs/permissions.md +++ b/gcp-integration-setup/docs/permissions.md @@ -77,7 +77,7 @@ Strict allowlist of `*.get` / `*.list` only. | Read Firestore document contents | No | `datastore.entities.*` is **not** granted. We only see the database list. The predefined `roles/datastore.viewer` is **not** used because it grants document reads. | | Run Vertex AI inference | No | `aiplatform.endpoints.predict` and `computeTokens` are **not** granted. We see endpoint config only — not models, datasets, or featurestores. The broader `roles/aiplatform.viewer` is **not** used. | | Read SCC findings | No | `securitycenter.findings.*` and `securitycenter.assets.*` are **not** granted. We only see which detection sources are configured. | -| Read any Cloud Build config, logs, or artifacts | No | No Cloud Build permission is granted at all. The `cloudbuild.buildTriggers.*` IAM family does not exist, and `cloudbuild.builds.*` would expose execution data, so Cloud Build visibility is omitted. | +| Read Cloud Build logs, build history or artifacts | No | No Cloud Build permission is granted. `cloudbuild.builds.*` would expose execution data. Trigger and private worker pool configuration is read through Cloud Asset Inventory (`roles/cloudasset.viewer`). | | Read Cloud Batch task logs | No | Job spec only. No task logs or output artifacts. | | 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. | diff --git a/gcp-integration-setup/scripts/install.sh b/gcp-integration-setup/scripts/install.sh index 92c8225..dd3aba0 100755 --- a/gcp-integration-setup/scripts/install.sh +++ b/gcp-integration-setup/scripts/install.sh @@ -29,7 +29,9 @@ SA_NAME="${NULLIFY_SA_NAME:-nullify-cloud-connector}" SA_EMAIL="${SA_NAME}@${NULLIFY_HOST_PROJECT}.iam.gserviceaccount.com" # Required APIs on the host project. Without these enabled, the WIF pool / -# provider / service account creation calls fail with cryptic 403s. Mirrors +# provider / service account creation calls fail with cryptic 403s, and the +# APIs that count calls against the service account's own project (cloudkms +# onwards) cannot be read. Enabling an API grants no access. Mirrors # `apis.tf` in the Terraform module. echo "==> Enabling required Google Cloud APIs on ${NULLIFY_HOST_PROJECT}" gcloud services enable \ @@ -39,6 +41,13 @@ gcloud services enable \ cloudresourcemanager.googleapis.com \ cloudasset.googleapis.com \ serviceusage.googleapis.com \ + cloudkms.googleapis.com \ + sqladmin.googleapis.com \ + spanner.googleapis.com \ + dataflow.googleapis.com \ + bigquery.googleapis.com \ + orgpolicy.googleapis.com \ + accesscontextmanager.googleapis.com \ --project="${NULLIFY_HOST_PROJECT}" # API enablement is documented as synchronous but the IAM API's "enabled" diff --git a/gcp-integration-setup/terraform/README.md b/gcp-integration-setup/terraform/README.md index f6cd322..3456530 100644 --- a/gcp-integration-setup/terraform/README.md +++ b/gcp-integration-setup/terraform/README.md @@ -80,7 +80,14 @@ 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`. +`serviceusage.googleapis.com`, `cloudkms.googleapis.com`, +`sqladmin.googleapis.com`, `spanner.googleapis.com`, +`dataflow.googleapis.com`, `bigquery.googleapis.com`, +`orgpolicy.googleapis.com`, `accesscontextmanager.googleapis.com`. + +The last seven count each read against the quota of the service account's +own project, so they must be enabled in the host project even when the +scanned projects already enable them. Enabling an API grants no access. To enable them manually: @@ -88,7 +95,9 @@ To enable them manually: gcloud services enable \ iam.googleapis.com iamcredentials.googleapis.com sts.googleapis.com \ cloudresourcemanager.googleapis.com cloudasset.googleapis.com \ - serviceusage.googleapis.com \ + serviceusage.googleapis.com cloudkms.googleapis.com sqladmin.googleapis.com \ + spanner.googleapis.com dataflow.googleapis.com bigquery.googleapis.com \ + orgpolicy.googleapis.com accesscontextmanager.googleapis.com \ --project=YOUR_HOST_PROJECT ``` diff --git a/gcp-integration-setup/terraform/modules/nullify-gcp-integration/apis.tf b/gcp-integration-setup/terraform/modules/nullify-gcp-integration/apis.tf index 8665a4c..59c3de1 100644 --- a/gcp-integration-setup/terraform/modules/nullify-gcp-integration/apis.tf +++ b/gcp-integration-setup/terraform/modules/nullify-gcp-integration/apis.tf @@ -5,6 +5,13 @@ # disabled"). Enabling them up front lets Terraform create everything in # one shot. # +# Some Google APIs count every call against the quota of the calling +# service account's own project, not the project that is read. The Nullify +# service account lives in the host project, so those APIs must also be +# enabled here, or every read of them fails with "API has not been used in +# project before or it is disabled". Enabling an API grants no access: +# the read-only roles below still decide what Nullify can read. +# # `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. @@ -16,6 +23,15 @@ resource "google_project_service" "required" { "cloudresourcemanager.googleapis.com", "cloudasset.googleapis.com", "serviceusage.googleapis.com", + + # Read through the host project's quota. + "cloudkms.googleapis.com", + "sqladmin.googleapis.com", + "spanner.googleapis.com", + "dataflow.googleapis.com", + "bigquery.googleapis.com", + "orgpolicy.googleapis.com", + "accesscontextmanager.googleapis.com", ]) project = var.host_project_id service = each.value From 0b800103a7a805669c1968120f334f0798cee99d Mon Sep 17 00:00:00 2001 From: Tim Thacker Date: Wed, 11 Nov 2026 20:52:33 +1100 Subject: [PATCH 2/3] fix(gcp): keep the install working when an org policy blocks a scan API Move the seven scan APIs into their own `scan_quota` resource that no other resource depends on, behind `enable_scan_quota_apis` (default true). install.sh enables them in a separate call that warns and goes on when it fails. Correct the stale Cloud Build comment in main.tf and leave docs/permissions.md as it was. Co-Authored-By: Claude Opus 5.5 --- gcp-integration-setup/docs/permissions.md | 2 +- gcp-integration-setup/scripts/install.sh | 16 ++++++++--- gcp-integration-setup/terraform/README.md | 8 +++++- .../modules/nullify-gcp-integration/apis.tf | 28 +++++++++++++------ .../modules/nullify-gcp-integration/main.tf | 5 ++-- .../nullify-gcp-integration/variables.tf | 6 ++++ 6 files changed, 47 insertions(+), 18 deletions(-) diff --git a/gcp-integration-setup/docs/permissions.md b/gcp-integration-setup/docs/permissions.md index 9fa30b9..99c4544 100644 --- a/gcp-integration-setup/docs/permissions.md +++ b/gcp-integration-setup/docs/permissions.md @@ -77,7 +77,7 @@ Strict allowlist of `*.get` / `*.list` only. | Read Firestore document contents | No | `datastore.entities.*` is **not** granted. We only see the database list. The predefined `roles/datastore.viewer` is **not** used because it grants document reads. | | Run Vertex AI inference | No | `aiplatform.endpoints.predict` and `computeTokens` are **not** granted. We see endpoint config only — not models, datasets, or featurestores. The broader `roles/aiplatform.viewer` is **not** used. | | Read SCC findings | No | `securitycenter.findings.*` and `securitycenter.assets.*` are **not** granted. We only see which detection sources are configured. | -| Read Cloud Build logs, build history or artifacts | No | No Cloud Build permission is granted. `cloudbuild.builds.*` would expose execution data. Trigger and private worker pool configuration is read through Cloud Asset Inventory (`roles/cloudasset.viewer`). | +| Read any Cloud Build config, logs, or artifacts | No | No Cloud Build permission is granted at all. The `cloudbuild.buildTriggers.*` IAM family does not exist, and `cloudbuild.builds.*` would expose execution data, so Cloud Build visibility is omitted. | | Read Cloud Batch task logs | No | Job spec only. No task logs or output artifacts. | | 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. | diff --git a/gcp-integration-setup/scripts/install.sh b/gcp-integration-setup/scripts/install.sh index dd3aba0..59e31d3 100755 --- a/gcp-integration-setup/scripts/install.sh +++ b/gcp-integration-setup/scripts/install.sh @@ -29,9 +29,7 @@ SA_NAME="${NULLIFY_SA_NAME:-nullify-cloud-connector}" SA_EMAIL="${SA_NAME}@${NULLIFY_HOST_PROJECT}.iam.gserviceaccount.com" # Required APIs on the host project. Without these enabled, the WIF pool / -# provider / service account creation calls fail with cryptic 403s, and the -# APIs that count calls against the service account's own project (cloudkms -# onwards) cannot be read. Enabling an API grants no access. Mirrors +# 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 \ @@ -41,6 +39,13 @@ gcloud services enable \ cloudresourcemanager.googleapis.com \ cloudasset.googleapis.com \ serviceusage.googleapis.com \ + --project="${NULLIFY_HOST_PROJECT}" + +# These APIs count Nullify's reads against the service account's own +# project. A failure here does not stop the install: only the blocked +# services are not read. Mirrors `scan_quota` in `apis.tf`. +echo "==> Enabling scan APIs on ${NULLIFY_HOST_PROJECT}" +if ! gcloud services enable \ cloudkms.googleapis.com \ sqladmin.googleapis.com \ spanner.googleapis.com \ @@ -48,7 +53,10 @@ gcloud services enable \ bigquery.googleapis.com \ orgpolicy.googleapis.com \ accesscontextmanager.googleapis.com \ - --project="${NULLIFY_HOST_PROJECT}" + --project="${NULLIFY_HOST_PROJECT}"; then + echo "WARNING: could not enable all scan APIs on ${NULLIFY_HOST_PROJECT}." >&2 + echo "Nullify cannot read the services whose API is not enabled there." >&2 +fi # API enablement is documented as synchronous but the IAM API's "enabled" # state propagates eventually-consistent. On a fresh project the next diff --git a/gcp-integration-setup/terraform/README.md b/gcp-integration-setup/terraform/README.md index 3456530..d737232 100644 --- a/gcp-integration-setup/terraform/README.md +++ b/gcp-integration-setup/terraform/README.md @@ -87,7 +87,13 @@ policies, ensure the following are allowlisted on the host project: The last seven count each read against the quota of the service account's own project, so they must be enabled in the host project even when the -scanned projects already enable them. Enabling an API grants no access. +scanned projects already enable them. The module enables them separately +(`enable_scan_quota_apis`, default `true`), so an organisation policy that +blocks one does not stop the install; Nullify then cannot read that service. +The roles, not the APIs, decide what Nullify can read. + +Existing installs: re-apply the module, or run the command below, to +enable the seven APIs. To enable them manually: diff --git a/gcp-integration-setup/terraform/modules/nullify-gcp-integration/apis.tf b/gcp-integration-setup/terraform/modules/nullify-gcp-integration/apis.tf index 59c3de1..7619d2d 100644 --- a/gcp-integration-setup/terraform/modules/nullify-gcp-integration/apis.tf +++ b/gcp-integration-setup/terraform/modules/nullify-gcp-integration/apis.tf @@ -5,13 +5,6 @@ # disabled"). Enabling them up front lets Terraform create everything in # one shot. # -# Some Google APIs count every call against the quota of the calling -# service account's own project, not the project that is read. The Nullify -# service account lives in the host project, so those APIs must also be -# enabled here, or every read of them fails with "API has not been used in -# project before or it is disabled". Enabling an API grants no access: -# the read-only roles below still decide what Nullify can read. -# # `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. @@ -23,8 +16,25 @@ resource "google_project_service" "required" { "cloudresourcemanager.googleapis.com", "cloudasset.googleapis.com", "serviceusage.googleapis.com", + ]) + project = var.host_project_id + service = each.value + disable_on_destroy = false +} - # Read through the host project's quota. +# Some Google APIs count every call against the quota of the calling +# service account's own project, not the project that is read. The Nullify +# service account lives in the host project, so these APIs must also be +# enabled here, or every read of them fails with "API has not been used in +# project before or it is disabled". The roles in main.tf, not these +# APIs, decide what Nullify can read. Google can enable dependent APIs with +# them. +# +# No other resource depends on these. If an organisation policy blocks one +# of them, the install still completes and only that service is not read. +# Set `enable_scan_quota_apis = false` to manage them yourself. +resource "google_project_service" "scan_quota" { + for_each = var.enable_scan_quota_apis ? toset([ "cloudkms.googleapis.com", "sqladmin.googleapis.com", "spanner.googleapis.com", @@ -32,7 +42,7 @@ resource "google_project_service" "required" { "bigquery.googleapis.com", "orgpolicy.googleapis.com", "accesscontextmanager.googleapis.com", - ]) + ]) : toset([]) 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 5f45ecf..2fc4563 100644 --- a/gcp-integration-setup/terraform/modules/nullify-gcp-integration/main.tf +++ b/gcp-integration-setup/terraform/modules/nullify-gcp-integration/main.tf @@ -183,9 +183,8 @@ locals { "bigquery.routines.get", "bigquery.routines.list", - # Cloud Build: there is no buildTriggers.* IAM permission family, and the - # builds.* permissions expose execution data (logs/artifacts) which we - # intentionally avoid. Trigger-config visibility is therefore not granted. + # Cloud Build: no permission is added here. Nullify reads trigger and + # worker pool configuration through Cloud Asset Inventory. # Cloud Batch job spec. No task logs or output artifacts. "batch.jobs.get", 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 f64da1e..046ca0c 100644 --- a/gcp-integration-setup/terraform/modules/nullify-gcp-integration/variables.tf +++ b/gcp-integration-setup/terraform/modules/nullify-gcp-integration/variables.tf @@ -85,3 +85,9 @@ variable "service_account_name" { type = string default = "nullify-cloud-connector" } + +variable "enable_scan_quota_apis" { + description = "Enable in the host project the APIs that count Nullify's reads against the service account's own project (KMS, Cloud SQL, Spanner, Dataflow, BigQuery, Org Policy, Access Context Manager). Without them Nullify cannot read those services." + type = bool + default = true +} From 883697b71bcf357490c93fd85a2171070ce1c669 Mon Sep 17 00:00:00 2001 From: Tim Thacker Date: Thu, 12 Nov 2026 14:44:51 +1100 Subject: [PATCH 3/3] fix(gcp): isolate API readiness and expose Terraform opt-out --- .github/workflows/terraform-validate.yml | 6 + gcp-integration-setup/scripts/install.sh | 49 +++++--- gcp-integration-setup/terraform/README.md | 22 +++- .../terraform/examples/folder/main.tf | 5 +- .../terraform/examples/organization/main.tf | 5 +- .../terraform/examples/single-project/main.tf | 5 +- gcp-integration-setup/terraform/main.tf | 1 + .../modules/nullify-gcp-integration/apis.tf | 5 +- .../nullify-gcp-integration/outputs.tf | 5 + .../nullify-gcp-integration/variables.tf | 2 +- gcp-integration-setup/terraform/outputs.tf | 5 + .../terraform/terraform.tfvars.example | 1 + .../tests/scan_quota_apis.tftest.hcl | 48 ++++++++ gcp-integration-setup/terraform/variables.tf | 6 + scripts/test_gcp_installer.py | 108 ++++++++++++++++++ 15 files changed, 244 insertions(+), 29 deletions(-) create mode 100644 gcp-integration-setup/terraform/tests/scan_quota_apis.tftest.hcl create mode 100644 scripts/test_gcp_installer.py diff --git a/.github/workflows/terraform-validate.yml b/.github/workflows/terraform-validate.yml index 8ea47bd..d6e0583 100644 --- a/.github/workflows/terraform-validate.yml +++ b/.github/workflows/terraform-validate.yml @@ -10,6 +10,7 @@ on: pull_request: paths: - "gcp-integration-setup/**" + - "scripts/test_gcp_installer.py" - "aws-integration-setup/**" - "azure-integration-setup/**" - "scripts/**" @@ -19,6 +20,7 @@ on: - main paths: - "gcp-integration-setup/**" + - "scripts/test_gcp_installer.py" - "aws-integration-setup/**" - "azure-integration-setup/**" - "scripts/**" @@ -106,6 +108,7 @@ jobs: - 1.8.5 - 1.10.5 directory: + - gcp-integration-setup/terraform - aws-integration-setup/terraform/modules/eks-managed-scan-access - aws-integration-setup/terraform/modules/k8s-resources - aws-integration-setup/terraform/modules/nullify-aws-integration @@ -162,3 +165,6 @@ jobs: sudo apt-get install -y shellcheck >/dev/null shellcheck gcp-integration-setup/scripts/install.sh shellcheck gcp-integration-setup/scripts/uninstall.sh + + - name: Exercise GCP installer with synthetic gcloud responses + run: python3 scripts/test_gcp_installer.py diff --git a/gcp-integration-setup/scripts/install.sh b/gcp-integration-setup/scripts/install.sh index 59e31d3..e081904 100755 --- a/gcp-integration-setup/scripts/install.sh +++ b/gcp-integration-setup/scripts/install.sh @@ -41,21 +41,38 @@ gcloud services enable \ serviceusage.googleapis.com \ --project="${NULLIFY_HOST_PROJECT}" -# These APIs count Nullify's reads against the service account's own -# project. A failure here does not stop the install: only the blocked -# services are not read. Mirrors `scan_quota` in `apis.tf`. +enable_scan_api() { + local api="$1" attempt + for attempt in 1 2 3; do + if gcloud services enable "$api" --project="${NULLIFY_HOST_PROJECT}" && + [[ "$(gcloud services describe "$api" --project="${NULLIFY_HOST_PROJECT}" --format='value(state)')" == "ENABLED" ]]; then + return 0 + fi + if [[ "$attempt" -lt 3 ]]; then + sleep "$attempt" + fi + done + return 1 +} + +SCAN_APIS=( + cloudkms.googleapis.com + sqladmin.googleapis.com + spanner.googleapis.com + dataflow.googleapis.com + bigquery.googleapis.com + orgpolicy.googleapis.com + accesscontextmanager.googleapis.com +) +UNREADY_SCAN_APIS=() echo "==> Enabling scan APIs on ${NULLIFY_HOST_PROJECT}" -if ! gcloud services enable \ - cloudkms.googleapis.com \ - sqladmin.googleapis.com \ - spanner.googleapis.com \ - dataflow.googleapis.com \ - bigquery.googleapis.com \ - orgpolicy.googleapis.com \ - accesscontextmanager.googleapis.com \ - --project="${NULLIFY_HOST_PROJECT}"; then - echo "WARNING: could not enable all scan APIs on ${NULLIFY_HOST_PROJECT}." >&2 - echo "Nullify cannot read the services whose API is not enabled there." >&2 +for api in "${SCAN_APIS[@]}"; do + if ! enable_scan_api "$api"; then + UNREADY_SCAN_APIS+=("$api") + fi +done +if [[ "${#UNREADY_SCAN_APIS[@]}" -gt 0 ]]; then + printf 'WARNING: scan APIs are not ready on %s: %s\n' "${NULLIFY_HOST_PROJECT}" "${UNREADY_SCAN_APIS[*]}" >&2 fi # API enablement is documented as synchronous but the IAM API's "enabled" @@ -167,6 +184,10 @@ done echo echo "Nullify GCP integration installed successfully." +printf 'Scan API readiness: %d/%d enabled\n' "$(( ${#SCAN_APIS[@]} - ${#UNREADY_SCAN_APIS[@]} ))" "${#SCAN_APIS[@]}" +if [[ "${#UNREADY_SCAN_APIS[@]}" -gt 0 ]]; then + printf 'Enable these APIs in %s before scanning their services: %s\n' "${NULLIFY_HOST_PROJECT}" "${UNREADY_SCAN_APIS[*]}" +fi echo echo "Paste these values into the Nullify console under Settings -> Cloud Integrations -> GCP:" echo " Service Account Email: ${SA_EMAIL}" diff --git a/gcp-integration-setup/terraform/README.md b/gcp-integration-setup/terraform/README.md index d737232..a45022c 100644 --- a/gcp-integration-setup/terraform/README.md +++ b/gcp-integration-setup/terraform/README.md @@ -87,13 +87,25 @@ policies, ensure the following are allowlisted on the host project: The last seven count each read against the quota of the service account's own project, so they must be enabled in the host project even when the -scanned projects already enable them. The module enables them separately -(`enable_scan_quota_apis`, default `true`), so an organisation policy that -blocks one does not stop the install; Nullify then cannot read that service. +scanned projects already enable them. The module manages them as separate Terraform resources +(`enable_scan_quota_apis`, default `true`). Terraform apply fails if an +organisation policy or permission blocks an API. Set +`enable_scan_quota_apis = false` in `terraform.tfvars` to manage these APIs +separately, then enable each permitted API in the host project. Nullify +cannot read a service until its API is enabled there. The roles, not the APIs, decide what Nullify can read. -Existing installs: re-apply the module, or run the command below, to -enable the seven APIs. +Existing installs: re-apply the module, or enable the seven APIs separately. +The shell installer retries each scanner API up to three times and reports +which remain disabled without stopping identity setup. Terraform remains +strict; the `managed_scan_quota_apis` output lists its configured services, +not their live readiness. + +The API additions are compatible with the deployed scanner. Deploy +Nullify-Platform/nullify#18346 before checking Cloud Build coverage because +that change reads its configuration through Cloud Asset Inventory. Customer +re-application and a fresh scan without `CONSUMER_API_DISABLED` are separate +activation checks after merging this module. To enable them manually: diff --git a/gcp-integration-setup/terraform/examples/folder/main.tf b/gcp-integration-setup/terraform/examples/folder/main.tf index a386f65..adcc24a 100644 --- a/gcp-integration-setup/terraform/examples/folder/main.tf +++ b/gcp-integration-setup/terraform/examples/folder/main.tf @@ -9,8 +9,9 @@ module "nullify" { source = "../../" - customer_name = "acme-corp" - host_project_id = "acme-security" + customer_name = "acme-corp" + host_project_id = "acme-security" + enable_scan_quota_apis = true scope = "folder" organization_id = "123456789012" diff --git a/gcp-integration-setup/terraform/examples/organization/main.tf b/gcp-integration-setup/terraform/examples/organization/main.tf index 81fd131..4fe2bea 100644 --- a/gcp-integration-setup/terraform/examples/organization/main.tf +++ b/gcp-integration-setup/terraform/examples/organization/main.tf @@ -8,8 +8,9 @@ module "nullify" { source = "../../" - customer_name = "acme-corp" - host_project_id = "acme-security" + customer_name = "acme-corp" + host_project_id = "acme-security" + enable_scan_quota_apis = true scope = "organization" organization_id = "123456789012" diff --git a/gcp-integration-setup/terraform/examples/single-project/main.tf b/gcp-integration-setup/terraform/examples/single-project/main.tf index dde3678..5d01785 100644 --- a/gcp-integration-setup/terraform/examples/single-project/main.tf +++ b/gcp-integration-setup/terraform/examples/single-project/main.tf @@ -6,8 +6,9 @@ module "nullify" { source = "../../" - customer_name = "acme-corp" - host_project_id = "acme-security" + customer_name = "acme-corp" + host_project_id = "acme-security" + enable_scan_quota_apis = true scope = "projects" project_ids = ["acme-security"] diff --git a/gcp-integration-setup/terraform/main.tf b/gcp-integration-setup/terraform/main.tf index 4aa29c0..7c96813 100644 --- a/gcp-integration-setup/terraform/main.tf +++ b/gcp-integration-setup/terraform/main.tf @@ -12,4 +12,5 @@ module "nullify_gcp_integration" { wif_pool_id = var.wif_pool_id wif_provider_id = var.wif_provider_id service_account_name = var.service_account_name + enable_scan_quota_apis = var.enable_scan_quota_apis } diff --git a/gcp-integration-setup/terraform/modules/nullify-gcp-integration/apis.tf b/gcp-integration-setup/terraform/modules/nullify-gcp-integration/apis.tf index 7619d2d..4e51e99 100644 --- a/gcp-integration-setup/terraform/modules/nullify-gcp-integration/apis.tf +++ b/gcp-integration-setup/terraform/modules/nullify-gcp-integration/apis.tf @@ -30,9 +30,8 @@ resource "google_project_service" "required" { # APIs, decide what Nullify can read. Google can enable dependent APIs with # them. # -# No other resource depends on these. If an organisation policy blocks one -# of them, the install still completes and only that service is not read. -# Set `enable_scan_quota_apis = false` to manage them yourself. +# Terraform apply fails if any API cannot be enabled. +# Set `enable_scan_quota_apis = false` to manage these APIs separately. resource "google_project_service" "scan_quota" { for_each = var.enable_scan_quota_apis ? toset([ "cloudkms.googleapis.com", 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 20417c7..2d740f3 100644 --- a/gcp-integration-setup/terraform/modules/nullify-gcp-integration/outputs.tf +++ b/gcp-integration-setup/terraform/modules/nullify-gcp-integration/outputs.tf @@ -27,3 +27,8 @@ 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." } + +output "managed_scan_quota_apis" { + description = "Scanner APIs managed by this configuration. This is not a live readiness check." + value = sort([for api in google_project_service.scan_quota : api.service]) +} 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 046ca0c..8fb141a 100644 --- a/gcp-integration-setup/terraform/modules/nullify-gcp-integration/variables.tf +++ b/gcp-integration-setup/terraform/modules/nullify-gcp-integration/variables.tf @@ -87,7 +87,7 @@ variable "service_account_name" { } variable "enable_scan_quota_apis" { - description = "Enable in the host project the APIs that count Nullify's reads against the service account's own project (KMS, Cloud SQL, Spanner, Dataflow, BigQuery, Org Policy, Access Context Manager). Without them Nullify cannot read those services." + description = "Enable in the host project the APIs that count Nullify's reads against the service account's own project (KMS, Cloud SQL, Spanner, Dataflow, BigQuery, Org Policy, Access Context Manager). Terraform apply fails if any API cannot be enabled. Set false to manage these APIs separately." type = bool default = true } diff --git a/gcp-integration-setup/terraform/outputs.tf b/gcp-integration-setup/terraform/outputs.tf index f8c5b02..ba49f39 100644 --- a/gcp-integration-setup/terraform/outputs.tf +++ b/gcp-integration-setup/terraform/outputs.tf @@ -24,3 +24,8 @@ output "next_steps" { To revoke access at any time, run `terraform destroy` from this directory. EOT } + +output "managed_scan_quota_apis" { + description = "Scanner APIs managed by this configuration. This is not a live readiness check." + value = module.nullify_gcp_integration.managed_scan_quota_apis +} diff --git a/gcp-integration-setup/terraform/terraform.tfvars.example b/gcp-integration-setup/terraform/terraform.tfvars.example index b8629c4..02844a7 100644 --- a/gcp-integration-setup/terraform/terraform.tfvars.example +++ b/gcp-integration-setup/terraform/terraform.tfvars.example @@ -3,6 +3,7 @@ customer_name = "acme-corp" host_project_id = "acme-security" +enable_scan_quota_apis = true # Choose ONE of the three scoping modes below. diff --git a/gcp-integration-setup/terraform/tests/scan_quota_apis.tftest.hcl b/gcp-integration-setup/terraform/tests/scan_quota_apis.tftest.hcl new file mode 100644 index 0000000..6bbf16a --- /dev/null +++ b/gcp-integration-setup/terraform/tests/scan_quota_apis.tftest.hcl @@ -0,0 +1,48 @@ +mock_provider "google" { + mock_data "google_project" { + defaults = { + number = "123456789012" + } + } + mock_resource "google_service_account" { + defaults = { + email = "nullify-cloud-connector@acme-security.iam.gserviceaccount.com" + } + } +} + +variables { + customer_name = "acme" + host_project_id = "acme-security" + scope = "projects" + project_ids = ["acme-security"] + nullify_oidc_issuer_uri = "https://gcp.nullify.ai" + nullify_tenant_id = "GitHub-12345" +} + +run "manage_scanner_apis_by_default" { + command = plan + assert { + condition = output.managed_scan_quota_apis == tolist([ + "accesscontextmanager.googleapis.com", + "bigquery.googleapis.com", + "cloudkms.googleapis.com", + "dataflow.googleapis.com", + "orgpolicy.googleapis.com", + "spanner.googleapis.com", + "sqladmin.googleapis.com", + ]) + error_message = "The supplied root configuration must manage all seven scanner APIs by default." + } +} + +run "root_opt_out_manages_no_scanner_apis" { + command = plan + variables { + enable_scan_quota_apis = false + } + assert { + condition = length(output.managed_scan_quota_apis) == 0 + error_message = "The root opt-out must reach the nested module so restricted projects can manage scanner APIs separately." + } +} diff --git a/gcp-integration-setup/terraform/variables.tf b/gcp-integration-setup/terraform/variables.tf index 6a7abf9..4b2506b 100644 --- a/gcp-integration-setup/terraform/variables.tf +++ b/gcp-integration-setup/terraform/variables.tf @@ -75,3 +75,9 @@ variable "service_account_name" { type = string default = "nullify-cloud-connector" } + +variable "enable_scan_quota_apis" { + description = "Manage the scanner APIs in the host project. Terraform apply fails if an API cannot be enabled. Set false to manage these APIs separately." + type = bool + default = true +} diff --git a/scripts/test_gcp_installer.py b/scripts/test_gcp_installer.py new file mode 100644 index 0000000..7e2beba --- /dev/null +++ b/scripts/test_gcp_installer.py @@ -0,0 +1,108 @@ +#!/usr/bin/env python3 +import json +import os +from pathlib import Path +import subprocess +import tempfile +import unittest + +ROOT = Path(__file__).resolve().parents[1] +SCAN_APIS = { + "cloudkms.googleapis.com", "sqladmin.googleapis.com", "spanner.googleapis.com", + "dataflow.googleapis.com", "bigquery.googleapis.com", "orgpolicy.googleapis.com", + "accesscontextmanager.googleapis.com", +} +GCLOUD = r'''#!/usr/bin/env python3 +import json, os, pathlib, sys +args = sys.argv[1:] +log = pathlib.Path(os.environ["INSTALLER_CALLS"]) +previous = [json.loads(line) for line in log.read_text().splitlines()] if log.exists() else [] +with log.open("a") as stream: + stream.write(json.dumps(args) + "\n") +if args[:2] == ["services", "enable"]: + apis = [item for item in args[2:] if not item.startswith("--")] + if len(apis) > 1 and os.environ.get("FAIL_REQUIRED"): + sys.exit(1) + if len(apis) == 1: + api = apis[0] + if api == os.environ.get("BLOCKED_API"): + sys.exit(1) + attempts = sum(call[:3] == args[:3] for call in previous) + if api == os.environ.get("TRANSIENT_API") and attempts == 0: + sys.exit(1) +elif args[:2] == ["services", "describe"]: + print("DISABLED" if args[2] == os.environ.get("UNREADY_API") else "ENABLED") +elif args[:2] == ["projects", "describe"]: + print("123456789012") +elif args[:3] == ["iam", "workload-identity-pools", "describe"]: + print("projects/123456789012/locations/global/workloadIdentityPools/nullify-cloud-connector") +''' + + +class GCPInstaller(unittest.TestCase): + def run_installer(self, **settings): + temporary = tempfile.TemporaryDirectory() + self.addCleanup(temporary.cleanup) + path = Path(temporary.name) + binary = path / "gcloud" + binary.write_text(GCLOUD) + binary.chmod(0o755) + sleeper = path / "sleep" + sleeper.write_text("#!/bin/sh\nexit 0\n") + sleeper.chmod(0o755) + calls = path / "calls.jsonl" + environment = { + **os.environ, + "PATH": str(path) + os.pathsep + os.environ["PATH"], + "NULLIFY_HOST_PROJECT": "acme-security", + "NULLIFY_ORG_ID": "123456789012", + "NULLIFY_OIDC_ISSUER_URI": "https://gcp.nullify.ai", + "NULLIFY_TENANT_ID": "GitHub-12345", + "INSTALLER_CALLS": str(calls), + **settings, + } + result = subprocess.run( + ["bash", str(ROOT / "gcp-integration-setup/scripts/install.sh")], + env=environment, text=True, capture_output=True, check=False, + ) + return result, [json.loads(line) for line in calls.read_text().splitlines()] + + def test_optional_failure_isolated_and_bounded(self): + blocked = "orgpolicy.googleapis.com" + result, calls = self.run_installer(BLOCKED_API=blocked) + self.assertEqual(0, result.returncode, result.stderr) + enables = [call[2] for call in calls if call[:2] == ["services", "enable"] and len(call) == 4] + self.assertEqual(SCAN_APIS, set(enables)) + self.assertEqual(3, enables.count(blocked)) + self.assertTrue(all(enables.count(api) == 1 for api in SCAN_APIS - {blocked})) + self.assertIn("Scan API readiness: 6/7 enabled", result.stdout) + self.assertIn(blocked, result.stderr) + self.assertIn("installed successfully", result.stdout) + self.assertTrue(any(call[:3] == ["iam", "service-accounts", "describe"] for call in calls)) + + def test_transient_failure_retried_before_readiness(self): + transient = "dataflow.googleapis.com" + result, calls = self.run_installer(TRANSIENT_API=transient) + self.assertEqual(0, result.returncode, result.stderr) + enables = [call for call in calls if call[:3] == ["services", "enable", transient]] + self.assertEqual(2, len(enables)) + self.assertIn("Scan API readiness: 7/7 enabled", result.stdout) + self.assertNotIn("WARNING", result.stderr) + + def test_disabled_after_enable_is_not_reported_ready(self): + result, calls = self.run_installer(UNREADY_API="bigquery.googleapis.com") + self.assertEqual(0, result.returncode, result.stderr) + describes = [call for call in calls if call[:3] == ["services", "describe", "bigquery.googleapis.com"]] + self.assertEqual(3, len(describes)) + self.assertIn("Scan API readiness: 6/7 enabled", result.stdout) + self.assertIn("bigquery.googleapis.com", result.stderr) + + def test_required_failure_stops_identity_setup(self): + result, calls = self.run_installer(FAIL_REQUIRED="1") + self.assertEqual(1, result.returncode) + self.assertEqual(1, len(calls)) + self.assertNotIn("installed successfully", result.stdout) + + +if __name__ == "__main__": + unittest.main()