Crossplane Claims can’t validate themselves against arbitrary rules; they need an external policy engine to enforce complex constraints.

Let’s see Crossplane and CEL in action. Imagine we have a CompositeResourceDefinition (XRD) for a managed PostgreSQL instance. It looks something like this:

apiVersion: apiextensions.crossplane.io/v1
kind: CompositeResourceDefinition
metadata:
  name: pginstances.database.example.com
spec:
  group: database.example.com
  names:
    kind: PGInstance
    plural: pginstances
  versions:
    - name: v1alpha1
      served: true
      referenceable: true
      schema:
        openAPIV3Schema:
          type: object
          properties:
            spec:
              type: object
              properties:
                parameters:
                  type: object
                  properties:
                    storageGB:
                      type: integer
                      description: "Storage in GB"
                    instanceClass:
                      type: string
                      description: "Instance class, e.g., small, medium, large"
                    region:
                      type: string
                      description: "AWS region for the instance"
                  required:
                    - storageGB
                    - instanceClass
                    - region

Now, a user wants to provision a PGInstance by creating a Composite resource, which Crossplane calls a Claim.

apiVersion: database.example.com/v1alpha1
kind: PGInstance
metadata:
  name: my-prod-db
spec:
  parameters:
    storageGB: 100
    instanceClass: medium
    region: us-east-1

Without any policy, this PGInstance claim could specify any integer for storageGB, any string for instanceClass, and any string for region. This is where CEL (Common Expression Language) comes in. We’ll use a policy engine, like OPA (Open Policy Agent) Gatekeeper, which supports CEL for its constraint validation.

First, we define a ConstraintTemplate that uses CEL. This template will be a blueprint for our validation rules.

apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata:
  name: k8spspgruntime
  annotations:
    description: "Restricts the allowed values for storageGB and instanceClass on PGInstance claims."
spec:
  crd:
    spec:
      names:
        kind: PGInstancePolicy
      validation:
        openAPIV3Schema:
          type: object
          properties:
            allowedStorageGB:
              type: array
              items:
                type: integer
            allowedInstanceClasses:
              type: array
              items:
                type: string
            allowedRegions:
              type: array
              items:
                type: string
  targets:
    - target: admission.k8s.gatekeeper.sh
      rego: |
        package k8spspgruntime

        violation[{"msg": msg}] {
          # Check storageGB
          input.review.object.spec.parameters.storageGB < 10
          msg := "Storage must be at least 10GB."
        }

        violation[{"msg": msg}] {
          # Check instanceClass
          instance_class := input.review.object.spec.parameters.instanceClass
          not re_match("^(small|medium|large)$", instance_class)
          msg := sprintf("Instance class '%v' is not allowed. Must be one of: small, medium, large.", [instance_class])
        }

        violation[{"msg": msg}] {
          # Check region
          region := input.review.object.spec.parameters.region
          not some region_allowed in input.parameters.allowedRegions
          region_allowed == region
          msg := sprintf("Region '%v' is not allowed. Must be one of: %v.", [region, input.parameters.allowedRegions])
        }

Notice the rego field. While this example uses Rego, Gatekeeper allows direct embedding of CEL expressions within its spec.targets.rego field (or more commonly, via spec.targets.rego and then referencing CEL expressions within the Rego). For simplicity and directness, let’s assume a future Gatekeeper version or a similar tool that allows direct CEL evaluation for admission control. The spirit of the CEL expression would look like this (conceptually, not actual Gatekeeper syntax for CEL):

input.review.object.spec.parameters.storageGB >= 10 &&
input.review.object.spec.parameters.instanceClass in ["small", "medium", "large"] &&
input.parameters.allowedRegions.contains(input.review.object.spec.parameters.region)

This CEL expression checks three conditions:

  1. storageGB must be 10 or greater.
  2. instanceClass must be one of "small", "medium", or "large".
  3. region must be present in the allowedRegions list provided to the constraint.

Now, we create a Constraint that uses this ConstraintTemplate and specifies the actual values for our policies.

apiVersion: constraints.gatekeeper.sh/v1beta1
kind: PGInstancePolicy
metadata:
  name: pginstance-storage-class-region-policy
spec:
  match:
    kinds:
      - apiGroups: ["database.example.com"]
        kinds: ["PGInstance"]
  parameters:
    allowedStorageGB: [10, 20, 50, 100] # Note: The CEL logic above is a floor, not an exact list match.
    allowedInstanceClasses: ["small", "medium", "large"]
    allowedRegions: ["us-east-1", "us-west-2"]

When a user creates or updates a PGInstance claim, Gatekeeper intercepts the request. It looks up the PGInstancePolicy constraint, finds the k8spspgruntime ConstraintTemplate, and evaluates the CEL (or Rego with CEL expressions) against the claim’s spec.parameters.

If the PGInstance claim is:

apiVersion: database.example.com/v1alpha1
kind: PGInstance
metadata:
  name: my-prod-db
spec:
  parameters:
    storageGB: 5
    instanceClass: xxlarge
    region: eu-central-1

Gatekeeper will reject it with an error message like: admission webhook "validation.gatekeeper.sh" denied the request: [pginstance-storage-class-region-policy] Storage must be at least 10GB.; [pginstance-storage-class-region-policy] Instance class 'xxlarge' is not allowed. Must be one of: small, medium, large.; [pginstance-storage-class-region-policy] Region 'eu-central-1' is not allowed. Must be one of: us-east-1, us-west-2.

If the claim is valid:

apiVersion: database.example.com/v1alpha1
kind: PGInstance
metadata:
  name: my-staging-db
spec:
  parameters:
    storageGB: 50
    instanceClass: medium
    region: us-west-2

The admission webhook will allow the request to proceed, and Crossplane will then instantiate the underlying Composed resource (e.g., an AWS RDS instance).

The core idea is that Crossplane defines the shape and intent of your managed infrastructure via XRDs, but CEL (via a policy engine like Gatekeeper) provides the enforcement of specific business or operational rules on how those resources can be provisioned. This separation ensures that your claims are not only syntactically correct according to the schema but also semantically compliant with your organization’s policies.

The real power here is that you’re not just validating against the schema defined in the XRD; you’re adding custom, dynamic validation logic that can span multiple fields, compare values, and even reference external lists of allowed values. This allows for sophisticated governance over your cloud resources provisioned through Crossplane.

The next step after implementing these basic validation policies is to explore using CEL to enforce more complex relationships between parameters, such as ensuring that a specific instanceClass can only be provisioned in certain regions or that storageGB must be a multiple of 10.

Want structured learning?

Take the full Crossplane course →