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:
conftestis 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, printsPASS/FAIL, and the process dies. There's noconftestcomponent running permanently anywhere — it doesn't exist until you run it, and it stops existing the moment it finishes.- Gatekeeper is a
Deploymentliving 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 forkube-apiserverto forward it every new object via lesson 2'sValidatingWebhookConfiguration. You never call it directly;kube-apiserverdoes it for you, on everykubectl apply, with no one having to remember to run any command. - The
inputeach one evaluates is fundamentally different in nature.conftest'sinputis aplan.jsonfile — a snapshot of what Terraform plans to do, generated by an explicit command, at a controlled moment. Gatekeeper'sinputis the completeAdmissionReviewJSON objectkube-apiserversends it in real time — there's no intermediate "generate a plan" step at all: the object you're trying to create is theinput, 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 thisConstraintTemplate. After applying this YAML,kubectl get k8srequiredresourcesis going to work as a valid command, exactly likekubectl get podsorkubectl 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 everyConstraintusing this template is going to accept (in this case, alimitsfield, 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 possibletargetvalues, outside this module's scope, that Gatekeeper supports for auditing already-existing resources).spec.targets[].rego— the logic itself. Noticeinput.review.object— it isn't justinput, like inconftest: Gatekeeper wraps the complete object inside anAdmissionReviewstructure, and your rule navigatesinput.review.objectto reach the realPod. This is, in practice, the most visible syntax difference between a rule written forconftestand one written for Gatekeeper — the declarative logic (violation[...] { conditions }) is the same idea ascloud-security-and-guardrails-guide'sdeny contains msg if { ... }, with a real Rego syntax difference: Gatekeeper, in its official examples, still uses theviolation[{"msg": msg}] { ... }form ("classic" Rego, without the explicitiforcontainskeywords), while recent versions of OPA/conftestpromote the newerdeny 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 theandes-cargonamespace. This field is what lets you have the same Rego logic (one singleConstraintTemplate) applied with different scopes in differentConstraints — for example, a stricter version forandes-cargoand a looser one forkube-system, without duplicating a single line of Rego.spec.parameters— the concrete values for the schema theConstraintTemplatedefined: 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
cloud-security-and-guardrails-guide(NIEVA), Module 4, lesson 2 — the Rego foundation (deny contains msg if { ... }) this lesson contrasts with Gatekeeper'sConstraintTemplatesyntax.- Gatekeeper — How to use Gatekeeper — official guide to
ConstraintTemplateandConstraint, with complete examples. - Gatekeeper — Constraint Templates — detailed reference for the CRD this lesson dissected.
- Open Policy Agent — Policy Language (Rego) — complete language reference, the same one
cloud-security-and-guardrails-guidecited forconftest.