DEV Community

Cover image for Policy-as-Code with Kyverno: Balance in Admission Control
Mustafa ERBAY
Mustafa ERBAY

Posted on Originally published at mustafaerbay.com.tr

Policy-as-Code with Kyverno: Balance in Admission Control

The policy-as-code pitch usually starts like this: you write the rules in YAML, commit them to git, and from then on the cluster defends itself. It sounds like a free win. It isn't. When you write an admission policy, what you are actually doing is placing a synchronously executing component in front of every write request to your cluster — and when that component slows down, crashes, or matches the wrong thing, the bill is paid not by the security team but by the on-call engineer whose deploy won't go through at 3 a.m.

This post is not a list of "which policies you should write." It is about the balance involved in operating a policy engine as a production dependency: what you deny versus what you merely report, what the cluster does when the engine is down, and whether — in the second half of 2026 — this job belongs to Kyverno or to Kubernetes' own engine.

My Own Post Went Stale in Four Months

I have to open with an honest confession. On 16 April 2026 I published a post on this blog about phased hardening with PSA and Kyverno. Every policy example in it was written with apiVersion: kyverno.io/v1, kind: ClusterPolicy, and validationFailureAction: Audit. It worked that day — but if I'm honest, I was already late.

The legacy-type schedule in Kyverno's policy types documentation reads: v1.17 (Feb 2026) "marked for deprecation," v1.18 (Apr 2026) "critical fixes only," v1.19 (Aug 2026) "officially deprecated — final release with full support," v1.20 (estimated Nov 2026) "removed." So when I wrote that post, ClusterPolicy had already been marked, and the CEL-based alternative had been around for over a year. I simply hadn't noticed. With 1.19 on 20 August 2026 the countdown became concrete: ClusterPolicy, Policy, CleanupPolicy, ClusterCleanupPolicy, and the legacy kyverno.io PolicyException are gone in November. Their replacements are five CEL-based types: ValidatingPolicy, MutatingPolicy, GeneratingPolicy, ImageValidatingPolicy, and DeletingPolicy.

Over the same period, the validationFailureAction field moved twice: first within the old API to spec.rules[*].validate[*].failureAction, then in the new API to a different field name altogether — spec.validationActions. So all three lines of the three-line example I wrote four months ago need to be rewritten today.

The lesson I take from this isn't specific to Kyverno. The "policy" half of policy-as-code lives for years; the "code" half rots release by release. If you don't know which API version the policies currently running in your cluster sit on, you will find out in the middle of an upgrade in November. Learning it now, with a single inventory command, is far cheaper.

Balance 1: Deny or Report

A policy's most visible setting is what happens on a violation. The new ValidatingPolicy expresses this through spec.validationActions, and its semantics come straight from Kubernetes' own ValidatingAdmissionPolicy. The Kubernetes documentation defines the three actions like this: Deny — "validation failure results in a denied request"; Warn — "validation failure is reported to the request client as a warning"; Audit — "validation failure is included in the audit event for the API request." The docs also note that Deny and Warn may not be used together, because that combination needlessly duplicates the failure in both the response body and the HTTP warning headers.

The practical rule here is less about the setting than about the ordering: never put a rule into service directly with Deny. Run it in reporting mode first, count the existing violations, find their owners, get them fixed — and leave denial for last. In the April post I called this audit → warn → enforce; the field names changed, the logic didn't.

Teams that skip this sequence always live through the same story: the rule goes live on a Friday afternoon with Deny, nobody deploys over the weekend, and on Monday morning half a dozen teams write in at once saying "my deploy is broken." The rule is right. The timing is a disaster.

The old API let you vary behaviour per namespace with validationFailureActionOverridesAudit in production, Enforce in newly created namespaces. That field has no direct CEL equivalent, and the migration guide doesn't hide it: against the failureActionOverrides row it says "not supported; use policy exceptions instead." The same goes for allowExistingViolations.

This is the most easily missed detail of the migration. If your staged rollout is built on namespace overrides today, that mechanism does not come with you — exceptions take its place. Which means exceptions are no longer an edge case in your policy design; they are the primary tool for staging, and that is why they get their own section below. Don't lose the ability to roll out gradually; if you do, you're left with a single switch, and that switch is either "no protection at all" or "hit everyone at the same time."

Balance 2: What the Cluster Does When the Engine Falls Over

This is the hard question, and most installations answer it by accident, through defaults.

Kubernetes' dynamic admission control documentation is clear: the failurePolicy field takes either Ignore or Fail, and the default is Fail. If your webhook can't answer, the request is rejected. Kyverno inherits that choice in its own installation documentation: "Kyverno by default configures its webhooks in a mode of Fail to be more secure by default." That behaviour can be overridden globally with --forceFailurePolicyIgnore, or per policy with the failurePolicy setting.

