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
- You run GKE (Standard or Autopilot) or Cloud Run and need to guarantee that only images built and signed by your own CI/CD pipeline can be deployed — supply-chain integrity for SLSA / software-supply-chain compliance.
- You want to roll out enforcement safely: start in dry-run, watch the audit logs for would-be-blocked images, then flip to blocking once the allow-list is clean.
- You need per-cluster exceptions — e.g. enforce attestation everywhere except a
sandboxcluster that runs inALWAYS_ALLOW, all expressed as code. - You operate a platform/landing-zone pattern where the security team owns the attestor keys and the policy, and application teams simply consume a “your image must be attested” guarantee.
- You must exempt known-good registries (Google’s
gke.gcr.io, your Artifact Registry path) from attestation while still gating first-party application images.
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 config — live/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 config — live/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
- Always roll out in dry-run first. Keep
default_enforcement_mode = "DRYRUN_AUDIT_LOG_ONLY", watch the Binary Authorization audit logs for would-be blocks, fix your allow-list and attestations, and only then switch toENFORCED_BLOCK_AND_AUDIT_LOG. Flipping straight to enforce can stop every new pod from scheduling. - Whitelist GKE system images. The defaults
gke.gcr.io/**andgcr.io/gke-release/**keep control-plane and add-on pods running; if you enableglobal_policy_evaluation_mode = "ENABLE"without attesting Google images, node bootstrap can fail. Add your own registry base-image paths too. - One attestor per signer, scoped notes. Give each pipeline (or each trust boundary) its own attestor and Container Analysis note, then grant CI only
roles/containeranalysis.notes.attacheron that note — never project-wide attestor admin — so a compromised pipeline can’t forge attestations for another team. - Rotate signing keys via the
idfield. PKIX keys are referenced by a stableid; add the new key alongside the old one, cut CI over to signing with it, then remove the retired key — Binary Authorization accepts any listed key, so rotation is zero-downtime. - Use cluster rules for graduated rollout, not as a backdoor. Carve out
sandbox/dev clusters withALWAYS_ALLOWwhile production runsREQUIRE_ATTESTATION, but audit those exceptions — an over-broadALWAYS_ALLOWrule silently defeats the entire policy for that cluster. - Remember the policy is a singleton and free. There is exactly one policy object per project and the resource fully replaces it, so always manage it as code (drift here is a security regression), and pair it with cluster-side
binary_authorization { evaluation_mode = "PROJECT_SINGLETON_POLICY_ENFORCE" }— the policy does nothing until a cluster opts in to consult it.