Safeguard
DevSecOps

Rego for intermediates: combining rules with AND/OR and writing actionable error messages

Rego has no `&&` or `||` operators — AND is implicit, OR means writing the same rule twice, and most teams miss both until a policy silently passes.

Safeguard Research Team
Research
7 min read

Rego, the policy language behind the Open Policy Agent (OPA) project, has no && or || operator anywhere in its grammar — and that single fact is responsible for more silently-broken policies than almost anything else in the language. OPA graduated from the Cloud Native Computing Foundation in February 2021 and, as of the v1.0 release in December 2024, made the contains and if keywords a mandatory part of rule syntax rather than an opt-in future-import. That change was aimed squarely at making rule composition less error-prone for exactly the two operations this post covers: combining conditions with AND, combining alternatives with OR, and turning the result into an error message a developer can act on without opening the policy source file. Conftest, the OPA-based tool for testing structured config files, ships with zero configuration required to pick up rules named deny, warn, or violation and print their string output straight to the CI log — which is why getting the composition and the messaging right isn't a style preference, it's what determines whether your CI failure reads like a stack trace or like a one-line fix instruction. This tutorial works through both.

How does AND actually work in Rego if there's no && operator?

AND in Rego is implicit: every expression inside a single rule body must evaluate to true for the rule itself to be true, and there is no separate conjunction operator because none is needed. Write allow if { input.method == "GET"; input.user.role == "admin" } and both statements — separated here by a semicolon, though a newline works identically — must hold. This is the same model SQL's WHERE a = 1 AND b = 2 uses, just without the keyword. The practical trap for newcomers is assuming a comma or semicolon is optional formatting; it is not, it is the AND. A common bug is splitting a condition across two rule bodies with the same name expecting AND, which instead produces OR (covered next) — the single most common mistake reported in OPA's own Slack and GitHub discussions from teams new to the language. If you want multiple conditions to all be required, they belong inside one body.

How do you express OR when Rego has no ||?

OR in Rego is expressed by defining the same rule name more than once: if any one of the multiple bodies is satisfied, the rule as a whole is true. So allow if { input.user.role == "admin" } followed by a second, separate allow if { input.user.role == "owner" } means "admin OR owner" — two independent definitions of allow, evaluated independently, unioned together. This is fundamentally different from AND and is arguably the single most important asymmetry to internalize in Rego: adding statements to the same body tightens the condition (AND), while adding a new body with the same rule name loosens it (OR). For genuinely sequential, mutually-exclusive alternatives — "try this, and only if it doesn't match, fall back to that" — Rego offers the dedicated else keyword instead, evaluated top-to-bottom with the first matching branch winning, which is a cleaner fit than stacking multiple OR bodies when the branches represent priority rather than parallel possibilities.

What's the idiomatic way to collect more than one violation instead of just one pass/fail?

The idiomatic pattern is a partial set rule — conventionally named deny, warn, or violation — that uses contains to accumulate one message per failing condition rather than returning a single boolean. Instead of one allow rule that goes false the instant any check fails, you write: deny contains msg if { not input.image.tag; msg := "image tag is required" } alongside a second, independent deny contains msg if { input.image.tag == "latest"; msg := "image tag 'latest' is not allowed" }. Because these are separate rule bodies sharing the deny name, OPA evaluates both independently and unions every message that fires into a single set. A manifest missing a tag and violating a separate CPU-limit rule produces two messages in one pass, not one generic failure that hides the second problem until the first is fixed and the pipeline is re-run. This is precisely the mechanism from the "How do you express OR" section above, repurposed: OR across rule bodies isn't just for permissions, it's the standard shape for exhaustive violation collection.

How do you turn a violation into a message someone can act on without reading the policy?

You turn a violation into an actionable message using the built-in sprintf function to interpolate the actual offending value — the specific image name, the specific tag, the specific missing field — directly into the string, rather than emitting a static description of the rule. Compare msg := "disallowed image tag" against msg := sprintf("image %q uses disallowed tag %q; use a pinned version instead of 'latest'", [input.image.name, input.image.tag]). The first tells an engineer a rule exists; the second tells them which image, which tag, and what to do next — no source-diving required. sprintf supports the standard Go-style verbs (%s, %q, %d, %v), and because it's a pure function evaluated at query time, the interpolated values are always the actual input data being checked, not a placeholder. This is the single highest-leverage habit in writing Rego for CI/CD gates: every msg := assignment should be built with sprintf against real input fields, never a hardcoded literal, unless the message genuinely can't reference anything input-specific.

Why do CI tools like Conftest specifically look for deny and warn?

Conftest, an open-source tool for running OPA policies against structured configuration files (Kubernetes manifests, Terraform plans, Dockerfiles), reads any rule package and specifically surfaces deny and warn sets as its pass/fail and warning output without requiring extra configuration — a convention that predates OPA's own v1.0 keyword changes and has effectively become the de facto standard across the policy-as-code ecosystem. A deny set with any members fails the check and exits non-zero; a warn set prints without failing the build. This means the naming choice in the "collecting violations" section above isn't just a style preference — it's what determines whether Conftest, and by extension most CI wrappers built on top of OPA, know how to route your policy's output at all. Naming a rule blocked_images instead of deny still evaluates correctly under raw opa eval, but Conftest's default report will silently ignore it, which is a common source of "the policy works locally but CI doesn't catch it" bug reports in OPA community channels.

What does a more structured violation object add over a plain string?

A more structured violation — returning an object like {"msg": sprintf(...), "field": "spec.containers[0].image"} instead of a bare string — adds machine-readable context that CI tooling and dashboards can extract without regex-parsing a sentence. As Rego adoption matured past simple pass/fail gates into larger platform-engineering pipelines, teams converged on this object pattern specifically so a field or _loc key could point automated remediation or a PR-comment bot at the exact line, rather than making a human read the message and locate the offending YAML by hand. The tradeoff is that Conftest's default text output only prints whatever key holds the human-readable string, so teams adopting structured objects generally standardize on a msg key precisely to stay compatible with that default while layering their own tooling on top of the rest of the object. It's the same "AND across fields" pattern from the top of this post, just building a return value instead of a boolean.

Policy-as-code succeeds or fails on exactly this kind of detail — not whether a gate exists, but whether the message it produces tells an engineer what broke and where. Safeguard's own pipeline guardrails, which gate CI/CD, registries, admission, and runtime against SBOM, CVE, license, and signing rules with BLOCK / WARN / AUTO_FIX effects, are built on the same underlying principle: a violation is only useful if it names the specific package, version, or field that triggered it, not just the rule that exists somewhere in a config file.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.