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
17 changes: 10 additions & 7 deletions gcp-integration-setup/docs/permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,22 +5,25 @@ Cloud Connector requests, and why. Use it to satisfy security review.

## Trust model

- **Workload Identity Federation (WIF)**, AWS source.
- **Workload Identity Federation (WIF)**, OIDC source.
- Nullify acts as an OpenID Connect identity provider. The JWKS document
(`{nullify_oidc_issuer_uri}/.well-known/jwks.json`) is publicly fetched
by Google STS to verify subject token signatures.
- Nullify never holds a long-lived service account key.
- Every API call is authenticated with a short-lived token (~1 hour) minted
by exchanging a signed AWS STS GetCallerIdentity request through your
workload identity pool.
- The pool's attribute condition restricts trust to a single AWS IAM role
in Nullify's AWS account. Any other AWS principal is rejected by GCP
before any permission check happens.
by exchanging a per-tenant RS256 JWT through your workload identity pool.
- The pool's attribute condition pins trust to your specific Nullify
tenant id (`assertion.tenant_id == "<your tenant id>"`). Any other
Nullify tenant's token is rejected by GCP before any permission check
happens — so even if Nullify's signing key were stolen, an attacker
could not mint a token accepted by another tenant's provider.

## Predefined roles

| 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. |
Expand Down
73 changes: 48 additions & 25 deletions gcp-integration-setup/scripts/install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
# Usage:
# export NULLIFY_HOST_PROJECT="acme-security"
# export NULLIFY_ORG_ID="123456789012"
# export NULLIFY_AWS_PRINCIPAL_ARN="arn:aws:iam::000000000000:role/nullify-cloud-connector"
# export NULLIFY_AWS_ACCOUNT_ID="000000000000"
# export NULLIFY_OIDC_ISSUER_URI="https://gcp.nullify.ai"
# export NULLIFY_TENANT_ID="Nullify-XXXXXXXXXXXX"
# ./install.sh
#
# Re-running this script is idempotent — every gcloud command checks for
Expand All @@ -20,24 +20,49 @@ set -euo pipefail

: "${NULLIFY_HOST_PROJECT:?NULLIFY_HOST_PROJECT is required}"
: "${NULLIFY_ORG_ID:?NULLIFY_ORG_ID is required}"
: "${NULLIFY_AWS_PRINCIPAL_ARN:?NULLIFY_AWS_PRINCIPAL_ARN is required}"
: "${NULLIFY_AWS_ACCOUNT_ID:?NULLIFY_AWS_ACCOUNT_ID is required}"
: "${NULLIFY_OIDC_ISSUER_URI:?NULLIFY_OIDC_ISSUER_URI is required (e.g. https://gcp.nullify.ai)}"
: "${NULLIFY_TENANT_ID:?NULLIFY_TENANT_ID is required (copy from the Nullify console)}"

POOL_ID="${NULLIFY_WIF_POOL_ID:-nullify-cloud-connector}"
PROVIDER_ID="${NULLIFY_WIF_PROVIDER_ID:-nullify-aws}"
PROVIDER_ID="${NULLIFY_WIF_PROVIDER_ID:-nullify-oidc}"
SA_NAME="${NULLIFY_SA_NAME:-nullify-cloud-connector}"
SA_EMAIL="${SA_NAME}@${NULLIFY_HOST_PROJECT}.iam.gserviceaccount.com"

# Path-tolerant friendly name extraction. Mirrors the Terraform module's
# `nullify_aws_role_name` local. Assumed-role assertions never include a
# path so the WIF condition we pin must reference only the friendly name.
NULLIFY_ROLE_NAME="${NULLIFY_AWS_PRINCIPAL_ARN##*/}"
# Required APIs on the host project. Without these enabled, the WIF pool /
# provider / service account creation calls fail with cryptic 403s. Mirrors
# `apis.tf` in the Terraform module.
echo "==> Enabling required Google Cloud APIs on ${NULLIFY_HOST_PROJECT}"
gcloud services enable \
iam.googleapis.com \
iamcredentials.googleapis.com \
sts.googleapis.com \
cloudresourcemanager.googleapis.com \
cloudasset.googleapis.com \
serviceusage.googleapis.com \
--project="${NULLIFY_HOST_PROJECT}"

