update admission webhook to handle multiple auth domains

pull/6/head
David Eads 2017-10-23 09:35:08 -04:00
parent 3ae0b84e0b
commit fd4ab3e061
8 changed files with 375 additions and 46 deletions

View File

@ -466,26 +466,6 @@ func BuildGenericConfig(s *options.ServerRunOptions, proxyTransport *http.Transp
if proxyTransport != nil && proxyTransport.Dial != nil {
webhookClientConfig.Dial = proxyTransport.Dial
}
// TODO: this is the wrong cert/key pair.
// Given the generic case of webhook admission from a generic apiserver,
// this key pair should be signed by the the API server's client CA.
// Read client cert/key for plugins that need to make calls out
certBytes, keyBytes := []byte{}, []byte{}
if len(s.ProxyClientCertFile) > 0 && len(s.ProxyClientKeyFile) > 0 {
var err error
certBytes, err = ioutil.ReadFile(s.ProxyClientCertFile)
if err != nil {
return nil, nil, nil, nil, nil, fmt.Errorf("failed to read proxy client cert file from: %s, err: %v", s.ProxyClientCertFile, err)
}
keyBytes, err = ioutil.ReadFile(s.ProxyClientKeyFile)
if err != nil {
return nil, nil, nil, nil, nil, fmt.Errorf("failed to read proxy client key file from: %s, err: %v", s.ProxyClientKeyFile, err)
}
webhookClientConfig.TLSClientConfig.CertData = certBytes
webhookClientConfig.TLSClientConfig.KeyData = keyBytes
}
webhookClientConfig.UserAgent = "kube-apiserver-admission"
webhookClientConfig.Timeout = 30 * time.Second
err = s.Admission.ApplyTo(
genericConfig,

View File

@ -5,6 +5,8 @@ go_library(
srcs = [
"admission.go",
"admissionreview.go",
"authentication.go",
"config.go",
"doc.go",
"rules.go",
"serviceresolver.go",
@ -21,15 +23,17 @@ go_library(
"//vendor/k8s.io/apimachinery/pkg/api/errors:go_default_library",
"//vendor/k8s.io/apimachinery/pkg/apis/meta/v1:go_default_library",
"//vendor/k8s.io/apimachinery/pkg/runtime:go_default_library",
"//vendor/k8s.io/apimachinery/pkg/runtime/schema:go_default_library",
"//vendor/k8s.io/apimachinery/pkg/runtime/serializer:go_default_library",
"//vendor/k8s.io/apimachinery/pkg/util/runtime:go_default_library",
"//vendor/k8s.io/apimachinery/pkg/util/wait:go_default_library",
"//vendor/k8s.io/apimachinery/pkg/util/yaml:go_default_library",
"//vendor/k8s.io/apiserver/pkg/admission:go_default_library",
"//vendor/k8s.io/apiserver/pkg/admission/configuration:go_default_library",
"//vendor/k8s.io/apiserver/pkg/admission/initializer:go_default_library",
"//vendor/k8s.io/client-go/kubernetes:go_default_library",
"//vendor/k8s.io/client-go/rest:go_default_library",
"//vendor/k8s.io/client-go/tools/clientcmd:go_default_library",
"//vendor/k8s.io/client-go/tools/clientcmd/api:go_default_library",
],
)
@ -37,6 +41,7 @@ go_test(
name = "go_default_test",
srcs = [
"admission_test.go",
"authentication_test.go",
"certs_test.go",
"rules_test.go",
"serviceresolver_test.go",
@ -49,11 +54,14 @@ go_test(
"//pkg/apis/admission/install:go_default_library",
"//vendor/k8s.io/api/admission/v1alpha1:go_default_library",
"//vendor/k8s.io/api/admissionregistration/v1alpha1:go_default_library",
"//vendor/k8s.io/apimachinery/pkg/api/equality:go_default_library",
"//vendor/k8s.io/apimachinery/pkg/apis/meta/v1:go_default_library",
"//vendor/k8s.io/apimachinery/pkg/runtime/schema:go_default_library",
"//vendor/k8s.io/apimachinery/pkg/util/diff:go_default_library",
"//vendor/k8s.io/apiserver/pkg/admission:go_default_library",
"//vendor/k8s.io/apiserver/pkg/authentication/user:go_default_library",
"//vendor/k8s.io/client-go/rest:go_default_library",
"//vendor/k8s.io/client-go/tools/clientcmd/api:go_default_library",
],
)

View File

@ -21,6 +21,7 @@ import (
"context"
"fmt"
"io"
"path"
"sync"
"github.com/golang/glog"
@ -30,10 +31,10 @@ import (
apierrors "k8s.io/apimachinery/pkg/api/errors"
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apimachinery/pkg/runtime"
"k8s.io/apimachinery/pkg/runtime/schema"
"k8s.io/apimachinery/pkg/runtime/serializer"
utilruntime "k8s.io/apimachinery/pkg/util/runtime"
"k8s.io/apimachinery/pkg/util/wait"
"k8s.io/apimachinery/pkg/util/yaml"
"k8s.io/apiserver/pkg/admission"
"k8s.io/apiserver/pkg/admission/configuration"
genericadmissioninit "k8s.io/apiserver/pkg/admission/initializer"
@ -42,17 +43,9 @@ import (
admissioninit "k8s.io/kubernetes/pkg/kubeapiserver/admission"
// install the clientgo admission API for use with api registry
"path"
_ "k8s.io/kubernetes/pkg/apis/admission/install"
)
var (
groupVersions = []schema.GroupVersion{
admissionv1alpha1.SchemeGroupVersion,
}
)
type ErrCallingWebhook struct {
WebhookName string
Reason error
@ -68,7 +61,7 @@ func (e *ErrCallingWebhook) Error() string {
// Register registers a plugin
func Register(plugins *admission.Plugins) {
plugins.Register("GenericAdmissionWebhook", func(configFile io.Reader) (admission.Interface, error) {
plugin, err := NewGenericAdmissionWebhook()
plugin, err := NewGenericAdmissionWebhook(configFile)
if err != nil {
return nil, err
}
@ -84,7 +77,23 @@ type WebhookSource interface {
}
// NewGenericAdmissionWebhook returns a generic admission webhook plugin.
func NewGenericAdmissionWebhook() (*GenericAdmissionWebhook, error) {
func NewGenericAdmissionWebhook(configFile io.Reader) (*GenericAdmissionWebhook, error) {
kubeconfigFile := ""
if configFile != nil {
// TODO: move this to a versioned configuration file format
var config AdmissionConfig
d := yaml.NewYAMLOrJSONDecoder(configFile, 4096)
err := d.Decode(&config)
if err != nil {
return nil, err
}
kubeconfigFile = config.KubeConfigFile
}
authInfoResolver, err := newDefaultAuthenticationInfoResolver(kubeconfigFile)
if err != nil {
return nil, err
}
return &GenericAdmissionWebhook{
Handler: admission.NewHandler(
admission.Connect,
@ -92,7 +101,8 @@ func NewGenericAdmissionWebhook() (*GenericAdmissionWebhook, error) {
admission.Delete,
admission.Update,
),
serviceResolver: defaultServiceResolver{},
authInfoResolver: authInfoResolver,
serviceResolver: defaultServiceResolver{},
}, nil
}
@ -103,7 +113,7 @@ type GenericAdmissionWebhook struct {
serviceResolver admissioninit.ServiceResolver
negotiatedSerializer runtime.NegotiatedSerializer
restClientConfig *rest.Config
authInfoResolver AuthenticationInfoResolver
}
var (
@ -112,8 +122,14 @@ var (
_ = genericadmissioninit.WantsExternalKubeClientSet(&GenericAdmissionWebhook{})
)
// TODO find a better way wire this, but keep this pull small for now.
func (a *GenericAdmissionWebhook) SetWebhookRESTClientConfig(in *rest.Config) {
a.restClientConfig = in
if in != nil {
a.authInfoResolver = &dialOverridingAuthenticationInfoResolver{
dialFn: in.Dial,
delegate: a.authInfoResolver,
}
}
}
// SetServiceResolver sets a service resolver for the webhook admission plugin.
@ -138,9 +154,6 @@ func (a *GenericAdmissionWebhook) SetExternalKubeClientSet(client clientset.Inte
}
func (a *GenericAdmissionWebhook) Validate() error {
if a.restClientConfig == nil {
return fmt.Errorf("the GenericAdmissionWebhook admission plugin requires a restClientConfig to be provided")
}
if a.hookSource == nil {
return fmt.Errorf("the GenericAdmissionWebhook admission plugin requires a Kubernetes client to be provided")
}
@ -266,16 +279,21 @@ func (a *GenericAdmissionWebhook) callHook(ctx context.Context, h *v1alpha1.Exte
}
func (a *GenericAdmissionWebhook) hookClient(h *v1alpha1.ExternalAdmissionHook) (*rest.RESTClient, error) {
serverName := h.ClientConfig.Service.Name + "." + h.ClientConfig.Service.Namespace + ".svc"
u, err := a.serviceResolver.ResolveEndpoint(h.ClientConfig.Service.Namespace, h.ClientConfig.Service.Name)
if err != nil {
return nil, err
}
// TODO: cache these instead of constructing one each time
cfg := rest.CopyConfig(a.restClientConfig)
restConfig, err := a.authInfoResolver.ClientConfigFor(serverName)
if err != nil {
return nil, err
}
cfg := rest.CopyConfig(restConfig)
cfg.Host = u.Host
cfg.APIPath = path.Join(u.Path, h.ClientConfig.URLPath)
cfg.TLSClientConfig.ServerName = h.ClientConfig.Service.Name + "." + h.ClientConfig.Service.Namespace + ".svc"
cfg.TLSClientConfig.ServerName = serverName
cfg.TLSClientConfig.CAData = h.ClientConfig.CABundle
cfg.ContentConfig.NegotiatedSerializer = a.negotiatedSerializer
return rest.UnversionedRESTClientFor(cfg)

View File

@ -32,10 +32,10 @@ import (
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
"k8s.io/apiserver/pkg/admission"
"k8s.io/apiserver/pkg/authentication/user"
"k8s.io/client-go/rest"
"k8s.io/kubernetes/pkg/api"
"k8s.io/kubernetes/pkg/api/legacyscheme"
"k8s.io/client-go/rest"
_ "k8s.io/kubernetes/pkg/apis/admission/install"
)
@ -86,14 +86,19 @@ func TestAdmit(t *testing.T) {
if err != nil {
t.Fatalf("this should never happen? %v", err)
}
wh, err := NewGenericAdmissionWebhook()
wh, err := NewGenericAdmissionWebhook(nil)
if err != nil {
t.Fatal(err)
}
wh.restClientConfig = &rest.Config{}
wh.restClientConfig.TLSClientConfig.CAData = caCert
wh.restClientConfig.TLSClientConfig.CertData = clientCert
wh.restClientConfig.TLSClientConfig.KeyData = clientKey
wh.authInfoResolver = &fakeAuthenticationInfoResolver{
restConfig: &rest.Config{
TLSClientConfig: rest.TLSClientConfig{
CAData: caCert,
CertData: clientCert,
KeyData: clientKey,
},
},
}
// Set up a test object for the call
kind := api.Kind("Pod").WithVersion("v1")
@ -318,3 +323,11 @@ func webhookHandler(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
}
}
type fakeAuthenticationInfoResolver struct {
restConfig *rest.Config
}
func (c *fakeAuthenticationInfoResolver) ClientConfigFor(server string) (*rest.Config, error) {
return c.restConfig, nil
}

View File

@ -0,0 +1,157 @@
/*
Copyright 2017 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 webhook
import (
"io/ioutil"
"net"
"strings"
"time"
"fmt"
"k8s.io/client-go/rest"
"k8s.io/client-go/tools/clientcmd"
clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
)
type AuthenticationInfoResolver interface {
ClientConfigFor(server string) (*rest.Config, error)
}
type defaultAuthenticationInfoResolver struct {
kubeconfig clientcmdapi.Config
}
func newDefaultAuthenticationInfoResolver(kubeconfigFile string) (AuthenticationInfoResolver, error) {
if len(kubeconfigFile) == 0 {
return &defaultAuthenticationInfoResolver{}, nil
}
loadingRules := clientcmd.NewDefaultClientConfigLoadingRules()
loadingRules.ExplicitPath = kubeconfigFile
loader := clientcmd.NewNonInteractiveDeferredLoadingClientConfig(loadingRules, &clientcmd.ConfigOverrides{})
clientConfig, err := loader.RawConfig()
if err != nil {
return nil, err
}
return &defaultAuthenticationInfoResolver{kubeconfig: clientConfig}, nil
}
func (c *defaultAuthenticationInfoResolver) ClientConfigFor(server string) (*rest.Config, error) {
// exact match
if authConfig, ok := c.kubeconfig.AuthInfos[server]; ok {
return restConfigFromKubeconfig(authConfig)
}
// star prefixed match
serverSteps := strings.Split(server, ".")
for i := 1; i < len(serverSteps); i++ {
nickName := "*." + strings.Join(serverSteps[i:], ".")
if authConfig, ok := c.kubeconfig.AuthInfos[nickName]; ok {
return restConfigFromKubeconfig(authConfig)
}
}
// if we're trying to hit the kube-apiserver and there wasn't an explicit config, use the in-cluster config
if server == "kubernetes.default.svc" {
// if we can find an in-cluster-config use that. If we can't, fall through.
inClusterConfig, err := rest.InClusterConfig()
if err == nil {
return setGlobalDefaults(inClusterConfig), nil
}
}
// star (default) match
if authConfig, ok := c.kubeconfig.AuthInfos["*"]; ok {
return restConfigFromKubeconfig(authConfig)
}
// use the current context from the kubeconfig if possible
if len(c.kubeconfig.CurrentContext) > 0 {
if currContext, ok := c.kubeconfig.Contexts[c.kubeconfig.CurrentContext]; ok {
if len(currContext.AuthInfo) > 0 {
if currAuth, ok := c.kubeconfig.AuthInfos[currContext.AuthInfo]; ok {
return restConfigFromKubeconfig(currAuth)
}
}
}
}
// anonymous
return setGlobalDefaults(&rest.Config{}), nil
}
func restConfigFromKubeconfig(configAuthInfo *clientcmdapi.AuthInfo) (*rest.Config, error) {
config := &rest.Config{}
// blindly overwrite existing values based on precedence
if len(configAuthInfo.Token) > 0 {
config.BearerToken = configAuthInfo.Token
} else if len(configAuthInfo.TokenFile) > 0 {
tokenBytes, err := ioutil.ReadFile(configAuthInfo.TokenFile)
if err != nil {
return nil, err
}
config.BearerToken = string(tokenBytes)
}
if len(configAuthInfo.Impersonate) > 0 {
config.Impersonate = rest.ImpersonationConfig{
UserName: configAuthInfo.Impersonate,
Groups: configAuthInfo.ImpersonateGroups,
Extra: configAuthInfo.ImpersonateUserExtra,
}
}
if len(configAuthInfo.ClientCertificate) > 0 || len(configAuthInfo.ClientCertificateData) > 0 {
config.CertFile = configAuthInfo.ClientCertificate
config.CertData = configAuthInfo.ClientCertificateData
config.KeyFile = configAuthInfo.ClientKey
config.KeyData = configAuthInfo.ClientKeyData
}
if len(configAuthInfo.Username) > 0 || len(configAuthInfo.Password) > 0 {
config.Username = configAuthInfo.Username
config.Password = configAuthInfo.Password
}
if configAuthInfo.AuthProvider != nil {
return nil, fmt.Errorf("auth provider not supported")
}
return setGlobalDefaults(config), nil
}
func setGlobalDefaults(config *rest.Config) *rest.Config {
config.UserAgent = "kube-apiserver-admission"
config.Timeout = 30 * time.Second
return config
}
type dialOverridingAuthenticationInfoResolver struct {
dialFn func(network, addr string) (net.Conn, error)
delegate AuthenticationInfoResolver
}
func (c *dialOverridingAuthenticationInfoResolver) ClientConfigFor(server string) (*rest.Config, error) {
cfg, err := c.delegate.ClientConfigFor(server)
if err != nil {
return nil, err
}
cfg.Dial = c.dialFn
return cfg, nil
}

View File

@ -0,0 +1,130 @@
/*
Copyright 2017 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 webhook
import (
"testing"
"k8s.io/apimachinery/pkg/api/equality"
"k8s.io/apimachinery/pkg/util/diff"
"k8s.io/client-go/rest"
clientcmdapi "k8s.io/client-go/tools/clientcmd/api"
)
func TestAuthenticationDetection(t *testing.T) {
tests := []struct {
name string
kubeconfig clientcmdapi.Config
serverName string
expected rest.Config
}{
{
name: "empty",
serverName: "foo.com",
},
{
name: "fallback to current context",
serverName: "foo.com",
kubeconfig: clientcmdapi.Config{
AuthInfos: map[string]*clientcmdapi.AuthInfo{
"bar.com": {Token: "bar"},
},
Contexts: map[string]*clientcmdapi.Context{
"ctx": {
AuthInfo: "bar.com",
},
},
CurrentContext: "ctx",
},
expected: rest.Config{BearerToken: "bar"},
},
{
name: "exact match",
serverName: "foo.com",
kubeconfig: clientcmdapi.Config{
AuthInfos: map[string]*clientcmdapi.AuthInfo{
"foo.com": {Token: "foo"},
"*.com": {Token: "foo-star"},
"bar.com": {Token: "bar"},
},
},
expected: rest.Config{BearerToken: "foo"},
},
{
name: "partial star match",
serverName: "foo.com",
kubeconfig: clientcmdapi.Config{
AuthInfos: map[string]*clientcmdapi.AuthInfo{
"*.com": {Token: "foo-star"},
"bar.com": {Token: "bar"},
},
},
expected: rest.Config{BearerToken: "foo-star"},
},
{
name: "full star match",
serverName: "foo.com",
kubeconfig: clientcmdapi.Config{
AuthInfos: map[string]*clientcmdapi.AuthInfo{
"*": {Token: "star"},
"bar.com": {Token: "bar"},
},
},
expected: rest.Config{BearerToken: "star"},
},
{
name: "skip bad in cluster config",
serverName: "kubernetes.default.svc",
kubeconfig: clientcmdapi.Config{
AuthInfos: map[string]*clientcmdapi.AuthInfo{
"*": {Token: "star"},
"bar.com": {Token: "bar"},
},
},
expected: rest.Config{BearerToken: "star"},
},
{
name: "most selective",
serverName: "one.two.three.com",
kubeconfig: clientcmdapi.Config{
AuthInfos: map[string]*clientcmdapi.AuthInfo{
"*.two.three.com": {Token: "first"},
"*.three.com": {Token: "second"},
"*.com": {Token: "third"},
},
},
expected: rest.Config{BearerToken: "first"},
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
resolver := defaultAuthenticationInfoResolver{kubeconfig: tc.kubeconfig}
actual, err := resolver.ClientConfigFor(tc.serverName)
if err != nil {
t.Fatal(err)
}
actual.UserAgent = ""
actual.Timeout = 0
if !equality.Semantic.DeepEqual(*actual, tc.expected) {
t.Errorf("%v", diff.ObjectReflectDiff(tc.expected, *actual))
}
})
}
}

View File

@ -0,0 +1,22 @@
/*
Copyright 2017 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 webhook
// AdmissionConfig holds config data that is unique to each API server.
type AdmissionConfig struct {
KubeConfigFile string `json:"kubeConfigFile"`
}

View File

@ -30,6 +30,7 @@ type pluginInitializer struct {
externalInformers informers.SharedInformerFactory
authorizer authorizer.Authorizer
// webhookRESTClientConfig provies a client used to contact webhooks
// TODO clean out cruft
webhookRESTClientConfig *rest.Config
scheme *runtime.Scheme
}