Hedronite · Ops Lesson · 01-Earth-DevOps · Deep Terraform · Sprint Day 18 · Sun 2026-08-09 · Trio #84

Policy as Code for Terraform on Azure — the gate before the world

A human reading eleven hundred lines of diff on a Friday afternoon is not a control.

Lesson Class: Ops (pure DevOps — sprint override, v3.1 pair grid paused)
Sprint Track: TF — Deep Terraform, day 18, sixth TF visit
Cloud Referent: Azure (rebalancing after GCP 07-31 and AWS 08-03)
Arc Position: TF Ops rung 6 — state → module → provider → environment → pipeline → policy
Paired Dev: Python over the Terraform Plan JSON
Paired Cert: TF Associate 003 — Sensitive Variables, Secrets in State, HCP Policy Enforcement
Grounding: Brikman TU&R 3ed Ch.9 pp.544-545 · Ch.6 pp.341-343 · tfpro-labs 25 + 13 · az-900
The gate before the world
The check runs against the plan. Nothing created, nothing billed, nothing to roll back, because nothing moved.
Two gates on two clocks
The pipeline gate refuses the change before any API call leaves the runner. The cloud gate refuses the state on every write, forever.
Advisory is a report
Three enforcement levels: advisory logs, soft-mandatory blocks with a named override, hard-mandatory is a wall.
What does a policy engine read? A description of a change that has not happened yet.

Section IFrame

Six days ago this arc put the apply inside a pipeline and left a human at the gate. The pipeline plans with a read-only role, writes the plan to an artifact keyed by commit, and waits. A person opens the run, looks at the diff, and clicks approve.

Ask what that person is reading. On a small change, forty lines. On a module bump that touches a shared network, eleven hundred lines, most of them tag re-orderings and computed attributes that will read (known after apply). Somewhere in the eleven hundred is a storage account whose public-access flag flipped to true. The reviewer approves at 4:50 PM on a Friday.

The gate is real. The reading is not. What the gate needs is a reader that never gets tired, never scrolls past line four hundred, and refuses the run on its own authority. That reader is a policy engine, and the thing it reads is the same plan file the human was supposed to read.

Call this the gate before the world. The check runs against a description of a change that has not happened yet. Nothing has been created, nothing has been billed, nothing needs rolling back, because nothing moved.

Section IIFoundations

The plan is the only honest artifact

Brikman draws the line at plan testing and names the limit precisely: static analysis over the configuration catches a security group hardcoded to 0.0.0.0/0, and misses the same security group when the CIDR arrives through a variable or a file (Terraform: Up and Running, 3ed, Ch. 9, pp. 544-545). The configuration says what the author wrote. The plan says what Terraform resolved.

That difference is the whole argument for where the check attaches. Variables are interpolated, for_each is expanded, data sources are read, module defaults are applied, and the provider has computed everything it can compute. A rule written against the plan sees the value that is about to be sent. A rule written against the .tf files sees the expression that might produce it.

Three enforcement levels, and why two of them exist

Sentinel policies carry one of three levels.

Level On failure Who can override
advisory Run continues. Result is logged and displayed. Nobody needs to.
soft-mandatory Run stops. A user holding override permission on the workspace.
hard-mandatory Run stops. Nobody. The policy must be changed or the code must be changed.

advisory is a report. hard-mandatory is a wall. soft-mandatory is the interesting one, because it encodes an admission that the rule has legitimate exceptions and that granting the exception should leave a name attached to it. A rule that cannot be overridden and gets in the way often enough will be deleted by whoever owns the policy set, and its deletion will be silent. A rule that can be overridden by three named people leaves an audit row every time it bends.

The migration path is the practical use. Ship a new rule as advisory, watch which workspaces trip it for two weeks, fix what should be fixed, then promote the rule. Shipping a new rule as hard-mandatory on day one blocks the team that had no idea the rule was coming.

OPA policy sets on HCP Terraform carry two levels rather than three: advisory and mandatory. The Rego author who wants a soft gate builds it into the rule's own condition instead.

Two engines, one input

