Chapter 12: Testing, Policy & Validation
Topic 78

Policy as Code

PolicySecurity

Policy as code is how an organization enforces rules — "buckets must have CMEK," "no public IPs," "only approved regions" — automatically instead of in a wiki nobody reads. The previous topics gated correctness and security on the way into Terraform. This one gates the rules the whole organization cannot break, and on Google Cloud that takes three layers, not two.

Sentinel and OPA gate the Terraform path at plan time. GCP Org Policy enforces at the API itself, at apply and at runtime, no matter how the change arrives. The third layer is the GCP-native one, and it closes a hole the first two structurally cannot: anything that leaves the Terraform path entirely.

Sentinel

Sentinel is HashiCorp's policy language, enforced by HCP Terraform between plan and apply. It reads the plan and the run's configuration and can hard-fail the run, soft-fail it (block unless overridden), or merely advise. The catch is scope: Sentinel gates only runs that execute on HCP Terraform, and it is a paid-tier feature. A terraform apply from a laptop or a plain CI runner never reaches it.

So Sentinel is the right plan-time layer when your runs already go through HCP Terraform and you want policy woven into the run pipeline. It is the wrong layer if some of your applies happen anywhere else, because those simply skip it.

OPA and conftest

Open Policy Agent uses Rego, and the open-source path is to export the plan as JSON and evaluate policies against it. You run terraform show -json plan.out to get the resolved plan, then conftest test over it in CI on any runner — no HCP Terraform, no paid tier. That gates the Terraform path wherever your pipeline runs.

The detail that matters is what you evaluate. OPA policies must run against the plan JSON, not the raw HCL, because computed values and module expansion only exist in the resolved plan. A policy written against the HCL sees module.ingest as an opaque call; the same policy against the plan JSON sees every resource it expands into, with its computed attributes filled in. But like Sentinel, OPA still only sees the Terraform path.

Exporting the plan for conftest
# resolve the plan, then evaluate Rego against the JSON — not the HCL
terraform plan -out=plan.out
terraform show -json plan.out > plan.json
conftest test plan.json

The two commands turn a binary plan into the JSON document Rego reasons over. Skip the export and write policy against .tf files directly, and the policy is blind to everything Terraform computes — which is most of what you want to check.

GCP Org Policy

GCP Org Policy is the GCP-native layer with no Terraform-tool analog. A google_org_policy_policy resource sets a constraint — storage.publicAccessPrevention, compute.vmExternalIpAccess, gcp.resourceLocations — at an organization, folder, or project node. The constraint is enforced by the API itself: a request that violates it is rejected server-side. That holds whether the change arrives from Terraform, from gcloud, from the Cloud Console, or from another pipeline entirely. It cannot be bypassed by leaving Terraform, because it does not live in Terraform.

The example sets two of the org's non-negotiables at the org node. Once these are enforced, no path — not a 2am gcloud command, not a Console click — can create a publicly readable bucket or attach an external IP, because the API refuses the request before the resource exists.

Org Policy enforced at the API
resource "google_org_policy_policy" "no_public_buckets" {
  name   = "organizations/123456789/policies/storage.publicAccessPrevention"
  parent = "organizations/123456789"

  spec {
    rules {
      enforce = "TRUE"     # API rejects any public bucket, any path
    }
  }
}

resource "google_org_policy_policy" "no_external_ip" {
  name   = "organizations/123456789/policies/compute.vmExternalIpAccess"
  parent = "organizations/123456789"

  spec {
    rules {
      deny_all = "TRUE"    # no VM gets a public IP, no matter who asks
    }
  }
}

Neither rule references Terraform. They configure GCP's own enforcement engine, so they apply to every actor against the API — which is exactly the property the plan-time layers lack.

Plan-Time vs API-Time

Sentinel and OPA evaluate a proposed plan and stop a bad apply before it runs. The feedback is fast and lands in the pull request — "your plan violates policy X" — so a developer fixes it before merge. Org Policy evaluates the actual API call and rejects it server-side. There is no bypass, but the failure shows up at apply as an API error, not in the PR. Neither timing is strictly better; they fail at different moments and catch different things.

That is why you want both. The plan-time layer gives early, legible feedback on the Terraform path. The API-time layer gives an unbypassable backstop on every path. Pick one and you either lose fast feedback or lose the guarantee that a non-Terraform change cannot violate the rule.

How the Three Complement

OPA or Sentinel give developers a fast "your plan violates policy" in CI before anything reaches GCP — cheap to run, easy to read, and it blocks the merge. Org Policy guarantees that even a panicked engineer running gcloud at 2am cannot create a public bucket, because the API itself refuses. The same rule is enforced at the cheap early gate and the expensive unbypassable one.

