CoursesPolicy-as-code at scaleDocuments, data, and packages

Documents, data, and packages

input, data, and how Rego loads the world.

Advanced30 min · lesson 2 of 13

A policy engine behaves more like a librarian than a robot. You hand it a document, it checks that document against the rules it has memorized and the reference binders on its shelf, and it hands back an answer. Rego, the language that OPA (Open Policy Agent, a general-purpose engine for making yes-or-no decisions about structured data) is programmed in, never logs into your cluster, never calls an API (application programming interface, the endpoint one program uses to talk to another), never runs a script against anything. It answers questions about JSON (JavaScript Object Notation, the nested key-and-value format Kubernetes speaks) trees that somebody else placed in front of it.

Two of those trees carry all the weight. input is the document under review right now. data is everything loaded ahead of time: approved registry prefixes, which team owns which namespace, the exception list and its expiry dates. Packages decide where your rules live inside data, so data.kubernetes.admission.deny and data.terraform.deny never trip over each other. Nearly every baffling Rego bug traces back to one of those three being different in production from what it was on your laptop.

The Form on the Desk and the Binder on the Shelf

A passport officer works from two things: the form you hand over, and the reference binder beside the counter. The form changes every thirty seconds. The binder changes when policy changes. input is the form. data is the binder. A rule that reads the binder as though it were the form, or the other way round, will pass every test you write and then behave like a stranger in production, because in your test you stuffed both into the same object.

terminal
mkdir -p /tmp/pol && cat > /tmp/pol/registry.rego <<'EOF'
package example
deny contains msg if {
input.kind == "Pod"
some c in input.spec.containers
not startswith(c.image, data.allowed.registry)
msg := sprintf("image registry not allowed: %v", [c.image])
}
EOF
echo '{"allowed":{"registry":"ghcr.io/acme/"}}' > /tmp/pol/data.json
echo '{"kind":"Pod","spec":{"containers":[{"image":"docker.io/library/nginx:1.25"}]}}' \
| opa eval -f pretty -I -d /tmp/pol 'data.example.deny'
output
[
"image registry not allowed: docker.io/library/nginx:1.25"
]

Read the flags, because they are the whole lesson in miniature. -I tells opa eval to take standard input and mount it as input. -d tells it to load these files into data. Two separate doors into the engine. The result prints as a JSON array because deny contains msg builds a set, and sets come out as arrays once they are written to JSON. The syntax here (if, contains, some ... in) is OPA 1.x. On an older 0.x binary the same file needs import rego.v1 at the top, and 1.x still accepts that line and ignores it, so writing it costs you nothing and buys you portability.

Where Data Actually Lands

People assume the filename decides the path. It does not. OPA builds the data path out of directory names and throws the file's own name away. A file called registries.json sitting at the root of your load directory merges straight into the top of data. Move it one folder down into registries/ and every key inside it shifts to data.registries.*. Nothing warns you and nothing fails to compile. Query the whole document when you want the truth.

terminal
mkdir -p /tmp/pol/registries && mv /tmp/pol/data.json /tmp/pol/registries/data.json
opa eval -f pretty -d /tmp/pol 'data'
output
{
"example": {
"deny": []
},
"registries": {
"allowed": {
"registry": "ghcr.io/acme/"
}
}
}

Two things to notice. The registry list moved to data.registries.allowed.registry, so data.allowed.registry is now nothing at all. And your rules show up in the same tree as the facts: data.example.deny evaluated to an empty set because there was no input this time. Rules and facts share one namespace, which is exactly why packages exist. Now run the policy again against an image that should be perfectly fine.

terminal
echo '{"kind":"Pod","spec":{"containers":[{"image":"ghcr.io/acme/api:1.4"}]}}' \
| opa eval -f pretty -I -d /tmp/pol 'data.example.deny'
output
[
"image registry not allowed: ghcr.io/acme/api:1.4"
]

