Policy-as-code gates

Kyverno & OPA enforce what must be true.

Advanced12 min · lesson 14 of 18

A wiki page that says 'only run images from our registry' is a note taped to the break-room wall. Everyone means to follow it. Someone, on a bad Friday, won't. Policy-as-code turns that note into a bouncer at the door holding the guest list, checking every single person who tries to walk in, every time, with no memory of who you are and no patience for your excuses. The rule stops being a suggestion and becomes something the system does on its own.

Signature verification, which you saw earlier in this section, is one such rule: reject any image that your build pipeline didn't sign. Policy-as-code is the general machine behind it. You write security requirements as files, keep them in Git next to your application code, review changes to them in pull requests, and a policy engine enforces them automatically. Two engines dominate, and picking between them is the first real decision.

Two engines, one idea

Kyverno is Kubernetes-native. You write policies as Kubernetes YAML (the indentation-based configuration format Kubernetes already speaks), so if you can read a Deployment manifest you can read a Kyverno policy. OPA (Open Policy Agent, a general-purpose policy engine) uses its own language, Rego, and its Kubernetes packaging is called Gatekeeper. Rego is more powerful and reaches far past Kubernetes: you can gate Terraform plans, raw API payloads, CI configs, almost any JSON. The cost is that Rego is a language you have to learn. Choose Kyverno when your policies live mostly in Kubernetes. Reach for OPA when you want one policy language across many systems.

/policies/restrict-registries.yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: restrict-registries
spec:
validationFailureAction: Enforce
rules:
- name: only-internal-registry
match:
any:
- resources:
kinds: [Pod]
validate:
message: "images must come from registry.acme.internal"
pattern:
spec:
containers:
- image: "registry.acme.internal/*"

validationFailureAction: Enforce is the switch that matters. On Enforce, a Pod (one or more containers scheduled together, the smallest thing Kubernetes runs) that breaks the rule is rejected outright. On Audit, Kyverno records the violation but lets the Pod run, which is how you introduce a policy without breaking anyone: watch what it would block, then turn it on. (Recent Kyverno also lets you set this per rule with validate.failureAction; the spec-level field shown here still works and reads more simply.) One detail worth knowing: Kyverno automatically generates matching rules for anything that creates Pods (Deployments, Jobs, StatefulSets), so you write the rule once against Pod and it covers all of them. That's why the failures below name an autogen- rule.

Two gates, not one

Run policy at two points, and be clear about why each exists. The first is in CI (continuous integration, the pipeline that checks every change before it merges). Tools like Conftest (which runs OPA/Rego) or the Kyverno command-line tool evaluate manifests before anything deploys. This is the polite front-desk guard: fast feedback, right in the pull request, telling a developer 'you pointed at docker.io again' while the fix costs thirty seconds. Shift-left, in the jargon.

The second gate is at admission (admission control, the checkpoint Kubernetes runs on every create or update before the object is written to the cluster). Here the same policy runs as a webhook, a callout where the Kubernetes API server (the single front door every change to the cluster passes through) phones the policy engine and asks 'allow or deny?'. This is the locked door. It doesn't care how you arrived. Anything that skipped CI, got applied straight to the cluster with kubectl, or came out of a pipeline an attacker had already compromised still walks into it.

CI is the gate developers see and cooperate with. Admission is the gate that actually holds. Run both from the same rule set so what CI warns about is exactly what the cluster enforces. A rule that only lives in CI is advice an attacker ignores. A rule that only lives at admission is a surprise your developers can't debug until deploy time.

