Lab CKS-13 — RBAC Remediation
Lab CKS-01 removed all API access from a workload that needed none. The harder and more common task is a workload that does need the API and has been given far more than it needs. “Minimize exposure” in the CKS objectives means finding every grant, removing the excess, and proving the workload still works.
Concepts: RBAC and Service Accounts.
Tested 2026-10-13 on kind v0.33.0 with Kubernetes v1.37.0. The checks use impersonation (--as), which evaluates exactly the rules a real request from that identity would meet.
1. Prerequisites & Starting State
- Working
k8s-labcluster from Lab CKAD-01 andjq. No other lab is required.
The requirement, as the application team states it:
The
reporterServiceAccount in namespacepaymentsreads one ConfigMap,report-settings, and lists the Pods in its own namespace. It needs nothing else.
Create the starting state:
cat << 'SETUP' > rbac-setup.sh
kubectl create namespace payments
kubectl -n payments create serviceaccount reporter
kubectl -n payments create configmap report-settings --from-literal=format=csv
kubectl -n payments create configmap other-settings --from-literal=x=y
kubectl -n payments create secret generic card-vault --from-literal=key=4111111111111111
kubectl create clusterrole payments-reporter --verb='*' --resource='*'
kubectl create clusterrolebinding payments-reporter --clusterrole=payments-reporter --serviceaccount=payments:reporter
kubectl -n payments create rolebinding reporter-edit --clusterrole=edit --serviceaccount=payments:reporter
kubectl -n payments create rolebinding everyone-view --clusterrole=view --group=system:serviceaccounts
SETUP
sh rbac-setup.sh
SA=system:serviceaccount:payments:reporter2. Audit: What Can This Identity Do?
kubectl auth can-i --list --as=$SA -n payments | head -n 4
kubectl auth can-i '*' '*' --as=$SA
kubectl auth can-i get secrets -n kube-system --as=$SAResources Non-Resource URLs Resource Names Verbs
* [] [] [*]
...
yes
yes
The first row is every verb on every resource. The identity is a cluster administrator in all but name: it can read Secrets in kube-system.
can-i --list tells you what is allowed, not which object allows it. Removing access means finding every binding that names the identity, and an identity can be named in three ways:
| Subject in a binding | Matches |
|---|---|
ServiceAccount reporter in payments | This account directly |
Group system:serviceaccounts:payments | Every ServiceAccount in the namespace |
Group system:serviceaccounts or system:authenticated | Every ServiceAccount, or everyone |
Search both binding kinds for all three:
kubectl get clusterrolebinding -o json | jq -r '
.items[] | select(.subjects[]? | (.kind=="ServiceAccount" and .name=="reporter" and .namespace=="payments"))
| "\(.metadata.name) -> \(.roleRef.kind)/\(.roleRef.name)"'
kubectl get rolebinding -A -o json | jq -r '
.items[] | select(.subjects[]? | ((.kind=="ServiceAccount" and .name=="reporter")
or (.kind=="Group" and (.name | startswith("system:serviceaccounts")))))
| "\(.metadata.namespace)/\(.metadata.name) -> \(.roleRef.kind)/\(.roleRef.name) [\(.subjects | map(.kind + ":" + .name) | join(","))]"'payments-reporter -> ClusterRole/payments-reporter
payments/everyone-view -> ClusterRole/view [Group:system:serviceaccounts]
payments/reporter-edit -> ClusterRole/edit [ServiceAccount:reporter]
Three grants, each a different mistake:
| Binding | Problem |
|---|---|
ClusterRoleBinding payments-reporter | A wildcard ClusterRole, bound cluster-wide |
RoleBinding reporter-edit | edit in the namespace: can change workloads and read Secrets |
RoleBinding everyone-view | Gives view in payments to every ServiceAccount in the cluster, through a group |
Without jq, kubectl get rolebinding,clusterrolebinding -A -o wide prints the subjects in the last columns and can be searched with grep.
A ClusterRole is not dangerous in itself. A RoleBinding to a ClusterRole grants its rules in one namespace only; a ClusterRoleBinding grants them everywhere.
3. Remediate
Remove all three grants, then add back exactly what was asked for:
kubectl delete clusterrolebinding payments-reporter
kubectl delete clusterrole payments-reporter
kubectl -n payments delete rolebinding reporter-edit everyone-view
kubectl -n payments create role report-reader --verb=get --resource=configmaps --resource-name=report-settings
kubectl -n payments create role pod-lister --verb=get,list --resource=pods
kubectl -n payments create rolebinding reporter-read --role=report-reader --serviceaccount=payments:reporter
kubectl -n payments create rolebinding reporter-pods --role=pod-lister --serviceaccount=payments:reporter--resource-namelimits the rule to one named object. It works withget,update,patchanddelete, and cannot restrictlistorwatch, which is why the requirement to read one ConfigMap becamegetonly.- The custom ClusterRole was deleted because nothing else used it. Never delete a built-in role such as
editorview; remove the binding. - Before deleting a group binding such as
everyone-viewon a real cluster, find out who else relies on it. Here the requirement says nobody should.
4. Verify Both Directions
A remediation has two ways to be wrong: something is still allowed that should not be, or something needed is now denied. Test both.
check() { printf '%-36s' "$*"; kubectl auth can-i "$@" -n payments --as=$SA; }
check get configmap/report-settings
check list pods
check get configmap/other-settings
check list configmaps
check delete pods
check get secret/card-vault
printf '%-36s' "get pods -n default"; kubectl auth can-i get pods -n default --as=$SA
kubectl auth can-i --list --as=$SA -n payments | grep -E "configmaps|pods|secrets|^\*"get configmap/report-settings yes
list pods yes
get configmap/other-settings no
list configmaps no
delete pods no
get secret/card-vault no
get pods -n default no
configmaps [] [report-settings] [get]
pods [] [] [list get]
The first two are the requirement and must say yes. The rest are the excess and must say no. The final listing shows two rules and nothing else for the resources that matter.
Finally, confirm no other ServiceAccount kept access through the group binding:
kubectl auth can-i list pods -n payments --as=system:serviceaccount:default:defaultno
5. Controlled Failure Scenario: The Binding That Looks Right
Create a Role and a binding for a second account, with one mistake:
kubectl -n payments create serviceaccount auditor
kubectl -n payments create role secret-names --verb=list --resource=secrets
kubectl -n payments create rolebinding auditor-secrets --role=secret-names --serviceaccount=default:auditor
kubectl auth can-i list secrets -n payments --as=system:serviceaccount:payments:auditorno
The Role exists, the binding exists, and the answer is no. Nothing reports an error, because a binding to a subject that does not exist is perfectly valid. Look at who the binding names:
kubectl -n payments get rolebinding auditor-secrets -o jsonpath='{.subjects}{"\n"}'[{"kind":"ServiceAccount","name":"auditor","namespace":"default"}]
--serviceaccount takes <namespace>:<name>, and the namespace is wrong. A binding’s subjects can be edited, but its roleRef cannot; a wrong role means deleting and recreating the binding.
kubectl -n payments delete rolebinding auditor-secrets
kubectl -n payments create rolebinding auditor-secrets --role=secret-names --serviceaccount=payments:auditor
kubectl auth can-i list secrets -n payments --as=system:serviceaccount:payments:auditor # yes
liston Secrets isgeton SecretsThe Role was named “secret-names”, but a
listresponse contains every Secret’s full content. There is no way to grant names without values. Treatlistandwatchon Secrets as read access to all of them.
Other verbs that grant more than they appear to: create on pods (run anything with any ServiceAccount in the namespace), escalate and bind on roles, impersonate, and create on serviceaccounts/token.
6. Cleanup
kubectl delete namespace payments
rm -f rbac-setup.shThe ClusterRole and ClusterRoleBinding were removed in section 3. Confirm with kubectl get clusterrolebinding | grep payments, which should print nothing.
Practice Without the Answer
Suggested time: 10 minutes.
kubectl create namespace ops
kubectl -n ops create serviceaccount deployer
kubectl create clusterrolebinding ops-deployer --clusterrole=cluster-admin --serviceaccount=ops:deployer
kubectl -n ops create rolebinding all-accounts --clusterrole=admin --group=system:serviceaccounts:opsThe deployer account must be able to get, list, update and patch Deployments in ops, and read (not change) ConfigMaps in ops. It must have no access outside ops. No other ServiceAccount in ops should have any access.
Check yourself:
D=system:serviceaccount:ops:deployer
kubectl auth can-i patch deployments -n ops --as=$D # yes
kubectl auth can-i get configmaps -n ops --as=$D # yes
kubectl auth can-i update configmaps -n ops --as=$D # no
kubectl auth can-i delete deployments -n ops --as=$D # no
kubectl auth can-i get secrets -n ops --as=$D # no
kubectl auth can-i get pods -n kube-system --as=$D # no
kubectl auth can-i list pods -n ops --as=system:serviceaccount:ops:default # noReset with kubectl delete namespace ops; kubectl delete clusterrolebinding ops-deployer.
Further reading
Next Lab
Proceed to Lab CKS-14 — Investigating an Unknown Incident, or return to the CKS Track.