An approved image, denied. Nobody edited the rule. Somebody tidied a folder. startswith(c.image, data.allowed.registry) could not resolve its second argument, so the whole expression became undefined, and in Rego not undefined succeeds. The rule went from "block unapproved registries" to "block everything" in one git mv. That is a self-inflicted outage across every namespace the constraint matches, and it lands at deploy time on a Friday afternoon.

Undefined spreads in both directions
Missing data underneath a not turns a targeted rule into a blanket deny. That failure is loud and it stops production, so you find out inside a minute. Missing input fields usually kill the rule body earlier, so the set comes back empty, which reads as "no violations found" and quietly allows everything. The loud failure wakes you up. The quiet one is the security incident nobody opens a ticket for. Every policy needs a fixture that must be denied and a fixture that must pass, and both have to run against the exact document shape the real enforcement point delivers.

One Policy, Three Input Shapes

The same question ("is this image from an approved registry?") gets asked by different tools, and each one hands you a differently wrapped envelope. Gatekeeper nests the object under input.review.object and puts tunable values under input.parameters. A plain OPA validating webhook receives the raw AdmissionReview that the API server sends and sees input.request.object. Conftest, running in CI (continuous integration, the automated checks that gate a merge), hands you the parsed file itself at the top level. Feed the wrong envelope to a working rule and watch what comes back.

terminal
mv /tmp/pol/registries/data.json /tmp/pol/data.json && rmdir /tmp/pol/registries
echo '{"review":{"object":{"kind":"Pod","spec":{"containers":[{"image":"docker.io/library/nginx:1.25"}]}}}}' \
| opa eval -f pretty -I -d /tmp/pol 'data.example.deny'
output
[]

Empty set. No error, no warning, exit code 0. input.kind does not exist in a Gatekeeper-shaped document, the first line of the body failed, and the rule produced nothing. An admission controller (the part of the Kubernetes API server that gets to inspect a write before it is stored) reads "nothing" as "admit it". This is how a team ships a registry allowlist, sees green unit tests, applies the ConstraintTemplate, and runs unprotected for four months until somebody pulls a cryptominer image from a public registry and it sails in. The same trap has two smaller cousins worth knowing. A rule that reads spec.containers never looks at initContainers or ephemeralContainers, so an unapproved image hidden in an init container walks straight past. And a rule written for a Pod finds nothing at all in a Deployment file, where the containers sit at spec.template.spec.containers, which matters the moment you point conftest at a repository full of Deployments.

Who fills in input, and where data comes from
Gatekeeper (admission)
input.review.object
the object the API server is about to write
input.parameters
values supplied by the Constraint resource
data.inventory
cached copies of the kinds you chose to sync
Conftest (pre-merge CI)
input
the parsed YAML or JSON file, unwrapped
-d ./facts
registries, owners, exception list
exit code 1
fails the pipeline before anything ships
OPA server or sidecar
input.request.object
raw AdmissionReview v1 payload
bundle at the data root
pulled over HTTP, pinned by revision
HTTP 200 with a decision
your webhook turns it into allow or deny
One shared helper, three thin adapters. The document shape is the contract, so version it like an API.

Packages Are Addresses, Entry Points Are Contracts

A package name is a street address inside data, and it has nothing to do with where the file sits on disk. Ten files can declare package lib.images and their rules merge into one place. The name you give the top rule is not decoration either. It is a contract with whatever calls you. Gatekeeper looks for violation and expects every element to be an object carrying a msg key. Conftest looks for deny, violation or warn inside whichever namespace you point it at. Rename the entry point and your policy still compiles, still passes its own tests, and enforces nothing whatsoever.

The envelope differs per tool. The security question does not. So put the real logic in a helper that takes the object as an argument, then write a two-line adapter for each place you enforce.

