Containerization Lesson 41 of 113

Designing Least-Privilege RBAC in Kubernetes: Roles, Aggregation & Auditing at Scale

In a nutshell

Kubernetes RBAC (Role-Based Access Control) answers exactly one question, over and over, for every single API call: can this subject perform this verb on this resource, in this scope? That is the whole model. A subject (a person, a group, or a workload) asks to do something (a verb like get, create, delete) to something (a resource like pods or secrets) somewhere (a namespace, or the whole cluster). RBAC checks whether some rule grants that exact combination. If one does, you are allowed. If none does, you are Forbidden — there is no “maybe.”

The mental model that keeps you safe is a keycard building. Every door (resource) has a lock. A keycard (your permissions) is programmed to open only specific doors on specific floors (namespaces). Least privilege means you hand out a card that opens nothing by default, then add exactly the doors a person or robot needs to do their job — and not one more. The wrong instinct, the one every cluster drifts toward, is issuing everyone the master key (cluster-admin) because it “just works.” It works right up until someone — or something that broke into a container — walks through a door they should never have reached.

Two properties make Kubernetes RBAC unusual, and you must internalize both. First, it is purely additive: permissions only ever add up. There is no “deny” rule, and order does not matter — your access is the union of every card you have been handed. Second, it is default-deny: absent a grant, the answer is no. So you never subtract access by writing a denial; you subtract it by removing or narrowing a grant. Keep those two facts in your head and most RBAC confusion evaporates.

New to the primitives? This lesson is the design and audit playbook. If terms like Role, RoleBinding, and ServiceAccount are brand new, read Kubernetes RBAC & ServiceAccounts: the fundamentals first, then come back here to learn how to scope, aggregate, and prove it at scale.

Level: Advanced · Time: ~31 min · You’ll be able to: design least-privilege Roles and ClusterRoles from personas; reuse a permission set across namespaces with one binding; wire the cluster to OIDC groups; hunt down privilege-escalation paths; and prove the model holds with kubectl auth can-i, admission policy, and a continuous audit trail.

Kubernetes least-privilege RBAC: a subject (User, Group, or ServiceAccount with its bound token) is matched by a RoleBinding to a Role or ClusterRole whose verbs×resources×scope the API server evaluates as an allow-by-union decision — Allowed if any rule matches, Denied by default otherwise

Read the diagram left to right: who (subject) → the grant (RoleBinding, which can reuse a ClusterRole and scope it to one namespace) → what & where (the Role’s verbs × resources, namespaced, or a cluster-wide ClusterRole) → the decision at the API server, which is the union of every matching rule: Allowed if at least one rule matches, Denied by default if none do. Each numbered badge marks a design decision — a token that is really an API credential, a binding that can silently reuse a moving target, an escalation verb that hands over the keys. The rest of this lesson is those six badges in depth.

Most clusters drift toward cluster-admin because it is the path of least resistance: someone hits a Forbidden, a binding gets widened, and nobody ever narrows it back. This is a playbook for doing the opposite — scoping permissions deliberately, binding to groups instead of people, and proving on a schedule that the model still holds.

1. RBAC primitives, revisited

Four object kinds do all the work. The split that trips people up is scope, not function.

Kind Scope Grants permissions in
Role namespaced its own namespace
ClusterRole cluster-wide all namespaces, plus cluster-scoped & non-resource URLs
RoleBinding namespaced one namespace (subject ← Role or ClusterRole)
ClusterRoleBinding cluster-wide every namespace

Think of them as two pairs. Roles are definitions — a named bag of permissions that grants nothing until something references it. Bindings are assignments — they attach a subject to a role. A role with no binding is inert; a binding with no subjects grants nothing. You always need one of each to actually give someone access.

The non-obvious combination: a RoleBinding can reference a ClusterRole. That lets you author a permission set once as a ClusterRole and grant it per namespace via RoleBinding — the workhorse pattern for multi-tenant clusters. The four legal wiring combinations, and the one illegal one, are worth memorizing:

Binding roleRef points to Effect
RoleBinding Role (same ns) subject gets those rules in that namespace
RoleBinding ClusterRole subject gets those rules scoped to that one namespace (author once, bind per-ns)
ClusterRoleBinding ClusterRole subject gets those rules in every namespace + cluster-scoped resources
ClusterRoleBinding Role illegal — the API server rejects it; a ClusterRoleBinding can only reference a ClusterRole

