Policy as Code, Cost Checks, and Supply-Chain Security

Policy as code, cost checks, and supply-chain security turn a Terraform pipeline from a plan runner into a release gate. The outcome is specific: a proposed infrastructure change is allowed only when the saved plan satisfies security rules, estimated spend stays within an agreed limit, and the code, modules, and providers used to build the plan are the ones the team intended to trust.

In this course section on testing and delivery, these checks sit after formatting, validation, and module tests, but before apply. They use Terraform’s plan as evidence. A human can miss a public bucket flag buried in a long diff; a policy engine can inspect the machine-readable plan every time and fail the run consistently.

How the Gate Works

A normal delivery flow starts with terraform init. Init downloads provider plugins and modules according to version constraints, records provider checksums in .terraform.lock.hcl, and prepares the working directory. The pipeline then runs terraform plan -out=tfplan, producing a binary saved plan tied to the configuration, variables, provider selections, state snapshot, and refreshed remote object data used during planning.

Policy tools usually do not read the binary plan directly. The pipeline converts it with terraform show -json tfplan. That JSON contains the proposed actions under resource_changes, input variable values, output changes, configuration metadata, and provider selections. For each resource address, the change.actions array describes whether Terraform will create, update, delete, perform a replacement as delete plus create, or take no-op. The before, after, and after_unknown fields explain known old values, known proposed values, and values that will only be known after apply.

A policy engine evaluates rules against that JSON. Open Policy Agent commonly uses Rego rules where denied messages are accumulated in a deny set. Terraform Cloud and Terraform Enterprise can also use policy sets attached to workspaces. Some organizations use Sentinel, others use OPA through Conftest, Checkov, Terrascan, or custom CI code. The important mechanism is the same: evaluate the immutable saved plan, return pass or fail, and apply only that exact plan if every required check passes.

Cost checks add another derived view of the plan. Tools such as Infracost compare before and after resource data with cloud pricing catalogs and produce monthly estimates and deltas. Cost output is an estimate, not an invoice. It is strongest for resource classes with predictable unit pricing, such as compute instance hours and storage size, and weaker for traffic, request volume, negotiated discounts, or provider services whose runtime usage is not expressed in Terraform configuration.

Supply-chain security asks whether the Terraform run used trusted ingredients. Provider source addresses, version constraints, lock-file hashes, module sources, module refs, and pipeline credentials all matter. A provider plugin is executable code. A module can define resources, data sources, provisioners, and outputs. A loose source such as a branch ref can change without a code review. A secure pipeline pins providers, commits the lock file, uses immutable module refs, reviews dependency updates, and limits the credentials available to planning and applying.

Syntax and Anatomy

The policy input is usually the JSON plan. The fields used most often are resource_changes[].address, resource_changes[].type, resource_changes[].name, resource_changes[].provider_name, resource_changes[].change.actions, and resource_changes[].change.after. Cost tooling consumes the same plan but maps resource attributes to provider pricing dimensions. Supply-chain checks inspect Terraform source files, module calls, and .terraform.lock.hcl.

The following examples use short fragments so the moving parts are visible. In a real pipeline, run them against the full saved plan from the same commit that will be applied.

Example 1: Deny Public S3 Buckets

The first policy rejects an AWS S3 bucket when the plan proposes a public ACL. The rule loops over resource changes, narrows to aws_s3_bucket, ignores deletions, and reads the planned acl value.

resource "aws_s3_bucket" "logs" {
  bucket = "example-company-prod-logs"
  acl    = "public-read"
}
package terraform.policy

deny[msg] {
  change := input.resource_changes[_]
  change.type == "aws_s3_bucket"
  not contains(change.change.actions, "delete")
  change.change.after.acl == "public-read"
  msg := sprintf("%s must not use public-read ACL", [change.address])
}

Expected behavior: if the plan includes aws_s3_bucket.logs with acl set to public-read, the policy returns a denial message such as aws_s3_bucket.logs must not use public-read ACL. If the ACL is private, or if the change is only deleting the bucket, this specific rule does not deny the plan. A production policy would also inspect ownership controls, public access block resources, bucket policies, and provider defaults because modern S3 exposure is not controlled by one argument alone.

