Lesson  in  Kubernetes 101

Pod Security Admission

Label a namespace with the restricted standard, watch the API reject a privileged Pod at the door and harden a manifest until it gets in.

RBAC decides who can create a Pod. It says nothing about what that Pod can be like. And a developer with legitimate permission to deploy in their namespace can ask, without meaning any harm, for a privileged: true container that mounts the node's filesystem. That Pod is no longer inside the cluster: it is above it.

The answer built into Kubernetes is called Pod Security Admission, and it applies the Pod Security Standards, three levels with deliberately boring names:

  • privileged: forbids nothing. It is the absence of policy.
  • baseline: forbids the obvious escalations (privileged containers, hostNetwork, hostPID, mounting node paths). The civilized minimum.
  • restricted: on top of that it demands good practices (not running as root, no privilege escalation, no capabilities, seccomp on). It is the goal to aim for.

Best of all: nothing gets installed. It is an admission controller that is already in the API server and is turned on by labeling a namespace.

Work from the dev-machine tab.

Step 1: Hardening the tienda Namespace

kubectl create namespace tienda
kubectl label namespace tienda \
  pod-security.kubernetes.io/enforce=restricted \
  pod-security.kubernetes.io/warn=restricted \
  pod-security.kubernetes.io/audit=restricted
kubectl get namespace tienda --show-labels

Why three labels and not one?

Because they are the three modes of admission, and they can be mixed:

  • enforce: rejects the Pod. It is the only one with consequences.
  • warn: lets it through but returns a warning in the terminal of whoever applies it.
  • audit: lets it through and records it in the cluster's audit log.

The truly useful combination, and the reason all three exist, is this: to harden a live namespace you start with warn and audit at restricted while enforce stays at baseline. That way you find out what would break without breaking anything, and only when the noise drops to zero do you raise enforce.

Step 2: The Pod that does not get in

Try to create the most innocent Pod in the world, the same one you have been creating since lesson 1 (rebelde means "rebel"):

kubectl run rebelde --image=ghcr.io/iximiuz/labs/nginx:alpine -n tienda

Read the error calmly, because it is one of the most instructive outputs in all of Kubernetes: the API lists every violation, one by one (allowPrivilegeEscalation != false, unrestricted capabilities, runAsNonRoot != true, seccompProfile). Nothing was created. There is no Pod in Pending or in CrashLoopBackOff waiting for someone to diagnose it: the object never came to exist. It is the same admission mechanism you will see again in the Policies module, and the key difference from a scheduler Pending, where the object does exist.

And now try the same command in the default namespace:

kubectl run rebelde --image=ghcr.io/iximiuz/labs/nginx:alpine
kubectl delete pod rebelde

It gets in without a single complaint. Admission is per namespace: security is applied where you say, and that is also its blind spot.

Note

💡 Admission is not retroactive: the Pods that already existed keep running as if nothing happened. Before raising the enforce of a live namespace, check what would break with a server-side dry run: kubectl label --dry-run=server ns <name> pod-security.kubernetes.io/enforce=restricted. The API gives you back the list of existing Pods that would violate the level, without changing anything.

Step 3: The manifest that does get in

Time to dress the Pod. Create api.yaml:

cat << 'EOF' > api.yaml
apiVersion: v1
kind: Pod
metadata:
  name: api
  namespace: tienda
spec:
  securityContext:
    runAsNonRoot: true
    runAsUser: 1000
    seccompProfile:
      type: RuntimeDefault
  containers:
  - name: app
    image: ghcr.io/iximiuz/labs/nginx:alpine
    command: ["sh", "-c", "sleep infinity"]
    securityContext:
      allowPrivilegeEscalation: false
      readOnlyRootFilesystem: true
      capabilities:
        drop:
        - ALL
EOF

The YAML, explained in questions and answers

Are there two securityContext? Which one wins?

Yes, and it helps to be clear about it: the Pod's sets the defaults for all its containers (user identity, seccomp, groups), and the container's fine-tunes them and wins in case of conflict. Some options only exist at one of the two levels: capabilities and readOnlyRootFilesystem are per container; fsGroup and runAsNonRoot make sense at Pod level.

What does runAsNonRoot: true mean compared with runAsUser: 1000?

runAsUser sets the UID. runAsNonRoot is a check: if the image tried to start as root, the kubelet refuses to run it. Both are set because they cover different things: one declares the intention, the other defends it even if the image changes.

What is allowPrivilegeEscalation: false?

It prevents a process from gaining more privileges than its parent (the mechanism of setuid binaries). It is one of the most profitable lines there are: it closes off a whole escalation route without changing a comma of the application.

Why drop: [ALL] in capabilities?

Because a container starts with a handful of Linux capabilities that almost no application uses. restricted demands dropping them all; if one is really needed (the typical one is NET_BIND_SERVICE, to listen on ports below 1024), you drop them all and add back just that one with add.

What is seccompProfile: RuntimeDefault?

It turns on the system-call filter the container runtime ships with, which blocks the dangerous and rarely needed syscalls. It has been there for years, it is free, and until restricted demanded it almost nobody turned it on.

And readOnlyRootFilesystem: true?

restricted does not require it, but it is one of the best practices there are: the container cannot write to its own filesystem. If the application needs to write somewhere, you mount an emptyDir on that specific path, as you learned in the Storage module. An attacker who gets code execution inside the container cannot even drop a file.

Why does this container run sleep and not nginx?

Because of an uncomfortable lesson: the nginx in this image wants to write to its cache and listen on port 80, and neither is possible as user 1000 with a read-only filesystem. Images have to be prepared to run without privileges (listen on a high port, write only to mounted paths). When restricted breaks a rollout, the policy is almost never to blame: it is an image that was built assuming it would be root.

Apply it:

kubectl apply -f api.yaml
kubectl wait --for=condition=Ready pod/api -n tienda --timeout=60s
kubectl get pod api -n tienda
kubectl exec api -n tienda -- id

That id returns uid=1000. The Pod runs, and it is not root.

Summary

  • Three levels (privileged, baseline, restricted) and three modes (enforce, warn, audit), turned on with labels on the namespace.
  • The rejection happens at admission: the Pod never comes to exist. And it is not retroactive.
  • The professional path to harden a live namespace: warn and audit first, enforce when the noise is zero.
  • The minimal manifest to pass restricted: runAsNonRoot, seccompProfile: RuntimeDefault, allowPrivilegeEscalation: false and capabilities.drop: [ALL].
  • If restricted breaks your application, suspect the image before the policy.