CoursesOPA & RegoDeny/allow patterns

Deny/allow patterns

The idioms real policies use.

Advanced14 min · lesson 5 of 12

A building inspector walks every room with a clipboard, writes down each problem, and hands the list to the contractor. An empty list means the place passed. Most Rego validation policies have exactly that shape. Rego is the language OPA (Open Policy Agent) reads, and real policies settle into a handful of shapes. Choosing the right one is most of the design work.

Validation collects violations into a set called deny, one message per problem. Empty set, pass. Authorization runs the other way round: you set default allow := false and then write allow if rules that hand out permission. Nothing matches, so the answer is no. Mix the two shapes up and you either block every deploy or quietly permit the exact thing you meant to stop.

The deny set for validation

Conftest (a command line tool that runs Rego against config files) and Gatekeeper (the admission controller that runs OPA inside a Kubernetes cluster, checking objects before they are created) both want a validation policy to hand back a set of violations. Gatekeeper reads a rule named violation. Conftest reads deny and warn. Inside the rule, iterate with [_] so you check every container, every label, every resource instead of only the first one you meet. An empty deny means compliant. A non-empty deny fails the CI (continuous integration) run or rejects the admission request. Make the messages actionable: name the resource, say what to change.

terminal
cat > main.rego <<'EOF'
package main
deny contains msg if {
input.kind == "Service"
input.spec.type == "LoadBalancer"
not input.metadata.annotations["corp.io/public-approved"]
msg := sprintf("Service %v is public LB without approval", [input.metadata.name])
}
deny contains msg if {
c := input.spec.containers[_]
c.securityContext.privileged == true
msg := sprintf("container %v is privileged", [c.name])
}
EOF
output
# validation policy — deny set idiom
terminal
echo '{"kind":"Service","metadata":{"name":"web"},"spec":{"type":"LoadBalancer"}}' > svc.json
opa eval -d main.rego -i svc.json --format pretty 'data.main.deny'
output
["Service web is public LB without approval"]

Deny-by-default authorization

Authorization (authz for short, deciding whether a caller may do a thing) flips the posture. default allow := false is your standing answer when nothing matched, and every allow if { ... } rule is an explicit grant sitting on top of it. With the default in place, input that matches no rule comes back as false, a real value a caller can compare against. Take the default away and it comes back undefined instead, and undefined travels straight out to whoever asked. A caller that fails open reads that silence as yes.

terminal
cat > authz.rego <<'EOF'
package authz
default allow := false
allow if {
input.user.roles[_] == "editor"
input.action == "write"
startswith(input.resource, "docs/")
}
allow if input.user.roles[_] == "admin"
EOF
echo '{"user":{"roles":["viewer"]},"action":"write","resource":"docs/x"}' > req.json
opa eval -d authz.rego -i req.json --format pretty 'data.authz.allow'
output
false

Undefined vs false at the integration boundary

Remove the default and the same request stops answering. opa eval prints nothing at all, not false. Over the Decision API (the HTTP endpoint OPA exposes so other services can ask it questions), undefined comes back as status 200 with a body of {} and no result key. Client code shaped like if (response.result !== false) allow() looks at undefined, decides it is not false, and waves the request through. So put a default on every boolean decision, and teach your HTTP clients, sidecars and gateways to read a missing result as a deny.

terminal
cat > no_default.rego <<'EOF'
package bad
allow if input.user == "alice"
EOF
echo '{"user":"bob"}' > req.json
opa eval -d no_default.rego -i req.json --format pretty 'data.bad.allow'
echo "exit: $?"
output
# empty — undefined
exit: 0
terminal
opa run --server no_default.rego &
sleep 1
curl -s localhost:8181/v1/data/bad/allow -d '{"input":{"user":"bob"}}'
output
{}
# HTTP 200, no "result" key — naive client may treat as allow
Pick the policy shape
validation (Conftest, Gatekeeper audit)
deny contains msg
empty set = pass
warn contains msg
advisory only in Conftest
authorization (Decision API, sidecar)
default allow := false
deny-by-default
allow if { ... }
explicit grants only
Validation piles up violations. Authorization starts at no and grants explicitly.