# API enablement is documented as synchronous but the IAM API's "enabled"
# state propagates eventually-consistent. On a fresh project the next
# service-accounts call can still return 403 for 10-30s. Retry the first
# IAM call a few times to absorb this, then every later gcloud iam call
# reuses the same warmed state.
echo "==> Creating service account ${SA_EMAIL}"
gcloud iam service-accounts describe "${SA_EMAIL}" --project="${NULLIFY_HOST_PROJECT}" >/dev/null 2>&1 || \
gcloud iam service-accounts create "${SA_NAME}" \
--project="${NULLIFY_HOST_PROJECT}" \
--display-name="Nullify Cloud Connector"
for attempt in 1 2 3 4 5; do
if gcloud iam service-accounts describe "${SA_EMAIL}" --project="${NULLIFY_HOST_PROJECT}" >/dev/null 2>&1; then
break
fi
if gcloud iam service-accounts create "${SA_NAME}" \
--project="${NULLIFY_HOST_PROJECT}" \
--display-name="Nullify Cloud Connector" 2>/dev/null; then
break
fi
if [ "${attempt}" -eq 5 ]; then
echo "ERROR: failed to create service account after 5 attempts (IAM API not yet usable on ${NULLIFY_HOST_PROJECT}?)" >&2
exit 1
fi
echo " waiting for IAM API to propagate (attempt ${attempt}/5)..." >&2
sleep 10
done

echo "==> Creating workload identity pool ${POOL_ID}"
gcloud iam workload-identity-pools describe "${POOL_ID}" \
Expand All @@ -46,30 +71,29 @@ gcloud iam workload-identity-pools describe "${POOL_ID}" \
--project="${NULLIFY_HOST_PROJECT}" --location=global \
--display-name="Nullify Cloud Connector"

# Mirror the Terraform module's WIF attribute_condition: trust ONLY the
# Nullify AWS role, not any principal in the Nullify AWS account. Without
# this condition the pool would accept any caller from the Nullify AWS
# account, which is meaningfully weaker than the Terraform path.
ATTRIBUTE_CONDITION="attribute.aws_role == \"arn:aws:sts::${NULLIFY_AWS_ACCOUNT_ID}:assumed-role/${NULLIFY_ROLE_NAME}\""
# Pin trust to this specific Nullify tenant. Nullify's OIDC issuer is
# multi-tenant; the JWT carries a `tenant_id` custom claim. Without this
# condition any Nullify tenant could exchange a token against this provider.
ATTRIBUTE_CONDITION="assertion.tenant_id == \"${NULLIFY_TENANT_ID}\""

echo "==> Creating workload identity provider ${PROVIDER_ID} (AWS source)"
echo "==> Creating workload identity provider ${PROVIDER_ID} (OIDC source)"
gcloud iam workload-identity-pools providers describe "${PROVIDER_ID}" \
--project="${NULLIFY_HOST_PROJECT}" --location=global \
--workload-identity-pool="${POOL_ID}" >/dev/null 2>&1 || \
gcloud iam workload-identity-pools providers create-aws "${PROVIDER_ID}" \
gcloud iam workload-identity-pools providers create-oidc "${PROVIDER_ID}" \
--project="${NULLIFY_HOST_PROJECT}" --location=global \
--workload-identity-pool="${POOL_ID}" \
--account-id="${NULLIFY_AWS_ACCOUNT_ID}" \
--attribute-mapping="google.subject=assertion.arn,attribute.account=assertion.account,attribute.aws_role=assertion.arn.contains(\"assumed-role\") ? assertion.arn.extract(\"{anything}assumed-role/\") + \"assumed-role/\" + assertion.arn.extract(\"assumed-role/{role}/\") : assertion.arn" \
--issuer-uri="${NULLIFY_OIDC_ISSUER_URI}" \
--attribute-mapping="google.subject=assertion.sub,attribute.tenant_id=assertion.tenant_id" \
--attribute-condition="${ATTRIBUTE_CONDITION}"

echo "==> Allowing Nullify principal to impersonate the service account"
echo "==> Allowing the Nullify tenant principal to impersonate the service account"
POOL_NAME="$(gcloud iam workload-identity-pools describe "${POOL_ID}" \
--project="${NULLIFY_HOST_PROJECT}" --location=global --format='value(name)')"
gcloud iam service-accounts add-iam-policy-binding "${SA_EMAIL}" \
--project="${NULLIFY_HOST_PROJECT}" \
--role="roles/iam.workloadIdentityUser" \
--member="principalSet://iam.googleapis.com/${POOL_NAME}/attribute.aws_role/arn:aws:sts::${NULLIFY_AWS_ACCOUNT_ID}:assumed-role/${NULLIFY_ROLE_NAME}"
--member="principalSet://iam.googleapis.com/${POOL_NAME}/attribute.tenant_id/${NULLIFY_TENANT_ID}"

