RBAC Least Privilege: Designing Roles People Can Live With
The audit finding arrived on a Wednesday at 2 PM, one line long: "47 subjects hold cluster-admin in production." I knew the number before I counted. I had personally created six of those bindings.
Every one of them had a story. The CI pipeline that needed to "just deploy things." The debugging session at midnight that became permanent. The new hire who needed access "like everyone else has." None of them were malicious, and that was the problem: RBAC debt doesn't accumulate through bad decisions, it accumulates through reasonable ones that nobody ever revisited. The fix isn't a big-bang lockdown -- I tried that in 2019 and it lasted eleven days before someone added back a wildcard in a panic. The fix is designing roles that are small enough to understand and convenient enough that people don't route around them.
Quick answer: Least-privilege RBAC that survives contact with reality comes down to four habits. Default to namespaced Roles bound by RoleBindings, and reserve ClusterRoleBindings for platform components that genuinely operate cluster-wide. List verbs explicitly -- never * -- because wildcards grant future verbs you never reviewed. Use aggregated ClusterRoles so each team owns a small piece of a composed role instead of one monolith nobody dares edit. And audit with kubectl auth can-i --list --as <subject> on a schedule, because the design drifts the day you ship it.
Role vs ClusterRole is a blast-radius decision
The rule I give every new platform engineer: if you can't name the cluster-scoped resource the workload needs, it gets a Role. Secrets, configmaps, deployments, pods -- all namespaced, all covered by a Role plus a RoleBinding. ClusterRoles bound cluster-wide are for controllers and operators that genuinely act across namespaces, and there are fewer of those than vendors' install docs suggest.
The pattern people underuse is binding a *ClusterRole* with a *RoleBinding*. You get a centrally maintained rule definition -- say, a curated "app-developer" ClusterRole with the standard verbs on the standard resources -- but the grant only exists in the namespace where you bind it. One definition, fifty namespaces, zero duplication:
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: app-developer
rules:
- apiGroups: ["", "apps", "batch"]
resources: ["pods", "pods/log", "deployments", "statefulsets", "jobs", "cronjobs", "services", "configmaps"]
verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
name: payments-developers
namespace: payments
subjects:
- kind: Group
name: "oidc:payments-team"
apiGroup: rbac.authorization.k8s.io
roleRef:
kind: ClusterRole
name: app-developer
apiGroup: rbac.authorization.k8s.io
Note the Group subject bound to an OIDC group, not individual users. Users join and leave; groups are managed by the identity provider, which is where joiners and leavers are actually handled. The day someone offboards, their cluster access dies with their IdP group membership, not three weeks later when someone remembers a RoleBinding.
Wildcards are a subscription to future permissions
rules:
- apiGroups: ["apps"]
resources: ["deployments"]
verbs: ["*"]
This looks harmless. It grants get, list, watch, create, update, patch, delete. It also grants every verb that gets invented later. The * in verbs -- and worse, in resources -- means "whatever this API can do, now and forever." Upgrade the cluster, install a CRD, and yesterday's carefully reviewed grant has silently grown teeth. I once watched a "read-only" role with resources: ["*"] and verbs: ["get", "list"] turn out to include a CRD whose get triggered a reconciler side effect. Read-only, technically.
List the verbs. It's tedious exactly once, and it's the difference between a grant you reviewed and a grant you inherited from the future.
Aggregate instead of monolith
The default admin, edit, and view ClusterRoles in your cluster are not single objects -- they're aggregates, composed from smaller ClusterRoles via aggregationRule label selectors. That's why installing cert-manager magically adds cert-manager permissions to edit: its chart ships ClusterRoles with the label rbac.authorization.k8s.io/aggregate-to-edit: "true" and the aggregation controller folds them in.
Steal the pattern for team roles. Instead of one 400-line ClusterRole that five teams share and nobody dares touch, give each team a small ClusterRole they own, labeled to roll up:
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: payments-team-role
labels:
rbac.example.com/aggregate-to-payments: "true"
rules:
- apiGroups: ["payments.example.com"]
resources: ["settlements"]
verbs: ["get", "list", "watch", "update", "patch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: payments-aggregate
aggregationRule:
clusterRoleSelectors:
- matchLabels:
rbac.example.com/aggregate-to-payments: "true"
rules: []
Now a team can extend its own permissions -- new CRD, new resource -- without a platform-team review of a shared monolith, and the blast radius of their edit is exactly their aggregate. The aggregate itself stays empty in rules and just collects.
Auditing without a spreadsheet
Two commands do most of the work. First, verify a grant does what you think:
$ kubectl auth can-i create deployments --as system:serviceaccount:payments:deployer -n payments
yes
$ kubectl auth can-i get secrets --as system:serviceaccount:payments:deployer -n payments
no
Second, find the bindings nobody remembers. kubectl get clusterrolebindings -o wide is the obvious start, but the queries that find real problems are kubectl get clusterrolebindings -o json | jq '.items[] | select(.roleRef.name=="cluster-admin")' for the crown jewels, and checking every RoleBinding whose subject is a User rather than a Group. Run them in CI against a cluster snapshot weekly and diff. When I'm doing this live I usually just flip through bindings in conndeck and eyeball the subjects, because a wall of yellow "User:" entries is visible in a way jq output somehow isn't -- but the CI check is what catches it when I'm on vacation.
The other audit that matters: revocations. When you remove a binding, prove it's gone with can-i impersonating the subject. I have removed a RoleBinding, declared victory, and later discovered a second binding from a Helm hook quietly granting the same thing. Trust, then can-i.
Frequently asked questions
What is the difference between a Role and a ClusterRole in Kubernetes?
A Role grants permissions within a single namespace and is the right default for application and team access. A ClusterRole is cluster-scoped: it can grant cluster-level resources like nodes, and when bound with a ClusterRoleBinding it grants its rules across every namespace at once. The subtle part is that a ClusterRole bound with a namespaced RoleBinding only applies inside that one namespace, which is a legitimate pattern for sharing rule definitions without widening the blast radius.
Why are wildcard verbs in RBAC rules dangerous?
A rule with verbs set to star grants every current verb and every verb added in the future, which means your permissions silently grow when the cluster is upgraded or a new CRD lands. Someone who could update deployments yesterday can suddenly create them, delete them, and read secrets tomorrow. Explicitly listing verbs like get, list, and watch keeps the grant frozen at exactly what you reviewed.
How do I check what permissions a user or service account actually has?
Run kubectl auth can-i with the --as flag to impersonate the identity, for example kubectl auth can-i create deployments --as system:serviceaccount:payments:deployer -n payments. For a full picture, kubectl auth can-i --list shows every rule that applies to that identity in the namespace. This is also the fastest way to confirm a revocation actually worked, rather than trusting the diff of the RoleBinding you deleted.
Should developers get cluster-admin in Kubernetes?
Almost never, and not even in dev clusters once more than one team shares them, because cluster-admin includes the ability to read every secret in every namespace and modify RBAC itself. Give developers namespaced Roles with the verbs their workflow actually needs, usually get, list, watch, plus create and patch on their own apps, and escalate through a time-boxed process for the rare exception. Every cluster-admin binding should be short-lived, audited, and slightly embarrassing to ask for.
What are aggregated ClusterRoles and when should I use them?
Aggregated ClusterRoles let several small ClusterRoles combine into one logical role using an aggregationRule that matches label selectors. Kubernetes ships this way internally: the default admin, edit, and view roles are built by aggregating extension roles, which is how CRD controllers like cert-manager add their permissions to edit without you touching anything. Use the same pattern for team roles so each team owns a small labeled ClusterRole and the platform owns the aggregate.
The field-notes version
Least privilege fails when it's inconvenient, so design for the person who will be paged at 2 AM and needs the obvious thing to also be the safe thing. Namespaced by default, explicit verbs, aggregated roles per team, group subjects from the IdP.
Then audit on a schedule, because RBAC is not a design you finish. It's a garden you weed.