Hedronite · Cert Lesson · CKS Cluster Hardening · Thu 2026-10-15

CKS RBAC escalation prevention, the escalate and bind verbs, and the aggregated role that grows on a label

You cannot hand out what you do not hold, unless someone gave you the verb that says you can.

Lesson Class: Cert (T2 · Cluster Hardening 15%)
Track: Kubernetes track · day 85
Referent: escalate · bind · resourceNames · aggregationRule
Not a repeat: not 10-09 audit policy · 10-03 attestation · 08-04 CKA RBAC basics
Paired Ops: Fail-closed PR gates for a law catalog
Hold It to Grant It
Role and binding writes are checked.
Name the Role
bind without resourceNames covers all.
A Label Is a Grant
Aggregation widens every binding.
No binding changed, and the auditors could read every Secret.

How the Kubernetes API server stops a Role editor from granting themselves more access, which two verbs switch that guard off, and how one label widens a built-in role, for the CKS "Cluster Hardening" domain.

The problem

A team lead owns the namespace team-a. The cluster administrator lets them write Roles and RoleBindings there, so that onboarding a teammate does not need a ticket. That works until the lead needs to read a Secret and sees a shortcut: write a Role that grants get on Secrets, then bind it to themselves. If the API server accepted that, permission to edit Roles would amount to permission to do anything in the namespace. Every careful grant elsewhere would then be decoration.

The second failure is quieter. A group of auditors is bound to the built-in view role, which is meant to be read-only and to exclude Secrets. Someone applies one small ClusterRole carrying a single label. No binding changes and nobody edits view, yet the auditors can now read every Secret in the namespace.

The first problem is stopped by the API server unless you hand out two special verbs. The second is how aggregation is designed to behave. Both appear in the Certified Kubernetes Security Specialist (CKS) exam, which tests Role Based Access Control (RBAC) on a live cluster. This lesson shows what the API server refuses, what switches the refusal off, and where a label acts as a grant.

The idea, in plain words

Before it lets you write or bind a role, the API server compares what you are trying to hand out with what you already have. If the role contains anything you lack, the write is refused. A verb is the action a rule permits, such as get, list, create or escalate. Two verbs, escalate and bind, switch that comparison off for writing roles and for binding them, respectively. Aggregation is a third path by which permissions move. A controller copies rules into a built-in role whenever a ClusterRole carries the matching label, so a label is a grant.

Concept A: you cannot write a role you do not hold

The rule: you can create or update a Role or ClusterRole only if at least one of two things is true. Either you already have every permission in the role, at the same scope as the object you are modifying. Or you have been granted the escalate verb on the roles or clusterroles resource in the rbac.authorization.k8s.io API group. A resource is the kind of object a rule covers, such as pods, secrets or roles. Scope is where a permission applies: one namespace or the whole cluster.

The reason is that RBAC permissions are purely additive and there are no deny rules. The Kubernetes Book puts it as a deny-by-default system where "you enable specific actions by creating allow rules". The API server therefore cannot repair an oversized grant afterwards by adding a deny. The only place to stop it is the moment the Role is written. Because the check lives in the API itself, it applies even when the RBAC authorizer is not in use.

The lab ran on a throwaway single-node k3s cluster, k3s v1.34.12+k3s1, driven by kubectl v1.33.9. The CKS exam page says the exam is based on Kubernetes v1.35, one minor version newer than the lab cluster. Every step run as the team lead uses kubectl --as=system:serviceaccount:team-a:role-manager. Impersonation means the cluster administrator's request is evaluated as if the named ServiceAccount, a Kubernetes identity for workloads and automation, had sent it. The lab never logs in as that account.

The setup file creates the namespace, two ServiceAccounts called role-manager and viewer, and the team lead's Role:

# role-manager may write Roles and RoleBindings in team-a and read Pods.
# It holds no access to Secrets.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: rbac-writer
  namespace: team-a
rules:
- apiGroups: ["rbac.authorization.k8s.io"]
  resources: ["roles", "rolebindings"]
  verbs: ["get", "list", "create", "update", "patch", "delete"]
