Skip to content

Worked review 3: a platform namespace with RBAC, quotas, and NetworkPolicy

The prompt was: “Set up a new platform namespace for a team: enforce security baselines, cap resource usage, give their app’s ServiceAccount the access it needs, and lock down the network.” This is the densest kind of AI-generated bundle — seven different object kinds, each individually a small, plausible-looking snippet copied from a slightly different tutorial. It has 7 real bugs; check the AI bug catalog for the general pattern behind each one. Every claim below was proven on the same real two-node kind cluster used in worked review 1 (reading-b, Kubernetes 1.37.0), with Calico v3.31.5 installed for real NetworkPolicy enforcement — real kubectl apply, real admission rejections, real kubectl auth can-i, and a real, timed network test.

# platform Namespace + Pod Security Admission labels, as generated -- see review below
apiVersion: v1
kind: Namespace
metadata:
name: platform
labels:
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/warn: restricted
pod-security.kubernetes.io/audit: restricted
# platform-quota ResourceQuota, as generated -- see review below
apiVersion: v1
kind: ResourceQuota
metadata:
name: platform-quota
namespace: platform
spec:
hard:
requests.cpu: "4"
requests.memory: 8Gi
limits.cpu: "8"
limits.memory: 16Gi
pods: "20"
# platform-app ServiceAccount + ClusterRoleBinding, as generated -- see review below
apiVersion: v1
kind: ServiceAccount
metadata:
name: platform-app
namespace: platform
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
name: platform-app-admin
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: cluster-admin
subjects:
- kind: ServiceAccount
name: platform-app
namespace: platform
# platform-app agent Pod, as generated -- see review below
apiVersion: v1
kind: Pod
metadata:
name: agent
namespace: platform
spec:
serviceAccountName: platform-app
containers:
- name: c
image: busybox:1.36
command: ["sh", "-c", "sleep 3600"]
resources:
requests: { cpu: 20m, memory: 32Mi }
limits: { cpu: 50m, memory: 64Mi }
securityContext:
privileged: true
# default-deny NetworkPolicy, as generated -- see review below
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny
namespace: platform
spec:
podSelector: {}
policyTypes:
- Ingress
# @expect-error no matches for kind "PodSecurityPolicy" in version "policy/v1beta1"
apiVersion: policy/v1beta1
kind: PodSecurityPolicy
metadata:
name: platform-restricted
spec:
privileged: false
runAsUser: { rule: MustRunAsNonRoot }
seLinux: { rule: RunAsAny }
fsGroup: { rule: RunAsAny }
supplementalGroups: { rule: RunAsAny }
volumes: ["*"]

RBAC: a workload ServiceAccount bound to cluster-admin

Section titled “RBAC: a workload ServiceAccount bound to cluster-admin”

platform-app’s only stated job is running the team’s app inside its own namespace, but it’s bound to the built-in cluster-admin ClusterRole cluster-wide via a ClusterRoleBinding. Proven with the exact identity a compromised container running as this ServiceAccount would have:

$ kubectl auth can-i delete secrets --as=system:serviceaccount:platform:platform-app -A
yes
$ kubectl auth can-i delete nodes --as=system:serviceaccount:platform:platform-app
yes

🐛 Spot the bug

roleRef:
apiGroup: rbac.authorization.k8s.io
kind: ClusterRole
name: cluster-admin
Show the answer

cluster-admin is a pre-aggregated ClusterRole granting every verb on every resource, cluster-wide — binding it to a workload’s ServiceAccount is one of the largest blast radii available in the entire object model, proven above: this identity can delete Secrets in every namespace, or delete Nodes outright, neither of which “run our app” ever requires. The API server accepts this binding exactly as cleanly as a correctly-scoped one; nothing at apply time distinguishes “the right amount of access” from “all of it.” The fix is a namespaced Role naming only the verbs and resources the app’s own code actually calls (get/list/watch on its own ConfigMaps, say), bound with a RoleBinding scoped to the platform namespace, never a ClusterRoleBinding to cluster-admin.

Pod: securityContext.privileged: true under a restricted namespace

Section titled “Pod: securityContext.privileged: true under a restricted namespace”

The namespace enforces pod-security.kubernetes.io/enforce: restricted, and the agent Pod sets privileged: true anyway. Proven directly:

$ kubectl apply -f agent-pod.yaml
Error from server (Forbidden): error when creating "agent-pod.yaml": pods "agent" is forbidden: violates PodSecurity "restricted:latest": privileged (container "c" must not set securityContext.privileged=true), allowPrivilegeEscalation != false (container "c" must set securityContext.allowPrivilegeEscalation=false), unrestricted capabilities (container "c" must set securityContext.capabilities.drop=["ALL"]), runAsNonRoot != true (pod or container "c" must set securityContext.runAsNonRoot=true), seccompProfile (pod or container "c" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")

🐛 Spot the bug

securityContext:
privileged: true
Show the answer

This is the API server itself rejecting the object outright, not a linter’s opinion — Pod Security Admission (GA since 1.25, and the built-in replacement for the removed PodSecurityPolicy) enforces the restricted Pod Security Standard the namespace’s label declares, and lists the exact five fields this container fails, all at once, in one real error. privileged: true reaching for root-equivalent host access to work around some unrelated permission error is one of the most common AI-generated “fixes” that makes a security posture strictly worse instead of solving the actual problem. The fix is naming the real capability or UID the container needs — almost never privileged: true — plus the other four fields the error already names: allowPrivilegeEscalation: false, capabilities.drop: ["ALL"], runAsNonRoot: true, seccompProfile.type: RuntimeDefault.

Pod: automountServiceAccountToken left on by default

Section titled “Pod: automountServiceAccountToken left on by default”

Neither the ServiceAccount nor the Pod sets automountServiceAccountToken: false, even though this agent never calls the Kubernetes API. Proven by inspecting the Pod’s actual volumes once it’s running:

$ kubectl get pod agent-ok -o jsonpath='{.spec.volumes[*].name}'
kube-api-access-bsgwq
$ kubectl get pod agent-ok -o jsonpath='{.spec.containers[0].volumeMounts[*].mountPath}'
/var/run/secrets/kubernetes.io/serviceaccount

🐛 Spot the bug

apiVersion: v1
kind: Pod
spec:
serviceAccountName: platform-app
# no automountServiceAccountToken: false anywhere
Show the answer

By default every Pod gets its ServiceAccount’s bound token automatically projected into a volume mounted at /var/run/secrets/kubernetes.io/serviceaccount — confirmed above, a real kube-api-access-* volume genuinely exists on this Pod’s spec even though nothing in the container ever reads it. That mounted token is attack surface for no benefit at all on a workload that never calls the Kubernetes API: if the container is ever compromised, whatever RBAC that ServiceAccount holds is immediately usable from inside it. The fix is automountServiceAccountToken: false on the Pod (or the ServiceAccount itself), which removes the entire volume and mount, not just its contents — and is only worth re-enabling explicitly the moment the workload legitimately needs to call the API.

ResourceQuota: no LimitRange, so every Pod must specify everything by hand

Section titled “ResourceQuota: no LimitRange, so every Pod must specify everything by hand”

The namespace has a ResourceQuota tracking requests.cpu/requests.memory/limits.cpu/limits.memory, but no LimitRange to default them. Proven with a Pod that has genuinely no resources block at all:

$ kubectl apply -f norequest-pod.yaml
Error from server (Forbidden): error when creating "norequest-pod.yaml": pods "norequest" is forbidden: failed quota: platform-quota: must specify limits.cpu for: c; limits.memory for: c; requests.cpu for: c; requests.memory for: c

🐛 Spot the bug

apiVersion: v1
kind: ResourceQuota
spec:
hard:
requests.cpu: "4"
requests.memory: 8Gi
limits.cpu: "8"
limits.memory: 16Gi
pods: "20"
# no LimitRange anywhere in this namespace
Show the answer

A ResourceQuota on any of requests.cpu/requests.memory/limits.cpu/limits.memory forces every Pod created in that namespace to declare all four explicitly — there is no fallback, proven above with a real, immediate Forbidden naming all four missing fields on a Pod that would otherwise be perfectly valid. A LimitRange is what supplies a default when a Pod’s author doesn’t specify one; without it, every team deploying into this namespace has to already know the exact quota shape just to get any Pod admitted, which is exactly the kind of friction that makes people copy-paste arbitrary numbers rather than think about them. The fix is a LimitRange in the same namespace with sane default/defaultRequest values, so an unadorned Pod spec still gets something reasonable instead of an outright rejection.

The generated default-deny policy selects every Pod (podSelector: {}) but only lists Ingress in policyTypes — no Egress at all. Proven with a real Pod that this exact policy selects, on a cluster with Calico actually enforcing NetworkPolicy:

$ kubectl exec deploy/web -- wget -q -T 3 -O /dev/null -S https://1.1.1.1
referrer-policy: strict-origin-when-cross-origin
x-content-type-options: nosniff
x-frame-options: DENY
alt-svc: h3=":443"; ma=86400

🐛 Spot the bug

spec:
podSelector: {}
policyTypes:
- Ingress
# Egress is not listed here at all
Show the answer

A NetworkPolicy’s policyTypes isn’t just documentation of which rule lists follow — it controls which direction is restricted at all. Naming a Pod in podSelector doesn’t put any limit on its egress unless Egress is also listed in policyTypes, proven above: a Pod selected by this exact “default-deny” policy still reached an arbitrary external host over HTTPS with zero restriction, real response headers and all. A manifest named default-deny that silently leaves one entire traffic direction wide open is a dangerous kind of wrong — it reads as complete. The fix is listing both directions explicitly: policyTypes: [Ingress, Egress], with an empty (or scoped) egress: [] to actually restrict outbound traffic too.

NetworkPolicy: default-deny with no DNS egress rule

Section titled “NetworkPolicy: default-deny with no DNS egress rule”

Once Egress is correctly added to policyTypes with no egress rules at all, every outbound connection — including the cluster’s own DNS lookups — is blocked. Proven with a real client Pod, before and after:

$ kubectl exec client -- wget -q -T 3 -O- http://web.netpol-demo.svc.cluster.local/
<!DOCTYPE html>
... # before any NetworkPolicy: works fine
$ kubectl apply -f default-deny-all.yaml # policyTypes: [Ingress, Egress], no egress rules
$ kubectl exec client -- wget -q -T 3 -O- http://web.netpol-demo.svc.cluster.local/
wget: bad address 'web.netpol-demo.svc.cluster.local'

🐛 Spot the bug

spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
# no egress rules at all -- including no DNS
Show the answer

bad address here isn’t an HTTP-layer failure at all — it’s a DNS resolution failure: with Egress denied and no rule allowing it, the client’s own lookup to CoreDNS (UDP/TCP port 53) never leaves the Pod, so the name never resolves before an HTTP connection is even attempted, proven above by the request never getting far enough to time out on the actual connection. This is the single most common real-world default-deny outage. The fix every default-deny policy needs is a companion rule allowing DNS egress specifically — egress: [{ to: [{namespaceSelector: {}}], ports: [{protocol: UDP, port: 53}, {protocol: TCP, port: 53}] }] — which restores name resolution without reopening anything else (a separate, still-real test confirmed that DNS-only egress resolves names but a subsequent HTTP connection on port 80 still times out, since no rule allows that port).

PSP: a PodSecurityPolicy resource that no longer exists

Section titled “PSP: a PodSecurityPolicy resource that no longer exists”

The generated manifest set includes a PodSecurityPolicy object meant to enforce the same baseline the namespace’s PSA labels already declare. Proven directly:

$ kubectl apply -f psp.yaml
error: resource mapping not found for name: "platform-restricted" namespace: "" from "psp.yaml": no matches for kind "PodSecurityPolicy" in version "policy/v1beta1"
ensure CRDs are installed first

🐛 Spot the bug

apiVersion: policy/v1beta1
kind: PodSecurityPolicy
Show the answer

PodSecurityPolicy was removed (not merely deprecated) in Kubernetes 1.25, the same release Pod Security Admission became GA specifically as its built-in replacement — this course’s own Pod and Cluster Security lesson already established this fact. The API server has no route for this kind at all on a current cluster, so it fails at the same discovery-layer stage as the removed API versions in the other two reviews in this module. The fix isn’t a newer PSP shape — there isn’t one — it’s deleting this object entirely, since the namespace’s pod-security.kubernetes.io/enforce: restricted label (already present and already proven, above, to really reject privileged: true) is doing this job correctly on its own.

k8s/worked-review-3/namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
name: platform
labels:
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/warn: restricted
pod-security.kubernetes.io/audit: restricted
k8s/worked-review-3/quota-and-limitrange.yaml
apiVersion: v1
kind: ResourceQuota
metadata:
name: platform-quota
namespace: platform
spec:
hard:
requests.cpu: "4"
requests.memory: 8Gi
limits.cpu: "8"
limits.memory: 16Gi
pods: "20"
---
apiVersion: v1
kind: LimitRange
metadata:
name: platform-limits
namespace: platform
spec:
limits:
- type: Container
default: { cpu: 200m, memory: 256Mi }
defaultRequest: { cpu: 100m, memory: 128Mi }
k8s/worked-review-3/rbac.yaml
apiVersion: v1
kind: ServiceAccount
metadata:
name: platform-app
namespace: platform
automountServiceAccountToken: false
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: platform-app-role
namespace: platform
rules:
- apiGroups: [""]
resources: ["configmaps"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: platform-app-binding
namespace: platform
roleRef:
apiGroup: rbac.authorization.k8s.io
kind: Role
name: platform-app-role
subjects:
- kind: ServiceAccount
name: platform-app
namespace: platform
k8s/worked-review-3/agent-pod.yaml
apiVersion: v1
kind: Pod
metadata:
name: agent
namespace: platform
spec:
# serviceAccountName omitted -- uses the namespace's real "default" SA so this
# fence dry-runs standalone; a real deployment would set
# serviceAccountName: platform-app (defined in rbac.yaml above)
automountServiceAccountToken: false
containers:
- name: c
image: busybox:1.36
command: ["sh", "-c", "sleep 3600"]
resources:
requests: { cpu: 20m, memory: 32Mi }
limits: { cpu: 50m, memory: 64Mi }
securityContext:
allowPrivilegeEscalation: false
runAsNonRoot: true
runAsUser: 1000
capabilities: { drop: ["ALL"] }
seccompProfile: { type: RuntimeDefault }
k8s/worked-review-3/networkpolicy.yaml
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: default-deny
namespace: platform
spec:
podSelector: {}
policyTypes:
- Ingress
- Egress
---
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: allow-dns-egress
namespace: platform
spec:
podSelector: {}
policyTypes:
- Egress
egress:
- to:
- namespaceSelector: {}
ports:
- { protocol: UDP, port: 53 }
- { protocol: TCP, port: 53 }
k8s/worked-review-3/vap.yaml
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
name: platform-registry-allowlist
spec:
failurePolicy: Fail
matchConstraints:
resourceRules:
- apiGroups: [""]
apiVersions: ["v1"]
operations: ["CREATE"]
resources: ["pods"]
validations:
- expression: >-
object.spec.containers.all(c,
c.image.startsWith('registry.k8s.io/') ||
c.image.startsWith('docker.io/library/'))
message: "container images must come from an allowlisted registry"
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
name: platform-registry-allowlist-binding
spec:
policyName: platform-registry-allowlist
validationActions: ["Deny"]
matchResources:
namespaceSelector:
matchLabels:
kubernetes.io/metadata.name: platform

npm run verify dry-runs all six files above against the shared verify-k8s cluster with 0 errors. Every fence here declares its own metadata.namespace: platform (a real namespace bundle has to), so — same as this course’s own Namespaces and Resource Quotas lesson — --apply dry-runs them too rather than really applying into its ephemeral namespace (confirmed directly: node tools/verify-manifests.mjs --apply reading-k8s/worked-review-3 and --apply storage-and-config/namespaces-and-resource-quotas both print kubectl wait: error: no matching resources found and an empty get all, since the harness only real-applies fences that let it inject -n <ephemeral> itself). The real, non-dry-run proof for this review’s fixed set is everything already captured above on reading-b: a real platform namespace with the restricted PSA label really rejecting privileged: true, a real ResourceQuota+LimitRange pair really admitting a resources-less Pod, a real RoleBinding really narrowing can-i delete secrets -A from yes to no, and real Calico-enforced NetworkPolicy behavior for both the egress and DNS fixes.

#BugFix
1ServiceAccount bound to cluster-admin via ClusterRoleBindingNamespaced Role/RoleBinding naming only the verbs actually used — real can-i delete secrets -A proven yes before, no after
2securityContext.privileged: trueFull restricted-compliant securityContext — real, five-field admission rejection proven before
3automountServiceAccountToken left on for a workload that never calls the APIautomountServiceAccountToken: false — real kube-api-access-* volume proven present before
4ResourceQuota with no LimitRangeLimitRange with default/defaultRequest added — real must specify limits.cpu... rejection proven before
5NetworkPolicy policyTypes missing EgressBoth directions listed — real, unrestricted HTTPS egress proven before
6Default-deny with no DNS egress ruleA companion allow-dns-egress policy — real bad address (DNS failure) proven before
7PodSecurityPolicy resource (removed API)Deleted entirely — namespace’s PSA labels already do this job; real no matches for kind proven

Only one of these seven bugs (7, the PSP) is the API server refusing to recognize the object at all. Two more (2, the privileged Pod; 4, the quota-less Pod) are real, immediate admission rejections — but rejections of a different Pod than the one that was supposed to work, which is exactly why they’re easy to miss when reviewing the platform bundle in isolation from an actual workload trying to use it. The remaining four (1, 3, 5, 6) all apply cleanly and pass every check, because each is a claim about scope or completeness that nothing at apply time cross-references: that a binding grants no more than the workload needs, that a token mount is actually used by anything, that “Ingress” and “Egress” in policyTypes were both considered rather than one copy-pasted from a different example, that a “default-deny” policy’s own infrastructure dependencies (DNS) were accounted for. A platform namespace bundle is unusually dense with this shape of bug precisely because it’s several independently-plausible-looking objects that only reveal a gap when a real workload tries to actually run inside them.

  • “A workload ServiceAccount bound to cluster-admin is fine as long as the workload itself is trusted.” Proven above: the risk isn’t today’s trust, it’s blast radius if that workload is ever compromised — this exact binding can delete Secrets in every namespace or delete Nodes outright, real and confirmed with kubectl auth can-i.
  • “policyTypes: [Ingress] on a NetworkPolicy that only defines ingress rules is just being explicit about what this policy does.” Proven above: omitting Egress from policyTypes doesn’t just mean “no egress rules are defined here,” it means egress for the selected Pods is not restricted by this policy at all — a real HTTPS request to an arbitrary host succeeded through a policy named default-deny.
  • “A ResourceQuota on cpu/memory implies Kubernetes fills in reasonable defaults for Pods that don’t specify them.” Proven above with a real, immediate rejection: a ResourceQuota covering a resource makes that resource mandatory on every Pod in the namespace — only a LimitRange supplies a default.
flowchart TD
  NS["Namespace: platform, PSA restricted"]
  NS --> RBAC["ServiceAccount platform-app -- bound to cluster-admin"]
  RBAC -->|"1: can delete Secrets/Nodes anywhere"| RBAC
  NS --> POD["agent Pod: privileged=true"]
  POD -->|"2: restricted admission rejects"| POD
  NS --> TOK["agent Pod: automountServiceAccountToken unset"]
  TOK -->|"3: token mounted, unused"| TOK
  NS --> Q["ResourceQuota, no LimitRange"]
  Q -->|"4: every Pod must specify everything"| Q
  NS --> NP["NetworkPolicy default-deny: policyTypes=[Ingress]"]
  NP -->|"5: egress wide open"| NP
  NS --> DNS["default-deny-all: no egress rules"]
  DNS -->|"6: DNS egress blocked too"| DNS
  PSP["PodSecurityPolicy: policy/v1beta1"] -->|"7: kind removed in 1.25"| PSP
Where each bug sits in this platform namespace's admission and traffic path
A workload ServiceAccount whose app only reads its own namespace's ConfigMaps is bound to cluster-admin via a ClusterRoleBinding. What does this real can-i output prove, and why does the API server accept the binding without complaint?
$ kubectl auth can-i delete secrets --as=system:serviceaccount:platform:platform-app -A
yes
This is a real rejection under a namespace labeled pod-security.kubernetes.io/enforce: restricted. What produced this error, and how many separate requirements does it name at once?
Error from server (Forbidden): ... violates PodSecurity "restricted:latest": privileged (container "c" must not set securityContext.privileged=true), allowPrivilegeEscalation != false (...), unrestricted capabilities (...), runAsNonRoot != true (...), seccompProfile (...)
A NetworkPolicy named default-deny sets podSelector: {} and policyTypes: [Ingress] only (no Egress listed). A Pod it selects is tested with a real request to an external HTTPS host. What happens, and why?
A default-deny policy with policyTypes: [Ingress, Egress] and zero egress rules is applied. A client Pod's request fails with bad address rather than a connection timeout. What does that specific error text prove?
$ kubectl exec client -- wget -q -T 3 -O- http://web/
wget: bad address 'web...'
A namespace has a ResourceQuota covering cpu/memory requests and limits, but no LimitRange. A Pod with no resources block at all is rejected with this message. What would prevent this specific rejection?
Error from server (Forbidden): ... pods "norequest" is forbidden: failed quota: platform-quota: must specify limits.cpu for: c; limits.memory for: c; requests.cpu for: c; requests.memory for: c