Example 2: Gate Monthly Cost Delta

The second example shows a cost policy over a simplified cost report. The Terraform plan might create an instance and enlarge storage; the cost tool turns that into a monthly delta. The policy blocks changes above a limit unless the workspace is explicitly marked for review.

{
  "totalMonthlyCost": "182.40",
  "pastTotalMonthlyCost": "42.10",
  "diffTotalMonthlyCost": "140.30",
  "metadata": {
    "workspace": "payments-stage",
    "cost_review": false
  }
}
package terraform.cost

monthly_delta := to_number(input.diffTotalMonthlyCost)

 deny[msg] {
  monthly_delta > 100
  not input.metadata.cost_review
  msg := sprintf("monthly cost delta %.2f exceeds 100.00 without review", [monthly_delta])
}

Expected behavior: this report fails because the estimated monthly increase is 140.30 and cost_review is false. If the delta were 75.00, it would pass. If the delta stayed at 140.30 but cost_review were true, the policy would allow the run to continue while preserving evidence that a cost exception was deliberately attached.

The design choice is whether the limit is absolute, percentage-based, workspace-specific, or resource-specific. A fixed 100 dollar gate is easy to explain but noisy for large environments and too permissive for small ones. Percentage gates catch sudden proportional growth but can miss expensive steady increases. Mature pipelines usually combine a hard organization limit with lower thresholds per environment.

Example 3: Pin Providers and Modules

The third example focuses on supply chain. The provider constraint permits compatible patch and minor releases within the selected major series, while the lock file records the exact selected version and acceptable hashes. The module source uses an immutable tag rather than a floating branch.

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

module "network" {
  source = "git::https://github.com/example/platform-modules.git//vpc?ref=v1.4.2"

  name = "payments"
  cidr = "10.40.0.0/16"
}
provider "registry.terraform.io/hashicorp/aws" {
  version     = "5.43.0"
  constraints = "~> 5.0"
  hashes = [
    "h1:exampleHashValueForTrainingOnly=",
    "zh:exampleZipHashValueForTrainingOnly"
  ]
}

Expected behavior: terraform init -lockfile=readonly succeeds only if the checked-in lock file already contains the selected provider checksums for the current platform. If a developer changes the provider constraint or module ref, review sees a small source diff. If a pipeline encounters a provider version or checksum not recorded in the lock file, readonly init fails before a plan is created.

Do not treat this as a guarantee that every dependency is safe. Pinning makes changes visible and repeatable; it does not audit provider source code, prove a module is well designed, or remove the need for dependency review. It does reduce the risk of silently executing a different plugin or module because an upstream branch, registry release, or local cache changed.

Design Choices and Trade-Offs

The first choice is enforcement location. Local pre-commit hooks give fast feedback, but developers can bypass them. CI checks are consistent for pull requests, but they might not see the exact workspace variables or state used at apply time. Terraform Cloud run tasks and policy sets evaluate close to the apply boundary, but they depend on that platform’s workflow. Many teams use all three: local checks for speed, pull request checks for review, and an apply-time gate for authority.

The second choice is fail-open or fail-closed behavior. Security and supply-chain checks should usually fail closed because an unavailable policy engine should not permit unreviewed infrastructure. Cost checks sometimes fail open for low-risk development work if pricing APIs are temporarily unavailable, but production cost gates should normally block or require manual approval. The rule should be explicit; accidental fail-open behavior is a release defect.

The third choice is plan-time versus configuration-time scanning. Plan-time policies see computed values, module expansion, provider defaults that appear in the plan, and replace/delete actions. Configuration scanners can run without credentials and catch insecure code before planning, but they may miss values produced by variables, modules, or provider behavior. Use configuration scanning to shift feedback left and plan scanning to decide whether this specific change may be applied.

Failure Modes and Troubleshooting

Symptom: a policy says a required tag is missing even though the HCL sets it. Cause: the tag may be added through provider-level default tags, a module local, or a resource-specific field that the policy is not reading. Diagnose: inspect terraform show -json tfplan and find the exact change.after.tags value for the failing address. Correction: update the policy to read the plan field actually used by the provider and add a regression fixture.

