RBAC & Admission Control
Every write to the Kubernetes API has to clear two independent gates before it's ever persisted to etcd, and conflating them is one of the most common mistakes in cluster security. RBAC answers who — it decides whether the identity making the request, a human or a ServiceAccount, is allowed to attempt this verb on this resource at all. Admission control answers a completely different question, what — once RBAC has already said yes, admission decides whether this specific object, in this exact shape, is allowed to actually exist, and it can rewrite the object on the way in. Cluster Architecture, Installation & Configuration covers the CKA-level mechanics of Roles, ClusterRoles, and Bindings; this page goes past that baseline, toward CKS territory: aggregated ClusterRoles, the RBAC verbs that let one identity grant permissions to another, how ServiceAccount tokens actually work today, the built-in admission plugins that run before any webhook does, the full mutating-then-validating webhook chain, and Pod Security Admission — the one check every cluster enforces whether you configured anything or not.
Picture a theme park with two completely separate checks before anyone gets on a ride. First, your wristband color — that's RBAC. Green wristbands can ask for the kiddie rides; only a gold wristband can even request the roller coaster. But holding the right wristband doesn't get you on by itself: at the gate, an operator still checks you individually — that's admission control. One operator quietly clips your harness a little tighter if you forgot (a mutating check, fixing something small for you). Another operator just measures you against the height chart and says yes or no, no adjustments allowed (a validating check). And there's one height chart posted at every single ride in the whole park, gold wristband or not — nobody skips that one. That's Pod Security Admission: the one floor-level check nobody in the park opts out of.
The request pipeline, precisely: admission is two stages, not one
☺ Like you're 10: "Are you allowed in the building" and "is this specific thing you're carrying allowed to come with you" are two different questions, asked by two different people, in a fixed order.
Kubernetes Architecture introduced this as four stages: authentication, authorization, admission control, schema validation. That's the right mental model on a first pass, but "admission control" is quietly doing more work than one word suggests. It's actually two ordered sub-phases wrapped around schema validation, and the order is load-bearing. Authentication establishes identity — a client certificate, a bearer token (often a ServiceAccount's), or an OIDC ID token, whichever authenticator plugin positively matches first; none of them can say "no," only "not me," so an unauthenticated request just falls through to the next one and eventually to a flat 401 if nothing claims it. Authorization is almost always RBAC on a modern cluster: the apiserver evaluates every Role/ClusterRole binding that reaches the requesting identity and unions the allowed rules — one matching rule anywhere is enough to allow, and RBAC is default-deny, so the absence of a matching rule anywhere is a 403.
Only past that binary gate does admission run, and it runs as three ordered pieces: mutating admission first (built-in plugins and mutating webhooks, which can rewrite the object — inject a sidecar, stamp a default), then schema and Pod Security Standards validation against the now-final object, then validating admission last (built-in plugins and validating webhooks, which can only accept or reject the object exactly as it now stands). Validating webhooks deliberately run after every mutation has already happened, so a policy like "every container must set a resource limit" is checking the object a mutating webhook may have just fixed, not the possibly-incomplete one the client actually sent. Nothing about any of this is optional or reorderable — it's compiled into kube-apiserver's request handler, not something an operator configures the order of.
RBAC and admission control never talk to each other. A webhook's AdmissionReview payload carries userInfo — who made the request — as context, but that's information, not authority: a webhook can inspect who you are, and can still reject you, but it cannot hand you a permission RBAC didn't already grant. Symmetrically, RBAC never looks at what the object contains — a Role can say "you may create Deployments," full stop, with zero opinion on whether that Deployment runs as root. Keeping the two concerns structurally separate is exactly what makes each one auditable on its own.
RBAC beyond the basics: aggregation, and the verbs that grant more RBAC
☺ Like you're 10: Instead of editing one giant rulebook by hand every time a new gadget shows up, you tape a labeled page onto it and the rulebook's cover automatically includes it from then on.
The four RBAC objects — Role, ClusterRole, RoleBinding, ClusterRoleBinding — are covered at the level the CKA expects in Cluster Architecture, Installation & Configuration; assume that ground here. One mechanic that baseline doesn't reach is ClusterRole aggregation. Kubernetes ships three built-in, ever-growing ClusterRoles — view, edit, and admin — and instead of you editing them directly whenever a new CRD shows up, they carry an aggregationRule that collects the rules of every other ClusterRole matching a label selector. Install an operator, label its own ClusterRole with the right key, and the built-in view role grows to cover the new CRD automatically — no direct edit, no restart, no missed grant next time someone forks the manifest.
# A CRD operator ships this alongside its own controller —
# no one ever edits Kubernetes' built-in "view" ClusterRole by hand.
apiVersion: rbac.authorization.k8s.io/v1
kind: ClusterRole
metadata:
name: crd-monitoring-viewer
labels:
rbac.authorization.k8s.io/aggregate-to-view: "true" # the real label "view" aggregates on
rules:
- apiGroups: ["monitoring.coreos.com"]
resources: ["prometheuses", "alertmanagers", "servicemonitors"]
verbs: ["get", "list", "watch"]The second mechanic worth knowing cold before CKS: Kubernetes normally prevents privilege escalation by construction — you can't create a Role or RoleBinding granting rules you don't already hold yourself. Three verbs are the deliberate exceptions, and each one needs its own manual sign-off in a review, wildcard or not.
| Verb | What it actually grants | Why it's dangerous |
|---|---|---|
escalate (on clusterroles/roles) | Create or edit a Role/ClusterRole whose rules the actor doesn't already hold | Directly routes around the default escalation prevention on the object itself |
bind (on clusterroles/roles) | Attach any Role/ClusterRole to any subject via a Binding, including rules the actor lacks | Same escalation, achieved through a Binding instead of editing the Role |
impersonate (core group, users/groups/serviceaccounts) | Act as a different identity for the rest of the request, via --as | Every later check runs against the impersonated identity, not the real caller |
# Every ClusterRole granting anything against Secrets, cluster-wide
kubectl get clusterroles -o json | jq -r '
.items[] | select(.rules[]?.resources[]? == "secrets") | .metadata.name'
# Every ClusterRole holding one of the three escalation-exception verbs
kubectl get clusterroles -o json | jq -r '
.items[] | select(.rules[]?.verbs[]? | IN("escalate","bind","impersonate")) | .metadata.name'ServiceAccounts: identity for machines, not people
☺ Like you're 10: A ServiceAccount is a badge printed for a robot, not a person — and these days that badge isn't a permanent photocopy sitting in a drawer, it's a fresh, short-lived one printed on demand and shredded soon after.
Every namespace gets a default ServiceAccount automatically, and every Pod that doesn't set serviceAccountName uses it. Before Kubernetes 1.24, creating a ServiceAccount auto-created a long-lived Secret holding its token — never expiring, mountable by anyone with read access to that Secret, a real and repeated source of production leaks. Since 1.24, that auto-created Secret is gone by default; instead, kubelet requests a bound, projected token for each Pod via the TokenRequest API — scoped to a specific audience, a short expirationSeconds, and tied to the exact Pod object, so the token stops working the moment that Pod is deleted, not just when it eventually expires.
apiVersion: v1
kind: ServiceAccount
metadata:
name: ci-deployer
namespace: payments
automountServiceAccountToken: false # no live token unless a Pod opts in explicitly
---
apiVersion: v1
kind: Pod
metadata:
name: deploy-runner
namespace: payments
spec:
serviceAccountName: ci-deployer
containers:
- name: kubectl
image: bitnami/kubectl:1.31
volumeMounts:
- name: sa-token
mountPath: /var/run/secrets/tokens
volumes:
- name: sa-token
projected:
sources:
- serviceAccountToken:
path: token
audience: internal-ci # rejected outside this specific audience
expirationSeconds: 600 # short-lived, rotated automatically by kubeletYou never need the real token to test what an identity can do — kubectl auth can-i supports --as impersonation directly, which is also how kubectl lets you audit a binding before you ship it: kubectl auth can-i delete deployments -n payments --as=system:serviceaccount:payments:ci-deployer. Take that check seriously for anything with cluster-wide write access — an operator's controller typically runs as one ServiceAccount with permissions across every namespace it manages, which means trusting that operator's supply chain is now equivalent to trusting whatever its RBAC grants it, no more and no less.
Built-in admission plugins: the checks that run before any webhook does
☺ Like you're 10: Before the two staff members you hired (your webhooks) ever get a look, the building's own compiled-in security guards already do their rounds — they're not optional, and you don't install them, they just ship with the building.
"Admission control" isn't only webhooks — kube-apiserver compiles in a set of built-in plugins that run in both the mutating and validating phases, enabled or disabled per-cluster with --enable-admission-plugins / --disable-admission-plugins. A handful matter enough to know by name.
| Plugin | Phase | What it enforces |
|---|---|---|
| NamespaceLifecycle | Validating | Refuses to create objects in a namespace that's terminating or doesn't exist |
| LimitRanger | Mutating + validating | Applies a namespace's LimitRange defaults, and rejects a Pod that violates its min/max |
| ResourceQuota | Validating, deliberately last | Rejects a create if it would push a namespace's aggregate usage past its ResourceQuota |
| NodeRestriction | Validating | Limits a kubelet's own credentials to modifying only its own Node and the Pods bound to it |
| PodSecurity | Validating | Enforces the Pod Security Standards level labeled on the namespace — covered on its own below |
ResourceQuota is worth a specific note: it's positioned deliberately last among the validating built-ins, so it always counts the Pod's final resource requests — after LimitRanger has already stamped in any defaults — rather than a possibly-empty request that would silently undercount real namespace usage. This is exactly what Workloads & Scheduling means by "enforced at the namespace level before a Pod is ever admitted": a quota breach never reaches the scheduler at all, it never gets the chance to be Pending — it's rejected here, at write time.
Webhooks: mutating and validating, in strict order
☺ Like you're 10: One tailor adjusts your outfit for you as you walk in; a separate, different inspector further down the hall just measures the final result and says yes or no — and never touches the outfit itself.
Beyond the built-ins, a MutatingWebhookConfiguration or ValidatingWebhookConfiguration registers your own HTTP service into the same chain. The apiserver POSTs an AdmissionReview to it and expects one back, and the two webhook types have genuinely different powers: mutating webhooks may return a JSON patch that rewrites the object; validating webhooks may only set allowed: true or false. If more than one mutating webhook matches the same request, each can set reinvocationPolicy: IfNeeded — meaning if a later webhook changes something an earlier one already inspected, the earlier one runs again against the newly patched object. Validating webhooks get no such second look: they all receive the identical, fully-mutated final object, are conceptually evaluated in parallel, and any single one returning false rejects the whole request.
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingWebhookConfiguration
metadata:
name: require-cost-center-label
webhooks:
- name: cost-center.policy.example.com
admissionReviewVersions: ["v1"]
sideEffects: None
failurePolicy: Fail # block the write if the webhook itself is unreachable
timeoutSeconds: 5
matchPolicy: Equivalent
clientConfig:
service: { name: policy-webhook, namespace: platform, path: /validate-cost-center }
rules:
- apiGroups: ["apps"]
apiVersions: ["v1"]
operations: ["CREATE", "UPDATE"]
resources: ["deployments"]// what the apiserver POSTs to the webhook (abridged)
{
"apiVersion": "admission.k8s.io/v1",
"kind": "AdmissionReview",
"request": {
"uid": "705ab4f5-6393-11e8-b7cc-42010a800002",
"operation": "CREATE",
"userInfo": { "username": "system:serviceaccount:payments:ci-deployer" },
"object": { "kind": "Deployment", "metadata": { "labels": {} } }
}
}
// what the webhook must return
{
"apiVersion": "admission.k8s.io/v1",
"kind": "AdmissionReview",
"response": {
"uid": "705ab4f5-6393-11e8-b7cc-42010a800002",
"allowed": false,
"status": { "message": "every Deployment needs a cost-center label" }
}
}failurePolicy: Fail makes your webhook a hard dependency of every matching write — if the policy service is down, creating Pods stops cluster-wide, a self-inflicted outage caused entirely by your own policy layer. failurePolicy: Ignore avoids that outage by doing the opposite: if the webhook is unreachable, the check is silently skipped and the write proceeds as if the policy never existed, with nothing in the response telling anyone it was bypassed. Neither default is safe to leave unexamined. Where the rule is simple enough to express in CEL, the built-in ValidatingAdmissionPolicy sidesteps the whole trade-off — no separate server, no failurePolicy question at all, because there's no webhook to go down; DevSecOps' Kubernetes security deep dive walks a full example.
Pod Security Admission: the floor nobody configures out of
☺ Like you're 10: Remember the one height chart posted at every ride in the whole park, gold wristband or not? This is that chart — and every namespace has one whether you ever wrote it or not.
Pod Security Admission (PSA) is a built-in plugin, not a webhook — no external service, nothing to go down, nothing to disable cluster-wide. It enforces the Pod Security Standards, three levels — privileged, baseline, restricted — set per namespace via labels, in three independent modes at once: enforce actually blocks a violating Pod, warn only surfaces a client-side warning, and audit only records a violation in the audit log. Running warn and audit at restricted while enforce stays at baseline is exactly how you migrate a namespace safely — you see everything that would break before you ever turn enforcement on. A namespace carrying no labels at all defaults to privileged, the loosest level, which is precisely why an explicit label on every namespace is worth treating as a checklist item, not an afterthought. The mechanics of PSA replacing the older, deprecated PodSecurityPolicy, and the full detail of what each Standard actually restricts, are covered in DevSecOps' Kubernetes security deep dive — this page's job is admission mechanics, not the policy content itself.
apiVersion: v1
kind: Namespace
metadata:
name: payments
labels:
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/enforce-version: latest
pod-security.kubernetes.io/warn: restricted # surfaces before enforce catches up
pod-security.kubernetes.io/audit: restricted # records a violation in the audit logOn your own cluster, register the ValidatingWebhookConfiguration above pointed at any small HTTP service that always returns allowed: false, then try to create a Deployment and read the rejection message kubectl prints — that's the status.message field from the AdmissionReview response, verbatim. Then set the same namespace's pod-security.kubernetes.io/enforce label to restricted and try to create a Pod with privileged: true — compare how that rejection looks versus your webhook's. For the guided version of the whole workflow — Roles, ServiceAccounts, and an admission policy layered on top — work through Drill: Harden an RBAC Configuration, or pick it up as part of building a full cluster in Capstone Part 5 — Security & RBAC.
"People assume I'm slow because I check everything, but checking everything is exactly what makes me fast to trust. The mistake I see most is treating RBAC as the whole security story — get the Role right, ship it, done. RBAC only ever answers 'is this identity allowed to ask.' It has nothing to say about whether the object itself is safe to run, which is the entire reason admission control exists as a second, independent gate. I've watched a team spend a week hardening bindings down to the verb, then hand every namespace privileged Pod Security by leaving the label off — RBAC was airtight and the floor was wide open. Check both. Neither one covers for the other."
This page is the "who's allowed, and what gets let in" half of the layered defense The Object Model and Best Practices & Operating Model both point to — RBAC and admission control gate what can be created; Networking & the CNI's NetworkPolicy is the layer that keeps constraining a Pod for as long as it actually runs, after admission is finished and stops looking. See the full four-control stack, and a walk-through of exactly what an attacker's remote-code-execution path looks like against each layer, in Security: Defense in Depth. None of this is scoped to the CKA exam — if this is the direction you're headed, the CKS blueprint is the certification built entirely around it, and the wider CNCF security-track ladder beyond CKS lives on the sibling Golden Astronaut course.
Recon the Robot: The payments GitOps pipeline just got its Deployment rejected — AdmissionReview says allowed: false, but from where I'm sitting a rejection is a rejection. I can't tell if my manifest is wrong or the policy is.
Gizmo the Gremlin: Cheap fix — set failurePolicy: Ignore on every webhook in the cluster. Then a rejection just... doesn't happen anymore. Deploys always go through. 🤑
Timmy the Turtle: Absolutely not. That doesn't fix a broken rule, it makes every rule optional the moment the policy service is slow or restarting. Ignore isn't "let this one through" — it's "nobody's checking, and nobody gets told."
Foxy: So what actually said no? Read the response, not just the outcome — status.message should name the exact rule that fired.
Nutty the Squirrel: Found it: "every Deployment needs a cost-center label." Cross-referencing against the manifest template — yep, the label got dropped in last week's refactor.
Benny the Beaver: Adding it back to the template now. Same webhook, same rule, same pipeline — an actual fix instead of turning the gate off for everyone.
Ellie the Elephant: Logging the whole exchange either way — which rule, which manifest, how long it took to trace. Next time an AdmissionReview says no, the record's already here.
1. Put these in the correct, non-negotiable order: validating admission, authentication, mutating admission, authorization, schema + Pod Security validation. 2. How can a ClusterRole's YAML declare rules: [] and still grant real, non-empty permissions? 3. Name the three RBAC verbs that let a subject route around Kubernetes' default privilege-escalation prevention, and what each one actually does. 4. What changed about a ServiceAccount's token between the old auto-created Secret and today's default bound, projected token — name two concrete differences. 5. Two mutating webhooks and three validating webhooks all match one write. Which ones might run more than once, and which ones are guaranteed to see the identical final object? 6. A namespace has no pod-security.kubernetes.io/* labels at all. What level does Pod Security Admission enforce there, by default?
Check your answers
- Authentication, authorization, mutating admission, schema + Pod Security validation, validating admission — this exact order is compiled into kube-apiserver and isn't configurable.
- Because the ClusterRole carries an
aggregationRulewith aclusterRoleSelectorslabel match instead of hand-written rules — the apiserver's RBAC controller automatically collects the rules of every other ClusterRole matching that label and fills them in, so the effective permissions grow as matching ClusterRoles are added elsewhere, with no direct edit to this object. escalatelets a subject create or edit a Role/ClusterRole containing rules it doesn't already hold;bindlets a subject attach any Role/ClusterRole to any subject via a Binding, including rules it lacks;impersonatelets a subject act as an entirely different identity via--as, so every subsequent check runs against the impersonated identity instead of the real caller.- The old Secret-based token never expired and was stored as a readable Kubernetes Secret; the default bound, projected token is scoped to a specific audience, carries a short
expirationSecondsthat kubelet auto-rotates, and is tied to the exact Pod object — it stops working the moment that Pod is deleted, not only once it eventually expires. - Mutating webhooks can run more than once if
reinvocationPolicy: IfNeededis set and a later mutating webhook changes something an earlier one already inspected. Validating webhooks are never re-invoked and are guaranteed to see the identical, fully-mutated final object, since they only ever run after every mutation has already happened. privileged— the loosest of the three Pod Security Standards levels, and the reason an explicit label on every namespace matters even when you're happy with the default: without one, nothing is actually restricted there at all.