Policy as Code: Kyverno, OPA/Gatekeeper, and Conftest¶
Skill metadata
Name policy-as-code-opa-kyverno · Level senior · Tags policy-as-code kyverno opa gatekeeper conftest governance kubernetes
"Policy as code for Kubernetes and infrastructure: authoring Kyverno ClusterPolicy rules, OPA Rego in a Gatekeeper ConstraintTemplate, and Conftest checks, plus Pod Security Standards enforcement, policy unit testing, and an owned exception register with expiry dates. Use when privileged containers or pods without resource limits must be rejected at admission rather than reported, when admission policies need tests so a rule cannot silently stop matching, or when choosing between Kyverno and OPA Gatekeeper."
Source: skills/devsecops-and-secops/policy-as-code-opa-kyverno/SKILL.md
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.