Deny-by-default patterns that scale
Partial sets, helpers, and reusable libraries.
In short. At scale, the only policy posture that survives contact with production is deny-by-default for the controls you care about: privileged pods, public exposures, unapproved registries, missing labels.
At scale, the only policy posture that survives contact with production is deny-by-default for the controls you care about: privileged pods, public exposures, unapproved registries, missing labels. Allow lists beat endless deny special cases. Partial sets in Rego (deny contains msg if { ... }) collect every violation message instead of short-circuiting on the first.
Copy-pasting the same startswith image check into twenty templates is how drift is born. Build a small helper library package and import it. Keep messages actionable: what failed and what good looks like.
Partial sets and helpers
package lib.podis_privileged(container) if {container.securityContext.privileged == true}unapproved_image(image, registry) if {not startswith(image, registry)}
printf '%s\n' 'package kubernetes.admission' 'import data.lib.pod' 'deny contains msg if {' ' some c in input.spec.containers' ' lib.pod.is_privileged(c)' ' msg := sprintf("privileged container %v", [c.name])' '}' > /tmp/priv.regoecho '{"spec":{"containers":[{"name":"x","securityContext":{"privileged":true}}]}}' \| opa eval -f raw -I -d /tmp/priv.rego -d /tmp/lib 'count(data.kubernetes.admission.deny)'
1
Message quality
deny without context trains developers to bypass. Include the field, the forbidden value, and the allowed pattern. Prefer one clear message per violation object so UIs and CI logs stay readable.
Going deeper
Prefer allowlists for registries, ingress classes, and privileged capabilities. Denylist catalogs go stale the day a new dangerous API lands. When you must denylist, pair with an allowlist default for the same field.
Structured errors beat strings alone. Some engines support denial reason objects — use them so dashboards can group by rule ID. Stable rule IDs also make exceptions precise.
Review helpers like any shared library: breaking change = major bundle bump. Semantic version your policy libraries and note breaking changes in CHANGELOG for app teams.
Keep deny messages under a readable length and free of internal jargon. Link to an internal doc URL for the standard. The message is part of the developer experience of your platform.
Fuzz helpers with unexpected types (string where you expected object). Rego's undefined behavior is a common source of silent allows — tests must include malformed fixtures.
Share helper packages across Gatekeeper templates and Conftest by keeping Rego in one repo path imported both ways. Duplicating helpers into ConstraintTemplate strings guarantees drift within a quarter.
Benchmark pathological inputs occasionally — a crafted Pod with thousands of containers can turn a careless comprehension into an admission timeout. Set engine resource limits and write policies that short-circuit.
Try this
Refactor a duplicated image-registry check into a helper and prove both a bad and good Pod fixture.
opa test ./policy -v
PASS: 6/6
Takeaway
Deny-by-default with partial sets, shared helpers, and actionable messages is how Rego stays maintainable past a handful of rules.
Next: choose Kyverno vs Gatekeeper with eyes open.