Policy as Code

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages.

Reviewers miss things, and a pull request that adds a database open to the whole Internet looks just like one that adds a tag. Policy as code writes the organisation’s rules (allowed instance sizes, mandatory encryption, no public ingress, approved module sources) as versioned, tested programs that run against every change, so the rule is enforced by the pipeline and not by memory. This page maps the places where such rules can run, from the cheapest (a linter on a laptop) to the strictest (a policy engine inside HCP Terraform that can stop a run), and shows the same Bookshelf rule, "nothing in the network may accept traffic from 0.0.0.0/0", in each of the engines.

It is the last page of the quality group. It builds on The Dependency Graph and the Plan (the plan is the input of most policy engines), on CLI Essentials (plan -out and show -json), and it complements Code Quality and Security Scanning (static scanners with built-in rules) and Testing (assertions about your own module). The pipeline wiring lives in CI/CD and Automation, and the platform that runs Sentinel, OPA and Terraform Policy server-side is described in HCP Terraform and Terraform Enterprise.

The baseline is Terraform 1.17 (1.17.0 was released on October 21, 2026), checked against the HCP Terraform policy enforcement documentation, the Sentinel documentation, the Terraform Policy documentation (reference version v0.5.x), and the documentation of Conftest, Checkov and TFLint. Terraform Policy is the youngest of these and its documentation and the changelog disagree about its maturity; see Maturity: read this before relying on it.

Why policy as code, and where it runs

A policy is a rule over data that Terraform already produces. There are four natural places to evaluate it, each trading speed for authority.

Stage What the rule can see Typical tools

Editor and pre-commit

The .tf source only; no provider, no plan

TFLint (built-in and custom rules), Checkov, Trivy

Pull request (CI), before apply

The JSON plan from terraform show -json, so computed and known values

Conftest with OPA, Checkov plan scanning, Sentinel CLI, tfpolicy

Run in HCP Terraform or Terraform Enterprise

Plan, configuration, state and run metadata; can block the run

Sentinel, OPA, Terraform Policy

After apply

Real infrastructure

Cloud-native services (outside Terraform’s scope)

Three properties make a policy engine different from a linter:

  • It sees the plan, not just the code. A module that builds an instance_class from three variables can only be judged once the values are known. The plan is the first artifact in which they are.

  • It has an enforcement level. A rule may be advisory (a warning in the run), or it may stop the run, with or without an override. A linter has only pass and fail.

  • It is applied by the platform, not by the author. A policy set attached to a workspace or project runs whether or not the author’s pipeline remembers to call it. That is the control that matters for compliance.

Policies are also software. Keep them in a repository, review them, unit-test them with mocks, and release them with a version, exactly as for the modules they govern.

The plan that engines read contains sensitive values in clear text when it is rendered with show -json, so the file is as sensitive as state; the official page warns about this. Treat plan artifacts accordingly (see Sensitive Data, Ephemeral Values and Write-Only Arguments).

# produce the plan every CI-side engine below consumes
terraform plan -out=tfplan -input=false
terraform show -json tfplan > tfplan.json

The JSON document has a stable, versioned shape (format_version, currently 1.0). Its most useful keys for policies are resource_changes (one entry per resource instance, each with address, mode, type, name and a change object holding actions, before, after, after_unknown, before_sensitive and after_sensitive), planned_values, prior_state, configuration, variables and output_changes. Consumers must ignore properties they do not recognise, because minor versions add them.

Choosing an engine

Sentinel OPA (Rego) Terraform Policy Linter rules

Language

Sentinel, a purpose-built DSL

Rego

HCL

Go plugin, Rego (TFLint OPA ruleset), Python or YAML (Checkov)

Runs in HCP Terraform

Yes (workspaces)

Yes (workspaces)

Yes (workspaces and Stacks)

No

Enforcement levels

advisory, soft-mandatory, hard-mandatory

advisory, mandatory

advisory, mandatory-overridable, mandatory

Exit code and severity

Can run before providers download

No

No

Yes, for provider and module policies at setup time

Yes (static)

Can query other resources or data sources

Manual iteration over plan data

Manual iteration over plan data

core::getresources and core::getdatasource

No

