Admission Policy with CEL

K
Kai··5 min read

Part XIII closed the operations section. Part XIV looks at a different group: features that just graduated to stable in the very v1.36 release the cluster runs — a from-scratch cluster is the ideal place to try them right away. We start with the one that connects directly to Article 58: admission without a webhook.

Article 58 built a validating webhook, and the hardest part wasn't the logic but the infrastructure: an HTTPS server, a cert signed by the cluster CA, a caBundle, a failurePolicy for when the server dies. For simple rules — block this field, default that one — all that machinery is a burden. v1.36 offers another way: write rules in CEL (Common Expression Language) right inside the API server, via two objects, ValidatingAdmissionPolicy and MutatingAdmissionPolicy (Mutating just went GA in 1.36). No server, no cert, no caBundle.

Two objects: policy and binding

Just as RBAC separates Role from RoleBinding, admission policy separates the rule from the scope it applies to:

   ValidatingAdmissionPolicy / MutatingAdmissionPolicy   — CEL rule + matchConstraints (which resource)
            │
   ...Binding                                            — where to apply the policy (namespaceSelector),
                                                            validationActions (Deny/Warn/Audit)

The separation lets you write one policy once and reuse it across multiple bindings with different scopes. CEL runs in-process in the API server, so there's no network round trip out like a webhook — faster and with no point of failure, in exchange for being limited to what CEL can express (it can't call external services or do complex state lookups).

Validating: block :latest images

A ValidatingAdmissionPolicy rejects any Deployment with a container using the :latest tag — the rule is a CEL expression that returns true when valid:

apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata: {name: deny-latest-tag}
spec:
  failurePolicy: Fail
  matchConstraints:
    resourceRules:
    - apiGroups: ["apps"], apiVersions: ["v1"], operations: ["CREATE","UPDATE"], resources: ["deployments"]
  validations:
  - expression: "object.spec.template.spec.containers.all(c, !c.image.endsWith(':latest'))"
    message: "image tag :latest is forbidden (CEL policy)"
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata: {name: deny-latest-binding}
spec:
  policyName: deny-latest-tag
  validationActions: ["Deny"]
  matchResources:
    namespaceSelector:
      matchLabels: {vap: enabled}

object.spec.template.spec.containers.all(c, !c.image.endsWith(':latest')) reads the object being created directly — CEL's all() macro checks every container. Label the namespace vap=enabled and try it:

kubectl -n vap-test create deployment bad  --image=nginx:latest
kubectl -n vap-test create deployment good --image=busybox:1.36
error: failed to create deployment: deployments.apps "bad" is forbidden:
  ValidatingAdmissionPolicy 'deny-latest-tag' with binding 'deny-latest-binding' denied request:
  image tag :latest is forbidden (CEL policy)
deployment.apps/good created

The :latest Deployment is rejected with the policy's message, the pinned tag passes — exactly the webhook result from Article 58, but with no webhook pod running, no cert, no Service. validationActions lets you choose Deny (block), Warn (warn), or Audit (log) — the same policy, with the handling level changed in the binding.

Mutating: auto-inject a label

MutatingAdmissionPolicy (GA in 1.36) modifies the object, in place of a mutating webhook. It uses ApplyConfiguration — a CEL expression that builds the piece of the object to add, server-side-apply style:

apiVersion: admissionregistration.k8s.io/v1
kind: MutatingAdmissionPolicy
metadata: {name: add-team-label}
spec:
  matchConstraints:
    resourceRules:
    - apiGroups: [""], apiVersions: ["v1"], operations: ["CREATE"], resources: ["pods"]
  reinvocationPolicy: Never
  mutations:
  - patchType: ApplyConfiguration
    applyConfiguration:
      expression: >
        Object{ metadata: Object.metadata{ labels: {"injected-by": "mutating-cel-policy"} } }

(plus a MutatingAdmissionPolicyBinding selecting namespaces with cel=enabled.) The Object{...} expression builds a patch containing only the label to add; the API server merges it into the pod. Create a pod that declares no labels:

kubectl -n cel-demo run p --image=busybox:1.36 --command -- sleep 100000
kubectl -n cel-demo get pod p -o jsonpath='{.metadata.labels}'
{"injected-by":"mutating-cel-policy","run":"p"}

The injected-by: mutating-cel-policy label appears even though we didn't declare it — the policy injected it at admission time. With a webhook (Article 58) this needs a server returning a JSONPatch; here it's one CEL expression in a manifest.

When CEL, when webhook

The two aren't mutually exclusive; they divide by rule complexity:

CEL admission policy Admission webhook (Article 58)
Infrastructure None — just policy objects HTTPS server + cert + caBundle + keep HA
Speed In-process in the apiserver One network round trip per request
Expressiveness Limited to CEL Arbitrary (code, calling external services, DB lookups)
Good for Rules on the object itself (forbid a field, set defaults, constrain a value) Logic that needs external data/services

The pragmatic rule: a rule that only looks at the object under review uses a CEL policy — compact, fast, no server to fail. A rule that needs to call out (look up a registry, query another API) is what needs a webhook. Most "block bad config" policies are in the first group, so CEL policy replaces the majority of common validating/mutating webhooks.

🧹 Cleanup

kubectl delete validatingadmissionpolicybinding deny-latest-binding
kubectl delete validatingadmissionpolicy deny-latest-tag
kubectl delete mutatingadmissionpolicybinding add-team-binding
kubectl delete mutatingadmissionpolicy add-team-label
kubectl delete namespace vap-test cel-demo

Everything is an object inside the API server, with nothing installed; deleting the policies + bindings + namespaces leaves it clean. Manifests at github.com/nghiadaulau/kubernetes-from-scratch, directory 68-cel-admission-policy.

Wrap-up

From v1.36, admission can be done with CEL right inside the API server, without a webhook server: ValidatingAdmissionPolicy blocks/warns (we blocked Deployments using :latest with containers.all(c, !c.image.endsWith(':latest'))), MutatingAdmissionPolicy (just GA) modifies objects via ApplyConfiguration (we injected an injected-by label). Each kind separates into a policy (rule + matchConstraints) and a binding (scope via namespaceSelector + validationActions Deny/Warn/Audit), the way RBAC separates Role/RoleBinding. Compared to the Article 58 webhook: a CEL policy has no server/cert/caBundle to stand up and keep alive, runs in-process so it's fast and has no point of failure — in exchange for only expressing what CEL can, with no calls out. A rule that looks at the object itself picks CEL; a rule that needs an external service is what needs a webhook.

Article 69 moves to another v1.36 feature that touches something that seemed immutable about a pod: changing a running pod's CPU/memory without recreating it — in-place pod resize, the "no restart needed" counterpart to the vertical scaling of Article 40.