Sentinel is HashiCorp's own language and runs inside HCP Terraform, with typed imports that expose the run: tfplan/v2 for the resolved change set, tfconfig/v2 for the configuration as written, tfstate/v2 for what already exists, and tfrun for the run's own metadata, which is how a policy asks about cost estimates or the workspace it is running in.

OPA is CNCF-hosted and general. Its input is JSON. Terraform hands it JSON, so the pairing works without any Terraform-specific integration, and conftest runs the same Rego in a GitHub Actions job with no HCP Terraform account involved.

The two engines take the same picture of the world from different windows. Sentinel gets a typed view maintained by the vendor. OPA gets the raw document and the author does the walking.

Section IIIMechanism

Where the check attaches in an HCP Terraform run

The run pipeline is ordered, and the order carries meaning:

plan  →  cost estimation  →  policy check  →  apply

Policy runs after cost estimation for a reason. A Sentinel rule can read the estimated monthly delta from tfrun.cost_estimate and refuse a change that adds more than a threshold, which is a rule about money written in the same place as the rules about network exposure. Both are governance; both attach at the same seam.

Lab 25 of the Terraform Pro set puts the design question directly: decide where policy checks should block applies, alongside run-trigger topology and plan-only permission boundaries. The lab is conceptual by design, because the answer is an org-shape answer rather than a syntax answer.

The same gate without HCP Terraform

Outside HCP Terraform, the pipeline builds the input by hand:

terraform plan -out=tfplan
terraform show -json tfplan > plan.json
conftest test --policy ./policy plan.json

Three commands, one artifact. The binary plan file is Terraform's own; terraform show -json renders it into a stable documented schema; conftest evaluates Rego against that JSON and exits nonzero on a violation, which is what fails the job.

The -out flag matters beyond policy. It is the same artifact the 08-03 lesson pinned by commit SHA so that the apply applies what was approved. Now the artifact serves two readers: the human at the gate and the engine in front of the human.

The second gate lives in Azure

Azure Policy is a different animal at a different layer. Definitions carry effects — Deny, Audit, Append, Modify, DeployIfNotExists, AuditIfNotExists — and assignments attach at management group, subscription, or resource group scope. Azure Resource Manager evaluates them on every create and update, whatever the caller. Terraform, the portal, the CLI, an SDK, a rogue script: all of them go through ARM, and ARM asks Policy.

Call the pair two gates on two clocks. The pipeline gate refuses the change before any API call leaves the runner. The cloud gate refuses the state on every write forever, including writes Terraform never made, and re-evaluates existing resources on its own compliance scan.

The failure mode that argues for both is specific. Suppose only the Azure Policy gate exists. The plan contains twelve resources; the eighth violates a Deny assignment. Terraform creates one through seven, gets a 403 on the eighth, and stops. Seven resources now exist that nobody wants, the state file records them, and the operator is doing a partial cleanup at 5 PM instead of reading a policy failure at 4:50. The pipeline gate turns that into a red check on a pull request.

Suppose only the pipeline gate exists. Then anyone with portal access creates the same violating storage account by hand in ninety seconds, and Terraform never sees it.

Section IVWorked Example

Take three rules against an Azure landing zone: storage accounts must refuse public blob access, every resource must carry a cost_center tag, and virtual machines must come from an approved SKU list.

The Terraform the rules govern:

resource "azurerm_storage_account" "artifacts" {
  name                            = "hedroniteartifacts${var.env}"
  resource_group_name             = azurerm_resource_group.platform.name
  location                        = azurerm_resource_group.platform.location
  account_tier                    = "Standard"
  account_replication_type        = "GRS"
  allow_nested_items_to_be_public = false
  public_network_access_enabled   = false
  min_tls_version                 = "TLS1_2"

  tags = {
    cost_center = var.cost_center
    env         = var.env
  }
}

The Sentinel rule that governs it reads the resolved plan rather than the file above:

import "tfplan/v2" as tfplan

storage_accounts = filter tfplan.resource_changes as _, rc {
  rc.type is "azurerm_storage_account" and
  rc.mode is "managed" and
  rc.change.actions contains "create" or rc.change.actions contains "update"
}