A policy set can contain policies of one framework only, but sets of different frameworks can be applied to the same workspace. That allows an incremental migration: keep existing Sentinel sets and add Terraform Policy or OPA sets next to them.

Policy sets and enforcement levels

Whatever the language, HCP Terraform and Terraform Enterprise organise policies the same way. A policy set is a collection of policies applied globally, to chosen projects, workspaces or Stacks, or to workspaces carrying chosen tags, with optional exclusions. Each policy has an enforcement level that decides what a failure does to the run.

Framework Levels Meaning of a failure

Sentinel

advisory, soft-mandatory, hard-mandatory

Advisory logs a warning and the run continues. Soft-mandatory stops the run unless someone with permission overrides it. Hard-mandatory stops the run and cannot be overridden by the policy level alone.

OPA

advisory, mandatory

Advisory warns. Mandatory stops the run; a user with the Manage Policy Overrides permission can override it.

Terraform Policy

advisory, mandatory-overridable, mandatory

In HCL the middle level is written mandatory_overridable.

A Sentinel policy set has a second override: enabling the set option that allows overriding mandatory failures lets administrators or team owners override even hard-mandatory failures, and the page states that this setting takes precedence over each policy’s level. That is a deliberate escape hatch, so decide who may use it before enabling it.

A policy set reaches HCP Terraform in three ways: pasted in the web UI (experiments only), connected to a VCS repository so that pushes update the set, or pushed from a pipeline through the API or the tfe_policy_set resource of the TFE provider. The Free edition includes one policy set of up to five policies; VCS and API publishing need the Standard or Premium edition. Sentinel checks (the original mechanism) support Sentinel up to 0.40.x and can read cost estimation data. Evaluations are required for OPA, can be used by Sentinel sets of the Agent type, allow overrides and Sentinel runtime pinning, but cannot read cost estimation data. Evaluations run on a self-managed agent pool only on the Premium edition and when the pool accepts policy jobs; otherwise they run on HashiCorp’s infrastructure.

Sentinel

Sentinel is HashiCorp’s embedded policy language. A policy file ends in .sentinel, declares the data it needs with import, and evaluates to a main rule. HCP Terraform provides four imports for Terraform policies and does not allow custom imports.

Import Content

tfplan (and tfplan/v2)

The plan produced by terraform plan.

tfconfig (and tfconfig/v2)

The parsed configuration files.

tfstate (and tfstate/v2)

The state at plan time.

tfrun

Run metadata: workspace, organization, variables, whether the run is a destroy, and so on.

New policies should use tfplan/v2, the version designed for Terraform 0.12 and later. It is loaded by path and given an alias, and its data comes from the same JSON that terraform show -json prints. Its top-level keys are terraform_version, variables (root-module variables, indexed by name), planned_values (a predicted state; unknown values are omitted), resource_changes (every resource and data source, indexed by address), resource_drift (the changes Terraform detected outside its control, same shape as resource_changes), output_changes, and raw (the unprocessed plan, for experts). In a resource_changes entry, mode is managed or data, change.actions lists the operations in execution order (a normal replacement is ["delete", "create"], a create-before-destroy replacement is ["create", "delete"]), change.before is null for a pure create and change.after is null for a pure delete, and change.after_unknown tells you that a value that is missing from after is merely unknown until apply.

The following policy restricts the Bookshelf database size. It filters resource_changes the way the import’s documentation does, skips instances that the plan deletes (their after is null) and uses else so that a value which is still unknown does not abort the policy.

import "tfplan/v2" as tfplan

allowed_classes = ["db.t4g.micro", "db.t4g.small"]

databases = filter tfplan.resource_changes as _, rc {
	rc.mode is "managed" and
		rc.type is "aws_db_instance" and
		"delete" not in rc.change.actions
}

main = rule {
	all databases as _, rc {
		(rc.change.after.instance_class else "") in allowed_classes
	}
}

Source: Terraform Docs — Sentinel tfplan/v2 import. The filter is the documented one; the rule and the allowed list are Bookshelf illustrations.

Sentinel’s all and any stop at the first failing element, which is fast but gives a poor message. The documentation recommends a helper that loops over every instance, records every violation with its full resource address, and skips computed attributes, and then a main rule that calls it. HCP Terraform has no server-side mock, so test with the Sentinel command-line tool and mock files for the imports.