# Custom role for the long-tail permissions Nullify needs that are not
# covered by predefined viewer roles. Mirrors locals.custom_role_permissions
Expand Down Expand Up @@ -103,7 +127,6 @@ echo "==> Granting predefined viewer roles at the organisation"
ROLES=(
roles/cloudasset.viewer
roles/iam.securityReviewer
roles/viewer
roles/compute.viewer
roles/container.clusterViewer
roles/cloudsql.viewer
Expand Down
3 changes: 1 addition & 2 deletions gcp-integration-setup/scripts/uninstall.sh
Original file line number Diff line number Diff line change
Expand Up @@ -10,15 +10,14 @@ set -euo pipefail
: "${NULLIFY_ORG_ID:?NULLIFY_ORG_ID is required}"

POOL_ID="${NULLIFY_WIF_POOL_ID:-nullify-cloud-connector}"
PROVIDER_ID="${NULLIFY_WIF_PROVIDER_ID:-nullify-aws}"
PROVIDER_ID="${NULLIFY_WIF_PROVIDER_ID:-nullify-oidc}"
SA_NAME="${NULLIFY_SA_NAME:-nullify-cloud-connector}"
SA_EMAIL="${SA_NAME}@${NULLIFY_HOST_PROJECT}.iam.gserviceaccount.com"
CUSTOM_ROLE_ID="nullifyCloudConnector"

ROLES=(
roles/cloudasset.viewer
roles/iam.securityReviewer
roles/viewer
roles/compute.viewer
roles/container.clusterViewer
roles/cloudsql.viewer
Expand Down
96 changes: 80 additions & 16 deletions gcp-integration-setup/terraform/README.md
Original file line number Diff line number Diff line change
@@ -1,20 +1,26 @@
# Nullify GCP Cloud Connector — Terraform

Read-only access to GCP for the Nullify Cloud Connector. Workload Identity
Federation only — no service account JSON keys, no long-lived secrets.
Read-only access to GCP for the Nullify Cloud Connector. OIDC + Workload
Identity Federation only — no service account JSON keys, no long-lived
secrets.

## What this provisions

- A `google_service_account` named `nullify-cloud-connector` in the host project.
- A `google_iam_workload_identity_pool` and AWS-source provider trusting the
Nullify AWS IAM role.
- A `google_project_iam_custom_role` with read-only permissions on the
long-tail services that don't have a predefined viewer role (Cloud Armor,
VPC Service Controls, AlloyDB, Filestore, Memorystore, Cloud DNS,
API Gateway, Artifact Registry).
- A `google_iam_workload_identity_pool` and an OIDC provider trusting
Nullify's OIDC issuer URL, with an `attribute_condition` pinned to your
specific Nullify tenant id.
- A custom role with read-only permissions on the long-tail services that
don't have a predefined viewer role (Cloud Armor, VPC Service Controls,
AlloyDB, Filestore, Memorystore, Cloud DNS, API Gateway, Artifact
Registry). Defined at the org for `scope = "organization" | "folder"`,
at the project for `scope = "projects"`.
- IAM bindings granting the Nullify service account a curated set of
predefined viewer roles plus the custom role above. Bound at organisation
scope by default; per-project scope is also supported.
scope by default; folder and per-project scopes are also supported.
- The required Google Cloud APIs on the host project
(`iam`, `iamcredentials`, `sts`, `cloudresourcemanager`, `cloudasset`,
`serviceusage`).

## What this does NOT provision

Expand All @@ -24,6 +30,48 @@ Federation only — no service account JSON keys, no long-lived secrets.
- No write permissions. Nullify cannot modify your environment.
- No long-lived secrets. Revoke at any time with `terraform destroy`.

## Prerequisites

### Installer-side IAM

The human / service identity running `terraform apply` needs, at minimum:

- `roles/iam.workloadIdentityPoolAdmin` — create the WIF pool + provider
- `roles/iam.serviceAccountAdmin` — create the Nullify service account
- `roles/serviceusage.serviceUsageAdmin` — enable the required APIs (on
the host project)
- `roles/iam.organizationRoleAdmin` — only when `scope` is `organization`
or `folder` (the long-tail custom role must be defined at the org)
- `roles/resourcemanager.organizationAdmin` or
`roles/resourcemanager.folderAdmin` — only when granting bindings at the
org or folder
- `roles/resourcemanager.projectIamAdmin` — when `scope = "projects"`,
needed on **every** project listed in `project_ids` (not just
`host_project_id`) so the module can grant the viewer + custom role
bindings on each

If you want a least-privilege one-off install, request these roles on the
operator running the apply and revoke them afterwards.

### APIs

The Terraform module enables the required APIs for you (see `apis.tf`).
If your organisation restricts API enablement via Service Usage org
policies, ensure the following are allowlisted on the host project:
`iam.googleapis.com`, `iamcredentials.googleapis.com`, `sts.googleapis.com`,
`cloudresourcemanager.googleapis.com`, `cloudasset.googleapis.com`,
`serviceusage.googleapis.com`.

To enable them manually:

```bash
gcloud services enable \
iam.googleapis.com iamcredentials.googleapis.com sts.googleapis.com \
cloudresourcemanager.googleapis.com cloudasset.googleapis.com \
serviceusage.googleapis.com \
--project=YOUR_HOST_PROJECT
```

## Quick start

```bash
Expand All @@ -48,18 +96,19 @@ and click "Verify".
| --- | --- |
| `customer_name` | You. Anything short and unique. |
| `host_project_id` | A GCP project you control. Typically a dedicated security project. |
| `scope` | `"organization"` (recommended) or `"projects"`. |
| `organization_id` | Required when `scope = "organization"`. Find with `gcloud organizations list`. |
| `scope` | `"organization"` (recommended), `"folder"`, or `"projects"`. |
| `organization_id` | Required when `scope = "organization"` or `scope = "folder"`. Find with `gcloud organizations list`. |
| `folder_id` | Required when `scope = "folder"`. |
| `project_ids` | Required when `scope = "projects"`. List of project IDs. |
| `nullify_aws_principal_arn` | Nullify console. |
| `nullify_aws_account_id` | Nullify console. |
| `tenant_external_id` | Nullify console. |
| `nullify_oidc_issuer_uri` | Nullify console. Use `https://gcp.nullify.ai` for prod, `https://gcp.dev.nullify.ai` for dev. |
| `nullify_tenant_id` | Nullify console. Pinned in the WIF provider's `attribute_condition` so this pool only accepts subject tokens minted for your tenant. |

## Permissions

See `modules/nullify-gcp-integration/main.tf` for the full list of predefined
roles + custom role permissions, with a comment next to each explaining why
Nullify needs it.
Nullify needs it. Full justification per role lives in
[`docs/permissions.md`](../docs/permissions.md).

## Revoking access

Expand All @@ -68,4 +117,19 @@ terraform destroy
```

This deletes the workload identity provider, the service account and every
IAM binding in one shot.
IAM binding in one shot. Note: the prerequisite Google Cloud APIs are
intentionally NOT disabled on `destroy` (they may be in use by other
resources in your project).

## Troubleshooting

| Symptom | Likely cause |
|---|---|
| `Error 400: The attribute condition must reference one of the provider's claims` on `terraform apply` | Your `nullify_tenant_id` is empty or contains characters outside `[A-Za-z0-9_-]`. Re-paste from the Nullify console. |
| `Error 403: Permission 'iam.workloadIdentityPools.create' denied` | The installer is missing `roles/iam.workloadIdentityPoolAdmin` on the host project. |
| `Error: Constraint constraints/iam.workloadIdentityPoolProviders violated` | Your org policy restricts which OIDC issuers are accepted by WIF. Ask your security team to add `nullify_oidc_issuer_uri` to the allowlist. |
| `Error 403: serviceusage.services.use` | The host project doesn't allow the required APIs to be enabled. Have an org admin enable them ahead of `terraform apply` (see *Prerequisites > APIs*). |
| Verify in Nullify console returns red with `oauth2/google: status code 401: ... invalid token` | `nullify_oidc_issuer_uri` doesn't match the URL Nullify's issuer actually signs with. Re-copy from the console — `https://gcp.nullify.ai` for prod, `https://gcp.dev.nullify.ai` for dev. |
| Verify returns red with `permission denied: assertion.tenant_id == "..."` from STS | Your `nullify_tenant_id` doesn't match your actual Nullify tenant id. Re-paste from the console. |
| Verify returns red per project with `PERMISSION_DENIED` | The Nullify SA is missing `roles/iam.serviceAccountTokenCreator` (the WIF binding) or the predefined viewer roles on the project. Re-run `terraform apply`. |
| `Error: ... iam.disableServiceAccountCreation` | An org policy blocks SA creation in this project. Either drop the org policy on the host project, or have an org admin pre-create the SA and contact Nullify support to wire it up. |
6 changes: 3 additions & 3 deletions gcp-integration-setup/terraform/examples/folder/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ module "nullify" {
organization_id = "123456789012"
folder_id = "987654321098"

# From the Nullify console.
nullify_aws_principal_arn = "arn:aws:iam::000000000000:role/nullify-cloud-connector"
nullify_aws_account_id = "000000000000"
# From the Nullify console (Settings -> Cloud Integrations -> GCP).
nullify_oidc_issuer_uri = "https://gcp.nullify.ai"
nullify_tenant_id = "Nullify-XXXXXXXXXXXX"
}

