One Wildcard RBAC Probe Instead of N Authorizations
The OwnerReferencesPermissionEnforcement plugin asks one wildcard 'update */* finalizers' question; if the answer is yes it skips resolving and authorizing every ownerReference individually.
TL;DR: Setting
blockOwnerDeletion: trueon an ownerReference normally means the API server must resolve every owner through the RESTMapper — which may hit discovery — and run a separate authorization check on each resolved object. TheOwnerReferencesPermissionEnforcementadmission plugin first asks a single question: may this userupdatethefinalizerssubresource of*/*? If yes, the entire per-reference loop is skipped: no RESTMapper lookups, no discovery, no N authorizations. Only if that wildcard probe is denied does the plugin fall back to the expensive path.
Admission plugins sit in the hot path of every write to the API server. The naive implementation of “check that this user is allowed to do the dangerous thing” is a per-object authorization check, and per-object checks multiply: one ownerReference is one lookup, three ownerReferences are three lookups, and each lookup may involve a RESTMapper resolution that talks to the discovery client.
The Kubernetes admission plugins have a recurring pattern for avoiding that: prove the whole class of checks unnecessary with one cheap predicate, and only then do the expensive work. The ownerReference plugin does it with a wildcard RBAC probe. Several other plugins do it with a cache hit, a diff, or a scan of an existing list. All of them exist because the expensive path is genuinely expensive.
The plugin
OwnerReferencesPermissionEnforcement is a validating plugin that runs on
Create and Update:
// plugin/pkg/admission/gc/gc_admission.go:39
func Register(plugins *admission.Plugins) {
plugins.Register(PluginName, func(config io.Reader) (admission.Interface, error) {
// the pods/status endpoint is ignored by this plugin since old kubelets
// corrupt them. the pod status strategy ensures status updates cannot mutate
// ownerRef.
whiteList := []whiteListItem{
{
groupResource: schema.GroupResource{Resource: "pods"},
subresource: "status",
},
}
return &gcPermissionsEnforcement{
Handler: admission.NewHandler(admission.Create, admission.Update),
whiteList: whiteList,
}, nil
})
}Note the whitelist comment: the pods/status subresource is skipped because
old kubelets wrote corrupt ownerReferences into pod status, and the pod status
strategy guarantees a status update cannot mutate ownerReferences anyway. The
plugin trusts that strategy rather than re-checking.
The whole plugin is one function:
// plugin/pkg/admission/gc/gc_admission.go:89
func (a *gcPermissionsEnforcement) Validate(ctx context.Context, attributes admission.Attributes, o admission.ObjectInterfaces) (err error) {
// // if the request is in the whitelist, we skip mutation checks for this resource.
if a.isWhiteListed(attributes.GetResource().GroupResource(), attributes.GetSubresource()) {
return nil
}
// if we aren't changing owner references, then the edit is always allowed
if !isChangingOwnerReference(attributes.GetObject(), attributes.GetOldObject()) {
return nil
}Two cheap exits before any authorization at all: the whitelist, and
isChangingOwnerReference — which does a semantic deep-equal comparison of
the old and new ownerReference slices and returns false if they are
identical. Most updates to most objects are not ownerReference changes, so
most requests never touch the authorizer.
Then there is the delete check for non-create operations, because setting an
ownerReference at all grants the owner a claim on the dependent’s lifetime:
// plugin/pkg/admission/gc/gc_admission.go:116
decision, reason, err := a.authorizer.Authorize(ctx, deleteAttributes)
if decision != authorizer.DecisionAllow {
return admission.NewForbidden(attributes, fmt.Errorf("cannot set an ownerRef on a resource you can't delete: %v, %v", reason, err))
}
}That check is on the dependent object, not the owner — the
AttributesRecord asks for delete on the resource being written, in its
own namespace, with its own name. The owner check is where the N-fold
blowup happens.
The wildcard probe
The plugin then computes which ownerReferences newly carry
blockOwnerDeletion: true — that is, newly added ones, plus existing ones
whose flag flipped from nil/false to true:
// plugin/pkg/admission/gc/gc_admission.go:125
newBlockingRefs := newBlockingOwnerDeletionRefs(attributes.GetObject(), attributes.GetOldObject())
if len(newBlockingRefs) == 0 {
return nil
}If there are no newly-blocking references, the plugin is done — no owner checks whatsoever. Only when there are does it reach the interesting part:
// plugin/pkg/admission/gc/gc_admission.go:130
// There can be a case where a restMapper tries to hit discovery endpoints and times out if the network is inaccessible.
// This can prevent creating the pod to run the network to be able to do discovery and it appears as a timeout, not a rejection.
// Because the timeout is wrapper on admission/request, we can run a single check to see if the user can finalize any
// possible resource.
if decision, _, _ := a.authorizer.Authorize(ctx, finalizeAnythingRecord(attributes.GetUserInfo())); decision == authorizer.DecisionAllow {
return nil
}The comment states the motivation exactly. The RESTMapper resolution below can hit discovery endpoints, and if the network is inaccessible that produces a timeout, not a rejection — a much worse failure mode than a 403. The timeout wraps the whole admission request, so a slow discovery lookup inside admission is indistinguishable from a hung API server. By asking one authorization question first, the plugin either never touches discovery at all, or knows in advance that it has to.
The probe is a single synthetic attribute record:
// plugin/pkg/admission/gc/gc_admission.go:189
func finalizeAnythingRecord(userInfo user.Info) authorizer.AttributesRecord {
return authorizer.AttributesRecord{
User: userInfo,
Verb: "update",
APIGroup: "*",
APIVersion: "*",
Resource: "*",
Subresource: "finalizers",
Name: "*",
ResourceRequest: true,
Path: "",
}
}update on */* with subresource finalizers and name *. The RBAC
evaluator resolves this against the user’s rules: a rule with
resources: ["*"] matches immediately (rbacv1.ResourceAll), and a rule
with resources: ["*/finalizers"] also matches, because ResourceMatches
in pkg/apis/rbac/v1/evaluation_helpers.go:52 explicitly supports the
*/subresource form.
So a cluster administrator — someone with resources: ["*"] — answers the
probe “allow” and the plugin returns immediately, no matter how many owner
references the object has. A narrowly-scoped user answers “deny” and pays the
full cost. That is the correct trade-off: the probe is a sufficient condition
for skipping, not a necessary one, so it errs towards doing more work rather
than less.
The fallback: N lookups and N authorizations
If the probe is denied, the plugin walks each newly-blocking reference:
// plugin/pkg/admission/gc/gc_admission.go:138
for _, ref := range newBlockingRefs {
records, err := a.ownerRefToDeleteAttributeRecords(ref, attributes)
if err != nil {
return admission.NewForbidden(attributes, fmt.Errorf("cannot set blockOwnerDeletion in this case because cannot find RESTMapping for APIVersion %s Kind %s: %v", ref.APIVersion, ref.Kind, err))
}
// Multiple records are returned if ref.Kind could map to multiple
// resources. User needs to have delete permission on all the
// matched Resources.
for _, record := range records {
decision, reason, err := a.authorizer.Authorize(ctx, record)
if decision != authorizer.DecisionAllow {
return admission.NewForbidden(attributes, fmt.Errorf("cannot set blockOwnerDeletion if an ownerReference refers to a resource you can't set finalizers on: %v, %v", reason, err))
}
}
}Two things happen per reference. First, the RESTMapper resolves the
reference’s APIVersion/Kind — and an ownerReference only records the
kind, which may map to multiple resources (a Deployment and an
extensions/v1beta1 Deployment, or a CRD with several versions), so more
than one authorization record can come back:
// plugin/pkg/admission/gc/gc_admission.go:212
mappings, err := a.restMapper.RESTMappings(schema.GroupKind{Group: groupVersion.Group, Kind: ref.Kind}, groupVersion.Version)
if err != nil {
return ret, err
}
for _, mapping := range mappings {
ar := authorizer.AttributesRecord{
User: attributes.GetUserInfo(),
Verb: "update",
APIGroup: mapping.Resource.Group,
APIVersion: mapping.Resource.Version,
Resource: mapping.Resource.Resource,
Subresource: "finalizers",
Name: ref.Name,
ResourceRequest: true,
Path: "",
}
if mapping.Scope.Name() == meta.RESTScopeNameNamespace {
// if the owner is namespaced, it must be in the same namespace as the dependent is.
ar.Namespace = attributes.GetNamespace()
}
ret = append(ret, ar)
}Two details worth pausing on. The verb is update on the finalizers
subresource, not delete — blockOwnerDeletion means “the owner’s finalizer
blocks deletion of the dependent”, so the permission you need is the ability
to add a finalizer to the owner. And the namespace is only filled in when
the resolved owner is namespaced; a cluster-scoped owner gets an empty
namespace, because a cluster-scoped finalizer is a different (and broader)
permission than a namespaced one.
Note also the ordering: the RESTMapper resolution happens before the authorization for that reference. This is exactly the discovery-timeout hazard the wildcard probe exists to avoid.
The pattern, elsewhere in admission
The GC plugin is the most dramatic example, but the same “cheap sufficient condition” shape shows up across the admission chain.
RuntimeClass: a stale cache is not fatal, a missing object is. The plugin looks the RuntimeClass up in its informer cache, and only falls back to a live GET if the cache says NotFound:
// plugin/pkg/admission/runtimeclass/admission.go:153
runtimeClass, err = r.runtimeClassLister.Get(*pod.Spec.RuntimeClassName)
if apierrors.IsNotFound(err) {
// if not found, our informer cache could be lagging, do a live lookup
runtimeClass, err = r.runtimeClassClient.Get(ctx, *pod.Spec.RuntimeClassName, metav1.GetOptions{})
if apierrors.IsNotFound(err) {
return pod, nil, admission.NewForbidden(attributes, fmt.Errorf("pod rejected: RuntimeClass %q not found", *pod.Spec.RuntimeClassName))
}
}The asymmetry is deliberate. A missing RuntimeClass is fatal for the pod — the scheduler and kubelet need it — so it is worth a live read to be sure. A stale cache is not fatal: the pod’s overhead and scheduling are derived from the RuntimeClass definition, and a slightly old definition is not worth a blocking API round trip on every pod creation.
AlwaysPullImages: skip the plugin when nothing changed. This one does a diff of the old and new pod’s image sets:
// plugin/pkg/admission/alwayspullimages/admission.go:151
hasNewImage := false
pods.VisitContainersWithPath(&pod.Spec, field.NewPath("spec"), func(c *api.Container, _ *field.Path) bool {
if !oldImages.Has(c.Image) {
hasNewImage = true
}
return !hasNewImage
})
return !hasNewImageThe old pod’s image set is collected first into oldImages
(plugin/pkg/admission/alwayspullimages/admission.go:145); the new pod’s
images are then walked and the first one that is not in the old set flips
hasNewImage. The check is used to skip the whole plugin on an update
where no new image appeared. The visitor short-circuits as soon as it finds
a new image (return !hasNewImage), so the walk stops early in the common
case where the image did change.
StorageObjectInUseProtection: idempotent finalizers by scan. This plugin
adds kubernetes.io/pv-protection or kubernetes.io/pvc-protection to new
volumes. Rather than appending blindly, it scans the existing finalizer list
first, and returns without mutating if the finalizer is already there:
// plugin/pkg/admission/storage/storageobjectinuseprotection/admission.go:97
for _, f := range pv.Finalizers {
if f == volumeutil.PVProtectionFinalizer {
// Finalizer is already present, nothing to do
return nil
}
}
klog.V(4).Infof("adding PV protection finalizer to %s", pv.Name)
pv.Finalizers = append(pv.Finalizers, volumeutil.PVProtectionFinalizer)Duplicated finalizers are not fatal — the finalizer controller would clean them up — but each duplicate is a wasted entry in every object write.
ServiceAccount: ten attempts for default, one for everything else. The
ServiceAccount plugin hits the same cache-lag problem as RuntimeClass, but
solves it with retries rather than a live GET fallback:
// plugin/pkg/admission/serviceaccount/admission.go:295
numAttempts := 1
if name == DefaultServiceAccountName {
// If this is the default serviceaccount, attempt more times, since it should be auto-created by the controller
numAttempts = 10
}
retryInterval := time.Duration(rand.Int63n(100)+int64(100)) * time.Millisecond
for i := 0; i < numAttempts; i++ {
if i != 0 {
time.Sleep(retryInterval)
}
serviceAccount, err := s.client.CoreV1().ServiceAccounts(namespace).Get(context.TODO(), name, metav1.GetOptions{})
if err == nil {
return serviceAccount, nil
}
if !errors.IsNotFound(err) {
return nil, err
}
}The default service account gets 10 attempts with a randomised 100–200 ms
sleep between them (note the rand.Int63n(100)+100, so the interval is in
[100ms, 200ms) and never zero — no thundering herd). Any other named account
gets exactly one attempt. The reason is asymmetry: the default service
account is auto-created by the serviceaccount controller, so a NotFound is
almost certainly a race with that controller, and the account will exist in a
moment. A missing user-named account is a real error and should fail fast.
PodSecurity: convert lazily, evaluate one pod per controller. The PodSecurity plugin wraps the admission attributes in a lazily-converting shim, so the internal-to-external object conversion only happens if something actually asks for the object:
// plugin/pkg/admission/security/podsecurity/admission.go:247
func (l *lazyConvertingAttributes) GetObject() (runtime.Object, error) {
l.convertObjectOnce.Do(func() {
l.convertedObject, l.convertedObjectError = convert(l.Attributes.GetObject())
})
return l.convertedObject, l.convertedObjectError
}sync.Once per attributes instance means the conversion cost is paid at most
once, and not at all if the plugin’s early exits fire first.
The other half of the PodSecurity optimisation is deduplication. When a controller creates ten pods, the plugin evaluates one of them and treats the other nine as duplicates:
// staging/src/k8s.io/pod-security-admission/admission/admission.go:702
evaluatedControllers := make(map[types.UID]bool)
for _, pod := range pods {
// short-circuit on exempt runtimeclass
if a.exemptRuntimeClass(pod.Spec.RuntimeClassName) {
continue
}
// short-circuit if pod from the same controller is evaluated
podOwnerControllerRef := metav1.GetControllerOfNoCopy(pod)
if podOwnerControllerRef == nil {
prioritizedPods = append(prioritizedPods, pod)
continue
}
if evaluatedControllers[podOwnerControllerRef.UID] {
duplicateReplicatedPods = append(duplicateReplicatedPods, pod)
continue
}
prioritizedPods = append(prioritizedPods, pod)
evaluatedControllers[podOwnerControllerRef.UID] = true
}The key is the controller UID, not the controller name — a UID is unique
per object instance, so the deduplication is exact. The same file also skips
evaluation entirely for updates that change nothing the policy cares about:
isSignificantPodUpdate
(staging/src/k8s.io/pod-security-admission/admission/admission.go:632)
boils down to “did the image of any container change”, plus the container
counts — so a label-only update to a pod never re-runs the checks.
The trade-off
The wildcard probe is not free. It is one extra authorization call on the path where the user is not a superuser — for a narrowly-scoped user, the probe is a wasted call that is guaranteed to be denied before the real per-reference work begins. The Kubernetes authors judged that worth it, because the alternative is a RESTMapper resolution that can hit discovery and time out.
The probe also has a subtle consequence: it is evaluated against the RBAC
authorizer’s view of the world, so an admin who has resources: ["*"] on
everything gets a fast path that no ordinary user can get. In practice this
means the probe fires for cluster admins and for the system:masters
superuser group, and nobody else. The broader lesson is that admission
plugins are not just validators — they are latency budgets.
Wrapping up
The one thing to remember: the OwnerReferencesPermissionEnforcement
plugin asks one wildcard question — update on */* with subresource
finalizers — and if that is allowed it skips the entire per-ownerReference
loop, including all RESTMapper resolution and discovery. It is a sufficient
condition, not a necessary one, so it errs towards doing more work.
The part people get wrong: that the plugin only checks ownerReferences
whose blockOwnerDeletion newly became true. An ownerReference that was
already blocking, or that was added without blockOwnerDeletion, does not
trigger any owner-side authorization at all — the plugin only requires
delete permission on the dependent for non-create operations. And the
pods/status subresource is whitelisted outright, because old kubelets
corrupt ownerReferences there and the pod status strategy already guarantees
status updates cannot mutate them.
What to go do about it: if you are debugging a 403 mentioning
blockOwnerDeletion, check the RBAC rules for the owner’s finalizers
subresource — the required verb is update on pods/finalizers (or the
owner’s resource), not delete. If your cluster’s controllers are hitting
discovery timeouts on pod creation, that is the RESTMapper path in
ownerRefToDeleteAttributeRecords (plugin/pkg/admission/gc/gc_admission.go:212)
and it is a strong sign the requesting user lacks broad permissions; granting
update on */finalizers to that service account takes the fast path
instead. Read the audit log for the authorization.k8s.io decision to see
which path was taken.