Tag Archives: Admission Controllers

Enforcing Kubernetes Security at the Gate: OPA/Gatekeeper + Kyverno in Production

Kubernetes RBAC is not enough. RBAC controls who can make API calls, but it does not control what those API calls can deploy. A developer with create pods permission in their namespace can deploy a container running as root, mounting the host filesystem, pulling from an untrusted registry, with no resource limits – and RBAC will not stop any of it.

This is the gap that Kubernetes Admission Controllers fill. Having hardened EKS clusters for ad-tech workloads at Smaato and energy trading platforms at work, I have learned that admission controllers are the most operationally impactful Kubernetes security control available. This post documents the production configuration I use.

How Admission Controllers Work

When a request hits the Kubernetes API server, it passes through a pipeline before being persisted to etcd:

The two relevant webhook types are:

  • Mutating Admission Webhooks: Intercept the request before validation and can modify the object. Use Kyverno here to inject secure defaults (non-root user, resource limits, labels) automatically, so developers don’t need to remember security configuration.
  • Validating Admission Webhooks: Intercept the request after mutation and either allow or deny it. Use OPA/Gatekeeper here to enforce hard policies (no privileged containers, approved registries only, required labels).

The split is intentional: Kyverno mutates to help developers, Gatekeeper validates to enforce compliance.

Installing OPA/Gatekeeper

Gatekeeper is the production-grade OPA integration for Kubernetes. Install via Helm:

helm repo add gatekeeper https://open-policy-agent.github.io/gatekeeper/charts
helm repo update

helm install gatekeeper gatekeeper/gatekeeper \
  --namespace gatekeeper-system \
  --create-namespace \
  --set replicas=3 \
  --set auditInterval=60 \
  --set constraintViolationsLimit=100 \
  --set logLevel=WARNING

The auditInterval=60 setting is important: Gatekeeper continuously audits existing resources against all policies, not just new requests. This catches drift from resources created before the policies were installed.

Installing Kyverno

helm repo add kyverno https://kyverno.github.io/kyverno/
helm repo update

helm install kyverno kyverno/kyverno \
  --namespace kyverno \
  --create-namespace \
  --set replicaCount=3 \
  --set config.webhooks[0].failurePolicy=Fail

Setting failurePolicy=Fail means if the Kyverno webhook is unavailable, API requests fail closed (denied) rather than open (allowed). This is the safer default for production.

Kyverno Mutating Policies

Policy 1: Inject Secure Container Defaults

This policy automatically injects security context into every new pod that does not already have it defined. Developers do not need to write this – Kyverno adds it transparently:

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: inject-security-context
  annotations:
    policies.kyverno.io/title: Inject Secure Defaults
    policies.kyverno.io/category: Security
    policies.kyverno.io/description: >
      Injects runAsNonRoot, readOnlyRootFilesystem, and
      allowPrivilegeEscalation=false into all containers.
spec:
  rules:
    - name: inject-security-context
      match:
        any:
          - resources:
              kinds: [Pod]
              namespaces: ["!kube-system", "!gatekeeper-system", "!kyverno"]
      mutate:
        patchStrategicMerge:
          spec:
            containers:
              - (name): "*"
                securityContext:
                  +(runAsNonRoot): true
                  +(readOnlyRootFilesystem): true
                  +(allowPrivilegeEscalation): false
                  +(runAsUser): 1000
            initContainers:
              - (name): "*"
                securityContext:
                  +(runAsNonRoot): true
                  +(allowPrivilegeEscalation): false

The +() syntax is Kyverno’s “add if not present” operator – it will not overwrite explicitly set values.

Policy 2: Inject Resource Limits

Pods without resource limits are a denial-of-service vector. This policy injects sensible defaults so the cluster scheduler always has resource information:

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: add-resource-limits
spec:
  rules:
    - name: add-default-resource-limits
      match:
        any:
          - resources:
              kinds: [Pod]
              namespaces: ["!kube-system"]
      mutate:
        patchStrategicMerge:
          spec:
            containers:
              - (name): "*"
                resources:
                  +(requests):
                    memory: "64Mi"
                    cpu: "50m"
                  +(limits):
                    memory: "512Mi"
                    cpu: "500m"

Policy 3: Add Mandatory Labels for NetworkPolicy

Network policies use label selectors. If pods don’t have consistent labels, network policies become fragile. This policy ensures every pod carries the labels required for policy enforcement:

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: add-team-labels
spec:
  rules:
    - name: add-labels-from-namespace
      match:
        any:
          - resources:
              kinds: [Pod]
      context:
        - name: namespaceLabels
          apiCall:
            urlPath: "/api/v1/namespaces/{{request.object.metadata.namespace}}"
            jmesPath: "metadata.labels"
      mutate:
        patchStrategicMerge:
          metadata:
            labels:
              +(app.kubernetes.io/managed-by): "helm"
              +(security.rohanbhagat.com/team): "{{namespaceLabels.\"team\" || 'unknown'}}"
              +(security.rohanbhagat.com/environment): "{{namespaceLabels.\"environment\" || 'unknown'}}"

OPA/Gatekeeper Validating Policies

Gatekeeper uses ConstraintTemplates (the Rego logic) and Constraints (the parameters). Each policy is a pair.

Policy 1: Block Privileged Containers

Privileged containers have full access to the host kernel. This policy denies any pod spec that requests privileged mode, host network, or host PID:

# constraint-template: no-privileged-containers.yaml
apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata:
  name: k8snoPrivilegedContainers
spec:
  crd:
    spec:
      names:
        kind: K8sNoPrivilegedContainers
  targets:
    - target: admission.k8s.gatekeeper.sh
      rego: |
        package k8snoprivilegedcontainers

        violation[{"msg": msg}] {
          container := input.review.object.spec.containers[_]
          container.securityContext.privileged == true
          msg := sprintf("Container '%v' must not run as privileged", [container.name])
        }

        violation[{"msg": msg}] {
          input.review.object.spec.hostPID == true
          msg := "Pod must not use hostPID"
        }

        violation[{"msg": msg}] {
          input.review.object.spec.hostNetwork == true
          msg := "Pod must not use hostNetwork"
        }

        violation[{"msg": msg}] {
          container := input.review.object.spec.containers[_]
          container.securityContext.capabilities.add[_] == "NET_ADMIN"
          msg := sprintf("Container '%v' may not add NET_ADMIN capability", [container.name])
        }
# constraint: no-privileged-containers.yaml
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sNoPrivilegedContainers
metadata:
  name: no-privileged-containers
spec:
  enforcementAction: deny
  match:
    kinds:
      - apiGroups: [""]
        kinds: ["Pod"]
    excludedNamespaces:
      - kube-system
      - gatekeeper-system
      - kyverno

Policy 2: Approved Container Registries Only

Supply chain attacks start with untrusted images. This policy denies any image not from the approved ECR registry or the internal registry:

apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata:
  name: k8sapprovedregistries
spec:
  crd:
    spec:
      names:
        kind: K8sApprovedRegistries
      validation:
        openAPIV3Schema:
          type: object
          properties:
            allowedRegistries:
              type: array
              items:
                type: string
  targets:
    - target: admission.k8s.gatekeeper.sh
      rego: |
        package k8sapprovedregistries

        violation[{"msg": msg}] {
          container := input.review.object.spec.containers[_]
          not image_from_approved_registry(container.image)
          msg := sprintf(
            "Container '%v' uses unapproved image '%v'. Use one of: %v",
            [container.name, container.image, input.parameters.allowedRegistries]
          )
        }

        image_from_approved_registry(image) {
          registry := input.parameters.allowedRegistries[_]
          startswith(image, registry)
        }
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sApprovedRegistries
metadata:
  name: approved-registries-only
