Dev.to Security πŸ” Cybersecurity πŸ‘ 0 πŸ“– 9 min read

Blocking Unlabelled Pods with Kyverno: From ClusterPolicy to CEL on a Local kind Cluster

Note: This is an independent walkthrough by a learner and is not officially endorsed by the CNCF or Kyverno maintainers. Summary In this walkthrough, I enforced mandatory team labeling on Kubernetes Pods using both Kyv

Note: This is an independent walkthrough by a learner and is not officially endorsed by the CNCF or Kyverno maintainers.

  1. Summary
    In this walkthrough, I enforced mandatory team labeling on Kubernetes Pods using both Kyverno's legacy ClusterPolicy and the modern CEL-based ValidatingPolicy. I learned how Kyverno extends the Kubernetes API with CRDs and how CEL-based policies run in Kyverno's admission webhook using a standard expression language instead of Kyverno's custom pattern-matching engine. This was built locally on a kind cluster to compare the two architectures side-by-side.

  2. Environment Setup
    Everything was tested locally using a standard kind cluster. I Note: This is an independent walkthrough by a learner and is not officially endorsed by the CNCF or Kyverno maintainers.

1. Summary

In this walkthrough, I enforced mandatory team labeling on Kubernetes Pods using both Kyverno's legacy ClusterPolicy and the modern CEL-based ValidatingPolicy. I learned how Kyverno extends the Kubernetes API with CRDs and how CEL-based policies run in Kyverno's admission webhook using a standard expression language instead of Kyverno's custom pattern-matching engine. This was built locally on a kind cluster to compare the two architectures side-by-side.

2. Environment Setup

Everything was tested locally using a standard kind cluster. I installed Kyverno via Helm, using chart version kyverno-3.9.2 (App version v1.19.2) that supports CEL policies.

Out of the box, Kubernetes has no idea what a Kyverno policy is. To verify the installation, I checked if Kyverno successfully registered its Custom Resource Definitions (CRDs), which extend the Kubernetes API so it can understand our custom policy objects:

kubectl get crd | findstr "policies.kyverno.io"

Output:

cleanuppolicies.kyverno.io                              Namespaced   v2(storage),v2beta1            2026-10-24T15:40:36Z
clustercleanuppolicies.kyverno.io                       Cluster      v2(storage),v2beta1            2026-10-24T15:40:36Z
clusterpolicies.kyverno.io                              Cluster      v1(storage),v2beta1            2026-10-24T15:40:37Z
deletingpolicies.policies.kyverno.io                    Cluster      v1,v1alpha1,v1beta1(storage)   2026-10-24T15:40:36Z
generatingpolicies.policies.kyverno.io                  Cluster      v1,v1alpha1,v1beta1(storage)   2026-10-24T15:40:36Z
imagevalidatingpolicies.policies.kyverno.io             Cluster      v1,v1alpha1,v1beta1(storage)   2026-10-24T15:40:36Z
mutatingpolicies.policies.kyverno.io                    Cluster      v1,v1alpha1,v1beta1(storage)   2026-10-24T15:40:36Z
namespaceddeletingpolicies.policies.kyverno.io          Namespaced   v1,v1beta1(storage)            2026-10-24T15:40:36Z
namespacedgeneratingpolicies.policies.kyverno.io        Namespaced   v1,v1beta1(storage)            2026-10-24T15:40:36Z
namespacedimagevalidatingpolicies.policies.kyverno.io   Namespaced   v1,v1beta1(storage)            2026-10-24T15:40:36Z
namespacedmutatingpolicies.policies.kyverno.io          Namespaced   v1,v1beta1(storage)            2026-10-24T15:40:36Z
namespacedvalidatingpolicies.policies.kyverno.io        Namespaced   v1,v1beta1(storage)            2026-10-24T15:40:36Z
policies.kyverno.io                                     Namespaced   v1(storage),v2beta1            2026-10-24T15:40:37Z
policyexceptions.policies.kyverno.io                    Namespaced   v1,v1alpha1,v1beta1(storage)   2026-10-24T15:40:36Z
validatingpolicies.policies.kyverno.io                  Cluster      v1,v1alpha1,v1beta1(storage)   2026-10-24T15:40:36Z

Seeing validatingpolicies.policies.kyverno.io in the output confirmed the CRDs were installed.

3. Policy v1: The Legacy ClusterPolicy

Before trying CEL, I wanted to see how this is traditionally done. In older versions of Kyverno, you create a ClusterPolicy that relies on Kyverno's custom pattern-matching engine.

Based on the official Kyverno Policy Library, a basic policy to enforce a label looks like this:

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-team-label
spec:
  validationFailureAction: Enforce
  rules:
  - name: check-team-label
    match:
      any:
      - resources:
          kinds:
          - Pod
    validate:
      message: "The label `team` is required."
      pattern:
        metadata:
          labels:
            team: "?*"

Notice the syntax: it tells the Kyverno controller to watch for Pod resources, and it uses a custom wildcard "?*" to say "this label must exist and have a value."

When I applied this policy and tried to create an unlabelled pod, the Kyverno controller caught it and blocked the request, sending back this rejection message:

Error from server: admission webhook "validate.kyverno.svc-fail" denied the request:
resource Pod/default/c1 was blocked due to the following policies
require-team-label:
  check-team-label: 'validation error: The label `team` is required. rule check-team-label failed at path /metadata/labels/team/'

4. Policy v2: The CEL ValidatingPolicy

Next, I deleted the legacy ClusterPolicy and replaced it with a modern ValidatingPolicy written in Common Expression Language (CEL).

Here is the exact ValidatingPolicy manifest I applied to the cluster:

apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
  name: require-team-label
spec:
  validationActions:
    - Deny
  matchConstraints:
    resourceRules:
      - apiGroups: [""]
        apiVersions: ["v1"]
        operations: ["CREATE", "UPDATE"]
        resources: ["pods"]
  validations:
    - expression: "has(object.metadata.labels) && 'team' in object.metadata.labels"
      message: "The label `team` is required."

Breaking Down the CEL Logic

The core of this policy is the CEL expression:
has(object.metadata.labels) && 'team' in object.metadata.labels

  • has(object.metadata.labels) safely checks if the incoming Pod object has any labels attached at all (preventing a null reference error).
  • 'team' in object.metadata.labels evaluates whether the specific key team exists within that metadata map.

Testing the CEL Policy

Negative Test (Missing Label):

First, I attempted to launch an unlabelled Pod:

kubectl run c1 --image=nginx

As expected, the request was immediately denied by the admission webhook:

Error from server: admission webhook "vpol.validate.kyverno.svc-fail" denied the request: Policy require-team-label failed: The label `team` is required.

Positive Test (Valid Label Attached):

Next, I ran the test command providing the required label:

kubectl run c2 --image=nginx --labels=team=payments

Output:

pod/c2 created

5. Comparison: What Changed?

Having deployed and tested both, the shift from traditional Kyverno policies to CEL-based validation introduces some major architectural changes. Here is a quick breakdown of the core differences:

Feature Legacy ClusterPolicy / Policy Modern CEL ValidatingPolicy
Kinds One kind holds validate, mutate, and generate rules A separate kind per purpose: Validating, Mutating, Generating, Deleting, ImageValidating
Shape spec.rules[], each with match and a validate or mutate block matchConstraints once, then validations[] with a CEL expression and a message (modeled on Kubernetes’ ValidatingAdmissionPolicy)
Matching Kinds like Pod, plus names, selectors, and more API groups, versions, operations, and plural resources like pods
Scope ClusterPolicy cluster-wide, Policy per namespace ValidatingPolicy cluster-wide, NamespacedValidatingPolicy per namespace
Enforcement Failure action (Enforce/Audit) Validation actions (Deny/Audit/Warn)

6. Takeaways & Next Steps

Transitioning from traditional Kyverno ClusterPolicy manifests to modern CEL-based ValidatingPolicy represents a significant architectural step forward:

  • Standardization: CEL rules use the same expression language and a similar structure as Kubernetes' own ValidatingAdmissionPolicy rather than relying on custom pattern-matching dialects.
  • Efficiency: CEL provides deterministic typing and a standard expression language, replacing Kyverno's custom pattern-matching dialect.

What I Haven't Tested

  • Policy reports: Using Kyverno's PolicyReport and ClusterPolicyReport CRDs to aggregate and query validation results over time.
  • Native ValidatingAdmissionPolicy generation: Generating upstream Kubernetes ValidatingAdmissionPolicy resources from Kyverno CEL policies (via kyverno generate).installed Kyverno via Helm, using chart version kyverno-3.9.2 (App version v1.19.2) that supports CEL policies.

Out of the box, Kubernetes has no idea what a Kyverno policy is. To verify the installation, I checked if Kyverno successfully registered its Custom Resource Definitions (CRDs), which extend the Kubernetes API so it can understand our custom policy objects:

kubectl get crd | findstr "policies.kyverno.io"

Output:

cleanuppolicies.kyverno.io Namespaced v2(storage),v2beta1 2026-10-24T15:40:36Z
clustercleanuppolicies.kyverno.io Cluster v2(storage),v2beta1 2026-10-24T15:40:36Z
clusterpolicies.kyverno.io Cluster v1(storage),v2beta1 2026-10-24T15:40:37Z
deletingpolicies.policies.kyverno.io Cluster v1,v1alpha1,v1beta1(storage) 2026-10-24T15:40:36Z
generatingpolicies.policies.kyverno.io Cluster v1,v1alpha1,v1beta1(storage) 2026-10-24T15:40:36Z
imagevalidatingpolicies.policies.kyverno.io Cluster v1,v1alpha1,v1beta1(storage) 2026-10-24T15:40:36Z
mutatingpolicies.policies.kyverno.io Cluster v1,v1alpha1,v1beta1(storage) 2026-10-24T15:40:36Z
namespaceddeletingpolicies.policies.kyverno.io Namespaced v1,v1beta1(storage) 2026-10-24T15:40:36Z
namespacedgeneratingpolicies.policies.kyverno.io Namespaced v1,v1beta1(storage) 2026-10-24T15:40:36Z
namespacedimagevalidatingpolicies.policies.kyverno.io Namespaced v1,v1beta1(storage) 2026-10-24T15:40:36Z
namespacedmutatingpolicies.policies.kyverno.io Namespaced v1,v1beta1(storage) 2026-10-24T15:40:36Z
namespacedvalidatingpolicies.policies.kyverno.io Namespaced v1,v1beta1(storage) 2026-10-24T15:40:36Z
policies.kyverno.io Namespaced v1(storage),v2beta1 2026-10-24T15:40:37Z
policyexceptions.policies.kyverno.io Namespaced v1,v1alpha1,v1beta1(storage) 2026-10-24T15:40:36Z
validatingpolicies.policies.kyverno.io Cluster v1,v1alpha1,v1beta1(storage) 2026-10-24T15:40:36Z

