Lesson 57 of 66 · 17 min read · Updated

ValidatingAdmissionPolicy — Enforcing Cluster Rules with CEL

The previous lessons in this subcategory used fixed rule sets. RBAC decides who may call the API, Pod Security Admission applies three predefined security levels, and NetworkPolicies control traffic. Sooner or later your platform needs rules that are specific to you: every Deployment must set a memory limit, images must never use :latest, every workload needs a team label so the bill can be split. None of the built-in features can express those.

For years the answer was an admission webhook: a separate service, often Kyverno or OPA Gatekeeper, that the API server calls for every request. Kubernetes now has a built-in alternative. A ValidatingAdmissionPolicy is a rule written in CEL, the Common Expression Language, and evaluated inside the API server itself. There is nothing to deploy, nothing to keep highly available, and no network call on every request.

We will cover the following topics:

  1. Where admission policies run in the life of an API request
  2. The two objects: ValidatingAdmissionPolicy and ValidatingAdmissionPolicyBinding
  3. Writing CEL expressions, with variables and readable error messages
  4. Rolling out safely with Warn, then switching to Deny
  5. Scoping a policy to selected namespaces
  6. Parameterising a policy with a ConfigMap, so each namespace can have different values
  7. Reading type-checking results, and their limits
  8. MutatingAdmissionPolicy, for changing objects instead of rejecting them
  9. When Kyverno or Gatekeeper is still the better tool

Technical Requirements

  1. A Kubernetes cluster at version 1.30 or later, where ValidatingAdmissionPolicy is generally available, and cluster-admin rights, because policies and bindings are cluster-scoped. The outputs come from Docker Desktop's Kubernetes with the kind provisioner (one control plane and three workers, Kubernetes v1.36).
  2. kubectl. No application code is needed.
  3. The previous lessons, especially Pod Security Admission, which follows the same enforce-and-warn pattern.

Core Concepts

Where Admission Fits

Every create, update or delete request to the API server goes through the same pipeline:

  1. Authentication: who are you? (ServiceAccount token, certificate, OIDC)
  2. Authorization: may you do this? (RBAC)
  3. Mutating admission: built-in plugins, mutating webhooks and MutatingAdmissionPolicies may change the object.
  4. Schema validation: is the object well-formed?
  5. Validating admission: built-in plugins such as Pod Security Admission, validating webhooks and ValidatingAdmissionPolicies may reject the object.
  6. Persistence to etcd.

RBAC answers "may this user create Deployments?". Admission answers "is this particular Deployment acceptable?". The two complement each other.

Two Objects: Policy and Binding

A ValidatingAdmissionPolicy is split into two cluster-scoped objects so that one rule can be reused with different scopes and actions:

ObjectAnswers
ValidatingAdmissionPolicyWhat is checked: which resources and operations it applies to (matchConstraints), the CEL expressions, the error messages, and optionally the kind of its parameters
ValidatingAdmissionPolicyBindingWhere and how: which namespaces or objects it applies to (matchResources), what to do on failure (validationActions), and which parameter object to use (paramRef)

A policy without a binding does nothing. This makes it safe to create policies first and activate them later.

CEL in Two Minutes

CEL expressions look like C#, Java or JavaScript and are designed to be safe: no loops, no side effects, and a bounded cost the API server can estimate in advance. Inside a policy you have these variables:

VariableContains
objectThe incoming object, or null for DELETE
oldObjectThe existing object on UPDATE, null on CREATE
requestRequest details such as request.userInfo.username and request.operation
namespaceObjectThe namespace the object is in
paramsThe parameter object selected by the binding
variablesNamed sub-expressions you define in the policy
authorizerLets you run RBAC checks from the expression

The operations you will use most:

ExpressionMeaning
has(object.metadata.labels)The field exists
'team' in object.metadata.labelsA map contains a key
list.all(x, cond) / list.exists(x, cond)Every / at least one element matches
list.filter(x, cond).map(x, x.name)Select and project
str.startsWith(), str.endsWith(), str.contains(), str.matches(regex)String checks
str.split(','), list.join(', '), str.trim()String and list helpers
cond ? a : bConditional

