diff --git a/internal/controller/identityprovider/webhookcachefiller/webhookcachefiller.go b/internal/controller/identityprovider/webhookcachefiller/webhookcachefiller.go new file mode 100644 index 00000000..6b67d940 --- /dev/null +++ b/internal/controller/identityprovider/webhookcachefiller/webhookcachefiller.go @@ -0,0 +1,127 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +// Package webhookcachefiller implements a controller for filling an idpcache.Cache with each added/updated WebhookIdentityProvider. +package webhookcachefiller + +import ( + "encoding/base64" + "fmt" + "io/ioutil" + "os" + + "github.com/go-logr/logr" + k8sauthv1beta1 "k8s.io/api/authentication/v1beta1" + "k8s.io/apimachinery/pkg/api/errors" + "k8s.io/apimachinery/pkg/util/net" + "k8s.io/apiserver/pkg/authentication/authenticator" + "k8s.io/apiserver/plugin/pkg/authenticator/token/webhook" + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + "k8s.io/klog/v2" + + idpv1alpha1 "github.com/suzerain-io/pinniped/generated/1.19/apis/idp/v1alpha1" + idpinformers "github.com/suzerain-io/pinniped/generated/1.19/client/informers/externalversions/idp/v1alpha1" + pinnipedcontroller "github.com/suzerain-io/pinniped/internal/controller" + "github.com/suzerain-io/pinniped/internal/controller/identityprovider/idpcache" + "github.com/suzerain-io/pinniped/internal/controllerlib" +) + +// New instantiates a new controllerlib.Controller which will populate the provided idpcache.Cache. +func New(cache *idpcache.Cache, webhookIDPs idpinformers.WebhookIdentityProviderInformer, log logr.Logger) controllerlib.Controller { + return controllerlib.New( + controllerlib.Config{ + Name: "webhookcachefiller-controller", + Syncer: &controller{ + cache: cache, + webhookIDPs: webhookIDPs, + log: log.WithName("webhookcachefiller-controller"), + }, + }, + controllerlib.WithInformer( + webhookIDPs, + pinnipedcontroller.NoOpFilter(), + controllerlib.InformerOption{}, + ), + ) +} + +type controller struct { + cache *idpcache.Cache + webhookIDPs idpinformers.WebhookIdentityProviderInformer + log logr.Logger +} + +// Sync implements controllerlib.Syncer. +func (c *controller) Sync(ctx controllerlib.Context) error { + obj, err := c.webhookIDPs.Lister().WebhookIdentityProviders(ctx.Key.Namespace).Get(ctx.Key.Name) + if err != nil && errors.IsNotFound(err) { + c.log.Info("Sync() found that the WebhookIdentityProvider does not exist yet or was deleted") + return nil + } + if err != nil { + return fmt.Errorf("failed to get WebhookIdentityProvider %s/%s: %w", ctx.Key.Namespace, ctx.Key.Name, err) + } + + webhookAuthenticator, err := newWebhookAuthenticator(&obj.Spec, ioutil.TempFile, clientcmd.WriteToFile) + if err != nil { + return fmt.Errorf("failed to build webhook config: %w", err) + } + + c.cache.Store(ctx.Key, webhookAuthenticator) + c.log.WithValues("idp", klog.KObj(obj), "endpoint", obj.Spec.Endpoint).Info("added new webhook IDP") + return nil +} + +// newWebhookAuthenticator creates a webhook from the provided API server url and caBundle +// used to validate TLS connections. +func newWebhookAuthenticator( + spec *idpv1alpha1.WebhookIdentityProviderSpec, + tempfileFunc func(string, string) (*os.File, error), + marshalFunc func(clientcmdapi.Config, string) error, +) (*webhook.WebhookTokenAuthenticator, error) { + temp, err := tempfileFunc("", "pinniped-webhook-kubeconfig-*") + if err != nil { + return nil, fmt.Errorf("unable to create temporary file: %w", err) + } + defer func() { _ = os.Remove(temp.Name()) }() + + cluster := &clientcmdapi.Cluster{Server: spec.Endpoint} + cluster.CertificateAuthorityData, err = getCABundle(spec.TLS) + if err != nil { + return nil, fmt.Errorf("invalid TLS configuration: %w", err) + } + + kubeconfig := clientcmdapi.NewConfig() + kubeconfig.Clusters["anonymous-cluster"] = cluster + kubeconfig.Contexts["anonymous"] = &clientcmdapi.Context{Cluster: "anonymous-cluster"} + kubeconfig.CurrentContext = "anonymous" + + if err := marshalFunc(*kubeconfig, temp.Name()); err != nil { + return nil, fmt.Errorf("unable to marshal kubeconfig: %w", err) + } + + // We use v1beta1 instead of v1 since v1beta1 is more prevalent in our desired + // integration points. + version := k8sauthv1beta1.SchemeGroupVersion.Version + + // At the current time, we don't provide any audiences because we simply don't + // have any requirements to do so. This can be changed in the future as + // requirements change. + var implicitAuds authenticator.Audiences + + // We set this to nil because we would only need this to support some of the + // custom proxy stuff used by the API server. + var customDial net.DialFunc + + return webhook.New(temp.Name(), version, implicitAuds, customDial) +} + +func getCABundle(spec *idpv1alpha1.TLSSpec) ([]byte, error) { + if spec == nil { + return nil, nil + } + return base64.RawStdEncoding.DecodeString(spec.CertificateAuthorityData) +} diff --git a/internal/controller/identityprovider/webhookcachefiller/webhookcachefiller_test.go b/internal/controller/identityprovider/webhookcachefiller/webhookcachefiller_test.go new file mode 100644 index 00000000..c2d9fc17 --- /dev/null +++ b/internal/controller/identityprovider/webhookcachefiller/webhookcachefiller_test.go @@ -0,0 +1,174 @@ +/* +Copyright 2020 VMware, Inc. +SPDX-License-Identifier: Apache-2.0 +*/ + +package webhookcachefiller + +import ( + "context" + "encoding/base64" + "fmt" + "io/ioutil" + "net/http" + "os" + "testing" + "time" + + "github.com/stretchr/testify/require" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + "k8s.io/client-go/tools/clientcmd" + clientcmdapi "k8s.io/client-go/tools/clientcmd/api" + + idpv1alpha1 "github.com/suzerain-io/pinniped/generated/1.19/apis/idp/v1alpha1" + pinnipedfake "github.com/suzerain-io/pinniped/generated/1.19/client/clientset/versioned/fake" + pinnipedinformers "github.com/suzerain-io/pinniped/generated/1.19/client/informers/externalversions" + "github.com/suzerain-io/pinniped/internal/controller/identityprovider/idpcache" + "github.com/suzerain-io/pinniped/internal/controllerlib" + "github.com/suzerain-io/pinniped/internal/testutil" + "github.com/suzerain-io/pinniped/internal/testutil/testlogger" +) + +func TestController(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + syncKey controllerlib.Key + webhookIDPs []runtime.Object + wantErr string + wantLogs []string + wantCacheEntries int + }{ + { + name: "not found", + syncKey: controllerlib.Key{Namespace: "test-namespace", Name: "test-name"}, + wantLogs: []string{ + `webhookcachefiller-controller "level"=0 "msg"="Sync() found that the WebhookIdentityProvider does not exist yet or was deleted"`, + }, + }, + { + name: "invalid webhook", + syncKey: controllerlib.Key{Namespace: "test-namespace", Name: "test-name"}, + webhookIDPs: []runtime.Object{ + &idpv1alpha1.WebhookIdentityProvider{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test-namespace", + Name: "test-name", + }, + Spec: idpv1alpha1.WebhookIdentityProviderSpec{ + Endpoint: "invalid url", + }, + }, + }, + wantErr: `failed to build webhook config: parse "http://invalid url": invalid character " " in host name`, + }, + { + name: "valid webhook", + syncKey: controllerlib.Key{Namespace: "test-namespace", Name: "test-name"}, + webhookIDPs: []runtime.Object{ + &idpv1alpha1.WebhookIdentityProvider{ + ObjectMeta: metav1.ObjectMeta{ + Namespace: "test-namespace", + Name: "test-name", + }, + Spec: idpv1alpha1.WebhookIdentityProviderSpec{ + Endpoint: "https://example.com", + TLS: &idpv1alpha1.TLSSpec{CertificateAuthorityData: ""}, + }, + }, + }, + wantLogs: []string{ + `webhookcachefiller-controller "level"=0 "msg"="added new webhook IDP" "endpoint"="https://example.com" "idp"={"name":"test-name","namespace":"test-namespace"}`, + }, + wantCacheEntries: 1, + }, + } + for _, tt := range tests { + tt := tt + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + fakeClient := pinnipedfake.NewSimpleClientset(tt.webhookIDPs...) + informers := pinnipedinformers.NewSharedInformerFactory(fakeClient, 0) + cache := idpcache.New() + testLog := testlogger.New(t) + + controller := New(cache, informers.IDP().V1alpha1().WebhookIdentityProviders(), testLog) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + informers.Start(ctx.Done()) + controllerlib.TestRunSynchronously(t, controller) + + syncCtx := controllerlib.Context{Context: ctx, Key: tt.syncKey} + + if err := controllerlib.TestSync(t, controller, syncCtx); tt.wantErr != "" { + require.EqualError(t, err, tt.wantErr) + } else { + require.NoError(t, err) + } + require.Equal(t, tt.wantLogs, testLog.Lines()) + require.Equal(t, tt.wantCacheEntries, len(cache.Keys())) + }) + } +} + +func TestNewWebhookAuthenticator(t *testing.T) { + t.Run("temp file failure", func(t *testing.T) { + brokenTempFile := func(_ string, _ string) (*os.File, error) { return nil, fmt.Errorf("some temp file error") } + res, err := newWebhookAuthenticator(nil, brokenTempFile, clientcmd.WriteToFile) + require.Nil(t, res) + require.EqualError(t, err, "unable to create temporary file: some temp file error") + }) + + t.Run("marshal failure", func(t *testing.T) { + marshalError := func(_ clientcmdapi.Config, _ string) error { return fmt.Errorf("some marshal error") } + res, err := newWebhookAuthenticator(&idpv1alpha1.WebhookIdentityProviderSpec{}, ioutil.TempFile, marshalError) + require.Nil(t, res) + require.EqualError(t, err, "unable to marshal kubeconfig: some marshal error") + }) + + t.Run("invalid base64", func(t *testing.T) { + res, err := newWebhookAuthenticator(&idpv1alpha1.WebhookIdentityProviderSpec{ + Endpoint: "https://example.com", + TLS: &idpv1alpha1.TLSSpec{CertificateAuthorityData: "invalid-base64"}, + }, ioutil.TempFile, clientcmd.WriteToFile) + require.Nil(t, res) + require.EqualError(t, err, "invalid TLS configuration: illegal base64 data at input byte 7") + }) + + t.Run("valid config with no TLS spec", func(t *testing.T) { + res, err := newWebhookAuthenticator(&idpv1alpha1.WebhookIdentityProviderSpec{ + Endpoint: "https://example.com", + }, ioutil.TempFile, clientcmd.WriteToFile) + require.NotNil(t, res) + require.NoError(t, err) + }) + + t.Run("success", func(t *testing.T) { + caBundle, url := testutil.TLSTestServer(t, func(w http.ResponseWriter, r *http.Request) { + body, err := ioutil.ReadAll(r.Body) + require.NoError(t, err) + require.Contains(t, string(body), "test-token") + _, err = w.Write([]byte(`{}`)) + require.NoError(t, err) + }) + spec := &idpv1alpha1.WebhookIdentityProviderSpec{ + Endpoint: url, + TLS: &idpv1alpha1.TLSSpec{ + CertificateAuthorityData: base64.RawStdEncoding.EncodeToString([]byte(caBundle)), + }, + } + res, err := newWebhookAuthenticator(spec, ioutil.TempFile, clientcmd.WriteToFile) + require.NoError(t, err) + require.NotNil(t, res) + + resp, authenticated, err := res.AuthenticateToken(context.Background(), "test-token") + require.NoError(t, err) + require.Nil(t, resp) + require.False(t, authenticated) + }) +}