Hands-On Labs · Guided Drills

Drill — Harden an RBAC Configuration

A ServiceAccount called release-bot exists in namespace storefront and has exactly one job: let a CI pipeline update the Deployments and ConfigMaps it pushes several times a day. It was wired up two weeks ago the fast way — one ClusterRoleBinding to the built-in cluster-admin ClusterRole, "just to get the pipeline through the demo," and nobody has been back to narrow it since. That's the whole setup for this drill: a self-contained, single-skill exercise with no capstone dependency, roughly 30 minutes end to end on a disposable local cluster. You'll stand up the over-permissive binding yourself, prove exactly what a leaked token for this identity could reach using kubectl auth can-i, design the least-privilege Role that actually matches the job, apply it in place of the ClusterRoleBinding, and verify the new boundary holds — what still works, and what now correctly refuses.

☺ Explain it like I'm 10

Imagine you hire someone to water your plants twice a week while you're away. Instead of cutting them a spare key to just your back door, you hand over the building superintendent's master key — it opens every apartment, the mailroom, the roof, the supply closet, all of it — because that was the key sitting in your pocket that morning and cutting a proper spare felt like a chore. For months nothing goes wrong: they only ever use it on your back door. Then either they lose the key, or someone simply watches them use it once. Now a stranger can open literally anything in the building, and it's your name on the key. Today you take the master key back, cut a brand-new key that opens only your back door, and actually test it against every other door in the building to be sure it opens nothing else.

🐢Your host for this drill: Timmy the Turtle — the guardrail who never signs off on a permission wider than the job actually in front of it, and who isn't satisfied by "it works" until kubectl auth can-i agrees.
⚠ Before you start

You need Docker (or Podman) and kind v0.20+ to stand up a disposable local cluster, plus kubectl 1.28 or newer. Everything in this drill runs on a single-node cluster you delete when you're done — nothing touches a real cloud account, nothing costs anything, and it's safe to hand a throwaway ServiceAccount cluster-admin on purpose because the whole cluster disappears at the end. Budget about 30 minutes. Already have a cluster you're happy to experiment on? Skip the kind create cluster step and use that instead — everything from namespace creation onward is cluster-agnostic. Flag output and exact column widths drift slightly between kubectl versions; if what you see doesn't match a snippet below character-for-character, that's a version difference, not a mistake on your part.

How this drill works

☺ Like you're 10: One over-permissive binding, one properly scoped fix, and you prove the difference with the exact command a real access review would run.

This is a single-skill drill, not a multi-finding investigation like Drill — Fix a Broken Cluster. There's one problem: a real, working ServiceAccount that does a real, useful job, currently holding vastly more power than that job requires. You'll seed it yourself, prove the gap with commands rather than guesswork, replace it with a Role scoped to exactly what release-bot needs, and confirm with kubectl auth can-i that the dangerous permissions are gone while the actual job still works. RBAC & Admission Control covers the mechanics behind every step here — aggregated ClusterRoles, the escalate/bind/impersonate verbs, how bound ServiceAccount tokens work — if anything below doesn't fully click, that's where to go first, then come back and actually do it.

Set up the over-permissive ServiceAccount

☺ Like you're 10: One small cluster, one small pipeline identity, one binding that grants it the keys to absolutely everything.

Stand up a disposable single-node cluster:

kind create cluster --name rbac-drill
kubectl config use-context kind-rbac-drill
kubectl get nodes      # one node, Ready — that's all this drill needs

Create the namespace release-bot actually deploys into, and a small Deployment standing in for the real workload it pushes updates to:

kubectl create namespace storefront
kubectl create serviceaccount release-bot -n storefront
# storefront-web.yaml — the workload release-bot's pipeline actually needs to touch
apiVersion: apps/v1
kind: Deployment
metadata:
  name: storefront-web
  namespace: storefront
spec:
  replicas: 2
  selector:
    matchLabels: { app: storefront-web }
  template:
    metadata:
      labels: { app: storefront-web }
    spec:
      containers:
      - name: web
        image: nginx:1.27
        ports: [{ containerPort: 80 }]

Also create a second namespace standing in for a different team's data — this is the canary that makes the over-permission concrete instead of theoretical:

kubectl create namespace payments
kubectl create secret generic db-creds -n payments --from-literal=password=hunter2
kubectl apply -f storefront-web.yaml

Now seed the actual problem — the binding as it was really written two weeks ago, under deadline pressure, "temporarily":

# release-bot-cluster-admin.yaml — as given: the fast, wrong way to unblock a demo
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRoleBinding
metadata:
  name: release-bot-cluster-admin
subjects:
- kind: ServiceAccount
  name: release-bot
  namespace: storefront
roleRef:
  kind: ClusterRole
  name: cluster-admin
  apiGroup: rbac.authorization.k8s.io