An expression must return true for the object to be valid. Return false, and the request fails with your message.

Validation Actions

The binding decides what a failed validation does:

ActionEffect
DenyThe request is rejected with the message
WarnThe request succeeds and the client shows a warning, just like Pod Security Admission's warn mode
AuditThe request succeeds and the failure is recorded in the API server's audit log

You can combine Warn with Audit, or Deny with Audit. Deny and Warn together are not allowed, which makes sense: a denied request has no warning to show.

Scoping with the Binding

Policies are cluster-scoped, so a careless binding affects every namespace, including kube-system. Always scope bindings with matchResources.namespaceSelector, objectSelector, or both. In this lesson every binding selects only namespaces labelled policy-enforcement: enabled, so no other workload in the cluster is affected while you experiment.

Policies Run on What You Match

Like Pod Security Admission, a policy matched on Pods sees Pods created by controllers. If it rejects them, the Deployment is accepted and the ReplicaSet fails quietly. Matching the workload resources (Deployments, StatefulSets, DaemonSets) instead returns the error to the person running kubectl apply, which is far easier to act on. Matching both gives complete coverage, and the variables feature makes that easy, as the image-tag policy shows.

Hands-On: Kubernetes Commands

List policies and bindings

The PARAMKIND and PARAMREF columns show which policies are parameterised.

kubectl get validatingadmissionpolicies,validatingadmissionpolicybindings

Check a policy's type-checking results

The API server type-checks expressions against the matched resources' schemas and stores warnings in the policy's status.

kubectl get validatingadmissionpolicy require-pinned-image-tags -o jsonpath='{.status.typeChecking}'

Test a manifest against all policies without creating it

A server-side dry run runs the full admission chain, including your policies.

kubectl apply --dry-run=server -f deployment.yaml

Switch a binding from Warn to Deny

Patch only the action; the policy itself stays the same.

kubectl patch validatingadmissionpolicybinding require-pinned-image-tags --type=merge -p '{"spec":{"validationActions":["Deny"]}}'

See which namespaces a binding will affect

Use the same selector the binding uses.

kubectl get namespaces -l policy-enforcement=enabled

Explore the API

kubectl explain documents every field, including the newest ones.

kubectl explain validatingadmissionpolicy.spec.validations
kubectl explain validatingadmissionpolicybinding.spec.paramRef

Step-by-Step Example

The Scenario

You run a platform for two teams, in namespaces sec-team-a and sec-team-b. You want three rules:

  1. Every container in a Deployment, StatefulSet or DaemonSet sets a memory limit, so a memory leak cannot take down a node.
  2. No container image uses :latest or no tag at all, so a restart cannot silently change the code that runs.
  3. Every Deployment carries the labels its team has agreed to: team A requires app.kubernetes.io/name and team, and team B additionally requires cost-center.

Finally you will add a mutating policy that fills in the team label automatically when it is missing.

Step 1 — Create the Namespaces

The label is what the bindings will select:

apiVersion: v1
kind: Namespace
metadata:
name: sec-team-a
labels:
policy-enforcement: enabled
---
apiVersion: v1
kind: Namespace
metadata:
name: sec-team-b
labels:
policy-enforcement: enabled
kubectl apply -f 1-namespaces.yaml
kubectl get namespaces -l policy-enforcement=enabled
NAME STATUS AGE
sec-team-a Active 1s
sec-team-b Active 1s

Step 2 — Require Memory Limits

The policy matches the three workload resources on create and update. messageExpression builds a message that names the offending containers, which is much more useful than a fixed sentence:

apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
name: require-memory-limits
spec:
failurePolicy: Fail
matchConstraints:
resourceRules:
- apiGroups: ["apps"]
apiVersions: ["v1"]
operations: ["CREATE", "UPDATE"]
resources: ["deployments", "statefulsets", "daemonsets"]
validations:
- expression: >-
object.spec.template.spec.containers.all(c,
has(c.resources) && has(c.resources.limits) && 'memory' in c.resources.limits)
messageExpression: >-
'every container must set resources.limits.memory; missing in: ' +
object.spec.template.spec.containers.filter(c,
!(has(c.resources) && has(c.resources.limits) && 'memory' in c.resources.limits)
).map(c, c.name).join(', ')
reason: Invalid
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
name: require-memory-limits
spec:
policyName: require-memory-limits
validationActions: ["Deny"]
matchResources:
namespaceSelector:
matchLabels:
policy-enforcement: enabled

