Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 70 additions & 0 deletions .github/workflows/terraform-validate.yml
Original file line number Diff line number Diff line change
@@ -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
19 changes: 18 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**

Expand All @@ -36,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)**

Expand Down
79 changes: 79 additions & 0 deletions gcp-integration-setup/docs/permissions.md
Original file line number Diff line number Diff line change
@@ -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.
133 changes: 133 additions & 0 deletions gcp-integration-setup/scripts/install.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
#!/usr/bin/env bash
# install.sh — gcloud-only installer for the Nullify GCP Cloud Connector.
#
# 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"
# ./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}"

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}" \
--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"

# 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 \
--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.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"
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}"

# 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
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
"organizations/${NULLIFY_ORG_ID}/roles/${CUSTOM_ROLE_ID}"
)
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}"
63 changes: 63 additions & 0 deletions gcp-integration-setup/scripts/uninstall.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
#!/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"
CUSTOM_ROLE_ID="nullifyCloudConnector"

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
"organizations/${NULLIFY_ORG_ID}/roles/${CUSTOM_ROLE_ID}"
)

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 "==> 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."
Loading
Loading