Exclude auth/authz virtual resources from admission webhooks

This commit is contained in:
Benjamin Elder 2026-07-13 23:51:26 -07:00
parent 26ae7380e6
commit 73ad530e91
14 changed files with 757 additions and 35 deletions

View file

@ -0,0 +1,87 @@
/*
Copyright The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package validation
import (
"fmt"
"slices"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/util/sets"
"k8s.io/apimachinery/pkg/util/validation/field"
"k8s.io/kubernetes/pkg/apis/admissionregistration"
)
// WarningsForValidatingWebhookRules returns warnings for rules in a ValidatingWebhookConfiguration
// that explicitly name a resource in excludedResources (virtual resources that admission webhooks
// do not intercept). The excluded set is supplied by the caller to avoid depending on server-side
// packages from here.
func WarningsForValidatingWebhookRules(webhooks []admissionregistration.ValidatingWebhook, excludedResources []schema.GroupResource) []string {
excluded := sets.New(excludedResources...)
webhooksPath := field.NewPath("webhooks")
var warnings []string
for i := range webhooks {
warnings = append(warnings, warningsForExcludedRules(webhooksPath.Index(i).Child("rules"), webhooks[i].Rules, excluded)...)
}
return warnings
}
// WarningsForMutatingWebhookRules returns warnings for rules in a MutatingWebhookConfiguration
// that explicitly name a resource in excludedResources.
func WarningsForMutatingWebhookRules(webhooks []admissionregistration.MutatingWebhook, excludedResources []schema.GroupResource) []string {
excluded := sets.New(excludedResources...)
webhooksPath := field.NewPath("webhooks")
var warnings []string
for i := range webhooks {
warnings = append(warnings, warningsForExcludedRules(webhooksPath.Index(i).Child("rules"), webhooks[i].Rules, excluded)...)
}
return warnings
}
func warningsForExcludedRules(rulesPath *field.Path, rules []admissionregistration.RuleWithOperations, excluded sets.Set[schema.GroupResource]) []string {
var warnings []string
for i := range rules {
for _, gr := range excludedResourcesNamedByRule(rules[i].APIGroups, rules[i].Resources, excluded) {
warnings = append(warnings, fmt.Sprintf("%s: %s is excluded from admission webhooks; this rule will have no effect", rulesPath.Index(i), gr))
}
}
return warnings
}
// excludedResourcesNamedByRule returns the excluded GroupResources a rule explicitly names.
// A rule that uses a wildcard ("*") in apiGroups or resources is not flagged because its intent
// toward the excluded resource is ambiguous. apiVersions are not considered because all versions
// of an excluded resource are excluded.
func excludedResourcesNamedByRule(apiGroups, resources []string, excluded sets.Set[schema.GroupResource]) []schema.GroupResource {
if slices.Contains(apiGroups, "*") || slices.Contains(resources, "*") {
return nil
}
// Dedupe with a set so a rule that repeats a group/resource is only reported once,
// preserving the order the rule lists them in.
seen := sets.New[schema.GroupResource]()
var matched []schema.GroupResource
for _, group := range apiGroups {
for _, resource := range resources {
gr := schema.GroupResource{Group: group, Resource: resource}
if excluded.Has(gr) && !seen.Has(gr) {
seen.Insert(gr)
matched = append(matched, gr)
}
}
}
return matched
}

View file

@ -0,0 +1,110 @@
/*
Copyright The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package validation
import (
"reflect"
"testing"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/kubernetes/pkg/apis/admissionregistration"
)
// testExcludedResources is a representative excluded set; the production set is supplied by the
// caller (see pkg/kubeapiserver/admission/exclusion), so these helpers don't depend on it.
var testExcludedResources = []schema.GroupResource{
{Group: "authentication.k8s.io", Resource: "tokenreviews"},
{Group: "authorization.k8s.io", Resource: "subjectaccessreviews"},
}
func rule(groups, versions, resources []string) admissionregistration.RuleWithOperations {
return admissionregistration.RuleWithOperations{
Rule: admissionregistration.Rule{
APIGroups: groups,
APIVersions: versions,
Resources: resources,
},
}
}
func TestWarningsForWebhookRules(t *testing.T) {
tests := []struct {
name string
rules []admissionregistration.RuleWithOperations
wantWarn []string
}{
{
name: "no warnings for normal resources",
rules: []admissionregistration.RuleWithOperations{rule([]string{""}, []string{"v1"}, []string{"pods", "configmaps"})},
wantWarn: nil,
},
{
name: "warning for explicit excluded resource",
rules: []admissionregistration.RuleWithOperations{rule([]string{"authentication.k8s.io"}, []string{"v1"}, []string{"tokenreviews"})},
wantWarn: []string{
`webhooks[0].rules[0]: tokenreviews.authentication.k8s.io is excluded from admission webhooks; this rule will have no effect`,
},
},
{
name: "warning only for the excluded resource in a mixed rule",
rules: []admissionregistration.RuleWithOperations{rule([]string{"authorization.k8s.io"}, []string{"v1"}, []string{"subjectaccessreviews", "somethingelse"})},
wantWarn: []string{
`webhooks[0].rules[0]: subjectaccessreviews.authorization.k8s.io is excluded from admission webhooks; this rule will have no effect`,
},
},
{
name: "no warning for all wildcards",
rules: []admissionregistration.RuleWithOperations{rule([]string{"*"}, []string{"*"}, []string{"*"})},
wantWarn: nil,
},
{
name: "no warning for wildcard apiGroups",
rules: []admissionregistration.RuleWithOperations{rule([]string{"*"}, []string{"v1"}, []string{"tokenreviews"})},
wantWarn: nil,
},
{
name: "warning for wildcard apiVersions naming an excluded resource",
rules: []admissionregistration.RuleWithOperations{rule([]string{"authentication.k8s.io"}, []string{"*"}, []string{"tokenreviews"})},
wantWarn: []string{
`webhooks[0].rules[0]: tokenreviews.authentication.k8s.io is excluded from admission webhooks; this rule will have no effect`,
},
},
{
name: "no warning for wildcard resources",
rules: []admissionregistration.RuleWithOperations{rule([]string{"authentication.k8s.io"}, []string{"v1"}, []string{"*"})},
wantWarn: nil,
},
{
name: "no warning for excluded resource in wrong group",
rules: []admissionregistration.RuleWithOperations{rule([]string{"example.com"}, []string{"v1"}, []string{"tokenreviews"})},
wantWarn: nil,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
validating := []admissionregistration.ValidatingWebhook{{Name: "w", Rules: tc.rules}}
if got := WarningsForValidatingWebhookRules(validating, testExcludedResources); !reflect.DeepEqual(got, tc.wantWarn) {
t.Errorf("WarningsForValidatingWebhookRules() = %v, want %v", got, tc.wantWarn)
}
mutating := []admissionregistration.MutatingWebhook{{Name: "w", Rules: tc.rules}}
if got := WarningsForMutatingWebhookRules(mutating, testExcludedResources); !reflect.DeepEqual(got, tc.wantWarn) {
t.Errorf("WarningsForMutatingWebhookRules() = %v, want %v", got, tc.wantWarn)
}
})
}
}

View file

@ -29,6 +29,7 @@ import (
"k8s.io/kubernetes/pkg/api/legacyscheme"
"k8s.io/kubernetes/pkg/apis/admissionregistration"
"k8s.io/kubernetes/pkg/apis/admissionregistration/validation"
"k8s.io/kubernetes/pkg/kubeapiserver/admission/exclusion"
)
// mutatingWebhookConfigurationStrategy implements verification logic for mutatingWebhookConfiguration.
@ -54,10 +55,14 @@ func (mutatingWebhookConfigurationStrategy) PrepareForCreate(ctx context.Context
// WarningsOnCreate returns warnings for the creation of the given object.
func (mutatingWebhookConfigurationStrategy) WarningsOnCreate(ctx context.Context, obj runtime.Object) []string {
ic := obj.(*admissionregistration.MutatingWebhookConfiguration)
var warnings []string
if !utilfeature.DefaultFeatureGate.Enabled(features.ManifestBasedAdmissionControlConfig) {
return validation.WarningsForStaticSuffix(ic.Name)
warnings = append(warnings, validation.WarningsForStaticSuffix(ic.Name)...)
}
return nil
if utilfeature.DefaultFeatureGate.Enabled(features.ExcludeAdmissionWebhookVirtualResources) {
warnings = append(warnings, validation.WarningsForMutatingWebhookRules(ic.Webhooks, exclusion.Excluded())...)
}
return warnings
}
// PrepareForUpdate clears fields that are not allowed to be set by end users on update.
@ -103,7 +108,12 @@ func (mutatingWebhookConfigurationStrategy) ValidateUpdate(ctx context.Context,
// WarningsOnUpdate returns warnings for the given update.
func (mutatingWebhookConfigurationStrategy) WarningsOnUpdate(ctx context.Context, obj, old runtime.Object) []string {
newIC := obj.(*admissionregistration.MutatingWebhookConfiguration)
return validation.WarningsForStaticSuffix(newIC.Name)
var warnings []string
warnings = append(warnings, validation.WarningsForStaticSuffix(newIC.Name)...)
if utilfeature.DefaultFeatureGate.Enabled(features.ExcludeAdmissionWebhookVirtualResources) {
warnings = append(warnings, validation.WarningsForMutatingWebhookRules(newIC.Webhooks, exclusion.Excluded())...)
}
return warnings
}
// AllowUnconditionalUpdate is the default update policy for mutatingWebhookConfiguration objects. Status update should

View file

@ -29,6 +29,7 @@ import (
"k8s.io/kubernetes/pkg/api/legacyscheme"
"k8s.io/kubernetes/pkg/apis/admissionregistration"
"k8s.io/kubernetes/pkg/apis/admissionregistration/validation"
"k8s.io/kubernetes/pkg/kubeapiserver/admission/exclusion"
)
// validatingWebhookConfigurationStrategy implements verification logic for validatingWebhookConfiguration.
@ -77,10 +78,14 @@ func (validatingWebhookConfigurationStrategy) Validate(ctx context.Context, obj
// WarningsOnCreate returns warnings for the creation of the given object.
func (validatingWebhookConfigurationStrategy) WarningsOnCreate(ctx context.Context, obj runtime.Object) []string {
ic := obj.(*admissionregistration.ValidatingWebhookConfiguration)
var warnings []string
if !utilfeature.DefaultFeatureGate.Enabled(features.ManifestBasedAdmissionControlConfig) {
return validation.WarningsForStaticSuffix(ic.Name)
warnings = append(warnings, validation.WarningsForStaticSuffix(ic.Name)...)
}
return nil
if utilfeature.DefaultFeatureGate.Enabled(features.ExcludeAdmissionWebhookVirtualResources) {
warnings = append(warnings, validation.WarningsForValidatingWebhookRules(ic.Webhooks, exclusion.Excluded())...)
}
return warnings
}
// Canonicalize normalizes the object after validation.
@ -103,7 +108,12 @@ func (validatingWebhookConfigurationStrategy) ValidateUpdate(ctx context.Context
// WarningsOnUpdate returns warnings for the given update.
func (validatingWebhookConfigurationStrategy) WarningsOnUpdate(ctx context.Context, obj, old runtime.Object) []string {
newIC := obj.(*admissionregistration.ValidatingWebhookConfiguration)
return validation.WarningsForStaticSuffix(newIC.Name)
var warnings []string
warnings = append(warnings, validation.WarningsForStaticSuffix(newIC.Name)...)
if utilfeature.DefaultFeatureGate.Enabled(features.ExcludeAdmissionWebhookVirtualResources) {
warnings = append(warnings, validation.WarningsForValidatingWebhookRules(newIC.Webhooks, exclusion.Excluded())...)
}
return warnings
}
// AllowUnconditionalUpdate is the default update policy for validatingWebhookConfiguration objects. Status update should

View file

@ -23,7 +23,9 @@ import (
v1 "k8s.io/api/admissionregistration/v1"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/runtime/schema"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
"k8s.io/apimachinery/pkg/util/sets"
"k8s.io/apiserver/pkg/admission/plugin/webhook"
"k8s.io/apiserver/pkg/admission/plugin/webhook/generic"
"k8s.io/client-go/informers"
@ -46,15 +48,17 @@ type mutatingWebhookConfigurationManager struct {
// This function is defined as field instead of a struct method to allow injection
// during tests
createMutatingWebhookAccessor mutatingWebhookAccessorCreator
excludedWebhookResources sets.Set[schema.GroupResource]
}
var _ generic.Source = &mutatingWebhookConfigurationManager{}
func NewMutatingWebhookConfigurationManager(f informers.SharedInformerFactory) generic.Source {
func NewMutatingWebhookConfigurationManager(f informers.SharedInformerFactory, excludedWebhookResources sets.Set[schema.GroupResource]) generic.Source {
informer := f.Admissionregistration().V1().MutatingWebhookConfigurations()
manager := &mutatingWebhookConfigurationManager{
lister: informer.Lister(),
createMutatingWebhookAccessor: webhook.NewMutatingWebhookAccessor,
excludedWebhookResources: excludedWebhookResources,
}
manager.lazy.Evaluate = manager.getConfiguration
@ -133,6 +137,8 @@ func (m *mutatingWebhookConfigurationManager) getMutatingWebhookConfigurations(c
continue
}
logExcludedResourcesForMutatingWebhook(c.Name, c.Webhooks, m.excludedWebhookResources)
// webhook names are not validated for uniqueness, so we check for duplicates and
// add a int suffix to distinguish between them
names := map[string]int{}

View file

@ -37,7 +37,7 @@ func testGetMutatingWebhookConfig(t *testing.T) {
stop := make(chan struct{})
defer close(stop)
manager := NewMutatingWebhookConfigurationManager(informerFactory).(*mutatingWebhookConfigurationManager)
manager := NewMutatingWebhookConfigurationManager(informerFactory, nil).(*mutatingWebhookConfigurationManager)
informerFactory.Start(stop)
informerFactory.WaitForCacheSync(stop)
@ -212,7 +212,7 @@ func TestGetMutatingWebhookConfigSmartReload(t *testing.T) {
informerFactory := informers.NewSharedInformerFactory(client, 0)
stop := make(chan struct{})
defer close(stop)
manager := NewMutatingWebhookConfigurationManager(informerFactory)
manager := NewMutatingWebhookConfigurationManager(informerFactory, nil)
managerStructPtr := manager.(*mutatingWebhookConfigurationManager)
fakeWebhookAccessorCreator := &mockCreateMutatingWebhookAccessor{}
managerStructPtr.createMutatingWebhookAccessor = fakeWebhookAccessorCreator.fn

View file

@ -23,7 +23,9 @@ import (
v1 "k8s.io/api/admissionregistration/v1"
"k8s.io/apimachinery/pkg/labels"
"k8s.io/apimachinery/pkg/runtime/schema"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
"k8s.io/apimachinery/pkg/util/sets"
"k8s.io/apiserver/pkg/admission/plugin/webhook"
"k8s.io/apiserver/pkg/admission/plugin/webhook/generic"
"k8s.io/client-go/informers"
@ -46,15 +48,17 @@ type validatingWebhookConfigurationManager struct {
// This function is defined as field instead of a struct method to allow injection
// during tests
createValidatingWebhookAccessor validatingWebhookAccessorCreator
excludedWebhookResources sets.Set[schema.GroupResource]
}
var _ generic.Source = &validatingWebhookConfigurationManager{}
func NewValidatingWebhookConfigurationManager(f informers.SharedInformerFactory) generic.Source {
func NewValidatingWebhookConfigurationManager(f informers.SharedInformerFactory, excludedWebhookResources sets.Set[schema.GroupResource]) generic.Source {
informer := f.Admissionregistration().V1().ValidatingWebhookConfigurations()
manager := &validatingWebhookConfigurationManager{
lister: informer.Lister(),
createValidatingWebhookAccessor: webhook.NewValidatingWebhookAccessor,
excludedWebhookResources: excludedWebhookResources,
}
manager.lazy.Evaluate = manager.getConfiguration
@ -130,6 +134,8 @@ func (v *validatingWebhookConfigurationManager) getValidatingWebhookConfiguratio
continue
}
logExcludedResourcesForValidatingWebhook(c.Name, c.Webhooks, v.excludedWebhookResources)
// webhook names are not validated for uniqueness, so we check for duplicates and
// add a int suffix to distinguish between them
names := map[string]int{}

View file

@ -37,7 +37,7 @@ func testGetValidatingWebhookConfig(t *testing.T) {
stop := make(chan struct{})
defer close(stop)
manager := NewValidatingWebhookConfigurationManager(informerFactory)
manager := NewValidatingWebhookConfigurationManager(informerFactory, nil)
informerFactory.Start(stop)
informerFactory.WaitForCacheSync(stop)
@ -211,7 +211,7 @@ func TestGetValidatingWebhookConfigSmartReload(t *testing.T) {
informerFactory := informers.NewSharedInformerFactory(client, 0)
stop := make(chan struct{})
defer close(stop)
manager := NewValidatingWebhookConfigurationManager(informerFactory)
manager := NewValidatingWebhookConfigurationManager(informerFactory, nil)
managerStructPtr := manager.(*validatingWebhookConfigurationManager)
fakeWebhookAccessorCreator := &mockCreateValidatingWebhookAccessor{}
managerStructPtr.createValidatingWebhookAccessor = fakeWebhookAccessorCreator.fn

View file

@ -0,0 +1,89 @@
/*
Copyright The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package configuration
import (
"slices"
v1 "k8s.io/api/admissionregistration/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/util/sets"
"k8s.io/klog/v2"
)
// logExcludedResourcesForValidatingWebhook logs an advisory for a ValidatingWebhookConfiguration whose rules name resources excluded from admission webhooks.
// It is a no-op when excludedWebhookResources is empty.
func logExcludedResourcesForValidatingWebhook(name string, webhooks []v1.ValidatingWebhook, excludedWebhookResources sets.Set[schema.GroupResource]) {
if excludedWebhookResources.Len() == 0 {
return
}
var rules []v1.RuleWithOperations
for _, w := range webhooks {
rules = append(rules, w.Rules...)
}
logExcludedWebhookResources("ValidatingWebhookConfiguration", name, rules, excludedWebhookResources)
}
// logExcludedResourcesForMutatingWebhook logs an advisory for a MutatingWebhookConfiguration whose rules name resources excluded from admission webhooks.
// It is a no-op when excludedWebhookResources is empty.
func logExcludedResourcesForMutatingWebhook(name string, webhooks []v1.MutatingWebhook, excludedWebhookResources sets.Set[schema.GroupResource]) {
if excludedWebhookResources.Len() == 0 {
return
}
var rules []v1.RuleWithOperations
for _, w := range webhooks {
rules = append(rules, w.Rules...)
}
logExcludedWebhookResources("MutatingWebhookConfiguration", name, rules, excludedWebhookResources)
}
func logExcludedWebhookResources(kind, name string, rules []v1.RuleWithOperations, excludedWebhookResources sets.Set[schema.GroupResource]) {
excluded := sets.New[schema.GroupResource]()
for _, r := range rules {
excluded.Insert(excludedResourcesNamedByRule(r.APIGroups, r.Resources, excludedWebhookResources)...)
}
if excluded.Len() == 0 {
return
}
resources := make([]string, 0, excluded.Len())
for gr := range excluded {
resources = append(resources, gr.String())
}
slices.Sort(resources)
klog.InfoS("Admission webhook configuration names resources that are excluded from admission webhooks; the matching rules will have no effect",
"kind", kind, "name", name, "excludedResources", resources)
}
// excludedResourcesNamedByRule returns the excluded GroupResources a rule explicitly names.
// A rule that uses a wildcard ("*") in apiGroups or resources is not flagged because its intent
// toward the excluded resource is ambiguous. apiVersions are not considered because all versions
// of an excluded resource are excluded.
func excludedResourcesNamedByRule(apiGroups, resources []string, excludedWebhookResources sets.Set[schema.GroupResource]) []schema.GroupResource {
if slices.Contains(apiGroups, "*") || slices.Contains(resources, "*") {
return nil
}
var matched []schema.GroupResource
for _, group := range apiGroups {
for _, resource := range resources {
gr := schema.GroupResource{Group: group, Resource: resource}
if excludedWebhookResources.Has(gr) {
matched = append(matched, gr)
}
}
}
return matched
}

View file

@ -0,0 +1,80 @@
/*
Copyright The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package configuration
import (
"reflect"
"testing"
v1 "k8s.io/api/admissionregistration/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/util/sets"
)
func TestExcludedResourcesNamedByRule(t *testing.T) {
tests := []struct {
name string
apiGroups []string
resources []string
want []schema.GroupResource
}{
{
name: "explicit excluded resource",
apiGroups: []string{"authentication.k8s.io"}, resources: []string{"tokenreviews"},
want: []schema.GroupResource{{Group: "authentication.k8s.io", Resource: "tokenreviews"}},
},
{
name: "non-excluded resource",
apiGroups: []string{""}, resources: []string{"pods"},
want: nil,
},
{
name: "wildcard group is not flagged",
apiGroups: []string{"*"}, resources: []string{"tokenreviews"},
want: nil,
},
{
name: "wildcard resource is not flagged",
apiGroups: []string{"authentication.k8s.io"}, resources: []string{"*"},
want: nil,
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
excluded := sets.New(
schema.GroupResource{Group: "authentication.k8s.io", Resource: "tokenreviews"},
schema.GroupResource{Group: "authorization.k8s.io", Resource: "subjectaccessreviews"},
)
if got := excludedResourcesNamedByRule(tc.apiGroups, tc.resources, excluded); !reflect.DeepEqual(got, tc.want) {
t.Errorf("excludedResourcesNamedByRule() = %v, want %v", got, tc.want)
}
})
}
}
// TestLogExcludedResourcesForWebhook is a smoke test ensuring the logging helpers run without
// panicking when no resources are excluded.
func TestLogExcludedResourcesForWebhook(t *testing.T) {
logExcludedResourcesForValidatingWebhook("test-config", []v1.ValidatingWebhook{{
Name: "test-webhook",
Rules: []v1.RuleWithOperations{{Rule: v1.Rule{APIGroups: []string{"authentication.k8s.io"}, APIVersions: []string{"v1"}, Resources: []string{"tokenreviews"}}}},
}}, nil)
logExcludedResourcesForMutatingWebhook("test-config", []v1.MutatingWebhook{{
Name: "test-mutating-webhook",
Rules: []v1.RuleWithOperations{{Rule: v1.Rule{APIGroups: []string{"authorization.k8s.io"}, APIVersions: []string{"v1"}, Resources: []string{"subjectaccessreviews"}}}},
}}, nil)
}

View file

@ -30,6 +30,7 @@ import (
v1 "k8s.io/api/admissionregistration/v1"
apierrors "k8s.io/apimachinery/pkg/api/errors"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/util/sets"
"k8s.io/apiserver/pkg/admission"
genericadmissioninit "k8s.io/apiserver/pkg/admission/initializer"
admissionmetrics "k8s.io/apiserver/pkg/admission/metrics"
@ -45,6 +46,7 @@ import (
"k8s.io/client-go/informers"
coreinformers "k8s.io/client-go/informers/core/v1"
clientset "k8s.io/client-go/kubernetes"
"k8s.io/component-base/featuregate"
)
// Webhook is an abstract admission plugin with all the infrastructure to define Admit or Validate on-top.
@ -52,6 +54,7 @@ type Webhook struct {
*admission.Handler
// Factories for creating webhook sources.
apiSourceInformers informers.SharedInformerFactory
apiSourceFactory sourceFactory
staticSourceFactory StaticSourceFactory
@ -80,16 +83,26 @@ type Webhook struct {
// Lifecycle.
stopCh <-chan struct{}
// excludedAdmissionResources are virtual resources (auth/authz reviews) that admission
// webhooks must not intercept when excludeVirtualResources is set. It is populated from
// the authoritative exclusion list via SetExcludedAdmissionResources.
excludedAdmissionResources sets.Set[schema.GroupResource]
// excludeVirtualResources caches whether the ExcludeAdmissionWebhookVirtualResources
// feature is enabled, set once via InspectFeatureGates to avoid a gate lookup per request.
excludeVirtualResources bool
}
var (
_ genericadmissioninit.WantsExternalKubeClientSet = &Webhook{}
_ genericadmissioninit.WantsDrainedNotification = &Webhook{}
_ genericadmissioninit.WantsAPIServerID = &Webhook{}
_ admission.Interface = &Webhook{}
_ genericadmissioninit.WantsExternalKubeClientSet = &Webhook{}
_ genericadmissioninit.WantsDrainedNotification = &Webhook{}
_ genericadmissioninit.WantsAPIServerID = &Webhook{}
_ genericadmissioninit.WantsExcludedAdmissionResources = &Webhook{}
_ genericadmissioninit.WantsFeatures = &Webhook{}
_ admission.Interface = &Webhook{}
)
type sourceFactory func(f informers.SharedInformerFactory) Source
type sourceFactory func(f informers.SharedInformerFactory, excludedWebhookResources sets.Set[schema.GroupResource]) Source
type dispatcherFactory func(cm *webhookutil.ClientManager) Dispatcher
// ReloadableSource extends Source with a method to run a reload loop
@ -132,14 +145,15 @@ func NewWebhook(handler *admission.Handler, configFile io.Reader, sourceFactory
cm.SetServiceResolver(webhookutil.NewDefaultServiceResolver())
return &Webhook{
Handler: handler,
apiSourceFactory: sourceFactory,
staticManifestsDir: cfg.StaticManifestsDir,
clientManager: &cm,
namespaceMatcher: &namespace.Matcher{},
objectMatcher: &object.Matcher{},
dispatcher: dispatcherFactory(&cm),
filterCompiler: cel.NewConditionCompiler(environment.MustBaseEnvSet(environment.DefaultCompatibilityVersion())),
Handler: handler,
apiSourceFactory: sourceFactory,
staticManifestsDir: cfg.StaticManifestsDir,
clientManager: &cm,
namespaceMatcher: &namespace.Matcher{},
objectMatcher: &object.Matcher{},
dispatcher: dispatcherFactory(&cm),
filterCompiler: cel.NewConditionCompiler(environment.MustBaseEnvSet(environment.DefaultCompatibilityVersion())),
excludedAdmissionResources: sets.New[schema.GroupResource](),
}, nil
}
@ -179,6 +193,18 @@ func (a *Webhook) SetDrainedNotification(stopCh <-chan struct{}) {
a.stopCh = stopCh
}
// SetExcludedAdmissionResources implements the WantsExcludedAdmissionResources interface.
func (a *Webhook) SetExcludedAdmissionResources(excludedResources []schema.GroupResource) {
a.excludedAdmissionResources.Insert(excludedResources...)
}
// InspectFeatureGates implements the WantsFeatures interface. It caches whether the
// ExcludeAdmissionWebhookVirtualResources feature is enabled so Dispatch avoids a gate
// lookup on every request.
func (a *Webhook) InspectFeatureGates(featureGates featuregate.FeatureGate) {
a.excludeVirtualResources = featureGates.Enabled(features.ExcludeAdmissionWebhookVirtualResources)
}
// SetExternalKubeClientSet implements the WantsExternalKubeInformerFactory interface.
// It sets external ClientSet for admission plugins that need it
func (a *Webhook) SetExternalKubeClientSet(client clientset.Interface) {
@ -191,8 +217,11 @@ func (a *Webhook) SetExternalKubeInformerFactory(f informers.SharedInformerFacto
a.namespaceMatcher.NamespaceLister = namespaceInformer.Lister()
a.namespaceInformer = namespaceInformer
// Create the API-based source (stored for later use in ValidateInitialization)
a.apiSource = a.apiSourceFactory(f)
// Stored for constructing apiSource in ValidateInitialization (after all initializers have
// run, so the excluded resource set is available). Reset apiSource so that re-setting the
// informer factory rebuilds it from the new factory.
a.apiSourceInformers = f
a.apiSource = nil
}
func (a *Webhook) SetUnconditionalAuthorizer(authorizer authorizer.UnconditionalAuthorizer) {
@ -200,12 +229,21 @@ func (a *Webhook) SetUnconditionalAuthorizer(authorizer authorizer.Unconditional
}
// ValidateInitialization implements the InitializationValidator interface.
// Static source creation happens here (after all initializers have run) because
// SetManifestLoaders may be called after SetExternalKubeInformerFactory.
// API and static source creation happens here (after all initializers have run) because
// initialization setter order is not guaranteed.
func (a *Webhook) ValidateInitialization() error {
// Construct apiSource if needed
if a.apiSource == nil {
return fmt.Errorf("kubernetes client is not properly setup")
if a.apiSourceInformers == nil {
return fmt.Errorf("kubernetes client is not properly setup")
}
if a.excludeVirtualResources {
a.apiSource = a.apiSourceFactory(a.apiSourceInformers, a.excludedAdmissionResources)
} else {
a.apiSource = a.apiSourceFactory(a.apiSourceInformers, nil)
}
}
if err := a.namespaceMatcher.Validate(); err != nil {
return fmt.Errorf("namespaceMatcher is not properly setup: %v", err)
}
@ -353,12 +391,31 @@ type attrWithResourceOverride struct {
func (a *attrWithResourceOverride) GetResource() schema.GroupVersionResource { return a.resource }
// isExcludedFromAllHooks returns true for non-persisted virtual resources (auth/authz reviews)
// that must not be intercepted by any webhook, static or REST-based. It returns false (no-op)
// when the ExcludeAdmissionWebhookVirtualResources feature is disabled.
func (a *Webhook) isExcludedFromAllHooks(gr schema.GroupResource) bool {
return a.excludeVirtualResources && a.excludedAdmissionResources.Has(gr)
}
// isExcludedFromAPIHooks returns true for admission configuration resources (webhook
// configurations and admission policies/bindings) that are excluded from API-based webhooks to
// prevent circular dependencies, but may still be evaluated by static (manifest-based) webhooks.
func (a *Webhook) isExcludedFromAPIHooks(attr admission.Attributes) bool {
return rules.IsExemptAdmissionConfigurationResource(attr)
}
// Dispatch is called by the downstream Validate or Admit methods.
func (a *Webhook) Dispatch(ctx context.Context, attr admission.Attributes, o admission.ObjectInterfaces) error {
if rules.IsExemptAdmissionConfigurationResource(attr) {
// Admission config resources are excluded from API-based webhooks to
// prevent circular dependencies. However, static (manifest-based) webhooks
// are safe to evaluate since they don't have self-referential concerns.
if a.isExcludedFromAllHooks(attr.GetResource().GroupResource()) {
// Virtual auth/authz resources are excluded from all webhooks (static and REST-based)
// so that admission cannot wedge a cluster out of its own auth path.
return nil
}
if a.isExcludedFromAPIHooks(attr) {
// Admission config resources are excluded from API-based webhooks to prevent circular
// dependencies. However, static (manifest-based) webhooks are safe to evaluate since
// they don't have self-referential concerns.
if a.staticSource != nil {
if !a.staticSource.HasSynced() {
return admission.NewForbidden(attr, fmt.Errorf("not yet ready to handle request"))

View file

@ -0,0 +1,128 @@
/*
Copyright The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package mutating
import (
"context"
"net/url"
"testing"
registrationv1 "k8s.io/api/admissionregistration/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/admission/initializer"
"k8s.io/apiserver/pkg/admission/plugin/webhook/testcerts"
webhooktesting "k8s.io/apiserver/pkg/admission/plugin/webhook/testing"
"k8s.io/apiserver/pkg/features"
"k8s.io/component-base/featuregate"
)
// TestExcludeVirtualResources verifies the MutatingAdmissionWebhook plugin implements the
// WantsExcludedAdmissionResources initializer and, when ExcludeAdmissionWebhookVirtualResources is
// enabled, skips dispatch for excluded resources (so a rejecting webhook is never called); when the
// gate is disabled it dispatches as before. The production excluded set is auth/authz virtual
// resources; here a core resource is marked excluded so the mechanism can be exercised with the
// fixtures the webhook test harness supports.
func TestExcludeVirtualResources(t *testing.T) {
testServer := webhooktesting.NewTestServer(t)
testServer.StartTLS()
defer testServer.Close()
serverURL, err := url.ParseRequestURI(testServer.URL)
if err != nil {
t.Fatal(err)
}
stopCh := make(chan struct{})
defer close(stopCh)
fail := registrationv1.Fail
none := registrationv1.SideEffectClassNone
path := "disallow"
webhooks := []registrationv1.MutatingWebhook{{
Name: "test-webhook.k8s.io",
Rules: []registrationv1.RuleWithOperations{{
Operations: []registrationv1.OperationType{registrationv1.OperationAll},
Rule: registrationv1.Rule{APIGroups: []string{"*"}, APIVersions: []string{"*"}, Resources: []string{"*/*"}},
}},
ClientConfig: registrationv1.WebhookClientConfig{
Service: &registrationv1.ServiceReference{Name: "webhook-test", Namespace: "default", Path: &path},
CABundle: testcerts.CACert,
},
FailurePolicy: &fail,
SideEffects: &none,
// Empty (non-nil) selectors match everything; nil would parse to "matches nothing".
NamespaceSelector: &metav1.LabelSelector{},
ObjectSelector: &metav1.LabelSelector{},
AdmissionReviewVersions: []string{"v1"},
}}
wh, err := NewMutatingWebhook(nil)
if err != nil {
t.Fatalf("failed to create mutating webhook: %v", err)
}
// WantsExcludedAdmissionResources wiring. NewAttribute issues a request for the core "pod"
// resource, so mark that excluded.
wants, ok := interface{}(wh).(initializer.WantsExcludedAdmissionResources)
if !ok {
t.Fatal("plugin does not implement WantsExcludedAdmissionResources")
}
wants.SetExcludedAdmissionResources([]schema.GroupResource{{Group: "", Resource: "pod"}})
wantsFeatures, ok := interface{}(wh).(initializer.WantsFeatures)
if !ok {
t.Fatal("plugin does not implement WantsFeatures")
}
client, informer := webhooktesting.NewFakeMutatingDataSource("default", webhooks, stopCh)
wh.SetAuthenticationInfoResolverWrapper(webhooktesting.Wrapper(webhooktesting.NewAuthenticationInfoResolver(new(int32))))
wh.SetServiceResolver(webhooktesting.NewServiceResolver(*serverURL))
wh.SetExternalKubeClientSet(client)
wh.SetExternalKubeInformerFactory(informer)
if err := wh.ValidateInitialization(); err != nil {
t.Fatalf("failed to validate initialization: %v", err)
}
informer.Start(stopCh)
informer.WaitForCacheSync(stopCh)
attr := webhooktesting.NewAttribute("default", nil, false)
o := webhooktesting.NewObjectInterfacesForTest()
// Gate enabled: the excluded resource is skipped, so the rejecting webhook is never called.
wantsFeatures.InspectFeatureGates(newExcludeGate(t, true))
if err := wh.Admit(context.TODO(), attr, o); err != nil {
t.Errorf("with gate enabled, expected the excluded resource to be skipped (nil), got: %v", err)
}
// Gate disabled: the webhook is dispatched and rejects the request.
wantsFeatures.InspectFeatureGates(newExcludeGate(t, false))
if err := wh.Admit(context.TODO(), attr, o); err == nil {
t.Error("with gate disabled, expected the webhook to reject the request, got nil")
}
}
func newExcludeGate(t *testing.T, enabled bool) featuregate.FeatureGate {
t.Helper()
gate := featuregate.NewFeatureGate()
if err := gate.Add(map[featuregate.Feature]featuregate.FeatureSpec{
features.ExcludeAdmissionWebhookVirtualResources: {Default: enabled, PreRelease: featuregate.Beta},
}); err != nil {
t.Fatalf("failed to build feature gate: %v", err)
}
return gate
}

