|8 min read|Yvann Lièvre

Policy-as-Code: Securing Kubernetes and IaC with OPA/Rego and Kyverno

Policy as code with OPA Rego and Kubernetes lets you deny a privileged pod before it ever starts. OPA/Rego vs Kyverno, real examples, testing, and NIS2/DORA proof.

Policy as CodeKubernetesOPACompliance
Policy-as-Code: Securing Kubernetes and IaC with OPA/Rego and Kyverno

A pod is running in production with privileged: true. Nobody decided that explicitly: a manifest copied from a tutorial, a Dockerfile nobody ever reviewed, an image pulled from a public registry without any signature check. The cluster accepted it because, technically, nothing stopped it. That is exactly the problem policy as code solves: turning a rule written in a security document into a barrier enforced by the platform itself, at object-creation time, not after the fact in an audit report.

Admission control, concretely

Kubernetes exposes a precise anchor point for this control: the admission webhook. Every request to create or modify an object (a pod, a deployment, a network rule) passes through a validation chain before it is persisted to etcd. A policy engine wired into that chain can reject the request before the pod ever starts.

Two concrete cases, common in audits:

  • A pod requests securityContext.privileged: true or mounts the host's Docker socket: without a policy, it starts normally and holds near-root access on the node.
  • An image comes from an unverified registry or is not signed (Cosign, Notary): without a policy, Kubernetes makes no distinction between an image signed by the official CI chain and one pushed manually by anyone holding registry credentials.

In both cases, admission control turns a potential incident into a silent, logged rejection, before impact.

OPA/Rego or Kyverno: two philosophies

Two engines dominate this space, with different logics.

OPA (Open Policy Agent) and its Rego language are general-purpose. The same engine can validate a Kubernetes manifest, a Terraform plan before apply, an Envoy routing rule, or a CI/CD pipeline step. Rego is a full logical query language with its own learning curve: you think in terms of facts and rules, not sequential YAML. That is the trade-off for its multi-stack versatility.

Kyverno is Kubernetes-native and written entirely in YAML, with no third-party language to learn. A team already comfortable with Kubernetes manifests can write a Kyverno policy in minutes. The limitation is scope: Kyverno never leaves the Kubernetes ecosystem, it will not validate a Terraform plan or an Envoy rule.

In practice, the hybrid approach is the most common in mature environments: Kyverno for purely Kubernetes policies with high volume and low complexity (mandatory labels, securityContext restrictions, quotas), OPA/Rego for anything that spans multiple stacks (IaC, API gateway, pipelines) or needs finer decision logic. The wrong instinct is picking a single engine on principle rather than on actual scope.

Anatomy of a Rego policy

A Rego policy is organized into a package (the logical namespace) and rules. The deny rule is the most common convention for admission control: every instance of deny that evaluates to true blocks the request and returns the associated message.

package kubernetes.admission
 
deny[msg] {
    input.request.kind.kind == "Pod"
    container := input.request.object.spec.containers[_]
    container.securityContext.privileged == true
    msg := sprintf(
        "Container '%v' in pod '%v' is privileged: denied by policy.",
        [container.name, input.request.object.metadata.name],
    )
}

The structure is readable: package scopes the policy, the deny rule decomposes the condition (object type, iteration over containers, field check), and msg builds the message returned to whoever attempted the deployment. That message is what shows up in the kubectl apply output, not a generic error.

The Kyverno equivalent for the same rule fits in declarative YAML, with no query logic to write:

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: disallow-privileged-containers
spec:
  validationFailureAction: Enforce
  rules:
    - name: block-privileged
      match:
        resources:
          kinds: ["Pod"]
      validate:
        message: "Privileged containers are not allowed."
        pattern:
          spec:
            containers:
              - =(securityContext):
                  =(privileged): "false"

Same intent, two syntaxes. The choice depends on the scope to cover, not on the team's stylistic preference.

Testing before you block a critical deployment

A badly written policy is as dangerous as no policy at all: it can block a legitimate deployment in the middle of an on-call shift, or let through a case it thought it covered. Two tools prevent that double failure.

opa test runs unit tests written in Rego against the policy, before any deployment to an admission controller:

opa test policies/ -v
package kubernetes.admission
 
test_privileged_pod_denied {
    deny["Container 'app' in pod 'demo' is privileged: denied by policy."] with input as {
        "request": {
            "kind": {"kind": "Pod"},
            "object": {
                "metadata": {"name": "demo"},
                "spec": {"containers": [{"name": "app", "securityContext": {"privileged": true}}]},
            },
        },
    }
}

conftest applies the same principle directly to manifest files or Terraform plans exported as JSON, which lets you test a policy in the CI/CD pipeline before it ever reaches the cluster:

conftest test --policy policies/ deployment.yaml

The recommended rollout order stays constant: opa test to validate the rule's own logic, conftest test in CI to validate a broad corpus of real manifests without regressions, and only then enforcement mode inside the cluster. Skipping that step is the classic scenario of an on-call engineer woken at 2 a.m. by a deployment blocked for a reason nobody ever tested.

From technical policy to compliance proof

NIS2 and DORA require documented technical controls and, increasingly, proof of automation rather than manual declarative procedures. Policy as code provides a direct mapping between a regulatory requirement and an enforced rule:

NIS2/DORA requirementCorresponding technical policy
Encryption at rest and in transitRego/Kyverno denying a PVC without encryption, or a service without TLS
Network segmentationKubernetes NetworkPolicy generated and validated by policy, blocking unauthorized pod-to-pod traffic
Access management (least privilege)RBAC rules audited automatically, denial of overly broad ClusterRoleBindings
Software supply chain integrityAdmission denial of unsigned images

This mapping is not cosmetic: in a NIS2 or DORA audit, proving that a control is enforced automatically and systematically (not merely documented in a procedure) is what separates audited compliance from declarative compliance. The admission controller's denial log becomes a directly usable piece of evidence, timestamped and tied to a specific object, rather than a screenshot taken manually once a quarter.

That distinction matters because auditors increasingly ask not just "do you have a policy" but "show me it firing." A denial log entry referencing a rejected ClusterRoleBinding or an unsigned image is far harder to dispute than a paragraph in a security handbook nobody has re-read since it was written.

In summary

Policy as code moves the security control from the document to the runtime: a Rego or Kyverno policy denies drift before it produces an incident, not weeks later in an audit report. The engine choice follows the scope (Kyverno for pure Kubernetes, OPA/Rego for multi-stack), and rollout to production never skips unit testing beforehand. The NIS2/DORA mapping then turns every technical policy into a piece of automated compliance evidence.

That is exactly what the ThreatClaw policy pack provides: pre-tested Rego and Kyverno rules, ready for admission control, with the compliance mapping already built in, so you are not writing and validating every rule under pressure the week of the audit.

Related articles