failurePolicy: Fail means that if the expression itself errors at runtime, for example on an unexpected null, the request is rejected rather than let through. For security rules this is the right default.

kubectl apply -f 2-require-limits-policy.yaml

Try a Deployment with two containers, where only the sidecar has a limit:

apiVersion: apps/v1
kind: Deployment
metadata:
name: catalog-api
namespace: sec-team-a
spec:
replicas: 1
selector:
matchLabels:
app: catalog-api
template:
metadata:
labels:
app: catalog-api
spec:
containers:
- name: api
image: traefik/whoami:v1.11.0
- name: log-shipper
image: busybox:1.37
command: ["sleep", "infinity"]
resources:
limits:
memory: 64Mi
kubectl apply -f 3-deploy-no-limits.yaml
The deployments "catalog-api" is invalid: : ValidatingAdmissionPolicy 'require-memory-limits' with binding 'require-memory-limits' denied request: every container must set resources.limits.memory; missing in: api

The message names the exact container to fix. The same manifest in a namespace without the label is not affected:

sed 's/namespace: sec-team-a/namespace: default/' 3-deploy-no-limits.yaml | kubectl apply --dry-run=server -f -
deployment.apps/catalog-api created (server dry run)

Step 3 — Read the Type-Checking Status

When you create a policy, the API server type-checks the expressions against the schemas of every matched resource and records the results in status.typeChecking. Look at this one:

kubectl get validatingadmissionpolicy require-memory-limits -o jsonpath='{.status.typeChecking}'
{"expressionWarnings":[{"fieldRef":"spec.validations[0].expression","warning":"apps/v1, Kind=DaemonSet: ERROR: <input>:2:26: undefined field 'limits' ..."}, ...]}

On this v1.36 cluster the type checker reports undefined field 'limits', yet the policy evaluated correctly in Step 2: it found the container without a limit and accepted the one with a limit. The same warning appears for resources.requests; the type checker does not model those two resource-quantity maps. This shows what type checking is: warnings, never errors. A policy with warnings is still active. Use them as a prompt to look closely at an expression, then trust a real test like the one above, not the status alone. For other fields the checker is very useful: a typo such as object.spec.tempalte is reported here before it ever reaches a request.

Step 4 — Forbid latest Tags, in Warn Mode First

The second rule must check Pods created directly and the Pod templates of workload resources. Their containers live at different paths, so the policy uses variables to find them once:

apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
name: require-pinned-image-tags
spec:
failurePolicy: Fail
matchConstraints:
resourceRules:
- apiGroups: [""]
apiVersions: ["v1"]
operations: ["CREATE", "UPDATE"]
resources: ["pods"]
- apiGroups: ["apps"]
apiVersions: ["v1"]
operations: ["CREATE", "UPDATE"]
resources: ["deployments", "statefulsets", "daemonsets"]
variables:
- name: podSpec
expression: "object.kind == 'Pod' ? object.spec : object.spec.template.spec"
- name: allContainers
expression: >-
variables.podSpec.containers +
(has(variables.podSpec.initContainers) ? variables.podSpec.initContainers : [])
- name: unpinned
expression: >-
variables.allContainers.filter(c,
!c.image.contains('@sha256:') &&
(!c.image.matches(':[^/:]+$') || c.image.endsWith(':latest'))
).map(c, c.name + '=' + c.image)
validations:
- expression: "size(variables.unpinned) == 0"
messageExpression: "'images must use a fixed tag or a digest, not latest or no tag: ' + variables.unpinned.join(', ')"
reason: Invalid
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
name: require-pinned-image-tags
spec:
policyName: require-pinned-image-tags
validationActions: ["Warn"]
matchResources:
namespaceSelector:
matchLabels:
policy-enforcement: enabled