kubectl apply -f release-bot-cluster-admin.yaml
kubectl get clusterrolebinding release-bot-cluster-admin
release-bot's reach, before and after Before — bound to cluster-admin After — scoped to storefront only ServiceAccount release-bot ServiceAccount release-bot ClusterRoleBinding roleRef: ClusterRole cluster-admin RoleBinding (in storefront) roleRef: Role release-bot-deployer storefront ns payments ns kube-system ns storefront ns ✕ payments ns no binding reaches here ✕ kube- system kubectl auth can-i delete secrets -n payments --as=release-bot before: yes · after: no kubectl auth can-i update deployments -n storefront --as=release-bot before: yes · after: yes — that permission was always the actual job

Audit what release-bot can actually do

☺ Like you're 10: You don't have to steal the badge to test what it opens — you can just ask the door "would this badge get me in," for any door in the building.

You never need the ServiceAccount's real token to test what it's allowed to do. kubectl auth can-i supports --as impersonation directly, using the exact identity string Kubernetes assigns every ServiceAccount: system:serviceaccount:<namespace>:<name>. Start with the broad view:

kubectl auth can-i --list --as=system:serviceaccount:storefront:release-bot
Resources                                       Non-Resource URLs   Resource Names   Verbs
*.*                                                                                   [*]
                                                  [*]                                 [*]

Two lines say everything. *.* with verbs [*] means every resource, in every API group, with every verb — get, list, delete, all of it. The second line, [*] under Non-Resource URLs, means it can hit any of the apiserver's own HTTP endpoints too, not just object CRUD. That's what cluster-admin actually is: not "a lot of access," but the literal absence of a boundary anywhere. Confirm the specific, concrete damage a leaked release-bot token could do today:

kubectl auth can-i delete secrets -n payments --as=system:serviceaccount:storefront:release-bot
# yes

kubectl auth can-i delete namespaces --as=system:serviceaccount:storefront:release-bot
# yes

kubectl auth can-i create clusterrolebindings --as=system:serviceaccount:storefront:release-bot
# yes

kubectl auth can-i get secrets --all-namespaces --as=system:serviceaccount:storefront:release-bot
# yes

Every one of those is a "yes" for an identity whose entire job is updating one Deployment and its ConfigMaps in one namespace. It can read and delete payments' database credentials, delete the payments namespace outright, and grant itself — or anything else — more ClusterRoleBindings, because bind is one of the verbs a wildcard already includes. A single leaked CI token is currently equivalent to full cluster compromise, and nothing about the pipeline's actual job required any of that.

Design the least-privilege Role

☺ Like you're 10: Before you cut the new key, write down every single door it actually needs to open — no guessing, no "probably fine," just the real list.

List release-bot's real job before writing a single line of RBAC: it updates the image and config on storefront-web, and it watches the rollout to know whether that update succeeded. Nothing in that job touches Secrets, Nodes, Namespaces, or any resource outside storefront.

ResourceVerbsWhy
deployments (apps)get list watch create update patchThe actual deploy: push a new image/replica count, read it back to confirm
replicasets (apps)get list watchRead-only — needed to check rollout status, never created directly by the bot
configmapsget list watch create update patchApp config the Deployment reads on startup, pushed the same way as the image
podsget list watchRead-only — confirm new Pods actually reach Ready before calling the rollout done
pods/loggetRead-only — pull logs when a rollout fails, to report why in the pipeline output

No delete anywhere, not even on the resources it manages — this pipeline only ever pushes forward; a rollback is a new update, not a deletion. No secrets access at all, in this namespace or any other. Nothing cluster-scoped. Write it as a Role, which is namespace-bound by construction — the API simply has no field for granting a Role reach outside the namespace it's created in:

# role-release-bot.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: release-bot-deployer
  namespace: storefront
rules:
- apiGroups: ["apps"]
  resources: ["deployments"]
  verbs: ["get", "list", "watch", "create", "update", "patch"]
- apiGroups: ["apps"]
  resources: ["replicasets"]
  verbs: ["get", "list", "watch"]
- apiGroups: [""]
  resources: ["configmaps"]
  verbs: ["get", "list", "watch", "create", "update", "patch"]
- apiGroups: [""]
  resources: ["pods"]
  verbs: ["get", "list", "watch"]
- apiGroups: [""]
  resources: ["pods/log"]
  verbs: ["get"]
# rolebinding-release-bot.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: release-bot-deployer
  namespace: storefront
subjects:
- kind: ServiceAccount
  name: release-bot
  namespace: storefront
roleRef:
  kind: Role
  name: release-bot-deployer
  apiGroup: rbac.authorization.k8s.io

Apply the fix — replace the ClusterRoleBinding, don't just add on top of it

