diff --git a/staging/src/k8s.io/apiserver/pkg/authentication/request/headerrequest/requestheader_controller.go b/staging/src/k8s.io/apiserver/pkg/authentication/request/headerrequest/requestheader_controller.go new file mode 100644 index 00000000000..9ef4057e313 --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/authentication/request/headerrequest/requestheader_controller.go @@ -0,0 +1,319 @@ +/* +Copyright 2020 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 headerrequest + +import ( + "context" + "encoding/json" + "fmt" + "time" + + corev1 "k8s.io/api/core/v1" + "k8s.io/apimachinery/pkg/api/equality" + "k8s.io/apimachinery/pkg/api/errors" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + utilruntime "k8s.io/apimachinery/pkg/util/runtime" + "k8s.io/apimachinery/pkg/util/wait" + coreinformers "k8s.io/client-go/informers/core/v1" + "k8s.io/client-go/kubernetes" + corev1listers "k8s.io/client-go/listers/core/v1" + "k8s.io/client-go/tools/cache" + "k8s.io/client-go/util/workqueue" + "k8s.io/klog" + "sync/atomic" +) + +const ( + authenticationRoleName = "extension-apiserver-authentication-reader" +) + +type requestHeaderBundle struct { + UsernameHeaders []string + GroupHeaders []string + ExtraHeaderPrefixes []string + AllowedClientNames []string +} + +// RequestHeaderAuthRequestController a controller that exposes a set of methods for dynamically filling RequestHeaderConfig struct. +// The methods are sourced from the config map which is being monitored by this controller. +// The controller is primed from the server at the construction time for components that don't want to dynamically react to changes +// in the config map. +type RequestHeaderAuthRequestController struct { + name string + + configmapName string + configmapNamespace string + + configmapLister corev1listers.ConfigMapNamespaceLister + configmapInformerSynced cache.InformerSynced + + queue workqueue.RateLimitingInterface + + // exportedRequestHeaderBundle is a requestHeaderBundle that contains the last read, non-zero length content of the configmap + exportedRequestHeaderBundle atomic.Value + + usernameHeadersKey string + groupHeadersKey string + extraHeaderPrefixesKey string + allowedClientNamesKey string +} + +// NewRequestHeaderAuthRequestController creates a new controller that implements RequestHeaderAuthRequestController +func NewRequestHeaderAuthRequestController( + cmName string, + cmNamespace string, + client kubernetes.Interface, + cmInformer coreinformers.ConfigMapInformer, + usernameHeadersKey, groupHeadersKey, extraHeaderPrefixesKey, allowedClientNamesKey string) (*RequestHeaderAuthRequestController, error) { + c := &RequestHeaderAuthRequestController{ + name: "RequestHeaderAuthRequestController", + + configmapName: cmName, + configmapNamespace: cmNamespace, + + usernameHeadersKey: usernameHeadersKey, + groupHeadersKey: groupHeadersKey, + extraHeaderPrefixesKey: extraHeaderPrefixesKey, + allowedClientNamesKey: allowedClientNamesKey, + + queue: workqueue.NewNamedRateLimitingQueue(workqueue.DefaultControllerRateLimiter(), "RequestHeaderAuthRequestController"), + } + + // use the live client to prime the controller + if err := c.syncOnce(client); err != nil { + return nil, err + } + + cmInformer.Informer().AddEventHandler(cache.FilteringResourceEventHandler{ + FilterFunc: func(obj interface{}) bool { + if cast, ok := obj.(*corev1.ConfigMap); ok { + return cast.Name == c.configmapName && cast.Namespace == c.configmapNamespace + } + if tombstone, ok := obj.(cache.DeletedFinalStateUnknown); ok { + if cast, ok := tombstone.Obj.(*corev1.ConfigMap); ok { + return cast.Name == c.configmapName && cast.Namespace == c.configmapNamespace + } + } + return true // always return true just in case. The checks are fairly cheap + }, + Handler: cache.ResourceEventHandlerFuncs{ + // we have a filter, so any time we're called, we may as well queue. We only ever check one configmap + // so we don't have to be choosy about our key. + AddFunc: func(obj interface{}) { + c.queue.Add(c.keyFn()) + }, + UpdateFunc: func(oldObj, newObj interface{}) { + c.queue.Add(c.keyFn()) + }, + DeleteFunc: func(obj interface{}) { + c.queue.Add(c.keyFn()) + }, + }, + }) + + c.configmapLister = cmInformer.Lister().ConfigMaps(c.configmapNamespace) + c.configmapInformerSynced = cmInformer.Informer().HasSynced + + return c, nil +} + +func (c *RequestHeaderAuthRequestController) UsernameHeaders() []string { + return c.loadRequestHeaderFor(c.usernameHeadersKey) +} + +func (c *RequestHeaderAuthRequestController) GroupHeaders() []string { + return c.loadRequestHeaderFor(c.groupHeadersKey) +} + +func (c *RequestHeaderAuthRequestController) ExtraHeaderPrefixes() []string { + return c.loadRequestHeaderFor(c.extraHeaderPrefixesKey) +} + +func (c *RequestHeaderAuthRequestController) AllowedClientNames() []string { + return c.loadRequestHeaderFor(c.allowedClientNamesKey) +} + +// Run starts RequestHeaderAuthRequestController controller and blocks until stopCh is closed. +func (c *RequestHeaderAuthRequestController) Run(workers int, stopCh <-chan struct{}) { + defer utilruntime.HandleCrash() + defer c.queue.ShutDown() + + klog.Infof("Starting %s", c.name) + defer klog.Infof("Shutting down %s", c.name) + + // wait for caches to fill before starting your work + if !cache.WaitForNamedCacheSync(c.name, stopCh, c.configmapInformerSynced) { + return + } + + // doesn't matter what workers say, only start one. + go wait.Until(c.runWorker, time.Second, stopCh) + + <-stopCh +} + +func (c *RequestHeaderAuthRequestController) runWorker() { + for c.processNextWorkItem() { + } +} + +func (c *RequestHeaderAuthRequestController) processNextWorkItem() bool { + dsKey, quit := c.queue.Get() + if quit { + return false + } + defer c.queue.Done(dsKey) + + err := c.sync() + if err == nil { + c.queue.Forget(dsKey) + return true + } + + utilruntime.HandleError(fmt.Errorf("%v failed with : %v", dsKey, err)) + c.queue.AddRateLimited(dsKey) + + return true +} + +func (c *RequestHeaderAuthRequestController) syncOnce(client kubernetes.Interface) error { + configMap, err := client.CoreV1().ConfigMaps(c.configmapNamespace).Get(context.TODO(), c.configmapName, metav1.GetOptions{}) + switch { + case errors.IsNotFound(err): + // ignore, authConfigMap is nil now + return nil + case errors.IsForbidden(err): + klog.Warningf("Unable to get configmap/%s in %s. Usually fixed by "+ + "'kubectl create rolebinding -n %s ROLEBINDING_NAME --role=%s --serviceaccount=YOUR_NS:YOUR_SA'", + c.configmapName, c.configmapNamespace, c.configmapNamespace, authenticationRoleName) + return err + case err != nil: + return err + } + return c.syncConfigMap(configMap) +} + +// sync reads the config and propagates the changes to exportedRequestHeaderBundle +// which is exposed by the set of methods that are used to fill RequestHeaderConfig struct +func (c *RequestHeaderAuthRequestController) sync() error { + configMap, err := c.configmapLister.Get(c.configmapName) + if err != nil { + return err + } + return c.syncConfigMap(configMap) +} + +func (c *RequestHeaderAuthRequestController) syncConfigMap(configMap *corev1.ConfigMap) error { + hasChanged, newRequestHeaderBundle, err := c.hasRequestHeaderBundleChanged(configMap) + if err != nil { + return err + } + if hasChanged { + c.exportedRequestHeaderBundle.Store(newRequestHeaderBundle) + } + return nil +} + +func (c *RequestHeaderAuthRequestController) hasRequestHeaderBundleChanged(cm *corev1.ConfigMap) (bool, *requestHeaderBundle, error) { + currentHeadersBundle, err := c.getRequestHeaderBundleFromConfigMap(cm) + if err != nil { + return false, nil, err + } + + rawHeaderBundle := c.exportedRequestHeaderBundle.Load() + if rawHeaderBundle == nil { + return true, currentHeadersBundle, nil + } + + // check to see if we have a change. If the values are the same, do nothing. + loadedHeadersBundle, ok := rawHeaderBundle.(*requestHeaderBundle) + if !ok { + return true, currentHeadersBundle, nil + } + + if !equality.Semantic.DeepEqual(loadedHeadersBundle, currentHeadersBundle) { + return true, currentHeadersBundle, nil + } + return false, nil, nil +} + +func (c *RequestHeaderAuthRequestController) getRequestHeaderBundleFromConfigMap(cm *corev1.ConfigMap) (*requestHeaderBundle, error) { + usernameHeaderCurrentValue, err := deserializeStrings(cm.Data[c.usernameHeadersKey]) + if err != nil { + return nil, err + } + + groupHeadersCurrentValue, err := deserializeStrings(cm.Data[c.groupHeadersKey]) + if err != nil { + return nil, err + } + + extraHeaderPrefixesCurrentValue, err := deserializeStrings(cm.Data[c.extraHeaderPrefixesKey]) + if err != nil { + return nil, err + + } + + allowedClientNamesCurrentValue, err := deserializeStrings(cm.Data[c.allowedClientNamesKey]) + if err != nil { + return nil, err + } + + return &requestHeaderBundle{ + UsernameHeaders: usernameHeaderCurrentValue, + GroupHeaders: groupHeadersCurrentValue, + ExtraHeaderPrefixes: extraHeaderPrefixesCurrentValue, + AllowedClientNames: allowedClientNamesCurrentValue, + }, nil +} + +func (c *RequestHeaderAuthRequestController) loadRequestHeaderFor(key string) []string { + rawHeaderBundle := c.exportedRequestHeaderBundle.Load() + if rawHeaderBundle == nil { + return nil // this can happen if we've been unable load data from the apiserver for some reason + } + headerBundle := rawHeaderBundle.(*requestHeaderBundle) + + switch key { + case c.usernameHeadersKey: + return headerBundle.UsernameHeaders + case c.groupHeadersKey: + return headerBundle.GroupHeaders + case c.extraHeaderPrefixesKey: + return headerBundle.ExtraHeaderPrefixes + case c.allowedClientNamesKey: + return headerBundle.AllowedClientNames + default: + return nil + } +} + +func (c *RequestHeaderAuthRequestController) keyFn() string { + // this format matches DeletionHandlingMetaNamespaceKeyFunc for our single key + return c.configmapNamespace + "/" + c.configmapName +} + +func deserializeStrings(in string) ([]string, error) { + if len(in) == 0 { + return nil, nil + } + var ret []string + if err := json.Unmarshal([]byte(in), &ret); err != nil { + return nil, err + } + return ret, nil +} diff --git a/staging/src/k8s.io/apiserver/pkg/authentication/request/headerrequest/requestheader_controller_test.go b/staging/src/k8s.io/apiserver/pkg/authentication/request/headerrequest/requestheader_controller_test.go new file mode 100644 index 00000000000..07c299398f6 --- /dev/null +++ b/staging/src/k8s.io/apiserver/pkg/authentication/request/headerrequest/requestheader_controller_test.go @@ -0,0 +1,284 @@ +/* +Copyright 2020 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 headerrequest + +import ( + "encoding/json" + "k8s.io/apimachinery/pkg/api/equality" + "testing" + + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/client-go/kubernetes/fake" + corev1listers "k8s.io/client-go/listers/core/v1" + "k8s.io/client-go/tools/cache" +) + +const ( + defConfigMapName = "extension-apiserver-authentication" + defConfigMapNamespace = "kube-system" + + defUsernameHeadersKey = "user-key" + defGroupHeadersKey = "group-key" + defExtraHeaderPrefixesKey = "extra-key" + defAllowedClientNamesKey = "names-key" +) + +type expectedHeadersHolder struct { + usernameHeaders []string + groupHeaders []string + extraHeaderPrefixes []string + allowedClientNames []string +} + +func TestRequestHeaderAuthRequestController(t *testing.T) { + scenarios := []struct { + name string + cm *corev1.ConfigMap + expectedHeader expectedHeadersHolder + expectErr bool + }{ + { + name: "happy-path: headers values are populated form a config map", + cm: defaultConfigMap(t, []string{"user-val"}, []string{"group-val"}, []string{"extra-val"}, []string{"names-val"}), + expectedHeader: expectedHeadersHolder{ + usernameHeaders: []string{"user-val"}, + groupHeaders: []string{"group-val"}, + extraHeaderPrefixes: []string{"extra-val"}, + allowedClientNames: []string{"names-val"}, + }, + }, + { + name: "passing an empty config map doesn't break the controller", + cm: func() *corev1.ConfigMap { + c := defaultConfigMap(t, nil, nil, nil, nil) + c.Data = map[string]string{} + return c + }(), + }, + { + name: "an invalid config map produces an error", + cm: func() *corev1.ConfigMap { + c := defaultConfigMap(t, nil, nil, nil, nil) + c.Data = map[string]string{ + defUsernameHeadersKey: "incorrect-json-array", + } + return c + }(), + expectErr: true, + }, + } + + for _, scenario := range scenarios { + t.Run(scenario.name, func(t *testing.T) { + // test data + indexer := cache.NewIndexer(cache.MetaNamespaceKeyFunc, cache.Indexers{}) + if err := indexer.Add(scenario.cm); err != nil { + t.Fatal(err.Error()) + } + target := newDefaultTarget() + target.configmapLister = corev1listers.NewConfigMapLister(indexer).ConfigMaps(defConfigMapNamespace) + + // act + err := target.sync() + + if err != nil && !scenario.expectErr { + t.Errorf("got unexpected error %v", err) + } + if err == nil && scenario.expectErr { + t.Error("expected an error but didn't get one") + } + + // validate + validateExpectedHeaders(t, target, scenario.expectedHeader) + }) + } +} + +func TestRequestHeaderAuthRequestControllerPreserveState(t *testing.T) { + scenarios := []struct { + name string + cm *corev1.ConfigMap + expectedHeader expectedHeadersHolder + expectErr bool + }{ + { + name: "scenario 1: headers values are populated form a config map", + cm: defaultConfigMap(t, []string{"user-val"}, []string{"group-val"}, []string{"extra-val"}, []string{"names-val"}), + expectedHeader: expectedHeadersHolder{ + usernameHeaders: []string{"user-val"}, + groupHeaders: []string{"group-val"}, + extraHeaderPrefixes: []string{"extra-val"}, + allowedClientNames: []string{"names-val"}, + }, + }, + { + name: "scenario 2: an invalid config map produces an error but doesn't destroy the state (scenario 1)", + cm: func() *corev1.ConfigMap { + c := defaultConfigMap(t, nil, nil, nil, nil) + c.Data = map[string]string{ + defUsernameHeadersKey: "incorrect-json-array", + } + return c + }(), + expectErr: true, + expectedHeader: expectedHeadersHolder{ + usernameHeaders: []string{"user-val"}, + groupHeaders: []string{"group-val"}, + extraHeaderPrefixes: []string{"extra-val"}, + allowedClientNames: []string{"names-val"}, + }, + }, + { + name: "scenario 3: some headers values have changed (prev set by scenario 1)", + cm: defaultConfigMap(t, []string{"user-val"}, []string{"group-val-scenario-3"}, []string{"extra-val"}, []string{"names-val"}), + expectedHeader: expectedHeadersHolder{ + usernameHeaders: []string{"user-val"}, + groupHeaders: []string{"group-val-scenario-3"}, + extraHeaderPrefixes: []string{"extra-val"}, + allowedClientNames: []string{"names-val"}, + }, + }, + { + name: "scenario 4: all headers values have changed (prev set by scenario 3)", + cm: defaultConfigMap(t, []string{"user-val-scenario-4"}, []string{"group-val-scenario-4"}, []string{"extra-val-scenario-4"}, []string{"names-val-scenario-4"}), + expectedHeader: expectedHeadersHolder{ + usernameHeaders: []string{"user-val-scenario-4"}, + groupHeaders: []string{"group-val-scenario-4"}, + extraHeaderPrefixes: []string{"extra-val-scenario-4"}, + allowedClientNames: []string{"names-val-scenario-4"}, + }, + }, + } + + target := newDefaultTarget() + + for _, scenario := range scenarios { + t.Run(scenario.name, func(t *testing.T) { + // test data + if scenario.cm != nil { + indexer := cache.NewIndexer(cache.MetaNamespaceKeyFunc, cache.Indexers{}) + if err := indexer.Add(scenario.cm); err != nil { + t.Fatal(err.Error()) + } + target.configmapLister = corev1listers.NewConfigMapLister(indexer).ConfigMaps(defConfigMapNamespace) + } + + // act + err := target.sync() + + if err != nil && !scenario.expectErr { + t.Errorf("got unexpected error %v", err) + } + if err == nil && scenario.expectErr { + t.Error("expected an error but didn't get one") + } + + // validate + validateExpectedHeaders(t, target, scenario.expectedHeader) + }) + } +} + +func TestRequestHeaderAuthRequestControllerSyncOnce(t *testing.T) { + scenarios := []struct { + name string + cm *corev1.ConfigMap + expectedHeader expectedHeadersHolder + expectErr bool + }{ + { + name: "headers values are populated form a config map", + cm: defaultConfigMap(t, []string{"user-val"}, []string{"group-val"}, []string{"extra-val"}, []string{"names-val"}), + expectedHeader: expectedHeadersHolder{ + usernameHeaders: []string{"user-val"}, + groupHeaders: []string{"group-val"}, + extraHeaderPrefixes: []string{"extra-val"}, + allowedClientNames: []string{"names-val"}, + }, + }, + } + + for _, scenario := range scenarios { + t.Run(scenario.name, func(t *testing.T) { + // test data + target := newDefaultTarget() + fakeKubeClient := fake.NewSimpleClientset(scenario.cm) + + // act + err := target.syncOnce(fakeKubeClient) + + if err != nil && !scenario.expectErr { + t.Errorf("got unexpected error %v", err) + } + if err == nil && scenario.expectErr { + t.Error("expected an error but didn't get one") + } + + // validate + validateExpectedHeaders(t, target, scenario.expectedHeader) + }) + } +} + +func defaultConfigMap(t *testing.T, usernameHeaderVal, groupHeadersVal, extraHeaderPrefixesVal, allowedClientNamesVal []string) *corev1.ConfigMap { + encode := func(val []string) string { + encodedVal, err := json.Marshal(val) + if err != nil { + t.Fatalf("unable to marshal %q , due to %v", usernameHeaderVal, err) + } + return string(encodedVal) + } + return &corev1.ConfigMap{ + ObjectMeta: metav1.ObjectMeta{ + Name: defConfigMapName, + Namespace: defConfigMapNamespace, + }, + Data: map[string]string{ + defUsernameHeadersKey: encode(usernameHeaderVal), + defGroupHeadersKey: encode(groupHeadersVal), + defExtraHeaderPrefixesKey: encode(extraHeaderPrefixesVal), + defAllowedClientNamesKey: encode(allowedClientNamesVal), + }, + } +} + +func newDefaultTarget() *RequestHeaderAuthRequestController { + return &RequestHeaderAuthRequestController{ + configmapName: defConfigMapName, + configmapNamespace: defConfigMapNamespace, + usernameHeadersKey: defUsernameHeadersKey, + groupHeadersKey: defGroupHeadersKey, + extraHeaderPrefixesKey: defExtraHeaderPrefixesKey, + allowedClientNamesKey: defAllowedClientNamesKey, + } +} + +func validateExpectedHeaders(t *testing.T, target *RequestHeaderAuthRequestController, expected expectedHeadersHolder) { + if !equality.Semantic.DeepEqual(target.UsernameHeaders(), expected.usernameHeaders) { + t.Fatalf("incorrect usernameHeaders, got %v, wanted %v", target.UsernameHeaders(), expected.usernameHeaders) + } + if !equality.Semantic.DeepEqual(target.GroupHeaders(), expected.groupHeaders) { + t.Fatalf("incorrect groupHeaders, got %v, wanted %v", target.GroupHeaders(), expected.groupHeaders) + } + if !equality.Semantic.DeepEqual(target.ExtraHeaderPrefixes(), expected.extraHeaderPrefixes) { + t.Fatalf("incorrect extraheaderPrefixes, got %v, wanted %v", target.ExtraHeaderPrefixes(), expected.extraHeaderPrefixes) + } + if !equality.Semantic.DeepEqual(target.AllowedClientNames(), expected.allowedClientNames) { + t.Fatalf("incorrect expectedAllowedClientNames, got %v, wanted %v", target.AllowedClientNames(), expected.allowedClientNames) + } +}