The policy is attached to a workspace through a policy set. The set’s repository has a sentinel.hcl file at its root, and every local policy file sits in the same directory and ends in .sentinel.

policy "restrict-db-instance-class" {
  source            = "./restrict-db-instance-class.sentinel"
  enforcement_level = "soft-mandatory"
}

Source: Terraform Docs — Sentinel policy sets from VCS. The block shape is the documented one; the name, file and level are Bookshelf choices.

The same file can also define module blocks (shared Sentinel code, for example a source pointing at a local .sentinel file under modules/) and a policy can use a remote source URL with a checksum, which is how policies published in the Terraform Registry are consumed.

The older tfplan import used in the Sentinel product documentation (tfplan.resources.<type> with applied values) is still shown in the Sentinel docs, but it exposes a flattened view. The v2 import mirrors the JSON plan and is the better starting point for new work.

OPA and Rego

Open Policy Agent policies are written in Rego, a declarative query language. HCP Terraform runs OPA through policy evaluations and passes the plan and the run as input documents.

  • input.plan is the plan JSON, including format_version, output_changes, planned_values, resource_changes and terraform_version.

  • input.run carries run data: id, created_at, created_by, is_destroy, speculative, variables, organization, project and workspace (with name, auto_apply, tags, execution_mode and vcs_repo).

The set is described by policies.hcl (or policies.json) in the repository root. Each policy block names a Rego query, typically a package plus a rule, such as data.terraform.policy1.deny. The query must return an array, and an empty array means the policy passed. description and enforcement_level are optional, and the level defaults to advisory when omitted. Rego files must end in .rego and may live in other directories of the repository.

policy "no-world-open-ingress" {
  query             = "data.terraform.policies.no_world_open_ingress.deny"
  enforcement_level = "mandatory"
  description       = "Bookshelf network rules must not accept traffic from 0.0.0.0/0"
}

Source: Terraform Docs — OPA policy sets from VCS. The block shape is the documented one; the names are Bookshelf illustrations.

The matching Rego collects a message for every offending rule. The resource type and attributes (aws_vpc_security_group_ingress_rule, cidr_ipv4) are those of the AWS provider resource.

package terraform.policies.no_world_open_ingress

import rego.v1

deny := [msg |
	some rc in input.plan.resource_changes
	rc.type == "aws_vpc_security_group_ingress_rule"
	not "delete" in rc.change.actions
	rc.change.after.cidr_ipv4 == "0.0.0.0/0"
	msg := sprintf("%s allows ingress from 0.0.0.0/0", [rc.address])
]

Source: Terraform Docs — Define OPA policies for the input document, AWS provider — aws_vpc_security_group_ingress_rule for the attributes. The rule itself is a Bookshelf illustration.

Test the Rego with opa test, as the official page does for its examples, and keep the test fixtures small plan fragments. OPA in HCP Terraform is pass or fail with a warning level; it has no soft-mandatory level.

Conftest against the JSON plan

The same Rego can run before the code reaches HCP Terraform, or in any CI system, with Conftest, a test runner for structured data built on OPA. By default Conftest reads policies from the policy directory (override it with --policy or -p), evaluates rules in the main package (override it with --namespace, or use --all-namespaces) and recognises rules named deny, violation and warn, including names with a suffix such as deny_myrule. deny and violation rules fail the run; warn rules only warn. A file argument, or - for standard input, selects the data.

For a Terraform plan the document root is the plan itself, so the resources are at input.resource_changes and not under input.plan.

package main

import rego.v1

deny contains msg if {
	some rc in input.resource_changes
	rc.type == "aws_vpc_security_group_ingress_rule"
	not "delete" in rc.change.actions
	rc.change.after.cidr_ipv4 == "0.0.0.0/0"
	msg := sprintf("%s allows ingress from 0.0.0.0/0", [rc.address])
}
terraform plan -out=tfplan -input=false
terraform show -json tfplan > tfplan.json
conftest test tfplan.json

Source: Conftest documentation for the policy directory, namespaces and rule names, Terraform Docs — terraform show and Terraform Docs — JSON Output Format for the plan JSON. The Conftest page does not document Terraform plan input itself; that usage follows from the JSON document layout.