☺ Like you're 10: Cutting the new key doesn't do anything by itself if the old master key still works too — you have to actually take the old one back.

Adding the new Role and RoleBinding without removing the old ClusterRoleBinding would change nothing — RBAC is additive, so release-bot would simply hold both grants at once, with cluster-admin still silently winning every check. Delete the binding first, then apply the scoped replacement:

kubectl delete clusterrolebinding release-bot-cluster-admin
kubectl apply -f role-release-bot.yaml
kubectl apply -f rolebinding-release-bot.yaml
⚠ Watch out

Deleting the ClusterRoleBinding doesn't touch the ServiceAccount object itself or anything it already deployed — storefront-web keeps running untouched. RBAC only ever gates new API requests going forward; it was never in the request path of Pods that already exist and are already running.

Verify with kubectl auth can-i

☺ Like you're 10: Ask the exact same questions you asked before, and confirm the answers actually changed where they were supposed to — and only there.

Start with the same broad view, now scoped to the one namespace that has any grant at all:

kubectl auth can-i --list --as=system:serviceaccount:storefront:release-bot -n storefront
Resources                                       Non-Resource URLs   Resource Names   Verbs
configmaps                                       []                  []               [create get list patch update watch]
deployments.apps                                 []                  []               [create get list patch update watch]
pods                                              []                  []               [get list watch]
pods/log                                          []                  []               [get]
replicasets.apps                                 []                  []               [get list watch]

Five rows instead of one wildcard row — exactly the table you designed above, nothing more. Re-run the specific checks from the audit step and confirm each one flips, then confirm the actual job still works:

kubectl auth can-i delete secrets -n payments --as=system:serviceaccount:storefront:release-bot
# no

kubectl auth can-i delete namespaces --as=system:serviceaccount:storefront:release-bot
# no

kubectl auth can-i create clusterrolebindings --as=system:serviceaccount:storefront:release-bot
# no

kubectl auth can-i update deployments -n storefront --as=system:serviceaccount:storefront:release-bot
# yes — the actual job still works

kubectl auth can-i update deployments -n payments --as=system:serviceaccount:storefront:release-bot
# no — same resource type, wrong namespace, correctly denied

That last check is the one people forget to run. It's not enough that the dangerous, unrelated permissions are gone — the fix also has to prove that the real permission it kept is scoped to the one namespace the job actually runs in, not just narrowed in which verbs it allows.

◆ Key idea

