Lesson 57 of 66 · 17 min read · Updated
ValidatingAdmissionPolicy — Enforcing Cluster Rules with CEL
On this page
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:
- Where admission policies run in the life of an API request
- The two objects: ValidatingAdmissionPolicy and ValidatingAdmissionPolicyBinding
- Writing CEL expressions, with variables and readable error messages
- Rolling out safely with
Warn, then switching toDeny - Scoping a policy to selected namespaces
- Parameterising a policy with a ConfigMap, so each namespace can have different values
- Reading type-checking results, and their limits
- MutatingAdmissionPolicy, for changing objects instead of rejecting them
- When Kyverno or Gatekeeper is still the better tool
Technical Requirements
- 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).
kubectl. No application code is needed.- 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:
- Authentication: who are you? (ServiceAccount token, certificate, OIDC)
- Authorization: may you do this? (RBAC)
- Mutating admission: built-in plugins, mutating webhooks and MutatingAdmissionPolicies may change the object.
- Schema validation: is the object well-formed?
- Validating admission: built-in plugins such as Pod Security Admission, validating webhooks and ValidatingAdmissionPolicies may reject the object.
- 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:
| Object | Answers |
|---|---|
ValidatingAdmissionPolicy | What is checked: which resources and operations it applies to (matchConstraints), the CEL expressions, the error messages, and optionally the kind of its parameters |
ValidatingAdmissionPolicyBinding | Where 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:
| Variable | Contains |
|---|---|
object | The incoming object, or null for DELETE |
oldObject | The existing object on UPDATE, null on CREATE |
request | Request details such as request.userInfo.username and request.operation |
namespaceObject | The namespace the object is in |
params | The parameter object selected by the binding |
variables | Named sub-expressions you define in the policy |
authorizer | Lets you run RBAC checks from the expression |
The operations you will use most:
| Expression | Meaning |
|---|---|
has(object.metadata.labels) | The field exists |
'team' in object.metadata.labels | A 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 : b | Conditional |
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:
| Action | Effect |
|---|---|
Deny | The request is rejected with the message |
Warn | The request succeeds and the client shows a warning, just like Pod Security Admission's warn mode |
Audit | The 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.
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.
Test a manifest against all policies without creating it
A server-side dry run runs the full admission chain, including your policies.
Switch a binding from Warn to Deny
Patch only the action; the policy itself stays the same.
See which namespaces a binding will affect
Use the same selector the binding uses.
Explore the API
kubectl explain documents every field, including the newest ones.
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:
- Every container in a Deployment, StatefulSet or DaemonSet sets a memory limit, so a memory leak cannot take down a node.
- No container image uses
:latestor no tag at all, so a restart cannot silently change the code that runs. - Every Deployment carries the labels its team has agreed to: team A requires
app.kubernetes.io/nameandteam, and team B additionally requirescost-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:
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:
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.
Try a Deployment with two containers, where only the sidecar has a limit:
The message names the exact container to fix. The same manifest in a namespace without the label is not affected:
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:
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:
Each variable builds on the previous one:
podSpecpicks the right path for the object's kind.allContainersadds init containers, which are easy to forget.unpinnedkeeps images that have no digest and either no tag or thelatesttag. The regular expression:[^/:]+$means "a colon followed by a tag at the very end". It deliberately ignores a registry port such asregistry.local:5000/team/app, which has a colon but no tag.
The binding starts with Warn. Nothing breaks, and everyone sees what would break:
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:
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:
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:
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:
Now give each team its parameters:
This Deployment satisfies all three policies in team A: it has a memory limit, a pinned image, and both of team A's labels:
The identical Deployment in team B's namespace meets team B's stricter rule only partly:
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:
The overview of everything you created:
*/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.
matchConditionslimits the mutation to Deployments that do not have ateamlabel, so an explicit label is never overwritten.- The
ApplyConfigurationpatch 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:
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:
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:
| Need | Built-in policies | Kyverno / Gatekeeper |
|---|---|---|
| Validate or mutate a single object | Yes | Yes |
| 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 visible | Yes |
| Generate new objects, such as a default NetworkPolicy in every new namespace | No | Kyverno |
| Report on objects that already exist and violate a rule | No; admission sees requests only | Yes, through background scans and policy reports |
| Verify image signatures | No | Kyverno, or Sigstore's policy-controller |
| Policy libraries and a policy-as-code workflow | Write your own | Large 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
- ValidatingAdmissionPolicy runs CEL rules inside the API server, with no webhook to deploy or keep available.
- 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. messageExpressionproduces errors that name the exact container, image or label to fix.variableskeep expressions readable and handle Pods and Pod templates in one policy.- Roll out with
Warn, then patch the binding toDeny. Existing objects are never re-checked. paramKindwith aparamRefthat has no namespace reads parameters from the request's own namespace: one policy, different settings per team.- Type-checking warnings in
status.typeCheckingcatch typos but are not authoritative; on v1.36 they flagresources.limitsfalsely. Always test with real requests or--dry-run=server. - MutatingAdmissionPolicy fills in defaults with the same CEL and binding model, and runs before validation.
- 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.