Each variable builds on the previous one:

  1. podSpec picks the right path for the object's kind.
  2. allContainers adds init containers, which are easy to forget.
  3. unpinned keeps images that have no digest and either no tag or the latest tag. The regular expression :[^/:]+$ means "a colon followed by a tag at the very end". It deliberately ignores a registry port such as registry.local:5000/team/app, which has a colon but no tag.

The binding starts with Warn. Nothing breaks, and everyone sees what would break:

kubectl apply -f 4-image-tag-policy.yaml
kubectl run untagged --image=nginx -n sec-team-a
Warning: Validation failed for ValidatingAdmissionPolicy 'require-pinned-image-tags' with binding 'require-pinned-image-tags': images must use a fixed tag or a digest, not latest or no tag: untagged=nginx
pod/untagged created

kubectl run latest --image=nginx:latest -n sec-team-a
Warning: Validation failed for ValidatingAdmissionPolicy 'require-pinned-image-tags' ... latest=nginx:latest
pod/latest created

kubectl run pinned --image=nginx:1.29-alpine -n sec-team-a
pod/pinned created

kubectl run regport --image=registry.local:5000/team/app -n sec-team-a --dry-run=server
Warning: Validation failed for ValidatingAdmissionPolicy 'require-pinned-image-tags' ... regport=registry.local:5000/team/app
pod/regport created (server dry run)

kubectl run digest --image=busybox@sha256:d82f458899c9696cb26a7c02d5568f81c8c8223f8661bb2a7988b269c8b9051e -n sec-team-a --dry-run=server
pod/digest created (server dry run)

All five cases behave as intended. The registry-port image is correctly flagged as untagged, and the digest is accepted.

Step 5 — Switch to Deny

Once the warnings have been dealt with, change the binding, not the policy:

kubectl patch validatingadmissionpolicybinding require-pinned-image-tags --type=merge -p '{"spec":{"validationActions":["Deny"]}}'

The API server picks up binding changes asynchronously, so a request sent in the same second may still be treated the old way. A few seconds later:

kubectl run untagged3 --image=nginx -n sec-team-a
The pods "untagged3" is invalid: : ValidatingAdmissionPolicy 'require-pinned-image-tags' with binding 'require-pinned-image-tags' denied request: images must use a fixed tag or a digest, not latest or no tag: untagged3=nginx

Pods created while the binding was in Warn mode keep running. Like every admission check, the policy only looks at requests, never at objects already stored.

Step 6 — Different Rules per Namespace with Parameters

The label rule differs between teams. Instead of writing two policies, write one that reads its settings from a parameter object. Any resource can be a parameter; a ConfigMap is the simplest:

apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
name: require-team-labels
spec:
failurePolicy: Fail
paramKind:
apiVersion: v1
kind: ConfigMap
matchConstraints:
resourceRules:
- apiGroups: ["apps"]
apiVersions: ["v1"]
operations: ["CREATE", "UPDATE"]
resources: ["deployments"]
variables:
- name: required
expression: "params.data.requiredLabels.split(',').map(l, l.trim())"
- name: missing
expression: >-
variables.required.filter(l,
!has(object.metadata.labels) || !(l in object.metadata.labels))
validations:
- expression: "size(variables.missing) == 0"
messageExpression: "'deployment ' + object.metadata.name + ' is missing required labels: ' + variables.missing.join(', ')"
reason: Invalid
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
name: require-team-labels
spec:
policyName: require-team-labels
validationActions: ["Deny"]
paramRef:
name: label-policy
parameterNotFoundAction: Deny
matchResources:
namespaceSelector:
matchLabels:
policy-enforcement: enabled

The paramRef names a ConfigMap but sets no namespace. That is the key: the API server then looks for a ConfigMap called label-policy in the namespace of the object being admitted. Each team gets its own settings, managed in its own namespace, with one policy and one binding. parameterNotFoundAction: Deny makes a missing ConfigMap a failure rather than a silent pass:

kubectl apply -f 5-required-labels-policy.yaml
kubectl apply -f 7-compliant-deployment.yaml
The deployments "catalog-api" is invalid: : ValidatingAdmissionPolicy 'require-team-labels' with binding 'require-team-labels' denied request: failed to configure binding: no params found for policy binding with `Deny` parameterNotFoundAction