Conftest is installed from a release archive, brew install conftest, scoop install conftest, mise, the openpolicyagent/conftest container image or go install; the release assets and images can be verified with GitHub artifact attestations and cosign. Pin the version in CI.

In a pipeline this is one step between plan and apply:

      - name: Policy check
        run: |
          terraform show -json tfplan > tfplan.json
          conftest test tfplan.json --policy policy

Source: Conftest documentation. The step is a sketch for the workflow shown in CI/CD and Automation.

The two Rego variants differ only in the document root (input.plan in HCP Terraform, input in Conftest) and in the way a failure is reported (an array returned to the platform, a deny set in Conftest). A policy written for both can normalise the root first, for example by reading input.plan when it exists and input otherwise.

Terraform Policy

Terraform Policy is HashiCorp’s HCL-based framework, designed so that rules read like Terraform configuration and can refer to any resource a provider defines. It adds three things the older engines lack: helpers to look up related resources and data sources, evaluation before providers and modules are downloaded, and evaluation of values that are only known after apply.

Maturity: read this before relying on it

The sources disagree, and the page states the disagreement rather than choosing a side.

  • The Terraform 1.17.0 changelog (October 21, 2026) says Terraform Policy is now generally available, and that the -policies flag for plan, apply and query no longer needs an experimental build or the -allow-experimental-features flag.

  • The Terraform Policy documentation still carries a BETA label and the warning not to use beta features in production, on the overview, install, policy-writing, compare and reference pages. Its version selector marks v0.5.x as latest and v0.1.x to v0.4.x as beta; the install page states that Terraform Policy requires Terraform v1.16 or later, and the HCP Terraform policy enforcement overview still mentions "v1.16alpha or later" and enabling pre-releases in the organization settings.

  • The terraform plan reference page (1.17.x) does not document a -policies option. The terraform query page and the changelog do.

  • The main branch changelog lists the -policies flag of terraform query as an experiment of the unreleased 1.18.

Treat the framework as new: pin the tfpolicy version, expect syntax changes between releases, check the documentation version that matches the CLI, and start with advisory policies. Re-read the changelog before upgrading.

Files, blocks and the tfpolicy CLI

The recommended repository layout keeps policies, tests and optional plugins apart.

./
├── policies/
│   ├── resource-tagging.policy.hcl
│   └── security-rules.policy.hcl
├── tests/
│   ├── resource-tagging.policytest.hcl
│   └── security-rules.policytest.hcl
├── plugins/
│   ├── src/
│   └── bin/
└── README.md

Source: Terraform Docs — Set up your project for Terraform policy. The tree is abbreviated from the documented one.

Policy files end in .policy.hcl and test files in .policytest.hcl. Unlike a Terraform configuration, each file in a directory is evaluated separately. A file that contains resource or provider policies starts with a policy block whose required_providers declares the provider the policies are written against. The policy blocks are provider_policy, resource_policy and module_policy; inside them, attrs. reads the attributes of the provider, resource or module, meta. reads arguments such as a source, and prior_attrs. (resource policies only) reads the previous values on update and delete. enforce blocks hold a condition and an error_message, locals and input blocks add values and parameters, filter limits which objects a policy applies to, and operations limits the lifecycle operations. Most Terraform operators and library functions work, plus core::getresources, core::getdatasource, core::gethttprequest and core::semverconstraint. Custom functions can be written as Go plugins and called as plugin::name::function.

The Bookshelf rule, in Terraform Policy:

policy {
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = ">= 6.0.0, < 7.0.0"
    }
  }
}

resource_policy "aws_vpc_security_group_ingress_rule" "no_world_open" {
  enforcement_level = "mandatory_overridable"

  enforce {
    condition     = attrs.cidr_ipv4 != "0.0.0.0/0"
    error_message = "Bookshelf network rules must not accept traffic from 0.0.0.0/0"
  }
}

Source: Terraform Docs — Terraform policy examples. The structure and the enforcement_level value follow the documented examples; the resource and condition are a Bookshelf illustration.

Its test file mocks resources and marks the one that must be rejected with expect_failure = true; targets points to the policy file under test.

policytest {
  targets = ["../policies/no_world_open.policy.hcl"]
}