View file

@ -0,0 +1,128 @@
/*
Copyright The Kubernetes Authors.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
package validating
import (
"context"
"net/url"
"testing"
registrationv1 "k8s.io/api/admissionregistration/v1"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apiserver/pkg/admission/initializer"
"k8s.io/apiserver/pkg/admission/plugin/webhook/testcerts"
webhooktesting "k8s.io/apiserver/pkg/admission/plugin/webhook/testing"
"k8s.io/apiserver/pkg/features"
"k8s.io/component-base/featuregate"
)
// TestExcludeVirtualResources verifies the ValidatingAdmissionWebhook plugin implements the
// WantsExcludedAdmissionResources initializer and, when ExcludeAdmissionWebhookVirtualResources is
// enabled, skips dispatch for excluded resources (so a rejecting webhook is never called); when the
// gate is disabled it dispatches as before. The production excluded set is auth/authz virtual
// resources; here a core resource is marked excluded so the mechanism can be exercised with the
// fixtures the webhook test harness supports.
func TestExcludeVirtualResources(t *testing.T) {
testServer := webhooktesting.NewTestServer(t)
testServer.StartTLS()
defer testServer.Close()
serverURL, err := url.ParseRequestURI(testServer.URL)
if err != nil {
t.Fatal(err)
}
stopCh := make(chan struct{})
defer close(stopCh)
fail := registrationv1.Fail
none := registrationv1.SideEffectClassNone
path := "disallow"
webhooks := []registrationv1.ValidatingWebhook{{
Name: "test-webhook.k8s.io",
Rules: []registrationv1.RuleWithOperations{{
Operations: []registrationv1.OperationType{registrationv1.OperationAll},
Rule: registrationv1.Rule{APIGroups: []string{"*"}, APIVersions: []string{"*"}, Resources: []string{"*/*"}},
}},
ClientConfig: registrationv1.WebhookClientConfig{
Service: &registrationv1.ServiceReference{Name: "webhook-test", Namespace: "default", Path: &path},
CABundle: testcerts.CACert,
},
FailurePolicy: &fail,
SideEffects: &none,
// Empty (non-nil) selectors match everything; nil would parse to "matches nothing".
NamespaceSelector: &metav1.LabelSelector{},
ObjectSelector: &metav1.LabelSelector{},
AdmissionReviewVersions: []string{"v1"},
}}
wh, err := NewValidatingAdmissionWebhook(nil)
if err != nil {
t.Fatalf("failed to create validating webhook: %v", err)
}
// WantsExcludedAdmissionResources wiring. NewAttribute issues a request for the core "pod"
// resource, so mark that excluded.
wants, ok := interface{}(wh).(initializer.WantsExcludedAdmissionResources)
if !ok {
t.Fatal("plugin does not implement WantsExcludedAdmissionResources")
}
wants.SetExcludedAdmissionResources([]schema.GroupResource{{Group: "", Resource: "pod"}})
wantsFeatures, ok := interface{}(wh).(initializer.WantsFeatures)
if !ok {
t.Fatal("plugin does not implement WantsFeatures")
}
client, informer := webhooktesting.NewFakeValidatingDataSource("default", webhooks, stopCh)
wh.SetAuthenticationInfoResolverWrapper(webhooktesting.Wrapper(webhooktesting.NewAuthenticationInfoResolver(new(int32))))
wh.SetServiceResolver(webhooktesting.NewServiceResolver(*serverURL))
wh.SetExternalKubeClientSet(client)
wh.SetExternalKubeInformerFactory(informer)
if err := wh.ValidateInitialization(); err != nil {
t.Fatalf("failed to validate initialization: %v", err)
}
informer.Start(stopCh)
informer.WaitForCacheSync(stopCh)
attr := webhooktesting.NewAttribute("default", nil, false)
o := webhooktesting.NewObjectInterfacesForTest()
// Gate enabled: the excluded resource is skipped, so the rejecting webhook is never called.
wantsFeatures.InspectFeatureGates(newExcludeGate(t, true))
if err := wh.Validate(context.TODO(), attr, o); err != nil {
t.Errorf("with gate enabled, expected the excluded resource to be skipped (nil), got: %v", err)
}
// Gate disabled: the webhook is dispatched and rejects the request.
wantsFeatures.InspectFeatureGates(newExcludeGate(t, false))
if err := wh.Validate(context.TODO(), attr, o); err == nil {
t.Error("with gate disabled, expected the webhook to reject the request, got nil")
}
}
func newExcludeGate(t *testing.T, enabled bool) featuregate.FeatureGate {
t.Helper()
gate := featuregate.NewFeatureGate()
if err := gate.Add(map[featuregate.Feature]featuregate.FeatureSpec{
features.ExcludeAdmissionWebhookVirtualResources: {Default: enabled, PreRelease: featuregate.Beta},
}); err != nil {
t.Fatalf("failed to build feature gate: %v", err)
}
return gate
}

