Get the Zoom link
Cloud Security Office Hours Banner

CEL policy expressions, hands-on

CEL is small on purpose: no loops, no side effects, guaranteed to terminate. That is why it runs inside the Kubernetes API server and inside Google Cloud IAM, and why it is now the default way to write admission policy.

Jump to the walk-through All how-to guides

· · Vendor-neutral

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:

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

  1. Expressions, macros, and the activation
  2. Walk-through
  3. Hands-on exercises
  4. Common mistakes
  5. Where next

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:

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: development

That 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: false

Google 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.

Exercise 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.

Exercise 2

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.

Exercise 3

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.

Exercise 4

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

Where next