RBAC is purely additive. There are no deny rules; effective permission is the union of every binding a subject matches. You reduce access by removing or narrowing bindings, never by adding a denial.

A rule is apiGroups × resources × verbs, optionally narrowed by resourceNames:

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: deploy-restart
  namespace: team-payments
rules:
  - apiGroups: ["apps"]
    resources: ["deployments"]
    verbs: ["get", "list", "watch", "patch"]   # patch = rollout restart
  - apiGroups: ["apps"]
    resources: ["deployments/scale"]            # subresource is a separate grant
    verbs: ["update", "patch"]

Read that rule as a sentence: “On resources in the apps API group, specifically deployments, allow the verbs get, list, watch, and patch.” A request is allowed only if all three axes match at once — the right verb, on the right resource, in the right group. Miss any axis and you get a Forbidden. Three axes trip up beginners constantly, so here is each one spelled out.

Verbs — the actions. These map to HTTP methods but are not identical to them; RBAC has a few special verbs with no CRUD equivalent.

Verb What it allows Note
get read one named object does not grant list
list read a collection (and see every field of every item) a list on secrets dumps them all
watch stream changes to a collection usually paired with list
create make a new object cannot be narrowed by resourceNames (the name doesn’t exist yet)
update replace an existing object full replace (PUT)
patch modify part of an object how kubectl rollout restart, scale, label work
delete remove one named object
deletecollection remove many at once separate from delete; grant deliberately
impersonate act as another user/group/SA effectively a superuser verb
escalate write a role with more rights than you hold opts out of the escalation check
bind bind an existing role to a subject opts out of the escalation check

The get/list split is the one beginners forget most: a get grant lets you read an object by name but returns Forbidden for kubectl get pods (which is a list). Grant both when someone needs to enumerate.

Resources — the nouns. pods, services, deployments, secrets, and so on. Two subtleties: subresources are addressed with a slash (pods/log, pods/exec, deployments/scale, pods/status) and are separate grants — access to pods does not include pods/exec. And resourceNames can pin a rule to specific named objects:

rules:
  - apiGroups: [""]
    resources: ["secrets"]
    resourceNames: ["payments-db-creds"]   # this ONE secret, nothing else
    verbs: ["get"]

resourceNames works for verbs that address a single object — get, update, patch, delete — but not for list, watch, create, or deletecollection. That is a security-critical caveat: you cannot grant “list only this one secret.” A list on secrets returns every secret in scope, full stop. To restrict to specific secrets, grant get with resourceNames and withhold list entirely.

apiGroups — the namespace of the nouns. Every resource lives in an API group, and the same resource name in a different group is a different resource. The empty string "" is the core group — the original built-ins.

apiGroup Common resources
"" (core) pods, services, secrets, configmaps, namespaces, nodes, serviceaccounts, persistentvolumeclaims, events, endpoints
apps deployments, statefulsets, daemonsets, replicasets
batch jobs, cronjobs
networking.k8s.io ingresses, networkpolicies
rbac.authorization.k8s.io roles, clusterroles, rolebindings, clusterrolebindings
apiextensions.k8s.io customresourcedefinitions

Writing apiGroups: ["core"] is a classic beginner error — the core group is the empty string, not the word “core.” If a rule silently never matches, check this first.

Subjects are not Kubernetes objects. Users and groups are opaque strings asserted by the authenticator (your OIDC provider or client cert). The API server never validates that a user “exists” — it only matches the string. ServiceAccounts are real namespaced objects.

That last point deserves expanding, because subjects come in three flavors and they behave very differently:

2. Map personas to scoped roles

Start from who is asking, not from a default role. A workable baseline for a shared cluster:

Persona Scope Core verbs
App developer own namespace read all; create/patch/delete deployments, configmaps, services; get pod logs; create pods/exec (gated)
SRE / on-call cluster-wide read everything; delete pods; patch nodes (cordon/drain); read events
CI pipeline (SA) own namespace apply app resources; no secrets read, no exec
Tenant team own namespace the developer set, bound only in their namespace

Author each as a ClusterRole so it is reusable, then bind with a namespaced RoleBinding:

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: app-developer
rules:
  - apiGroups: ["", "apps", "batch", "networking.k8s.io"]
    resources: ["deployments", "replicasets", "pods", "services",
                "configmaps", "jobs", "cronjobs", "ingresses"]
    verbs: ["get", "list", "watch", "create", "update", "patch", "delete"]
  - apiGroups: [""]
    resources: ["pods/log"]
    verbs: ["get", "list"]
  # NOTE: secrets and pods/exec are deliberately omitted
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: payments-developers
  namespace: team-payments
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: ClusterRole          # reuse the cluster role, scope it here
  name: app-developer