Structured decisions that say why

A rejection slip that says only "no" sends the reader off to guess. Return an object instead: allowed, plus reasons, plus a remediation hint, so the caller can print something a person can act on. Gatekeeper's violation output is already structured JSON (JavaScript Object Notation). In your own API a decision document reads far better in a user interface than a bare false. The safety property does not change: allowed still defaults to false.

terminal
cat > decision.rego <<'EOF'
package api
default decision := {"allowed": false, "reasons": []}
decision := {"allowed": true, "reasons": []} if {
input.user == "alice"
}
EOF
opa eval -d decision.rego -i '{"user":"bob"}' --format pretty 'data.api.decision'
output
{"allowed": false, "reasons": []}

Gatekeeper uses violation, not deny

Same logic, different name on the door. A Gatekeeper ConstraintTemplate (the object that packages your Rego for the cluster) expects violation[{"msg": msg}] rules. Conftest expects deny contains msg. When you port a rule between the two, keep the body identical and change only the head. Or keep one shared package holding the real logic and write a thin wrapper for each surface.

Default deny, or a gap turns into a yes
For authorization, always write default allow := false and grant access explicitly. Skip the default and unmatched input comes back undefined, and any integration that treats "not true" as permission fails open without making a sound. The same trap sits in how your services read Decision API responses: a missing result is not a denial unless your code says it is. Validation is the opposite case. An empty deny set is how a file passes, so never add default deny := true, which would mark everything as a violation.

Keep the two shapes in separate packages behind separate query paths: data.main.deny for validation, data.authz.allow for authorization. One path serving both shapes confuses callers and makes the tests hard to read.

not allow is not allow == false

not allow succeeds in two very different situations: allow is false, or allow was never defined at all. The expression cannot tell those apart. A test that checks only not allow will pass happily against a policy that fails open in production. Check allow == false instead, and give the rule a default allow := false so that false is a value that actually exists.

terminal
opa eval -d no_default.rego -i req.json --format pretty 'data.bad.allow == false'
output
# undefined — empty output, not true

A good deny message lets an operator fix a red pipeline without opening a single .rego file. Name the resource, name the field path, say what to set: "set resources.limits.memory on container app." Gatekeeper prints msg verbatim in the kubectl error. Conftest prints it next to the filename. You write that sentence once and every team that ever trips the rule reads it.

In a service mesh, or anywhere you run mutual TLS (Transport Layer Security where both sides of the connection prove who they are with certificates), allow rules usually pull a SPIFFE ID (Secure Production Identity Framework For Everyone, a standard URI-style name for a workload) out of input while data holds the map of which service may talk to which. Deny-by-default earns its keep twice over here, because a missing peer identity would otherwise sail through the sidecar check as undefined.

warn rules in Conftest

warn contains msg is deny's quieter twin. Same shape, but Conftest still exits zero unless you pass --fail-on-warn. It is the right tool mid-migration: "this label becomes mandatory next quarter." Put the promotion date on it the day you write it. A warn with no sunset turns into permanent theatre, the same way a Gatekeeper dryrun constraint left on long after onboarding stops meaning anything to anyone.

Kubernetes ships its own PodSecurity admission based on the PSS (Pod Security Standards), and it already covers a chunk of what people reach for Gatekeeper to do. Teams keep OPA for the rules PSS has no vocabulary for: approved image registries, cost centre labels, required network annotations. Encode those house standards in your deny sets and leave the PSS baseline alone, unless you need a custom message or a stricter threshold.

API gateways such as Envoy ext_authz and the Kong OPA plugin ask an allow rule about every single request, so anything slow lands on real user traffic. Keep authorization packages small and boring. Work that has to walk a big array belongs in Conftest at build time, not in the request path.

Write down the query path each integration uses, in a table in the policy repository README: "Service X posts to /v1/data/authz/allow, Service Y posts to /v1/data/rbac/decision." Nobody on call should be grepping Rego to work out which rule said no.