View file

@ -63,6 +63,7 @@ import (
featuregatetesting "k8s.io/component-base/featuregate/testing"
kubeapiservertesting "k8s.io/kubernetes/cmd/kube-apiserver/app/testing"
apisv1beta1 "k8s.io/kubernetes/pkg/apis/admissionregistration/v1beta1"
"k8s.io/kubernetes/pkg/kubeapiserver/admission/exclusion"
"k8s.io/kubernetes/test/integration/etcd"
"k8s.io/kubernetes/test/integration/framework"
)
@ -161,6 +162,10 @@ var (
gvr("admissionregistration.k8s.io", "v1", "mutatingadmissionpolicybindings"): true,
}
// webhookExcludedResources are the virtual resources admission webhooks skip when
// ExcludeAdmissionWebhookVirtualResources is enabled (the same set as VAP/MAP).
webhookExcludedResources = sets.New(exclusion.Excluded()...)
parentResources = map[schema.GroupVersionResource]schema.GroupVersionResource{
gvr("extensions", "v1beta1", "replicationcontrollers/scale"): gvr("", "v1", "replicationcontrollers"),
}
@ -361,7 +366,13 @@ func (h *holder) verifyRequest(webhookOptions webhookOptions, request *admission
converted := webhookOptions.converted
// Check if current resource should be exempted from Admission processing
if admissionExemptResources[gvr(h.recordGVR.Group, h.recordGVR.Version, h.recordGVR.Resource)] {
gvrKey := gvr(h.recordGVR.Group, h.recordGVR.Version, h.recordGVR.Resource)
isExempt := admissionExemptResources[gvrKey]
if !isExempt && utilfeature.DefaultFeatureGate.Enabled(features.ExcludeAdmissionWebhookVirtualResources) &&
webhookExcludedResources.Has(gvrKey.GroupResource()) {
isExempt = true
}
if isExempt {
if request == nil {
return nil
}