Policy-as-code in Terraform tends to fail in one of two ways. Either the policies never make it past “advisory” because nobody trusts them enough to block a run, or they block everything and the platform team becomes a ticket queue. Both failures come from the same root cause: policies that were written by reading the docs, shipped without tests, and never exercised against the messy plan data that real workspaces produce.
Sentinel is HashiCorp’s policy-as-code framework, and in HCP Terraform (formerly Terraform Cloud) it runs between plan and apply to gate every run. This guide treats Sentinel the way you would treat any other production code: we tour the data model, write a real cost guardrail, generate mocks from actual runs so we can test offline, structure a versioned policy set, and wire it into VCS with a rollout strategy. Everything here targets the tfplan/v2, tfconfig/v2, tfstate/v2, and tfrun imports and the Sentinel CLI test harness.
1. Enforcement levels: advisory, soft-mandatory, hard-mandatory
Every policy in a policy set is assigned one of three enforcement levels. The level decides what happens when the policy’s main rule evaluates to false.
| Level | On failure | Who can override | Use it for |
|---|---|---|---|
advisory |
Logs the failure, run continues | Nobody needs to | New policies, soft nudges, deprecation warnings |
soft-mandatory |
Blocks the run | Users with Manage Policy Overrides (org owners, or the permission delegated) | Cost ceilings, tagging, the 90% case |
hard-mandatory |
Blocks the run, cannot be overridden | No one, ever | Hard security boundaries: public S3, unencrypted volumes, banned regions |
The distinction that matters in practice is soft-mandatory versus hard-mandatory. Soft-mandatory is the right default for almost everything, because it lets a human with the right permission make an exception under audit when reality disagrees with the policy. Reserve hard-mandatory for the handful of rules where “I need an exception” is itself the security incident.
Enforcement level is not set in the policy source. It is declared in the policy set’s sentinel.hcl, which means the same .sentinel file can run advisory in staging and hard-mandatory in production. We will use that.
2. Touring tfplan/v2, tfconfig/v2, tfstate/v2, and tfrun
Sentinel evaluates against imports, which are read-only data structures populated from the run. Four matter for Terraform.
tfplan/v2is the proposed change. Itsresource_changescollection is the workhorse: each entry hasaddress,type,mode,change.actions(a list like["create"],["update"],["delete"], or["create","delete"]for replacement), andchange.afterholding the planned attribute values.tfconfig/v2is the configuration as written, before values are resolved. Use it to assert on the source (for example, acount/for_eachexpression, or a provider config) rather than the computed result.tfstate/v2is prior state. Use it to compare against what already exists, or to catch resources drifting outside policy.tfrunis metadata about the run itself:workspace.name,organization.name, thecost_estimateblock, and the speculative flag.
The single most useful helper is tfplan/functions, HashiCorp’s published module that flattens these collections. Without it you write nested-loop boilerplate; with it you filter resources in one expression. Here is the idiomatic pattern for “find every resource of a type that is being created or updated”:
import "tfplan/v2" as tfplan
import "strings"
# All managed resources being created or updated (not deleted, not data sources)
ec2_instances = filter tfplan.resource_changes as _, rc {
rc.type is "aws_instance" and
rc.mode is "managed" and
(rc.change.actions contains "create" or
rc.change.actions contains "update")
}
# A rule that holds when every matched instance uses an approved type
allowed_types = ["t3.micro", "t3.small", "m6i.large"]
instance_type_allowed = rule {
all ec2_instances as _, instance {
instance.change.after.instance_type in allowed_types
}
}
main = rule {
instance_type_allowed
}
Two language details trip people up. First, filter and all/any iterate maps as key, value, and you almost always discard the key with _. Second, main must be a single boolean rule named exactly main; that is the entry point HCP Terraform evaluates.
A subtle correctness point:
change.aftercan containnullfor attributes that are unknown at plan time (computed values that depend on other resources). Always guard for it, becausenull in allowed_typesisfalse, which can fail a run for the wrong reason. Test for the unknown explicitly when it matters.
3. A cost guardrail using tfrun cost estimation
Cost estimation is one of the highest-signal guardrails you can ship, and it is the canonical use of the tfrun import. When cost estimation is enabled on the organization, HCP Terraform populates tfrun.cost_estimate with prior_monthly_cost, proposed_monthly_cost, and delta_monthly_cost, all as strings.
The policy below blocks a run whose proposed monthly cost increase exceeds a threshold. Note the explicit decimal import and the string-to-float conversion, because the values arrive as strings and comparing strings numerically is a classic bug.
import "tfrun"
import "decimal"
# Threshold in USD of *additional* monthly spend this run may introduce.
param limit default 1000
# delta_monthly_cost is the proposed minus prior, as a string.
delta = decimal.new(tfrun.cost_estimate.delta_monthly_cost)
# rule {} is true when the run is within budget
within_budget = rule {
delta.less_than(limit) or delta.equals(limit)
}
print("Proposed monthly delta:", tfrun.cost_estimate.delta_monthly_cost)
print("Configured limit:", limit)
main = rule {
within_budget
}
decimal ships with the Sentinel runtime and gives you less_than, greater_than, equals, and arithmetic with correct semantics for money. The print statements surface in the run’s policy output, which is exactly where a developer who just got blocked will look, so spend the two lines.
Two failure modes to design around. If cost estimation is disabled, tfrun.cost_estimate is undefined and the policy errors; guard with a check or accept that the policy requires the feature. And cost estimation only covers resources for supported providers (AWS, Azure, GCP) with known pricing, so treat the number as a guardrail, not a billing oracle.
4. Generating mocks from real runs for offline testing
This is the step that separates policies that work from policies that pass review. You cannot author against tfplan/v2 reliably by guessing its shape; you need the real thing. HCP Terraform generates Sentinel mocks from any actual plan.
In the HCP Terraform UI, open a finished run, expand the plan, and use the Download Sentinel mocks option. You get a .tar.gz containing one file per import:
mock-tfplan-v2.sentinel
mock-tfconfig-v2.sentinel
mock-tfstate-v2.sentinel
mock-tfrun.sentinel
Each file is valid Sentinel that assigns the import’s full data structure to a variable. That means you can import it under the real import name and your policy runs against a faithful snapshot of production data, entirely offline. This is the same data the platform evaluated, so what passes locally passes in HCP Terraform.
You can also pull mocks via the API for automation, for example to refresh fixtures in CI nightly. The run’s plan resource exposes a JSON output that the API can convert; in practice most teams script the download against the runs API and commit the result. Whichever path you use, the discipline is the same: keep a small library of mocks that captures the cases you care about (a compliant plan, a too-expensive plan, a forbidden-region plan), and treat them as test fixtures.
policies/
test/
restrict-ec2-instance-type/
pass.hcl
fail.hcl
mock-tfplan-pass.sentinel
mock-tfplan-fail.sentinel
5. Structuring a policy set: sentinel.hcl and the test harness
A policy set is a directory containing policies, a sentinel.hcl manifest, and (in source control) tests. The manifest declares each policy, where its source lives, its enforcement level, and any parameters. It also declares external modules so the runtime can resolve them.
# sentinel.hcl
module "tfplan-functions" {
source = "https://raw.githubusercontent.com/hashicorp/terraform-sentinel-policies/main/common-functions/tfplan-functions/tfplan-functions.sentinel"
}
policy "restrict-ec2-instance-type" {
source = "./restrict-ec2-instance-type.sentinel"
enforcement_level = "soft-mandatory"
}
policy "limit-monthly-cost-delta" {
source = "./limit-monthly-cost-delta.sentinel"
enforcement_level = "hard-mandatory"
params = {
limit = 2000
}
}
For local development, install the Sentinel CLI (a separate binary from terraform) and run policies against your mocks. The CLI reads test cases from a test/<policy-name>/ directory by convention, where each .hcl file is one case that maps mock files onto import names and asserts the expected result of main.
# test/limit-monthly-cost-delta/fail.hcl
# Map mock data onto the imports the policy uses.
mock "tfrun" {
module {
source = "../../mock-tfrun-expensive.sentinel"
}
}
# Override the parameter for this case.
param "limit" {
value = 100
}
# Assert how main should evaluate for this fixture.
test {
rules = {
main = false
}
}
# Validate that the policy parses and applies cleanly against a single mock
sentinel apply -global limit=100 limit-monthly-cost-delta.sentinel
# Run the full test suite for one policy (reads test/<name>/*.hcl)
sentinel test ./limit-monthly-cost-delta.sentinel
# Run every test in the policy set, verbose so you see each case
sentinel test -verbose
sentinel test is the gate you run in CI. A passing pass.hcl and a passing fail.hcl for the same policy proves the rule discriminates, not just that it returns true. A policy without a failing test case is a policy you have not actually tested.
6. Parameterizing policies and per-workspace exceptions
Hardcoding thresholds and allow-lists into .sentinel files forces a code change for every tweak. Use param blocks instead. A param name default <value> declaration reads from the policy set’s params in sentinel.hcl, and falls back to the default when unset. We already used this for limit and the instance-type list.
Per-workspace exceptions are the harder problem. Sentinel policy sets attach to workspaces, but you frequently want one policy to apply broadly with carve-outs. Two patterns work, in order of preference:
-
Scope the policy set to the right workspaces. In HCP Terraform a policy set can be global, or scoped to specific workspaces, or scoped by project. This is the cleanest exception mechanism: a sandbox project simply does not get the production-grade set. Prefer this whenever the exception is structural.
-
Branch on
tfrun.workspace.nameinside the policy for genuine one-off carve-outs. Drive the exceptions from a parameter so the list is reviewable in the manifest, not buried in logic.
import "tfrun"
# Workspaces exempt from the region restriction, supplied via sentinel.hcl.
param exempt_workspaces default []
is_exempt = tfrun.workspace.name in exempt_workspaces
main = rule when not is_exempt {
# ...region-restriction logic...
true
}
rule when <condition> is the construct to know: when the condition is false the rule short-circuits to true, cleanly skipping enforcement for exempt workspaces without nesting. Keep the exempt list in sentinel.hcl so granting an exception is a reviewed, audited pull request.
7. VCS-backed policy sets and rollout
Storing policy sets in version control is the only sane way to operate at scale. HCP Terraform connects a policy set to a VCS repository (or subdirectory), and on every merge to the tracked branch it ingests the new version automatically. No manual uploads, full git history, PR review on every policy change.
Roll out in stages so a new rule never blocks production on day one:
- Land it advisory, globally. Set
enforcement_level = "advisory"and let it run across all workspaces for a sprint. Watch the policy output on real runs. Advisory failures tell you exactly which existing workspaces would have been blocked, with zero disruption. - Promote to soft-mandatory in non-prod. Scope a soft-mandatory copy to staging and dev projects. Now developers feel the gate but can self-serve an override while the team fixes fixtures and edge cases.
- Promote to soft- or hard-mandatory in prod. Once advisory has been quiet for the prod workspaces, flip the production-scoped set. Use
hard-mandatoryonly for the non-negotiable security rules from step 1.
Because enforcement level lives in sentinel.hcl, each stage is a small, reviewable diff, and the rollout itself is captured in git history. Keep the API-driven and VCS-driven sets separate; mixing upload methods on one set leads to confusion about which version is live.
8. Migrating equivalent rules between Sentinel and OPA
Teams standardizing on Open Policy Agent for Kubernetes admission and image policy often want a single policy language. Sentinel and OPA/Rego both evaluate Terraform plan JSON, so most rules port, but the data model and idioms differ.
| Concern | Sentinel | OPA / Rego (Conftest) |
|---|---|---|
| Input | tfplan/v2, native imports |
terraform show -json plan, fed as input |
| Iteration | filter / all / any over maps |
comprehensions and some over arrays |
| Outcome | boolean main rule |
deny[msg] set, non-empty means fail |
| Enforcement | advisory / soft / hard, set in HCP Terraform | exit code in CI, or Gatekeeper at admission |
| Cost data | native tfrun.cost_estimate |
not available; needs an external data source |
The mechanical translation of the instance-type rule from section 2 looks like this in Rego:
package terraform.ec2
import future.keywords.in
allowed := {"t3.micro", "t3.small", "m6i.large"}
deny contains msg if {
some rc in input.resource_changes
rc.type == "aws_instance"
some action in rc.change.actions
action in {"create", "update"}
not rc.change.after.instance_type in allowed
msg := sprintf("%s uses disallowed instance_type %q", [rc.address, rc.change.after.instance_type])
}
The semantic gap that does not port is cost estimation. tfrun.cost_estimate is a Sentinel-only signal computed by HCP Terraform. In an OPA pipeline you reproduce it by running Infracost, emitting its JSON, and evaluating that as a second input document. Plan a hybrid for a transition window rather than a hard cutover, and keep cost guardrails in Sentinel even if security rules move to OPA.
Verify
Confirm the policy set works end to end, locally first, then in the platform.
# 1. Every policy parses and the manifest is valid
sentinel fmt -check ./*.sentinel
sentinel apply ./restrict-ec2-instance-type.sentinel
# 2. The full suite passes, including paired pass/fail cases
sentinel test -verbose
# 3. Exercise the cost policy against the expensive mock with a low limit;
# this must fail, proving the rule discriminates
sentinel test ./limit-monthly-cost-delta/
# 4. Confirm enforcement levels in the manifest match intent
grep -n "enforcement_level" sentinel.hcl
In HCP Terraform, after the VCS push ingests the new policy set version: trigger a speculative plan on a workspace, open the run, and confirm the Policy check stage appears with each policy’s name, result, and your print output. An advisory failure shows but does not block; a soft-mandatory failure shows an Override button to users with the permission; a hard-mandatory failure shows no override path. Seeing all three behave correctly on a real run is the only verification that counts.