Python and the Kubernetes Anonymous Binding Census — the API that still answers
The client lists ClusterRoleBindings. system:anonymous still names cluster-admin. Your loop is not a delete.
<!-- hal:authoritative:yaml -->
The client lists ClusterRoleBindings. system:anonymous still names cluster-admin. Your loop is not a delete.
§I — Frame
The day's Ops lesson closes three AKS doors and names the leftover those doors do not cancel. Authorized IP ranges refuse a source address. Disable-local-accounts refuses a kubeconfig user who is not in Entra. Azure RBAC adds an ARM authorizer. None of those reads delete a ClusterRoleBinding that grants cluster-admin to system:anonymous. Q04 LabSetUp creates that object on purpose. A human can create it on AKS. The API still answers.
This slot is Python touching K8s. Cert is CKS Cluster Hardening. Dev stays on the client. 08-10 already watched events. 08-16 already predicted a scheduler no and refused to bind. 08-19 already read encryptionConfig and could not see etcd. 08-22 already listed PersistentVolumes and refused to patch claimRef. 08-13 already mutated a RuntimeClass with kopf. 08-04 already issued SelfSubjectAccessReview. None of those tools answer the question Ops just asked: which ClusterRoleBindings still hand the world a verb, and which of those subjects are anonymous?
Call the failure the census that must not delete. A first draft will list the leftover binding and call delete_cluster_role_binding so the next kubectl from nowhere fails. That is Q04 Step 2. It is not a census. LabSetUp plants one binding so a candidate can practice the delete. A cluster-wide loop that does the same thing deletes the binding a human is still paging about, deletes the binding a controller is about to recreate, and deletes the binding you needed as evidence. MariaDB on the wrong disk was 08-22. Today the wrong delete is a missing audit row.
Today's tool does one read. RbacAuthorizationV1Api.list_cluster_role_binding for roleRef.name and subjects. It prints rows whose subject is system:anonymous or system:unauthenticated. It flags cluster-admin bound to system:authenticated. It does not delete. It does not create. It does not impersonate. It does not call az aks show. The Azure doors are a different client.
§II — Language idiom: the generated RBAC object
K8sUR3 Ch. 13 is blunt about how you talk to this API. The OpenAPI spec is the source. The generated client libraries are how a language consumes it (p. 216). Python's kubernetes package is that generated client. 08-22 used CoreV1Api because PersistentVolume is core v1. ClusterRoleBinding is not. It lives on rbac.authorization.k8s.io/v1. The client is RbacAuthorizationV1Api. There is no CoreV1Api.list_cluster_role_binding. Do not mix the two clients and then wonder why the method is missing.
Load config the way 08-04 already taught. In-cluster uses the projected token. Out-of-cluster uses kubeconfig. Today's tool does not care which, so long as the ServiceAccount can get and list clusterrolebindings. A delete verb is a bug. A bind verb is a bug. The Role that owns this tool lists one resource and two verbs.
from kubernetes import client, config
def rbac() -> client.RbacAuthorizationV1Api:
try:
config.load_incluster_config()
except config.ConfigException:
config.load_kube_config()
return client.RbacAuthorizationV1Api()
The list call returns V1ClusterRoleBindingList. Each item is a V1ClusterRoleBinding. role_ref is a V1RoleRef. subjects is a list of V1Subject or None. Prefer the attributes. They fail loud when the generated class changes. A dict silently returns None for a typo.
Poulton counts four RBAC objects (Ch. 13, p. 182). Role and RoleBinding are namespaced. ClusterRole and ClusterRoleBinding are cluster-scoped. Today's leftover is cluster-scoped by definition. An anonymous cluster-admin in one namespace would still be a finding, and it would be a RoleBinding, and it would not match Q04. Do not scan RoleBindings in this tool. Scope is a feature. A second tool can walk namespaces later. This fire is the cluster door.
system:anonymous is a user. system:unauthenticated is a group. They are not the same subject. A binding can name either. Q04 LabSetUp names the user. A "temporary" debug binding often names the group. The census matches both.
system:authenticated is everyone who passed Door 2. Binding cluster-admin to that group is a quieter disaster than anonymous. The caller had a cert. The cert was enough for the world. Include it.
from dataclasses import dataclass
from typing import Optional
DANGER_SUBJECTS = frozenset({"system:anonymous", "system:unauthenticated"})
BROAD_GROUPS = frozenset({"system:authenticated"})
@dataclass(frozen=True)
class Leftover:
binding: str
role: str
kind: str
name: str
namespace: Optional[str]
why: str
why is a short tag, not a paragraph. anonymous_admin, unauthenticated_any, authenticated_admin. The pager reads the tag. The ticket holds the YAML.
Pagination is not optional on a year-old cluster. list_cluster_role_binding takes _continue. 08-22 already used the same pattern on PersistentVolumes. Reuse it. A tool that prints the first page and exits zero is a tool that missed the binding on page two.
def iter_bindings(api: client.RbacAuthorizationV1Api):
_continue = None
while True:
page = api.list_cluster_role_binding(limit=200, _continue=_continue)
for item in page.items:
yield item
_continue = page.metadata._continue if page.metadata else None
if not _continue:
return
The attribute is _continue on the Python object because continue is a keyword. The query parameter is continue. The generated client already did that mapping. Do not send continue= as a kwarg and then debug a TypeError.
§III — Code worked example: print the leftover, keep the object
Classify each binding. One binding can produce more than one row if it names more than one dangerous subject. That is correct. Two subjects are two findings.
def leftovers(api: client.RbacAuthorizationV1Api) -> list[Leftover]:
rows: list[Leftover] = []
for b in iter_bindings(api):
role = b.role_ref.name if b.role_ref else ""
subjects = b.subjects or []
for s in subjects:
name = s.name or ""
kind = s.kind or ""
ns = s.namespace
if name in DANGER_SUBJECTS:
rows.append(Leftover(
binding=b.metadata.name,
role=role,
kind=kind,
name=name,
namespace=ns,
why="anonymous_admin" if role == "cluster-admin" else "unauthenticated_any",
))
elif name in BROAD_GROUPS and role == "cluster-admin":
rows.append(Leftover(
binding=b.metadata.name,
role=role,
kind=kind,
name=name,
namespace=ns,
why="authenticated_admin",
))
return rows
Print, then exit. Exit 0 if the list is empty. Exit 1 if a leftover exists. Exit 2 if the API refused the list. That is the 08-02 / 08-09 convention this track already uses. Do not exit 0 on a 403. A 403 means the tool could not run. An empty list means the tool ran and the cluster is clean on this census. Those are different Tuesdays.
from kubernetes.client.exceptions import ApiException
import sys
def main() -> int:
try:
api = rbac()
rows = leftovers(api)
except ApiException as exc:
if exc.status == 403:
print("list clusterrolebindings: 403", file=sys.stderr)
return 2
raise
for row in rows:
print(f"{row.why}\t{row.binding}\t{row.role}\t{row.kind}\t{row.name}")
return 1 if rows else 0
if __name__ == "__main__":
raise SystemExit(main())
A 404 on a named get is a finding. This tool does not get by name. It lists. If you later add a get for system:anonymous as a shortcut, treat 404 as clean for that one name and still run the list. LabSetUp uses that name. A human will use another name.
Do not add api.delete_cluster_role_binding(row.binding) under a flag. The flag will be passed. Q04 Verify.bash checks that the binding is gone. That is the exam's job. Production wants the object in the ticket. Snapshot kubectl get clusterrolebinding <name> -o yaml into the incident. Then a human deletes it. Then the next census is empty.
Q37 asks you to create a Role that lists pods and cannot list secrets, then bind it, then point a Pod at that ServiceAccount. That is a write. This client does not do it. kubectl auth can-i with --as is the verify. 08-04 already built the Python form of that question as SelfSubjectAccessReview. Do not reopen SSAR. The census is a list of objects, not a question asked as a subject.
Automount is Q03. CoreV1Api.list_namespaced_service_account for automount_service_account_token is not False is a second census on a second client. Name it. Do not ship it in this binary. Two doors, two tools. Today's door is the cluster binding.
Walk Q04 LabSetUp through the classifier so the tags are not abstract. LabSetUp applies one binding:
kubectl create clusterrolebinding system:anonymous \
--clusterrole=cluster-admin \
--user=system:anonymous
role_ref.name is cluster-admin. The single subject has kind=User and name=system:anonymous. The row is anonymous_admin system:anonymous cluster-admin User system:anonymous. Exit 1. Verify.bash later checks that this name is gone. Your tool checked that this name is present. Both can be right on the same cluster at different minutes. The exam minute deletes. The fleet minute reports.
A second leftover shows up on clusters that tried to "open the API for the ingress health check." Someone bound a custom ClusterRole with get on nodes to system:unauthenticated. role is not cluster-admin, so why is unauthenticated_any. Still a row. Still exit 1. The tag tells the on-call this is not the Q04 object and still a world-readable verb.
A third leftover is cluster-admin bound to Group system:authenticated. Every valid cert is now cluster-admin. why is authenticated_admin. This one often survives a Q04 pass because Verify.bash only deletes system:anonymous. The census exists to catch the sibling.
Do not add system:masters to DANGER_SUBJECTS without a comment in the ticket template. system:masters is the kubeadm break-glass group. It is supposed to exist. A row for it is noise that trains the on-call to ignore the printer. The dangerous pattern is a user-created binding that adds a human or a CI ServiceAccount to system:masters. That is a different classifier: scan subjects for kind=User or kind=ServiceAccount where role_ref.name is cluster-admin or the binding name is cluster-admin. Out of scope today. Write it down. Ship it next K8s-day Dev if the leftover is still live.
V1Subject.namespace is only meaningful for ServiceAccounts. A User named system:anonymous has namespace=None. Print it as a dash, not as the string "None". Downstream CSV users will join on that column.
metadata.name can equal the subject name. LabSetUp chose system:anonymous for both. A human will choose tmp-anon-debug. Match on subjects[].name, never on the binding name. A get-by-name shortcut that only looks for system:anonymous misses tmp-anon-debug.
The Azure fields Ops closed never appear on this API. authorizedIpRanges is ARM. disableLocalAccounts is ARM. enableAzureRbac is ARM. A clean census on a public AKS cluster with an empty allow-list is a clean RBAC picture in front of an API that still answers from the internet. Do not let a green exit 0 become a hardening report. Print a second line if you want honesty: rbac_census_only. The operator still has to run az aks show.
Type the Role that owns the tool before you ship the image. A ClusterRole with get and list on clusterrolebindings is enough. Add delete and the next on-call will pass --fix. Add * and you have rebuilt cluster-admin for the scanner. The binary has no delete method. The Role is the second copy of that decision. Poulton's deny-by-default sentence applies to the tool that measures deny-by-default (Ch. 13, p. 179). Bind the Role to a dedicated ServiceAccount. Do not reuse default. Q03 is why. Set automountServiceAccountToken: true on that ServiceAccount alone, and false on default in the same namespace. The scanner needs a token. The idle pods in that namespace do not. Project the token with a one-hour expiry the way 08-04 already taught. A year-old bound token in a Secret is Q03's other half, and it is not this binary.
§IV — Connection to today's Ops lesson
Ops coined the API that still answers. Three doors, then a leftover object kubectl can still see. This file is that leftover as a typed list.
The Python client cannot flip --anonymous-auth. It cannot see --authorization-mode. Poulton already told you the hosted apiserver is hidden (Ch. 13, p. 183). AKS is that host. The client sees the binding Q04 deletes. It does not see the flag Q04 edits. If the task is the flag, you are in the Cert file. Stay here for the object.
Ops printed a one-shot python3 -c against kubectl get -o json. That snippet is a desk check. This lesson is the same read with pagination, exit codes, and a frozen dataclass. The desk check misses page two. The tool should not.
Ops also named --admin kubeconfigs on laptops. This client cannot shred those files. It can only list what the API still believes. A disabled local account fails the handshake before RBAC. The census will not mention that user. Absence from this list is not absence from disk.
§V — Prior-lesson reach
08-22 listed PersistentVolumes, printed claimRef, and refused to patch. Today's shape is the same shape on a different group. RbacAuthorizationV1Api instead of CoreV1Api. A subject instead of a claim. Delete instead of patch as the forbidden write. The reason is the same: LabSetUp is a one-object cleanup. A loop is a fleet event.
08-19 read encryptionConfig and could not see etcd. Today's analog: the client can see the binding and cannot see the apiserver flag. Two blinds. Two honest tools. Do not pretend a list of bindings proves --anonymous-auth=false.
08-16 predicted a scheduler no and did not bind. Today predicts nothing. It reports objects that already exist. A fit-checker that binds is a scheduler. A census that deletes is an authorizer. Neither is this process.
08-04 loaded in-cluster config, refreshed a projected token, and asked SelfSubjectAccessReview. That lesson answered "what can I do." This lesson answers "what did someone already grant the world." Impersonation is a different verb. Leave it in 08-04.
08-10 watched events with resourceVersion. Today's list is not a watch. Bindings change rarely. A watch that never reconnects looks like a clean cluster. A periodic list that exits is enough.
One more prior-art collision to refuse: 08-01's kopf operator applied NetworkPolicy objects. That was a write on a namespaced kind. Today's client has no create method in the file. If you reach for kopf because "operator" sounds like hardening, you are writing ClusterRoleBindings from a reconcile loop. That is how a leftover gets recreated after a human deleted it. Inventory is a timer that prints. It is not a handler that applies.
§VI — Close
List ClusterRoleBindings. Print anonymous, unauthenticated, and cluster-admin on system:authenticated. Exit 1 when a row exists. Exit 2 on 403. Do not delete.
The AKS doors Ops closed are ARM. This client is the API. A green census on an open range is not hardening. A leftover row on a closed range is still the API that still answers.
Examine well. The object is the finding. The delete is a later Tuesday.
Related
- Prior arc: Python PV leftover checker (2026-08-22)
- Language hub: Cross-References/dev-languages/Python
- Grounding tome: K8sUR3 Ch.13, generated client libraries, p. 216