output "service_account_email" {
Expand Down
6 changes: 3 additions & 3 deletions gcp-integration-setup/terraform/examples/organization/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,9 @@ module "nullify" {
scope = "organization"
organization_id = "123456789012"

# From the Nullify console.
nullify_aws_principal_arn = "arn:aws:iam::000000000000:role/nullify-cloud-connector"
nullify_aws_account_id = "000000000000"
# From the Nullify console (Settings -> Cloud Integrations -> GCP).
nullify_oidc_issuer_uri = "https://gcp.nullify.ai"
nullify_tenant_id = "Nullify-XXXXXXXXXXXX"
}

output "service_account_email" {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,9 @@ module "nullify" {
scope = "projects"
project_ids = ["acme-security"]

# From the Nullify console.
nullify_aws_principal_arn = "arn:aws:iam::000000000000:role/nullify-cloud-connector"
nullify_aws_account_id = "000000000000"
# From the Nullify console (Settings -> Cloud Integrations -> GCP).
nullify_oidc_issuer_uri = "https://gcp.nullify.ai"
nullify_tenant_id = "Nullify-XXXXXXXXXXXX"
}

output "service_account_email" {
Expand Down
22 changes: 11 additions & 11 deletions gcp-integration-setup/terraform/main.tf
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
module "nullify_gcp_integration" {
source = "./modules/nullify-gcp-integration"

customer_name = var.customer_name
host_project_id = var.host_project_id
scope = var.scope
organization_id = var.organization_id
folder_id = var.folder_id
project_ids = var.project_ids
nullify_aws_principal_arn = var.nullify_aws_principal_arn
nullify_aws_account_id = var.nullify_aws_account_id
wif_pool_id = var.wif_pool_id
wif_provider_id = var.wif_provider_id
service_account_name = var.service_account_name
customer_name = var.customer_name
host_project_id = var.host_project_id
scope = var.scope
organization_id = var.organization_id
folder_id = var.folder_id
project_ids = var.project_ids
nullify_oidc_issuer_uri = var.nullify_oidc_issuer_uri
nullify_tenant_id = var.nullify_tenant_id
wif_pool_id = var.wif_pool_id
wif_provider_id = var.wif_provider_id
service_account_name = var.service_account_name
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
# Required Google Cloud APIs on the host project. Without these enabled,
# the very first `terraform apply` against a fresh project fails with
# cryptic 403 errors from the resource-creation calls (e.g. "Workload
# Identity Pools API has not been used in project … before or it is
# disabled"). Enabling them up front lets Terraform create everything in
# one shot.
#
# `disable_on_destroy = false` because these APIs may be in use by other
# resources in the project; disabling them on `terraform destroy` would
# break those.
resource "google_project_service" "required" {
for_each = toset([
"iam.googleapis.com",
"iamcredentials.googleapis.com",
"sts.googleapis.com",
"cloudresourcemanager.googleapis.com",
"cloudasset.googleapis.com",
"serviceusage.googleapis.com",
])
project = var.host_project_id
service = each.value
disable_on_destroy = false
}
Loading
Loading