policy/lib/images.rego
package lib.images
# offending returns the set of images in obj that match no allowed prefix.
# Checks init and ephemeral containers too, because attackers read specs.
offending(obj, prefixes) := {img |
some c in all_containers(obj)
img := c.image
not any_prefix(img, prefixes)
}
all_containers(obj) := array.concat(
array.concat(
object.get(obj, ["spec", "containers"], []),
object.get(obj, ["spec", "initContainers"], []),
),
object.get(obj, ["spec", "ephemeralContainers"], []),
)
any_prefix(img, prefixes) if {
some p in prefixes
startswith(img, p)
}
policy/conftest/images.rego
package conftest.images
import data.lib.images
deny contains msg if {
input.kind == "Pod"
some img in images.offending(input, data.registries.prefixes)
msg := sprintf("image %v is not from an approved registry", [img])
}
facts/registries/data.json
{
"prefixes": [
"ghcr.io/acme/",
"registry.acme.internal/"
]
}

Look at where that fact file sits before you run anything. The rule asks for data.registries.prefixes, and the only reason that path exists is the folder named registries. The filename data.json contributes nothing. Put the same content in facts/data.json and the keys land at data.prefixes, the rule reads undefined, and you are back in the failure from two sections ago.

terminal
conftest test /tmp/pod.yaml -p policy/ -d facts/ --namespace conftest.images; echo "exit=$?"
output
FAIL - /tmp/pod.yaml - conftest.images - image docker.io/library/nginx:1.25 is not from an approved registry
1 test, 0 passed, 0 warnings, 1 failure, 0 exceptions
exit=1

That non-zero exit code is the entire value of a CI gate. Notice the two doors again: -p loads policy, -d loads facts into data. Forget -d and data.registries.prefixes is undefined, any_prefix can never succeed, every image drops into the offending set, and the pipeline goes red for the whole organization. Somebody appends || true to the step by lunchtime, and the control is dead with everyone's approval. One more version note: conftest carries its own copy of OPA inside it, so a build older than roughly v0.56 will reject the if and contains keywords unless you add import rego.v1. Run conftest --version before you blame your rule.

Audit, Enforce, and What a Denial Actually Returns

Gatekeeper splits one policy into two objects. The ConstraintTemplate carries the Rego and generates a CRD (custom resource definition, which is how you teach Kubernetes a brand new object type). The Constraint is an instance of that new type: it says which resources to match and supplies the parameters. One template, many constraints, different values per environment.

templates/allowed-registries.yaml
apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata:
name: k8sallowedregistries
spec:
crd:
spec:
names:
kind: K8sAllowedRegistries
validation:
openAPIV3Schema:
type: object
properties:
prefixes:
type: array
items:
type: string
targets:
- target: admission.k8s.gatekeeper.sh
rego: |
package k8sallowedregistries
import rego.v1
violation contains {"msg": msg} if {
some c in input.review.object.spec.containers
not allowed(c.image)
msg := sprintf("image %v is not from an approved registry (allowed prefixes: %v)", [c.image, concat(", ", input.parameters.prefixes)])
}
allowed(image) if {
some p in input.parameters.prefixes
startswith(image, p)
}

That rego: block is compiled by the copy of OPA baked into the Gatekeeper image, which trails the standalone binary you have on your laptop. Gatekeeper 3.15 and newer accept import rego.v1, so the if and contains keywords work. Older releases need the pre-1.0 shape, violation[{"msg": msg}] { ... }, with no if anywhere. Check what the cluster is actually running with kubectl -n gatekeeper-system get deploy gatekeeper-controller-manager -o jsonpath='{.spec.template.spec.containers[0].image}' before you paste a template in and wonder why it will not compile.

constraints/prod-registries.yaml
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sAllowedRegistries
metadata:
name: prod-registries
spec:
enforcementAction: dryrun
match:
kinds:
- apiGroups: [""]
kinds: ["Pod"]
namespaces: ["payments"]
parameters:
prefixes:
- "ghcr.io/acme/"
- "registry.acme.internal/"

enforcementAction: dryrun means admission always says yes. Nothing is blocked, nothing comes back to the person running kubectl, and the only trace is the audit loop writing findings onto the Constraint's status roughly every sixty seconds. Start here every single time, because audit hands you the blast radius before you own the pager for it.

