Containerization Lesson 42 of 113

Migrating to Pod Security Admission: Enforcing Baseline and Restricted Profiles Without Breaking Workloads

In a nutshell

Imagine every namespace in your cluster has a bouncer on the door with a dress code. When you try to start a Pod, the bouncer checks the Pod’s security settings against that dress code and either lets it in, lets it in but notes your name in a logbook, or turns it away at the door. Pod Security Admission (PSA) is that bouncer — a security gate built into Kubernetes itself. You do not install anything; you just tell each namespace which dress code to apply.

There are three dress codes, from loosest to strictest: privileged (anything goes), baseline (no obvious break-out tricks), and restricted (hardened, locked down). And there are three ways the bouncer can react, each set independently: enforce (turn violators away), audit (let them in but record it), and warn (let them in but tell you). You choose the code and the reaction per namespace with plain Kubernetes labels — nothing more.

PSA is the successor to PodSecurityPolicy (PSP), an older feature that tried to do the same job but was so awkward to configure safely that Kubernetes removed it in v1.25. PSA deliberately trades PSP’s flexibility for predictability: fewer knobs, no ordering surprises, no RBAC wiring — which is exactly what makes it possible to roll out across a live cluster without an outage.

Level: Advanced · Time: ~30 min

Prerequisites. You should be comfortable applying YAML with kubectl, and you will get far more from this if you have met a Pod’s securityContext — the per-Pod and per-container security fields PSA actually inspects. If those are hazy, read Kubernetes Security Contexts, In Depth: runAsNonRoot, Capabilities, seccomp & AppArmor first. PSA is only one of several gatekeepers inside the API server’s admission chain, so a rough mental model of admission control helps too.

After this lesson you can:

The diagram below is the whole lesson in one picture: a Pod is created in a namespace, PSA reads that namespace’s labels to pick a level, checks the Pod’s fields against it, and — depending on the mode — admits, rejects, or merely records the result. The five numbered notes are the traps that account for almost every PSA surprise; we hit each one in turn.

How Pod Security Admission gates a Pod — the namespace level times the mode decides admit, reject, or warn

PodSecurityPolicy was deprecated in Kubernetes 1.21 and removed in 1.25. If your hardening still depends on it, the upgrade that drops it is a cliff, not a ramp. The built-in replacement, Pod Security Admission (PSA), is deliberately simpler: no policy objects, no RBAC to bind, no ordering ambiguity. It trades flexibility for predictability, and that trade is the whole reason a migration can be done without an outage. This is the playbook I run: inventory first, label deliberately, fix the handful of Restricted blockers that account for almost every failure, and flip enforce only when audit has been quiet for a release cycle.

1. The model: three modes, three levels

PSA is a built-in admission controller, enabled by default since 1.25. It evaluates Pods against the Pod Security Standards and applies a verdict per namespace, configured purely through labels. There is nothing to install.

Two axes define behavior. The mode decides what happens on a violation; the level decides how strict the bar is.

Mode On violation Blocks creation? Use for
enforce Pod is rejected Yes The actual gate
audit Allowed; annotation written to the audit log No Inventory without disruption
warn Allowed; warning returned to the client No Feedback to whoever applied it
Level Intent Typical fit
privileged Unrestricted, no constraints System / infra namespaces only
baseline Blocks known privilege escalations; minimally restrictive Most application workloads
restricted Hardened, current best practice New workloads, regulated estates

The three modes are independent and can each point at a different level. That is the single most important property for a safe rollout: you can set enforce to baseline while pointing audit and warn at restricted, so the cluster is protected at one bar while you measure the cost of the stricter one.

PSA controls only the Pod security context fields covered by the Pod Security Standards. It does not do image provenance, network policy, resource quotas, or anything custom. If your requirement is not one of runAsNonRoot, capabilities, host namespaces, volume types, seccompProfile, and the like, PSA is the wrong tool and you want Kyverno or a validating webhook (step 6).

A namespace with no PSA labels inherits the cluster defaults, which out of the box are privileged for every mode. That means doing nothing leaves you wide open — an empty cluster is not secure by default, it is permissive by default.