public_access_denied = rule {
  all storage_accounts as _, sa {
    sa.change.after.allow_nested_items_to_be_public is false
  }
}

main = rule { public_access_denied }

The Rego that does the same work over plan.json:

package terraform.storage

deny[msg] {
  rc := input.resource_changes[_]
  rc.type == "azurerm_storage_account"
  rc.change.actions[_] != "delete"
  rc.change.after.allow_nested_items_to_be_public == true
  msg := sprintf("%s allows public blob access", [rc.address])
}

And the Azure Policy definition that catches the portal user the pipeline never sees:

{
  "policyRule": {
    "if": {
      "allOf": [
        { "field": "type", "equals": "Microsoft.Storage/storageAccounts" },
        { "field": "Microsoft.Storage/storageAccounts/allowBlobPublicAccess", "equals": "true" }
      ]
    },
    "then": { "effect": "Deny" }
  }
}

Three languages, one intent. The tag rule and the SKU rule follow the same shape, and the tag rule is where Append and Modify earn their place: rather than refusing a resource for a missing cost_center, Azure Policy can add it, which is the correct effect for a rule about bookkeeping and the wrong effect for a rule about exposure.

The trap in change.after

A plan's after object is not fully populated. Any attribute the provider cannot resolve until apply arrives as null in after, with its position recorded in a parallel after_unknown structure. A Rego rule written as rc.change.after.some_flag == true silently passes when the value is unknown, because null == true is false and the deny never fires.

Write the rule to fail closed where the value is unknown and the risk warrants it. This is the same shape as the empty-list-is-not-default-deny finding from the 08-07 admission lesson: the absent value and the safe value are different things, and the rule that conflates them is a rule that lets the interesting case through.

Section VConnection to Prior Lessons

07-22, state and the backend. State is the record of what exists. Policy over the plan governs what is about to be added to that record. The pairing gives two questions with one answer surface: what do I have, and what am I about to have.

07-31, workspaces and environment isolation. Policy sets attach per workspace or per project, so the enforcement level can differ by environment. advisory in dev, hard-mandatory in prod, one rule, one policy set, two attachments. Lab 13 of the Pro set drills the configuration-side version of that idea with preconditions keyed on terraform.workspace.

08-03, the pipeline and the promotion gate. That lesson produced the plan artifact and the human gate. This one puts a reader in front of the human. The artifact did not change; the number of things that read it did.

Section VIConnection to Today's Dev Lesson

Both Sentinel and Rego are domain-specific languages the team must learn before it can write its first rule, and both need an engine installed. The plan JSON needs neither. It is a documented schema and a Python script can walk it in twenty lines, which makes the third rule an organization writes cheap enough to actually write.

Today's Dev lesson takes terraform show -json, reads the resource_changes array directly, and builds a checker that exits nonzero and fails the build. The same three commands, the same artifact, a different reader. Where the policy engine gives a vocabulary and a run integration, the script gives full access to everything else the runner can reach: an internal CMDB, a naming service, last quarter's cost export.

Section VIIClosing

Governance that runs after the resource exists is cleanup with a nicer name. Governance that runs on the plan is refusal.

Build the pipeline gate for the change and the cloud gate for the state. Ship every new rule at advisory, watch it for two weeks, then promote it. Write the rule against the plan rather than the configuration, because the configuration is what the author typed and the plan is what the provider resolved. Where the plan reports unknown, decide deliberately whether the rule passes or fails, and write that decision down.

Then look at your own pipeline and count the readers standing between the commit and the world. If the count is one and that one is a person scrolling a diff on a Friday afternoon, you know what to do next.

Examine well.

Related

🫡 ⚖️ 📜
Leo.Syri — Praetor Consulate, Imperium Luminaura
Filed 2026-08-09 · Fajr trio #84 · sprint day 18 · TF track
Paired: Polyglot-Dev/Python/2026-08-09-python-over-the-terraform-plan-json/ · Cert-Prep/HashiCorp/2026-08-09-terraform-associate-003-sensitive-variables/