One sentence is enough to see the cost of that default: in Fail mode, your policy engine is a single point of failure on your cluster's write path. If the engine's pods go down, if it loses a node, or if the webhook times out, no CREATE or UPDATE gets through for the resources it matches. Kubernetes' admission webhook good practices guide describes the worst version of this under "avoid self-mutations": "A webhook running inside the cluster might cause deadlocks for its own deployment if it is configured to intercept resources required to start its own Pods."

On the timeout side, the numbers are these: the default timeout for a Kubernetes webhook call is 10 seconds. Kyverno's own webhookTimeout setting also defaults to 10 seconds with an accepted range of 1–30 seconds; the same value is available per policy in the new ValidatingPolicy as spec.webhookConfiguration.timeoutSeconds. You only understand what that window means once you've debugged a deploy frozen by an admission webhook timeout: ten seconds is nothing on a human scale, and an eternity in the middle of a release.

Diagram

The bottom-right corner of that diagram carries the whole tension of the architecture: the same failure becomes an outage under Fail and a silent protection gap under Ignore. Note that the Ignore branch produces no report either — the policy is never evaluated, so the violation isn't recorded anywhere. The reassurance of "I'm in audit mode, at least I'll see it" fails precisely when the engine does. In my view the right trade-off here isn't one global switch but a choice that varies with the nature of the policy. For a rule that guards a genuine door — unsigned images, privileged containers — Fail is correct; that door should stay shut even at the cost of the cluster's availability. For a rule enforcing a labelling standard, Fail is absurd. Nobody halts their cluster over a missing cost-center label.

When you make this call, do three things together. Run the engine highly available: Kyverno's HA guide states that "the minimum supported replica count for a highly-available admission controller deployment is three." Narrow the scope: the same good-practices guide recommends avoiding matching objects in the kube-system namespace, using an objectSelector if you do run your own pods there so you don't disturb a critical workload, and limiting each webhook to a specific namespace with a namespaceSelector. And as a direct consequence of the deadlock warning above, keep the resources needed to start the engine's own pods out of your match scope. Choosing Fail without those three isn't security; it's a bet.

I've walked through the mechanics of all three — lowering the timeout, narrowing scope, a temporary Ignore in an emergency — step by step in a separate runbook. What I'm adding here isn't the mechanics; it's the choice of which rule deserves which mode.

There's also a kind of dependency that sneaks onto the write path unnoticed. Kyverno 1.18 hardened HTTP calls from within policies: in namespaced policies, CEL HTTP is disabled by default and has to be enabled explicitly with --allowHTTPInNamespacedPolicies=true. Cluster-scoped policies, however, allow HTTP calls by default, and the documentation attaches an explicit warning: treat this as a privileged capability and restrict who can create cluster-scoped policies. In practice that means anyone who can write a cluster-scoped ValidatingPolicy can attach an arbitrary external dependency to your cluster's write path — and in Fail mode, that service's outage becomes your outage.

Balance 3: You May Not Need a Webhook at All

What shifted the ground under this debate in 2026 is that Kubernetes gained a policy engine of its own. ValidatingAdmissionPolicy has been stable since Kubernetes v1.30, and in the documentation's own words it offers "a declarative, in-process alternative to validating admission webhooks." In-process means there is no external service for you to keep alive, and therefore no failure class where that service becomes unreachable.

This doesn't make Kyverno redundant — but it does sharpen the boundary. If your rule is pure validation of the form "this field must satisfy this condition" and it can be expressed in CEL, handing it to the cluster's own engine means fewer moving parts. Where Kyverno genuinely earns its place is everything beyond that: mutating resources, generating and synchronising new ones, verifying image signatures and attestations (ImageValidatingPolicy), centralised exception management, and collecting all of it into a single report format.

A year ago I'd have added "but mutation is still webhook territory" here. I can't any more. MutatingAdmissionPolicy has been stable and enabled by default since Kubernetes v1.36. Mutation, like validation, can now run in the native engine, and the "you need a webhook" argument has lost both of its legs.

Kyverno leaves a door open to bridge the two: set spec.autogen.validatingAdmissionPolicy.enabled: true inside a ValidatingPolicy and it generates the native ValidatingAdmissionPolicy equivalent. You write the policy in Kyverno's syntax and delegate enforcement to the API server. But don't skip the note box: pod controller auto-generation and ValidatingAdmissionPolicy generation are mutually exclusive — when spec.autogen.podControllers is configured, Kyverno skips generating the ValidatingAdmissionPolicy and reports the reason in the policy status. In any setup that wants automatic coverage of Deployments and CronJobs — that is, most real ones — this door quietly closes. If you don't read the policy status, you'll be sure you opened a path you're not actually using.