How a label becomes a decision. To make the two axes concrete, trace one Pod. Suppose team-payments carries pod-security.kubernetes.io/enforce: baseline and pod-security.kubernetes.io/warn: restricted. You kubectl apply a Deployment whose container sets securityContext.privileged: true. At admission the API server reads the namespace labels, picks baseline for the enforce decision, evaluates the Pod, finds a privileged container (a baseline violation), and rejects the create — the Deployment’s ReplicaSet cannot produce a Pod. Now suppose instead the container is not privileged but also does not set allowPrivilegeEscalation: false. That is fine for baseline (enforce admits it) but violates restricted, so the warn axis fires: the client sees a warning while the Pod still starts. Same Pod, two axes, two independent outcomes — that is the entire mental model, and it is what makes a staged rollout possible.

The labels are ordinary Kubernetes labels. You can set them with kubectl label ns, bake them into the Namespace manifest, or template them from Helm/Kustomize. There is no PodSecurity object to create and no controller to install; the API server reads the label value directly at admission time. That simplicity is the point — and it is the whole reason PSA can be reasoned about where PSP could not.

2. Inventory violations cluster-wide before enforcing anything

Never lead with enforce. Lead with measurement. The fastest read on what a target level would cost is the dry-run check on an existing namespace, which evaluates every running Pod against a level without changing any configuration:

# What would `restricted` reject in this namespace, right now?
kubectl label --dry-run=server --overwrite ns team-payments \
  pod-security.kubernetes.io/enforce=restricted

The server runs the admission check against all current Pods and prints every workload that would be denied, with the exact field at fault. Nothing is persisted. Loop it across the cluster to build the estate-wide picture:

for ns in $(kubectl get ns -o jsonpath='{.items[*].metadata.name}'); do
  echo "== $ns =="
  kubectl label --dry-run=server --overwrite ns "$ns" \
    pod-security.kubernetes.io/enforce=restricted 2>&1 | grep -E 'warn|violate' || echo "clean"
done

For a durable, queryable inventory rather than a one-shot scan, set warn and audit cluster-wide via the AdmissionConfiguration file passed to the API server. This evaluates every new and updated Pod without blocking anything:

# admission-config.yaml — referenced by --admission-control-config-file
apiVersion: apiserver.config.k8s.io/v1
kind: AdmissionConfiguration
plugins:
  - name: PodSecurity
    configuration:
      apiVersion: pod-security.admission.config.k8s.io/v1
      kind: PodSecurityConfiguration
      defaults:
        enforce: "privileged"      # do NOT enforce yet
        enforce-version: "latest"
        audit: "restricted"        # measure the strict bar everywhere
        audit-version: "latest"
        warn: "restricted"
        warn-version: "latest"
      exemptions:
        namespaces:
          - kube-system            # never evaluate control-plane add-ons
          - kube-node-lease

On a managed control plane (EKS, AKS, GKE) you cannot pass API-server flags. There, drive the same outcome with namespace labels at the warn/audit level, or — cleaner at scale — a Kyverno policy in Audit mode that mirrors the standards. Either way the rule holds: collect the full violation set before a single namespace moves to enforce.

Audit verdicts land in the API-server audit log as annotations. If you ship audit logs to a SIEM, that is your dashboard source:

// Azure Monitor / Log Analytics example: PSA audit violations by namespace
AzureDiagnostics
| where Category == "kube-audit"
| extend ann = parse_json(log_s)
| where tostring(ann.annotations["pod-security.kubernetes.io/audit-violations"]) != ""
| summarize violations = count() by namespace = tostring(ann.objectRef.namespace)
| order by violations desc

3. Namespace labeling strategy and exemptions

PSA configuration is three labels per namespace, optionally paired with a version pin (step 7):

apiVersion: v1
kind: Namespace
metadata:
  name: team-payments
  labels:
    pod-security.kubernetes.io/enforce: baseline
    pod-security.kubernetes.io/enforce-version: v1.31
    pod-security.kubernetes.io/audit: restricted
    pod-security.kubernetes.io/warn: restricted

This is the asymmetry that makes the rollout safe: enforce is held at baseline (the bar you have already cleared), while audit and warn run restricted ahead of it. The namespace is genuinely protected, and you have a live readout of the remaining work to reach restricted — without rejecting anything.

The enforce label is evaluated only at Pod create / update admission. Adding it does not evict Pods that already violate it — they keep running until their next rollout. That is a feature for migration (no surprise outage) and a trap for assurance (an unenforced violation can linger for weeks). Roll the affected Deployments deliberately once you flip enforce, and treat “labeled” and “compliant” as different states.

