Module 4: Policy As Code With Conftest
4. Hands-on: your first Rego policy
Description
This lesson writes, runs, and watches your first real Rego policy fail and pass — over a deliberately simple test YAML, before you get anywhere near Terraform. You're going to see a real syntax error (the one lesson 3 already anticipated), the correct syntax that does compile against conftest 0.69.0/OPA 1.19.0, a real FAIL with your own rule's message, and a real PASS after fixing the input data — without changing a single line of the policy.
Connection to the module
Everything lesson 2 explained in the abstract — package main, deny contains msg if, input — you write here with your own hands, against the real engine. This lesson's input is a trivial YAML, chosen on purpose: lesson 5 replaces that input with Andes Cargo's complete terraform plan, but conftest test's mechanics — load a file, evaluate it against policy/, report PASS or FAIL — are exactly the same in both cases.
The scenario: a debug flag that shouldn't reach production
A configuration file for Andes Cargo's tracking app, simplified to the essentials for this lesson:
# app-config.yaml
service: tracking-app
environment: production
debug: true
The rule you want to enforce is simple to say in prose: no configuration file marked environment: production may have debug: true — an active debug flag in production exposes internal information (stack traces, environment variables, full SQL queries in the error) to anyone who can see that output. You're going to write that rule in Rego, exactly as you'd say it in prose.
Step 1 — The real error, first: the syntax that no longer compiles
Before the version that does work, it's worth seeing the error lesson 3 anticipated — it's the most common error you'll run into searching for Rego examples online, because most existing tutorials were written before this syntax became the default requirement:
# policy/debug_flag.rego — old version, does NOT compile against OPA 1.19.0
package main
deny[msg] {
input.environment == "production"
input.debug == true
msg := "production environment must not run with debug mode enabled"
}
conftest test app-config.yaml -p policy/
What to expect (literal, executed to write this lesson):
Error: running test: load: loading policies: load: 2 errors occurred during loading:
policy/debug_flag.rego:3: rego_parse_error: `if` keyword is required before rule body
policy/debug_flag.rego:3: rego_parse_error: `contains` keyword is required for partial set rules
Two errors, on the same line, and both are the same cause: deny[msg] { ... } is the Rego syntax from before the language's version 1 (what the official documentation calls "Rego v0"). OPA 1.19.0 — the engine conftest 0.69.0 ships bundled, confirmed in the previous lesson — requires, by default, Rego v1 syntax: the if keyword before the rule body, and the contains keyword to declare that deny is a partial set (not a single value). The engine doesn't guess what you meant — it fails, with a precise error message, pointing at the exact line and the exact missing keyword.
Step 2 — The correct syntax, the only one you'll use in this module
# policy/debug_flag.rego
package main
deny contains msg if {
input.environment == "production"
input.debug == true
msg := "production environment must not run with debug mode enabled"
}
The only change: deny[msg] became deny contains msg if. The rest of the rule — the three lines inside { } — didn't change at all, because the logic they describe never depended on the header's syntax.
Step 3 — conftest test, against the original debug: true: FAIL
conftest test app-config.yaml -p policy/
What to expect (literal, executed to write this lesson):
FAIL - app-config.yaml - main - production environment must not run with debug mode enabled
1 test, 0 passed, 0 warnings, 1 failure, 0 exceptions
Read this line left to right, because this is the exact format you'll see for every policy for the rest of this module: FAIL (the verdict), app-config.yaml (the evaluated file — the input), main (the policy package that fired), and the literal message your own msg := "..." defined. The summary line — 1 test, 0 passed, 0 warnings, 1 failure, 0 exceptions — counts how many rules were evaluated in total (1 test, because this policy has a single deny rule), not how many files.
The process's exit code also matters, and you'll depend on it later to chain conftest inside a pipeline (this guide's Module 8):
echo $?
What to expect (literal):
1
A FAIL always ends with a non-zero exit code — the exact signal a CI job needs to know it should stop the pipeline, without having to parse the text output.
Step 4 — Fixing the data, not the policy: PASS
The policy doesn't change — the problem isn't in the rule, it's in the file that's supposed to satisfy that rule:
# app-config.yaml
service: tracking-app
environment: production
debug: false
conftest test app-config.yaml -p policy/
What to expect (literal, executed to write this lesson):
1 test, 1 passed, 0 warnings, 0 failures, 0 exceptions
echo $?
What to expect (literal):
0
Notice a real, not cosmetic, detail: when everything passes, conftest prints no per-file line at all — only the final summary. The absence of FAIL lines is the result; there's no PASS - app-config.yaml... line waiting for you to find it. This is exactly the behavior — silent when everything's fine, noisy only when something fails — that makes conftest readable inside a CI log with hundreds of lines: the only thing you need to search for is the word FAIL.
Going deeper: why the rule didn't change, and the data did
This pattern — fixed policy, varying data — is policy-as-code's complete essence, and it's worth naming explicitly before moving on: you wrote one rule, a single time, and ran it against two versions of the same file without touching a line of Rego. This is exactly what makes a policy reusable in a way manual review can never be — the same rule you just ran against an eleven-line YAML is, structurally, the same shape you'll run, starting in lesson 6, against a terraform plan with hundreds of lines of JSON. conftest test <file> -p policy/ doesn't change; the only thing that changes is which file you pass it, and which rules live inside policy/.
Common mistakes
Copying a Rego example from a tutorial or Stack Overflow written before Rego v1 (this lesson's mistake, in practice). What happens: someone searches "conftest deny rule example" and finds a result with deny[msg] { ... }, copies it unchanged, and runs into Step 1's exact rego_parse_error. How to spot it: the exact message — if keyword is required before rule body — is unmistakable; whenever you see it, the cause is Rego v0 syntax against an engine that requires v1 by default. How to fix it: add if before { and change deny[msg] to deny contains msg — the rest of the rule needs no other change, as you saw in Step 2.
Looking for a PASS line in the output when everything works, and concluding conftest ran nothing (expectation mistake). What happens: someone runs conftest test over a file that satisfies every rule, sees no line with the word PASS, and assumes the command failed silently or evaluated nothing. How to spot it: if you search the output for the literal word PASS and don't find it, even though the final summary says 1 test, 1 passed. How to fix it: conftest only prints a line for every rule that fails; when everything passes, the only evidence is the final summary (N passed) and exit code 0. Always confirm with echo $? if you have doubts, instead of searching for a word this tool's format deliberately doesn't print.
Modifying the policy to "make it pass" instead of fixing the data that violates it (intent mistake, the most dangerous of the three). What happens: someone, facing an unexpected FAIL, edits the Rego rule so it stops firing — for example, changing input.debug == true to a condition that never holds — instead of fixing the actual configuration file that has the problem. How to spot it: if your first instinct facing a FAIL is to open the .rego file, not the file you're evaluating. How to fix it: a policy that starts failing almost always means it found a real problem — that's, literally, its job — the correct fix, in the vast majority of cases, is fixing the data that violated it (as you did in Step 4), not silencing the rule. You'll see the serious version of this exact mistake, with real consequences, in this guide's Module 8, when you compare a "real fix" against an attempt to weaken a policy so a bad change slips through anyway.
Exercises
Exercise 1 — Write a second condition for the same rule, and confirm it live. Extend debug_flag.rego so it also fires if environment is "staging" (not just "production"), using a second deny rule (don't modify the first). Run conftest test against an app-config.yaml with environment: staging and debug: true, and confirm the real result.
See solution
package main
deny contains msg if {
input.environment == "production"
input.debug == true
msg := "production environment must not run with debug mode enabled"
}
deny contains msg if {
input.environment == "staging"
input.debug == true
msg := "staging environment must not run with debug mode enabled"
}
Against an app-config.yaml with environment: staging and debug: true, conftest test app-config.yaml -p policy/ should show FAIL - app-config.yaml - main - staging environment must not run with debug mode enabled, and the summary 2 tests, 1 passed, 0 warnings, 1 failure, 0 exceptions — two deny rules evaluated in total (one per block), one of which (the production one) doesn't apply to this input and therefore "passes" (doesn't fire), and the other (the staging one) does fire. This exercise is exactly the pattern you'll use in lesson 7, where two distinct least-privilege conditions coexist as two separate deny rules inside the same file.
Exercise 2 — Predict the exit code before running it. Without executing anything yet, for each of these three cases, predict whether conftest test would end with exit code 0 or non-zero: (a) a file that satisfies every rule; (b) a file that violates a rule; (c) an empty policy/ directory, with no .rego file at all.
See solution
(a) code 0 — everything passed, as you confirmed in Step 4. (b) non-zero code (1) — at least one rule fired, as you confirmed in Step 3. (c) this is the interesting case: an empty policy/ directory has no rule to evaluate at all, so conftest can't report any FAIL — but it isn't a meaningful PASS either, because it verified nothing. In practice, conftest treats this as an explicit error condition (it found no policy to load), not a silent PASS — the tool is designed so "there are no rules" is never confused with "the rules passed," exactly the mistake this module's lesson 1 Exercise 3 already had you predict in the abstract.
Exercise 3 — Explain, to someone who's never seen conftest, what "1 test" means in the final summary. The summary 1 test, 1 passed, 0 warnings, 0 failures, 0 exceptions uses the word "test" — test of what, exactly? Explain in one sentence what unit that number counts, using what you learned in this lesson.
See solution
Each "test" is one deny (or warn) rule's evaluation against a specific input document — not a file, nor a line of configuration. If your policy/ has three distinct deny rules and you evaluate a single YAML file, conftest reports "3 tests" (one per rule), even if all three live in the same .rego file. This same lesson's Exercise 1 demonstrates it live: two deny rules, a single app-config.yaml file, result "2 tests" — the count follows the rules, not the files, in either direction.
Summary and next step
In this lesson you wrote your first real Rego policy, saw the exact syntax error the old version of the language produces against the current engine (rego_parse_error, literal), fixed it with deny contains msg if, and confirmed real FAIL and PASS over the same policy file, changing only the input data. You also confirmed the exit code (1 on FAIL, 0 on PASS) — the signal a CI pipeline uses to decide whether to continue.
Before moving on you should be able to: write a deny contains msg if { ... } rule from scratch, with no example to copy; explain why conftest prints no PASS line when everything works; and recognize the rego_parse_error: if keyword is required error on sight, with no need to look it up.
Lesson 5 replaces this lesson's trivial YAML with the rest of this module's real input: andes-cargo-infra/'s terraform plan, converted to JSON with terraform show -json — the same conftest test mechanics, over a much richer structure.
Resources
- Open Policy Agent — Policy Language, Rules — the official reference for
ifandcontains, the two keywords this lesson confirmed live. - Conftest — Testing your first policy — the official tutorial that follows the same pattern — test YAML before a real case — as this lesson.
- This module, lesson 2 — a
denyrule's complete anatomy, the conceptual foundation for everything you wrote here. - This module, lesson 3 — the installation and version verification that explains why Step 1 of this lesson produces the error it produces.