CoursesOPA & RegoRego basics: rules & queries

Rego basics: rules & queries

How Rego actually evaluates.

Advanced14 min · lesson 3 of 12

Most languages you have written are imperative: a recipe of steps, run top to bottom. Rego is not one of those. It behaves more like a spreadsheet. You never tell a spreadsheet how to compute a cell; you state what the cell means and it works out the value. Rego is the same deal. You declare what a document means, something along the lines of "allow is true when these facts line up", and OPA (Open Policy Agent, the engine that answers yes-or-no questions about your systems) goes looking for the variable bindings that make your statement hold.

Three sentences carry most of the language. Inside one rule body, the conditions are joined by AND. Two rules sharing a name are joined by OR. And when nothing matches, the answer is not false; it is undefined, which means no value came back at all. Get those three straight and the rest of Rego stops feeling alien.

A rule produces a value, not a sequence of steps

Each rule fills one slot in a single big tree of JSON (JavaScript Object Notation, the plain-text data format everything here speaks). The package name is the folder and the rule name is the leaf, so a rule called allow inside package authz answers to the query data.authz.allow. data is the root of that whole tree: every rule you write hangs off it, and so does any plain JSON you load beside your policy with -d, which is where allowlists, exemptions and priority tables live. input is the separate document that arrives fresh with every query. A complete rule like allow carries a single value. The block after if is a list of expressions, and the rule only takes its value when every expression in that block holds. If the block does not hold, you do not get false back. You get nothing, which Rego calls undefined. Three operators do most of the work: := assigns a local variable, == compares two known things, and = unifies, meaning it binds whatever is still unknown so both sides can match. Since version 1.0, OPA insists on if before a rule body and contains on partial set rules, so snippets written before that will not compile as-is.

terminal
cat > authz.rego <<'EOF'
package authz
default allow := false
allow if {
input.method == "GET"
input.user == "alice"
}
EOF
echo '{"method":"GET","user":"alice"}' > input.json
opa eval -d authz.rego -i input.json --format pretty 'data.authz.allow'
output
true

Inside a body it is AND, across bodies it is OR

Every line inside one body has to succeed. Rego ANDs them for you, no operator needed. If a single line comes back undefined, say because the input JSON has no user key at all, the whole body fails and the rule stays undefined. There is no partial credit. To say OR, you write the rule name a second time with a different body. OPA evaluates each definition on its own, and if any one of them holds, the document takes that value. Read a file with three allow rules out loud as "allow if this, or allow if that, or allow if the other thing". That reading holds only because every one of those bodies produces the same value, true. Two complete rules of the same name that hold at the same time and produce different values are a runtime error, complete rules must not produce multiple outputs, so repeating the name is free for booleans and turns into a conflict the moment a rule starts returning strings or numbers.

terminal
cat >> authz.rego <<'EOF'
allow if input.user == "admin"
allow if {
input.method == "GET"
input.path == "/public"
}
EOF
echo '{"user":"admin"}' > input.json
opa eval -d authz.rego -i input.json --format pretty 'data.authz.allow'
output
true

Iteration without a loop

Rego has no for-loop. Leave a variable unbound inside a reference and you have already asked for iteration, because an unbound variable means "try every value that makes this work". There are two ways to write it. some name in coll declares the variable and walks the collection. coll[_] does the same thing with an anonymous placeholder, no name needed. A partial rule, the kind written with contains, gathers every solution the search finds into a set, so one rule can hand back many values. Put a condition next to the iteration and you get a filter for free, because OPA keeps only the bindings where the whole body holds.

terminal
cat > iterate.rego <<'EOF'
package team
admins contains name if {
some name, role in input.roles
role == "admin"
}
EOF
echo '{"roles":{"alice":"admin","bob":"viewer"}}' > input.json
opa eval -d iterate.rego -i input.json --format pretty 'data.team.admins'
output
["alice"]

Collecting violations with contains

