Restricted Pod Security blocks the CI deployer while RBAC blocks a second team
Namespace ci-builds carries pod-security.kubernetes.io/enforce: restricted. A CI controller creates batch/v1 Jobs there with ServiceAccount ci-deployer, bound by a Role granting create,get,list,watch,delete on pods, pods/log, jobs.batch and deployments.apps in ci-builds. Build pod spec: image node:20, no securityContext, no resources, serviceAccountName: ci-deployer. Builds write to /workspace, which is owned by root in the image.
Two different errors land in the same ticket queue:
pods "build-7f2c9" is forbidden: violates PodSecurity "restricted:latest": allowPrivilegeEscalation != false, unrestricted capabilities (must set securityContext.capabilities.drop=["ALL"]), runAsNonRoot != true, seccompProfilejobs.batch is forbidden: User "system:serviceaccount:ci-builds:ci-deployer" cannot create resource "jobs" in API group "batch" in the namespace "ci-staging"
A developer proposes relabelling ci-builds to privileged, calling it "only build jobs".
1. Both are 403s. How do you tell a Pod Security Admission rejection from an RBAC denial, and what does each one tell you to change?
Read the worked answer
Read the message, not the status code. A PSA rejection is emitted by an admission plugin and always quotes the level and version plus the specific fields to fix: violates PodSecurity "restricted:latest": .... An RBAC denial comes from the authorizer and names verb, resource, API group and namespace: cannot create resource "jobs" in API group "batch" in the namespace "ci-staging".
The remedies are in different layers. PSA blocks the object regardless of who submits it, so the fix is the pod spec or the namespace's enforce level. RBAC is about identity, so the fix is a RoleBinding - here the SA is bound only in ci-builds while the Job targets ci-staging; either create the Job in the right namespace or add the binding there.
Confirm without guessing:
kubectl auth can-i create jobs.batch -n ci-staging --as=system:serviceaccount:ci-builds:ci-deployer
The useful tell is a mismatch: if auth can-i says yes and the create still fails, the block is admission, not RBAC. For PSA, inspect the namespace labels (kubectl get ns ci-builds -o jsonpath='{.metadata.labels}') and, if configured, the API server audit annotation pod-security.kubernetes.io/audit-violations.
2. Write a securityContext that satisfies `restricted` for a build container that must run as non-root and write to `/workspace`, and describe how you would find the next breakage before enforcing.
Read the worked answer
spec:
securityContext:
runAsNonRoot: true
runAsUser: 1000
runAsGroup: 1000
fsGroup: 1000
seccompProfile: {type: RuntimeDefault}
containers:
- name: build
image: node:20
securityContext:
allowPrivilegeEscalation: false
capabilities: {drop: ["ALL"]}
volumeMounts:
- {name: workspace, mountPath: /workspace}
volumes:
- name: workspace
emptyDir: {}
Reasoning: restricted requires allowPrivilegeEscalation: false, all capabilities dropped, runAsNonRoot: true and an explicit seccomp profile; it also forbids hostPath, hostNetwork, hostPort and privileged containers. node:20 runs as root, so runAsUser: 1000 alone makes the build crash on /workspace with EACCES. fsGroup: 1000 on an emptyDir makes the mount group-writable so a non-root process can write - the practical alternative is baking USER 1000 plus a chown into your own image, which is better because it also fixes the root-owned cache directories npm creates.
To catch the rest safely: set pod-security.kubernetes.io/enforce: baseline together with pod-security.kubernetes.io/warn: restricted and audit: restricted, run a full day of real pipelines, and harvest the warnings from kubectl stderr and the audit log. That surfaces every remaining violation with zero broken builds, and the flip to enforce: restricted becomes a no-op.
3. Respond to the proposal to relabel the namespace `privileged`, and give two controls that reduce blast radius better.
Read the worked answer
No. privileged removes the only guardrail from the one namespace in the cluster that executes arbitrary code from pull requests. A build job that can be privileged, mount the node filesystem or use hostNetwork is a node-level compromise, and the build image is the least trusted artifact you run. It also lets a compromised pipeline reach the kubelet's credentials on the node.
Two better controls:
Shrink what CI may create.
ci-deployerholdscreateonpods- that is effectivelycreateon pods running as any ServiceAccount in the namespace, which is a privilege-escalation path to a more privileged identity (RBAC cannot restrict whichserviceAccountNamea client sets without help). Droppods: createand let the controller create onlyjobs.batch, then add a ValidatingAdmissionPolicy or Kyverno rule pinningspec.template.spec.serviceAccountNametoci-deployerand images to your registry.Isolate the workload physically. Schedule builds onto a dedicated node pool with a taint, and run them under a sandboxed runtime (gVisor or Kata via
RuntimeClass) so a container escape does not own the node. Keeprestrictedenforced throughout - it is compatible with both measures, and it is what makes the escape harder in the first place.