Enforcement points

The same policy in CI, admission, and audit.

Advanced30 min · lesson 10 of 13

An international airport checks your passport more than once. Once when you book the ticket. Again at check-in, again at the security line, again at the boarding gate, and sometimes once more when you land. No single desk is trusted to be the *only* check, because every desk can be skipped on the right day: online check-in, a rushed gate agent, a systems outage. Policy-as-code works the same way. An enforcement point is any place in your delivery pipeline where something asks "is this allowed?" and then *acts on the answer*. A merge gets blocked. An API request gets rejected. A violation shows up as an alert.

The names for the moving parts come from access-control research, and they are less frightening than they sound. The policy decision point (PDP), the part that decides, is the engine that reads your rules and answers allow or deny. That is OPA (Open Policy Agent) or Kyverno, both covered in depth by earlier lessons. The policy enforcement point (PEP), the part that acts, is whatever intercepts an action, asks the decision point, and then does what it is told. Splitting those two apart is the whole trick. You author and test one set of rules, then enforce them in many places. What remains is choosing the *where*, and wiring it up.

Why one checkpoint is never enough

Every enforcement point, used on its own, has a bypass that people already know about. Enforce only in CI (continuous integration, the pipeline that builds and tests every change), and anyone holding cluster credentials can kubectl apply a manifest that never went near that pipeline. So can a stolen CI token, a GitOps controller syncing an unreviewed branch, or an engineer improvising at 3am during an incident. Enforce only at admission, and developers hear nothing until deploy time, which is slow and expensive to fix. The thousands of resources admitted *before* your policy existed never get re-checked, and a webhook outage quietly becomes a policy outage. Enforce only with runtime audit, and you spend your week writing incident reports instead of preventing incidents.

Production setups therefore stack three layers. CI validation on every pull request. Admission control at the cluster API (application programming interface) server, the front door every Kubernetes request has to walk through. And a continuous audit loop over whatever is already running. The pattern travels well beyond Kubernetes: conftest will evaluate a Terraform plan in JSON form as happily as it evaluates a manifest, and OPA can sit behind Envoy's ext_authz filter to approve or reject service-to-service traffic. Build the three-layer Kubernetes version first.

Layer 1: fail fast in CI

conftest is a command-line tool that runs Rego policies against files sitting on your disk. YAML and JSON config, HCL (HashiCorp Configuration Language, the language Terraform is written in), Dockerfiles. No cluster involved at any point. It is the cheapest enforcement point you will ever own, because feedback arrives in seconds, on the developer's own branch, while nothing yet exists that would need rolling back. Pull down the versioned policy bundle your team publishes, then run it over the rendered manifests.

shell
$ conftest pull oci://ghcr.io/acme/policy-bundle:v1.4.2 # downloads into ./policy/
$ conftest test deploy/api.yaml -p policy/
FAIL - deploy/api.yaml - main - Deployment "api": container "api" must set resources.limits.memory
FAIL - deploy/api.yaml - main - Deployment "api": set securityContext.runAsNonRoot=true
6 tests, 4 passed, 0 warnings, 2 failures, 0 exceptions

A failure exits non-zero, so a required CI job blocks the merge with no extra glue on your side. Be honest about what this layer actually covers, though. It sees only the changes that travel *through the pipeline*. Treat CI enforcement as a developer-experience feature, the fastest feedback loop you have, and never as your security boundary.

Be honest about the rules themselves too. conftest collects deny rules from a package like main, and each document in the file arrives as input. Gatekeeper, which is OPA packaged as a Kubernetes admission controller and the subject of the next section, collects violation rules from inside a ConstraintTemplate, and the object arrives as input.review.object. Same intent, two different shapes, and nothing on the Gatekeeper side answers to conftest pull, because constraint templates ship as cluster resources rather than as a bundle you fetch. Generating both from one source and proving they still agree is a job somebody has to own, and it is the first thing that rots when nobody does. Kyverno is the stack where that parity comes for free, because kyverno apply in CI evaluates the same policy YAML the cluster admits with.

Layer 2: admission control, the only gate that answers in real time

When any client creates or changes an object, the Kubernetes API server runs it through authentication, authorization and mutation, then packs the request into an AdmissionReview JSON document and POSTs it over TLS (Transport Layer Security, the encryption behind the padlock in your browser) to every registered validating webhook whose rules match. At this layer, Gatekeeper and Kyverno are HTTPS servers that answer allowed: true/false before a deadline expires. The answer comes back while the request is still waiting, which is what *synchronous* means here, and that is why this is the only place that can stop an object from ever existing, whoever created it and by whatever route. The same power is why its failure behavior deserves a hard look.

shell
$ kubectl apply -f deploy/api.yaml
Error from server (Forbidden): error when creating "deploy/api.yaml":
admission webhook "validation.gatekeeper.sh" denied the request:
[require-memory-limits] Deployment "api": container "api" must set resources.limits.memory
# The webhook's failure mode is configuration, not code — inspect it:
$ kubectl get validatingwebhookconfiguration gatekeeper-validating-webhook-configuration \
-o jsonpath='{range .webhooks[*]}{.name}{"\t"}{.failurePolicy}{"\t"}{.timeoutSeconds}{"\n"}{end}'
validation.gatekeeper.sh Ignore 3
check-ignore-label.gatekeeper.sh Fail 3
failurePolicy: Ignore is a bypass nobody announces
Gatekeeper ships failurePolicy: Ignore on its main validation webhook. If the webhook pods go unreachable, and a node drain, an OOM kill (out of memory, the kernel shooting a process that asked for too much) or an expired serving certificate will each do it, the API server admits everything and writes a log line about having skipped the check. Anyone who can crash your policy controller can then deploy whatever they like. Flip to Fail only once you run at least 3 webhook replicas spread across zones with a PodDisruptionBudget, and exempt kube-system from interception so that a total policy outage cannot lock you out of repairing the cluster. Replicas and a budget only reduce the odds, though. The structural answer is to take the webhook off the failure path: ValidatingAdmissionPolicy, which the API server evaluates itself using CEL (Common Expression Language) rather than calling out to a webhook, has been generally available since Kubernetes 1.30, and both Gatekeeper and Kyverno can generate one from a policy you already wrote, so your simpler constraints keep working when the controller does not.

Layer 3: audit what admission missed

The audit loop goes back over objects that already exist and re-runs the policies against them. Gatekeeper's audit controller sweeps the cluster every 60 seconds by default and writes what it finds into each constraint's status. That picks up the four populations admission structurally cannot see: resources created before the policy existed, resources admitted while the webhook was down or set to Ignore, resources sitting in exempted namespaces, and violations you introduced by *changing the policy itself*.

shell
$ kubectl get constraints
NAME ENFORCEMENT-ACTION TOTAL-VIOLATIONS
k8srequiredresources.constraints.gatekeeper.sh/require-memory-limits deny 12
$ kubectl get k8srequiredresources require-memory-limits -o json | jq '.status.violations[0]'
{
"enforcementAction": "deny",
"group": "apps",
"kind": "Deployment",
"message": "Deployment \"legacy-worker\": container \"worker\" must set resources.limits.memory",
"name": "legacy-worker",
"namespace": "batch",
"version": "v1"
}

There is a ceiling here worth knowing about. Constraint status keeps a capped list of violations, 20 by default and tunable through --constraint-violations-limit, so at any real scale you read audit results from the exported Prometheus metrics or a violation-export sink rather than scraping status fields.

One rule set, three enforcement points
Layer 1 - CI validation (conftest)
Feedback in seconds, on the branch
Rego against local YAML/HCL, no cluster involved
Non-zero exit blocks the merge
One required CI job, no extra glue
Bypass: it only sees pipeline changes
Developer experience, never your security boundary
Layer 2 - Admission control (webhook)
The only gate that answers in real time
Can stop an object from ever existing
timeoutSeconds <= 3, scope rules narrow
It sits on the API server's hot path
Bypass: failurePolicy: Ignore
Webhook down = admit everything, log it, move on
Layer 3 - Audit loop (every 60s)
Re-checks objects already running
Writes findings into constraint status
Catches pre-existing / exempt / webhook-down
Plus violations caused by changing the policy itself
Doubles as a bypass detector
Audit climbs while CI/admission stay flat = someone routed around you
All three layers enforce one rule set, so whatever slips past one gets caught by the next. conftest and Gatekeeper run different shapes of it, so keeping both generated from a single source is work you own.

Hardening and what breaks at scale

Admission webhooks sit on the API server's hot path, so every hardening choice is really a latency choice. Keep timeoutSeconds at 3, and never push it past 10, because one slow webhook stalls *every* matching request across the whole cluster. Scope your webhook rules tightly: intercepting high-churn objects like events or leases multiplies webhook QPS (queries per second) and buys you no policy value whatsoever. Watch apiserver_admission_webhook_admission_duration_seconds for creep as your policy count grows. Then instrument the *relationship between the layers*. If audit violations climb while CI failures and admission denials stay flat, something is deploying around your pipeline. That divergence is your bypass detector, and it only reads cleanly while all three layers are enforcing the same rule set.

The problems waiting after this one are organizational rather than technical. Getting a single bundle onto fifty clusters without a flag day. Handing out a time-boxed exception that expires on its own instead of quietly becoming permanent. Deciding who owns a policy at the moment it blocks somebody else's deploy on a Friday afternoon. Those are the scale problems the rest of this course takes on.

Try this

Run these in a lab or a throwaway environment, so you see the real shape of the output instead of a screenshot from someone's blog. Point -p at whichever policy directory you already have, and use any manifest that breaks one of its rules.

terminal
conftest test deploy/api.yaml -p policy/ || true
kubectl auth can-i create pods --as=system:serviceaccount:default:default
output
FAIL - deploy/api.yaml - main - Deployment "api": container "api" must set resources.limits.memory
FAIL - deploy/api.yaml - main - Deployment "api": set securityContext.runAsNonRoot=true
6 tests, 4 passed, 0 warnings, 2 failures, 0 exceptions
no

Read the two answers together. conftest test exits non-zero the moment anything fails, which is what the || true swallows so the second command still gets to run. The FAIL lines are Layer 1 doing its job on your branch, before anything exists to roll back. The no says that service account cannot create pods at all, so it has no route around your pipeline. Now run the same check against an identity that answers yes, your own user for instance, and you have found a way into the cluster that CI never sees. That is the gap Layer 2 is there to close.

Takeaway

One decision should be able to fail a pull request, deny an admission, and turn up in an audit report. Many enforcement points, one policy brain.

Next: CI parity with Conftest and opa eval, so developers see failures before the cluster does.

Quick check
01You run the jsonpath check on your Gatekeeper webhook configuration and get two lines back: validation.gatekeeper.sh Ignore 3 and check-ignore-label.gatekeeper.sh Fail 3. That afternoon an OOM kill takes every Gatekeeper pod down. What happens to writes hitting the API server while the pods are gone?
Incorrect — failurePolicy is set per webhook, which is exactly why that jsonpath prints a separate value on each line. The second webhook is set to Fail and will not fail open alongside the first one.
Incorrect — A fail-closed webhook only refuses the requests its own rules match. Scoping those rules tightly is what keeps a Fail setting from turning into a cluster-wide outage.
Correct — Ignore fails open while Fail fails closed, so a single outage splits your cluster in two. The traffic your main policy covers sails past unchecked, and the narrow webhook becomes an outage of its own.
Incorrect — Audit runs on its own clock over objects that already exist, and it gates nothing. Admission is the only layer that answers while the request is still waiting.
02Your dashboard scrapes .status.violations from each constraint. Today kubectl get constraints shows TOTAL-VIOLATIONS 12 for require-memory-limits and the dashboard agrees. Six months later that column reads 340 while the dashboard is stuck at 20. What is going on?
Incorrect — The sweep re-runs your policies over everything that already exists on that clock, and it is not rationed per object. A longer interval would add nothing to a list that is capped.
Incorrect — Exempted namespaces are one of the four populations the audit loop exists to cover, so those findings are precisely what it writes down. The list is short because it is capped.
Incorrect — The webhook answers admission in real time and never writes audit results at all. The audit controller owns constraint status, and the cap belongs to that list.
Correct — TOTAL-VIOLATIONS counts every finding while the list written into status stops at the cap. You can raise the cap with --constraint-violations-limit, though at that size the real answer is exported Prometheus metrics or a violation-export sink.
03Your CI job runs conftest test deploy/api.yaml -p policy/. A teammate keeps CI in step with the cluster by copying the Rego out of a ConstraintTemplate straight into policy/. CI now reports zero failures on the very manifest the cluster denies with [require-memory-limits]. Why does CI stay quiet?
Correct — Same intent, two shapes. Gatekeeper collects violation from inside a ConstraintTemplate and passes input.review.object, while conftest wants deny in a package like main with the document itself as input. Pasted across, the rule matches nothing and passes in silence.
Incorrect — A stale bundle is a genuine way for the two layers to drift, but the rule here was pasted in by hand, so no bundle version comes into it. What differs is the shape of the rule.
Incorrect — The inputs really do arrive in different shapes, but that does not make agreement impossible. Write the same intent in the shape conftest expects and it fails on the manifest CI just passed.
Incorrect — Nothing on the Gatekeeper side answers to conftest pull, and that is the point: templates are cluster resources rather than a bundle you fetch. Kyverno is where that parity comes free, because kyverno apply in CI evaluates the same policy YAML the cluster admits with.

Related