Validation policies nearly all land on the same shape: a deny set. deny contains msg adds one message for each violation it finds, and an empty set means the thing you checked came out clean. That is exactly the shape Conftest (the tool that runs Rego against config files) looks for. Gatekeeper (the Kubernetes admission controller built on OPA) wants the same idea under a different name: inside a ConstraintTemplate the rule has to be called violation and hand back objects rather than strings, violation contains {"msg": msg}, and it reads the resource out of input.review.object, because the apiserver gives the policy a whole AdmissionReview and not the bare manifest. Notice how different this is from allow. A single allow boolean answers one question with one value, true or false. A deny set answers "what is wrong here" with a list, so one rule walking input.spec.containers[_] can produce a separate complaint for every bad container in the pod.

terminal
cat > k8s.rego <<'EOF'
package main
deny contains msg if {
c := input.spec.containers[_]
c.securityContext.privileged == true
msg := sprintf("container %v is privileged", [c.name])
}
EOF
echo '{"spec":{"containers":[{"name":"web","securityContext":{"privileged":true}}]}}' > pod.json
opa eval -d k8s.rego -i pod.json --format pretty 'data.main.deny'
output
["container web is privileged"]
How OPA evaluates a query
1Pose query
e.g. data.authz.allow
2Bind input + data
facts as documents
3Search bodies
unify vars, AND expressions
4Return value
defined or undefined
The second box is the step people skip. input arrives with the query, data was loaded when OPA started, and a body reaching for a field that is missing from either one drops out of the search before the last box ever gets a value.

A default turns undefined into a real answer

For any decision meant to be a plain yes or no, put default allow := false at the top of the file (or default deny := false when your logic runs the other way round). The default fires when no rule body holds, so the caller gets a solid false instead of an empty answer. Leave it out and your authorization policy fails open the first time some caller reads "not true" as "go ahead". The same trap hides inside comparisons. allow == false succeeds when allow really is false, and goes undefined when allow has no value at all. Those two outcomes are not interchangeable.

terminal
echo '{"method":"DELETE"}' > input.json
opa eval -d authz.rego -i input.json --format pretty 'data.authz.allow'
output
false
# without default allow := false this would be undefined (empty output)
Nothing back is not the same as a no
This is the trap that catches nearly everyone. A rule whose body does not hold is undefined, not false. Query data.authz.allow with no matching body and no default and you get nothing back, an empty result. Any caller that reads a missing decision as permission fails open. One absent key does the same damage quietly: reference input.user.role when there is no user object and the expression goes undefined rather than raising an error, so the rule silently never fires. Test with the field present and with the field missing. And watch out for not allow. It succeeds for false and for undefined alike, so it can never tell you which one you actually have.

Coming from Python or Go, you will want to read a .rego file top to bottom as control flow. Resist that. Ask a different question instead: what would have to be true for this query to come back with a value? OPA backtracks through bindings hunting for an answer, so debugging is mostly a matter of finding which body refused to hold. opa eval --explain=fails tells you that when a rule you expected to fire does not.

Reading the trace with --explain

The fails trace prints one line for every expression that did not hold, so it points straight at the one that stopped the search. Use --explain=full when you want the successful steps around it as well. The mode you do not want here is --explain=notes: that one only prints messages a policy emitted itself through the trace() builtin, so a file with no trace() calls in it hands you an empty trace and the sinking feeling that you broke something. Reach for the fails trace in two situations: while moving older policies onto OPA 1.0 syntax, and when a nested field you assumed was present is missing and quietly voids a body.

terminal
opa eval -d authz.rego -i input.json --explain=fails --format pretty 'data.authz.allow'
output
# one Fail line per expression that did not hold, then the value
# undefined reference → body fails, not error