My decision framework: move pure validations — and now simple mutations — closer to the native engine, and keep in Kyverno the work that truly earns an engine. That remainder is not small: background scanning of resources that already existed when the policy landed, generating and synchronising resources, signature and attestation verification, centralised exception management, unified reporting, and the ability to evaluate non-Kubernetes JSON and YAML payloads. That way the risk surface of Fail mode shrinks to the small number of rules that genuinely warrant it.

Migration in Practice: What Moves Where

For most teams, November's removal date currently sits in the "we'll look at it someday" bucket. I would take the inventory now and do the migration later — because the hard part isn't the conversion, it's deciding which policies are still genuinely needed.

Kyverno's migration to CEL guide provides a field-by-field mapping table. In practice the equivalents are these: matching moves from spec.rules.match to spec.matchConstraints plus spec.matchConditions; spec.rules.exclude becomes spec.matchConstraints.excludeResourceRules; spec.rules.validate.failureAction becomes spec.validationActions; spec.background becomes spec.evaluation.background.enabled; spec.rules.preconditions becomes spec.matchConditions; and the validation itself moves from spec.rules.validate.pattern to a CEL expression in spec.validations[].expression. The message field comes along too, as spec.validations[].message, right next to its rule. One caveat: the guide's table lists spec.matchExpressions as the equivalent of spec.rules.match, but the real schema — including the example on that same page — uses matchConstraints. Trust kubectl explain vpol.spec over the table.

The guide's own example produces something longer than the old pattern matching, but with fewer surprises:

apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
  name: require-app-version-labels
spec:
  matchConstraints:
    resourceRules:
      - apiGroups: [""]
        apiVersions: ["v1"]
        operations: ["CREATE", "UPDATE"]
        resources: ["pods"]
  validationActions:
    - Deny
  validations:
    - expression: >
        ['app', 'version'].all(label,
          object.metadata.?labels[label].orValue('') != ''
        )
      message: 'Required labels missing'
Enter fullscreen mode Exit fullscreen mode

The real gain of moving from pattern-based YAML to CEL shows up here. The old pattern syntax, with its conditional anchors, meant learning a dialect that existed nowhere else. CEL is a shared expression language you meet elsewhere in Kubernetes — learn it once and you use the same knowledge in a CRD validation rule and in a native admission policy alike. In the long run that is the best protection there is against syntax rot.

Exceptions migrate too: the legacy kyverno.io PolicyException moves to the policies.kyverno.io group, spec.exceptions.policyName becomes spec.policyRefs.name, and rule-level configuration turns into CEL expressions under spec.matchConditions. Don't expect a command that converts your policies to CEL for you — the guide gives you the field mapping, and you do the conversion. Which conveniently turns the migration into a natural occasion to write tests.

There is a separate command not to confuse this with, though: kyverno migrate. It doesn't convert your policy to CEL; it rewrites objects already stored in etcd to the current storage version after an upgrade. Per the 1.19 announcement, the storage version of the policies.kyverno.io types is still v1beta1 and moves to v1 in v1.20 — so that step belongs on your November upgrade checklist too.

Taking the "Code" Half Seriously

If we're calling it policy-as-code, then the policy has to be tested like code. The Kyverno CLI provides kyverno apply and kyverno test for this, and the CLI documentation confirms the new CEL-based types are supported as well. A test declaration is written as a Test resource in the cli.kyverno.io/v1alpha1 API, holding policies, sample resources, and expected results together:

apiVersion: cli.kyverno.io/v1alpha1
kind: Test
metadata:
  name: require-app-version-labels
policies:
  - policies/require-labels.yaml
resources:
  - resources/pod-with-labels.yaml
  - resources/pod-missing-labels.yaml
results:
  - policy: require-app-version-labels
    isValidatingPolicy: true
    resources:
      - pod-with-labels
    kind: Pod
    result: pass
  - policy: require-app-version-labels
    isValidatingPolicy: true
    resources:
      - pod-missing-labels
    kind: Pod
    result: fail
Enter fullscreen mode Exit fullscreen mode

Two details to watch: resources is plural and takes a list (as namespace/name for namespaced resources), and when the policy under test is a ValidatingPolicy the isValidatingPolicy: true field is required — the equivalent when testing a native ValidatingAdmissionPolicy is isValidatingAdmissionPolicy. The result values are pass, fail, skip, and warn. The subtlety is in what fail means: writing result: fail on a test line does not mean "this test failed" — it means "I expect this resource not to pass this policy." You test the violation just as explicitly as the compliance, and when the tests match your expectations the command reports both as passing.

I'm labouring this detail because the negative side is what gets skipped most often in policy testing. Writing a test that says "I wrote a rule, and a compliant pod passes" is half the job. The real question is this: if I accidentally made this rule match nothing at all, would my tests notice? A typo in matchConstraints silently takes the policy out of scope; no error appears in the cluster, no alert fires, and the protection sits on paper for years. Negative tests are the only early warning you have against that class of mistake.

