Lab 12 — Ship It Like Tesla, Then Cut It: RBAC & Network Policy as Code¶
Variant D · build-first / audit→build. ← Back to the module concept
Hands-on lab. Environment:
plaintext-labs/cloud/12-kubernetes-rbac-network(runs on a localkindcluster you own — no cloud account, no real credentials). Objective: ship a cluster the way Tesla shipped it (acluster-adminServiceAccount, a flat pod network), prove the cost, then author least-privilege RBAC and a default-deny NetworkPolicy as code and re-verify the cut. Target: ~90 min, one finish line.
✈ Flight card — the 6 things to hold¶
Glance here when you lose the thread. This replaces re-reading the module.
| # | Fact | Why it matters |
|---|---|---|
| 1 | RBAC is cloud IAM one layer down: Subject → Binding → Role. | You already wrote this evaluation for AWS in module 02 — just relabel it for the cluster. |
| 2 | The binding sets the scope, not the Role. | A RoleBinding grants in one namespace; a ClusterRoleBinding grants in every namespace — same rules, different reach. |
| 3 | The SA token is auto-mounted into every pod. | Unless automountServiceAccountToken: false, a compromised process always has a key to the API server. |
| 4 | cluster-admin on a workload SA = read every Secret, every namespace. |
That is the Tesla path: an over-broad SA reading credentials it should never see. |
| 5 | NetworkPolicy is opt-in — no policy means a flat network across namespaces. | The default-allow LAN you'd never ship on-prem; a deny in one namespace covers only that namespace. |
| 6 | Least-privilege + default-deny only count when verified. | kubectl auth can-i is the RBAC test; a connectivity probe is the network test. YAML that "looks right" isn't proof. |
↳ Go deeper — pull only when a step doesn't click: the module's it's IAM, one layer down.
Warm-up — answer before you build (2 min)¶
Don't look below. Being forced to retrieve is what builds the memory.
- A pod is compromised via app RCE. By default, what can it reach on the network, and what can its ServiceAccount token read?
cluster-adminis theResource: "*"of Kubernetes. Map each RBAC piece to its cloud-IAM equivalent: ServiceAccount, Role, RoleBinding.
Setup¶
This is a reference lab — it ships a one-command environment in the companion
plaintext-labs repo. It runs on a local
kind cluster you own — no cloud account, no real credentials.
Prerequisites: Docker (running), kind (≥ v0.23.0), and kubectl.
git clone https://github.com/plaintext-security/plaintext-labs
cd plaintext-labs/cloud/12-kubernetes-rbac-network
make up # create the kind cluster + apply the seeded misconfigured RBAC ("like Tesla")
make demo # run kube-bench + show the cluster-admin SA findings
make shell # drop a kubectl shell into demo-pod (runs as the over-privileged ci-deployer SA)
make down # delete the cluster when done (make reset = down + up)
The seeded cluster (single-node kind, Kubernetes v1.30, NetworkPolicy-enforcing CNI) is deliberately
shipped wrong: a ci-deployer ServiceAccount bound to cluster-admin (ci-deployer-admin
ClusterRoleBinding), a demo-pod that mounts its token, a payments namespace with no
NetworkPolicy (flat network), and a kube-bench job. The manifests/ dir carries both the broken and
the fixed RBAC/NetworkPolicy so you can diff your work against a reference — try it yourself first.
▸ On track if:
make demoprints kube-bench FAILs in section 5 (RBAC and Service Accounts), theci-deployer-adminClusterRoleBinding YAML, anauth can-i --listforci-deployerthat shows*on*(it can do everything), andNo resources foundfornetworkpolicy -n payments— the misconfigured cluster is live.Authorization. This lab runs against a local
kindcluster you own. The exploitation steps (reading every Secret, creating a privileged pod) are intentional and self-contained. Only test clusters you own or have explicit written permission to access — never run these against a shared or production cluster.
Honest about the lab. The seeded Secrets are the cluster's own (kube-system bootstrap tokens, CA,
controller creds) plus a planted lab Secret standing in for "the AWS keys in a pod" — this lab does
not reach a real cloud account. The skill is identical: a cluster-admin SA reads Secrets it
shouldn't, and in the real Tesla incident those Secrets were AWS credentials. You rebuild the
mechanism, then close it.
Scenario¶
The target account provisioned an EKS-style cluster six months ago. A contractor gave the CI/CD
pipeline's ServiceAccount cluster-admin "to get things working," and the payments namespace has no
segmentation — exactly the two conditions that, on a Tesla cluster in 2018, turned an open dashboard into
stolen cloud keys and a cryptojacking bill. A security review just flagged it and handed it to you.
Build the fix: least-privilege RBAC and a default-deny-plus-allow NetworkPolicy, both as code, both
verified. Each step runs the same rhythm: Predict → Do → Reveal → Record.
Build it — read a little, do a little¶
Step 1 — Audit: read what shipped (the Tesla conditions)¶
Concept (30 sec): Flight-card #6. A platform team audits every new cluster with kube-bench (CIS Benchmark); section 5 covers RBAC and Service Accounts.
Do it: make demo. Read the kube-bench output; note the control ID and remediation text for the
cluster-admin / over-broad-binding finding.
▸ On track if: section 5 shows FAIL(s) on the over-broad binding, and the demo prints the
ci-deployer-adminClusterRoleBinding withroleRef: cluster-admin. Record: the control ID + the remediation line — this is the audit baseline you'll clear at the end.
Step 2 — Prove the RBAC blast radius (the heart of it)¶
Concept (30 sec): Flight-cards #3 and #4. You predicted what a compromised pod can reach in the
warm-up; now prove it. make shell drops you into demo-pod, running as ci-deployer.
Predict, then do — from inside the pod:
- kubectl auth can-i --list — note this SA can do everything.
- kubectl get secrets -n kube-system — read Secrets across namespaces, including the planted
cloud-creds Secret. This is the Tesla hop: a workload SA reading credentials it should never see.
- kubectl run pwned --image=alpine --privileged=true --restart=Never -- sleep 3600 then
kubectl get pod pwned — the SA can launch a privileged pod (a node-escape primitive).
Clean up: kubectl delete pod pwned.
▸ On track if:
auth can-i --listshows*verbs on*resources,get secrets -n kube-systemreturns Secret contents (incl.cloud-creds), and the privilegedpwnedpod schedules. Record: owner = customer; the SA reads Secrets it doesn't own → the cloud-creds path.
Step 3 — Prove the network is flat¶
Concept (30 sec): Flight-card #5. With no NetworkPolicy, every pod reaches every pod across namespaces — the flat LAN.
Do it: from a pod in default, reach the payments-api Service in the payments namespace:
kubectl run probe --rm -it --image=curlimages/curl --restart=Never -- \
curl -s payments-api.payments:8080
▸ On track if: the probe responds with
Payments API — seed data— an unrelated namespace reachedpaymentswith no policy in the way. Record: owner = customer; plane = network; the flat pod network is default-allow.
Step 4 — Cut the RBAC to the minimum, then re-verify¶
Concept (30 sec): Flight-cards #1 and #2. Replace the cluster-admin ClusterRoleBinding with a
namespace-scoped Role + RoleBinding — only the verbs the CI pipeline needs — and disable the token
auto-mount.
Do it: read manifests/rbac-bad.yaml, write your replacement in manifests/rbac-fixed.yaml
(get/list/create/update/patch on deployments, services, configmaps; get/list on pods; no
Secrets, no cluster scope; automountServiceAccountToken: false) — write yours first, then diff
against the bundled reference. Apply and re-verify:
kubectl delete -f manifests/rbac-bad.yaml
kubectl apply -f manifests/rbac-fixed.yaml
kubectl auth can-i get secrets -n kube-system --as=system:serviceaccount:default:ci-deployer
kubectl auth can-i create deployments -n default --as=system:serviceaccount:default:ci-deployer
▸ On track if: the first
auth can-inow returnsno(the dangerous action is denied) and the second returnsyes(the legitimate deploy verb still works). The cut is real only when the denied and the allowed answer both land as expected.
Step 5 — Default-deny the network, then add back the one flow¶
Concept (30 sec): Flight-card #5. podSelector: {} with no ingress: rules is the firewall
default-deny from module 04, expressed in the pod plane; then you add back only what the app needs.
Do it: apply the default-deny, re-probe (from Step 3 — it must now fail), then apply the targeted allow and prove the flip:
kubectl apply -f manifests/netpol-default-deny.yaml # default-deny-ingress in payments
kubectl run probe --rm -it --image=curlimages/curl --restart=Never -- curl -s --max-time 5 payments-api.payments:8080
kubectl apply -f manifests/netpol-allow-frontend.yaml # allow only app=frontend in default, :8080
kubectl run frontend --rm -it --labels app=frontend --image=curlimages/curl --restart=Never -- curl -s --max-time 5 payments-api.payments:8080
▸ On track if: after default-deny the unlabelled
probetimes out / is refused (denied); after the allow, a pod labelledapp=frontendgetsPayments API — seed datawhile any unlabelled pod still fails. Connectivity flips: reachable → denied → selectively reachable.
Step 6 — Re-audit¶
Do it: re-run kube-bench (make demo, or the job alone) and confirm the section-5 RBAC finding has
cleared on the fixed binding. The before/after delta goes in your report.
▸ On track if: the section-5 finding that FAILed on
rbac-bad.yamlin Step 1 is no longer present againstrbac-fixed.yaml.
Prove the control (your finish line)¶
Least-privilege RBAC and default-deny networking, each proven with an allow + deny pair — not a YAML that merely looks right:
- RBAC.
auth can-i get secrets -n kube-system --as=system:serviceaccount:default:ci-deployerreturnsno(deny) whileauth can-i create deployments -n default --as=…ci-deployerreturnsyes(allow). The dangerous action is gone; the job still works. - Network. From
payments' perspective: anapp=frontendpod reachespayments-api(allow) and any other pod is denied (deny), with the default-deny in place.
If either control only denies (breaks the app) or only allows (proves nothing), the cut isn't done.
Recall check — close the doc, answer from memory (3 min)¶
- Map ServiceAccount, Role, RoleBinding, and
cluster-adminto their cloud-IAM equivalents. - A pod is compromised. By default, what can it reach on the network and what can its SA read — and which two settings take each away?
- Why isn't a default-deny NetworkPolicy in one namespace enough, and what two tests verify the RBAC and network cuts?
Success criteria — you're done when¶
- [ ] You demonstrated the over-broad SA reaching
kube-systemSecrets (incl. the plantedcloud-creds) and creating a privileged pod — the Tesla blast radius, reproduced. - [ ]
kubectl auth can-i get secrets -n kube-system --as=system:serviceaccount:default:ci-deployerreturns yes before your fix and no after, while the legitimate deploy verbs still return yes. - [ ] The
paymentsnamespace has a default-deny ingress policy plus a targeted allow, and you proved connectivity flips: reachable → denied → selectively reachable. - [ ] kube-bench's section-5 RBAC finding is present on
rbac-bad.yamland cleared onrbac-fixed.yaml.
Deliverables¶
rbac-audit.md— kube-bench before/after summary, the identifiedcluster-adminbinding, the blast-radius demonstration (Secrets + privileged pod), and the before/afterauth can-icomparison.manifests/rbac-fixed.yaml— your least-privilege Role + RoleBinding (the portfolio artifact).manifests/netpol-default-deny.yamlandmanifests/netpol-allow-frontend.yaml— the segmentation policies.
Commit these. Kubeconfigs, SA tokens, and Secret contents stay out of the commit.
Automate & own it¶
Required — judgment-as-code, not keystroke scripting. Your finding is "a workload SA must never have
cluster-admin or read Secrets it doesn't own." Encode that verdict as a check that fails the bad state
and passes the fix, so it can't recur silently:
- A
conftest/OPA-Rego (orkubectl get clusterrolebindings -o json | jq+ assertions) policy that fails any binding tocluster-adminfor a ServiceAccount subject, fails a workload SA grantedsecretsget/list, and flags any SA withoutautomountServiceAccountToken: false— and passes yourrbac-fixed.yaml. - Run it against both
rbac-bad.yaml(exit non-zero) andrbac-fixed.yaml(exit zero) and show it flips.
Have a model draft the Rego/jq; review every line and confirm it fails the bad binding for the right
reason (the cluster-admin roleRef, not an unrelated field). This is the same guardrail discipline you
built for IAM in module 02 — your cut, made un-recurrable, and the seed of the admission policy you'll
write in module 13.
Definition of done (kubernetes-rbac-network ✅)¶
- [ ]
make demoreproduced the section-5 RBAC FAIL and thecluster-adminblast radius (Secrets + privileged pod). - [ ]
rbac-fixed.yamlis a namespace-scoped Role + RoleBinding withautomountServiceAccountToken: false, written before diffing the reference. - [ ] The RBAC allow+deny pair lands as expected (
get secrets= no,create deployments= yes). - [ ]
paymentshas default-deny + targeted allow, and connectivity flips reachable → denied → selectively reachable. - [ ] Your guardrail fails
rbac-bad.yamland passesrbac-fixed.yaml. - [ ] You can explain all six flight-card facts cold.
AI acceleration¶
Paste your kube-bench JSON (--output json) and ask a model to triage findings by exploitability for "a
single-tenant cluster, CI/CD has a real SA." It re-ranks theory vs. practical impact well. Then paste your
NetworkPolicy and ask it to find a path that still reaches payments — if it can (a forgotten namespace, a
missing egress rule, an unlabelled pod that matches), your policy is too narrow or your coverage is partial.
Validate every claim against the live cluster with auth can-i and the connectivity probe — the model
can't see cluster state.
Connects forward¶
- Module 13 (Admission & Runtime) turns this manual fix into a gate: a Kyverno admission policy that
blocks a pod from requesting a
cluster-admin-equivalent SA at deploy time, and Falco for what slips past at runtime — the automated enforcement of what you fixed by hand here. - Module 04 (Cloud Network Security) is the same default-deny you wrote there for Security Groups, now in the pod plane — the SG ruleset and the NetworkPolicy are one discipline in two layers.
- Module 15 (Logging & Detection) ingests Kubernetes audit logs: the
get secretsandcreate pod --privilegedcalls you made are exactly the signals a detection rule fires on.
Marketable proof¶
"I audit Kubernetes RBAC with kube-bench, reproduce the over-privileged-ServiceAccount blast radius (read cluster Secrets, launch a privileged pod), and close it with least-privilege Role/RoleBinding and default-deny NetworkPolicy — all as code in git, verified with
kubectl auth can-iand connectivity probes, and guarded by a policy check that fails the over-broad binding in CI. I can explain why this is the same model that exposed Tesla's cloud keys in 2018."
Stretch¶
- Add a
PodSecurityrestrictedlabel to thepaymentsnamespace and confirm it blocksprivileged: truepods (closing the node-escape primitive from step 2 at the namespace level). - Use
kubectl-who-can create pods --all-namespacesto enumerate every subject that can launch a pod — the blast-radius query for "who can escape to the node?" - Extend your guardrail into a GitHub Actions workflow that runs kube-bench after cluster creation and fails the pipeline on any High section-5 RBAC finding.
Comments
Sign in with GitHub to comment. Choose the type: Feedback (errors or suggestions on this page) · Hints (help for fellow learners — no spoilers) · General (anything else).