Learnixo
Back to blog
Cloud & DevOpsadvanced

Platform Engineering: Policy as Code — Kyverno, OPA Gatekeeper, and Admission Controllers

Deep guide to Kubernetes policy enforcement — Kyverno validate/mutate/generate policies with full YAML, OPA Gatekeeper with Rego constraint templates, CEL-native ValidatingAdmissionPolicy, and a platform policy library strategy.

LearnixoJune 11, 20268 min read
Platform EngineeringKyvernoOPAPolicy as CodeKubernetesSecurityIDP
Share:𝕏

Policy as Code: What It Solves

Without policy enforcement, Kubernetes is permissive by default — teams can deploy containers as root, skip resource limits, use unapproved images, and skip security contexts. Drift is invisible until a security audit or incident exposes it.

Policy as Code embeds governance directly into the admission control pipeline. Every kubectl apply or Helm install passes through policy evaluation before anything lands in etcd. Non-compliant resources are rejected with a human-readable error.

Three approaches:

  1. Kyverno — Kubernetes-native policies in YAML. No new language to learn. Best for teams that want "policies that look like Kubernetes."
  2. OPA Gatekeeper — Policies in Rego (Datalog-inspired). More expressive, cross-system (can also policy Terraform, CI). Best for complex logic.
  3. ValidatingAdmissionPolicy (CEL) — Built into Kubernetes 1.26+. No CRDs needed. Limited but zero-dependency.

Most platform teams use Kyverno for cluster governance (image policies, security contexts, labels) and OPA for complex business rules or multi-system policies.


Admission Controller Flow

kubectl apply → API Server → Authentication → Authorization (RBAC)
                                                     ↓
                                         MutatingAdmissionWebhook
                                         (Kyverno mutate / Istio inject)
                                                     ↓
                                         Schema validation
                                                     ↓
                                         ValidatingAdmissionWebhook
                                         (Kyverno validate / OPA Gatekeeper)
                                                     ↓
                                         Persisted to etcd ✓

Mutations run first (sidecars injected, defaults added), then validation. A rejected resource never reaches etcd.


Kyverno: Kubernetes-Native Policies

Bash
helm install kyverno kyverno/kyverno \
  --namespace kyverno \
  --create-namespace \
  --set replicaCount=3   # HA for production

Validate: reject non-compliant resources

Require resource requests and limits on all containers:

YAML
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-resource-limits
spec:
  validationFailureAction: Enforce   # Enforce = reject; Audit = log only
  background: true                    # also audit existing resources
  rules:
    - name: check-resource-limits
      match:
        any:
          - resources:
              kinds: [Pod]
      exclude:
        any:
          - resources:
              namespaces: [kube-system, kyverno, monitoring]
      validate:
        message: "CPU and memory limits are required for all containers."
        pattern:
          spec:
            containers:
              - name: "*"
                resources:
                  limits:
                    cpu: "?*"
                    memory: "?*"
                  requests:
                    cpu: "?*"
                    memory: "?*"

Disallow privileged containers:

YAML
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: disallow-privileged-containers
spec:
  validationFailureAction: Enforce
  rules:
    - name: no-privileged
      match:
        any:
          - resources:
              kinds: [Pod]
      validate:
        message: "Privileged containers are not allowed."
        deny:
          conditions:
            any:
              - key: "{{ request.object.spec.containers[].securityContext.privileged | contains(@, `true`) }}"
                operator: Equals
                value: true

Require non-root user:

YAML
rules:
  - name: check-run-as-non-root
    validate:
      message: "Containers must not run as root (runAsNonRoot: true required)."
      pattern:
        spec:
          securityContext:
            runAsNonRoot: true
          containers:
            - name: "*"
              securityContext:
                runAsNonRoot: true
                allowPrivilegeEscalation: false

Mutate: auto-add defaults

Auto-add team label and cost center from namespace annotations:

YAML
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: add-team-labels
spec:
  rules:
    - name: add-labels-from-namespace
      match:
        any:
          - resources:
              kinds: [Pod]
      mutate:
        patchStrategicMerge:
          metadata:
            labels:
              +(team): "{{ namespace_labels.'team' }}"
              +(cost-center): "{{ namespace_labels.'cost-center' }}"

Set default security context if missing:

YAML
rules:
  - name: set-security-context
    match:
      any:
        - resources:
            kinds: [Pod]
    mutate:
      patchStrategicMerge:
        spec:
          securityContext:
            +(runAsNonRoot): true
            +(seccompProfile):
              type: RuntimeDefault
          containers:
            - (name): "*"
              securityContext:
                +(allowPrivilegeEscalation): false
                +(readOnlyRootFilesystem): true

