Rego basics: rules & queries
How Rego actually evaluates.
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.
cat > authz.rego <<'EOF'package authzdefault allow := falseallow if {input.method == "GET"input.user == "alice"}EOFecho '{"method":"GET","user":"alice"}' > input.jsonopa eval -d authz.rego -i input.json --format pretty 'data.authz.allow'
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.
cat >> authz.rego <<'EOF'allow if input.user == "admin"allow if {input.method == "GET"input.path == "/public"}EOFecho '{"user":"admin"}' > input.jsonopa eval -d authz.rego -i input.json --format pretty 'data.authz.allow'
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.
cat > iterate.rego <<'EOF'package teamadmins contains name if {some name, role in input.rolesrole == "admin"}EOFecho '{"roles":{"alice":"admin","bob":"viewer"}}' > input.jsonopa eval -d iterate.rego -i input.json --format pretty 'data.team.admins'
["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.
cat > k8s.rego <<'EOF'package maindeny contains msg if {c := input.spec.containers[_]c.securityContext.privileged == truemsg := sprintf("container %v is privileged", [c.name])}EOFecho '{"spec":{"containers":[{"name":"web","securityContext":{"privileged":true}}]}}' > pod.jsonopa eval -d k8s.rego -i pod.json --format pretty 'data.main.deny'
["container web is privileged"]
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.
echo '{"method":"DELETE"}' > input.jsonopa eval -d authz.rego -i input.json --format pretty 'data.authz.allow'
false# without default allow := false this would be undefined (empty output)
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.
opa eval -d authz.rego -i input.json --explain=fails --format pretty 'data.authz.allow'
# 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.
opa eval --format pretty '[n | n := numbers.range(1,5)[_]; n % 2 == 0]'
[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.
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.
cat > rules.rego <<'EOF'package demodefault allow := falseallow if input.role == "admin"deny contains msg if {input.privileged == truemsg := "privileged not allowed"}EOFopa 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'
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.