A policy gate that blocks non-compliant Terraform before it applies

Infrastructure-as-code makes it trivial to ship anything, including insecure or non-compliant resources. A change that adds a publicly-readable storage account is one terraform apply from live. The two usual guardrails both miss it: runtime alerts fire after the resource exists, and blanket cloud-policy denials are blunt enough that people request exceptions or turn them off.

This gate grades a Terraform change against one policy before any of it applies. If any part of the change is non-compliant, none of it applies. It is the deploy-time companion to the subscription tripwire I wrote about earlier: the tripwire contains what already happened at runtime; the gate prevents it at deploy time.

What it does

Top to bottom: a Jenkins deploy job runs gate-check.sh, which copies the repo to a throwaway workspace, injects plan-scoped mocks, plans every unit and POSTs each plan to a private Azure Function that grades it with OPA and a model; the apply runs only if every unit is allowed, otherwise the job aborts
The path a change takes, top to bottom: plan the whole change in a throwaway copy, grade it against OPA and a model, and apply only if every unit passes.

Two pieces. A grader script that runs inside the deploy pipeline (a Jenkins job that wraps terragrunt), and a decision service: a private Azure Function that holds the policy and answers one question, given this plan, allow or deny. The script produces a plan and asks the service. The service answers. The script blocks the apply on a deny. Before the pipeline applies anything, the grader plans every unit in the change, sends each plan to the Function, and stops the whole run if any unit comes back denied.

Two ways to say no

The Function grades a terraform show -json plan with two independent layers. The first is OPA with a Rego policy: the bright-line rules you can write down and unit-test.

# no anonymous (unauthenticated) blob access
deny contains msg if {
    rc := input.resource_changes[_]
    rc.type == "azurerm_storage_account"
    rc.change.after.allow_nested_items_to_be_public == true
    msg := sprintf("%s: allow_nested_items_to_be_public must be false", [rc.address])
}

The second is a language model (Azure OpenAI, called with a managed identity, no keys) for the rules you cannot easily write down: this opens a management port to the world, this grants Owner to a principal with no history in the resource group. It returns a structured risk assessment.

{
  "risk": "HIGH",
  "reasons": [
    "opens management port 22 to 0.0.0.0/0",
    "grants Owner to a principal with no prior activity here"
  ]
}

The verdict combines them: allow only if OPA found zero violations and the model risk is not HIGH. OPA is the layer you rely on; the model can raise a concern but cannot loosen a deny, and if it is unavailable the call returns UNKNOWN rather than a false block, so a flaky model never wedges the delivery path.

Grading the whole change

The hard part is atomicity. None of it applies unless all of it passes means grading the entire change before applying any of it. But Terraform infrastructure is built in units with separate state, and a brand-new unit cannot be planned before the thing it depends on exists. A fresh stack is full of units that error with dependency has no outputs yet.

The easy workaround is to skip the un-plannable units and grade the rest. That quietly breaks all-or-nothing. Instead the grader plans the whole change in a throwaway copy of the repo, and makes the un-plannable units plannable with generated mock values.

WORK="$(mktemp -d)"
trap 'rm -rf "$WORK"' EXIT
rsync -a --exclude '.git' "$REPO_ROOT"/ "$WORK"/
cd "$WORK"