Exemptions are the escape hatch for components that legitimately cannot satisfy any standard — CNI agents, CSI drivers, node-problem-detector, monitoring DaemonSets that read host paths. There are two mechanisms with very different blast radius:

Never enforce restricted or baseline on kube-system. Core add-ons run privileged by design, and rejecting them will brick the control plane. Exempt it explicitly, then put the privileged components you own in dedicated namespaces (step 5) rather than dumping them into kube-system.

4. Fix the common Restricted blockers

Roughly five fields produce the overwhelming majority of restricted rejections. Knowing them turns “audit the whole estate” into a short, mechanical fixup. Restricted requires all of the following at the Pod and container level:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
spec:
  template:
    spec:
      securityContext:
        runAsNonRoot: true              # must not run as UID 0
        seccompProfile:
          type: RuntimeDefault          # required by restricted
      containers:
        - name: api
          image: ghcr.io/acme/api@sha256:...
          securityContext:
            allowPrivilegeEscalation: false   # required
            runAsNonRoot: true
            capabilities:
              drop: ["ALL"]                   # drop everything
            readOnlyRootFilesystem: true      # baseline-recommended, restricted-friendly

The recurring failures and their fixes:

Blocker Why it fails Fix
runAsNonRoot Image defaults to root; no runAsUser/runAsNonRoot set Set runAsNonRoot: true; ensure the image has a numeric non-root user
seccompProfile Unset — restricted requires it explicitly seccompProfile.type: RuntimeDefault at Pod or container level
capabilities Not dropped, or adds beyond NET_BIND_SERVICE drop: ["ALL"]; the only add restricted permits is NET_BIND_SERVICE
allowPrivilegeEscalation Defaults to true Set false explicitly on every container
Running as root for ports < 1024 App binds 80/443 directly Bind a high port + Service remap, or add: ["NET_BIND_SERVICE"]

The runAsNonRoot failure catches teams off guard because runAsNonRoot: true is an assertion, not a coercion. It does not change the UID — it tells the kubelet to refuse a container whose image would run as 0. If the image has no non-root user baked in, PSA admits the Pod and the kubelet then fails it with container has runAsNonRoot and image will run as root. The real fix lives in the Dockerfile:

# Give the image a non-root user so runAsNonRoot is satisfiable
RUN addgroup -S app && adduser -S -G app -u 10001 app
USER 10001:10001

One more frequent trap: restricted narrows volumes to a safe list (configMap, csi, downwardAPI, emptyDir, ephemeral, persistentVolumeClaim, projected, secret), and hostPath is also forbidden one rung down at baseline. A sidecar mounting hostPath for logs or metrics will fail no securityContext tweak — it needs a different volume (emptyDir, projected, CSI), or the workload moves to a namespace exempt from PSA (or explicitly pinned to privileged). Dropping to baseline will not save a hostPath mount, because baseline forbids hostPath too.

5. Workloads that genuinely need Privileged

Some workloads cannot be hardened: ebpf agents, GPU device plugins, storage drivers, anything touching host namespaces or devices. The mistake is granting privileged broadly to accommodate them. Quarantine them instead.

Create dedicated, clearly named namespaces, set them to privileged, and compensate with controls outside PSA’s scope — because PSA cannot express “privileged but only for this ServiceAccount”:

apiVersion: v1
kind: Namespace
metadata:
  name: infra-privileged
  labels:
    pod-security.kubernetes.io/enforce: privileged
    pod-security.kubernetes.io/audit: privileged
    pod-security.kubernetes.io/warn: privileged
    purpose: privileged-system-components

Wrap the namespace in defense in depth:

The discipline is: privileged is a property of a small, named set of namespaces you can list on one screen — not a default that leaks into application space.

6. Layer Kyverno where PSA is too coarse

PSA is intentionally blunt: a namespace is privileged, baseline, or restricted, full stop. Real estates need finer rules — “restricted everywhere, except this one DaemonSet may add SYS_PTRACE,” or “allow hostPath, but only under /var/log.” That granularity is exactly what PSA omits, and where a policy engine earns its place.