Seeing validatingpolicies.policies.kyverno.io in the output confirmed the CRDs were installed.

  1. Policy v1: The Legacy ClusterPolicy Before trying CEL, I wanted to see how this is traditionally done. In older versions of Kyverno, you create a ClusterPolicy that relies on Kyverno's custom pattern-matching engine.

Based on the official Kyverno Policy Library, a basic policy to enforce a label looks like this:

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-team-label
spec:
validationFailureAction: Enforce
rules:

  • name: check-team-label match: any:
    • resources: kinds:
      • Pod validate: message: "The label team is required." pattern: metadata: labels: team: "?*"

Notice the syntax: it tells the Kyverno controller to watch for Pod resources, and it uses a custom wildcard "?*" to say "this label must exist and have a value."

When I applied this policy and tried to create an unlabelled pod, the Kyverno controller caught it and blocked the request, sending back this rejection message:

Error from server: admission webhook "validate.kyverno.svc-fail" denied the request:
resource Pod/default/c1 was blocked due to the following policies
require-team-label:
check-team-label: 'validation error: The label team is required. rule check-team-label failed at path /metadata/labels/team/'

  1. Policy v2: The CEL ValidatingPolicy Next, I deleted the legacy ClusterPolicy and replaced it with a modern ValidatingPolicy written in Common Expression Language (CEL).

Here is the exact ValidatingPolicy manifest I applied to the cluster:

apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
name: require-team-label
spec:
validationActions:
- Deny
matchConstraints:
resourceRules:
- apiGroups: [""]
apiVersions: ["v1"]
operations: ["CREATE", "UPDATE"]
resources: ["pods"]
validations:
- expression: "has(object.metadata.labels) && 'team' in object.metadata.labels"
message: "The label team is required."

Breaking Down the CEL Logic
The core of this policy is the CEL expression: has(object.metadata.labels) && 'team' in object.metadata.labels

has(object.metadata.labels) safely checks if the incoming Pod object has any labels attached at all (preventing a null reference error).
'team' in object.metadata.labels evaluates whether the specific key team exists within that metadata map.
Testing the CEL Policy
Negative Test (Missing Label):
First, I attempted to launch an unlabelled Pod:

kubectl run c1 --image=nginx

As expected, the request was immediately denied by the admission webhook:

Error from server: admission webhook "vpol.validate.kyverno.svc-fail" denied the request: Policy require-team-label failed: The label team is required.

Positive Test (Valid Label Attached):
Next, I ran the test command providing the required label:

kubectl run c2 --image=nginx --labels=team=payments

Output:

pod/c2 created

  1. Comparison: What Changed? Having deployed and tested both, the shift from traditional Kyverno policies to CEL-based validation introduces some major architectural changes. Here is a quick breakdown of the core differences:

Feature Legacy ClusterPolicy / Policy Modern CEL ValidatingPolicy
Kinds One kind holds validate, mutate, and generate rules A separate kind per purpose: Validating, Mutating, Generating, Deleting, ImageValidating
Shape spec.rules[], each with match and a validate or mutate block matchConstraints once, then validations[] with a CEL expression and a message (modeled on Kubernetes’ ValidatingAdmissionPolicy)
Matching Kinds like Pod, plus names, selectors, and more API groups, versions, operations, and plural resources like pods
Scope ClusterPolicy cluster-wide, Policy per namespace ValidatingPolicy cluster-wide, NamespacedValidatingPolicy per namespace
Enforcement Failure action (Enforce/Audit) Validation actions (Deny/Audit/Warn)

  1. Takeaways & Next Steps Transitioning from traditional Kyverno ClusterPolicy manifests to modern CEL-based ValidatingPolicy represents a significant architectural step forward:

Standardization: CEL rules use the same expression language and a similar structure as Kubernetes' own ValidatingAdmissionPolicy rather than relying on custom pattern-matching dialects.
Efficiency: CEL provides deterministic typing and a standard expression language, replacing Kyverno's custom pattern-matching dialect.
What I Haven't Tested
Policy reports: Using Kyverno's PolicyReport and ClusterPolicyReport CRDs to aggregate and query validation results over time.
Native ValidatingAdmissionPolicy generation: Generating upstream Kubernetes ValidatingAdmissionPolicy resources from Kyverno CEL policies (via kyverno generate).

πŸ“° Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.