IaC GCP

Terraform Module: GCP Binary Authorization — Attestor-Gated Deploy Policy with Dry-Run-First Enforcement

Quick take — A reusable hashicorp/google ~> 5.0 Terraform module for GCP Binary Authorization that wires a project policy, PKIX attestors, cluster admission rules, and registry allow-lists with a safe dry-run-to-enforce rollout. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.

Quickstart (copy-paste)

Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):

provider "google" {
  project = "my-project"
  region  = "us-central1"
}

module "binary_authorization" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-binary-authorization?ref=v1.0.0"

  project_id = "..."  # Project whose singleton Binary Authorization policy is …
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

Binary Authorization is GCP’s deploy-time admission control for container images. When a GKE node, Cloud Run service, or Anthos cluster tries to start a pod, the Binary Authorization enforcer evaluates the image’s digest against a policy and either admits it, admits-and-logs it, or blocks it outright. The policy is a singleton per project — there is exactly one google_binary_authorization_policy and it governs every cluster in that project unless you carve out per-cluster exceptions.

The policy itself is only half the story. A meaningful policy says “an image may only run if it has been cryptographically attested” — and an attestation is a signature, made by an attestor, recorded against a Container Analysis note. So a real-world setup needs three things wired together: a google_binary_authorization_attestor (which references a note and one or more PKIX/PGP public keys), the policy’s default_admission_rule that requires that attestor, and usually some admission_whitelist_patterns so platform images (GKE system pods, Istio sidecars, your registry’s base images) aren’t accidentally blocked.

The footguns are sharp. The default admission rule’s evaluation_mode and enforcement_mode are independent: you can require attestation but run in DRYRUN_AUDIT_LOG_ONLY, which logs violations without blocking — the only sane way to roll this out. Get it wrong and you flip a project to ALWAYS_DENY + ENFORCED_BLOCK_AND_AUDIT_LOG and every new pod in production fails to schedule. This module wraps the policy, attestors, cluster rules, and allow-lists behind validated variables so teams get a consistent, dry-run-first rollout instead of hand-editing the singleton policy and bricking a cluster.

When to use it

Skip it only if you have a single throwaway project and accept any image — but note the policy is a singleton that already exists in every project as ALWAYS_ALLOW, so managing it as code is cheap insurance.

Module structure

terraform-module-gcp-binary-authorization/
├── versions.tf      # provider + Terraform version pins
├── main.tf          # attestors, project policy, cluster rules, allow-lists
├── variables.tf     # var-driven inputs with validation
└── outputs.tf       # policy id + attestor ids/names and notes

versions.tf

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    google = {
      source  = "hashicorp/google"
      version = "~> 5.0"
    }
  }
}

main.tf

locals {
  # Normalise the attestor map: each attestor needs a Container Analysis note
  # and one or more PKIX (or PGP) public keys. We default the note name to the
  # attestor name when not supplied.
  attestors = {
    for name, cfg in var.attestors : name => {
      note_name      = coalesce(try(cfg.note_name, null), "${name}-note")
      description     = try(cfg.description, "Attestor ${name} managed by Terraform")
      pkix_public_keys = try(cfg.pkix_public_keys, [])
    }
  }

  # Build the list of attestor resource IDs the default rule requires. An empty
  # list is only valid when the default evaluation mode is not REQUIRE_ATTESTATION.
  default_required_attestors = [
    for name in var.default_require_attestation_by :
    google_binary_authorization_attestor.this[name].id
  ]
}

# Container Analysis note that each attestor signs against. One note per attestor
# keeps attestations cleanly attributable to a single signer.
resource "google_container_analysis_note" "this" {
  for_each = local.attestors

  project = var.project_id
  name    = each.value.note_name

  attestation_authority {
    hint {
      human_readable_name = each.key
    }
  }
}

# Attestor: binds the note to the public key(s) Binary Authorization uses to
# verify signatures at admission time.
resource "google_binary_authorization_attestor" "this" {
  for_each = local.attestors

  project     = var.project_id
  name        = each.key
  description = each.value.description

  attestation_authority_note {
    note_reference = google_container_analysis_note.this[each.key].name

    dynamic "public_keys" {
      for_each = each.value.pkix_public_keys
      content {
        id = public_keys.value.id
        pkix_public_key {
          public_key_pem      = public_keys.value.public_key_pem
          signature_algorithm = public_keys.value.signature_algorithm
        }
      }
    }
  }
}

