Get the Zoom link
Cloud Security Office Hours Banner

OPA and Rego, hands-on

Open Policy Agent is the closest thing cloud security has to a universal policy language. Start with what Rego actually is, write rules that run, gate a real Terraform plan, then test the policy itself.

Jump to the walk-through All how-to guides

· · Vendor-neutral

Time: ~90 minutes  ·  Difficulty: Beginner to intermediate  ·  You need: a terminal, the opa binary, and optionally Docker for the Conftest step

What OPA actually is

Open Policy Agent is a general-purpose policy engine. That description is vague on purpose, and the vagueness is the point: OPA does not know what Kubernetes is, what Terraform is, or what an S3 bucket is. It knows one thing. You hand it a JSON document, it evaluates rules against that document, and it hands you back a decision, also JSON.

Everything else is plumbing built around that core. Gatekeeper is plumbing that feeds Kubernetes admission requests into OPA. Conftest is plumbing that feeds a Terraform plan into OPA. Envoy's external authorization filter is plumbing that feeds an HTTP request into OPA. The engine underneath is identical in all three, and so is the language.

That is the practical reason to learn Rego rather than any single tool's rule format. A Sentinel policy works in Terraform Cloud. A Kyverno policy works in Kubernetes. Rego is the one policy language that a cloud security engineer runs into at the infrastructure layer, the orchestration layer, and the application layer, and it is a CNCF graduated project, so it is not going anywhere.

Check your OPA version before you read any other tutorial. OPA 1.0 shipped on 20 December 2024 and changed the language's surface syntax: if before a rule body and contains for multi-value rules went from optional to mandatory. Most Rego on the internet predates that. Everything on this page is written for 1.x.

On this page

  1. The one idea that makes Rego click
  2. Walk-through
  3. Hands-on exercises
  4. Common mistakes
  5. Where next

The one idea that makes Rego click

Rego is not an imperative language wearing unfamiliar syntax. It is a query language, and the thing you are querying is a single JSON tree. Everything OPA knows lives at one of two roots:

A rule is a claim about that tree. opa eval asks whether the claim holds and what value it produces. Once you read rules as queries rather than as functions, the two things that confuse everybody stop being confusing.

Undefined is not false

In most languages a boolean expression is true or false. In Rego there is a third outcome, and it is the common one: undefined. A rule whose body cannot be satisfied does not return false. It returns nothing at all.

package example

# This rule is defined only when input.encrypted is exactly false.
unencrypted if {          # `if` is mandatory in Rego v1
    input.encrypted == false
}

Feed that {"encrypted": true} and unencrypted is undefined. Feed it {} and it is also undefined, because input.encrypted does not exist and a reference to a missing key is itself undefined, which makes the whole body fail. Those two cases look identical from outside and they mean completely different things: one is an encrypted bucket, the other is a document you forgot to check. This is the single largest source of policies that pass when they should fail.

Deny sets, not if-then-else

Idiomatic Rego does not return "allow" or "deny" from one big branch. It accumulates violations into a set, and each rule contributes independently. An empty set means nothing objected.

package example

# `contains` marks this as a multi-value (set) rule. Every matching
# input adds one more message to the set `deny`.
deny contains msg if {
    input.encrypted == false
    msg := "storage is not encrypted"
}

deny contains msg if {
    input.public == true
    msg := "storage is publicly readable"
}

Both rules are named deny, and that is not a mistake. A bucket that is unencrypted and public produces a set with two messages. This is why policy-as-code tooling reports every violation at once instead of stopping at the first, and it is why adding a new check never means editing an existing rule.

Walk-through

1. Install OPA and confirm you are on 1.x

# macOS. On Linux, grab the static binary from the GitHub releases page.
brew install opa

opa version        # confirm "Version: 1.x" - the syntax below needs it

If a package manager hands you 0.x, download the binary directly from the releases page instead. On 0.x every example here fails to parse, and the error message points at the if keyword rather than saying "your OPA is old", which is a confusing first hour.

2. Your first policy, evaluated from the command line

bucket.rego:

package storage        # the package path becomes data.storage

deny contains msg if {
    input.encryption.enabled == false
    msg := sprintf("bucket %v is not encrypted", [input.name])
}

bucket.json:

{ "name": "prod-invoices", "encryption": { "enabled": false }, "public": true }
# -d loads a policy file (or directory), -i loads the input document.
# The final argument is the query: a path into the data tree.
opa eval -d bucket.rego -i bucket.json 'data.storage.deny'

# --format pretty prints just the value instead of the full result envelope
opa eval --format pretty -d bucket.rego -i bucket.json 'data.storage.deny'

You should get back a set containing one message. Now flip enabled to true in the input and run it again: the pretty output is [], an empty set. That empty set is your "allowed" signal.

3. Watch undefined bite you

Delete the whole encryption object from bucket.json so it reads {"name": "prod-invoices", "public": true}, then run the same command.

opa eval --format pretty -d bucket.rego -i bucket.json 'data.storage.deny'
# []   <-- an empty deny set. The policy says this bucket is fine.

A bucket with no encryption configuration at all just passed a policy whose entire job is to require encryption. input.encryption.enabled is undefined, the body fails, no message is added. The fix is to stop asking "is it false" and start asking "is it not true":

deny contains msg if {
    not input.encryption.enabled == true        # true for false AND for missing
    msg := sprintf("bucket %v is not encrypted", [input.name])
}

not succeeds when the expression it wraps is false or undefined, which is exactly the coverage you wanted. Re-run against all three inputs (encrypted, unencrypted, missing) and confirm the last two now produce a message.

4. Iterate over a collection

Real inputs are lists. some ... in ... binds a variable to each element in turn, and because the rule is a set rule, every element that satisfies the body contributes its own message.

package storage

deny contains msg if {
    some bucket in input.buckets          # iterate; `bucket` is bound per element
    not bucket.encryption.enabled == true
    msg := sprintf("bucket %v is not encrypted", [bucket.name])
}

# A second, independent check over the same collection.
deny contains msg if {
    some bucket in input.buckets
    bucket.public == true
    msg := sprintf("bucket %v is public", [bucket.name])
}

There is no loop counter, no break, and no accumulator variable. You describe what a violation looks like and OPA finds every one of them.

5. Gate a real Terraform plan with Conftest

This is where it stops being a toy. Terraform can export its plan as JSON, and that JSON is just another input document.

terraform plan -out=tfplan.binary            # write the plan to a file
terraform show -json tfplan.binary > tfplan.json   # convert it to JSON

The interesting key is resource_changes: an array where each entry has an address, a type, a change.actions list, and change.after holding the resource as it will exist. policy/s3.rego:

package main        # Conftest looks in `main` by default

deny contains msg if {
    some rc in input.resource_changes
    rc.type == "aws_s3_bucket_public_access_block"

    # Only judge resources that will exist afterwards. A destroy has
    # change.after == null, and reading fields off null is undefined,
    # which would silently skip the check rather than pass it.
    "delete" not in rc.change.actions

    not rc.change.after.block_public_acls == true
    msg := sprintf("%v does not block public ACLs", [rc.address])
}
# Conftest wraps OPA and knows how to read plan JSON, YAML, Dockerfiles, and more.
brew install conftest
conftest test --policy policy/ tfplan.json

echo $?    # 0 = no denials, 1 = at least one. This is your CI gate.

6. Unit-test the policy itself

A policy is code that decides whether other code ships. Untested, it is the least trustworthy thing in the pipeline. OPA has a test runner built in: any rule named test_* is a test, and it passes if it evaluates to true.

policy/s3_test.rego:

package main

# `with input as` swaps in a fake document for the duration of one expression.
test_denies_bucket_that_allows_public_acls if {
    count(deny) == 1 with input as {
        "resource_changes": [{
            "address": "aws_s3_bucket_public_access_block.bad",
            "type": "aws_s3_bucket_public_access_block",
            "change": {"actions": ["create"], "after": {"block_public_acls": false}},
        }]
    }
}

# The direction people forget. Without this, a policy that denies
# EVERYTHING passes the test above and looks perfectly healthy.
test_allows_compliant_bucket if {
    count(deny) == 0 with input as {
        "resource_changes": [{
            "address": "aws_s3_bucket_public_access_block.good",
            "type": "aws_s3_bucket_public_access_block",
            "change": {"actions": ["create"], "after": {"block_public_acls": true}},
        }]
    }
}
opa test policy/ -v      # -v names each test instead of printing only a tally

