Worked review 3: a platform namespace with RBAC, quotas, and NetworkPolicy
The feature
Section titled “The feature”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.
The generated code
Section titled “The generated code”# platform Namespace + Pod Security Admission labels, as generated -- see review belowapiVersion: v1kind: Namespacemetadata: 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 belowapiVersion: v1kind: ResourceQuotametadata: name: platform-quota namespace: platformspec: hard: requests.cpu: "4" requests.memory: 8Gi limits.cpu: "8" limits.memory: 16Gi pods: "20"# platform-app ServiceAccount + ClusterRoleBinding, as generated -- see review belowapiVersion: v1kind: ServiceAccountmetadata: name: platform-app namespace: platform---apiVersion: rbac.authorization.k8s.io/v1kind: ClusterRoleBindingmetadata: name: platform-app-adminroleRef: apiGroup: rbac.authorization.k8s.io kind: ClusterRole name: cluster-adminsubjects:- kind: ServiceAccount name: platform-app namespace: platform# platform-app agent Pod, as generated -- see review belowapiVersion: v1kind: Podmetadata: name: agent namespace: platformspec: 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 belowapiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata: name: default-deny namespace: platformspec: podSelector: {} policyTypes: - Ingress# @expect-error no matches for kind "PodSecurityPolicy" in version "policy/v1beta1"apiVersion: policy/v1beta1kind: PodSecurityPolicymetadata: name: platform-restrictedspec: privileged: false runAsUser: { rule: MustRunAsNonRoot } seLinux: { rule: RunAsAny } fsGroup: { rule: RunAsAny } supplementalGroups: { rule: RunAsAny } volumes: ["*"]The review walk
Section titled “The review walk”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 -Ayes
$ kubectl auth can-i delete nodes --as=system:serviceaccount:platform:platform-appyes🐛 Spot the bug
roleRef:apiGroup: rbac.authorization.k8s.iokind: ClusterRolename: cluster-adminShow 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.yamlError 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: trueShow 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: v1kind: Podspec:serviceAccountName: platform-app# no automountServiceAccountToken: false anywhereShow 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.yamlError 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: v1kind: ResourceQuotaspec:hard: requests.cpu: "4" requests.memory: 8Gi limits.cpu: "8" limits.memory: 16Gi pods: "20"# no LimitRange anywhere in this namespaceShow 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.
NetworkPolicy: policyTypes missing Egress
Section titled “NetworkPolicy: policyTypes missing Egress”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 allShow 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 DNSShow 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.yamlerror: 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/v1beta1kind: PodSecurityPolicyShow 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.
The corrected code
Section titled “The corrected code”apiVersion: v1kind: Namespacemetadata: name: platform labels: pod-security.kubernetes.io/enforce: restricted pod-security.kubernetes.io/warn: restricted pod-security.kubernetes.io/audit: restrictedapiVersion: v1kind: ResourceQuotametadata: name: platform-quota namespace: platformspec: hard: requests.cpu: "4" requests.memory: 8Gi limits.cpu: "8" limits.memory: 16Gi pods: "20"---apiVersion: v1kind: LimitRangemetadata: name: platform-limits namespace: platformspec: limits: - type: Container default: { cpu: 200m, memory: 256Mi } defaultRequest: { cpu: 100m, memory: 128Mi }apiVersion: v1kind: ServiceAccountmetadata: name: platform-app namespace: platformautomountServiceAccountToken: false---apiVersion: rbac.authorization.k8s.io/v1kind: Rolemetadata: name: platform-app-role namespace: platformrules:- apiGroups: [""] resources: ["configmaps"] verbs: ["get", "list", "watch"]---apiVersion: rbac.authorization.k8s.io/v1kind: RoleBindingmetadata: name: platform-app-binding namespace: platformroleRef: apiGroup: rbac.authorization.k8s.io kind: Role name: platform-app-rolesubjects:- kind: ServiceAccount name: platform-app namespace: platformapiVersion: v1kind: Podmetadata: name: agent namespace: platformspec: # 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 }apiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata: name: default-deny namespace: platformspec: podSelector: {} policyTypes: - Ingress - Egress---apiVersion: networking.k8s.io/v1kind: NetworkPolicymetadata: name: allow-dns-egress namespace: platformspec: podSelector: {} policyTypes: - Egress egress: - to: - namespaceSelector: {} ports: - { protocol: UDP, port: 53 } - { protocol: TCP, port: 53 }apiVersion: admissionregistration.k8s.io/v1kind: ValidatingAdmissionPolicymetadata: name: platform-registry-allowlistspec: 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/v1kind: ValidatingAdmissionPolicyBindingmetadata: name: platform-registry-allowlist-bindingspec: policyName: platform-registry-allowlist validationActions: ["Deny"] matchResources: namespaceSelector: matchLabels: kubernetes.io/metadata.name: platformnpm 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.
What changed and why
Section titled “What changed and why”| # | Bug | Fix |
|---|---|---|
| 1 | ServiceAccount bound to cluster-admin via ClusterRoleBinding | Namespaced Role/RoleBinding naming only the verbs actually used — real can-i delete secrets -A proven yes before, no after |
| 2 | securityContext.privileged: true | Full restricted-compliant securityContext — real, five-field admission rejection proven before |
| 3 | automountServiceAccountToken left on for a workload that never calls the API | automountServiceAccountToken: false — real kube-api-access-* volume proven present before |
| 4 | ResourceQuota with no LimitRange | LimitRange with default/defaultRequest added — real must specify limits.cpu... rejection proven before |
| 5 | NetworkPolicy policyTypes missing Egress | Both directions listed — real, unrestricted HTTPS egress proven before |
| 6 | Default-deny with no DNS egress rule | A companion allow-dns-egress policy — real bad address (DNS failure) proven before |
| 7 | PodSecurityPolicy resource (removed API) | Deleted entirely — namespace’s PSA labels already do this job; real no matches for kind proven |
Mental Model
Section titled “Mental Model”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.
Common Misconceptions
Section titled “Common Misconceptions”- “A workload ServiceAccount bound to
cluster-adminis 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 withkubectl auth can-i. - “
policyTypes: [Ingress]on a NetworkPolicy that only definesingressrules is just being explicit about what this policy does.” Proven above: omittingEgressfrompolicyTypesdoesn’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 nameddefault-deny. - “A
ResourceQuotaon cpu/memory implies Kubernetes fills in reasonable defaults for Pods that don’t specify them.” Proven above with a real, immediate rejection: aResourceQuotacovering a resource makes that resource mandatory on every Pod in the namespace — only aLimitRangesupplies 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