The +() prefix means: only set if the field is missing. This avoids overwriting intentional values.

Generate: auto-create resources for new namespaces

Auto-create a NetworkPolicy for every new namespace:

YAML
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: generate-default-networkpolicy
spec:
  rules:
    - name: generate-deny-all
      match:
        any:
          - resources:
              kinds: [Namespace]
      generate:
        apiVersion: networking.k8s.io/v1
        kind: NetworkPolicy
        name: default-deny-all
        namespace: "{{request.object.metadata.name}}"
        synchronize: true    # update the generated resource if the policy changes
        data:
          spec:
            podSelector: {}
            policyTypes: [Ingress, Egress]

synchronize: true means if someone deletes the generated NetworkPolicy, Kyverno re-creates it. The platform enforces it persistently.

Auto-create LimitRange for every namespace:

YAML
generate:
  apiVersion: v1
  kind: LimitRange
  name: default-limits
  namespace: "{{request.object.metadata.name}}"
  data:
    spec:
      limits:
        - type: Container
          default:
            cpu: "500m"
            memory: "256Mi"
          defaultRequest:
            cpu: "100m"
            memory: "128Mi"
          max:
            cpu: "4"
            memory: "4Gi"

Image verification with Kyverno (supply chain security)

YAML
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: verify-image-signatures
spec:
  validationFailureAction: Enforce
  rules:
    - name: check-image-signature
      match:
        any:
          - resources:
              kinds: [Pod]
      verifyImages:
        - imageReferences:
            - "registry.example.com/*"
          attestors:
            - count: 1
              entries:
                - keyless:
                    subject: "https://github.com/org/*"
                    issuer: "https://token.actions.githubusercontent.com"
                    rekor:
                      url: https://rekor.sigstore.dev
          attestations:
            - predicateType: https://slsa.dev/provenance/v0.2
              conditions:
                - all:
                    - key: "{{ buildType }}"
                      operator: Equals
                      value: "https://github.com/slsa-framework/slsa-github-generator"

Only images signed by GitHub Actions via Sigstore keyless are allowed. An unsigned image or one from an unapproved registry is rejected at admission.


OPA Gatekeeper: Rego Policies

Bash
helm install gatekeeper gatekeeper/gatekeeper \
  --namespace gatekeeper-system \
  --create-namespace

Gatekeeper uses a two-step model:

  1. ConstraintTemplate — defines the policy logic in Rego
  2. Constraint — instantiates the template with parameters for a specific enforcement scope

ConstraintTemplate + Constraint

Require specific labels on all Deployments:

YAML
# Step 1: Define the policy logic in Rego
apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata:
  name: requirelabels
spec:
  crd:
    spec:
      names:
        kind: RequireLabels
      validation:
        openAPIV3Schema:
          properties:
            labels:
              type: array
              items:
                type: string
  targets:
    - target: admission.k8s.gatekeeper.sh
      rego: |
        package requirelabels

        violation[{"msg": msg}] {
          provided := {label | input.review.object.metadata.labels[label]}
          required := {label | label := input.parameters.labels[_]}
          missing := required - provided
          count(missing) > 0
          msg := sprintf("Missing required labels: %v", [missing])
        }
YAML
# Step 2: Instantiate with parameters
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: RequireLabels
metadata:
  name: require-team-labels
spec:
  enforcementAction: deny
  match:
    kinds:
      - apiGroups: ["apps"]
        kinds: ["Deployment"]
    excludedNamespaces: [kube-system]
  parameters:
    labels:
      - team
      - cost-center
      - environment

Rego: allowed image registries

YAML
rego: |
  package allowedregistries

  violation[{"msg": msg}] {
    container := input.review.object.spec.containers[_]
    not starts_with_allowed(container.image)
    msg := sprintf("Container image '%v' from unapproved registry", [container.image])
  }

  starts_with_allowed(image) {
    allowed := input.parameters.registries[_]
    startswith(image, allowed)
  }
YAML
parameters:
  registries:
    - "registry.example.com/"
    - "gcr.io/distroless/"
    - "ghcr.io/org/"

Rego: advanced — cross-object validation with data sync

OPA Gatekeeper can sync cluster state into OPA's data store for cross-object policy. For example, require that a ServiceAccount referenced by a Pod actually exists:

YAML
apiVersion: config.gatekeeper.sh/v1alpha1
kind: Config
metadata:
  name: config
  namespace: gatekeeper-system
spec:
  sync:
    syncOnly:
      - group: ""
        version: v1
        kind: ServiceAccount
      - group: ""
        version: v1
        kind: Namespace
