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.
In a nutshell
Think of a Sentinel policy set as an automated compliance reviewer that sits inside every Terraform run and reads the proposed change before anything is built. A human reviewer approving a pull request can eyeball the diff and say “this opens a public bucket — no.” Sentinel does the same job mechanically, on every single run, without getting tired or going on holiday — and depending on how strict you make it, it can block the merge outright rather than just leave a comment.
That is the whole idea of policy-as-code: the rules your organization cares about (no public storage, cost under a ceiling, only approved regions, everything tagged) stop living in a wiki nobody reads and become executable code that runs automatically. In HCP Terraform the reviewer runs at a very specific moment — after terraform plan has computed exactly what will change, but before terraform apply touches a single real resource. So a policy can veto a bad change while it is still just a plan, when vetoing it costs nothing.
Three ideas to hold onto before the details:
- Policies read the plan, not your intentions. Sentinel evaluates imports — read-only snapshots of the plan (
tfplan/v2), the config as written (tfconfig/v2), prior state (tfstate/v2), and run metadata (tfrun). Every rule is a question asked of that data. - You choose how hard the reviewer can push back. Advisory just warns, soft-mandatory blocks but a permitted human can override under audit, hard-mandatory blocks with no escape hatch.
- You test the reviewer before you trust it. Because a policy is code, it can be wrong. HCP Terraform lets you download mocks — real plan data captured from a real run — so you can run the policy offline and prove it passes good plans and blocks bad ones before it ever gates production.
Level: Advanced · Time: ~30 min
Prerequisites — read these first if the names are new. You should already be comfortable with the HCP Terraform run lifecycle (plan → apply, VCS-driven workspaces) and how workspaces isolate state. If not, start with HCP Terraform fundamentals: workspaces, VCS, runs, registry and Terraform workspaces deep dive. A reading knowledge of HCL and of what terraform plan produces is assumed.
After this lesson you will be able to:
- Read the four Sentinel imports and write a
mainrule that filterstfplan/v2.resource_changes. - Ship a cost guardrail off
tfrun.cost_estimatewithout the classic string-compare bug. - Download mocks from a real run and drive
sentinel testwith paired pass/fail cases. - Structure a versioned, VCS-backed policy set with a
sentinel.hclmanifest and scoped enforcement. - Choose advisory vs soft- vs hard-mandatory deliberately, and roll a new rule out without blocking prod on day one.
- Decide when Sentinel is the right tool versus OPA/conftest or native
validation/check.
Read the map left to right: a VCS-backed policy set feeds the run, terraform plan produces the read-only imports, Sentinel evaluates its main rules against that plan data post-plan, and the enforcement level decides whether terraform apply is allowed to proceed.
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.
Going deeper
The first eight sections gave you a working policy set. This section is for when you want to understand why the pieces are shaped the way they are — the language semantics, the run internals, the enforcement mechanics, and where Sentinel stops and other tools start.
9. The Sentinel language in one pass
Sentinel is a small, purpose-built language. You can read almost any policy once you know six constructs.
| Construct | What it is | Example |
|---|---|---|
import |
Load an import or standard library, optionally aliased | import "tfplan/v2" as tfplan |
rule { } |
A named, lazily-evaluated boolean expression | ok = rule { x is 5 } |
main |
The one rule every policy must define; its value is the verdict | main = rule { ok } |
filter |
Narrow a collection to matching entries (returns a map) | filter c as _, v { v.mode is "managed" } |
all / any |
Quantifiers over a collection | all list as _, v { v > 0 } |
func |
A reusable function | func square(x) { return x * x } |
The mental model: a policy is a pyramid of boolean rules with main at the apex. Rules are lazy — a rule is only evaluated if something references it, and evaluation stops at the first term that decides the result — so you compose small named rules and reference them from main. That laziness is also why print() inside a rule may not fire if the rule short-circuits before reaching it; put diagnostic prints at file scope when you need them unconditionally, as the cost policy in section 3 does.
Here is a fuller policy that uses functions, a default with else, the all quantifier, and a filter together — a required-tags rule you would actually ship:
import "tfplan/v2" as tfplan
# Tags every managed resource must carry.
required_tags = ["owner", "cost-center", "environment"]
# A function: does this resource carry every required tag, non-empty?
has_required_tags = func(resource) {
tags = resource.change.after.tags else {}
return all required_tags as t {
tags contains t and tags[t] is not ""
}
}
# Every taggable managed resource being created or updated.
taggable = filter tfplan.resource_changes as _, rc {
rc.mode is "managed" and
(rc.change.actions contains "create" or rc.change.actions contains "update") and
rc.change.after.tags is not null
}
tags_present = rule {
all taggable as _, rc {
has_required_tags(rc)
}
}
main = rule {
tags_present
}
A few semantics worth naming. The x else y operator supplies y when x is undefined or null — indispensable when reading optional attributes that may not exist on every resource. is is equality (== also works, but is/is not reads better for policy). contains tests membership in a list or map key set; in tests the reverse (t in list). And matches runs a regex, which you reach for on names and ARNs.
Standard imports do the heavy lifting so you rarely write algorithms: strings (case, prefixes, splitting), types (type checks like types.type_of(x) is "undefined"), decimal (money, as we saw), json, collection/maps, units, and time. The published tfplan/functions module goes further, giving helpers like plan.find_resources("aws_instance") and attribute filters so your policy body is a few readable lines instead of nested loops.
10. Enforcement levels and the override trail
The three levels from section 1 are the whole enforcement model, but the override mechanics are what make soft-mandatory safe to rely on:
- A soft-mandatory failure shows an Override & Continue control to any user holding the Manage Policy Overrides permission — organization owners by default, delegatable to a team. The override is recorded against that user, with an optional comment, and the run then proceeds to apply. That record is the audit trail: you can answer “who let this expensive change through, and why” after the fact.
- A hard-mandatory failure shows no override path. The only way forward is to change the plan (fix the config) or change the policy (a reviewed PR to the policy set). This is exactly why you reserve it for rules where an exception would itself be a security event.
- advisory never blocks; it is your safety-valve for shipping a new rule and for deprecations. Its value is diagnostic: an advisory failure on a real run is a free preview of who would have been blocked.
The design guidance falls straight out of this: default to soft-mandatory, promote to hard-mandatory only when you can defend “no human should ever be able to override this,” and use advisory as the on-ramp for everything new.
11. Policy sets: versioning and scoping, as code
A policy set is the unit of attachment. Its three dials are source (where the policies live), scope (which workspaces evaluate it), and enforcement (per-policy, in sentinel.hcl). Managing the set itself as Terraform via the tfe provider keeps all three reviewable:
# Manage the policy set with the tfe provider (org/token are placeholders).
resource "tfe_policy_set" "security_baseline" {
name = "security-baseline"
organization = "my-org" # placeholder
kind = "sentinel"
# VCS-backed: every merge to `branch` ingests a new version.
vcs_repo {
identifier = "my-org/tf-policies" # placeholder org/repo
branch = "main"
oauth_token_id = var.oauth_token_id
}
policies_path = "policy-sets/security"
# Scope: attach only to these workspaces. Omit for `global = true`.
workspace_ids = [var.prod_workspace_id]
}
The scope choices, in order of preference:
| Scope | How | When |
|---|---|---|
| Project | attach the set to a project (tfe_project_policy_set) |
The default for org-wide baselines — every workspace in prod inherits it |
| Global | global = true |
Truly universal rules (naming, mandatory tags) that no workspace is exempt from |
| Workspace | workspace_ids = [...] |
Narrow rules, or the “exempt by not attaching” pattern for structural carve-outs |
Because the set is VCS-backed, versioning is just git: the tracked branch is the live version, history is the change log, and a rollback is a revert. This is why section 7 insists you never mix VCS ingestion with manual API uploads on the same set — two sources of truth means you can no longer answer “which version is live” from git alone.
12. Mocks and the test harness, one level down
A mock file is not magic — open one and it is plain Sentinel assigning the import’s data to a variable the runtime substitutes for the real import. That is why the same mock drives both a sentinel apply smoke test and a sentinel test assertion, and why what passes offline passes in the platform: it is byte-for-byte the data the platform produced.
The test harness has exactly three moving parts in each test/<policy>/<case>.hcl file:
mock "<import>" { module { source = "..." } }— bind a mock to an import name ("tfplan/v2","tfrun", and so on). Any import your policy reads but you do not mock is empty, which is itself a useful test.param "<name>" { value = ... }— override a parameter for this case, so one policy tests several thresholds.test { rules = { main = true|false } }— assert the verdict. You can assert any named rule, not justmain, which helps localize why a complex policy failed.
The non-negotiable discipline: every policy ships a pass.hcl and a fail.hcl. A green pass.hcl proves the rule accepts good input; a green fail.hcl (which expects main = false) proves it rejects bad input. A policy with only a pass case has never been shown to block anything — it might be a rule that returns true unconditionally, and you would not know until it silently waves through a violation in production. Wire sentinel test into CI so a broken or non-discriminating policy blocks the merge to the policy-set repo, the same way application tests block a merge to app code.
13. Where Sentinel sits in the run — and what it is not
An HCP Terraform run is an ordered pipeline, and Sentinel occupies one specific stage:
plan → cost estimation → policy check (Sentinel / OPA) → apply
▲ ▲
post-plan, pre-apply only runs if the
(this is Sentinel) policy check passes
Two consequences of that placement are worth internalizing. First, Sentinel sees a completed plan, so tfplan/v2 is fully computed (except values genuinely unknown until apply) and tfrun.cost_estimate is already populated — you are judging a concrete proposal, not guessing. Second, a blocked run never reaches apply, so a hard-mandatory failure is a guarantee, not a best-effort: no real resource is touched.
Do not confuse policy checks with run tasks, the other extensibility hook. Run tasks call external services (Checkov, Snyk, a custom endpoint) and can attach at pre-plan, post-plan, or pre-apply; policy checks run HashiCorp’s own Sentinel/OPA engines inside the run. They are complementary: many platform teams run a Checkov run task for broad static findings and Sentinel policies for the handful of hard organizational gates. For the static-scanner side of that pairing, see Checkov, Trivy and tfsec in the IaC pipeline.
14. Sentinel vs OPA/conftest vs native validation / check
Sentinel is not the only place to enforce a rule, and choosing the wrong layer is a common design error. The three layers differ mainly in who controls them and where they run:
| Dimension | Sentinel | OPA / conftest | Native validation / check |
|---|---|---|---|
| Where it runs | HCP Terraform / TFE run, post-plan | Any CI, or HCP Terraform (OPA policy sets) | Inside terraform itself, during plan/apply |
| Language | Sentinel | Rego | HCL expressions |
| Central enforcement | Yes — config author cannot bypass | Yes, if wired into CI/platform | No — lives in the config the author controls |
| Enforcement levels | advisory / soft / hard + override audit | exit code, or soft/hard via TFC OPA sets | warning (check) or hard error (validation, pre/postcondition) |
| Cost data | native tfrun.cost_estimate |
needs Infracost as a second input | none |
| Best for | org-wide guardrails in HCP Terraform | portable guardrails across any pipeline | local invariants a module must always hold |
The dividing line is ownership. Native validation (variable validation blocks, lifecycle pre/postconditions, and check blocks with assert) lives in the config, so a module author can edit or delete it — perfect for invariants the module must hold for its own correctness (an instance count between 1 and 10; a CIDR that is actually a CIDR), useless as an organizational gate because the person being governed controls the rule. check blocks in particular produce warnings, not hard failures, so they are for continuous assertions, not gates. Sentinel and OPA run in the platform, above the config author, which is what makes them real governance. Choose Sentinel when you are standardized on HCP Terraform and want cost data and override audit for free; choose OPA/conftest when you need the same rules to run in a provider-agnostic pipeline. For the OPA path end to end, see OPA and conftest policy gates for Terraform plans.
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.
Practice challenges
Work these in order — they escalate from a single filter to a full rollout decision. Each has a worked solution, but write yours first. Assume you have the Sentinel CLI installed and a test/ layout like section 5.
1 (beginner) — Filter for a resource and assert an attribute. Write a policy that collects every azurerm_storage_account being created and makes main hold only when each one sets min_tls_version = "TLS1_2".
<details> <summary>Solution</summary>
import "tfplan/v2" as tfplan
accounts = filter tfplan.resource_changes as _, rc {
rc.type is "azurerm_storage_account" and
rc.mode is "managed" and
rc.change.actions contains "create"
}
tls_ok = rule {
all accounts as _, sa {
sa.change.after.min_tls_version is "TLS1_2"
}
}
main = rule { tls_ok }
Why: filter narrows to exactly the resource and action you care about, and all makes main hold only when every matched account passes — one non-compliant account fails the run.
</details>
2 (beginner) — Guard the unknown value. A run fails your challenge-1 policy because min_tls_version is computed and unknown at plan time. Fix it so an unknown value does not cause a false failure, and decide explicitly whether unknown should pass or fail.
<details> <summary>Solution</summary>
tls_ok = rule {
all accounts as _, sa {
sa.change.after.min_tls_version is "TLS1_2" or
sa.change.after.min_tls_version is null # unknown at plan → pass; re-checked next run
}
}
Why: change.after is null for values unknown at plan time. Deciding the null case on purpose stops the run failing “for the wrong reason.” Here we fail open (unknown passes) because a later run will re-evaluate the resolved value; for a security-critical attribute you might fail closed instead.
</details>
3 (intermediate) — Prove the rule discriminates. Using mocks from a compliant and a non-compliant plan, wire a pass.hcl and a fail.hcl so sentinel test proves the rule both accepts and rejects.
<details> <summary>Solution</summary>
# test/require-tls12/pass.hcl
mock "tfplan/v2" {
module { source = "../../mock-tfplan-tls-pass.sentinel" }
}
test { rules = { main = true } }
# test/require-tls12/fail.hcl
mock "tfplan/v2" {
module { source = "../../mock-tfplan-tls-fail.sentinel" }
}
test { rules = { main = false } }
sentinel test ./require-tls12.sentinel
Why: the passing fail.hcl — the one that expects main = false — is what proves the policy actually blocks bad input. A policy with only a pass case has never been shown to block anything.
</details>
4 (intermediate) — Parameterize the threshold. Move the hardcoded "TLS1_2" out of the policy source and into a param set in sentinel.hcl, so tightening to TLS1_3 later is a one-line manifest change.
<details> <summary>Solution</summary>
param required_tls default "TLS1_2"
tls_ok = rule {
all accounts as _, sa {
sa.change.after.min_tls_version is required_tls or
sa.change.after.min_tls_version is null
}
}
policy "require-tls12" {
source = "./require-tls12.sentinel"
enforcement_level = "soft-mandatory"
params = { required_tls = "TLS1_2" }
}
Why: parameters make the rule reviewable and reusable. The strictness lives in the manifest (a small, auditable diff), the logic lives in the source, and neither changes when you adjust the other. </details>
5 (advanced) — Exempt a workspace, reviewably. Exempt a sandbox-experiments workspace from the TLS rule without deleting the policy, and keep the exemption visible in review.
<details> <summary>Solution</summary>
import "tfrun"
param exempt_workspaces default []
is_exempt = tfrun.workspace.name in exempt_workspaces
main = rule when not is_exempt {
tls_ok
}
params = { exempt_workspaces = ["sandbox-experiments"] }
Why: rule when <cond> short-circuits to true when the condition is false, so an exempt workspace cleanly skips enforcement without nesting. Keeping the list in sentinel.hcl makes each exemption an audited PR. (Prefer project/workspace scoping when the carve-out is structural rather than a one-off.)
</details>
6 (advanced) — Port to Rego, and know what won’t port. Your security team is standardizing on OPA for Kubernetes but wants the TLS rule to keep running in HCP Terraform. Port it to Rego for conftest, and name the one signal that cannot come across.
<details> <summary>Solution</summary>
package terraform.storage
import future.keywords.in
deny contains msg if {
some rc in input.resource_changes
rc.type == "azurerm_storage_account"
"create" in rc.change.actions
rc.change.after.min_tls_version != "TLS1_2"
msg := sprintf("%s has min_tls_version %q", [rc.address, rc.change.after.min_tls_version])
}
Why: the plan-shape rule ports cleanly because Rego reads the same terraform show -json data. What does not port is anything reading tfrun.cost_estimate — cost estimation is a Sentinel-only signal computed by HCP Terraform, so cost guardrails stay in Sentinel (or move to Infracost in the OPA pipeline). See the OPA + conftest policy gates lesson.
</details>
Common beginner mistakes
- “My policy returns
true, so it works.” Returningtrueonly proves the policy accepts something — it may accept everything. A policy that has never returnedfalseon a real fixture has never been shown to block. The right model: prove it with a failing test case (fail.hclexpectingmain = false); that is the actual evidence it discriminates. - “I’ll write the policy from the docs and ship it.” The docs describe the import shape in the abstract; real plans have nulls, computed values, replacement actions (
["create","delete"]), and nested blocks you did not anticipate. The right model: download mocks from a real run and author against them, so you are coding against production reality, not a diagram. - “hard-mandatory everywhere is the most secure.” Hard-mandatory means no override, ever, so the first false positive turns the platform team into a ticket queue and developers start routing around Terraform entirely — which is strictly less secure. The right model: soft-mandatory is the default; reserve hard-mandatory for the rules where an exception is itself the incident.
- “I’ll compare the cost delta as a number.”
delta_monthly_costis a string, so"900" > "1000"is a lexical comparison and lies (it isfalse, but for the wrong reason). The right model:decimal.new(...)then.less_than()/.greater_than(). - “
change.afterhas all my values.” At plan time, attributes that depend on not-yet-created resources are unknown and appear asnullinchange.after. Comparingnullsilently fails membership and equality checks. The right model: guard nulls explicitly and choose fail-open vs fail-closed per attribute. - “Enforcement level is set in the policy file.” It is not — it lives in
sentinel.hcl. The same.sentinelfile can run advisory in staging and hard-mandatory in prod. The right model: rule logic in the source, strictness in the manifest. - “Ship the new rule straight to hard-mandatory in prod.” A brand-new rule you have never run against real workspaces will surprise you with a case you did not foresee. The right model: land it advisory globally, watch which real runs it would have blocked, then promote by scope through staging to prod.
Glossary
- Policy-as-code — governance rules written as executable code that runs automatically on every change, instead of a wiki page humans are supposed to remember.
- Sentinel — HashiCorp’s policy-as-code language and runtime, embedded in HCP Terraform between
planandapply. - Import — a read-only data structure Sentinel evaluates against:
tfplan/v2(proposed change),tfconfig/v2(config as written),tfstate/v2(prior state),tfrun(run metadata). mainrule — the single boolean rule every policy must define; its value is the pass/fail verdict and the entry point HCP Terraform reads.- Rule — a named, lazily-evaluated boolean expression.
rule when <cond>short-circuits totrue(skips enforcement) when the condition is false. filter/all/any— Sentinel’s collection operators;filternarrows a map to matching entries,allandanyare quantifiers over it.resource_changes— the workhorse collection intfplan/v2: one entry per resource, each withaddress,type,mode, and achangeblock (actions,before,after).change.after— the planned attribute values for a resource;nullfor any value unknown at plan time, which must be guarded.- Enforcement level — how hard a failed policy pushes back: advisory (warn), soft-mandatory (block, overridable under audit), hard-mandatory (block, no override). Set in
sentinel.hcl. - Override — a user with the Manage Policy Overrides permission choosing to proceed past a soft-mandatory failure; recorded with the user and an optional comment for audit.
- Policy set — a directory of policies plus a
sentinel.hclmanifest, attached to workspaces/projects (globally or scoped), usually VCS-backed and versioned. sentinel.hcl— the manifest declaring each policy’s source, enforcement level, params, and any external modules.- Param — a policy input (
param name default ...) set insentinel.hcl, so thresholds and allow-lists are configuration, not hardcoded source. - Mock — a snapshot of an import’s data, generated from a real run, that lets you evaluate a policy offline. Files like
mock-tfplan-v2.sentinel. sentinel test— the CLI harness that runs a policy against mocks usingtest/<name>/*.hclcases and asserts howmain(or any named rule) evaluates.tfplan/functions— HashiCorp’s published helper module that flattens the import collections so you filter resources in one expression instead of nested loops.- Cost estimation — HCP Terraform’s per-run cost calculation, exposed to Sentinel as
tfrun.cost_estimate(strings: prior / proposed / delta monthly cost). - Policy check — the run stage (post-plan, pre-apply) at which Sentinel and OPA policy sets evaluate; distinct from run tasks, which call external services.
- OPA / Rego / conftest — Open Policy Agent, its language Rego, and the
conftestCLI: a portable, pipeline-agnostic alternative that evaluatesterraform show -json. - Native validation — Terraform’s in-config guards (
variablevalidation,lifecyclepre/postconditions,checkblocks): author-controlled invariants, not central governance. - Advisory rollout — shipping a new rule at advisory first to see which real runs it would have blocked, before promoting it to mandatory.