# refuse to inject unless we are provably in the throwaway
case "$PWD/" in "$REPO_ROOT"/*) echo "FAIL-CLOSED"; exit 1;; esac

python3 gen-mocks.py "$STACK" --write   # inject plan-scoped mocks

A small generator reads which outputs each unit actually consumes and injects a matching mock into its dependency, scoped so the mock is only ever used for read-only commands.

dependency "rg" {
  config_path = "../01.rg"
  mock_outputs = {
    name = "mock-name"
  }
  mock_outputs_allowed_terraform_commands = ["init", "validate", "plan", "show"]
}

apply and destroy are deliberately excluded, so a mock value can never reach a real apply. The mocks only ever exist in the scratch copy; the real repository never carries them. Fake dependency values are safe here because the policy judges the resource being created, its region, its public-access flag, its anonymous flag, which live in the unit own config, not in the mocked upstream. If the gate allows, the real apply runs from the real tree against real state. If it denies, nothing runs and the scratch copy is deleted.

Fail-closed and all-or-nothing

Two properties make it a gate rather than a linter. If the gate is unreachable, or a unit cannot be planned even with mocks, that is a deny, not a pass; uncertainty blocks. And the apply step is wired so it literally cannot proceed on a non-zero exit.

( cd iac && bash ci/gate-check.sh "$STACK" "$UNIT" ) \
  || { echo "==== GATE BLOCKED THE APPLY ===="; exit 1; }

One denied unit fails the whole run, so terragrunt apply never starts, not even for the compliant units in the same change.

Result

There is a permanent fixture: a storage account that is westus, anonymous, and public-open, depending on a resource group that is not applied yet. A CI job runs it through the gate on every check and asserts the block. The actual output:

gate: ALLOW poc-badsa/_iac/01.rg          # compliant RG plans clean (mocked deps)
gate: DENY  poc-badsa/_iac/02.badsa       # the bad storage account is caught
   OPA: allow_nested_items_to_be_public must be false (no anonymous blob access)
   OPA: location 'westus' is not in the allowed regions eastus/eastus2
   OPA: public network access enabled without a default-Deny firewall
GATE: DENIED - aborting apply.
SELFTEST PASS: the gate DENIED poc-badsa on its actual policy violations

The compliant resource group plans clean but is never applied, because a sibling failed. That is atomicity, not politeness. Zero resources were created; the greenfield stack never touched the cloud.

The interesting part is not the OPA rules or the model call, both are standard. It is the throwaway-workspace-plus-mocks step that makes whole-change grading possible on a multi-unit layout, without letting a fake value ever reach a real apply.

What broke when I pointed it at something hard

The gate is clean on simple changes. The results worth recording came from pointing it at a genuinely complex stack – one that deliberately provisions a highly-privileged identity for a purple-team exercise. Two things happened, and both are the gate working, not failing.

The model caught a role I actually wanted

The change created a custom role granting roleAssignments/write, locks/delete, and diagnosticSettings/delete – deliberately privileged. The deterministic OPA rules had nothing to say (they cover storage, TLS, regions, Key Vault – not custom RBAC). The model layer did:

{
  "risk": "HIGH",
  "reasons": [
    "custom role grants roleAssignments/write (privilege escalation)",
    "includes locks/delete - an assignee could remove resource protections",
    "includes diagnosticSettings/delete - could disable telemetry/alerts"
  ]
}

That is the judgment layer earning its place: the bright-line rules do not cover every dangerous thing, and here the model caught a genuinely risky role the rules would have missed. The catch is that I wanted this role – it is the point of the exercise. Which exposes a missing feature. A real policy system needs auditable exceptions: you should be able to approve a flagged change with a recorded justification, but only for a judgment call, never for a bright-line rule. That distinction – an exception versus a hole – is the whole design of a good exemption.

The mocks were not real enough

All-or-nothing grading depends on planning the whole change in a throwaway workspace, with generated mocks standing in for not-yet-applied dependencies. On simple stacks that works. On a complex one it broke:

Error: parsing "mock-id": Segment 3 - not found, Segment 4 - not found ...
  with azurerm_monitor_diagnostic_setting.this
  log_analytics_workspace_id = var.log_analytics_workspace_id

The mock generator was substituting a plain string, "mock-id", for a dependency resource ID. Most resources do not care, but a diagnostic-setting parses that value as an Azure resource ID – and "mock-id" is not one – so the plan failed and the gate fail-closed. The lesson: a mock has to be shaped like the real thing, not merely present. A mocked resource ID has to look like a resource ID; a mocked map has to be a map. To a fail-closed gate, "present but wrong-shaped" reads as "cannot evaluate this – deny", which is the safe answer, but it means the mocks have to earn their keep before a complex greenfield stack can be graded.

Both are the gate working

Neither of these is a failure. The model flagging a dangerous role is the gate succeeding. Fail-closing on a unit it cannot plan is the gate succeeding. What they expose is the next layer of work: exceptions for the judgment calls, and higher-fidelity mocks so complex greenfield stacks are plannable. A gate that only works on simple changes is not a gate. Finding where it strains is how it becomes one.


Posted

in

by

Tags:

Comments

Leave a Reply

Your email address will not be published. Required fields are marked *