The some keyword declares a variable and scopes it to the rule body it sits in, so a reader can tell at a glance that the name is a fresh local and not a rule defined somewhere else in the file. It goes on a line of its own: some x in input.items, then x.score > 90 on the next line, which reads close to English. There is no brace-block form of some. Braces after in belong to every, as in every x in input.items { x.score > 90 }, and that is the all-of check rather than the any-of one. Write some in anything new. Learn to read [_] anyway, because older examples and most Gatekeeper snippets in the official docs still use it.

Functions, written f(x) := ..., strip out repeated logic, and they are easy to overdo. A one-line helper called from a single place belongs inline where a reader can see it. Where functions earn their keep: formatting the same message across five rules, pulling a path apart, or normalizing an image reference before you compare it against an allowlist.

Comprehensions build a collection on the spot

A comprehension like [x | x := input.items[_]; x.active] works the way a list comprehension does in Python. Filter some elements, get a fresh array or set back. Use one to pull out the admin names, the resources that broke a rule, or a tidied list of image names before you check them against data. They keep a deny rule readable when you need an intermediate collection that is not worth a rule of its own.

terminal
opa eval --format pretty '[n | n := numbers.range(1,5)[_]; n % 2 == 0]'
output
[2, 4]

The keywords exist because bare unification left readers guessing, so OPA 1.0 made if and contains mandatory in new policies. Older files may still carry import future.keywords at the top. Convert the snippets you copy off blogs before they land in a production repo.

Examples online for Gatekeeper and Conftest often leave out default allow := false, because those examples are all about deny and violation sets, where a default is not the idiom. Port one of those snippets into an authorization package and the default is on you to add. Mixing the two idioms by copy and paste causes more fail-open bugs in production than syntax errors ever will, because a syntax error stops the build and undefined says nothing.

Name packages after the directory they sit in. A file at policy/kubernetes/admission/main.rego declaring package kubernetes.admission keeps a monorepo readable, and the query path data.kubernetes.admission.deny falls out of it for free. Putting package main on everything works fine right up until the fortieth file.

terminal
opa check --strict policy/
# catches unused imports and ref errors before test

References use a dot for object keys and brackets for array positions: input.spec.containers[0].name. Miss one key in the middle of that chain and the entire chain is undefined, not an error. When the input shape shifts between what CI (continuous integration, the automated checks that run on every push) sees and what the admission controller sees, reach for object.get(input, ["spec", "containers"], []) or check the key exists before you use it.

Order does not matter. OPA collects every definition of a rule before it evaluates anything, so nothing wins by sitting higher in the file. If you need priority between rules, load the ranking as data and compare it in the body yourself: data.exemptions[input.team] is how one team gets let out of a check that binds everyone else.

sprintf builds your deny messages and concat builds paths. Once tests assert on those strings, keep the templates stable, because the person squinting at a dashboard at 2am is pattern-matching on the exact wording.

Open the OPA policy language reference the moment a builtin surprises you. regex.match, glob.match, units.parse_bytes and time.parse_rfc3339_ns turn up in real policies constantly. Keep your own file of patterns you have already verified (parsing an image reference, testing whether an address falls in a CIDR range, which is the slash notation for a block of IP addresses) rather than rebuilding them off Stack Overflow every sprint.

When you are writing a Gatekeeper ConstraintTemplate, copy the Rego out of the YAML (the indented config format Kubernetes manifests use) into a policy/ package with a matching package name, iterate there, then paste it back once the tests pass. Editing inside the template means waiting on kubectl apply for every typo.

The object the Kubernetes apiserver hands to an admission webhook is not the YAML you committed. Fields like status and managedFields are stripped on create, so a policy that requires them will never fire. When a deny rule stays mysteriously quiet, run kubectl create --dry-run=server -o json, feed that JSON to opa eval, and compare it against the checked-in file Conftest reads.

Before a policy pack leaves your org, read the deny messages and data paths for internal hostnames. Open-source policy bundles get redacted for a reason: the strings inside an error message end up as public as the rule that produced them.

Turn on Regal's completion in VS Code (Regal is the dedicated Rego linter). Catching a syntax slip while you type costs you seconds; catching it when opa check runs in CI costs you a build.