subjects:
  - apiGroup: rbac.authorization.k8s.io
    kind: Group
    name: "eng-payments"     # an OIDC group, not a person

Note that pods/log and pods/exec are separate subresources. Granting log access does not grant exec — keep exec on its own, more tightly bound role.

3. Aggregated ClusterRoles for maintainable sets

Hand-maintaining the rule list above across a dozen teams is how drift starts. Use ClusterRole aggregation: a controller automatically merges the rules of any ClusterRole whose labels match an aggregation selector.

apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: monitoring-reader        # the aggregate — leave rules empty
aggregationRule:
  clusterRoleSelectors:
    - matchLabels:
        rbac.kv.io/aggregate-to-monitoring: "true"
rules: []                        # filled in by the controller; do not hand-edit
---
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: monitoring-prometheus
  labels:
    rbac.kv.io/aggregate-to-monitoring: "true"   # contributes into the aggregate
rules:
  - apiGroups: [""]
    resources: ["nodes/metrics", "services", "endpoints", "pods"]
    verbs: ["get", "list", "watch"]

Bind a subject to monitoring-reader; later, drop in a new labeled ClusterRole and the grant expands with zero edits to bindings. This is exactly how the built-in admin, edit, and view roles absorb CRD permissions — operators ship roles labeled rbac.authorization.k8s.io/aggregate-to-edit: "true" and they merge automatically.

Aggregation is convenient and a footgun. Any actor who can create a ClusterRole with the magic label silently widens every aggregate. Treat create on clusterroles as a privileged grant (more on this in section 7).

4. ServiceAccount hygiene

Every pod runs as a ServiceAccount. If you do nothing, the default SA token is mounted into every pod at /var/run/secrets/kubernetes.io/serviceaccount/ — a ready-made credential for anyone who lands RCE in a container.

Disable automount unless the workload calls the API. Set it on the SA (and you can override per-pod):

apiVersion: v1
kind: ServiceAccount
metadata:
  name: web-frontend
  namespace: team-payments
automountServiceAccountToken: false   # frontend never talks to the API
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: web-frontend
  namespace: team-payments
spec:
  template:
    spec:
      serviceAccountName: web-frontend
      automountServiceAccountToken: false   # belt-and-suspenders at pod level

Give each workload its own SA and bind only the verbs it needs. Never reuse default, and never bind anything to the default SA. Find pods still riding the default SA:

