RBAC minimization
Least privilege and the grants that are really escalation.
A service account that only ever needed to restart one deployment ends up able to read every secret in the cluster. Nobody planned that. It happens because a grant is the fastest way to unbreak a pipeline at 2am, and once the build goes green nobody circles back to take the extra access away. RBAC, short for Role-Based Access Control, is the gate that decides, after Kubernetes already knows who you are, whether you're allowed to do the thing you just asked it to do.
Think of a bouncer holding a stack of guest lists. Authentication is the ID check at the door: it proves you're you. RBAC is the lists, and they say which rooms you're allowed into. Two rules do all the work. There's no 'banned' list anywhere, only 'allowed' lists, so a subject can do the sum of every list its name shows up on. And if a name isn't on any list, that name gets nothing. That second rule is default-deny. The first is why tightening RBAC is a subtraction game. You don't add blocks. You track down grants that quietly piled up and take them back.
There are four objects, in two pairs. A Role lives inside one namespace; a ClusterRole applies across the whole cluster. Both carry the same kind of rule: an apiGroup, a resource, and the verbs allowed on it, like get or patch. A RoleBinding or ClusterRoleBinding then hands a role to a subject, which is a user, a group, or a service account. One trick earns its keep constantly: a RoleBinding can point at a ClusterRole and apply it only in its own namespace, so you define the role once and reuse it namespace by namespace without granting it cluster-wide.
Write it tight, then prove it
Here's a role for a CI (continuous integration) service account that deploys into the payments namespace. Every verb is spelled out. No wildcard, because a wildcard is how a deploy account quietly grows the power to read production secrets.
apiVersion: rbac.authorization.k8s.io/v1kind: Role # namespacedmetadata: { namespace: payments, name: deployer }rules:- apiGroups: ["apps"]resources: ["deployments"]verbs: ["get", "list", "update", "patch"] # named verbs, never ["*"]---apiVersion: rbac.authorization.k8s.io/v1kind: RoleBindingmetadata: { namespace: payments, name: ci-deployer }subjects:- kind: ServiceAccountname: deployernamespace: ciroleRef: { kind: Role, name: deployer, apiGroup: rbac.authorization.k8s.io }
Applying the YAML isn't the finish line. The finish line is confirming the account can do its job and nothing past it. kubectl auth can-i answers that, and it's authoritative because it runs the same authorizer the API server uses. You're not reading the manifest and hoping. You're asking the real judge, as the subject, using --as to impersonate it.
# apply the scoped role and its binding$ kubectl apply -f role.yamlrole.rbac.authorization.k8s.io/deployer createdrolebinding.rbac.authorization.k8s.io/ci-deployer created# it can do its job...$ kubectl auth can-i patch deployments \--as=system:serviceaccount:ci:deployer -n paymentsyes# ...and nothing past it$ kubectl auth can-i delete deployments \--as=system:serviceaccount:ci:deployer -n paymentsno$ kubectl auth can-i get secrets \--as=system:serviceaccount:ci:deployer -n paymentsno
Audit what's already there
On a cluster that's been running for a year, the grants that matter are the ones you didn't write. Start with cluster-admin, the built-in role that can do everything to everything. The system:masters group is bound to it on purpose, so leave that one alone. A workload service account bound to it is not on purpose, and that's the first thing to hunt for. For any account you're unsure about, impersonate it with --as and let can-i answer for that identity instead of yours.
# service accounts bound to cluster-admin (there should be none)$ kubectl get clusterrolebindings -o json | jq -r '.items[] | select(.roleRef.name=="cluster-admin")| .subjects[]? | select(.kind=="ServiceAccount")| .namespace + "/" + .name'ci/runner # a workload SA holding cluster-admin. red flag.# prove the scoped deployer is really scoped, as the deployer itself$ kubectl auth can-i --list --as=system:serviceaccount:ci:deployer -n paymentsResources Non-Resource URLs Resource Names Verbsdeployments.apps [] [] [get list patch update]# ...and that it can't reach into kube-system$ kubectl auth can-i create pods --as=system:serviceaccount:ci:deployer -n kube-systemno
The grants that read narrow but aren't
Some keys open more doors than the label suggests. A handful of RBAC permissions look modest in a manifest and sit right next to admin in practice. get on secrets reads every credential in scope, and so do list and watch, because both hand back the full secret data: a role that grants list but not get looks harmless and is not. Most secrets are the keys to something else. create on pods is the sneaky one: a subject who can create a pod can make that pod mount any service account in that namespace, including a powerful one, then read that account's token from inside the container. pods/exec is a shell into any running pod in scope. Then two verbs act as amplifiers on write access to the RBAC objects themselves. escalate on roles or clusterroles turns off the guard that normally stops you writing a role stronger than the one you already hold, so a subject that can also create or update roles can write itself any permission it wants. bind on roles or clusterroles is the same trick for bindings: with create on rolebindings next to it, the subject can attach an existing powerful role to itself. Alone neither verb does anything, which is exactly why they get waved through in review. impersonate stands apart, because it needs no partner: it lets a subject act as another identity entirely, with everything that identity can do. Count all of these as near-admin, whatever the resource list says.
kube-bench is the standard scanner for the CIS Kubernetes Benchmark, the hardening baseline published by the Center for Internet Security (CIS). It has a whole section on RBAC. Those checks are marked manual, so kube-bench warns rather than fails, and that's fine. Let it point you at the wildcard roles, then let can-i deliver the verdict.
# CIS Kubernetes Benchmark, section 5.1. Wildcard use is 5.1.3, a manual check -> WARN$ kube-bench run --targets=policies[INFO] 5 Kubernetes Policies[INFO] 5.1 RBAC and Service Accounts[WARN] 5.1.3 Minimize wildcard use in Roles and ClusterRoles...== Remediations policies ==5.1.3 Where possible replace any use of wildcards in roles andclusterroles with specific objects or actions.# find the custom ClusterRoles that still carry a wildcard verb$ kubectl get clusterroles -o json | jq -r '.items[]| select(.rules[]?.verbs[]? == "*")| select(.metadata.name | test("^system:|^cluster-admin$") | not)| .metadata.name'ci-builder# rewrite it to named verbs, then confirm the read path to secrets is closed$ kubectl apply -f ci-builder-scoped.yamlclusterrole.rbac.authorization.k8s.io/ci-builder configured$ kubectl auth can-i get secrets \--as=system:serviceaccount:ci:builder -n paymentsno$ kubectl auth can-i list secrets \--as=system:serviceaccount:ci:builder -n paymentsno
escalate, bind, and impersonate are privilege escalation verbs. Treat them like root, and treat escalate or bind sitting next to create or update on roles and rolebindings as the pairing that actually fires. Most app service accounts never need them; pipelines that do need a break-glass story and an audit trail.
ClusterRoles that look narrow still bite when bound cluster-wide. Prefer Role + RoleBinding in the workload namespace unless the controller truly must see every namespace.
Audit existing grants with kubectl auth can-i --list and with a periodic RBAC review. The dangerous binding is usually the one added at 2am and never removed.
Human users and pipeline identities need different lives. Humans get short-lived access through your SSO provider and groups. Pipelines get ServiceAccounts scoped to one namespace and one set of resourceNames. Mixing them in a single ClusterRoleBinding is how contractors inherit cluster-admin from a build robot. Write the verification command next to the control in the same pull request, and keep the sample output so the next on-call person can tell pass from fail without guessing.
Try this
Create a tight Role for one Deployment restart, bind it to a ServiceAccount, then prove can-i is yes for that verb and no for secrets.
$ kubectl -n payments apply -f - <<'EOF'apiVersion: v1kind: ServiceAccountmetadata: { name: deployer }---apiVersion: rbac.authorization.k8s.io/v1kind: Rolemetadata: { name: restart-payments }rules:- apiGroups: ["apps"]resources: ["deployments"]resourceNames: ["payments-api"]verbs: ["get","patch"]---apiVersion: rbac.authorization.k8s.io/v1kind: RoleBindingmetadata: { name: deployer-restart }subjects: [{ kind: ServiceAccount, name: deployer }]roleRef: { kind: Role, name: restart-payments, apiGroup: rbac.authorization.k8s.io }EOF$ kubectl auth can-i patch deployments/payments-api -n payments \--as=system:serviceaccount:payments:deployeryes$ kubectl auth can-i get secrets -n payments \--as=system:serviceaccount:payments:deployerno
Takeaway
RBAC is default-deny plus additive grants. Name the resource, shrink the verbs, and prove with kubectl auth can-i — not with hope.
The habit that keeps a cluster tight isn't writing flawless roles on the first try. It's re-running can-i after every change, as the subject, against the exact thing it shouldn't be able to do. 'I removed the wildcard' is a hope. 'can-i get secrets returns no' is a fact you can paste into the ticket and move on.