7. Format, check, and lint

opa fmt -w policy/          # -w rewrites files in place, gofmt style
opa check --strict policy/  # --strict rejects unused vars, shadowed names, and
                            # other things that parse but almost always mean a bug

# Regal is a dedicated Rego linter and catches idiom problems opa check does not.
brew install regal
regal lint policy/

Put opa check --strict and opa test in CI next to the gate itself. A policy repository where the policies are not tested is a repository that will eventually block a deploy for the wrong reason, and the fastest way to get policy-as-code removed from a pipeline is to have it be wrong once at a bad moment.

Hands-on exercises

Do these against the files you built above. Each one has a trap in it that this page has already warned you about at least once.

Exercise 1

Write a rule that denies any EC2 instance in a Terraform plan whose change.after.metadata_options.http_tokens is not "required". That setting is what forces IMDSv2, and a missing metadata_options block is the default-insecure case that matters most.

Show the answer
deny contains msg if {
    some rc in input.resource_changes
    rc.type == "aws_instance"
    "delete" not in rc.change.actions
    not rc.change.after.metadata_options.http_tokens == "required"
    msg := sprintf("%v does not require IMDSv2", [rc.address])
}

The not ... == "required" form is doing the real work. If you wrote rc.change.after.metadata_options.http_tokens == "optional" you covered the explicitly-bad case and missed the far more common one where the block is absent entirely.

Exercise 2

This rule never fires, no matter what you feed it. Find out why without changing the input.

deny contains msg if {
    some rc in input.resource_changes
    rc.type == "aws_security_group_rule"
    rc.change.after.cidr_blocks[_] == "0.0.0.0/0"
    rc.change.after.from_port = 22
    msg := sprintf("%v exposes SSH to the internet", [rc.address])
}
Show the answer

= is not ==. A single = is unification, which here assigns 22 to a fresh binding rather than comparing against it, so that line succeeds no matter what the port is. That is not the bug that stops the rule firing, though; it is the one that would make it fire too often. Run opa check --strict and you get told directly.

The reason nothing fires is more mundane and more common: in a Terraform plan, from_port is a number and the surrounding structure is cidr_blocks, but real aws_security_group_rule resources frequently express the same thing through aws_vpc_security_group_ingress_rule with a cidr_ipv4 field instead. A type filter that names a resource type your plan does not contain produces an empty iteration and therefore an empty deny set, forever, with no error.

The habit worth building: before debugging the logic, prove the iteration is finding anything at all.

opa eval --format pretty -d policy/ -i tfplan.json \
  'data.main.deny'                                     # [] tells you nothing

opa eval --format pretty -i tfplan.json \
  '[rc.type | some rc in input.resource_changes]'      # what types are ACTUALLY here?
Exercise 3

Take any rule you have written and deliberately break it so that it denies everything: delete the conditions and leave only the message. Now run your existing tests. Which of them fail, and what does that tell you about the tests you wrote before reading this?

Show the answer

Only test_allows_compliant_bucket fails. The deny-direction test still passes, because a rule that denies everything also denies the bad input.

This is the same shape as testing a firewall by confirming it blocks traffic: a correctly configured firewall and a completely broken one produce identical evidence. Every policy needs at least one test asserting that a compliant input produces no findings, and that is the test people skip, because writing it feels like testing that nothing happened.

Exercise 4

Harder. Write a rule that denies when an IAM policy document grants "Action": "*" on "Resource": "*", handling the fact that both fields may be either a string or an array of strings.

Show the answer
package main

# A helper that normalises "string or array of strings" into a set.
# Two rules with the same name and different bodies: whichever one is
# defined for the given input wins. This is Rego's answer to overloading.
as_set(x) := {x} if is_string(x)
as_set(x) := {v | some v in x} if is_array(x)

deny contains msg if {
    some stmt in input.Statement
    stmt.Effect == "Allow"
    "*" in as_set(stmt.Action)
    "*" in as_set(stmt.Resource)
    msg := "statement grants Action * on Resource *"
}

{v | some v in x} is a set comprehension: read it as "the set of every v such that v is in x". Comprehensions are how you build a value in Rego without a loop, and this normalise-then-compare pattern shows up constantly, because the AWS IAM document schema is genuinely polymorphic in five different places.

Common mistakes

Where next