Add a controller to fill the idpcache.Cache from WebhookIdentityProvider objects.

Signed-off-by: Matt Moyer <moyerm@vmware.com>
This commit is contained in:
Matt Moyer 2020-09-14 10:41:36 -05:00
parent 6506a82b19
commit acfc5acfb2
No known key found for this signature in database
GPG Key ID: EAE88AD172C5AE2D
2 changed files with 301 additions and 0 deletions

View File

@ -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)
}

View File

@ -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)
})
}