terminal
kubectl get k8sallowedregistries prod-registries -o json \
| jq '{total: .status.totalViolations, listed: (.status.violations | length), first: .status.violations[0]}'
output
{
"total": 41,
"listed": 20,
"first": {
"enforcementAction": "dryrun",
"group": "",
"kind": "Pod",
"message": "image docker.io/library/nginx:1.25 is not from an approved registry (allowed prefixes: ghcr.io/acme/, registry.acme.internal/)",
"name": "checkout-7c9f8b6d4-2xk9p",
"namespace": "payments",
"version": "v1"
}
}

Forty-one workloads would break if you flipped this today. That number is your migration plan. The listed count stops at 20 because Gatekeeper caps how many violations it writes into the status object, controlled by --constraint-violations-limit and set to 20 by default, so the resource does not grow without limit. Trust totalViolations for the size of the problem and the audit pod's logs for the full list. Fix or exempt the offenders, then change one field and try the same Pod again.

terminal
kubectl patch k8sallowedregistries prod-registries --type merge \
-p '{"spec":{"enforcementAction":"deny"}}'
kubectl -n payments apply -f /tmp/pod.yaml
output
k8sallowedregistries.constraints.gatekeeper.sh/prod-registries patched
Error from server (Forbidden): error when creating "/tmp/pod.yaml": admission webhook "validation.gatekeeper.sh" denied the request: [prod-registries] image docker.io/library/nginx:1.25 is not from an approved registry (allowed prefixes: ghcr.io/acme/, registry.acme.internal/)

Look closely at what came back, because people describe this wrongly in incident reviews. It is an HTTP 403 from the API server, printed by kubectl as Forbidden. Gatekeeper's webhook (a small HTTPS service the API server calls out to before storing an object) returned an admission response with allowed: false plus a status message, and Gatekeeper stamped the name of the Constraint that produced it in front of that message. The bracketed [prod-registries] is the best debugging gift in the system: it tells the developer exactly which object to go and read. Nothing was deleted and nothing was rolled back, because the write never happened. There is a middle setting too. enforcementAction: warn admits the object and returns the same sentence as a client-side warning, which is how you let people feel a policy before it bites them. Pods already running are untouched by all three settings, because admission only ever inspects writes to the API. Those 41 offenders keep running happily until something recreates them, and that is the moment they stop.

Pulling Cluster State Into data

Some questions need a second document on the desk. "Does this Ingress hostname clash with one in another namespace?" cannot be answered from the object under review by itself. Gatekeeper can hold a cached copy of chosen kinds and expose them at data.inventory.namespace[ns][groupVersion][kind][name] for namespaced objects, and data.inventory.cluster[groupVersion][kind][name] for cluster-scoped ones. You opt in one kind at a time with a Config resource.

gatekeeper/config.yaml
apiVersion: config.gatekeeper.sh/v1alpha1
kind: Config
metadata:
name: config
namespace: gatekeeper-system
spec:
sync:
syncOnly:
- group: ""
version: "v1"
kind: "Namespace"
- group: "networking.k8s.io"
version: "v1"
kind: "Ingress"
The inventory is a cache, not the cluster
Two Ingresses claiming the same hostname, created in the same second, can both pass a uniqueness check, because neither one is in the cache when the other is evaluated. Treat inventory rules as strong hygiene and never as a hard guarantee. Keep syncOnly short as well: syncing Secrets or every ConfigMap in a large cluster inflates Gatekeeper's memory, adds latency to every admission call your API server makes, and copies data you probably did not want replicated into a policy pod's heap where a different set of people can read it.

Bundles Pin the Whole World

Rego plus the data files it depends on is a single artifact, and OPA can package it the way you package a container image. opa build produces a gzipped tar file with a manifest inside that records a revision string. Point OPA at a bundle server, let it poll, and every decision the cluster makes is traceable to one version of both the rules and the facts.

