Impersonation gives you a second identity for free
Every real RBAC bug looks the same in hindsight: someone wrote a Role, believed it granted only what the comment said, and never checked. The check is one command. kubectl auth can-i <verb> <resource> --as=<identity> -n <namespace> asks the API server's authorizer the exact question a real request would ask, without you needing a second kubeconfig, a second cluster user, or a second terminal signed in as someone else. The --as flag runs the check as if you were that service account, using your own admin credentials to ask the question.
You will create a namespace, a service account with no permissions of its own, a Role that grants exactly what a "read pods" job needs, and a RoleBinding connecting the two. Then you interrogate it with can-i, over-grant it on purpose to see the blast radius change, and narrow it back to least privilege — the loop every access review is supposed to run and rarely does.
A Role is a claim. can-i is the test.
You need a free Azure account and the Azure CLI (az), signed in with az login. First time? The 15-minute Set up your machine page covers the account, the installs, and sign-in. Prefer zero installs? Run everything in Azure Cloud Shell (Bash) — az and kubectl are preinstalled and already signed in.
This lab assumes the cluster from Your first AKS cluster. If you already tore that one down, Step 1 recreates it with the same command. Everything after that is API objects, not compute — the only ongoing cost is the same one node, cents per hour. Tear down the cluster at the end regardless.
The cluster
If you still have the cluster from Your first AKS cluster, skip to get-credentials. Otherwise recreate it with the identical command.
# Windows/Git Bash: stop it mangling /subscriptions/... arguments (harmless on macOS/Linux) export MSYS_NO_PATHCONV=1 RG="campux-lab-aks-rg" az group create -n "$RG" -l eastus # same command as the first-cluster lab: free control plane, one small node az aks create -n campux-aks -g "$RG" \ --tier free \ --node-count 1 \ --node-vm-size Standard_B2s \ --enable-managed-identity \ --generate-ssh-keys az aks get-credentials -n campux-aks -g "$RG" kubectl get nodes
kubectl get nodes lists one node, status Ready. Confirm you are cluster-admin for this exercise — you need the authority to grant permissions before you can test taking some away: kubectl auth can-i create rolebindings --all-namespaces returns yes.A namespace and an unprivileged identity
Create a namespace to scope everything to, and a ServiceAccount that starts with zero permissions of its own — Kubernetes grants nothing by default.
kubectl create namespace demo
kubectl create serviceaccount app-reader -n demo
# prove it starts with nothing
kubectl auth can-i list pods --as=system:serviceaccount:demo:app-reader -n demo
kubectl auth can-i get secrets --as=system:serviceaccount:demo:app-reader -n demo
no. app-reader exists but has no Role bound to it yet — this is the default-deny posture Kubernetes RBAC starts from, and it is exactly why the next step is additive rather than something you have to remember to restrict.Grant exactly what a pod-reading job needs
Write a Role scoped to the demo namespace that allows reading pods and nothing else, then bind it to app-reader with a RoleBinding.
cat > pod-reader-role.yaml <<'EOF' apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: namespace: demo name: pod-reader rules: - apiGroups: [""] resources: ["pods"] verbs: ["get", "list", "watch"] EOF cat > pod-reader-binding.yaml <<'EOF' apiVersion: rbac.authorization.k8s.io/v1 kind: RoleBinding metadata: name: pod-reader-binding namespace: demo subjects: - kind: ServiceAccount name: app-reader namespace: demo roleRef: kind: Role name: pod-reader apiGroup: rbac.authorization.k8s.io EOF kubectl apply -f pod-reader-role.yaml kubectl apply -f pod-reader-binding.yaml
kubectl get role,rolebinding -n demo shows both objects. That is what the YAML claims. The next command is what actually matters.kubectl auth can-i list pods --as=system:serviceaccount:demo:app-reader -n demo kubectl auth can-i get pods --as=system:serviceaccount:demo:app-reader -n demo kubectl auth can-i get secrets --as=system:serviceaccount:demo:app-reader -n demo kubectl auth can-i delete pods --as=system:serviceaccount:demo:app-reader -n demo kubectl auth can-i list pods --as=system:serviceaccount:demo:app-reader -n default
list pods and get pods in demo both return yes. get secrets, delete pods, and list pods in the default namespace all return no — a RoleBinding only reaches its own namespace, and the Role granted only get/list/watch on pods, nothing else. Five checks, five answers that match the YAML exactly. That match is what "least privilege" means in practice: not a policy document, a set of can-i answers you can point to.Over-grant on purpose — and prove the difference
Now make the mistake most RBAC reviews exist to catch: widen the Role to a wildcard on all resources and verbs, "just to unblock someone." Re-run the same checks and watch the answers change.
cat > pod-reader-role.yaml <<'EOF' apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: namespace: demo name: pod-reader rules: - apiGroups: ["*"] resources: ["*"] verbs: ["*"] EOF kubectl apply -f pod-reader-role.yaml kubectl auth can-i get secrets --as=system:serviceaccount:demo:app-reader -n demo kubectl auth can-i delete pods --as=system:serviceaccount:demo:app-reader -n demo kubectl auth can-i create rolebindings --as=system:serviceaccount:demo:app-reader -n demo
can-i is the only way to close it without waiting for an incident to find out.A wildcard resources: ["*"] with verbs: ["*"] is a common shortcut when a permission error is blocking someone and the fastest fix is "grant everything." It works, which is the danger — it removes the error without anyone noticing the scope just grew from one resource type to every resource type Kubernetes knows about, in that namespace, including Secrets and the RBAC objects themselves. A Role's rules are additive and there is no separate "except this" clause; the only ceiling is what you write.
Narrow it back to least privilege
Reapply the original scoped Role. The RoleBinding does not need to change — only the Role it points at.
cat > pod-reader-role.yaml <<'EOF' apiVersion: rbac.authorization.k8s.io/v1 kind: Role metadata: namespace: demo name: pod-reader rules: - apiGroups: [""] resources: ["pods"] verbs: ["get", "list", "watch"] EOF kubectl apply -f pod-reader-role.yaml kubectl auth can-i get secrets --as=system:serviceaccount:demo:app-reader -n demo kubectl auth can-i delete pods --as=system:serviceaccount:demo:app-reader -n demo kubectl auth can-i list pods --as=system:serviceaccount:demo:app-reader -n demo
get secrets and delete pods are back to no; list pods is back to yes. Nothing about the ServiceAccount or the RoleBinding moved — narrowing the Role was the entire fix, because a Role is the only place the permission actually lives. Re-run the full five-check block from Step 3 if you want the full before/after side by side.Tear it down
Clear the namespace, then the cluster.
kubectl delete namespace demo
az group delete -n campux-lab-aks-rg --yes --no-wait
az group exists -n campux-lab-aks-rg # -> false once complete
MC_campux-lab-aks-rg_campux-aks_eastus) are gone.What you can now honestly claim
You created a Role and RoleBinding, then proved what they actually granted with kubectl auth can-i --as instead of trusting the YAML — five checks whose answers had to match the rules exactly, or something was wrong. You widened the Role to a wildcard, watched a Secret-read and a pod-delete both flip to yes with no other change, and narrowed it back. In an access review, that is the whole job: state the claim, run the test, and never let "I wrote a Role for that" stand in for "I checked what it allows."