Kyverno can apply the Pod Security profiles itself, with per-control exclusions PSA cannot express:

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: psa-restricted-with-exception
spec:
  validationFailureAction: Audit     # start in Audit, promote to Enforce later
  background: true
  rules:
    - name: restricted-baseline
      match:
        any:
          - resources:
              kinds: ["Pod"]
      validate:
        podSecurity:
          level: restricted
          version: latest
          exclude:
            # allow ONLY this control, ONLY for the matched images
            - controlName: "Capabilities"
              images: ["ghcr.io/acme/network-agent*"]

The decision rule I use: PSA is the floor, Kyverno is the scalpel. Keep PSA enabled and enforcing baseline/restricted at the namespace level so there is always a backstop that survives a Kyverno outage. Reach for Kyverno only when you need an exception narrower than a whole namespace, or a control PSA does not cover (image signatures, required labels, registry allow-lists). Running both is not redundant — PSA is the dependency-free guarantee; Kyverno is the expressive layer on top.

7. Phased rollout: warn to audit to enforce, with version pinning

The version pin is the underrated control. The Pod Security Standards tighten across Kubernetes releasesrestricted in 1.31 forbids things 1.27 allowed. If you pin enforce to latest, a cluster upgrade can silently start rejecting Pods that were compliant yesterday. Pin every enforce to an explicit version, and bump it as a deliberate, reviewed change:

metadata:
  labels:
    pod-security.kubernetes.io/enforce: restricted
    pod-security.kubernetes.io/enforce-version: v1.31   # pinned, not "latest"
    pod-security.kubernetes.io/warn: restricted         # warn can ride latest
    pod-security.kubernetes.io/warn-version: latest

The per-namespace progression I run:

  1. Warn + audit at the target level (enforce still privileged or baseline). No rejections; collect violations from logs and client warnings for a full sprint.
  2. Fix the workloads using step 4. Re-run dry-run until the namespace is clean against the target.
  3. Enforce at the target, pinned to the current version. Now roll the Deployments so existing Pods are re-admitted under the new bar — labeling alone does not evict them.
  4. Hold for one release cycle, watching audit annotations for regressions from new deploys, then advance the next namespace.

Stage the levels too: most application namespaces should reach enforce: baseline first (cheap, high value), then graduate to restricted as the Dockerfile and securityContext work lands. Do not try to land restricted everywhere in one change.

Going deeper

The seven steps above are the playbook. This section is the reference and the why behind it: the history that explains PSA’s shape, the exact field rules the audit log points at, and the edges where PSA hands off to a policy engine.

Why PodSecurityPolicy was removed — and what PSA fixed

PSP was Kubernetes’ first built-in pod-hardening mechanism, and it failed in the field for reasons worth understanding, because PSA was designed as a direct answer to each one.

The community concluded the model was unfixable, deprecated it in 1.21, and removed it in 1.25. PSA is the deliberate opposite: it is authorization-independent (labels on the namespace, not RBAC on a policy), never mutates (it only validates — admit or deny), has no ordering (one level per mode per namespace), and is trivially dry-runnable (kubectl label --dry-run=server). The cost of that simplicity is expressiveness — which is the whole reason a policy engine still has a place (see Where PSA stops).

If you are migrating off PSP-era clusters, the job is not “translate each PSP to a PSA label.” It is “measure what your Pods actually need with audit mode, then choose the coarsest level that holds.” Most PSP estates collapse to two or three namespaces of privileged plus baseline/restricted everywhere else.

What each level forbids, field by field

This is the reference the audit annotations point at. Baseline blocks the well-known break-out vectors; restricted adds hardening and, crucially, starts requiring fields to be set rather than only forbidding values. Every rule below applies to spec.containers[*], spec.initContainers[*], and spec.ephemeralContainers[*] unless noted otherwise.

Baseline control Field(s) Allowed
Privileged containers securityContext.privileged unset or false
Host namespaces spec.hostNetwork, spec.hostPID, spec.hostIPC unset or false
HostPath volumes spec.volumes[*].hostPath not present
Host ports ports[*].hostPort unset or 0
Added capabilities securityContext.capabilities.add only the default runtime set + NET_BIND_SERVICE
Seccomp securityContext.seccompProfile.type anything except Unconfined (unset is allowed)
/proc mount securityContext.procMount unset or Default
AppArmor securityContext.appArmorProfile.type unset, RuntimeDefault, or Localhost
SELinux seLinuxOptions.type/user/role only the safe container types; user/role must be empty
Sysctls securityContext.sysctls[*].name unset or the kernel safe list
HostProcess (Windows) securityContext.windowsOptions.hostProcess unset or false