# The singleton project policy. Exactly one per project; this resource fully
# replaces whatever policy currently exists (default is ALWAYS_ALLOW).
resource "google_binary_authorization_policy" "this" {
  project = var.project_id

  # Whether Google-maintained system images (GKE control plane, etc.) are
  # exempt from policy. ENABLE evaluates them; DISABLE allows them implicitly.
  global_policy_evaluation_mode = var.global_policy_evaluation_mode

  # Registries/image paths exempt from attestation. Patterns end in /* or are
  # exact digests. Always allow the GKE system images to avoid bricking nodes.
  dynamic "admission_whitelist_patterns" {
    for_each = toset(var.admission_whitelist_patterns)
    content {
      name_pattern = admission_whitelist_patterns.value
    }
  }

  # The catch-all rule for any image not matched by a cluster-specific rule.
  default_admission_rule {
    evaluation_mode  = var.default_evaluation_mode
    enforcement_mode = var.default_enforcement_mode
    require_attestation_by = (
      var.default_evaluation_mode == "REQUIRE_ATTESTATION"
      ? local.default_required_attestors
      : null
    )
  }

  # Per-cluster overrides, keyed by "location.clusterId"
  # (e.g. "asia-south1.prod-gke" or "us-central1-a.sandbox").
  dynamic "cluster_admission_rules" {
    for_each = var.cluster_admission_rules
    content {
      cluster          = cluster_admission_rules.key
      evaluation_mode  = cluster_admission_rules.value.evaluation_mode
      enforcement_mode = cluster_admission_rules.value.enforcement_mode
      require_attestation_by = (
        cluster_admission_rules.value.evaluation_mode == "REQUIRE_ATTESTATION"
        ? [for n in cluster_admission_rules.value.require_attestation_by :
           google_binary_authorization_attestor.this[n].id]
        : null
      )
    }
  }
}

variables.tf

variable "project_id" {
  description = "GCP project ID whose singleton Binary Authorization policy is managed."
  type        = string
}

variable "global_policy_evaluation_mode" {
  description = "Whether to evaluate Google-maintained system images against the policy. ENABLE evaluates them; DISABLE exempts them. Keep DISABLE unless you fully attest system images."
  type        = string
  default     = "DISABLE"

  validation {
    condition     = contains(["ENABLE", "DISABLE"], var.global_policy_evaluation_mode)
    error_message = "global_policy_evaluation_mode must be ENABLE or DISABLE."
  }
}

variable "default_evaluation_mode" {
  description = "Default admission rule evaluation mode: ALWAYS_ALLOW, ALWAYS_DENY, or REQUIRE_ATTESTATION."
  type        = string
  default     = "REQUIRE_ATTESTATION"

  validation {
    condition     = contains(["ALWAYS_ALLOW", "ALWAYS_DENY", "REQUIRE_ATTESTATION"], var.default_evaluation_mode)
    error_message = "default_evaluation_mode must be ALWAYS_ALLOW, ALWAYS_DENY, or REQUIRE_ATTESTATION."
  }
}

variable "default_enforcement_mode" {
  description = "Default admission rule enforcement mode. DRYRUN_AUDIT_LOG_ONLY logs violations without blocking (use first); ENFORCED_BLOCK_AND_AUDIT_LOG blocks non-conforming images."
  type        = string
  default     = "DRYRUN_AUDIT_LOG_ONLY"

  validation {
    condition     = contains(["ENFORCED_BLOCK_AND_AUDIT_LOG", "DRYRUN_AUDIT_LOG_ONLY"], var.default_enforcement_mode)
    error_message = "default_enforcement_mode must be ENFORCED_BLOCK_AND_AUDIT_LOG or DRYRUN_AUDIT_LOG_ONLY."
  }
}

variable "default_require_attestation_by" {
  description = "List of attestor names (keys of var.attestors) required by the default rule. Only honoured when default_evaluation_mode is REQUIRE_ATTESTATION."
  type        = list(string)
  default     = []
}

variable "attestors" {
  description = <<-EOT
    Map of attestors to create, keyed by attestor name. Each value may set:
      note_name        - Container Analysis note name (defaults to "<name>-note")
      description      - human-readable attestor description
      pkix_public_keys - list of { id, public_key_pem, signature_algorithm }
                         e.g. signature_algorithm = "RSA_PSS_2048_SHA256" or
                         "ECDSA_P256_SHA256". id is a stable key fingerprint.
  EOT
  type = map(object({
    note_name   = optional(string)
    description = optional(string)
    pkix_public_keys = optional(list(object({
      id                  = string
      public_key_pem      = string
      signature_algorithm = string
    })), [])
  }))
  default = {}
}

variable "admission_whitelist_patterns" {
  description = <<-EOT
    Image path patterns exempt from attestation. Each must end in '/*', '/**',
    or be an exact image. Always include the GKE system images to avoid blocking
    node bootstrap, e.g. "gke.gcr.io/**" and "gcr.io/gke-release/**".
  EOT
  type    = list(string)
  default = ["gke.gcr.io/**", "gcr.io/gke-release/**"]
}

variable "cluster_admission_rules" {
  description = <<-EOT
    Per-cluster admission rules, keyed by "<location>.<clusterId>"
    (e.g. "asia-south1.prod-gke"). Each value sets evaluation_mode,
    enforcement_mode, and require_attestation_by (list of attestor names).
  EOT
  type = map(object({
    evaluation_mode        = string
    enforcement_mode       = string
    require_attestation_by = optional(list(string), [])
  }))
  default = {}

  validation {
    condition = alltrue([
      for k, v in var.cluster_admission_rules :
      contains(["ALWAYS_ALLOW", "ALWAYS_DENY", "REQUIRE_ATTESTATION"], v.evaluation_mode)
    ])
    error_message = "Each cluster rule evaluation_mode must be ALWAYS_ALLOW, ALWAYS_DENY, or REQUIRE_ATTESTATION."
  }

  validation {
    condition = alltrue([
      for k, v in var.cluster_admission_rules :
      contains(["ENFORCED_BLOCK_AND_AUDIT_LOG", "DRYRUN_AUDIT_LOG_ONLY"], v.enforcement_mode)
    ])
    error_message = "Each cluster rule enforcement_mode must be ENFORCED_BLOCK_AND_AUDIT_LOG or DRYRUN_AUDIT_LOG_ONLY."
  }
}

outputs.tf

output "policy_id" {
  description = "Fully-qualified ID of the project Binary Authorization policy (projects/<project>/policy)."
  value       = google_binary_authorization_policy.this.id
}

output "default_evaluation_mode" {
  description = "Effective evaluation mode of the default admission rule (echoes input for downstream assertions)."
  value       = var.default_evaluation_mode
}

output "default_enforcement_mode" {
  description = "Effective enforcement mode of the default admission rule (DRYRUN_AUDIT_LOG_ONLY or ENFORCED_BLOCK_AND_AUDIT_LOG)."
  value       = var.default_enforcement_mode
}

output "attestor_ids" {
  description = "Map of attestor name => fully-qualified attestor ID, suitable for `gcloud beta container binauthz attestations sign` and CI signing steps."
  value       = { for name, a in google_binary_authorization_attestor.this : name => a.id }
}

output "attestor_names" {
  description = "Map of attestor name => short attestor name as registered in the project."
  value       = { for name, a in google_binary_authorization_attestor.this : name => a.name }
}

output "attestor_note_ids" {
  description = "Map of attestor name => Container Analysis note ID the attestor signs against (used when granting CI the containeranalysis.notes.attacher role)."
  value       = { for name, n in google_container_analysis_note.this : name => n.id }
}

How to use it

# The public half of the keypair your CI pipeline signs attestations with.
# (Private key lives in your signer — e.g. Cloud KMS or a CI secret.)
variable "ci_signing_public_key_pem" {
  type = string
}

module "binary_authorization" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-binary-authorization?ref=v1.0.0"

  project_id = var.project_id

  attestors = {
    "ci-build-attestor" = {
      description = "Signs images built and tested by the KloudVin CI pipeline."
      pkix_public_keys = [
        {
          id                  = "kloudvin-ci-2026-q2"
          public_key_pem      = var.ci_signing_public_key_pem
          signature_algorithm = "RSA_PSS_2048_SHA256"
        }
      ]
    }
  }

  # Project-wide default: every image must be attested by the CI attestor,
  # but start in DRY-RUN so violations are only logged, not blocked.
  default_evaluation_mode        = "REQUIRE_ATTESTATION"
  default_enforcement_mode       = "DRYRUN_AUDIT_LOG_ONLY"
  default_require_attestation_by = ["ci-build-attestor"]

  # Don't block GKE system pods or our own Artifact Registry base images.
  admission_whitelist_patterns = [
    "gke.gcr.io/**",
    "gcr.io/gke-release/**",
    "asia-south1-docker.pkg.dev/${var.project_id}/base-images/**",
  ]

  # The sandbox cluster stays wide open; prod is gated by the same attestor.
  cluster_admission_rules = {
    "us-central1-a.sandbox-gke" = {
      evaluation_mode  = "ALWAYS_ALLOW"
      enforcement_mode = "DRYRUN_AUDIT_LOG_ONLY"
    }
    "asia-south1.prod-gke" = {
      evaluation_mode        = "REQUIRE_ATTESTATION"
      enforcement_mode       = "DRYRUN_AUDIT_LOG_ONLY"
      require_attestation_by = ["ci-build-attestor"]
    }
  }
}

