RBAC minimization

Least privilege and the grants that are really escalation.

Advanced14 min · lesson 7 of 24

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.

How an RBAC decision is made
1subjectuser / group / SA2bindinglinks subject to role3roleapiGroups x resources x verbs4allow / denydefault: deny
Role vs ClusterRole is where a permission is defined. RoleBinding vs ClusterRoleBinding is where the grant applies. It's all additive, so a subject can do the union of every rule that matches it.

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.

role.yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role # namespaced
metadata: { namespace: payments, name: deployer }
rules:
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["get", "list", "update", "patch"] # named verbs, never ["*"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata: { namespace: payments, name: ci-deployer }
subjects:
- kind: ServiceAccount
name: deployer
namespace: ci
roleRef: { 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.

terminal
# apply the scoped role and its binding
$ kubectl apply -f role.yaml
role.rbac.authorization.k8s.io/deployer created
rolebinding.rbac.authorization.k8s.io/ci-deployer created
# it can do its job...
$ kubectl auth can-i patch deployments \
--as=system:serviceaccount:ci:deployer -n payments
yes
# ...and nothing past it
$ kubectl auth can-i delete deployments \
--as=system:serviceaccount:ci:deployer -n payments
no
$ kubectl auth can-i get secrets \
--as=system:serviceaccount:ci:deployer -n payments
no

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.

terminal
# 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 payments
Resources Non-Resource URLs Resource Names Verbs
deployments.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-system
no

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.

A namespaced binding doesn't shrink the role it points at
A RoleBinding feels contained because it lives in one namespace. But if it references a ClusterRole that carries verbs: ["*"] on resources: ["*"], the subject gets full wildcard power inside that namespace, secrets and all. The binding controls where the grant lands, not how broad it is. Read the role a binding points at, never just the binding.

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.

terminal
# 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 and
clusterroles 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.yaml
clusterrole.rbac.authorization.k8s.io/ci-builder configured
$ kubectl auth can-i get secrets \
--as=system:serviceaccount:ci:builder -n payments
no
$ kubectl auth can-i list secrets \
--as=system:serviceaccount:ci:builder -n payments
no

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.

terminal
$ kubectl -n payments apply -f - <<'EOF'
apiVersion: v1
kind: ServiceAccount
metadata: { name: deployer }
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata: { name: restart-payments }
rules:
- apiGroups: ["apps"]
resources: ["deployments"]
resourceNames: ["payments-api"]
verbs: ["get","patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata: { 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:deployer
yes
$ kubectl auth can-i get secrets -n payments \
--as=system:serviceaccount:payments:deployer
no

Takeaway

RBAC is default-deny plus additive grants. Name the resource, shrink the verbs, and prove with kubectl auth can-i — not with hope.

Quick check
01A CI service account has a RoleBinding in the payments namespace to the built-in edit ClusterRole, and separately a grant of create on pods. Why is the create-pods grant the bigger escalation risk?
Incorrect — create doesn't imply delete, and deletion isn't the escalation risk here.
Correct — create pods is a token-mounting primitive: the pod runs as whatever SA it names, and that SA's API token is readable from inside the container.
Incorrect — editing bindings needs verbs on rolebindings, not on pods.
Incorrect — even if it did, that wouldn't remove the risk; create pods is dangerous wherever it comes from.
02A ClusterRole grants a service account the escalate verb on roles, and nothing else at all. What does that grant let the account do?
Incorrect — That is pods/exec, a separate near-admin permission.
Incorrect — That is closer to bind, and bind is also inert without create on rolebindings.
Correct — escalate only lifts the check that stops you writing a role stronger than your own, so it needs write access on roles beside it.
Incorrect — That is impersonate, which is dangerous on its own, unlike escalate.
03A RoleBinding in the dev namespace references a ClusterRole whose only rule is apiGroups: ["*"], resources: ["*"], verbs: ["*"]. What can the bound subject actually do?
Correct — a namespaced binding scopes the location, not the power, so the wildcard ClusterRole grants full control within dev.
Incorrect — a RoleBinding pointing at a ClusterRole is a supported, common pattern for reusing one role namespace by namespace.
Incorrect — a RoleBinding confines the grant to its own namespace; only a ClusterRoleBinding would make it cluster-wide.
Incorrect — wildcards are not downgraded; the subject gets every verb, including write and delete.

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.

Related