Note the shape: baseline is almost entirely “do not turn on the dangerous switch.” Ordinary application workloads that never asked for host access usually pass baseline untouched.

Restricted adds (on top of all of baseline) Field(s) Requirement
Run as non-root securityContext.runAsNonRoot must be true (Pod or container level)
Non-root UID securityContext.runAsUser if set, must not be 0
Privilege escalation securityContext.allowPrivilegeEscalation must be false on every container
Drop capabilities securityContext.capabilities.drop must include ALL
Add capabilities securityContext.capabilities.add at most NET_BIND_SERVICE, nothing else
Seccomp securityContext.seccompProfile.type must be RuntimeDefault or Localhost (stricter than baseline)
Volume types spec.volumes[*] only configMap, csi, downwardAPI, emptyDir, ephemeral, persistentVolumeClaim, projected, secret

The jump from baseline to restricted is a change in kind: baseline forbids values, restricted demands them. That is why a Pod which sails through baseline still fails restricted — it is not doing anything dangerous, it simply has not declared runAsNonRoot: true, allowPrivilegeEscalation: false, capabilities.drop: [ALL], and a seccomp profile. Those four lines are the tax restricted charges, and step 4 shows the exact block to paste.

The restricted profile’s exact requirements — the minimum passing Pod

Stated positively, here is the smallest securityContext that satisfies restricted for a normal web workload:

spec:
  securityContext:
    runAsNonRoot: true
    seccompProfile:
      type: RuntimeDefault
  containers:
    - name: app
      image: ghcr.io/acme/app@sha256:...
      securityContext:
        allowPrivilegeEscalation: false
        capabilities:
          drop: ["ALL"]
      # plus: the image must ship a non-root user (USER 10001 in the Dockerfile)

runAsNonRoot and seccompProfile can live at the Pod level and be inherited by every container; allowPrivilegeEscalation and capabilities.drop must be set on each container, including init and ephemeral containers. Miss one sidecar and the whole Pod is rejected — restricted is evaluated per container, and the strictest result wins.

Where PSA stops: no mutation, coarse granularity

PSA has exactly three limits, and each one is a signpost to a policy engine:

  1. It never mutates. PSA can only say yes or no. It will not add drop: ["ALL"] for you, will not inject a seccomp profile, will not set a non-root UID. Every fix is your manifest’s job. If you want the cluster to auto-remediate — quietly hardening Pods on the way in — you need a mutating engine such as Kyverno.
  2. Its unit is the whole namespace. A namespace is privileged, baseline, or restricted — full stop. You cannot say “restricted, except this one DaemonSet may add SYS_PTRACE.” The moment you need an exception narrower than a namespace, PSA is out of room.
  3. It only knows the Pod Security Standards. Image provenance, registry allow-lists, required labels, resource limits, network posture — none are PSS fields, so PSA is blind to them.

The decision rule, restated: PSA is the floor; a policy engine is the scalpel. Keep PSA enforcing baseline/restricted as a dependency-free backstop that survives a controller outage, and reach for Kyverno or OPA Gatekeeper only for the exceptions and the non-PSS rules — as step 6 shows in practice.

Exemptions and their blast radius

Some subjects legitimately cannot meet any standard, and PSA can exempt them along three dimensions, all set in the cluster-wide AdmissionConfiguration: namespaces, runtimeClasses, and usernames. An exemption is evaluated before the level check, so an exempt subject skips PSA entirely — it is not “privileged,” it is unchecked.

The three differ sharply in blast radius:

One caveat that surprises people: PSA evaluates the Pod, not the Deployment or Job. Labels and exemptions are matched against the Pod’s namespace and the creating identity — which for a Deployment is the ReplicaSet controller’s ServiceAccount, not the human who ran kubectl apply. Step 3 covers the operational side; the rule here is exempt the fewest subjects, at the smallest scope, that you can name out loud.

The audit → warn → enforce rollout, as one motion

The three modes are not a sequence you click through on a single setting — they run simultaneously, each pointed at a different level, and that is the actual mechanism of a safe migration. The canonical staggered configuration:

Phase enforce audit warn What it buys you
Measure privileged (or current) restricted restricted Full violation inventory, zero rejections
Protect + measure baseline restricted restricted A real backstop now, cost of restricted still visible
Enforce restricted (pinned) restricted restricted The gate is live; audit/warn now catch regressions

