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: true on 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. The OwnerReferencesPermissionEnforcement admission plugin first asks a single question: may this user update the finalizers subresource 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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 !hasNewImage

The 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:

go
// 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:

go
// 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:

go
// 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:

go
// 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.

KubernetesInternalsApiserver