spec:
  enforcementAction: deny
  match:
    kinds:
      - apiGroups: [""]
        kinds: ["Pod"]
    excludedNamespaces: ["kube-system"]
  parameters:
    allowedRegistries:
      - "123456789012.dkr.ecr.eu-central-1.amazonaws.com"
      - "registry.k8s.io"
      - "quay.io/kyverno"

Policy 3: Block latest Tag

The latest tag makes deployments non-reproducible and bypasses security scanning (you scan one digest, deploy a different one). This policy enforces explicit tags or digest references:

apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata:
  name: k8snolatestimage
spec:
  crd:
    spec:
      names:
        kind: K8sNoLatestImage
  targets:
    - target: admission.k8s.gatekeeper.sh
      rego: |
        package k8snolatestimage

        violation[{"msg": msg}] {
          container := input.review.object.spec.containers[_]
          endswith(container.image, ":latest")
          msg := sprintf("Container '%v' uses ':latest' tag. Use an explicit version or digest.", [container.name])
        }

        violation[{"msg": msg}] {
          container := input.review.object.spec.containers[_]
          not contains(container.image, ":")
          msg := sprintf("Container '%v' has no tag. Specify an explicit version or SHA digest.", [container.name])
        }

Audit Mode vs Enforce Mode

Rolling out admission controllers to an existing cluster without prior audit is high-risk – you will likely break existing workloads. Use this three-phase rollout:

Phase 1 – Audit (week 1-2): Set enforcementAction: warn in all Constraints. Gatekeeper logs violations but does not block. Review the audit report to understand current posture:

kubectl get constraint -A -o json | jq '.items[].status.totalViolations'

Phase 2 – Dry-run (week 3-4): Switch to enforcementAction: dryrun. Violations appear in kubectl describe constraint but requests are still allowed. Alert on high-violation counts.

Phase 3 – Enforce (week 5+): Switch to enforcementAction: deny. Coordinate with engineering teams to fix any remaining violations beforehand.

Testing Policies with conftest

Before deploying policy changes, test them against Kubernetes manifests locally using conftest:

# Install conftest
brew install conftest

# Test a Kubernetes manifest against your OPA policies
conftest test k8s/deployment.yaml \
  --policy policies/gatekeeper/ \
  --namespace k8s

<em># Example output:</em>
<em># FAIL - k8s/deployment.yaml - Container 'app' uses ':latest' tag.</em>
<em># FAIL - k8s/deployment.yaml - Container 'app' must not run as privileged.</em>
<em># 2 tests, 0 passed, 0 warnings, 2 failures</em>

Integrate conftest into the CI/CD pipeline to catch policy violations before they reach the cluster:

# .github/workflows/k8s-policy-check.yml
- name: Validate K8s manifests against policies
  run: |
    conftest test k8s/ \
      --policy policies/gatekeeper/ \
      --namespace k8s \
      --output github

Namespace-Level Network Policies as a Complement

Admission controllers control what runs in the cluster. Network policies control how workloads communicate. The two work together. After the label-injection Kyverno policy ensures all pods have consistent labels, these Network Policies enforce zero-trust within the cluster:

# Default deny-all for every namespace
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: default-deny-all
spec:
  podSelector: {}
  policyTypes: [Ingress, Egress]
---
# Allow intra-namespace traffic only
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: allow-same-namespace
spec:
  podSelector: {}
  ingress:
    - from:
        - podSelector: {}
  egress:
    - to:
        - podSelector: {}
  policyTypes: [Ingress, Egress]

Results

After deploying this configuration on a 40-node EKS production cluster at Smaato:

  • Zero privileged containers running in production (down from 8 before enforcement)
  • 100% of pods have explicit resource limits (up from ~40% before mutation policies)
  • CI policy gate catches manifest violations in 90 seconds, before the image is even built
  • CIS Kubernetes Benchmark score on control plane 5.x (Admission Control) moved from 3/9 to 9/9 controls passing