That is defense in depth applied to policy. The plan-time layer optimizes for developer experience; the API-time layer optimizes for the guarantee. Encode the org's non-negotiables as Org Policy so nothing can violate them, and mirror them in OPA or Sentinel so a violation is caught in PR rather than as an opaque apply-time error. Both layers, the same rules, two different moments.

Three policy layers on GCP
Sentinel
HCP Terraform, plan-time, paid tier; gates only runs on HCP, the Terraform path.
OPA / conftest
Any CI runner, plan-time, evaluates exported plan JSON; open-source, still the Terraform path.
GCP Org Policy
API-time, server-side; rejects gcloud and Console too, unbypassable.
The three policy layers

Sentinel — HCP Terraform, plan-time, the Sentinel language, paid tier. Gates only runs executed by HCP Terraform. Choose it if you are on HCP Terraform and want policy gating inside the run pipeline.

OPA / conftest — any CI runner, plan-time, Rego, open-source. Evaluates the exported plan JSON on any pipeline. Choose it for plan-time gating without HCP Terraform.

GCP Org Policygoogle_org_policy_policy, API-time at apply and runtime, GCP-native constraints, free. Choose it always, because it is the only one a gcloud or Console change cannot bypass. Sentinel and OPA gate the Terraform path; Org Policy gates the API.

Common Mistakes
  • Treating Sentinel or OPA as the only guardrail and assuming "no public buckets" is enforced — neither sees a gcloud storage buckets add-iam-policy-binding run outside Terraform; only Org Policy's storage.publicAccessPrevention stops that.
  • Relying solely on Org Policy and giving developers no fast feedback — the violation surfaces as an opaque apply-time API error instead of a clear "policy X failed" in PR, so add a plan-time layer too.
  • Writing OPA policies against the HCL instead of the plan JSON — policy must evaluate the resolved plan from terraform show -json, because computed values and module expansion only exist there.
  • Setting an Org Policy constraint at the org node without an exemption for the one project that legitimately needs an external IP, then breaking a working deployment org-wide — scope to the right folder or project and use dry-run mode first.
  • Assuming Sentinel runs everywhere — it only enforces on runs executed by HCP Terraform, so a local terraform apply skips it entirely.
Best Practices
  • Run a plan-time layer — OPA/conftest in CI, or Sentinel on HCP Terraform — for fast developer feedback and GCP Org Policy as the unbypassable API-level backstop. Both, not either.
  • Encode the org's non-negotiables (storage.publicAccessPrevention, compute.vmExternalIpAccess off, a gcp.resourceLocations allowlist) as google_org_policy_policy so no path — Terraform, gcloud, or Console — can violate them.
  • Evaluate OPA or Sentinel policies against the plan JSON, never the raw HCL, so computed values and expanded modules are visible.
  • Roll out a new Org Policy constraint in dry-run (audit) mode first, scope it to the correct hierarchy node, and add explicit exemptions before enforcing.
  • Mirror the same rule across both layers so a violation is caught early in PR and still cannot reach GCP through any other path.
Comparable tools OPA/conftest · Sentinel the plan-time policy engines GCP Org Policy the native API guardrail with no Terraform-tool analog Policy Controller · Gatekeeper the same idea on GKE and Config Connector via admission checkov · tfsec enforce policy on source, not on the plan or API

Knowledge Check

An engineer runs gcloud to make a bucket public, bypassing Terraform entirely. Which layer stops them?

  • GCP Org Policy's storage.publicAccessPrevention, because the API rejects the request server-side
  • Sentinel, because it evaluates every change made to the project no matter which tool or person issues it
  • OPA/conftest, because it continuously scans every live GCP resource and intercepts the call
  • None — a change made directly through gcloud cannot be policed by any layer at all

What is the trade-off between plan-time and API-time enforcement?

  • Plan-time gives fast PR feedback but only on the Terraform path; API-time is unbypassable but fails later, at apply
  • Plan-time enforcement is fully unbypassable, while API-time only advises and can always be overridden by an operator
  • API-time gives faster feedback because it runs server-side before the Terraform plan even starts
  • There is no real difference; both layers evaluate the change at the exact same moment

Why must OPA policies evaluate the plan JSON rather than the raw HCL?

  • Computed values and module expansion exist only in the resolved plan, not in the source files
  • Rego cannot parse raw HCL block syntax at all, so plan JSON is the only readable input
  • The raw HCL stays encrypted on disk until apply, so OPA has no way to read the source
  • Plan JSON is the only format that embeds the provider credentials OPA needs to evaluate rules

Where does Sentinel enforce, and where does it not?

  • Only on runs executed by HCP Terraform — a local terraform apply skips it entirely
  • On every single terraform apply anywhere, including workstation and local CI runs
  • At the GCP API itself, on any change to a resource regardless of which tool sends it
  • Only on gcloud and Cloud Console changes, never on any Terraform-driven run

You got correct