- apiGroups: [""]
  resources: ["pods"]
  verbs: ["get", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: role-manager-rbac-writer
  namespace: team-a
subjects:
- kind: ServiceAccount
  name: role-manager
  namespace: team-a
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: Role
  name: rbac-writer
...

The Role rbac-writer is the team lead's whole power: it can create and update Roles and RoleBindings inside team-a and read Pods, and it has no Secrets rule at all. The RoleBinding connects that Role to its subject, meaning the identity receiving the permissions, here the role-manager ServiceAccount. A RoleBinding only applies inside its own namespace. The trailing ... marks the rest of the file, which creates the viewer account and binds it to view, a step Concept C uses.

Now the shortcut from the problem statement, written as a file:

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: secret-reader
  namespace: team-a
rules:
- apiGroups: [""]
  resources: ["secrets"]
  verbs: ["get", "list"]

Steps 1 and 2 of the lab script apply a harmless Role first and then this one:

## 1. role-manager writes a Role it already holds
$ kubectl --as=system:serviceaccount:team-a:role-manager auth can-i list pods -n team-a
yes
[exit 0]
$ kubectl --as=system:serviceaccount:team-a:role-manager apply -f pod-reader-role.yaml
role.rbac.authorization.k8s.io/pod-reader created
[exit 0]

## 2. role-manager writes a Role with a permission it lacks
$ kubectl --as=system:serviceaccount:team-a:role-manager auth can-i get secrets -n team-a
no
[exit 1]
$ kubectl --as=system:serviceaccount:team-a:role-manager apply -f secret-reader-role.yaml
Error from server (Forbidden): error when creating "secret-reader-role.yaml": roles.rbac.authorization.k8s.io "secret-reader" is forbidden: user "system:serviceaccount:team-a:role-manager" (groups=["system:serviceaccounts" "system:serviceaccounts:team-a" "system:authenticated"]) is attempting to grant RBAC permissions not currently held:
{APIGroups:[""], Resources:["secrets"], Verbs:["get" "list"]}
[exit 1]

The command kubectl auth can-i asks the API server whether an identity may perform an action. The pod-reader Role passes because role-manager already has get and list on pods, so the new Role grants nothing it lacks. The secret-reader Role fails with Forbidden, and the error names exactly the rule that is missing, which makes it a useful debugging tool on the exam. Note that the refusal comes from the escalation check, not from a missing create permission: role-manager can create Roles, just not that Role.

Now the exception. Step 5 grants the escalate verb on the roles resource and retries:

## 5. grant escalate on roles, then retry the secret-reader Role
$ kubectl apply -f grant-escalate.yaml
role.rbac.authorization.k8s.io/escalate-roles created
rolebinding.rbac.authorization.k8s.io/role-manager-escalate-roles created
[exit 0]
$ kubectl --as=system:serviceaccount:team-a:role-manager auth can-i escalate roles -n team-a
yes
[exit 0]
$ kubectl --as=system:serviceaccount:team-a:role-manager apply -f secret-reader-role.yaml
role.rbac.authorization.k8s.io/secret-reader created
[exit 0]
$ kubectl --as=system:serviceaccount:team-a:role-manager auth can-i get secrets -n team-a
no
[exit 1]

The file grant-escalate.yaml holds one rule, with the verb escalate on the resource roles in the rbac.authorization.k8s.io group, plus a RoleBinding to role-manager. With it, the same secret-reader file that failed a moment ago is created. The pitfall is in the last two lines: can-i get secrets still answers no. Writing a Role is not the same as holding it, and the Role only helps someone who is bound to it.

The real danger of escalate is a pairing the lab did not run: escalate together with update on a Role that is already bound to you. By the rules in and, the usual check that you hold everything in the role no longer applies, so you could edit your own Role and add any rule you like. That is why the Good Practices page warns that users with this right "can effectively escalate their privileges".

Concept B: you cannot bind a role you do not hold, unless you have bind

The rule: you can create or update a RoleBinding only if you already have all the permissions in the referenced role, at the same scope as the binding, or if you are authorized to perform the bind verb on that role. Without this twin of the first check, the guard would be pointless. A user who cannot write a powerful Role could simply create a binding to an existing one such as admin and receive everything in it.

A related design choice supports this. After a binding is created you cannot change its roleRef, the field naming the Role or ClusterRole it grants. That lets an administrator give someone update permission on a binding to manage its subjects, without that person being able to change which role the subjects receive. A common pattern is to define a ClusterRole once and bind it into individual namespaces with RoleBindings, so the definition is reused. That pattern is exactly what the bind verb controls.

The lab hands role-manager a narrow bind permission:

# Lets role-manager bind the "view" ClusterRole (and only that one) in team-a.
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: bind-view-only
  namespace: team-a
rules:
- apiGroups: ["rbac.authorization.k8s.io"]
  resources: ["clusterroles"]
  verbs: ["bind"]
  resourceNames: ["view"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: role-manager-bind-view-only
  namespace: team-a
subjects:
- kind: ServiceAccount
  name: role-manager
  namespace: team-a
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: Role
  name: bind-view-only

The key line is resourceNames: ["view"]. It narrows the permission to the single named ClusterRole, so role-manager may bind view and nothing else. If you omit resourceNames, the permission covers binding any ClusterRole, which is a much larger grant. The rest of the file is the familiar pair: a Role holding the rule and a RoleBinding that gives it to role-manager.

Step 3 shows the refusal before this grant exists, trimmed to its first rule lines:

$ kubectl --as=system:serviceaccount:team-a:role-manager create rolebinding viewer-view-2 -n team-a --clusterrole=view --serviceaccount=team-a:viewer
error: failed to create rolebinding: rolebindings.rbac.authorization.k8s.io "viewer-view-2" is forbidden: user "system:serviceaccount:team-a:role-manager" (groups=["system:serviceaccounts" "system:serviceaccounts:team-a" "system:authenticated"]) is attempting to grant RBAC permissions not currently held:
{APIGroups:[""], Resources:["bindings"], Verbs:["get" "list" "watch"]}
{APIGroups:[""], Resources:["configmaps"], Verbs:["get" "list" "watch"]}
{APIGroups:[""], Resources:["endpoints"], Verbs:["get" "list" "watch"]}
{APIGroups:[""], Resources:["events"], Verbs:["get" "list" "watch"]}
...
[exit 1]

The full error runs to dozens of lines because view covers many resources. It lists only what role-manager lacks: further down, the pods line reads just the watch verb, since role-manager already has get and list on pods. That subtraction is the escalation check made visible. Step 4 then applies grant-bind-view.yaml and retries, then tries the more powerful edit role:

## 4. grant bind on clusterroles/view only, then retry
$ kubectl apply -f grant-bind-view.yaml
role.rbac.authorization.k8s.io/bind-view-only created
rolebinding.rbac.authorization.k8s.io/role-manager-bind-view-only created
[exit 0]
$ kubectl --as=system:serviceaccount:team-a:role-manager auth can-i bind clusterroles/view -n team-a
Warning: resource 'clusterroles' is not namespace scoped in group 'rbac.authorization.k8s.io'

yes
[exit 0]
$ kubectl --as=system:serviceaccount:team-a:role-manager create rolebinding viewer-view-2 -n team-a --clusterrole=view --serviceaccount=team-a:viewer
rolebinding.rbac.authorization.k8s.io/viewer-view-2 created
[exit 0]
$ kubectl --as=system:serviceaccount:team-a:role-manager create rolebinding viewer-edit -n team-a --clusterrole=edit --serviceaccount=team-a:viewer
error: failed to create rolebinding: rolebindings.rbac.authorization.k8s.io "viewer-edit" is forbidden: user "system:serviceaccount:team-a:role-manager" (groups=["system:serviceaccounts" "system:serviceaccounts:team-a" "system:authenticated"]) is attempting to grant RBAC permissions not currently held:
{APIGroups:[""], Resources:["bindings"], Verbs:["get" "list" "watch"]}
{APIGroups:[""], Resources:["configmaps"], Verbs:["create" "delete" "deletecollection" "patch" "update" "get" "list" "watch"]}
{APIGroups:[""], Resources:["endpoints"], Verbs:["get" "list" "watch"]}
{APIGroups:[""], Resources:["events"], Verbs:["create" "delete" "deletecollection" "patch" "update" "get" "list" "watch"]}
{APIGroups:[""], Resources:["limitranges"], Verbs:["get" "list" "watch"]}
...
[exit 1]

The view binding is created, because the bind verb on exactly that ClusterRole is enough. The edit binding is still refused, because bind on view says nothing about edit. The warning that clusterroles is not namespace scoped is harmless: the answer printed after it is still yes, and the binding works.

The pitfall is how much a binding to edit really gives away. The edit role does not allow viewing or modifying roles and role bindings, but it does allow accessing Secrets and running Pods as any ServiceAccount in the namespace, so it can be used to gain the API access of any ServiceAccount there. The full refusal list in the transcript shows this: it includes secrets, serviceaccounts with the impersonate verb, and create on serviceaccounts/token. A binding that looks like "edit rights" is closer to a path to every identity in the namespace.

Concept C: aggregated ClusterRoles grow on a label

An aggregated ClusterRole is one whose rules are not written by hand but assembled from other ClusterRoles. A controller running in the control plane (the aggregation controller) watches for ClusterRoles with an aggregationRule set. The aggregationRule holds a label selector, which is a query that matches objects by their labels, and every ClusterRole it matches has its rules merged into the aggregate. Creating a new ClusterRole that matches the selector triggers the merge.

Aggregation exists so that you can add rules to the admin, edit or view roles without editing those roles. You create a ClusterRole with one of the labels rbac.authorization.k8s.io/aggregate-to-admin, aggregate-to-edit or aggregate-to-view set to "true". Every binding of the target role then picks up the new rules automatically.

The lab's file shows how little it takes:

# The label alone adds these rules to the built-in "view" ClusterRole.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
  name: lab-secrets-into-view
  labels:
    rbac.authorization.k8s.io/aggregate-to-view: "true"
rules:
- apiGroups: [""]
  resources: ["secrets"]
  verbs: ["get", "list"]

The ClusterRole's name does not matter. Its label matches the selector on view, so its single rule, get and list on secrets, is copied into view. Nothing touches the viewer account or its RoleBinding from setup.yaml, which already points at view. The docs say view excludes Secrets because reading them reveals ServiceAccount credentials, which would allow API access as any ServiceAccount in the namespace. Adding this one label therefore removes a deliberate safeguard.

Step 6 runs the whole sequence as it happened:

## 6. aggregation: a label grows the built-in view role
$ kubectl auth can-i --as=system:serviceaccount:team-a:viewer get secrets -n team-a
no
[exit 1]
$ kubectl get clusterrole view -o jsonpath={.aggregationRule.clusterRoleSelectors}
[{"matchLabels":{"rbac.authorization.k8s.io/aggregate-to-view":"true"}}][exit 0]

$ kubectl apply -f aggregate-secrets-to-view.yaml
clusterrole.rbac.authorization.k8s.io/lab-secrets-into-view created
[exit 0]
$ kubectl get clusterrole view -o json | jq '.rules[] | select(.resources | index("secrets"))'
{"apiGroups":[""],"resources":["secrets"],"verbs":["get","list"]}
$ kubectl auth can-i --as=system:serviceaccount:team-a:viewer get secrets -n team-a
yes
[exit 0]

Before the label, viewer cannot read Secrets. The jsonpath query prints view's selector, which matches the label in the lab's file. After the ClusterRole is applied, view itself contains the secrets rule, and viewer's answer flips to yes without any binding changing. Pitfall 1 follows directly: a label is a grant. Anyone who can create ClusterRoles can push permissions into every binding of view, admin or edit across the cluster. The escalation check on writing the ClusterRole still applies, so the author needs the permissions themselves or the escalate verb, but an account with escalate on clusterroles has no such limit. In the lab, the cluster administrator applied it.

Pitfall 2 is that hand edits to an aggregated role do not last, and step 7 proves it:

## 7. hand-edit of an aggregated role is overwritten; deleting the labeled role removes the rule
$ kubectl patch clusterrole view --type=json -p=[{"op":"add","path":"/rules/-","value":{"apiGroups":[""],"resources":["nodes"],"verbs":["get"]}}]
clusterrole.rbac.authorization.k8s.io/view patched
[exit 0]
$ kubectl get clusterrole view -o json | jq '[.rules[] | select(.resources | index("nodes"))] | length'
0
$ kubectl delete clusterrole lab-secrets-into-view
clusterrole.rbac.authorization.k8s.io "lab-secrets-into-view" deleted
[exit 0]
$ kubectl auth can-i --as=system:serviceaccount:team-a:viewer get secrets -n team-a
no
[exit 1]

The JSON patch reported "patched", but three seconds later (the lab script sleeps that long) view contained zero rules mentioning nodes, because the controller rewrote the rules field. The docs say the control plane overwrites values set manually in the rules field of an aggregate ClusterRole, so changes belong in the ClusterRoles the selector picks up. They also advise omitting the rules field from YAML files for aggregated ClusterRoles, since setting it, even to an empty list, claims ownership of the field under server-side apply. Deleting the labeled ClusterRole reverses the grant, and viewer is back to no.

Putting it together: an exam-style audit

An exam task may ask who can bypass the escalation checks. The lab's query lists every Role and ClusterRole with a rule containing escalate, bind, impersonate or the wildcard verb:

kubectl get roles,clusterroles -A -o json | jq -r '.items[] | select(any(.rules[]?; (.verbs // []) | any(. == "escalate" or . == "bind" or . == "impersonate" or . == "*"))) | "\(.kind)\t\(.metadata.namespace // "-")\t\(.metadata.name)"' | sort

On the lab cluster, after the earlier steps, it printed:

ClusterRole	-	admin
ClusterRole	-	cluster-admin
ClusterRole	-	edit
ClusterRole	-	k3s-cloud-controller-manager
ClusterRole	-	local-path-provisioner-role
ClusterRole	-	system:aggregate-to-edit
ClusterRole	-	system:controller:clusterrole-aggregation-controller
ClusterRole	-	system:kubelet-api-admin
Role	team-a	bind-view-only
Role	team-a	escalate-roles

The output is a list of leads, not a verdict, because the query does not say which verb matched. For each hit, open the role with kubectl get clusterrole NAME -o yaml, find the matching verb, and then find who is bound to it. The built-in names (admin, cluster-admin, edit, the aggregate-to-edit role and the aggregation controller's own role) are expected here. The two k3s entries and the kubelet-api-admin role are other hits on this cluster, so confirm they are supposed to be there. The last two lines are the lab's own grants, which should be deleted when the exercise is over. The Good Practices page asks for a periodic review of RBAC settings "for redundant entries and possible privilege escalations". Also avoid adding users to the system:masters group, whose members bypass all RBAC rights checks.

To answer "let user X grant view in namespace Y and nothing more", use the role-grantor pattern. Create a ClusterRole with create on rolebindings plus bind on clusterroles with resourceNames: ["view"], then bind it to X with a RoleBinding in namespace Y. The lab's bind-view-only Role is the same shape, and it was applied by a credential allowed to do so. Watch impersonate as well: it lets a user act as another user and gain their rights, so grant it with care. The kubectl forms the docs show are quick to type under exam time pressure:

kubectl create role pod-reader --verb=get --verb=list --verb=watch --resource=pods
kubectl create clusterrole monitoring --aggregation-rule="rbac.example.com/aggregate-to-monitoring=true"
kubectl create rolebinding bob-admin-binding --clusterrole=admin --user=bob --namespace=acme

Exam lens

This lesson maps to the CKS domain "Cluster Hardening" (15% of the exam), competency "Use Role Based Access Controls to minimize exposure". The exam is online, proctored and performance-based: you solve tasks from a command line within 2 hours, on a cluster based on Kubernetes v1.35. The lab used k3s v1.34.12+k3s1 with kubectl v1.33.9, so confirm any exact wording of an error message against the cluster in front of you. Traps to avoid:

  1. Having escalate or bind does not give you the permissions themselves. In step 5 the Role was created and can-i get secrets still said no.
  2. A bind rule without resourceNames covers every ClusterRole. Name the roles you intend, such as view, to keep the grant narrow.
  3. Binding edit is not a small step up from view. It includes access to Secrets and the ability to run Pods as any ServiceAccount in the namespace.
  4. A label on a ClusterRole can grant access. The aggregate-to-view, aggregate-to-edit and aggregate-to-admin labels change what existing bindings give.
  5. Edits to the rules of an aggregated ClusterRole are overwritten. Add rules in a selected ClusterRole instead, and omit the rules field from your own YAML files.
  6. RBAC has no deny rules, and roleRef cannot be changed after a binding is created. To remove access you remove the grant, and update permission on a binding only lets someone manage its subjects.

Check yourself

Question 1
A ServiceAccount can create Roles in team-a and holds only get and list on pods. It applies a Role granting get and list on secrets. What happens? A) The Role is created and works immediately B) The API server refuses it because the Role grants permissions the account does not hold C) The Role is created but stays inactive D) The Role is created only if the RBAC authorizer is not in use
tap to reveal
Answer: B. Without escalate on roles, a Role is accepted only if the writer already holds every permission in it, and the refusal names the missing rule.
Question 2
Which grant lets an account write Roles that contain permissions it does not hold? A) bind on roles B) impersonate on serviceaccounts C) escalate on roles in the rbac.authorization.k8s.io group D) create on rolebindings
tap to reveal
Answer: C. The escalate verb on the roles or clusterroles resource switches off the write-time check. Bind applies to creating bindings, not to writing roles.
Question 3
After a ServiceAccount is granted escalate on roles and writes a Role granting get on secrets, what does kubectl auth can-i get secrets say for that ServiceAccount? A) yes, because it wrote the Role B) yes, because escalate implies the permissions C) no, because writing a Role does not grant it to the writer D) it returns an error
tap to reveal
Answer: C. Step 5 shows the Role created and the answer still no, since nobody bound that Role to the account.
Question 4
User-1 must be able to grant admin, edit and view in one namespace and nothing more. Which setup matches the documented pattern? A) A ClusterRole with rolebindings create and clusterroles bind with resourceNames admin, edit and view, bound to user-1 by a RoleBinding in that namespace B) The cluster-admin ClusterRole bound to user-1 C) escalate on clusterroles with no resourceNames D) Membership of the system:masters group
tap to reveal
Answer: A. That is the role-grantor pattern. Omitting resourceNames would allow binding any ClusterRole, and the other options hand out far more.
Question 5
A ClusterRole carrying the label rbac.authorization.k8s.io/aggregate-to-view: "true" is created. What changes for existing bindings of view? A) Nothing until the bindings are recreated B) The controller adds the new rules into view, so existing bindings gain them C) The new ClusterRole is rejected unless view is edited first D) Only subjects in the same namespace as the ClusterRole are affected
tap to reveal
Answer: B. The aggregation controller merges the matching ClusterRole's rules into view, and bindings that reference view see them without any change.
Question 6
An administrator patches the view ClusterRole to add a rule. The command reports "patched", but the rule vanishes shortly afterwards. Why, and what is the fix? A) The patch needs --force; reapply it B) The control plane overwrites the rules of an aggregated ClusterRole, so the rule belongs in a ClusterRole selected by the aggregationRule C) The rule was rejected by the escalation check D) The patched object was a RoleBinding
tap to reveal
Answer: B. The controller rewrites the rules field of an aggregate ClusterRole. Add the rule to a separate ClusterRole with the matching label instead.

Sources