kubectl get pods -A -o json | jq -r '
  .items[]
  | select((.spec.serviceAccountName // "default") == "default")
  | "\(.metadata.namespace)/\(.metadata.name)"'

Since Kubernetes 1.24, SAs no longer auto-create long-lived Secret tokens; pods get short-lived bound tokens via the TokenRequest API, auto-rotated and audience-scoped. If you find a manually created kubernetes.io/service-account-token Secret, treat it as a static credential to eliminate. (For the interplay between SA tokens and the Secrets they can read, see Kubernetes ConfigMaps & Secrets deep dive.)

5. Bind to OIDC groups, not individuals

Per-user bindings are unauditable and they outlive the user. Wire the API server to your IdP (Entra ID, Okta, or Dex fronting either) and bind to groups.

API server flags (managed control planes expose these as cluster settings — e.g. AKS/EKS OIDC integration — rather than raw flags):

kube-apiserver \
  --oidc-issuer-url=https://login.microsoftonline.com/<tenant-id>/v2.0 \
  --oidc-client-id=<app-client-id> \
  --oidc-username-claim=sub \
  --oidc-username-prefix="oidc:" \
  --oidc-groups-claim=groups \
  --oidc-groups-prefix="oidc:"

The prefixes namespace external identities so they cannot collide with built-ins like system:masters. Subjects then reference the prefixed group:

subjects:
  - apiGroup: rbac.authorization.k8s.io
    kind: Group
    name: "oidc:eng-payments"

Now access control lives in your IdP: add a user to the eng-payments group and they get exactly the bound role; remove them and access is gone at next token refresh. No cluster change, full IdP audit trail. For local CLI auth, kubectl uses the oidc exec/auth plugin (or kubelogin for Entra) to fetch and refresh tokens.

6. Find over-permissioned subjects

kubectl auth can-i is the built-in primitive — and the only one you should trust as ground truth, because it asks the API server’s actual authorizer:

# Impersonate a subject and ask a specific question
kubectl auth can-i delete secrets -n team-payments \
  --as="oidc:alice@corp.com" --as-group="oidc:eng-payments"

# List everything a ServiceAccount can do
kubectl auth can-i --list \
  --as=system:serviceaccount:team-payments:ci-deployer

For the reverse question — who can do something — reach for community tools. They parse RBAC objects, so confirm hits with auth can-i:

# krew plugins
kubectl krew install who-can rbac-tool access-matrix

# Who can read secrets anywhere?
kubectl who-can get secrets -A

# Resolve a subject's full effective permissions (transitively)
kubectl rbac-tool lookup oidc:alice@corp.com
kubectl rbac-tool policy-rules -e '^system:serviceaccount:.*'

# rakkess: per-resource access matrix for the current (or impersonated) subject
kubectl access-matrix --as=system:serviceaccount:team-payments:ci-deployer

rbac-tool also generates a least-privilege ClusterRole from observed audit-log activity (rbac-tool gen) — a strong starting point when retrofitting a permissive SA.

7. Catch privilege-escalation paths

Some grants are dangerous regardless of how narrow they look, because they let a subject grant themselves more. Hunt these specifically:

Verb / resource Why it is an escalation path
escalate on roles/clusterroles create/update a role with more rights than you hold (bypasses the built-in escalation check)
bind on roles/clusterroles bind an existing high-priv role to yourself
impersonate on users/groups/serviceaccounts act as any subject — effectively a superuser
create on pods (+ a privileged SA in ns) launch a pod mounting another SA’s token, or a hostPath/privileged pod
get/list on secrets read SA tokens, then authenticate as those SAs
pods/exec, pods/attach execute inside a running pod, inheriting its identity
update on */status or nodes tamper with scheduling / admission outcomes

Normally the API server blocks you from creating a role more powerful than your own. The escalate and bind verbs opt out of that check — so granting them, even scoped, hands over the keys. Find every subject holding them:

# Any binding granting escalate/bind/impersonate
kubectl rbac-tool policy-rules -o wide \
  | grep -Ei 'escalate|impersonate|(^|[^a-z])bind([^a-z]|$)'

# Wildcards are almost always over-grants — surface them
kubectl get clusterroles -o json | jq -r '
  .items[]
  | select([.rules[]? | select((.verbs[]?=="*") or (.resources[]?=="*"))] | length > 0)
  | .metadata.name'

Reading Secrets is the most underrated escalation. A “read-only” role that includes secrets can read every mounted SA token in the namespace and then become those SAs. Exclude secrets from broad read roles and grant specific secrets by resourceNames only.

8. Continuous RBAC auditing

A correct model on Tuesday means nothing if Friday’s incident-fix binding never gets reverted. Make auditing continuous.

Enable the audit log and capture authorization decisions. A minimal policy that records RBAC changes and denials at metadata level:

apiVersion: audit.k8s.io/v1
kind: Policy
rules:
  - level: RequestResponse
    verbs: ["create", "update", "patch", "delete"]
    resources:
      - group: "rbac.authorization.k8s.io"
        resources: ["roles", "clusterroles", "rolebindings", "clusterrolebindings"]
  - level: Metadata
    resources:
      - group: ""
        resources: ["secrets"]   # who read what, without logging values

authorization.k8s.io/decision: forbid annotations in the log are gold — a steady stream of denials for one subject is a missing (legitimate) grant or an attacker probing. Ship the log to your SIEM and alert on:

Policy-as-code stops bad grants before they merge. With Kyverno or Gatekeeper/OPA, reject dangerous bindings at admission. A Kyverno policy blocking new cluster-admin ClusterRoleBindings:

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: restrict-cluster-admin
spec:
  validationFailureAction: Enforce
  rules:
    - name: no-cluster-admin-binding
      match:
        any:
          - resources:
              kinds: ["ClusterRoleBinding"]
      validate:
        message: "Binding to cluster-admin is not allowed; request a scoped role."
        deny:
          conditions:
            any:
              - key: "{{ request.object.roleRef.name }}"
                operator: Equals
                value: "cluster-admin"

Keep all Roles/Bindings in Git, reconciled by Argo CD or Flux. GitOps gives you the missing pieces: drift detection (the cluster is corrected back to Git), peer review on every permission change, and a full history of who widened what and when.

Going deeper

Everything above is the design surface. This section is the machinery underneath it — how the answer is actually computed, what you already have installed, and the exact paths an attacker walks. Read it once and the design rules stop feeling arbitrary.

How the API server actually decides

Every request runs a three-stage gauntlet, in order:

  1. Authentication (authn)who are you? The request’s credential (OIDC token, client cert, SA bound token) is turned into a username + group list. If this fails, you get 401 Unauthorized. RBAC has not even run yet.
  2. Authorization (authz)are you allowed? Here the RBAC authorizer runs. A Forbidden is a 403.
  3. Admissionshould this specific object be allowed? Validating/mutating webhooks, Pod Security Admission, quotas. This is a different stage — an admission denied is not an RBAC problem.

The crucial nuance in stage 2: the API server usually runs several authorizers in a chain — commonly Node, then RBAC (and optionally Webhook, ABAC). They are combined with allow-by-union: the first authorizer to say “allow” wins, and the request proceeds. No authorizer can issue a “deny” that overrides another’s “allow” — a not-allowed answer just means “I have no opinion, ask the next one.” Only if every authorizer declines does the request get Forbidden. This is why RBAC has no deny rule: deny is not a concept the chain can express.

Within the RBAC authorizer itself, the algorithm is simple: collect every RoleBinding and ClusterRoleBinding whose subjects list matches your username or any of your groups or your SA identity; gather the rules from every role they reference; and check whether any rule matches (verb, apiGroup, resource, resourceName). One match anywhere in that union is enough. Order is irrelevant. This is the whole reason auditing means auditing the union, not the latest object you touched.

system:masters is the one identity that skips all of this. It is hard-wired into the RBAC authorizer as a superuser — no binding required, and admission webhooks are the only thing left that can stop it. The kubeadm admin.conf client cert carries O=system:masters. Guard that file like a root password.

The default ClusterRoles you already have

Every cluster ships with user-facing ClusterRoles. You rarely need to write a “read-only” role from scratch — bind these (scoped with a RoleBinding) and you inherit CRD permissions for free via aggregation.

ClusterRole Grants The trap
view read-only on most namespaced resources excludes Secrets by design (so it can’t be used to steal tokens) — but includes configmaps
edit read/write most namespaced resources, including Secrets can read and write Secrets in its scope → can read mounted SA tokens
admin edit plus managing Roles/RoleBindings in the namespace can grant others access (up to its own rights); cannot touch ResourceQuota or the Namespace object
cluster-admin * on * + all non-resource URLs the master key; bind via ClusterRoleBinding and it is cluster-wide superuser

The line beginners miss: view is safe with Secrets, edit and admin are not. Handing a tenant the built-in edit role — even scoped to their namespace via a RoleBinding — gives them read/write on Secrets in that namespace, and therefore the ability to read any SA token mounted there and impersonate it. For untrusted tenants, pin an explicit non-aggregating ClusterRole instead of binding edit/admin. The hundreds of system:* ClusterRoles (system:kube-scheduler, system:node, …) are the control plane’s own identities — never bind humans to them.

kubectl auth can-i and impersonation are your source of truth

Static analysis of RBAC YAML is always an approximation; the API server’s authorizer is the only authority. kubectl auth can-i asks it directly. Two flags turn it into an audit tool:

# Am I allowed? (asks about YOUR identity)
kubectl auth can-i create deployments -n team-payments

# Ask about SOMEONE ELSE with impersonation
kubectl auth can-i list secrets -n team-payments \
  --as=oidc:alice@corp.com --as-group=oidc:eng-payments

# The full permission inventory for any subject
kubectl auth can-i --list \
  --as=system:serviceaccount:team-payments:ci-deployer

--as / --as-group use the impersonation feature: your own identity must hold the impersonate verb (cluster-admins do). You are asking “if I were this subject, what could I do?” — computed by the real authorizer, so it accounts for webhook authorizers and Node authorization that static tools miss. Because impersonation is that powerful, impersonate on users/groups/serviceaccounts is itself a superuser grant: whoever holds it can become system:masters and bypass everything. Inventory it as carefully as cluster-admin.

Escalation prevention: why you can’t grant what you don’t have

Kubernetes actively stops privilege escalation through RBAC itself with two built-in checks:

The two escape hatches are the verbs escalate and bind:

That is why the section-7 table flags them so hard: they look like narrow, obscure verbs, but either one converts a limited account into a self-service cluster-admin. Grant them to controllers that genuinely provision RBAC (and audit those), never to humans.

ServiceAccount token projection, under the hood

Since the bound-token model went GA (v1.22) and legacy auto-Secrets were switched off (v1.24), a pod’s credential is a projected bound token, not a static Secret. When a pod mounts the default serviceAccountToken projected volume, the kubelet calls the TokenRequest API and receives a JWT that is:

# Request a second, differently-audienced token (e.g. for an external system)
spec:
  containers:
    - name: app
      volumeMounts:
        - name: vault-token
          mountPath: /var/run/secrets/vault
  volumes:
    - name: vault-token
      projected:
        sources:
          - serviceAccountToken:
              path: token
              audience: vault           # NOT valid against the kube API
              expirationSeconds: 3600

The security win is huge: a token exfiltrated from a compromised pod is short-lived, can’t be replayed against a different audience, and dies with the pod. But it does not change what the token authorizes — that is still pure RBAC. A bound token whose SA is bound to cluster-admin is a cluster-admin credential for the next hour. Token projection limits blast radius over time; least-privilege RBAC limits blast radius in scope. You need both.

A privilege-escalation path, walked end to end

To feel why the section-7 verbs matter, trace the single most common cluster takeover. Assume an attacker has landed code execution in a pod whose SA can only create pods in namespace team-payments — which sounds harmless:

  1. create pods lets them schedule a new pod, and a pod spec chooses its own serviceAccountName.
  2. They point the new pod at a more privileged SA that already exists in team-payments — say the CI deployer bound to edit.
  3. The kubelet dutifully mounts that SA’s bound token into the attacker’s pod.
  4. Now they hold an edit credential: they get secrets, read every mounted token in the namespace, and impersonate whichever SA is most powerful.
  5. If any reachable SA can touch clusterrolebindings or holds escalate/bind, the game is over — they mint themselves cluster-admin.

Every hop is legal RBAC. The lesson: create pods is only as safe as the most privileged SA in the same namespace, and reading Secrets is a lateral-movement primitive, not a read. This is why you keep one SA per workload, exclude Secrets from broad roles, and never leave a powerful SA sharing a namespace with an untrusted one.

Namespace-vs-cluster scoping traps

The single richest source of RBAC bugs is scope confusion. Keep this table close:

Symptom Cause Fix
“view in dev” leaked into prod bound with a ClusterRoleBinding instead of a namespaced RoleBinding use a RoleBinding per namespace
Rule for nodes/persistentvolumes never works those are cluster-scoped; a Role/RoleBinding can’t grant them use a ClusterRole + ClusterRoleBinding
ClusterRoleBinding to a Role rejected on apply a CRB can only reference a ClusterRole reference a ClusterRole, or switch to a RoleBinding
Grant to /metrics, /healthz ignored non-resource URLs only exist in ClusterRole rules (nonResourceURLs) move it to a ClusterRole
Tenant sees another namespace’s objects a list/watch grant via a ClusterRoleBinding is cluster-wide scope with a RoleBinding so list is namespaced

The mental shortcut: cluster-scoped resources (nodes, PVs, namespaces, the RBAC objects themselves, non-resource URLs) can only be granted by a ClusterRole, and binding cluster-wide vs per-namespace is decided by the binding kind, not the role kind. A ClusterRole scoped by a RoleBinding is namespaced; the same ClusterRole under a ClusterRoleBinding is everywhere.

Enterprise scenario

A fintech platform team running multi-tenant EKS thought their tenants were boxed into their own namespaces. Each tenant got the app-developer ClusterRole bound via a namespaced RoleBinding — the canonical pattern from section 2. During a quarterly access review, rbac-tool flagged that one tenant’s CI ServiceAccount could read Secrets in every namespace. The grant looked scoped, so nobody believed it until auth can-i confirmed it.

The cause was the EKS-managed aws-node and a vendor monitoring operator both shipping ClusterRoles labeled rbac.authorization.k8s.io/aggregate-to-edit: "true". A platform engineer, building a self-service “give tenants edit in their namespace” flow, had bound tenants to the built-in edit ClusterRole. Aggregation silently merged the vendor’s secrets: ["get","list"] rule into edit cluster-wide — and because the tenant binding used edit, every tenant inherited Secret read in their namespace, including mounted SA tokens they could then impersonate.

The fix was to stop binding the aggregated built-ins for tenants and pin an explicit, non-aggregating ClusterRole instead, plus a Kyverno policy rejecting any new ClusterRole carrying an aggregation label unless it lives in an allowlisted namespace prefix.

# Prove the blast radius before and after the fix
kubectl auth can-i list secrets -n tenant-acme \
  --as=system:serviceaccount:tenant-acme:ci   # was: yes  ->  now: no

# Audit which ClusterRoles feed the built-in edit aggregate
kubectl get clusterroles -l rbac.authorization.k8s.io/aggregate-to-edit=true

Lesson: binding aggregated roles is binding a moving target. Any operator you install can widen them.

Verify

Prove the model end to end before you call it done.

# 1. The least-priv personas can do their job...
kubectl auth can-i patch deployments -n team-payments \
  --as=oidc:alice@corp.com --as-group=oidc:eng-payments        # expect: yes

# 2. ...and cannot do what they must not
kubectl auth can-i get secrets -n team-payments \
  --as=oidc:alice@corp.com --as-group=oidc:eng-payments        # expect: no
kubectl auth can-i create pods/exec -n team-payments \
  --as=oidc:alice@corp.com --as-group=oidc:eng-payments        # expect: no

# 3. CI cannot read secrets or exec
kubectl auth can-i get secrets \
  --as=system:serviceaccount:team-payments:ci-deployer         # expect: no

# 4. No unexpected holders of escalate/bind/impersonate
kubectl rbac-tool policy-rules -o wide | grep -Ei 'escalate|impersonate'

# 5. Nobody outside the break-glass list binds cluster-admin
kubectl get clusterrolebindings -o json | jq -r '
  .items[] | select(.roleRef.name=="cluster-admin")
  | "\(.metadata.name): \([.subjects[]?.name] | join(", "))"'

# 6. No pods silently using the default SA
kubectl get pods -A -o json | jq -r '
  .items[] | select((.spec.serviceAccountName // "default")=="default")
  | "\(.metadata.namespace)/\(.metadata.name)"'

When a can-i answer is the opposite of what you expected, work the scope traps in the RBAC troubleshooting methodology — the four-axis rule (verb × resource × group × scope) tells you which axis missed.

Practice challenges

Work these in order — they escalate from “write a Role” to “find the escalation.” Each solution is one command or manifest plus the one-line why. Try before you peek.

<details> <summary><strong>1 · Beginner — read-only into one namespace.</strong> Give the OIDC group <code>oidc:auditors</code> read (get/list/watch) on pods, deployments, and configmaps in <code>team-payments</code> — and <em>nothing</em> in any other namespace. Which binding kind, and why not <code>view</code>?</summary>

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: ns-reader
  namespace: team-payments
rules:
  - apiGroups: ["", "apps"]
    resources: ["pods", "deployments", "configmaps"]
    verbs: ["get", "list", "watch"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: auditors-read
  namespace: team-payments
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: Role
  name: ns-reader
subjects:
  - apiGroup: rbac.authorization.k8s.io
    kind: Group
    name: "oidc:auditors"

Why: a RoleBinding (not a ClusterRoleBinding) keeps it to one namespace. You could bind the built-in view ClusterRole with a RoleBinding, but this explicit Role is narrower and won’t grow when an operator aggregates new rules into view. </details>

<details> <summary><strong>2 · Beginner→Intermediate — a least-privilege CI bot.</strong> Design a role for a CI ServiceAccount <code>system:serviceaccount:team-payments:ci-deployer</code> that can apply app resources (deployments, services, configmaps) but must never read Secrets or exec into pods. Then prove both halves with <code>auth can-i</code>.</summary>

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: ci-deployer
  namespace: team-payments
rules:
  - apiGroups: ["", "apps"]
    resources: ["deployments", "services", "configmaps"]
    verbs: ["get", "list", "watch", "create", "update", "patch"]
  # secrets and pods/exec deliberately absent
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: ci-deployer
  namespace: team-payments
roleRef: { apiGroup: rbac.authorization.k8s.io, kind: Role, name: ci-deployer }
subjects:
  - kind: ServiceAccount
    name: ci-deployer
    namespace: team-payments
SA=system:serviceaccount:team-payments:ci-deployer
kubectl auth can-i patch deployments -n team-payments --as=$SA   # yes
kubectl auth can-i get secrets     -n team-payments --as=$SA   # no
kubectl auth can-i create pods/exec -n team-payments --as=$SA   # no

Why: omitting secrets and pods/exec from the rule set is the whole control — RBAC is default-deny, so what you don’t list is already forbidden. Note the ServiceAccount subject needs no apiGroup. </details>

<details> <summary><strong>3 · Intermediate — one permission set, two namespaces.</strong> The same <code>app-developer</code> permissions are needed in both <code>team-payments</code> and <code>team-ledger</code>. Do it without duplicating the rules.</summary>

# Author ONCE as a ClusterRole (from section 2), then bind per-namespace:
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata: { name: devs, namespace: team-payments }
roleRef: { apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: app-developer }
subjects: [{ apiGroup: rbac.authorization.k8s.io, kind: Group, name: "oidc:eng-payments" }]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata: { name: devs, namespace: team-ledger }
roleRef: { apiGroup: rbac.authorization.k8s.io, kind: ClusterRole, name: app-developer }
subjects: [{ apiGroup: rbac.authorization.k8s.io, kind: Group, name: "oidc:eng-ledger" }]

Why: a RoleBinding can reference a ClusterRole and scope it to its own namespace. One rule set, N namespaced grants, zero duplication — and it stays a RoleBinding, so it is not cluster-wide. </details>

<details> <summary><strong>4 · Intermediate→Advanced — find what a role can escalate to.</strong> A namespace <code>team-x</code> contains a SA <code>builder</code> whose Role grants only <code>create</code> on <code>pods</code>. The namespace also has a SA <code>deployer</code> bound to the built-in <code>edit</code>. What can <code>builder</code> actually reach, and how do you prove it?</summary>

# builder can create a pod that RUNS AS deployer -> inherits edit -> reads secrets
kubectl auth can-i create pods -n team-x \
  --as=system:serviceaccount:team-x:builder            # yes
kubectl auth can-i get secrets -n team-x \
  --as=system:serviceaccount:team-x:deployer           # yes  <- the real prize

# So builder's effective reach ⊇ deployer's. Confirm the lateral hop exists:
kubectl get sa -n team-x         # both SAs live in the same namespace

Why: create pods lets builder set serviceAccountName: deployer on a new pod; the kubelet mounts deployer’s token; builder now has edit, hence Secrets, hence every other SA token. The escalation isn’t in builder’s Role — it’s in sharing a namespace with a more privileged SA. Fix: don’t co-locate, or remove create pods. </details>

<details> <summary><strong>5 · Advanced — pin a Secret grant to one object.</strong> A workload must read exactly the Secret <code>payments-db-creds</code> in <code>team-payments</code> and no other. Write the rule, then explain why you must <em>not</em> also grant <code>list</code>.</summary>

rules:
  - apiGroups: [""]
    resources: ["secrets"]
    resourceNames: ["payments-db-creds"]
    verbs: ["get"]        # get only — NOT list/watch

Why: resourceNames narrows verbs that address a single object (get, update, patch, delete) but is ignored by list/watch — the API has no way to “list one named secret.” Add list and the subject can enumerate every secret in the namespace, defeating the whole point. Grant get + resourceNames, never list. </details>

<details> <summary><strong>6 · Advanced — hunt every dangerous grant in the cluster.</strong> In one pass, surface every subject that can escalate (holds <code>escalate</code>/<code>bind</code>/<code>impersonate</code>, or a wildcard, or binds <code>cluster-admin</code>).</summary>

# escalation verbs
kubectl rbac-tool policy-rules -o wide \
  | grep -Ei 'escalate|impersonate|(^|[^a-z])bind([^a-z]|$)'

# wildcard verbs or resources in any ClusterRole
kubectl get clusterroles -o json | jq -r '
  .items[] | select([.rules[]? |
    select((.verbs[]?=="*") or (.resources[]?=="*"))] | length > 0)
  | .metadata.name'

# who is bound to cluster-admin
kubectl get clusterrolebindings -o json | jq -r '
  .items[] | select(.roleRef.name=="cluster-admin")
  | "\(.metadata.name): \([.subjects[]?.name] | join(", "))"'

Why: these three checks cover the realistic self-escalation surface — the special verbs that opt out of the escalation check, wildcards that absorb future APIs, and direct master-key bindings. Every hit needs a name and a justification, or it gets removed. Wire this into CI so it runs on every change. </details>

Common beginner mistakes

These are misconceptions, not typos — each one comes from a wrong mental model, so the fix is a corrected model, not just a corrected command.

Checklist

Pitfalls

Glossary

KubernetesRBACSecurityOIDCAudit
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments