Skip to main content

Kyverno 1.17's CEL Policies Hit v1: What One Policy Language From Admission to Audit Buys a Multi-Tenant Fleet Over Gatekeeper's Rego

9 min readDora NodaDora Noda
Share
On this page

On February 2, 2026, Kyverno 1.17 promoted its CEL policy engine to v1 — and in the same breath started a removal clock on every ClusterPolicy you have ever written. The legacy JMESPath-based ClusterPolicy and CleanupPolicy types are now deprecated, get critical fixes only through 1.18 and 1.19, and are planned for removal in v1.20 (October 2026). If you operate a multi-tenant fleet, this is not a routine upgrade. It is a forced choice of policy language for the next five years: standardize on CEL — the same expression dialect the Kubernetes API server itself evaluates — or keep paying the two-language tax of Rego on the side.

TL;DR for fleet operators: pin Kyverno to v1.17.2 or later (1.17.0 shipped with background-scan PolicyReports broken, fixed in 1.17.2), port validate rules to ValidatingPolicy first, and give tenants NamespacedMutatingPolicy instead of cluster-wide mutate rights. The deprecation clock is real:

ReleaseLegacy ClusterPolicy statusWhat you do
1.17 (Feb 2026)Deprecated, still functionalStart porting; new policies in CEL only
1.18 (Apr 2026)Critical fixes onlyFinish validate-rule ports
1.19 (Jul 2026)Critical fixes onlyFinish mutate/generate/image ports
1.20 (Oct 2026)Planned removalNothing legacy left to break

One more thing the release notes will not tell you plainly: nobody has published credible survey data showing Kyverno passing Gatekeeper in raw adoption — the last hard CNCF numbers put Kyverno at 10% production / 13% evaluating in 2024. This post makes no adoption-leadership claim. It makes a narrower, checkable one: for a team running tenants on Kubernetes, the CEL engine now does everything the old engine did, in the language your API server already speaks, and the side-by-side below shows exactly what that is worth.

What 1.17 actually shipped

The headline is the graduation of the full CEL policy family to v1, after 1.16 introduced it as v1beta1: ValidatingPolicy, MutatingPolicy, GeneratingPolicy, ImageValidatingPolicy, DeletingPolicy, each with a namespaced variant, plus PolicyException. Two details matter more than the version string.

First, 1.17 completes the namespaced set by adding NamespacedMutatingPolicy and NamespacedGeneratingPolicy. Before this, a tenant who wanted their own defaulting or scaffolding logic needed cluster-wide permissions or a ticket to the platform team. Now a namespace owner can define mutation and generation scoped to their own namespace — true multi-tenancy without handing out cluster-scoped mutate rights.

Second, the ecosystem flipped with the release: the kyverno-policies Helm chart changed its default policyType from ClusterPolicy to ValidatingPolicy, the sample library of 300+ policies is now filterable by CEL vs JMESPath, and new CEL function libraries (hashing, math) plus upcoming Cosign v3 support close the expressiveness gap the old engine used to own. New authoring targets CEL; the old types are in maintenance mode by design.

Showdown 1: one validation, three languages

Take the simplest fleet guardrail there is — no container may run the :latest tag — and write it the three ways a platform team chooses between today. Legacy Kyverno first:

yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: disallow-latest-tag
spec:
  validationFailureAction: Enforce
  rules:
  - name: require-image-tag
    match:
      any:
      - resources:
          kinds: [Pod]
    validate:
      message: "Image tag ':latest' is not allowed."
      pattern:
        spec:
          containers:
          - image: "!*:latest"

Now the same rule as a v1 CEL policy:

yaml
apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
  name: disallow-latest-tag
spec:
  evaluation:
    admission:
      enabled: true
    background:
      enabled: true
  matchConstraints:
    resourceRules:
    - apiGroups: [""]
      apiVersions: [v1]
      operations: [CREATE, UPDATE]
      resources: [pods]
  validations:
  - expression: "object.spec.containers.all(c, !c.image.endsWith(':latest'))"
    message: "Image tag ':latest' is not allowed."

And the same rule in Gatekeeper, which always comes as two objects — a ConstraintTemplate carrying Rego, plus a Constraint instantiating it (this one mirrors the well-known K8sDisallowLatestTag library policy):

yaml
apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata:
  name: k8sdisallowlatesttag
spec:
  crd:
    spec:
      names:
        kind: K8sDisallowLatestTag
  targets:
    - target: admission.k8s.gatekeeper.sh
      rego: |
        package k8sdisallowlatesttag
        violation[{"msg": msg}] {
          c := input.review.object.spec.containers[_]
          endswith(c.image, ":latest")
          msg := sprintf("image %v uses :latest", [c.image])
        }