resource "aws_vpc_security_group_ingress_rule" "pass_private" {
  attrs = {
    cidr_ipv4   = "10.0.0.0/16"
    ip_protocol = "tcp"
    from_port   = 5432
    to_port     = 5432
  }
}

resource "aws_vpc_security_group_ingress_rule" "fail_world_open" {
  expect_failure = true

  attrs = {
    cidr_ipv4   = "0.0.0.0/0"
    ip_protocol = "tcp"
    from_port   = 443
    to_port     = 443
  }
}

The tfpolicy command-line tool (installed with brew install hashicorp/tap/tfpolicy or from the HashiCorp releases site; checksums and signatures are published) has validate, test and version commands.

$ tfpolicy validate --policies=policies/
Success! All policies are valid.

$ tfpolicy test --policies=policies/ --tests=tests/
# no_world_open.policytest.hcl... running
# resource.aws_vpc_security_group_ingress_rule.pass_private... pass
# resource.aws_vpc_security_group_ingress_rule.fail_world_open... pass
# no_world_open.policytest.hcl... pass

Source: Terraform Docs — Terraform policy examples. The output follows the documented format; the file and resource names are illustrative.

A policy with core::getresources shows the main advantage over the other engines: it can check a related resource without iterating the plan by hand. The official example requires that the S3 bucket named by a CloudTrail trail has a private ACL, and a second one (prior_attrs.size) forbids shrinking a volume on update. Provider and module policies (for example a condition on meta.source that requires the official AWS provider address) are evaluated at setup time, so a forbidden module source can be refused during initialization.

The -policies flag and HCP Terraform

Three evaluation stages exist. Setup-time policies (provider and module policies) run during workspace initialization, before providers or modules are downloaded. Plan-time policies run against the plan when the referenced attributes are available. Apply-time policies run after apply for values that were "known after apply"; violations are then reported after the change has happened, so someone must fix them and redeploy.

In HCP Terraform the repository is connected as a VCS-backed policy set and targeted at projects, workspaces or tags; a run evaluates it and reports results. Locally, the Terraform CLI flag evaluates explicit policy directories. The only command whose reference documents the flag at the time of writing is terraform query, where policies are evaluated against the resources discovered by list blocks (see Import and Query for the prerequisites, such as include_resource = true).

terraform query -policies=./policies
terraform query -policies=./policies/security -policies=./policies/tagging

The changelog entry for 1.17.0 also names plan and apply as accepting the flag. The CLI reference of those commands did not list it when this page was written, so run terraform plan -help on your version before putting it in a pipeline.

Custom rules in Checkov and TFLint

Static scanners run on source, need no HCP Terraform and fit pre-commit hooks. Both accept your own rules, which is the lightest form of policy as code. Their built-in rule sets and installation are covered in Code Quality and Security Scanning. They see configuration, not plan values, so they cannot judge a value computed from variables.

Checkov custom policies

Checkov supports custom policies written in Python, which can check the status of configuration attributes, and in YAML, which can also check the connection between resource types and combine conditions with and, or and not. A YAML policy has metadata (name, ID, category, optional guideline, optional severity of INFO, LOW, MEDIUM, HIGH or CRITICAL) and a definition. A Python policy extends BaseResourceCheck, sets name, id, supported_resources and categories, implements scan_resource_conf (every attribute value arrives as a list, so the value is read from index [0]) and registers itself by instantiating the class at module level. The directory needs an __init__.py, file names must be unique, and the directory is passed with --external-checks-dir.

from __future__ import annotations

from typing import Any

from checkov.common.models.enums import CheckResult, CheckCategories
from checkov.terraform.checks.resource.base_resource_check import BaseResourceCheck


class BookshelfDbEncrypted(BaseResourceCheck):
    def __init__(self) -> None:
        name = "Ensure Bookshelf databases are encrypted at rest"
        id = "CKV_BOOKSHELF_1"
        supported_resources = ("aws_db_instance",)
        categories = (CheckCategories.ENCRYPTION,)
        super().__init__(name=name, id=id, categories=categories, supported_resources=supported_resources)

    def scan_resource_conf(self, conf: dict[str, list[Any]]) -> CheckResult:
        encrypted = conf.get("storage_encrypted")
        if encrypted and encrypted[0]:
            return CheckResult.PASSED
        return CheckResult.FAILED