REGO
violation[{"msg": msg}] {
  sa_name := input.review.object.spec.serviceAccountName
  ns := input.review.object.metadata.namespace
  # Look up synced state
  not data.inventory.namespace[ns]["v1"]["ServiceAccount"][sa_name]
  msg := sprintf("ServiceAccount '%v' does not exist in namespace '%v'", [sa_name, ns])
}

ValidatingAdmissionPolicy (CEL) — Kubernetes Built-in

Available in Kubernetes 1.26+ (stable in 1.30). No webhook, no CRDs from external projects:

YAML
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
  name: require-run-as-non-root
spec:
  failurePolicy: Fail
  matchConstraints:
    resourceRules:
      - apiGroups: [""]
        apiVersions: ["v1"]
        operations: ["CREATE", "UPDATE"]
        resources: ["pods"]
  validations:
    - expression: >
        object.spec.securityContext.runAsNonRoot == true ||
        object.spec.containers.all(c,
          c.?securityContext.?runAsNonRoot.orValue(false) == true
        )
      message: "Pods must run as non-root."
    - expression: >
        !object.spec.containers.exists(c,
          c.?securityContext.?privileged.orValue(false) == true
        )
      message: "Privileged containers are not allowed."
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
  name: require-run-as-non-root-binding
spec:
  policyName: require-run-as-non-root
  validationActions: [Deny]
  matchResources:
    namespaceSelector:
      matchExpressions:
        - key: kubernetes.io/metadata.name
          operator: NotIn
          values: [kube-system, kyverno]

CEL policies are processed in-process by the API server — no webhook latency, no external dependency. The tradeoff: less expressive than Rego, no mutation, no generation.


Platform Policy Library

Structure your policies as a Git-managed library deployed via ArgoCD:

gitops-repo/
└── platform/
    └── policies/
        ├── kyverno/
        │   ├── baseline/          # Pod Security Standards baseline
        │   │   ├── disallow-privileged.yaml
        │   │   ├── require-non-root.yaml
        │   │   └── require-seccomp.yaml
        │   ├── restricted/        # Stricter — for payment/PII namespaces
        │   │   ├── require-readonly-root.yaml
        │   │   └── require-drop-all-caps.yaml
        │   ├── supply-chain/      # Image signing and SBOM
        │   │   ├── verify-image-signatures.yaml
        │   │   └── require-sbom-attestation.yaml
        │   └── generators/        # Auto-create resources for new namespaces
        │       ├── networkpolicy.yaml
        │       ├── limitrange.yaml
        │       └── resourcequota.yaml
        └── gatekeeper/
            ├── templates/
            └── constraints/

ArgoCD syncs this directory to every cluster. New policies go through the same GitOps review process as application changes — a PR with a policy change gets reviewed before enforcement.

Policy tiers

| Tier | Scope | Mode | |---|---|---| | baseline | All namespaces except kube-system | Enforce | | restricted | Namespaces with label security-tier=restricted | Enforce | | audit | New policies under review | Audit (log, don't reject) |

Teams onboard in audit mode first — they see violations in Kyverno's PolicyReport without being blocked:

Bash
# Check policy violations for a namespace (audit mode)
kubectl get policyreport -n my-team

# NAME                          PASS   FAIL   WARN   ERROR   SKIP
# polr-ns-my-team               47     3      0      0       0

# See which pods are failing
kubectl describe policyreport polr-ns-my-team -n my-team

Teams fix violations, then the namespace label is promoted to Enforce.


Choosing: Kyverno vs Gatekeeper vs CEL

| Criterion | Kyverno | Gatekeeper (OPA) | CEL | |---|---|---|---| | Language | YAML | Rego | CEL (Go expression) | | Mutation | Yes | No | No | | Generation | Yes | No | No | | Cross-object policy | Limited | Yes (via sync) | No | | Image verification | Yes (Cosign) | No (external) | No | | Learning curve | Low | High | Medium | | Performance | External webhook | External webhook | In-process (fast) | | Best for | Cluster governance | Complex logic | Simple validation (no deps) |

Recommended default: Kyverno for cluster governance, image verification, and namespace bootstrapping. Add Gatekeeper only when Rego's expressiveness is genuinely needed (cross-resource policy, multi-system policy, complex arithmetic). Use CEL for simple rules on clusters where you want zero external dependencies.

Enjoyed this article?

Explore the Cloud & DevOps learning path for more.

Found this helpful?

Share:𝕏

Leave a comment

Have a question, correction, or just found this helpful? Leave a note below.