# Downstream: grant the CI service account permission to create attestations
# against the attestor's note, using the module's note output.
resource "google_container_analysis_note_iam_member" "ci_attacher" {
  project = var.project_id
  note    = module.binary_authorization.attestor_note_ids["ci-build-attestor"]
  role    = "roles/containeranalysis.notes.attacher"
  member  = "serviceAccount:ci-pipeline@${var.project_id}.iam.gserviceaccount.com"
}

# Downstream: a GKE cluster with Binary Authorization enabled so it actually
# consults the policy this module manages.
resource "google_container_cluster" "prod" {
  name     = "prod-gke"
  project  = var.project_id
  location = "asia-south1"

  binary_authorization {
    evaluation_mode = "PROJECT_SINGLETON_POLICY_ENFORCE"
  }

  # ... node pools, networking, etc.
}

With Terragrunt

Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.

1. Root configlive/terragrunt.hcl (inherited by every module):

remote_state {
  backend = "gcs"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...gcs state bucket/container + key per path...
  }
}

2. Module configlive/prod/binary_authorization/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-gcp-binary-authorization?ref=v1.0.0"
}

inputs = {
  project_id = "..."
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/binary_authorization && terragrunt apply        # this module
terragrunt run-all apply                      # every module under live/prod

Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.

Inputs

Name Type Default Required Description
project_id string Yes Project whose singleton Binary Authorization policy is managed.
global_policy_evaluation_mode string "DISABLE" No Evaluate Google system images (ENABLE) or exempt them (DISABLE).
default_evaluation_mode string "REQUIRE_ATTESTATION" No Default rule mode: ALWAYS_ALLOW / ALWAYS_DENY / REQUIRE_ATTESTATION.
default_enforcement_mode string "DRYRUN_AUDIT_LOG_ONLY" No DRYRUN_AUDIT_LOG_ONLY (log only) or ENFORCED_BLOCK_AND_AUDIT_LOG (block).
default_require_attestation_by list(string) [] No Attestor names required by the default rule (only when REQUIRE_ATTESTATION).
attestors map(object) {} No Attestors to create, each with a note and PKIX/PGP public keys.
admission_whitelist_patterns list(string) ["gke.gcr.io/**", "gcr.io/gke-release/**"] No Image path patterns exempt from attestation.
cluster_admission_rules map(object) {} No Per-cluster overrides keyed by <location>.<clusterId>.

Outputs

Name Description
policy_id Fully-qualified ID of the project policy (projects/<project>/policy).
default_evaluation_mode Effective evaluation mode of the default rule.
default_enforcement_mode Effective enforcement mode (dry-run vs. block).
attestor_ids Map of attestor name → fully-qualified attestor ID (for CI signing steps).
attestor_names Map of attestor name → short attestor name.
attestor_note_ids Map of attestor name → Container Analysis note ID (for notes.attacher grants).

Enterprise scenario

A SaaS company subject to SOC 2 and SLSA Level 3 needs to prove that nothing reaches production GKE except images their own pipeline built, scanned, and signed. The platform team deploys this module once per environment project: an attestors entry holds the public key that Cloud Build signs with after a clean vulnerability scan, the default rule is set to REQUIRE_ATTESTATION, and a cluster_admission_rules carve-out keeps the shared sandbox-gke cluster on ALWAYS_ALLOW for experiments. They ship the whole estate in DRYRUN_AUDIT_LOG_ONLY for two weeks, mine the binaryauthorization.googleapis.com audit logs for any unsigned image that would have been blocked, clean up the allow-list, and then flip default_enforcement_mode to ENFORCED_BLOCK_AND_AUDIT_LOG in a single one-line PR — turning supply-chain policy into a reviewable, reversible code change instead of a console toggle.

Best practices

TerraformGCPBinary AuthorizationModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading