Policy as Code: Kyverno, OPA/Gatekeeper, and Conftest
When to Use This Skill
Triggers — load this skill when:
- Privileged containers, missing limits, or untagged resources must be blocked, not reported
- Admission policies need unit tests, because an untested rule silently stops matching
- Kubernetes needs Pod Security Standards enforced with real exceptions for real workloads
- Kyverno versus OPA/Gatekeeper has to be decided for a platform
Route elsewhere when:
- Verifying image signatures and provenance specifically ->
supply-chain-security-slsa-sigstore - Cloud account posture scanning after the fact ->
cloud-security-posture-cspm-cis - Terraform repository layout and module structure ->
terraform-iac-modules - Multi-account IaC governance and CI gating at scale ->
enterprise-iac-governance-terragrunt - Runtime behaviour detection rather than admission ->
container-runtime-security-falco
1. Start with Pod Security Standards, then add policy for what they miss
Pod Security Admission is built in, costs nothing, and covers most container-hardening rules. Use it as the baseline and reserve a policy engine for what PSA cannot express.
apiVersion: v1
kind: Namespace
metadata:
name: production
labels:
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/enforce-version: v1.34
pod-security.kubernetes.io/warn: restricted # surfaces future tightening before it blocks
PSA cannot do cross-object rules, mutation, image-registry allowlists, or anything referencing another resource's state. That is what Kyverno and Gatekeeper are for.
2. Kyverno: validate, mutate, and generate in YAML
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-resources-and-probes
annotations:
policies.kyverno.io/severity: medium
spec:
validationFailureAction: Audit # Audit -> measure -> Enforce. Never Enforce first.
background: true # also evaluate existing resources, to size the blast radius
rules:
- name: require-limits
match:
any:
- resources:
kinds: [Pod]
namespaces: [production, staging]
exclude:
any:
- resources:
namespaces: [kube-system] # cluster components predate your policy
validate:
message: >-
Every container needs cpu/memory requests and a memory limit.
Unbounded pods are the usual cause of node-level OOM cascades.
pattern:
spec:
containers:
- resources:
requests:
cpu: "?*"
memory: "?*"
limits:
memory: "?*"
Kyverno's advantage is that policy is Kubernetes YAML, so the whole team can read it. Its mutate
rules (patchStrategicMerge, e.g. forcing automountServiceAccountToken: false) and generate
rules also fix problems rather than only rejecting them.
3. OPA/Gatekeeper: Rego when the logic is genuinely conditional
package k8srequiredlabels
import future.keywords.in
violation[{"msg": msg}] {
# Only production workloads need a cost-allocation owner
input.review.object.metadata.namespace == "production"
required := {"owner", "cost-center"}
provided := {k | some k, _ in input.review.object.metadata.labels}
missing := required - provided
count(missing) > 0
msg := sprintf("production workloads must carry labels: %v", [missing])
}
That Rego is delivered as a Gatekeeper ConstraintTemplate (which defines a K8sRequiredLabels CRD),
then instantiated per scope by a Constraint — the two-object split is what lets one policy be
enforced with different parameters per namespace.
Choosing between them:
| Need | Choose |
|---|---|
| Policy the team reads and writes without learning a language; mutation, generation, image verification | Kyverno |
| Conditional logic and set arithmetic; one language shared across Kubernetes, Terraform and API authorization | OPA/Gatekeeper |
Do not run both engines for overlapping concerns. Two admission webhooks with different verdicts is a debugging problem nobody wants at 03:00.
4. Shift the same rules left: Conftest on the Terraform plan
Blocking at admission is too late for infrastructure: by then the bucket exists. Gate the plan.
# policy/terraform/s3.rego
package main
deny[msg] {
r := input.resource_changes[_]
r.type == "aws_s3_bucket"
not r.change.after.tags.owner
msg := sprintf("%s: missing required tag 'owner'", [r.address])
}
Inspect r.change.actions the same way to block removals — a rule that only checks the final
state will happily allow encryption to be deleted.
terraform plan -out=tfplan
terraform show -json tfplan > tfplan.json
conftest test --policy policy/terraform tfplan.json # non-zero exit fails the pipeline
Test the policies themselves — a guardrail with no test is a guardrail that silently stops matching.
conftest verify --policy policy/terraform runs test_* rules from *_test.rego, and
kyverno test ./policies asserts expected results per resource fixture. Both belong in CI.
5. Exceptions are the part everyone gets wrong
A policy with no exception mechanism gets disabled the first time it blocks something urgent. Give exceptions a structure that expires.
apiVersion: kyverno.io/v2
kind: PolicyException
metadata:
name: legacy-batch-privileged
namespace: batch
annotations:
owner: "@platform-team"
expires: "2026-11-30" # a gate in CI fails the build once this date passes
reason: "Vendor agent requires CAP_SYS_ADMIN. Replacement tracked in PLAT-1841."
spec:
exceptions:
- policyName: disallow-privileged-containers
ruleNames: ["privileged-containers"]
match:
any:
- resources: { kinds: [Pod], namespaces: [batch], names: ["legacy-agent-*"] }
Every exception needs an owner, an expiry, and a reason. Enforce that with a CI check over the exception directory — an expired exception is a build failure, not a stale file nobody reviews.
6. Best practices and anti-patterns
Do:
- Roll out in three stages:
Auditwithbackground: trueto find existing violations, fix or except them, thenEnforce. - Scope policies to namespaces and exclude cluster components. A cluster-wide
Enforcepolicy that matcheskube-systemcan prevent the cluster from recovering after a reboot. - Set
failurePolicy: Ignorewhile piloting, and only move toFailonce the webhook's availability and latency are proven. AFailwebhook that is down blocks every deployment. - Write the message for the developer who will hit it — say what to change, not which rule fired.
- Version and test policies like code, with
conftest verifyandkyverno testin CI.
Do not:
- Enforce on day one. The first policy is always broader than you think.
- Duplicate the same rule in two engines. Pick one enforcement point per concern.
- Write policy that reaches the network. Admission is on the request path; an external lookup turns a policy engine into a latency and availability dependency for every deploy.
- Grant blanket namespace exclusions as a shortcut.
productionexcluded "temporarily" is how a guardrail quietly stops covering the thing it existed for. - Rely on admission alone. It only sees create and update, so a policy added later leaves
existing violations running — that is what
background: trueand a periodic report are for.