Every RBAC grant answers four questions at once, and a wildcard anywhere leaves one of them unanswered: which identity (the exact ServiceAccount, not "anything with a token"), which namespace (a Role, not a ClusterRole, so the API itself can't reach further even by mistake), which resources (five named types, not *.*), and which verbs (no delete, because the job never deletes anything). cluster-admin wasn't one bad decision — it was all four questions left unanswered simultaneously, which is exactly why auditing it produced one wildcard row instead of a list you could actually reason about.

🐢 Timmy's challenge · going further

Comfortable with the core drill? Push further. (1) Set automountServiceAccountToken: false on the release-bot ServiceAccount and mount a scoped, short-lived projected token only on the Pod that actually needs it — RBAC & Admission Control covers exactly how a bound token differs from the old auto-mounted Secret. (2) Layer an independent admission check on top of the RBAC fix: label the storefront namespace pod-security.kubernetes.io/enforce: baseline, then try to hand-edit storefront-web to run privileged: true and watch Pod Security Admission reject it — RBAC and admission control never talk to each other, so proving both independently is the whole point, not redundant effort. (3) Run kubectl get clusterrolebindings -o json | jq against the whole cluster and look for any other subject still bound to cluster-admin or edit cluster-wide — release-bot is rarely the only one. (4) Confirm the escalate and bind verbs specifically aren't hiding anywhere in the new Role — they aren't here, but knowing how to check is the CKS-level habit this drill is building toward.

0 / 8 steps complete
1Stand up the cluster, namespaces, ServiceAccount, and the workload it deploys
Done when: kubectl get deployment storefront-web -n storefront shows 2/2 ready and kubectl get secret db-creds -n payments exists.
2Bind release-bot to cluster-admin via ClusterRoleBinding — seed the real problem
Done when: kubectl get clusterrolebinding release-bot-cluster-admin shows it bound to cluster-admin.
3Run kubectl auth can-i --list and read the wildcard grant
Done when: the output shows *.* with verbs [*].
4Confirm the specific, concrete damage: cross-namespace secrets, namespace deletion, self-escalation
Done when: all four can-i checks against payments/cluster-scoped resources return yes.
5Write the least-privilege Role: five named resources, no delete, no cross-namespace reach
Done when: role-release-bot.yaml matches the table above — nothing extra, nothing missing.
6Delete the ClusterRoleBinding, apply the Role and RoleBinding
Done when: kubectl get clusterrolebinding release-bot-cluster-admin returns NotFound, and the new RoleBinding exists in storefront.
7Re-run every dangerous check and confirm each now returns no
Done when: secrets in payments, namespace deletion, and clusterrolebindings creation all return no.
8Confirm the real job still works, and only in its own namespace
Done when: update deployments -n storefront returns yes and update deployments -n payments returns no, for the same identity.
🎬 At the Pod Squad
🦫

Benny the Beaver: Okay, confession — I'm the one who bound release-bot to cluster-admin two weeks ago. The demo was in twenty minutes and it was the only binding I was sure would actually work.

🐢

Timmy the Turtle: And it's been sitting like that ever since, Benny. "Temporarily" isn't a permission scope — it's a permission scope with no expiration and no one watching it.

👺

Gizmo the Gremlin: Or — hot take — leave it. It's an internal CI bot, not a public endpoint. Who's even going to see the token? 🤑

🦊

Foxy: Anyone who reads a leaked CI log, compromises the pipeline's build image, or finds the token cached on a laptop. "Internal" isn't a security boundary, it's just a hope.

🐢

Timmy the Turtle: Right. I checked what this identity can reach today — payments' secrets, every namespace in the cluster, the ability to bind itself even more power. None of that is the pipeline's job.

🤖

Recon the Robot: Scoped Role's applied. Five resources, five verb sets, one namespace. BEEP. Reconciling against the actual job description now, not against "whatever definitely works."

🐘

Ellie the Elephant: Logging the before-and-after either way — which binding, which verbs, who checked it and when. Next audit, the record's already here instead of someone re-deriving all of this from scratch.

🐢 Timmy's checkpoint

1. Why doesn't release-bot's storefront-web Deployment stop running the moment you delete the ClusterRoleBinding to cluster-admin? 2. You add the new Role and RoleBinding but forget to delete the old ClusterRoleBinding. What can release-bot do at that point — the old permissions, the new ones, or something in between? 3. Why did the fix use a Role rather than a ClusterRole, given both could technically list the same five resource types? 4. Name one RBAC verb that, if it slipped into the new Role by mistake, would let release-bot quietly grant itself more permissions later — and why does a scanner or reviewer have to look for it by name, not just by wildcard? 5. The checkpoint step ran update deployments -n payments and expected no. What would it have meant if that check had come back yes instead, even after every other fix was correctly applied?

Check your answers
  1. RBAC only ever gates new API requests going forward — it has no role in keeping an already-scheduled Pod running. Deleting a binding changes what future requests that identity can make; it does nothing to objects that already exist.
  2. Both — RBAC is additive, so a subject's effective permissions are the union of every binding that applies to it. With the old ClusterRoleBinding still present, release-bot keeps full cluster-admin access regardless of how tightly the new Role is scoped, because the wider grant is still there answering every check with "yes."
  3. A Role is namespace-scoped by construction — the object simply has no field that could grant reach into another namespace, so there's no way to misconfigure it into a cluster-wide grant later. A ClusterRole bound via a namespaced RoleBinding can technically be scoped correctly too, but it leaves the door open for someone to later bind that same ClusterRole cluster-wide by mistake — the Role's narrower type closes that mistake off entirely.
  4. escalate (or bind, or impersonate) — any of the three verbs that deliberately bypass Kubernetes' default privilege-escalation prevention. A wildcard scan for * wouldn't catch it, because these verbs are ordinary, individually-named strings sitting in a rules list next to get and update — nothing about them looks unusual on a skim, which is exactly why RBAC & Admission Control calls them out by name rather than trusting a generic wildcard check to catch them.
  5. It would mean the fix wasn't actually namespace-scoped — either the Role had somehow become a ClusterRole, or a second, wider binding was still present alongside the new one. Verbs alone being narrow isn't the whole fix; the namespace boundary has to hold too, and this is the one check that specifically proves it does.

Boundary holds? That's the whole drill. For the full mechanics behind every step here — aggregated ClusterRoles, bound ServiceAccount tokens, and the admission-control layer that runs independently of RBAC — see RBAC & Admission Control and Security: Defense in Depth; for the CKA-level Role/RoleBinding baseline this drill assumes, see Cluster Architecture, Installation & Configuration. DevSecOps' Kubernetes security deep dive covers Pod Security Standards and policy engines at full depth if you took on the going-further challenge above. Practice the same tightening pass again, at full cluster scale alongside NetworkPolicy and Pod Security, in Capstone Part 5 — Security & RBAC — or start the capstone from the beginning at Build a Cluster — Start Here. Heading toward the certification this exact habit is built for? See the CKS blueprint, and the wider CNCF ladder beyond it on the sibling Golden Astronaut course. Want a different single skill next? Try Drill — Fix a Broken Cluster.