0x55aa
Back to Blog

⚖️ Policy-as-Code with OPA: Teaching Your Pipeline to Say No

|
5 min read

⚖️ Policy-as-Code with OPA: Teaching Your Pipeline to Say No

Every infrastructure team has a rulebook. "No public S3 buckets." "All pods need resource limits." "Prod deploys need two approvals." "Nothing runs as root." The rulebook lives in one of three places: a wiki page nobody opens, a Slack pin from 2023, or the tribal memory of the one senior engineer who reviews every PR and will absolutely notice if you skip it.

None of those scale. The wiki drifts out of date, the Slack pin gets buried, and the senior engineer goes on vacation exactly when someone ships a wildcard IAM policy. Rules that live in someone's head aren't rules — they're vibes with good intentions.

Policy-as-code is the fix: write the rule once, as code, and let a machine enforce it on every single plan, manifest, and request — consistently, tirelessly, and without needing to be tagged in a PR review. Open Policy Agent (OPA) is the tool most teams reach for, and its query language, Rego, is the part that trips everyone up on day one. Let's fix that.

What OPA actually is

OPA is a general-purpose policy engine. You feed it structured input (JSON) — a Terraform plan, a Kubernetes admission request, an API payload — and it evaluates that input against rules you've written in Rego. The output is a decision: allow, deny, or a list of violations. It doesn't care what's asking; it's the same engine whether you're gating terraform apply, Kubernetes admission control, or your own API gateway.

The mental model that finally made Rego click for me: it's not a scripting language, it's a query over a big nested document. Your input is a tree of JSON. A Rego rule is a query that asks "does some path through this tree match my condition?" That's why Rego feels backwards if you're used to imperative code — you're not writing steps, you're writing constraints.

The Terraform example everyone starts with

Here's a rule that blocks public S3 buckets, evaluated against a terraform plan JSON output:

package terraform.s3

deny[msg] {
  resource := input.resource_changes[_]
  resource.type == "aws_s3_bucket_acl"
  resource.change.after.acl == "public-read"
  msg := sprintf("Bucket '%s' must not be publicly readable", [resource.address])
}

Run it with conftest, a wrapper around OPA built for exactly this:

terraform plan -out=tfplan.binary
terraform show -json tfplan.binary > tfplan.json
conftest test tfplan.json --policy policies/

If someone's PR sets acl = "public-read", CI fails with a human-readable message instead of a silent AWS misconfiguration that a security scanner finds three weeks later — or that a bot on the internet finds three hours later.

The Kubernetes admission controller version

The same engine, wired into the cluster itself via Gatekeeper (OPA's Kubernetes-native wrapper), rejects bad manifests at kubectl apply time instead of after they're already running:

package kubernetes.admission

deny[msg] {
  input.request.kind.kind == "Pod"
  container := input.request.object.spec.containers[_]
  not container.resources.limits.memory
  msg := sprintf("Container '%s' has no memory limit", [container.name])
}

No memory limit, no admission. The pod never schedules, and nobody discovers the missing limit at 2am when it OOM-kills a node and takes three unrelated services with it.

Where this actually saved us

At Cubet, we rolled out an OPA gate on Terraform plans after a change slipped through that opened a security group to 0.0.0.0/0 on a port that definitely should not have been public. Nobody did it maliciously — it was a copy-pasted module default that nobody re-checked. Human review had approved it because humans are bad at diffing forty lines of HCL and noticing the one CIDR block that matters.

We wrote the rule once:

deny[msg] {
  resource := input.resource_changes[_]
  resource.type == "aws_security_group_rule"
  resource.change.after.cidr_blocks[_] == "0.0.0.0/0"
  resource.change.after.from_port != 443
  msg := sprintf("Security group rule '%s' opens a non-443 port to the world", [resource.address])
}

It's been running in CI ever since, and the class of bug it exists for has not recurred — not because people got more careful, but because the check doesn't get tired, distracted, or talked into an exception in a stand-up.

The part nobody warns you about

Rego's biggest gotcha is that rules are implicitly OR'd, not AND'd — multiple deny blocks with the same name accumulate into a set, they don't need to all be true. This trips up anyone coming from imperative languages who expects rule bodies to short-circuit like an if chain. Test your policies against known-good and known-bad inputs before trusting them in a gate — opa test supports exactly this, and skipping it is how a "strict" policy quietly lets everything through because one condition was subtly wrong.

The second gotcha: policy-as-code is only as good as its exception path. If engineers can't get a legitimate, time-boxed override without opening a ticket that sits for three days, they'll route around the gate entirely — disabling the check, or worse, applying changes outside the pipeline where nothing is checking anything. Build the escape hatch before you need it, and log every time it's used.

Give it a try

Pick one rule from your team's tribal-knowledge rulebook — the one everyone quotes but nobody can point to in code — and write it in Rego this week. Run it against last month's Terraform plans with conftest test and see how many would have failed. That number is usually the argument you need to get budget for the rest of the rollout.

Share:LinkedInXHacker News

Thanks for reading!

Back to all posts