Time: ~90 minutes · Difficulty: Intermediate · You need: Python for the expression lab; Docker and kind if you want to run the admission policy for real
The policy language that lives inside the thing it governs
Common Expression Language is deliberately small. It is not Turing complete, it has no side effects, and every expression is guaranteed to terminate. Those three properties sound like limitations, and they are exactly why CEL is everywhere: if evaluation cannot loop forever and cannot reach out to the network, it is safe to run inside a hot path.
So CEL runs where a general-purpose policy engine cannot. It is embedded in the Kubernetes API server itself, in Google Cloud IAM's condition evaluator, in Envoy, in Firebase security rules, and in CRD field validation. There is no sidecar, no webhook, no extra network hop, and nothing to be unavailable at the moment a decision is needed.
That is the real contrast with OPA and Rego, and it is worth having a clear view of it because the two are increasingly presented as alternatives:
- Rego is a full policy language evaluated by a separate engine. It can join across documents, load external data, and express arbitrarily complex logic. It costs you a component to deploy, secure, and keep available.
- CEL is an expression evaluated in-process by the host. It cannot do joins or fetch data. It also cannot be down, cannot add latency you did not already have, and cannot be bypassed by a network failure.
For "no privileged containers in this namespace", CEL is straightforwardly the better answer now that it is built in. For "no image outside our registry unless the team owns a documented exception recorded in this other system", it is the wrong tool and no amount of cleverness fixes that.
Learning CEL is two separate jobs, and only one of them is the language. The syntax is small enough to learn in an afternoon. The other job, which is where the actual time goes, is learning what variables each host hands you. Kubernetes admission gives you object, oldObject, request, params, authorizer, namespaceObject, and variables. GCP IAM gives you request.time, resource.name, resource.type, and friends. Same language, entirely different vocabulary.
On this page
Expressions, macros, and the activation
A CEL policy is one expression that evaluates to a boolean. There is no statement sequence, no assignment, no early return, and no function you define yourself. If you are reaching for those, you have hit the ceiling and should be looking at a different layer.
Iteration exists, but only through macros, and only bounded by a collection you already have:
list.all(x, predicate)is true when every element satisfies the predicate.list.exists(x, predicate)is true when at least one does.list.exists_one(x, predicate)is true when exactly one does.list.filter(x, predicate)returns the matching elements; wrap insize()to count them.list.map(x, expression)transforms each element.has(x.field)tests presence, which is not the same as testing truth.
That last one is the thing to internalise first. Reading a field that is not there is an error in CEL, not a false and not a null. An error propagates and the whole expression fails, which in Kubernetes admission means the policy's failurePolicy decides your fate. has() before you dereference an optional field is not defensive style, it is the difference between a working policy and one that fails open or blocks every deployment.
# Errors, if any container omits securityContext: object.spec.containers.all(c, c.securityContext.runAsNonRoot == true) # Correct: presence-check first. && short-circuits, so the right-hand # side is never evaluated when has() is false. object.spec.containers.all(c, has(c.securityContext) && c.securityContext.runAsNonRoot == true)
Walk-through
1. Get an evaluator you can iterate in
Writing CEL by pushing YAML at a cluster and reading admission errors is a miserable loop. Evaluate expressions locally first.
mkdir -p ~/cel-lab && cd ~/cel-lab
pip3 install cel-python
cat > pod.json <<'EOF'
{"object": {"metadata": {"name": "web", "labels": {"team": "payments"}},
"spec": {"replicas": 7,
"containers": [
{"name": "app", "image": "ghcr.io/acme/app:1.2.3",
"securityContext": {"runAsNonRoot": true, "privileged": false},
"resources": {"limits": {"cpu": "500m", "memory": "512Mi"}}},
{"name": "sidecar", "image": "docker.io/library/busybox:latest"}
]}}}
EOF
cat > cel.py <<'EOF'
import sys, json, celpy
env = celpy.Environment()
doc = celpy.json_to_cel(json.load(open('pod.json')))
for src in sys.argv[1:]:
print(f"{src}\n -> {env.program(env.compile(src)).evaluate(doc)}\n")
EOF
python3 cel.py 'object.spec.replicas <= 5'2. The syntax, in one pass
python3 cel.py \
'object.metadata.name == "web"' \
'object.spec.replicas > 5 && object.spec.replicas < 20' \
'"team" in object.metadata.labels' \
'object.metadata.labels["team"] == "payments"' \
'object.metadata.name.startsWith("web")' \
'size(object.spec.containers) == 2' \
'object.spec.replicas > 5 ? "too many" : "ok"'C-like, and mostly unsurprising. Strings are double-quoted or single-quoted, in tests map keys and list membership, and there is a ternary. The standard library gives you startsWith, endsWith, contains, matches (a regular expression, RE2 flavoured, so no lookaround), size, and duration and timestamp arithmetic.
3. Macros, which is where real policies live
python3 cel.py \
'object.spec.containers.all(c, has(c.resources) && has(c.resources.limits))' \
'object.spec.containers.exists(c, c.image.endsWith(":latest"))' \
'object.spec.containers.map(c, c.name)' \
'size(object.spec.containers.filter(c, !has(c.securityContext)))' \
'object.spec.containers.all(c, c.image.startsWith("ghcr.io/acme/"))'Run these against the sample and read the results carefully. The sidecar container has no securityContext and no resources, and pulls :latest from Docker Hub, so it fails three of the five. That is a deliberately realistic shape: the workload people wrote carefully, plus the container somebody added in a hurry.
4. Write a real ValidatingAdmissionPolicy
This is native Kubernetes admission control, generally available since Kubernetes 1.30. No webhook, no controller, no certificate rotation.
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
name: "require-resource-limits.csoh.org"
spec:
# Fail = an error evaluating the policy denies the request.
# Ignore = an error lets it through. Choose deliberately.
failurePolicy: Fail
# Coarse filter: which API objects this policy is even considered for.
matchConstraints:
resourceRules:
- apiGroups: ["apps"]
apiVersions: ["v1"]
operations: ["CREATE", "UPDATE"]
resources: ["deployments"]
validations:
- expression: >-
object.spec.template.spec.containers.all(c,
has(c.resources) && has(c.resources.limits) &&
has(c.resources.limits.memory))
message: "every container must set a memory limit"
- expression: >-
object.spec.template.spec.containers.all(c,
!c.image.endsWith(":latest"))
message: "image tags must be pinned; :latest is not a version"The policy on its own does nothing. It needs a binding, and the separation is the good part: one policy, several bindings, each scoped differently and each choosing its own enforcement strength.
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
name: "require-resource-limits-prod.csoh.org"
spec:
policyName: "require-resource-limits.csoh.org"
# Deny | Warn | Audit. Warn and Audit can be combined; Deny stands alone.
validationActions: [Deny]
matchResources:
namespaceSelector:
matchLabels:
environment: production
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
name: "require-resource-limits-dev.csoh.org"
spec:
policyName: "require-resource-limits.csoh.org"
validationActions: [Warn, Audit] # same rule, observe-only in dev
matchResources:
namespaceSelector:
matchLabels:
environment: developmentThat two-binding pattern is how you roll a policy out without an outage. Bind it as [Warn, Audit] everywhere first, read the audit log for a week to find out what it would have blocked, and only then add a Deny binding scoped to the namespaces you have cleaned up.
5. Try it on a throwaway cluster
kind create cluster --name cel-lab # any recent Kubernetes; VAP is GA in 1.30+ kubectl apply -f policy.yaml kubectl create namespace prod kubectl label namespace prod environment=production # Should be rejected, with your message kubectl -n prod create deployment bad --image=nginx:latest kind delete cluster --name cel-lab
6. Two other places the same expressions turn up
CRD field validation. The same CEL, attached to a schema, so an invalid custom resource is rejected on write with no controller involved:
openAPIV3Schema:
type: object
properties:
spec:
type: object
properties:
retentionDays: { type: integer }
tier: { type: string }
x-kubernetes-validations:
- rule: "self.tier != 'archive' || self.retentionDays >= 365"
message: "archive tier requires at least 365 days retention"
# self.oldSelf is available for transition rules on update
- rule: "self.retentionDays >= oldSelf.retentionDays"
message: "retention may be increased but never reduced"
optionalOldSelf: falseGoogle Cloud IAM conditions. Same language, completely different variables, and a genuinely useful control:
# Grant applies only during a maintenance window, and only to one bucket.
request.time < timestamp("2026-09-01T00:00:00Z") &&
resource.type == "storage.googleapis.com/Bucket" &&
resource.name.startsWith("projects/_/buckets/acme-backups")Time-bound IAM grants are one of the highest-value, least-used features in Google Cloud, and they are a CEL expression in a text box. The same instinct works for AWS through IAM condition keys and for Azure through ABAC conditions, though neither of those uses CEL.
7. The authorizer variable
Kubernetes admission hands your expression an authorizer, which lets a policy ask the API server's own RBAC a question. This is unusual and worth knowing about, because it lets you write "this is allowed, but only for principals who already hold a specific permission" without maintaining a parallel list of who those are.
validations:
- expression: >-
!object.spec.template.spec.hostNetwork ||
authorizer.group('policy.csoh.org').resource('hostnetwork')
.check('use').allowed()
message: "hostNetwork requires the hostnetwork/use permission"The privilege becomes an ordinary RBAC grant on a virtual resource, which means it is granted, reviewed, and audited by the same machinery as everything else, rather than by an annotation somebody added to a namespace two years ago.
Hands-on exercises
Run these with the cel.py harness and pod.json from step 1.
Write one expression that is true only when every container both sets runAsNonRoot: true and does not run privileged. Remember that securityContext is optional.
Show the answer
python3 cel.py 'object.spec.containers.all(c, has(c.securityContext) && c.securityContext.runAsNonRoot == true && (!has(c.securityContext.privileged) || c.securityContext.privileged == false))'
Two presence checks, at two different depths. The nested one matters because privileged is frequently absent even when securityContext exists, and its absence means false, which is what you want. Writing c.securityContext.privileged == false alone would error on exactly the compliant pods you were trying to allow.
Against the sample this returns false, because of sidecar. Confirm that is the reason rather than assuming: object.spec.containers.filter(c, !has(c.securityContext)).map(c, c.name) names it.
What does object.spec.nodeName == "" evaluate to, given that pod.json has no nodeName? Predict first, then run it.
Show the answer
It is an error, not false. There is no such key, so the field selection fails and the error propagates out of the whole expression.
This is the most consequential difference between CEL and the languages most people arrive from. In JavaScript you get undefined; in Rego the rule becomes undefined and quietly contributes nothing; in CEL you get a hard error, and inside Kubernetes admission that error is resolved by failurePolicy. With Fail, a typo in a field name blocks every matching deployment in the cluster. With Ignore, the same typo silently disables the control.
The correct forms are has(object.spec.nodeName), or !has(object.spec.nodeName) || object.spec.nodeName == "" if you want to treat absent and empty alike.
Write a validation that permits a Deployment's replica count to increase but never decrease. Which admission variable do you need, and what breaks on CREATE?
Show the answer
validations:
- expression: >-
object.spec.replicas >= oldObject.spec.replicas
message: "replica count may not be reduced"oldObject is only populated for UPDATE and DELETE. On CREATE it is null, so dereferencing it errors. Two fixes, and the second is the better habit:
# Guard inside the expression
- expression: >-
oldObject == null || object.spec.replicas >= oldObject.spec.replicas
# Or keep the expression clean and narrow matchConstraints to UPDATE only
matchConstraints:
resourceRules:
- apiGroups: ["apps"]
apiVersions: ["v1"]
operations: ["UPDATE"] # CREATE never reaches this policy
resources: ["deployments"]Narrowing the match is preferable when it fits, because it makes the policy's scope legible from the YAML rather than buried in a conditional. It also costs nothing at evaluation time: requests that do not match are never evaluated at all.
Take this Rego rule and express it as a CEL validation. Then name one thing the Rego version can do that the CEL version cannot.
deny contains msg if {
some c in input.request.object.spec.containers
not startswith(c.image, "ghcr.io/acme/")
msg := sprintf("image %v is not from the approved registry", [c.image])
}Show the answer
validations:
- expression: >-
object.spec.containers.all(c, c.image.startsWith("ghcr.io/acme/"))
message: "all images must come from ghcr.io/acme/"The Rego version names the offending image in its message. CEL's message is a fixed string, so the reply cannot say which container failed. There is a messageExpression field that evaluates CEL to build the message, which recovers most of this, and it is worth using: a denial that does not say what to fix generates a support ticket every time it fires.
The larger thing Rego can do is consult data: an allowlist of registries loaded from elsewhere, a mapping of namespaces to permitted teams, anything requiring a join. CEL has no equivalent. Kubernetes closes part of that gap with paramKind and params, which let a binding point at a real cluster object holding configuration, so the allowlist becomes a resource you can RBAC and version rather than a literal in the expression. That is a genuinely good pattern and worth reaching for the moment a policy contains a hardcoded list.
Common mistakes
- Dereferencing an optional field without
has(). The result is an error, and what happens next is decided byfailurePolicyrather than by you. - Choosing
failurePolicyby accident.Failturns a typo into a cluster-wide outage;Ignoreturns it into a control that silently is not there. Both are defensible; neither should be a default you never looked at. - Going straight to
Deny. Bind as[Warn, Audit], read what it would have blocked, then enforce. - Assuming
oldObjectexists. It is null on CREATE. Guard it or restrict the operations you match. - Writing a policy with no binding. A
ValidatingAdmissionPolicyalone does nothing at all, and nothing warns you. It looks deployed. - Expecting PCRE in
matches(). It is RE2, so no lookahead, no lookbehind, no backreferences. Same constraint the regex page covers. - Hardcoding lists in expressions. Use
paramKindandparamsso the list is a reviewable cluster object. - Ignoring the cost budget. Kubernetes bounds how expensive an expression may be and rejects ones that exceed it. Deeply nested macros over large collections are the usual cause, and the rejection happens when you apply the policy, not when it runs.
Where next
- OPA and Rego for the comparison this page keeps making, and for the cases CEL genuinely cannot reach.
- IAM policy languages, where the GCP IAM conditions above sit alongside AWS condition keys and Cedar.
- Kubernetes security for admission control in the context of the rest of the cluster's controls.
- The Kubernetes security lab if you want a cluster to enforce these against, with something worth blocking already running in it.
- cel.dev for the language spec, and the Kubernetes CEL reference for the variables and limits that apply inside the API server.