terminal
opa build -o bundle.tar.gz -r "$(git rev-parse --short HEAD)" policy/ facts/
opa inspect -f json bundle.tar.gz | jq '.manifest'
output
{
"revision": "9f31c0a",
"roots": [
""
]
}

Note that there is no -b on that build. --bundle is an on/off flag meaning "the paths I am giving you are already bundles", and policy/ and facts/ are ordinary directories, so passing it would be wrong. The revision is what makes "but it passed CI" an answerable claim. If the pipeline evaluated bundle 9f31c0a and the cluster is still serving 4b70de2 because its download has been failing for six days, the two disagree and both are behaving exactly as designed. Log the revision alongside every decision and alarm on staleness, which OPA will tell you about through its status plugin and its /health?bundles=true endpoint. Sign bundles with opa build --signing-key and, this is the half everyone forgets, make the OPA that consumes them verify with --verification-key or the matching signing block in its service configuration. A signature nobody checks is decoration. An unsigned, unversioned policy store is a control surface an attacker can edit far more quietly than your cluster.

Try This

Take the registry rule from the top of this lesson and give it a second life. Save one Pod fixture that must be denied and one that must pass. Run both through opa eval -I -d /tmp/pol and confirm you get one message and then []. Now move data.json into facts/data.json without touching a character of the rule, and run the good fixture again. Watch a clean Pod get denied, and sit for a moment with how invisible that failure is from the rule's source code. Then wrap both fixtures in {"review":{"object": ... }} and watch the opposite failure, where the bad Pod comes back as []. Two commands, two directions of wrong, and a solid reason to keep golden fixtures for every enforcement point you run. Next you will turn those fixtures into opa test cases so the build fails instead of production.

Quick check
01A Gatekeeper ConstraintTemplate needs to read the Pod that is being created. Which path holds it?
Incorrect — That is the raw AdmissionReview shape a plain OPA validating webhook receives. Gatekeeper repackages the review before evaluating your Rego.
Correct — Gatekeeper places the object under review at input.review.object, alongside input.review.operation, input.review.userInfo and input.review.oldObject.
Incorrect — That holds cached copies of other synced objects, which is useful for cross-object checks, but it never contains the object currently being admitted.
Incorrect — input.parameters carries the values supplied by the Constraint resource, such as your allowed prefix list. The resource being admitted is not in there.
02Your bundle download fails, so data.allowed.registry is missing. The rule body contains not startswith(c.image, data.allowed.registry). What happens at admission time?
Correct — startswith cannot resolve its second argument, so the expression is undefined, and negating undefined succeeds, which makes the rule fire on every image including approved ones.
Incorrect — OPA has no fail-open behaviour for missing data. A missing path is undefined, and what undefined does depends entirely on where it sits in the rule body.
Incorrect — A missing data path is not a runtime error, so nothing returns 500. failurePolicy only comes into play when the webhook itself is unreachable, times out, or errors.
Incorrect — Rego never fills in default values for missing references. There is no implicit empty string, only undefined, unless you wrote a default rule yourself.
03Audit reports 41 violations for the Constraint prod-registries, which has enforcementAction: dryrun. Applying a non-compliant Pod still succeeds with no message. You want new non-compliant Pods rejected while the 41 already running stay up. What do you change?
Incorrect — That widens the blast radius to every namespace in the cluster and still blocks nothing, because dryrun admits regardless of how many objects it matches.
Incorrect — warn returns the message to the client as a warning but still admits the object, so non-compliant Pods keep being created. It is a good step before deny, not a substitute for it.
Correct — Admission only inspects writes, so new and updated Pods get a 403 Forbidden while the 41 already running are untouched until something recreates them.
Incorrect — failurePolicy governs what the API server does when the webhook is unavailable or times out. It has no bearing on how a matched violation is enforced.

Takeaway

The trap worth remembering here: undefined spreads in both directions. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related