Symptom: cost checks report zero or a much smaller value than expected. Cause: the changed resource may not be supported by the cost tool, usage-based fields such as requests or egress may be absent from Terraform, or variables may differ between the cost job and the real plan job. Diagnose: compare the cost report resources with resource_changes, confirm the same plan JSON is used, and identify unsupported items. Correction: add explicit usage estimates where the tool supports them and require human review for unsupported expensive services.

Symptom: CI fails during init with a lock-file error. Cause: a provider was upgraded locally but the lock file was not committed, or the lock file lacks hashes for the CI platform. Diagnose: run init with readonly mode in a clean checkout and inspect the provider entry. Correction: intentionally update the lock file in a dependency-change pull request, include the target platforms if needed, and review the provider release notes.

Symptom: a module changed behavior without a visible Terraform source diff. Cause: the module source used a branch, mutable tag, registry version range, or local path overwritten by automation. Diagnose: inspect every module block and the init log, then compare the downloaded module revision. Correction: pin module sources to immutable tags or commits and require code review for ref updates.

Security, Performance, and Reliability

Policy and supply-chain checks protect credentials as much as resources. Plan jobs often need read access to existing infrastructure and state, while apply jobs need write access. Splitting those credentials limits damage if a pull request job is compromised. Avoid exposing secret variable values in policy logs; plan JSON may contain sensitive values marked by Terraform, but external tooling can still mishandle files if logs or artifacts are too broad.

Performance matters because plan JSON can be large. Expensive policy rules that repeatedly scan all resources can add minutes to a monorepo pipeline. Prefer rules that filter by resource type first, reuse helper predicates, and test policies with representative plan fixtures. Reliability depends on storing the saved plan and applying that exact artifact. Re-planning after policy approval creates a gap where state, variables, providers, or modules may have changed.

Hands-On Lab

Prerequisites: Terraform, a CI-like shell environment, and one policy tool such as OPA or Conftest. Use a disposable directory and do not apply cloud resources for this lab. The goal is to evaluate a saved plan and prove that the gate blocks a risky change before apply.

  1. Create a small Terraform configuration containing an S3 bucket or another resource your provider account can plan. Add one intentionally noncompliant attribute, such as a missing required tag or public setting.
  2. Run terraform init, then terraform plan -out=tfplan. If you cannot use cloud credentials, create a captured plan JSON fixture from a non-production workspace instead.
  3. Convert the plan with terraform show -json tfplan > tfplan.json.
  4. Write a policy that denies the noncompliant resource by inspecting resource_changes and returning a clear message containing the resource address.
  5. Run the policy tool against tfplan.json. Verification: the command must fail or return at least one denial message naming the risky resource.
  6. Fix the Terraform configuration, create a new saved plan, convert it again, and rerun the policy. Verification: the denial disappears and the new plan still matches the intended change.
  7. Add a cost report step if your tooling supports it. Set a deliberately low threshold, verify that the check fails, then raise the threshold or add an approved review flag and verify that it passes.
  8. Cleanup: delete tfplan, tfplan.json, cost reports, and any local plugin caches or test credentials created only for the lab. If you created real resources, destroy them from the same reviewed configuration.

Assessment Exercises

  1. A plan replaces a database instance because an immutable storage setting changed. Which policy fields would you inspect to distinguish an in-place update from a replacement, and why should the policy treat them differently?
  2. Your team wants to block unpinned modules. Design a rule that handles Git sources, registry modules, and local modules without preventing legitimate local development.
  3. A cost gate passes a change that later produces a large network bill. Explain which parts of the spend were invisible to Terraform and how you would add a review path for them.
  4. A provider lock file changes in the same pull request as several infrastructure resources. What review evidence would you require before approving the combined change?
  5. Write a troubleshooting checklist for a policy failure that appears only in CI and not on a developer laptop.

Summary

Terraform policy as code evaluates the saved plan, cost checks estimate the financial effect of that plan, and supply-chain controls make provider and module inputs repeatable. The strongest delivery gate combines configuration scanning for early feedback, plan scanning for authoritative apply decisions, explicit cost thresholds, committed provider locks, immutable module refs, and short-lived credentials. The practical test is simple: a risky plan should fail with a precise reason, a corrected plan should pass, and the approved artifact should be the exact plan that gets applied.