with the Constraint that instantiates it:

yaml
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sDisallowLatestTag
metadata:
  name: no-latest
spec:
  enforcementAction: deny
  match:
    kinds:
    - apiGroups: [""]
      kinds: [Pod]

The comparison is not about line count — it is about how many distinct skills one rule demands:

AxisLegacy ClusterPolicyCEL ValidatingPolicyGatekeeper
Languages per ruleYAML + JMESPath patternsYAML + CELYAML + Rego
Objects per rule112 (template + constraint)
Expression reusable in upstream ValidatingAdmissionPolicyNoYes, near-verbatimNo
Background scan of existing podsBuilt in (background.enabled)Built inVia audit results on constraints
Removal date hanging over itv1.20 (Oct 2026)None — this is the futureNone

The CEL row's quiet advantage is the third line. Upstream ValidatingAdmissionPolicy and MutatingAdmissionPolicy evaluate CEL inside the API server — no webhook at all. A ValidatingPolicy expression and a native admission-policy expression are the same language with the same standard library, so a rule your team learns once ports between "enforced by Kyverno with background scans and exceptions" and "enforced natively by the API server" with edits, not rewrites. Rego knowledge ports nowhere inside Kubernetes.

Showdown 2: tenant defaulting without cluster-wide power

Validation keeps bad things out; defaulting puts good things in. Every multi-tenant fleet mutates workloads at admission — stamping a tenant label, injecting a logging sidecar, setting default resource requests. Here the question is not language but who is allowed to own the rule.

The legacy answer is a cluster-scoped mutate rule that only the platform team can touch:

yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: stamp-tenant-label
spec:
  rules:
  - name: add-tenant-label
    match:
      any:
      - resources:
          kinds: [Pod]
    mutate:
      patchStrategicMerge:
        metadata:
          labels:
            tenant: tenant-a

The 1.17 answer moves the same intent into the tenant's own namespace, owned by the tenant's own RBAC:

yaml
apiVersion: policies.kyverno.io/v1
kind: NamespacedMutatingPolicy
metadata:
  name: stamp-tenant-label
  namespace: tenant-a
spec:
  matchConstraints:
    resourceRules:
      - apiGroups: [""]
        apiVersions: ["v1"]
        operations: ["CREATE"]
        resources: ["pods"]
  mutations:
    - patchType: ApplyConfiguration
      applyConfiguration:
        expression: >
          Object{
            metadata: Object.metadata{
              labels: Object.metadata.labels{
                tenant: "tenant-a"
              }
            }
          }

Gatekeeper's answer is an Assign mutator — also cluster-installed, also platform-team-owned, and notably less expressive for anything derived (computing a value from the requesting namespace or user leans on fromMetadata or an external-data provider):

yaml
apiVersion: mutations.gatekeeper.sh/v1
kind: Assign
metadata:
  name: stamp-tenant-label
spec:
  applyTo:
  - groups: [""]
    kinds: [Pod]
    versions: [v1]
  match:
    scope: Namespaced
    namespaces: [tenant-a]
  location: "metadata.labels.tenant"
  parameters:
    assign:
      value: tenant-a

Ownership is the whole story for a fleet:

AxisLegacy mutateNamespacedMutatingPolicyGatekeeper Assign
Install scopeCluster-wideSingle namespaceCluster-wide
Tenant self-serviceNo — platform ticketYes — tenant RBAC sufficesNo — platform ticket
Value computed from request (user, namespace)JMESPath contextFull CEL + request.*fromMetadata or external data
Existing resourcesRe-mutate on policy changemutateExisting backgroundNot covered

This is the "completing the circle" line from the release notes made concrete: namespaced generation and mutation mean the defaulting backlog — sidecars, labels, default ConfigMaps — stops being platform-team tickets and becomes tenant-owned configuration with a blast radius of one namespace.

Showdown 3: image verification, in-engine vs bolted-on

The third fleet proof is supply chain: admit only signed images. Kyverno's ImageValidatingPolicy verifies Cosign signatures inside the policy engine, against image references matched by glob, with the result cached and re-checkable by background scans:

yaml
apiVersion: policies.kyverno.io/v1
kind: ImageValidatingPolicy
metadata:
  name: verify-tenant-images
spec:
  matchConstraints:
    resourceRules:
      - apiGroups: [""]
        apiVersions: ["v1"]
        operations: ["CREATE", "UPDATE"]
        resources: ["pods"]
  matchImageReferences:
    - glob: "registry.example.com/tenant-a/*"
  attestors:
    - name: tenant-a-key
      cosign:
        key:
          data: |
            -----BEGIN PUBLIC KEY-----
            <tenant-a cosign public key>
            -----END PUBLIC KEY-----
  validations:
    - expression: >
        images.containers.map(image,
          verifyImageSignatures(image, [attestors.tenant-a-key])
        ).all(e, e > 0)
      message: "Image must be signed by the tenant key."

Gatekeeper has no image-verification primitive. The standard answer is a second system — Ratify or a custom external-data provider — wired to a Rego constraint, which means a second deployment, a second cache, and a second failure mode on the admission path. Nothing wrong with Ratify; but "policy engine plus bolted-on verifier" is operationally two systems where Kyverno fields one. The honest caveat runs both ways: key distribution, rotation, and keyless (Fulcio/Rekor) trust roots are the hard part of image policy under either engine, and 1.17's own notes flag Cosign v3 support as still upcoming — soak-test image policies on a canary tenant before enforcing fleet-wide.

What one dialect actually buys day to day

The three showdowns share one payoff: a new hire who learns CEL for Kyverno policies already knows the language of upstream admission control and of the kubectl-readable expressions inside native policies. Three smaller wins ride along.

PolicyException is now v1, so per-tenant exemptions become versioned objects with scope and expiry — instead of policy forks or Rego carved out with input.review.namespace special cases. Background scans produce PolicyReports for every CEL policy, one audit surface for admission-time and existing resources, and the report API itself is migrating from wgpolicyk8s.io to openreports.io, so point new dashboards at the new API. And because MutatingPolicy is documented as a superset of upstream MutatingAdmissionPolicy (Kyverno can even auto-generate native policies from it), the migration has an exit ramp in both directions: prototype in the engine, graduate hot-path rules into the API server.

Where Gatekeeper still wins

None of this makes Rego dead weight. Three fleets should stay put. If you have a library of battle-tested ConstraintTemplates with years of audit history, rewriting them in CEL buys risk, not capability. If your policy estate spans beyond Kubernetes — Terraform plans, application config, data pipelines evaluated by OPA — Rego is the one language across all of it, and that parity is worth more than CEL's Kubernetes-native edge.

And Gatekeeper's dry-run-first rollout discipline (warn, then deny, per constraint, with audit backlog to prove blast radius) remains the gold standard for landing policy on a fleet that is already running tenants.

Your situationPick
Greenfield fleet policy, Kubernetes-onlyKyverno CEL (v1, ≥1.17.2)
Legacy ClusterPolicy estateKyverno CEL — the clock expires Oct 2026
Big Rego library, multi-system OPA usageGatekeeper — keep the parity
Tenant self-service defaultingKyverno namespaced policies — no contest
In-engine image signing gatesKyverno ImageValidatingPolicy

The fleet migration checklist

Ordered by blast radius, smallest first:

  1. Inventory. List every ClusterPolicy, Policy, and CleanupPolicy across management and workload clusters — including the ones vendored inside Helm charts you did not write.
  2. Pin the version. Go to v1.17.2 or later (1.18+ if your window allows). Do not park on 1.17.0/1.17.1: the broken background-scan PolicyReports mean your audit surface silently lies about existing resources.
  3. Flip the chart default. Set policyType: ValidatingPolicy on the kyverno-policies chart so vendored pod-security baselines arrive as CEL policies; keep one cluster on the legacy default as the control group.
  4. Port validate rules first. They are the most mechanical (the JMESPath-to-CEL migration guide covers the mapping), enforceable in audit mode side by side with the legacy rule until reports agree.
  5. Move exemptions to PolicyException. Every namespace carve-out embedded in old rules becomes a scoped, expiring object — this is also the moment to discover exemptions nobody remembers granting.
  6. Port mutate and generate next, namespaced where possible. Each tenant-owned default that moves into a NamespacedMutatingPolicy is a platform-team ticket queue that stops growing.
  7. Re-verify image policies on a canary. Signature verification touches registries, keys, and transparency logs at admission latency — enforce per tenant, watch p99 admission time, then widen.
  8. Aim dashboards at openreports.io. The report API migration is opt-in today and the direction of travel; do it while you are already touching every policy.

Kyverno 1.17 did not win a popularity contest — it ended a language war inside its own project, and aligned the survivor with the API server. For a fleet, that is the rarer and more useful event: one expression language from admission webhook to background audit to native policy, tenant-scoped ownership of the rules that touch tenant workloads, and a deprecation clock that makes "later" a date, not a strategy. October 2026 is seven releases of someone else's changelog away. Start with the inventory.

Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own. Star the repo on GitHub or deploy your first app today.

Related articles

Run this on infrastructure you own

bex is the open-source, AI-native Render alternative — push a git repo and get a running HTTPS service on your own machines.

Get started with bex