Open Policy Agent (OPA) entered the CNCF sandbox on March 29, 2018, moved to incubating on April 2, 2019, and graduated on January 29, 2021 — at the time, the most recently graduated project in the foundation, joining the ranks of already-graduated projects like Kubernetes and Prometheus. That trajectory made Rego, OPA's purpose-built declarative policy language, the de facto standard for expressing "is this action allowed?" as code rather than as a wiki page nobody reads during an incident. Rego compiles down to queries over JSON-like data, borrowing its evaluation model from Datalog, which is why it fits so naturally over Kubernetes manifests, Terraform plans, and CI pipeline metadata — all of which are just structured data by the time a policy engine sees them. The catch is that Rego's flexibility is also its biggest operational risk: a rule that looks like it enforces a control can silently evaluate to "undefined" instead of "false," and undefined is not the same as denied. This post walks through writing Rego for two of its most common real-world deployments — Kubernetes admission control via Gatekeeper, and CI policy gates via tools like Conftest — and the specific pitfalls that turn a well-intentioned policy into a gate that never actually closes.
What is Rego actually evaluating, and why does that matter?
Rego evaluates a query against two separate data sources: input, the runtime object being checked right now (a pod spec, a Terraform plan, a pull request's changed files), and data, the static policy bundle and any reference data loaded alongside it. Confusing the two is a common early mistake — a rule written to check data.kubernetes.pods when it meant input.request.object will happily evaluate against whatever static fixture happens to be loaded, not the live admission request, and pass every real deployment through unchecked. A Rego rule is really a set of implications: given facts matching a pattern, conclude a value. There's no single "the policy," in the imperative sense — a policy is however many rules with the same name resolve when OPA evaluates them, and by default results across multiple rule bodies for one rule name are OR'd together. That OR semantics is exactly what makes rule composition powerful for expressing "allow if any of these conditions hold" — and exactly what causes the most damaging class of Gatekeeper and CI misconfigurations, covered next.
How does a rule silently fail open instead of closed?
The single most cited Rego pitfall in OPA's own documentation and community guidance is failing to set default allow = false before writing conditional allow rules. Rego rules that never match don't evaluate to false — they evaluate to undefined, and most integrations (Gatekeeper, opa eval, custom middleware) treat "no defined result" as "no violation found," which in an allow-list policy means the request passes. Consider a rule reading allow { input.user.role == "admin" } with no default allow = false above it: for any request where the role isn't admin, allow is undefined rather than false, and depending on how the calling code checks the result, that undefined value can be treated as non-blocking. The fix is mechanical but easy to skip under deadline pressure — every allow/deny policy needs an explicit default line for its top-level rule, and OPA's own style guide has called this out for years as the first thing to check in a policy review. Teams who skip it typically discover the gap only when an audit or opa eval trace shows a rule that has never once returned false in production.
Why does adding one convenience rule sometimes break the whole policy?
Because Rego OR's multiple bodies of the same rule name together, adding a narrowly-scoped "convenience" exception can inadvertently satisfy the rule for requests it was never meant to cover. A team writing a Gatekeeper ConstraintTemplate for "images must come from our internal registry" might add a second allow block for "unless it's a break-glass emergency deploy," checking for an annotation like emergency-override. If that second block's condition is looser than intended — checking only that the annotation key exists, not that it carries an approved, time-boxed value — any pod with that annotation set to anything at all now satisfies allow, regardless of the registry it pulls from. This is not a bug in OPA; it's Rego behaving exactly as documented. The defensive pattern is to keep exception logic in a clearly separate, narrowly scoped rule (for example allow_emergency_override) that is combined with the primary rule via an explicit and/not structure in a single rule body, rather than relying on implicit OR-composition across separately named or duplicated rule heads to do the narrowing for you.
How does Gatekeeper differ from applying Rego files directly?
Gatekeeper, the Kubernetes-native admission controller built on top of OPA within the same open-policy-agent project, does not let you kubectl apply a raw .rego file as an admission policy — it wraps Rego in two custom resources, ConstraintTemplate (which defines the Rego logic and a parameterized schema) and Constraint (which instantiates that template with specific parameters, like an allowed-registry list or required labels). This indirection exists so cluster operators can reuse one vetted template across many constraints without touching Rego per-team, but it introduces its own pitfall: a ConstraintTemplate's violation rules only fire for resource kinds and API groups explicitly listed in the constraint's match block. A perfectly correct Rego body attached to a Constraint that forgot to list apps/v1 Deployments alongside Pods will simply never be evaluated against Deployment-created pods, producing a false sense of coverage that only shows up when someone traces why a non-compliant Deployment made it to production untouched.
How do CI policy gates complement admission control instead of duplicating it?
CI-stage Rego evaluation — most commonly via Conftest, an open-source wrapper around OPA purpose-built for testing structured configuration files — checks Terraform plans, Kubernetes manifests, and Dockerfiles for policy violations before they're ever applied, catching the same class of issues Gatekeeper enforces at admission time but days or weeks earlier in the pipeline. This is a legitimate "shift-left" complement rather than redundant duplication: a CI gate can block a pull request with a clear diff and inline comment, while an admission-time rejection at deploy time is a much more disruptive, late-stage failure for the same underlying mistake. The two layers should share the same Rego logic where practical — teams that maintain separate, drifting Rego bundles for CI versus admission commonly end up with a CI check that passes a manifest an admission controller then rejects, which erodes trust in the gate faster than having no CI gate at all.
What does unit-testing a Rego policy actually catch?
opa test runs table-driven test cases against Rego rules the same way a unit test suite runs against application code, and it's the most effective way to catch the default-allow and rule-composition pitfalls above before they reach a cluster. A test file defines expected outcomes for specific input fixtures — a pod with a privileged container should produce deny with a specific message, a pod without one should not — and opa test -v reports which assertions failed after any policy edit. The gap in practice isn't that opa test is hard to use; it's coverage discipline: policies that started as a handful of straightforward rules accrete edge cases and exceptions over months, and without a growing test suite alongside them, a change meant to tighten one constraint can silently loosen another via the OR-composition behavior described above. Treating a Rego bundle as a live codebase — reviewed, tested, and versioned the same as application code — is the difference between a policy gate that holds under change and one that quietly regresses.
How Safeguard fits alongside OPA-based gates
Safeguard's own admission-layer enforcement runs on a different engine — Kyverno and Sigstore's policy-controller, not OPA/Gatekeeper — but the operational lesson is identical: policy-as-code is only as strong as its default state and its test coverage. Safeguard expresses its guardrails as YAML policy-as-code with explicit BLOCK, WARN, and AUTO_FIX effects enforced consistently across IDE, commit, CI, registry, and admission stages, which sidesteps the Rego default-allow pitfall by design — every guardrail has an explicit effect rather than relying on an implicit undefined-equals-pass fallthrough. For teams running OPA or Gatekeeper alongside Safeguard, the practical takeaway is to audit every allow/deny rule for an explicit default statement, keep exception logic in narrowly scoped, explicitly combined rules rather than duplicate rule heads, and run opa test in the same CI pipeline that gates the manifests those policies are meant to protect.