Now give each team its parameters:

apiVersion: v1
kind: ConfigMap
metadata:
name: label-policy
namespace: sec-team-a
data:
requiredLabels: "app.kubernetes.io/name, team"
---
apiVersion: v1
kind: ConfigMap
metadata:
name: label-policy
namespace: sec-team-b
data:
requiredLabels: "app.kubernetes.io/name, team, cost-center"

This Deployment satisfies all three policies in team A: it has a memory limit, a pinned image, and both of team A's labels:

apiVersion: apps/v1
kind: Deployment
metadata:
name: catalog-api
namespace: sec-team-a
labels:
app.kubernetes.io/name: catalog-api
team: catalog
spec:
replicas: 1
selector:
matchLabels:
app: catalog-api
template:
metadata:
labels:
app: catalog-api
spec:
containers:
- name: api
image: traefik/whoami:v1.11.0
resources:
requests:
cpu: 50m
memory: 32Mi
limits:
memory: 64Mi
kubectl apply -f 6-label-policy-params.yaml
kubectl apply -f 7-compliant-deployment.yaml
deployment.apps/catalog-api created

The identical Deployment in team B's namespace meets team B's stricter rule only partly:

sed 's/namespace: sec-team-a/namespace: sec-team-b/' 7-compliant-deployment.yaml | kubectl apply -f -
The deployments "catalog-api" is invalid: : ValidatingAdmissionPolicy 'require-team-labels' with binding 'require-team-labels' denied request: deployment catalog-api is missing required labels: cost-center

When several policies fail, you see one at a time. A quick kubectl create deployment in team A, which sets neither limits nor labels, is stopped by the first failing policy:

kubectl create deployment quick-test --image=traefik/whoami:v1.11.0 -n sec-team-a
error: failed to create deployment: deployments.apps "quick-test" is forbidden: ValidatingAdmissionPolicy 'require-memory-limits' with binding 'require-memory-limits' denied request: every container must set resources.limits.memory; missing in: whoami

The overview of everything you created:

kubectl get validatingadmissionpolicies,validatingadmissionpolicybindings
NAME VALIDATIONS PARAMKIND AGE
validatingadmissionpolicy.admissionregistration.k8s.io/require-memory-limits 1 <unset> 94s
validatingadmissionpolicy.admissionregistration.k8s.io/require-pinned-image-tags 1 <unset> 56s
validatingadmissionpolicy.admissionregistration.k8s.io/require-team-labels 1 v1/ConfigMap 30s

NAME POLICYNAME PARAMREF AGE
validatingadmissionpolicybinding.admissionregistration.k8s.io/require-memory-limits require-memory-limits <unset> 94s
validatingadmissionpolicybinding.admissionregistration.k8s.io/require-pinned-image-tags require-pinned-image-tags <unset> 56s
validatingadmissionpolicybinding.admissionregistration.k8s.io/require-team-labels require-team-labels */label-policy 30s

*/label-policy means "the ConfigMap label-policy in whatever namespace the request is for".

Step 7 — Fix Instead of Reject: MutatingAdmissionPolicy

Rejecting a Deployment because it lacks a team label is correct but unfriendly when the platform already knows the team: it is the namespace. A MutatingAdmissionPolicy changes the object instead. It uses the same CEL, the same binding model, and runs in the mutating phase, before validation. In Kubernetes 1.36 it is served as admissionregistration.k8s.io/v1; on older clusters check kubectl api-resources | grep mutatingadmission first.

