Module 6: Runtime Security Admission Control And Image Scanning

3. OPA Gatekeeper: `ConstraintTemplate` and `Constraint`

Description

Lesson 2 made clear where an admission controller runs (phase 3 of a request's lifecycle, before etcd). This lesson resolves how OPA Gatekeeper — the first of this module's two engines — turns a Rego rule, the same declarative language cloud-security-and-guardrails-guide already used with conftest, into an object Kubernetes understands, validates against its own schema, and applies live against every Pod trying to be born. Lesson 4 installs Gatekeeper and actually runs this; this lesson, first, separates the concept from the syntax, exactly as cloud-security-and-guardrails-guide's Module 4, lesson 2 did before installing conftest.

Connection to the module

Gatekeeper doesn't reinvent Rego — it packages it. The new piece this lesson teaches isn't the language (you already know it if you worked through cloud-security-and-guardrails-guide), it's the container: two Kubernetes Custom Resource Definitions (CRDs), ConstraintTemplate and Constraint, that turn a .rego file — something conftest reads off disk — into a Kubernetes object living inside the cluster, with its own apiVersion, its own kind, and its own lifecycle managed by kubectl.


conftest versus Gatekeeper: the same language, two completely different architectures

Before diving into Gatekeeper's syntax, it's worth fixing this comparison, because it's the most common conceptual mistake when arriving from cloud-security-and-guardrails-guide:

   conftest (cloud-security-and-guardrails-guide, M4)     Gatekeeper (this module)

   ┌──────────────────┐                                    ┌──────────────────────┐
   │  terraform plan    │                                    │  kubectl apply -f      │
   │  (manual command)  │                                    │  pod.yaml (or ArgoCD)  │
   └─────────┬──────────┘                                    └──────────┬────────────┘
             │ generates                                                 │ HTTP request
             ▼                                                          ▼
   ┌──────────────────┐        OUTSIDE THE CLUSTER          ┌──────────────────────┐
   │  plan.json          │        (file on disk)               │  kube-apiserver         │  INSIDE THE CLUSTER
   └─────────┬──────────┘                                    └──────────┬────────────┘  (HTTP webhook)
             │ conftest test plan.json                                   │ forwards the object
             ▼                                                          ▼
   ┌──────────────────┐                                    ┌──────────────────────┐
   │  OPA engine         │                                    │  gatekeeper-             │
   │  (CLI process,       │                                    │  controller-manager     │
   │   runs and exits)    │                                    │  (Pod that lives         │
   └─────────┬──────────┘                                    │   permanently)           │
             │ PASS / FAIL                                    └──────────┬────────────┘
             ▼                                                          │ allowed: true/false
   your terminal, your CI                                                 ▼
   (nothing has been created                                 the object EXISTS or NEVER
    in real infrastructure yet)                               got to exist

The difference isn't one of rigor or quality — both are real OPA engines, evaluating real Rego. It's one of architecture and timing:

  • conftest is a binary that runs and exits. You invoke it from your terminal or from a CI job, hand it a file (plan.json), it evaluates, prints PASS/FAIL, and the process dies. There's no conftest component running permanently anywhere — it doesn't exist until you run it, and it stops existing the moment it finishes.
  • Gatekeeper is a Deployment living inside the cluster, indefinitely. You don't "invoke" it — it's always there, running three replicas (you're going to see this in lesson 4), waiting for kube-apiserver to forward it every new object via lesson 2's ValidatingWebhookConfiguration. You never call it directly; kube-apiserver does it for you, on every kubectl apply, with no one having to remember to run any command.
  • The input each one evaluates is fundamentally different in nature. conftest's input is a plan.json file — a snapshot of what Terraform plans to do, generated by an explicit command, at a controlled moment. Gatekeeper's input is the complete AdmissionReview JSON object kube-apiserver sends it in real time — there's no intermediate "generate a plan" step at all: the object you're trying to create is the input, at the exact instant of the attempt.

This is the exact technical reason this guide's Module 1 citation (lesson 1) distinguishes "a Terraform plan outside the cluster" from "live objects, inside the cluster" — it isn't a decorative sentence, it's the complete architectural difference between the two tools.


ConstraintTemplate: the rule, without yet saying what it applies to

A ConstraintTemplate is a Kubernetes object — apiVersion: templates.gatekeeper.sh/v1 — that does two things at once: it defines the Rego logic of a rule, and it defines the shape (the schema) of the parameters that rule is going to accept. It's, in spirit, like declaring a reusable function: you write the logic once, then apply it with different parameters as many times as you want.

apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata:
  name: k8srequiredresources
spec:
  crd:
    spec:
      names:
        kind: K8sRequiredResources
      validation:
        openAPIV3Schema:
          type: object
          properties:
            limits:
              type: array
              items:
                type: string
  targets:
    - target: admission.k8s.gatekeeper.sh
      rego: |
        package k8srequiredresources

        violation[{"msg": msg}] {
          container := input.review.object.spec.containers[_]
          required := input.parameters.limits
          provided := {key | container.resources.limits[key]}
          missing := {key | key := required[_]; not provided[key]}
          count(missing) > 0
          msg := sprintf("container <%v> is missing required resource limits: %v", [container.name, missing])
        }

Four pieces, each with a concrete job:

  • spec.crd.spec.names.kind: K8sRequiredResources — this is the name Kubernetes is going to use for the new resource type Gatekeeper automatically creates from this ConstraintTemplate. After applying this YAML, kubectl get k8srequiredresources is going to work as a valid command, exactly like kubectl get pods or kubectl get deployments — Gatekeeper registers a new CRD, live, with no need for you to hand-write the CRD yourself.
  • spec.crd.spec.validation.openAPIV3Schema — declares what parameters every Constraint using this template is going to accept (in this case, a limits field, an array of strings). This is what makes the same Rego logic reusable: next time you need to require a different resource — for example, ephemeral-storage — you don't rewrite the rule, you just change the parameter.
  • spec.targets[].target: admission.k8s.gatekeeper.sh — tells Gatekeeper this rule evaluates Kubernetes objects at the moment of admission (unlike other possible target values, outside this module's scope, that Gatekeeper supports for auditing already-existing resources).
  • spec.targets[].rego — the logic itself. Notice input.review.object — it isn't just input, like in conftest: Gatekeeper wraps the complete object inside an AdmissionReview structure, and your rule navigates input.review.object to reach the real Pod. This is, in practice, the most visible syntax difference between a rule written for conftest and one written for Gatekeeper — the declarative logic (violation[...] { conditions }) is the same idea as cloud-security-and-guardrails-guide's deny contains msg if { ... }, with a real Rego syntax difference: Gatekeeper, in its official examples, still uses the violation[{"msg": msg}] { ... } form ("classic" Rego, without the explicit if or contains keywords), while recent versions of OPA/conftest promote the newer deny contains msg if { ... } form. Both forms are valid Rego — the underlying engine accepts either interchangeably — the difference is a style gap between generations of examples, not one of capability.

Constraint: applying the template, with concrete parameters

Once the ConstraintTemplate exists — and Kubernetes already recognizes K8sRequiredResources as a valid type — a Constraint is the concrete instance: which objects it applies to, and with which parameters.

apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sRequiredResources
metadata:
  name: andes-cargo-must-have-resource-limits
spec:
  match:
    kinds:
      - apiGroups: [""]
        kinds: ["Pod"]
    namespaces:
      - "andes-cargo"
  parameters:
    limits:
      - cpu
      - memory

Notice the apiVersion and kind: apiVersion: constraints.gatekeeper.sh/v1beta1, kind: K8sRequiredResources — this last one is, exactly, the name the previous ConstraintTemplate declared in spec.crd.spec.names.kind. It's not a coincidence: Gatekeeper automatically generated that CRD when the ConstraintTemplate got applied, and this Constraint is an instance of that new CRD, just as a Pod is an instance of the Pod type that already existed out of the box in Kubernetes.

  • spec.match — which objects this specific instance applies to: Pod, only in the andes-cargo namespace. This field is what lets you have the same Rego logic (one single ConstraintTemplate) applied with different scopes in different Constraints — for example, a stricter version for andes-cargo and a looser one for kube-system, without duplicating a single line of Rego.
  • spec.parameters — the concrete values for the schema the ConstraintTemplate defined: in this case, limits: [cpu, memory], requiring every container to declare both.

The relationship between the two objects, summed up in one sentence: the ConstraintTemplate is the class; the Constraint is the instance. You can have one ConstraintTemplate and ten different Constraints using it, each with its own match and its own parameters — and if tomorrow you need to fix a bug in the Rego logic, you fix it once, in the ConstraintTemplate, and all ten Constraints inherit the fix automatically.


Common mistakes

Trying to apply a Constraint before its ConstraintTemplate finishes registering (order of operations). What happens: someone applies both files with kubectl apply -f . in the same alphabetical order they appear in a folder, and the Constraint fails with an error like no matches for kind "K8sRequiredResources". How to spot it: if the error message explicitly mentions that Kubernetes doesn't recognize the kind you're applying. How to fix it: Gatekeeper needs a few seconds to read the ConstraintTemplate, generate the new CRD, and register it against kube-apiserver — it's an asynchronous process, not an instant one. Lesson 4 applies both separately, with an explicit pause between them, exactly for this reason.

Writing Rego for Gatekeeper using input.spec instead of input.review.object.spec (syntax inherited from conftest). What happens: someone who already wrote rules for conftest in cloud-security-and-guardrails-guide tries to reuse the same input navigation (input.environment, input.debug, from that guide's example) directly in a Gatekeeper rule. How to spot it: if your rule never triggers, not even against an object that clearly should violate it. How to fix it: the input conftest receives is the file you handed it as-is; the input Gatekeeper receives is a complete AdmissionReview, with the real object nested inside input.review.object. It's the same special input variable, populated by two different systems, in two different shapes.

Confusing the ConstraintTemplate's apiVersion/kind with the Constraint's (two objects with similar names). What happens: someone copies apiVersion: templates.gatekeeper.sh/v1 when writing the Constraint, or vice versa. How to spot it: kubectl apply responds with a schema validation error, or the object gets created but never shows up in kubectl get k8srequiredresources. How to fix it: they're two different apiVersions, on purpose — templates.gatekeeper.sh/v1 is fixed, the same for any ConstraintTemplate you write; constraints.gatekeeper.sh/v1beta1 (or whichever API group matches the specific kind) is the one each Constraint instance uses, and its kind changes depending on which ConstraintTemplate you're instantiating.


Exercises

Exercise 1 — Identify which of the two objects you'd change to add ephemeral-storage to the list of required resources. Without looking back at this lesson's YAML, decide: to require andes-cargo's Pods to also declare ephemeral-storage limits (in addition to cpu and memory), would you modify the ConstraintTemplate, the Constraint, or both?

See solution

Only the Constraint — specifically, the spec.parameters.limits field, adding "ephemeral-storage" to the ["cpu", "memory"] array. The ConstraintTemplate already declared limits as a generic array of strings, without fixing beforehand which strings are valid — the Rego logic iterates over whatever input.parameters.limits contains, regardless of how many elements it has or what they are. If your answer was "both," revisit the "Constraint: applying the template" section — the split between reusable logic (template) and concrete parameters (instance) is exactly what avoids having to touch Rego for this kind of change.

Exercise 2 — Explain, in your own words, why Gatekeeper can't evaluate a terraform plan. A colleague asks: "if Gatekeeper also uses Rego, could I use it to replace conftest in cloud-security-and-guardrails-guide, and evaluate the Terraform plan right there?" What would you answer?

See solution

Technically, not without considerable extra work — and not because Rego can't express the logic (it could), but because Gatekeeper's architecture depends on being connected to a real Kubernetes cluster's ValidatingWebhookConfiguration, receiving AdmissionReviews for objects kube-apiserver is trying to create. A terraform plan never goes through kube-apiserver at all — there's no moment in a Terraform apply's lifecycle where an AdmissionReview exists to evaluate. conftest, on the other hand, was designed for exactly the opposite case: evaluating any structured file, with no cluster or webhook needed behind it. Both share the engine (OPA) and the language (Rego), but they're built for completely different input architectures — one expects a file on disk, the other expects a live HTTP webhook.

Exercise 3 — Predict what happens if you delete a ConstraintTemplate while a Constraint using it still exists. Without running anything yet (you're going to confirm this in lesson 4), predict: if you run kubectl delete constrainttemplate k8srequiredresources while andes-cargo-must-have-resource-limits (the Constraint) still exists, what would you expect to happen to the Constraint, and to the protection it offered?

See solution

Kubernetes deletes the entire K8sRequiredResources CRD along with the ConstraintTemplate that generated it — and, by Kubernetes' standard semantics for deleting a CRD, any instance of that type (including your Constraint) gets deleted in cascade too. The practical result: the protection disappears completely, immediately, with no additional warning beyond whatever kubectl shows you at the moment of the delete. It's the same strong dependency relationship that exists between any CRD and its custom resources in Kubernetes — deleting the type definition takes all its instances down with it.


Summary and next step

This lesson precisely separated Gatekeeper's concept from the syntax lesson 4 is going to actually run: a ConstraintTemplate (the reusable Rego logic, plus its parameters' schema) and a Constraint (the concrete instance, with specific match and parameters) — the difference between a class and an object, applied to policies. You saw, with the complete diagram, why Gatekeeper and conftest share a language (Rego) but not an architecture: one evaluates a static file generated before any apply, outside the cluster; the other evaluates a live object, inside the cluster, at the exact instant of every creation attempt.

Before moving on you should be able to: explain the relationship between a ConstraintTemplate and a Constraint unassisted; distinguish input.parameters from input.environment (conftest's syntax) from input.review.object (Gatekeeper's syntax); and explain, in your own words, why Gatekeeper can't evaluate a terraform plan.

Next lesson: hands-on — installing Gatekeeper and your first policy. There you're going to apply the official v3.23.0 manifest against andes-cargo-cluster, actually apply this lesson's ConstraintTemplate and Constraint, and see the literal message Gatekeeper returns when a real Pod tries to violate it.

Resources

  1. cloud-security-and-guardrails-guide (NIEVA), Module 4, lesson 2 — the Rego foundation (deny contains msg if { ... }) this lesson contrasts with Gatekeeper's ConstraintTemplate syntax.
  2. Gatekeeper — How to use Gatekeeper — official guide to ConstraintTemplate and Constraint, with complete examples.
  3. Gatekeeper — Constraint Templates — detailed reference for the CRD this lesson dissected.
  4. Open Policy Agent — Policy Language (Rego) — complete language reference, the same one cloud-security-and-guardrails-guide cited for conftest.