Because audit and warn never block, you can run the strict bar ahead of enforce indefinitely, watching the audit annotation stream go quiet as teams fix workloads. When it has been silent for a release cycle, promoting enforce to that level is a non-event — you already know nothing will break. Step 7 gives the per-namespace command sequence; the idea to hold onto is that “audit-first” is not a phase you leave behind, it is a permanent early-warning layer you keep running above enforce.

Enterprise scenario

A payments platform team ran a 1.24 EKS fleet hardened entirely with PodSecurityPolicies — dozens of PSPs plus the RBAC ClusterRole/RoleBinding web PSP required to take effect. The 1.25 upgrade removed PSP. Because enforcement depended on those bindings, the upgrade did not error loudly; it just silently stopped enforcing. For three days every namespace was effectively privileged and nobody noticed, until a routine CIS benchmark scan flagged the regression.

The constraint: ~140 namespaces, a hard PCI-DSS requirement that workloads not run as root, and zero tolerance for blocking payment Deployments during business hours. A flat enforce: restricted would have rejected legacy services still running as UID 0 and taken down a node-local fraud-scoring DaemonSet mounting a hostPath socket.

What they did, in order:

  1. Set cluster-wide audit: restricted and warn: restricted via the AdmissionConfiguration (self-managed control plane), enforce left at privileged. The audit annotations went to the existing Splunk pipeline, producing a ranked list: 31 namespaces clean, 12 needing runAsNonRoot/seccompProfile fixes, 1 genuinely needing privileged.
  2. Moved the fraud-scoring DaemonSet into a dedicated infra-privileged namespace pinned to privileged, fenced with RBAC, a default-deny NetworkPolicy, and a Kyverno rule blocking hostNetwork.
  3. Promoted the 31 clean namespaces straight to enforce: restricted, version-pinned to v1.25, rolling each Deployment off-hours.
  4. Fixed the 12 laggards over two sprints — mostly a USER 10001 line in the Dockerfile and adding seccompProfile: RuntimeDefault — then enforced them.

The version pin paid off six months later: the 1.28 upgrade introduced no surprise rejections, because enforce-version was held at the level the workloads were validated against. The graduation to the 1.28 restricted profile was scheduled as its own change with its own dry-run pass.

# the load-bearing config: strict where measured, privileged only where named
defaults:
  enforce: "restricted"
  enforce-version: "v1.25"   # pinned to the validated standard
  audit: "restricted"
  warn: "restricted"
exemptions:
  namespaces: ["kube-system", "kube-node-lease", "infra-privileged"]

Verify

Confirm the enforcement is real, not just labeled.

# 1. Inspect the live PSA labels on a namespace
kubectl get ns team-payments -o jsonpath='{.metadata.labels}' | jq

# 2. Prove enforce actually rejects — this Pod violates restricted and must fail
kubectl run psa-probe --image=nginx -n team-payments
# expected: Error ... violates PodSecurity "restricted:v1.31": allowPrivilegeEscalation != false,
#           unrestricted capabilities, runAsNonRoot != true, seccompProfile ...

# 3. Confirm existing Pods were actually re-admitted (not lingering pre-enforce)
kubectl get pods -n team-payments -o json \
  | jq '.items[].metadata.annotations["pod-security.kubernetes.io/enforce-policy"]'

# 4. Server-side dry-run: does anything STILL violate the target level?
kubectl label --dry-run=server --overwrite ns team-payments \
  pod-security.kubernetes.io/enforce=restricted

Catch regressions before they reach the cluster by running the standards in CI with conftest, so a non-compliant manifest fails the PR rather than the namespace:

# Evaluate rendered manifests against an OPA/Rego PSA policy in the pipeline
helm template ./chart | conftest test --policy ./policy/pod-security.rego -

A green CI gate plus a quiet audit annotation stream for a full release cycle is the signal that a namespace is genuinely converged — not the presence of the label.

Practice challenges

Work these in order; each has a graded solution. Assume Kubernetes 1.29+ with PSA enabled (the default since 1.25). Where a live cluster would print output, it is labelled representative.

Challenge 1 (Beginner) — Turn on warnings without blocking anything. Label the namespace dev-web so that applying a Pod that violates restricted prints a warning but still succeeds.

<details> <summary>Solution</summary>