apiVersion: admissionregistration.k8s.io/v1
kind: MutatingAdmissionPolicy
metadata:
name: default-team-label
spec:
matchConstraints:
resourceRules:
- apiGroups: ["apps"]
apiVersions: ["v1"]
operations: ["CREATE"]
resources: ["deployments"]
matchConditions:
- name: team-label-missing
expression: "!has(object.metadata.labels) || !('team' in object.metadata.labels)"
failurePolicy: Fail
reinvocationPolicy: IfNeeded
mutations:
- patchType: ApplyConfiguration
applyConfiguration:
expression: >-
Object{
metadata: Object.metadata{
labels: {"team": namespaceObject.metadata.name.replace('sec-', '')}
}
}
---
apiVersion: admissionregistration.k8s.io/v1
kind: MutatingAdmissionPolicyBinding
metadata:
name: default-team-label
spec:
policyName: default-team-label
matchResources:
namespaceSelector:
matchLabels:
policy-enforcement: enabled
  1. matchConditions limits the mutation to Deployments that do not have a team label, so an explicit label is never overwritten.
  2. The ApplyConfiguration patch describes the fields to merge in, here one label whose value comes from the namespace name.

Deploy something to team B with cost-center but no team:

apiVersion: apps/v1
kind: Deployment
metadata:
name: search-api
namespace: sec-team-b
labels:
app.kubernetes.io/name: search-api
cost-center: cc-1042
spec:
replicas: 1
selector:
matchLabels:
app: search-api
template:
metadata:
labels:
app: search-api
spec:
containers:
- name: api
image: traefik/whoami:v1.11.0
resources:
limits:
memory: 64Mi
kubectl apply -f 8-mutating-policy.yaml
kubectl apply -f 9-deployment-without-team.yaml
deployment.apps/search-api created
kubectl get deploy search-api -n sec-team-b --show-labels
NAME READY UP-TO-DATE AVAILABLE AGE LABELS
search-api 0/1 1 0 0s app.kubernetes.io/name=search-api,cost-center=cc-1042,team=team-b

The mutating policy added team=team-b, and the validating label policy, which runs afterwards, accepted the result. Mutation first, validation second: that order is what makes the two work together.

Step 8 — Clean Up

Policies and bindings are cluster-scoped, so deleting the namespaces does not remove them. Delete them explicitly:

kubectl delete validatingadmissionpolicybinding require-memory-limits require-pinned-image-tags require-team-labels
kubectl delete validatingadmissionpolicy require-memory-limits require-pinned-image-tags require-team-labels
kubectl delete mutatingadmissionpolicybinding default-team-label
kubectl delete mutatingadmissionpolicy default-team-label
kubectl delete namespace sec-team-a sec-team-b

When to Use Kyverno or Gatekeeper Instead

Built-in policies cover most "this object must look like that" rules. A policy engine is still worth its extra moving parts when you need:

NeedBuilt-in policiesKyverno / Gatekeeper
Validate or mutate a single objectYesYes
Rules that look up other objects, such as "an Ingress host must be unique in the cluster"No; only the object, its namespace and the parameter are visibleYes
Generate new objects, such as a default NetworkPolicy in every new namespaceNoKyverno
Report on objects that already exist and violate a ruleNo; admission sees requests onlyYes, through background scans and policy reports
Verify image signaturesNoKyverno, or Sigstore's policy-controller
Policy libraries and a policy-as-code workflowWrite your ownLarge libraries of ready-made policies

A common setup uses built-in policies for simple, critical guardrails, which work even if a webhook is down, and a policy engine for the rest.

Summary

  1. ValidatingAdmissionPolicy runs CEL rules inside the API server, with no webhook to deploy or keep available.
  2. A policy says what to check; a binding says where (namespace and object selectors) and how (Deny, Warn, Audit). Always scope bindings with a selector.
  3. messageExpression produces errors that name the exact container, image or label to fix. variables keep expressions readable and handle Pods and Pod templates in one policy.
  4. Roll out with Warn, then patch the binding to Deny. Existing objects are never re-checked.
  5. paramKind with a paramRef that has no namespace reads parameters from the request's own namespace: one policy, different settings per team.
  6. Type-checking warnings in status.typeChecking catch typos but are not authoritative; on v1.36 they flag resources.limits falsely. Always test with real requests or --dry-run=server.
  7. MutatingAdmissionPolicy fills in defaults with the same CEL and binding model, and runs before validation.
  8. Reach for Kyverno or Gatekeeper when rules need other objects, resource generation, background reports or image verification.

This completes the Kubernetes Security subcategory: RBAC and ServiceAccounts for identity, Pod Security for the node, NetworkPolicies for traffic, and admission policies for your own rules.