Exceptions: A Contract, Not an Escape Hatch

Every policy system dies the same death: in an emergency someone turns a rule off "temporarily," and the temporary becomes permanent.

Kyverno provides the PolicyException resource for this, now in the policies.kyverno.io group. An exception references its policy through spec.policyRefs and describes the exempt resource with CEL expressions in matchConditions. Two design details matter. First, per the exceptions documentation, PolicyExceptions are disabled by default: "To enable them, set the enablePolicyException flag to true. When enabling PolicyExceptions, you must also specify which namespaces they can be used in by setting the exceptionNamespace flag." Second, the same document warns: "PolicyExceptions are always Namespaced yet may provide an exception for a cluster-scoped resource as well."

Put those two together: the namespace in which you grant permission to write exceptions is effectively a namespace that can produce cluster-wide exemptions. Choosing exceptionNamespace is therefore not a convenience setting; it is an authorisation decision.

There is also something I could not find in the documentation, and I want to say so plainly: no built-in expiry (TTL) mechanism for exceptions is documented. There is no field for "this exemption is valid for two weeks." You have to fill that gap yourself — and in practice the simplest thing that works is putting an expiry date in a label or annotation on every exception and running a periodic audit that lists the expired ones. I'd rather that audit open a ticket than delete anything automatically; an exemption that disappears silently is a deploy that explodes at midnight.

Measurement: An Invisible Policy Can't Be Managed

Reporting is where audit mode pays you back. Kyverno publishes results in the Kubernetes Policy working group's common format: namespaced PolicyReport and cluster-scoped ClusterPolicyReport in the wgpolicyk8s.io/v1alpha2 group. These are fed from two distinct sources — what was caught at admission time, and what came from background scanning — and telling them apart matters, because background scanning also evaluates the policy against resources that already existed before it took effect. The numeric answer to "how much breaks if I switch this rule to Deny?" lives precisely there.

The detail that best proves this post's thesis lives right here: the reporting API itself is on the move. Since 1.15 Kyverno can also publish results through the openreports.io/v1alpha1 API (the --openreportsEnabled flag, ALPHA status), and the documentation doesn't hide the intent — it is "an initial step to eventually deprecate wgpolicyk8s and fully depend on openreports.io as the API group for permanent reports." So the dashboards and automation that query your reports have a second API group migration ahead of them.

Two practical notes. If you want to turn off reporting for a specific policy, since 1.16 it's enough to add the reports.kyverno.io/disabled label to it — that's the clean way to silence a noisy rule. And on large clusters the etcd footprint of report objects can be significant: reports are stored in etcd as custom resources by default, and during heavy reporting the read/write volume can put the API server under enough load to degrade performance. Kyverno has a Reports Server project for exactly this, keeping reports in a relational database instead of etcd. Growing your policy count without measuring report volume is one of the quieter ways to step on your own tail.

Checklist

  1. Take inventory. If kubectl get clusterpolicy,policy -A returns anything, you need a migration plan before v1.20 in November — and put "refresh the storage version with kyverno migrate after the upgrade" on that plan. Write new policies with the CEL-based types.
  2. Never open a rule directly with Deny. Report first, then count, then talk to the owners, then deny.
  3. Choose failurePolicy per policy class. Rules that draw a security boundary get Fail; hygiene and labelling rules get Ignore.
  4. Wherever you choose Fail, pay for it: at least three replicas, exclude kube-system, and keep the engine's own namespace out of scope.
  5. Set the timeout deliberately. Default 10 seconds, range 1–30. If your policy calls out to an external service, that window is your outage window.
  6. Write negative tests. A compliant resource passing is half the evidence; a non-compliant resource actually being caught is the real proof.
  7. Give every exception an expiry date. If the product won't, you should — a label plus a periodic audit is enough.
  8. Move pure validations — and now simple mutations — closer to the native engine. ValidatingAdmissionPolicy has been stable since v1.30, MutatingAdmissionPolicy since v1.36. Making a webhook do work the cluster's own engine can do is buying a dependency that isn't free.

Conclusion

Balance in admission control is not the answer to "how strict should I be?" The real question is this: when this rule misbehaves, in what form should it surface? A misbehaving rule either stops you (Fail + Deny) or quietly waves you through (Ignore + Audit). Both are a cost; choosing which one you pay is your job, and unless you make that choice rule by rule, the defaults make it for you.

My own April post going stale in four months taught me a second thing. Policy engines are not set-and-forget infrastructure; they are living dependencies with release calendars, accruing their own migration debt. If you don't know which API group the policies in your cluster sit on today — and most teams don't — the cheapest moment to find out is now, and the most expensive is the middle of an upgrade.

Official Sources

Top comments (0)