Pulling in a public policy library? Pin the git submodule to a specific SHA (the commit hash) and run opa test after every bump. Upstream can quietly change a deny rule, and nothing will tell you until your own tests go red.

Treat Rego the way you treat SQL (Structured Query Language, the one you query databases with): readable, declarative, and dangerous when copied by someone who has not thought it through. In review, ask what input makes this rule go undefined. Whether it compiles is the easy half.

Try this

Write one allow rule and one deny-set rule in the same file, then query both. Then run --explain=fails on a case you expect to fail. Watching why a body refused to unify will teach you Rego faster than any syntax chart.

terminal
cat > rules.rego <<'EOF'
package demo
default allow := false
allow if input.role == "admin"
deny contains msg if {
input.privileged == true
msg := "privileged not allowed"
}
EOF
opa eval -d rules.rego -i <(echo '{"role":"admin"}') --format pretty 'data.demo.allow'
opa eval -d rules.rego -i <(echo '{"privileged":true}') --format pretty 'data.demo.deny'
opa eval -d rules.rego -i <(echo '{"role":"guest"}') --explain=fails --format pretty 'data.demo.allow'
output
true
["privileged not allowed"]
# then the Fail lines for the body that did not hold, followed by:
false

Takeaway

Four things to carry out of here. Expressions inside one body are ANDed. Extra definitions of the same rule name are ORed. A default is what turns undefined into an honest false. And contains is how a deny set piles up one message per violation, which is why validation policies look nothing like authorization policies.

Keep running opa eval --explain=fails on rules that refuse to fire until the search story reads as obvious, then freeze that behavior with opa test so tomorrow's edit cannot quietly turn a deny back into undefined.

Quick check
01authz.rego has default allow := false plus one allow body requiring input.method == "GET" and input.user == "alice". With {"method":"DELETE"} in input.json, opa eval -d authz.rego -i input.json --format pretty 'data.authz.allow' prints false. A teammate deletes the default line and runs the same command. What prints now?
Incorrect — A body that does not hold leaves the rule undefined, not false. The false you saw in the first run came from the default line, and that line is now gone.
Correct — Query a rule when nothing holds and no default is present and you get no value back at all. Any caller that reads a missing decision as permission now fails open.
Incorrect — Undefined is a normal outcome in Rego, not a failure. Nothing prints and nothing complains, which is exactly what makes this quiet.
Incorrect — OPA never invents true out of an unmet body. It hands back nothing, and the risk lives in whatever the caller does with that emptiness.
02iterate.rego holds admins contains name if { some name, role in input.roles; role == "admin" }. With {"roles":{"alice":"admin","bob":"viewer"}} the query data.team.admins prints ["alice"]. You change bob's role to "admin" and rerun. What prints?
Incorrect — A contains rule builds a set of values, not a yes or no. Only a complete rule like allow answers with true or false.
Incorrect — There is no first match to stop at. OPA hunts for every binding that makes the body hold and keeps all of them.
Incorrect — Two values would clash in a complete rule. A partial rule is built to collect many, so both names sit happily in one set.
Correct — Leaving name unbound is the request to iterate, and the role == "admin" line beside it acts as the filter. Bob now passes that filter, so he joins the set.
03You want allow only when the user is admin and the method is GET. A colleague writes that as two separate allow rules, one checking input.user == "admin" and one checking input.method == "GET". You send {"method":"GET","user":"bob"}. What comes back?
Correct — Repeating a rule name means or, so either body alone is enough to produce true. To require both conditions, put the two lines inside one body where Rego ands them for you.
Incorrect — And applies to the lines inside a single body. Split those lines across two rules and you have written or without meaning to.
Incorrect — Both definitions agree on true here. Undefined turns up when no body holds at all, not when more than one does.
Incorrect — A second definition of the same name is ordinary Rego and compiles cleanly. That is precisely why this bug survives review and reaches production.

Related