check = BookshelfDbEncrypted()
checkov -d . --external-checks-dir my_extra_checks

Source: Checkov Docs — Python Custom Policies, Checkov Docs — YAML Custom Policies. The structure follows the documented example; the check ID, class and attribute are Bookshelf illustrations.

TFLint custom rules

TFLint rules beyond the built-in ones come from ruleset plugins, independent binaries that talk to TFLint over gRPC. To write one in Go, create a repository named with the prefix tflint-ruleset- from the official template, enable it in .tflint.hcl with a plugin block, build it with make install (which installs it under the .tflint.d/plugins directory of your home), and add rules with the plugin SDK. To distribute it so that tflint --init can install it, publish GitHub Releases with the required assets, checksums and a signature.

plugin "template" {
  enabled = true
}

For a few organisation rules, the OPA ruleset lets you write them in Rego instead, in the .tflint.d/policies directory of the project (or of your home directory). The ruleset is documented as experimental, with frequent breaking changes, and cannot judge values that are unknown, null or undefined in a static view of the code.

plugin "opa" {
  enabled = true
  version = "0.11.0"
  source  = "github.com/terraform-linters/tflint-ruleset-opa"
}
package tflint

import rego.v1

deny_invalid_s3_bucket_name contains issue if {
	buckets := terraform.resources("aws_s3_bucket", {"bucket": "string"}, {})
	name := buckets[_].config.bucket
	not startswith(name.value, "example-com-")

	issue := tflint.issue(`Bucket names should always start with "example-com-"`, name.range)
}

Source: TFLint OPA ruleset — README. The rule is the README’s own example.

The README’s rule of thumb: start with the OPA ruleset for a small number of rules and a small team; move to a custom Go ruleset when you have many complex rules or distribute them to many teams.

A layered strategy for Bookshelf

Layer Bookshelf practice

Pre-commit

terraform fmt, validate, TFLint with the cloud ruleset, Checkov with the Bookshelf external checks directory.

Pull request

plan -out and show -json, then Conftest against the shared Rego library, with the results posted to the pull request.

Platform

HCP Terraform policy sets attached by project: advisory first, then mandatory or soft-mandatory once the false positives are gone.

Exceptions

A named team may override soft failures; the Sentinel set-level override stays disabled for production projects.

Change of policy

Policies live in their own repository, with tests (opa test, tfpolicy test, Sentinel mocks) and releases.

Roll out new rules in advisory mode, measure the failures for a sprint, fix the modules, and only then raise the level. A mandatory rule that blocks every run teaches teams to look for the override rather than for the cause.

What changed recently

  • Terraform 1.17.0 (October 21, 2026): the changelog announces Terraform Policy as generally available and the -policies flag for plan, apply and query without an experimental build; the documentation still labels the framework beta (see Maturity: read this before relying on it). Query results for policies can also be Unknown or N/A.

  • Terraform Policy docs: reference version v0.5.x is the latest, and v0.1.x to v0.4.x are marked beta. The tfpolicy CLI needs Terraform 1.16 or later according to its install page.

  • Sentinel in HCP Terraform: policy evaluations allow overrides and pinning of the Sentinel runtime (documented from Sentinel 0.23.1, with agent 1.13.1 or later), while checks remain limited to Sentinel 0.40.x.

  • OpenTofu: has no Sentinel, OPA or Terraform Policy integration, because those are HCP Terraform features. Conftest, Checkov and TFLint work with its plans and sources (plans are rendered with tofu show -json), and platforms such as Spacelift or env0 provide their own OPA integration.

Pitfalls

  • Evaluating a plan without handling unknown values: after omits them. Use else in Sentinel, object.get in Rego, and read after_unknown.

  • Forgetting deleted resources: change.after is null when the action is a pure delete.

  • Policies on aws_security_group inline rules: the provider recommends separate rule resources, so a policy must cover both shapes if the code base mixes them.

  • Confusing the Rego document root: input.plan in HCP Terraform, the plan itself in Conftest.

  • Relying on a static linter for values that come from variables.

  • Treating the plan JSON as harmless: it can contain secrets in clear text.

  • Making a brand-new policy mandatory on day one.