How a manifest reaches (or doesn't reach) the cluster
1Author writes manifest
image, registry, securityContext
2CI gate
conftest / kyverno CLI on the PR
3Merge and deploy
kubectl apply or a Git-driven sync
4Admission gate
Kyverno webhook: allow or deny
5Workload runs
only if the gate allowed it
Apply straight to the cluster and you skip CI, but admission still catches you, unless the webhook is exempt or fails open.

Here's the front-desk guard in action. A small Rego policy, then Conftest running it against a manifest that points at the wrong registry.

/policies/registry.rego
package main
# Conftest collects every message under `deny` as a violation.
deny contains msg if {
input.kind == "Deployment"
some container in input.spec.template.spec.containers
not startswith(container.image, "registry.acme.internal/")
msg := sprintf("image %q must come from registry.acme.internal", [container.image])
}
terminal
$ conftest test k8s/deploy.yaml --policy policies/
output
FAIL - k8s/deploy.yaml - main - image "docker.io/library/nginx:1.27" must come from registry.acme.internal
1 test, 0 passed, 0 warnings, 1 failure, 0 exceptions

Conftest exits non-zero on a failure, which fails the CI job and blocks the merge. The Kyverno command-line tool does the same job for Kyverno policies, and because it's the same engine the cluster runs, its verdict matches what admission will decide.

terminal
$ kyverno apply policies/ --resource k8s/deploy.yaml
output
Applying 1 policy rule(s) to 1 resource(s)...
policy restrict-registries -> resource default/Deployment/web failed:
1. autogen-only-internal-registry: validation error: images must come from registry.acme.internal. rule autogen-only-internal-registry failed at path /spec/template/spec/containers/0/image/
pass: 0, fail: 1, warn: 0, error: 0, skip: 0

How the locked door looks from both sides

Now the wall. Someone (a rushed engineer, an automated job, an attacker holding cluster credentials) applies that same Deployment straight to the cluster, skipping the pipeline entirely.

terminal
$ kubectl apply -f k8s/deploy.yaml
output
Error from server: error when creating "k8s/deploy.yaml": admission webhook "validate.kyverno.svc-fail" denied the request:
resource Deployment/default/web was blocked due to the following policies
restrict-registries:
autogen-only-internal-registry: 'validation error: images must come from
registry.acme.internal. rule autogen-only-internal-registry failed at path
/spec/template/spec/containers/0/image/'

That rejection is two things at once. It's the defender's win: an off-registry workload never started, and Kyverno records the denial as a Kubernetes event (and in the API server's audit log, if you run one), naming the exact policy and field path that blocked it. It's also your test. The only real proof a gate works is watching it reject something real. That's why the next thing to check is whether the gate is actually live.

terminal
$ kubectl get clusterpolicy restrict-registries
output
NAME ADMISSION BACKGROUND READY AGE MESSAGE
restrict-registries true true True 9d Ready

READY: True means the webhook is registered and the policy is enforcing right now. ADMISSION: true confirms it runs at admission, not only in background scans. If a policy ever shows READY: False, it is installed but guarding nothing, which is the most dangerous state of all: it shows up in kubectl get and enforces zero rules, so it looks like coverage while an attacker walks past it.

Kyverno also evaluates everything already running and writes the results to policy reports. That turns the same rule set into an audit of your live cluster.

terminal
$ kubectl get policyreport -A
output
NAMESPACE NAME PASS FAIL WARN ERROR SKIP AGE
default 3f9c1a2e-7b4d-4e10-9a6c-2d8e5f0b1c34 6 1 0 0 0 9d
default 7d1b8e04-2c6a-4f19-b3e8-5a9c1d0f7b23 7 0 0 0 0 9d
default c4e7a9b3-1d52-4f80-a6c9-3b8e1f4d7a05 6 1 0 0 0 9d
payments a71b0d5c-2e93-4c88-8f21-6b4a9c0e7d12 7 0 0 0 0 9d

Each row is one workload, named by its resource ID and scored against every policy at once. The two rows in default with a 1 in the FAIL column are your remediation list: two workloads pulling images from outside your registry right now. Roll a new policy out in Audit, read these reports, fix the fails, then flip to Enforce. You never surprise a running service with a rule it was already breaking, and you never turn a security control into an outage.

What a mature policy set encodes

One rule closes one hole. A supply-chain-aware policy set closes the whole chain, written once and enforced everywhere: images must be signed by your pipeline's identity and carry provenance (a signed record of how and where they were built); images may come only from your registry; no :latest tags, so every deploy names an exact version you can trace to a commit; workloads must run non-root and drop Linux capabilities (the fine-grained kernel privileges a process can hold) down to none; every Pod mounts a read-only root filesystem, so a compromised process can't rewrite its own binary; required labels and CPU and memory limits must be present. The non-root and dropped-capabilities rules are the Kubernetes 'restricted' Pod Security Standard; the read-only filesystem, the labels, and the limits are hardening you bolt on beside it. The signature rule is the one that ties this course together.

/policies/verify-signatures.yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: verify-image-signatures
spec:
validationFailureAction: Enforce
webhookTimeoutSeconds: 30
rules:
- name: check-cosign-keyless
match:
any:
- resources:
kinds: [Pod]
verifyImages:
- imageReferences:
- "registry.acme.internal/*"
attestors:
- count: 1
entries:
- keyless:
subject: "https://gitlab.acme.internal/acme/*"
issuer: "https://gitlab.acme.internal"
rekor:
url: "https://rekor.sigstore.dev"

This is keyless verification. Instead of a long-lived signing key someone can steal, Kyverno checks that the image was signed by an identity matching your pipeline: the subject is the pipeline's OIDC token (OpenID Connect, the short-lived identity token your CI system presents when it signs), the issuer is who vouched for that token, and Rekor (the public transparency log that permanently records signatures) provides the receipt. An image signed by anyone else, or pulled from anywhere else, fails the check and never admits. The registry rule and the signature rule together mean an attacker needs both your registry and your pipeline's identity to land a workload, not one or the other.

A webhook that fails open is a gate that isn't there
The API server has to decide what to do when the policy webhook is unreachable (Kyverno crashed, the node was drained, someone scaled it to zero). That choice is the webhook's failurePolicy. Set to Ignore (fail-open), admission requests sail through unchecked whenever the engine is down, so an outage, or an attacker who can disrupt the Kyverno pods, becomes a window where unsigned, off-registry images deploy freely. Set to Fail (fail-closed), a down engine blocks all deploys, which is safer but can wedge the cluster. Kyverno ships both webhooks (validate.kyverno.svc-fail and validate.kyverno.svc-ignore) and routes each policy by its failurePolicy. Know which one guards your signature and registry rules, and make those Fail.

One more failure mode, quieter than a crash. Policy-as-code rots two ways. Broad exemptions creep ('exclude kube-system' turns into 'exclude everything that complained'), and stale rules sit untouched while the threats they targeted change shape. Keep exemptions narrow, explicit, and reviewed. Treat the policy repo as security-critical code, with named owners and change control on every merge. Run kubectl get policyexception -A on a schedule and actually read what it excludes. A gate everyone trusts, but that quietly exempts the workloads that matter, looks like coverage and buys you nothing but false confidence.

Quick check
01Your restrict-registries policy passes in CI on every pull request, yet a Pod running docker.io/library/nginx:1.27 is live in production. What best explains it?
Incorrect — That step runs the real policy engine. In this lesson it fails the deploy with autogen-only-internal-registry and names the offending image path.
Correct — A pipeline check only sees what travels through the pipeline. Anything applied directly meets admission instead, and an exempted or fail open gate lets it in.
Incorrect — Both run the same engine over the same rules on purpose, so a passing pipeline and a passing admission check tell you the same thing.
Incorrect — Background scanning writes policy reports that give you a remediation list. It scores what is already running, it does not stop or delete it.
02Your platform team wants one rule set covering Kubernetes manifests, Terraform plans, and CI config files. Which engine fits, and for what reason?
Incorrect — A Kyverno rule matches Kubernetes kinds, the way restrict-registries matches Pod. A Terraform plan is not a kind, so there is nothing for it to bind to.
Incorrect — That familiarity is the genuine case for Kyverno, and it wins while the policies stay inside Kubernetes. It does nothing for a Terraform plan.
Incorrect — You still write Kubernetes YAML either way, and Rego arrives on top of it. The lesson counts that as the price of OPA, not a saving.
Correct — Reaching beyond the cluster is the whole argument for OPA. Gatekeeper is simply how that engine gets packaged for Kubernetes.
03kubectl get clusterpolicy shows your signature policy with ADMISSION true, BACKGROUND true, READY False. A teammate says it is listed, so coverage is fine. What is really going on?
Correct — This is the worst state the lesson names, because every listing and every audit says the control is there while an attacker strolls past it.
Incorrect — BACKGROUND is its own column and reads true here. READY answers a different question: is the webhook registered and enforcing right now.
Incorrect — A denial surfaces as a Kubernetes event naming the policy and the field path that blocked it. It leaves the policy running and never touches this column.
Incorrect — ADMISSION tells you where the rule would run. READY tells you whether it runs at all, and False means your signature gate is down.

Keep a known-bad manifest in your policy repo: wrong registry, root user, unsigned image. Apply it against a throwaway kind cluster (Kubernetes-in-Docker, a real cluster whose nodes run as Docker containers) on every change to your policies, and assert that it gets rejected. The day that manifest is admitted, your gate is down, and you will hear it from your own pipeline instead of from an incident.

Try this

Run conftest test k8s/deploy.yaml --policy policies/ on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.

Takeaway

The trap worth remembering here: a webhook that fails open is a gate that isn't there. 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