Dev & EngARTICLE

PodSecurityPolicy is dead: how to migrate to Pod Security Admission before your next upgrade

PodSecurityPolicy was removed in Kubernetes 1.25, for good. Anyone still relying on it needs to map the three levels of the Pod Security Standards and apply the native controller per namespace.

PodSecurityPolicy is dead: how to migrate to Pod Security Admission before your next upgrade
Image: Rafael Oliveira

PodSecurityPolicy (PSP) was deprecated in July 2021 and removed for good in Kubernetes 1.25. There's no going back: clusters stuck on old versions because of PSP accumulate control-plane CVEs and end up unable to upgrade. The official replacement is Pod Security Admission (PSA), an admission controller built into the kube-apiserver that enforces the Pod Security Standards described in the Kubernetes documentation. The change in model is significant, and migrating without understanding it breaks workloads in production.

The three profiles, from permissive to restrictive

The Pod Security Standards define three cumulative policies, and this is where the architecture decision lives:

  • Privileged: no restrictions at all. Meant for infrastructure and system workloads managed by trusted users. A Pod at this level can access the host network, run as privileged, mount hostPath, whatever it needs.
  • Baseline: blocks known privilege escalations while staying easy to adopt. It forbids spec.hostNetwork, spec.hostPID, spec.hostIPC, containers with privileged: true, hostPath volumes, and hostPort. It's the realistic target for most applications that today run without much ceremony.
  • Restricted: applies real hardening, at the cost of compatibility. On top of everything in baseline, it requires runAsNonRoot: true, forbids runAsUser: 0, mandates an explicit seccompProfile.type (RuntimeDefault or Localhost), requires allowPrivilegeEscalation: false, forces capabilities.drop: [ALL] (only NET_BIND_SERVICE can be added back), and limits volume types to configMap, csi, downwardAPI, emptyDir, ephemeral, persistentVolumeClaim, projected, and secret.

There is no intermediate profile between privileged and baseline, and the documentation explains why: privileges above baseline are so application-specific that they can't be standardized. Anyone who needs something in between falls into the territory of external tools.

How PSA works under the hood

The mental shift is this: PSP was a cluster-wide object tied to RBAC, notoriously hard to get right (the evaluation order of multiple PSPs took down people even in production). PSA applies policy per namespace, via labels. Each profile can be activated in three independent modes:

  • enforce: rejects Pods that violate the policy.
  • audit: allows it, but logs the violation to the audit log.
  • warn: allows it, but returns a warning to the user on kubectl apply.

In practice, you label the namespace:

yaml
apiVersion: v1
kind: Namespace
metadata:
  name: pagamentos
  labels:
    pod-security.kubernetes.io/enforce: baseline
    pod-security.kubernetes.io/enforce-version: v1.30
    pod-security.kubernetes.io/warn: restricted
    pod-security.kubernetes.io/audit: restricted

This is the safe migration pattern: enforce at baseline so nothing breaks right now, and warn/audit at restricted to discover everything that would need to change before tightening the screw. The enforce-version label pins the behavior to a specific Kubernetes version, preventing a cluster upgrade from silently changing what the policy considers valid, an important detail because Restricted has changed between versions (the pod.spec.os.name field started relaxing Linux-only controls on Windows as of 1.25).

The migration plan, measuring before reverting

Migrating from PSP to PSA without downtime is, above all, an exercise in observability. A pragmatic roadmap:

  1. Before upgrading to 1.25, while PSP is still alive, enable PSA in warn/audit mode across all namespaces. Since the two coexist, you can collect violations without duplicate enforcement.
  2. Run --dry-run on your manifests against each profile. The goal is to generate the list of Pods that would fail baseline and restricted, with name and namespace, not a general impression that "it's secure".
  3. Categorize the namespaces: infrastructure (kube-system, ingress, CNI, storage) almost always needs privileged; regular applications go to baseline; new or sensitive services are born in restricted.
  4. Apply enforce gradually, namespace by namespace, starting with the least critical ones. The trade-off is explicit: every Pod that moves up to restricted requires adjusting its securityContext (running as non-root, dropping capabilities, setting seccomp), and any image that assumes UID 0 will break.

What PSA doesn't do, and PSP didn't do well either: mutation. PSA only validates, it doesn't fix the Pod into compliance. If your codebase relied on PSP to inject security defaults, that doesn't migrate directly, you'll need to revise the manifests or use a mutation tool.

Where native PSA isn't enough

The built-in controller is deliberately simple, and it has limits that the documentation itself admits. hostPort, for instance, can only be forbidden entirely (value 0); restricting it to a known list of ports isn't supported by native PSA. Granular policy per workload, label-based exceptions, custom rules that fall outside the three profiles: none of that fits into PSA.

For these cases, the documentation explicitly points to three alternatives from the CNCF ecosystem: Kyverno, OPA Gatekeeper, and Kubewarden. They handle validation and mutation with custom-written policies, and it's common to see them running alongside PSA: PSA guarantees the floor (baseline across the whole cluster) and the external tool covers the fine-grained rules. The trade-off here is operational: one more critical component in the admission path, more latency on apply, and one more thing to monitor. It's only worth it when the three fixed profiles genuinely don't express the policy the organization needs.

What Brazilian teams need to do now

Anyone still running 1.24 or earlier because of PSP has gone without control-plane security patches for too long, and the cost of delaying only grows. The migration isn't optional or reversible: PSP no longer exists in the codebase. The lowest-risk path is to label namespaces with enforce: baseline and warn/audit: restricted before the upgrade, read the audit log for a few weeks, and only then tighten enforcement. If something doesn't fit into baseline, the Pod goes into an isolated, explicitly trusted privileged namespace, a recorded decision, not a silent default. A security postmortem starts here: knowing exactly which namespace runs under which profile, and why.

Translated from the Brazilian Portuguese original · Read the original

Read also