Agree the severity ladder up front: block the deploy, block the merge with a Conftest deny, or only raise an alert with warn or a dryrun constraint. Every rule gets exactly one row on that ladder in the policy README. Then nobody argues about what a rule was meant to do while the incident is still open.

Moving authorization out of inline Java or Python and into OPA goes best with a parity run. Feed the old code and the new policy the same shared CSV (comma-separated values) file of inputs, compare the answers, and keep fixing until the disagreement count is zero. Only then flip the integration flag.

Rehearse the failure questions as a tabletop exercise before any of this reaches production. If the API response is undefined, does the service deny? If deny is empty, does Conftest exit zero? With a dryrun constraint in place, does kubectl still create the object? You want those three answers written down, not discovered at 2am.

Have each service count its authorization decisions and label them true, false and undefined. The third counter is the interesting one. A spike in undefined right after a deploy is a broken integration or a policy regression, never organic traffic.

Give the policy README a column for CI enforcement (Conftest) and a column for runtime enforcement (Gatekeeper or the Decision API), and fill both in for every deny rule. Gaps hide well in prose. A rule whose runtime column has said "planned" for three quarters is not a control.

Tag each deny rule with the control it satisfies in a metadata comment, for example SOC 2 CC6.1 or PCI DSS 1.2.1. Auditors ask to be shown the rule that enforces a control. They do not ask to read Rego.

Incident response

When a policy blocks a production deploy, roll the bundle back to the previous tag first and argue about it afterwards. If the rule change was intentional and the deploy was the wrong one, fix forward: add the exemption through a data path, file the ticket, move on. Switching Gatekeeper off across the whole cluster is not a fix, and on the rare day you do it, it gets an expiry time attached before you walk away from the keyboard.

Keep the allow/deny matrix as a spreadsheet generated from your exported test cases. Compliance can sign off on the matrix because they can read it. Engineers write the Rego. The tests prove the two still agree.

When an incident opens with "how did that pod ever get scheduled," your deny-set fixtures are the paper trail. Save the exact bad JSON that slipped through as a permanent test case. That file is proof the rule exists because something real broke, not because a checklist asked for it.

Try this

Write a deny contains rule for a privileged container and query the set. Piling up readable messages is the shape Conftest and your pipeline expect, and it behaves nothing like a single allow boolean.

terminal
cat > main.rego <<'EOF'
package main
import rego.v1
deny contains msg if {
input.kind == "Deployment"
some c in input.spec.template.spec.containers
c.securityContext.privileged == true
msg := sprintf("container %v is privileged", [c.name])
}
EOF
echo '{"kind":"Deployment","spec":{"template":{"spec":{"containers":[{"name":"app","securityContext":{"privileged":true}}]}}}}' > bad.json
opa eval -d main.rego -i bad.json --format pretty 'data.main.deny'
output
{"container app is privileged"}

Takeaway

Deny sets for validation, default-deny allow for authorization. Undefined stops being harmless the moment the answer leaves OPA, so default your booleans and make every client treat a missing result as a denial.

Gatekeeper says violation, Conftest says deny. One idea wearing two names, so share the rule body and change only the head.

Quick check
01Which shape do Conftest and Gatekeeper expect from a validation policy?
Correct — You iterate over the bad conditions and pile up messages a human can read.
Incorrect — That waves everything through without checking anything.
Incorrect — Rego hands values back through rules, not through printing.
Incorrect — Rego gathers every violation into one set instead of stopping at the first.
02For an authorization decision, what should you write?
Incorrect — Input that matches nothing comes back undefined, and fail-open callers read that as yes.
Correct — Anything you did not think of lands on deny instead of on an accidental permit.
Incorrect — That permits whatever you forgot to deny, which is the wrong posture for authz.
Incorrect — Authorization grants with allow; deny sets belong to validation.
03The Decision API answers HTTP 200 with a body of {}. What happened?
Incorrect — A defined denial would come back as {"result": false}.
Incorrect — A crash shows up as a 5xx or a dead connection.
Correct — An empty body with no result key is exactly the undefined case.
Incorrect — An allow would come back as {"result": true}.

Related