kubectl label ns dev-web \
  pod-security.kubernetes.io/warn=restricted \
  pod-security.kubernetes.io/warn-version=latest

warn (unlike enforce) never blocks, so latest is safe here — a warning cannot break anything. A non-compliant apply now prints Warning: would violate PodSecurity "restricted:latest": ... and still creates the Pod. </details>

Challenge 2 (Beginner) — Predict the verdict. team-api has pod-security.kubernetes.io/enforce: baseline. You apply a Pod whose only unusual field is hostNetwork: true. Admit or reject? What if the field were allowPrivilegeEscalation: true instead?

<details> <summary>Solution</summary>

hostNetwork: truerejected: host namespaces are a baseline control. allowPrivilegeEscalation: trueadmitted: that field is only a restricted requirement, so baseline does not care. This is the baseline-vs-restricted line captured in one example. </details>

Challenge 3 (Intermediate) — Measure before enforcing. Without changing any configuration, find out which workloads in team-payments would be rejected if you set enforce: restricted.

<details> <summary>Solution</summary>

kubectl label --dry-run=server --overwrite ns team-payments \
  pod-security.kubernetes.io/enforce=restricted

Server-side dry-run evaluates every running Pod against the level and prints each violator with the offending field, persisting nothing. This is the single most useful PSA command in a migration. </details>

Challenge 4 (Intermediate) — Fix a failing Pod. This Deployment is rejected by restricted. Add the minimum securityContext to make it pass (assume the image already ships a non-root user).

apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
spec:
  template:
    spec:
      containers:
        - name: web
          image: ghcr.io/acme/web@sha256:abc123

<details> <summary>Solution</summary>

apiVersion: apps/v1
kind: Deployment
metadata:
  name: web
spec:
  template:
    spec:
      securityContext:
        runAsNonRoot: true
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: web
          image: ghcr.io/acme/web@sha256:abc123
          securityContext:
            allowPrivilegeEscalation: false
            capabilities:
              drop: ["ALL"]

Four requirements: runAsNonRoot and seccompProfile (both inheritable from the Pod), plus allowPrivilegeEscalation: false and capabilities.drop: [ALL] on the container. Miss any one and it is still rejected. </details>

Challenge 5 (Advanced) — Order the rollout. You must move team-payments (dozens of running Pods, some non-compliant) to enforce: restricted with zero downtime. List the steps in order.

<details> <summary>Solution</summary>

  1. Set warn: restricted and audit: restricted; leave enforce at baseline. Collect violations for a release cycle.
  2. Fix the flagged workloads (step 4) and re-run the dry-run until clean.
  3. Set enforce: restricted with enforce-version pinned to your cluster’s version.
  4. Roll the Deployments (kubectl rollout restart deploy -n team-payments) so existing Pods are re-admitted under the new bar — labelling alone does not evict them.
  5. Hold and watch audit annotations for a cycle before advancing the next namespace.

The load-bearing detail is step 4: enforce only checks Pods at admission, so old Pods keep running until you roll them. </details>

Challenge 6 (Advanced) — Accommodate a workload that needs hostPath. A node-local log shipper must mount hostPath: /var/log. Your cluster is restricted by default. How do you run it without weakening the whole cluster — and why will dropping its namespace to baseline not help?

<details> <summary>Solution</summary>

hostPath is forbidden at both restricted and baseline, so dropping to baseline changes nothing. Options, best first: (a) put the shipper in a dedicated namespace labelled enforce: privileged (or exempt it in the AdmissionConfiguration), fenced with RBAC, a default-deny NetworkPolicy, and a Kyverno rule permitting only the one hostPath it needs; (b) redesign it to read logs via the Kubernetes logging API or a projected volume instead of hostPath. PSA cannot express “privileged for this one path,” which is exactly why the quarantine-plus-Kyverno pattern exists. </details>

Common beginner mistakes

These are misconceptions about how PSA thinks — distinct from the operational traps in Pitfalls below. Each is a wrong mental model paired with the right one.

Checklist

Pitfalls

Glossary

Next steps: wire the conftest PSA gate into the same CI stage as your image-signature verification so admission and pipeline policy never drift, and schedule the standards version bump (e.g., to the 1.31 restricted profile) as a recurring, dry-run-gated change rather than letting a cluster upgrade decide it for you.

kubernetessecuritypod-security-admissionadmission-controlcompliance
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments